@bitkyc08/opencodex 2.90.0 → 2.92.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/bin/ocx.mjs +37 -3
- package/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
- package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
- package/gui/dist/assets/index-BlFE_VB_.js +94 -0
- package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
- package/gui/dist/index.html +1 -1
- package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
- package/package.json +4 -3
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/claude-cli/usage-admission.ts +14 -0
- package/src/adapters/google-vertex-endpoint.ts +9 -1
- package/src/adapters/google-vertex-key-project.ts +223 -0
- package/src/adapters/google.ts +13 -3
- package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/adapters/openai-responses/request-strips.ts +92 -0
- package/src/adapters/openai-responses/transport.ts +6 -0
- package/src/adapters/registry.ts +5 -0
- package/src/adapters/workbuddy-schema.ts +61 -0
- package/src/adapters/workbuddy.ts +68 -0
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +1 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +47 -8
- package/src/chat/reasoning-details.ts +137 -0
- package/src/cli/capabilities-agents-routing.ts +53 -3
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-cli-update.ts +8 -0
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/combo-result.ts +6 -0
- package/src/cli/combo-stats.ts +3 -0
- package/src/cli/combo.ts +66 -42
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/launcher-context.ts +49 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +123 -35
- package/src/cli/provider.ts +46 -3
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/cli/usage-compare.ts +68 -12
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-ingress.ts +1 -1
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-readiness.ts +97 -0
- package/src/client/link-relay-transport.ts +84 -9
- package/src/client/link-relay.ts +98 -15
- package/src/client/link-tunnel.ts +45 -18
- package/src/client/machine-listener.ts +103 -3
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +155 -25
- package/src/codex/account-store.ts +17 -3
- package/src/codex/app-server-restart-service.ts +6 -5
- package/src/codex/auth-context.ts +10 -6
- package/src/codex/catalog/effort.ts +9 -2
- package/src/codex/catalog/gather-capture.ts +4 -2
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/provider-models.ts +14 -8
- package/src/codex/chatgpt-refresh-failure.ts +6 -2
- package/src/codex/cli-install-provenance.ts +418 -0
- package/src/codex/cli-installation-targets.ts +5 -1
- package/src/codex/cli-update-plan.ts +43 -16
- package/src/codex/desktop-app/picker-freshness.ts +10 -8
- package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
- package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/pool-refresh-backoff.ts +3 -1
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/codex/user-identity.ts +35 -6
- package/src/codex/windows-current-sid.ts +162 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/jev-quota-activation.ts +5 -1
- package/src/combos/jev-quota-route.ts +1 -1
- package/src/combos/jev-quota.ts +9 -1
- package/src/combos/jev-session-pin-config.ts +51 -0
- package/src/combos/jev.ts +6 -0
- package/src/combos/resolve.ts +12 -0
- package/src/combos/types.ts +19 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -0
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +375 -195
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/decision-quota-sink.ts +10 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/lib/translator-budget.ts +4 -4
- package/src/lib/upstream-retry.ts +4 -1
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/diagnostics.ts +193 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/relay-auth.ts +6 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +63 -2
- package/src/link/supervisor.ts +45 -12
- package/src/link/tunnel-history.ts +109 -0
- package/src/link/tunnel-state.ts +80 -17
- package/src/messaging/remote-files.ts +24 -1
- package/src/oauth/generic-account-failover.ts +91 -7
- package/src/oauth/index.ts +16 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/model-discovery-auth.ts +5 -0
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +6 -2
- package/src/oauth/token-guardian.ts +7 -0
- package/src/oauth/types.ts +9 -0
- package/src/oauth/workbuddy.ts +139 -0
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- package/src/providers/antigravity-quota-identity.ts +37 -0
- package/src/providers/base-url-choices.ts +8 -5
- package/src/providers/derive.ts +23 -3
- package/src/providers/mistral-transport.ts +37 -4
- package/src/providers/model-discovery.ts +2 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/quota/account-cache.ts +3 -2
- package/src/providers/quota/antigravity.ts +27 -22
- package/src/providers/quota/report-cache.ts +13 -4
- package/src/providers/quota/vendor-probes-key.ts +60 -0
- package/src/providers/quota/workbuddy.ts +83 -0
- package/src/providers/quota-decision-antigravity.ts +64 -0
- package/src/providers/quota-decision-publication.ts +11 -0
- package/src/providers/quota-decision-snapshot.ts +21 -4
- package/src/providers/quota-types.ts +2 -0
- package/src/providers/quota.ts +3 -1
- package/src/providers/registry/entries-core.ts +9 -0
- package/src/providers/registry/entries-extended.ts +20 -7
- package/src/providers/registry/model-ids.ts +3 -0
- package/src/providers/registry/model-seeds.ts +27 -28
- package/src/providers/registry/types.ts +19 -1
- package/src/providers/resolved-model-policy.ts +4 -4
- package/src/providers/routed-config.ts +15 -0
- package/src/providers/stale-model-roster-migration.ts +83 -15
- package/src/providers/workbuddy-credit-balance.ts +81 -0
- package/src/providers/workbuddy-models.ts +37 -0
- package/src/providers/workbuddy.ts +60 -0
- package/src/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-replay-cache.ts +18 -14
- package/src/router.ts +2 -0
- package/src/routing/analytics.ts +2 -3
- package/src/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +7 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +22 -2
- package/src/server/chat-native-reasoning.ts +10 -3
- package/src/server/chat-native.ts +13 -2
- package/src/server/claude-messages.ts +3 -0
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +20 -7
- package/src/server/index/optional-listeners.ts +11 -2
- package/src/server/index/serve-options.ts +19 -2
- package/src/server/live.ts +5 -0
- package/src/server/local-management-read-client.ts +3 -1
- package/src/server/management/combo-routes.ts +10 -4
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +16 -2
- package/src/server/management/provider-routes.ts +1 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.ts +2 -1
- package/src/server/port-reclaim.ts +51 -9
- package/src/server/request-log-conversation.ts +49 -0
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
- package/src/server/responses/compact.ts +12 -4
- package/src/server/responses/core-auth.ts +11 -1
- package/src/server/responses/core-combo.ts +125 -32
- package/src/server/responses/core-lifetime.ts +2 -2
- package/src/server/responses/core-normalize.ts +6 -3
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +2 -1
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/jev-combo-choice.ts +39 -0
- package/src/server/responses/jev-session-pin.ts +203 -0
- package/src/server/responses/main-hard-lock-routing.ts +54 -2
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/request-prepare.ts +19 -6
- package/src/server/responses/request-transport.ts +25 -6
- package/src/server/responses/run-turn-execution.ts +2 -4
- package/src/server/responses/sidecar-execution.ts +2 -4
- package/src/server/responses-phase-inference.ts +267 -0
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/sse-payload-rewrite.ts +14 -1
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/definition.ts +20 -8
- package/src/service/diagnostics.ts +20 -6
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +13 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +9 -1
- package/src/types/request.ts +1 -0
- package/src/types/wire.ts +1 -0
- package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/usage/jev-stats.ts +54 -14
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/index-TmaHa54u.js +0 -94
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
|
@@ -24,6 +24,66 @@ export function stripProviderInputItemMetadata(body: unknown): unknown {
|
|
|
24
24
|
input ??= [...body.input];
|
|
25
25
|
input[index] = next;
|
|
26
26
|
}
|
|
27
|
+
return repairUrlCitationAnnotations(input ? { ...body, input } : body);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isUrlCitationIndex(value: unknown): value is number {
|
|
31
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Coerce a replayed citation index to a non-negative safe integer, falling back to 0. */
|
|
35
|
+
function repairUrlCitationIndex(value: unknown): number {
|
|
36
|
+
if (isUrlCitationIndex(value)) return value;
|
|
37
|
+
if (typeof value === "string" && /^\d+$/.test(value)) {
|
|
38
|
+
const index = Number(value);
|
|
39
|
+
if (isUrlCitationIndex(index)) return index;
|
|
40
|
+
}
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* OpenAI Responses requires `title` (string) and integer `start_index`/`end_index` on every
|
|
46
|
+
* `url_citation` annotation of a replayed `output_text` part. History written by an older bridge
|
|
47
|
+
* (title omitted when the search source had none) or a lossy client otherwise fails every later
|
|
48
|
+
* request carrying it, most visibly remote compaction, with a deterministic HTTP 400
|
|
49
|
+
* (`Missing required parameter: input[N].content[M].annotations[K].title`).
|
|
50
|
+
* Repair only those fields on `url_citation` annotations of assistant message content; other
|
|
51
|
+
* annotation types, unknown keys and every other item are untouched. Copy on write: an already
|
|
52
|
+
* valid body is returned by reference.
|
|
53
|
+
*/
|
|
54
|
+
export function repairUrlCitationAnnotations(body: unknown): unknown {
|
|
55
|
+
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
56
|
+
let input: unknown[] | undefined;
|
|
57
|
+
for (let itemIndex = 0; itemIndex < body.input.length; itemIndex += 1) {
|
|
58
|
+
const item = body.input[itemIndex];
|
|
59
|
+
if (!isPlainObject(item) || !Array.isArray(item.content)) continue;
|
|
60
|
+
let content: unknown[] | undefined;
|
|
61
|
+
for (let partIndex = 0; partIndex < item.content.length; partIndex += 1) {
|
|
62
|
+
const part = item.content[partIndex];
|
|
63
|
+
if (!isPlainObject(part) || part.type !== "output_text" || !Array.isArray(part.annotations)) continue;
|
|
64
|
+
let annotations: unknown[] | undefined;
|
|
65
|
+
for (let annIndex = 0; annIndex < part.annotations.length; annIndex += 1) {
|
|
66
|
+
const ann = part.annotations[annIndex];
|
|
67
|
+
if (!isPlainObject(ann) || ann.type !== "url_citation") continue;
|
|
68
|
+
const titleOk = typeof ann.title === "string";
|
|
69
|
+
const startOk = isUrlCitationIndex(ann.start_index);
|
|
70
|
+
const endOk = isUrlCitationIndex(ann.end_index);
|
|
71
|
+
if (titleOk && startOk && endOk) continue;
|
|
72
|
+
const next: Record<string, unknown> = { ...ann };
|
|
73
|
+
if (!titleOk) next.title = typeof ann.url === "string" ? ann.url : "";
|
|
74
|
+
if (!startOk) next.start_index = repairUrlCitationIndex(ann.start_index);
|
|
75
|
+
if (!endOk) next.end_index = repairUrlCitationIndex(ann.end_index);
|
|
76
|
+
annotations ??= [...part.annotations];
|
|
77
|
+
annotations[annIndex] = next;
|
|
78
|
+
}
|
|
79
|
+
if (!annotations) continue;
|
|
80
|
+
content ??= [...item.content];
|
|
81
|
+
content[partIndex] = { ...part, annotations };
|
|
82
|
+
}
|
|
83
|
+
if (!content) continue;
|
|
84
|
+
input ??= [...body.input];
|
|
85
|
+
input[itemIndex] = { ...item, content };
|
|
86
|
+
}
|
|
27
87
|
return input ? { ...body, input } : body;
|
|
28
88
|
}
|
|
29
89
|
|
|
@@ -187,6 +247,38 @@ export function stripCanonicalOnlyTopLevelFields(body: unknown): unknown {
|
|
|
187
247
|
return next;
|
|
188
248
|
}
|
|
189
249
|
|
|
250
|
+
/**
|
|
251
|
+
* OpenAI-private input item TYPES, the item-level sibling of the two tables above.
|
|
252
|
+
*
|
|
253
|
+
* Each one is a positional control the OpenAI Responses backend interprets where it sits in
|
|
254
|
+
* history, never a turn of the conversation, so dropping it leaves every message, call and output
|
|
255
|
+
* in place. Codex gates sending one on its own `is_openai` provider check, which loopback
|
|
256
|
+
* injection always satisfies, so a gateway this proxy does not operate can still receive one and
|
|
257
|
+
* reject the whole body as an unknown item type.
|
|
258
|
+
*
|
|
259
|
+
* Same rule as the top-level table: only types a client is OBSERVED to send.
|
|
260
|
+
*/
|
|
261
|
+
const CANONICAL_ONLY_INPUT_ITEM_TYPES: ReadonlySet<string> = new Set([
|
|
262
|
+
// Codex 0.164 `ResponseItem::ConfigurationUpdate` ({type, reasoning: {effort}}): a durable
|
|
263
|
+
// reasoning-effort change recorded at its position in history. codex-rs sends it only for a
|
|
264
|
+
// catalog row with `supports_reasoning_effort_updates`, and keeps it in saved history after a
|
|
265
|
+
// model switch. Mapping the effort onto another provider is out of scope (#6895).
|
|
266
|
+
"configuration_update",
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Remove OpenAI-private input items. The caller decides the boundary, exactly as for
|
|
271
|
+
* {@link stripCanonicalOnlyTopLevelFields}; the canonical and OpenAI-operated destinations keep
|
|
272
|
+
* the items verbatim and in order. Returns the input unchanged when none is present.
|
|
273
|
+
*/
|
|
274
|
+
export function stripCanonicalOnlyInputItems(body: unknown): unknown {
|
|
275
|
+
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
276
|
+
const isPrivate = (item: unknown): boolean => isPlainObject(item)
|
|
277
|
+
&& typeof item.type === "string" && CANONICAL_ONLY_INPUT_ITEM_TYPES.has(item.type);
|
|
278
|
+
if (!body.input.some(isPrivate)) return body;
|
|
279
|
+
return { ...body, input: body.input.filter(item => !isPrivate(item)) };
|
|
280
|
+
}
|
|
281
|
+
|
|
190
282
|
/**
|
|
191
283
|
* Sampling fields a model on the provider's `noStopModels` / `noPenaltyModels` list rejects on
|
|
192
284
|
* every wire. Claude inbound translates `stop_sequences` into a Responses `stop`
|
|
@@ -3,6 +3,7 @@ import type { OcxProviderConfig } from "../../types";
|
|
|
3
3
|
import { CODEX_RESPONSES_LITE_HEADER } from "../../codex/forward-transport-headers";
|
|
4
4
|
import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers-destination";
|
|
5
5
|
import { normalizeForwardedClientHeaderName } from "../../lib/provider-client-headers";
|
|
6
|
+
import { AGENT_ASSERTION_HEADER } from "../../lib/authorization-scheme";
|
|
6
7
|
import { openaiResponsesUrl } from "../openai-responses-url";
|
|
7
8
|
|
|
8
9
|
// Caller credentials are forwarded only to the canonical Codex destination, never by capability.
|
|
@@ -69,6 +70,11 @@ export function buildResponsesTransport(provider: OcxProviderConfig, incoming: P
|
|
|
69
70
|
if (override && mayForwardCallerCredentials) {
|
|
70
71
|
headers.authorization = `Bearer ${override.accessToken}`;
|
|
71
72
|
headers["chatgpt-account-id"] = override.chatgptAccountId;
|
|
73
|
+
} else if (mayForwardCallerCredentials) {
|
|
74
|
+
// dual_header runtime assertion: first-party Direct only, beside the caller's own Bearer.
|
|
75
|
+
// A pool override above never carries it, and non-canonical transports never see it.
|
|
76
|
+
const assertion = incoming.headers.get(AGENT_ASSERTION_HEADER);
|
|
77
|
+
if (assertion) headers[AGENT_ASSERTION_HEADER] = assertion;
|
|
72
78
|
}
|
|
73
79
|
} else {
|
|
74
80
|
url = provider.responsesPath === undefined
|
package/src/adapters/registry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createAnthropicAdapter } from "./anthropic";
|
|
2
|
+
import { createWorkBuddyAdapter } from "./workbuddy";
|
|
2
3
|
import { createDshAccountAdapter } from "./dsh-account";
|
|
3
4
|
import { createAzureAdapter } from "./azure";
|
|
4
5
|
import type { ProviderAdapter } from "./base";
|
|
@@ -74,6 +75,10 @@ type InheritedAdapterDefinition = {
|
|
|
74
75
|
type AdapterDefinition = DirectAdapterDefinition | InheritedAdapterDefinition;
|
|
75
76
|
|
|
76
77
|
export const ADAPTER_REGISTRY = {
|
|
78
|
+
"workbuddy-ai": {
|
|
79
|
+
contractParent: "openai-chat",
|
|
80
|
+
create: (provider: OcxProviderConfig) => withUniqueToolCallIds(createWorkBuddyAdapter(provider)),
|
|
81
|
+
},
|
|
77
82
|
codebuddy: {
|
|
78
83
|
wire: "codebuddy",
|
|
79
84
|
mutation: "codex-owned",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const schemaMaps = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]);
|
|
2
|
+
const schemaLists = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]);
|
|
3
|
+
const schemaValues = new Set(["items", "additionalProperties", "unevaluatedProperties", "contains", "not", "if", "then", "else", "propertyNames"]);
|
|
4
|
+
const jsonTypes = new Set(["string", "number", "integer", "boolean", "object", "array", "null"]);
|
|
5
|
+
|
|
6
|
+
/** Normalize schemas only; defaults, examples and property names are data, never schemas. */
|
|
7
|
+
export function workBuddySchema(value: unknown, depth = 0): Record<string, unknown> {
|
|
8
|
+
if (depth > 64) throw new Error("WorkBuddy AI tool schema exceeds maximum depth");
|
|
9
|
+
if (value === false) return { not: {} };
|
|
10
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
11
|
+
const input = value as Record<string, unknown>, out = { ...input };
|
|
12
|
+
for (const [key, child] of Object.entries(input)) {
|
|
13
|
+
if (schemaMaps.has(key) && child && typeof child === "object" && !Array.isArray(child)) {
|
|
14
|
+
out[key] = Object.fromEntries(Object.entries(child).map(([name, schema]) => [name, workBuddySchema(schema, depth + 1)]));
|
|
15
|
+
} else if (schemaLists.has(key) && Array.isArray(child)) {
|
|
16
|
+
out[key] = child.map(schema => workBuddySchema(schema, depth + 1));
|
|
17
|
+
} else if (schemaValues.has(key)) {
|
|
18
|
+
if ((key === "additionalProperties" || key === "unevaluatedProperties") && typeof child === "boolean") continue;
|
|
19
|
+
out[key] = key === "items" && Array.isArray(child)
|
|
20
|
+
? { anyOf: child.map(schema => workBuddySchema(schema, depth + 1)) } : workBuddySchema(child, depth + 1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (typeof out.pattern === "string") out.pattern = out.pattern.replace(/\\\\|\\0(?![0-9])/g, match => match === "\\0" ? "\\u0000" : match);
|
|
24
|
+
if (out.required !== undefined && !Array.isArray(out.required)) delete out.required;
|
|
25
|
+
if (typeof out.type === "string") {
|
|
26
|
+
out.type = out.type.toLowerCase();
|
|
27
|
+
if (!jsonTypes.has(out.type as string)) delete out.type;
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(out.enum)) {
|
|
30
|
+
if (!out.enum.length) delete out.enum;
|
|
31
|
+
else if (out.type === undefined) {
|
|
32
|
+
const groups = new Map<string, unknown[]>();
|
|
33
|
+
for (const item of out.enum) {
|
|
34
|
+
const type = item === null ? "null" : Array.isArray(item) ? "array" : typeof item;
|
|
35
|
+
const values = groups.get(type) ?? []; values.push(item); groups.set(type, values);
|
|
36
|
+
}
|
|
37
|
+
if (groups.size === 1) out.type = groups.keys().next().value;
|
|
38
|
+
else {
|
|
39
|
+
const union = { anyOf: [...groups].map(([type, values]) => ({ type, enum: values })) };
|
|
40
|
+
out.allOf = [...(Array.isArray(out.allOf) ? out.allOf : []), union];
|
|
41
|
+
delete out.enum;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function workBuddyToolParameters(value: unknown): Record<string, unknown> {
|
|
49
|
+
const out = workBuddySchema(value);
|
|
50
|
+
const properties: Record<string, unknown> = { ...(out.properties as Record<string, unknown> ?? {}) };
|
|
51
|
+
const required = new Set(Array.isArray(out.required) ? out.required.filter(x => typeof x === "string") : []);
|
|
52
|
+
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
53
|
+
for (const variant of Array.isArray(out[key]) ? out[key] : []) {
|
|
54
|
+
const row = variant as Record<string, unknown>;
|
|
55
|
+
Object.assign(properties, row.properties);
|
|
56
|
+
if (key === "allOf" && Array.isArray(row.required)) for (const field of row.required) if (typeof field === "string") required.add(field);
|
|
57
|
+
}
|
|
58
|
+
delete out[key];
|
|
59
|
+
}
|
|
60
|
+
return { ...out, type: "object", properties, ...(required.size ? { required: [...required] } : {}) };
|
|
61
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import type { ProviderAdapter } from "./base";
|
|
3
|
+
import { workBuddyToolParameters } from "./workbuddy-schema";
|
|
4
|
+
import { retainTranslatedEvent, releaseTranslatedEvent } from "../lib/translator-budget";
|
|
5
|
+
import { createOpenAIChatAdapter } from "./openai-chat";
|
|
6
|
+
import type { AdapterEvent, OcxProviderConfig } from "../types";
|
|
7
|
+
import { assertWorkBuddyDestination, WORKBUDDY_BASE_URL, workBuddyHeaders } from "../providers/workbuddy";
|
|
8
|
+
|
|
9
|
+
const sessionSalt = randomBytes(32);
|
|
10
|
+
const opaqueId = (value: unknown) => createHmac("sha256", sessionSalt).update(JSON.stringify(value)).digest("hex").slice(0, 32);
|
|
11
|
+
|
|
12
|
+
export function createWorkBuddyAdapter(provider: OcxProviderConfig): ProviderAdapter {
|
|
13
|
+
const base = createOpenAIChatAdapter({ ...provider, adapter: "openai-chat", baseUrl: WORKBUDDY_BASE_URL, headers: undefined });
|
|
14
|
+
return {
|
|
15
|
+
...base, name: "workbuddy-ai",
|
|
16
|
+
async buildRequest(parsed, incoming) {
|
|
17
|
+
assertWorkBuddyDestination(provider.baseUrl);
|
|
18
|
+
if (!provider.apiKey || !parsed._workbuddyAuthContext) throw new Error("WorkBuddy AI account unavailable; run ocx login workbuddy-ai");
|
|
19
|
+
const req = await base.buildRequest({ ...parsed, stream: true }, incoming);
|
|
20
|
+
const body = JSON.parse(req.body);
|
|
21
|
+
const messages = body.messages as Array<Record<string, unknown>>;
|
|
22
|
+
for (const message of messages) if (message.role === "developer") message.role = "system";
|
|
23
|
+
if (messages[0]?.role !== "system") messages.unshift({ role: "system", content: "You are a helpful assistant." });
|
|
24
|
+
if (body.max_completion_tokens !== undefined) { body.max_tokens = body.max_completion_tokens; delete body.max_completion_tokens; }
|
|
25
|
+
if (body.tool_choice === "none") { delete body.tools; delete body.tool_choice; }
|
|
26
|
+
else if (body.tool_choice?.type === "function") body.tool_choice = body.tool_choice.function.name;
|
|
27
|
+
for (const tool of body.tools ?? []) {
|
|
28
|
+
if (tool.type === "function") tool.function.parameters = workBuddyToolParameters(tool.function.parameters);
|
|
29
|
+
}
|
|
30
|
+
const users = messages.map((m, index) => ({ m, index })).filter(({ m }) => m.role === "user");
|
|
31
|
+
const session = parsed._codexOwnThreadId || parsed._clientThreadId
|
|
32
|
+
|| (!parsed._promptCacheKeyIsSharedCohort ? parsed.options.promptCacheKey : undefined)
|
|
33
|
+
|| incoming.headers.get("session_id") || incoming.headers.get("x-session-id");
|
|
34
|
+
const conversation = session || users[0]?.m.content;
|
|
35
|
+
const latest = users.at(-1);
|
|
36
|
+
const requestId = randomUUID().replaceAll("-", "");
|
|
37
|
+
const headers = {
|
|
38
|
+
"Content-Type": "application/json", Accept: "text/event-stream",
|
|
39
|
+
...workBuddyHeaders(provider.apiKey, parsed._workbuddyAuthContext),
|
|
40
|
+
"X-Request-ID": requestId, "X-Conversation-Message-ID": requestId,
|
|
41
|
+
"X-Conversation-Request-ID": latest ? opaqueId([conversation, latest.index, latest.m.content]) : requestId,
|
|
42
|
+
...(conversation ? { "X-Conversation-ID": opaqueId(conversation) } : {}),
|
|
43
|
+
};
|
|
44
|
+
return { ...req, url: `${WORKBUDDY_BASE_URL}/chat/completions`, headers, body: JSON.stringify(body), redirect: "error" as const };
|
|
45
|
+
},
|
|
46
|
+
async parseResponse(response, budget, tier) {
|
|
47
|
+
const events: AdapterEvent[] = [];
|
|
48
|
+
try {
|
|
49
|
+
for await (const event of base.parseStream(response, budget, tier)) {
|
|
50
|
+
retainTranslatedEvent(event, budget, events.at(-1));
|
|
51
|
+
events.push(event);
|
|
52
|
+
}
|
|
53
|
+
return events;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
for (const event of events) releaseTranslatedEvent(event, budget);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
formatErrorBody(status, _headers, payload) {
|
|
60
|
+
let code: unknown;
|
|
61
|
+
try { code = JSON.parse(payload)?.code; } catch { /* Only structured numeric codes are public. */ }
|
|
62
|
+
if (Number(code) === 11128) return "WorkBuddy AI rejected this client channel (11128).";
|
|
63
|
+
if (Number(code) === 11140) return "WorkBuddy AI restricted this account (11140); contact WorkBuddy support.";
|
|
64
|
+
if (Number(code) === 14018) return "WorkBuddy AI credits exhausted (14018).";
|
|
65
|
+
return `WorkBuddy AI request failed (HTTP ${status}).`;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -253,7 +253,7 @@ function buildResponseJSONWithBudget(
|
|
|
253
253
|
const text = stripCitationMarkers(currentTextStr);
|
|
254
254
|
const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0);
|
|
255
255
|
const annotations = pendingWebSources.map(s => ({
|
|
256
|
-
type: "url_citation", url: s.url,
|
|
256
|
+
type: "url_citation", url: s.url, title: s.title || s.url, start_index: 0, end_index: 0,
|
|
257
257
|
}));
|
|
258
258
|
pendingWebSources = [];
|
|
259
259
|
const item = {
|
package/src/bridge/sse.ts
CHANGED
|
@@ -488,7 +488,7 @@ export function bridgeToResponsesSSE(
|
|
|
488
488
|
const takeWebAnnotations = (): { type: string; url: string; title?: string; start_index: number; end_index: number }[] => {
|
|
489
489
|
if (pendingWebSources.length === 0) return [];
|
|
490
490
|
const anns = pendingWebSources.map(s => ({
|
|
491
|
-
type: "url_citation", url: s.url,
|
|
491
|
+
type: "url_citation", url: s.url, title: s.title || s.url, start_index: 0, end_index: 0,
|
|
492
492
|
}));
|
|
493
493
|
const annotationBytes = bytesOf(JSON.stringify(anns));
|
|
494
494
|
const reservation = budget?.reserveTransient(annotationBytes, { kind: "retained_collectors" });
|
package/src/chat/inbound.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* responsesRequestSchema so routing/OAuth/pool/sidecars are inherited unchanged.
|
|
7
7
|
*/
|
|
8
8
|
import { chatImageUrlFromPart } from "./image-parts";
|
|
9
|
+
import { signedReasoningInputItems } from "./reasoning-details";
|
|
9
10
|
import { untranslatedChatInputMedia, untranslatedInputMediaMessage } from "../responses/input-media";
|
|
10
11
|
|
|
11
12
|
export class ChatCompletionsRequestError extends Error {}
|
|
@@ -439,7 +440,16 @@ function resolveReasoningSummary(raw: Rec): string | undefined {
|
|
|
439
440
|
* Translate an OpenAI Chat Completions request body into a /v1/responses request body.
|
|
440
441
|
* Throws ChatCompletionsRequestError (-> 400) on malformed input.
|
|
441
442
|
*/
|
|
442
|
-
export
|
|
443
|
+
export interface ChatResponsesProjectionOptions {
|
|
444
|
+
/**
|
|
445
|
+
* The route is one first-party Anthropic destination that cannot change credential domain, so
|
|
446
|
+
* signed thinking a client returns in `reasoning_details` may be replayed to it (#7429).
|
|
447
|
+
* Every other route keeps the plaintext projection.
|
|
448
|
+
*/
|
|
449
|
+
acceptAnthropicReasoningSignatures?: boolean;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function chatCompletionsToResponsesBody(raw: unknown, options: ChatResponsesProjectionOptions = {}): Rec {
|
|
443
453
|
assertChatCompletionsRoutingBody(raw);
|
|
444
454
|
// Only the translated path reaches this function. Native Chat can retain its
|
|
445
455
|
// provider-specific file/audio blocks; projecting them here would discard them.
|
|
@@ -510,7 +520,13 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
|
|
|
510
520
|
// is no attachment point on the message itself, and the parser buffers a
|
|
511
521
|
// reasoning item and prepends it to the NEXT assistant message. Emitting it
|
|
512
522
|
// here keeps that adjacency intact.
|
|
513
|
-
const
|
|
523
|
+
const signedReasoning = options.acceptAnthropicReasoningSignatures === true
|
|
524
|
+
? signedReasoningInputItems(msg.reasoning_details)
|
|
525
|
+
: undefined;
|
|
526
|
+
const reasoningText = signedReasoning ? undefined : assistantReasoningText(msg);
|
|
527
|
+
// Signed entries carry their own text, so `reasoning_content` (the same text) is not
|
|
528
|
+
// projected a second time beside them.
|
|
529
|
+
if (signedReasoning) input.push(...signedReasoning);
|
|
514
530
|
if (reasoningText !== undefined) {
|
|
515
531
|
// `summary` is required on a reasoning input item by the OpenAI Responses API, and our
|
|
516
532
|
// own responsesRequestSchema marks it optional, so a summary-less item validated locally
|
|
@@ -525,6 +541,8 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
|
|
|
525
541
|
content: [{ type: "reasoning_text", text: reasoningText }],
|
|
526
542
|
});
|
|
527
543
|
}
|
|
544
|
+
// An Opus progress update also sits in `content`. The wire has no marker that tells it
|
|
545
|
+
// from answer text, so the content is replayed as sent, as it was before #7429.
|
|
528
546
|
const blocks = assistantContentToBlocks(msg.content);
|
|
529
547
|
if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks });
|
|
530
548
|
if (msg.tool_calls !== undefined) {
|
package/src/chat/outbound.ts
CHANGED
|
@@ -22,7 +22,9 @@ import {
|
|
|
22
22
|
UNDECLARED_TOOL_CALL_ERROR_CODE,
|
|
23
23
|
} from "../lib/errors";
|
|
24
24
|
import { redactSecretString } from "../lib/redact";
|
|
25
|
+
import { truncationReasonFor } from "../responses/truncated-stop-reason";
|
|
25
26
|
import { createSseBlockBuffer, sseDataPayload } from "../server/sse-payload-rewrite";
|
|
27
|
+
import { reasoningDetailEntriesForItem } from "./reasoning-details";
|
|
26
28
|
|
|
27
29
|
function isRec(v: unknown): v is Rec {
|
|
28
30
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
@@ -149,7 +151,7 @@ export function chunkBase(id: string, model: string, created: number): Rec {
|
|
|
149
151
|
};
|
|
150
152
|
}
|
|
151
153
|
|
|
152
|
-
function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number {
|
|
154
|
+
export function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number {
|
|
153
155
|
let nextBytes = previousBytes + Buffer.byteLength(fragment);
|
|
154
156
|
const previousLast = previous.charCodeAt(previous.length - 1);
|
|
155
157
|
const fragmentFirst = fragment.charCodeAt(0);
|
|
@@ -242,8 +244,14 @@ export function chatCompletionsIncompleteOutcome(
|
|
|
242
244
|
reason: unknown,
|
|
243
245
|
message: unknown,
|
|
244
246
|
): { finishReason: "length" | "content_filter" } | { failMessage: string } {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
+
// Upstream Responses endpoints do not all use OpenAI's `max_output_tokens`: Volcengine Ark
|
|
248
|
+
// (Coding Plan `/responses`) sends `incomplete_details.reason: "length"` for a hit output cap.
|
|
249
|
+
// Read the reason through the shared truncation vocabulary so such a turn ends with
|
|
250
|
+
// finish_reason "length" instead of a 502. Proxy reasons (adapter_eof, upstream_stall_timeout,
|
|
251
|
+
// missing_terminal_event) are not in that vocabulary and still fail.
|
|
252
|
+
const truncation = typeof reason === "string" ? truncationReasonFor(reason) : undefined;
|
|
253
|
+
if (truncation === "max_output_tokens") return { finishReason: "length" };
|
|
254
|
+
if (truncation === "content_filter") return { finishReason: "content_filter" };
|
|
247
255
|
const why = typeof reason === "string" ? reason : "unknown";
|
|
248
256
|
return {
|
|
249
257
|
failMessage: typeof message === "string" && message.length > 0
|
|
@@ -511,6 +519,17 @@ export function responsesSseToChatCompletionsSse(
|
|
|
511
519
|
frame.choices = [{ index: 0, delta: { reasoning_content: text }, finish_reason: null }];
|
|
512
520
|
emit(frame);
|
|
513
521
|
};
|
|
522
|
+
// #7429: provider-issued thinking state, one complete frame per finished reasoning item.
|
|
523
|
+
let nextReasoningDetailIndex = 0;
|
|
524
|
+
const emitReasoningDetails = (item: Rec) => {
|
|
525
|
+
const entries = reasoningDetailEntriesForItem(item, nextReasoningDetailIndex, translatorBudget);
|
|
526
|
+
if (entries.length === 0) return;
|
|
527
|
+
nextReasoningDetailIndex += entries.length;
|
|
528
|
+
ensureRole();
|
|
529
|
+
const frame = chunkBase(id, model, created);
|
|
530
|
+
frame.choices = [{ index: 0, delta: { reasoning_details: entries }, finish_reason: null }];
|
|
531
|
+
emit(frame);
|
|
532
|
+
};
|
|
514
533
|
const resolveFinalArguments = (candidate: unknown, streamed: string) => {
|
|
515
534
|
if (typeof candidate !== "string") return streamed;
|
|
516
535
|
// Some compatible providers send an empty final snapshot. Do not let that erase
|
|
@@ -717,6 +736,7 @@ export function responsesSseToChatCompletionsSse(
|
|
|
717
736
|
&& refusalItems.has(data.output_index)) {
|
|
718
737
|
refusalItem(data.output_index, data, "item_id");
|
|
719
738
|
}
|
|
739
|
+
if (item.type === "reasoning") emitReasoningDetails(item);
|
|
720
740
|
if (item.type === "function_call") {
|
|
721
741
|
sawToolUse = true;
|
|
722
742
|
const callId = typeof item.call_id === "string" ? item.call_id : "";
|
|
@@ -858,8 +878,8 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran
|
|
|
858
878
|
const incomplete = isRec(body.incomplete_details) ? body.incomplete_details : {};
|
|
859
879
|
let incompleteFinish: "length" | "content_filter" | undefined;
|
|
860
880
|
if (body.status === "incomplete") {
|
|
861
|
-
|
|
862
|
-
|
|
881
|
+
const outcome = chatCompletionsIncompleteOutcome(incomplete.reason, undefined);
|
|
882
|
+
if ("finishReason" in outcome) incompleteFinish = outcome.finishReason;
|
|
863
883
|
else throw new ChatCompletionsStreamError("upstream response ended without a supported completion boundary", {
|
|
864
884
|
code: "upstream_incomplete", type: "upstream_error",
|
|
865
885
|
});
|
|
@@ -872,6 +892,7 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran
|
|
|
872
892
|
let contentBytes = 0;
|
|
873
893
|
let reasoningBytes = 0;
|
|
874
894
|
const toolCalls: Rec[] = [];
|
|
895
|
+
const reasoningDetails: Rec[] = [];
|
|
875
896
|
const append = (previous: string, previousBytes: number, fragment: string): { text: string; bytes: number } => {
|
|
876
897
|
if (!fragment) return { text: previous, bytes: previousBytes };
|
|
877
898
|
const scope = { kind: "retained_collectors" as const };
|
|
@@ -902,6 +923,11 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran
|
|
|
902
923
|
}
|
|
903
924
|
}
|
|
904
925
|
} else if (raw.type === "reasoning") {
|
|
926
|
+
const entries = reasoningDetailEntriesForItem(raw, reasoningDetails.length, translatorBudget);
|
|
927
|
+
if (entries.length > 0) {
|
|
928
|
+
translatorBudget?.chargeRetained(Buffer.byteLength(JSON.stringify(entries)), { kind: "retained_collectors" });
|
|
929
|
+
reasoningDetails.push(...entries);
|
|
930
|
+
}
|
|
905
931
|
if (Array.isArray(raw.summary)) {
|
|
906
932
|
for (const part of raw.summary) {
|
|
907
933
|
if (isRec(part) && part.type === "summary_text" && typeof part.text === "string") {
|
|
@@ -948,6 +974,7 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran
|
|
|
948
974
|
refusal,
|
|
949
975
|
};
|
|
950
976
|
if (reasoning) message.reasoning_content = reasoning;
|
|
977
|
+
if (reasoningDetails.length > 0) message.reasoning_details = reasoningDetails;
|
|
951
978
|
if (toolCalls.length > 0) message.tool_calls = toolCalls;
|
|
952
979
|
|
|
953
980
|
return {
|
|
@@ -981,7 +1008,8 @@ export async function collectChatCompletion(
|
|
|
981
1008
|
let content = "";
|
|
982
1009
|
let refusal: string | null = null;
|
|
983
1010
|
let reasoning = "";
|
|
984
|
-
const
|
|
1011
|
+
const reasoningDetails: Rec[] = [];
|
|
1012
|
+
const retainedBytes = { content: 0, refusal: 0, reasoning: 0, reasoningDetails: 0 };
|
|
985
1013
|
const toolCalls = new Map<number, { id: string; name: string; arguments: string; argumentBytes: number }>();
|
|
986
1014
|
// Per-call budget scopes (2 MiB/call enforced by the budget): the map key is the
|
|
987
1015
|
// wire index, which is stable across deltas and present before the call id.
|
|
@@ -1005,8 +1033,8 @@ export async function collectChatCompletion(
|
|
|
1005
1033
|
}
|
|
1006
1034
|
};
|
|
1007
1035
|
const releaseCollectors = () => {
|
|
1008
|
-
translatorBudget.releaseRetained(retainedBytes.content + retainedBytes.refusal + retainedBytes.reasoning
|
|
1009
|
-
{ kind: "retained_collectors" });
|
|
1036
|
+
translatorBudget.releaseRetained(retainedBytes.content + retainedBytes.refusal + retainedBytes.reasoning
|
|
1037
|
+
+ retainedBytes.reasoningDetails, { kind: "retained_collectors" });
|
|
1010
1038
|
};
|
|
1011
1039
|
try {
|
|
1012
1040
|
// Share native relay framing, while retaining the collector's existing admission
|
|
@@ -1055,6 +1083,16 @@ export async function collectChatCompletion(
|
|
|
1055
1083
|
refusal = appendRetained("refusal", refusal ?? "", delta.refusal);
|
|
1056
1084
|
}
|
|
1057
1085
|
if (typeof delta.reasoning_content === "string") reasoning = appendRetained("reasoning", reasoning, delta.reasoning_content);
|
|
1086
|
+
// Each entry arrives complete in one frame (#7429), so the fold is a concatenation.
|
|
1087
|
+
if (Array.isArray(delta.reasoning_details)) {
|
|
1088
|
+
for (const entry of delta.reasoning_details) {
|
|
1089
|
+
if (!isRec(entry)) continue;
|
|
1090
|
+
const entryBytes = Buffer.byteLength(JSON.stringify(entry));
|
|
1091
|
+
translatorBudget.chargeRetained(entryBytes, { kind: "retained_collectors" });
|
|
1092
|
+
retainedBytes.reasoningDetails += entryBytes;
|
|
1093
|
+
reasoningDetails.push(entry);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1058
1096
|
if (Array.isArray(delta.tool_calls)) {
|
|
1059
1097
|
for (const tc of delta.tool_calls) {
|
|
1060
1098
|
if (!isRec(tc)) continue;
|
|
@@ -1121,6 +1159,7 @@ export async function collectChatCompletion(
|
|
|
1121
1159
|
refusal,
|
|
1122
1160
|
};
|
|
1123
1161
|
if (reasoning) message.reasoning_content = reasoning;
|
|
1162
|
+
if (reasoningDetails.length > 0) message.reasoning_details = reasoningDetails;
|
|
1124
1163
|
if (toolCalls.size > 0) {
|
|
1125
1164
|
// Final owner transfer is itself a charging operation: if it overflows,
|
|
1126
1165
|
// release every copy already charged in this loop and close every scope
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic thinking state on the Chat Completions wire, as OpenRouter-style `reasoning_details`
|
|
3
|
+
* (#7429).
|
|
4
|
+
*
|
|
5
|
+
* Chat has no slot for a thinking block's signature, so a Chat client echoing `reasoning_content`
|
|
6
|
+
* gave Claude back unsigned text, which the Anthropic adapter must drop: every tool step lost its
|
|
7
|
+
* reasoning. The carrier is the one OpenRouter documents for Claude and Hermes and pi already round
|
|
8
|
+
* trip: an ordered array on the assistant message, `{type: "reasoning.text", text, signature}`
|
|
9
|
+
* for a signed thinking block and `{type: "reasoning.encrypted", data}` for a redacted one.
|
|
10
|
+
*
|
|
11
|
+
* Outbound, the entries are built from the signature and redacted payloads the provider issued,
|
|
12
|
+
* never from anything this proxy computes. A signature that signs text the client never saw
|
|
13
|
+
* (hidden thinking) is withheld, because pairing it with the visible text would attest to content
|
|
14
|
+
* Anthropic did not sign. Inbound, the entries are read only for a single-destination first-party
|
|
15
|
+
* Anthropic route (the caller decides that), only when every entry is well formed, and become the
|
|
16
|
+
* same `ocxr1` envelope the Responses wire already uses; anything else falls back to plaintext.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
decodeReasoningEnvelope,
|
|
20
|
+
encodeReasoningEnvelope,
|
|
21
|
+
isLikelyRealAnthropicThinkingSignature,
|
|
22
|
+
type ReasoningEnvelope,
|
|
23
|
+
} from "../responses/reasoning-envelope";
|
|
24
|
+
import type { TranslatorBudget } from "../lib/translator-budget";
|
|
25
|
+
|
|
26
|
+
type Rec = Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
function isRec(value: unknown): value is Rec {
|
|
29
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** OpenRouter's `format` for Anthropic thinking entries. */
|
|
33
|
+
export const ANTHROPIC_REASONING_DETAIL_FORMAT = "anthropic-claude-v1";
|
|
34
|
+
|
|
35
|
+
/** One closed reasoning item, as either outbound path sees it. */
|
|
36
|
+
export interface ReasoningDetailSource {
|
|
37
|
+
/** The thinking text the client was shown for this item. */
|
|
38
|
+
text: string;
|
|
39
|
+
signature?: string;
|
|
40
|
+
redacted?: readonly string[];
|
|
41
|
+
/** The signature covers thinking text the client was not shown. */
|
|
42
|
+
hidden?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The `reasoning_details` entries for one reasoning item, numbered from `firstIndex`. Redacted
|
|
47
|
+
* blocks come first: that is the order the adapter replays them in. Empty when the item carries no
|
|
48
|
+
* provider-issued state a Chat client can return.
|
|
49
|
+
*/
|
|
50
|
+
export function reasoningDetailEntries(source: ReasoningDetailSource, firstIndex: number): Rec[] {
|
|
51
|
+
const entries: Rec[] = [];
|
|
52
|
+
for (const data of source.redacted ?? []) {
|
|
53
|
+
if (typeof data !== "string" || data.length === 0) continue;
|
|
54
|
+
entries.push({
|
|
55
|
+
type: "reasoning.encrypted",
|
|
56
|
+
data,
|
|
57
|
+
format: ANTHROPIC_REASONING_DETAIL_FORMAT,
|
|
58
|
+
index: firstIndex + entries.length,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (source.hidden !== true && isLikelyRealAnthropicThinkingSignature(source.signature)) {
|
|
62
|
+
entries.push({
|
|
63
|
+
type: "reasoning.text",
|
|
64
|
+
text: source.text,
|
|
65
|
+
signature: source.signature,
|
|
66
|
+
format: ANTHROPIC_REASONING_DETAIL_FORMAT,
|
|
67
|
+
index: firstIndex + entries.length,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return entries;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The visible text of a Responses reasoning item, read the way the parser reads it. */
|
|
74
|
+
export function responsesReasoningItemText(item: Rec): string {
|
|
75
|
+
const pick = (parts: unknown, type: string): string => Array.isArray(parts)
|
|
76
|
+
? parts.filter((part): part is Rec => isRec(part) && part.type === type && typeof part.text === "string")
|
|
77
|
+
.map(part => part.text as string).join("")
|
|
78
|
+
: "";
|
|
79
|
+
return pick(item.summary, "summary_text") || pick(item.content, "reasoning_text");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The entries for a finished Responses reasoning item from the internal bridge. Its `ocxr1`
|
|
84
|
+
* envelope holds what the provider issued. `txt` alone means the signed text was hidden; `txt`
|
|
85
|
+
* with `upd` is an Opus progress update, whose signed text the client received as `content`.
|
|
86
|
+
*/
|
|
87
|
+
export function reasoningDetailEntriesForItem(item: Rec, firstIndex: number, budget?: TranslatorBudget): Rec[] {
|
|
88
|
+
if (typeof item.encrypted_content !== "string") return [];
|
|
89
|
+
const envelope = decodeReasoningEnvelope(item.encrypted_content, budget);
|
|
90
|
+
if (!envelope || (!envelope.sig && !envelope.red)) return [];
|
|
91
|
+
const progress = envelope.upd !== undefined && typeof envelope.txt === "string";
|
|
92
|
+
return reasoningDetailEntries({
|
|
93
|
+
text: progress ? envelope.txt! : responsesReasoningItemText(item),
|
|
94
|
+
...(envelope.sig ? { signature: envelope.sig } : {}),
|
|
95
|
+
...(envelope.red ? { redacted: envelope.red } : {}),
|
|
96
|
+
hidden: !progress && envelope.txt !== undefined,
|
|
97
|
+
}, firstIndex);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function reasoningInputItem(text: string, envelope: ReasoningEnvelope): Rec {
|
|
101
|
+
return {
|
|
102
|
+
type: "reasoning",
|
|
103
|
+
summary: text ? [{ type: "summary_text", text }] : [],
|
|
104
|
+
...(text ? { content: [{ type: "reasoning_text", text }] } : {}),
|
|
105
|
+
encrypted_content: encodeReasoningEnvelope(envelope),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Responses reasoning input items carrying the signatures a client returned in
|
|
111
|
+
* `reasoning_details`, or `undefined` when the array is absent, empty or holds anything this
|
|
112
|
+
* carrier did not issue — an unsigned or malformed signature, a summary entry, another provider's
|
|
113
|
+
* encrypted format. Undefined means the caller keeps its plaintext projection unchanged.
|
|
114
|
+
*/
|
|
115
|
+
export function signedReasoningInputItems(details: unknown): Rec[] | undefined {
|
|
116
|
+
if (!Array.isArray(details) || details.length === 0) return undefined;
|
|
117
|
+
const items: Rec[] = [];
|
|
118
|
+
let redacted: string[] = [];
|
|
119
|
+
for (const raw of details) {
|
|
120
|
+
if (!isRec(raw)) return undefined;
|
|
121
|
+
if (raw.type === "reasoning.encrypted") {
|
|
122
|
+
if (raw.format !== ANTHROPIC_REASONING_DETAIL_FORMAT || typeof raw.data !== "string" || raw.data.length === 0) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
redacted.push(raw.data);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (raw.type !== "reasoning.text") return undefined;
|
|
129
|
+
if (raw.format !== undefined && raw.format !== ANTHROPIC_REASONING_DETAIL_FORMAT) return undefined;
|
|
130
|
+
const signature = typeof raw.signature === "string" ? raw.signature : undefined;
|
|
131
|
+
if (typeof raw.text !== "string" || !isLikelyRealAnthropicThinkingSignature(signature)) return undefined;
|
|
132
|
+
items.push(reasoningInputItem(raw.text, { sig: signature, ...(redacted.length > 0 ? { red: redacted } : {}) }));
|
|
133
|
+
redacted = [];
|
|
134
|
+
}
|
|
135
|
+
if (redacted.length > 0) items.push(reasoningInputItem("", { red: redacted }));
|
|
136
|
+
return items;
|
|
137
|
+
}
|