diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 6e5fd3516e0b..bba467deec41 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -31,6 +31,7 @@ import { RunId, ThreadId, TurnItemId, + type ServerProviderModel, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import * as CodexClient from "effect-codex-app-server/client"; @@ -230,6 +231,34 @@ describe("CodexAdapterV2 runtime policy", () => { }), ); + it.effect("sends the chosen cyber access program, else the model's default", () => + Effect.gen(function* () { + const build = (value?: string, cyberAccess?: { defaultProgram: string | undefined }) => + CodexAdapterV2.buildCodexTurnStartParams({ + nativeThreadId: "native-cyber", + codexInput: [{ type: "text", text: "test" }], + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + ...(value === undefined ? {} : { options: [{ id: "cyberAccessProgram", value }] }), + }, + cyberAccess, + }); + + // Custom models offer no Cyber Access, so even an explicit choice is dropped. + assert.notProperty(yield* build(), "cyberAccessProgram"); + assert.notProperty(yield* build("daybreakBlue"), "cyberAccessProgram"); + const daybreak = { defaultProgram: "daybreakBlue" }; + assert.equal((yield* build(undefined, daybreak)).cyberAccessProgram, "daybreakBlue"); + assert.equal((yield* build("standard", daybreak)).cyberAccessProgram, "standard"); + // An unread config leaves no default, but an explicit Standard still goes. + const unknown = { defaultProgram: undefined }; + assert.notProperty(yield* build(undefined, unknown), "cyberAccessProgram"); + assert.equal((yield* build("standard", unknown)).cyberAccessProgram, "standard"); + }), + ); + it.effect("sends MCP app model context as untrusted Codex context", () => Effect.gen(function* () { const policy = { runtimeMode: "full-access", interactionMode: "default", cwd: null } as const; @@ -1302,6 +1331,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { onUsageLimits?: CodexAdapterV2.CodexAdapterV2Options["onUsageLimits"], promptSuggestions: Effect.Effect = Effect.succeed(false), onNotification: (method: string) => Effect.Effect = () => Effect.void, + models?: CodexAdapterV2.CodexAdapterV2Options["models"], ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1364,6 +1394,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { environment: {}, clientFactory, ...(onUsageLimits ? { onUsageLimits } : {}), + ...(models ? { models } : {}), digest: yield* Digest.Digest, fileSystem, idAllocator, @@ -2705,12 +2736,18 @@ describe("CodexAdapterV2 post-settle continuation", () => { Effect.gen(function* () { const nativeThreadId = "context-thread"; const nativeTurnId = "context-turn"; + // An explicit Standard must reach Codex, or Codex falls back to Daybreak. + const modelSelection = { + ...CODEX_TEST_MODEL_SELECTION, + options: [{ id: "cyberAccessProgram", value: "standard" }], + }; const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ nativeThreadId, codexInput: [{ type: "text", text: "work" }], runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - modelSelection: CODEX_TEST_MODEL_SELECTION, + modelSelection, hasT3Mcp: true, + cyberAccess: { defaultProgram: "daybreakBlue" }, }); assert.include( params.additionalContext?.t3_code_orchestration_01?.value ?? "", @@ -2790,6 +2827,27 @@ describe("CodexAdapterV2 post-settle continuation", () => { if (method === "item/completed" || method === "turn/completed") wireOrder.push(method); }), + Effect.succeed>([ + { + slug: modelSelection.model, + name: modelSelection.model, + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { id: "standard", label: "Standard" }, + { id: "daybreakBlue", label: "Daybreak Blue", isDefault: true }, + ], + currentValue: "daybreakBlue", + }, + ], + }, + }, + ]), ); yield* (yield* McpProviderSessions.McpProviderSessions).set({ environmentId: EnvironmentId.make("test"), @@ -2800,15 +2858,16 @@ describe("CodexAdapterV2 post-settle continuation", () => { authorizationHeader: "Bearer test", browserToolsAvailable: true, }); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, attemptId: RunAttemptId.make("context-attempt"), text: "work", }), - ); + modelSelection, + }); yield* harness.firstTerminal; yield* Deferred.await(restoreRequested); assert.equal(harness.terminalEvents()[0]?.status, "completed"); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 4e8fd1ac5f30..e9b41ac8a5b0 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -38,6 +38,7 @@ import { ProviderDriverKind, ProviderSetupError, type ServerProviderCompatibilityAdvisory, + type ServerProviderModel, isDelegatedAppThread, } from "@t3tools/contracts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; @@ -712,19 +713,8 @@ const decodeTurnReasoningEffort = Schema.decodeUnknownEffect( Schema.Union([CodexSchema.V2TurnStartParams__ReasoningEffort, Schema.Null]), ); -const CodexTurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pipe( - Schema.fieldsAssign({ - collaborationMode: Schema.optionalKey(CodexSchema.ClientRequest__CollaborationMode), - additionalContext: Schema.optionalKey( - Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), - ), - }), -); -type CodexTurnStartParamsWithCollaborationMode = - typeof CodexTurnStartParamsWithCollaborationMode.Type; -const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( - CodexTurnStartParamsWithCollaborationMode, -); +type CodexTurnStartParams = typeof CodexClient.V2TurnStartParams.Type; +const decodeCodexTurnStartParams = Schema.decodeUnknownEffect(CodexClient.V2TurnStartParams); const isProviderAdapterRuntimeRequestResponseError = Schema.is( ProviderAdapter.ProviderAdapterRuntimeRequestResponseError, ); @@ -770,6 +760,18 @@ function codexRuntimeModeTurnDefaults(runtimeMode: RuntimeMode): { } } +const isCodexCyberAccessProgram = Schema.is(CodexSchema.V2TurnStartParams__CyberAccessProgram); + +function codexCyberAccess( + models: ReadonlyArray, + model: string, +): { readonly defaultProgram: string | undefined } | undefined { + const descriptor = models + .find((candidate) => candidate.slug === model) + ?.capabilities?.optionDescriptors?.find((candidate) => candidate.id === "cyberAccessProgram"); + return descriptor?.type === "select" ? { defaultProgram: descriptor.currentValue } : undefined; +} + export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTurnStartParams")( function* (input: { readonly nativeThreadId: string; @@ -782,6 +784,8 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur /** ChatGPT token sharing does not accept service tiers. */ readonly omitServiceTier?: boolean; readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"]; + /** Set only for a model that offers Cyber Access; custom models never do. */ + readonly cyberAccess?: { readonly defaultProgram: string | undefined } | undefined; /** The user's `model_reasoning_summary` from the Codex config, when set. */ readonly configuredReasoningSummary?: CodexSchema.V2TurnStartParams__ReasoningSummary; }) { @@ -803,6 +807,14 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.omitServiceTier === true ? undefined : getCodexServiceTierOptionValue(input.modelSelection); + // A chosen program goes as is, even one the model lacks, so Codex rejects it visibly. + const cyberAccessProgram = + input.cyberAccess === undefined + ? undefined + : [ + getModelSelectionStringOptionValue(input.modelSelection, "cyberAccessProgram"), + input.cyberAccess.defaultProgram, + ].find(isCodexCyberAccessProgram); // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ @@ -824,7 +836,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur t3Context === undefined && Object.keys(appContext).length === 0 ? undefined : { ...t3Context, ...appContext }; - return yield* decodeCodexTurnStartParamsWithCollaborationMode({ + return yield* decodeCodexTurnStartParams({ threadId: input.nativeThreadId, input: input.codexInput, ...(additionalContext ? { additionalContext } : {}), @@ -839,6 +851,8 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), ...(effort === undefined ? {} : { effort }), ...(serviceTier === undefined ? {} : { serviceTier }), + // Codex keeps no per-thread choice for this, so every turn must carry it. + ...(cyberAccessProgram === undefined ? {} : { cyberAccessProgram }), // Codex supplies its own mode instructions; send both modes to clear Plan on exit. collaborationMode: { mode: input.runtimePolicy.interactionMode, @@ -1683,7 +1697,10 @@ export type CodexAdapterV2DriverEnv = export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapterV2")(function* ( { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput, - hooks: Pick = {}, + hooks: Pick< + CodexAdapterV2Options, + "onUsageLimits" | "resolveRuntime" | "compatibility" | "models" + > = {}, ) { const clientFactory = yield* CodexAppServerClientFactory; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; @@ -1733,6 +1750,8 @@ export interface CodexAdapterV2Options { readonly environment: NodeJS.ProcessEnv; readonly clientFactory: CodexAppServerClientFactoryShape; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; + /** The provider's published models, read per turn for catalog defaults. */ + readonly models?: Effect.Effect>; readonly resolveRuntime?: Effect.Effect; readonly compatibility?: ( version: string, @@ -1792,10 +1811,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( environment: resolvedRuntime?.environment ?? adapterOptions.environment, }); const additionalContextByThread = yield* Ref.make( - new Map< - string, - NonNullable - >(), + new Map>(), ); const threadsAwaitingContextRestore = yield* Ref.make(new Set()); // Codex drops client developer messages during compaction but only resends additionalContext @@ -7240,6 +7256,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ) { const threadId = yield* getNativeThreadId(turnInput.providerThread); const mcpSession = yield* mcpSessions.read(turnInput.threadId); + const models = adapterOptions.models === undefined ? [] : yield* adapterOptions.models; const configuredReasoningSummary = yield* client .request("config/read", { cwd: turnInput.runtimePolicy.cwd, includeLayers: false }) .pipe( @@ -7249,6 +7266,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), ); const turnStartParams = yield* buildCodexTurnStartParams({ + cyberAccess: codexCyberAccess(models, turnInput.modelSelection.model), nativeThreadId: threadId, codexInput, runtimePolicy: turnInput.runtimePolicy, diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 8cad52282978..4ce583944357 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -6,6 +6,7 @@ import { CommandId, EventId, MessageId, + type ModelSelection, NodeId, type OrchestrationV2Run, ProjectId, @@ -744,7 +745,10 @@ const parentScope: McpInvocationContext.McpInvocationScope = { }; /** The laptop's parent thread, mid-turn when its agent delegates. */ -const seedParent = (laptopEnv: Effect.Success>) => +const seedParent = ( + laptopEnv: Effect.Success>, + parentModelSelection: ModelSelection = modelSelection, +) => Effect.gen(function* () { const now = yield* DateTime.now; const providerThreadId = ProviderThreadId.make("provider-thread:laptop-parent"); @@ -756,7 +760,7 @@ const seedParent = (laptopEnv: Effect.Success>) => threadId: parentThreadId, projectId: ProjectId.make("project:laptop"), title: "Laptop parent", - modelSelection, + modelSelection: parentModelSelection, runtimeMode: "full-access", interactionMode: "default", branch: null, @@ -830,7 +834,7 @@ const seedParent = (laptopEnv: Effect.Success>) => threadId: parentThreadId, ordinal: 1, providerInstanceId: instanceId, - modelSelection, + modelSelection: parentModelSelection, providerThreadId, userMessageId: MessageId.make("message:seed-user:laptop-parent"), rootNodeId: parentRootNode, @@ -1191,16 +1195,24 @@ it.effect("a task named only by environment inherits the parent's model, within const a = yield* makeLaptop(database, offers); // The parent runs with full access; the link allows less. yield* linkTo(a.links, b, "auto-accept-edits"); - yield* seedParent(a); + // An explicit Standard must survive, or a Daybreak default there would replace it. + const parentModelSelection = { + ...modelSelection, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "cyberAccessProgram", value: "standard" }, + ], + }; + yield* seedParent(a, parentModelSelection); const result = yield* a.call("delegate_task", { task: "Run the suite on the box.", target: { environmentId: box.environmentId }, }); expect(result.isError, JSON.stringify(result.content)).toBe(false); - // The parent's model, and the link's modes rather than the parent's. + // The parent's model and options, and the link's modes rather than the parent's. expect(yield* Ref.get(thread.launchedWith)).toEqual({ - modelSelection, + modelSelection: parentModelSelection, runtimeMode: "auto-accept-edits", interactionMode: "default", }); diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index c9a992bcf516..7eadf550eab2 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -16,6 +16,7 @@ it("maps current Codex model capability fields", () => { id: "gpt-test", isDefault: true, model: "gpt-test", + availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, defaultServiceTier: "flex", serviceTiers: [ { @@ -65,6 +66,16 @@ it("maps current Codex model capability fields", () => { ], currentValue: "flex", }, + { + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { id: "standard", label: "Standard", isDefault: true }, + { id: "daybreakBlue", label: "Daybreak Blue" }, + ], + currentValue: "standard", + }, ]); }); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 3fde32add219..197eed97ff82 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -82,6 +82,18 @@ const CodexRoutingConfig = Schema.Struct({ }); const decodeCodexRoutingConfig = Schema.decodeUnknownOption(CodexRoutingConfig); +const CodexCyberAccessConfig = Schema.Struct({ + daybreak: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), + features: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + api_key_cyber_access_programs: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), + }), + ), + ), +}); +const decodeCodexCyberAccessConfig = Schema.decodeUnknownOption(CodexCyberAccessConfig); + function routesToNativeOpenai(config: unknown): boolean { const decoded = decodeCodexRoutingConfig(config); if (Option.isNone(decoded)) return false; @@ -133,6 +145,9 @@ export interface CodexAppServerProviderSnapshot { readonly version: string | undefined; readonly models: ReadonlyArray | undefined; readonly serviceTier?: string | undefined; + /** Undefined when `config/read` failed, so T3 cannot know Codex's default program. */ + readonly daybreakEnabled?: boolean | undefined; + readonly cyberAccessProgramsUnavailable?: boolean | undefined; readonly skills: ReadonlyArray; } @@ -153,6 +168,12 @@ const SERVICE_TIER_DESCRIPTIONS: Readonly> = { ultrafast: "Even faster, more expensive", }; +const CYBER_ACCESS_PROGRAM_LABELS: Readonly> = { + standard: "Standard", + daybreakBlue: "Daybreak Blue", + daybreakRed: "Daybreak Red", +}; + function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; } @@ -281,6 +302,25 @@ export function mapCodexModelCapabilities( currentValue: defaultServiceTier, }); } + const cyberAccessPrograms = model.availableAccessPrograms?.cyber ?? []; + if (cyberAccessPrograms.length > 0) { + const hasStandard = cyberAccessPrograms.includes("standard"); + const defaultProgram = hasStandard ? "standard" : cyberAccessPrograms[0]; + optionDescriptors.push({ + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + // Standard stays selectable as an opt-out; Codex rejects it visibly when the login cannot use it. + options: (hasStandard ? cyberAccessPrograms : ["standard", ...cyberAccessPrograms]).map( + (program) => ({ + id: program, + label: CYBER_ACCESS_PROGRAM_LABELS[program] ?? program, + ...(program === defaultProgram ? { isDefault: true } : {}), + }), + ), + ...(defaultProgram === undefined ? {} : { currentValue: defaultProgram }), + }); + } return createModelCapabilities({ optionDescriptors, @@ -320,37 +360,95 @@ export function applyPreferredCodexDefaultModel( }); } -function applyConfiguredCodexServiceTier( +function mapCodexOptionDescriptors( models: ReadonlyArray, - serviceTier: string | undefined, + update: ( + descriptors: ReadonlyArray, + model: ServerProviderModel, + ) => ReadonlyArray, ): ReadonlyArray { - if (!serviceTier) return models; return models.map((model) => { if (!model.capabilities?.optionDescriptors) return model; return { ...model, capabilities: { ...model.capabilities, - optionDescriptors: model.capabilities.optionDescriptors.map((descriptor) => { - if (descriptor.id !== "serviceTier" || descriptor.type !== "select") return descriptor; - const value = descriptor.options.find( - (option) => - option.id === serviceTier || (serviceTier === "fast" && option.id === "priority"), - )?.id; - if (!value) return descriptor; - return { - ...descriptor, - currentValue: value, - options: descriptor.options.map(({ isDefault: _isDefault, ...option }) => - option.id === value ? { ...option, isDefault: true } : option, - ), - }; - }), + optionDescriptors: update(model.capabilities.optionDescriptors, model), }, }; }); } +function withConfiguredSelectDefault( + models: ReadonlyArray, + descriptorId: string, + pick: (options: ReadonlyArray<{ readonly id: string }>) => string | undefined, +): ReadonlyArray { + return mapCodexOptionDescriptors(models, (descriptors) => + descriptors.map((descriptor) => { + if (descriptor.id !== descriptorId || descriptor.type !== "select") return descriptor; + const value = pick(descriptor.options); + if (!value) return descriptor; + return { + ...descriptor, + currentValue: value, + options: descriptor.options.map(({ isDefault: _isDefault, ...option }) => + option.id === value ? { ...option, isDefault: true } : option, + ), + }; + }), + ); +} + +function applyConfiguredCodexServiceTier( + models: ReadonlyArray, + serviceTier: string | undefined, +): ReadonlyArray { + if (!serviceTier) return models; + return withConfiguredSelectDefault( + models, + "serviceTier", + (options) => + options.find( + (option) => + option.id === serviceTier || (serviceTier === "fast" && option.id === "priority"), + )?.id, + ); +} + +/** Mirrors Codex: `daybreak = true` prefers a Daybreak program, Blue before Red. */ +function applyConfiguredCodexCyberAccess( + models: ReadonlyArray, + snapshot: Pick< + CodexAppServerProviderSnapshot, + "daybreakEnabled" | "cyberAccessProgramsUnavailable" + >, +): ReadonlyArray { + // Without a descriptor no turn sends a program; without a default only an explicit choice goes. + const offered = mapCodexOptionDescriptors(models, (descriptors, model) => + descriptors.flatMap((descriptor): ReadonlyArray => { + if (descriptor.id !== "cyberAccessProgram" || descriptor.type !== "select") { + return [descriptor]; + } + if (model.isCustom || snapshot.cyberAccessProgramsUnavailable === true) return []; + if (snapshot.daybreakEnabled !== undefined) return [descriptor]; + const { currentValue: _currentValue, ...withoutDefault } = descriptor; + return [ + { + ...withoutDefault, + options: descriptor.options.map(({ isDefault: _isDefault, ...option }) => option), + }, + ]; + }), + ); + if (snapshot.daybreakEnabled !== true) return offered; + return withConfiguredSelectDefault(offered, "cyberAccessProgram", (options) => + ["daybreakBlue", "daybreakRed"].find((program) => + options.some((option) => option.id === program), + ), + ); +} + /** * Codex has no static default capability set, so a bare custom slug borrows * the first built-in's descriptors; an entry with its own capabilities keeps @@ -619,7 +717,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // Optional discovery must finish before the outer timeout can discard the account result. const deadlineMs = spawnedAtMs + CODEX_AUTH_PROBE_TIMEOUT_MS - CODEX_DISCOVERY_MARGIN_MS; - const [skillsResponse, models, { serviceTier, nativeUsage, rateLimits }] = yield* Effect.all( + const [skillsResponse, models, { config, nativeUsage, rateLimits }] = yield* Effect.all( [ withCodexDiscoveryDeadline(client.request("skills/list", { cwds: [input.cwd] }), deadlineMs), accountResponse.account?.type === "chatgpt" @@ -648,7 +746,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun : Effect.undefined; return readUsage.pipe( Effect.map((rateLimits) => ({ - serviceTier: config?.config.service_tier ?? undefined, + config: config?.config, nativeUsage, rateLimits, })), @@ -659,13 +757,19 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun { concurrency: "unbounded" }, ); + const cyberAccess = Option.getOrUndefined(decodeCodexCyberAccessConfig(config)); return { account: accountResponse, nativeUsage, ...(rateLimits ? { rateLimits } : {}), version, models, - serviceTier, + serviceTier: config?.service_tier ?? undefined, + daybreakEnabled: config === undefined ? undefined : cyberAccess?.daybreak === true, + // An unread config cannot rule out an API key with the programs turned off. + cyberAccessProgramsUnavailable: + accountResponse.account?.type === "apiKey" && + (config === undefined || cyberAccess?.features?.api_key_cyber_access_programs === false), skills: skillsResponse ? parseCodexSkillsListResponse(skillsResponse, input.cwd) : [], } satisfies CodexAppServerProviderSnapshot; }); @@ -942,6 +1046,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu appendCustomCodexModels(snapshot.models ?? [], codexSettings.customModels), applyPreferredCodexDefaultModel, (models) => applyConfiguredCodexServiceTier(models, snapshot.serviceTier), + (models) => applyConfiguredCodexCyberAccess(models, snapshot), ), skills: snapshot.skills, slashCommands: [ diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index c9cbb937c2eb..50fbbc7024b9 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -248,6 +248,9 @@ export const CodexDriver: ProviderDriver< }, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), + models: Effect.suspend(() => snapshot.getSnapshot).pipe( + Effect.map((value) => value.models), + ), compatibility: installation.compatibility, }, ).pipe( diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index ab5e7add556a..f182245f8840 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -242,8 +242,10 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu const resolveRuntime = runtime.auth.controller.withAccess!(runtime.resolve); // Launch settings bind the refresh command to the signed-in profile. The registry // already wraps openSession in withAccess, so resolve without re-entering it. + const models = snapshot.getSnapshot.pipe(Effect.map((value) => value.models)); const orchestrationAdapter = yield* createCodexAdapterV2(input, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), + models, resolveRuntime: runtime.resolve, }).pipe( Effect.mapError( @@ -259,7 +261,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu const nativeGeneration = yield* makeCodexTextGeneration( config, undefined, - snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), + models, resolveRuntime, ); const protect = (operation: string, effect: Effect.Effect) => diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index b5501943ca04..0f6540d439d4 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -44,7 +44,11 @@ import { deepMerge } from "@t3tools/shared/Struct"; import { createModelCapabilities } from "@t3tools/shared/model"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; -import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; +import { + checkCodexProviderStatus, + mapCodexModelCapabilities, + type CodexAppServerProviderSnapshot, +} from "./CodexProvider.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG } from "./ClaudeModelCatalog.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -503,6 +507,84 @@ it.layer( }), ); + it.effect("offers cyber access only on catalog models that advertise it", () => + Effect.gen(function* () { + const catalogModel = (slug: string, cyber?: Array<"standard" | "daybreakBlue">) => ({ + slug, + name: slug, + isCustom: false, + capabilities: mapCodexModelCapabilities({ + additionalSpeedTiers: [], + ...(cyber ? { availableAccessPrograms: { cyber } } : {}), + defaultReasoningEffort: "medium", + description: slug, + displayName: slug, + hidden: false, + id: slug, + isDefault: false, + model: slug, + supportedReasoningEfforts: [], + }), + }); + const defaultPrograms = (snapshot: Partial) => + checkCodexProviderStatus(decodeCodexSettings({ customModels: ["my-model"] }), () => + Effect.succeed( + makeCodexProbeSnapshot({ + models: [ + catalogModel("both", ["standard", "daybreakBlue"]), + catalogModel("daybreak-only", ["daybreakBlue"]), + catalogModel("plain"), + ], + ...snapshot, + }), + ), + ).pipe( + Effect.map((status) => + Object.fromEntries( + status.models.map((model) => { + const descriptor = model.capabilities?.optionDescriptors?.find( + (option) => option.id === "cyberAccessProgram", + ); + return [ + model.slug, + descriptor?.type === "select" + ? (descriptor.currentValue ?? "no default") + : null, + ]; + }), + ), + ), + ); + + assert.deepStrictEqual(yield* defaultPrograms({ daybreakEnabled: false }), { + both: "standard", + "daybreak-only": "daybreakBlue", + plain: null, + "my-model": null, + }); + assert.deepStrictEqual(yield* defaultPrograms({ daybreakEnabled: true }), { + both: "daybreakBlue", + "daybreak-only": "daybreakBlue", + plain: null, + "my-model": null, + }); + // An unread config keeps the choice but cannot know Codex's default. + assert.deepStrictEqual(yield* defaultPrograms({}), { + both: "no default", + "daybreak-only": "no default", + plain: null, + "my-model": null, + }); + assert.deepStrictEqual( + yield* defaultPrograms({ + account: { account: { type: "apiKey" }, requiresOpenaiAuth: false }, + cyberAccessProgramsUnavailable: true, + }), + { both: null, "daybreak-only": null, plain: null, "my-model": null }, + ); + }), + ); + it.effect("reads the version of a Codex CLI too old to run app-server (#20351)", () => Effect.gen(function* () { if ((yield* HostProcess.Platform) === "win32") return; diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 3384831aa87b..63f7f9ac95b1 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -100,6 +100,15 @@ in the thread on web, desktop, or mobile. Some tools offer access for one reques the current session, or permanently. See [Permission modes](./permission-modes.md) for command and file approvals. +## Use Daybreak + +Models with cyber access programs show a Cyber Access option in the model +options. It starts on Standard, or on Daybreak when `config.toml` in your Codex +home sets `daybreak = true` or the model only runs under Daybreak. Your choice carries +over to new threads. Custom models do not offer it. If Codex rejects a turn +because your login lacks the program, switch Cyber Access back to Standard or +sign in to Codex with an account that has it. + ## Codex says I hit a usage limit When Codex stops on a usage limit, the thread names the window that ran out and diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index 28f677e03ee0..4bc5873688d2 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -86,12 +86,14 @@ type ServerNotificationHandler = ( payload: unknown, ) => Effect.Effect; -const V2TurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pipe( +/** The generated `turn/start` schema omits these fields; this is what the wire accepts. */ +export const V2TurnStartParams = CodexSchema.V2TurnStartParams.pipe( Schema.fieldsAssign({ collaborationMode: Schema.optionalKey(CodexSchema.ClientRequest__CollaborationMode), additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), ), + cyberAccessProgram: Schema.optionalKey(CodexSchema.V2TurnStartParams__CyberAccessProgram), }), ); @@ -131,7 +133,7 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make | Schema.Codec | undefined => method === "turn/start" - ? (V2TurnStartParamsWithCollaborationMode as never) + ? (V2TurnStartParams as never) : (CodexRpc.CLIENT_REQUEST_PARAMS[method] as never); const getClientRequestResponseSchema = (