Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/server/src/git/Layers/RoutingTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
170 changes: 167 additions & 3 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as OS from "node:os";
import type {
ClaudeSettings,
ModelCapabilities,
Expand All @@ -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";
Expand Down Expand Up @@ -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<ServerProviderModel> = [
{
slug: "claude-opus-4-6",
Expand Down Expand Up @@ -92,6 +106,103 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
},
];

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<string, unknown> | undefined {
return typeof value === "object" && value !== null && !globalThis.Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}

function asTrimmedString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}

function readClaudeGlmIntegrationFromEnv(
env: Record<string, string | undefined>,
): 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<ServerProviderModel> {
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<string, string | undefined>;
}
try {
const parsed = JSON.parse(content) as unknown;
const envRecord = asPlainRecord(asPlainRecord(parsed)?.env);
if (!envRecord) {
return {} as Record<string, string | undefined>;
}
return Object.fromEntries(
Object.entries(envRecord).flatMap(([key, value]) => {
const stringValue = asTrimmedString(value);
return stringValue ? [[key, stringValue]] : [];
}),
) as Record<string, string | undefined>;
} catch {
return {} as Record<string, string | undefined>;
}
})();

return readClaudeGlmIntegrationFromEnv({
...fileEnv,
...process.env,
});
});

export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities {
const slug = model?.trim();
return (
Expand Down Expand Up @@ -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,
Expand All @@ -466,6 +584,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
enabled: false,
checkedAt,
models,
displayName,
probe: {
installed: false,
version: null,
Expand All @@ -488,6 +607,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
enabled: claudeSettings.enabled,
checkedAt,
models,
displayName,
probe: {
installed: !isCommandMissingCause(error),
version: null,
Expand All @@ -506,6 +626,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
enabled: claudeSettings.enabled,
checkedAt,
models,
displayName,
probe: {
installed: true,
version: null,
Expand All @@ -526,6 +647,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
enabled: claudeSettings.enabled,
checkedAt,
models,
displayName,
probe: {
installed: true,
version: parsedVersion,
Expand All @@ -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(
Expand Down Expand Up @@ -574,6 +731,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
enabled: claudeSettings.enabled,
checkedAt,
models: resolvedModels,
displayName,
probe: {
installed: true,
version: parsedVersion,
Expand All @@ -593,6 +751,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
enabled: claudeSettings.enabled,
checkedAt,
models: resolvedModels,
displayName,
probe: {
installed: true,
version: parsedVersion,
Expand All @@ -610,6 +769,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
enabled: claudeSettings.enabled,
checkedAt,
models: resolvedModels,
displayName,
probe: {
installed: true,
version: parsedVersion,
Expand All @@ -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({
Expand All @@ -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),
);

Expand Down
Loading
Loading