Skip to content
Closed
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
10 changes: 10 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,16 @@ Create a key in the [OpenGateway dashboard](https://opengateway.ai/api-keys), th
use the configured Bearer key; the public model list does not validate that key.
On the Responses wire OpenGateway rejects replayed native custom tool calls, so the preset
lowers Codex custom tools such as code-mode `exec` to function tools before sending them.
Tool-result content arrays are sent as text, with images preserved after the paired tool-result group. Unsupported parts, including refusals, stay on the original array instead of being rewritten into a successful string.
Image detail `original` maps to `high`, which the gateway accepts; image bytes remain unchanged.
The native Responses route also checks text-only empty completions before exposing them as success.
Exhausted output budgets produce `incomplete` with `max_output_tokens`; the proxy does not increase
the requested budget. A replay-safe empty completion with budget remaining may retry once, within
the existing send limit. Explicit `emptyCompletionRetry: false` or `OCX_EMPTY_COMPLETION_RETRY=0`
disables that retry. Text, refusals, tool activity, and unknown stream events prevent replay and
retain the original stream. The protection does not change other providers or their retry defaults.
Replay requires explicit `store: false`, complete input, and client-executed tools only; a hosted
tool such as `web_search` prevents automatic replay even if no tool event was observed.

**TokenLab** ([sponsor](https://github.com/lidge-jun/opencodex/blob/main/SPONSORS.md)) is an
OpenAI-compatible API gateway at [tokenlab.sh](https://tokenlab.sh/r/OPENCODEX),
Expand Down
10 changes: 10 additions & 0 deletions docs-site/src/content/docs/ko/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,16 @@ Chat Completions 모델(그리고 Responses 전용이라 Responses로 보내는
[OpenGateway 대시보드](https://opengateway.ai/api-keys)에서 키를 발급한 뒤
`ocx provider add opengateway`를 실행하거나 대시보드에서 **OpenGateway**를 선택하세요.
Chat 요청은 설정한 Bearer 키를 사용하며 공개 모델 목록은 키의 유효성을 검증하지 않습니다.
Responses 도구 결과 배열은 텍스트로 변환하고 이미지는 짝이 맞는 도구 결과 묶음 뒤의 메시지에 보존합니다. refusal을 포함한 지원하지 않는 part는 성공 문자열로 바꾸지 않고 원래 배열에 남깁니다.
이미지의 `original` 상세도는 게이트웨이가 지원하는 `high`로 바꾸며 이미지 바이트는 그대로 유지합니다.
네이티브 Responses 경로는 텍스트가 없는 완료 응답도 검사합니다. 출력 예산이 소진됐으면
`incomplete / max_output_tokens`로 알리며 요청한 예산을 임의로 늘리지 않습니다.
예산이 남고 안전하게 재전송할 수 있는 빈 완료는 기존 호출 한도 안에서 한 번만 재시도합니다.
`emptyCompletionRetry: false` 또는 `OCX_EMPTY_COMPLETION_RETRY=0`이면 이 재시도를 끕니다.
텍스트·거절·도구 동작·알 수 없는 스트림 이벤트가 관측되면 재전송하지 않고 원본을 유지합니다.
다른 제공자의 동작과 재시도 기본값에는 적용하지 않습니다.
재전송은 명시적인 `store: false`, 완전한 입력, 클라이언트 실행 도구만 있는 요청에 한정합니다.
`web_search` 같은 서버 실행 도구가 선언돼 있으면 도구 이벤트가 없어도 자동 재전송하지 않습니다.

**OpenCode Zen**(`opencode-zen`)과 키 없는 **OpenCode Free** 프리셋은
`https://opencode.ai/zen/v1`을 공유합니다. 그 게이트웨이의 무료 모델은 종종 분당 약 15–20회 요청의 짧은 창 속도 제한에 걸립니다(커뮤니티 측정; OpenCode는 RPM을 공개하지 않음). Zen은 `Retry-After` / `X-RateLimit-*` 헤더 없는 일반 429를 반환할 수 있습니다. 이는 키 없는 데스크톱 할당량(`opencode-free`에서 약 5시간당 Big Pickle/무료 모델 200회)과 별개입니다. Zen이 그런 429에서 `Retry-After`를 생략하면 opencodex는 클라이언트 오류에 안내를 더하고 합성 `Retry-After`를 붙입니다(업스트림 `Retry-After`가 있으면 그것이 우선). 동일 키 대기 재시도는 [`retryOn429`](/ko/reference/configuration/)로 선택합니다.
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,7 @@
"empty-completion-core.test.ts": "responses",
"empty-completion-guard.test.ts": "responses",
"empty-completion-hardening.test.ts": "responses",
"opengateway-completion-receipts.test.ts": "responses",
"empty-tool-output-annotation.test.ts": "adapters",
"ensure-desired-integrations-race.test.ts": "cli",
"error-fidelity.test.ts": "server",
Expand Down Expand Up @@ -1350,6 +1351,7 @@
"opencode-zen-deepseek-reasoning.test.ts": "providers", "opencode-zen-gemini-wire.test.ts": "providers",
"opencode-zen-rate-limit.test.ts": "providers",
"opengateway-provider.test.ts": "providers",
"opengateway-input.test.ts": "adapters/openai",
"opengateway-key-validation.test.ts": "providers",
"openrouter-provider-routing.test.ts": "providers",
"openrouter-quota-reset-cooldown-4024.test.ts": "providers",
Expand Down
127 changes: 127 additions & 0 deletions src/adapters/openai-responses/opengateway-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { isPlainObject } from "./internal";
import { normalizeResponsesToolResultAdjacency } from "./tool-output-recovery";

const TOOL_ROUND_TYPES = new Set([
"function_call",
"function_call_output",
"custom_tool_call",
"custom_tool_call_output",
"local_shell_call",
"local_shell_call_output",
]);

function normalizeImageDetail(part: unknown): unknown {
return isPlainObject(part) && part.type === "input_image" && part.detail === "original"
? { ...part, detail: "high" }
: part;
}

function isToolRound(item: unknown): boolean {
return isPlainObject(item) && typeof item.type === "string" && TOOL_ROUND_TYPES.has(item.type);
}

function pairedCallId(item: Record<string, unknown>): string | undefined {
return typeof item.call_id === "string" && item.call_id.length > 0 ? item.call_id : undefined;
}

type ProjectedInput = { readonly changed: boolean; readonly input: unknown[] };

function projectOpenGatewayInput(input: readonly unknown[]): ProjectedInput {
let changed = false;
const projected: unknown[] = [];
const pendingImages: unknown[] = [];
const flushImages = (): void => {
if (pendingImages.length === 0) return;
projected.push(...pendingImages);
pendingImages.length = 0;
};
for (const item of input) {
if (!isToolRound(item)) flushImages();
if (!isPlainObject(item)) {
projected.push(item);
continue;
}
if (item.type === "function_call_output" && Array.isArray(item.output)) {
const callId = pairedCallId(item);
const content: Record<string, unknown>[] = [];
const text: string[] = [];
let supported = callId !== undefined;
let hasImage = false;
if (supported) {
for (const part of item.output) {
if (!isPlainObject(part)) {
supported = false;
break;
}
switch (part.type) {
case "text":
case "input_text":
case "output_text":
if (typeof part.text !== "string") {
supported = false;
break;
}
text.push(part.text);
content.push({ type: "input_text", text: part.text });
break;
case "input_image":
if (typeof part.image_url !== "string" && typeof part.file_id !== "string") {
supported = false;
break;
}
content.push({ ...part, ...(part.detail === "original" ? { detail: "high" } : {}) });
hasImage = true;
break;
default:
supported = false;
}
if (!supported) break;
}
}
if (supported && callId !== undefined) {
changed = true;
const marker = `[tool output for ${callId}]`;
projected.push({
...item,
output: text.length > 0 ? text.join("\n") : hasImage ? marker : "",
});
if (hasImage) {
pendingImages.push({
type: "message",
role: "user",
content: [{ type: "input_text", text: marker }, ...content],
});
}
continue;
}
}
if (Array.isArray(item.content)) {
const originalContent = item.content;
const content = originalContent.map(normalizeImageDetail);
if (content.some((part, index) => part !== originalContent[index])) {
changed = true;
projected.push({ ...item, content });
continue;
}
}
projected.push(item);
}
flushImages();
return { changed, input: projected };
}

/** OpenGateway requires string tool results and auto/low/high image detail. */
export function normalizeOpenGatewayResponsesInput(body: unknown, url: string): unknown {
const destination = new URL(url);
if (destination.origin !== "https://apis.opengateway.ai" || destination.pathname !== "/v1/responses"
|| !isPlainObject(body) || !Array.isArray(body.input)) return body;

const direct = projectOpenGatewayInput(body.input);
if (!direct.changed) return body;
const aligned = normalizeResponsesToolResultAdjacency(body);
if (!isPlainObject(aligned) || !Array.isArray(aligned.input) || aligned === body) {
return { ...body, input: direct.input };
}
const paired = projectOpenGatewayInput(aligned.input);
return { ...aligned, input: paired.changed ? paired.input : direct.input };
}
2 changes: 2 additions & 0 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { observeOutbound } from "../../usage/cache-diagnostic";
import { normalizeMuseToolChoice } from "./muse-tool-choice";
import { renderCompactionSearchHistoryItems } from "./compaction-search-history";
import { stripKimiEmptyAssistantMessages } from "./empty-assistant";
import { normalizeOpenGatewayResponsesInput } from "./opengateway-input";

/**
* Identifies DeepSeek's strict Responses replay contract: tool-bearing continuations need
Expand Down Expand Up @@ -503,6 +504,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
&& item.type === "item_reference" && typeof item.id === "string" ? [item.id] : []));
finalBody = { ...finalBody, input: renderCompactionSearchHistoryItems(finalBody.input, referencedIds) };
}
finalBody = normalizeOpenGatewayResponsesInput(finalBody, url);
observeOutbound(parsed._rawBody, finalBody, headers);
if (!isCanonicalOpenAiForwardProvider(provider)) {
validateFinalCustomToolCompatibility(finalBody, provider.supportsResponsesCustomTools);
Expand Down
Loading
Loading