Skip to content

Commit 5a43324

Browse files
feat: add custom model names and option descriptors (#9807)
1 parent dd7bc14 commit 5a43324

19 files changed

Lines changed: 1423 additions & 150 deletions

‎apps/server/src/provider/ClaudeModelCatalog.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import {
77
formatClaudeVersionUpgradeMessage,
88
normalizeClaudeCatalogEffort,
99
resolveClaudeCatalogApiModelId,
10+
resolveClaudeCatalogEffort,
1011
resolveClaudeModelCatalog,
1112
resolveClaudeModelsForVersion,
1213
resolveClaudeModelSlug,
14+
scopeClaudeModelCatalog,
1315
} from "./ClaudeModelCatalog.ts";
1416

1517
/**
@@ -134,4 +136,58 @@ describe("Claude model catalog", () => {
134136
};
135137
assert.isFalse(hasValidClaudeManifestAdapters(malformed));
136138
});
139+
140+
it("appends custom models with their own descriptors and keeps bare slugs opaque", () => {
141+
const catalog = scopeClaudeModelCatalog(resolveClaudeModelCatalog(manifest()), [
142+
"synthetic",
143+
{
144+
slug: "claude-custom-tuned",
145+
name: "Tuned",
146+
capabilities: {
147+
optionDescriptors: [
148+
{
149+
id: "effort",
150+
label: "Reasoning",
151+
type: "select",
152+
options: [
153+
{ id: "gentle", label: "Gentle", isDefault: true },
154+
{ id: "brutal", label: "Brutal" },
155+
],
156+
},
157+
],
158+
},
159+
},
160+
]);
161+
162+
// The bare custom slug shadows the built-in alias, so it no longer resolves to it.
163+
assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "synthetic");
164+
assert.strictEqual(resolveClaudeCatalogEffort(catalog, "synthetic", "extreme"), undefined);
165+
166+
// The entry with descriptors resolves user-defined effort ids and passes
167+
// them through untouched (no effortMap, no model suffix).
168+
assert.strictEqual(
169+
resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "brutal"),
170+
"brutal",
171+
);
172+
assert.strictEqual(
173+
resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "bogus"),
174+
"gentle",
175+
);
176+
assert.strictEqual(
177+
normalizeClaudeCatalogEffort(catalog, "brutal", "claude-custom-tuned"),
178+
"brutal",
179+
);
180+
assert.strictEqual(
181+
resolveClaudeCatalogApiModelId(catalog, {
182+
instanceId: ProviderInstanceId.make("claudeAgent"),
183+
model: "claude-custom-tuned",
184+
options: [{ id: "effort", value: "brutal" }],
185+
}),
186+
"claude-custom-tuned",
187+
);
188+
assert.deepStrictEqual(
189+
resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug),
190+
["claude-synthetic-next", "claude-custom-tuned"],
191+
);
192+
});
137193
});

‎apps/server/src/provider/ClaudeModelCatalog.ts‎

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
type CustomModelSetting,
23
type ModelCapabilities,
34
type ModelSelection,
45
ProviderDriverKind,
@@ -9,7 +10,7 @@ import {
910
getModelSelectionStringOptionValue,
1011
getProviderOptionCurrentValue,
1112
getProviderOptionDescriptors,
12-
normalizeCustomModelSlug,
13+
readCustomModelEntries,
1314
} from "@t3tools/shared/model";
1415
import { compareSemverVersions } from "@t3tools/shared/semver";
1516

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

7172
export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST);
7273

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

86-
return {
87-
models: catalog.models.map((entry) => {
88-
if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) {
89-
return entry;
90-
}
91-
return {
92-
...entry,
93-
model: {
94-
...entry.model,
95-
aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())),
96-
},
97-
};
98-
}),
99-
};
90+
const builtInModels = catalog.models.map((entry) => {
91+
if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) {
92+
return entry;
93+
}
94+
return {
95+
...entry,
96+
model: {
97+
...entry.model,
98+
aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())),
99+
},
100+
};
101+
});
102+
const builtInSlugs = new Set(builtInModels.map((entry) => entry.model.slug));
103+
const customCatalogModels: Array<ClaudeCatalogModel> = [];
104+
for (const entry of customEntries) {
105+
if (!entry.capabilities || builtInSlugs.has(entry.slug)) continue;
106+
customCatalogModels.push({
107+
model: {
108+
slug: entry.slug,
109+
name: entry.name,
110+
isCustom: true,
111+
capabilities: entry.capabilities,
112+
},
113+
runtime: {},
114+
compatibility: {},
115+
});
116+
}
117+
118+
return { models: [...builtInModels, ...customCatalogModels] };
100119
}
101120

102121
export function resolveClaudeCatalogModel(

‎apps/server/src/provider/Layers/CodexProvider.ts‎

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as CodexErrors from "effect-codex-app-server/errors";
1515

1616
import type {
1717
CodexSettings,
18+
CustomModelSetting,
1819
ServerProvider,
1920
ServerProviderState,
2021
ModelCapabilities,
@@ -24,7 +25,7 @@ import type {
2425
} from "@t3tools/contracts";
2526
import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts";
2627

27-
import { createModelCapabilities } from "@t3tools/shared/model";
28+
import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model";
2829
import { resolveSpawnCommand } from "@t3tools/shared/shell";
2930
import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
3031
import {
@@ -244,9 +245,14 @@ export function applyPreferredCodexDefaultModel(
244245
});
245246
}
246247

248+
/**
249+
* Codex has no static default capability set, so a bare custom slug borrows
250+
* the first built-in's descriptors; an entry with its own capabilities keeps
251+
* them.
252+
*/
247253
function appendCustomCodexModels(
248254
models: ReadonlyArray<ServerProviderModel>,
249-
customModels: ReadonlyArray<string>,
255+
customModels: ReadonlyArray<CustomModelSetting>,
250256
): ReadonlyArray<ServerProviderModel> {
251257
if (customModels.length === 0) {
252258
return models;
@@ -255,17 +261,16 @@ function appendCustomCodexModels(
255261
const seen = new Set(models.map((model) => model.slug));
256262
const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null;
257263
const customEntries: ServerProviderModel[] = [];
258-
for (const rawModel of customModels) {
259-
const slug = rawModel.trim();
260-
if (!slug || seen.has(slug)) {
264+
for (const entry of readCustomModelEntries(customModels)) {
265+
if (seen.has(entry.slug)) {
261266
continue;
262267
}
263-
seen.add(slug);
268+
seen.add(entry.slug);
264269
customEntries.push({
265-
slug,
266-
name: slug,
270+
slug: entry.slug,
271+
name: entry.name,
267272
isCustom: true,
268-
capabilities: fallbackCapabilities,
273+
capabilities: entry.capabilities ?? fallbackCapabilities,
269274
});
270275
}
271276
return customEntries.length === 0 ? models : [...models, ...customEntries];
@@ -399,7 +404,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
399404
readonly homePath?: string;
400405
readonly launchArgs?: string;
401406
readonly cwd: string;
402-
readonly customModels?: ReadonlyArray<string>;
407+
readonly customModels?: ReadonlyArray<CustomModelSetting>;
403408
readonly environment?: NodeJS.ProcessEnv;
404409
}) {
405410
const { client, initialize } = yield* withCodexAppServerClient(input);
@@ -470,21 +475,8 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi
470475
return parseCodexSkillsListResponse(skillsResponse, input.cwd);
471476
});
472477

473-
const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => {
474-
const models = new Set<string>();
475-
for (const model of codexSettings.customModels) {
476-
const trimmed = model.trim();
477-
if (trimmed.length > 0) {
478-
models.add(trimmed);
479-
}
480-
}
481-
return Array.from(models, (model) => ({
482-
slug: model,
483-
name: model,
484-
isCustom: true,
485-
capabilities: null,
486-
}));
487-
};
478+
const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] =>
479+
appendCustomCodexModels([], codexSettings.customModels);
488480

489481
const makePendingCodexProvider = (
490482
codexSettings: CodexSettings,
@@ -562,7 +554,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
562554
readonly homePath?: string;
563555
readonly launchArgs?: string;
564556
readonly cwd: string;
565-
readonly customModels: ReadonlyArray<string>;
557+
readonly customModels: ReadonlyArray<CustomModelSetting>;
566558
readonly environment?: NodeJS.ProcessEnv;
567559
}) => Effect.Effect<
568560
CodexAppServerProviderSnapshot,

‎apps/server/src/provider/Layers/GrokProvider.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
type CustomModelSetting,
23
type GrokSettings,
34
type ModelCapabilities,
45
type ServerProvider,
@@ -104,7 +105,7 @@ export function buildInitialGrokProviderSnapshot(
104105
}
105106

106107
function grokModelsFromSettings(
107-
customModels: ReadonlyArray<string> | undefined,
108+
customModels: ReadonlyArray<CustomModelSetting> | undefined,
108109
builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,
109110
): ReadonlyArray<ServerProviderModel> {
110111
return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);

‎apps/server/src/provider/providerSnapshot.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,27 @@ describe("providerModelsFromSettings", () => {
5252
]);
5353
});
5454

55+
it("keeps an entry's own name and capabilities over the driver default", () => {
56+
const capabilities = createModelCapabilities({
57+
optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }],
58+
});
59+
const models = providerModelsFromSettings(
60+
[],
61+
["bare", { slug: "named", name: "Named", capabilities }],
62+
OPENCODE_CUSTOM_MODEL_CAPABILITIES,
63+
);
64+
65+
expect(models).toEqual([
66+
{
67+
slug: "bare",
68+
name: "bare",
69+
isCustom: true,
70+
capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES,
71+
},
72+
{ slug: "named", name: "Named", isCustom: true, capabilities },
73+
]);
74+
});
75+
5576
it("preserves a custom slug that collides with a provider alias", () => {
5677
const capabilities = createModelCapabilities({ optionDescriptors: [] });
5778
const models = providerModelsFromSettings(

‎apps/server/src/provider/providerSnapshot.ts‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
CustomModelSetting,
23
ProviderDriverKind,
34
ModelCapabilities,
45
ServerProvider,
@@ -14,7 +15,7 @@ import * as PlatformError from "effect/PlatformError";
1415
import * as Schema from "effect/Schema";
1516
import * as Stream from "effect/Stream";
1617
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
17-
import { normalizeCustomModelSlug } from "@t3tools/shared/model";
18+
import { readCustomModelEntries } from "@t3tools/shared/model";
1819
import { isWindowsCommandNotFound } from "../processRunner.ts";
1920
import { createProviderVersionAdvisory } from "./providerMaintenance.ts";
2021
import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
@@ -145,26 +146,30 @@ export function parseGenericCliVersion(output: string): string | null {
145146
return match?.[1] ?? null;
146147
}
147148

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

157-
for (const candidate of customModels) {
158-
const normalized = normalizeCustomModelSlug(candidate);
159-
if (!normalized || seen.has(normalized)) {
163+
for (const entry of readCustomModelEntries(customModels)) {
164+
if (seen.has(entry.slug)) {
160165
continue;
161166
}
162-
seen.add(normalized);
167+
seen.add(entry.slug);
163168
customEntries.push({
164-
slug: normalized,
165-
name: normalized,
169+
slug: entry.slug,
170+
name: entry.name,
166171
isCustom: true,
167-
capabilities: customModelCapabilities,
172+
capabilities: entry.capabilities ?? customModelCapabilities,
168173
});
169174
}
170175

0 commit comments

Comments
 (0)