Skip to content

Commit 676bfb1

Browse files
committed
feat: add GLM (Z.ai) as a third provider
Add GLM as a Codex-backed provider that routes through a local Responses-to-ChatCompletions bridge. GLM sessions reuse the Codex app-server runtime while presenting as a separate provider in the UI. Contracts: add "glm" to ProviderKind, ModelSelection, GlmSettings, and all Record<ProviderKind, ...> exhaustiveness sites. Server: GlmAdapter (thin CodexAdapter delegate), GlmProvider (snapshot service checking GLM_API_KEY), GLM bridge (loopback HTTP translating Responses <-> Chat Completions), shared codexLaunchConfig builder, text generation routing for GLM. Web: GLM in provider picker, settings panel with env var hint, composer registry entry, model selection config, GlmIcon. Tests: 40 new tests covering bridge translation (Responses -> Chat Completions, Chat Completions streaming -> Responses SSE), launch config builder, and updated existing tests for the new provider.
1 parent afc3924 commit 676bfb1

32 files changed

Lines changed: 2058 additions & 49 deletions

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,16 @@ const makeRoutingTextGeneration = Effect.gen(function* () {
3939
const codex = yield* CodexTextGen;
4040
const claude = yield* ClaudeTextGen;
4141

42-
const route = (provider?: TextGenerationProvider): TextGenerationShape =>
43-
provider === "claudeAgent" ? claude : codex;
42+
const route = (provider?: TextGenerationProvider): TextGenerationShape => {
43+
switch (provider) {
44+
case "claudeAgent":
45+
return claude;
46+
case "codex":
47+
case "glm":
48+
case undefined:
49+
return codex;
50+
}
51+
};
4452

4553
return {
4654
generateCommitMessage: (input) =>

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";
16+
export type TextGenerationProvider = "codex" | "claudeAgent" | "glm";
1717

1818
export interface CommitMessageGenerationInput {
1919
cwd: string;
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type {
2+
ApprovalRequestId,
3+
ProviderApprovalDecision,
4+
ProviderRuntimeEvent,
5+
ProviderSendTurnInput,
6+
ProviderSession,
7+
ProviderSessionStartInput,
8+
ProviderTurnStartResult,
9+
ProviderUserInputAnswers,
10+
ThreadId,
11+
TurnId,
12+
} from "@t3tools/contracts";
13+
import { Effect, Layer, Queue, Stream } from "effect";
14+
15+
import type { ProviderAdapterError } from "../Errors.ts";
16+
import { GlmAdapter, type GlmAdapterShape } from "../Services/GlmAdapter.ts";
17+
import { CodexAdapter } from "../Services/CodexAdapter.ts";
18+
import type {
19+
ProviderAdapterCapabilities,
20+
ProviderThreadSnapshot,
21+
} from "../Services/ProviderAdapter.ts";
22+
import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts";
23+
24+
const PROVIDER = "glm" as const;
25+
26+
export interface GlmAdapterLiveOptions {
27+
readonly nativeEventLogger?: EventNdjsonLogger;
28+
}
29+
30+
function remapSessionProvider(session: ProviderSession): ProviderSession {
31+
return { ...session, provider: PROVIDER };
32+
}
33+
34+
export const GlmAdapterLive = Layer.effect(
35+
GlmAdapter,
36+
Effect.gen(function* () {
37+
const codexAdapter = yield* CodexAdapter;
38+
const glmEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
39+
const glmThreadIds = new Set<ThreadId>();
40+
41+
const capabilities: ProviderAdapterCapabilities = {
42+
sessionModelSwitch: "restart-session",
43+
};
44+
45+
const startSession = (
46+
input: ProviderSessionStartInput,
47+
): Effect.Effect<ProviderSession, ProviderAdapterError> =>
48+
Effect.gen(function* () {
49+
glmThreadIds.add(input.threadId);
50+
const session = yield* codexAdapter.startSession({
51+
...input,
52+
provider: "codex",
53+
});
54+
return remapSessionProvider(session);
55+
});
56+
57+
const sendTurn = (
58+
input: ProviderSendTurnInput,
59+
): Effect.Effect<ProviderTurnStartResult, ProviderAdapterError> => codexAdapter.sendTurn(input);
60+
61+
const interruptTurn = (
62+
threadId: ThreadId,
63+
turnId?: TurnId,
64+
): Effect.Effect<void, ProviderAdapterError> => codexAdapter.interruptTurn(threadId, turnId);
65+
66+
const respondToRequest = (
67+
threadId: ThreadId,
68+
requestId: ApprovalRequestId,
69+
decision: ProviderApprovalDecision,
70+
): Effect.Effect<void, ProviderAdapterError> =>
71+
codexAdapter.respondToRequest(threadId, requestId, decision);
72+
73+
const respondToUserInput = (
74+
threadId: ThreadId,
75+
requestId: ApprovalRequestId,
76+
answers: ProviderUserInputAnswers,
77+
): Effect.Effect<void, ProviderAdapterError> =>
78+
codexAdapter.respondToUserInput(threadId, requestId, answers);
79+
80+
const stopSession = (threadId: ThreadId): Effect.Effect<void, ProviderAdapterError> =>
81+
Effect.gen(function* () {
82+
yield* codexAdapter.stopSession(threadId);
83+
glmThreadIds.delete(threadId);
84+
});
85+
86+
const listSessions = (): Effect.Effect<ReadonlyArray<ProviderSession>> =>
87+
codexAdapter
88+
.listSessions()
89+
.pipe(
90+
Effect.map((sessions) =>
91+
sessions.filter((s) => glmThreadIds.has(s.threadId)).map(remapSessionProvider),
92+
),
93+
);
94+
95+
const hasSession = (threadId: ThreadId): Effect.Effect<boolean> =>
96+
glmThreadIds.has(threadId) ? codexAdapter.hasSession(threadId) : Effect.succeed(false);
97+
98+
const readThread = (
99+
threadId: ThreadId,
100+
): Effect.Effect<ProviderThreadSnapshot, ProviderAdapterError> =>
101+
codexAdapter.readThread(threadId);
102+
103+
const rollbackThread = (
104+
threadId: ThreadId,
105+
numTurns: number,
106+
): Effect.Effect<ProviderThreadSnapshot, ProviderAdapterError> =>
107+
codexAdapter.rollbackThread(threadId, numTurns);
108+
109+
const stopAll = (): Effect.Effect<void, ProviderAdapterError> =>
110+
Effect.gen(function* () {
111+
for (const threadId of glmThreadIds) {
112+
yield* codexAdapter.stopSession(threadId).pipe(Effect.ignore);
113+
}
114+
glmThreadIds.clear();
115+
});
116+
117+
return {
118+
provider: PROVIDER,
119+
capabilities,
120+
startSession,
121+
sendTurn,
122+
interruptTurn,
123+
respondToRequest,
124+
respondToUserInput,
125+
stopSession,
126+
listSessions,
127+
hasSession,
128+
readThread,
129+
rollbackThread,
130+
stopAll,
131+
get streamEvents() {
132+
return Stream.fromQueue(glmEventQueue);
133+
},
134+
} satisfies GlmAdapterShape;
135+
}),
136+
);
137+
138+
export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) {
139+
return GlmAdapterLive;
140+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type { GlmSettings, ModelCapabilities, ServerProviderModel } from "@t3tools/contracts";
2+
import { Effect, Equal, Layer, Stream } from "effect";
3+
4+
import {
5+
buildServerProvider,
6+
providerModelsFromSettings,
7+
type ProviderProbeResult,
8+
} from "../providerSnapshot.ts";
9+
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
10+
import { GlmProvider } from "../Services/GlmProvider.ts";
11+
import { ServerSettingsService } from "../../serverSettings.ts";
12+
13+
const PROVIDER = "glm" as const;
14+
15+
const DEFAULT_GLM_MODEL_CAPABILITIES: ModelCapabilities = {
16+
reasoningEffortLevels: [],
17+
supportsFastMode: false,
18+
supportsThinkingToggle: false,
19+
contextWindowOptions: [],
20+
promptInjectedEffortLevels: [],
21+
};
22+
23+
const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
24+
{
25+
slug: "glm-5.1",
26+
name: "GLM 5.1",
27+
isCustom: false,
28+
capabilities: DEFAULT_GLM_MODEL_CAPABILITIES,
29+
},
30+
{
31+
slug: "glm-5",
32+
name: "GLM 5",
33+
isCustom: false,
34+
capabilities: DEFAULT_GLM_MODEL_CAPABILITIES,
35+
},
36+
{
37+
slug: "glm-5-turbo",
38+
name: "GLM 5 Turbo",
39+
isCustom: false,
40+
capabilities: DEFAULT_GLM_MODEL_CAPABILITIES,
41+
},
42+
{
43+
slug: "glm-4.7",
44+
name: "GLM 4.7",
45+
isCustom: false,
46+
capabilities: DEFAULT_GLM_MODEL_CAPABILITIES,
47+
},
48+
{
49+
slug: "glm-4.6",
50+
name: "GLM 4.6",
51+
isCustom: false,
52+
capabilities: DEFAULT_GLM_MODEL_CAPABILITIES,
53+
},
54+
{
55+
slug: "glm-4.5",
56+
name: "GLM 4.5",
57+
isCustom: false,
58+
capabilities: DEFAULT_GLM_MODEL_CAPABILITIES,
59+
},
60+
{
61+
slug: "glm-4.5-air",
62+
name: "GLM 4.5 Air",
63+
isCustom: false,
64+
capabilities: DEFAULT_GLM_MODEL_CAPABILITIES,
65+
},
66+
];
67+
68+
function checkGlmProviderStatus(_glmSettings: GlmSettings): ProviderProbeResult {
69+
const hasApiKey = Boolean(process.env.GLM_API_KEY);
70+
71+
if (!hasApiKey) {
72+
return {
73+
installed: true,
74+
version: null,
75+
status: "error",
76+
auth: { status: "unauthenticated" },
77+
message: "Set the GLM_API_KEY environment variable to authenticate.",
78+
};
79+
}
80+
81+
return {
82+
installed: true,
83+
version: null,
84+
status: "ready",
85+
auth: { status: "authenticated", type: "apiKey" },
86+
};
87+
}
88+
89+
export const GlmProviderLive = Layer.effect(
90+
GlmProvider,
91+
Effect.gen(function* () {
92+
const serverSettings = yield* ServerSettingsService;
93+
94+
const checkProvider = Effect.gen(function* () {
95+
const settings = yield* serverSettings.getSettings;
96+
const glmSettings = settings.providers.glm;
97+
const probe = checkGlmProviderStatus(glmSettings);
98+
99+
const models = providerModelsFromSettings(
100+
BUILT_IN_MODELS,
101+
PROVIDER,
102+
glmSettings.customModels,
103+
DEFAULT_GLM_MODEL_CAPABILITIES,
104+
);
105+
106+
return buildServerProvider({
107+
provider: PROVIDER,
108+
enabled: glmSettings.enabled,
109+
checkedAt: new Date().toISOString(),
110+
models,
111+
probe,
112+
});
113+
});
114+
115+
return yield* makeManagedServerProvider<GlmSettings>({
116+
getSettings: serverSettings.getSettings.pipe(
117+
Effect.map((settings) => settings.providers.glm),
118+
Effect.orDie,
119+
),
120+
streamSettings: serverSettings.streamChanges.pipe(
121+
Stream.map((settings) => settings.providers.glm),
122+
),
123+
haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),
124+
checkProvider,
125+
});
126+
}),
127+
);

apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Effect, Layer, Stream } from "effect";
66

77
import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
88
import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts";
9+
import { GlmAdapter, GlmAdapterShape } from "../Services/GlmAdapter.ts";
910
import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
1011
import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
1112
import { ProviderUnsupportedError } from "../Errors.ts";
@@ -45,13 +46,31 @@ const fakeClaudeAdapter: ClaudeAdapterShape = {
4546
streamEvents: Stream.empty,
4647
};
4748

49+
const fakeGlmAdapter: GlmAdapterShape = {
50+
provider: "glm",
51+
capabilities: { sessionModelSwitch: "restart-session" },
52+
startSession: vi.fn(),
53+
sendTurn: vi.fn(),
54+
interruptTurn: vi.fn(),
55+
respondToRequest: vi.fn(),
56+
respondToUserInput: vi.fn(),
57+
stopSession: vi.fn(),
58+
listSessions: vi.fn(),
59+
hasSession: vi.fn(),
60+
readThread: vi.fn(),
61+
rollbackThread: vi.fn(),
62+
stopAll: vi.fn(),
63+
streamEvents: Stream.empty,
64+
};
65+
4866
const layer = it.layer(
4967
Layer.mergeAll(
5068
Layer.provide(
5169
ProviderAdapterRegistryLive,
5270
Layer.mergeAll(
5371
Layer.succeed(CodexAdapter, fakeCodexAdapter),
5472
Layer.succeed(ClaudeAdapter, fakeClaudeAdapter),
73+
Layer.succeed(GlmAdapter, fakeGlmAdapter),
5574
),
5675
),
5776
NodeServices.layer,
@@ -64,11 +83,13 @@ layer("ProviderAdapterRegistryLive", (it) => {
6483
const registry = yield* ProviderAdapterRegistry;
6584
const codex = yield* registry.getByProvider("codex");
6685
const claude = yield* registry.getByProvider("claudeAgent");
86+
const glm = yield* registry.getByProvider("glm");
6787
assert.equal(codex, fakeCodexAdapter);
6888
assert.equal(claude, fakeClaudeAdapter);
89+
assert.equal(glm, fakeGlmAdapter);
6990

7091
const providers = yield* registry.listProviders();
71-
assert.deepEqual(providers, ["codex", "claudeAgent"]);
92+
assert.deepEqual(providers, ["codex", "claudeAgent", "glm"]);
7293
}),
7394
);
7495

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "../Services/ProviderAdapterRegistry.ts";
1818
import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
1919
import { CodexAdapter } from "../Services/CodexAdapter.ts";
20+
import { GlmAdapter } from "../Services/GlmAdapter.ts";
2021

2122
export interface ProviderAdapterRegistryLiveOptions {
2223
readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>;
@@ -28,7 +29,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun
2829
const adapters =
2930
options?.adapters !== undefined
3031
? options.adapters
31-
: [yield* CodexAdapter, yield* ClaudeAdapter];
32+
: [yield* CodexAdapter, yield* ClaudeAdapter, yield* GlmAdapter];
3233
const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
3334

3435
const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {

0 commit comments

Comments
 (0)