Skip to content

Commit 7cf4cc3

Browse files
committed
feat: surface GLM-backed Codex and Claude runtimes
1 parent aaeaf5b commit 7cf4cc3

38 files changed

+437
-2015
lines changed

apps/server/src/git/Layers/RoutingTextGeneration.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ const makeRoutingTextGeneration = Effect.gen(function* () {
4444
case "claudeAgent":
4545
return claude;
4646
case "codex":
47-
case "glm":
4847
case undefined:
4948
return codex;
5049
}

apps/server/src/git/Services/TextGeneration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";
1313
import type { TextGenerationError } from "@t3tools/contracts";
1414

1515
/** Providers that support git text generation (commit messages, PR content, branch names). */
16-
export type TextGenerationProvider = "codex" | "claudeAgent" | "glm";
16+
export type TextGenerationProvider = "codex" | "claudeAgent";
1717

1818
export interface CommitMessageGenerationInput {
1919
cwd: string;

apps/server/src/provider/Layers/ClaudeProvider.ts

Lines changed: 176 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as OS from "node:os";
12
import type {
23
ClaudeSettings,
34
ModelCapabilities,
@@ -6,7 +7,19 @@ import type {
67
ServerProviderAuth,
78
ServerProviderState,
89
} from "@t3tools/contracts";
9-
import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect";
10+
import {
11+
Cache,
12+
Duration,
13+
Effect,
14+
Equal,
15+
FileSystem,
16+
Layer,
17+
Option,
18+
Path,
19+
Result,
20+
Schema,
21+
Stream,
22+
} from "effect";
1023
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1124
import { decodeJsonResult } from "@t3tools/shared/schemaJson";
1225
import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk";
@@ -36,6 +49,12 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = {
3649
};
3750

3851
const PROVIDER = "claudeAgent" as const;
52+
const ZAI_ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic";
53+
const DEFAULT_CLAUDE_GLM_MODEL_MAPPING = {
54+
opus: "glm-4.7",
55+
sonnet: "glm-4.7",
56+
haiku: "glm-4.5-air",
57+
} as const;
3958
const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
4059
{
4160
slug: "claude-opus-4-6",
@@ -92,6 +111,107 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
92111
},
93112
];
94113

114+
interface ClaudeGlmIntegration {
115+
readonly hasAuthToken: boolean;
116+
readonly opusModel: string;
117+
readonly sonnetModel: string;
118+
readonly haikuModel: string;
119+
}
120+
121+
function normalizeUrl(value: string | undefined): string | undefined {
122+
const trimmed = value?.trim();
123+
return trimmed ? trimmed.replace(/\/+$/g, "").toLowerCase() : undefined;
124+
}
125+
126+
function asPlainRecord(value: unknown): Record<string, unknown> | undefined {
127+
return typeof value === "object" && value !== null && !globalThis.Array.isArray(value)
128+
? (value as Record<string, unknown>)
129+
: undefined;
130+
}
131+
132+
function asTrimmedString(value: unknown): string | undefined {
133+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
134+
}
135+
136+
function readClaudeGlmIntegrationFromEnv(
137+
env: Record<string, string | undefined>,
138+
): ClaudeGlmIntegration | undefined {
139+
if (normalizeUrl(env.ANTHROPIC_BASE_URL) !== normalizeUrl(ZAI_ANTHROPIC_BASE_URL)) {
140+
return undefined;
141+
}
142+
143+
return {
144+
hasAuthToken: Boolean(asTrimmedString(env.ANTHROPIC_AUTH_TOKEN)),
145+
opusModel:
146+
asTrimmedString(env.ANTHROPIC_DEFAULT_OPUS_MODEL) ?? DEFAULT_CLAUDE_GLM_MODEL_MAPPING.opus,
147+
sonnetModel:
148+
asTrimmedString(env.ANTHROPIC_DEFAULT_SONNET_MODEL) ??
149+
DEFAULT_CLAUDE_GLM_MODEL_MAPPING.sonnet,
150+
haikuModel:
151+
asTrimmedString(env.ANTHROPIC_DEFAULT_HAIKU_MODEL) ?? DEFAULT_CLAUDE_GLM_MODEL_MAPPING.haiku,
152+
};
153+
}
154+
155+
function buildClaudeModels(
156+
integration: ClaudeGlmIntegration | undefined,
157+
): ReadonlyArray<ServerProviderModel> {
158+
if (!integration) {
159+
return BUILT_IN_MODELS;
160+
}
161+
162+
return BUILT_IN_MODELS.map((model) => {
163+
let mappedModel: string | undefined;
164+
switch (model.slug) {
165+
case "claude-opus-4-6":
166+
mappedModel = integration.opusModel;
167+
break;
168+
case "claude-sonnet-4-6":
169+
mappedModel = integration.sonnetModel;
170+
break;
171+
case "claude-haiku-4-5":
172+
mappedModel = integration.haikuModel;
173+
break;
174+
}
175+
176+
return mappedModel ? { ...model, name: `${model.name} (${mappedModel})` } : model;
177+
});
178+
}
179+
180+
export const readClaudeGlmIntegration = Effect.fn("readClaudeGlmIntegration")(function* () {
181+
const fileSystem = yield* FileSystem.FileSystem;
182+
const path = yield* Path.Path;
183+
const settingsPath = path.join(OS.homedir(), ".claude", "settings.json");
184+
const content = yield* fileSystem
185+
.readFileString(settingsPath)
186+
.pipe(Effect.orElseSucceed(() => undefined));
187+
188+
const fileEnv = (() => {
189+
if (!content) {
190+
return {} as Record<string, string | undefined>;
191+
}
192+
try {
193+
const parsed = JSON.parse(content) as unknown;
194+
const envRecord = asPlainRecord(asPlainRecord(parsed)?.env);
195+
if (!envRecord) {
196+
return {} as Record<string, string | undefined>;
197+
}
198+
return Object.fromEntries(
199+
Object.entries(envRecord).flatMap(([key, value]) => {
200+
const stringValue = asTrimmedString(value);
201+
return stringValue ? [[key, stringValue]] : [];
202+
}),
203+
) as Record<string, string | undefined>;
204+
} catch {
205+
return {} as Record<string, string | undefined>;
206+
}
207+
})();
208+
209+
return readClaudeGlmIntegrationFromEnv({
210+
...fileEnv,
211+
...process.env,
212+
});
213+
});
214+
95215
export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities {
96216
const slug = model?.trim();
97217
return (
@@ -446,15 +566,22 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
446566
): Effect.fn.Return<
447567
ServerProvider,
448568
ServerSettingsError,
449-
ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService
569+
| ChildProcessSpawner.ChildProcessSpawner
570+
| FileSystem.FileSystem
571+
| Path.Path
572+
| ServerSettingsService
450573
> {
451574
const claudeSettings = yield* Effect.service(ServerSettingsService).pipe(
452575
Effect.flatMap((service) => service.getSettings),
453576
Effect.map((settings) => settings.providers.claudeAgent),
454577
);
578+
const glmIntegration = yield* readClaudeGlmIntegration().pipe(
579+
Effect.orElseSucceed(() => undefined),
580+
);
455581
const checkedAt = new Date().toISOString();
582+
const displayName = glmIntegration ? "Claude / GLM" : "Claude";
456583
const models = providerModelsFromSettings(
457-
BUILT_IN_MODELS,
584+
buildClaudeModels(glmIntegration),
458585
PROVIDER,
459586
claudeSettings.customModels,
460587
DEFAULT_CLAUDE_MODEL_CAPABILITIES,
@@ -466,6 +593,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
466593
enabled: false,
467594
checkedAt,
468595
models,
596+
displayName,
469597
probe: {
470598
installed: false,
471599
version: null,
@@ -488,6 +616,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
488616
enabled: claudeSettings.enabled,
489617
checkedAt,
490618
models,
619+
displayName,
491620
probe: {
492621
installed: !isCommandMissingCause(error),
493622
version: null,
@@ -506,6 +635,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
506635
enabled: claudeSettings.enabled,
507636
checkedAt,
508637
models,
638+
displayName,
509639
probe: {
510640
installed: true,
511641
version: null,
@@ -526,6 +656,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
526656
enabled: claudeSettings.enabled,
527657
checkedAt,
528658
models,
659+
displayName,
529660
probe: {
530661
installed: true,
531662
version: parsedVersion,
@@ -538,6 +669,41 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
538669
});
539670
}
540671

672+
if (glmIntegration) {
673+
return buildServerProvider({
674+
provider: PROVIDER,
675+
enabled: claudeSettings.enabled,
676+
checkedAt,
677+
models,
678+
displayName,
679+
probe: glmIntegration.hasAuthToken
680+
? {
681+
installed: true,
682+
version: parsedVersion,
683+
status: "ready",
684+
auth: {
685+
status: "authenticated",
686+
type: "apiKey",
687+
label: "Z.AI GLM Plan",
688+
},
689+
message:
690+
"Configured to use Z.AI's Anthropic-compatible endpoint. Claude model tiers map to GLM models from your Claude settings.",
691+
}
692+
: {
693+
installed: true,
694+
version: parsedVersion,
695+
status: "error",
696+
auth: {
697+
status: "unauthenticated",
698+
type: "apiKey",
699+
label: "Z.AI GLM Plan",
700+
},
701+
message:
702+
"Configured to use Z.AI's Anthropic-compatible endpoint, but ANTHROPIC_AUTH_TOKEN is missing.",
703+
},
704+
});
705+
}
706+
541707
// ── Auth check + subscription detection ────────────────────────────
542708

543709
const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe(
@@ -574,6 +740,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
574740
enabled: claudeSettings.enabled,
575741
checkedAt,
576742
models: resolvedModels,
743+
displayName,
577744
probe: {
578745
installed: true,
579746
version: parsedVersion,
@@ -593,6 +760,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
593760
enabled: claudeSettings.enabled,
594761
checkedAt,
595762
models: resolvedModels,
763+
displayName,
596764
probe: {
597765
installed: true,
598766
version: parsedVersion,
@@ -610,6 +778,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
610778
enabled: claudeSettings.enabled,
611779
checkedAt,
612780
models: resolvedModels,
781+
displayName,
613782
probe: {
614783
installed: true,
615784
version: parsedVersion,
@@ -627,6 +796,8 @@ export const ClaudeProviderLive = Layer.effect(
627796
ClaudeProvider,
628797
Effect.gen(function* () {
629798
const serverSettings = yield* ServerSettingsService;
799+
const fileSystem = yield* FileSystem.FileSystem;
800+
const path = yield* Path.Path;
630801
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
631802

632803
const subscriptionProbeCache = yield* Cache.make({
@@ -640,6 +811,8 @@ export const ClaudeProviderLive = Layer.effect(
640811
Cache.get(subscriptionProbeCache, binaryPath),
641812
).pipe(
642813
Effect.provideService(ServerSettingsService, serverSettings),
814+
Effect.provideService(FileSystem.FileSystem, fileSystem),
815+
Effect.provideService(Path.Path, path),
643816
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
644817
);
645818

0 commit comments

Comments
 (0)