@bitkyc08/opencodex 2.19.0 → 2.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/index-DF_UFrGS.css +1 -0
- package/gui/dist/assets/index-DSK3S5HY.js +76 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/mimo-free.ts +17 -0
- package/src/adapters/openai-chat.ts +367 -32
- package/src/adapters/registry.ts +144 -0
- package/src/chat/inbound.ts +13 -7
- package/src/cli/claude.ts +2 -1
- package/src/cli/combo.ts +3 -0
- package/src/cli/dispatch.ts +8 -0
- package/src/cli/export-command.ts +2 -2
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +3 -2
- package/src/cli/lab.ts +135 -1
- package/src/cli/minimax.ts +491 -0
- package/src/cli/models-runtime.ts +22 -1
- package/src/cli/models.ts +67 -2
- package/src/cli/opencode.ts +2 -1
- package/src/cli/registry.ts +22 -2
- package/src/clients/config-export.ts +125 -7
- package/src/codex/app-server-processes.ts +57 -2
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/catalog/aggregation.ts +10 -1
- package/src/codex/catalog/effort.ts +15 -3
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +45 -5
- package/src/codex/catalog/sync.ts +74 -4
- package/src/codex/convergence.ts +2 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/request.ts +30 -0
- package/src/combos/types.ts +6 -0
- package/src/config.ts +52 -0
- package/src/generated/compatibility-version.json +224 -76
- package/src/images/loop.ts +11 -1
- package/src/integrations/registry.ts +7 -0
- package/src/lab/conformance/jcs.ts +42 -2
- package/src/lab/conformance/negative-controls.ts +6 -2
- package/src/lab/conformance/runner.ts +16 -5
- package/src/lab/fabric/observe.ts +49 -14
- package/src/lab/index.ts +16 -0
- package/src/lab/ledger/purge.ts +152 -83
- package/src/lab/ledger/store.ts +168 -54
- package/src/lab/observe/from-conformance.ts +8 -6
- package/src/lab/observe/from-live.ts +8 -2
- package/src/lab/paths.ts +23 -0
- package/src/lab/public/bundle.ts +217 -0
- package/src/lab/public/community-authority.ts +175 -0
- package/src/lab/public/community-files.ts +29 -0
- package/src/lab/public/community.ts +479 -0
- package/src/lab/public/file-safety.ts +155 -0
- package/src/lab/public/ids.ts +26 -0
- package/src/lab/public/index.ts +16 -0
- package/src/lab/public/mutation-lock.ts +424 -0
- package/src/lab/public/operator.ts +353 -0
- package/src/lab/public/origin-purge.ts +79 -0
- package/src/lab/public/origin.ts +203 -0
- package/src/lab/public/privacy.ts +143 -0
- package/src/lab/public/private-file.ts +261 -0
- package/src/lab/public/project.ts +124 -0
- package/src/lab/public/purge-test-fault.ts +21 -0
- package/src/lab/public/purge.ts +223 -0
- package/src/lab/public/registry.ts +44 -0
- package/src/lab/public/revocation.ts +252 -0
- package/src/lab/public/signature.ts +219 -0
- package/src/lab/public/storage.ts +105 -0
- package/src/lab/public/strict-json.ts +206 -0
- package/src/lab/public/time.ts +26 -0
- package/src/lab/public/types.ts +172 -0
- package/src/lab/public/validate.ts +391 -0
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/lab-activation.ts +109 -47
- package/src/lib/lab-live-pinned-sender.ts +16 -5
- package/src/lib/pinned-http.ts +70 -16
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/state-store-registrations.ts +2 -0
- package/src/lib/upstream-reachability.ts +4 -0
- package/src/lib/windows-elevation.ts +10 -1
- package/src/providers/derive.ts +24 -4
- package/src/providers/registry.ts +7 -3
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +143 -0
- package/src/providers/static-model-discovery.ts +86 -0
- package/src/reasoning-effort.ts +27 -1
- package/src/router.ts +23 -6
- package/src/routing/capability.ts +4 -2
- package/src/routing/compatibility/behavior.ts +5 -1
- package/src/server/adapter-resolve.ts +2 -32
- package/src/server/auth-cors.ts +8 -0
- package/src/server/chat-completions.ts +74 -36
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +371 -0
- package/src/server/management/combo-routes.ts +16 -2
- package/src/server/management/config-routes.ts +6 -4
- package/src/server/management/context.ts +17 -0
- package/src/server/management/lab-routes.ts +181 -19
- package/src/server/management/model-routes.ts +76 -2
- package/src/server/management/model-rows.ts +8 -0
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +76 -4
- package/src/server/management/system-restart.ts +4 -2
- package/src/server/management/system-routes.ts +38 -0
- package/src/server/relay.ts +17 -3
- package/src/server/responses/compact.ts +4 -1
- package/src/server/responses/core.ts +257 -46
- package/src/server/responses/empty-completion-guard.ts +276 -0
- package/src/server/responses/fetch-helpers.ts +35 -4
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/policy-fallback.ts +16 -2
- package/src/server/responses/terminal-guard.ts +1 -1
- package/src/server/responses/upstream-error.ts +5 -0
- package/src/server/responses.ts +17 -2
- package/src/types.ts +66 -4
- package/src/update/index.ts +6 -5
- package/src/update/job.ts +5 -6
- package/src/update/notify.ts +5 -3
- package/src/usage/log.ts +11 -1
- package/gui/dist/assets/index-CQ7bIKee.css +0 -1
- package/gui/dist/assets/index-D_JUZLEC.js +0 -76
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import type { OcxConfig } from "../types";
|
|
14
14
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
15
|
+
import { serviceTierSupportForModel } from "../providers/service-tier";
|
|
15
16
|
import { applyProviderContextCap, providerContextCap } from "../providers/context-cap";
|
|
16
17
|
import { PROVIDER_REGISTRY } from "../providers/registry";
|
|
17
18
|
import {
|
|
@@ -185,8 +186,9 @@ export function candidateCapabilityEvidence(
|
|
|
185
186
|
?? catalogRow?.reasoningEfforts
|
|
186
187
|
?? (isNative ? nativeReasoningEfforts(modelId) : undefined);
|
|
187
188
|
|
|
188
|
-
const tierSupport = provider
|
|
189
|
-
|
|
189
|
+
const tierSupport = provider
|
|
190
|
+
? serviceTierSupportForModel(provider, modelId, providerName)
|
|
191
|
+
: registryEntry ? serviceTierSupportForModel(registryEntry, modelId, providerName) : undefined;
|
|
190
192
|
const serviceTier = tierSupport === true
|
|
191
193
|
? "supported"
|
|
192
194
|
: tierSupport === false ? "unsupported" : "unknown";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OcxConfig, OcxProviderConfig } from "../../types";
|
|
2
2
|
import { PROVIDER_REGISTRY, type ProviderAuthKind } from "../../providers/registry";
|
|
3
|
+
import { serviceTierSupportForModel } from "../../providers/service-tier";
|
|
3
4
|
import { localFingerprint } from "../../lab/digest";
|
|
4
5
|
import type { LabBehaviorSource, LabBehaviorValues } from "../../lab/live/types";
|
|
5
6
|
|
|
@@ -116,7 +117,10 @@ export function resolveProductionBehaviorValues(
|
|
|
116
117
|
"auth.mode": behaviorRow("provider_config", authMode),
|
|
117
118
|
"auth.transport": behaviorRow("provider_config", authTransportFor(effective, adapter, authMode)),
|
|
118
119
|
"responses.stateful": behaviorRow("provider_config", effective.statelessResponses !== true),
|
|
119
|
-
"responses.serviceTier": behaviorRow(
|
|
120
|
+
"responses.serviceTier": behaviorRow(
|
|
121
|
+
"provider_config",
|
|
122
|
+
serviceTierSupportForModel(effective, modelId, providerName) ?? null,
|
|
123
|
+
),
|
|
120
124
|
"responses.snapshotRepair": behaviorRow("provider_config", effective.responsesSnapshotRepair === true),
|
|
121
125
|
"responses.itemIdRepair": behaviorRow("provider_config", effective.responsesItemIdRepair ?? null),
|
|
122
126
|
"limits.contextWindow": behaviorRow(
|
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createAzureAdapter } from "../adapters/azure";
|
|
3
|
-
import { createCursorAdapter } from "../adapters/cursor";
|
|
4
|
-
import { createGoogleAdapter } from "../adapters/google";
|
|
5
|
-
import { createKiroAdapter } from "../adapters/kiro";
|
|
6
|
-
import { createMimoFreeAdapter } from "../adapters/mimo-free";
|
|
7
|
-
import { createOpenAIChatAdapter } from "../adapters/openai-chat";
|
|
8
|
-
import { createCommandCodeAdapter } from "../adapters/command-code";
|
|
9
|
-
import { createResponsesPassthroughAdapter } from "../adapters/openai-responses";
|
|
1
|
+
import { createRegisteredAdapter } from "../adapters/registry";
|
|
10
2
|
import type { OcxProviderConfig } from "../types";
|
|
11
3
|
import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "../types";
|
|
12
4
|
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
@@ -57,27 +49,5 @@ export function resolveWireProtocolOverride(
|
|
|
57
49
|
|
|
58
50
|
/** Build the provider adapter for a resolved provider config. */
|
|
59
51
|
export function resolveAdapter(providerConfig: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") {
|
|
60
|
-
|
|
61
|
-
case "command-code":
|
|
62
|
-
return createCommandCodeAdapter(providerConfig);
|
|
63
|
-
case "openai-chat":
|
|
64
|
-
return createOpenAIChatAdapter(providerConfig);
|
|
65
|
-
case "anthropic":
|
|
66
|
-
return createAnthropicAdapter(providerConfig, cacheRetention);
|
|
67
|
-
case "openai-responses":
|
|
68
|
-
return createResponsesPassthroughAdapter(providerConfig);
|
|
69
|
-
case "google":
|
|
70
|
-
return createGoogleAdapter(providerConfig);
|
|
71
|
-
case "kiro":
|
|
72
|
-
return createKiroAdapter(providerConfig);
|
|
73
|
-
case "azure":
|
|
74
|
-
case "azure-openai":
|
|
75
|
-
return createAzureAdapter(providerConfig);
|
|
76
|
-
case "cursor":
|
|
77
|
-
return createCursorAdapter(providerConfig);
|
|
78
|
-
case "mimo-free":
|
|
79
|
-
return createMimoFreeAdapter(providerConfig);
|
|
80
|
-
default:
|
|
81
|
-
throw new Error(`Unknown adapter: ${providerConfig.adapter}`);
|
|
82
|
-
}
|
|
52
|
+
return createRegisteredAdapter(providerConfig, { cacheRetention });
|
|
83
53
|
}
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
providerModelCostsConfigError,
|
|
15
15
|
reasoningSummaryDeliveryRecordConfigError,
|
|
16
16
|
retryOn429PolicyConfigError,
|
|
17
|
+
requestPacingConfigError,
|
|
17
18
|
sanitizeModelCostsForDisplay,
|
|
18
19
|
} from "../config";
|
|
19
20
|
import { providerDestinationConfigError } from "../lib/destination-policy";
|
|
@@ -453,6 +454,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
|
|
|
453
454
|
// modelCosts is a user-owned display overlay, not part of the canonical
|
|
454
455
|
// forward seed; it is validated separately below (providerModelCostsConfigError).
|
|
455
456
|
delete canonicalCandidate.modelCosts;
|
|
457
|
+
// requestPacing is a user-owned transport overlay, not part of the canonical seed.
|
|
458
|
+
delete canonicalCandidate.requestPacing;
|
|
456
459
|
const canonical = seed && sameCanonicalProviderSeed(canonicalCandidate, seed);
|
|
457
460
|
if (!canonical) {
|
|
458
461
|
return `provider ${name} must equal the canonical built-in provider seed`;
|
|
@@ -477,6 +480,10 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
|
|
|
477
480
|
// it before it reaches the management API response.
|
|
478
481
|
return `provider ${JSON.stringify(redactSecretString(name))} ${retryOn429Error}`;
|
|
479
482
|
}
|
|
483
|
+
const requestPacingError = requestPacingConfigError(raw.requestPacing);
|
|
484
|
+
if (requestPacingError) {
|
|
485
|
+
return `provider ${JSON.stringify(redactSecretString(name))} ${requestPacingError}`;
|
|
486
|
+
}
|
|
480
487
|
const modelCostsError = providerModelCostsConfigError(raw.modelCosts);
|
|
481
488
|
if (modelCostsError) {
|
|
482
489
|
// The provider name is caller-controlled and can be token-shaped; redact and JSON-escape
|
|
@@ -580,6 +587,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
580
587
|
"keyOptional",
|
|
581
588
|
"freeTier",
|
|
582
589
|
"liveModels",
|
|
590
|
+
"requestPacing",
|
|
583
591
|
"models",
|
|
584
592
|
"contextWindow",
|
|
585
593
|
"modelContextWindows",
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
* OpenAI Chat Completions inbound (/v1/chat/completions) for GitHub Copilot App
|
|
3
3
|
* and other OpenAI-compatible clients.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Ordinary openai-chat routes send directly on the Chat Completions wire. Routes
|
|
6
|
+
* that need Responses-only behavior keep the Chat -> Responses -> Chat bridge.
|
|
7
7
|
*/
|
|
8
8
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
assertChatCompletionsRoutingBody,
|
|
11
|
+
ChatCompletionsRequestError,
|
|
12
|
+
chatCompletionsToResponsesBody,
|
|
13
|
+
} from "../chat/inbound";
|
|
10
14
|
import {
|
|
11
15
|
chatCompletionsErrorResponse,
|
|
12
16
|
collectChatCompletion,
|
|
@@ -40,6 +44,7 @@ import {
|
|
|
40
44
|
isTranslatorBudgetExceededError,
|
|
41
45
|
type TranslatorBudget,
|
|
42
46
|
} from "../lib/translator-budget";
|
|
47
|
+
import { handleNativeChatCompletions, isNativeChatRouteEligible } from "./chat-native";
|
|
43
48
|
|
|
44
49
|
type Rec = Record<string, unknown>;
|
|
45
50
|
|
|
@@ -80,11 +85,11 @@ async function handleChatCompletionsWithBudget(
|
|
|
80
85
|
translatorBudget: TranslatorBudget,
|
|
81
86
|
logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease },
|
|
82
87
|
): Promise<Response> {
|
|
83
|
-
let chatBody:
|
|
84
|
-
let internalBody: Rec;
|
|
88
|
+
let chatBody: Rec;
|
|
85
89
|
try {
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
const rawBody = await readChatBody(req, translatorBudget);
|
|
91
|
+
assertChatCompletionsRoutingBody(rawBody);
|
|
92
|
+
chatBody = rawBody;
|
|
88
93
|
} catch (err) {
|
|
89
94
|
const overflow = isTranslatorBudgetExceededError(err);
|
|
90
95
|
const status = overflow ? 413 : err instanceof ChatCompletionsRequestError ? 400 : 500;
|
|
@@ -97,20 +102,17 @@ async function handleChatCompletionsWithBudget(
|
|
|
97
102
|
);
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
const requestedModel =
|
|
101
|
-
const stream =
|
|
105
|
+
const requestedModel = chatBody.model as string;
|
|
106
|
+
const stream = chatBody.stream === true;
|
|
102
107
|
// Best-effort Grok attribution: the managed fence stamps this header on every model
|
|
103
108
|
// it registers (extra_headers, sent verbatim by upstream Grok). Dashboard usage
|
|
104
109
|
// bucketing only — never an auth or billing signal.
|
|
105
110
|
if (req.headers.get("x-opencodex-grok") === "1") logCtx.surface = "grok";
|
|
106
|
-
// Routed adapters only support streamed turns; always stream internally and fold
|
|
107
|
-
// for non-streaming clients.
|
|
108
|
-
internalBody.stream = true;
|
|
109
|
-
|
|
110
|
-
let nativeRoute = false;
|
|
111
111
|
let directRoute = false;
|
|
112
|
+
let settledRoute: ReturnType<typeof routeModel> | null = null;
|
|
113
|
+
let chatNativeRoute: ReturnType<typeof routeModel> | null = null;
|
|
112
114
|
try {
|
|
113
|
-
const route = routeModel(config,
|
|
115
|
+
const route = routeModel(config, requestedModel, evidenceFromBody(chatBody));
|
|
114
116
|
// Settle the wire once so every branch below reads the adapter this model will
|
|
115
117
|
// actually use, not the provider-wide default (#404).
|
|
116
118
|
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "chat");
|
|
@@ -119,33 +121,17 @@ async function handleChatCompletionsWithBudget(
|
|
|
119
121
|
logCtx.requestedModel = requestedModel;
|
|
120
122
|
logCtx.provider = route.providerName;
|
|
121
123
|
logCtx.routeDecision = route.routeDecision;
|
|
124
|
+
settledRoute = route;
|
|
122
125
|
if (route.provider.adapter === "openai-responses") {
|
|
123
|
-
nativeRoute = true;
|
|
124
126
|
directRoute = route.codexAccountMode === "direct";
|
|
125
|
-
// ChatGPT backend rejects store:true and unsupported sampling knobs.
|
|
126
|
-
internalBody.store = false;
|
|
127
|
-
delete internalBody.max_output_tokens;
|
|
128
|
-
delete internalBody.temperature;
|
|
129
|
-
delete internalBody.top_p;
|
|
130
|
-
delete internalBody.stop;
|
|
131
|
-
delete internalBody.user;
|
|
132
|
-
} else if (internalBody.store === undefined) {
|
|
133
|
-
internalBody.store = false;
|
|
134
127
|
}
|
|
135
128
|
if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") {
|
|
136
|
-
const raw = chatBody as Rec;
|
|
137
129
|
const parts: string[] = [];
|
|
138
|
-
if (
|
|
139
|
-
if (
|
|
130
|
+
if (chatBody.messages !== undefined) parts.push(JSON.stringify(chatBody.messages));
|
|
131
|
+
if (chatBody.tools !== undefined) parts.push(JSON.stringify(chatBody.tools));
|
|
140
132
|
logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel));
|
|
141
133
|
}
|
|
142
|
-
if (
|
|
143
|
-
const { stripEmptyLadderEffort, supportedLadderFor } = await import("./effort-policy");
|
|
144
|
-
const ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId });
|
|
145
|
-
const next = stripEmptyLadderEffort(internalBody.reasoning, ladder);
|
|
146
|
-
if (next === undefined) delete internalBody.reasoning;
|
|
147
|
-
else internalBody.reasoning = next;
|
|
148
|
-
}
|
|
134
|
+
if (isNativeChatRouteEligible(route, chatBody)) chatNativeRoute = route;
|
|
149
135
|
} catch (err) {
|
|
150
136
|
if (err instanceof NoEligiblePolicyCandidateError) {
|
|
151
137
|
logCtx.routeDecision = err.trace;
|
|
@@ -154,7 +140,59 @@ async function handleChatCompletionsWithBudget(
|
|
|
154
140
|
}
|
|
155
141
|
/* unknown model: let handleResponses shape the 404 */
|
|
156
142
|
}
|
|
157
|
-
|
|
143
|
+
|
|
144
|
+
if (chatNativeRoute) {
|
|
145
|
+
return handleNativeChatCompletions({
|
|
146
|
+
req,
|
|
147
|
+
config,
|
|
148
|
+
logCtx,
|
|
149
|
+
...(logIds ? { logIds } : {}),
|
|
150
|
+
route: chatNativeRoute,
|
|
151
|
+
chatBody,
|
|
152
|
+
requestedModel,
|
|
153
|
+
requestedStream: stream,
|
|
154
|
+
translatorBudget,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let internalBody: Rec;
|
|
159
|
+
try {
|
|
160
|
+
// Validate the full Chat boundary after routing. Native Chat keeps `chatBody` as
|
|
161
|
+
// its wire source; this Responses projection is used only by the fallback path.
|
|
162
|
+
internalBody = chatCompletionsToResponsesBody(chatBody);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
const overflow = isTranslatorBudgetExceededError(err);
|
|
165
|
+
const status = overflow ? 413 : err instanceof ChatCompletionsRequestError ? 400 : 500;
|
|
166
|
+
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" });
|
|
167
|
+
return chatCompletionsErrorResponse(
|
|
168
|
+
status,
|
|
169
|
+
overflow ? "request translation buffer exceeded the safe limit" : err instanceof Error ? err.message : String(err),
|
|
170
|
+
overflow ? "request_too_large" : undefined,
|
|
171
|
+
overflow ? "translation_buffer_limit" : undefined,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Routed adapters only support streamed turns; always stream internally and fold
|
|
176
|
+
// for non-streaming clients. Native Chat uses the caller's original stream bit.
|
|
177
|
+
internalBody.stream = true;
|
|
178
|
+
if (settledRoute?.provider.adapter === "openai-responses") {
|
|
179
|
+
// ChatGPT backend rejects store:true and unsupported sampling knobs.
|
|
180
|
+
internalBody.store = false;
|
|
181
|
+
delete internalBody.max_output_tokens;
|
|
182
|
+
delete internalBody.temperature;
|
|
183
|
+
delete internalBody.top_p;
|
|
184
|
+
delete internalBody.stop;
|
|
185
|
+
delete internalBody.user;
|
|
186
|
+
} else if (internalBody.store === undefined) {
|
|
187
|
+
internalBody.store = false;
|
|
188
|
+
}
|
|
189
|
+
if (settledRoute && internalBody.reasoning !== undefined) {
|
|
190
|
+
const { stripEmptyLadderEffort, supportedLadderFor } = await import("./effort-policy");
|
|
191
|
+
const ladder = supportedLadderFor({ provider: settledRoute.provider, modelId: settledRoute.modelId });
|
|
192
|
+
const next = stripEmptyLadderEffort(internalBody.reasoning, ladder);
|
|
193
|
+
if (next === undefined) delete internalBody.reasoning;
|
|
194
|
+
else internalBody.reasoning = next;
|
|
195
|
+
}
|
|
158
196
|
|
|
159
197
|
const headers = new Headers({ "content-type": "application/json" });
|
|
160
198
|
for (const name of FORWARD_HEADERS) {
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { chatCompletionsErrorBody } from "../chat/outbound";
|
|
2
|
+
import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors";
|
|
3
|
+
import { redactSecretString } from "../lib/redact";
|
|
4
|
+
import {
|
|
5
|
+
isTranslatorBudgetExceededError,
|
|
6
|
+
TRANSLATOR_MAX_SSE_EVENT_BYTES,
|
|
7
|
+
type TranslatorBudget,
|
|
8
|
+
} from "../lib/translator-budget";
|
|
9
|
+
import type { OcxUsage } from "../types";
|
|
10
|
+
import { nextSseBlock, replaceSseDataPayload, sseDataPayload } from "./sse-payload-rewrite";
|
|
11
|
+
|
|
12
|
+
type Rec = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
function isRec(value: unknown): value is Rec {
|
|
15
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function usageFromChat(value: unknown): OcxUsage | undefined {
|
|
19
|
+
if (!isRec(value)) return undefined;
|
|
20
|
+
const prompt = typeof value.prompt_tokens === "number" ? value.prompt_tokens : undefined;
|
|
21
|
+
const completion = typeof value.completion_tokens === "number" ? value.completion_tokens : undefined;
|
|
22
|
+
if (prompt === undefined && completion === undefined) return undefined;
|
|
23
|
+
const promptDetails = isRec(value.prompt_tokens_details) ? value.prompt_tokens_details : undefined;
|
|
24
|
+
const completionDetails = isRec(value.completion_tokens_details) ? value.completion_tokens_details : undefined;
|
|
25
|
+
return {
|
|
26
|
+
inputTokens: prompt ?? 0,
|
|
27
|
+
outputTokens: completion ?? 0,
|
|
28
|
+
...(typeof promptDetails?.cached_tokens === "number" ? { cachedInputTokens: promptDetails.cached_tokens } : {}),
|
|
29
|
+
...(typeof completionDetails?.reasoning_tokens === "number"
|
|
30
|
+
? { reasoningOutputTokens: completionDetails.reasoning_tokens }
|
|
31
|
+
: {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function structuredError(value: unknown): {
|
|
36
|
+
message: string;
|
|
37
|
+
type?: string;
|
|
38
|
+
code?: string | null;
|
|
39
|
+
status?: number;
|
|
40
|
+
} | null {
|
|
41
|
+
if (!isRec(value) || value.error === undefined || value.error === null) return null;
|
|
42
|
+
const error = value.error;
|
|
43
|
+
if (typeof error === "string") return { message: redactSecretString(error) };
|
|
44
|
+
if (!isRec(error)) return { message: "upstream error" };
|
|
45
|
+
const rawMessage = typeof error.message === "string" ? error.message : "upstream error";
|
|
46
|
+
return {
|
|
47
|
+
message: redactSecretString(rawMessage),
|
|
48
|
+
...(typeof error.type === "string" ? { type: error.type } : {}),
|
|
49
|
+
...(error.code === null || typeof error.code === "string" ? { code: error.code } : {}),
|
|
50
|
+
...(typeof error.status === "number" && Number.isInteger(error.status) ? { status: error.status } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizedChunk(value: Rec, requestedModel: string): Rec {
|
|
55
|
+
return {
|
|
56
|
+
...value,
|
|
57
|
+
id: typeof value.id === "string" ? value.id : `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`,
|
|
58
|
+
object: "chat.completion.chunk",
|
|
59
|
+
created: typeof value.created === "number" ? value.created : Math.floor(Date.now() / 1000),
|
|
60
|
+
model: requestedModel,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function jsonCompletionSse(value: Rec, requestedModel: string): string {
|
|
65
|
+
const id = typeof value.id === "string" ? value.id : `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
|
|
66
|
+
const created = typeof value.created === "number" ? value.created : Math.floor(Date.now() / 1000);
|
|
67
|
+
const model = requestedModel;
|
|
68
|
+
const choices = Array.isArray(value.choices) ? value.choices : [];
|
|
69
|
+
const choice = isRec(choices[0]) ? choices[0] : {};
|
|
70
|
+
const message = isRec(choice.message) ? choice.message : {};
|
|
71
|
+
const frames: Rec[] = [{
|
|
72
|
+
id,
|
|
73
|
+
object: "chat.completion.chunk",
|
|
74
|
+
created,
|
|
75
|
+
model,
|
|
76
|
+
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
|
|
77
|
+
}];
|
|
78
|
+
const delta: Rec = {};
|
|
79
|
+
if (typeof message.content === "string" && message.content.length > 0) delta.content = message.content;
|
|
80
|
+
if (typeof message.reasoning_content === "string" && message.reasoning_content.length > 0) {
|
|
81
|
+
delta.reasoning_content = message.reasoning_content;
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
|
|
84
|
+
delta.tool_calls = message.tool_calls.map((tool, index) => isRec(tool) ? { index, ...tool } : tool);
|
|
85
|
+
}
|
|
86
|
+
if (Object.keys(delta).length > 0) {
|
|
87
|
+
frames.push({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: null }] });
|
|
88
|
+
}
|
|
89
|
+
frames.push({
|
|
90
|
+
id,
|
|
91
|
+
object: "chat.completion.chunk",
|
|
92
|
+
created,
|
|
93
|
+
model,
|
|
94
|
+
choices: [{ index: 0, delta: {}, finish_reason: typeof choice.finish_reason === "string" ? choice.finish_reason : "stop" }],
|
|
95
|
+
...(value.usage !== undefined ? { usage: value.usage } : {}),
|
|
96
|
+
});
|
|
97
|
+
return `${frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("")}data: [DONE]\n\n`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface NativeChatSseOptions {
|
|
101
|
+
requestedModel: string;
|
|
102
|
+
translatorBudget: TranslatorBudget;
|
|
103
|
+
signal: AbortSignal;
|
|
104
|
+
onFirstOutput?: () => void;
|
|
105
|
+
onUsage: (usage: OcxUsage) => void;
|
|
106
|
+
onTerminal?: (status: number, message?: string) => void;
|
|
107
|
+
onCancel?: () => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function nativeChatSse(
|
|
111
|
+
body: ReadableStream<Uint8Array>,
|
|
112
|
+
options: NativeChatSseOptions,
|
|
113
|
+
): ReadableStream<Uint8Array> {
|
|
114
|
+
const reader = body.getReader();
|
|
115
|
+
const decoder = new TextDecoder();
|
|
116
|
+
const encoder = new TextEncoder();
|
|
117
|
+
const scope = { kind: "live_transient" as const };
|
|
118
|
+
let buffer = "";
|
|
119
|
+
let bufferBytes = 0;
|
|
120
|
+
let queuedBytes = 0;
|
|
121
|
+
let sawFinish = false;
|
|
122
|
+
let sawDone = false;
|
|
123
|
+
let firstOutput = false;
|
|
124
|
+
let settled = false;
|
|
125
|
+
let cancelled = false;
|
|
126
|
+
let cancelledBySignal = false;
|
|
127
|
+
|
|
128
|
+
const releaseQueued = () => {
|
|
129
|
+
if (queuedBytes === 0) return;
|
|
130
|
+
options.translatorBudget.releaseRetained(queuedBytes, scope);
|
|
131
|
+
queuedBytes = 0;
|
|
132
|
+
};
|
|
133
|
+
const replaceBuffer = (next: string) => {
|
|
134
|
+
const nextBytes = encoder.encode(next).byteLength;
|
|
135
|
+
const reservation = options.translatorBudget.reserveTransient(nextBytes, scope);
|
|
136
|
+
reservation.commitRetained();
|
|
137
|
+
options.translatorBudget.releaseRetained(bufferBytes, scope);
|
|
138
|
+
buffer = next;
|
|
139
|
+
bufferBytes = nextBytes;
|
|
140
|
+
};
|
|
141
|
+
const appendBuffer = (fragment: string) => {
|
|
142
|
+
if (!fragment) return;
|
|
143
|
+
const fragmentBytes = encoder.encode(fragment).byteLength;
|
|
144
|
+
const nextBytes = bufferBytes + fragmentBytes;
|
|
145
|
+
if (nextBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) {
|
|
146
|
+
throw new Error("upstream SSE event exceeded the safe limit", { cause: { code: "translation_buffer_limit" } });
|
|
147
|
+
}
|
|
148
|
+
const reservation = options.translatorBudget.reserveTransient(nextBytes, scope);
|
|
149
|
+
try {
|
|
150
|
+
buffer += fragment;
|
|
151
|
+
reservation.commitRetained();
|
|
152
|
+
options.translatorBudget.releaseRetained(bufferBytes, scope);
|
|
153
|
+
bufferBytes = nextBytes;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
reservation.release();
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const enqueue = (controller: ReadableStreamDefaultController<Uint8Array>, text: string) => {
|
|
160
|
+
const bytes = encoder.encode(text);
|
|
161
|
+
const reservation = options.translatorBudget.reserveTransient(bytes.byteLength, scope);
|
|
162
|
+
controller.enqueue(bytes);
|
|
163
|
+
reservation.commitRetained();
|
|
164
|
+
queuedBytes += bytes.byteLength;
|
|
165
|
+
};
|
|
166
|
+
const releaseBuffer = () => {
|
|
167
|
+
options.translatorBudget.releaseRetained(bufferBytes, scope);
|
|
168
|
+
buffer = "";
|
|
169
|
+
bufferBytes = 0;
|
|
170
|
+
};
|
|
171
|
+
const settle = (status: number, message?: string) => {
|
|
172
|
+
if (settled) return;
|
|
173
|
+
settled = true;
|
|
174
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
175
|
+
options.onTerminal?.(status, message);
|
|
176
|
+
};
|
|
177
|
+
const fail = (
|
|
178
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
179
|
+
message: string,
|
|
180
|
+
code: string,
|
|
181
|
+
status = 502,
|
|
182
|
+
type = "upstream_error",
|
|
183
|
+
) => {
|
|
184
|
+
const safeMessage = redactSecretString(message);
|
|
185
|
+
try {
|
|
186
|
+
const frame = `data: ${JSON.stringify(chatCompletionsErrorBody(status, safeMessage, type, code))}\n\n`;
|
|
187
|
+
if (code === "translation_buffer_limit") controller.enqueue(encoder.encode(frame));
|
|
188
|
+
else enqueue(controller, frame);
|
|
189
|
+
} catch { /* controller may already be closed */ }
|
|
190
|
+
releaseBuffer();
|
|
191
|
+
settle(status, safeMessage);
|
|
192
|
+
try { void reader.cancel(new Error(safeMessage)).catch(() => {}); } catch { /* already closed */ }
|
|
193
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
194
|
+
};
|
|
195
|
+
const processBlock = (
|
|
196
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
197
|
+
block: string,
|
|
198
|
+
delimiter: string,
|
|
199
|
+
): void => {
|
|
200
|
+
const payload = sseDataPayload(block);
|
|
201
|
+
if (payload === null) {
|
|
202
|
+
enqueue(controller, block + delimiter);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const trimmed = payload.trim();
|
|
206
|
+
if (trimmed === "[DONE]") {
|
|
207
|
+
sawDone = true;
|
|
208
|
+
enqueue(controller, replaceSseDataPayload(block, "[DONE]") + delimiter);
|
|
209
|
+
settle(200);
|
|
210
|
+
try { void reader.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
211
|
+
controller.close();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
let parsed: unknown;
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(payload);
|
|
217
|
+
} catch {
|
|
218
|
+
fail(controller, "upstream returned malformed SSE JSON", "upstream_sse_invalid");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (!isRec(parsed)) {
|
|
222
|
+
fail(controller, "upstream returned an invalid SSE event", "upstream_sse_invalid");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const error = structuredError(parsed);
|
|
226
|
+
if (error) {
|
|
227
|
+
const status = error.status ?? 502;
|
|
228
|
+
const classified = classifyError(status, error.type ?? "upstream_error", error.message);
|
|
229
|
+
if (isCyberPolicyCode(error.code)) {
|
|
230
|
+
classified.code = CYBER_POLICY_ERROR_CODE;
|
|
231
|
+
classified.type = "invalid_request_error";
|
|
232
|
+
} else if (error.code !== undefined && error.code !== null) {
|
|
233
|
+
classified.code = error.code;
|
|
234
|
+
}
|
|
235
|
+
const safe = chatCompletionsErrorBody(status, classified.message, classified.type, classified.code);
|
|
236
|
+
enqueue(controller, replaceSseDataPayload(block, JSON.stringify(safe)) + delimiter);
|
|
237
|
+
settle(isCyberPolicyCode(classified.code) ? 400 : status, classified.message);
|
|
238
|
+
try { void reader.cancel(new Error(classified.message)).catch(() => {}); } catch { /* already closed */ }
|
|
239
|
+
controller.close();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const usage = usageFromChat(parsed.usage);
|
|
243
|
+
if (usage) options.onUsage(usage);
|
|
244
|
+
const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
|
|
245
|
+
if (choices.some(choice => isRec(choice) && typeof choice.finish_reason === "string" && choice.finish_reason.length > 0)) {
|
|
246
|
+
sawFinish = true;
|
|
247
|
+
}
|
|
248
|
+
if (!firstOutput && choices.some(choice => {
|
|
249
|
+
if (!isRec(choice) || !isRec(choice.delta)) return false;
|
|
250
|
+
const delta = choice.delta;
|
|
251
|
+
return (typeof delta.content === "string" && delta.content.length > 0)
|
|
252
|
+
|| (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0)
|
|
253
|
+
|| (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0);
|
|
254
|
+
})) {
|
|
255
|
+
firstOutput = true;
|
|
256
|
+
options.onFirstOutput?.();
|
|
257
|
+
}
|
|
258
|
+
enqueue(controller, replaceSseDataPayload(block, JSON.stringify(normalizedChunk(parsed, options.requestedModel))) + delimiter);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
function onAbort() {
|
|
262
|
+
cancelledBySignal = true;
|
|
263
|
+
if (!settled) {
|
|
264
|
+
settled = true;
|
|
265
|
+
options.onCancel?.();
|
|
266
|
+
}
|
|
267
|
+
try { void reader.cancel(options.signal.reason).catch(() => {}); } catch { /* already closed */ }
|
|
268
|
+
}
|
|
269
|
+
if (options.signal.aborted) onAbort();
|
|
270
|
+
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
271
|
+
|
|
272
|
+
return new ReadableStream<Uint8Array>({
|
|
273
|
+
async pull(controller) {
|
|
274
|
+
releaseQueued();
|
|
275
|
+
try {
|
|
276
|
+
for (;;) {
|
|
277
|
+
const next = nextSseBlock(buffer);
|
|
278
|
+
if (next) {
|
|
279
|
+
replaceBuffer(next.rest);
|
|
280
|
+
processBlock(controller, next.block, next.delimiter);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const { done, value } = await reader.read();
|
|
284
|
+
if (cancelled) return;
|
|
285
|
+
if (cancelledBySignal) {
|
|
286
|
+
releaseBuffer();
|
|
287
|
+
controller.close();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!done) {
|
|
291
|
+
appendBuffer(decoder.decode(value, { stream: true }));
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
appendBuffer(decoder.decode());
|
|
295
|
+
if (buffer.trim().length > 0) {
|
|
296
|
+
fail(controller, "upstream SSE ended with an unterminated event", "upstream_sse_unterminated");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
releaseBuffer();
|
|
300
|
+
if (sawDone || sawFinish) {
|
|
301
|
+
if (!sawDone) enqueue(controller, "data: [DONE]\n\n");
|
|
302
|
+
settle(200);
|
|
303
|
+
controller.close();
|
|
304
|
+
} else {
|
|
305
|
+
fail(controller, "upstream SSE ended before a terminal event", "upstream_sse_truncated");
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
const overflow = isTranslatorBudgetExceededError(error)
|
|
311
|
+
|| (error instanceof Error && (error.cause as { code?: unknown } | undefined)?.code === "translation_buffer_limit");
|
|
312
|
+
fail(
|
|
313
|
+
controller,
|
|
314
|
+
overflow ? "upstream SSE event exceeded the safe limit" : error instanceof Error ? error.message : String(error),
|
|
315
|
+
overflow ? "translation_buffer_limit" : "upstream_sse_error",
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
cancel(reason) {
|
|
320
|
+
cancelled = true;
|
|
321
|
+
releaseQueued();
|
|
322
|
+
releaseBuffer();
|
|
323
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
324
|
+
if (!settled) {
|
|
325
|
+
settled = true;
|
|
326
|
+
options.onCancel?.();
|
|
327
|
+
}
|
|
328
|
+
try { return reader.cancel(reason).then(() => undefined, () => undefined); } catch { return Promise.resolve(); }
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
}
|