@coseung2/opencodex 2.8.0-cs.15 → 2.8.0-cs.17

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 (61) hide show
  1. package/gui/dist/assets/{index-BhXIu7c0.js → index-Ch-99jy3.js} +2 -2
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/packages/ocx-notch/README.md +3 -1
  5. package/src/adapters/base.ts +12 -0
  6. package/src/adapters/identity.ts +1 -1
  7. package/src/adapters/kiro-calibration.ts +83 -0
  8. package/src/adapters/kiro-constants.ts +11 -2
  9. package/src/adapters/kiro-errors.ts +11 -0
  10. package/src/adapters/kiro-events.ts +19 -1
  11. package/src/adapters/kiro-thinking.ts +18 -2
  12. package/src/adapters/kiro-tools.ts +12 -3
  13. package/src/adapters/kiro.ts +300 -78
  14. package/src/adapters/openai-chat.ts +1 -42
  15. package/src/adapters/openai-responses.ts +126 -9
  16. package/src/adapters/xai-schema-analysis.ts +78 -0
  17. package/src/adapters/xai-tool-schema.ts +274 -0
  18. package/src/adapters/xai-web-search.ts +138 -0
  19. package/src/bridge.ts +61 -6
  20. package/src/cli/observe.ts +18 -3
  21. package/src/codex/app-server-processes.ts +3 -5
  22. package/src/codex/catalog/effort.ts +4 -2
  23. package/src/codex/catalog/metadata.ts +42 -9
  24. package/src/codex/catalog/parsing.ts +17 -2
  25. package/src/codex/catalog/provider-fetch.ts +9 -3
  26. package/src/codex/catalog/sync.ts +11 -5
  27. package/src/codex/data/upstream-models.json +169 -0
  28. package/src/grok/inject.ts +1 -1
  29. package/src/lib/errors.ts +18 -0
  30. package/src/lib/token-estimate.ts +42 -38
  31. package/src/lib/translator-budget.ts +34 -0
  32. package/src/oauth/index.ts +10 -4
  33. package/src/oauth/kiro.ts +71 -6
  34. package/src/oauth/store.ts +3 -1
  35. package/src/oauth/types.ts +4 -0
  36. package/src/providers/derive.ts +7 -5
  37. package/src/providers/opencode-go-transport.ts +59 -0
  38. package/src/providers/quota.ts +68 -60
  39. package/src/providers/registry.ts +41 -10
  40. package/src/providers/xai-transport.ts +10 -0
  41. package/src/responses/compaction.ts +8 -1
  42. package/src/responses/namespace-aliases.ts +56 -0
  43. package/src/responses/parser.ts +12 -0
  44. package/src/responses/reasoning-envelope.ts +9 -1
  45. package/src/responses/snapshot-policy.ts +108 -0
  46. package/src/responses/state.ts +23 -10
  47. package/src/responses/turn-termination.ts +108 -0
  48. package/src/responses/xai-custom-tool-compat.ts +237 -0
  49. package/src/server/grok-responses-snapshot-repair.ts +338 -0
  50. package/src/server/index.ts +2 -1
  51. package/src/server/relay-eager.ts +1 -0
  52. package/src/server/request-log-conversation.ts +8 -0
  53. package/src/server/request-log.ts +5 -4
  54. package/src/server/responses/core.ts +233 -16
  55. package/src/server/responses-image-gen-repair.ts +2 -2
  56. package/src/server/sse-payload-rewrite.ts +20 -3
  57. package/src/types.ts +10 -1
  58. package/src/usage/cost.ts +0 -0
  59. package/src/usage/expected-prices.ts +7 -0
  60. package/src/usage/log.ts +1 -2
  61. package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
@@ -0,0 +1,138 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+ import { isXaiResponsesDestination } from "../providers/xai-transport";
3
+
4
+ const CODEX_WEB_SEARCH_TOOL = "web_search";
5
+ const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview";
6
+
7
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
8
+ return !!value && typeof value === "object" && !Array.isArray(value);
9
+ }
10
+
11
+ function isCodexWebSearchToolType(value: unknown): boolean {
12
+ return value === CODEX_WEB_SEARCH_TOOL || value === CODEX_WEB_SEARCH_PREVIEW_TOOL;
13
+ }
14
+
15
+ type ToolGroupRewrite = { tools: unknown[]; changed: boolean };
16
+
17
+ /**
18
+ * Translate Codex hosted-search fields to xAI's public Responses shape.
19
+ * `external_web_access:false` means cached/index-only search; xAI has no equivalent, so omit the
20
+ * whole tool rather than silently widening network access. Live search keeps supported fields and
21
+ * drops only the private fields the xAI endpoints reject.
22
+ */
23
+ function normalizeToolGroup(tools: unknown[]): ToolGroupRewrite {
24
+ const normalized: unknown[] = [];
25
+ let changed = false;
26
+ for (const tool of tools) {
27
+ if (!isPlainObject(tool) || !isCodexWebSearchToolType(tool.type)) {
28
+ normalized.push(tool);
29
+ continue;
30
+ }
31
+ const hasExternalAccess = Object.hasOwn(tool, "external_web_access");
32
+ if (hasExternalAccess && tool.external_web_access !== true) {
33
+ changed = true;
34
+ continue;
35
+ }
36
+ const searchContentTypes = Array.isArray(tool.search_content_types) ? tool.search_content_types : undefined;
37
+ const enableImageSearch = searchContentTypes?.includes("image") === true;
38
+ const next: Record<string, unknown> = { ...tool, type: CODEX_WEB_SEARCH_TOOL };
39
+ delete next.external_web_access;
40
+ delete next.search_context_size;
41
+ if (enableImageSearch && !Object.hasOwn(next, "enable_image_search")) next.enable_image_search = true;
42
+ const toolChanged = Object.keys(next).length !== Object.keys(tool).length
43
+ || Object.entries(next).some(([key, value]) => tool[key] !== value);
44
+ changed ||= toolChanged;
45
+ normalized.push(toolChanged ? next : tool);
46
+ }
47
+ return { tools: changed ? normalized : tools, changed };
48
+ }
49
+
50
+ function hasWebSearchTool(body: Record<string, unknown>): boolean {
51
+ if (Array.isArray(body.tools) && body.tools.some(tool => isPlainObject(tool) && isCodexWebSearchToolType(tool.type))) return true;
52
+ return Array.isArray(body.input) && body.input.some(item =>
53
+ isPlainObject(item)
54
+ && item.type === "additional_tools"
55
+ && Array.isArray(item.tools)
56
+ && item.tools.some(tool => isPlainObject(tool) && isCodexWebSearchToolType(tool.type))
57
+ );
58
+ }
59
+
60
+ function hasAnyDeclaredTool(body: Record<string, unknown>): boolean {
61
+ if (Array.isArray(body.tools) && body.tools.length > 0) return true;
62
+ return Array.isArray(body.input) && body.input.some(item =>
63
+ isPlainObject(item)
64
+ && item.type === "additional_tools"
65
+ && Array.isArray(item.tools)
66
+ && item.tools.length > 0
67
+ );
68
+ }
69
+
70
+ function normalizeToolChoice(body: Record<string, unknown>): Record<string, unknown> {
71
+ const choice = body.tool_choice;
72
+ if (choice === undefined) return body;
73
+ const hasSearch = hasWebSearchTool(body);
74
+ if (isPlainObject(choice) && isCodexWebSearchToolType(choice.type)) {
75
+ if (!hasSearch) return { ...body, tool_choice: "none" };
76
+ return choice.type === CODEX_WEB_SEARCH_TOOL
77
+ ? body
78
+ : { ...body, tool_choice: { ...choice, type: CODEX_WEB_SEARCH_TOOL } };
79
+ }
80
+ if (isPlainObject(choice) && choice.type === "allowed_tools" && Array.isArray(choice.tools)) {
81
+ let changed = false;
82
+ const tools: unknown[] = [];
83
+ for (const tool of choice.tools) {
84
+ if (!isPlainObject(tool) || !isCodexWebSearchToolType(tool.type)) {
85
+ tools.push(tool);
86
+ continue;
87
+ }
88
+ if (!hasSearch) {
89
+ changed = true;
90
+ continue;
91
+ }
92
+ if (tool.type === CODEX_WEB_SEARCH_PREVIEW_TOOL) {
93
+ tools.push({ ...tool, type: CODEX_WEB_SEARCH_TOOL });
94
+ changed = true;
95
+ } else tools.push(tool);
96
+ }
97
+ if (!changed) return body;
98
+ return { ...body, tool_choice: tools.length > 0 ? { ...choice, tools } : "none" };
99
+ }
100
+ if (choice === "required" && !hasAnyDeclaredTool(body)) return { ...body, tool_choice: "none" };
101
+ return body;
102
+ }
103
+
104
+ /** Normalize both public xAI and Grok CLI Responses destinations; custom gateways are untouched. */
105
+ export function normalizeXaiResponsesWebSearch(
106
+ body: unknown,
107
+ provider: Pick<OcxProviderConfig, "baseUrl">,
108
+ ): unknown {
109
+ if (!isXaiResponsesDestination(provider) || !isPlainObject(body)) return body;
110
+ let next: Record<string, unknown> = body;
111
+ if (Array.isArray(body.tools)) {
112
+ const rewritten = normalizeToolGroup(body.tools);
113
+ if (rewritten.changed) {
114
+ next = { ...next };
115
+ if (rewritten.tools.length > 0) next.tools = rewritten.tools;
116
+ else delete next.tools;
117
+ }
118
+ }
119
+ if (Array.isArray(next.input)) {
120
+ let inputChanged = false;
121
+ const input: unknown[] = [];
122
+ for (const item of next.input) {
123
+ if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) {
124
+ input.push(item);
125
+ continue;
126
+ }
127
+ const rewritten = normalizeToolGroup(item.tools);
128
+ if (!rewritten.changed) {
129
+ input.push(item);
130
+ continue;
131
+ }
132
+ inputChanged = true;
133
+ if (rewritten.tools.length > 0) input.push({ ...item, tools: rewritten.tools });
134
+ }
135
+ if (inputChanged) next = { ...next, input };
136
+ }
137
+ return normalizeToolChoice(next);
138
+ }
package/src/bridge.ts CHANGED
@@ -258,11 +258,11 @@ export function bridgeToResponsesSSE(
258
258
  terminalReported = true;
259
259
  try { options?.onTerminal?.(status); } catch { /* terminal metrics must not break the stream */ }
260
260
  };
261
- // RC3 keep-alive: Codex's idle timer is timeout(idle_timeout, stream.next()) over an
262
- // eventsource_stream; ANY received event re-arms it, while an unknown type is ignored
263
- // (responses.rs `_ => Ok(None)`). Emit a parser-ignored `response.heartbeat` whenever the
264
- // *wire* has been silent, even if invisible adapter heartbeats are still flowing (web-search
265
- // buffering + raw-byte progress). Upstream activity only resets the stall watchdog.
261
+ // RC3 keep-alive: keep the event-stream transport active without inventing a Responses event
262
+ // variant. Strict clients such as Grok Build deserialize every typed event and reject unknown
263
+ // `response.heartbeat` frames, while an SSE comment is transport-only and still keeps the
264
+ // connection alive. Emit it whenever the *wire* has been silent, even if invisible adapter
265
+ // heartbeats are still flowing (web-search buffering + raw-byte progress).
266
266
  let upstreamActivity = false;
267
267
  let wireActivity = false;
268
268
  let beat: unknown;
@@ -328,7 +328,7 @@ export function bridgeToResponsesSSE(
328
328
  ...(endTurn !== undefined ? { end_turn: endTurn } : {}),
329
329
  });
330
330
 
331
- const heartbeatFrame = encoder.encode('event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n');
331
+ const heartbeatFrame = encoder.encode(": opencodex heartbeat\n\n");
332
332
  let stallTicks = 0;
333
333
  const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec);
334
334
  const maxStallTicks = Math.ceil((stallSec * 1000) / heartbeatMs);
@@ -400,6 +400,29 @@ export function bridgeToResponsesSSE(
400
400
  retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning");
401
401
  outputIndex++;
402
402
  };
403
+ // Kiro reasoning round-trip. Kiro sends its encrypted blob at the END of a turn, while the
404
+ // assistant message is still open, so this CANNOT emit on arrival: the open message still
405
+ // owns `outputIndex` (it only advances on close), and an item emitted here would both reuse
406
+ // that index and land BEFORE the message — where the parser's backwards pairing drops it as
407
+ // orphaned. Stash it and flush after `done` has closed every open item instead.
408
+ let pendingKiroRedacted: string | undefined;
409
+ let pendingKiroRedactedBytes = 0;
410
+ const flushKiroRedactedReasoning = () => {
411
+ if (!pendingKiroRedacted) return;
412
+ const previousBytes = pendingKiroRedactedBytes;
413
+ const encrypted = encodeReasoningEnvelope({ krc: pendingKiroRedacted });
414
+ const reservation = budget?.reserveTransient(bytesOf(encrypted), { kind: "reasoning" });
415
+ pendingKiroRedacted = undefined;
416
+ pendingKiroRedactedBytes = 0;
417
+ reservation?.commitRetained();
418
+ budget?.releaseRetained(previousBytes, { kind: "reasoning" });
419
+ const itemId = `rs_${uuid()}`;
420
+ const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted };
421
+ emit("response.output_item.added", { output_index: outputIndex, item });
422
+ emit("response.output_item.done", { output_index: outputIndex, item });
423
+ retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning");
424
+ outputIndex++;
425
+ };
403
426
  // Full assistant text of a compaction turn (across message boundaries) — becomes the
404
427
  // synthetic compaction item's payload on done.
405
428
  let compactionText = "";
@@ -817,6 +840,12 @@ export function bridgeToResponsesSSE(
817
840
  pendingRedacted.push(event.data);
818
841
  break;
819
842
  }
843
+ case "kiro_redacted_reasoning": {
844
+ // Stash only — see flushKiroRedactedReasoning. One blob per turn, so last wins.
845
+ pendingKiroRedactedBytes = replaceRetainedString(pendingKiroRedactedBytes, event.data, "reasoning");
846
+ pendingKiroRedacted = event.data;
847
+ break;
848
+ }
820
849
  case "reasoning_raw_delta": {
821
850
  if (options?.hideThinkingSummary) {
822
851
  ({ value: hiddenRawReasoningText, bytes: hiddenRawReasoningBytes } = appendString(
@@ -987,6 +1016,9 @@ export function bridgeToResponsesSSE(
987
1016
  // Redacted-only turns (or hidden thinking without a trailing signature event) still
988
1017
  // need their envelope-only reasoning item so the blocks replay next turn.
989
1018
  flushHiddenReasoningEnvelope();
1019
+ // After every close above, so the blob lands AFTER the assistant message it belongs
1020
+ // to and the parser's backwards pairing finds it.
1021
+ flushKiroRedactedReasoning();
990
1022
  if (options?.compaction) {
991
1023
  // Exactly one compaction item per turn; codex-rs takes the first and fatals on 0.
992
1024
  const item = {
@@ -1288,6 +1320,10 @@ export function buildResponseJSON(
1288
1320
  let batchSignatureBytes = 0;
1289
1321
  let batchRedacted: string[] = [];
1290
1322
  let batchRedactedBytes = 0;
1323
+ // Kiro reasoning blob, held until after the trailing flushes so it lands AFTER the assistant
1324
+ // message (see the streaming path). Retained because it outlives releaseTranslatedEvent.
1325
+ let batchKiroRedacted: string | undefined;
1326
+ let batchKiroRedactedBytes = 0;
1291
1327
  let currentToolCallId = "";
1292
1328
  let currentToolCallName = "";
1293
1329
  let currentToolCallArgs = "";
@@ -1455,6 +1491,16 @@ export function buildResponseJSON(
1455
1491
  }
1456
1492
  batchRedacted.push(e.data);
1457
1493
  break;
1494
+ case "kiro_redacted_reasoning":
1495
+ // Stash only — pushed after the trailing flushes. One blob per turn, so last wins.
1496
+ {
1497
+ const dataBytes = bytesOf(e.data);
1498
+ budget?.chargeRetained(dataBytes, { kind: "reasoning" });
1499
+ if (batchKiroRedactedBytes > 0) budget?.releaseRetained(batchKiroRedactedBytes, { kind: "reasoning" });
1500
+ batchKiroRedactedBytes = dataBytes;
1501
+ }
1502
+ batchKiroRedacted = e.data;
1503
+ break;
1458
1504
  case "reasoning_raw_delta":
1459
1505
  if (currentText) flushText("commentary");
1460
1506
  if (currentSummaryReasoning) flushSummaryReasoning();
@@ -1553,6 +1599,15 @@ export function buildResponseJSON(
1553
1599
  flushRawReasoning();
1554
1600
  // Open tool call on a failed/incomplete turn must not land as status:"completed".
1555
1601
  if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent ? "incomplete" : "completed");
1602
+ if (batchKiroRedacted) {
1603
+ // pushOutput reserves the item itself and releases the retained raw blob it replaces.
1604
+ pushOutput({
1605
+ type: "reasoning", id: `rs_${uuid()}`, summary: [],
1606
+ encrypted_content: encodeReasoningEnvelope({ krc: batchKiroRedacted }),
1607
+ }, batchKiroRedactedBytes, "reasoning");
1608
+ batchKiroRedacted = undefined;
1609
+ batchKiroRedactedBytes = 0;
1610
+ }
1556
1611
  // A truncated turn must never be installed as replacement history: emit the
1557
1612
  // compaction item only when the turn actually completed (#422).
1558
1613
  if (
@@ -21,7 +21,16 @@ const USAGE = `Usage:
21
21
  ocx observe claude-inbound [--limit <n>] [--json]
22
22
  ocx observe injection [--limit <n>] [--json]`;
23
23
 
24
- type LogEntry = Record<string, unknown> & { id?: string | number; timestamp?: string; provider?: string; model?: string; status?: number };
24
+ type LogEntry = Record<string, unknown> & {
25
+ id?: string | number;
26
+ timestamp?: string;
27
+ provider?: string;
28
+ model?: string;
29
+ status?: number;
30
+ displayMetrics?: {
31
+ tokPerSecond?: { kind?: string; value?: number; estimated?: boolean };
32
+ };
33
+ };
25
34
 
26
35
  function query(params: Record<string, string | number | undefined>): string {
27
36
  const search = new URLSearchParams();
@@ -43,8 +52,14 @@ function formatLog(row: LogEntry): string {
43
52
  const time = String(row.timestamp ?? row.createdAt ?? "");
44
53
  const route = [row.provider, row.model].filter(Boolean).join("/");
45
54
  const status = row.status ?? row.statusCode ?? "?";
46
- const duration = row.durationMs !== undefined ? `${String(row.durationMs)}ms` : "";
47
- return [time, String(status), route, duration].filter(Boolean).join(" ");
55
+ const metric = row.displayMetrics?.tokPerSecond;
56
+ const rate = metric?.kind === "value"
57
+ && typeof metric.value === "number"
58
+ && Number.isFinite(metric.value)
59
+ && metric.value > 0
60
+ ? `${metric.estimated ? "~" : ""}${metric.value.toLocaleString("en-US", { maximumFractionDigits: 1 })} tok/s`
61
+ : "— tok/s";
62
+ return [time, String(status), route, rate].filter(Boolean).join(" ");
48
63
  }
49
64
 
50
65
  async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
@@ -329,11 +329,9 @@ export function listWindowsSnapshots(): ProcessSnapshot[] {
329
329
  const psCommand = [
330
330
  "$ErrorActionPreference='SilentlyContinue'",
331
331
  "$me=[System.Security.Principal.WindowsIdentity]::GetCurrent().Name",
332
- "Get-CimInstance Win32_Process | Where-Object {",
333
- " -not [string]::IsNullOrWhiteSpace($_.CommandLine) -and (",
334
- ` $_.CommandLine -match ${basenameMatch} -or`,
335
- ` $_.CommandLine -match ${codeModeMatch}`,
336
- " )",
332
+ "Get-CimInstance Win32_Process -Filter \"CommandLine LIKE '%codex%' OR CommandLine LIKE '%code-mode-host%'\" -Property Handle,ProcessId,CommandLine | Where-Object {",
333
+ ` $_.CommandLine -match ${basenameMatch} -or`,
334
+ ` $_.CommandLine -match ${codeModeMatch}`,
337
335
  "} | ForEach-Object {",
338
336
  " try {",
339
337
  " $o=Invoke-CimMethod -InputObject $_ -MethodName GetOwner -ErrorAction Stop",
@@ -33,7 +33,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
33
33
 
34
34
  import { readCatalog, readCodexCatalogPath } from "./parsing";
35
35
  import type { CatalogModel, RawEntry } from "./parsing";
36
- import { UPSTREAM_NATIVE_ENTRIES } from "./metadata";
36
+ import { NATIVE_GPT6_ASTRA_MODEL, UPSTREAM_NATIVE_ENTRIES } from "./metadata";
37
37
  import { loadBundledCodexCatalog } from "./bundled";
38
38
  import type { BundledCatalogDeps } from "./bundled";
39
39
  import { deriveEntry } from "./sync";
@@ -181,7 +181,9 @@ export function applyReasoningLevels(
181
181
  }
182
182
 
183
183
  export function isGpt56NativeSlug(slug: string): boolean {
184
- return !slug.includes("/") && slug.startsWith("gpt-5.6-");
184
+ // Historical name: this predicate protects the full native ladder/Responses Lite,
185
+ // not the context-window family. Astra uses its own pinned metadata and ceiling.
186
+ return !slug.includes("/") && (slug.startsWith("gpt-5.6-") || slug === NATIVE_GPT6_ASTRA_MODEL);
185
187
  }
186
188
 
187
189
  export function ensureGpt56ReasoningLevels(entry: RawEntry): void {
@@ -36,17 +36,20 @@ import type { RawEntry } from "./parsing";
36
36
  import { readCurrentCatalogOrCache, unique } from "./bundled";
37
37
 
38
38
  export const NATIVE_DAYBREAK_BLUE_MODEL = "gpt-daybreak-blue-latest";
39
+ export const NATIVE_GPT6_ASTRA_MODEL = "gpt-6-astra";
39
40
 
40
41
  export const NATIVE_OPENAI_MODELS = [
41
42
  "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark",
42
43
  "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
43
44
  NATIVE_DAYBREAK_BLUE_MODEL,
45
+ NATIVE_GPT6_ASTRA_MODEL,
44
46
  ];
45
47
 
46
48
  export const DOCUMENTED_NATIVE_OPENAI_ADDITIONS = [
47
49
  "gpt-5.3-codex-spark",
48
50
  "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
49
51
  NATIVE_DAYBREAK_BLUE_MODEL,
52
+ NATIVE_GPT6_ASTRA_MODEL,
50
53
  ];
51
54
 
52
55
  export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS);
@@ -69,17 +72,35 @@ export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: n
69
72
  "gpt-5.6-terra": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
70
73
  "gpt-5.6-luna": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
71
74
  [NATIVE_DAYBREAK_BLUE_MODEL]: { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
75
+ [NATIVE_GPT6_ASTRA_MODEL]: { contextWindow: 272_000, maxContextWindow: 872_000, maxInputTokens: 872_000 },
72
76
  };
73
77
 
74
- export function nativeOpenAiContextWindow(slug: string): number | undefined {
75
- return NATIVE_OPENAI_CONTEXT_OVERRIDES[slug]?.contextWindow
78
+ export type NativeModelConfig = Partial<Pick<OcxConfig, "providers" | "providerContextCaps">>;
79
+
80
+ function positiveNativeWindow(value: unknown): number | undefined {
81
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
82
+ }
83
+
84
+ /** Astra has its own opt-in ceiling; do not change the fork's existing GPT-5.6 policy. */
85
+ export function nativeOpenAiContextWindow(slug: string, config?: NativeModelConfig): number | undefined {
86
+ const raw = NATIVE_OPENAI_CONTEXT_OVERRIDES[slug]?.contextWindow
76
87
  ?? (typeof UPSTREAM_NATIVE_ENTRIES.get(slug)?.context_window === "number"
77
88
  ? UPSTREAM_NATIVE_ENTRIES.get(slug)!.context_window as number
78
89
  : undefined);
90
+ if (slug !== NATIVE_GPT6_ASTRA_MODEL || raw === undefined) return raw;
91
+ const provider = config?.providers?.openai;
92
+ const canonical = provider && isCanonicalOpenAiForwardProvider(provider) ? provider : undefined;
93
+ const overlay = positiveNativeWindow(canonical?.modelContextWindows?.[slug])
94
+ ?? positiveNativeWindow(canonical?.contextWindow);
95
+ const cap = positiveNativeWindow(config?.providerContextCaps?.openai);
96
+ return Math.min(overlay ?? cap ?? raw, 872_000, cap ?? Number.POSITIVE_INFINITY);
79
97
  }
80
98
 
81
- export function nativeOpenAiMaxInputTokens(slug: string): number | undefined {
82
- return NATIVE_OPENAI_CONTEXT_OVERRIDES[slug]?.maxInputTokens;
99
+ export function nativeOpenAiMaxInputTokens(slug: string, config?: NativeModelConfig): number | undefined {
100
+ const maximum = NATIVE_OPENAI_CONTEXT_OVERRIDES[slug]?.maxInputTokens;
101
+ return slug === NATIVE_GPT6_ASTRA_MODEL && maximum !== undefined
102
+ ? Math.min(maximum, nativeOpenAiContextWindow(slug, config) ?? maximum)
103
+ : maximum;
83
104
  }
84
105
 
85
106
  export function nativeInputModalities(slug: string): string[] {
@@ -138,10 +159,10 @@ export function desktopVisibleNativeSlugs(config: Pick<OcxConfig, "claudeCode" |
138
159
  return visibleNativeSlugs(config);
139
160
  }
140
161
 
141
- export function nativeModelRows(config: Pick<OcxConfig, "disabledModels">): Array<{ slug: string; disabled: boolean; contextWindow?: number }> {
162
+ export function nativeModelRows(config: Pick<OcxConfig, "disabledModels"> & NativeModelConfig): Array<{ slug: string; disabled: boolean; contextWindow?: number }> {
142
163
  const disabled = disabledNativeSlugs(config);
143
164
  return NATIVE_OPENAI_MODELS.map(slug => {
144
- const contextWindow = nativeOpenAiContextWindow(slug);
165
+ const contextWindow = nativeOpenAiContextWindow(slug, config);
145
166
  return { slug, disabled: disabled.has(slug), ...(contextWindow !== undefined ? { contextWindow } : {}) };
146
167
  });
147
168
  }
@@ -160,8 +181,19 @@ export const UPSTREAM_NATIVE_ENTRIES: Map<string, RawEntry> = (() => {
160
181
  ((upstreamModelsSnapshot as unknown as { models?: RawEntry[] }).models ?? [])
161
182
  .filter(m => typeof m.slug === "string"
162
183
  && SUPPORTED_NATIVE_OPENAI_SLUGS.has(m.slug as string)
163
- && (m.slug as string).startsWith("gpt-5.6-"))
164
- .map(m => [m.slug as string, m] as const),
184
+ && ((m.slug as string).startsWith("gpt-5.6-") || m.slug === NATIVE_GPT6_ASTRA_MODEL))
185
+ .map(m => {
186
+ // Astra ships the instructions template without base_instructions. Derive the
187
+ // catalog projection without changing the pinned prompt or widening replacement
188
+ // authority to older native models.
189
+ const messages = m.model_messages;
190
+ const template = messages && typeof messages === "object" && !Array.isArray(messages)
191
+ ? (messages as Record<string, unknown>).instructions_template : undefined;
192
+ const hasBase = typeof m.base_instructions === "string" && m.base_instructions.length > 0;
193
+ const projected = m.slug === NATIVE_GPT6_ASTRA_MODEL && !hasBase
194
+ && typeof template === "string" && template.length > 0 ? { ...m, base_instructions: template } : m;
195
+ return [m.slug as string, projected] as const;
196
+ }),
165
197
  );
166
198
  const sol = entries.get("gpt-5.6-sol");
167
199
  if (sol) {
@@ -185,7 +217,8 @@ export function upstreamNativeEntry(slug: string): RawEntry | null {
185
217
  export function shouldUpgradeToUpstreamEntry(entry: RawEntry): boolean {
186
218
  return typeof entry.slug === "string"
187
219
  && UPSTREAM_NATIVE_ENTRIES.has(entry.slug)
188
- && entry.display_name === entry.slug;
220
+ && (entry.display_name === entry.slug
221
+ || (entry.slug === NATIVE_GPT6_ASTRA_MODEL && entry.display_name === "GPT-6 Astra"));
189
222
  }
190
223
 
191
224
  export function nativeOpenAiSlugs(): string[] {
@@ -31,7 +31,7 @@ import { redactSecretString } from "../../lib/redact";
31
31
  import upstreamModelsSnapshot from "../data/upstream-models.json";
32
32
 
33
33
 
34
- import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES } from "./metadata";
34
+ import { NATIVE_GPT6_ASTRA_MODEL, NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeModelConfig } from "./metadata";
35
35
 
36
36
  export function legacyCatalogBackupPath(): string {
37
37
  return join(getConfigDir(), "catalog-backup.json");
@@ -206,6 +206,11 @@ const NO_FAST_TIER_NATIVE_SLUGS = new Set([
206
206
  ]);
207
207
 
208
208
  export function normalizeServiceTiers(entry: RawEntry): RawEntry {
209
+ if (entry.slug === NATIVE_GPT6_ASTRA_MODEL && Array.isArray(entry.service_tiers)) {
210
+ entry.service_tiers = entry.service_tiers.map(tier =>
211
+ tier?.id === "priority" && tier.description === "1.5x speed, increased usage"
212
+ ? { ...tier, description: "2x speed, increased usage" } : tier);
213
+ }
209
214
  // Strip service tiers for models that do not actually support the Fast tier.
210
215
  if (typeof entry.slug === "string" && NO_FAST_TIER_NATIVE_SLUGS.has(entry.slug)) {
211
216
  delete entry.service_tier;
@@ -250,10 +255,19 @@ function nativeAutoCompactLimit(contextWindow: number, maxInputTokens?: number):
250
255
  : ninetyPercent;
251
256
  }
252
257
 
253
- export function applyNativeOpenAiContextOverride(entry: RawEntry): void {
258
+ export function applyNativeOpenAiContextOverride(entry: RawEntry, config?: NativeModelConfig): void {
254
259
  if (!isNativeOpenAiEntry(entry)) return;
255
260
  const override = NATIVE_OPENAI_CONTEXT_OVERRIDES[entry.slug as string];
256
261
  if (!override) return;
262
+ if (entry.slug === NATIVE_GPT6_ASTRA_MODEL) {
263
+ const window = nativeOpenAiContextWindow(entry.slug, config)!;
264
+ entry.context_window = window;
265
+ const cap = config?.providerContextCaps?.openai;
266
+ const ceiling = typeof cap === "number" && Number.isSafeInteger(cap) && cap > 0 ? Math.min(872_000, cap) : 872_000;
267
+ entry.max_context_window = Math.max(window, ceiling);
268
+ entry.auto_compact_token_limit = nativeAutoCompactLimit(window, nativeOpenAiMaxInputTokens(entry.slug, config));
269
+ return;
270
+ }
257
271
  if (typeof override.contextWindow === "number") {
258
272
  entry.context_window = override.contextWindow;
259
273
  entry.auto_compact_token_limit = nativeAutoCompactLimit(override.contextWindow, override.maxInputTokens);
@@ -346,6 +360,7 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls =
346
360
  delete entry.model_messages;
347
361
  delete entry.tool_mode;
348
362
  delete entry.multi_agent_version;
363
+ delete entry.multi_agent_reasoning_effort;
349
364
  delete entry.use_responses_lite;
350
365
  delete entry.supports_websockets;
351
366
  delete entry.additional_speed_tiers;
@@ -767,14 +767,14 @@ async function gatherRoutedModelsUncached(
767
767
  const disabled = disabledNativeSlugs(config);
768
768
  for (const slug of nativeOpenAiSlugs()) {
769
769
  if (disabled.has(slug)) continue;
770
- const contextWindow = nativeOpenAiContextWindow(slug);
770
+ const contextWindow = nativeOpenAiContextWindow(slug, config);
771
771
  if (contextWindow === undefined) continue;
772
772
  const synthetic: CatalogModel = {
773
773
  provider: "openai",
774
774
  id: slug,
775
775
  owned_by: "openai",
776
776
  contextWindow,
777
- maxInputTokens: nativeOpenAiMaxInputTokens(slug) ?? contextWindow,
777
+ maxInputTokens: nativeOpenAiMaxInputTokens(slug, config) ?? contextWindow,
778
778
  inputModalities: nativeInputModalities(slug),
779
779
  reasoningEfforts: nativeReasoningEfforts(slug),
780
780
  ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}),
@@ -854,7 +854,11 @@ export function augmentRoutedModelsWithRegistryOpenAiApiRows(
854
854
  ? Math.min(officialContext, userContext ?? officialContext, providerCap ?? officialContext)
855
855
  : undefined;
856
856
  const maxInputTokens = typeof officialMaxInput === "number"
857
- ? Math.min(officialMaxInput, userMaxInput ?? officialMaxInput)
857
+ ? Math.min(officialMaxInput, userMaxInput ?? officialMaxInput, contextWindow ?? officialMaxInput)
858
+ : undefined;
859
+ const officialMaxOutput = entry.modelMaxOutputTokens?.[id];
860
+ const maxOutputTokens = typeof officialMaxOutput === "number"
861
+ ? Math.min(officialMaxOutput, configured.modelMaxOutputTokens?.[id] ?? officialMaxOutput, contextWindow ?? officialMaxOutput)
858
862
  : undefined;
859
863
  return {
860
864
  provider: OPENAI_API_PROVIDER_ID,
@@ -862,6 +866,8 @@ export function augmentRoutedModelsWithRegistryOpenAiApiRows(
862
866
  owned_by: OPENAI_API_PROVIDER_ID,
863
867
  ...(contextWindow ? { contextWindow } : {}),
864
868
  ...(maxInputTokens ? { maxInputTokens } : {}),
869
+ ...(maxOutputTokens ? { maxOutputTokens } : {}),
870
+ ...(entry.modelDefaultReasoningEfforts?.[id] ? { defaultReasoningEffort: entry.modelDefaultReasoningEfforts[id] } : {}),
865
871
  ...(entry.modelInputModalities?.[id] ? { inputModalities: [...entry.modelInputModalities[id]!] } : {}),
866
872
  ...(entry.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...entry.modelReasoningEfforts[id]!] } : {}),
867
873
  };
@@ -14,7 +14,7 @@ import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../..
14
14
  import { getProviderRegistryEntry } from "../../providers/registry";
15
15
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
16
16
  import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
17
- import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
17
+ import { CODEX_GPT5_IDENTITY_LINE, neutralizeIdentity } from "../../adapters/identity";
18
18
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
19
19
  import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
20
20
  import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
@@ -194,10 +194,10 @@ export function deriveEntry(
194
194
  if (typeof e.base_instructions === "string") {
195
195
  // Proxy-neutral: keep the GPT-5/OpenAI disclaimer but never advertise the opencodex proxy
196
196
  // (leaking that into base_instructions is a non-first-party signature → ToS risk).
197
- e.base_instructions = e.base_instructions.replace(
197
+ e.base_instructions = neutralizeIdentity(e.base_instructions.replace(
198
198
  CODEX_GPT5_IDENTITY_LINE,
199
199
  `You are a coding agent powered by the ${modelName} model. Do not claim to be GPT-5 or made by OpenAI.`,
200
- );
200
+ ));
201
201
  }
202
202
  applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
203
203
  normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true);
@@ -233,6 +233,9 @@ export function deriveEntry(
233
233
  };
234
234
  if (isRouted) {
235
235
  applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
236
+ // The no-template fallback does not pass through normalizeRoutedCatalogEntry, so pin the
237
+ // capability before strict defaults would otherwise advertise parallel calls unconditionally.
238
+ entry.supports_parallel_tool_calls = model?.provider === "cursor" || model?.parallelToolCalls === true;
236
239
  }
237
240
  else {
238
241
  applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]);
@@ -255,6 +258,7 @@ export function buildCatalogEntries(
255
258
  wsEnabled = false,
256
259
  multiAgentMode: MultiAgentMode = "default",
257
260
  exactComboSlugs: ReadonlySet<string> = new Set(),
261
+ config?: OcxConfig,
258
262
  ): RawEntry[] {
259
263
  // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
260
264
  // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
@@ -268,6 +272,7 @@ export function buildCatalogEntries(
268
272
  .map(catalogModelSlug));
269
273
  for (const slug of gptSlugs) {
270
274
  const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9);
275
+ applyNativeOpenAiContextOverride(e, config);
271
276
  if (rank.has(slug)) e.priority = rank.get(slug)!;
272
277
  out.push(e);
273
278
  }
@@ -367,6 +372,7 @@ export function mergeCatalogEntriesForSync(
367
372
  exactComboSlugs: ReadonlySet<string> = new Set(),
368
373
  hasPhysicalComboProvider = false,
369
374
  includeNativeOpenAi = true,
375
+ config?: OcxConfig,
370
376
  ): RawEntry[] {
371
377
  const rank = new Map(featured.map((slug, i) => [slug, i] as const));
372
378
  const native = includeNativeOpenAi
@@ -475,7 +481,7 @@ export function mergeCatalogEntriesForSync(
475
481
 
476
482
  const mergedEntries = [...native, ...finalRoutedEntries].map(m => {
477
483
  const normalized = normalizeServiceTiers(m);
478
- applyNativeOpenAiContextOverride(normalized);
484
+ applyNativeOpenAiContextOverride(normalized, config);
479
485
  const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug);
480
486
  const e = ensureStrictCatalogFields(normalized, {
481
487
  preserveExactInputModalities: exactCombo,
@@ -565,7 +571,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
565
571
  // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no
566
572
  // providers are configured yet (fresh install / catalog bootstrap tests).
567
573
  const includeNativeOpenAi = enabledProviders.length === 0 || hasCanonicalOpenai;
568
- catalog.models = mergeCatalogEntriesForSync(catalogModelsForMerge, goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi);
574
+ catalog.models = mergeCatalogEntriesForSync(catalogModelsForMerge, goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi, config);
569
575
  clampCatalogModelsToCodexSupport(catalog.models);
570
576
 
571
577
  atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");