@bitkyc08/opencodex 2.7.33 → 2.7.34

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.
Files changed (64) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +21 -10
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/gui/dist/assets/index-BkmJJgg6.js +52 -0
  7. package/gui/dist/assets/index-Sg-7L_oZ.css +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic.ts +13 -6
  11. package/src/adapters/cursor/discovery.ts +39 -4
  12. package/src/adapters/cursor/exec-policy.ts +11 -13
  13. package/src/adapters/cursor/live-transport.ts +22 -4
  14. package/src/adapters/cursor/protobuf-events.ts +140 -8
  15. package/src/adapters/cursor/protobuf-request.ts +15 -0
  16. package/src/adapters/cursor/request-builder.ts +10 -5
  17. package/src/adapters/cursor/transport.ts +3 -2
  18. package/src/adapters/cursor/types.ts +14 -0
  19. package/src/adapters/kiro-constants.ts +12 -0
  20. package/src/adapters/kiro-errors.ts +111 -2
  21. package/src/adapters/kiro-events.ts +154 -35
  22. package/src/adapters/kiro-retry.ts +116 -32
  23. package/src/adapters/kiro-tools.ts +30 -20
  24. package/src/adapters/kiro-wire.ts +47 -6
  25. package/src/adapters/kiro.ts +891 -228
  26. package/src/adapters/openai-chat.ts +12 -5
  27. package/src/adapters/openai-responses.ts +7 -2
  28. package/src/bridge.ts +109 -26
  29. package/src/claude/outbound.ts +27 -4
  30. package/src/cli/index.ts +1 -1
  31. package/src/codex/catalog.ts +375 -33
  32. package/src/combos/index.ts +3 -0
  33. package/src/combos/request.ts +4 -4
  34. package/src/combos/resolve.ts +2 -2
  35. package/src/combos/types.ts +104 -2
  36. package/src/config.ts +70 -1
  37. package/src/lib/eventstream-decoder.ts +9 -0
  38. package/src/oauth/index.ts +3 -1
  39. package/src/oauth/kiro-credentials.ts +48 -20
  40. package/src/oauth/login-cli.ts +2 -0
  41. package/src/providers/derive.ts +8 -0
  42. package/src/providers/kiro-models.ts +2 -2
  43. package/src/providers/openai-sidecar.ts +28 -1
  44. package/src/providers/registry.ts +39 -2
  45. package/src/responses/parser.ts +22 -10
  46. package/src/responses/schema.ts +1 -0
  47. package/src/responses/state.ts +50 -10
  48. package/src/router.ts +15 -3
  49. package/src/server/auth-cors.ts +7 -0
  50. package/src/server/claude-messages.ts +6 -0
  51. package/src/server/index.ts +6 -3
  52. package/src/server/management-api.ts +187 -43
  53. package/src/server/ports.ts +4 -2
  54. package/src/server/request-log.ts +3 -2
  55. package/src/server/responses-item-id-repair.ts +281 -0
  56. package/src/server/responses.ts +274 -73
  57. package/src/types.ts +109 -16
  58. package/src/update/job.ts +81 -1
  59. package/src/vision/describe.ts +2 -1
  60. package/src/web-search/executor.ts +2 -1
  61. package/src/web-search/loop.ts +9 -1
  62. package/src/web-search/progress-stream.ts +12 -10
  63. package/gui/dist/assets/index-D6Fcl4yM.css +0 -1
  64. package/gui/dist/assets/index-d63HMU0x.js +0 -52
@@ -53,6 +53,8 @@ export interface ProviderRegistryEntry {
53
53
  contextWindow?: number;
54
54
  modelContextWindows?: Record<string, number>;
55
55
  modelInputModalities?: Record<string, string[]>;
56
+ defaultMaxOutputTokens?: number;
57
+ modelMaxOutputTokens?: Record<string, number>;
56
58
  reasoningEfforts?: string[];
57
59
  modelReasoningEfforts?: Record<string, string[]>;
58
60
  modelDefaultReasoningEfforts?: Record<string, string>;
@@ -87,7 +89,7 @@ export type ProviderConfigSeed = Pick<
87
89
  OcxProviderConfig,
88
90
  "adapter" | "baseUrl" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models"
89
91
  | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities"
90
- | "modelMaxInputTokens"
92
+ | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens"
91
93
  | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap"
92
94
  | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
93
95
  | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames"
@@ -219,6 +221,13 @@ const ALIBABA_INTL_TOKEN_PLAN_MODELS = [
219
221
  const ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS = [
220
222
  "qwen3.8-max-preview", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash",
221
223
  ];
224
+
225
+ // 260722 Tencent Cloud Coding Plan. The plan's model set is explicitly dynamic; these are the
226
+ // current documented ids and live discovery remains enabled so successful /models responses win.
227
+ // Tencent marks every Coding Plan model as text-only input and restricts plan keys to interactive
228
+ // coding tools (not custom application backends or non-interactive batch automation).
229
+ // Evidence: https://cloud.tencent.cn/document/product/1823/130092
230
+ const TENCENT_CODING_PLAN_MODELS = ["tc-code-latest", "glm-5", "kimi-k2.5", "minimax-m2.5"];
222
231
  const ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES: Record<string, string[]> = {
223
232
  "qwen3.8-max-preview": ["text", "image"],
224
233
  "qwen3.7-max": ["text", "image"],
@@ -343,7 +352,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
343
352
  authKind: "oauth",
344
353
  featured: false,
345
354
  dashboardPreset: true,
346
- note: "Experimental Cursor bridge. Live transport and live model discovery are enabled after a standalone PKCE browser login via 'ocx login cursor'; native read/write/delete/shell/fetch execution defaults to codex-sandbox mode (auto-enabled when the request declares Codex danger-full-access sandbox); override with \"nativeLocalExec\": \"on\" (always), \"off\" (never), or \"codex-sandbox\" (only for requests declaring the Codex danger-full-access sandbox; the declaration is caller-controlled prose the proxy cannot verify, and the auth-free loopback bind admits any process on this host, including other local users — enable only where every data-plane client is trusted) — legacy \"unsafeAllowNativeLocalExec\": true still means \"on\" on providers.cursor in ~/.opencodex/config.json (dashboard: Providers Cursor Edit JSON) for a trusted local experiment.",
355
+ note: "Experimental Cursor bridge. Live transport and live model discovery are enabled after a standalone PKCE browser login via 'ocx login cursor'; native read/write/delete/shell/fetch execution is disabled by default and request text such as Codex sandbox markers never authorizes it. Set \"nativeLocalExec\": \"on\" on providers.cursor in ~/.opencodex/config.json (dashboard: Providers Cursor Edit JSON) only for a trusted local experiment where every data-plane caller is trusted. \"off\" denies all, \"codex-sandbox\" is accepted for backwards compatibility but fails closed, and legacy \"unsafeAllowNativeLocalExec\": true still means explicit operator opt-in.",
347
356
  models: cursorModelIds(CURSOR_STATIC_MODELS),
348
357
  liveModels: true,
349
358
  defaultModel: "auto",
@@ -743,6 +752,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
743
752
  },
744
753
  { id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api" },
745
754
  { id: "synthetic", label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://synthetic.new" },
755
+ // SiliconFlow publishes an OpenAI-compatible chat endpoint and a dynamic model catalog. Do not
756
+ // freeze reasoning controls here: enable_thinking/thinking_budget support and limits vary by
757
+ // model, so live metadata or an explicit user override must own those capabilities.
758
+ // Evidence: https://docs.siliconflow.cn/en/api-reference/chat-completions/chat-completions
759
+ {
760
+ id: "siliconflow",
761
+ label: "SiliconFlow",
762
+ baseUrl: "https://api.siliconflow.cn/v1",
763
+ adapter: "openai-chat",
764
+ authKind: "key",
765
+ dashboardUrl: "https://cloud.siliconflow.cn/account/ak",
766
+ liveModels: true,
767
+ note: "OpenAI-compatible live model catalog; reasoning controls vary by model.",
768
+ },
746
769
  // Qwen Cloud: token plan is the preset default; GUI offers pay-as-you-go + custom via baseUrlChoices.
747
770
  // Formerly `qwen-portal` / portal.qwen.ai — that host is outdated.
748
771
  {
@@ -756,6 +779,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
756
779
  dashboardUrl: "https://docs.qwencloud.com",
757
780
  note: "Pick token plan, pay as you go, or a custom compatible-mode base URL",
758
781
  },
782
+ {
783
+ id: "tencent-coding-plan",
784
+ label: "Tencent Cloud Coding Plan",
785
+ baseUrl: "https://api.lkeap.cloud.tencent.com/coding/v3",
786
+ adapter: "openai-chat",
787
+ authKind: "key",
788
+ dashboardUrl: "https://console.cloud.tencent.com/tokenhub/codingplan",
789
+ defaultModel: "tc-code-latest",
790
+ models: TENCENT_CODING_PLAN_MODELS,
791
+ liveModels: true,
792
+ modelInputModalities: Object.fromEntries(TENCENT_CODING_PLAN_MODELS.map(id => [id, ["text"]])),
793
+ noVisionModels: TENCENT_CODING_PLAN_MODELS,
794
+ note: "Coding tools only. Tencent forbids general API automation, custom backends, and non-interactive batch use.",
795
+ },
759
796
  // 2026-07-10: docs unverified; model data frozen. Evidence: devlog/_plan/260710_provider_hardening/002_research_cn.md.
760
797
  { id: "qianfan", label: "Qianfan (Baidu)", baseUrl: "https://qianfan.baidubce.com/v2", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.bce.baidu.com/iam/#/iam/apikey/list" },
761
798
  // 2026-07-10: docs unverified; model data frozen. Evidence: devlog/_plan/260710_provider_hardening/002_research_cn.md.
@@ -13,6 +13,7 @@ import type {
13
13
  import { namespacedToolName } from "../types";
14
14
  import { responsesRequestSchema } from "./schema";
15
15
  import { compactionItemToText } from "./compaction";
16
+ import { previousResponseReplayPrefixLength } from "./state";
16
17
  import { decodeReasoningEnvelope } from "./reasoning-envelope";
17
18
  import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
18
19
 
@@ -198,6 +199,10 @@ function outputToToolResultContent(output: string | unknown[] | undefined): stri
198
199
  return parts;
199
200
  }
200
201
 
202
+ function toolOutputContainsEncryptedContent(output: string | unknown[] | undefined): boolean {
203
+ return Array.isArray(output) && output.some(raw => isObj(raw) && raw.type === "encrypted_content");
204
+ }
205
+
201
206
  /**
202
207
  * codex-rs ImageDetail allows "original", but chat-completions providers only accept
203
208
  * auto|low|high on image_url.detail — degrade "original" to "high" (the codex default).
@@ -220,6 +225,7 @@ function findToolById(messages: OcxMessage[], callId: string): { name: string; n
220
225
  const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
221
226
 
222
227
  export function parseRequest(body: unknown): OcxParsedRequest {
228
+ const replayedInputPrefixLength = previousResponseReplayPrefixLength(body);
223
229
  const parsed = responsesRequestSchema.safeParse(body);
224
230
  if (!parsed.success) {
225
231
  throw new Error(`responses parse error: ${parsed.error.message}`);
@@ -248,6 +254,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
248
254
  // Remote compaction v2: the input tail carries `{type:"compaction_trigger"}` and Codex expects a
249
255
  // synthetic `{type:"compaction"}` output item (src/responses/compaction.ts). Flagged for the server.
250
256
  let compactionRequest = false;
257
+ let contextCompactionBoundary = false;
251
258
 
252
259
  if (typeof data.instructions === "string" && data.instructions.length > 0) {
253
260
  systemPrompt.push(data.instructions);
@@ -256,7 +263,8 @@ export function parseRequest(body: unknown): OcxParsedRequest {
256
263
  if (typeof data.input === "string") {
257
264
  messages.push({ role: "user", content: data.input, timestamp: now });
258
265
  } else if (data.input) {
259
- for (const item of data.input) {
266
+ for (let inputIndex = 0; inputIndex < data.input.length; inputIndex++) {
267
+ const item = data.input[inputIndex];
260
268
  const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined);
261
269
 
262
270
  if (effectiveType === "compaction_trigger") {
@@ -281,7 +289,10 @@ export function parseRequest(body: unknown): OcxParsedRequest {
281
289
  // the routed model keeps the compacted context; real OpenAI-encrypted blobs degrade to a note.
282
290
  // `context_compaction` (encrypted_content optional) is codex-rs's local-compaction marker;
283
291
  // with no payload it is a pure marker (the summary follows as its own user message), so it
284
- // is dropped silently. It must NOT flag _compactionRequest.
292
+ // is dropped silently. It must NOT flag _compactionRequest. Only a marker newly appended in
293
+ // this request starts a provider-private context epoch; markers inside the prefix restored by
294
+ // previous_response_id were already acknowledged on the turn that introduced them.
295
+ if (inputIndex >= replayedInputPrefixLength) contextCompactionBoundary = true;
285
296
  const encrypted = (item as { encrypted_content?: unknown }).encrypted_content;
286
297
  if (effectiveType === "context_compaction" && typeof encrypted !== "string") continue;
287
298
  pendingReasoning.length = 0;
@@ -323,7 +334,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
323
334
  }
324
335
 
325
336
  if (effectiveType === "message") {
326
- const msg = item as { role?: string; content?: unknown };
337
+ const msg = item as { role?: string; content?: unknown; phase?: "commentary" | "final_answer" };
327
338
  switch (msg.role) {
328
339
  case "system": {
329
340
  pendingReasoning.length = 0;
@@ -346,6 +357,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
346
357
  content: pendingReasoning.length > 0
347
358
  ? [...pendingReasoning.map(entry => entry.part), ...parts]
348
359
  : parts,
360
+ ...(msg.phase ? { phase: msg.phase } : {}),
349
361
  model: data.model,
350
362
  timestamp: now,
351
363
  });
@@ -443,13 +455,10 @@ export function parseRequest(body: unknown): OcxParsedRequest {
443
455
  }
444
456
 
445
457
  if (effectiveType === "web_search_call") {
446
- // Replayed hosted web-search evidence. Textify it into assistant history so the model
447
- // knows the search already ran (prevents re-search loops); there is no output to pair.
448
- const call = item as { action?: { type?: string; query?: string } };
449
- const query = typeof call.action?.query === "string" ? call.action.query : "";
450
- assistantHolderWithReasoning().content.push({
451
- type: "text", text: query ? `[web search performed: ${query}]` : "[web search performed]",
452
- });
458
+ // Replayed hosted web-search evidence has no paired result payload that routed providers can
459
+ // consume. Keep it out of assistant-visible text: the old marker was useful as an internal
460
+ // loop hint, but when no sidecar is available the model can echo it as a fake answer.
461
+ pendingReasoning.length = 0;
453
462
  continue;
454
463
  }
455
464
 
@@ -504,6 +513,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
504
513
  role: "toolResult", toolCallId: output.call_id,
505
514
  toolName: toolInfo.name, toolNamespace: toolInfo.namespace,
506
515
  content: outputToToolResultContent(output.output), isError: false, timestamp: now,
516
+ ...(toolOutputContainsEncryptedContent(output.output) ? { containsEncryptedContent: true } : {}),
507
517
  });
508
518
  continue;
509
519
  }
@@ -518,6 +528,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
518
528
  // Same payload shape as function_call_output (codex-rs FunctionCallOutputPayload):
519
529
  // string or content items — normalize arrays instead of leaking raw wire blocks.
520
530
  content: outputToToolResultContent(output.output), isError: false, timestamp: now,
531
+ ...(toolOutputContainsEncryptedContent(output.output) ? { containsEncryptedContent: true } : {}),
521
532
  });
522
533
  }
523
534
  }
@@ -586,6 +597,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
586
597
  ...(webSearch ? { _webSearch: webSearch } : {}),
587
598
  ...(structuredOutput ? { _structuredOutput: true } : {}),
588
599
  ...(compactionRequest ? { _compactionRequest: true } : {}),
600
+ ...(contextCompactionBoundary ? { _contextCompactionBoundary: true } : {}),
589
601
  };
590
602
  }
591
603
 
@@ -47,6 +47,7 @@ const assistantMessageItemSchema = z.object({
47
47
  type: z.literal("message").optional(),
48
48
  role: z.literal("assistant"),
49
49
  content: z.union([z.string(), z.array(outputContentBlockSchema)]).optional(),
50
+ phase: z.enum(["commentary", "final_answer"]).optional(),
50
51
  });
51
52
  const reasoningItemSchema = z.object({
52
53
  type: z.literal("reasoning"),
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { atomicWriteFile, getConfigDir } from "../config";
4
+ import type { OcxProviderContinuationState } from "../types";
4
5
 
5
6
  const MAX_STORED_RESPONSES = 1_000;
6
7
  const RESPONSE_TTL_MS = 60 * 60 * 1_000;
@@ -13,11 +14,18 @@ const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
13
14
  interface StoredResponseState {
14
15
  createdAt: number;
15
16
  items: unknown[];
17
+ /** v2 provider-keyed continuation metadata. */
18
+ providers?: OcxProviderContinuationState;
19
+ /** v1 Cursor-only metadata, accepted only while loading old snapshots. */
16
20
  conversationId?: string;
17
21
  cursorCheckpointUsable?: boolean;
18
22
  }
19
23
 
20
24
  const states = new Map<string, StoredResponseState>();
25
+ // Expansion provenance must stay proxy-private: a WeakMap distinguishes replayed history from the
26
+ // newly appended input suffix without adding an unknown field that native passthrough could send
27
+ // upstream. The parser uses this boundary to acknowledge historical compaction markers exactly once.
28
+ const replayedInputPrefixLengths = new WeakMap<object, number>();
21
29
  let loaded = false;
22
30
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
23
31
  let pendingPersistPath: string | null = null;
@@ -44,14 +52,28 @@ function ensureLoaded(): void {
44
52
  const path = snapshotPath();
45
53
  if (!existsSync(path)) return;
46
54
  const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
47
- if (raw.version !== 1 || !Array.isArray(raw.states)) return;
55
+ if ((raw.version !== 1 && raw.version !== 2) || !Array.isArray(raw.states)) return;
48
56
  for (const entry of raw.states) {
49
57
  if (!Array.isArray(entry) || entry.length !== 2) continue;
50
58
  const [id, state] = entry as [unknown, unknown];
51
59
  if (typeof id !== "string" || !state || typeof state !== "object") continue;
52
60
  const rec = state as StoredResponseState;
53
61
  if (typeof rec.createdAt !== "number" || !Array.isArray(rec.items)) continue;
54
- states.set(id, rec);
62
+ const providers = rec.providers ?? (rec.conversationId
63
+ ? {
64
+ cursor: {
65
+ conversationId: rec.conversationId,
66
+ ...(rec.cursorCheckpointUsable !== undefined
67
+ ? { checkpointUsable: rec.cursorCheckpointUsable }
68
+ : {}),
69
+ },
70
+ }
71
+ : undefined);
72
+ states.set(id, {
73
+ createdAt: rec.createdAt,
74
+ items: rec.items,
75
+ ...(providers ? { providers } : {}),
76
+ });
55
77
  }
56
78
  pruneResponses();
57
79
  } catch {
@@ -81,7 +103,7 @@ function persistNow(path: string): void {
81
103
  // mkdirSync's mode only applies on creation — re-harden an existing config dir so the
82
104
  // conversation-content snapshot never lands in a group/world-readable directory.
83
105
  try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
84
- atomicWriteFile(path, JSON.stringify({ version: 1, states: entries }));
106
+ atomicWriteFile(path, JSON.stringify({ version: 2, states: entries }));
85
107
  } catch {
86
108
  /* best-effort: disk trouble must never affect request handling */
87
109
  }
@@ -131,23 +153,36 @@ export function expandPreviousResponseInput(body: unknown): unknown {
131
153
  pruneResponses();
132
154
  const previous = states.get(previousId);
133
155
  if (!previous) return body;
134
- return {
156
+ const expanded = {
135
157
  ...request,
136
158
  input: [...previous.items, ...inputItems(request.input)],
137
159
  };
160
+ replayedInputPrefixLengths.set(expanded, previous.items.length);
161
+ return expanded;
162
+ }
163
+
164
+ /** Number of leading input items restored from previous_response_id state for this exact body. */
165
+ export function previousResponseReplayPrefixLength(body: unknown): number {
166
+ if (!body || typeof body !== "object" || Array.isArray(body)) return 0;
167
+ return replayedInputPrefixLengths.get(body) ?? 0;
138
168
  }
139
169
 
140
170
  export function previousResponseConversationId(responseId: string | undefined): string | undefined {
171
+ return previousResponseProviderState(responseId)?.cursor?.conversationId;
172
+ }
173
+
174
+ export function previousResponseProviderState(responseId: string | undefined): OcxProviderContinuationState | undefined {
141
175
  if (!responseId) return undefined;
142
176
  ensureLoaded();
143
177
  pruneResponses();
144
- return states.get(responseId)?.conversationId;
178
+ const providers = states.get(responseId)?.providers;
179
+ return providers ? structuredClone(providers) : undefined;
145
180
  }
146
181
 
147
182
  export function rememberResponseState(
148
183
  requestBody: unknown,
149
184
  response: { id?: unknown; output?: unknown; status?: unknown },
150
- conversationId?: string,
185
+ providerState?: OcxProviderContinuationState | string,
151
186
  opts?: { force?: boolean },
152
187
  ): void {
153
188
  if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return;
@@ -161,6 +196,14 @@ export function rememberResponseState(
161
196
  if (typeof response.id !== "string" || !Array.isArray(response.output)) return;
162
197
  if (response.status !== undefined && response.status !== "completed") return;
163
198
  ensureLoaded();
199
+ const normalizedProviderState: OcxProviderContinuationState = typeof providerState === "string"
200
+ ? { cursor: { conversationId: providerState } }
201
+ : structuredClone(providerState ?? {});
202
+ if (normalizedProviderState.cursor?.conversationId) {
203
+ normalizedProviderState.cursor.checkpointUsable = !response.output.some(item => {
204
+ return !!item && typeof item === "object" && (item as { type?: unknown }).type === "function_call";
205
+ });
206
+ }
164
207
  states.set(response.id, {
165
208
  createdAt: now(),
166
209
  items: [...inputItems(request.input), ...response.output],
@@ -169,10 +212,7 @@ export function rememberResponseState(
169
212
  // checkpoint/cache is safe to reuse: a turn that ended with a pending client tool call produced an
170
213
  // incomplete agent turn on the Cursor side (we suspended without a real mcpResult), so its
171
214
  // checkpoint must not be reused — but the conversation id string itself is still valid.
172
- ...(conversationId ? { conversationId } : {}),
173
- cursorCheckpointUsable: !response.output.some(item => {
174
- return !!item && typeof item === "object" && (item as { type?: unknown }).type === "function_call";
175
- }),
215
+ ...(Object.keys(normalizedProviderState).length > 0 ? { providers: normalizedProviderState } : {}),
176
216
  });
177
217
  pruneResponses();
178
218
  schedulePersist();
package/src/router.ts CHANGED
@@ -49,6 +49,7 @@ export function knownModelIdsForProvider(provName: string, prov: OcxProviderConf
49
49
  registry?.modelReasoningEfforts,
50
50
  registry?.modelDefaultReasoningEfforts,
51
51
  registry?.modelReasoningEffortMap,
52
+ registry?.modelMaxOutputTokens,
52
53
  ]) {
53
54
  for (const id of Object.keys(map ?? {})) ids.add(id);
54
55
  }
@@ -141,6 +142,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
141
142
  const modelMaxInputTokens = providerName === OPENAI_API_PROVIDER_ID
142
143
  ? mergePositiveNumberCaps(registryEntry.modelMaxInputTokens, provider.modelMaxInputTokens)
143
144
  : mergeRecordFill(registryEntry.modelMaxInputTokens, provider.modelMaxInputTokens);
145
+ const modelMaxOutputTokens = mergeRecordFill(registryEntry.modelMaxOutputTokens, provider.modelMaxOutputTokens);
144
146
  const noVisionModels = mergeStringArray(registryEntry.noVisionModels, provider.noVisionModels);
145
147
  const noReasoningModels = mergeStringArray(registryEntry.noReasoningModels, provider.noReasoningModels);
146
148
  const noTemperatureModels = mergeStringArray(registryEntry.noTemperatureModels, provider.noTemperatureModels);
@@ -183,9 +185,13 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
183
185
  // opt-in, while an explicit user `false` keeps overriding registry `true`.
184
186
  ...(provider.parallelToolCalls === undefined && registryEntry.parallelToolCalls !== undefined ? { parallelToolCalls: registryEntry.parallelToolCalls } : {}),
185
187
  ...(provider.promptCacheKey === undefined && registryEntry.promptCacheKey !== undefined ? { promptCacheKey: registryEntry.promptCacheKey } : {}),
188
+ ...(provider.defaultMaxOutputTokens === undefined && registryEntry.defaultMaxOutputTokens !== undefined
189
+ ? { defaultMaxOutputTokens: registryEntry.defaultMaxOutputTokens }
190
+ : {}),
186
191
  ...(modelContextWindows ? { modelContextWindows } : {}),
187
192
  ...(modelInputModalities ? { modelInputModalities } : {}),
188
193
  ...(modelMaxInputTokens ? { modelMaxInputTokens } : {}),
194
+ ...(modelMaxOutputTokens ? { modelMaxOutputTokens } : {}),
189
195
  ...(modelReasoningEfforts ? { modelReasoningEfforts } : {}),
190
196
  ...(modelDefaultReasoningEfforts ? { modelDefaultReasoningEfforts } : {}),
191
197
  ...(reasoningEffortMap ? { reasoningEffortMap } : {}),
@@ -228,15 +234,17 @@ function routeResult(providerName: string, provider: OcxProviderConfig, modelId:
228
234
  };
229
235
  }
230
236
 
231
- export function routeModel(config: OcxConfig, modelId: string): RouteResult {
237
+ function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: boolean): RouteResult {
232
238
  const preservePhysicalComboProvider =
233
239
  hasOwnProvider(config.providers, COMBO_NAMESPACE)
234
240
  && Object.keys(config.combos ?? {}).length === 0;
235
- if (!preservePhysicalComboProvider) {
241
+ if (!bypassCombos && !preservePhysicalComboProvider) {
236
242
  const combo = tryPickComboModel(config, modelId);
237
243
  if (combo) {
238
244
  const concrete = `${combo.target.provider}/${combo.target.model}`;
239
- const routed = routeModel(config, concrete);
245
+ // The selected target is already a concrete provider/model reference. Resolve it without
246
+ // consulting combo aliases again, otherwise an alias that shadows the target can recurse.
247
+ const routed = routeModelInternal(config, concrete, true);
240
248
  return { ...routed, combo };
241
249
  }
242
250
  }
@@ -300,6 +308,10 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
300
308
  throw new Error(`No provider configured for model: ${modelId}`);
301
309
  }
302
310
 
311
+ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
312
+ return routeModelInternal(config, modelId, false);
313
+ }
314
+
303
315
  function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResult | undefined {
304
316
  for (const { providerNames, prefixes } of MODEL_PROVIDER_PATTERNS) {
305
317
  if (prefixes.some(prefix => modelId.startsWith(prefix))) {
@@ -2,6 +2,7 @@ import { timingSafeEqual } from "node:crypto";
2
2
  import { formatErrorResponse } from "../bridge";
3
3
  import {
4
4
  codexAutoStartEnabled,
5
+ positiveIntegerConfigError,
5
6
  positiveIntegerRecordConfigError,
6
7
  providerBaseUrlConfigError,
7
8
  providerHeadersConfigError,
@@ -227,6 +228,10 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
227
228
  if (headersError) return `provider ${name} ${headersError}`;
228
229
  const maxInputError = positiveIntegerRecordConfigError(raw.modelMaxInputTokens, "modelMaxInputTokens");
229
230
  if (maxInputError) return `provider ${name} ${maxInputError}`;
231
+ const defaultMaxOutputError = positiveIntegerConfigError(raw.defaultMaxOutputTokens, "defaultMaxOutputTokens");
232
+ if (defaultMaxOutputError) return `provider ${name} ${defaultMaxOutputError}`;
233
+ const maxOutputError = positiveIntegerRecordConfigError(raw.modelMaxOutputTokens, "modelMaxOutputTokens");
234
+ if (maxOutputError) return `provider ${name} ${maxOutputError}`;
230
235
  const openRouterError = openRouterRoutingConfigError(typed);
231
236
  if (openRouterError) return `provider ${name} ${openRouterError}`;
232
237
  if (typed.authMode === "local") {
@@ -293,6 +298,8 @@ export function safeConfigDTO(config: OcxConfig): unknown {
293
298
  "models",
294
299
  "contextWindow",
295
300
  "modelContextWindows",
301
+ "defaultMaxOutputTokens",
302
+ "modelMaxOutputTokens",
296
303
  "openRouterRouting",
297
304
  "modelOpenRouterRouting",
298
305
  "reasoningEfforts",
@@ -729,6 +729,12 @@ export async function handleClaudeMessages(
729
729
  return anthropicErrorResponse(502, error?.message ?? "upstream request failed", "api_error");
730
730
  }
731
731
  const message = responsesJsonToAnthropicMessage(json, requestedModel);
732
+ if ((message as Rec).type === "error") {
733
+ return new Response(JSON.stringify(message), {
734
+ status: 529,
735
+ headers: { "Content-Type": "application/json", "Retry-After": "2" },
736
+ });
737
+ }
732
738
  if (!stream) {
733
739
  return new Response(JSON.stringify(message), { status: 200, headers: { "Content-Type": "application/json" } });
734
740
  }
@@ -134,9 +134,12 @@ const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
134
134
  // Source invariant for tests/passthrough-abort.test.ts after the pure module split:
135
135
  // if (isEventStream && upstreamResponse.body) {
136
136
  // upstreamResponse.body.tee()
137
+ // const repairConfig = route.provider.responsesItemIdRepair;
138
+ // const repairedBody = hasResponsesItemIdRepair(repairConfig)
137
139
  // process.platform === "win32"
140
+ // && !hasResponsesItemIdRepair(repairConfig)
138
141
  // ? nativeBody
139
- // relaySseWithFailedTail(nativeBody, upstream)
142
+ // relaySseWithFailedTail(repairedBody, upstream)
140
143
  // new Response(clientBody
141
144
  // markNativePassthroughSseResponse
142
145
  // const body = relayWithAbort(upstreamResponse.body, upstream);
@@ -251,7 +254,7 @@ export function startServer(port?: number) {
251
254
  return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
252
255
  }
253
256
  const goModels = await fetchAllModels(config);
254
- const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels, visibleNativeSlugs } = await import("../codex/catalog");
257
+ const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs } = await import("../codex/catalog");
255
258
  const nativeSlugs = nativeOpenAiSlugs();
256
259
  const goEnabled = filterCatalogVisibleModels(goModels, config);
257
260
  const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
@@ -301,7 +304,7 @@ export function startServer(port?: number) {
301
304
  // (pure availability list — disabled natives are omitted entirely).
302
305
  const data = [
303
306
  ...visibleNativeSlugs(config).map(id => ({ id, object: "model", created: 0, owned_by: "openai" })),
304
- ...goOrdered.map(m => ({ id: `${m.provider}/${m.id}`, object: "model", created: 0, owned_by: m.owned_by ?? m.provider })),
307
+ ...uniqueCatalogModelsForRawPublicList(goOrdered).map(m => ({ id: m.alias ?? `${m.provider}/${m.id}`, object: "model", created: 0, owned_by: m.owned_by ?? m.provider })),
305
308
  ];
306
309
  return jsonResponse({ object: "list", data }, 200, req, config);
307
310
  }