| author | |
| committer | |
| log | badae6a5cc8325dcd5a145bea6f7b8ac692818a1 |
| tree | 993d911666ab0532319e1a8d6ff8be971fe9d326 |
| parent | 504177797676048bf70f64ce56c21949d0b8a018 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>14 files changed, 503 insertions(+), 76 deletions(-)
apps/server/src/provider/Drivers/ClaudeDriver.ts+27-8| ... | @@ -36,6 +36,7 @@ import { | ... | @@ -36,6 +36,7 @@ import { |
| 36 | } from "../Layers/ClaudeProvider.ts"; | 36 | } from "../Layers/ClaudeProvider.ts"; |
| 37 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | 37 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| 38 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 38 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 39 | import * as ModelManifest from "../ModelManifest.ts"; | ||
| 39 | import { | 40 | import { |
| 40 | defaultProviderContinuationIdentity, | 41 | defaultProviderContinuationIdentity, |
| 41 | type ProviderDriver, | 42 | type ProviderDriver, |
| ... | @@ -87,6 +88,7 @@ export type ClaudeDriverEnv = | ... | @@ -87,6 +88,7 @@ export type ClaudeDriverEnv = |
| 87 | | Crypto.Crypto | 88 | | Crypto.Crypto |
| 88 | | FileSystem.FileSystem | 89 | | FileSystem.FileSystem |
| 89 | | HttpClient.HttpClient | 90 | | HttpClient.HttpClient |
| 91 | | ModelManifest.ModelManifest | ||
| 90 | | Path.Path | 92 | | Path.Path |
| 91 | | ProviderEventLoggers | 93 | | ProviderEventLoggers |
| 92 | | ServerConfig | 94 | | ServerConfig |
| ... | @@ -125,6 +127,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { | ... | @@ -125,6 +127,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 125 | const httpClient = yield* HttpClient.HttpClient; | 127 | const httpClient = yield* HttpClient.HttpClient; |
| 126 | const serverSettings = yield* ServerSettingsService; | 128 | const serverSettings = yield* ServerSettingsService; |
| 127 | const eventLoggers = yield* ProviderEventLoggers; | 129 | const eventLoggers = yield* ProviderEventLoggers; |
| 130 | const modelManifest = yield* ModelManifest.ModelManifest; | ||
| 128 | const processEnv = mergeProviderInstanceEnvironment(environment); | 131 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 129 | const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ | 132 | const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ |
| 130 | driverKind: DRIVER_KIND, | 133 | driverKind: DRIVER_KIND, |
| ... | @@ -163,13 +166,24 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { | ... | @@ -163,13 +166,24 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 163 | }); | 166 | }); |
| 164 | const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); | 167 | const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); |
| 165 | 168 | ||
| 166 | const checkProvider = checkClaudeProviderStatus( | 169 | // Kick the TTL-gated manifest refresh in the background and classify |
| 167 | effectiveConfig, | 170 | // with the in-memory manifest, so a slow or hung fetch never delays the |
| 168 | () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), | 171 | // provider check. A refresh that lands mid-probe applies on the next one. |
| 169 | processEnv, | 172 | const checkProvider = modelManifest.refreshInBackground.pipe( |
| 170 | cwd, | 173 | Effect.andThen( |
| 171 | ).pipe( | 174 | Effect.zipWith( |
| 172 | Effect.map(stampIdentity), | 175 | checkClaudeProviderStatus( |
| 176 | effectiveConfig, | ||
| 177 | () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), | ||
| 178 | processEnv, | ||
| 179 | cwd, | ||
| 180 | ), | ||
| 181 | modelManifest.current, | ||
| 182 | (draft, manifest) => | ||
| 183 | stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), | ||
| 184 | { concurrent: true }, | ||
| 185 | ), | ||
| 186 | ), | ||
| 173 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 187 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 174 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 188 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 175 | Effect.provideService(Path.Path, path), | 189 | Effect.provideService(Path.Path, path), |
| ... | @@ -182,7 +196,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { | ... | @@ -182,7 +196,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 182 | streamSettings: snapshotSettings.streamSettings, | 196 | streamSettings: snapshotSettings.streamSettings, |
| 183 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, | 197 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, |
| 184 | initialSnapshot: (settings) => | 198 | initialSnapshot: (settings) => |
| 185 | makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)), | 199 | Effect.zipWith( |
| 200 | makePendingClaudeProvider(settings.provider), | ||
| 201 | modelManifest.current, | ||
| 202 | (draft, manifest) => | ||
| 203 | stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), | ||
| 204 | ), | ||
| 186 | checkProvider, | 205 | checkProvider, |
| 187 | enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => | 206 | enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => |
| 188 | enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { | 207 | enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { |
apps/server/src/provider/Drivers/CodexDriver.ts+22-3| ... | @@ -39,6 +39,7 @@ import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; | ... | @@ -39,6 +39,7 @@ import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; |
| 39 | import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; | 39 | import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; |
| 40 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | 40 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| 41 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 41 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 42 | import * as ModelManifest from "../ModelManifest.ts"; | ||
| 42 | import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; | 43 | import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; |
| 43 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; | 44 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; |
| 44 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | 45 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| ... | @@ -78,6 +79,7 @@ export type CodexDriverEnv = | ... | @@ -78,6 +79,7 @@ export type CodexDriverEnv = |
| 78 | | Crypto.Crypto | 79 | | Crypto.Crypto |
| 79 | | FileSystem.FileSystem | 80 | | FileSystem.FileSystem |
| 80 | | HttpClient.HttpClient | 81 | | HttpClient.HttpClient |
| 82 | | ModelManifest.ModelManifest | ||
| 81 | | Path.Path | 83 | | Path.Path |
| 82 | | ProviderEventLoggers | 84 | | ProviderEventLoggers |
| 83 | | ServerConfig | 85 | | ServerConfig |
| ... | @@ -119,6 +121,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ... | @@ -119,6 +121,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 119 | const httpClient = yield* HttpClient.HttpClient; | 121 | const httpClient = yield* HttpClient.HttpClient; |
| 120 | const serverSettings = yield* ServerSettingsService; | 122 | const serverSettings = yield* ServerSettingsService; |
| 121 | const eventLoggers = yield* ProviderEventLoggers; | 123 | const eventLoggers = yield* ProviderEventLoggers; |
| 124 | const modelManifest = yield* ModelManifest.ModelManifest; | ||
| 122 | const processEnv = mergeProviderInstanceEnvironment(environment); | 125 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 123 | const homeLayout = yield* resolveCodexHomeLayout(config); | 126 | const homeLayout = yield* resolveCodexHomeLayout(config); |
| 124 | const continuationIdentity = codexContinuationIdentity(homeLayout); | 127 | const continuationIdentity = codexContinuationIdentity(homeLayout); |
| ... | @@ -166,8 +169,19 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ... | @@ -166,8 +169,19 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 166 | // in as instance rebuilds from the registry rather than in-place | 169 | // in as instance rebuilds from the registry rather than in-place |
| 167 | // updates. Pre-provide `ChildProcessSpawner` so the check fits | 170 | // updates. Pre-provide `ChildProcessSpawner` so the check fits |
| 168 | // `makeManagedServerProvider.checkProvider`'s `R = never`. | 171 | // `makeManagedServerProvider.checkProvider`'s `R = never`. |
| 169 | const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( | 172 | // Kick the TTL-gated manifest refresh in the background and classify |
| 170 | Effect.map(stampIdentity), | 173 | // with the in-memory manifest, so a slow or hung fetch never delays the |
| 174 | // provider check. A refresh that lands mid-probe applies on the next one. | ||
| 175 | const checkProvider = modelManifest.refreshInBackground.pipe( | ||
| 176 | Effect.andThen( | ||
| 177 | Effect.zipWith( | ||
| 178 | checkCodexProviderStatus(effectiveConfig, undefined, processEnv), | ||
| 179 | modelManifest.current, | ||
| 180 | (draft, manifest) => | ||
| 181 | stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), | ||
| 182 | { concurrent: true }, | ||
| 183 | ), | ||
| 184 | ), | ||
| 171 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 185 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 172 | ); | 186 | ); |
| 173 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); | 187 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); |
| ... | @@ -177,7 +191,12 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ... | @@ -177,7 +191,12 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 177 | streamSettings: snapshotSettings.streamSettings, | 191 | streamSettings: snapshotSettings.streamSettings, |
| 178 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, | 192 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, |
| 179 | initialSnapshot: (settings) => | 193 | initialSnapshot: (settings) => |
| 180 | makePendingCodexProvider(settings.provider).pipe(Effect.map(stampIdentity)), | 194 | Effect.zipWith( |
| 195 | makePendingCodexProvider(settings.provider), | ||
| 196 | modelManifest.current, | ||
| 197 | (draft, manifest) => | ||
| 198 | stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), | ||
| 199 | ), | ||
| 181 | checkProvider, | 200 | checkProvider, |
| 182 | enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => | 201 | enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => |
| 183 | enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { | 202 | enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { |
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts-16| ... | @@ -9,27 +9,11 @@ import * as Schema from "effect/Schema"; | ... | @@ -9,27 +9,11 @@ import * as Schema from "effect/Schema"; |
| 9 | import { | 9 | import { |
| 10 | buildClaudeCapabilitiesProbeQueryOptions, | 10 | buildClaudeCapabilitiesProbeQueryOptions, |
| 11 | CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, | 11 | CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, |
| 12 | isLegacyClaudeModel, | ||
| 13 | probeClaudeCapabilities, | 12 | probeClaudeCapabilities, |
| 14 | } from "./ClaudeProvider.ts"; | 13 | } from "./ClaudeProvider.ts"; |
| 15 | 14 | ||
| 16 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); | 15 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); |
| 17 | 16 | ||
| 18 | it("keeps only the Claude 5 family out of legacy models", () => { | ||
| 19 | assert.deepStrictEqual( | ||
| 20 | ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ | ||
| 21 | model, | ||
| 22 | isLegacyClaudeModel(model), | ||
| 23 | ]), | ||
| 24 | [ | ||
| 25 | ["claude-fable-5", false], | ||
| 26 | ["claude-opus-5", false], | ||
| 27 | ["claude-sonnet-5", false], | ||
| 28 | ["claude-opus-4-8", true], | ||
| 29 | ], | ||
| 30 | ); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("isolates Claude capability probes without dropping workspace setting sources", () => { | 17 | it("isolates Claude capability probes without dropping workspace setting sources", () => { |
| 34 | const abortController = new AbortController(); | 18 | const abortController = new AbortController(); |
| 35 | const options = buildClaudeCapabilitiesProbeQueryOptions({ | 19 | const options = buildClaudeCapabilitiesProbeQueryOptions({ |
apps/server/src/provider/Layers/ClaudeProvider.ts+3-9| ... | @@ -56,12 +56,6 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; | ... | @@ -56,12 +56,6 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; |
| 56 | const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; | 56 | const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; |
| 57 | const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; | 57 | const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; |
| 58 | 58 | ||
| 59 | const CURRENT_CLAUDE_MODELS = new Set(["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]); | ||
| 60 | |||
| 61 | export function isLegacyClaudeModel(model: string): boolean { | ||
| 62 | return !CURRENT_CLAUDE_MODELS.has(model); | ||
| 63 | } | ||
| 64 | |||
| 65 | const CLAUDE_MODEL_CATALOG: ReadonlyArray<ServerProviderModel> = [ | 59 | const CLAUDE_MODEL_CATALOG: ReadonlyArray<ServerProviderModel> = [ |
| 66 | { | 60 | { |
| 67 | slug: "claude-fable-5", | 61 | slug: "claude-fable-5", |
| ... | @@ -327,9 +321,9 @@ const CLAUDE_MODEL_CATALOG: ReadonlyArray<ServerProviderModel> = [ | ... | @@ -327,9 +321,9 @@ const CLAUDE_MODEL_CATALOG: ReadonlyArray<ServerProviderModel> = [ |
| 327 | }, | 321 | }, |
| 328 | ]; | 322 | ]; |
| 329 | 323 | ||
| 330 | const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = CLAUDE_MODEL_CATALOG.map((model) => | 324 | // Legacy classification happens at the driver boundary via `applyModelManifest`, |
| 331 | isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model, | 325 | // so the catalog itself carries no `isLegacy` flags. |
| 332 | ); | 326 | const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = CLAUDE_MODEL_CATALOG; |
| 333 | 327 | ||
| 334 | function supportsClaudeOpus5(version: string | null | undefined): boolean { | 328 | function supportsClaudeOpus5(version: string | null | undefined): boolean { |
| 335 | return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; | 329 | return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; |
apps/server/src/provider/Layers/CodexProvider.test.ts+1-26| ... | @@ -1,31 +1,6 @@ | ... | @@ -1,31 +1,6 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; |
| 4 | applyPreferredCodexDefaultModel, | ||
| 5 | isLegacyCodexModel, | ||
| 6 | mapCodexModelCapabilities, | ||
| 7 | } from "./CodexProvider.ts"; | ||
| 8 | |||
| 9 | it("keeps current Codex models out of legacy models", () => { | ||
| 10 | assert.deepStrictEqual( | ||
| 11 | [ | ||
| 12 | "gpt-5.6-luna", | ||
| 13 | "gpt-5.6-terra", | ||
| 14 | "gpt-5.6-sol", | ||
| 15 | "gpt-daybreak-blue-latest", | ||
| 16 | "gpt-daybreak-red-latest", | ||
| 17 | "gpt-5.4", | ||
| 18 | ].map((model) => [model, isLegacyCodexModel(model)]), | ||
| 19 | [ | ||
| 20 | ["gpt-5.6-luna", false], | ||
| 21 | ["gpt-5.6-terra", false], | ||
| 22 | ["gpt-5.6-sol", false], | ||
| 23 | ["gpt-daybreak-blue-latest", false], | ||
| 24 | ["gpt-daybreak-red-latest", false], | ||
| 25 | ["gpt-5.4", true], | ||
| 26 | ], | ||
| 27 | ); | ||
| 28 | }); | ||
| 29 | 4 | ||
| 30 | it("maps current Codex model capability fields", () => { | 5 | it("maps current Codex model capability fields", () => { |
| 31 | const capabilities = mapCodexModelCapabilities({ | 6 | const capabilities = mapCodexModelCapabilities({ |
apps/server/src/provider/Layers/CodexProvider.ts-12| ... | @@ -62,17 +62,6 @@ const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = { | ... | @@ -62,17 +62,6 @@ const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = { |
| 62 | }; | 62 | }; |
| 63 | 63 | ||
| 64 | const DEFAULT_SERVICE_TIER_ID = "default"; | 64 | const DEFAULT_SERVICE_TIER_ID = "default"; |
| 65 | const CURRENT_CODEX_MODELS = new Set([ | ||
| 66 | "gpt-5.6-luna", | ||
| 67 | "gpt-5.6-terra", | ||
| 68 | "gpt-5.6-sol", | ||
| 69 | "gpt-daybreak-blue-latest", | ||
| 70 | "gpt-daybreak-red-latest", | ||
| 71 | ]); | ||
| 72 | |||
| 73 | export function isLegacyCodexModel(model: string): boolean { | ||
| 74 | return !CURRENT_CODEX_MODELS.has(model); | ||
| 75 | } | ||
| 76 | 65 | ||
| 77 | function reasoningEffortLabel(reasoningEffort: string): string { | 66 | function reasoningEffortLabel(reasoningEffort: string): string { |
| 78 | return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; | 67 | return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; |
| ... | @@ -201,7 +190,6 @@ function parseCodexModelListResponse( | ... | @@ -201,7 +190,6 @@ function parseCodexModelListResponse( |
| 201 | name: toDisplayName(model), | 190 | name: toDisplayName(model), |
| 202 | isCustom: false, | 191 | isCustom: false, |
| 203 | ...(model.isDefault ? { isDefault: true } : {}), | 192 | ...(model.isDefault ? { isDefault: true } : {}), |
| 204 | ...(isLegacyCodexModel(model.model) ? { isLegacy: true } : {}), | ||
| 205 | capabilities: mapCodexModelCapabilities(model), | 193 | capabilities: mapCodexModelCapabilities(model), |
| 206 | })); | 194 | })); |
| 207 | } | 195 | } |
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+5-1| ... | @@ -41,6 +41,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -41,6 +41,7 @@ import * as Stream from "effect/Stream"; |
| 41 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 41 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 42 | 42 | ||
| 43 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 43 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 44 | import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; | ||
| 44 | import { ServerConfig } from "../../config.ts"; | 45 | import { ServerConfig } from "../../config.ts"; |
| 45 | import { ServerSettingsService } from "../../serverSettings.ts"; | 46 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 46 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; | 47 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; |
| ... | @@ -48,6 +49,7 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts"; | ... | @@ -48,6 +49,7 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts"; |
| 48 | import { CursorDriver } from "../Drivers/CursorDriver.ts"; | 49 | import { CursorDriver } from "../Drivers/CursorDriver.ts"; |
| 49 | import { GrokDriver } from "../Drivers/GrokDriver.ts"; | 50 | import { GrokDriver } from "../Drivers/GrokDriver.ts"; |
| 50 | import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; | 51 | import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; |
| 52 | import * as ModelManifest from "../ModelManifest.ts"; | ||
| 51 | import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; | 53 | import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; |
| 52 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; | 54 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; |
| 53 | import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; | 55 | import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; |
| ... | @@ -148,6 +150,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { | ... | @@ -148,6 +150,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { |
| 148 | Layer.provideMerge(ServerSettingsService.layerTest()), | 150 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 149 | Layer.provideMerge(TestHttpClientLive), | 151 | Layer.provideMerge(TestHttpClientLive), |
| 150 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | 152 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 153 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 151 | ); | 154 | ); |
| 152 | 155 | ||
| 153 | it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => | 156 | it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => |
| ... | @@ -313,6 +316,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { | ... | @@ -313,6 +316,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { |
| 313 | Layer.provideMerge(ServerSettingsService.layerTest()), | 316 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 314 | Layer.provideMerge(TestHttpClientLive), | 317 | Layer.provideMerge(TestHttpClientLive), |
| 315 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | 318 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 319 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 316 | ); | 320 | ); |
| 317 | 321 | ||
| 318 | it.live("boots one instance of every shipped driver from a single config map", () => | 322 | it.live("boots one instance of every shipped driver from a single config map", () => |
| ... | @@ -365,7 +369,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { | ... | @@ -365,7 +369,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { |
| 365 | }, | 369 | }, |
| 366 | }; | 370 | }; |
| 367 | 371 | ||
| 368 | const { registry } = yield* makeProviderInstanceRegistry({ | 372 | const { registry } = yield* makeProviderInstanceRegistry<BuiltInDriversEnv>({ |
| 369 | drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], | 373 | drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], |
| 370 | configMap, | 374 | configMap, |
| 371 | }); | 375 | }); |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+5| ... | @@ -34,6 +34,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | ... | @@ -34,6 +34,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 34 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; | 34 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; |
| 35 | import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; | 35 | import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; |
| 36 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 36 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 37 | import * as ModelManifest from "../ModelManifest.ts"; | ||
| 37 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | 38 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; |
| 38 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; | 39 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; |
| 39 | import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; | 40 | import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; |
| ... | @@ -1423,6 +1424,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -1423,6 +1424,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1423 | ProviderEventLoggers.NoOpProviderEventLoggers, | 1424 | ProviderEventLoggers.NoOpProviderEventLoggers, |
| 1424 | ), | 1425 | ), |
| 1425 | ), | 1426 | ), |
| 1427 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 1426 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), | 1428 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), |
| 1427 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | 1429 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), |
| 1428 | // NO spawner mock — `ChildProcessSpawner` is supplied by the | 1430 | // NO spawner mock — `ChildProcessSpawner` is supplied by the |
| ... | @@ -1516,6 +1518,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -1516,6 +1518,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1516 | ProviderEventLoggers.NoOpProviderEventLoggers, | 1518 | ProviderEventLoggers.NoOpProviderEventLoggers, |
| 1517 | ), | 1519 | ), |
| 1518 | ), | 1520 | ), |
| 1521 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 1519 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), | 1522 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), |
| 1520 | Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => | 1523 | Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => |
| 1521 | ChildProcessSpawner.make((command) => { | 1524 | ChildProcessSpawner.make((command) => { |
| ... | @@ -1638,6 +1641,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -1638,6 +1641,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1638 | ProviderEventLoggers.NoOpProviderEventLoggers, | 1641 | ProviderEventLoggers.NoOpProviderEventLoggers, |
| 1639 | ), | 1642 | ), |
| 1640 | ), | 1643 | ), |
| 1644 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 1641 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), | 1645 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), |
| 1642 | Layer.provideMerge(NodeServices.layer), | 1646 | Layer.provideMerge(NodeServices.layer), |
| 1643 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | 1647 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), |
| ... | @@ -1697,6 +1701,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -1697,6 +1701,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1697 | ProviderEventLoggers.NoOpProviderEventLoggers, | 1701 | ProviderEventLoggers.NoOpProviderEventLoggers, |
| 1698 | ), | 1702 | ), |
| 1699 | ), | 1703 | ), |
| 1704 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 1700 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), | 1705 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), |
| 1701 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), | 1706 | Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), |
| 1702 | Layer.provideMerge( | 1707 | Layer.provideMerge( |
apps/server/src/provider/ModelManifest.test.ts created+185| ... | @@ -0,0 +1,185 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 7 | |||
| 8 | import * as ServerConfig from "../config.ts"; | ||
| 9 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 10 | import { | ||
| 11 | BUNDLED_MODEL_MANIFEST, | ||
| 12 | classifyModels, | ||
| 13 | isLegacyModel, | ||
| 14 | make, | ||
| 15 | type ModelManifestData, | ||
| 16 | } from "./ModelManifest.ts"; | ||
| 17 | |||
| 18 | const CODEX = ProviderDriverKind.make("codex"); | ||
| 19 | const CLAUDE = ProviderDriverKind.make("claudeAgent"); | ||
| 20 | const CURSOR = ProviderDriverKind.make("cursor"); | ||
| 21 | |||
| 22 | describe("isLegacyModel (bundled manifest)", () => { | ||
| 23 | it("keeps current Codex models out of legacy models", () => { | ||
| 24 | assert.deepStrictEqual( | ||
| 25 | [ | ||
| 26 | "gpt-5.6-luna", | ||
| 27 | "gpt-5.6-terra", | ||
| 28 | "gpt-5.6-sol", | ||
| 29 | "gpt-daybreak-blue-latest", | ||
| 30 | "gpt-daybreak-red-latest", | ||
| 31 | "gpt-5.4", | ||
| 32 | ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CODEX, model)]), | ||
| 33 | [ | ||
| 34 | ["gpt-5.6-luna", false], | ||
| 35 | ["gpt-5.6-terra", false], | ||
| 36 | ["gpt-5.6-sol", false], | ||
| 37 | ["gpt-daybreak-blue-latest", false], | ||
| 38 | ["gpt-daybreak-red-latest", false], | ||
| 39 | ["gpt-5.4", true], | ||
| 40 | ], | ||
| 41 | ); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("keeps only the Claude 5 family out of legacy models", () => { | ||
| 45 | assert.deepStrictEqual( | ||
| 46 | ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ | ||
| 47 | model, | ||
| 48 | isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model), | ||
| 49 | ]), | ||
| 50 | [ | ||
| 51 | ["claude-fable-5", false], | ||
| 52 | ["claude-opus-5", false], | ||
| 53 | ["claude-sonnet-5", false], | ||
| 54 | ["claude-opus-4-8", true], | ||
| 55 | ], | ||
| 56 | ); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("leaves driver kinds without a manifest entry unflagged", () => { | ||
| 60 | assert.isFalse(isLegacyModel(BUNDLED_MODEL_MANIFEST, CURSOR, "composer-1.5")); | ||
| 61 | }); | ||
| 62 | }); | ||
| 63 | |||
| 64 | const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel => ({ | ||
| 65 | slug: "gpt-test", | ||
| 66 | name: "GPT Test", | ||
| 67 | isCustom: false, | ||
| 68 | capabilities: null, | ||
| 69 | ...overrides, | ||
| 70 | }); | ||
| 71 | |||
| 72 | describe("classifyModels", () => { | ||
| 73 | it("flags non-current models, clears stale flags, and skips custom models", () => { | ||
| 74 | const models = [ | ||
| 75 | model({ slug: "gpt-5.6-sol" }), | ||
| 76 | // Stale flag from a previous classification pass must be cleared. | ||
| 77 | model({ slug: "gpt-5.6-luna", isLegacy: true }), | ||
| 78 | model({ slug: "gpt-5.4" }), | ||
| 79 | // Custom models are user-defined and never reclassified. | ||
| 80 | model({ slug: "my-own-model", isCustom: true }), | ||
| 81 | ]; | ||
| 82 | assert.deepStrictEqual( | ||
| 83 | classifyModels(models, BUNDLED_MODEL_MANIFEST, CODEX).map((entry) => [ | ||
| 84 | entry.slug, | ||
| 85 | entry.isLegacy ?? false, | ||
| 86 | ]), | ||
| 87 | [ | ||
| 88 | ["gpt-5.6-sol", false], | ||
| 89 | ["gpt-5.6-luna", false], | ||
| 90 | ["gpt-5.4", true], | ||
| 91 | ["my-own-model", false], | ||
| 92 | ], | ||
| 93 | ); | ||
| 94 | }); | ||
| 95 | }); | ||
| 96 | |||
| 97 | const REMOTE_MANIFEST: ModelManifestData = { | ||
| 98 | version: 1, | ||
| 99 | currentModels: { | ||
| 100 | codex: ["gpt-5.4"], | ||
| 101 | claudeAgent: ["claude-fable-5"], | ||
| 102 | }, | ||
| 103 | }; | ||
| 104 | |||
| 105 | const httpClientLayer = (handler: () => Response) => | ||
| 106 | Layer.succeed( | ||
| 107 | HttpClient.HttpClient, | ||
| 108 | HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler()))), | ||
| 109 | ); | ||
| 110 | |||
| 111 | const serviceLayers = (input: { | ||
| 112 | readonly prefix: string; | ||
| 113 | readonly response: () => Response; | ||
| 114 | readonly settings?: Parameters<typeof ServerSettings.layerTest>[0]; | ||
| 115 | }) => | ||
| 116 | ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( | ||
| 117 | Layer.provideMerge(NodeServices.layer), | ||
| 118 | Layer.provideMerge(ServerSettings.layerTest(input.settings ?? {})), | ||
| 119 | Layer.provideMerge(httpClientLayer(input.response)), | ||
| 120 | ); | ||
| 121 | |||
| 122 | describe("ModelManifest service", () => { | ||
| 123 | it.live("prefers a fetched manifest over the bundle and caches it to disk", () => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const service = yield* make; | ||
| 126 | const refreshed = yield* service.refresh; | ||
| 127 | assert.deepStrictEqual(refreshed, REMOTE_MANIFEST); | ||
| 128 | assert.isTrue(isLegacyModel(refreshed, CODEX, "gpt-5.6-sol")); | ||
| 129 | assert.isFalse(isLegacyModel(refreshed, CODEX, "gpt-5.4")); | ||
| 130 | |||
| 131 | // A fresh service instance sees the disk cache without another fetch: | ||
| 132 | // its HTTP layer is still stubbed, but `current` never fetches at all. | ||
| 133 | const rebooted = yield* make; | ||
| 134 | assert.deepStrictEqual(yield* rebooted.current, REMOTE_MANIFEST); | ||
| 135 | }).pipe( | ||
| 136 | Effect.scoped, | ||
| 137 | Effect.provide( | ||
| 138 | serviceLayers({ | ||
| 139 | prefix: "model-manifest-fetch-test", | ||
| 140 | response: () => Response.json(REMOTE_MANIFEST), | ||
| 141 | }), | ||
| 142 | ), | ||
| 143 | ), | ||
| 144 | ); | ||
| 145 | |||
| 146 | it.live("keeps the bundled manifest when the remote payload is malformed", () => | ||
| 147 | Effect.gen(function* () { | ||
| 148 | const service = yield* make; | ||
| 149 | assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); | ||
| 150 | }).pipe( | ||
| 151 | Effect.scoped, | ||
| 152 | Effect.provide( | ||
| 153 | serviceLayers({ | ||
| 154 | prefix: "model-manifest-malformed-test", | ||
| 155 | response: () => Response.json({ version: 999, nonsense: true }), | ||
| 156 | }), | ||
| 157 | ), | ||
| 158 | ), | ||
| 159 | ); | ||
| 160 | |||
| 161 | it.live("does not fetch when provider update checks are disabled", () => | ||
| 162 | Effect.gen(function* () { | ||
| 163 | let fetchCount = 0; | ||
| 164 | const service = yield* make.pipe( | ||
| 165 | Effect.provide( | ||
| 166 | httpClientLayer(() => { | ||
| 167 | fetchCount += 1; | ||
| 168 | return Response.json(REMOTE_MANIFEST); | ||
| 169 | }), | ||
| 170 | ), | ||
| 171 | ); | ||
| 172 | assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); | ||
| 173 | assert.strictEqual(fetchCount, 0); | ||
| 174 | }).pipe( | ||
| 175 | Effect.scoped, | ||
| 176 | Effect.provide( | ||
| 177 | serviceLayers({ | ||
| 178 | prefix: "model-manifest-optout-test", | ||
| 179 | response: () => Response.json(REMOTE_MANIFEST), | ||
| 180 | settings: { enableProviderUpdateChecks: false }, | ||
| 181 | }), | ||
| 182 | ), | ||
| 183 | ), | ||
| 184 | ); | ||
| 185 | }); | ||
apps/server/src/provider/ModelManifest.ts created+221| ... | @@ -0,0 +1,221 @@ | ||
| 1 | /** | ||
| 2 | * ModelManifest — decides which provider models are current and which belong | ||
| 3 | * in the model picker's legacy section. | ||
| 4 | * | ||
| 5 | * The classification data (current slugs per driver kind) lives in | ||
| 6 | * `model-manifest.json` next to this file. The bundled copy ships with every | ||
| 7 | * release. At runtime the service refreshes it from the same file on `main` | ||
| 8 | * via raw.githubusercontent.com, so a new model can leave the legacy section | ||
| 9 | * with a commit to `main` instead of a release. Preference order is remote, | ||
| 10 | * then the on-disk copy of the last successful fetch, then the bundle. A | ||
| 11 | * failed fetch never fails a provider check. | ||
| 12 | * | ||
| 13 | * Drivers apply the manifest to snapshot drafts with `applyModelManifest` | ||
| 14 | * before publishing, so every path that produces models (pending, probe, | ||
| 15 | * error fallbacks) is classified the same way. | ||
| 16 | */ | ||
| 17 | import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts"; | ||
| 18 | import * as Clock from "effect/Clock"; | ||
| 19 | import * as Context from "effect/Context"; | ||
| 20 | import * as Effect from "effect/Effect"; | ||
| 21 | import * as FileSystem from "effect/FileSystem"; | ||
| 22 | import * as Layer from "effect/Layer"; | ||
| 23 | import * as Path from "effect/Path"; | ||
| 24 | import * as Schema from "effect/Schema"; | ||
| 25 | import * as Semaphore from "effect/Semaphore"; | ||
| 26 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 27 | |||
| 28 | import { ServerConfig } from "../config.ts"; | ||
| 29 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 30 | import bundledManifestJson from "./model-manifest.json" with { type: "json" }; | ||
| 31 | import type { ServerProviderDraft } from "./providerSnapshot.ts"; | ||
| 32 | |||
| 33 | const MODEL_MANIFEST_URL = | ||
| 34 | "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json"; | ||
| 35 | |||
| 36 | /** How long a fetched manifest stays fresh before the next probe re-fetches. */ | ||
| 37 | const MANIFEST_TTL_MS = 60 * 60 * 1000; | ||
| 38 | |||
| 39 | /** Minimum gap between fetch attempts after a failure, so an offline server | ||
| 40 | * does not pay a network timeout on every provider check. */ | ||
| 41 | const MANIFEST_RETRY_MS = 5 * 60 * 1000; | ||
| 42 | |||
| 43 | const FETCH_TIMEOUT_MS = 10_000; | ||
| 44 | |||
| 45 | /** | ||
| 46 | * `version` gates breaking schema changes: a build only accepts remote | ||
| 47 | * manifests whose version it understands, and keeps its bundled copy | ||
| 48 | * otherwise. `currentModels` is keyed by driver kind; kinds absent from the | ||
| 49 | * map have no legacy concept and their models are left unflagged. | ||
| 50 | */ | ||
| 51 | const ModelManifestSchema = Schema.Struct({ | ||
| 52 | version: Schema.Literal(1), | ||
| 53 | currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), | ||
| 54 | }); | ||
| 55 | export type ModelManifestData = typeof ModelManifestSchema.Type; | ||
| 56 | |||
| 57 | const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); | ||
| 58 | |||
| 59 | export const BUNDLED_MODEL_MANIFEST: ModelManifestData = | ||
| 60 | Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); | ||
| 61 | |||
| 62 | /** On-disk shape of the last successfully fetched manifest. */ | ||
| 63 | const ManifestCacheFile = Schema.Struct({ | ||
| 64 | fetchedAtMs: Schema.Number, | ||
| 65 | manifest: ModelManifestSchema, | ||
| 66 | }); | ||
| 67 | const decodeManifestCache = Schema.decodeUnknownEffect( | ||
| 68 | Schema.fromJsonString( | ||
| 69 | ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>, | ||
| 70 | ), | ||
| 71 | ); | ||
| 72 | const encodeManifestCache = Schema.encodeEffect( | ||
| 73 | Schema.fromJsonString( | ||
| 74 | ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>, | ||
| 75 | ), | ||
| 76 | ); | ||
| 77 | |||
| 78 | /** True when the manifest classifies `slug` as legacy for `driverKind`. */ | ||
| 79 | export function isLegacyModel( | ||
| 80 | manifest: ModelManifestData, | ||
| 81 | driverKind: ProviderDriverKind, | ||
| 82 | slug: string, | ||
| 83 | ): boolean { | ||
| 84 | const currentModels = manifest.currentModels[driverKind]; | ||
| 85 | if (!currentModels) return false; | ||
| 86 | return !currentModels.includes(slug); | ||
| 87 | } | ||
| 88 | |||
| 89 | /** | ||
| 90 | * Reclassifies every built-in model on a snapshot draft against the manifest. | ||
| 91 | * Custom models are user-defined and never reclassified. | ||
| 92 | */ | ||
| 93 | export function applyModelManifest( | ||
| 94 | draft: ServerProviderDraft, | ||
| 95 | manifest: ModelManifestData, | ||
| 96 | driverKind: ProviderDriverKind, | ||
| 97 | ): ServerProviderDraft { | ||
| 98 | return { ...draft, models: classifyModels(draft.models, manifest, driverKind) }; | ||
| 99 | } | ||
| 100 | |||
| 101 | /** Model-level half of `applyModelManifest`, exported for focused tests. */ | ||
| 102 | export function classifyModels( | ||
| 103 | models: ReadonlyArray<ServerProviderModel>, | ||
| 104 | manifest: ModelManifestData, | ||
| 105 | driverKind: ProviderDriverKind, | ||
| 106 | ): ReadonlyArray<ServerProviderModel> { | ||
| 107 | return models.map((model) => { | ||
| 108 | if (model.isCustom) return model; | ||
| 109 | if (isLegacyModel(manifest, driverKind, model.slug)) { | ||
| 110 | return model.isLegacy ? model : { ...model, isLegacy: true }; | ||
| 111 | } | ||
| 112 | if (!model.isLegacy) return model; | ||
| 113 | const { isLegacy: _isLegacy, ...rest } = model; | ||
| 114 | return rest; | ||
| 115 | }); | ||
| 116 | } | ||
| 117 | |||
| 118 | export class ModelManifest extends Context.Service< | ||
| 119 | ModelManifest, | ||
| 120 | { | ||
| 121 | /** Manifest already in memory (disk cache or bundle); never fetches. | ||
| 122 | * Snapshot classification reads this, so it never waits on the network. */ | ||
| 123 | readonly current: Effect.Effect<ModelManifestData>; | ||
| 124 | /** Manifest after a TTL-gated remote refresh; never fails. */ | ||
| 125 | readonly refresh: Effect.Effect<ModelManifestData>; | ||
| 126 | /** Forks `refresh` into the service's own scope. Drivers call this from | ||
| 127 | * provider checks: the fetch is process-shared state, so it must survive | ||
| 128 | * the teardown of whichever instance happened to trigger it. */ | ||
| 129 | readonly refreshInBackground: Effect.Effect<void>; | ||
| 130 | } | ||
| 131 | >()("t3/provider/ModelManifest") {} | ||
| 132 | |||
| 133 | /** Constant service for tests and callers that only need the bundled data. */ | ||
| 134 | export const BundledOnlyModelManifest: ModelManifest["Service"] = { | ||
| 135 | current: Effect.succeed(BUNDLED_MODEL_MANIFEST), | ||
| 136 | refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), | ||
| 137 | refreshInBackground: Effect.void, | ||
| 138 | }; | ||
| 139 | |||
| 140 | export const layerTest = Layer.succeed(ModelManifest, BundledOnlyModelManifest); | ||
| 141 | |||
| 142 | export const make = Effect.gen(function* () { | ||
| 143 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 144 | const path = yield* Path.Path; | ||
| 145 | const config = yield* ServerConfig; | ||
| 146 | const settingsService = yield* ServerSettings.ServerSettingsService; | ||
| 147 | const httpClient = yield* HttpClient.HttpClient; | ||
| 148 | const serviceScope = yield* Effect.scope; | ||
| 149 | |||
| 150 | const cachePath = path.join(config.stateDir, "model-manifest.json"); | ||
| 151 | let manifest = BUNDLED_MODEL_MANIFEST; | ||
| 152 | let fetchedAtMs: number | null = null; | ||
| 153 | let lastAttemptMs: number | null = null; | ||
| 154 | const refreshSemaphore = yield* Semaphore.make(1); | ||
| 155 | |||
| 156 | // `Effect.cached` makes concurrent first readers await the same disk load | ||
| 157 | // rather than racing a "loaded" flag. Only `refreshed` takes the fetch | ||
| 158 | // semaphore; `current` must never wait behind an in-flight network refresh. | ||
| 159 | const ensureDiskCacheLoaded = yield* Effect.cached( | ||
| 160 | Effect.gen(function* () { | ||
| 161 | const fromDisk = yield* fileSystem.readFileString(cachePath).pipe( | ||
| 162 | Effect.flatMap((raw) => decodeManifestCache(raw)), | ||
| 163 | Effect.catchCause(() => Effect.succeed(null)), | ||
| 164 | ); | ||
| 165 | if (fromDisk === null) return; | ||
| 166 | // The disk copy is the last-seen remote manifest, so it outranks the | ||
| 167 | // bundle even when stale: it is refreshed on the next successful fetch. | ||
| 168 | manifest = fromDisk.manifest; | ||
| 169 | fetchedAtMs = fromDisk.fetchedAtMs; | ||
| 170 | }), | ||
| 171 | ); | ||
| 172 | |||
| 173 | const refresh = Effect.fn("ModelManifest.refresh")(function* () { | ||
| 174 | yield* ensureDiskCacheLoaded; | ||
| 175 | const now = yield* Clock.currentTimeMillis; | ||
| 176 | // A timestamp in the future means the wall clock moved backwards (the | ||
| 177 | // disk cache crosses restarts, so monotonic time cannot cover it). Treat | ||
| 178 | // it as expired: the refetch rewrites both timestamps and self-heals. | ||
| 179 | const isWithin = (sinceMs: number | null, windowMs: number) => | ||
| 180 | sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; | ||
| 181 | if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; | ||
| 182 | if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; | ||
| 183 | |||
| 184 | // The same switch that gates provider CLI update checks. It stops network | ||
| 185 | // fetches only: a manifest already cached on disk from an earlier fetch | ||
| 186 | // stays in effect, since the setting is about phoning home, not about | ||
| 187 | // discarding data the server already holds. | ||
| 188 | const settings = yield* settingsService.getSettings.pipe( | ||
| 189 | Effect.catchCause(() => Effect.succeed(null)), | ||
| 190 | ); | ||
| 191 | if (settings !== null && !settings.enableProviderUpdateChecks) return manifest; | ||
| 192 | |||
| 193 | lastAttemptMs = now; | ||
| 194 | const fetched = yield* httpClient.get(MODEL_MANIFEST_URL).pipe( | ||
| 195 | Effect.flatMap(HttpClientResponse.filterStatusOk), | ||
| 196 | Effect.flatMap((response) => response.json), | ||
| 197 | Effect.flatMap((json) => decodeManifest(json)), | ||
| 198 | Effect.timeout(FETCH_TIMEOUT_MS), | ||
| 199 | Effect.catchCause(() => Effect.succeed(null)), | ||
| 200 | ); | ||
| 201 | if (fetched === null) return manifest; | ||
| 202 | |||
| 203 | manifest = fetched; | ||
| 204 | fetchedAtMs = now; | ||
| 205 | yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe( | ||
| 206 | Effect.flatMap((serialized) => fileSystem.writeFileString(cachePath, serialized)), | ||
| 207 | Effect.catchCause(() => Effect.void), | ||
| 208 | ); | ||
| 209 | return manifest; | ||
| 210 | }); | ||
| 211 | |||
| 212 | const guardedRefresh = refreshSemaphore.withPermits(1)(refresh()); | ||
| 213 | |||
| 214 | return ModelManifest.of({ | ||
| 215 | current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)), | ||
| 216 | refresh: guardedRefresh, | ||
| 217 | refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid), | ||
| 218 | }); | ||
| 219 | }); | ||
| 220 | |||
| 221 | export const layer = Layer.effect(ModelManifest, make); | ||
apps/server/src/provider/model-manifest.json created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | { | ||
| 2 | "version": 1, | ||
| 3 | "currentModels": { | ||
| 4 | "codex": [ | ||
| 5 | "gpt-5.6-luna", | ||
| 6 | "gpt-5.6-terra", | ||
| 7 | "gpt-5.6-sol", | ||
| 8 | "gpt-daybreak-blue-latest", | ||
| 9 | "gpt-daybreak-red-latest" | ||
| 10 | ], | ||
| 11 | "claudeAgent": ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"] | ||
| 12 | } | ||
| 13 | } | ||
apps/server/src/server.ts+5-1| ... | @@ -32,6 +32,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | ... | @@ -32,6 +32,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; |
| 32 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; | 32 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; |
| 33 | import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; | 33 | import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; |
| 34 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; | 34 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; |
| 35 | import * as ModelManifest from "./provider/ModelManifest.ts"; | ||
| 35 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; | 36 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; |
| 36 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; | 37 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; |
| 37 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; | 38 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; |
| ... | @@ -395,7 +396,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | ... | @@ -395,7 +396,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 395 | // `ProviderService` (canonical stream, written after event normalization). | 396 | // `ProviderService` (canonical stream, written after event normalization). |
| 396 | // Provided once at the runtime level so every consumer sees the same | 397 | // Provided once at the runtime level so every consumer sees the same |
| 397 | // logger instances. | 398 | // logger instances. |
| 398 | Layer.provideMerge(ProviderEventLoggers.layer), | 399 | // `ModelManifest.layer` is the legacy-model classification data, refreshed |
| 400 | // from the repo's `model-manifest.json` on `main` and applied by the | ||
| 401 | // Codex/Claude drivers. | ||
| 402 | Layer.provideMerge(Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer)), | ||
| 399 | // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old | 403 | // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old |
| 400 | // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but | 404 | // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but |
| 401 | // the rewritten registry reads snapshots off the instance registry and | 405 | // the rewritten registry reads snapshots off the instance registry and |
docs/internals/glossary.md+4| ... | @@ -116,6 +116,10 @@ Controls how assistant text reaches the thread timeline. In [the contracts][1], | ... | @@ -116,6 +116,10 @@ Controls how assistant text reaches the thread timeline. In [the contracts][1], |
| 116 | 116 | ||
| 117 | A point-in-time view of state. The word is used in multiple layers, including orchestration, provider, and checkpointing. See [ProjectionSnapshotQuery.ts][10], [ProviderAdapter.ts][15], and [CheckpointStore.ts][19]. | 117 | A point-in-time view of state. The word is used in multiple layers, including orchestration, provider, and checkpointing. See [ProjectionSnapshotQuery.ts][10], [ProviderAdapter.ts][15], and [CheckpointStore.ts][19]. |
| 118 | 118 | ||
| 119 | #### Model manifest | ||
| 120 | |||
| 121 | The per-driver list of current model slugs that decides which models land in the model picker's legacy section. Bundled at `apps/server/src/provider/model-manifest.json` and refreshed at runtime from the same file on `main`, so classification updates ship as commits instead of releases. See the [provider architecture][16] model manifest section. | ||
| 122 | |||
| 119 | ### Checkpointing | 123 | ### Checkpointing |
| 120 | 124 | ||
| 121 | Checkpointing captures workspace state over time so the app can diff turns and restore earlier points. The main pieces are [CheckpointStore.ts][19], [CheckpointDiffQuery.ts][20], and [CheckpointReactor.ts][6]. | 125 | Checkpointing captures workspace state over time so the app can diff turns and restore earlier points. The main pieces are [CheckpointStore.ts][19], [CheckpointDiffQuery.ts][20], and [CheckpointReactor.ts][6]. |
docs/internals/providers.md+12| ... | @@ -39,6 +39,18 @@ directory to route session and turn operations for a thread, so callers name a t | ... | @@ -39,6 +39,18 @@ directory to route session and turn operations for a thread, so callers name a t |
| 39 | Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No | 39 | Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No |
| 40 | orchestration, contract, or client change is required for the common case. | 40 | orchestration, contract, or client change is required for the common case. |
| 41 | 41 | ||
| 42 | ## Model manifest | ||
| 43 | |||
| 44 | The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which | ||
| 45 | lists the current (non-legacy) model slugs per driver kind. The `ModelManifest` service | ||
| 46 | (`apps/server/src/provider/ModelManifest.ts`) refreshes that data from the same file on `main` via | ||
| 47 | raw.githubusercontent.com, so moving a model in or out of the legacy section is a commit, not a | ||
| 48 | release. Preference order is remote fetch, then the on-disk copy of the last successful fetch (in | ||
| 49 | the state directory), then the bundled copy. Fetches are TTL-gated, run concurrently with provider | ||
| 50 | probes, respect the `enableProviderUpdateChecks` setting, and never fail a provider check. The | ||
| 51 | Codex and Claude drivers apply the classification to every snapshot with `applyModelManifest`; | ||
| 52 | driver kinds absent from the manifest have no legacy concept. | ||
| 53 | |||
| 42 | ## How provider work is requested | 54 | ## How provider work is requested |
| 43 | 55 | ||
| 44 | Clients never call a provider directly. They dispatch orchestration commands over the RPC method | 56 | Clients never call a provider directly. They dispatch orchestration commands over the RPC method |