Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
36 changes: 36 additions & 0 deletions src/responses-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof adapter.buildRequest>[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" });
});
});
52 changes: 51 additions & 1 deletion src/responses-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Record<string, "max" | "xhigh">> = {
"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`
Expand All @@ -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)),
};
};
Loading