Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
220ced2
fix(claude): discover models from configured gateways
greyxp1 Oct 31, 2026
dc7694d
fix(claude): match gateway request configuration
greyxp1 Oct 31, 2026
002f837
fix(claude): retain custom models and handle invalid catalog headers
greyxp1 Oct 31, 2026
93287a7
fix(claude): publish authoritative gateway models to clients
maria-rcks Nov 1, 2026
f6601bb
fix(claude): preserve dated gateway model identifiers
maria-rcks Nov 1, 2026
8551db6
fix(claude): preserve gateway models across helper and health paths
maria-rcks Nov 1, 2026
942f260
fix(claude): preserve canonical subagent model metadata
maria-rcks Nov 1, 2026
05fdd67
fix(claude): preserve catalog metadata for gateway aliases
greyxp1 Nov 3, 2026
b8e6af2
fix(claude): reject gateway catalog redirects
greyxp1 Nov 3, 2026
d77400a
chore: merge main into claude gateway models
maria-rcks Nov 4, 2026
e00d2b9
fix(claude): retain saved gateway models across catalog refresh
maria-rcks Nov 4, 2026
c276e66
Merge remote-tracking branch 'github/main' into fix/claude-gateway-mo…
maria-rcks Nov 4, 2026
744f157
Merge remote-tracking branch 'github/main' into fix/claude-gateway-mo…
maria-rcks Nov 5, 2026
74dfb53
fix(claude): map gateway header failures to a typed error
maria-rcks Nov 5, 2026
3dd14d2
chore: merge main into claude gateway models
maria-rcks Nov 11, 2026
9a333f6
fix(claude): retain metadata for dated gateway models
maria-rcks Nov 11, 2026
75c3da5
fix(claude): compile options for dated gateway models
maria-rcks Nov 11, 2026
3a59e53
chore: merge main into claude gateway models
maria-rcks Nov 15, 2026
29b795a
fix(claude): retain canonical gateway metadata and backend routes
maria-rcks Nov 15, 2026
f7e29b1
fix(claude): coerce gateway settings environment values
maria-rcks Nov 15, 2026
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
31 changes: 18 additions & 13 deletions apps/server/src/claudeModelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,24 @@ const selection = (

describe("compileClaudeModelSelection", () => {
it("compiles context, effort, and settings together", () => {
expect(
compileClaudeModelSelection(
selection("claude-opus-4-6", [
{ id: "contextWindow", value: "1m" },
{ id: "effort", value: "max" },
{ id: "fastMode", value: true },
]),
),
).toMatchObject({
apiModelId: "claude-opus-4-6[1m]",
effort: "max",
settings: { fastMode: true },
});
expect(compileClaudeModelSelection(selection("claude-haiku-4-5-20251001", [])).apiModelId).toBe(
"claude-haiku-4-5-20251001",
);
for (const model of ["claude-opus-4-6", "claude-opus-4-6-20261101"]) {
expect(
compileClaudeModelSelection(
selection(model, [
{ id: "contextWindow", value: "1m" },
{ id: "effort", value: "max" },
{ id: "fastMode", value: true },
]),
),
).toMatchObject({
apiModelId: model + "[1m]",
effort: "max",
settings: { fastMode: true },
});
}
});

it("runs Ultracode at the chosen effort and reads the old Ultracode effort as xhigh", () => {
Expand Down
81 changes: 42 additions & 39 deletions apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,44 +74,47 @@ it.effect("reports a deleted worktree's scan as finished, not pending", () =>
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("keeps the probe subprocess alive while gateway model discovery can finish", () =>
Effect.gen(function* () {
let abortSignal: AbortSignal | undefined;
const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
abortSignal = options?.abortController?.signal;
return {
initializationResult: async () => ({
account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },
commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],
models: [{ value: "gateway/claude-relay", displayName: "Relay", description: "" }],
it.effect.each(["1", true])(
"keeps the probe subprocess alive for gateway discovery flag %s",
(enabled) =>
Effect.gen(function* () {
let abortSignal: AbortSignal | undefined;
const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
abortSignal = options?.abortController?.signal;
return {
initializationResult: async () => ({
account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },
commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],
models: [{ value: "gateway/claude-relay", displayName: "Relay", description: "" }],
}),
usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () =>
new Promise((_, reject) => reject(new Error("usage unavailable"))),
} as ReturnType<typeof ClaudeSdk.query>;
});
yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
const fileSystem = yield* FileSystem.FileSystem;
const configDir = yield* fileSystem.makeTempDirectoryScoped();
// Claude Code also reads these from settings.json, which is where gateway users set them.
yield* fileSystem.writeFileString(
`${configDir}/settings.json`,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: "https://gateway.example",
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: enabled,
CLAUDE_CODE_GATEWAY_MODEL_DISCOVERY_TIMEOUT_MS: 1000,
},
}),
usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () =>
new Promise((_, reject) => reject(new Error("usage unavailable"))),
} as ReturnType<typeof ClaudeSdk.query>;
});
yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
const fileSystem = yield* FileSystem.FileSystem;
const configDir = yield* fileSystem.makeTempDirectoryScoped();
// Claude Code also reads these from settings.json, which is where gateway users set them.
yield* fileSystem.writeFileString(
`${configDir}/settings.json`,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: "https://gateway.example",
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
},
}),
);
const capabilities = yield* probeClaudeCapabilities(
yield* decodeClaudeSettings({ binaryPath: "claude" }),
{ PATH: (yield* HostProcess.Environment).PATH, CLAUDE_CONFIG_DIR: configDir },
);
assert.deepEqual(
capabilities.models?.map((model) => model.value),
["gateway/claude-relay"],
);
assert.equal(abortSignal?.aborted, false);
yield* TestClock.adjust("3 seconds");
assert.equal(abortSignal?.aborted, true);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
const capabilities = yield* probeClaudeCapabilities(
yield* decodeClaudeSettings({ binaryPath: "claude" }),
{ PATH: (yield* HostProcess.Environment).PATH, CLAUDE_CONFIG_DIR: configDir },
);
assert.deepEqual(
capabilities.models?.map((model) => model.value),
["gateway/claude-relay"],
);
assert.equal(abortSignal?.aborted, false);
yield* TestClock.adjust("1 second");
assert.equal(abortSignal?.aborted, true);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
78 changes: 77 additions & 1 deletion apps/server/src/provider/ClaudeModelCatalog.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { assert, describe, it } from "@effect/vitest";
import { type ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
import { buildServerProvider } from "@t3tools/provider-core/server/snapshotProbe";

import { applyClaudeGatewayModels } from "./Drivers/ClaudeGatewayModels.ts";

import { resolveProviderCatalog, type ModelManifestData } from "./ModelManifest.ts";
import {
Expand Down Expand Up @@ -74,6 +77,64 @@ const catalogFromManifest = (data: ModelManifestData) =>
resolveClaudeModelCatalog(resolveProviderCatalog(data, CLAUDE) ?? undefined);

describe("Claude model catalog", () => {
it("advertises discovered gateway models without requiring saved custom model settings", () => {
const opus = BUNDLED_CLAUDE_MODEL_CATALOG.models.find(
(entry) => entry.model.slug === "claude-opus-4-5",
)!.model;
const provider = buildServerProvider({
presentation: { displayName: "Claude" },
enabled: true,
checkedAt: "2026-11-01T00:00:00Z",
updateRequiredModels: [{ slug: "claude-haiku-5-5", name: "Haiku", minVersion: "2.1.293" }],
models: [
opus,
{
slug: "claude-opus-4-5-20251101",
name: "SDK dated Opus",
isCustom: false,
capabilities: { optionDescriptors: [] },
},
{ slug: "haiku", name: "Custom Haiku", isCustom: true, capabilities: null },
{ slug: "claude-sonnet-4-6", name: "Saved Sonnet", isCustom: false, capabilities: null },
{ slug: "known", name: "Known", isCustom: false, capabilities: null },
{ slug: "omitted", name: "Omitted", isCustom: false, capabilities: null },
{ slug: "saved", name: "Saved", isCustom: true, capabilities: null },
],
probe: {
installed: true,
version: "2.1.316",
status: "ready",
auth: { status: "authenticated" },
},
});
const result = applyClaudeGatewayModels(
provider,
[
{ id: "known" },
{ id: "claude-opus-4-5-20251101" },
{ id: "claude-haiku-5-5-20261101" },
{ id: "claude-haiku-4-5-20251001", display_name: "Gateway Haiku" },
{ id: "haiku" },
],
BUNDLED_CLAUDE_MODEL_CATALOG,
["saved", "haiku", "claude-sonnet-4-6"],
);
assert.deepStrictEqual(result.models, [
{ slug: "known", name: "Known", isCustom: false, capabilities: null },
{ ...opus, slug: "claude-opus-4-5-20251101" },
{
slug: "claude-haiku-4-5-20251001",
name: "Gateway Haiku",
isCustom: false,
capabilities: { optionDescriptors: [] },
},
{ slug: "haiku", name: "Custom Haiku", isCustom: true, capabilities: null },
{ slug: "claude-sonnet-4-6", name: "Saved Sonnet", isCustom: false, capabilities: null },
{ slug: "saved", name: "Saved", isCustom: true, capabilities: null },
]);
assert.deepStrictEqual(result.updateRequiredModels, provider.updateRequiredModels);
});

it("resolves capacity from selected options and fixed catalog windows without guessing custom models", () => {
const source = manifest();
const profile = source.providers!.claudeAgent!.profiles.synthetic!;
Expand Down Expand Up @@ -161,12 +222,26 @@ describe("Claude model catalog", () => {
aliases: ["claude-synthetic-next"],
status: "current",
},
...base.providers!.claudeAgent!.models,
...base.providers!.claudeAgent!.models.map((model) => ({
...model,
aliases: [...(model.aliases ?? []), "claude-synthetic-next-20251001"],
})),
],
},
},
};
const catalog = catalogFromManifest(input);
assert.strictEqual(
resolveClaudeModelSlug(catalog, "claude-synthetic-next-20251001"),
"claude-synthetic-next",
);
assert.strictEqual(
resolveClaudeCatalogApiModelId(catalog, {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "claude-synthetic-next-20251001",
}),
"claude-synthetic-next-20251001[large]",
);
assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "claude-synthetic-next");
assert.strictEqual(
resolveClaudeModelSlug(catalog, "claude-synthetic-next"),
Expand Down Expand Up @@ -289,6 +364,7 @@ describe("Claude model catalog", () => {
"us.anthropic.claude-opus-4-6-v1",
);
assert.strictEqual(apiModel(defaultBedrock, "claude-haiku-4-5"), "haiku");
assert.strictEqual(apiModel(defaultBedrock, "claude-haiku-4-5-20251001"), "haiku");
assert.strictEqual(apiModel(defaultBedrock, "claude-haiku-5-5"), "claude-haiku-5-5");

// First-party ids already are what Claude Code runs, even when an alias points elsewhere.
Expand Down
11 changes: 9 additions & 2 deletions apps/server/src/provider/ClaudeModelCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,10 @@ export function resolveClaudeCatalogModel(
catalog.models.find((entry) => entry.model.slug === value) ??
catalog.models.find((entry) =>
entry.model.aliases?.some((alias) => alias.toLowerCase() === value.toLowerCase()),
)
) ??
(/^claude-.+-\d{8}$/.test(value)
? resolveClaudeCatalogModel(catalog, value.replace(/-\d{8}$/, ""))
: undefined)
);
}

Expand Down Expand Up @@ -546,7 +549,11 @@ export function resolveClaudeCatalogApiModelId(
modelSelection: ModelSelection,
): string {
const entry = resolveClaudeCatalogModel(catalog, modelSelection.model);
const slug = entry?.apiModel ?? entry?.model.slug ?? modelSelection.model;
const slug =
entry?.apiModel ??
(/^claude-.+-\d{8}$/.test(modelSelection.model)
? modelSelection.model
: (entry?.model.slug ?? modelSelection.model));
const descriptors = getProviderOptionDescriptors({
caps: entry?.model.capabilities ?? EMPTY_CAPABILITIES,
selections: modelSelection.options,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
import { ProviderDriverError } from "../Errors.ts";
import { claudeUsageReader } from "./claudeUsage.ts";
import { applyClaudeGatewayModels, discoverClaudeGatewayModels } from "./ClaudeGatewayModels.ts";
import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts";
import * as ClaudeResetCredits from "../claudeResetCredits.ts";
import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";
Expand Down Expand Up @@ -280,6 +281,25 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
),
).pipe(
Effect.flatMap((provider) =>
Effect.gen(function* () {
if (!provider.enabled || !provider.installed) return provider;
const models = yield* discoverClaudeGatewayModels(
effectiveConfig,
processEnv,
stateDir,
).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
return models === undefined
? provider
: applyClaudeGatewayModels(
provider,
models,
catalog,
effectiveConfig.customModels,
);
}),
),
),
),
Effect.tap((provider) =>
Expand Down
Loading
Loading