@remodex/rmx 1.0.2
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/AGENTS_INSTALL.md +91 -0
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/assets/architecture.png +0 -0
- package/assets/banner.png +0 -0
- package/assets/claude-code-models.gif +0 -0
- package/assets/codex-app-picker.png +0 -0
- package/bin/ocx.mjs +584 -0
- package/bin/package-main.mjs +9 -0
- package/gui/dist/assets/index-CZqebSPQ.css +1 -0
- package/gui/dist/assets/index-CkETtt7P.js +71 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/fonts/google-sans-cyrillic.woff2 +0 -0
- package/gui/dist/fonts/google-sans-latin.woff2 +0 -0
- package/gui/dist/icons.svg +24 -0
- package/gui/dist/index.html +25 -0
- package/gui/dist/logo.png +0 -0
- package/gui/dist/provider-icons/alibaba-color.svg +1 -0
- package/gui/dist/provider-icons/antigravity-color.svg +1 -0
- package/gui/dist/provider-icons/claude-color.svg +1 -0
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -0
- package/gui/dist/provider-icons/cursor-color.svg +2 -0
- package/gui/dist/provider-icons/deepseek-color.svg +1 -0
- package/gui/dist/provider-icons/discord.svg +1 -0
- package/gui/dist/provider-icons/firepass-color.svg +1 -0
- package/gui/dist/provider-icons/fireworks-color.svg +1 -0
- package/gui/dist/provider-icons/gemini-color.svg +1 -0
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
- package/gui/dist/provider-icons/grok.svg +1 -0
- package/gui/dist/provider-icons/groq-color.svg +1 -0
- package/gui/dist/provider-icons/huggingface-color.svg +1 -0
- package/gui/dist/provider-icons/kimi-color.svg +1 -0
- package/gui/dist/provider-icons/kiro-color.svg +15 -0
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
- package/gui/dist/provider-icons/mistral-color.svg +1 -0
- package/gui/dist/provider-icons/moonshot-color.svg +1 -0
- package/gui/dist/provider-icons/nvidia-color.svg +1 -0
- package/gui/dist/provider-icons/ollama-color.svg +1 -0
- package/gui/dist/provider-icons/openai.svg +1 -0
- package/gui/dist/provider-icons/opencode.svg +2 -0
- package/gui/dist/provider-icons/openrouter-color.svg +1 -0
- package/gui/dist/provider-icons/pi.svg +21 -0
- package/gui/dist/provider-icons/qianfan-color.svg +1 -0
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
- package/gui/dist/provider-icons/telegram.svg +1 -0
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/vllm-color.svg +1 -0
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
- package/package.json +118 -0
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic-image-guard.ts +251 -0
- package/src/adapters/anthropic-image-normalize.ts +518 -0
- package/src/adapters/anthropic.ts +1205 -0
- package/src/adapters/azure.ts +36 -0
- package/src/adapters/base.ts +83 -0
- package/src/adapters/client-fingerprint.ts +59 -0
- package/src/adapters/command-code.ts +453 -0
- package/src/adapters/cursor/arg-codec.ts +38 -0
- package/src/adapters/cursor/arg-normalize.ts +104 -0
- package/src/adapters/cursor/cursor-errors.ts +165 -0
- package/src/adapters/cursor/discovery.ts +276 -0
- package/src/adapters/cursor/effort-map.ts +139 -0
- package/src/adapters/cursor/exec-policy.ts +88 -0
- package/src/adapters/cursor/framing.ts +250 -0
- package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
- package/src/adapters/cursor/kv-store.ts +52 -0
- package/src/adapters/cursor/live-models.ts +153 -0
- package/src/adapters/cursor/live-smoke-gate.ts +41 -0
- package/src/adapters/cursor/live-transport.ts +1235 -0
- package/src/adapters/cursor/mcp-config.ts +42 -0
- package/src/adapters/cursor/mcp-manager.ts +333 -0
- package/src/adapters/cursor/message-mapper.ts +49 -0
- package/src/adapters/cursor/native-exec-common.ts +59 -0
- package/src/adapters/cursor/native-exec-desktop.ts +184 -0
- package/src/adapters/cursor/native-exec-fs.ts +332 -0
- package/src/adapters/cursor/native-exec-mcp.ts +153 -0
- package/src/adapters/cursor/native-exec-network.ts +43 -0
- package/src/adapters/cursor/native-exec-shell.ts +548 -0
- package/src/adapters/cursor/native-exec-tools.ts +118 -0
- package/src/adapters/cursor/native-exec.ts +604 -0
- package/src/adapters/cursor/protobuf-events.ts +735 -0
- package/src/adapters/cursor/protobuf-request.ts +719 -0
- package/src/adapters/cursor/request-builder.ts +280 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/tool-definitions.ts +621 -0
- package/src/adapters/cursor/transport-retry.ts +132 -0
- package/src/adapters/cursor/transport.ts +57 -0
- package/src/adapters/cursor/types.ts +59 -0
- package/src/adapters/cursor.ts +196 -0
- package/src/adapters/google-antigravity-replay.ts +520 -0
- package/src/adapters/google-antigravity-wire.ts +140 -0
- package/src/adapters/google-errors.ts +85 -0
- package/src/adapters/google-http.ts +100 -0
- package/src/adapters/google-tool-schema.ts +173 -0
- package/src/adapters/google-truncation.ts +24 -0
- package/src/adapters/google-wire-compiler.ts +232 -0
- package/src/adapters/google.ts +859 -0
- package/src/adapters/identity.ts +77 -0
- package/src/adapters/image.ts +23 -0
- package/src/adapters/kiro-constants.ts +16 -0
- package/src/adapters/kiro-errors.ts +208 -0
- package/src/adapters/kiro-events.ts +197 -0
- package/src/adapters/kiro-images.ts +129 -0
- package/src/adapters/kiro-retry.ts +312 -0
- package/src/adapters/kiro-thinking.ts +104 -0
- package/src/adapters/kiro-tool-fallback.ts +36 -0
- package/src/adapters/kiro-tools.ts +224 -0
- package/src/adapters/kiro-truncation.ts +33 -0
- package/src/adapters/kiro-wire.ts +129 -0
- package/src/adapters/kiro.ts +1924 -0
- package/src/adapters/mimo-free.ts +263 -0
- package/src/adapters/openai-chat.ts +1265 -0
- package/src/adapters/openai-responses.ts +1309 -0
- package/src/adapters/run-turn-queue.ts +114 -0
- package/src/adapters/tool-catalog-nudge.ts +71 -0
- package/src/adapters/upstream-http-error.ts +48 -0
- package/src/android-remote/assets.ts +218 -0
- package/src/android-remote/attachments.ts +168 -0
- package/src/android-remote/auth.ts +237 -0
- package/src/android-remote/cloudflare-provisioning.ts +409 -0
- package/src/android-remote/cloudflare-secret.ts +106 -0
- package/src/android-remote/cloudflare-tunnel.ts +488 -0
- package/src/android-remote/cloudflared.ts +286 -0
- package/src/android-remote/codex-app-server.ts +565 -0
- package/src/android-remote/desktop-history-page.ts +936 -0
- package/src/android-remote/desktop-ipc.ts +3643 -0
- package/src/android-remote/desktop-ownership-store.ts +98 -0
- package/src/android-remote/desktop-project-registration.ts +129 -0
- package/src/android-remote/desktop-session-stream.ts +1110 -0
- package/src/android-remote/desktop-workspace-state.ts +443 -0
- package/src/android-remote/file-change-parser.ts +112 -0
- package/src/android-remote/gateway.ts +9108 -0
- package/src/android-remote/mutation-store.ts +299 -0
- package/src/android-remote/projection.ts +1780 -0
- package/src/android-remote/queued-turn-store.ts +248 -0
- package/src/android-remote/session-command-recovery.ts +1384 -0
- package/src/android-remote/store.ts +466 -0
- package/src/android-remote/thread-reconciliation.ts +133 -0
- package/src/android-remote/thread-source-paths.ts +307 -0
- package/src/android-remote/thread-stream.ts +546 -0
- package/src/android-remote/turn-activity.ts +235 -0
- package/src/android-remote/user-message-identity.ts +94 -0
- package/src/bridge.ts +1793 -0
- package/src/chat/inbound.ts +295 -0
- package/src/chat/outbound.ts +821 -0
- package/src/claude/agents-inject.ts +267 -0
- package/src/claude/alias.ts +149 -0
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/context-windows.ts +189 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p-paths.ts +84 -0
- package/src/claude/desktop-3p.ts +601 -0
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/gateway-cache.ts +70 -0
- package/src/claude/inbound-debug.ts +163 -0
- package/src/claude/inbound.ts +519 -0
- package/src/claude/model-info.ts +154 -0
- package/src/claude/outbound.ts +898 -0
- package/src/cli/access.ts +108 -0
- package/src/cli/account-api.ts +296 -0
- package/src/cli/account-auth.ts +250 -0
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +476 -0
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +297 -0
- package/src/cli/agent-driven.ts +70 -0
- package/src/cli/agent.ts +184 -0
- package/src/cli/catalog-prewarm.ts +27 -0
- package/src/cli/claude-desktop.ts +211 -0
- package/src/cli/claude.ts +302 -0
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/codex-shim-readiness.ts +69 -0
- package/src/cli/combo.ts +124 -0
- package/src/cli/config-command.ts +183 -0
- package/src/cli/debug.ts +228 -0
- package/src/cli/desktop-first-run.ts +25 -0
- package/src/cli/doctor.ts +1022 -0
- package/src/cli/export-command.ts +201 -0
- package/src/cli/help.ts +370 -0
- package/src/cli/index.ts +1565 -0
- package/src/cli/init.ts +224 -0
- package/src/cli/integrations.ts +225 -0
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/internal-dispatch.ts +35 -0
- package/src/cli/launcher-context.ts +77 -0
- package/src/cli/models-runtime.ts +224 -0
- package/src/cli/models.ts +340 -0
- package/src/cli/observe.ts +170 -0
- package/src/cli/opencode.ts +587 -0
- package/src/cli/provider-runtime.ts +179 -0
- package/src/cli/provider.ts +476 -0
- package/src/cli/ready.ts +301 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +328 -0
- package/src/cli/star-prompt.ts +211 -0
- package/src/cli/status-oauth.ts +78 -0
- package/src/cli/status.ts +321 -0
- package/src/cli/system-command.ts +196 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +205 -0
- package/src/cli/v2.ts +200 -0
- package/src/cli.ts +10 -0
- package/src/clients/config-export.ts +1109 -0
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-label.ts +34 -0
- package/src/codex/account-lifecycle.ts +172 -0
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +195 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/account-runtime-state.ts +31 -0
- package/src/codex/account-store.ts +517 -0
- package/src/codex/account-usability.ts +40 -0
- package/src/codex/admission.ts +263 -0
- package/src/codex/app-server-processes.ts +799 -0
- package/src/codex/auth-api.ts +2098 -0
- package/src/codex/auth-collision.ts +107 -0
- package/src/codex/auth-context.ts +480 -0
- package/src/codex/autostart-health.ts +156 -0
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +471 -0
- package/src/codex/catalog/bundled.ts +533 -0
- package/src/codex/catalog/effort.ts +432 -0
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +287 -0
- package/src/codex/catalog/native-models.ts +7 -0
- package/src/codex/catalog/parsing.ts +503 -0
- package/src/codex/catalog/provider-fetch.ts +2267 -0
- package/src/codex/catalog/sync.ts +1606 -0
- package/src/codex/catalog-admission.ts +197 -0
- package/src/codex/catalog-refresh-status.ts +87 -0
- package/src/codex/catalog-write-serialization.ts +242 -0
- package/src/codex/catalog.ts +15 -0
- package/src/codex/codex-write-lock.ts +384 -0
- package/src/codex/convergence-types.ts +593 -0
- package/src/codex/convergence.ts +580 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/data/upstream-models.json +830 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/desktop-client-processes.ts +521 -0
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/features.ts +1091 -0
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +347 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-migration-guardian.ts +115 -0
- package/src/codex/history-provider.ts +1075 -0
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +204 -0
- package/src/codex/home.ts +206 -0
- package/src/codex/inject-coordination.ts +257 -0
- package/src/codex/inject.ts +1857 -0
- package/src/codex/injected-marker.ts +79 -0
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +105 -0
- package/src/codex/journal.ts +172 -0
- package/src/codex/main-account-cache.ts +56 -0
- package/src/codex/main-account.ts +40 -0
- package/src/codex/management-convergence.ts +114 -0
- package/src/codex/model-cache.ts +267 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +167 -0
- package/src/codex/native-main-lock-file.ts +162 -0
- package/src/codex/native-main-owner.ts +329 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1531 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +348 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +691 -0
- package/src/codex/paths.ts +78 -0
- package/src/codex/plugins-doctor.ts +242 -0
- package/src/codex/pool-rotation.ts +295 -0
- package/src/codex/project-config-warnings.ts +426 -0
- package/src/codex/prompt-journal.ts +311 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/provider-adoption.ts +242 -0
- package/src/codex/quota-rejection.ts +224 -0
- package/src/codex/quota.ts +494 -0
- package/src/codex/refresh.ts +60 -0
- package/src/codex/routing.ts +1855 -0
- package/src/codex/runtime.ts +659 -0
- package/src/codex/shim.ts +1215 -0
- package/src/codex/subagent-defaults.ts +557 -0
- package/src/codex/subagent-model-fallback.ts +560 -0
- package/src/codex/sync.ts +238 -0
- package/src/codex/transition-state.ts +612 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +374 -0
- package/src/codex/warmup.ts +192 -0
- package/src/codex/websocket-registry.ts +100 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +140 -0
- package/src/combos/index.ts +44 -0
- package/src/combos/request.ts +64 -0
- package/src/combos/resolve.ts +232 -0
- package/src/combos/types.ts +392 -0
- package/src/config.ts +3270 -0
- package/src/generated/model-metadata.ts +144 -0
- package/src/github/star-state.ts +203 -0
- package/src/grok/inject.ts +540 -0
- package/src/grok/inspect.ts +45 -0
- package/src/grok/status.ts +127 -0
- package/src/grok/sync.ts +66 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +149 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +922 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/index.ts +22 -0
- package/src/integrations/config-io.ts +151 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/native/ownership-preflight.ts +202 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +108 -0
- package/src/integrations/serialize.ts +235 -0
- package/src/integrations/state.ts +290 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer.ts +492 -0
- package/src/lib/abort.ts +146 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bounded-body.ts +242 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +184 -0
- package/src/lib/bun-stream-caps.ts +127 -0
- package/src/lib/config-ownership.ts +438 -0
- package/src/lib/crash-guard.ts +344 -0
- package/src/lib/debug-log-buffer.ts +83 -0
- package/src/lib/debug-settings.ts +108 -0
- package/src/lib/debug.ts +31 -0
- package/src/lib/destination-policy.ts +316 -0
- package/src/lib/errors.ts +364 -0
- package/src/lib/eventstream-decoder.ts +253 -0
- package/src/lib/gcp-adc.ts +341 -0
- package/src/lib/injection-debug-log.ts +58 -0
- package/src/lib/local-management-attestation.ts +51 -0
- package/src/lib/open-url.ts +25 -0
- package/src/lib/pinned-http.ts +182 -0
- package/src/lib/privacy.ts +20 -0
- package/src/lib/process-control.ts +168 -0
- package/src/lib/provider-environment.ts +470 -0
- package/src/lib/provider-outbound.ts +203 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/redact.ts +510 -0
- package/src/lib/remodex-home.ts +616 -0
- package/src/lib/retry-after.ts +55 -0
- package/src/lib/service-secrets.ts +178 -0
- package/src/lib/shadow-call.ts +54 -0
- package/src/lib/sidecar-tracker.ts +52 -0
- package/src/lib/sse-decoder.ts +364 -0
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/test-home-guard.ts +98 -0
- package/src/lib/token-estimate.ts +69 -0
- package/src/lib/translator-budget.ts +366 -0
- package/src/lib/upstream-reachability.ts +91 -0
- package/src/lib/upstream-retry.ts +508 -0
- package/src/lib/win-exec.ts +115 -0
- package/src/lib/win-paths.ts +68 -0
- package/src/lib/windows-elevation.ts +705 -0
- package/src/lib/windows-secret-acl.ts +817 -0
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +402 -0
- package/src/model-sources.ts +73 -0
- package/src/oauth/anthropic-routing.ts +594 -0
- package/src/oauth/anthropic.ts +177 -0
- package/src/oauth/callback-server.ts +294 -0
- package/src/oauth/chatgpt.ts +150 -0
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +231 -0
- package/src/oauth/github-copilot.ts +428 -0
- package/src/oauth/google-antigravity.ts +230 -0
- package/src/oauth/health.ts +443 -0
- package/src/oauth/index.ts +1280 -0
- package/src/oauth/key-providers.ts +128 -0
- package/src/oauth/kimi.ts +213 -0
- package/src/oauth/kiro-credentials.ts +726 -0
- package/src/oauth/kiro.ts +621 -0
- package/src/oauth/local-token-detect.ts +121 -0
- package/src/oauth/log.ts +48 -0
- package/src/oauth/login-cli.ts +163 -0
- package/src/oauth/pkce.ts +15 -0
- package/src/oauth/store.ts +655 -0
- package/src/oauth/token-guardian.ts +309 -0
- package/src/oauth/types.ts +62 -0
- package/src/oauth/xai.ts +241 -0
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +156 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/antigravity-models.ts +317 -0
- package/src/providers/api-keys.ts +140 -0
- package/src/providers/base-url-choices.ts +64 -0
- package/src/providers/codex-capacity.ts +288 -0
- package/src/providers/command-code-efforts.ts +85 -0
- package/src/providers/context-cap.ts +73 -0
- package/src/providers/derive.ts +451 -0
- package/src/providers/free-directory.ts +187 -0
- package/src/providers/github-copilot-transport.ts +56 -0
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +271 -0
- package/src/providers/kiro-models.ts +67 -0
- package/src/providers/label.ts +19 -0
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +361 -0
- package/src/providers/openai-sidecar.ts +235 -0
- package/src/providers/openai-tier-startup.ts +27 -0
- package/src/providers/openai-tiers.ts +301 -0
- package/src/providers/openai-virtual-models.ts +83 -0
- package/src/providers/openrouter-routing.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +179 -0
- package/src/providers/quota.ts +1942 -0
- package/src/providers/reasoning-capabilities.ts +336 -0
- package/src/providers/registry.ts +2375 -0
- package/src/providers/slug-codec.ts +74 -0
- package/src/providers/xai-transport.ts +149 -0
- package/src/reasoning-effort.ts +243 -0
- package/src/responses/compaction.ts +124 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/parser.ts +714 -0
- package/src/responses/reasoning-envelope.ts +60 -0
- package/src/responses/reasoning-replay-cache.ts +106 -0
- package/src/responses/schema.ts +159 -0
- package/src/responses/spill-store.ts +431 -0
- package/src/responses/state.ts +1039 -0
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +802 -0
- package/src/routing/analytics.ts +377 -0
- package/src/routing/capability.ts +205 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +444 -0
- package/src/routing/health.ts +401 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +605 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +424 -0
- package/src/routing/quota.ts +145 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +686 -0
- package/src/server/adapter-resolve.ts +83 -0
- package/src/server/auth-cors.ts +606 -0
- package/src/server/chat-completions.ts +379 -0
- package/src/server/claude-messages.ts +980 -0
- package/src/server/effort-policy.ts +251 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +152 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/images.ts +485 -0
- package/src/server/index.ts +1633 -0
- package/src/server/lifecycle.ts +482 -0
- package/src/server/live.ts +609 -0
- package/src/server/management/agent-settings-routes.ts +1180 -0
- package/src/server/management/android-remote-routes.ts +390 -0
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +244 -0
- package/src/server/management/config-routes.ts +602 -0
- package/src/server/management/context.ts +88 -0
- package/src/server/management/integration-routes.ts +538 -0
- package/src/server/management/logs-usage-routes.ts +516 -0
- package/src/server/management/model-routes.ts +519 -0
- package/src/server/management/model-rows.ts +143 -0
- package/src/server/management/native-integration-routes.ts +781 -0
- package/src/server/management/oauth-account-routes.ts +573 -0
- package/src/server/management/provider-routes.ts +781 -0
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +384 -0
- package/src/server/management/shared.ts +277 -0
- package/src/server/management/sidebar-routes.ts +106 -0
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +433 -0
- package/src/server/management/system-routes.ts +141 -0
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +269 -0
- package/src/server/management-auth.ts +353 -0
- package/src/server/memory-watchdog.ts +156 -0
- package/src/server/port-reclaim.ts +307 -0
- package/src/server/ports.ts +156 -0
- package/src/server/proxy-liveness.ts +326 -0
- package/src/server/proxy-stop.ts +92 -0
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +353 -0
- package/src/server/relay.ts +1179 -0
- package/src/server/request-decompress.ts +132 -0
- package/src/server/request-log-conversation.ts +168 -0
- package/src/server/request-log.ts +1072 -0
- package/src/server/responses/collaboration.ts +409 -0
- package/src/server/responses/compact.ts +710 -0
- package/src/server/responses/core.ts +3561 -0
- package/src/server/responses/encrypted-payload.ts +308 -0
- package/src/server/responses/fetch-helpers.ts +171 -0
- package/src/server/responses/passthrough-error.ts +78 -0
- package/src/server/responses/policy-fallback.ts +152 -0
- package/src/server/responses/terminal-guard.ts +230 -0
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/responses-image-gen-repair.ts +132 -0
- package/src/server/responses-item-id-repair.ts +272 -0
- package/src/server/responses-json-events.ts +52 -0
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/responses.ts +10 -0
- package/src/server/search.ts +181 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +263 -0
- package/src/server/startup-action-control.ts +308 -0
- package/src/server/startup-health-cache.ts +119 -0
- package/src/server/system-env.ts +418 -0
- package/src/server/windows-tcp-drop.ts +184 -0
- package/src/server/windows-tray-control.ts +41 -0
- package/src/server/ws-bridge.ts +470 -0
- package/src/service-manager-probe.ts +824 -0
- package/src/service.ts +3011 -0
- package/src/stall-timeout.ts +20 -0
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +3085 -0
- package/src/storage/policy-job.ts +457 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +59 -0
- package/src/storage/policy.ts +527 -0
- package/src/storage/restore-job.ts +299 -0
- package/src/storage/restore-worker.ts +58 -0
- package/src/storage/scanner.ts +238 -0
- package/src/storage/storage-mutation-coordinator.ts +139 -0
- package/src/storage/worker-lifecycle.ts +215 -0
- package/src/tray/assets/opencodex-tray-offline.ico +0 -0
- package/src/tray/assets/opencodex-tray-online.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning.ico +0 -0
- package/src/tray/assets/opencodex-tray.png +0 -0
- package/src/tray/windows-tray.ps1 +364 -0
- package/src/tray/windows.ts +738 -0
- package/src/types.ts +1531 -0
- package/src/update/badge.ts +72 -0
- package/src/update/desktop-release.ts +1620 -0
- package/src/update/index.ts +402 -0
- package/src/update/job.ts +1906 -0
- package/src/update/notify.ts +261 -0
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/update/tray-update-plan.d.mts +18 -0
- package/src/update/tray-update-plan.mjs +38 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/debug.ts +97 -0
- package/src/usage/expected-prices.ts +283 -0
- package/src/usage/log.ts +695 -0
- package/src/usage/summary.ts +585 -0
- package/src/usage/totals.ts +14 -0
- package/src/vision/anthropic-describe.ts +185 -0
- package/src/vision/describe.ts +127 -0
- package/src/vision/index.ts +558 -0
- package/src/vision/reasoning.ts +55 -0
- package/src/web-search/anthropic-executor.ts +189 -0
- package/src/web-search/executor.ts +105 -0
- package/src/web-search/format-result.ts +89 -0
- package/src/web-search/index.ts +196 -0
- package/src/web-search/loop.ts +791 -0
- package/src/web-search/parse.ts +235 -0
- package/src/web-search/progress-stream.ts +342 -0
- package/src/web-search/synthetic-tool.ts +47 -0
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
import type { AdapterRequest, ProviderAdapter } from "./base";
|
|
2
|
+
import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
|
|
3
|
+
import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
|
|
4
|
+
import { isReasoningEffortRequired, mapReasoningEffort, modelRecordValue, reasoningControlKindFor } from "../reasoning-effort";
|
|
5
|
+
import { debugProviderDiagnostic } from "../lib/debug";
|
|
6
|
+
import { sseFieldValue } from "../lib/sse-decoder";
|
|
7
|
+
import { isDebugEnabled } from "../lib/debug-settings";
|
|
8
|
+
import { isCyberPolicyCode } from "../lib/errors";
|
|
9
|
+
import { redactSecretString } from "../lib/redact";
|
|
10
|
+
import { contentPartsToText } from "./image";
|
|
11
|
+
import { identifyRoutedModel } from "./identity";
|
|
12
|
+
import { peekReasoningForCall } from "../responses/reasoning-replay-cache";
|
|
13
|
+
import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
|
|
14
|
+
import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
|
|
15
|
+
import {
|
|
16
|
+
isTranslatorBudgetExceededError,
|
|
17
|
+
retainTranslatedEventBatch,
|
|
18
|
+
TRANSLATOR_MAX_SSE_EVENT_BYTES,
|
|
19
|
+
type TranslatorBudget,
|
|
20
|
+
} from "../lib/translator-budget";
|
|
21
|
+
|
|
22
|
+
// Providers may opt into stripping one trailing "[...]" group from the wire model id.
|
|
23
|
+
// Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211;
|
|
24
|
+
// unflagged OpenAI-compatible providers and the Anthropic adapter keep ids verbatim.
|
|
25
|
+
export function stripBracketedModelSuffix(modelId: string): string {
|
|
26
|
+
return modelId.replace(/\[[^\]]*\]\s*$/, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 260715 (issue #126): surface upstream error detail through the web-search sidecar loop.
|
|
30
|
+
// loop.ts only appends a suffix to "Provider error N" when the adapter exposes
|
|
31
|
+
// formatErrorBody; without it, strict OpenAI-compatible backends (NVIDIA NIM pydantic
|
|
32
|
+
// validation, "This model only supports single tool-calls at once!", etc.) were reduced
|
|
33
|
+
// to a bare status code. JSON-only extraction: recognized string fields are returned,
|
|
34
|
+
// HTML/non-JSON bodies yield "" so raw markup is never echoed to the client.
|
|
35
|
+
export function formatOpenAIChatErrorBody(status: number, _headers: Headers, payloadText: string): string {
|
|
36
|
+
let parsed: unknown;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(payloadText);
|
|
39
|
+
} catch {
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
const detail = extractErrorDetail(parsed);
|
|
43
|
+
if (!detail) return "";
|
|
44
|
+
return redactSecretString(detail).slice(0, 400);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function extractErrorDetail(parsed: unknown): string | undefined {
|
|
48
|
+
if (typeof parsed === "string") return parsed.trim() || undefined;
|
|
49
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
50
|
+
const obj = parsed as Record<string, unknown>;
|
|
51
|
+
// OpenAI shape: { error: { message } } or { error: "..." }
|
|
52
|
+
const err = obj.error;
|
|
53
|
+
if (typeof err === "string" && err.trim()) return err.trim();
|
|
54
|
+
if (err !== null && typeof err === "object" && !Array.isArray(err)) {
|
|
55
|
+
const msg = (err as Record<string, unknown>).message;
|
|
56
|
+
if (typeof msg === "string" && msg.trim()) return msg.trim();
|
|
57
|
+
}
|
|
58
|
+
// FastAPI/pydantic shape (NVIDIA NIM): { detail: "..." } or { detail: [{ msg, loc }, ...] }
|
|
59
|
+
const det = obj.detail;
|
|
60
|
+
if (typeof det === "string" && det.trim()) return det.trim();
|
|
61
|
+
if (Array.isArray(det)) {
|
|
62
|
+
const msgs = det
|
|
63
|
+
.map(item => (item !== null && typeof item === "object" && typeof (item as Record<string, unknown>).msg === "string"
|
|
64
|
+
? ((item as Record<string, unknown>).msg as string).trim()
|
|
65
|
+
: ""))
|
|
66
|
+
.filter(m => m.length > 0);
|
|
67
|
+
if (msgs.length > 0) return msgs.join("; ");
|
|
68
|
+
}
|
|
69
|
+
// Generic fallbacks: { message } / RFC7807 { title }
|
|
70
|
+
if (typeof obj.message === "string" && obj.message.trim()) return obj.message.trim();
|
|
71
|
+
if (typeof obj.title === "string" && obj.title.trim()) return obj.title.trim();
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ClinePass live responses observed 2026-08-02 wrap non-stream Chat Completions in
|
|
76
|
+
// `{ success, error, data }`; its public Chat Completions docs do not currently describe that
|
|
77
|
+
// envelope. Keep ordinary OpenAI-shaped responses on the direct path.
|
|
78
|
+
function unwrapChatCompletionPayload(json: Record<string, unknown>): Record<string, unknown> {
|
|
79
|
+
if ((json.error !== undefined && json.error !== null) || Array.isArray(json.choices)) return json;
|
|
80
|
+
const data = json.data;
|
|
81
|
+
return data !== null && typeof data === "object" && !Array.isArray(data)
|
|
82
|
+
? data as Record<string, unknown>
|
|
83
|
+
: json;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface OpenAIChatError {
|
|
87
|
+
message?: unknown;
|
|
88
|
+
code?: unknown;
|
|
89
|
+
type?: unknown;
|
|
90
|
+
status?: unknown;
|
|
91
|
+
metadata?: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function safeUpstreamRequestId(metadata: unknown): string | undefined {
|
|
95
|
+
if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
|
|
96
|
+
const record = metadata as Record<string, unknown>;
|
|
97
|
+
const value = record.request_id ?? record.requestId;
|
|
98
|
+
if (typeof value !== "string") return undefined;
|
|
99
|
+
const requestId = value.trim();
|
|
100
|
+
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(requestId)
|
|
101
|
+
&& redactSecretString(requestId) === requestId
|
|
102
|
+
? requestId
|
|
103
|
+
: undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function upstreamErrorEvent(
|
|
107
|
+
error: unknown,
|
|
108
|
+
usage?: OcxUsage,
|
|
109
|
+
): Extract<AdapterEvent, { type: "error" }> {
|
|
110
|
+
const details = error !== null && typeof error === "object" && !Array.isArray(error)
|
|
111
|
+
? error as OpenAIChatError
|
|
112
|
+
: undefined;
|
|
113
|
+
const rawMessage = typeof error === "string"
|
|
114
|
+
? error.trim() || "upstream error"
|
|
115
|
+
: typeof details?.message === "string" ? details.message : "upstream error";
|
|
116
|
+
const safeMessage = redactSecretString(rawMessage);
|
|
117
|
+
const requestId = safeUpstreamRequestId(details?.metadata);
|
|
118
|
+
const message = requestId !== undefined && !safeMessage.includes(requestId)
|
|
119
|
+
? `${safeMessage} (request ID: ${requestId})`
|
|
120
|
+
: safeMessage;
|
|
121
|
+
const code = typeof details?.code === "string"
|
|
122
|
+
? details.code
|
|
123
|
+
: typeof details?.code === "number" && Number.isFinite(details.code) && Number.isInteger(details.code)
|
|
124
|
+
? String(details.code)
|
|
125
|
+
: undefined;
|
|
126
|
+
const errorType = typeof details?.type === "string" ? details.type : undefined;
|
|
127
|
+
const codeStatus = typeof details?.code === "number"
|
|
128
|
+
&& Number.isInteger(details.code)
|
|
129
|
+
&& details.code >= 100
|
|
130
|
+
&& details.code <= 599
|
|
131
|
+
? details.code
|
|
132
|
+
: undefined;
|
|
133
|
+
const status = isCyberPolicyCode(code)
|
|
134
|
+
? 400
|
|
135
|
+
: typeof details?.status === "number" && Number.isInteger(details.status)
|
|
136
|
+
? details.status
|
|
137
|
+
: codeStatus;
|
|
138
|
+
return {
|
|
139
|
+
type: "error",
|
|
140
|
+
message,
|
|
141
|
+
...(usage !== undefined ? { usage } : {}),
|
|
142
|
+
...(code !== undefined ? { code } : {}),
|
|
143
|
+
...(errorType !== undefined ? { errorType } : {}),
|
|
144
|
+
...(status !== undefined ? { status } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stopReasonFor(finishReason: unknown): "max_tokens" | "content_filter" | undefined {
|
|
149
|
+
return finishReason === "length"
|
|
150
|
+
? "max_tokens"
|
|
151
|
+
: finishReason === "content_filter"
|
|
152
|
+
? "content_filter"
|
|
153
|
+
: undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function reasoningTextFrom(record: Record<string, unknown>): string | undefined {
|
|
157
|
+
return typeof record.reasoning_content === "string" && record.reasoning_content.length > 0
|
|
158
|
+
? record.reasoning_content
|
|
159
|
+
: typeof record.reasoning === "string" && record.reasoning.length > 0
|
|
160
|
+
? record.reasoning
|
|
161
|
+
: undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function invalidChoicesEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
|
|
165
|
+
return {
|
|
166
|
+
type: "error",
|
|
167
|
+
message: "upstream response contained invalid choices",
|
|
168
|
+
...(usage !== undefined ? { usage } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function developerSystemText(message: OcxMessage): string | undefined {
|
|
173
|
+
if (message.role !== "developer") return undefined;
|
|
174
|
+
if (typeof message.content === "string") return message.content;
|
|
175
|
+
if (message.content.some(part => part.type === "image")) return undefined;
|
|
176
|
+
return message.content.map(part => (part as OcxTextContent).text).join("");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool"
|
|
181
|
+
* content is text-only on every chat provider, so these ride in a follow-up user message instead of
|
|
182
|
+
* being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https
|
|
183
|
+
* URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64.
|
|
184
|
+
*/
|
|
185
|
+
function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[] {
|
|
186
|
+
if (typeof content === "string") return [];
|
|
187
|
+
const parts: unknown[] = [];
|
|
188
|
+
for (const p of content) {
|
|
189
|
+
// Skip parts without a usable URL (the tool-output parser accepts the empty file_id shape):
|
|
190
|
+
// a {"url":""} part would fail the whole request where the "[image]" marker degrades safely.
|
|
191
|
+
if (p.type !== "image" || !p.imageUrl) continue;
|
|
192
|
+
parts.push({ type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } });
|
|
193
|
+
}
|
|
194
|
+
return parts;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
|
|
198
|
+
const out: unknown[] = [];
|
|
199
|
+
const { context, options } = parsed;
|
|
200
|
+
// Mirror the bridge's replay-cache scope (issue #950): provider call ids are
|
|
201
|
+
// not globally unique, so reasoning must not cross conversation boundaries.
|
|
202
|
+
const replayCacheScope = parsed._clientThreadId ?? "global";
|
|
203
|
+
|
|
204
|
+
// 260718 dangling tool_calls hardening (devlog/_plan/260718_dangling_toolcall_hardening):
|
|
205
|
+
// strict chat providers (Kimi/Moonshot) 400 when an assistant tool_call is not answered
|
|
206
|
+
// immediately by role:"tool" messages. Repair order: (1) reattach a real result to its
|
|
207
|
+
// original call (barrier messages are DEFERRED until the open tool round closes),
|
|
208
|
+
// (2) synthesize an explicit unavailable-result only when no real result exists,
|
|
209
|
+
// (3) manufacture an orphan assistant call only when no call occurrence matches at all.
|
|
210
|
+
// Occurrences are kept as an ordered list (never a Map) so duplicated ids survive.
|
|
211
|
+
interface PendingToolCall { id: string; name: string }
|
|
212
|
+
let pendingToolCalls: PendingToolCall[] = [];
|
|
213
|
+
let deferredBarrierMessages: unknown[] = [];
|
|
214
|
+
let pendingToolResultImageParts: unknown[] = [];
|
|
215
|
+
let mintedIdSeq = 0;
|
|
216
|
+
const seenWireCallIds = new Set<string>();
|
|
217
|
+
|
|
218
|
+
const mintCallId = (): string => {
|
|
219
|
+
let id = "";
|
|
220
|
+
do {
|
|
221
|
+
id = `call_ocx_minted_${++mintedIdSeq}`;
|
|
222
|
+
} while (seenWireCallIds.has(id));
|
|
223
|
+
seenWireCallIds.add(id);
|
|
224
|
+
return id;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const releaseDeferredBarriers = (): void => {
|
|
228
|
+
if (deferredBarrierMessages.length === 0) return;
|
|
229
|
+
out.push(...deferredBarrierMessages);
|
|
230
|
+
deferredBarrierMessages = [];
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// Tool-result images collected during the open round land in ONE user vision message once the
|
|
234
|
+
// round closes — never inside it, where strict providers (Kimi/Moonshot) 400 on interleaved
|
|
235
|
+
// user messages. Released before deferred barriers so the images stay adjacent to the results
|
|
236
|
+
// they came from (mirrors google.ts sibling inline_data parts and the Kiro carrier images).
|
|
237
|
+
const flushToolResultImages = (): void => {
|
|
238
|
+
if (pendingToolResultImageParts.length === 0) return;
|
|
239
|
+
out.push({
|
|
240
|
+
role: "user",
|
|
241
|
+
content: [
|
|
242
|
+
{ type: "text", text: "[ocx] image output from the preceding tool result(s):" },
|
|
243
|
+
...pendingToolResultImageParts,
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
pendingToolResultImageParts = [];
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Close an unresolved tool round with explicit unavailable-result messages. The wording
|
|
250
|
+
// must not claim interruption, success, failure, or user intent: execution status is
|
|
251
|
+
// UNKNOWN, and for user-input tools this must not read as an answer.
|
|
252
|
+
const flushPendingToolCalls = (): void => {
|
|
253
|
+
if (pendingToolCalls.length === 0) return;
|
|
254
|
+
for (const call of pendingToolCalls) {
|
|
255
|
+
out.push({
|
|
256
|
+
role: "tool",
|
|
257
|
+
tool_call_id: call.id,
|
|
258
|
+
content: `[ocx] no tool result was recorded for "${call.name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
pendingToolCalls = [];
|
|
262
|
+
flushToolResultImages();
|
|
263
|
+
releaseDeferredBarriers();
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider)
|
|
267
|
+
? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice)
|
|
268
|
+
: undefined;
|
|
269
|
+
// Chat templates used by LM Studio, llama.cpp, and other strict OpenAI-compatible
|
|
270
|
+
// backends require every system instruction to precede conversation history. Codex can
|
|
271
|
+
// append developer reminders after user turns, so fold text-only developer messages into
|
|
272
|
+
// the single leading system message instead of emitting role:"system" in place. Developer
|
|
273
|
+
// messages with images cannot be represented as system content and remain user-compatible
|
|
274
|
+
// vision messages at their original position below.
|
|
275
|
+
const developerSystemParts = context.messages
|
|
276
|
+
.map(developerSystemText)
|
|
277
|
+
.filter((part): part is string => part !== undefined && part.length > 0);
|
|
278
|
+
const systemParts = [
|
|
279
|
+
...(context.systemPrompt ?? []),
|
|
280
|
+
...developerSystemParts,
|
|
281
|
+
...(toolCatalogNudge ? [toolCatalogNudge] : []),
|
|
282
|
+
];
|
|
283
|
+
if (systemParts.length > 0) {
|
|
284
|
+
// Codex sends its GPT-5 identity prompt for EVERY model (the per-model catalog
|
|
285
|
+
// base_instructions is ignored at request time). Neutralize that one identity line
|
|
286
|
+
// so routed, non-OpenAI models don't misreport themselves as GPT-5 / OpenAI — without
|
|
287
|
+
// leaking the proxy identity into the payload.
|
|
288
|
+
const wireModelId = provider.modelSuffixBracketStrip
|
|
289
|
+
? stripBracketedModelSuffix(parsed.modelId)
|
|
290
|
+
: parsed.modelId;
|
|
291
|
+
const sys = identifyRoutedModel(systemParts.join("\n\n"), wireModelId);
|
|
292
|
+
out.push({ role: "system", content: sys });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const msg of context.messages) {
|
|
296
|
+
switch (msg.role) {
|
|
297
|
+
case "user":
|
|
298
|
+
case "developer": {
|
|
299
|
+
const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[];
|
|
300
|
+
const hasImages = parts?.some(p => p.type === "image") ?? false;
|
|
301
|
+
if (msg.role === "developer" && !hasImages) break;
|
|
302
|
+
let chatMsg: Record<string, unknown>;
|
|
303
|
+
if (typeof msg.content === "string") {
|
|
304
|
+
chatMsg = { role: "user", content: msg.content };
|
|
305
|
+
} else {
|
|
306
|
+
if (!hasImages) {
|
|
307
|
+
chatMsg = { role: "user", content: parts!.map(p => (p as OcxTextContent).text).join("") };
|
|
308
|
+
} else {
|
|
309
|
+
// Vision: chat-completions content-parts array. Images are only valid on the user role,
|
|
310
|
+
// and the data URL goes straight into image_url.url (never the token-exploding text path).
|
|
311
|
+
const chatParts = parts!.map(p => p.type === "image"
|
|
312
|
+
? { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }
|
|
313
|
+
: { type: "text", text: (p as OcxTextContent).text });
|
|
314
|
+
chatMsg = { role: "user", content: chatParts };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// A barrier must not split an open tool round: defer it until the round closes
|
|
318
|
+
// (real result arrives) or the round is synthesized shut.
|
|
319
|
+
if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg);
|
|
320
|
+
else out.push(chatMsg);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case "assistant": {
|
|
324
|
+
const aMsg = msg as OcxAssistantMessage;
|
|
325
|
+
const textParts = aMsg.content.filter(p => p.type === "text") as OcxTextContent[];
|
|
326
|
+
const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[];
|
|
327
|
+
const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[];
|
|
328
|
+
const chatMsg: Record<string, unknown> = { role: "assistant" };
|
|
329
|
+
if (textParts.length > 0) {
|
|
330
|
+
chatMsg.content = textParts.map(p => p.text).join("");
|
|
331
|
+
}
|
|
332
|
+
let reasoningContent = thinkingParts.map(p => p.thinking).join("");
|
|
333
|
+
// History transformations (compaction, lost assistant turn, resumed
|
|
334
|
+
// threads) can strip the reasoning item while the tool round survives.
|
|
335
|
+
// Re-attach the reasoning the bridge recorded for these call ids so
|
|
336
|
+
// preserveReasoningContentModels providers (DeepSeek thinking mode)
|
|
337
|
+
// never receive a bare tool-call continuation (issue #950).
|
|
338
|
+
if (
|
|
339
|
+
reasoningContent.length === 0
|
|
340
|
+
&& toolCalls.length > 0
|
|
341
|
+
&& modelInList(provider.preserveReasoningContentModels, parsed.modelId)
|
|
342
|
+
) {
|
|
343
|
+
const cached = toolCalls
|
|
344
|
+
.map(tc => (tc.id ? peekReasoningForCall(tc.id, replayCacheScope) : undefined))
|
|
345
|
+
.filter((text): text is string => typeof text === "string" && text.length > 0);
|
|
346
|
+
// Parallel calls share one preceding reasoning block, which is
|
|
347
|
+
// recorded under every call id — join unique texts only.
|
|
348
|
+
if (cached.length > 0) {
|
|
349
|
+
reasoningContent = [...new Set(cached)].join("\n");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) {
|
|
353
|
+
chatMsg.reasoning_content = reasoningContent;
|
|
354
|
+
}
|
|
355
|
+
// Skip empty assistant messages: chat APIs like DeepSeek reject an assistant message
|
|
356
|
+
// with neither content, tool calls, nor a provider-supported reasoning_content field.
|
|
357
|
+
if (chatMsg.content === undefined && toolCalls.length === 0 && chatMsg.reasoning_content === undefined) break;
|
|
358
|
+
// A new assistant starts while a previous round is still open: close the previous
|
|
359
|
+
// round synthetically first so its tool_calls are never left dangling.
|
|
360
|
+
flushPendingToolCalls();
|
|
361
|
+
const wireToolCalls = toolCalls.map(tc => {
|
|
362
|
+
let id = tc.id;
|
|
363
|
+
if (!id) id = mintCallId();
|
|
364
|
+
else seenWireCallIds.add(id);
|
|
365
|
+
return { tc, id };
|
|
366
|
+
});
|
|
367
|
+
if (wireToolCalls.length > 0) {
|
|
368
|
+
chatMsg.tool_calls = wireToolCalls.map(({ tc, id }) => ({
|
|
369
|
+
id,
|
|
370
|
+
type: "function",
|
|
371
|
+
function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) },
|
|
372
|
+
}));
|
|
373
|
+
// "" instead of null: strict validators (xAI: "Each message must have at least one
|
|
374
|
+
// content element", langchain#34140) reject content-less assistant history entries.
|
|
375
|
+
if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider);
|
|
376
|
+
}
|
|
377
|
+
if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
|
|
378
|
+
chatMsg.content = emptyAssistantContent(provider);
|
|
379
|
+
}
|
|
380
|
+
out.push(chatMsg);
|
|
381
|
+
pendingToolCalls = wireToolCalls.map(({ tc, id }) => ({ id, name: namespacedToolName(tc.namespace, tc.name) }));
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
case "toolResult": {
|
|
385
|
+
let toolCallId = msg.toolCallId;
|
|
386
|
+
const matchIdx = toolCallId ? pendingToolCalls.findIndex(c => c.id === toolCallId) : -1;
|
|
387
|
+
if (matchIdx >= 0 && toolCallId) {
|
|
388
|
+
// Real result reattached to its original call. Barriers were deferred, so the
|
|
389
|
+
// tool message lands immediately inside the open round.
|
|
390
|
+
out.push({
|
|
391
|
+
role: "tool",
|
|
392
|
+
tool_call_id: toolCallId,
|
|
393
|
+
content: contentPartsToText(msg.content),
|
|
394
|
+
});
|
|
395
|
+
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
|
|
396
|
+
pendingToolCalls.splice(matchIdx, 1);
|
|
397
|
+
if (pendingToolCalls.length === 0) {
|
|
398
|
+
flushToolResultImages();
|
|
399
|
+
releaseDeferredBarriers();
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
if (!toolCallId) toolCallId = `call_orphan_${out.length}`;
|
|
403
|
+
// No matching call in the open round. Close any unresolved round first so the
|
|
404
|
+
// synthesized orphan pair never splits it, then keep the historical repair:
|
|
405
|
+
// WS turns can arrive with only tool outputs; chat-completions providers reject a bare
|
|
406
|
+
// role:"tool" message unless an assistant tool_call with the same id immediately precedes it.
|
|
407
|
+
flushPendingToolCalls();
|
|
408
|
+
const name = safeToolName(msg.toolName);
|
|
409
|
+
// The orphan repair synthesizes an assistant tool call for a result
|
|
410
|
+
// whose assistant turn was lost; carry the recorded reasoning so the
|
|
411
|
+
// replayed round stays valid for thinking-mode providers (#950).
|
|
412
|
+
const cachedReasoning =
|
|
413
|
+
toolCallId && modelInList(provider.preserveReasoningContentModels, parsed.modelId)
|
|
414
|
+
? peekReasoningForCall(toolCallId, replayCacheScope)
|
|
415
|
+
: undefined;
|
|
416
|
+
out.push({
|
|
417
|
+
role: "assistant",
|
|
418
|
+
content: emptyAssistantContent(provider),
|
|
419
|
+
...(cachedReasoning ? { reasoning_content: cachedReasoning } : {}),
|
|
420
|
+
tool_calls: [{
|
|
421
|
+
id: toolCallId,
|
|
422
|
+
type: "function",
|
|
423
|
+
function: { name, arguments: "{}" },
|
|
424
|
+
}],
|
|
425
|
+
});
|
|
426
|
+
seenWireCallIds.add(toolCallId);
|
|
427
|
+
out.push({
|
|
428
|
+
role: "tool",
|
|
429
|
+
tool_call_id: toolCallId,
|
|
430
|
+
content: contentPartsToText(msg.content),
|
|
431
|
+
});
|
|
432
|
+
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
|
|
433
|
+
flushToolResultImages();
|
|
434
|
+
}
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Trailing dangle: a turn interrupted after the assistant requested tools leaves the
|
|
441
|
+
// round open; close it synthetically (then release any deferred barriers in order).
|
|
442
|
+
flushPendingToolCalls();
|
|
443
|
+
releaseDeferredBarriers();
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function safeToolName(name: string | undefined): string {
|
|
448
|
+
const raw = name && name.trim().length > 0 ? name : "tool_result";
|
|
449
|
+
const sanitized = raw.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
450
|
+
return sanitized;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const ZEN_SCHEMA_MAP_KEYS = new Set(["properties", "$defs", "definitions"]);
|
|
454
|
+
const ZEN_DROPPED_SCHEMA_KEYS = new Set(["encrypted"]);
|
|
455
|
+
|
|
456
|
+
function sanitizeZenSchemaMap(value: unknown): unknown {
|
|
457
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return sanitizeZenToolParameters(value);
|
|
458
|
+
const out: Record<string, unknown> = {};
|
|
459
|
+
for (const [name, child] of Object.entries(value as Record<string, unknown>)) {
|
|
460
|
+
out[name] = sanitizeZenToolParameters(child);
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function sanitizeZenToolParameters(value: unknown): unknown {
|
|
466
|
+
if (Array.isArray(value)) return value.map(sanitizeZenToolParameters);
|
|
467
|
+
if (!value || typeof value !== "object") return value;
|
|
468
|
+
const input = value as Record<string, unknown>;
|
|
469
|
+
const out: Record<string, unknown> = {};
|
|
470
|
+
for (const [key, child] of Object.entries(input)) {
|
|
471
|
+
if (ZEN_DROPPED_SCHEMA_KEYS.has(key)) continue;
|
|
472
|
+
if (key === "required" && Array.isArray(child) && child.length === 0) continue;
|
|
473
|
+
if (key === "type" && Array.isArray(child)) {
|
|
474
|
+
const nonNull = child.filter(entry => entry !== "null");
|
|
475
|
+
if (child.includes("null")) out.nullable = true;
|
|
476
|
+
if (nonNull.length > 0) out.type = nonNull[0];
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
out[key] = ZEN_SCHEMA_MAP_KEYS.has(key) ? sanitizeZenSchemaMap(child) : sanitizeZenToolParameters(child);
|
|
480
|
+
}
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function ensureZenRootObjectSchema(schema: unknown): Record<string, unknown> {
|
|
485
|
+
const obj = schema && typeof schema === "object" && !Array.isArray(schema)
|
|
486
|
+
? schema as Record<string, unknown>
|
|
487
|
+
: {};
|
|
488
|
+
const compositionKeys = ["oneOf", "anyOf", "allOf"] as const;
|
|
489
|
+
const hasComposition = compositionKeys.some(key => Array.isArray(obj[key]));
|
|
490
|
+
const rootType = obj.type;
|
|
491
|
+
const rootObjectType = rootType === "object" || (Array.isArray(rootType) && rootType.includes("object"));
|
|
492
|
+
if (!hasComposition) {
|
|
493
|
+
const base = sanitizeZenToolParameters(obj) as Record<string, unknown>;
|
|
494
|
+
return rootObjectType && base.type === "object" ? base : { ...base, type: "object" };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const props: Record<string, unknown> = {};
|
|
498
|
+
const required = new Set<string>();
|
|
499
|
+
if (obj.properties && typeof obj.properties === "object") {
|
|
500
|
+
Object.assign(props, sanitizeZenSchemaMap(obj.properties) as Record<string, unknown>);
|
|
501
|
+
}
|
|
502
|
+
if (Array.isArray(obj.required)) {
|
|
503
|
+
for (const entry of obj.required) if (typeof entry === "string") required.add(entry);
|
|
504
|
+
}
|
|
505
|
+
for (const key of compositionKeys) {
|
|
506
|
+
const variants = obj[key];
|
|
507
|
+
if (!Array.isArray(variants)) continue;
|
|
508
|
+
const mergeRequired = key === "allOf";
|
|
509
|
+
for (const variant of variants) {
|
|
510
|
+
if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue;
|
|
511
|
+
const rec = variant as Record<string, unknown>;
|
|
512
|
+
if (rec.properties && typeof rec.properties === "object") {
|
|
513
|
+
Object.assign(props, sanitizeZenSchemaMap(rec.properties) as Record<string, unknown>);
|
|
514
|
+
}
|
|
515
|
+
if (mergeRequired && Array.isArray(rec.required)) {
|
|
516
|
+
for (const entry of rec.required) if (typeof entry === "string") required.add(entry);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const merged = sanitizeZenToolParameters(obj) as Record<string, unknown>;
|
|
522
|
+
delete merged.oneOf;
|
|
523
|
+
delete merged.anyOf;
|
|
524
|
+
delete merged.allOf;
|
|
525
|
+
merged.type = "object";
|
|
526
|
+
if (Object.keys(props).length > 0) merged.properties = props;
|
|
527
|
+
if (required.size > 0) merged.required = [...required];
|
|
528
|
+
return merged;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function shouldSanitizeZenToolParameters(provider: OcxProviderConfig): boolean {
|
|
532
|
+
const baseUrl = provider.baseUrl.replace(/\/+$/, "");
|
|
533
|
+
return baseUrl === "https://opencode.ai/zen/v1"
|
|
534
|
+
|| baseUrl === "https://opencode.ai/zen/go/v1";
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const XAI_SCHEMA_BASE_URLS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]);
|
|
538
|
+
|
|
539
|
+
function isXaiSchemaTarget(provider: OcxProviderConfig): boolean {
|
|
540
|
+
try {
|
|
541
|
+
return XAI_SCHEMA_BASE_URLS.has(new URL(provider.baseUrl).hostname);
|
|
542
|
+
} catch {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Volcengine Ark regional endpoints. Ark validates an assistant message's text field as a
|
|
548
|
+
// REQUIRED parameter and treats "" as absent, so a tool-call-only assistant in history 400s with
|
|
549
|
+
// `MissingParameter: input.content.text` (#796). Every other OpenAI-compatible provider accepts
|
|
550
|
+
// "", and xAI actively requires it ("Each message must have at least one content element"), so
|
|
551
|
+
// the two contracts are in direct conflict and this cannot be a global change.
|
|
552
|
+
const VOLCENGINE_ARK_HOSTNAMES = new Set([
|
|
553
|
+
"ark.cn-beijing.volces.com",
|
|
554
|
+
"ark.ap-southeast.volces.com",
|
|
555
|
+
]);
|
|
556
|
+
|
|
557
|
+
function isVolcengineArkTarget(provider: OcxProviderConfig): boolean {
|
|
558
|
+
try {
|
|
559
|
+
return VOLCENGINE_ARK_HOSTNAMES.has(new URL(provider.baseUrl).hostname);
|
|
560
|
+
} catch {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Placeholder content for an assistant history entry carrying only tool calls or reasoning.
|
|
567
|
+
*
|
|
568
|
+
* UNVERIFIED HYPOTHESIS for Ark. The reported error names `input.content.text`, a nested path,
|
|
569
|
+
* which suggests Ark wants the structured content form `[{type:"text",text:""}]` rather than a
|
|
570
|
+
* bare string — no string value, `""` or `" "`, exposes a `content.text` path at all. But Ark's
|
|
571
|
+
* published examples only show array content for MULTIMODAL USER input, never for an assistant
|
|
572
|
+
* history entry, so this shape is inferred from the error message and not confirmed by the docs
|
|
573
|
+
* or by a live request. The empty inner text at least adds no tokens either way.
|
|
574
|
+
*
|
|
575
|
+
* Confirm against a real Ark endpoint before relying on this; #796 records what is still missing.
|
|
576
|
+
*
|
|
577
|
+
* Every other provider keeps the bare `""`, which xAI's validator specifically requires ("Each
|
|
578
|
+
* message must have at least one content element"), so this cannot be applied globally.
|
|
579
|
+
*/
|
|
580
|
+
function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
|
|
581
|
+
return isVolcengineArkTarget(provider) ? [{ type: "text", text: "" }] : "";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Providers like Kimi and DeepSeek reject function parameter schemas whose root
|
|
586
|
+
* `type` is missing or `null` — JSON Schema requires `"object"` at the root of
|
|
587
|
+
* function parameters. Add `type: "object"` at the root while preserving
|
|
588
|
+
* `oneOf`, `$defs`, and every other schema key.
|
|
589
|
+
*
|
|
590
|
+
* This mirrors `normalizeFunctionToolSchema` in openai-responses.ts, which
|
|
591
|
+
* applies the same root-only normalization unconditionally on the responses
|
|
592
|
+
* path. Nested schema content is intentionally left untouched.
|
|
593
|
+
*/
|
|
594
|
+
function ensureRootObjectType(parameters: unknown): Record<string, unknown> {
|
|
595
|
+
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
|
|
596
|
+
return { type: "object", properties: {} };
|
|
597
|
+
}
|
|
598
|
+
const obj = parameters as Record<string, unknown>;
|
|
599
|
+
if (obj.type === "object") return obj;
|
|
600
|
+
return { ...obj, type: "object" };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function expandXaiRootObjectSchemas(schema: unknown): Record<string, unknown>[] | undefined {
|
|
604
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
|
|
605
|
+
const obj = schema as Record<string, unknown>;
|
|
606
|
+
const compositionKey = ["oneOf", "anyOf"].find(key => Array.isArray(obj[key]));
|
|
607
|
+
if (!compositionKey) {
|
|
608
|
+
if (obj.type !== undefined && obj.type !== "object") return undefined;
|
|
609
|
+
return [{ ...obj, type: "object" }];
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const siblings = Object.fromEntries(Object.entries(obj).filter(([key]) => key !== compositionKey));
|
|
613
|
+
const branches = obj[compositionKey];
|
|
614
|
+
if (!Array.isArray(branches)) return undefined;
|
|
615
|
+
const expanded: Record<string, unknown>[] = [];
|
|
616
|
+
for (const branch of branches) {
|
|
617
|
+
const variants = expandXaiRootObjectSchemas(branch);
|
|
618
|
+
if (!variants) return undefined;
|
|
619
|
+
for (const variant of variants) expanded.push({ ...siblings, ...variant });
|
|
620
|
+
}
|
|
621
|
+
return expanded.length > 0 ? expanded : undefined;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown> | undefined {
|
|
625
|
+
const variants = expandXaiRootObjectSchemas(parameters);
|
|
626
|
+
if (!variants) return undefined;
|
|
627
|
+
if (variants.length === 1) return variants[0];
|
|
628
|
+
const root = parameters && typeof parameters === "object" && !Array.isArray(parameters)
|
|
629
|
+
? parameters as Record<string, unknown>
|
|
630
|
+
: {};
|
|
631
|
+
const metadata = Object.fromEntries(Object.entries(root).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
|
|
632
|
+
return { ...metadata, oneOf: variants };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
|
|
636
|
+
if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
|
|
637
|
+
const allowed = isAllowedToolChoice(parsed.options.toolChoice)
|
|
638
|
+
? new Set(parsed.options.toolChoice.allowedTools)
|
|
639
|
+
: undefined;
|
|
640
|
+
const tools = allowed
|
|
641
|
+
? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
|
|
642
|
+
: parsed.context.tools;
|
|
643
|
+
if (tools.length === 0) return undefined;
|
|
644
|
+
const xaiTarget = isXaiSchemaTarget(provider);
|
|
645
|
+
const formatted = tools.flatMap(t => {
|
|
646
|
+
const parameters = xaiTarget
|
|
647
|
+
? normalizeXaiToolParameters(t.parameters)
|
|
648
|
+
: ensureRootObjectType(t.parameters);
|
|
649
|
+
|
|
650
|
+
if (parameters === undefined) return [];
|
|
651
|
+
return [{
|
|
652
|
+
type: "function",
|
|
653
|
+
function: {
|
|
654
|
+
name: namespacedToolName(t.namespace, t.name),
|
|
655
|
+
description: t.description,
|
|
656
|
+
parameters,
|
|
657
|
+
...(t.strict !== undefined ? { strict: t.strict } : {}),
|
|
658
|
+
},
|
|
659
|
+
}];
|
|
660
|
+
});
|
|
661
|
+
return formatted.length > 0 ? formatted : undefined;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
|
|
665
|
+
const base = toolsToChatFormat(parsed, provider);
|
|
666
|
+
if (!base || !shouldSanitizeZenToolParameters(provider)) return base;
|
|
667
|
+
return base.map(tool => {
|
|
668
|
+
if (!tool || typeof tool !== "object") return tool;
|
|
669
|
+
const functionDef = (tool as { function?: Record<string, unknown> }).function;
|
|
670
|
+
if (!functionDef || typeof functionDef !== "object") return tool;
|
|
671
|
+
return {
|
|
672
|
+
...tool,
|
|
673
|
+
function: {
|
|
674
|
+
...functionDef,
|
|
675
|
+
parameters: ensureZenRootObjectSchema(functionDef.parameters ?? {}),
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function toolChoiceToChatFormat(tc: OcxParsedRequest["options"]["toolChoice"], tools: OcxParsedRequest["context"]["tools"]): unknown {
|
|
682
|
+
if (!tc) return undefined;
|
|
683
|
+
if (isAllowedToolChoice(tc)) return tc.mode === "required" ? "required" : "auto";
|
|
684
|
+
if (tc === "auto" || tc === "none" || tc === "required") return tc;
|
|
685
|
+
if ("name" in tc) return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } };
|
|
686
|
+
return undefined;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function usageFromOpenAIChat(usage: Record<string, unknown> | undefined): OcxUsage | undefined {
|
|
690
|
+
if (!usage) return undefined;
|
|
691
|
+
const promptDetails = usage.prompt_tokens_details as Record<string, number> | undefined;
|
|
692
|
+
const completionDetails = usage.completion_tokens_details as Record<string, number> | undefined;
|
|
693
|
+
return {
|
|
694
|
+
inputTokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0,
|
|
695
|
+
outputTokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0,
|
|
696
|
+
...(promptDetails?.cached_tokens !== undefined ? { cachedInputTokens: promptDetails.cached_tokens } : {}),
|
|
697
|
+
...(completionDetails?.reasoning_tokens !== undefined ? { reasoningOutputTokens: completionDetails.reasoning_tokens } : {}),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function resolveMaxTokens(provider: OcxProviderConfig, parsed: OcxParsedRequest): number | undefined {
|
|
702
|
+
return parsed.options.maxOutputTokens
|
|
703
|
+
?? modelRecordValue(provider.modelMaxOutputTokens, parsed.modelId)
|
|
704
|
+
?? provider.defaultMaxOutputTokens;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function thinkingBudgetForEffort(parsed: OcxParsedRequest, reasoningEffort: string, maxOutputTokens?: number): number | undefined {
|
|
708
|
+
if (parsed.options.reasoning === "minimal") return 0;
|
|
709
|
+
const maxBudget = maxOutputTokens ?? 32768;
|
|
710
|
+
const fractions: Record<string, number> = {
|
|
711
|
+
low: 0.20,
|
|
712
|
+
medium: 0.50,
|
|
713
|
+
high: 0.75,
|
|
714
|
+
xhigh: 0.90,
|
|
715
|
+
max: 1.0,
|
|
716
|
+
};
|
|
717
|
+
const fraction = fractions[reasoningEffort];
|
|
718
|
+
return fraction === undefined ? undefined : Math.max(1, Math.floor(maxBudget * fraction));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
|
|
722
|
+
return {
|
|
723
|
+
name: "openai-chat",
|
|
724
|
+
|
|
725
|
+
formatErrorBody: formatOpenAIChatErrorBody,
|
|
726
|
+
|
|
727
|
+
buildRequest(parsed: OcxParsedRequest) {
|
|
728
|
+
const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
|
|
729
|
+
if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
|
|
730
|
+
throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const messages = messagesToChatFormat(parsed, provider);
|
|
734
|
+
const tools = toolsToChatFormatForProvider(parsed, provider);
|
|
735
|
+
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools);
|
|
736
|
+
|
|
737
|
+
const body: Record<string, unknown> = {
|
|
738
|
+
model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId,
|
|
739
|
+
messages,
|
|
740
|
+
stream: parsed.stream,
|
|
741
|
+
};
|
|
742
|
+
if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true;
|
|
743
|
+
const maxTokens = resolveMaxTokens(provider, parsed);
|
|
744
|
+
const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId);
|
|
745
|
+
if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
|
|
746
|
+
if (tools) body.tools = tools;
|
|
747
|
+
if (tools && toolChoice !== undefined) {
|
|
748
|
+
body.tool_choice = modelInList(provider.autoToolChoiceOnlyModels, parsed.modelId)
|
|
749
|
+
? (toolChoice === "none" ? "none" : "auto")
|
|
750
|
+
: toolChoice;
|
|
751
|
+
}
|
|
752
|
+
if (maxTokens !== undefined) body.max_tokens = maxTokens;
|
|
753
|
+
if (parsed.options.temperature !== undefined && !modelInList(provider.noTemperatureModels, parsed.modelId)) {
|
|
754
|
+
body.temperature = parsed.options.temperature;
|
|
755
|
+
}
|
|
756
|
+
if (parsed.options.topP !== undefined && !modelInList(provider.noTopPModels, parsed.modelId)) {
|
|
757
|
+
body.top_p = parsed.options.topP;
|
|
758
|
+
}
|
|
759
|
+
if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences;
|
|
760
|
+
const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId);
|
|
761
|
+
const reasoningRequired = isReasoningEffortRequired(provider, parsed.modelId);
|
|
762
|
+
const reasoningControl = reasoningControlKindFor(provider, parsed.modelId);
|
|
763
|
+
const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
|
|
764
|
+
let reasoningLog: AdapterRequest["reasoningLog"];
|
|
765
|
+
// ClinePass live requests observed 2026-08-02 require this gateway-specific object; the
|
|
766
|
+
// public API docs do not currently specify its request shape.
|
|
767
|
+
if (
|
|
768
|
+
!reasoningDisabled
|
|
769
|
+
&& provider.reasoningWireFormat === "gateway-object"
|
|
770
|
+
&& reasoningControl === "toggle"
|
|
771
|
+
&& typeof parsed.options.reasoning === "string"
|
|
772
|
+
) {
|
|
773
|
+
const enabled = parsed.options.reasoning !== "none";
|
|
774
|
+
body.reasoning = { enabled };
|
|
775
|
+
reasoningLog = {
|
|
776
|
+
effectiveEffort: enabled ? "on" : "none",
|
|
777
|
+
wireField: "reasoning.enabled",
|
|
778
|
+
wireValue: enabled,
|
|
779
|
+
};
|
|
780
|
+
} else if (
|
|
781
|
+
!reasoningDisabled
|
|
782
|
+
&& !reasoningRequired
|
|
783
|
+
&& provider.reasoningWireFormat === "gateway-object"
|
|
784
|
+
&& parsed.options.reasoning === "none"
|
|
785
|
+
) {
|
|
786
|
+
body.reasoning = { enabled: false };
|
|
787
|
+
reasoningLog = {
|
|
788
|
+
effectiveEffort: "none",
|
|
789
|
+
wireField: "reasoning.enabled",
|
|
790
|
+
wireValue: false,
|
|
791
|
+
};
|
|
792
|
+
} else if (reasoningEffort !== undefined) {
|
|
793
|
+
if (provider.reasoningWireFormat === "gateway-object") {
|
|
794
|
+
body.reasoning = { enabled: true, effort: reasoningEffort };
|
|
795
|
+
reasoningLog = {
|
|
796
|
+
effectiveEffort: reasoningEffort,
|
|
797
|
+
wireField: "reasoning.effort",
|
|
798
|
+
wireValue: reasoningEffort,
|
|
799
|
+
};
|
|
800
|
+
} else if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) {
|
|
801
|
+
const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens);
|
|
802
|
+
if (budget !== undefined) {
|
|
803
|
+
body.thinking_budget = budget;
|
|
804
|
+
reasoningLog = {
|
|
805
|
+
effectiveEffort: parsed.options.reasoning === "minimal" ? "minimal" : reasoningEffort,
|
|
806
|
+
wireField: "thinking_budget",
|
|
807
|
+
wireValue: budget,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
} else if (modelInList(provider.thinkingToggleModels, parsed.modelId)) {
|
|
811
|
+
// Vendor thinking-toggle wire: the mapped value is sent as `thinking: {type}` because
|
|
812
|
+
// these models ignore/reject reasoning_effort. Most use enabled/disabled; MiniMax-M3
|
|
813
|
+
// uses adaptive/disabled.
|
|
814
|
+
if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") {
|
|
815
|
+
body.thinking = { type: reasoningEffort };
|
|
816
|
+
reasoningLog = {
|
|
817
|
+
effectiveEffort: reasoningEffort,
|
|
818
|
+
wireField: "thinking.type",
|
|
819
|
+
wireValue: reasoningEffort,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
} else {
|
|
823
|
+
body.reasoning_effort = reasoningEffort;
|
|
824
|
+
reasoningLog = {
|
|
825
|
+
effectiveEffort: reasoningEffort,
|
|
826
|
+
wireField: "reasoning_effort",
|
|
827
|
+
wireValue: reasoningEffort,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (parsed.options.presencePenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
|
|
832
|
+
body.presence_penalty = parsed.options.presencePenalty;
|
|
833
|
+
}
|
|
834
|
+
if (parsed.options.frequencyPenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
|
|
835
|
+
body.frequency_penalty = parsed.options.frequencyPenalty;
|
|
836
|
+
}
|
|
837
|
+
// prompt_cache_key is an OpenAI-specific chat extension; strict backends (Groq,
|
|
838
|
+
// Cerebras, etc.) reject unknown fields. Only forward when the provider opts in.
|
|
839
|
+
if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) {
|
|
840
|
+
body.prompt_cache_key = parsed.options.promptCacheKey;
|
|
841
|
+
}
|
|
842
|
+
// Responses `text.format` -> chat `response_format`. json_object maps 1:1; json_schema
|
|
843
|
+
// re-nests the flattened Responses fields under `json_schema` — the exact inverse of
|
|
844
|
+
// responseFormatToText in src/chat/inbound.ts. Forwarded unconditionally (like `stop`):
|
|
845
|
+
// response_format is a first-class Chat Completions field, it is only present when the
|
|
846
|
+
// caller explicitly asked for structured output, and a backend that rejects it should
|
|
847
|
+
// fail loud rather than silently return prose the caller will try to JSON.parse.
|
|
848
|
+
const textFormat = parsed.options.textFormat;
|
|
849
|
+
if (textFormat?.type === "json_object") {
|
|
850
|
+
body.response_format = { type: "json_object" };
|
|
851
|
+
} else if (textFormat?.type === "json_schema") {
|
|
852
|
+
body.response_format = {
|
|
853
|
+
type: "json_schema",
|
|
854
|
+
json_schema: {
|
|
855
|
+
name: textFormat.name ?? "response",
|
|
856
|
+
...(textFormat.description !== undefined ? { description: textFormat.description } : {}),
|
|
857
|
+
...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}),
|
|
858
|
+
...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}),
|
|
859
|
+
},
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (tools) {
|
|
864
|
+
// Default-ON for chat-completions providers (user decision 260709): the buffered
|
|
865
|
+
// parser assembles multi-call streams safely, so `parallelToolCalls: false` is the
|
|
866
|
+
// only per-provider opt-out; Codex's request bit can still force false per request.
|
|
867
|
+
// Rationale + provider evidence: devlog/_plan/260709_parallel_tool_calls.
|
|
868
|
+
body.parallel_tool_calls = provider.parallelToolCalls === false
|
|
869
|
+
? false
|
|
870
|
+
: parsed.options.parallelToolCalls !== false;
|
|
871
|
+
}
|
|
872
|
+
if (parsed.stream) {
|
|
873
|
+
body.stream_options = { include_usage: true };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const url = `${provider.baseUrl}/chat/completions`;
|
|
877
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
878
|
+
// Precedence preserved from pre-#128 behavior: apiKey Authorization first, then
|
|
879
|
+
// provider.headers may override (user/registry-configured headers win). Registry
|
|
880
|
+
// staticHeaders (e.g. opencode-free x-opencode-client) flow in via derive.ts and
|
|
881
|
+
// never carry Authorization, so keyless providers are unaffected.
|
|
882
|
+
if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`;
|
|
883
|
+
if (provider.headers) Object.assign(headers, provider.headers);
|
|
884
|
+
|
|
885
|
+
const bodyJson = JSON.stringify(body);
|
|
886
|
+
// Never log pathname/query — tenant-scoped hosts (e.g. Cloudflare
|
|
887
|
+
// /accounts/<account_id>/ai/v1) would otherwise leak account identifiers (#452).
|
|
888
|
+
if (isDebugEnabled()) {
|
|
889
|
+
let host = "upstream";
|
|
890
|
+
try { host = new URL(url).host; } catch { /* keep fallback */ }
|
|
891
|
+
debugProviderDiagnostic("openai-chat", "request", {
|
|
892
|
+
host,
|
|
893
|
+
model: body.model,
|
|
894
|
+
stream: parsed.stream,
|
|
895
|
+
messageCount: Array.isArray(messages) ? messages.length : 0,
|
|
896
|
+
toolCount: Array.isArray(tools) ? tools.length : 0,
|
|
897
|
+
hasCredential,
|
|
898
|
+
bodyBytes: new TextEncoder().encode(bodyJson).length,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return {
|
|
903
|
+
url,
|
|
904
|
+
method: "POST",
|
|
905
|
+
headers,
|
|
906
|
+
body: bodyJson,
|
|
907
|
+
...(reasoningLog ? { reasoningLog } : {}),
|
|
908
|
+
};
|
|
909
|
+
},
|
|
910
|
+
|
|
911
|
+
async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
|
|
912
|
+
if (!response.body) {
|
|
913
|
+
yield { type: "error", message: "No response body" };
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const reader = response.body.getReader();
|
|
918
|
+
const decoder = new TextDecoder();
|
|
919
|
+
const budgetEncoder = new TextEncoder();
|
|
920
|
+
let buffer = "";
|
|
921
|
+
let bufferBytes = 0;
|
|
922
|
+
// Streamed tool calls are BUFFERED until a terminal signal, then flushed as atomic
|
|
923
|
+
// start/delta/end sequences. The bridge treats text/reasoning deltas as barriers that
|
|
924
|
+
// close an open tool-call item (bridge.ts closeCurrentToolCall on text_delta), so
|
|
925
|
+
// emitting calls incrementally would orphan later argument deltas whenever a provider
|
|
926
|
+
// interleaves content — and parallel tool calls (multiple ids, index-keyed continuation
|
|
927
|
+
// chunks, whole-chunk calls) cannot be represented live without overlapping sequences.
|
|
928
|
+
// Keyed by `index` (OpenAI wire standard), falling back to `id`, falling back to the
|
|
929
|
+
// last-seen call for providers that omit both on continuation chunks.
|
|
930
|
+
interface PendingToolCall { key: string; id: string; name: string; args: string; argsBytes: number }
|
|
931
|
+
const pendingToolCalls: PendingToolCall[] = [];
|
|
932
|
+
let toolCallSeq = 0;
|
|
933
|
+
const closeToolCalls = (): PendingToolCall[] => {
|
|
934
|
+
const calls = [...pendingToolCalls];
|
|
935
|
+
for (const call of calls) budget.closeCall(call.key);
|
|
936
|
+
pendingToolCalls.length = 0;
|
|
937
|
+
return calls;
|
|
938
|
+
};
|
|
939
|
+
const flushToolCalls = function* (): Generator<AdapterEvent> {
|
|
940
|
+
// Do not treat flushed tool calls as user-facing output for the finish-less EOF
|
|
941
|
+
// fallback — incomplete tool args must stay on the truncation path.
|
|
942
|
+
for (const call of closeToolCalls()) {
|
|
943
|
+
if (!call.id) call.id = `call_${++toolCallSeq}`;
|
|
944
|
+
yield { type: "tool_call_start", id: call.id, name: call.name };
|
|
945
|
+
if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args };
|
|
946
|
+
yield { type: "tool_call_end" };
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
const terminateWithError = function* (
|
|
950
|
+
event: Extract<AdapterEvent, { type: "error" }>,
|
|
951
|
+
): Generator<AdapterEvent, "terminate"> {
|
|
952
|
+
closeToolCalls();
|
|
953
|
+
yield event;
|
|
954
|
+
return "terminate";
|
|
955
|
+
};
|
|
956
|
+
let pendingUsage: OcxUsage | undefined;
|
|
957
|
+
// Track terminal signals so a socket EOF without any terminator can fail closed instead of
|
|
958
|
+
// being reported as a clean completion (silent truncation). A graceful close is either an
|
|
959
|
+
// explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
|
|
960
|
+
// OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
|
|
961
|
+
let finishReason: string | undefined;
|
|
962
|
+
// Only answer text enables the finish-less EOF fallback. Reasoning-only streams can be
|
|
963
|
+
// suppressed by hideThinkingSummary and must not complete as empty successful turns.
|
|
964
|
+
let sawUserFacingOutput = false;
|
|
965
|
+
|
|
966
|
+
// Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
|
|
967
|
+
// a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
|
|
968
|
+
// Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that
|
|
969
|
+
// must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state.
|
|
970
|
+
const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
|
|
971
|
+
const rawPayload = sseFieldValue(line, "data");
|
|
972
|
+
if (rawPayload === null) return "continue";
|
|
973
|
+
const payload = rawPayload.trim();
|
|
974
|
+
// A bare `data:` line carries nothing (heartbeat-style keep-alive on some gateways);
|
|
975
|
+
// it is not a malformed frame, just nothing to parse.
|
|
976
|
+
if (payload.length === 0) return "continue";
|
|
977
|
+
if (payload === "[DONE]") {
|
|
978
|
+
yield* flushToolCalls();
|
|
979
|
+
const stopReason = stopReasonFor(finishReason);
|
|
980
|
+
yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
|
|
981
|
+
return "terminate";
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
let parsed: unknown;
|
|
985
|
+
try {
|
|
986
|
+
parsed = JSON.parse(payload);
|
|
987
|
+
} catch {
|
|
988
|
+
yield { type: "error", message: "malformed upstream SSE data frame" };
|
|
989
|
+
return "terminate";
|
|
990
|
+
}
|
|
991
|
+
// Validate the shape instead of asserting it. `JSON.parse` yields a value, not necessarily
|
|
992
|
+
// an object — `JSON.parse("null")` returns null without throwing, so the catch above never
|
|
993
|
+
// sees it and the `chunk.error` read below crashed the stream mid-flight.
|
|
994
|
+
//
|
|
995
|
+
// Skip rather than terminate: `data: null` is emitted as a benign padding frame BETWEEN
|
|
996
|
+
// content deltas by real OpenAI-compatible routes (issue #1219), so failing here would
|
|
997
|
+
// discard the finish_reason chunk and [DONE] still in flight and turn a healthy response
|
|
998
|
+
// into a failed turn. Skipping cannot mask a genuinely broken stream — a stream carrying
|
|
999
|
+
// only such frames still sets neither finishReason nor sawUserFacingOutput and so trips
|
|
1000
|
+
// the EOF truncation guard below. An unparseable frame stays terminal.
|
|
1001
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1002
|
+
return "continue";
|
|
1003
|
+
}
|
|
1004
|
+
const chunk = parsed as Record<string, unknown>;
|
|
1005
|
+
|
|
1006
|
+
// A 200/OK chat-completions stream may carry an inline provider error envelope
|
|
1007
|
+
// instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
|
|
1008
|
+
// classified response.failed (bridge case "error") — never a truncated completion.
|
|
1009
|
+
if (chunk.error !== undefined && chunk.error !== null) {
|
|
1010
|
+
const event = upstreamErrorEvent(chunk.error, pendingUsage);
|
|
1011
|
+
debugProviderDiagnostic("openai-chat", "stream-error", { message: event.message });
|
|
1012
|
+
return yield* terminateWithError(event);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (chunk.usage) {
|
|
1016
|
+
// Record usage but keep parsing: some providers send usage and the final content
|
|
1017
|
+
// delta in the SAME chunk; a bail here would drop that content. The choices
|
|
1018
|
+
// guard below no-ops a usage-only chunk.
|
|
1019
|
+
pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const choices = chunk.choices;
|
|
1023
|
+
if (choices === undefined) return "continue";
|
|
1024
|
+
if (!Array.isArray(choices)) {
|
|
1025
|
+
return yield* terminateWithError(invalidChoicesEvent(pendingUsage));
|
|
1026
|
+
}
|
|
1027
|
+
if (choices.length === 0) return "continue";
|
|
1028
|
+
const rawChoice = choices[0];
|
|
1029
|
+
if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) {
|
|
1030
|
+
return yield* terminateWithError(invalidChoicesEvent(pendingUsage));
|
|
1031
|
+
}
|
|
1032
|
+
const choice = rawChoice as {
|
|
1033
|
+
delta?: Record<string, unknown>;
|
|
1034
|
+
finish_reason?: string;
|
|
1035
|
+
error?: unknown;
|
|
1036
|
+
};
|
|
1037
|
+
if (choice.finish_reason === "error") {
|
|
1038
|
+
const event = upstreamErrorEvent(choice.error, pendingUsage);
|
|
1039
|
+
debugProviderDiagnostic("openai-chat", "stream-error", { message: event.message });
|
|
1040
|
+
return yield* terminateWithError(event);
|
|
1041
|
+
}
|
|
1042
|
+
// Observe the terminator BEFORE the delta guard: a finish-only chunk (finish_reason set,
|
|
1043
|
+
// no delta) is a graceful close and must record finishReason even though we skip it below.
|
|
1044
|
+
if (typeof choice.finish_reason === "string" && choice.finish_reason) {
|
|
1045
|
+
finishReason = choice.finish_reason;
|
|
1046
|
+
}
|
|
1047
|
+
const delta = choice.delta;
|
|
1048
|
+
if (delta) {
|
|
1049
|
+
const reasoningText = reasoningTextFrom(delta);
|
|
1050
|
+
if (reasoningText !== undefined) {
|
|
1051
|
+
yield { type: "reasoning_raw_delta", text: reasoningText };
|
|
1052
|
+
}
|
|
1053
|
+
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
1054
|
+
sawUserFacingOutput = true;
|
|
1055
|
+
yield { type: "text_delta", text: delta.content };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const toolCalls = delta.tool_calls as { index?: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
|
|
1059
|
+
if (toolCalls) {
|
|
1060
|
+
for (const tc of toolCalls) {
|
|
1061
|
+
const key = typeof tc.index === "number"
|
|
1062
|
+
? `i:${tc.index}`
|
|
1063
|
+
: tc.id
|
|
1064
|
+
? `id:${tc.id}`
|
|
1065
|
+
: pendingToolCalls[pendingToolCalls.length - 1]?.key;
|
|
1066
|
+
let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined;
|
|
1067
|
+
// Mixed keying rescue: a call opened under an index key must still absorb an
|
|
1068
|
+
// id-only continuation for the same provider id (and vice versa) instead of
|
|
1069
|
+
// splitting into two calls that share one call_id downstream.
|
|
1070
|
+
if (!call && tc.id) call = pendingToolCalls.find(c => c.id === tc.id);
|
|
1071
|
+
if (!call) {
|
|
1072
|
+
call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "", argsBytes: 0 };
|
|
1073
|
+
pendingToolCalls.push(call);
|
|
1074
|
+
budget.openCall(call.key);
|
|
1075
|
+
}
|
|
1076
|
+
if (tc.id && !call.id) call.id = tc.id;
|
|
1077
|
+
if (tc.function?.name && !call.name) call.name = tc.function.name;
|
|
1078
|
+
if (tc.function?.arguments) {
|
|
1079
|
+
const previousBytes = call.argsBytes;
|
|
1080
|
+
const nextBytes = previousBytes + budgetEncoder.encode(tc.function.arguments).byteLength;
|
|
1081
|
+
const scope = { kind: "tool_args" as const, callId: call.key };
|
|
1082
|
+
const reservation = budget.reserveTransient(nextBytes, scope);
|
|
1083
|
+
try {
|
|
1084
|
+
call.args += tc.function.arguments;
|
|
1085
|
+
reservation.commitRetained();
|
|
1086
|
+
budget.releaseRetained(previousBytes, scope);
|
|
1087
|
+
call.argsBytes = nextBytes;
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
reservation.release();
|
|
1090
|
+
throw error;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Any non-empty finish_reason ends the generation: flush assembled tool calls as
|
|
1098
|
+
// atomic sequences (covers "tool_calls" AND providers that close tool turns with "stop").
|
|
1099
|
+
if (typeof choice.finish_reason === "string" && choice.finish_reason) {
|
|
1100
|
+
yield* flushToolCalls();
|
|
1101
|
+
}
|
|
1102
|
+
return "continue";
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
try {
|
|
1106
|
+
while (true) {
|
|
1107
|
+
const { done, value } = await reader.read();
|
|
1108
|
+
if (done) break;
|
|
1109
|
+
const decoded = decoder.decode(value, { stream: true });
|
|
1110
|
+
const nextBufferBytes = bufferBytes + budgetEncoder.encode(decoded).byteLength;
|
|
1111
|
+
if (nextBufferBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) {
|
|
1112
|
+
throw new Error(`translation SSE event exceeded ${TRANSLATOR_MAX_SSE_EVENT_BYTES} bytes`, {
|
|
1113
|
+
cause: { code: "translation_buffer_limit" },
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
const appendReservation = budget.reserveTransient(nextBufferBytes, { kind: "live_transient" });
|
|
1117
|
+
try {
|
|
1118
|
+
buffer += decoded;
|
|
1119
|
+
appendReservation.commitRetained();
|
|
1120
|
+
budget.releaseRetained(bufferBytes, { kind: "live_transient" });
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
appendReservation.release();
|
|
1123
|
+
throw error;
|
|
1124
|
+
}
|
|
1125
|
+
bufferBytes = nextBufferBytes;
|
|
1126
|
+
|
|
1127
|
+
const lines = buffer.split("\n");
|
|
1128
|
+
buffer = lines.pop() ?? "";
|
|
1129
|
+
const residualBytes = budgetEncoder.encode(buffer).byteLength;
|
|
1130
|
+
const residualReservation = budget.reserveTransient(residualBytes, { kind: "live_transient" });
|
|
1131
|
+
residualReservation.commitRetained();
|
|
1132
|
+
budget.releaseRetained(bufferBytes, { kind: "live_transient" });
|
|
1133
|
+
bufferBytes = residualBytes;
|
|
1134
|
+
|
|
1135
|
+
for (const line of lines) {
|
|
1136
|
+
if ((yield* handleDataLine(line)) === "terminate") return;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Some providers send the terminal `data:` frame (carrying the final delta, finish_reason,
|
|
1141
|
+
// and/or usage) WITHOUT a trailing newline before closing the socket, so it never crosses
|
|
1142
|
+
// the split("\n") boundary and stays in `buffer`. Run it through the SAME handler so its
|
|
1143
|
+
// content/tool-calls are emitted and its terminal signal observed — otherwise a genuinely
|
|
1144
|
+
// complete stream loses its last frame and may be falsely failed below.
|
|
1145
|
+
if (buffer.length > 0) {
|
|
1146
|
+
if ((yield* handleDataLine(buffer)) === "terminate") return;
|
|
1147
|
+
}
|
|
1148
|
+
// Reader EOF. Prefer failing closed before flushing pending tool calls so the bridge
|
|
1149
|
+
// never sees a fabricated tool_call_end on a truncated mid-assembly stream.
|
|
1150
|
+
//
|
|
1151
|
+
// Checked BEFORE flushToolCalls(), because that helper emits tool_call_end and there is no
|
|
1152
|
+
// taking it back: a half-assembled argument string would reach the client as a completed
|
|
1153
|
+
// call. Tool calls are buffered here (unlike the Anthropic adapter, which forwards
|
|
1154
|
+
// fragments live), so this adapter can still decide.
|
|
1155
|
+
const sawFinish = finishReason !== undefined;
|
|
1156
|
+
if (!sawFinish && pendingToolCalls.length > 0) {
|
|
1157
|
+
debugProviderDiagnostic("openai-chat", "stream-truncated", {
|
|
1158
|
+
finishReason: null,
|
|
1159
|
+
hadUsage: pendingUsage !== undefined,
|
|
1160
|
+
pendingToolCalls: pendingToolCalls.length,
|
|
1161
|
+
});
|
|
1162
|
+
yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
// Finish-less EOF is only safe when answer text was emitted. Reasoning-only / usage-only
|
|
1166
|
+
// truncations must stay on the error path (hideThinkingSummary can suppress reasoning).
|
|
1167
|
+
// Trailing usage alone is not a terminal signal for this adapter (#735 / restore #773).
|
|
1168
|
+
if (!sawFinish && !sawUserFacingOutput) {
|
|
1169
|
+
debugProviderDiagnostic("openai-chat", "stream-truncated", {
|
|
1170
|
+
finishReason: finishReason ?? null,
|
|
1171
|
+
hadUsage: pendingUsage !== undefined,
|
|
1172
|
+
});
|
|
1173
|
+
yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
yield* flushToolCalls();
|
|
1177
|
+
// Graceful close that omitted [DONE] but delivered finish_reason and/or answer text.
|
|
1178
|
+
const stopReason = stopReasonFor(finishReason);
|
|
1179
|
+
yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
if (isTranslatorBudgetExceededError(error)
|
|
1182
|
+
|| (error instanceof Error && (error.cause as { code?: unknown } | undefined)?.code === "translation_buffer_limit")) {
|
|
1183
|
+
yield {
|
|
1184
|
+
type: "error",
|
|
1185
|
+
status: 502,
|
|
1186
|
+
errorType: "upstream_error",
|
|
1187
|
+
code: "translation_buffer_limit",
|
|
1188
|
+
message: "upstream translation buffer exceeded the safe limit",
|
|
1189
|
+
};
|
|
1190
|
+
try { await reader.cancel(error); } catch { /* already closed */ }
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
throw error;
|
|
1194
|
+
} finally {
|
|
1195
|
+
budget.releaseRetained(bufferBytes, { kind: "live_transient" });
|
|
1196
|
+
closeToolCalls();
|
|
1197
|
+
reader.releaseLock();
|
|
1198
|
+
}
|
|
1199
|
+
},
|
|
1200
|
+
|
|
1201
|
+
async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
|
|
1202
|
+
const json = await response.json() as Record<string, unknown>;
|
|
1203
|
+
const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength;
|
|
1204
|
+
budget.chargeRetained(responseBytes, { kind: "retained_collectors" });
|
|
1205
|
+
try {
|
|
1206
|
+
const payload = unwrapChatCompletionPayload(json);
|
|
1207
|
+
const usage = usageFromOpenAIChat(payload.usage as Record<string, unknown> | undefined);
|
|
1208
|
+
if (json.success === false && payload.error === undefined) {
|
|
1209
|
+
return [{
|
|
1210
|
+
type: "error",
|
|
1211
|
+
message: "upstream reported failure without an error payload",
|
|
1212
|
+
...(usage ? { usage } : {}),
|
|
1213
|
+
}];
|
|
1214
|
+
}
|
|
1215
|
+
if (payload.error !== undefined && payload.error !== null) {
|
|
1216
|
+
return [upstreamErrorEvent(payload.error, usage)];
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const events: AdapterEvent[] = [];
|
|
1220
|
+
const choices = payload.choices as {
|
|
1221
|
+
message?: Record<string, unknown>;
|
|
1222
|
+
finish_reason?: unknown;
|
|
1223
|
+
error?: OpenAIChatError;
|
|
1224
|
+
}[] | undefined;
|
|
1225
|
+
if (!Array.isArray(choices) || choices.length === 0) {
|
|
1226
|
+
return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
|
|
1227
|
+
}
|
|
1228
|
+
const rawChoice = choices[0];
|
|
1229
|
+
if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) {
|
|
1230
|
+
return [invalidChoicesEvent(usage)];
|
|
1231
|
+
}
|
|
1232
|
+
const choice = rawChoice;
|
|
1233
|
+
if (choice.finish_reason === "error") return [upstreamErrorEvent(choice.error, usage)];
|
|
1234
|
+
if (!choice.message) return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
|
|
1235
|
+
|
|
1236
|
+
const msg = choice.message;
|
|
1237
|
+
const reasoningText = reasoningTextFrom(msg);
|
|
1238
|
+
if (reasoningText !== undefined) {
|
|
1239
|
+
events.push({ type: "reasoning_raw_delta", text: reasoningText });
|
|
1240
|
+
}
|
|
1241
|
+
if (typeof msg.content === "string") {
|
|
1242
|
+
events.push({ type: "text_delta", text: msg.content });
|
|
1243
|
+
}
|
|
1244
|
+
const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined;
|
|
1245
|
+
if (toolCalls) {
|
|
1246
|
+
for (const tc of toolCalls) {
|
|
1247
|
+
events.push({ type: "tool_call_start", id: tc.id, name: tc.function.name });
|
|
1248
|
+
events.push({ type: "tool_call_delta", arguments: tc.function.arguments });
|
|
1249
|
+
events.push({ type: "tool_call_end" });
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
const stopReason = stopReasonFor(choice.finish_reason);
|
|
1253
|
+
events.push({
|
|
1254
|
+
type: "done",
|
|
1255
|
+
usage,
|
|
1256
|
+
...(stopReason ? { stopReason } : {}),
|
|
1257
|
+
});
|
|
1258
|
+
retainTranslatedEventBatch(events, budget);
|
|
1259
|
+
return events;
|
|
1260
|
+
} finally {
|
|
1261
|
+
budget.releaseRetained(responseBytes, { kind: "retained_collectors" });
|
|
1262
|
+
}
|
|
1263
|
+
},
|
|
1264
|
+
};
|
|
1265
|
+
}
|