Skip to content
Merged
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
56 changes: 56 additions & 0 deletions apps/server/src/provider/ClaudeModelCatalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
formatClaudeVersionUpgradeMessage,
normalizeClaudeCatalogEffort,
resolveClaudeCatalogApiModelId,
resolveClaudeCatalogEffort,
resolveClaudeModelCatalog,
resolveClaudeModelsForVersion,
resolveClaudeModelSlug,
scopeClaudeModelCatalog,
} from "./ClaudeModelCatalog.ts";

/**
Expand Down Expand Up @@ -134,4 +136,58 @@ describe("Claude model catalog", () => {
};
assert.isFalse(hasValidClaudeManifestAdapters(malformed));
});

it("appends custom models with their own descriptors and keeps bare slugs opaque", () => {
const catalog = scopeClaudeModelCatalog(resolveClaudeModelCatalog(manifest()), [
"synthetic",
{
slug: "claude-custom-tuned",
name: "Tuned",
capabilities: {
optionDescriptors: [
{
id: "effort",
label: "Reasoning",
type: "select",
options: [
{ id: "gentle", label: "Gentle", isDefault: true },
{ id: "brutal", label: "Brutal" },
],
},
],
},
},
]);

// The bare custom slug shadows the built-in alias, so it no longer resolves to it.
assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "synthetic");
assert.strictEqual(resolveClaudeCatalogEffort(catalog, "synthetic", "extreme"), undefined);

// The entry with descriptors resolves user-defined effort ids and passes
// them through untouched (no effortMap, no model suffix).
assert.strictEqual(
resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "brutal"),
"brutal",
);
assert.strictEqual(
resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "bogus"),
"gentle",
);
assert.strictEqual(
normalizeClaudeCatalogEffort(catalog, "brutal", "claude-custom-tuned"),
"brutal",
);
assert.strictEqual(
resolveClaudeCatalogApiModelId(catalog, {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "claude-custom-tuned",
options: [{ id: "effort", value: "brutal" }],
}),
"claude-custom-tuned",
);
assert.deepStrictEqual(
resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug),
["claude-synthetic-next", "claude-custom-tuned"],
);
});
});
67 changes: 43 additions & 24 deletions apps/server/src/provider/ClaudeModelCatalog.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type CustomModelSetting,
type ModelCapabilities,
type ModelSelection,
ProviderDriverKind,
Expand All @@ -9,7 +10,7 @@ import {
getModelSelectionStringOptionValue,
getProviderOptionCurrentValue,
getProviderOptionDescriptors,
normalizeCustomModelSlug,
readCustomModelEntries,
} from "@t3tools/shared/model";
import { compareSemverVersions } from "@t3tools/shared/semver";

Expand Down Expand Up @@ -70,33 +71,51 @@ export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeMo

export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST);

/** Keeps custom model aliases opaque while preserving canonical built-in models and capabilities. */
/**
* Scope the catalog to one instance's settings: custom model slugs stay opaque
* (a built-in alias they shadow is dropped, canonical slugs and capabilities
* are preserved), and custom entries that declare their own capabilities are
* appended so the adapter resolves effort / fast mode / thinking against the
* user's descriptors instead of the empty default. Custom entries carry no
* runtime profile, so option values pass through to Claude Code verbatim.
*/
export function scopeClaudeModelCatalog(
catalog: ClaudeModelCatalog,
customModels: ReadonlyArray<string>,
customModels: ReadonlyArray<CustomModelSetting>,
): ClaudeModelCatalog {
const customAliases = new Set(
customModels.flatMap((model) => {
const slug = normalizeCustomModelSlug(model);
return slug ? [slug.toLowerCase()] : [];
}),
);
if (customAliases.size === 0) return catalog;
const customEntries = readCustomModelEntries(customModels);
if (customEntries.length === 0) return catalog;
const customAliases = new Set(customEntries.map((entry) => entry.slug.toLowerCase()));

return {
models: catalog.models.map((entry) => {
if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) {
return entry;
}
return {
...entry,
model: {
...entry.model,
aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())),
},
};
}),
};
const builtInModels = catalog.models.map((entry) => {
if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) {
return entry;
}
return {
...entry,
model: {
...entry.model,
aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())),
},
};
});
const builtInSlugs = new Set(builtInModels.map((entry) => entry.model.slug));
const customCatalogModels: Array<ClaudeCatalogModel> = [];
for (const entry of customEntries) {
if (!entry.capabilities || builtInSlugs.has(entry.slug)) continue;
customCatalogModels.push({
model: {
slug: entry.slug,
name: entry.name,
isCustom: true,
capabilities: entry.capabilities,
},
runtime: {},
compatibility: {},
});
}

return { models: [...builtInModels, ...customCatalogModels] };
}

export function resolveClaudeCatalogModel(
Expand Down
47 changes: 20 additions & 27 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as CodexErrors from "effect-codex-app-server/errors";

import type {
CodexSettings,
CustomModelSetting,
ServerProvider,
ServerProviderState,
ModelCapabilities,
Expand All @@ -27,7 +28,7 @@ import type {
} from "@t3tools/contracts";
import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts";

import { createModelCapabilities } from "@t3tools/shared/model";
import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model";
import { resolveSpawnCommand } from "@t3tools/shared/shell";
import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
import {
Expand Down Expand Up @@ -258,9 +259,14 @@ export function applyPreferredCodexDefaultModel(
});
}

/**
* Codex has no static default capability set, so a bare custom slug borrows
* the first built-in's descriptors; an entry with its own capabilities keeps
* them.
*/
function appendCustomCodexModels(
models: ReadonlyArray<ServerProviderModel>,
customModels: ReadonlyArray<string>,
customModels: ReadonlyArray<CustomModelSetting>,
): ReadonlyArray<ServerProviderModel> {
if (customModels.length === 0) {
return models;
Expand All @@ -269,17 +275,16 @@ function appendCustomCodexModels(
const seen = new Set(models.map((model) => model.slug));
const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null;
const customEntries: ServerProviderModel[] = [];
for (const rawModel of customModels) {
const slug = rawModel.trim();
if (!slug || seen.has(slug)) {
for (const entry of readCustomModelEntries(customModels)) {
if (seen.has(entry.slug)) {
continue;
}
seen.add(slug);
seen.add(entry.slug);
customEntries.push({
slug,
name: slug,
slug: entry.slug,
name: entry.name,
isCustom: true,
capabilities: fallbackCapabilities,
capabilities: entry.capabilities ?? fallbackCapabilities,
});
}
return customEntries.length === 0 ? models : [...models, ...customEntries];
Expand Down Expand Up @@ -427,7 +432,7 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu
readonly homePath?: string;
readonly launchArgs?: string;
readonly cwd: string;
readonly customModels?: ReadonlyArray<string>;
readonly customModels?: ReadonlyArray<CustomModelSetting>;
readonly environment?: NodeJS.ProcessEnv;
}) {
// `~` is not shell-expanded when env vars are set via `child_process.spawn`,
Expand Down Expand Up @@ -500,7 +505,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
readonly homePath?: string;
readonly launchArgs?: string;
readonly cwd: string;
readonly customModels?: ReadonlyArray<string>;
readonly customModels?: ReadonlyArray<CustomModelSetting>;
readonly environment?: NodeJS.ProcessEnv;
}) {
const { client, version, sharedHomePath } = yield* withCodexAppServerClient(input);
Expand Down Expand Up @@ -599,25 +604,13 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi
);
yield* client.request("initialize", buildCodexInitializeParams());
yield* client.notify("initialized", undefined);

const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] });
return parseCodexSkillsListResponse(skillsResponse, input.cwd);
});

const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => {
const models = new Set<string>();
for (const model of codexSettings.customModels) {
const trimmed = model.trim();
if (trimmed.length > 0) {
models.add(trimmed);
}
}
return Array.from(models, (model) => ({
slug: model,
name: model,
isCustom: true,
capabilities: null,
}));
};
const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] =>
appendCustomCodexModels([], codexSettings.customModels);

const makePendingCodexProvider = (
codexSettings: CodexSettings,
Expand Down Expand Up @@ -699,7 +692,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
readonly homePath?: string;
readonly launchArgs?: string;
readonly cwd: string;
readonly customModels: ReadonlyArray<string>;
readonly customModels: ReadonlyArray<CustomModelSetting>;
readonly environment?: NodeJS.ProcessEnv;
}) => Effect.Effect<
CodexAppServerProviderSnapshot,
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/provider/Layers/GrokProvider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type CustomModelSetting,
type GrokSettings,
type ModelCapabilities,
type ServerProvider,
Expand Down Expand Up @@ -104,7 +105,7 @@ export function buildInitialGrokProviderSnapshot(
}

function grokModelsFromSettings(
customModels: ReadonlyArray<string> | undefined,
customModels: ReadonlyArray<CustomModelSetting> | undefined,
builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,
): ReadonlyArray<ServerProviderModel> {
return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/provider/Layers/PrimeAgentProvider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type CustomModelSetting,
type ModelCapabilities,
type PrimeAgentSettings,
type ServerProvider,
Expand Down Expand Up @@ -210,7 +211,7 @@ export function parsePrimeAgentModelDiscoveryOutput(
}

export function primeAgentModelsFromSettings(
customModels: ReadonlyArray<string> | undefined,
customModels: ReadonlyArray<CustomModelSetting> | undefined,
discoveredModels: ReadonlyArray<ServerProviderModel> = [],
): ReadonlyArray<ServerProviderModel> {
return providerModelsFromSettings(
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/provider/providerSnapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ describe("providerModelsFromSettings", () => {
]);
});

it("keeps an entry's own name and capabilities over the driver default", () => {
const capabilities = createModelCapabilities({
optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }],
});
const models = providerModelsFromSettings(
[],
["bare", { slug: "named", name: "Named", capabilities }],
OPENCODE_CUSTOM_MODEL_CAPABILITIES,
);

expect(models).toEqual([
{
slug: "bare",
name: "bare",
isCustom: true,
capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES,
},
{ slug: "named", name: "Named", isCustom: true, capabilities },
]);
});

it("preserves a custom slug that collides with a provider alias", () => {
const capabilities = createModelCapabilities({ optionDescriptors: [] });
const models = providerModelsFromSettings(
Expand Down
23 changes: 14 additions & 9 deletions apps/server/src/provider/providerSnapshot.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
CustomModelSetting,
ProviderDriverKind,
ModelCapabilities,
ServerProvider,
Expand All @@ -15,7 +16,7 @@ import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { normalizeCustomModelSlug } from "@t3tools/shared/model";
import { readCustomModelEntries } from "@t3tools/shared/model";
import { isWindowsCommandNotFound } from "../processRunner.ts";
import { createProviderVersionAdvisory } from "./providerMaintenance.ts";
import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
Expand Down Expand Up @@ -146,26 +147,30 @@ export function parseGenericCliVersion(output: string): string | null {
return match?.[1] ?? null;
}

/**
* Append the user's custom models after the built-ins. A custom entry that
* declares its own capabilities keeps them; a bare slug gets the driver's
* default set. Slugs that collide with a built-in are dropped.
*/
export function providerModelsFromSettings(
builtInModels: ReadonlyArray<ServerProviderModel>,
customModels: ReadonlyArray<string>,
customModels: ReadonlyArray<CustomModelSetting>,
customModelCapabilities: ModelCapabilities,
): ReadonlyArray<ServerProviderModel> {
const resolvedBuiltInModels = [...builtInModels];
const seen = new Set(resolvedBuiltInModels.map((model) => model.slug));
const customEntries: ServerProviderModel[] = [];

for (const candidate of customModels) {
const normalized = normalizeCustomModelSlug(candidate);
if (!normalized || seen.has(normalized)) {
for (const entry of readCustomModelEntries(customModels)) {
if (seen.has(entry.slug)) {
continue;
}
seen.add(normalized);
seen.add(entry.slug);
customEntries.push({
slug: normalized,
name: normalized,
slug: entry.slug,
name: entry.name,
isCustom: true,
capabilities: customModelCapabilities,
capabilities: entry.capabilities ?? customModelCapabilities,
});
}

Expand Down
Loading
Loading