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
7 changes: 5 additions & 2 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go";
import { isOpenCodeGo, normalizeOpenCodeGoAdditionalTools, normalizeOpenCodeGoAgentMessages } from "./opencode-go";
import { createHash } from "node:crypto";
import type { IncomingMeta, ProviderAdapter } from "./base";
import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types";
Expand Down Expand Up @@ -2363,7 +2363,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
parsed._rawBody,
forward || parsed._previousResponseInputExpanded === true,
);
if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody);
if (!forward && isOpenCodeGo(provider.baseUrl)) {
outBody = normalizeOpenCodeGoAgentMessages(outBody);
outBody = normalizeOpenCodeGoAdditionalTools(outBody);
}
outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId);
// stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the
// tier write so a force-fast/default decision can never mutate parsed._rawBody.
Expand Down
118 changes: 114 additions & 4 deletions src/adapters/opencode-go.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import { customToolWireName } from "../responses/custom-tool-compat";

function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}

/** Match the Go destination, including user-renamed provider entries. */
export function isOpenCodeGo(baseUrl: string): boolean {
try {
Expand All @@ -6,6 +12,102 @@ export function isOpenCodeGo(baseUrl: string): boolean {
} catch { return false; }
}

/** Plaintext part types Console Go accepts inside a converted message. */
const GO_PLAINTEXT_PART_TYPES = ["input_text", "input_image", "input_file"];

/** Wire-safe content part check for Console Go message conversion. */
function isGoPlaintextPart(part: unknown): boolean {
return !!part && typeof part === "object" && !Array.isArray(part)
&& GO_PLAINTEXT_PART_TYPES.includes((part as { type?: unknown }).type as string);
}

/** Dedupe identity for promoted declarations: type plus wire name (namespace-aware). */
function toolIdentityKey(tool: unknown): string | undefined {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return undefined;
const rec = tool as { type?: unknown; name?: unknown; namespace?: unknown };
if (typeof rec.type !== "string" || typeof rec.name !== "string") return undefined;
// Compare by wire identity so a flat declaration and the same tool inside the
// builtin `functions` namespace group dedupe instead of doubling upstream,
// where duplicate function names are rejected.
return `${rec.type}\n${customToolWireName(typeof rec.namespace === "string" ? rec.namespace : undefined, rec.name)}`;
}

/**
* Promote Codex Desktop's responses-lite `additional_tools` input items to top-level
* `tools` and drop the items. The parser already collects these declarations into the
* tool surface, but the outbound body keeps the item verbatim and Console Go's validator
* rejects the unknown item type (`input[N] did not match any supported type`). Promoting
* preserves every declaration (deduplicated by wire identity, descending into namespace
* groups so a flat declaration and the same tool inside a group do not double upstream)
* in the standard shape the downstream namespace/custom lowering passes already handle.
*/
export function normalizeOpenCodeGoAdditionalTools(body: unknown): unknown {
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const record = body as Record<string, unknown>;
if (!Array.isArray(record.input)) return body;
const existing = Array.isArray(record.tools) ? (record.tools as unknown[]) : [];
const seen = new Set<string>();
// Claim a child declaration; returns false for duplicates and unidentifiable
// entries. Claiming inside the filter (rather than after it) keeps two equal
// children of the same container from both surviving.
const claim = (child: unknown): boolean => {
const key = toolIdentityKey(child);
if (key === undefined || seen.has(key)) return false;
seen.add(key);
return true;
};
// Output buckets: top-level declarations, with at most one container per
// namespace name. Existing containers are copied before merging so the
// caller's declarations are never mutated.
const outTools: unknown[] = [];
const groupSlot = new Map<string, number>();
const mergeGroup = (name: string, first: Record<string, unknown>, kept: unknown[]): void => {
const slot = groupSlot.get(name);
if (slot === undefined) {
const group = { ...first, tools: [] as unknown[] };
groupSlot.set(name, outTools.length);
outTools.push(group);
(group.tools as unknown[]).push(...kept);
return;
}
const current = outTools[slot] as Record<string, unknown>;
outTools[slot] = { ...current, tools: [...(current.tools as unknown[]), ...kept] };
};
let dropped = false;
// Normalize one declaration into the output buckets, dropping duplicates and
// unidentifiable entries. Used for pre-existing top-level tools and promoted
// item tools alike so both paths share one invariant.
const ingest = (tool: unknown): void => {
if (!isRecord(tool)) { dropped = true; return; }
if (tool.type === "namespace" && typeof tool.name === "string" && Array.isArray(tool.tools)) {
const kept = (tool.tools as unknown[]).filter(child => claim(child));
if (kept.length === 0) { dropped = true; return; }
mergeGroup(tool.name, tool, kept);
return;
}
if (!claim(tool)) { dropped = true; return; }
outTools.push(tool);
};
for (const tool of existing) ingest(tool);
let changed = false;
const input: unknown[] = [];
for (const item of record.input as unknown[]) {
if (!isRecord(item)
|| item.type !== "additional_tools"
|| !Array.isArray(item.tools)) {
input.push(item);
continue;
}
changed = true;
// Entries without a type/name identity cannot be matched by any downstream
// pass (namespace/custom lowering and tool_choice filtering all key on them);
// promoting them would only add a guaranteed-400 entry on a closed validator.
for (const tool of item.tools as unknown[]) ingest(tool);
}
if (!changed && !dropped) return body;
return { ...record, input, tools: outTools };
}

/** Public Responses rejects Codex's private agent_message variant, even with plaintext content. */
export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown {
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
Expand All @@ -16,9 +118,17 @@ export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown {
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
const message = item as Record<string, unknown>;
if (message.type !== "agent_message" || !Array.isArray(message.content) || message.content.length === 0) return item;
// Genuine ciphertext and unknown part types must retain their existing fail-closed path.
if (!message.content.every(part => part && typeof part === "object"
&& ["input_text", "input_image", "input_file"].includes(part.type))) return item;
// Genuine ciphertext and unknown part types must retain their existing fail-closed path
// when no plaintext survives: an empty message would be rejected too. But a MIXED item
// (plaintext task envelope beside inter-agent ciphertext) must not fail the whole
// request: Console Go can never decode ciphertext minted for another Codex agent, and
// its validator rejects the unknown agent_message type outright. Convert carrying only
// the plaintext parts so the task envelope still reaches the model.
const plaintext = (message.content as unknown[]).filter(isGoPlaintextPart);
if (plaintext.length === 0) return item;
const content = plaintext.length === (message.content as unknown[]).length
? (message.content as unknown[])
: plaintext;
const identities = Object.fromEntries(["author", "recipient"]
.filter(key => typeof message[key] === "string")
.map(key => [key, message[key]]));
Expand All @@ -27,7 +137,7 @@ export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown {
type: "message", role: "user",
content: [
...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []),
...message.content,
...content,
],
};
});
Expand Down
6 changes: 6 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1616,6 +1616,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
// Zen Go can close a Chat stream after a fully assembled function call without sending
// finish_reason or [DONE] (#2260). The adapter still rejects incomplete argument JSON.
openaiChatEofTolerance: true,
// Console Go rejects replayed reasoning.encrypted_content combined with
// previous_response_id ("reasoning.encrypted_content cannot be used with
// previous_response_id"), so chained tool turns must go out stateless:
// full explicit history, no server-side continuation. Verified live
// (chained 400 without, 200 with).
statelessResponses: true,
/* [Decision Log]
- 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, Grok 4.6, and Muse Spark Contributor (#2617).
- 기존 구현 및 제약 조건: The provider is mixed-wire but its provider-wide `openai-chat` adapter sent Luna to `/chat/completions`; explicit user `modelAdapters` entries must remain authoritative.
Expand Down
3 changes: 2 additions & 1 deletion src/responses/custom-tool-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}

function customToolWireName(namespace: string | undefined, name: string): string {
/** Wire name for a routed custom tool: the builtin functions namespace collapses to the bare name. */
export function customToolWireName(namespace: string | undefined, name: string): string {
return namespace === BUILTIN_FUNCTIONS_NAMESPACE ? name : namespacedToolName(namespace, name);
}

Expand Down
Loading
Loading