@bitkyc08/opencodex 2.48.0 → 2.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +9 -1
- package/README.md +11 -5
- package/SPONSORS.md +1 -1
- package/assets/sponsors/orcarouter.png +0 -0
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/index-BoBRSehJ.css +1 -0
- package/gui/dist/assets/index-C39tnjXO.js +115 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/packycode.svg +19 -0
- package/gui/dist/provider-icons/qoder.svg +5 -0
- package/package.json +5 -3
- package/src/adapters/anthropic.ts +31 -16
- package/src/adapters/codebuddy/adapter.ts +85 -0
- package/src/adapters/codebuddy/profiles.ts +52 -0
- package/src/adapters/coding-agent/profile.ts +100 -0
- package/src/adapters/coding-agent/protocol.ts +463 -0
- package/src/adapters/coding-agent/turn.ts +353 -0
- package/src/adapters/google.ts +15 -11
- package/src/adapters/mimo-free.ts +3 -0
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses.ts +18 -11
- package/src/adapters/qoder/adapter.ts +70 -0
- package/src/adapters/qoder/live-models.ts +89 -0
- package/src/adapters/qoder/profiles.ts +36 -0
- package/src/adapters/registry.ts +12 -0
- package/src/adapters/responses-tool-schema.ts +113 -8
- package/src/claude/inbound.ts +17 -5
- package/src/cli/account-api.ts +18 -3
- package/src/cli/account-auth.ts +8 -1
- package/src/cli/account-extended.ts +2 -1
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +15 -1
- package/src/cli/dispatch.ts +2 -0
- package/src/cli/doctor.ts +40 -0
- package/src/cli/effort.ts +24 -8
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +29 -2
- package/src/cli/models-runtime.ts +8 -3
- package/src/cli/observe.ts +13 -3
- package/src/cli/provider-runtime.ts +2 -1
- package/src/cli/registry.ts +2 -2
- package/src/cli/system-command.ts +10 -3
- package/src/cli/usage-report.ts +9 -5
- package/src/clients/config-export/zcode.ts +24 -0
- package/src/codex/account-lifecycle.ts +35 -2
- package/src/codex/account-runtime-state.ts +6 -1
- package/src/codex/account-store.ts +72 -9
- package/src/codex/account-usability.ts +3 -2
- package/src/codex/auth-api.ts +113 -26
- package/src/codex/auth-collision.ts +12 -2
- package/src/codex/auth-context.ts +96 -7
- package/src/codex/catalog/parsing.ts +23 -0
- package/src/codex/catalog/provider-fetch.ts +144 -11
- package/src/codex/catalog/sync.ts +14 -0
- package/src/codex/inject.ts +128 -30
- package/src/codex/internal/catalog-writer.ts +3 -0
- package/src/codex/journal.ts +61 -12
- package/src/codex/model-cache.ts +11 -4
- package/src/codex/native-profile-startup.ts +72 -5
- package/src/codex/native-profile-store.ts +2 -2
- package/src/codex/ocx-compaction-history.ts +226 -0
- package/src/codex/project-config-warnings.ts +3 -1
- package/src/codex/quota-auto-refresh.ts +6 -1
- package/src/codex/quota.ts +71 -15
- package/src/codex/reserve-availability.ts +21 -5
- package/src/codex/runtime.ts +45 -1
- package/src/codex/sync.ts +5 -0
- package/src/combos/index.ts +2 -0
- package/src/combos/resolve.ts +52 -0
- package/src/config.ts +59 -0
- package/src/generated/compatibility-version.json +178 -114
- package/src/images/loop.ts +1 -0
- package/src/images/xai-video-client.ts +2 -0
- package/src/integrations/registry.ts +1 -0
- package/src/lib/errors.ts +8 -0
- package/src/lib/privacy.ts +25 -0
- package/src/lib/process-control.ts +52 -8
- package/src/lib/upstream-retry.ts +1 -0
- package/src/oauth/chatgpt.ts +83 -0
- package/src/oauth/health.ts +47 -12
- package/src/oauth/index.ts +46 -8
- package/src/oauth/token-guardian.ts +32 -6
- package/src/oauth/xai.ts +151 -8
- package/src/providers/api-key-selection-capture.ts +10 -0
- package/src/providers/api-key-selection.ts +2 -7
- package/src/providers/caller-authorization.ts +36 -0
- package/src/providers/codebuddy-models.ts +184 -0
- package/src/providers/derive.ts +5 -0
- package/src/providers/free-directory.ts +26 -2
- package/src/providers/google-ai-studio-model-discovery.ts +74 -0
- package/src/providers/openai-sidecar.ts +35 -11
- package/src/providers/opencode-zen-rate-limit.ts +75 -0
- package/src/providers/qoder-models.ts +25 -0
- package/src/providers/quota.ts +15 -0
- package/src/providers/registry.ts +140 -1
- package/src/responses/compaction.ts +4 -0
- package/src/responses/task-input.ts +21 -1
- package/src/router.ts +1 -1
- package/src/server/auth-cors.ts +6 -0
- package/src/server/chat-completions.ts +30 -13
- package/src/server/chat-native.ts +10 -1
- package/src/server/claude-messages.ts +17 -7
- package/src/server/images.ts +3 -2
- package/src/server/index.ts +25 -2
- package/src/server/management/account-selection-stream.ts +13 -4
- package/src/server/management/config-routes.ts +24 -5
- package/src/server/management/logs-usage-routes.ts +5 -1
- package/src/server/management/model-rows.ts +16 -1
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/oauth-account-routes.ts +6 -2
- package/src/server/management/provider-routes.ts +33 -2
- package/src/server/management/request-history-routes.ts +4 -2
- package/src/server/management/route-registry.ts +5 -4
- package/src/server/management/shared.ts +66 -3
- package/src/server/management-api.ts +15 -1
- package/src/server/port-reclaim.ts +11 -26
- package/src/server/request-decompress.ts +91 -3
- package/src/server/request-log.ts +16 -0
- package/src/server/responses/codex-ws-wire.ts +1 -1
- package/src/server/responses/collaboration.ts +4 -9
- package/src/server/responses/compact.ts +8 -2
- package/src/server/responses/context-overflow.ts +11 -0
- package/src/server/responses/core.ts +285 -57
- package/src/server/responses/fetch-helpers.ts +18 -7
- package/src/server/responses/policy-fallback.ts +18 -2
- package/src/server/search.ts +2 -2
- package/src/service.ts +128 -9
- package/src/storage/cleanup.ts +77 -45
- package/src/types/accounts.ts +18 -0
- package/src/types/config.ts +43 -1
- package/src/types/provider.ts +56 -0
- package/src/types.ts +4 -0
- package/src/usage/log.ts +24 -0
- package/src/vision/anthropic-describe.ts +1 -0
- package/src/web-search/anthropic-executor.ts +1 -0
- package/src/web-search/loop.ts +1 -0
- package/src/web-search/ollama-executor.ts +127 -0
- package/src/web-search/passthrough-bridge.ts +761 -0
- package/src/web-search/progress-stream.ts +4 -0
- package/gui/dist/assets/index-B5r7LNHN.js +0 -115
- package/gui/dist/assets/index-D5SiRo8X.css +0 -1
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxUsage } from "../../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared stream-json protocol for official coding-agent CLIs (CodeBuddy Code and Qoder CLI).
|
|
5
|
+
*
|
|
6
|
+
* The vendor speaks the Anthropic/Claude-Code `stream-json` protocol ("the naming and protocol
|
|
7
|
+
* align with Anthropic Claude Code v2.1.88"). A headless turn is a newline-delimited JSON stream on stdout:
|
|
8
|
+
*
|
|
9
|
+
* {"type":"system","subtype":"init", ...}
|
|
10
|
+
* {"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta",...}}} (with --include-partial-messages)
|
|
11
|
+
* {"type":"assistant","message":{"role":"assistant","content":[{"type":"text"|"thinking"|"tool_use",...}]}}
|
|
12
|
+
* {"type":"result","subtype":"success","is_error":false,"usage":{...},"total_cost_usd":...,"session_id":...}
|
|
13
|
+
*
|
|
14
|
+
* Diagnostics ride stderr and are NOT protocol data. This module is pure: it never spawns a process
|
|
15
|
+
* and never touches the network, so it is unit-testable against captured fixtures.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Hard ceiling on a single buffered stdout line, so a runaway frame cannot exhaust memory. */
|
|
19
|
+
export const MAX_STREAM_LINE_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
/** Hard ceiling on the total stdout bytes consumed for one turn. */
|
|
21
|
+
export const MAX_STREAM_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
22
|
+
/** Hard ceiling on projected conversation history text (characters) to prevent runaway memory. */
|
|
23
|
+
export const MAX_PROJECTED_HISTORY_CHARS = 200_000;
|
|
24
|
+
|
|
25
|
+
export class CodingAgentStreamLimitError extends Error {
|
|
26
|
+
constructor(message: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "CodingAgentStreamLimitError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class CodingAgentProtocolError extends Error {
|
|
33
|
+
readonly code: string = "protocol_error";
|
|
34
|
+
readonly status: number = 502;
|
|
35
|
+
constructor(message: string) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "CodingAgentProtocolError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A parsed protocol frame. */
|
|
42
|
+
export type StreamMessage = Record<string, unknown>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Split an async byte stream into JSONL frames.
|
|
46
|
+
*
|
|
47
|
+
* Handles the streaming hazards the task calls out (§二十五): fragmented JSON across chunks, split
|
|
48
|
+
* multi-byte UTF-8 (via the decoder's `stream` mode), partial trailing lines, and multiple frames in
|
|
49
|
+
* one chunk. A non-empty frame that does not parse to a JSON record fails closed so corrupted
|
|
50
|
+
* protocol output cannot be mistaken for a successful response.
|
|
51
|
+
*/
|
|
52
|
+
export async function* readJsonLines(
|
|
53
|
+
chunks: AsyncIterable<Uint8Array>,
|
|
54
|
+
limits: { maxLineBytes?: number; maxTotalBytes?: number } = {},
|
|
55
|
+
): AsyncGenerator<StreamMessage> {
|
|
56
|
+
const maxLineBytes = limits.maxLineBytes ?? MAX_STREAM_LINE_BYTES;
|
|
57
|
+
const maxTotalBytes = limits.maxTotalBytes ?? MAX_STREAM_TOTAL_BYTES;
|
|
58
|
+
const decoder = new TextDecoder();
|
|
59
|
+
const encoder = new TextEncoder();
|
|
60
|
+
let buffer = "";
|
|
61
|
+
let totalBytes = 0;
|
|
62
|
+
|
|
63
|
+
const flushLine = function* (line: string): Generator<StreamMessage> {
|
|
64
|
+
if (encoder.encode(line).byteLength > maxLineBytes) {
|
|
65
|
+
throw new CodingAgentStreamLimitError("Coding-agent stream line exceeded the byte ceiling");
|
|
66
|
+
}
|
|
67
|
+
const trimmed = line.trim();
|
|
68
|
+
if (!trimmed) return; // Blank lines and whitespace-only lines are ignored as padding.
|
|
69
|
+
let parsed: unknown;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(trimmed);
|
|
72
|
+
} catch {
|
|
73
|
+
const snippet = trimmed.slice(0, 64).replace(/[\r\n]+/g, " ");
|
|
74
|
+
throw new CodingAgentProtocolError(
|
|
75
|
+
`Malformed stream-json frame received from coding-agent CLI: ${snippet}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
79
|
+
const snippet = trimmed.slice(0, 64).replace(/[\r\n]+/g, " ");
|
|
80
|
+
throw new CodingAgentProtocolError(
|
|
81
|
+
`Non-object stream-json frame received from coding-agent CLI: ${snippet}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
yield parsed as StreamMessage;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
for await (const chunk of chunks) {
|
|
88
|
+
totalBytes += chunk.byteLength;
|
|
89
|
+
if (totalBytes > maxTotalBytes) {
|
|
90
|
+
throw new CodingAgentStreamLimitError("Coding-agent stream exceeded the total byte ceiling");
|
|
91
|
+
}
|
|
92
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
93
|
+
let newline = buffer.indexOf("\n");
|
|
94
|
+
while (newline >= 0) {
|
|
95
|
+
const line = buffer.slice(0, newline);
|
|
96
|
+
buffer = buffer.slice(newline + 1);
|
|
97
|
+
yield* flushLine(line);
|
|
98
|
+
newline = buffer.indexOf("\n");
|
|
99
|
+
}
|
|
100
|
+
if (encoder.encode(buffer).byteLength > maxLineBytes) {
|
|
101
|
+
throw new CodingAgentStreamLimitError("Coding-agent stream line exceeded the byte ceiling");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Flush the decoder's trailing bytes and any final line without a newline terminator.
|
|
105
|
+
buffer += decoder.decode();
|
|
106
|
+
if (buffer.trim()) yield* flushLine(buffer);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
110
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function asString(value: unknown): string | undefined {
|
|
114
|
+
return typeof value === "string" ? value : undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Extract OpenCodex usage from a `result` frame's Anthropic-shaped usage object. */
|
|
118
|
+
export function usageFromResult(message: StreamMessage): OcxUsage | undefined {
|
|
119
|
+
const usage = asRecord(message.usage);
|
|
120
|
+
if (!usage) return undefined;
|
|
121
|
+
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
|
|
122
|
+
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
|
|
123
|
+
const cachedInputTokens = typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined;
|
|
124
|
+
const cacheCreationInputTokens =
|
|
125
|
+
typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : undefined;
|
|
126
|
+
if (inputTokens === 0 && outputTokens === 0 && cachedInputTokens === undefined) return undefined;
|
|
127
|
+
return {
|
|
128
|
+
inputTokens,
|
|
129
|
+
outputTokens,
|
|
130
|
+
totalTokens: inputTokens + outputTokens,
|
|
131
|
+
...(cachedInputTokens !== undefined ? { cachedInputTokens, cacheReadInputTokens: cachedInputTokens } : {}),
|
|
132
|
+
...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Mutable per-turn parse state shared across frames of one stream (§十二).
|
|
138
|
+
* Thinking and text states are strictly decoupled.
|
|
139
|
+
*/
|
|
140
|
+
export interface StreamParseState {
|
|
141
|
+
sawPartialText: boolean;
|
|
142
|
+
sawPartialThinking: boolean;
|
|
143
|
+
sawTerminalResult: boolean;
|
|
144
|
+
openToolCallId?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Map ONE protocol frame to zero or more AdapterEvents.
|
|
149
|
+
*
|
|
150
|
+
* Token-level streaming comes from `stream_event` frames (enabled by `--include-partial-messages`);
|
|
151
|
+
* the complete `assistant` frame is only used as a fallback when no partial deltas were seen, so text
|
|
152
|
+
* and thinking are never emitted twice.
|
|
153
|
+
*/
|
|
154
|
+
export function mapStreamMessageToEvents(message: StreamMessage, state: StreamParseState): AdapterEvent[] {
|
|
155
|
+
const type = asString(message.type);
|
|
156
|
+
const events: AdapterEvent[] = [];
|
|
157
|
+
|
|
158
|
+
if (type === "stream_event") {
|
|
159
|
+
const event = asRecord(message.event);
|
|
160
|
+
if (event) events.push(...mapRawStreamEvent(event, state));
|
|
161
|
+
return events;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (type === "assistant") {
|
|
165
|
+
// Fallback path: a complete assistant message. Surface text and thinking independently
|
|
166
|
+
// only when the partial delta stream did not already carry them (§十二).
|
|
167
|
+
const content = asRecord(message.message)?.content;
|
|
168
|
+
if (Array.isArray(content)) {
|
|
169
|
+
for (const block of content) {
|
|
170
|
+
const part = asRecord(block);
|
|
171
|
+
if (!part) continue;
|
|
172
|
+
const blockType = asString(part.type);
|
|
173
|
+
if (blockType === "text" && !state.sawPartialText) {
|
|
174
|
+
const text = asString(part.text);
|
|
175
|
+
if (text) events.push({ type: "text_delta", text });
|
|
176
|
+
} else if (blockType === "thinking" && !state.sawPartialThinking) {
|
|
177
|
+
const thinking = asString(part.thinking);
|
|
178
|
+
if (thinking) events.push({ type: "thinking_delta", thinking });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return events;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (type === "result") {
|
|
186
|
+
const isError = message.is_error === true || asString(message.subtype) === "error_during_execution";
|
|
187
|
+
const usage = usageFromResult(message);
|
|
188
|
+
if (isError) {
|
|
189
|
+
const errors = Array.isArray(message.errors)
|
|
190
|
+
? message.errors.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
191
|
+
: [];
|
|
192
|
+
const detail = asString(message.result) || errors[0] || "Coding-agent CLI ended the turn with an execution error";
|
|
193
|
+
const vendorCode = typeof message.error_code === "number" ? message.error_code : undefined;
|
|
194
|
+
// Qoder documents code 118 and emits the "credit usage limit" wording. Keep the
|
|
195
|
+
// match deliberately narrow so other coding-agent CLIs retain their established
|
|
196
|
+
// generic-upstream handling for ambiguous text such as "insufficient credits".
|
|
197
|
+
const insufficientQuota = vendorCode === 118 || /credit usage limit/i.test(detail);
|
|
198
|
+
// Anchor to credential verdicts. A bare "authentication" substring also matches upstream
|
|
199
|
+
// service-degradation text, and a false 401 drives reauth messaging and key-pool rotation.
|
|
200
|
+
const authentication = /not logged in|invalid (?:personal access )?token|authentication (?:failed|error|required)|unauthorized/i.test(detail);
|
|
201
|
+
const rateLimited = !insufficientQuota && /rate limit|too many requests/i.test(detail);
|
|
202
|
+
const modelUnavailable = /model (?:is )?(?:not found|unavailable|unsupported)|invalid model/i.test(detail);
|
|
203
|
+
events.push({
|
|
204
|
+
type: "error",
|
|
205
|
+
message: detail,
|
|
206
|
+
status: insufficientQuota || rateLimited ? 429 : authentication ? 401 : modelUnavailable ? 400 : 502,
|
|
207
|
+
errorType: insufficientQuota
|
|
208
|
+
? "insufficient_quota"
|
|
209
|
+
: rateLimited
|
|
210
|
+
? "rate_limit_error"
|
|
211
|
+
: authentication
|
|
212
|
+
? "authentication_error"
|
|
213
|
+
: modelUnavailable
|
|
214
|
+
? "invalid_request_error"
|
|
215
|
+
: "upstream_error",
|
|
216
|
+
code: insufficientQuota
|
|
217
|
+
? "insufficient_quota"
|
|
218
|
+
: rateLimited
|
|
219
|
+
? "rate_limit_exceeded"
|
|
220
|
+
: authentication
|
|
221
|
+
? "invalid_api_key"
|
|
222
|
+
: modelUnavailable
|
|
223
|
+
? "model_not_found"
|
|
224
|
+
: "upstream_error",
|
|
225
|
+
retryable: rateLimited,
|
|
226
|
+
...(usage ? { usage } : {}),
|
|
227
|
+
});
|
|
228
|
+
return events;
|
|
229
|
+
}
|
|
230
|
+
state.sawTerminalResult = true;
|
|
231
|
+
events.push({ type: "done", ...(usage ? { usage } : {}), stopReason: "stop" });
|
|
232
|
+
return events;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// system/init, user echoes, task_* background events: not client-visible output.
|
|
236
|
+
return events;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Map a raw Anthropic SSE event (carried inside a `stream_event` frame) to AdapterEvents. */
|
|
240
|
+
function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): AdapterEvent[] {
|
|
241
|
+
const events: AdapterEvent[] = [];
|
|
242
|
+
const eventType = asString(event.type);
|
|
243
|
+
|
|
244
|
+
if (eventType === "content_block_delta") {
|
|
245
|
+
const delta = asRecord(event.delta);
|
|
246
|
+
const deltaType = asString(delta?.type);
|
|
247
|
+
if (deltaType === "text_delta") {
|
|
248
|
+
const text = asString(delta?.text);
|
|
249
|
+
if (text) {
|
|
250
|
+
state.sawPartialText = true;
|
|
251
|
+
events.push({ type: "text_delta", text });
|
|
252
|
+
}
|
|
253
|
+
} else if (deltaType === "thinking_delta") {
|
|
254
|
+
const thinking = asString(delta?.thinking);
|
|
255
|
+
if (thinking) {
|
|
256
|
+
state.sawPartialThinking = true;
|
|
257
|
+
events.push({ type: "thinking_delta", thinking });
|
|
258
|
+
}
|
|
259
|
+
} else if (deltaType === "input_json_delta") {
|
|
260
|
+
// Tool-input streaming. Inert while tools are disabled (Codex's catalog is not advertised),
|
|
261
|
+
// but parsed so the seam is ready and an unexpected frame never crashes.
|
|
262
|
+
const partial = asString(delta?.partial_json);
|
|
263
|
+
if (partial && state.openToolCallId) events.push({ type: "tool_call_delta", arguments: partial });
|
|
264
|
+
}
|
|
265
|
+
return events;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (eventType === "content_block_start") {
|
|
269
|
+
const block = asRecord(event.content_block);
|
|
270
|
+
if (asString(block?.type) === "tool_use") {
|
|
271
|
+
const id = asString(block?.id) ?? "";
|
|
272
|
+
const name = asString(block?.name) ?? "tool";
|
|
273
|
+
if (id) {
|
|
274
|
+
state.openToolCallId = id;
|
|
275
|
+
events.push({ type: "tool_call_start", id, name });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return events;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (eventType === "content_block_stop") {
|
|
282
|
+
if (state.openToolCallId) {
|
|
283
|
+
state.openToolCallId = undefined;
|
|
284
|
+
events.push({ type: "tool_call_end" });
|
|
285
|
+
}
|
|
286
|
+
return events;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return events;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** One content part on the stream-json input wire (Anthropic message shape). */
|
|
293
|
+
type WireContentPart = Record<string, unknown>;
|
|
294
|
+
|
|
295
|
+
function textPart(text: string): WireContentPart {
|
|
296
|
+
return { type: "text", text };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Encode an OpenCodex image content part as an Anthropic base64/url image block; never drop it. */
|
|
300
|
+
function imagePart(imageUrl: string): WireContentPart | undefined {
|
|
301
|
+
const match = /^data:([^;]+);base64,(.+)$/s.exec(imageUrl);
|
|
302
|
+
if (match) return { type: "image", source: { type: "base64", media_type: match[1], data: match[2] } };
|
|
303
|
+
if (/^https?:\/\//i.test(imageUrl)) return { type: "image", source: { type: "url", url: imageUrl } };
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function formatMessageForHistory(message: OcxMessage): string {
|
|
308
|
+
if (message.role === "user") {
|
|
309
|
+
const text = typeof message.content === "string"
|
|
310
|
+
? message.content
|
|
311
|
+
: message.content.map(p => (p.type === "text" ? p.text : `[${p.type}]`)).join("\n");
|
|
312
|
+
return `USER:\n${text}`;
|
|
313
|
+
}
|
|
314
|
+
if (message.role === "assistant") {
|
|
315
|
+
const parts: string[] = [];
|
|
316
|
+
for (const part of message.content) {
|
|
317
|
+
if (part.type === "text" && part.text.trim()) {
|
|
318
|
+
parts.push(part.text.trim());
|
|
319
|
+
} else if (part.type === "thinking" && part.thinking.trim()) {
|
|
320
|
+
parts.push(`[Thinking: ${part.thinking.trim()}]`);
|
|
321
|
+
} else if (part.type === "toolCall") {
|
|
322
|
+
const args = JSON.stringify(part.arguments ?? {});
|
|
323
|
+
parts.push(`[Tool call: ${part.name} (call_id: ${part.id}) with args: ${args}]`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return `ASSISTANT:\n${parts.join("\n") || "(empty response)"}`;
|
|
327
|
+
}
|
|
328
|
+
if (message.role === "toolResult") {
|
|
329
|
+
const text = typeof message.content === "string"
|
|
330
|
+
? message.content
|
|
331
|
+
: message.content.map(p => (p.type === "text" ? p.text : "[image]")).join("");
|
|
332
|
+
const status = message.isError ? " (error)" : "";
|
|
333
|
+
return `TOOL RESULT (call_id: ${message.toolCallId})${status}:\n${text}`;
|
|
334
|
+
}
|
|
335
|
+
return "";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Format an isolated OpenCodex message into stream-json user message input lines.
|
|
340
|
+
*
|
|
341
|
+
* In stream-json mode, the official CLI stdin parser (`StreamJsonUtils.parseUserMessage`) only
|
|
342
|
+
* accepts `type: "user"` frames. Writing undocumented `type: "assistant"` frames is rejected.
|
|
343
|
+
* Non-user messages are therefore projected into valid user frames.
|
|
344
|
+
*/
|
|
345
|
+
export function buildInputLines(message: OcxMessage): string[] {
|
|
346
|
+
if (message.role === "developer") return [];
|
|
347
|
+
|
|
348
|
+
const content: WireContentPart[] = [];
|
|
349
|
+
if (message.role === "user") {
|
|
350
|
+
if (typeof message.content === "string") {
|
|
351
|
+
content.push(textPart(message.content));
|
|
352
|
+
} else {
|
|
353
|
+
for (const part of message.content) {
|
|
354
|
+
if (part.type === "text") content.push(textPart(part.text));
|
|
355
|
+
else if (part.type === "image") {
|
|
356
|
+
const image = imagePart(part.imageUrl);
|
|
357
|
+
if (image) content.push(image);
|
|
358
|
+
} else {
|
|
359
|
+
content.push(textPart("[video]"));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
const formatted = formatMessageForHistory(message);
|
|
365
|
+
if (formatted) content.push(textPart(formatted));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return content.length > 0 ? [JSON.stringify({ type: "user", message: { role: "user", content } })] : [];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Fold the request's system + developer prompts into one system-prompt string. */
|
|
372
|
+
export function buildSystemPrompt(parsed: OcxParsedRequest): string | undefined {
|
|
373
|
+
const parts: string[] = [];
|
|
374
|
+
for (const line of parsed.context.systemPrompt ?? []) {
|
|
375
|
+
if (line && line.trim()) parts.push(line);
|
|
376
|
+
}
|
|
377
|
+
for (const message of parsed.context.messages) {
|
|
378
|
+
if (message.role !== "developer") continue;
|
|
379
|
+
const text = typeof message.content === "string"
|
|
380
|
+
? message.content
|
|
381
|
+
: message.content.map(part => (part.type === "text" ? part.text : "")).join("");
|
|
382
|
+
if (text.trim()) parts.push(text);
|
|
383
|
+
}
|
|
384
|
+
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Build the ordered stream-json input lines for a turn (Strategy C: Legal user-message projection).
|
|
389
|
+
*
|
|
390
|
+
* In stream-json mode, the vendor CLI stdin parser strictly accepts `type: "user"` frames
|
|
391
|
+
* (`{"type":"user","message":{"role":"user","content":...}}`).
|
|
392
|
+
* Undocumented `{"type":"assistant",...}` frames are dropped by the vendor parser.
|
|
393
|
+
*
|
|
394
|
+
* Multi-turn history (user, assistant, tool results) is projected into a legal user message:
|
|
395
|
+
* prior conversation turns are structured as bounded context text with tool results as text,
|
|
396
|
+
* clearly demarcated from the current user request. Codex retains tool control; vendor tools are never invoked.
|
|
397
|
+
*/
|
|
398
|
+
export function buildConversationInput(parsed: OcxParsedRequest): string[] {
|
|
399
|
+
const nonDev = parsed.context.messages.filter(m => m.role !== "developer");
|
|
400
|
+
if (nonDev.length === 0) {
|
|
401
|
+
return [JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: "" }] } })];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (nonDev.length === 1 && nonDev[0]!.role === "user") {
|
|
405
|
+
return buildInputLines(nonDev[0]!);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Multi-turn conversation or history with tool results:
|
|
409
|
+
const historyMessages = nonDev.slice(0, -1);
|
|
410
|
+
const currentMessage = nonDev[nonDev.length - 1]!;
|
|
411
|
+
|
|
412
|
+
const imageBlocks: WireContentPart[] = [];
|
|
413
|
+
let currentRequestText = "";
|
|
414
|
+
|
|
415
|
+
if (currentMessage.role === "user") {
|
|
416
|
+
if (typeof currentMessage.content === "string") {
|
|
417
|
+
currentRequestText = currentMessage.content;
|
|
418
|
+
} else {
|
|
419
|
+
const textParts: string[] = [];
|
|
420
|
+
for (const part of currentMessage.content) {
|
|
421
|
+
if (part.type === "text") textParts.push(part.text);
|
|
422
|
+
else if (part.type === "image") {
|
|
423
|
+
const image = imagePart(part.imageUrl);
|
|
424
|
+
if (image) imageBlocks.push(image);
|
|
425
|
+
} else {
|
|
426
|
+
textParts.push("[video]");
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
currentRequestText = textParts.join("\n");
|
|
430
|
+
}
|
|
431
|
+
} else if (currentMessage.role === "toolResult") {
|
|
432
|
+
const text = typeof currentMessage.content === "string"
|
|
433
|
+
? currentMessage.content
|
|
434
|
+
: currentMessage.content.map(p => (p.type === "text" ? p.text : "[image]")).join("");
|
|
435
|
+
const status = currentMessage.isError ? " (error)" : "";
|
|
436
|
+
currentRequestText = `TOOL RESULT (call_id: ${currentMessage.toolCallId})${status}:\n${text}\n\nPlease proceed based on the above tool result.`;
|
|
437
|
+
} else {
|
|
438
|
+
currentRequestText = formatMessageForHistory(currentMessage);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Also collect any images from history messages so multimodal attachments are never dropped:
|
|
442
|
+
for (const msg of historyMessages) {
|
|
443
|
+
if (msg.role === "user" && Array.isArray(msg.content)) {
|
|
444
|
+
for (const part of msg.content) {
|
|
445
|
+
if (part.type === "image") {
|
|
446
|
+
const img = imagePart(part.imageUrl);
|
|
447
|
+
if (img) imageBlocks.push(img);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
let historyText = historyMessages.map(formatMessageForHistory).filter(Boolean).join("\n\n");
|
|
454
|
+
if (historyText.length > MAX_PROJECTED_HISTORY_CHARS) {
|
|
455
|
+
historyText = `[Earlier conversation history truncated for length...]\n\n` +
|
|
456
|
+
historyText.slice(historyText.length - MAX_PROJECTED_HISTORY_CHARS);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const combinedText = `Prior conversation context:\n\n${historyText}\n\nCurrent user request:\n\n${currentRequestText}`;
|
|
460
|
+
|
|
461
|
+
const content: WireContentPart[] = [{ type: "text", text: combinedText }, ...imageBlocks];
|
|
462
|
+
return [JSON.stringify({ type: "user", message: { role: "user", content } })];
|
|
463
|
+
}
|