diff --git a/AGENTS.md b/AGENTS.md index ca56dc4..ba69daf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ than reimplementing OAuth or the Responses wire protocol. build-time rewrite script to paper over an extensionless import. - Tests exist only for load-bearing risk: the exact Codex request shape, `accountIdFromIdToken` on hostile input, the bridge-message tag structure, - and the content-type repair's conditions. + the neutral-effort mapping, and the content-type repair's conditions. ## Local development diff --git a/README.md b/README.md index cf71a2a..1e2d2da 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ process.stdout.write("\n"); | `CODEX_DEFAULT_MODELS` | The models the Codex CLI's bundled catalog lists in its picker. | | `CODEX_ACCOUNT_ID_OPTION` | `providerOptions` key sent as the `chatgpt-account-id` header. | | `CODEX_SESSION_ID_OPTION` | `providerOptions` key sent as `prompt_cache_key` and the `session_id` header. | -| `CODEX_REASONING_EFFORT_OPTION` | `providerOptions` key sent as `reasoning.effort`; `"none"` omits it. | +| `CODEX_REASONING_EFFORT_OPTION` | `providerOptions` key sent as `reasoning.effort`; `"none"` omits it. Wins over the neutral `effort`. | | `CODEX_REFRESH_SKEW_MS` | Refresh this long before expiry. | The OAuth constants behind `codexOAuthConfig` (`CODEX_CLIENT_ID`, `CODEX_AUTHORIZE_URL`, `CODEX_TOKEN_URL`, `CODEX_REDIRECT_URI`, `CODEX_SCOPES`, `CODEX_AUTHORIZE_EXTRA_PARAMS`, `CODEX_ORIGINATOR`, `CODEX_TOKEN_TIMEOUT_MS`) are exported too. @@ -161,6 +161,8 @@ export const deps = { Each Codex source points at `CODEX_BASE_URL`, uses the OAuth access token as its credential, and carries `quirks: { productName, environmentTagName }` naming the host. Put the credential's `accountId` metadata in the source's quirks as `accountId`; it is sent as `chatgpt-account-id`, and a per-call `providerOptions[CODEX_ACCOUNT_ID_OPTION]` overrides it. The quirk binds the source to one ChatGPT account, so each account's credential needs its own source, and the quirk is re-set when the credential is signed in to another account. +Interchange's neutral `effort` is sent as `reasoning.effort`: `low`, `medium` and `high` as given, and `max` as the highest level up to `max` that the model lists in the Codex CLI catalog (`max`, or `xhigh` on `gpt-5.5`), or `high` for a model the catalog does not list. + ## License [LGPL-2.1-only](https://github.com/corbitsdev/corbits-codex-provider/blob/main/LICENSE) diff --git a/src/responses-adapter.test.ts b/src/responses-adapter.test.ts index 549a349..eecc70c 100644 --- a/src/responses-adapter.test.ts +++ b/src/responses-adapter.test.ts @@ -129,3 +129,39 @@ describe("Codex responses adapter — request shape", () => { }); }); }); + +describe("Codex responses adapter — neutral effort", () => { + const adapter = createCodexResponsesAdapter(source, quirks); + const effortOf = ( + model: string, + options: Parameters[2], + ) => + parseCodexBody(adapter.buildRequest(turns, model, options).body).reasoning; + + test("max is the model's highest listed level, else high", () => { + expect(effortOf("gpt-6-sol", { effort: "max" })).toEqual({ effort: "max" }); + expect(effortOf("gpt-5.5", { effort: "max" })).toEqual({ effort: "xhigh" }); + expect(effortOf("unlisted", { effort: "max" })).toEqual({ effort: "high" }); + }); + + test("a non-empty explicit effort option wins", () => { + expect( + effortOf("gpt-5.5", { + effort: "high", + providerOptions: { codexReasoningEffort: "low" }, + }), + ).toEqual({ effort: "low" }); + expect( + effortOf("gpt-5.5", { + effort: "high", + providerOptions: { codexReasoningEffort: "none" }, + }), + ).toBeUndefined(); + expect( + effortOf("gpt-5.5", { + effort: "high", + providerOptions: { codexReasoningEffort: "" }, + }), + ).toEqual({ effort: "high" }); + }); +}); diff --git a/src/responses-adapter.ts b/src/responses-adapter.ts index 5d31916..7bbc453 100644 --- a/src/responses-adapter.ts +++ b/src/responses-adapter.ts @@ -4,6 +4,7 @@ import { type ResponsesQuirks, } from "@corbits/openai-responses"; import type { AdapterFactory } from "@intx/inference"; +import type { InferenceEffort, InferenceOptions } from "@intx/types/runtime"; import { CODEX_ACCOUNT_ID_OPTION, CODEX_ORIGINATOR, @@ -55,6 +56,50 @@ function codexResponsesHooks(quirks: CodexQuirks): ResponsesHooks { }; } +// The highest reasoning level each model lists, from the same catalog as +// `CODEX_DEFAULT_MODELS` (`supported_reasoning_levels`). `ultra` sits above +// `max` on some models but is not the neutral `max`. A model missing here, +// including `gpt-6.1`, which that catalog does not list, sends `high`. +const CODEX_MAX_EFFORT: Readonly> = { + "gpt-6.1-sol": "max", + "gpt-6-astra": "max", + "gpt-6-sol": "max", + "gpt-6-luna": "max", + "gpt-5.6-sol": "max", + "gpt-5.6-terra": "max", + "gpt-5.6-luna": "max", + "gpt-5.5": "xhigh", +}; + +function codexEffort( + model: string, + effort: InferenceEffort, +): InferenceEffort | "xhigh" { + return effort === "max" ? (CODEX_MAX_EFFORT[model] ?? "high") : effort; +} + +// Interchange's neutral `effort` becomes the effort option the request +// builder reads, unless the caller already set that option to a non-empty +// string, which is the only value the builder sends. +function withNeutralEffort( + model: string, + options: InferenceOptions, +): InferenceOptions { + const explicit = options.providerOptions?.[CODEX_REASONING_EFFORT_OPTION]; + if ( + options.effort === undefined || + (typeof explicit === "string" && explicit.length > 0) + ) + return options; + return { + ...options, + providerOptions: { + ...options.providerOptions, + [CODEX_REASONING_EFFORT_OPTION]: codexEffort(model, options.effort), + }, + }; +} + /** * `AdapterFactory` for Codex's Responses-speaking ChatGPT backend. `quirks` * is this package's own {@link CodexQuirks} (an `InferenceSource.quirks` @@ -67,8 +112,13 @@ function codexResponsesHooks(quirks: CodexQuirks): ResponsesHooks { */ export const createCodexResponsesAdapter: AdapterFactory = (source, quirks) => { const parsed = parseCodexQuirks(quirks); - return responsesAdapterFactory( + const adapter = responsesAdapterFactory( codexResponsesQuirks(parsed), codexResponsesHooks(parsed), )(source); + return { + ...adapter, + buildRequest: (messages, model, options) => + adapter.buildRequest(messages, model, withNeutralEffort(model, options)), + }; };