diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index dee12a3e0e..979aa6b070 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -39,8 +39,15 @@ const makeRoutingTextGeneration = Effect.gen(function* () { const codex = yield* CodexTextGen; const claude = yield* ClaudeTextGen; - const route = (provider?: TextGenerationProvider): TextGenerationShape => - provider === "claudeAgent" ? claude : codex; + const route = (provider?: TextGenerationProvider): TextGenerationShape => { + switch (provider) { + case "claudeAgent": + return claude; + case "codex": + case undefined: + return codex; + } + }; return { generateCommitMessage: (input) => diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 9feec28637..c714a088d0 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,3 +1,4 @@ +import * as OS from "node:os"; import type { ClaudeSettings, ModelCapabilities, @@ -6,7 +7,19 @@ import type { ServerProviderAuth, ServerProviderState, } from "@t3tools/contracts"; -import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; +import { + Cache, + Duration, + Effect, + Equal, + FileSystem, + Layer, + Option, + Path, + Result, + Schema, + Stream, +} from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk"; @@ -36,6 +49,7 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { }; const PROVIDER = "claudeAgent" as const; +const ZAI_ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic"; const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-opus-4-6", @@ -92,6 +106,103 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; +interface ClaudeGlmIntegration { + readonly hasAuthToken: boolean; + readonly opusModel: string | undefined; + readonly sonnetModel: string | undefined; + readonly haikuModel: string | undefined; +} + +function normalizeUrl(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed.replace(/\/+$/g, "").toLowerCase() : undefined; +} + +function asPlainRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !globalThis.Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asTrimmedString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function readClaudeGlmIntegrationFromEnv( + env: Record, +): ClaudeGlmIntegration | undefined { + if (normalizeUrl(env.ANTHROPIC_BASE_URL) !== normalizeUrl(ZAI_ANTHROPIC_BASE_URL)) { + return undefined; + } + + return { + hasAuthToken: Boolean(asTrimmedString(env.ANTHROPIC_AUTH_TOKEN)), + opusModel: asTrimmedString(env.ANTHROPIC_DEFAULT_OPUS_MODEL), + sonnetModel: asTrimmedString(env.ANTHROPIC_DEFAULT_SONNET_MODEL), + haikuModel: asTrimmedString(env.ANTHROPIC_DEFAULT_HAIKU_MODEL), + }; +} + +function buildClaudeModels( + integration: ClaudeGlmIntegration | undefined, +): ReadonlyArray { + if (!integration) { + return BUILT_IN_MODELS; + } + + return BUILT_IN_MODELS.map((model) => { + let mappedModel: string | undefined; + switch (model.slug) { + case "claude-opus-4-6": + mappedModel = integration.opusModel; + break; + case "claude-sonnet-4-6": + mappedModel = integration.sonnetModel; + break; + case "claude-haiku-4-5": + mappedModel = integration.haikuModel; + break; + } + + return mappedModel ? { ...model, name: `${model.name} (${mappedModel})` } : model; + }); +} + +export const readClaudeGlmIntegration = Effect.fn("readClaudeGlmIntegration")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const settingsPath = path.join(OS.homedir(), ".claude", "settings.json"); + const content = yield* fileSystem + .readFileString(settingsPath) + .pipe(Effect.orElseSucceed(() => undefined)); + + const fileEnv = (() => { + if (!content) { + return {} as Record; + } + try { + const parsed = JSON.parse(content) as unknown; + const envRecord = asPlainRecord(asPlainRecord(parsed)?.env); + if (!envRecord) { + return {} as Record; + } + return Object.fromEntries( + Object.entries(envRecord).flatMap(([key, value]) => { + const stringValue = asTrimmedString(value); + return stringValue ? [[key, stringValue]] : []; + }), + ) as Record; + } catch { + return {} as Record; + } + })(); + + return readClaudeGlmIntegrationFromEnv({ + ...fileEnv, + ...process.env, + }); +}); + export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { const slug = model?.trim(); return ( @@ -446,15 +557,22 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ): Effect.fn.Return< ServerProvider, ServerSettingsError, - ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ServerSettingsService > { const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( Effect.flatMap((service) => service.getSettings), Effect.map((settings) => settings.providers.claudeAgent), ); + const glmIntegration = yield* readClaudeGlmIntegration().pipe( + Effect.orElseSucceed(() => undefined), + ); const checkedAt = new Date().toISOString(); + const displayName = glmIntegration ? "Claude / GLM" : "Claude"; const models = providerModelsFromSettings( - BUILT_IN_MODELS, + buildClaudeModels(glmIntegration), PROVIDER, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, @@ -466,6 +584,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: false, checkedAt, models, + displayName, probe: { installed: false, version: null, @@ -488,6 +607,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, + displayName, probe: { installed: !isCommandMissingCause(error), version: null, @@ -506,6 +626,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, + displayName, probe: { installed: true, version: null, @@ -526,6 +647,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, + displayName, probe: { installed: true, version: parsedVersion, @@ -538,6 +660,41 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + if (glmIntegration) { + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + displayName, + probe: glmIntegration.hasAuthToken + ? { + installed: true, + version: parsedVersion, + status: "ready", + auth: { + status: "authenticated", + type: "apiKey", + label: "Z.AI GLM Plan", + }, + message: + "Configured to use Z.AI's Anthropic-compatible endpoint. Claude model tiers map to GLM models from your Claude settings.", + } + : { + installed: true, + version: parsedVersion, + status: "error", + auth: { + status: "unauthenticated", + type: "apiKey", + label: "Z.AI GLM Plan", + }, + message: + "Configured to use Z.AI's Anthropic-compatible endpoint, but ANTHROPIC_AUTH_TOKEN is missing.", + }, + }); + } + // ── Auth check + subscription detection ──────────────────────────── const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( @@ -574,6 +731,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models: resolvedModels, + displayName, probe: { installed: true, version: parsedVersion, @@ -593,6 +751,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models: resolvedModels, + displayName, probe: { installed: true, version: parsedVersion, @@ -610,6 +769,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models: resolvedModels, + displayName, probe: { installed: true, version: parsedVersion, @@ -627,6 +787,8 @@ export const ClaudeProviderLive = Layer.effect( ClaudeProvider, Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const subscriptionProbeCache = yield* Cache.make({ @@ -640,6 +802,8 @@ export const ClaudeProviderLive = Layer.effect( Cache.get(subscriptionProbeCache, binaryPath), ).pipe( Effect.provideService(ServerSettingsService, serverSettings), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 3509fa9257..1785af5692 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -62,6 +62,14 @@ const DEFAULT_CODEX_MODEL_CAPABILITIES: ModelCapabilities = { promptInjectedEffortLevels: [], }; +const DEFAULT_GLM_MODEL_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; + const PROVIDER = "codex" as const; const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); const BUILT_IN_MODELS: ReadonlyArray = [ @@ -169,8 +177,16 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; +interface CodexConfigSnapshot { + readonly modelProvider: string | undefined; + readonly configuredModels: ReadonlyArray; +} + export function getCodexModelCapabilities(model: string | null | undefined): ModelCapabilities { const slug = model?.trim(); + if (slug?.startsWith("glm-")) { + return DEFAULT_GLM_MODEL_CAPABILITIES; + } return ( BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? DEFAULT_CODEX_MODEL_CAPABILITIES @@ -257,7 +273,55 @@ export function parseAuthStatusFromOutput(result: CommandResult): { }; } -export const readCodexConfigModelProvider = Effect.fn("readCodexConfigModelProvider")(function* () { +function parseCodexConfigSnapshot(content: string): CodexConfigSnapshot { + let inTopLevel = true; + let inProfileSection = false; + let modelProvider: string | undefined; + const configuredModels: string[] = []; + const seenModels = new Set(); + + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + if (trimmed.startsWith("[")) { + const sectionName = trimmed.slice(1, trimmed.lastIndexOf("]")).trim(); + inTopLevel = false; + inProfileSection = + sectionName === "profiles" || + sectionName.startsWith("profiles.") || + sectionName.startsWith('profiles."') || + sectionName.startsWith("profiles.'"); + continue; + } + + const modelMatch = trimmed.match(/^model\s*=\s*["']([^"']+)["']/); + if (modelMatch && (inTopLevel || inProfileSection)) { + const model = modelMatch[1]?.trim(); + if (model && !seenModels.has(model)) { + seenModels.add(model); + configuredModels.push(model); + } + } + + if (!inTopLevel) { + continue; + } + + const providerMatch = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); + if (providerMatch) { + modelProvider = providerMatch[1]; + } + } + + return { + modelProvider, + configuredModels, + }; +} + +export const readCodexConfigSnapshot = Effect.fn("readCodexConfigSnapshot")(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const settingsService = yield* ServerSettingsService; @@ -275,23 +339,21 @@ export const readCodexConfigModelProvider = Effect.fn("readCodexConfigModelProvi .readFileString(configPath) .pipe(Effect.orElseSucceed(() => undefined)); if (content === undefined) { - return undefined; + return { + modelProvider: undefined, + configuredModels: [], + } satisfies CodexConfigSnapshot; } - let inTopLevel = true; - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - if (trimmed.startsWith("[")) { - inTopLevel = false; - continue; - } - if (!inTopLevel) continue; + return parseCodexConfigSnapshot(content); +}); - const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); - if (match) return match[1]; - } - return undefined; +export const readCodexConfigModelProvider = Effect.fn("readCodexConfigModelProvider")(function* () { + return (yield* readCodexConfigSnapshot()).modelProvider; +}); + +export const readCodexConfiguredModels = Effect.fn("readCodexConfiguredModels")(function* () { + return (yield* readCodexConfigSnapshot()).configuredModels; }); export const hasCustomModelProvider = readCodexConfigModelProvider().pipe( @@ -299,6 +361,91 @@ export const hasCustomModelProvider = readCodexConfigModelProvider().pipe( Effect.orElseSucceed(() => false), ); +function toTitleCaseWords(value: string): string { + return value + .split(/[\s_-]+/g) + .filter(Boolean) + .map((part) => part[0]!.toUpperCase() + part.slice(1).toLowerCase()) + .join(" "); +} + +function codexDisplayName(modelProvider: string | undefined): string { + if (!modelProvider || OPENAI_AUTH_PROVIDERS.has(modelProvider)) { + return "Codex"; + } + if (modelProvider === "glm") { + return "Codex / GLM"; + } + return `Codex / ${toTitleCaseWords(modelProvider)}`; +} + +function codexCustomProviderMessage(modelProvider: string | undefined): string { + if (modelProvider === "glm") { + return "Using Z.AI GLM through Codex custom model provider config; OpenAI login check skipped."; + } + return "Using a custom Codex model provider; OpenAI login check skipped."; +} + +function codexCustomModelCapabilities(modelProvider: string | undefined): ModelCapabilities { + return modelProvider === "glm" + ? DEFAULT_GLM_MODEL_CAPABILITIES + : DEFAULT_CODEX_MODEL_CAPABILITIES; +} + +function configuredCodexModels( + modelProvider: string | undefined, + configuredModels: ReadonlyArray, +): ReadonlyArray { + const capabilities = codexCustomModelCapabilities(modelProvider); + const models: ServerProviderModel[] = []; + const seen = new Set(); + + for (const slug of configuredModels) { + if (!slug || seen.has(slug)) { + continue; + } + seen.add(slug); + + const builtInModel = + modelProvider === undefined || OPENAI_AUTH_PROVIDERS.has(modelProvider) + ? BUILT_IN_MODELS.find((candidate) => candidate.slug === slug) + : undefined; + + models.push( + builtInModel ?? { + slug, + name: slug, + isCustom: false, + capabilities, + }, + ); + } + + return models; +} + +function codexBuiltInModels(config: CodexConfigSnapshot): ReadonlyArray { + if (config.modelProvider !== undefined && !OPENAI_AUTH_PROVIDERS.has(config.modelProvider)) { + return configuredCodexModels(config.modelProvider, config.configuredModels); + } + + const configuredModelEntries = configuredCodexModels( + config.modelProvider, + config.configuredModels, + ); + const seen = new Set(BUILT_IN_MODELS.map((model) => model.slug)); + return [ + ...BUILT_IN_MODELS, + ...configuredModelEntries.filter((model) => { + if (seen.has(model.slug)) { + return false; + } + seen.add(model.slug); + return true; + }), + ]; +} + const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; const probeCodexCapabilities = (input: { @@ -347,11 +494,19 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu Effect.map((settings) => settings.providers.codex), ); const checkedAt = new Date().toISOString(); + const config = yield* readCodexConfigSnapshot().pipe( + Effect.orElseSucceed(() => ({ + modelProvider: undefined, + configuredModels: [], + })), + ); + const modelProvider = config.modelProvider; + const displayName = codexDisplayName(modelProvider); const models = providerModelsFromSettings( - BUILT_IN_MODELS, + codexBuiltInModels(config), PROVIDER, codexSettings.customModels, - DEFAULT_CODEX_MODEL_CAPABILITIES, + codexCustomModelCapabilities(modelProvider), ); if (!codexSettings.enabled) { @@ -360,6 +515,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: false, checkedAt, models, + displayName, probe: { installed: false, version: null, @@ -382,6 +538,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models, + displayName, probe: { installed: !isCommandMissingCause(error), version: null, @@ -400,6 +557,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models, + displayName, probe: { installed: true, version: null, @@ -421,6 +579,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models, + displayName, probe: { installed: true, version: parsedVersion, @@ -449,18 +608,19 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }); } - if (yield* hasCustomModelProvider) { + if (modelProvider !== undefined && !OPENAI_AUTH_PROVIDERS.has(modelProvider)) { return buildServerProvider({ provider: PROVIDER, enabled: codexSettings.enabled, checkedAt, models, + displayName, probe: { installed: true, version: parsedVersion, status: "ready", auth: { status: "unknown" }, - message: "Using a custom Codex model provider; OpenAI login check skipped.", + message: codexCustomProviderMessage(modelProvider), }, }); } @@ -484,6 +644,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + displayName, probe: { installed: true, version: parsedVersion, @@ -503,6 +664,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + displayName, probe: { installed: true, version: parsedVersion, @@ -521,6 +683,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + displayName, probe: { installed: true, version: parsedVersion, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ca27371b61..fbae187d46 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -27,6 +27,7 @@ import { checkCodexProviderStatus, hasCustomModelProvider, parseAuthStatusFromOutput, + readCodexConfiguredModels, readCodexConfigModelProvider, } from "./CodexProvider"; import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; @@ -156,6 +157,36 @@ function withTempCodexHome(configContent?: string) { }); } +function withTempHomeFile(relativePath: string, content: string) { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-test-home-" }); + + yield* Effect.acquireRelease( + Effect.sync(() => { + const originalHome = process.env.HOME; + process.env.HOME = tmpDir; + return originalHome; + }), + (originalHome) => + Effect.sync(() => { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + }), + ); + + const filePath = path.join(tmpDir, relativePath); + yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, content); + + return { tmpDir } as const; + }); +} + it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( "ProviderRegistry", (it) => { @@ -656,6 +687,45 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.installed, false); }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), ); + + it.effect( + "surfaces only configured GLM models when Codex is configured with model_provider=glm", + () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + 'model_provider = "glm"', + 'model = "glm-5.1"', + "", + "[profiles.fast]", + 'model = "glm-5-air"', + "", + "[model_providers.glm]", + 'base_url = "https://api.z.ai/api/coding/paas/v4"', + 'env_key = "GLM_API_KEY"', + ].join("\n"), + ); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.displayName, "Codex / GLM"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual( + status.message, + "Using Z.AI GLM through Codex custom model provider config; OpenAI login check skipped.", + ); + assert.deepStrictEqual( + status.models.map((model) => model.slug), + ["glm-5.1", "glm-5-air"], + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Auth probe should have been skipped but got args: ${joined}`); + }), + ), + ), + ); }); describe("checkCodexProviderStatus with openai model provider", () => { @@ -781,6 +851,52 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ); }); + describe("readCodexConfiguredModels", () => { + it.effect("returns an empty list when config file does not exist", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + assert.deepStrictEqual(yield* readCodexConfiguredModels(), []); + }), + ); + + it.effect("reads configured models from top-level and profile sections", () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + 'model = "glm-5.1"', + 'model_provider = "glm"', + "", + "[profiles.fast]", + 'model = "glm-5-air"', + "", + "[profiles.review]", + 'model = "glm-5.1"', + "", + "[model_providers.glm]", + 'base_url = "https://api.z.ai/api/coding/paas/v4"', + 'env_key = "GLM_API_KEY"', + ].join("\n"), + ); + assert.deepStrictEqual(yield* readCodexConfiguredModels(), ["glm-5.1", "glm-5-air"]); + }), + ); + + it.effect("ignores model keys outside top-level and profile sections", () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + "[model_providers.glm]", + 'model = "should-be-ignored"', + "", + "[profiles.fast]", + 'model = "glm-5-fast"', + ].join("\n"), + ); + assert.deepStrictEqual(yield* readCodexConfiguredModels(), ["glm-5-fast"]); + }), + ); + }); + // ── hasCustomModelProvider tests ─────────────────────────────────── describe("hasCustomModelProvider", () => { @@ -861,6 +977,49 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("detects Z.AI GLM config from Claude settings", () => + Effect.gen(function* () { + yield* withTempHomeFile( + ".claude/settings.json", + JSON.stringify({ + env: { + ANTHROPIC_AUTH_TOKEN: "glm-api-key", + ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic", + ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1", + ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5.1", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air", + }, + }), + ); + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.displayName, "Claude / GLM"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "apiKey"); + assert.strictEqual(status.auth.label, "Z.AI GLM Plan"); + assert.strictEqual( + status.message, + "Configured to use Z.AI's Anthropic-compatible endpoint. Claude model tiers map to GLM models from your Claude settings.", + ); + assert.deepStrictEqual( + status.models.slice(0, 3).map((model) => model.name), + [ + "Claude Opus 4.6 (glm-5.1)", + "Claude Sonnet 4.6 (glm-5.1)", + "Claude Haiku 4.5 (glm-4.5-air)", + ], + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Auth probe should have been skipped but got args: ${joined}`); + }), + ), + ), + ); + it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(() => Effect.succeed("maxplan")); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index fb2f33c293..2d0648cc56 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -14,10 +14,7 @@ import type { CodexProviderShape } from "../Services/CodexProvider"; import { CodexProvider } from "../Services/CodexProvider"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; -const loadProviders = ( - codexProvider: CodexProviderShape, - claudeProvider: ClaudeProviderShape, -): Effect.Effect => +const loadProviders = (codexProvider: CodexProviderShape, claudeProvider: ClaudeProviderShape) => Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { concurrency: "unbounded", }); diff --git a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts index 490c4d3d14..68e5db6a40 100644 --- a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts @@ -38,5 +38,3 @@ export class ProviderAdapterRegistry extends ServiceMap.Service< ProviderAdapterRegistry, ProviderAdapterRegistryShape >()("t3/provider/Services/ProviderAdapterRegistry") {} - -// Dummy comment for workflow testing. diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 4c80d78e20..766e5fae97 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -131,6 +131,7 @@ export function buildServerProvider(input: { enabled: boolean; checkedAt: string; models: ReadonlyArray; + displayName?: string; probe: ProviderProbeResult; }): ServerProvider { return { @@ -138,6 +139,7 @@ export function buildServerProvider(input: { enabled: input.enabled, installed: input.probe.installed, version: input.probe.version, + ...(input.displayName ? { displayName: input.displayName } : {}), status: input.enabled ? input.probe.status : "disabled", auth: input.probe.auth, checkedAt: input.checkedAt, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 578c679798..c2f033d3f5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1741,17 +1741,22 @@ export default function ChatView(props: ChatViewProps) { AVAILABLE_PROVIDER_OPTIONS.filter( (option) => lockedProvider === null || option.value === lockedProvider, ).flatMap((option) => - modelOptionsByProvider[option.value].map(({ slug, name }) => ({ - provider: option.value, - providerLabel: option.label, - slug, - name, - searchSlug: slug.toLowerCase(), - searchName: name.toLowerCase(), - searchProvider: option.label.toLowerCase(), - })), + modelOptionsByProvider[option.value].map(({ slug, name }) => { + const providerLabel = + providerStatuses.find((provider) => provider.provider === option.value)?.displayName ?? + option.label; + return { + provider: option.value, + providerLabel, + slug, + name, + searchSlug: slug.toLowerCase(), + searchName: name.toLowerCase(), + searchProvider: providerLabel.toLowerCase(), + }; + }), ), - [lockedProvider, modelOptionsByProvider], + [lockedProvider, modelOptionsByProvider, providerStatuses], ); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 37a47bb01b..9f1db8be46 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -545,3 +545,12 @@ export const OpenCodeIcon: Icon = (props) => ( ); + +export const GlmIcon: Icon = (props) => ( + + + +); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 01fa37516e..a3611b8350 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -151,6 +151,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const liveProvider = props.providers ? getProviderSnapshot(props.providers, option.value) : undefined; + const optionLabel = liveProvider?.displayName ?? option.label; if (liveProvider && liveProvider.status !== "ready") { const unavailableLabel = !liveProvider.enabled ? "Disabled" @@ -166,7 +167,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { providerIconClassName(option.value, "text-muted-foreground/85"), )} /> - {option.label} + {optionLabel} {unavailableLabel} @@ -183,7 +184,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { providerIconClassName(option.value, "text-muted-foreground/85"), )} /> - {option.label} + {optionLabel} diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index e709e75da3..8581781b5d 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -12,7 +12,8 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ return null; } - const providerLabel = PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider; + const providerLabel = + status.displayName ?? PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider; const defaultMessage = status.status === "error" ? `${providerLabel} provider is unavailable.` diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 74d8d85cff..853f62d7a6 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -82,7 +82,9 @@ function getProviderStateFromCapabilities( const normalizedOptions = provider === "codex" ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) - : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); + : provider === "claudeAgent" + ? normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions) + : undefined; // Ultrathink styling (driven by capabilities data, not provider identity) const ultrathinkActive = diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c8766f2643..33e0895b8d 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -101,11 +101,12 @@ const TIMESTAMP_FORMAT_LABELS = { type InstallProviderSettings = { provider: ProviderKind; title: string; - binaryPlaceholder: string; - binaryDescription: ReactNode; + binaryPlaceholder?: string; + binaryDescription?: ReactNode; homePathKey?: "codexHomePath"; homePlaceholder?: string; homeDescription?: ReactNode; + envVarHint?: string; }; const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ @@ -663,7 +664,8 @@ export function GeneralSettingsPanel() { homePathKey: providerSettings.homePathKey, homePlaceholder: providerSettings.homePlaceholder, homeDescription: providerSettings.homeDescription, - binaryPathValue: providerConfig.binaryPath, + envVarHint: providerSettings.envVarHint, + binaryPathValue: "binaryPath" in providerConfig ? providerConfig.binaryPath : undefined, isDirty: !Equal.equals(providerConfig, defaultProviderConfig), liveProvider, models, @@ -1009,7 +1011,9 @@ export function GeneralSettingsPanel() { const customModelInput = customModelInputByProvider[providerCard.provider]; const customModelError = customModelErrorByProvider[providerCard.provider] ?? null; const providerDisplayName = - PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title; + providerCard.liveProvider?.displayName ?? + PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? + providerCard.title; return (
@@ -1111,37 +1115,55 @@ export function GeneralSettingsPanel() { >
-
-
+ ) : null} {providerCard.homePathKey ? (
diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 98e2884adf..55e9deba66 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -34,8 +34,9 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record