Skip to content
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/fr/guides/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ d’exportation de la variable d’environnement et le nombre de modèles dotés
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
"apiKey": "$OPENCODEX_API_KEY",
"compat": {
"sendSessionAffinityHeaders": true
},
"models": [
{
"id": "anthropic/claude-opus-5",
Expand All @@ -41,6 +44,8 @@ d’exportation de la variable d’environnement et le nombre de modèles dotés
}
```

Les fournisseurs Pi générés activent `compat.sendSessionAffinityHeaders`. Conservez ce réglage lors de la fusion ou de la modification manuelle du fournisseur : Pi transmet un identifiant de session stable, dont OpenCodex dérive l’affinité pour la destination canonique OpenCode Go. Pi peut omettre cet identifiant lorsque `cacheRetention` vaut `none`.

Les identifiants de modèle sont les sélecteurs canoniques du proxy : les modèles routés apparaissent donc sous la forme `provider/model`
(`anthropic/claude-opus-5`) et les slugs natifs OpenAI restent sans préfixe (`gpt-5.6-sol`). Le `name`
suffixe — `(anthropic)`, `(native)`, `(routed)` — permet de distinguer, dans le sélecteur de Pi, deux modèles de même nom
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export line, and how many models carry authoritative context limits.
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
"apiKey": "$OPENCODEX_API_KEY",
"compat": {
"sendSessionAffinityHeaders": true
},
"models": [
{
"id": "anthropic/claude-opus-5",
Expand All @@ -41,6 +44,8 @@ export line, and how many models carry authoritative context limits.
}
```

Generated Pi providers enable `compat.sendSessionAffinityHeaders`. Keep this flag when merging or manually editing the provider: Pi supplies a stable session identity and OpenCodex derives canonical OpenCode Go affinity from it. Pi may omit the identity when `cacheRetention` is `none`.

Model ids are the proxy's canonical selectors, so routed models appear as `provider/model`
(`anthropic/claude-opus-5`) and native OpenAI slugs stay unprefixed (`gpt-5.6-sol`). The `name`
suffix — `(anthropic)`, `(native)`, `(routed)` — is what makes two same-named models from
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/ja/guides/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ ocx export --client pi
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
"apiKey": "$OPENCODEX_API_KEY",
"compat": {
"sendSessionAffinityHeaders": true
},
"models": [
{
"id": "anthropic/claude-opus-5",
Expand All @@ -37,6 +40,8 @@ ocx export --client pi
}
```

生成される Pi プロバイダーでは `compat.sendSessionAffinityHeaders` が有効です。設定をマージしたり手動で編集したりする際も、このフラグを保持してください。Pi が送る安定したセッション識別子から、OpenCodex が正規の OpenCode Go 接続先用の affinity を生成します。`cacheRetention` が `none` の場合、Pi は識別子を送信しないことがあります。

モデル ID はプロキシの正規セレクターであるため、ルーティングされたモデルは `provider/model` (`anthropic/claude-opus-5`) として表示され、ネイティブ OpenAI スラグはプレフィックスなし (`gpt-5.6-sol`) のままになります。 `name` サフィックス (`(anthropic)`、`(native)`、`(routed)`) により、異なるアップストリームの 2 つの同じ名前のモデルが Pi のピッカーで区別できるようになります。

## どこへ行くのか
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/ko/guides/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ ocx export --client pi
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
"apiKey": "$OPENCODEX_API_KEY",
"compat": {
"sendSessionAffinityHeaders": true
},
"models": [
{
"id": "anthropic/claude-opus-5",
Expand All @@ -41,6 +44,8 @@ ocx export --client pi
}
```

생성된 Pi provider에는 `compat.sendSessionAffinityHeaders`가 활성화됩니다. provider를 병합하거나 직접 수정할 때 이 설정을 유지하세요. Pi가 안정적인 세션 식별자를 보내면 OpenCodex가 이를 바탕으로 정규 OpenCode Go 대상의 affinity를 계산합니다. `cacheRetention`이 `none`이면 Pi가 식별자를 보내지 않을 수 있습니다.

모델 id는 프록시의 정규 선택자이므로, 라우팅된 모델은 `provider/model`
(`anthropic/claude-opus-5`) 형태로 나타나고, 네이티브 OpenAI slug는 접두사 없이
(`gpt-5.6-sol`) 유지됩니다. `name` 접미사인 `(anthropic)`, `(native)`, `(routed)`는
Expand Down
9 changes: 8 additions & 1 deletion docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,9 +402,16 @@ conversation.

| Route type | Behavior |
| --- | --- |
| Canonical ChatGPT or official OpenAI route | Forwards the request to the native `/responses/compact` endpoint with the resolved account and model authentication |
| Canonical ChatGPT or official OpenAI route | Tries the native `/responses/compact` endpoint with the resolved account and model authentication; HTTP 404 falls back to a regular Responses compaction turn |
| Other routed model | Runs an internal, non-streaming, no-tools compaction turn with a `compaction_trigger`; requires exactly one synthetic `compaction` item whose `encrypted_content` is an `ocx1:` envelope; decodes that summary into v1 replacement history |

If the native compact endpoint returns HTTP 404, OpenCodex retries compaction through a regular
Responses turn with the same model selector and session headers. Canonical ChatGPT fallback
turns use upstream SSE; the compact caller still receives JSON. A completed native opaque
compaction item is preserved, while an `ocx1:` summary is decoded into replacement user history.
Failed or incomplete fallback turns return an error instead of replacement history. Other
native compact statuses retain their existing handling.

Codex names a bare OpenAI-family model (for example `gpt-5.6-sol`) for its compaction turns
regardless of which provider the operator routes ordinary turns to. Ordinary requests reserve
such ids for the canonical `openai` provider. On the compaction surface only — `POST
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/ru/guides/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ ocx export --client pi
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
"apiKey": "$OPENCODEX_API_KEY",
"compat": {
"sendSessionAffinityHeaders": true
},
"models": [
{
"id": "anthropic/claude-opus-5",
Expand All @@ -41,6 +44,8 @@ ocx export --client pi
}
```

В создаваемой конфигурации Pi включён `compat.sendSessionAffinityHeaders`. Сохраняйте этот флаг при объединении или ручном редактировании провайдера: Pi передаёт стабильный идентификатор сессии, из которого OpenCodex формирует affinity для канонического OpenCode Go. При `cacheRetention: none` Pi может не передавать идентификатор.

Id моделей — это канонические селекторы прокси, поэтому маршрутизируемые модели появляются как
`provider/model` (`anthropic/claude-opus-5`), а нативные slug OpenAI остаются без префикса
(`gpt-5.6-sol`). Суффикс в `name` — `(anthropic)`, `(native)`, `(routed)` — как раз и позволяет
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/tr/guides/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export line, and how many models carry authoritative context limits.
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
"apiKey": "$OPENCODEX_API_KEY",
"compat": {
"sendSessionAffinityHeaders": true
},
"models": [
{
"id": "anthropic/claude-opus-5",
Expand All @@ -45,6 +48,8 @@ export line, and how many models carry authoritative context limits.
}
```

Oluşturulan Pi sağlayıcılarında `compat.sendSessionAffinityHeaders` etkinleştirilir. Sağlayıcıyı birleştirirken veya elle düzenlerken bu ayarı koruyun: Pi sabit bir oturum kimliği gönderir ve OpenCodex bu kimlikten kanonik OpenCode Go hedefi için oturum yakınlığı üretir. `cacheRetention` değeri `none` olduğunda Pi kimliği göndermeyebilir.

Model ids are the proxy's canonical selectors, so routed models appear as
`provider/model`
(`anthropic/claude-opus-5`) and native OpenAI slugs stay unprefixed
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/zh-cn/guides/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ ocx export --client pi
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
"apiKey": "$OPENCODEX_API_KEY",
"compat": {
"sendSessionAffinityHeaders": true
},
"models": [
{
"id": "anthropic/claude-opus-5",
Expand All @@ -37,6 +40,8 @@ ocx export --client pi
}
```

生成的 Pi 提供方配置启用了 `compat.sendSessionAffinityHeaders`。合并或手动编辑提供方时请保留该设置:Pi 提供稳定的会话标识,OpenCodex 据此为规范的 OpenCode Go 目标生成会话亲和标识。`cacheRetention` 为 `none` 时,Pi 可能不发送会话标识。

模型 id 是代理的规范选择器,因此已路由模型会显示为 `provider/model`(`anthropic/claude-opus-5`),而原生 OpenAI slug 会保持不带前缀(`gpt-5.6-sol`)。`name` 后缀 - `(anthropic)`、`(native)`、`(routed)` - 负责让两个同名但来自不同上游的模型在 Pi 的选择器中可区分。

## 放置位置
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/zh-tw/guides/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ ocx export --client pi
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
"apiKey": "$OPENCODEX_API_KEY",
"compat": {
"sendSessionAffinityHeaders": true
},
"models": [
{
"id": "anthropic/claude-opus-5",
Expand All @@ -37,6 +40,8 @@ ocx export --client pi
}
```

產生的 Pi 供應商設定會啟用 `compat.sendSessionAffinityHeaders`。合併或手動編輯供應商時請保留此設定:Pi 提供穩定的工作階段識別碼,OpenCodex 據此為標準 OpenCode Go 目標產生工作階段親和識別碼。當 `cacheRetention` 為 `none` 時,Pi 可能不傳送識別碼。

模型 id 是代理的規範選擇器,因此路由模型顯示為 `provider/model`(`anthropic/claude-opus-5`),而原生 OpenAI slug 保持無前綴(`gpt-5.6-sol`)。`name` 後綴 — `(anthropic)`、`(native)`、`(routed)` — 正是讓來自不同上游的兩個同名模型在 Pi 的 picker 中可區分的關鍵。

## 放置位置
Expand Down
8 changes: 5 additions & 3 deletions src/clients/config-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,7 @@ export interface PiProviderBlock {
baseUrl: string;
api: string;
apiKey: string;
compat?: { sendSessionAffinityHeaders: boolean };
models: PiModelEntry[];
}

Expand Down Expand Up @@ -816,7 +817,7 @@ export interface GajaeGeneratedConfig {
* model. The rest of this contract (omitting `cost`) is still ours rather than
* a claim about Pi's acceptance.
*/
function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig {
function buildPiClientConfig(ctx: ExportContext, sendSessionAffinityHeaders = false): PiGeneratedConfig {
const models: PiModelEntry[] = [];
for (const model of normalizeExportModels(ctx.models)) {
// Text is the one modality every routed model supports; anything richer must come
Expand Down Expand Up @@ -859,6 +860,7 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig {
baseUrl: ctx.baseUrl,
api: PI_API_DIALECT,
apiKey: LOOPBACK_API_KEY_PLACEHOLDER,
...(sendSessionAffinityHeaders ? { compat: { sendSessionAffinityHeaders: true } } : {}),
models,
},
},
Expand Down Expand Up @@ -1031,7 +1033,7 @@ function buildOpencodeContribution(ctx: ExportContext): ManagedContribution {
}

function buildPiContribution(ctx: ExportContext): ManagedContribution {
const doc = buildPiClientConfig(ctx);
const doc = buildPiClientConfig(ctx, true);
return singleFragment("pi", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
}

Expand Down Expand Up @@ -1127,7 +1129,7 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
destination: env => piConfigPath(env),
apiKeyEnv: "",
exportHint: "Pi reads a non-secret placeholder from models.json; loopback needs no key.",
build: buildPiClientConfig,
build: ctx => buildPiClientConfig(ctx, true),
format: "json",
summarize: summarizePi,
buildContribution: buildPiContribution,
Expand Down
7 changes: 7 additions & 0 deletions src/server/chat-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { estimateTokens } from "../lib/token-estimate";
import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router";
import { evidenceFromBody } from "../routing/request-evidence";
import { resolveWireProtocolOverride } from "./adapter-resolve";
import { resolveOpenCodeGoTransport } from "../providers/opencode-go-transport";
import { normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation";
import type { OcxConfig } from "../types";
import { readJsonRequestBody } from "./request-decompress";
import {
Expand Down Expand Up @@ -136,6 +138,8 @@ async function handleChatCompletionsWithBudget(
let chatNativeRoute: ReturnType<typeof routeModel> | null = null;
try {
const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody));
route.provider = resolveOpenCodeGoTransport(route.provider,
sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session")));
// Settle the wire once so every branch below reads the adapter this model will
// actually use, not the provider-wide default (#404).
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "chat");
Expand Down Expand Up @@ -237,6 +241,9 @@ async function handleChatCompletionsWithBudget(
return chatCompletionsErrorResponse(400, CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, "invalid_request_error");
}
const headers = new Headers({ "content-type": "application/json" });
// Internal bridge metadata; the Go resolver scopes and hashes it before upstream use.
const openCodeSession = req.headers.get("x-opencode-session");
if (openCodeSession) headers.set("x-opencode-session", openCodeSession);
for (const name of FORWARD_HEADERS) {
if (name === "authorization" && !directRoute) continue;
const value = req.headers.get(name);
Expand Down
5 changes: 3 additions & 2 deletions src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1094,7 +1094,8 @@ export async function handleResponsesCompact(
}
}
}
return buffered;
// A native compact 404 falls back to a regular Responses compaction turn.
if (buffered.status !== 404) return buffered;
} finally {
releaseUpstreamHostAdmission(compactHostAdmissionLease);
releaseCodexAuthContextProbeLease(authCtx);
Expand All @@ -1111,7 +1112,7 @@ export async function handleResponsesCompact(
// the completed event back into the v1 compact JSON contract below. Combo-dispatched
// turns also go out as SSE: failover can land on a canonical child that rejects a
// non-streaming turn, and every combo-capable provider already serves streaming traffic.
stream: accountGatedCompactWireModel || route.combo ? true : false,
stream: isCanonicalOpenAiForwardProvider(route.provider) || accountGatedCompactWireModel || route.combo ? true : false,
input: [...inputItems, { type: "compaction_trigger" }],
};
const internalHeaders = new Headers({ "content-type": "application/json" });
Expand Down
3 changes: 2 additions & 1 deletion src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2304,7 +2304,8 @@ async function applyFinalRouteRequestNormalization(args: {

// Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter
// this request will actually use (#404).
route.provider = resolveOpenCodeGoTransport(route.provider, sessionLaneIdFromRequest(req.headers));
route.provider = resolveOpenCodeGoTransport(route.provider,
sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session")));
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId;
logCtx.model = route.modelId;
Expand Down
20 changes: 8 additions & 12 deletions tests/clients/prime-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,14 @@ function context(): ExportContext {
}

describe("Prime Agent client config", () => {
/**
* The load-bearing claim of this client: Prime Agent is the pi coding agent
* under a different brand, so it reads the SAME models.json contract rather
* than a lookalike. Locking the two documents together is what keeps that
* claim true — if a future Pi-only change diverges, this fails here instead
* of silently shipping Prime users a config their agent rejects.
*/
test("generates byte-for-byte the document Pi generates", () => {
const prime = buildClientConfigText("prime", context());
const pi = buildClientConfigText("pi", context());
expect(prime.format).toBe("json");
expect(prime.text).toBe(pi.text);
test("shares Pi's model contract without opting Prime into session headers", () => {
const prime = buildClientConfig("prime", context()) as PiGeneratedConfig;
const pi = buildClientConfig("pi", context()) as PiGeneratedConfig;
expect(pi.providers[OPENCODE_PROVIDER_ID]!.compat).toEqual({ sendSessionAffinityHeaders: true });
delete pi.providers[OPENCODE_PROVIDER_ID]!.compat;
expect(prime).toEqual(pi);
expect(buildClientContribution("prime", context()).fragments[0]!.value)
.toEqual(prime.providers[OPENCODE_PROVIDER_ID]);
});

test("adds only providers.opencodex, wired to the loopback proxy", () => {
Expand Down
8 changes: 7 additions & 1 deletion tests/config/client-config-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
LOOPBACK_API_KEY_PLACEHOLDER,
SCHEMA_REQUIRED_OUTPUT_BUDGET,
buildClientConfig,
buildClientContribution,
buildClientConfigText,
isExportClientId,
normalizeExportModels,
Expand Down Expand Up @@ -315,6 +316,8 @@ describe("Pi serializer (accept criterion 2)", () => {
expect(provider.baseUrl).toBe(BASE_URL);
expect(provider.api).toBe("openai-completions");
expect(provider.apiKey).toBe(LOOPBACK_API_KEY_PLACEHOLDER);
expect(provider.compat?.sendSessionAffinityHeaders).toBe(true);
expect(buildClientContribution("pi", ctx()).fragments[0]!.value).toEqual(provider);
});

test("cost is omitted on every entry — zeros would assert routed models are free", () => {
Expand Down Expand Up @@ -899,7 +902,7 @@ describe("EXPORT_CLIENTS registry", () => {
`);
});

test("pi bytes are unchanged, to the last newline", () => {
test("pi bytes include session affinity, to the last newline", () => {
const built = buildClientConfigText("pi", ctx({ config: cfg() }));
expect(built.format).toBe("json");
expect(built.text).toBe(`{
Expand All @@ -908,6 +911,9 @@ describe("EXPORT_CLIENTS registry", () => {
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
"apiKey": "opencodex-loopback",
"compat": {
"sendSessionAffinityHeaders": true
},
"models": [
{
"id": "anthropic/claude-opus-5",
Expand Down
Loading
Loading