@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.
- package/gui/dist/assets/{index-MUpaVatk.js → index-BZGMtkmp.js} +2 -2
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/packages/ocx-notch/README.md +3 -1
- package/src/adapters/identity.ts +1 -1
- package/src/adapters/openai-responses.ts +39 -1
- package/src/cli/observe.ts +18 -3
- package/src/codex/app-server-processes.ts +3 -5
- package/src/codex/catalog/metadata.ts +4 -0
- package/src/codex/catalog/sync.ts +3 -0
- package/src/codex/routing.ts +7 -1
- package/src/lib/errors.ts +18 -0
- package/src/providers/opencode-go-transport.ts +41 -0
- package/src/providers/quota.ts +68 -60
- package/src/providers/registry.ts +7 -0
- package/src/server/request-log-conversation.ts +8 -0
- package/src/server/request-log.ts +5 -4
- package/src/server/responses/core.ts +61 -4
- package/src/usage/log.ts +1 -2
- package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
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.
|
|
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,
|
|
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.
|
package/src/adapters/identity.ts
CHANGED
|
@@ -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-
|
|
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,
|
package/src/cli/observe.ts
CHANGED
|
@@ -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> & {
|
|
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
|
|
47
|
-
|
|
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
|
-
|
|
334
|
-
`
|
|
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"]);
|
package/src/codex/routing.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/providers/quota.ts
CHANGED
|
@@ -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
|
|
1005
|
-
if (
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
label: "",
|
|
1029
|
-
percent: 0,
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
|
1054
|
-
*
|
|
1055
|
-
* endpoint rejects fall back to the local
|
|
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
|
|
1072
|
+
return [entry.id, api] as const;
|
|
1071
1073
|
}));
|
|
1072
|
-
for (const [keyId,
|
|
1073
|
-
if (
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
|
1086
|
+
const limits = OPENCODE_GO_LIMITS[dominant]!;
|
|
1091
1087
|
for (const [keyId, count] of estimate.perKeyMonthlyCounts) {
|
|
1092
1088
|
if (out[keyId]) continue;
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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 =
|
|
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
|
|
256
|
-
|
|
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(
|
|
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
|
-
|
|
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 === "
|
|
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 {
|
|
Binary file
|