From 5802c5e193504999c414bbc33213afc84cb852d7 Mon Sep 17 00:00:00 2001 From: Martin Meredith Date: Thu, 29 Oct 2026 14:02:31 +0000 Subject: [PATCH 1/4] feat(codex): choose a cyber access program for Daybreak-eligible models Codex's app server only applies Daybreak when each turn/start carries cyberAccessProgram; its config.toml `daybreak` setting does not reach app-server turns. T3 never sent the field, and the turn/start encode schema dropped it. Models whose catalog entry lists a Daybreak program in availableAccessPrograms.cyber now get a Cyber Access select. Its default, Automatic, omits the field so Codex keeps its own behaviour. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/codexModelOptions.ts | 10 +++ .../Adapters/CodexAdapterV2.test.ts | 89 +++++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 9 +- .../server/src/provider/CodexProvider.test.ts | 41 +++++++++ apps/server/src/provider/CodexProvider.ts | 28 ++++++ .../effect-codex-app-server/src/client.ts | 1 + 6 files changed, 177 insertions(+), 1 deletion(-) diff --git a/apps/server/src/codexModelOptions.ts b/apps/server/src/codexModelOptions.ts index 43b8a3c184f5a..0c7776c964a1d 100644 --- a/apps/server/src/codexModelOptions.ts +++ b/apps/server/src/codexModelOptions.ts @@ -12,3 +12,13 @@ export function getCodexServiceTierOptionValue( (getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true ? "fast" : undefined) ); } + +export const CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID = "automatic"; + +/** Undefined leaves Codex's automatic cyber treatment in place. */ +export function getCodexCyberAccessProgramOptionValue( + modelSelection: ModelSelection | null | undefined, +): string | undefined { + const value = getModelSelectionStringOptionValue(modelSelection, "cyberAccessProgram"); + return value === CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID ? undefined : value; +} diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 2d14fa9518de6..c269fcb105b45 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -222,6 +222,27 @@ describe("CodexAdapterV2 runtime policy", () => { }), ); + it.effect("leaves Codex's automatic cyber access in place unless a program is chosen", () => + Effect.gen(function* () { + const build = (value?: string) => + 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 }] }), + }, + }); + + assert.notProperty(yield* build(), "cyberAccessProgram"); + assert.notProperty(yield* build("automatic"), "cyberAccessProgram"); + assert.equal((yield* build("standard")).cyberAccessProgram, "standard"); + assert.equal((yield* build("daybreakBlue")).cyberAccessProgram, "daybreakBlue"); + }), + ); + 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; @@ -2072,6 +2093,74 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect("sends the selected cyber access program on the wire", () => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "cyber-thread"; + const nativeTurnId = "cyber-turn"; + const entries = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "review" }); + const transcript = makeCodexReplayTranscript({ + scenario: "cyber-access-program", + entries: [ + ...entries.slice(0, 5), + { + type: "expect_outbound", + label: "turn/start", + frame: { + id: 3, + method: "turn/start", + params: { + threadId: nativeThreadId, + input: [{ type: "text", text: "review" }], + cwd: "/workspace", + model: "gpt-5.4", + approvalPolicy: "never", + approvalsReviewer: "user", + sandboxPolicy: { type: "dangerFullAccess" }, + summary: "detailed", + cyberAccessProgram: "daybreakBlue", + collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, + }, + }, + }, + ...entries.slice(6), + { + type: "emit_inbound", + label: "done", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("cyber-attempt"), + text: "review", + }), + modelSelection: { + ...CODEX_TEST_MODEL_SELECTION, + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, + }); + yield* harness.firstTerminal; + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("preserves T3 context on the wire and restores it after compaction", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index f81a3809b35fe..d2c13e37e7c7f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -102,7 +102,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { resolveAttachmentPath, resolveAttachmentPathById } from "../../attachmentStore.ts"; import * as PortScanner from "../../preview/PortScanner.ts"; -import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; +import { + getCodexCyberAccessProgramOptionValue, + getCodexServiceTierOptionValue, +} from "../../codexModelOptions.ts"; import { ServerConfig } from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { buildCodexAdditionalContext } from "../../provider/CodexDeveloperInstructions.ts"; @@ -685,6 +688,7 @@ const CodexTurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams. additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), ), + cyberAccessProgram: Schema.optionalKey(CodexSchema.V2ModelListResponse__CyberAccessProgram), }), ); type CodexTurnStartParamsWithCollaborationMode = @@ -768,6 +772,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.omitServiceTier === true ? undefined : getCodexServiceTierOptionValue(input.modelSelection); + const cyberAccessProgram = getCodexCyberAccessProgramOptionValue(input.modelSelection); // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ @@ -803,6 +808,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, diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 30f63b21f318e..fffeb798132c2 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -153,3 +153,44 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); + +it("offers cyber access programs only when the catalog has a Daybreak program", () => { + const model = { + additionalSpeedTiers: [], + defaultReasoningEffort: "medium", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + supportedReasoningEfforts: [], + }; + + assert.deepStrictEqual( + mapCodexModelCapabilities({ + ...model, + availableAccessPrograms: { cyber: ["standard"] }, + }).optionDescriptors, + [], + ); + assert.deepStrictEqual( + mapCodexModelCapabilities({ + ...model, + availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, + }).optionDescriptors, + [ + { + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { id: "automatic", label: "Automatic", isDefault: true }, + { id: "standard", label: "Standard" }, + { id: "daybreakBlue", label: "Daybreak Blue" }, + ], + currentValue: "automatic", + }, + ], + ); +}); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index ed9e3d0cde684..162fc2a5ba1b0 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -52,6 +52,7 @@ import { type CodexRateLimitSnapshot, type CodexResetCreditsSummary, } from "./codexUsageLimits.ts"; +import { CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID } from "../codexModelOptions.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import packageJson from "../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); @@ -113,6 +114,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; } @@ -241,6 +248,27 @@ export function mapCodexModelCapabilities( currentValue: defaultServiceTier, }); } + const cyberAccessPrograms = model.availableAccessPrograms?.cyber ?? []; + // Only Daybreak programmes need a choice; standard-only models keep their usual controls. + if (cyberAccessPrograms.some((program) => program !== "standard")) { + optionDescriptors.push({ + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { + id: CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID, + label: "Automatic", + isDefault: true, + }, + ...cyberAccessPrograms.map((program) => ({ + id: program, + label: CYBER_ACCESS_PROGRAM_LABELS[program] ?? program, + })), + ], + currentValue: CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID, + }); + } return createModelCapabilities({ optionDescriptors, diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index 28f677e03ee0a..c0644ea4e9662 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -92,6 +92,7 @@ const V2TurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pip additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), ), + cyberAccessProgram: Schema.optionalKey(CodexSchema.V2ModelListResponse__CyberAccessProgram), }), ); From ed453461b1ee245d49795484d9e8c2d7eb88bd53 Mon Sep 17 00:00:00 2001 From: Martin Meredith Date: Thu, 29 Oct 2026 15:33:47 +0000 Subject: [PATCH 2/4] fix(codex): custom models do not inherit Daybreak access A bare custom Codex slug borrows the first built-in model's options, so it could show Cyber Access even though Daybreak eligibility is per model. Strip that option from borrowed capabilities, type the turn/start field with the turn/start schema, and document the setting. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.ts | 2 +- .../server/src/provider/CodexProvider.test.ts | 32 ++++++++++++++++++- apps/server/src/provider/CodexProvider.ts | 12 +++++-- docs/user/providers-codex.md | 8 +++++ .../effect-codex-app-server/src/client.ts | 2 +- 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index d2c13e37e7c7f..4c74aad68002c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -688,7 +688,7 @@ const CodexTurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams. additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), ), - cyberAccessProgram: Schema.optionalKey(CodexSchema.V2ModelListResponse__CyberAccessProgram), + cyberAccessProgram: Schema.optionalKey(CodexSchema.V2TurnStartParams__CyberAccessProgram), }), ); type CodexTurnStartParamsWithCollaborationMode = diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index fffeb798132c2..2a41b437740e8 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -1,6 +1,10 @@ import { assert, it } from "@effect/vitest"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + appendCustomCodexModels, + applyPreferredCodexDefaultModel, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -194,3 +198,29 @@ it("offers cyber access programs only when the catalog has a Daybreak program", ], ); }); + +it("keeps Daybreak access off custom models that borrow built-in options", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: [], + availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, + defaultReasoningEffort: "medium", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Medium" }], + }); + const models = appendCustomCodexModels( + [{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities }], + [{ slug: "my-model" }], + ); + + const optionIds = (slug: string) => + models + .find((model) => model.slug === slug) + ?.capabilities?.optionDescriptors?.map((descriptor) => descriptor.id); + assert.deepStrictEqual(optionIds("gpt-test"), ["reasoningEffort", "cyberAccessProgram"]); + assert.deepStrictEqual(optionIds("my-model"), ["reasoningEffort"]); +}); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 162fc2a5ba1b0..c57eae720bb6b 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -342,13 +342,19 @@ function applyConfiguredCodexServiceTier( /** * Codex has no static default capability set, so a bare custom slug borrows * the first built-in's descriptors; an entry with its own capabilities keeps - * them. + * them. Daybreak access is per model, so a borrowed slug never inherits it. */ -function appendCustomCodexModels( +export function appendCustomCodexModels( models: ReadonlyArray, customModels: ReadonlyArray, ): ReadonlyArray { - const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; + const borrowed = models.find((model) => model.capabilities)?.capabilities ?? null; + const fallbackCapabilities = borrowed && { + ...borrowed, + optionDescriptors: borrowed.optionDescriptors?.filter( + (descriptor) => descriptor.id !== "cyberAccessProgram", + ), + }; return providerModelsFromSettings(models, customModels, fallbackCapabilities); } diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index bdc87902a63b2..e2c068e3f5c46 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -92,6 +92,14 @@ 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 + +If your ChatGPT account has Daybreak access, models that offer it show a Cyber +Access choice in the model options next to the composer. Automatic, the +default, leaves the choice to Codex. Pick Daybreak Blue or another programme to +send it with every turn in that thread. Models without Daybreak, including +custom models, do not show the choice. + ## 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 c0644ea4e9662..4899c055f6f6a 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -92,7 +92,7 @@ const V2TurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pip additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), ), - cyberAccessProgram: Schema.optionalKey(CodexSchema.V2ModelListResponse__CyberAccessProgram), + cyberAccessProgram: Schema.optionalKey(CodexSchema.V2TurnStartParams__CyberAccessProgram), }), ); From 323448184470b2e33d0560191f482bbd2cfaaba7 Mon Sep 17 00:00:00 2001 From: Martin Meredith Date: Sat, 31 Oct 2026 01:33:49 +0000 Subject: [PATCH 3/4] docs(codex): explain Daybreak login errors --- docs/user/providers-codex.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index e2c068e3f5c46..0c1a203b0aa50 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -100,6 +100,10 @@ default, leaves the choice to Codex. Pick Daybreak Blue or another programme to send it with every turn in that thread. Models without Daybreak, including custom models, do not show the choice. +If Codex reports that you are not logged in with a Daybreak key, sign in to +Codex again using the appropriate Daybreak credentials for your account, then +retry the turn. Use the same Codex home as the provider instance in T3 Code. + ## Codex says I hit a usage limit When Codex stops on a usage limit, the thread names the window that ran out and From 7cd0e5736ba6f09858b314aff63b3129b476ba44 Mon Sep 17 00:00:00 2001 From: Martin Meredith Date: Sat, 31 Oct 2026 22:45:04 +0000 Subject: [PATCH 4/4] fix(codex): default cyber access to Standard, matching Codex Omitting cyberAccessProgram leaves the choice to OpenAI's server, while Codex's own clients send Standard unless Daybreak is on. Models in the catalog now default to Standard, or to Daybreak when Codex's daybreak setting is on, and T3 sends the program on every turn. Turns with no selection use the model's default from the provider snapshot. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/codexModelOptions.ts | 6 +- .../Adapters/CodexAdapterV2.test.ts | 13 ++- .../Adapters/CodexAdapterV2.ts | 19 +++- .../server/src/provider/CodexProvider.test.ts | 105 +++++++++++++----- apps/server/src/provider/CodexProvider.ts | 96 +++++++++++++--- .../src/provider/Drivers/CodexDriver.ts | 5 + .../provider/Drivers/CodexManagedProvider.ts | 5 + docs/user/providers-codex.md | 11 +- 8 files changed, 200 insertions(+), 60 deletions(-) diff --git a/apps/server/src/codexModelOptions.ts b/apps/server/src/codexModelOptions.ts index 0c7776c964a1d..54ba4b100e15b 100644 --- a/apps/server/src/codexModelOptions.ts +++ b/apps/server/src/codexModelOptions.ts @@ -13,12 +13,8 @@ export function getCodexServiceTierOptionValue( ); } -export const CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID = "automatic"; - -/** Undefined leaves Codex's automatic cyber treatment in place. */ export function getCodexCyberAccessProgramOptionValue( modelSelection: ModelSelection | null | undefined, ): string | undefined { - const value = getModelSelectionStringOptionValue(modelSelection, "cyberAccessProgram"); - return value === CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID ? undefined : value; + return getModelSelectionStringOptionValue(modelSelection, "cyberAccessProgram"); } diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index c269fcb105b45..da47b7b53f1a5 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -222,9 +222,9 @@ describe("CodexAdapterV2 runtime policy", () => { }), ); - it.effect("leaves Codex's automatic cyber access in place unless a program is chosen", () => + it.effect("sends the chosen cyber access program, else the model's default", () => Effect.gen(function* () { - const build = (value?: string) => + const build = (value?: string, defaultCyberAccessProgram?: string) => CodexAdapterV2.buildCodexTurnStartParams({ nativeThreadId: "native-cyber", codexInput: [{ type: "text", text: "test" }], @@ -234,12 +234,15 @@ describe("CodexAdapterV2 runtime policy", () => { model: "gpt-5.4", ...(value === undefined ? {} : { options: [{ id: "cyberAccessProgram", value }] }), }, + defaultCyberAccessProgram, }); + // Custom models have no catalog entry, so Codex keeps its own treatment. assert.notProperty(yield* build(), "cyberAccessProgram"); - assert.notProperty(yield* build("automatic"), "cyberAccessProgram"); - assert.equal((yield* build("standard")).cyberAccessProgram, "standard"); - assert.equal((yield* build("daybreakBlue")).cyberAccessProgram, "daybreakBlue"); + assert.equal((yield* build(undefined, "standard")).cyberAccessProgram, "standard"); + assert.equal((yield* build("automatic", "standard")).cyberAccessProgram, "standard"); + assert.equal((yield* build("daybreakBlue", "standard")).cyberAccessProgram, "daybreakBlue"); + assert.equal((yield* build("standard", "daybreakBlue")).cyberAccessProgram, "standard"); }), ); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 4c74aad68002c..1b2cd95358af5 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -741,6 +741,8 @@ function codexRuntimeModeTurnDefaults(runtimeMode: RuntimeMode): { } } +const isCodexCyberAccessProgram = Schema.is(CodexSchema.V2TurnStartParams__CyberAccessProgram); + export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTurnStartParams")( function* (input: { readonly nativeThreadId: string; @@ -753,6 +755,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur /** ChatGPT token sharing does not accept service tiers. */ readonly omitServiceTier?: boolean; readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"]; + readonly defaultCyberAccessProgram?: string | undefined; }) { const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode); const approvalPolicy = @@ -772,7 +775,10 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.omitServiceTier === true ? undefined : getCodexServiceTierOptionValue(input.modelSelection); - const cyberAccessProgram = getCodexCyberAccessProgramOptionValue(input.modelSelection); + const selectedCyberAccessProgram = getCodexCyberAccessProgramOptionValue(input.modelSelection); + const cyberAccessProgram = [selectedCyberAccessProgram, input.defaultCyberAccessProgram].find( + isCodexCyberAccessProgram, + ); // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ @@ -1590,7 +1596,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" | "defaultCyberAccessProgram" + > = {}, ) { const clientFactory = yield* CodexAppServerClientFactory; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; @@ -1640,6 +1649,7 @@ export interface CodexAdapterV2Options { readonly environment: NodeJS.ProcessEnv; readonly clientFactory: CodexAppServerClientFactoryShape; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; + readonly defaultCyberAccessProgram?: (model: string) => Effect.Effect; readonly resolveRuntime?: Effect.Effect; readonly compatibility?: ( version: string, @@ -6710,7 +6720,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ) { const threadId = yield* getNativeThreadId(turnInput.providerThread); const mcpSession = yield* mcpSessions.read(turnInput.threadId); + const defaultCyberAccessProgram = + adapterOptions.defaultCyberAccessProgram === undefined + ? undefined + : yield* adapterOptions.defaultCyberAccessProgram(turnInput.modelSelection.model); const turnStartParams = yield* buildCodexTurnStartParams({ + defaultCyberAccessProgram, nativeThreadId: threadId, codexInput, runtimePolicy: turnInput.runtimePolicy, diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 2a41b437740e8..9a5383b7c89e4 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -3,6 +3,8 @@ import { assert, it } from "@effect/vitest"; import { appendCustomCodexModels, applyPreferredCodexDefaultModel, + applyConfiguredCodexCyberAccess, + codexDefaultCyberAccessProgram, mapCodexModelCapabilities, } from "./CodexProvider.ts"; @@ -158,7 +160,7 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); -it("offers cyber access programs only when the catalog has a Daybreak program", () => { +it("defaults cyber access to Standard when the catalog offers it", () => { const model = { additionalSpeedTiers: [], defaultReasoningEffort: "medium", @@ -170,33 +172,86 @@ it("offers cyber access programs only when the catalog has a Daybreak program", model: "gpt-test", supportedReasoningEfforts: [], }; - - assert.deepStrictEqual( - mapCodexModelCapabilities({ - ...model, - availableAccessPrograms: { cyber: ["standard"] }, - }).optionDescriptors, - [], - ); - assert.deepStrictEqual( + const cyber = (programs?: ReadonlyArray<"standard" | "daybreakBlue">) => mapCodexModelCapabilities({ ...model, - availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, - }).optionDescriptors, - [ - { - id: "cyberAccessProgram", - label: "Cyber Access", - type: "select", - options: [ - { id: "automatic", label: "Automatic", isDefault: true }, - { id: "standard", label: "Standard" }, - { id: "daybreakBlue", label: "Daybreak Blue" }, - ], - currentValue: "automatic", - }, + ...(programs ? { availableAccessPrograms: { cyber: [...programs] } } : {}), + }).optionDescriptors?.[0]; + + assert.strictEqual(cyber(), undefined); + assert.deepStrictEqual(cyber(["standard", "daybreakBlue"]), { + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { id: "standard", label: "Standard", isDefault: true }, + { id: "daybreakBlue", label: "Daybreak Blue" }, ], - ); + currentValue: "standard", + }); + assert.deepInclude(cyber(["standard"]), { currentValue: "standard" }); + assert.deepInclude(cyber(["daybreakBlue"]), { currentValue: "daybreakBlue" }); +}); + +it("finds the default cyber access program for a model", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: [], + availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, + defaultReasoningEffort: "medium", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + supportedReasoningEfforts: [], + }); + const models = [{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities }]; + assert.strictEqual(codexDefaultCyberAccessProgram(models, "gpt-test"), "standard"); + assert.strictEqual(codexDefaultCyberAccessProgram(models, "my-model"), undefined); +}); + +it("follows Codex's daybreak setting for the default program", () => { + const capabilities = (cyber: ReadonlyArray<"standard" | "daybreakBlue">) => + mapCodexModelCapabilities({ + additionalSpeedTiers: [], + availableAccessPrograms: { cyber: [...cyber] }, + defaultReasoningEffort: "medium", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + supportedReasoningEfforts: [], + }); + const models = [ + { + slug: "both", + name: "Both", + isCustom: false, + capabilities: capabilities(["standard", "daybreakBlue"]), + }, + { + slug: "standard", + name: "Standard", + isCustom: false, + capabilities: capabilities(["standard"]), + }, + ]; + + const on = applyConfiguredCodexCyberAccess(models, { + daybreakEnabled: true, + apiKeyProgramsDisabled: false, + }); + assert.strictEqual(codexDefaultCyberAccessProgram(on, "both"), "daybreakBlue"); + assert.strictEqual(codexDefaultCyberAccessProgram(on, "standard"), "standard"); + + const disabled = applyConfiguredCodexCyberAccess(models, { + daybreakEnabled: false, + apiKeyProgramsDisabled: true, + }); + assert.strictEqual(codexDefaultCyberAccessProgram(disabled, "both"), undefined); }); it("keeps Daybreak access off custom models that borrow built-in options", () => { diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index c57eae720bb6b..062f0313218d6 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -52,7 +52,6 @@ import { type CodexRateLimitSnapshot, type CodexResetCreditsSummary, } from "./codexUsageLimits.ts"; -import { CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID } from "../codexModelOptions.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import packageJson from "../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); @@ -94,6 +93,8 @@ export interface CodexAppServerProviderSnapshot { readonly version: string | undefined; readonly models: ReadonlyArray | undefined; readonly serviceTier?: string | undefined; + readonly daybreakEnabled?: boolean | undefined; + readonly apiKeyCyberAccessProgramsDisabled?: boolean | undefined; readonly skills: ReadonlyArray; } @@ -249,24 +250,21 @@ export function mapCodexModelCapabilities( }); } const cyberAccessPrograms = model.availableAccessPrograms?.cyber ?? []; - // Only Daybreak programmes need a choice; standard-only models keep their usual controls. - if (cyberAccessPrograms.some((program) => program !== "standard")) { + if (cyberAccessPrograms.length > 0) { + // Codex's own clients request Standard unless Daybreak is on; omitting the field does not. + const defaultProgram = cyberAccessPrograms.includes("standard") + ? "standard" + : cyberAccessPrograms[0]; optionDescriptors.push({ id: "cyberAccessProgram", label: "Cyber Access", type: "select", - options: [ - { - id: CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID, - label: "Automatic", - isDefault: true, - }, - ...cyberAccessPrograms.map((program) => ({ - id: program, - label: CYBER_ACCESS_PROGRAM_LABELS[program] ?? program, - })), - ], - currentValue: CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID, + options: cyberAccessPrograms.map((program) => ({ + id: program, + label: CYBER_ACCESS_PROGRAM_LABELS[program] ?? program, + ...(program === defaultProgram ? { isDefault: true } : {}), + })), + ...(defaultProgram === undefined ? {} : { currentValue: defaultProgram }), }); } @@ -308,6 +306,58 @@ export function applyPreferredCodexDefaultModel( }); } +/** Mirrors Codex: `daybreak = true` prefers a Daybreak program, Blue before Red. */ +export function applyConfiguredCodexCyberAccess( + models: ReadonlyArray, + input: { + readonly daybreakEnabled: boolean; + readonly apiKeyProgramsDisabled: boolean; + }, +): ReadonlyArray { + if (!input.daybreakEnabled && !input.apiKeyProgramsDisabled) return models; + return models.map((model) => { + const descriptors = model.capabilities?.optionDescriptors; + if (!model.capabilities || !descriptors) return model; + return { + ...model, + capabilities: { + ...model.capabilities, + // Codex rejects explicit programs in this session, so none can be offered or sent. + optionDescriptors: (input.apiKeyProgramsDisabled + ? descriptors.filter((descriptor) => descriptor.id !== "cyberAccessProgram") + : descriptors + ).map((descriptor) => { + if (descriptor.id !== "cyberAccessProgram" || descriptor.type !== "select") { + return descriptor; + } + const value = ["daybreakBlue", "daybreakRed"].find((program) => + descriptor.options.some((option) => option.id === program), + ); + if (!value) return descriptor; + return { + ...descriptor, + currentValue: value, + options: descriptor.options.map(({ isDefault: _isDefault, ...option }) => + option.id === value ? { ...option, isDefault: true } : option, + ), + }; + }), + }, + }; + }); +} + +/** Applies when a selection names no program, such as older threads and agent-started turns. */ +export function codexDefaultCyberAccessProgram( + models: ReadonlyArray, + slug: string, +): string | undefined { + const descriptor = models + .find((model) => model.slug === slug) + ?.capabilities?.optionDescriptors?.find(({ id }) => id === "cyberAccessProgram"); + return descriptor?.type === "select" ? descriptor.currentValue : undefined; +} + function applyConfiguredCodexServiceTier( models: ReadonlyArray, serviceTier: string | undefined, @@ -585,7 +635,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, rateLimits] = yield* Effect.all( + const [skillsResponse, models, config, rateLimits] = yield* Effect.all( [ withCodexDiscoveryDeadline(client.request("skills/list", { cwds: [input.cwd] }), deadlineMs), accountResponse.account?.type === "chatgpt" @@ -602,7 +652,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun deadlineMs, CODEX_CONFIG_PROBE_TIMEOUT_MS, ).pipe( - Effect.map((response) => response?.config.service_tier ?? undefined), + Effect.map((response) => response?.config), Effect.catch(() => Effect.logDebug("Codex config read failed.").pipe(Effect.as(undefined))), ), // Usage is an enrichment: a failure or a slow answer degrades to "no @@ -640,7 +690,12 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun ...(rateLimits ? { rateLimits } : {}), version, models, - serviceTier, + serviceTier: config?.service_tier ?? undefined, + daybreakEnabled: config?.["daybreak"] === true, + apiKeyCyberAccessProgramsDisabled: + accountResponse.account?.type === "apiKey" && + (config?.["features"] as { readonly api_key_cyber_access_programs?: unknown } | undefined) + ?.api_key_cyber_access_programs === false, skills: skillsResponse ? parseCodexSkillsListResponse(skillsResponse, input.cwd) : [], } satisfies CodexAppServerProviderSnapshot; }); @@ -901,6 +956,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu appendCustomCodexModels(snapshot.models ?? [], codexSettings.customModels), applyPreferredCodexDefaultModel, (models) => applyConfiguredCodexServiceTier(models, snapshot.serviceTier), + (models) => + applyConfiguredCodexCyberAccess(models, { + daybreakEnabled: snapshot.daybreakEnabled === true, + apiKeyProgramsDisabled: snapshot.apiKeyCyberAccessProgramsDisabled === true, + }), ), skills: snapshot.skills, slashCommands: [ diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 61f26c3f8cc7d..fcf59fd7eb814 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -25,6 +25,7 @@ import { makePendingCodexProvider, probeCodexSkillsForCwd, withCodexAppServerClient, + codexDefaultCyberAccessProgram, } from "../CodexProvider.ts"; import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; @@ -210,6 +211,10 @@ export const CodexDriver: ProviderDriver< }, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), + defaultCyberAccessProgram: (model) => + snapshot.getSnapshot.pipe( + Effect.map((value) => codexDefaultCyberAccessProgram(value.models, model)), + ), compatibility: installation.compatibility, }, ).pipe( diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index f9728f9a24973..6f9332a0da088 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -20,6 +20,7 @@ import { checkCodexProviderStatus, makePendingCodexProvider, probeCodexSkillsForCwd, + codexDefaultCyberAccessProgram, } from "../CodexProvider.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; @@ -244,6 +245,10 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu // already wraps openSession in withAccess, so resolve without re-entering it. const orchestrationAdapter = yield* createCodexAdapterV2(input, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), + defaultCyberAccessProgram: (model) => + snapshot.getSnapshot.pipe( + Effect.map((value) => codexDefaultCyberAccessProgram(value.models, model)), + ), resolveRuntime: runtime.resolve, }).pipe( Effect.mapError( diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 0c1a203b0aa50..1c44c8bc647e5 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -94,11 +94,12 @@ for command and file approvals. ## Use Daybreak -If your ChatGPT account has Daybreak access, models that offer it show a Cyber -Access choice in the model options next to the composer. Automatic, the -default, leaves the choice to Codex. Pick Daybreak Blue or another programme to -send it with every turn in that thread. Models without Daybreak, including -custom models, do not show the choice. +Models that list cyber access programs show a Cyber Access choice in the model +options next to the composer. T3 Code sends it with every turn. As in the Codex +CLI, the default is Standard unless you set `daybreak = true` in Codex's +`config.toml`. Your last choice stays selected for new threads until you change +it, so check it before starting unrelated work. Models that only offer Daybreak +stay on Daybreak. Custom models do not show the choice. If Codex reports that you are not logged in with a Daybreak key, sign in to Codex again using the appropriate Daybreak credentials for your account, then