| author | |
| committer | |
| log | 2c1915b3193f16e2493709b480e6622645d0ca4e |
| tree | 5f957d00266df248107cf8fadb2017bc870a056e |
| parent | a6698271a41a4c1f2c42f25ca5d69cd7d1fa9c30 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>14 files changed, 579 insertions(+), 449 deletions(-)
apps/server/src/provider/ClaudeModelCatalog.test.ts+12-6| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 2 | import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; | |
| 3 | 3 | |
| 4 | 4 | import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; |
| 5 | import type { ModelManifestData } from "./ModelManifest.ts"; | |
| 5 | import { resolveProviderCatalog, type ModelManifestData } from "./ModelManifest.ts"; | |
| 6 | 6 | import { |
| 7 | 7 | formatClaudeVersionUpgradeMessage, |
| 8 | 8 | normalizeClaudeCatalogEffort, |
| ... | ... | @@ -71,11 +71,17 @@ const manifest = (): ModelManifestData => ({ |
| 71 | 71 | }, |
| 72 | 72 | }); |
| 73 | 73 | |
| 74 | const CLAUDE = ProviderDriverKind.make("claudeAgent"); | |
| 75 | ||
| 76 | /** Resolves Claude's catalog the way the driver does: through its manifest catalog entry. */ | |
| 77 | const catalogFromManifest = (data: ModelManifestData) => | |
| 78 | resolveClaudeModelCatalog(resolveProviderCatalog(data, CLAUDE) ?? undefined); | |
| 79 | ||
| 74 | 80 | describe("Claude model catalog", () => { |
| 75 | 81 | it("resolves capacity from selected options and fixed catalog windows without guessing custom models", () => { |
| 76 | 82 | const source = manifest(); |
| 77 | 83 | const profile = source.providers!.claudeAgent!.profiles.synthetic!; |
| 78 | const catalog = resolveClaudeModelCatalog({ | |
| 84 | const catalog = catalogFromManifest({ | |
| 79 | 85 | ...source, |
| 80 | 86 | providers: { |
| 81 | 87 | claudeAgent: { |
| ... | ... | @@ -121,7 +127,7 @@ describe("Claude model catalog", () => { |
| 121 | 127 | }); |
| 122 | 128 | |
| 123 | 129 | it("filters models at runtime-version boundaries and derives the upgrade message", () => { |
| 124 | const catalog = resolveClaudeModelCatalog(manifest()); | |
| 130 | const catalog = catalogFromManifest(manifest()); | |
| 125 | 131 | assert.deepStrictEqual(resolveClaudeModelsForVersion(catalog, "3.1.9"), []); |
| 126 | 132 | assert.deepStrictEqual( |
| 127 | 133 | resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug), |
| ... | ... | @@ -157,7 +163,7 @@ describe("Claude model catalog", () => { |
| 157 | 163 | }, |
| 158 | 164 | }, |
| 159 | 165 | }; |
| 160 | const catalog = resolveClaudeModelCatalog(input); | |
| 166 | const catalog = catalogFromManifest(input); | |
| 161 | 167 | assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "claude-synthetic-next"); |
| 162 | 168 | assert.strictEqual( |
| 163 | 169 | resolveClaudeModelSlug(catalog, "claude-synthetic-next"), |
| ... | ... | @@ -195,7 +201,7 @@ describe("Claude model catalog", () => { |
| 195 | 201 | }); |
| 196 | 202 | |
| 197 | 203 | it("appends custom models with their own descriptors and keeps bare slugs opaque", () => { |
| 198 | const catalog = scopeClaudeModelCatalog(resolveClaudeModelCatalog(manifest()), [ | |
| 204 | const catalog = scopeClaudeModelCatalog(catalogFromManifest(manifest()), [ | |
| 199 | 205 | "synthetic", |
| 200 | 206 | { |
| 201 | 207 | slug: "claude-custom-tuned", |
apps/server/src/provider/ClaudeModelCatalog.ts+21-12| ... | ... | @@ -21,11 +21,8 @@ import { |
| 21 | 21 | decodeClaudeModelAdapter, |
| 22 | 22 | decodeClaudeProfileAdapter, |
| 23 | 23 | } from "./ClaudeModelManifest.ts"; |
| 24 | import { | |
| 25 | BUNDLED_MODEL_MANIFEST, | |
| 26 | type ModelManifestData, | |
| 27 | resolveProviderCatalog, | |
| 28 | } from "./ModelManifest.ts"; | |
| 24 | import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; | |
| 25 | import { BUNDLED_MODEL_MANIFEST, resolveProviderCatalog } from "./ModelManifest.ts"; | |
| 29 | 26 | |
| 30 | 27 | const CLAUDE = ProviderDriverKind.make("claudeAgent"); |
| 31 | 28 | const EMPTY_CAPABILITIES: ModelCapabilities = { optionDescriptors: [] }; |
| ... | ... | @@ -40,8 +37,9 @@ export interface ClaudeModelCatalog { |
| 40 | 37 | readonly models: ReadonlyArray<ClaudeCatalogModel>; |
| 41 | 38 | } |
| 42 | 39 | |
| 43 | function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog | null { | |
| 44 | const resolved = resolveProviderCatalog(manifest, CLAUDE); | |
| 40 | function tryResolveClaudeModelCatalog( | |
| 41 | resolved: ModelCatalog.ProviderCatalog | undefined, | |
| 42 | ): ClaudeModelCatalog | null { | |
| 45 | 43 | if (!resolved) return null; |
| 46 | 44 | |
| 47 | 45 | const models: Array<ClaudeCatalogModel> = []; |
| ... | ... | @@ -50,7 +48,7 @@ function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelC |
| 50 | 48 | const adapter = decodeClaudeModelAdapter(entry.adapter ?? {}); |
| 51 | 49 | if (Option.isNone(profile) || Option.isNone(adapter)) return null; |
| 52 | 50 | models.push({ |
| 53 | model: entry.model, | |
| 51 | model: ModelCatalog.catalogServerModel(resolved, entry), | |
| 54 | 52 | runtime: profile.value.claudeCode ?? {}, |
| 55 | 53 | compatibility: adapter.value.claudeCode ?? {}, |
| 56 | 54 | }); |
| ... | ... | @@ -61,16 +59,27 @@ function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelC |
| 61 | 59 | }; |
| 62 | 60 | } |
| 63 | 61 | |
| 64 | export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog { | |
| 62 | const BUNDLED_CLAUDE_PROVIDER_CATALOG = | |
| 63 | resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, CLAUDE) ?? undefined; | |
| 64 | ||
| 65 | /** | |
| 66 | * Claude's built-in catalog from its manifest entry, falling back to the | |
| 67 | * bundled entry when the current one is missing or has invalid adapters. | |
| 68 | */ | |
| 69 | export function resolveClaudeModelCatalog( | |
| 70 | catalog: ModelCatalog.ProviderCatalog | undefined, | |
| 71 | ): ClaudeModelCatalog { | |
| 65 | 72 | return ( |
| 66 | tryResolveClaudeModelCatalog(manifest) ?? | |
| 67 | tryResolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST) ?? { | |
| 73 | tryResolveClaudeModelCatalog(catalog) ?? | |
| 74 | tryResolveClaudeModelCatalog(BUNDLED_CLAUDE_PROVIDER_CATALOG) ?? { | |
| 68 | 75 | models: [], |
| 69 | 76 | } |
| 70 | 77 | ); |
| 71 | 78 | } |
| 72 | 79 | |
| 73 | export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST); | |
| 80 | export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog( | |
| 81 | BUNDLED_CLAUDE_PROVIDER_CATALOG, | |
| 82 | ); | |
| 74 | 83 | |
| 75 | 84 | /** |
| 76 | 85 | * Scope the catalog to one instance's settings: custom model slugs stay opaque |
apps/server/src/provider/Drivers/AntigravityDriver.ts+8-11| ... | ... | @@ -52,7 +52,7 @@ import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeL |
| 52 | 52 | import { ProviderDriverError } from "../Errors.ts"; |
| 53 | 53 | import { makeAntigravityProvider } from "../AntigravityProvider.ts"; |
| 54 | 54 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; |
| 55 | import * as ModelManifest from "../ModelManifest.ts"; | |
| 55 | import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; | |
| 56 | 56 | import { |
| 57 | 57 | defaultProviderContinuationIdentity, |
| 58 | 58 | type ProviderDriver, |
| ... | ... | @@ -73,7 +73,7 @@ export type AntigravityDriverEnv = |
| 73 | 73 | | Crypto.Crypto |
| 74 | 74 | | FileSystem.FileSystem |
| 75 | 75 | | IdAllocator.IdAllocatorV2 |
| 76 | | ModelManifest.ModelManifest | |
| 76 | | ModelCatalog.ModelCatalog | |
| 77 | 77 | | Path.Path |
| 78 | 78 | | ProviderEventLoggers.ProviderEventLoggers; |
| 79 | 79 | |
| ... | ... | @@ -93,7 +93,8 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 93 | 93 | const selfInvocation = yield* resolveSelfInvocation(); |
| 94 | 94 | const installation = yield* AntigravityInstallation.AntigravityInstallation; |
| 95 | 95 | const loggers = yield* ProviderEventLoggers.ProviderEventLoggers; |
| 96 | const modelManifest = yield* ModelManifest.ModelManifest; | |
| 96 | const modelCatalog = yield* ModelCatalog.ModelCatalog; | |
| 97 | const currentCatalog = modelCatalog.current(DRIVER); | |
| 97 | 98 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; |
| 98 | 99 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); |
| 99 | 100 | const settings = { ...config, enabled } satisfies AntigravitySettings; |
| ... | ... | @@ -146,10 +147,8 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 146 | 147 | continuationGroupKey: continuationIdentity.continuationKey, |
| 147 | 148 | }); |
| 148 | 149 | const classifyModels = (draft: ServerProviderDraft) => |
| 149 | modelManifest.current.pipe( | |
| 150 | Effect.map((manifest) => | |
| 151 | stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER)), | |
| 152 | ), | |
| 150 | currentCatalog.pipe( | |
| 151 | Effect.map((catalog) => stampIdentity(ModelCatalog.applyModelCatalog(draft, catalog))), | |
| 153 | 152 | ); |
| 154 | 153 | |
| 155 | 154 | const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* ( |
| ... | ... | @@ -336,7 +335,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 336 | 335 | // and version. The response below is synthetic: only agentInfo.version |
| 337 | 336 | // is read from it. Sessions and manual refreshes still spawn. |
| 338 | 337 | const probe = Effect.gen(function* () { |
| 339 | yield* modelManifest.refreshInBackground; | |
| 338 | yield* modelCatalog.refreshInBackground; | |
| 340 | 339 | if (authConfigIssue !== null) { |
| 341 | 340 | return yield* new ProviderSetupError({ |
| 342 | 341 | instanceId, |
| ... | ... | @@ -393,9 +392,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 393 | 392 | }), |
| 394 | 393 | ), |
| 395 | 394 | ); |
| 396 | const defaultModel = modelManifest.current.pipe( | |
| 397 | Effect.map((manifest) => ModelManifest.manifestDefaultModel(manifest, DRIVER)), | |
| 398 | ); | |
| 395 | const defaultModel = currentCatalog.pipe(Effect.map((catalog) => catalog?.defaultChatModel)); | |
| 399 | 396 | const orchestrationAdapter = yield* makeAntigravityAdapterV2({ |
| 400 | 397 | instanceId, |
| 401 | 398 | selfInvocation, |
apps/server/src/provider/Drivers/ClaudeDriver.ts+12-12| ... | ... | @@ -43,7 +43,7 @@ import { |
| 43 | 43 | probeClaudeWorkspaceSnapshot, |
| 44 | 44 | } from "../ClaudeProvider.ts"; |
| 45 | 45 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; |
| 46 | import * as ModelManifest from "../ModelManifest.ts"; | |
| 46 | import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; | |
| 47 | 47 | import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; |
| 48 | 48 | import { |
| 49 | 49 | defaultProviderContinuationIdentity, |
| ... | ... | @@ -100,7 +100,7 @@ export type ClaudeDriverEnv = |
| 100 | 100 | | Crypto.Crypto |
| 101 | 101 | | FileSystem.FileSystem |
| 102 | 102 | | HttpClient.HttpClient |
| 103 | | ModelManifest.ModelManifest | |
| 103 | | ModelCatalog.ModelCatalog | |
| 104 | 104 | | Path.Path |
| 105 | 105 | | ProviderEventLoggers.ProviderEventLoggers |
| 106 | 106 | | ServerConfig.ServerConfig; |
| ... | ... | @@ -122,8 +122,10 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 122 | 122 | const httpClient = yield* HttpClient.HttpClient; |
| 123 | 123 | const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; |
| 124 | 124 | const host = yield* ProviderHost; |
| 125 | const modelManifest = yield* ModelManifest.ModelManifest; | |
| 126 | const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog)); | |
| 125 | const catalogService = yield* ModelCatalog.ModelCatalog; | |
| 126 | const modelCatalog = catalogService | |
| 127 | .current(DRIVER_KIND) | |
| 128 | .pipe(Effect.map(resolveClaudeModelCatalog)); | |
| 127 | 129 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 128 | 130 | const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ |
| 129 | 131 | driverKind: DRIVER_KIND, |
| ... | ... | @@ -208,16 +210,16 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 208 | 210 | |
| 209 | 211 | // Start the TTL-gated refresh without delaying provider readiness. The |
| 210 | 212 | // next check observes a remote manifest after the background fetch lands. |
| 211 | const checkProvider = modelManifest.refreshInBackground.pipe( | |
| 213 | const checkProvider = catalogService.refreshInBackground.pipe( | |
| 212 | 214 | Effect.andThen( |
| 213 | modelManifest.current.pipe( | |
| 214 | Effect.flatMap((manifest) => | |
| 215 | modelCatalog.pipe( | |
| 216 | Effect.flatMap((catalog) => | |
| 215 | 217 | checkClaudeProviderStatus( |
| 216 | 218 | effectiveConfig, |
| 217 | 219 | () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), |
| 218 | 220 | processEnv, |
| 219 | 221 | cwd, |
| 220 | resolveClaudeModelCatalog(manifest), | |
| 222 | catalog, | |
| 221 | 223 | scopedLimitNames, |
| 222 | 224 | (version) => |
| 223 | 225 | ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( |
| ... | ... | @@ -242,10 +244,8 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 242 | 244 | streamSettings: snapshotSettings.streamSettings, |
| 243 | 245 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, |
| 244 | 246 | initialSnapshot: (settings) => |
| 245 | modelManifest.current.pipe( | |
| 246 | Effect.flatMap((manifest) => | |
| 247 | makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)), | |
| 248 | ), | |
| 247 | modelCatalog.pipe( | |
| 248 | Effect.flatMap((catalog) => makePendingClaudeProvider(settings.provider, catalog)), | |
| 249 | 249 | Effect.map(stampIdentity), |
| 250 | 250 | ), |
| 251 | 251 | checkProvider, |
apps/server/src/provider/Drivers/CodexDriver.ts+10-10| ... | ... | @@ -49,7 +49,8 @@ import { |
| 49 | 49 | } from "../CodexProvider.ts"; |
| 50 | 50 | import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; |
| 51 | 51 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; |
| 52 | import * as ModelManifest from "../ModelManifest.ts"; | |
| 52 | import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; | |
| 53 | import { applyCodexModelCatalog } from "../codexModelCatalog.ts"; | |
| 53 | 54 | import type { ProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; |
| 54 | 55 | import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; |
| 55 | 56 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; |
| ... | ... | @@ -114,7 +115,7 @@ export type CodexDriverEnv = |
| 114 | 115 | | Crypto.Crypto |
| 115 | 116 | | FileSystem.FileSystem |
| 116 | 117 | | HttpClient.HttpClient |
| 117 | | ModelManifest.ModelManifest | |
| 118 | | ModelCatalog.ModelCatalog | |
| 118 | 119 | | Path.Path |
| 119 | 120 | | ProviderEventLoggers.ProviderEventLoggers |
| 120 | 121 | | ServerConfig.ServerConfig |
| ... | ... | @@ -147,7 +148,8 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 147 | 148 | const pathService = yield* Path.Path; |
| 148 | 149 | const httpClient = yield* HttpClient.HttpClient; |
| 149 | 150 | const host = yield* ProviderHost; |
| 150 | const modelManifest = yield* ModelManifest.ModelManifest; | |
| 151 | const modelCatalog = yield* ModelCatalog.ModelCatalog; | |
| 152 | const currentCatalog = modelCatalog.current(DRIVER_KIND); | |
| 151 | 153 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 152 | 154 | const homeLayout = yield* resolveCodexHomeLayout(config); |
| 153 | 155 | const continuationIdentity = codexContinuationIdentity(homeLayout); |
| ... | ... | @@ -218,13 +220,12 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 218 | 220 | // Kick the TTL-gated manifest refresh in the background and classify |
| 219 | 221 | // with the in-memory manifest, so a slow or hung fetch never delays the |
| 220 | 222 | // provider check. A refresh that lands mid-probe applies on the next one. |
| 221 | const checkProvider = modelManifest.refreshInBackground.pipe( | |
| 223 | const checkProvider = modelCatalog.refreshInBackground.pipe( | |
| 222 | 224 | Effect.andThen( |
| 223 | 225 | Effect.zipWith( |
| 224 | 226 | checkCodexProviderStatus(effectiveConfig, undefined, processEnv), |
| 225 | modelManifest.current, | |
| 226 | (draft, manifest) => | |
| 227 | stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), | |
| 227 | currentCatalog, | |
| 228 | (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), | |
| 228 | 229 | { concurrent: true }, |
| 229 | 230 | ), |
| 230 | 231 | ), |
| ... | ... | @@ -239,9 +240,8 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 239 | 240 | initialSnapshot: (settings) => |
| 240 | 241 | Effect.zipWith( |
| 241 | 242 | makePendingCodexProvider(settings.provider), |
| 242 | modelManifest.current, | |
| 243 | (draft, manifest) => | |
| 244 | stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), | |
| 243 | currentCatalog, | |
| 244 | (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), | |
| 245 | 245 | ), |
| 246 | 246 | checkProvider, |
| 247 | 247 | enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => |
apps/server/src/provider/ModelManifest.test.ts+8-216| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; | |
| 3 | import { ProviderDriverKind } from "@t3tools/contracts"; | |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | 6 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -20,212 +20,6 @@ import * as ModelManifest from "./ModelManifest.ts"; |
| 20 | 20 | * synthetic models for resolver coverage. |
| 21 | 21 | */ |
| 22 | 22 | |
| 23 | const CODEX = ProviderDriverKind.make("codex"); | |
| 24 | const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel => ({ | |
| 25 | slug: "gpt-test", | |
| 26 | name: "GPT Test", | |
| 27 | isCustom: false, | |
| 28 | capabilities: null, | |
| 29 | ...overrides, | |
| 30 | }); | |
| 31 | ||
| 32 | describe("classifyModels", () => { | |
| 33 | it("classifies qualified Codex families without changing their wire ids", () => { | |
| 34 | const manifest: ModelManifest.ModelManifestData = { | |
| 35 | version: 1, | |
| 36 | currentModels: { codex: ["gpt-test"] }, | |
| 37 | providers: { | |
| 38 | codex: { | |
| 39 | profiles: {}, | |
| 40 | models: [{ slug: "gpt-old", name: "Old", status: "legacy" }], | |
| 41 | }, | |
| 42 | }, | |
| 43 | }; | |
| 44 | const models = [ | |
| 45 | model({ slug: "openai.gpt-test", isLegacy: true }), | |
| 46 | model({ slug: "openai.gpt-old" }), | |
| 47 | ]; | |
| 48 | assert.deepStrictEqual( | |
| 49 | ModelManifest.classifyModels(models, manifest, CODEX).map((entry) => [ | |
| 50 | entry.slug, | |
| 51 | entry.isLegacy ?? false, | |
| 52 | ]), | |
| 53 | [ | |
| 54 | ["openai.gpt-test", false], | |
| 55 | ["openai.gpt-old", true], | |
| 56 | ], | |
| 57 | ); | |
| 58 | }); | |
| 59 | it("flags only known legacy models, clears stale flags, and skips custom models", () => { | |
| 60 | const manifest: ModelManifest.ModelManifestData = { | |
| 61 | version: 1, | |
| 62 | currentModels: { codex: ["current-a", "current-b"] }, | |
| 63 | providers: { | |
| 64 | codex: { | |
| 65 | profiles: {}, | |
| 66 | models: [{ slug: "old-model", name: "Old", status: "legacy" }], | |
| 67 | }, | |
| 68 | }, | |
| 69 | }; | |
| 70 | const models = [ | |
| 71 | model({ slug: "current-a" }), | |
| 72 | // Stale flag from a previous classification pass must be cleared. | |
| 73 | model({ slug: "current-b", isLegacy: true }), | |
| 74 | model({ slug: "old-model" }), | |
| 75 | model({ slug: "new-release", isLegacy: true }), | |
| 76 | // Custom models are user-defined and never reclassified. | |
| 77 | model({ slug: "my-own-model", isCustom: true }), | |
| 78 | ]; | |
| 79 | assert.deepStrictEqual( | |
| 80 | ModelManifest.classifyModels(models, manifest, CODEX).map((entry) => [ | |
| 81 | entry.slug, | |
| 82 | entry.isLegacy ?? false, | |
| 83 | ]), | |
| 84 | [ | |
| 85 | ["current-a", false], | |
| 86 | ["current-b", false], | |
| 87 | ["old-model", true], | |
| 88 | ["new-release", false], | |
| 89 | ["my-own-model", false], | |
| 90 | ], | |
| 91 | ); | |
| 92 | }); | |
| 93 | it.each(["codex", "antigravity"])( | |
| 94 | "keeps newly discovered %s models current when the manifest has no catalog", | |
| 95 | (driverKind) => { | |
| 96 | const models = [model({ slug: "new-release", isLegacy: true })]; | |
| 97 | assert.deepStrictEqual( | |
| 98 | ModelManifest.classifyModels( | |
| 99 | models, | |
| 100 | { version: 1, currentModels: { [driverKind]: ["known-current"] } }, | |
| 101 | ProviderDriverKind.make(driverKind), | |
| 102 | ), | |
| 103 | [model({ slug: "new-release" })], | |
| 104 | ); | |
| 105 | }, | |
| 106 | ); | |
| 107 | }); | |
| 108 | ||
| 109 | describe("applyManifestDefault", () => { | |
| 110 | it("resolves the manifest default to the qualified live model", () => { | |
| 111 | const manifest: ModelManifest.ModelManifestData = { | |
| 112 | version: 1, | |
| 113 | currentModels: {}, | |
| 114 | providers: { codex: { models: [], profiles: {}, defaults: { chat: "gpt-test" } } }, | |
| 115 | }; | |
| 116 | const models = [ | |
| 117 | model({ slug: "openai.gpt-old", isDefault: true }), | |
| 118 | model({ slug: "openai.gpt-test" }), | |
| 119 | ]; | |
| 120 | assert.strictEqual( | |
| 121 | ModelManifest.applyManifestDefault(models, manifest, CODEX).find((entry) => entry.isDefault) | |
| 122 | ?.slug, | |
| 123 | "openai.gpt-test", | |
| 124 | ); | |
| 125 | }); | |
| 126 | it("moves the default flag and its aliases to the manifest's chat default", () => { | |
| 127 | const driver = ProviderDriverKind.make("antigravity"); | |
| 128 | const manifest: ModelManifest.ModelManifestData = { | |
| 129 | version: 1, | |
| 130 | currentModels: {}, | |
| 131 | providers: { | |
| 132 | antigravity: { | |
| 133 | defaults: { chat: "gemini-new" }, | |
| 134 | profiles: {}, | |
| 135 | models: [{ slug: "gemini-new", name: "New", status: "current" }], | |
| 136 | }, | |
| 137 | }, | |
| 138 | }; | |
| 139 | const models = [ | |
| 140 | model({ slug: "gemini-old", isDefault: true, aliases: ["antigravity-default"] }), | |
| 141 | model({ slug: "gemini-new" }), | |
| 142 | ]; | |
| 143 | assert.deepStrictEqual(ModelManifest.applyManifestDefault(models, manifest, driver), [ | |
| 144 | model({ slug: "gemini-old" }), | |
| 145 | model({ slug: "gemini-new", isDefault: true, aliases: ["antigravity-default"] }), | |
| 146 | ]); | |
| 147 | // The account does not offer the manifest default: keep the runtime's choice. | |
| 148 | assert.deepStrictEqual( | |
| 149 | ModelManifest.applyManifestDefault(models.slice(0, 1), manifest, driver), | |
| 150 | models.slice(0, 1), | |
| 151 | ); | |
| 152 | }); | |
| 153 | }); | |
| 154 | ||
| 155 | describe("applyModelManifest", () => { | |
| 156 | const manifest: ModelManifest.ModelManifestData = { | |
| 157 | version: 1, | |
| 158 | currentModels: {}, | |
| 159 | providers: { | |
| 160 | codex: { | |
| 161 | profiles: {}, | |
| 162 | models: [ | |
| 163 | { | |
| 164 | slug: "gpt-next", | |
| 165 | name: "GPT Next", | |
| 166 | status: "current", | |
| 167 | badge: "new", | |
| 168 | adapter: { codex: { minVersion: "1.2.0" } }, | |
| 169 | }, | |
| 170 | { slug: "gpt-unversioned", name: "GPT Unversioned", status: "current" }, | |
| 171 | { | |
| 172 | slug: "gpt-retired", | |
| 173 | name: "GPT Retired", | |
| 174 | status: "legacy", | |
| 175 | adapter: { codex: { minVersion: "1.2.0" } }, | |
| 176 | }, | |
| 177 | ], | |
| 178 | }, | |
| 179 | }, | |
| 180 | }; | |
| 181 | const draft = (version: string | null, models: ReadonlyArray<ServerProviderModel> = []) => ({ | |
| 182 | enabled: true, | |
| 183 | installed: true, | |
| 184 | version, | |
| 185 | status: "ready" as const, | |
| 186 | auth: { status: "authenticated" as const }, | |
| 187 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 188 | models, | |
| 189 | slashCommands: [], | |
| 190 | skills: [], | |
| 191 | }); | |
| 192 | ||
| 193 | it("names current Codex models that need a newer CLI and are missing from discovery", () => { | |
| 194 | assert.deepStrictEqual( | |
| 195 | ModelManifest.applyModelManifest(draft("1.1.9"), manifest, CODEX).updateRequiredModels, | |
| 196 | [{ slug: "gpt-next", name: "GPT Next", badge: "new", minVersion: "1.2.0" }], | |
| 197 | ); | |
| 198 | for (const result of [ | |
| 199 | // The CLI is new enough. | |
| 200 | ModelManifest.applyModelManifest(draft("1.2.0"), manifest, CODEX), | |
| 201 | // The CLI already lists the model, even under a qualified slug. | |
| 202 | ModelManifest.applyModelManifest( | |
| 203 | draft("1.1.9", [model({ slug: "openai.gpt-next" })]), | |
| 204 | manifest, | |
| 205 | CODEX, | |
| 206 | ), | |
| 207 | // An unknown version cannot be compared. | |
| 208 | ModelManifest.applyModelManifest(draft(null), manifest, CODEX), | |
| 209 | // A qualified manifest slug still matches the discovered family. | |
| 210 | ModelManifest.applyModelManifest( | |
| 211 | draft("1.1.9", [model({ slug: "gpt-next" })]), | |
| 212 | { | |
| 213 | ...manifest, | |
| 214 | providers: { | |
| 215 | codex: { | |
| 216 | profiles: {}, | |
| 217 | models: [{ ...manifest.providers!.codex!.models[0]!, slug: "openai.gpt-next" }], | |
| 218 | }, | |
| 219 | }, | |
| 220 | }, | |
| 221 | CODEX, | |
| 222 | ), | |
| 223 | ]) { | |
| 224 | assert.isUndefined(result.updateRequiredModels); | |
| 225 | } | |
| 226 | }); | |
| 227 | }); | |
| 228 | ||
| 229 | 23 | describe("resolveProviderCatalog", () => { |
| 230 | 24 | it("resolves generic model presentation through a reusable profile", () => { |
| 231 | 25 | const manifest: ModelManifest.ModelManifestData = { |
| ... | ... | @@ -268,18 +62,16 @@ describe("resolveProviderCatalog", () => { |
| 268 | 62 | ProviderDriverKind.make("synthetic"), |
| 269 | 63 | ); |
| 270 | 64 | assert.deepStrictEqual(catalog?.models[0], { |
| 271 | model: { | |
| 272 | slug: "model-next", | |
| 273 | name: "Model Next", | |
| 274 | aliases: ["next"], | |
| 275 | badge: "new", | |
| 276 | isCustom: false, | |
| 277 | isDefault: true, | |
| 278 | capabilities: manifest.providers!.synthetic!.profiles.standard!.capabilities!, | |
| 279 | }, | |
| 65 | slug: "model-next", | |
| 66 | name: "Model Next", | |
| 67 | aliases: ["next"], | |
| 68 | badge: "new", | |
| 69 | status: "current", | |
| 70 | capabilities: manifest.providers!.synthetic!.profiles.standard!.capabilities!, | |
| 280 | 71 | adapter: undefined, |
| 281 | 72 | profileAdapter: { opaque: true }, |
| 282 | 73 | }); |
| 74 | assert.strictEqual(catalog?.defaultChatModel, "model-next"); | |
| 283 | 75 | }); |
| 284 | 76 | |
| 285 | 77 | it("rejects invalid catalog references", () => { |
apps/server/src/provider/ModelManifest.ts+39-177| ... | ... | @@ -17,17 +17,12 @@ import { |
| 17 | 17 | ModelCapabilities, |
| 18 | 18 | TrimmedNonEmptyString, |
| 19 | 19 | type ProviderDriverKind, |
| 20 | type ServerProviderModel, | |
| 21 | type ServerProviderUpdateRequiredModel, | |
| 22 | 20 | } from "@t3tools/contracts"; |
| 23 | import { codexModelFamily } from "@t3tools/shared/model"; | |
| 24 | import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; | |
| 25 | 21 | import * as Clock from "effect/Clock"; |
| 26 | 22 | import * as Context from "effect/Context"; |
| 27 | 23 | import * as Effect from "effect/Effect"; |
| 28 | 24 | import * as FileSystem from "effect/FileSystem"; |
| 29 | 25 | import * as Layer from "effect/Layer"; |
| 30 | import * as Option from "effect/Option"; | |
| 31 | 26 | import * as Path from "effect/Path"; |
| 32 | 27 | import * as Schema from "effect/Schema"; |
| 33 | 28 | import * as Semaphore from "effect/Semaphore"; |
| ... | ... | @@ -39,7 +34,7 @@ import * as ServerSettings from "../serverSettings.ts"; |
| 39 | 34 | import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; |
| 40 | 35 | import bundledManifestJson from "./model-manifest.json" with { type: "json" }; |
| 41 | 36 | import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts"; |
| 42 | import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 37 | import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; | |
| 43 | 38 | |
| 44 | 39 | const MODEL_MANIFEST_URL = |
| 45 | 40 | "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json"; |
| ... | ... | @@ -126,19 +121,6 @@ const ModelManifestSchema = ModelManifestEnvelopeSchema.pipe( |
| 126 | 121 | ); |
| 127 | 122 | export type ModelManifestData = typeof ModelManifestSchema.Type; |
| 128 | 123 | |
| 129 | export interface ResolvedManifestModel { | |
| 130 | readonly model: ServerProviderModel; | |
| 131 | readonly adapter: unknown; | |
| 132 | readonly profileAdapter: unknown; | |
| 133 | } | |
| 134 | ||
| 135 | export interface ResolvedProviderCatalog { | |
| 136 | readonly models: ReadonlyArray<ResolvedManifestModel>; | |
| 137 | readonly defaults: { | |
| 138 | readonly chat: string | undefined; | |
| 139 | }; | |
| 140 | } | |
| 141 | ||
| 142 | 124 | const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); |
| 143 | 125 | |
| 144 | 126 | export const BUNDLED_MODEL_MANIFEST: ModelManifestData = |
| ... | ... | @@ -151,16 +133,19 @@ function manifestUpdatedAtMs(manifest: ModelManifestData): number { |
| 151 | 133 | return Number.isNaN(parsed) ? 0 : parsed; |
| 152 | 134 | } |
| 153 | 135 | |
| 154 | /** Resolve provider-neutral model presentation and capability data. */ | |
| 136 | /** | |
| 137 | * A driver's catalog entry as provider packages see it, or `null` when the | |
| 138 | * manifest has none or its references are invalid. | |
| 139 | */ | |
| 155 | 140 | export function resolveProviderCatalog( |
| 156 | 141 | manifest: ModelManifestData, |
| 157 | 142 | driverKind: ProviderDriverKind, |
| 158 | ): ResolvedProviderCatalog | null { | |
| 143 | ): ModelCatalog.ProviderCatalog | null { | |
| 159 | 144 | const catalog = manifest.providers?.[driverKind]; |
| 160 | 145 | if (!catalog) return null; |
| 161 | 146 | |
| 162 | 147 | const seen = new Set<string>(); |
| 163 | const models: Array<ResolvedManifestModel> = []; | |
| 148 | const models: Array<ModelCatalog.ProviderCatalogModel> = []; | |
| 164 | 149 | for (const entry of catalog.models) { |
| 165 | 150 | if (seen.has(entry.slug)) return null; |
| 166 | 151 | seen.add(entry.slug); |
| ... | ... | @@ -169,18 +154,14 @@ export function resolveProviderCatalog( |
| 169 | 154 | if (entry.profile && !profile) return null; |
| 170 | 155 | |
| 171 | 156 | models.push({ |
| 172 | model: { | |
| 173 | slug: entry.slug, | |
| 174 | name: entry.name, | |
| 175 | ...(entry.shortName ? { shortName: entry.shortName } : {}), | |
| 176 | ...(entry.subProvider ? { subProvider: entry.subProvider } : {}), | |
| 177 | ...(entry.aliases ? { aliases: entry.aliases } : {}), | |
| 178 | ...(entry.badge ? { badge: entry.badge } : {}), | |
| 179 | isCustom: false, | |
| 180 | ...(catalog.defaults?.chat === entry.slug ? { isDefault: true } : {}), | |
| 181 | ...(entry.status === "legacy" ? { isLegacy: true } : {}), | |
| 182 | capabilities: profile?.capabilities ?? null, | |
| 183 | }, | |
| 157 | slug: entry.slug, | |
| 158 | name: entry.name, | |
| 159 | ...(entry.shortName ? { shortName: entry.shortName } : {}), | |
| 160 | ...(entry.subProvider ? { subProvider: entry.subProvider } : {}), | |
| 161 | ...(entry.aliases ? { aliases: entry.aliases } : {}), | |
| 162 | ...(entry.badge ? { badge: entry.badge } : {}), | |
| 163 | status: entry.status, | |
| 164 | capabilities: profile?.capabilities ?? null, | |
| 184 | 165 | adapter: entry.adapter, |
| 185 | 166 | profileAdapter: profile?.adapter, |
| 186 | 167 | }); |
| ... | ... | @@ -188,12 +169,7 @@ export function resolveProviderCatalog( |
| 188 | 169 | |
| 189 | 170 | if (catalog.defaults?.chat !== undefined && !seen.has(catalog.defaults.chat)) return null; |
| 190 | 171 | |
| 191 | return { | |
| 192 | models, | |
| 193 | defaults: { | |
| 194 | chat: catalog.defaults?.chat, | |
| 195 | }, | |
| 196 | }; | |
| 172 | return { models, defaultChatModel: catalog.defaults?.chat }; | |
| 197 | 173 | } |
| 198 | 174 | |
| 199 | 175 | /** On-disk shape of the last successfully fetched manifest. */ |
| ... | ... | @@ -213,142 +189,6 @@ export const encodeManifestCache = Schema.encodeEffect( |
| 213 | 189 | ), |
| 214 | 190 | ); |
| 215 | 191 | |
| 216 | /** True when the manifest classifies `slug` as legacy for `driverKind`. */ | |
| 217 | function isLegacyModel( | |
| 218 | manifest: ModelManifestData, | |
| 219 | driverKind: ProviderDriverKind, | |
| 220 | slug: string, | |
| 221 | ): boolean { | |
| 222 | const family = driverKind === "codex" ? codexModelFamily(slug) : slug; | |
| 223 | const catalog = manifest.providers?.[driverKind]?.models; | |
| 224 | const catalogModel = | |
| 225 | catalog?.find((model) => model.slug === slug) ?? | |
| 226 | catalog?.find((model) => model.slug === family); | |
| 227 | return catalogModel?.status === "legacy"; | |
| 228 | } | |
| 229 | ||
| 230 | /** | |
| 231 | * Reclassifies every built-in model on a snapshot draft against the manifest. | |
| 232 | * Custom models are user-defined and never reclassified. | |
| 233 | */ | |
| 234 | export function applyModelManifest( | |
| 235 | draft: ServerProviderDraft, | |
| 236 | manifest: ModelManifestData, | |
| 237 | driverKind: ProviderDriverKind, | |
| 238 | ): ServerProviderDraft { | |
| 239 | const { updateRequiredModels: _previous, ...rest } = draft; | |
| 240 | const updateRequiredModels = | |
| 241 | driverKind === "codex" ? codexUpdateRequiredModels(manifest, draft) : []; | |
| 242 | return { | |
| 243 | ...rest, | |
| 244 | models: applyManifestDefault( | |
| 245 | classifyModels(draft.models, manifest, driverKind), | |
| 246 | manifest, | |
| 247 | driverKind, | |
| 248 | ), | |
| 249 | ...(updateRequiredModels.length > 0 ? { updateRequiredModels } : {}), | |
| 250 | }; | |
| 251 | } | |
| 252 | ||
| 253 | const CodexModelAdapter = Schema.Struct({ | |
| 254 | codex: Schema.optional(Schema.Struct({ minVersion: Schema.optional(TrimmedNonEmptyString) })), | |
| 255 | }); | |
| 256 | const decodeCodexModelAdapter = Schema.decodeUnknownOption(CodexModelAdapter); | |
| 257 | ||
| 258 | /** | |
| 259 | * Codex lists only the models its own build knows, so a model released after | |
| 260 | * the installed CLI never shows up. A current manifest entry with | |
| 261 | * `adapter.codex.minVersion` names that model, letting the picker say an update | |
| 262 | * unlocks it instead of leaving users to wonder where it is. | |
| 263 | */ | |
| 264 | function codexUpdateRequiredModels( | |
| 265 | manifest: ModelManifestData, | |
| 266 | draft: ServerProviderDraft, | |
| 267 | ): ReadonlyArray<ServerProviderUpdateRequiredModel> { | |
| 268 | const version = draft.version?.replace(/^v/, ""); | |
| 269 | if (!version || parseSemver(version) === null) return []; | |
| 270 | const discovered = new Set(draft.models.map((model) => codexModelFamily(model.slug))); | |
| 271 | return (manifest.providers?.codex?.models ?? []).flatMap((entry) => { | |
| 272 | if (entry.status !== "current" || discovered.has(codexModelFamily(entry.slug))) return []; | |
| 273 | const minVersion = Option.getOrUndefined(decodeCodexModelAdapter(entry.adapter ?? {}))?.codex | |
| 274 | ?.minVersion; | |
| 275 | if (!minVersion || parseSemver(minVersion) === null) return []; | |
| 276 | if (compareSemverVersions(version, minVersion) >= 0) return []; | |
| 277 | return [ | |
| 278 | { | |
| 279 | slug: entry.slug, | |
| 280 | name: entry.name, | |
| 281 | ...(entry.badge ? { badge: entry.badge } : {}), | |
| 282 | minVersion, | |
| 283 | }, | |
| 284 | ]; | |
| 285 | }); | |
| 286 | } | |
| 287 | ||
| 288 | /** The manifest's chat default for `driverKind`, when it names one. */ | |
| 289 | export function manifestDefaultModel( | |
| 290 | manifest: ModelManifestData, | |
| 291 | driverKind: ProviderDriverKind, | |
| 292 | ): string | undefined { | |
| 293 | return manifest.providers?.[driverKind]?.defaults?.chat; | |
| 294 | } | |
| 295 | ||
| 296 | /** | |
| 297 | * Moves `isDefault` to the manifest's chat default when the catalog carries | |
| 298 | * it. Providers that learn their default from the runtime (Antigravity takes | |
| 299 | * Google's current model) can be overridden here without a release. Aliases | |
| 300 | * that pointed at the old default move with the flag so the shared | |
| 301 | * "provider default" alias keeps resolving. | |
| 302 | */ | |
| 303 | export function applyManifestDefault( | |
| 304 | models: ReadonlyArray<ServerProviderModel>, | |
| 305 | manifest: ModelManifestData, | |
| 306 | driverKind: ProviderDriverKind, | |
| 307 | ): ReadonlyArray<ServerProviderModel> { | |
| 308 | const requestedSlug = manifestDefaultModel(manifest, driverKind); | |
| 309 | if (requestedSlug === undefined) return models; | |
| 310 | const slug = | |
| 311 | models.find((model) => model.slug === requestedSlug)?.slug ?? | |
| 312 | (driverKind === "codex" | |
| 313 | ? models.find( | |
| 314 | (model) => | |
| 315 | !model.isCustom && codexModelFamily(model.slug) === codexModelFamily(requestedSlug), | |
| 316 | )?.slug | |
| 317 | : undefined); | |
| 318 | if (slug === undefined) return models; | |
| 319 | const previous = models.find((model) => model.isDefault && model.slug !== slug); | |
| 320 | if (!previous) return models; | |
| 321 | const movedAliases = previous.aliases ?? []; | |
| 322 | return models.map((model) => { | |
| 323 | if (model.slug === previous.slug) { | |
| 324 | const { isDefault: _isDefault, aliases: _aliases, ...rest } = model; | |
| 325 | return rest; | |
| 326 | } | |
| 327 | if (model.slug === slug) { | |
| 328 | const aliases = [...new Set([...(model.aliases ?? []), ...movedAliases])]; | |
| 329 | return { ...model, isDefault: true, ...(aliases.length > 0 ? { aliases } : {}) }; | |
| 330 | } | |
| 331 | return model; | |
| 332 | }); | |
| 333 | } | |
| 334 | ||
| 335 | /** Model-level half of `applyModelManifest`, exported for focused tests. */ | |
| 336 | export function classifyModels( | |
| 337 | models: ReadonlyArray<ServerProviderModel>, | |
| 338 | manifest: ModelManifestData, | |
| 339 | driverKind: ProviderDriverKind, | |
| 340 | ): ReadonlyArray<ServerProviderModel> { | |
| 341 | return models.map((model) => { | |
| 342 | if (model.isCustom) return model; | |
| 343 | if (isLegacyModel(manifest, driverKind, model.slug)) { | |
| 344 | return model.isLegacy ? model : { ...model, isLegacy: true }; | |
| 345 | } | |
| 346 | if (!model.isLegacy) return model; | |
| 347 | const { isLegacy: _isLegacy, ...rest } = model; | |
| 348 | return rest; | |
| 349 | }); | |
| 350 | } | |
| 351 | ||
| 352 | 192 | export class ModelManifest extends Context.Service< |
| 353 | 193 | ModelManifest, |
| 354 | 194 | { |
| ... | ... | @@ -374,7 +214,7 @@ const BundledOnlyModelManifest: ModelManifest["Service"] = { |
| 374 | 214 | refreshInBackground: Effect.void, |
| 375 | 215 | }; |
| 376 | 216 | |
| 377 | export const layerTest = Layer.succeed(ModelManifest, BundledOnlyModelManifest); | |
| 217 | const layerBundledOnly = Layer.succeed(ModelManifest, BundledOnlyModelManifest); | |
| 378 | 218 | |
| 379 | 219 | export const make = Effect.gen(function* () { |
| 380 | 220 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -470,3 +310,25 @@ export const make = Effect.gen(function* () { |
| 470 | 310 | }); |
| 471 | 311 | |
| 472 | 312 | export const layer = Layer.effect(ModelManifest, make); |
| 313 | ||
| 314 | /** Bundled data only, with the provider catalog port drivers read; for tests. */ | |
| 315 | export const layerTest = Layer.suspend(() => | |
| 316 | layerModelCatalog.pipe(Layer.provideMerge(layerBundledOnly)), | |
| 317 | ); | |
| 318 | ||
| 319 | /** Exposes the manifest to provider packages as their per-driver catalogs. */ | |
| 320 | export const layerModelCatalog = Layer.effect( | |
| 321 | ModelCatalog.ModelCatalog, | |
| 322 | Effect.gen(function* () { | |
| 323 | const manifest = yield* ModelManifest; | |
| 324 | return ModelCatalog.ModelCatalog.of({ | |
| 325 | current: (driverKind) => | |
| 326 | manifest.current.pipe( | |
| 327 | Effect.map((data) => resolveProviderCatalog(data, driverKind) ?? undefined), | |
| 328 | ), | |
| 329 | bundled: (driverKind) => | |
| 330 | resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, driverKind) ?? undefined, | |
| 331 | refreshInBackground: manifest.refreshInBackground, | |
| 332 | }); | |
| 333 | }), | |
| 334 | ); |
apps/server/src/provider/codexModelCatalog.test.ts created+91| ... | ... | @@ -0,0 +1,91 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import type { ServerProviderModel } from "@t3tools/contracts"; | |
| 3 | import type * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; | |
| 4 | ||
| 5 | import { applyCodexModelCatalog } from "./codexModelCatalog.ts"; | |
| 6 | ||
| 7 | /** Synthetic models only; real Codex model data lives in model-manifest.json. */ | |
| 8 | const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel => ({ | |
| 9 | slug: "gpt-test", | |
| 10 | name: "GPT Test", | |
| 11 | isCustom: false, | |
| 12 | capabilities: null, | |
| 13 | ...overrides, | |
| 14 | }); | |
| 15 | ||
| 16 | const entry = ( | |
| 17 | overrides: Partial<ModelCatalog.ProviderCatalogModel> & { readonly slug: string }, | |
| 18 | ): ModelCatalog.ProviderCatalogModel => ({ | |
| 19 | name: overrides.slug, | |
| 20 | status: "current", | |
| 21 | capabilities: null, | |
| 22 | adapter: undefined, | |
| 23 | profileAdapter: undefined, | |
| 24 | ...overrides, | |
| 25 | }); | |
| 26 | ||
| 27 | const catalog: ModelCatalog.ProviderCatalog = { | |
| 28 | defaultChatModel: undefined, | |
| 29 | models: [ | |
| 30 | entry({ | |
| 31 | slug: "gpt-next", | |
| 32 | name: "GPT Next", | |
| 33 | badge: "new", | |
| 34 | adapter: { codex: { minVersion: "1.2.0" } }, | |
| 35 | }), | |
| 36 | entry({ slug: "gpt-unversioned" }), | |
| 37 | entry({ slug: "gpt-retired", status: "legacy", adapter: { codex: { minVersion: "1.2.0" } } }), | |
| 38 | ], | |
| 39 | }; | |
| 40 | ||
| 41 | const draft = (version: string | null, models: ReadonlyArray<ServerProviderModel> = []) => ({ | |
| 42 | enabled: true, | |
| 43 | installed: true, | |
| 44 | version, | |
| 45 | status: "ready" as const, | |
| 46 | auth: { status: "authenticated" as const }, | |
| 47 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 48 | models, | |
| 49 | slashCommands: [], | |
| 50 | skills: [], | |
| 51 | }); | |
| 52 | ||
| 53 | describe("applyCodexModelCatalog", () => { | |
| 54 | it("names current models that need a newer Codex and are missing from discovery", () => { | |
| 55 | assert.deepStrictEqual(applyCodexModelCatalog(draft("1.1.9"), catalog).updateRequiredModels, [ | |
| 56 | { slug: "gpt-next", name: "GPT Next", badge: "new", minVersion: "1.2.0" }, | |
| 57 | ]); | |
| 58 | for (const result of [ | |
| 59 | // The CLI is new enough. | |
| 60 | applyCodexModelCatalog(draft("1.2.0"), catalog), | |
| 61 | // The CLI already lists the model, even under a qualified slug. | |
| 62 | applyCodexModelCatalog(draft("1.1.9", [model({ slug: "openai.gpt-next" })]), catalog), | |
| 63 | // An unknown version cannot be compared. | |
| 64 | applyCodexModelCatalog(draft(null), catalog), | |
| 65 | // A qualified catalog slug still matches the discovered family. | |
| 66 | applyCodexModelCatalog(draft("1.1.9", [model({ slug: "gpt-next" })]), { | |
| 67 | ...catalog, | |
| 68 | models: [{ ...catalog.models[0]!, slug: "openai.gpt-next" }], | |
| 69 | }), | |
| 70 | ]) { | |
| 71 | assert.isUndefined(result.updateRequiredModels); | |
| 72 | } | |
| 73 | }); | |
| 74 | ||
| 75 | it("classifies qualified Codex families without changing their wire ids", () => { | |
| 76 | const classified = applyCodexModelCatalog( | |
| 77 | draft("1.2.0", [ | |
| 78 | model({ slug: "openai.gpt-test", isLegacy: true }), | |
| 79 | model({ slug: "openai.gpt-retired" }), | |
| 80 | ]), | |
| 81 | catalog, | |
| 82 | ); | |
| 83 | assert.deepStrictEqual( | |
| 84 | classified.models.map((entry) => [entry.slug, entry.isLegacy ?? false]), | |
| 85 | [ | |
| 86 | ["openai.gpt-test", false], | |
| 87 | ["openai.gpt-retired", true], | |
| 88 | ], | |
| 89 | ); | |
| 90 | }); | |
| 91 | }); |
apps/server/src/provider/codexModelCatalog.ts created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | /** | |
| 2 | * Codex's view of its manifest catalog. Codex discovers models from its app | |
| 3 | * server, so the catalog only reclassifies them, moves the default, and names | |
| 4 | * models a newer Codex build would unlock. | |
| 5 | * | |
| 6 | * @module provider/codexModelCatalog | |
| 7 | */ | |
| 8 | import type { ServerProviderUpdateRequiredModel } from "@t3tools/contracts"; | |
| 9 | import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; | |
| 10 | import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 11 | import { codexModelFamily } from "@t3tools/shared/model"; | |
| 12 | import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; | |
| 13 | import * as Option from "effect/Option"; | |
| 14 | import * as Schema from "effect/Schema"; | |
| 15 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 16 | ||
| 17 | /** Dated Codex builds match their base model's catalog entry. */ | |
| 18 | const CODEX_MATCHING: ModelCatalog.CatalogMatching = { family: codexModelFamily }; | |
| 19 | ||
| 20 | const CodexModelAdapter = Schema.Struct({ | |
| 21 | codex: Schema.optional(Schema.Struct({ minVersion: Schema.optional(TrimmedNonEmptyString) })), | |
| 22 | }); | |
| 23 | const decodeCodexModelAdapter = Schema.decodeUnknownOption(CodexModelAdapter); | |
| 24 | ||
| 25 | /** | |
| 26 | * Codex lists only the models its own build knows, so a model released after | |
| 27 | * the installed CLI never shows up. A current catalog entry with | |
| 28 | * `adapter.codex.minVersion` names that model, letting the picker say an update | |
| 29 | * unlocks it instead of leaving users to wonder where it is. | |
| 30 | */ | |
| 31 | function codexUpdateRequiredModels( | |
| 32 | catalog: ModelCatalog.ProviderCatalog | undefined, | |
| 33 | draft: ServerProviderDraft, | |
| 34 | ): ReadonlyArray<ServerProviderUpdateRequiredModel> { | |
| 35 | const version = draft.version?.replace(/^v/, ""); | |
| 36 | if (!catalog || !version || parseSemver(version) === null) return []; | |
| 37 | const discovered = new Set(draft.models.map((model) => codexModelFamily(model.slug))); | |
| 38 | return catalog.models.flatMap((entry) => { | |
| 39 | if (entry.status !== "current" || discovered.has(codexModelFamily(entry.slug))) return []; | |
| 40 | const minVersion = Option.getOrUndefined(decodeCodexModelAdapter(entry.adapter ?? {}))?.codex | |
| 41 | ?.minVersion; | |
| 42 | if (!minVersion || parseSemver(minVersion) === null) return []; | |
| 43 | if (compareSemverVersions(version, minVersion) >= 0) return []; | |
| 44 | return [ | |
| 45 | { | |
| 46 | slug: entry.slug, | |
| 47 | name: entry.name, | |
| 48 | ...(entry.badge ? { badge: entry.badge } : {}), | |
| 49 | minVersion, | |
| 50 | }, | |
| 51 | ]; | |
| 52 | }); | |
| 53 | } | |
| 54 | ||
| 55 | /** Reclassifies a Codex snapshot draft's models against its catalog. */ | |
| 56 | export function applyCodexModelCatalog( | |
| 57 | draft: ServerProviderDraft, | |
| 58 | catalog: ModelCatalog.ProviderCatalog | undefined, | |
| 59 | ): ServerProviderDraft { | |
| 60 | const updateRequiredModels = codexUpdateRequiredModels(catalog, draft); | |
| 61 | return { | |
| 62 | ...ModelCatalog.applyModelCatalog(draft, catalog, CODEX_MATCHING), | |
| 63 | ...(updateRequiredModels.length > 0 ? { updateRequiredModels } : {}), | |
| 64 | }; | |
| 65 | } |
apps/server/src/server.ts+8-4| ... | ... | @@ -604,11 +604,15 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( |
| 604 | 604 | // the rewritten telemetry pipeline can account for logical NDJSON writes. |
| 605 | 605 | // Provided once at the runtime level so every consumer sees the same |
| 606 | 606 | // logger instances. |
| 607 | // `ModelManifest.layer` is the legacy-model classification data, refreshed | |
| 608 | // from the repo's `model-manifest.json` on `main` and applied by the | |
| 609 | // Codex/Claude drivers. | |
| 607 | // `ModelManifest.layer` is the model manifest, refreshed from the repo's | |
| 608 | // `model-manifest.json` on `main`. Drivers read their entry through the | |
| 609 | // `ModelCatalog` port it provides. | |
| 610 | 610 | Layer.provideMerge( |
| 611 | Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer), | |
| 611 | Layer.mergeAll( | |
| 612 | ProviderEventLoggers.layer, | |
| 613 | ModelManifest.layerModelCatalog.pipe(Layer.provideMerge(ModelManifest.layer)), | |
| 614 | ResetCreditCoordinator.layer, | |
| 615 | ), | |
| 612 | 616 | ), |
| 613 | 617 | // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old |
| 614 | 618 | // `ProviderRegistry.layer` pulled `OpenCodeRuntimeLive` in for itself, but |
docs/internals/model-manifest.md+4-1| ... | ... | @@ -11,7 +11,10 @@ correct model data before the next successful fetch. Bump `updatedAt` whenever t |
| 11 | 11 | file changes. Fetch time cannot establish which copy contains the newer edit. |
| 12 | 12 | |
| 13 | 13 | Generic catalog data describes presentation and capabilities. Each provider owns |
| 14 | its adapter schema and dispatch mappings. Claude uses the manifest for its entire | |
| 14 | its adapter schema and dispatch mappings. Providers never read the manifest | |
| 15 | itself: the server exposes each driver's entry through the | |
| 16 | [`ModelCatalog`](../../packages/provider-core/src/server/ModelCatalog.ts) port, | |
| 17 | and compatibility policies stay with the server. Claude uses the manifest for its entire | |
| 15 | 18 | built-in catalog. Adding a model with an existing capability profile is a JSON |
| 16 | 19 | edit; a new profile is needed only for a new capability combination. Codex still |
| 17 | 20 | gets its model list from its app server. |
packages/provider-core/package.json+4| ... | ... | @@ -75,6 +75,10 @@ |
| 75 | 75 | "types": "./src/server/mcpToolPresentation.ts", |
| 76 | 76 | "import": "./src/server/mcpToolPresentation.ts" |
| 77 | 77 | }, |
| 78 | "./server/ModelCatalog": { | |
| 79 | "types": "./src/server/ModelCatalog.ts", | |
| 80 | "import": "./src/server/ModelCatalog.ts" | |
| 81 | }, | |
| 78 | 82 | "./server/nativeProtocolLogging": { |
| 79 | 83 | "types": "./src/server/nativeProtocolLogging.ts", |
| 80 | 84 | "import": "./src/server/nativeProtocolLogging.ts" |
packages/provider-core/src/server/ModelCatalog.test.ts created+126| ... | ... | @@ -0,0 +1,126 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import type { ServerProviderModel } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import * as ModelCatalog from "./ModelCatalog.ts"; | |
| 5 | ||
| 6 | /** | |
| 7 | * Covers catalog semantics with synthetic models. Real model data lives in the | |
| 8 | * server's model-manifest.json and is not asserted here. | |
| 9 | */ | |
| 10 | const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel => ({ | |
| 11 | slug: "model-test", | |
| 12 | name: "Model Test", | |
| 13 | isCustom: false, | |
| 14 | capabilities: null, | |
| 15 | ...overrides, | |
| 16 | }); | |
| 17 | ||
| 18 | const catalogModel = ( | |
| 19 | slug: string, | |
| 20 | status: ModelCatalog.ProviderCatalogModel["status"], | |
| 21 | ): ModelCatalog.ProviderCatalogModel => ({ | |
| 22 | slug, | |
| 23 | name: slug, | |
| 24 | status, | |
| 25 | capabilities: null, | |
| 26 | adapter: undefined, | |
| 27 | profileAdapter: undefined, | |
| 28 | }); | |
| 29 | ||
| 30 | /** Strips a vendor prefix, the way a driver maps qualified slugs to catalog families. */ | |
| 31 | const prefixFamily: ModelCatalog.CatalogMatching = { | |
| 32 | family: (slug) => slug.replace(/^vendor\./, ""), | |
| 33 | }; | |
| 34 | ||
| 35 | describe("classifyCatalogModels", () => { | |
| 36 | it("flags only known legacy models, clears stale flags, and skips custom models", () => { | |
| 37 | const catalog: ModelCatalog.ProviderCatalog = { | |
| 38 | models: [catalogModel("old-model", "legacy")], | |
| 39 | defaultChatModel: undefined, | |
| 40 | }; | |
| 41 | const models = [ | |
| 42 | model({ slug: "current-a" }), | |
| 43 | // Stale flag from a previous classification pass must be cleared. | |
| 44 | model({ slug: "current-b", isLegacy: true }), | |
| 45 | model({ slug: "old-model" }), | |
| 46 | // Custom models are user-defined and never reclassified. | |
| 47 | model({ slug: "my-own-model", isCustom: true, isLegacy: true }), | |
| 48 | ]; | |
| 49 | assert.deepStrictEqual( | |
| 50 | ModelCatalog.classifyCatalogModels(models, catalog).map((entry) => [ | |
| 51 | entry.slug, | |
| 52 | entry.isLegacy ?? false, | |
| 53 | ]), | |
| 54 | [ | |
| 55 | ["current-a", false], | |
| 56 | ["current-b", false], | |
| 57 | ["old-model", true], | |
| 58 | ["my-own-model", true], | |
| 59 | ], | |
| 60 | ); | |
| 61 | }); | |
| 62 | ||
| 63 | it("matches qualified slugs through the driver's family without changing their ids", () => { | |
| 64 | const catalog: ModelCatalog.ProviderCatalog = { | |
| 65 | models: [catalogModel("model-old", "legacy")], | |
| 66 | defaultChatModel: undefined, | |
| 67 | }; | |
| 68 | assert.deepStrictEqual( | |
| 69 | ModelCatalog.classifyCatalogModels( | |
| 70 | [model({ slug: "vendor.model-test", isLegacy: true }), model({ slug: "vendor.model-old" })], | |
| 71 | catalog, | |
| 72 | prefixFamily, | |
| 73 | ).map((entry) => [entry.slug, entry.isLegacy ?? false]), | |
| 74 | [ | |
| 75 | ["vendor.model-test", false], | |
| 76 | ["vendor.model-old", true], | |
| 77 | ], | |
| 78 | ); | |
| 79 | }); | |
| 80 | ||
| 81 | it("keeps discovered models current when the driver has no catalog", () => { | |
| 82 | assert.deepStrictEqual( | |
| 83 | ModelCatalog.classifyCatalogModels([model({ slug: "new", isLegacy: true })], undefined), | |
| 84 | [model({ slug: "new" })], | |
| 85 | ); | |
| 86 | }); | |
| 87 | }); | |
| 88 | ||
| 89 | describe("applyCatalogDefault", () => { | |
| 90 | it("moves the default flag and its aliases to the catalog's chat default", () => { | |
| 91 | const catalog: ModelCatalog.ProviderCatalog = { | |
| 92 | models: [catalogModel("model-new", "current")], | |
| 93 | defaultChatModel: "model-new", | |
| 94 | }; | |
| 95 | const models = [ | |
| 96 | model({ slug: "model-old", isDefault: true, aliases: ["provider-default"] }), | |
| 97 | model({ slug: "model-new" }), | |
| 98 | ]; | |
| 99 | assert.deepStrictEqual(ModelCatalog.applyCatalogDefault(models, catalog), [ | |
| 100 | model({ slug: "model-old" }), | |
| 101 | model({ slug: "model-new", isDefault: true, aliases: ["provider-default"] }), | |
| 102 | ]); | |
| 103 | // The account does not offer the catalog default: keep the runtime's choice. | |
| 104 | assert.deepStrictEqual( | |
| 105 | ModelCatalog.applyCatalogDefault(models.slice(0, 1), catalog), | |
| 106 | models.slice(0, 1), | |
| 107 | ); | |
| 108 | }); | |
| 109 | ||
| 110 | it("resolves the catalog default to a qualified discovered model", () => { | |
| 111 | const catalog: ModelCatalog.ProviderCatalog = { | |
| 112 | models: [], | |
| 113 | defaultChatModel: "model-test", | |
| 114 | }; | |
| 115 | const models = [ | |
| 116 | model({ slug: "vendor.model-old", isDefault: true }), | |
| 117 | model({ slug: "vendor.model-test" }), | |
| 118 | ]; | |
| 119 | assert.strictEqual( | |
| 120 | ModelCatalog.applyCatalogDefault(models, catalog, prefixFamily).find( | |
| 121 | (entry) => entry.isDefault, | |
| 122 | )?.slug, | |
| 123 | "vendor.model-test", | |
| 124 | ); | |
| 125 | }); | |
| 126 | }); |
packages/provider-core/src/server/ModelCatalog.ts created+171| ... | ... | @@ -0,0 +1,171 @@ |
| 1 | /** | |
| 2 | * ModelCatalog — the model metadata a provider reads from T3's model manifest. | |
| 3 | * | |
| 4 | * The server owns the manifest itself (bundle, remote refresh, disk cache, | |
| 5 | * compatibility policies). A provider only sees its own catalog entry, so a | |
| 6 | * provider package never depends on the manifest file or how it is fetched. | |
| 7 | * Reads never wait on the network. | |
| 8 | * | |
| 9 | * @module provider-core/server/ModelCatalog | |
| 10 | */ | |
| 11 | import type { | |
| 12 | ModelCapabilities, | |
| 13 | ProviderDriverKind, | |
| 14 | ServerProviderModel, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | import * as Context from "effect/Context"; | |
| 17 | import type * as Effect from "effect/Effect"; | |
| 18 | ||
| 19 | import type { ServerProviderDraft } from "./snapshotProbe.ts"; | |
| 20 | ||
| 21 | /** One model entry in a provider's manifest catalog. */ | |
| 22 | export interface ProviderCatalogModel { | |
| 23 | readonly slug: string; | |
| 24 | readonly name: string; | |
| 25 | readonly shortName?: string; | |
| 26 | readonly subProvider?: string; | |
| 27 | readonly aliases?: ReadonlyArray<string>; | |
| 28 | readonly status: "current" | "legacy"; | |
| 29 | readonly badge?: "new"; | |
| 30 | readonly capabilities: ModelCapabilities | null; | |
| 31 | /** Provider-owned metadata for this model; the provider decodes it. */ | |
| 32 | readonly adapter: unknown; | |
| 33 | /** Provider-owned metadata for the model's capability profile. */ | |
| 34 | readonly profileAdapter: unknown; | |
| 35 | } | |
| 36 | ||
| 37 | /** A provider's catalog entry: its models and the chat default. */ | |
| 38 | export interface ProviderCatalog { | |
| 39 | readonly models: ReadonlyArray<ProviderCatalogModel>; | |
| 40 | readonly defaultChatModel: string | undefined; | |
| 41 | } | |
| 42 | ||
| 43 | export class ModelCatalog extends Context.Service< | |
| 44 | ModelCatalog, | |
| 45 | { | |
| 46 | /** | |
| 47 | * The driver's catalog from the manifest in memory (disk cache or bundle), | |
| 48 | * or `undefined` when the manifest has no entry for it. Never fetches. | |
| 49 | */ | |
| 50 | readonly current: ( | |
| 51 | driverKind: ProviderDriverKind, | |
| 52 | ) => Effect.Effect<ProviderCatalog | undefined>; | |
| 53 | /** The same catalog from the bundled manifest; for fallbacks when remote data is invalid. */ | |
| 54 | readonly bundled: (driverKind: ProviderDriverKind) => ProviderCatalog | undefined; | |
| 55 | /** | |
| 56 | * Starts a TTL-gated remote refresh in the background. Provider checks call | |
| 57 | * this; it never fails and never blocks the check. | |
| 58 | */ | |
| 59 | readonly refreshInBackground: Effect.Effect<void>; | |
| 60 | } | |
| 61 | >()("@t3tools/provider-core/server/ModelCatalog") {} | |
| 62 | ||
| 63 | /** A catalog model as the snapshot model it presents. */ | |
| 64 | export const catalogServerModel = ( | |
| 65 | catalog: ProviderCatalog, | |
| 66 | entry: ProviderCatalogModel, | |
| 67 | ): ServerProviderModel => ({ | |
| 68 | slug: entry.slug, | |
| 69 | name: entry.name, | |
| 70 | ...(entry.shortName ? { shortName: entry.shortName } : {}), | |
| 71 | ...(entry.subProvider ? { subProvider: entry.subProvider } : {}), | |
| 72 | ...(entry.aliases ? { aliases: entry.aliases } : {}), | |
| 73 | ...(entry.badge ? { badge: entry.badge } : {}), | |
| 74 | isCustom: false, | |
| 75 | ...(catalog.defaultChatModel === entry.slug ? { isDefault: true } : {}), | |
| 76 | ...(entry.status === "legacy" ? { isLegacy: true } : {}), | |
| 77 | capabilities: entry.capabilities, | |
| 78 | }); | |
| 79 | ||
| 80 | /** How a driver matches its discovered slugs to catalog entries. */ | |
| 81 | export interface CatalogMatching { | |
| 82 | /** | |
| 83 | * Maps a slug to the family the catalog names it by, such as a dated Codex | |
| 84 | * build to its base model. Defaults to the slug itself. | |
| 85 | */ | |
| 86 | readonly family?: (slug: string) => string; | |
| 87 | } | |
| 88 | ||
| 89 | const familyOf = (matching: CatalogMatching | undefined, slug: string) => | |
| 90 | matching?.family?.(slug) ?? slug; | |
| 91 | ||
| 92 | /** | |
| 93 | * Reclassifies built-in models as legacy or current from the catalog. Custom | |
| 94 | * models are user-defined and never reclassified. | |
| 95 | */ | |
| 96 | export function classifyCatalogModels( | |
| 97 | models: ReadonlyArray<ServerProviderModel>, | |
| 98 | catalog: ProviderCatalog | undefined, | |
| 99 | matching?: CatalogMatching, | |
| 100 | ): ReadonlyArray<ServerProviderModel> { | |
| 101 | return models.map((model) => { | |
| 102 | if (model.isCustom) return model; | |
| 103 | const entry = | |
| 104 | catalog?.models.find((candidate) => candidate.slug === model.slug) ?? | |
| 105 | catalog?.models.find((candidate) => candidate.slug === familyOf(matching, model.slug)); | |
| 106 | if (entry?.status === "legacy") return model.isLegacy ? model : { ...model, isLegacy: true }; | |
| 107 | if (!model.isLegacy) return model; | |
| 108 | const { isLegacy: _isLegacy, ...rest } = model; | |
| 109 | return rest; | |
| 110 | }); | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * Moves `isDefault` to the catalog's chat default when the catalog names one | |
| 115 | * the models include. Providers that learn their default from the runtime can | |
| 116 | * be overridden here without a release. Aliases that pointed at the old | |
| 117 | * default move with the flag so the shared "provider default" alias keeps | |
| 118 | * resolving. | |
| 119 | */ | |
| 120 | export function applyCatalogDefault( | |
| 121 | models: ReadonlyArray<ServerProviderModel>, | |
| 122 | catalog: ProviderCatalog | undefined, | |
| 123 | matching?: CatalogMatching, | |
| 124 | ): ReadonlyArray<ServerProviderModel> { | |
| 125 | const requestedSlug = catalog?.defaultChatModel; | |
| 126 | if (requestedSlug === undefined) return models; | |
| 127 | const slug = | |
| 128 | models.find((model) => model.slug === requestedSlug)?.slug ?? | |
| 129 | (matching?.family === undefined | |
| 130 | ? undefined | |
| 131 | : models.find( | |
| 132 | (model) => | |
| 133 | !model.isCustom && familyOf(matching, model.slug) === familyOf(matching, requestedSlug), | |
| 134 | )?.slug); | |
| 135 | if (slug === undefined) return models; | |
| 136 | const previous = models.find((model) => model.isDefault && model.slug !== slug); | |
| 137 | if (!previous) return models; | |
| 138 | const movedAliases = previous.aliases ?? []; | |
| 139 | return models.map((model) => { | |
| 140 | if (model.slug === previous.slug) { | |
| 141 | const { isDefault: _isDefault, aliases: _aliases, ...rest } = model; | |
| 142 | return rest; | |
| 143 | } | |
| 144 | if (model.slug === slug) { | |
| 145 | const aliases = [...new Set([...(model.aliases ?? []), ...movedAliases])]; | |
| 146 | return { ...model, isDefault: true, ...(aliases.length > 0 ? { aliases } : {}) }; | |
| 147 | } | |
| 148 | return model; | |
| 149 | }); | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * Reclassifies a snapshot draft's built-in models and moves its default from | |
| 154 | * the catalog. Drivers that announce models a newer CLI unlocks add | |
| 155 | * `updateRequiredModels` themselves; this clears a stale list. | |
| 156 | */ | |
| 157 | export function applyModelCatalog( | |
| 158 | draft: ServerProviderDraft, | |
| 159 | catalog: ProviderCatalog | undefined, | |
| 160 | matching?: CatalogMatching, | |
| 161 | ): ServerProviderDraft { | |
| 162 | const { updateRequiredModels: _previous, ...rest } = draft; | |
| 163 | return { | |
| 164 | ...rest, | |
| 165 | models: applyCatalogDefault( | |
| 166 | classifyCatalogModels(draft.models, catalog, matching), | |
| 167 | catalog, | |
| 168 | matching, | |
| 169 | ), | |
| 170 | }; | |
| 171 | } |