@coseung2/opencodex 2.8.0-cs.14 → 2.8.0-cs.16

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.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-MUpaVatk.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-BZGMtkmp.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-CxisOo-q.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coseung2/opencodex",
3
- "version": "2.8.0-cs.14",
3
+ "version": "2.8.0-cs.16",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -45,7 +45,7 @@ Startup failures and Rust panics are written to the bounded diagnostic log `%LOC
45
45
  - Online shutdown uses OCX's authenticated `POST /api/stop` graceful-stop endpoint directly, with CLI fallback for older OCX versions. Power transitions probe health immediately and then every ~75ms with a short timeout so the control reflects the real listener state quickly.
46
46
  - The header keeps Private and WS on separate rows, with fixed 0-to-Max segmented gauges beside them. The unboxed power and minus controls retain generous invisible hit areas and show hover/pressed feedback.
47
47
  - Click the notch to expand provider details.
48
- - Use the inline Providers and Logs tabs below the memory header to switch content. Logs show only the latest 10 requests with status, duration, relative time, reasoning effort, Fast state, and token usage.
48
+ - Use the inline Providers and Logs tabs below the memory header to switch content. Logs show only the latest 10 requests with status, output tok/s, relative time, reasoning effort, Fast state, and token usage. Estimated rates are prefixed with `~`; unavailable rates use an em dash.
49
49
  - When expanded, click the top-right minus control to collapse back to the 58px notch.
50
50
  - Drag either side edge to resize the notch width. Position and width are restored on the next launch.
51
51
  - Drag anywhere on the notch to move it; its chosen position is preserved while it expands, collapses, or refreshes.
@@ -58,3 +58,5 @@ Startup failures and Rust panics are written to the bounded diagnostic log `%LOC
58
58
  - Right-click to add a provider, set the real OCX account rotation threshold, fine-tune it by 1%, **Refresh**, or **Exit**. The opaque provider modal groups supported presets into Account, Free, and Paid tabs. Canonical OpenAI adds another Codex account, OAuth presets use browser/device authorization, and required-key presets use masked API-key entry. Fixed-endpoint key-optional presets (including OpenCode Free and MiMo Free) accept an empty key; entering a key instead adds a distinct account slot to OCX's existing key pool, which can be switched from the provider row. Ollama, vLLM, and LM Studio use local auth without a key. Cloudflare Workers AI also appears in Free and asks for its Account ID before creating the provider. Endpoint-choice and other unresolved placeholder-URL presets remain omitted because this compact modal cannot preserve those setup contracts safely. `Off` writes threshold `0`.
59
59
 
60
60
  Quota percentages are shown as used percentages with 5-hour/weekly/monthly/custom-window columns in one compact row, reset countdowns, 5px progress bars, green fill, and the green-to-amber threshold warning used by the OCX dashboard. Columns are derived from whichever quota windows the management API returns, so provider-specific windows appear without a plan-name list in the notch. Provider usage is merged by exact provider name, limited to the newest day, and formatted with Korean `만/억/조` units. OpenAI account rows show their own 5-hour/weekly/monthly quotas; OAuth and key-pool rows show their masked identity and active/health state, while provider-level quota remains associated with the active account. The native window uses a subtle 238/255 global alpha.
61
+
62
+ For OpenCode Go, the collapsed header keeps the provider report's 5-hour/weekly/monthly windows rather than copying a monthly-only key row, and each expanded key row shows that key's own 5-hour/weekly/monthly allocation.
@@ -25,7 +25,7 @@ export const CODEX_GPT5_IDENTITY_LINE_AGENT = "You are Codex, an agent based on
25
25
  * Avoid a broad `You are Codex.*` rewrite that could touch unrelated content.
26
26
  */
27
27
  const CODEX_GPT5_IDENTITY_RE =
28
- /You are Codex, (?:a coding agent|an agent) based on GPT-5(?:\.[0-9]+)*\./g;
28
+ /You are Codex, (?:a coding agent|an agent) based on GPT-[0-9]+(?:\.[0-9]+)*\./g;
29
29
 
30
30
  /** Proxy-neutral replacement: no "opencodex proxy" mention, just the GPT-5/OpenAI disclaimer. */
31
31
  export const NEUTRAL_IDENTITY_LINE = "You are a coding agent. Do not claim to be GPT-5 or to be made by OpenAI.";
@@ -255,6 +255,44 @@ function normalizeConfiguredReasoningSummaryDelivery(
255
255
  * - Drops tool_search_call/tool_search_output input items
256
256
  * - Sets parallel_tool_calls to false
257
257
  */
258
+ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set(["muse-spark-1.3-contributor", "muse-spark-1.2-contributor"]);
259
+
260
+ function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknown): unknown {
261
+ if (!isPlainObject(body) || typeof modelId !== "string"
262
+ || !MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) return body;
263
+ const rewrite = (tools: unknown[]) => {
264
+ let changed = false;
265
+ const next = tools.map(tool => {
266
+ if (!isPlainObject(tool) || tool.type !== "web_search") return tool;
267
+ const copy = { ...tool };
268
+ let toolChanged = false;
269
+ for (const field of ["search_content_types", "indexed_web_access"]) {
270
+ if (Object.hasOwn(copy, field)) { delete copy[field]; toolChanged = true; }
271
+ }
272
+ if (toolChanged) changed = true;
273
+ return toolChanged ? copy : tool;
274
+ });
275
+ return changed ? next : tools;
276
+ };
277
+ let changed = false;
278
+ const next: Record<string, unknown> = { ...body };
279
+ if (Array.isArray(body.tools)) {
280
+ const tools = rewrite(body.tools);
281
+ if (tools !== body.tools) { next.tools = tools; changed = true; }
282
+ }
283
+ if (Array.isArray(body.input)) {
284
+ const input = body.input.map(item => {
285
+ if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
286
+ const tools = rewrite(item.tools);
287
+ if (tools === item.tools) return item;
288
+ changed = true;
289
+ return { ...item, tools };
290
+ });
291
+ if (changed) next.input = input;
292
+ }
293
+ return changed ? next : body;
294
+ }
295
+
258
296
  function stripSparkCompatibility(body: unknown): unknown {
259
297
  if (!isPlainObject(body)) return body;
260
298
  const model = typeof body.model === "string" ? body.model : "";
@@ -1024,7 +1062,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1024
1062
  if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
1025
1063
  outBody = buildRoutedCompactionBody(outBody);
1026
1064
  }
1027
- const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody))))))));
1065
+ const sanitizedBody = normalizeToolSchemas(stripMuseSparkUnsupportedWebSearchFields(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody))))))), parsed.modelId));
1028
1066
  const body = JSON.stringify(stripDisabledReasoningSummaries(
1029
1067
  normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId),
1030
1068
  provider,
@@ -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",
@@ -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,6 +72,7 @@ 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
78
  export function nativeOpenAiContextWindow(slug: string): number | undefined {
@@ -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"]);
@@ -298,8 +298,14 @@ export function computeCodexUsageScore(quota: {
298
298
  ? quota.monthlyPercent
299
299
  : CODEX_UNKNOWN_USAGE_SCORE;
300
300
  }
301
+ if (isCodexFiveHourQuotaPlan(plan)
302
+ && typeof quota.fiveHourPercent === "number"
303
+ && Number.isFinite(quota.fiveHourPercent)
304
+ && quota.fiveHourPercent >= 0
305
+ && quota.fiveHourPercent <= 100) {
306
+ return quota.fiveHourPercent;
307
+ }
301
308
  const values = [
302
- ...(isCodexFiveHourQuotaPlan(plan) ? [quota.fiveHourPercent] : []),
303
309
  quota.weeklyPercent,
304
310
  quota.monthlyPercent,
305
311
  ]
package/src/lib/errors.ts CHANGED
@@ -4,6 +4,24 @@ export interface OcxErrorPayload {
4
4
  code: string | null;
5
5
  }
6
6
 
7
+ /** Canonical human-readable message paths used by Responses upstream failures. */
8
+ export function upstreamErrorMessageFromPayload(payload: unknown): string | undefined {
9
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
10
+ const json = payload as {
11
+ error?: { message?: unknown };
12
+ last_error?: { message?: unknown };
13
+ response?: {
14
+ error?: { message?: unknown };
15
+ incomplete_details?: { message?: unknown };
16
+ };
17
+ };
18
+ const message = json.error?.message
19
+ ?? json.last_error?.message
20
+ ?? json.response?.error?.message
21
+ ?? json.response?.incomplete_details?.message;
22
+ return typeof message === "string" ? message : undefined;
23
+ }
24
+
7
25
  /** OpenAI / Codex hard block for high-risk cybersecurity activity (HTTP 400 or mid-stream). */
8
26
  export const CYBER_POLICY_ERROR_CODE = "cyber_policy";
9
27
 
@@ -0,0 +1,41 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { OcxProviderConfig } from "../types";
3
+ import { registryEntryForProviderDestination } from "./registry";
4
+
5
+ export const OPENCODE_GO_SESSION_HEADER = "x-opencode-session";
6
+
7
+ function hasHeaderCaseInsensitive(
8
+ headers: Record<string, string> | undefined,
9
+ name: string,
10
+ ): boolean {
11
+ const target = name.toLowerCase();
12
+ return Object.keys(headers ?? {}).some(key => key.toLowerCase() === target);
13
+ }
14
+
15
+ /** Derive a provider-scoped opaque value without exposing Codex task or subagent ids. */
16
+ export function deriveOpenCodeGoSessionId(sessionLane: string): string {
17
+ const digest = createHash("sha256")
18
+ .update("opencodex/opencode-go/session/v1\0")
19
+ .update(sessionLane)
20
+ .digest("hex")
21
+ .slice(0, 32);
22
+ return `ocx_${digest}`;
23
+ }
24
+
25
+ /** Add per-conversation Go affinity only to the canonical fixed-key destination. */
26
+ export function resolveOpenCodeGoTransport<T extends OcxProviderConfig>(
27
+ provider: T,
28
+ sessionLane: string | undefined,
29
+ ): T {
30
+ if (registryEntryForProviderDestination(provider)?.id !== "opencode-go") return provider;
31
+ if (!sessionLane) return provider;
32
+ if (hasHeaderCaseInsensitive(provider.headers, OPENCODE_GO_SESSION_HEADER)) return provider;
33
+
34
+ return {
35
+ ...provider,
36
+ headers: {
37
+ ...(provider.headers ?? {}),
38
+ [OPENCODE_GO_SESSION_HEADER]: deriveOpenCodeGoSessionId(sessionLane),
39
+ },
40
+ };
41
+ }
@@ -992,6 +992,22 @@ function opencodeGoSegments(api: NonNullable<Awaited<ReturnType<typeof fetchOpen
992
992
  return segments;
993
993
  }
994
994
 
995
+ function opencodeGoQuotaFromSegments(
996
+ segments: NonNullable<ProviderQuotaWindow["segments"]>,
997
+ now: number,
998
+ ): ProviderQuota | null {
999
+ if (segments.length === 0) return null;
1000
+ return {
1001
+ customWindows: [{
1002
+ // Row label intentionally empty: the segments carry their own labels.
1003
+ label: "",
1004
+ percent: 0,
1005
+ segments,
1006
+ }],
1007
+ updatedAt: now,
1008
+ };
1009
+ }
1010
+
995
1011
  /**
996
1012
  * opencode.go allocation: prefer the key-scoped usage endpoint (exact console
997
1013
  * percents + real reset times); fall back to the local request-count estimate
@@ -1001,18 +1017,8 @@ async function fetchOpencodeGoQuota(name: string, config: OcxProviderConfig): Pr
1001
1017
  const activeKey = resolveEnvValue(config.apiKey)?.trim() ?? config.apiKey;
1002
1018
  const api = await fetchOpencodeGoUsageApi(activeKey).catch(() => null);
1003
1019
  if (api) {
1004
- const segments = opencodeGoSegments(api);
1005
- if (segments.length > 0) {
1006
- return report(name, "opencode-go:usage-api", {
1007
- customWindows: [{
1008
- // Row label intentionally empty: the segments carry their own labels.
1009
- label: "",
1010
- percent: 0,
1011
- segments,
1012
- }],
1013
- updatedAt: Date.now(),
1014
- });
1015
- }
1020
+ const quota = opencodeGoQuotaFromSegments(opencodeGoSegments(api), Date.now());
1021
+ if (quota) return report(name, "opencode-go:usage-api", quota);
1016
1022
  }
1017
1023
 
1018
1024
  // Fallback: dominant model's local request counts against published limits.
@@ -1023,36 +1029,30 @@ async function fetchOpencodeGoQuota(name: string, config: OcxProviderConfig): Pr
1023
1029
  if (!dominant) return null;
1024
1030
  const { fiveHour, weekly, monthly } = OPENCODE_GO_LIMITS[dominant]!;
1025
1031
  const now = Date.now();
1026
- return report(name, "opencode-go:docs-estimate", {
1027
- customWindows: [{
1028
- label: "",
1029
- percent: 0,
1030
- segments: [
1031
- {
1032
- label: "5h",
1033
- percent: normalizePercent(((estimate.fiveHourCounts.get(dominant) ?? 0) / fiveHour) * 100) ?? 0,
1034
- resetAt: now + OPENCODE_GO_FIVE_HOUR_MS,
1035
- },
1036
- {
1037
- label: "Weekly",
1038
- percent: normalizePercent(((estimate.weeklyCounts.get(dominant) ?? 0) / weekly) * 100) ?? 0,
1039
- resetAt: now + OPENCODE_GO_WEEK_MS,
1040
- },
1041
- {
1042
- label: "Monthly",
1043
- percent: normalizePercent(((estimate.monthlyCounts.get(dominant) ?? 0) / monthly) * 100) ?? 0,
1044
- resetAt: now + OPENCODE_GO_COST_WINDOW_MS,
1045
- },
1046
- ],
1047
- }],
1048
- updatedAt: now,
1049
- });
1032
+ const quota = opencodeGoQuotaFromSegments([
1033
+ {
1034
+ label: "5h",
1035
+ percent: normalizePercent(((estimate.fiveHourCounts.get(dominant) ?? 0) / fiveHour) * 100) ?? 0,
1036
+ resetAt: now + OPENCODE_GO_FIVE_HOUR_MS,
1037
+ },
1038
+ {
1039
+ label: "Weekly",
1040
+ percent: normalizePercent(((estimate.weeklyCounts.get(dominant) ?? 0) / weekly) * 100) ?? 0,
1041
+ resetAt: now + OPENCODE_GO_WEEK_MS,
1042
+ },
1043
+ {
1044
+ label: "Monthly",
1045
+ percent: normalizePercent(((estimate.monthlyCounts.get(dominant) ?? 0) / monthly) * 100) ?? 0,
1046
+ resetAt: now + OPENCODE_GO_COST_WINDOW_MS,
1047
+ },
1048
+ ], now);
1049
+ return quota ? report(name, "opencode-go:docs-estimate", quota) : null;
1050
1050
  }
1051
1051
 
1052
1052
  /**
1053
- * Per-key monthly-allocation percent for every connected key: the usage endpoint
1054
- * answers per key, so each pool key reports its own real monthly percent. Keys the
1055
- * endpoint rejects fall back to the local 30-day request estimate.
1053
+ * Per-key 5h/weekly/monthly allocation for every connected key. The usage
1054
+ * endpoint is key-scoped, so each pool key reports its own live windows. Keys
1055
+ * the endpoint rejects fall back to the local request-count estimate.
1056
1056
  */
1057
1057
  export async function opencodeGoKeyQuotaEstimates(config: OcxConfig, name: string): Promise<Record<string, ProviderQuota> | null> {
1058
1058
  const provider = config.providers[name];
@@ -1064,40 +1064,48 @@ export async function opencodeGoKeyQuotaEstimates(config: OcxConfig, name: strin
1064
1064
  const now = Date.now();
1065
1065
  const out: Record<string, ProviderQuota> = {};
1066
1066
  const pool = provider.apiKeyPool ?? [];
1067
+ const activeKey = resolveEnvValue(provider.apiKey)?.trim() ?? provider.apiKey;
1068
+ const activeKeyId = activeKey ? pool.find(entry => entry.key === activeKey)?.id : undefined;
1067
1069
  if (pool.length > 0) {
1068
1070
  const results = await Promise.all(pool.map(async entry => {
1069
1071
  const api = await fetchOpencodeGoUsageApi(entry.key).catch(() => null);
1070
- return [entry.id, api?.monthly] as const;
1072
+ return [entry.id, api] as const;
1071
1073
  }));
1072
- for (const [keyId, monthly] of results) {
1073
- if (monthly?.percent === undefined) continue;
1074
- out[keyId] = {
1075
- customWindows: [{
1076
- label: "월간 할당",
1077
- percent: monthly.percent,
1078
- ...(monthly.resetAt !== undefined ? { resetAt: monthly.resetAt } : {}),
1079
- }],
1080
- updatedAt: now,
1081
- };
1074
+ for (const [keyId, api] of results) {
1075
+ if (!api) continue;
1076
+ const quota = opencodeGoQuotaFromSegments(opencodeGoSegments(api), now);
1077
+ if (quota) out[keyId] = quota;
1082
1078
  }
1083
1079
  }
1084
1080
  // Fallback for keys the endpoint did not answer.
1085
1081
  const estimate = estimateOpencodeGoUsage(name, provider);
1086
1082
  if (estimate) {
1087
1083
  const dominant = [...estimate.monthlyCounts.entries()]
1088
- .sort((a, b) => b[1] - a[1])[0]?.[0];
1084
+ .sort((a, b) => b[1] - a[1] || (estimate.weeklyCounts.get(b[0]) ?? 0) - (estimate.weeklyCounts.get(a[0]) ?? 0))[0]?.[0];
1089
1085
  if (dominant) {
1090
- const monthlyLimit = OPENCODE_GO_LIMITS[dominant]!.monthly;
1086
+ const limits = OPENCODE_GO_LIMITS[dominant]!;
1091
1087
  for (const [keyId, count] of estimate.perKeyMonthlyCounts) {
1092
1088
  if (out[keyId]) continue;
1093
- out[keyId] = {
1094
- customWindows: [{
1095
- label: "월간 할당",
1096
- percent: normalizePercent((count / monthlyLimit) * 100) ?? 0,
1097
- resetAt: now + OPENCODE_GO_COST_WINDOW_MS,
1098
- }],
1099
- updatedAt: now,
1100
- };
1089
+ const segments: NonNullable<ProviderQuotaWindow["segments"]> = [];
1090
+ if (keyId === activeKeyId) {
1091
+ segments.push({
1092
+ label: "5h",
1093
+ percent: normalizePercent(((estimate.fiveHourCounts.get(dominant) ?? 0) / limits.fiveHour) * 100) ?? 0,
1094
+ resetAt: now + OPENCODE_GO_FIVE_HOUR_MS,
1095
+ });
1096
+ segments.push({
1097
+ label: "Weekly",
1098
+ percent: normalizePercent(((estimate.weeklyCounts.get(dominant) ?? 0) / limits.weekly) * 100) ?? 0,
1099
+ resetAt: now + OPENCODE_GO_WEEK_MS,
1100
+ });
1101
+ }
1102
+ segments.push({
1103
+ label: "Monthly",
1104
+ percent: normalizePercent((count / limits.monthly) * 100) ?? 0,
1105
+ resetAt: now + OPENCODE_GO_COST_WINDOW_MS,
1106
+ });
1107
+ const quota = opencodeGoQuotaFromSegments(segments, now);
1108
+ if (quota) out[keyId] = quota;
1101
1109
  }
1102
1110
  }
1103
1111
  }
@@ -808,6 +808,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
808
808
  // catalog authoritative so a spurious 2xx from runtime.../models cannot drop seeded ids
809
809
  // (e.g. newly listed GPT-5.6 tiers) via live-discovery reconciliation.
810
810
  liveModels: false,
811
+ // Kiro rejects request-level parallel tool calls; keep persisted presets and the Codex
812
+ // catalog aligned with the adapter's single-call capability guard.
813
+ parallelToolCalls: false,
811
814
  // Per-model context metadata is maintained next to the Kiro model list.
812
815
  modelContextWindows: KIRO_MODEL_CONTEXT_WINDOWS,
813
816
  modelReasoningEfforts: KIRO_MODEL_REASONING_EFFORTS,
@@ -862,6 +865,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
862
865
  id: "opencode-go", label: "opencode go", adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1",
863
866
  authKind: "key", featured: true, dashboardUrl: "https://opencode.ai/auth", defaultModel: "kimi-k2.7-code",
864
867
  jawcodeBundle: "opencode-go", note: "GLM, DeepSeek, Kimi, Qwen, MiMo…",
868
+ modelWireDefaults: {
869
+ "muse-spark-1.3-contributor": "openai-responses",
870
+ "muse-spark-1.2-contributor": "openai-responses",
871
+ },
865
872
  modelContextWindows: {
866
873
  "kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW,
867
874
  [OPENCODE_OX_ALPHA_FREE_MODEL]: OX_ALPHA_CONTEXT_WINDOW,
@@ -61,6 +61,14 @@ export function sessionIdHeaderFromRequest(headers: Headers): string | null {
61
61
  return headers.get("session_id") ?? headers.get("session-id");
62
62
  }
63
63
 
64
+ export function sessionLaneIdFromRequest(headers: Headers): string | undefined {
65
+ const parent = headers.get("x-codex-parent-thread-id")?.trim();
66
+ const thread = headers.get("thread-id")?.trim();
67
+ const session = sessionIdHeaderFromRequest(headers)?.trim();
68
+ const lane = [parent, thread, session].filter(Boolean);
69
+ return lane.length > 0 ? lane.join("\0") : undefined;
70
+ }
71
+
64
72
  export function conversationIdFromResponsesRequest(input: {
65
73
  clientThreadId?: string;
66
74
  sessionIdHeader?: string | null;
@@ -4,6 +4,9 @@ import {
4
4
  classifyError,
5
5
  httpStatusFromTerminalError as httpStatusFromClassifiedTerminalError,
6
6
  isClientClosedMessage,
7
+ isCyberPolicyCode,
8
+ isCyberPolicyMessage,
9
+ upstreamErrorMessageFromPayload,
7
10
  } from "../lib/errors";
8
11
  import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
9
12
  import { readCodexCatalogPath } from "../codex/catalog";
@@ -674,9 +677,7 @@ function captureUpstreamErrorParsed(
674
677
  logCtx.terminalIncompleteReason = reason.trim();
675
678
  }
676
679
  if (logCtx.upstreamError) return;
677
- const message = json?.error?.message
678
- ?? json?.last_error?.message
679
- ?? json?.response?.error?.message;
680
+ const message = upstreamErrorMessageFromPayload(parsed);
680
681
  if (typeof message === "string" && message.trim()) {
681
682
  logCtx.upstreamError = redactSecretString(message).slice(0, 500);
682
683
  return;
@@ -939,7 +940,7 @@ function finalizedUsage(
939
940
  const usageFallback = !finalUsage && estimate !== undefined
940
941
  ? { inputTokens: estimate, outputTokens: 0, estimated: true }
941
942
  : undefined;
942
- const loggedUsage = finalUsage && estimate !== undefined
943
+ const loggedUsage = finalUsage?.estimated && estimate !== undefined
943
944
  ? {
944
945
  ...finalUsage,
945
946
  inputTokens: Math.max(finalUsage.inputTokens, estimate),
@@ -101,10 +101,15 @@ import type { InboundWire } from "../../providers/registry";
101
101
  import { hasKeyPoolFailover, rotateProviderTransportOn429 } from "../../providers/key-failover";
102
102
  import { shouldAttemptImageTierRetry } from "../image-retry";
103
103
  import { resolveProviderTransport } from "../../providers/xai-transport";
104
+ import { resolveOpenCodeGoTransport } from "../../providers/opencode-go-transport";
104
105
  import type { WsData } from "../ws-bridge";
105
106
  import { trackActiveTurnLease, trackStreamLifetime } from "../lifecycle";
106
107
  import { redactSecretString } from "../../lib/redact";
107
108
  import { readBoundedResponseBody } from "../../lib/bounded-body";
109
+ import {
110
+ isRateLimitOrQuotaFailureMessage,
111
+ upstreamErrorMessageFromPayload,
112
+ } from "../../lib/errors";
108
113
  import type { AdmissionLease } from "../../lib/admission";
109
114
  import { supportedLadderFor } from "../effort-policy";
110
115
  import { isThreadSpawnRequest } from "../effort-policy";
@@ -130,6 +135,7 @@ import {
130
135
  import {
131
136
  conversationIdFromResponsesRequest,
132
137
  normalizeLogConversationId,
138
+ sessionLaneIdFromRequest,
133
139
  sessionIdHeaderFromRequest,
134
140
  } from "../request-log-conversation";
135
141
  import type { AttemptRecoveryKind } from "../../usage/log";
@@ -252,8 +258,40 @@ async function shouldRetryCodexPoolAccountModel400(
252
258
  }
253
259
 
254
260
  /** Pre-stream quota/billing rejections that warrant one alternate-account attempt (#584). */
255
- function shouldRetryCodexPoolAccountQuota(response: Response): boolean {
256
- return response.status === 429 || response.status === 402;
261
+ function codexQuotaFailureMessage(body: string): string | undefined {
262
+ try {
263
+ const payload = JSON.parse(body) as unknown;
264
+ const canonical = upstreamErrorMessageFromPayload(payload);
265
+ if (canonical !== undefined) return canonical;
266
+ if (typeof payload === "string") return payload;
267
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
268
+ const record = payload as Record<string, unknown>;
269
+ if (typeof record.message === "string") return record.message;
270
+ return typeof record.error === "string" ? record.error : undefined;
271
+ } catch {
272
+ // Plain-text gateways remain supported. Valid JSON is inspected only at recognized
273
+ // message fields so echoed request content elsewhere cannot trigger account cooldown.
274
+ return body;
275
+ }
276
+ }
277
+
278
+ export async function shouldRetryCodexPoolAccountQuota(
279
+ response: Response,
280
+ signal?: AbortSignal,
281
+ ): Promise<boolean> {
282
+ if (response.status === 402 || response.status === 429) return true;
283
+ if (response.status < 500 || response.status >= 600) return false;
284
+ try {
285
+ // Reject malformed UTF-8 instead of matching quota words around replacement characters.
286
+ const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true });
287
+ const message = body.displaySafe && !body.truncated
288
+ ? codexQuotaFailureMessage(body.text)
289
+ : undefined;
290
+ return message !== undefined
291
+ && isRateLimitOrQuotaFailureMessage(message);
292
+ } catch {
293
+ return false;
294
+ }
257
295
  }
258
296
 
259
297
  interface CodexPoolAccountRetryArgs {
@@ -351,6 +389,19 @@ async function retryCodexPoolOnAlternateAccount(
351
389
  ) throw error;
352
390
  }
353
391
  if (retryAuthCtx?.kind !== "pool" && retryAuthCtx?.kind !== "main-pool") {
392
+ // A body-confirmed quota response may arrive under HTTP 5xx. Without an alternate,
393
+ // the ordinary terminal recorder sees only that wire status and would misclassify it
394
+ // as transient, leaving the exhausted account immediately selectable next turn.
395
+ if (outcomeStatus !== firstResponse.status && (outcomeStatus === 429 || outcomeStatus === 402)) {
396
+ recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
397
+ ...codexQuotaOutcomeMeta(firstResponse),
398
+ threadId: req.headers.get("x-codex-parent-thread-id"),
399
+ modelId: route.modelId,
400
+ probeLeaseId: codexProbeLeaseId(firstAuthCtx),
401
+ probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
402
+ writerGeneration: firstAuthCtx.writerGeneration,
403
+ });
404
+ }
354
405
  return { kind: "no-alternate" };
355
406
  }
356
407
 
@@ -826,6 +877,7 @@ async function applyFinalRouteRequestNormalization(args: {
826
877
  }
827
878
  // Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter
828
879
  // this request will actually use (#404).
880
+ route.provider = resolveOpenCodeGoTransport(route.provider, sessionLaneIdFromRequest(req.headers));
829
881
  route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
830
882
  logCtx.model = route.modelId;
831
883
  logCtx.provider = route.providerName;
@@ -1671,9 +1723,14 @@ async function handleResponsesInner(
1671
1723
  options.abortSignal,
1672
1724
  )) {
1673
1725
  poolRetryOutcome = 400;
1674
- } else if (shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
1726
+ } else if (!authCtx.fixedAccount && await shouldRetryCodexPoolAccountQuota(
1727
+ upstreamResponse,
1728
+ options.abortSignal,
1729
+ )) {
1675
1730
  // Pre-stream only: once SSE has begun, mid-stream quota stays terminal.
1676
- poolRetryOutcome = upstreamResponse.status;
1731
+ // ChatGPT sometimes wraps quota exhaustion in a generic 5xx. Normalize only
1732
+ // body-confirmed cases to quota evidence so cooldown and rotation both apply.
1733
+ poolRetryOutcome = upstreamResponse.status >= 500 ? 429 : upstreamResponse.status;
1677
1734
  }
1678
1735
 
1679
1736
  if (poolRetryOutcome !== undefined) {
package/src/usage/log.ts CHANGED
@@ -147,8 +147,7 @@ export function usageTotalTokens(usage: OcxUsage | undefined): number | undefine
147
147
  * fallback for paths that only know the configured provider name (e.g. "cursor-mykey").
148
148
  */
149
149
  function isEstimatedUsageProvider(providerOrAdapter: string): boolean {
150
- return providerOrAdapter === "kiro" || providerOrAdapter.startsWith("kiro-")
151
- || providerOrAdapter === "cursor" || providerOrAdapter.startsWith("cursor-");
150
+ return providerOrAdapter === "cursor" || providerOrAdapter.startsWith("cursor-");
152
151
  }
153
152
 
154
153
  export function usageForFinalLog(provider: string, usage: OcxUsage | undefined): OcxUsage | undefined {