@iislee/opencodex 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +225 -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 +451 -0
- package/bin/package-main.mjs +9 -0
- package/gui/dist/assets/index-DTpMHS4F.js +67 -0
- package/gui/dist/assets/index-ZNVDE3C7.css +1 -0
- package/gui/dist/favicon.png +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/antigravity.svg +1 -0
- package/gui/dist/provider-icons/claude-color.svg +1 -0
- package/gui/dist/provider-icons/claude.svg +1 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -0
- package/gui/dist/provider-icons/copilot.svg +1 -0
- package/gui/dist/provider-icons/cursor-color.svg +2 -0
- package/gui/dist/provider-icons/cursor.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/gemini.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-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/kiro.svg +14 -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 +1 -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 +102 -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 +1003 -0
- package/src/adapters/azure.ts +36 -0
- package/src/adapters/base.ts +72 -0
- package/src/adapters/client-fingerprint.ts +59 -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 +127 -0
- package/src/adapters/cursor/exec-policy.ts +88 -0
- package/src/adapters/cursor/framing.ts +211 -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 +1214 -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 +55 -0
- package/src/adapters/cursor/native-exec-desktop.ts +184 -0
- package/src/adapters/cursor/native-exec-fs.ts +329 -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 +576 -0
- package/src/adapters/cursor/protobuf-events.ts +563 -0
- package/src/adapters/cursor/protobuf-request.ts +714 -0
- package/src/adapters/cursor/request-builder.ts +255 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/tool-definitions.ts +505 -0
- package/src/adapters/cursor/transport-retry.ts +132 -0
- package/src/adapters/cursor/transport.ts +57 -0
- package/src/adapters/cursor/types.ts +52 -0
- package/src/adapters/cursor.ts +196 -0
- package/src/adapters/google-antigravity-replay.ts +303 -0
- package/src/adapters/google-antigravity-wire.ts +108 -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 +13 -0
- package/src/adapters/google-wire-compiler.ts +232 -0
- package/src/adapters/google.ts +758 -0
- package/src/adapters/identity.ts +44 -0
- package/src/adapters/image.ts +23 -0
- package/src/adapters/kiro-constants.ts +16 -0
- package/src/adapters/kiro-errors.ts +197 -0
- package/src/adapters/kiro-events.ts +179 -0
- package/src/adapters/kiro-images.ts +129 -0
- package/src/adapters/kiro-retry.ts +312 -0
- package/src/adapters/kiro-thinking.ts +96 -0
- package/src/adapters/kiro-tool-fallback.ts +36 -0
- package/src/adapters/kiro-tools.ts +215 -0
- package/src/adapters/kiro-truncation.ts +33 -0
- package/src/adapters/kiro-wire.ts +129 -0
- package/src/adapters/kiro.ts +1898 -0
- package/src/adapters/mimo-free.ts +263 -0
- package/src/adapters/openai-chat.ts +1005 -0
- package/src/adapters/openai-responses.ts +1137 -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/bridge.ts +1619 -0
- package/src/chat/inbound.ts +295 -0
- package/src/chat/outbound.ts +765 -0
- package/src/claude/agents-inject.ts +243 -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 +381 -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 +509 -0
- package/src/claude/model-info.ts +151 -0
- package/src/claude/outbound.ts +872 -0
- package/src/cli/access.ts +108 -0
- package/src/cli/account-api.ts +268 -0
- package/src/cli/account-auth.ts +223 -0
- package/src/cli/account-extended.ts +350 -0
- package/src/cli/account.ts +275 -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 +188 -0
- package/src/cli/claude.ts +286 -0
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/combo.ts +119 -0
- package/src/cli/config-command.ts +145 -0
- package/src/cli/debug.ts +228 -0
- package/src/cli/doctor.ts +930 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +354 -0
- package/src/cli/index.ts +1113 -0
- package/src/cli/init.ts +224 -0
- package/src/cli/integrations.ts +142 -0
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/internal-dispatch.ts +20 -0
- package/src/cli/models-runtime.ts +212 -0
- package/src/cli/models.ts +336 -0
- package/src/cli/observe.ts +117 -0
- package/src/cli/opencode.ts +586 -0
- package/src/cli/pi.ts +188 -0
- package/src/cli/provider-runtime.ts +162 -0
- package/src/cli/provider.ts +463 -0
- package/src/cli/runtime-api.ts +325 -0
- package/src/cli/star-prompt.ts +155 -0
- package/src/cli/status-oauth.ts +78 -0
- package/src/cli/status.ts +321 -0
- package/src/cli/sync-cloud.ts +283 -0
- package/src/cli/system-command.ts +112 -0
- package/src/cli/tray-proxy.ts +52 -0
- package/src/cli/v2.ts +173 -0
- package/src/cli.ts +10 -0
- package/src/clients/config-export.ts +377 -0
- package/src/clients/effective-status.ts +385 -0
- package/src/clients/probes/agy.ts +55 -0
- package/src/clients/probes/cc-switch.ts +110 -0
- package/src/clients/probes/claude.ts +90 -0
- package/src/clients/probes/codex.ts +125 -0
- package/src/clients/probes/grok.ts +29 -0
- package/src/clients/probes/opencode.ts +109 -0
- package/src/clients/probes/paseo.ts +55 -0
- package/src/clients/probes/pi.ts +55 -0
- package/src/cloud/onedrive-auth.ts +666 -0
- package/src/cloud/onedrive-graph.ts +108 -0
- package/src/cloud/settings.ts +75 -0
- package/src/cloud/sync.ts +212 -0
- package/src/cloud/types.ts +56 -0
- package/src/cloud/vault.ts +89 -0
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-label.ts +34 -0
- package/src/codex/account-lifecycle.ts +55 -0
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-runtime-state.ts +31 -0
- package/src/codex/account-store.ts +517 -0
- package/src/codex/account-usability.ts +20 -0
- package/src/codex/app-server-processes.ts +756 -0
- package/src/codex/auth-api.ts +1540 -0
- package/src/codex/auth-collision.ts +107 -0
- package/src/codex/auth-context.ts +352 -0
- package/src/codex/autostart-health.ts +149 -0
- package/src/codex/catalog/aggregation.ts +378 -0
- package/src/codex/catalog/bundled.ts +251 -0
- package/src/codex/catalog/effort.ts +355 -0
- package/src/codex/catalog/metadata.ts +180 -0
- package/src/codex/catalog/parsing.ts +456 -0
- package/src/codex/catalog/provider-fetch.ts +902 -0
- package/src/codex/catalog/sync.ts +620 -0
- package/src/codex/catalog.ts +12 -0
- package/src/codex/data/upstream-models.json +830 -0
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/features.ts +969 -0
- package/src/codex/history-migration-guardian.ts +102 -0
- package/src/codex/history-provider.ts +776 -0
- package/src/codex/home.ts +206 -0
- package/src/codex/inject.ts +799 -0
- package/src/codex/injected-marker.ts +72 -0
- package/src/codex/journal.ts +163 -0
- package/src/codex/main-account-cache.ts +32 -0
- package/src/codex/main-account.ts +40 -0
- package/src/codex/model-cache.ts +227 -0
- package/src/codex/paths.ts +65 -0
- package/src/codex/plugins-doctor.ts +242 -0
- package/src/codex/pool-rotation.ts +225 -0
- package/src/codex/project-config-warnings.ts +411 -0
- package/src/codex/quota.ts +411 -0
- package/src/codex/refresh.ts +53 -0
- package/src/codex/routing.ts +1477 -0
- package/src/codex/runtime.ts +538 -0
- package/src/codex/shim.ts +1189 -0
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +469 -0
- package/src/codex/sync.ts +130 -0
- package/src/codex/warmup.ts +192 -0
- package/src/codex/websocket-registry.ts +100 -0
- package/src/combos/failover.ts +140 -0
- package/src/combos/index.ts +41 -0
- package/src/combos/request.ts +62 -0
- package/src/combos/resolve.ts +232 -0
- package/src/combos/types.ts +326 -0
- package/src/config.ts +2356 -0
- package/src/generated/jawcode-model-metadata.ts +104 -0
- package/src/github/star-state.ts +203 -0
- package/src/grok/inject.ts +545 -0
- package/src/grok/status.ts +121 -0
- package/src/grok/sync.ts +103 -0
- package/src/grok/usage-hook/report.mjs +348 -0
- package/src/grok/usage-hook.ts +278 -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 +829 -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/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 +202 -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 +71 -0
- package/src/lib/bun-stream-caps.ts +126 -0
- package/src/lib/config-ownership.ts +360 -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/open-url.ts +25 -0
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/privacy.ts +20 -0
- package/src/lib/process-control.ts +165 -0
- package/src/lib/provider-outbound.ts +170 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/redact.ts +105 -0
- package/src/lib/retry-after.ts +55 -0
- package/src/lib/service-secrets.ts +25 -0
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/sidecar-tracker.ts +52 -0
- package/src/lib/sse-decoder.ts +323 -0
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/token-estimate.ts +69 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/upstream-retry.ts +239 -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 +514 -0
- package/src/lib/winsw.ts +375 -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/cursor.ts +211 -0
- package/src/oauth/github-copilot.ts +428 -0
- package/src/oauth/google-antigravity.ts +230 -0
- package/src/oauth/health.ts +399 -0
- package/src/oauth/index.ts +1174 -0
- package/src/oauth/key-providers.ts +108 -0
- package/src/oauth/kimi.ts +213 -0
- package/src/oauth/kiro-credentials.ts +726 -0
- package/src/oauth/kiro.ts +577 -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 +630 -0
- package/src/oauth/token-guardian.ts +303 -0
- package/src/oauth/types.ts +62 -0
- package/src/oauth/xai.ts +241 -0
- package/src/pi/extensions.ts +72 -0
- package/src/pi/home.ts +42 -0
- package/src/pi/index.ts +40 -0
- package/src/pi/models.ts +278 -0
- package/src/pi/packages.ts +219 -0
- package/src/pi/settings.ts +365 -0
- package/src/pi/status.ts +68 -0
- package/src/pi/sync.ts +75 -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 +205 -0
- package/src/providers/api-keys.ts +140 -0
- package/src/providers/base-url-choices.ts +64 -0
- package/src/providers/context-cap.ts +65 -0
- package/src/providers/derive.ts +339 -0
- package/src/providers/free-directory.ts +184 -0
- package/src/providers/github-copilot-transport.ts +56 -0
- package/src/providers/key-failover.ts +203 -0
- package/src/providers/kiro-models.ts +67 -0
- package/src/providers/label.ts +19 -0
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/openai-sidecar.ts +175 -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 +82 -0
- package/src/providers/openrouter-routing.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +150 -0
- package/src/providers/quota.ts +1260 -0
- package/src/providers/registry.ts +1600 -0
- package/src/providers/slug-codec.ts +67 -0
- package/src/providers/xai-transport.ts +141 -0
- package/src/reasoning-effort.ts +135 -0
- package/src/responses/compaction.ts +117 -0
- package/src/responses/parser.ts +656 -0
- package/src/responses/reasoning-envelope.ts +52 -0
- package/src/responses/schema.ts +159 -0
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +895 -0
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +425 -0
- package/src/server/adapter-resolve.ts +80 -0
- package/src/server/auth-cors.ts +530 -0
- package/src/server/chat-completions.ts +368 -0
- package/src/server/claude-messages.ts +914 -0
- package/src/server/effort-policy.ts +172 -0
- package/src/server/gui-static.ts +123 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/images.ts +476 -0
- package/src/server/index.ts +1126 -0
- package/src/server/lifecycle.ts +227 -0
- package/src/server/live.ts +598 -0
- package/src/server/management/agent-settings-routes.ts +1169 -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/clients-routes.ts +63 -0
- package/src/server/management/cloud-sync-routes.ts +266 -0
- package/src/server/management/combo-routes.ts +220 -0
- package/src/server/management/config-routes.ts +422 -0
- package/src/server/management/context.ts +31 -0
- package/src/server/management/logs-usage-routes.ts +707 -0
- package/src/server/management/model-routes.ts +525 -0
- package/src/server/management/oauth-account-routes.ts +563 -0
- package/src/server/management/provider-routes.ts +556 -0
- package/src/server/management/shared.ts +277 -0
- package/src/server/management/sidebar-routes.ts +90 -0
- package/src/server/management/system-restart.ts +179 -0
- package/src/server/management/system-routes.ts +117 -0
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +215 -0
- package/src/server/management-auth.ts +267 -0
- package/src/server/memory-watchdog.ts +156 -0
- package/src/server/port-reclaim.ts +307 -0
- package/src/server/ports.ts +116 -0
- package/src/server/proxy-liveness.ts +201 -0
- package/src/server/relay-eager.ts +313 -0
- package/src/server/relay.ts +1049 -0
- package/src/server/request-decompress.ts +132 -0
- package/src/server/request-log-conversation.ts +168 -0
- package/src/server/request-log.ts +1046 -0
- package/src/server/responses/collaboration.ts +354 -0
- package/src/server/responses/compact.ts +384 -0
- package/src/server/responses/core.ts +2758 -0
- package/src/server/responses/encrypted-payload.ts +308 -0
- package/src/server/responses/fetch-helpers.ts +157 -0
- package/src/server/responses/passthrough-error.ts +78 -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 +224 -0
- package/src/server/responses.ts +9 -0
- package/src/server/search.ts +136 -0
- package/src/server/sse-payload-rewrite.ts +175 -0
- package/src/server/startup-action-control.ts +308 -0
- package/src/server/startup-health-cache.ts +113 -0
- package/src/server/system-env.ts +413 -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 +471 -0
- package/src/service.ts +2554 -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 +290 -0
- package/src/tray/windows.ts +730 -0
- package/src/types.ts +1237 -0
- package/src/update/badge.ts +72 -0
- package/src/update/index.ts +407 -0
- package/src/update/job.ts +1520 -0
- package/src/update/notify.ts +257 -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 +164 -0
- package/src/usage/log.ts +658 -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 +125 -0
- package/src/vision/index.ts +467 -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 +664 -0
- package/src/web-search/parse.ts +220 -0
- package/src/web-search/progress-stream.ts +342 -0
- package/src/web-search/synthetic-tool.ts +47 -0
|
@@ -0,0 +1,1005 @@
|
|
|
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 { mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
|
|
5
|
+
import { debugProviderDiagnostic } from "../lib/debug";
|
|
6
|
+
import { isDebugEnabled } from "../lib/debug-settings";
|
|
7
|
+
import { isCyberPolicyCode } from "../lib/errors";
|
|
8
|
+
import { redactSecretString } from "../lib/redact";
|
|
9
|
+
import { contentPartsToText } from "./image";
|
|
10
|
+
import { neutralizeIdentity } from "./identity";
|
|
11
|
+
import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
|
|
12
|
+
import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
|
|
13
|
+
import {
|
|
14
|
+
isTranslatorBudgetExceededError,
|
|
15
|
+
retainTranslatedEventBatch,
|
|
16
|
+
TRANSLATOR_MAX_SSE_EVENT_BYTES,
|
|
17
|
+
type TranslatorBudget,
|
|
18
|
+
} from "../lib/translator-budget";
|
|
19
|
+
|
|
20
|
+
// Providers may opt into stripping one trailing "[...]" group from the wire model id.
|
|
21
|
+
// Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211;
|
|
22
|
+
// unflagged OpenAI-compatible providers and the Anthropic adapter keep ids verbatim.
|
|
23
|
+
export function stripBracketedModelSuffix(modelId: string): string {
|
|
24
|
+
return modelId.replace(/\[[^\]]*\]\s*$/, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 260715 (issue #126): surface upstream error detail through the web-search sidecar loop.
|
|
28
|
+
// loop.ts only appends a suffix to "Provider error N" when the adapter exposes
|
|
29
|
+
// formatErrorBody; without it, strict OpenAI-compatible backends (NVIDIA NIM pydantic
|
|
30
|
+
// validation, "This model only supports single tool-calls at once!", etc.) were reduced
|
|
31
|
+
// to a bare status code. JSON-only extraction: recognized string fields are returned,
|
|
32
|
+
// HTML/non-JSON bodies yield "" so raw markup is never echoed to the client.
|
|
33
|
+
export function formatOpenAIChatErrorBody(status: number, _headers: Headers, payloadText: string): string {
|
|
34
|
+
let parsed: unknown;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(payloadText);
|
|
37
|
+
} catch {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
const detail = extractErrorDetail(parsed);
|
|
41
|
+
if (!detail) return "";
|
|
42
|
+
return redactSecretString(detail).slice(0, 400);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function extractErrorDetail(parsed: unknown): string | undefined {
|
|
46
|
+
if (typeof parsed === "string") return parsed.trim() || undefined;
|
|
47
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
48
|
+
const obj = parsed as Record<string, unknown>;
|
|
49
|
+
// OpenAI shape: { error: { message } } or { error: "..." }
|
|
50
|
+
const err = obj.error;
|
|
51
|
+
if (typeof err === "string" && err.trim()) return err.trim();
|
|
52
|
+
if (err !== null && typeof err === "object" && !Array.isArray(err)) {
|
|
53
|
+
const msg = (err as Record<string, unknown>).message;
|
|
54
|
+
if (typeof msg === "string" && msg.trim()) return msg.trim();
|
|
55
|
+
}
|
|
56
|
+
// FastAPI/pydantic shape (NVIDIA NIM): { detail: "..." } or { detail: [{ msg, loc }, ...] }
|
|
57
|
+
const det = obj.detail;
|
|
58
|
+
if (typeof det === "string" && det.trim()) return det.trim();
|
|
59
|
+
if (Array.isArray(det)) {
|
|
60
|
+
const msgs = det
|
|
61
|
+
.map(item => (item !== null && typeof item === "object" && typeof (item as Record<string, unknown>).msg === "string"
|
|
62
|
+
? ((item as Record<string, unknown>).msg as string).trim()
|
|
63
|
+
: ""))
|
|
64
|
+
.filter(m => m.length > 0);
|
|
65
|
+
if (msgs.length > 0) return msgs.join("; ");
|
|
66
|
+
}
|
|
67
|
+
// Generic fallbacks: { message } / RFC7807 { title }
|
|
68
|
+
if (typeof obj.message === "string" && obj.message.trim()) return obj.message.trim();
|
|
69
|
+
if (typeof obj.title === "string" && obj.title.trim()) return obj.title.trim();
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function developerSystemText(message: OcxMessage): string | undefined {
|
|
74
|
+
if (message.role !== "developer") return undefined;
|
|
75
|
+
if (typeof message.content === "string") return message.content;
|
|
76
|
+
if (message.content.some(part => part.type === "image")) return undefined;
|
|
77
|
+
return message.content.map(part => (part as OcxTextContent).text).join("");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
|
|
81
|
+
const out: unknown[] = [];
|
|
82
|
+
const { context, options } = parsed;
|
|
83
|
+
|
|
84
|
+
// 260718 dangling tool_calls hardening (devlog/_plan/260718_dangling_toolcall_hardening):
|
|
85
|
+
// strict chat providers (Kimi/Moonshot) 400 when an assistant tool_call is not answered
|
|
86
|
+
// immediately by role:"tool" messages. Repair order: (1) reattach a real result to its
|
|
87
|
+
// original call (barrier messages are DEFERRED until the open tool round closes),
|
|
88
|
+
// (2) synthesize an explicit unavailable-result only when no real result exists,
|
|
89
|
+
// (3) manufacture an orphan assistant call only when no call occurrence matches at all.
|
|
90
|
+
// Occurrences are kept as an ordered list (never a Map) so duplicated ids survive.
|
|
91
|
+
interface PendingToolCall { id: string; name: string }
|
|
92
|
+
let pendingToolCalls: PendingToolCall[] = [];
|
|
93
|
+
let deferredBarrierMessages: unknown[] = [];
|
|
94
|
+
let mintedIdSeq = 0;
|
|
95
|
+
const seenWireCallIds = new Set<string>();
|
|
96
|
+
|
|
97
|
+
const mintCallId = (): string => {
|
|
98
|
+
let id = "";
|
|
99
|
+
do {
|
|
100
|
+
id = `call_ocx_minted_${++mintedIdSeq}`;
|
|
101
|
+
} while (seenWireCallIds.has(id));
|
|
102
|
+
seenWireCallIds.add(id);
|
|
103
|
+
return id;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const releaseDeferredBarriers = (): void => {
|
|
107
|
+
if (deferredBarrierMessages.length === 0) return;
|
|
108
|
+
out.push(...deferredBarrierMessages);
|
|
109
|
+
deferredBarrierMessages = [];
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Close an unresolved tool round with explicit unavailable-result messages. The wording
|
|
113
|
+
// must not claim interruption, success, failure, or user intent: execution status is
|
|
114
|
+
// UNKNOWN, and for user-input tools this must not read as an answer.
|
|
115
|
+
const flushPendingToolCalls = (): void => {
|
|
116
|
+
if (pendingToolCalls.length === 0) return;
|
|
117
|
+
for (const call of pendingToolCalls) {
|
|
118
|
+
out.push({
|
|
119
|
+
role: "tool",
|
|
120
|
+
tool_call_id: call.id,
|
|
121
|
+
content: `[ocx] no tool result was recorded for "${call.name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
pendingToolCalls = [];
|
|
125
|
+
releaseDeferredBarriers();
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider)
|
|
129
|
+
? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice)
|
|
130
|
+
: undefined;
|
|
131
|
+
// Chat templates used by LM Studio, llama.cpp, and other strict OpenAI-compatible
|
|
132
|
+
// backends require every system instruction to precede conversation history. Codex can
|
|
133
|
+
// append developer reminders after user turns, so fold text-only developer messages into
|
|
134
|
+
// the single leading system message instead of emitting role:"system" in place. Developer
|
|
135
|
+
// messages with images cannot be represented as system content and remain user-compatible
|
|
136
|
+
// vision messages at their original position below.
|
|
137
|
+
const developerSystemParts = context.messages
|
|
138
|
+
.map(developerSystemText)
|
|
139
|
+
.filter((part): part is string => part !== undefined && part.length > 0);
|
|
140
|
+
const systemParts = [
|
|
141
|
+
...(context.systemPrompt ?? []),
|
|
142
|
+
...developerSystemParts,
|
|
143
|
+
...(toolCatalogNudge ? [toolCatalogNudge] : []),
|
|
144
|
+
];
|
|
145
|
+
if (systemParts.length > 0) {
|
|
146
|
+
// Codex sends its GPT-5 identity prompt for EVERY model (the per-model catalog
|
|
147
|
+
// base_instructions is ignored at request time). Neutralize that one identity line
|
|
148
|
+
// so routed, non-OpenAI models don't misreport themselves as GPT-5 / OpenAI — without
|
|
149
|
+
// leaking the proxy identity into the payload.
|
|
150
|
+
const sys = neutralizeIdentity(systemParts.join("\n\n"));
|
|
151
|
+
out.push({ role: "system", content: sys });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const msg of context.messages) {
|
|
155
|
+
switch (msg.role) {
|
|
156
|
+
case "user":
|
|
157
|
+
case "developer": {
|
|
158
|
+
const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[];
|
|
159
|
+
const hasImages = parts?.some(p => p.type === "image") ?? false;
|
|
160
|
+
if (msg.role === "developer" && !hasImages) break;
|
|
161
|
+
let chatMsg: Record<string, unknown>;
|
|
162
|
+
if (typeof msg.content === "string") {
|
|
163
|
+
chatMsg = { role: "user", content: msg.content };
|
|
164
|
+
} else {
|
|
165
|
+
if (!hasImages) {
|
|
166
|
+
chatMsg = { role: "user", content: parts!.map(p => (p as OcxTextContent).text).join("") };
|
|
167
|
+
} else {
|
|
168
|
+
// Vision: chat-completions content-parts array. Images are only valid on the user role,
|
|
169
|
+
// and the data URL goes straight into image_url.url (never the token-exploding text path).
|
|
170
|
+
const chatParts = parts!.map(p => p.type === "image"
|
|
171
|
+
? { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }
|
|
172
|
+
: { type: "text", text: (p as OcxTextContent).text });
|
|
173
|
+
chatMsg = { role: "user", content: chatParts };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// A barrier must not split an open tool round: defer it until the round closes
|
|
177
|
+
// (real result arrives) or the round is synthesized shut.
|
|
178
|
+
if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg);
|
|
179
|
+
else out.push(chatMsg);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case "assistant": {
|
|
183
|
+
const aMsg = msg as OcxAssistantMessage;
|
|
184
|
+
const textParts = aMsg.content.filter(p => p.type === "text") as OcxTextContent[];
|
|
185
|
+
const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[];
|
|
186
|
+
const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[];
|
|
187
|
+
const chatMsg: Record<string, unknown> = { role: "assistant" };
|
|
188
|
+
if (textParts.length > 0) {
|
|
189
|
+
chatMsg.content = textParts.map(p => p.text).join("");
|
|
190
|
+
}
|
|
191
|
+
const reasoningContent = thinkingParts.map(p => p.thinking).join("");
|
|
192
|
+
if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) {
|
|
193
|
+
chatMsg.reasoning_content = reasoningContent;
|
|
194
|
+
}
|
|
195
|
+
// Skip empty assistant messages: chat APIs like DeepSeek reject an assistant message
|
|
196
|
+
// with neither content, tool calls, nor a provider-supported reasoning_content field.
|
|
197
|
+
if (chatMsg.content === undefined && toolCalls.length === 0 && chatMsg.reasoning_content === undefined) break;
|
|
198
|
+
// A new assistant starts while a previous round is still open: close the previous
|
|
199
|
+
// round synthetically first so its tool_calls are never left dangling.
|
|
200
|
+
flushPendingToolCalls();
|
|
201
|
+
const wireToolCalls = toolCalls.map(tc => {
|
|
202
|
+
let id = tc.id;
|
|
203
|
+
if (!id) id = mintCallId();
|
|
204
|
+
else seenWireCallIds.add(id);
|
|
205
|
+
return { tc, id };
|
|
206
|
+
});
|
|
207
|
+
if (wireToolCalls.length > 0) {
|
|
208
|
+
chatMsg.tool_calls = wireToolCalls.map(({ tc, id }) => ({
|
|
209
|
+
id,
|
|
210
|
+
type: "function",
|
|
211
|
+
function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) },
|
|
212
|
+
}));
|
|
213
|
+
// "" instead of null: strict validators (xAI: "Each message must have at least one
|
|
214
|
+
// content element", langchain#34140) reject content-less assistant history entries.
|
|
215
|
+
if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider);
|
|
216
|
+
}
|
|
217
|
+
if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
|
|
218
|
+
chatMsg.content = emptyAssistantContent(provider);
|
|
219
|
+
}
|
|
220
|
+
out.push(chatMsg);
|
|
221
|
+
pendingToolCalls = wireToolCalls.map(({ tc, id }) => ({ id, name: namespacedToolName(tc.namespace, tc.name) }));
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case "toolResult": {
|
|
225
|
+
let toolCallId = msg.toolCallId;
|
|
226
|
+
const matchIdx = toolCallId ? pendingToolCalls.findIndex(c => c.id === toolCallId) : -1;
|
|
227
|
+
if (matchIdx >= 0 && toolCallId) {
|
|
228
|
+
// Real result reattached to its original call. Barriers were deferred, so the
|
|
229
|
+
// tool message lands immediately inside the open round.
|
|
230
|
+
out.push({
|
|
231
|
+
role: "tool",
|
|
232
|
+
tool_call_id: toolCallId,
|
|
233
|
+
content: contentPartsToText(msg.content),
|
|
234
|
+
});
|
|
235
|
+
pendingToolCalls.splice(matchIdx, 1);
|
|
236
|
+
if (pendingToolCalls.length === 0) releaseDeferredBarriers();
|
|
237
|
+
} else {
|
|
238
|
+
if (!toolCallId) toolCallId = `call_orphan_${out.length}`;
|
|
239
|
+
// No matching call in the open round. Close any unresolved round first so the
|
|
240
|
+
// synthesized orphan pair never splits it, then keep the historical repair:
|
|
241
|
+
// WS turns can arrive with only tool outputs; chat-completions providers reject a bare
|
|
242
|
+
// role:"tool" message unless an assistant tool_call with the same id immediately precedes it.
|
|
243
|
+
flushPendingToolCalls();
|
|
244
|
+
const name = safeToolName(msg.toolName);
|
|
245
|
+
out.push({
|
|
246
|
+
role: "assistant",
|
|
247
|
+
content: emptyAssistantContent(provider),
|
|
248
|
+
tool_calls: [{
|
|
249
|
+
id: toolCallId,
|
|
250
|
+
type: "function",
|
|
251
|
+
function: { name, arguments: "{}" },
|
|
252
|
+
}],
|
|
253
|
+
});
|
|
254
|
+
seenWireCallIds.add(toolCallId);
|
|
255
|
+
out.push({
|
|
256
|
+
role: "tool",
|
|
257
|
+
tool_call_id: toolCallId,
|
|
258
|
+
content: contentPartsToText(msg.content),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Trailing dangle: a turn interrupted after the assistant requested tools leaves the
|
|
267
|
+
// round open; close it synthetically (then release any deferred barriers in order).
|
|
268
|
+
flushPendingToolCalls();
|
|
269
|
+
releaseDeferredBarriers();
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function safeToolName(name: string | undefined): string {
|
|
274
|
+
const raw = name && name.trim().length > 0 ? name : "tool_result";
|
|
275
|
+
const sanitized = raw.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
276
|
+
return sanitized;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const ZEN_SCHEMA_MAP_KEYS = new Set(["properties", "$defs", "definitions"]);
|
|
280
|
+
const ZEN_DROPPED_SCHEMA_KEYS = new Set(["encrypted"]);
|
|
281
|
+
|
|
282
|
+
function sanitizeZenSchemaMap(value: unknown): unknown {
|
|
283
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return sanitizeZenToolParameters(value);
|
|
284
|
+
const out: Record<string, unknown> = {};
|
|
285
|
+
for (const [name, child] of Object.entries(value as Record<string, unknown>)) {
|
|
286
|
+
out[name] = sanitizeZenToolParameters(child);
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function sanitizeZenToolParameters(value: unknown): unknown {
|
|
292
|
+
if (Array.isArray(value)) return value.map(sanitizeZenToolParameters);
|
|
293
|
+
if (!value || typeof value !== "object") return value;
|
|
294
|
+
const input = value as Record<string, unknown>;
|
|
295
|
+
const out: Record<string, unknown> = {};
|
|
296
|
+
for (const [key, child] of Object.entries(input)) {
|
|
297
|
+
if (ZEN_DROPPED_SCHEMA_KEYS.has(key)) continue;
|
|
298
|
+
if (key === "required" && Array.isArray(child) && child.length === 0) continue;
|
|
299
|
+
if (key === "type" && Array.isArray(child)) {
|
|
300
|
+
const nonNull = child.filter(entry => entry !== "null");
|
|
301
|
+
if (child.includes("null")) out.nullable = true;
|
|
302
|
+
if (nonNull.length > 0) out.type = nonNull[0];
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
out[key] = ZEN_SCHEMA_MAP_KEYS.has(key) ? sanitizeZenSchemaMap(child) : sanitizeZenToolParameters(child);
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function ensureZenRootObjectSchema(schema: unknown): Record<string, unknown> {
|
|
311
|
+
const obj = schema && typeof schema === "object" && !Array.isArray(schema)
|
|
312
|
+
? schema as Record<string, unknown>
|
|
313
|
+
: {};
|
|
314
|
+
const compositionKeys = ["oneOf", "anyOf", "allOf"] as const;
|
|
315
|
+
const hasComposition = compositionKeys.some(key => Array.isArray(obj[key]));
|
|
316
|
+
const rootType = obj.type;
|
|
317
|
+
const rootObjectType = rootType === "object" || (Array.isArray(rootType) && rootType.includes("object"));
|
|
318
|
+
if (!hasComposition) {
|
|
319
|
+
const base = sanitizeZenToolParameters(obj) as Record<string, unknown>;
|
|
320
|
+
return rootObjectType && base.type === "object" ? base : { ...base, type: "object" };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const props: Record<string, unknown> = {};
|
|
324
|
+
const required = new Set<string>();
|
|
325
|
+
if (obj.properties && typeof obj.properties === "object") {
|
|
326
|
+
Object.assign(props, sanitizeZenSchemaMap(obj.properties) as Record<string, unknown>);
|
|
327
|
+
}
|
|
328
|
+
if (Array.isArray(obj.required)) {
|
|
329
|
+
for (const entry of obj.required) if (typeof entry === "string") required.add(entry);
|
|
330
|
+
}
|
|
331
|
+
for (const key of compositionKeys) {
|
|
332
|
+
const variants = obj[key];
|
|
333
|
+
if (!Array.isArray(variants)) continue;
|
|
334
|
+
const mergeRequired = key === "allOf";
|
|
335
|
+
for (const variant of variants) {
|
|
336
|
+
if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue;
|
|
337
|
+
const rec = variant as Record<string, unknown>;
|
|
338
|
+
if (rec.properties && typeof rec.properties === "object") {
|
|
339
|
+
Object.assign(props, sanitizeZenSchemaMap(rec.properties) as Record<string, unknown>);
|
|
340
|
+
}
|
|
341
|
+
if (mergeRequired && Array.isArray(rec.required)) {
|
|
342
|
+
for (const entry of rec.required) if (typeof entry === "string") required.add(entry);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const merged = sanitizeZenToolParameters(obj) as Record<string, unknown>;
|
|
348
|
+
delete merged.oneOf;
|
|
349
|
+
delete merged.anyOf;
|
|
350
|
+
delete merged.allOf;
|
|
351
|
+
merged.type = "object";
|
|
352
|
+
if (Object.keys(props).length > 0) merged.properties = props;
|
|
353
|
+
if (required.size > 0) merged.required = [...required];
|
|
354
|
+
return merged;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function shouldSanitizeZenToolParameters(provider: OcxProviderConfig): boolean {
|
|
358
|
+
const baseUrl = provider.baseUrl.replace(/\/+$/, "");
|
|
359
|
+
return baseUrl === "https://opencode.ai/zen/v1"
|
|
360
|
+
|| baseUrl === "https://opencode.ai/zen/go/v1";
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const XAI_SCHEMA_BASE_URLS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]);
|
|
364
|
+
|
|
365
|
+
function isXaiSchemaTarget(provider: OcxProviderConfig): boolean {
|
|
366
|
+
try {
|
|
367
|
+
return XAI_SCHEMA_BASE_URLS.has(new URL(provider.baseUrl).hostname);
|
|
368
|
+
} catch {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isKimiSchemaTarget(provider: OcxProviderConfig): boolean {
|
|
374
|
+
try {
|
|
375
|
+
return new URL(provider.baseUrl).hostname === "api.kimi.com";
|
|
376
|
+
} catch {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Volcengine Ark regional endpoints. Ark validates an assistant message's text field as a
|
|
382
|
+
// REQUIRED parameter and treats "" as absent, so a tool-call-only assistant in history 400s with
|
|
383
|
+
// `MissingParameter: input.content.text` (#796). Every other OpenAI-compatible provider accepts
|
|
384
|
+
// "", and xAI actively requires it ("Each message must have at least one content element"), so
|
|
385
|
+
// the two contracts are in direct conflict and this cannot be a global change.
|
|
386
|
+
const VOLCENGINE_ARK_HOSTNAMES = new Set([
|
|
387
|
+
"ark.cn-beijing.volces.com",
|
|
388
|
+
"ark.ap-southeast.volces.com",
|
|
389
|
+
]);
|
|
390
|
+
|
|
391
|
+
function isVolcengineArkTarget(provider: OcxProviderConfig): boolean {
|
|
392
|
+
try {
|
|
393
|
+
return VOLCENGINE_ARK_HOSTNAMES.has(new URL(provider.baseUrl).hostname);
|
|
394
|
+
} catch {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Placeholder content for an assistant history entry carrying only tool calls or reasoning.
|
|
401
|
+
*
|
|
402
|
+
* UNVERIFIED HYPOTHESIS for Ark. The reported error names `input.content.text`, a nested path,
|
|
403
|
+
* which suggests Ark wants the structured content form `[{type:"text",text:""}]` rather than a
|
|
404
|
+
* bare string — no string value, `""` or `" "`, exposes a `content.text` path at all. But Ark's
|
|
405
|
+
* published examples only show array content for MULTIMODAL USER input, never for an assistant
|
|
406
|
+
* history entry, so this shape is inferred from the error message and not confirmed by the docs
|
|
407
|
+
* or by a live request. The empty inner text at least adds no tokens either way.
|
|
408
|
+
*
|
|
409
|
+
* Confirm against a real Ark endpoint before relying on this; #796 records what is still missing.
|
|
410
|
+
*
|
|
411
|
+
* Every other provider keeps the bare `""`, which xAI's validator specifically requires ("Each
|
|
412
|
+
* message must have at least one content element"), so this cannot be applied globally.
|
|
413
|
+
*/
|
|
414
|
+
function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
|
|
415
|
+
return isVolcengineArkTarget(provider) ? [{ type: "text", text: "" }] : "";
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Kimi requires function.parameters.type to be exactly "object" at the root.
|
|
420
|
+
* Codex tools with oneOf/anyOf schemas omit the root type, causing 400 errors.
|
|
421
|
+
* Add type: "object" at the root while preserving oneOf, $defs, and other schema keys.
|
|
422
|
+
*/
|
|
423
|
+
function ensureKimiRootObjectType(parameters: unknown): Record<string, unknown> {
|
|
424
|
+
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
|
|
425
|
+
return { type: "object", properties: {} };
|
|
426
|
+
}
|
|
427
|
+
const obj = parameters as Record<string, unknown>;
|
|
428
|
+
if (obj.type === "object") return obj;
|
|
429
|
+
return { ...obj, type: "object" };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function expandXaiRootObjectSchemas(schema: unknown): Record<string, unknown>[] | undefined {
|
|
433
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
|
|
434
|
+
const obj = schema as Record<string, unknown>;
|
|
435
|
+
const compositionKey = ["oneOf", "anyOf"].find(key => Array.isArray(obj[key]));
|
|
436
|
+
if (!compositionKey) {
|
|
437
|
+
if (obj.type !== undefined && obj.type !== "object") return undefined;
|
|
438
|
+
return [{ ...obj, type: "object" }];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const siblings = Object.fromEntries(Object.entries(obj).filter(([key]) => key !== compositionKey));
|
|
442
|
+
const branches = obj[compositionKey];
|
|
443
|
+
if (!Array.isArray(branches)) return undefined;
|
|
444
|
+
const expanded: Record<string, unknown>[] = [];
|
|
445
|
+
for (const branch of branches) {
|
|
446
|
+
const variants = expandXaiRootObjectSchemas(branch);
|
|
447
|
+
if (!variants) return undefined;
|
|
448
|
+
for (const variant of variants) expanded.push({ ...siblings, ...variant });
|
|
449
|
+
}
|
|
450
|
+
return expanded.length > 0 ? expanded : undefined;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown> | undefined {
|
|
454
|
+
const variants = expandXaiRootObjectSchemas(parameters);
|
|
455
|
+
if (!variants) return undefined;
|
|
456
|
+
if (variants.length === 1) return variants[0];
|
|
457
|
+
const root = parameters && typeof parameters === "object" && !Array.isArray(parameters)
|
|
458
|
+
? parameters as Record<string, unknown>
|
|
459
|
+
: {};
|
|
460
|
+
const metadata = Object.fromEntries(Object.entries(root).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
|
|
461
|
+
return { ...metadata, oneOf: variants };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
|
|
465
|
+
if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
|
|
466
|
+
const allowed = isAllowedToolChoice(parsed.options.toolChoice)
|
|
467
|
+
? new Set(parsed.options.toolChoice.allowedTools)
|
|
468
|
+
: undefined;
|
|
469
|
+
const tools = allowed
|
|
470
|
+
? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
|
|
471
|
+
: parsed.context.tools;
|
|
472
|
+
if (tools.length === 0) return undefined;
|
|
473
|
+
const xaiTarget = isXaiSchemaTarget(provider);
|
|
474
|
+
const kimiTarget = isKimiSchemaTarget(provider);
|
|
475
|
+
const formatted = tools.flatMap(t => {
|
|
476
|
+
const parameters = xaiTarget
|
|
477
|
+
? normalizeXaiToolParameters(t.parameters)
|
|
478
|
+
: kimiTarget
|
|
479
|
+
? ensureKimiRootObjectType(t.parameters)
|
|
480
|
+
: t.parameters;
|
|
481
|
+
if (parameters === undefined) return [];
|
|
482
|
+
return [{
|
|
483
|
+
type: "function",
|
|
484
|
+
function: {
|
|
485
|
+
name: namespacedToolName(t.namespace, t.name),
|
|
486
|
+
description: t.description,
|
|
487
|
+
parameters,
|
|
488
|
+
...(t.strict !== undefined ? { strict: t.strict } : {}),
|
|
489
|
+
},
|
|
490
|
+
}];
|
|
491
|
+
});
|
|
492
|
+
return formatted.length > 0 ? formatted : undefined;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
|
|
496
|
+
const base = toolsToChatFormat(parsed, provider);
|
|
497
|
+
if (!base || !shouldSanitizeZenToolParameters(provider)) return base;
|
|
498
|
+
return base.map(tool => {
|
|
499
|
+
if (!tool || typeof tool !== "object") return tool;
|
|
500
|
+
const functionDef = (tool as { function?: Record<string, unknown> }).function;
|
|
501
|
+
if (!functionDef || typeof functionDef !== "object") return tool;
|
|
502
|
+
return {
|
|
503
|
+
...tool,
|
|
504
|
+
function: {
|
|
505
|
+
...functionDef,
|
|
506
|
+
parameters: ensureZenRootObjectSchema(functionDef.parameters ?? {}),
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function toolChoiceToChatFormat(tc: OcxParsedRequest["options"]["toolChoice"], tools: OcxParsedRequest["context"]["tools"]): unknown {
|
|
513
|
+
if (!tc) return undefined;
|
|
514
|
+
if (isAllowedToolChoice(tc)) return tc.mode === "required" ? "required" : "auto";
|
|
515
|
+
if (tc === "auto" || tc === "none" || tc === "required") return tc;
|
|
516
|
+
if ("name" in tc) return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } };
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function usageFromOpenAIChat(usage: Record<string, unknown> | undefined): OcxUsage | undefined {
|
|
521
|
+
if (!usage) return undefined;
|
|
522
|
+
const promptDetails = usage.prompt_tokens_details as Record<string, number> | undefined;
|
|
523
|
+
const completionDetails = usage.completion_tokens_details as Record<string, number> | undefined;
|
|
524
|
+
return {
|
|
525
|
+
inputTokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0,
|
|
526
|
+
outputTokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0,
|
|
527
|
+
...(promptDetails?.cached_tokens !== undefined ? { cachedInputTokens: promptDetails.cached_tokens } : {}),
|
|
528
|
+
...(completionDetails?.reasoning_tokens !== undefined ? { reasoningOutputTokens: completionDetails.reasoning_tokens } : {}),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function resolveMaxTokens(provider: OcxProviderConfig, parsed: OcxParsedRequest): number | undefined {
|
|
533
|
+
return parsed.options.maxOutputTokens
|
|
534
|
+
?? modelRecordValue(provider.modelMaxOutputTokens, parsed.modelId)
|
|
535
|
+
?? provider.defaultMaxOutputTokens;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function thinkingBudgetForEffort(parsed: OcxParsedRequest, reasoningEffort: string, maxOutputTokens?: number): number | undefined {
|
|
539
|
+
if (parsed.options.reasoning === "minimal") return 0;
|
|
540
|
+
const maxBudget = maxOutputTokens ?? 32768;
|
|
541
|
+
const fractions: Record<string, number> = {
|
|
542
|
+
low: 0.20,
|
|
543
|
+
medium: 0.50,
|
|
544
|
+
high: 0.75,
|
|
545
|
+
xhigh: 0.90,
|
|
546
|
+
max: 1.0,
|
|
547
|
+
};
|
|
548
|
+
const fraction = fractions[reasoningEffort];
|
|
549
|
+
return fraction === undefined ? undefined : Math.max(1, Math.floor(maxBudget * fraction));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
|
|
553
|
+
return {
|
|
554
|
+
name: "openai-chat",
|
|
555
|
+
|
|
556
|
+
formatErrorBody: formatOpenAIChatErrorBody,
|
|
557
|
+
|
|
558
|
+
buildRequest(parsed: OcxParsedRequest) {
|
|
559
|
+
const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
|
|
560
|
+
if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
|
|
561
|
+
throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const messages = messagesToChatFormat(parsed, provider);
|
|
565
|
+
const tools = toolsToChatFormatForProvider(parsed, provider);
|
|
566
|
+
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools);
|
|
567
|
+
|
|
568
|
+
const body: Record<string, unknown> = {
|
|
569
|
+
model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId,
|
|
570
|
+
messages,
|
|
571
|
+
stream: parsed.stream,
|
|
572
|
+
};
|
|
573
|
+
if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true;
|
|
574
|
+
const maxTokens = resolveMaxTokens(provider, parsed);
|
|
575
|
+
const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId);
|
|
576
|
+
if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
|
|
577
|
+
if (tools) body.tools = tools;
|
|
578
|
+
if (tools && toolChoice !== undefined) {
|
|
579
|
+
body.tool_choice = modelInList(provider.autoToolChoiceOnlyModels, parsed.modelId)
|
|
580
|
+
? (toolChoice === "none" ? "none" : "auto")
|
|
581
|
+
: toolChoice;
|
|
582
|
+
}
|
|
583
|
+
if (maxTokens !== undefined) body.max_tokens = maxTokens;
|
|
584
|
+
if (parsed.options.temperature !== undefined && !modelInList(provider.noTemperatureModels, parsed.modelId)) {
|
|
585
|
+
body.temperature = parsed.options.temperature;
|
|
586
|
+
}
|
|
587
|
+
if (parsed.options.topP !== undefined && !modelInList(provider.noTopPModels, parsed.modelId)) {
|
|
588
|
+
body.top_p = parsed.options.topP;
|
|
589
|
+
}
|
|
590
|
+
if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences;
|
|
591
|
+
const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
|
|
592
|
+
let reasoningLog: AdapterRequest["reasoningLog"];
|
|
593
|
+
if (reasoningEffort !== undefined) {
|
|
594
|
+
if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) {
|
|
595
|
+
const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens);
|
|
596
|
+
if (budget !== undefined) {
|
|
597
|
+
body.thinking_budget = budget;
|
|
598
|
+
reasoningLog = {
|
|
599
|
+
effectiveEffort: parsed.options.reasoning === "minimal" ? "minimal" : reasoningEffort,
|
|
600
|
+
wireField: "thinking_budget",
|
|
601
|
+
wireValue: budget,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
} else if (modelInList(provider.thinkingToggleModels, parsed.modelId)) {
|
|
605
|
+
// Vendor thinking-toggle wire: the mapped value is sent as `thinking: {type}` because
|
|
606
|
+
// these models ignore/reject reasoning_effort. Most use enabled/disabled; MiniMax-M3
|
|
607
|
+
// uses adaptive/disabled.
|
|
608
|
+
if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") {
|
|
609
|
+
body.thinking = { type: reasoningEffort };
|
|
610
|
+
reasoningLog = {
|
|
611
|
+
effectiveEffort: reasoningEffort,
|
|
612
|
+
wireField: "thinking.type",
|
|
613
|
+
wireValue: reasoningEffort,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
} else {
|
|
617
|
+
body.reasoning_effort = reasoningEffort;
|
|
618
|
+
reasoningLog = {
|
|
619
|
+
effectiveEffort: reasoningEffort,
|
|
620
|
+
wireField: "reasoning_effort",
|
|
621
|
+
wireValue: reasoningEffort,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (parsed.options.presencePenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
|
|
626
|
+
body.presence_penalty = parsed.options.presencePenalty;
|
|
627
|
+
}
|
|
628
|
+
if (parsed.options.frequencyPenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
|
|
629
|
+
body.frequency_penalty = parsed.options.frequencyPenalty;
|
|
630
|
+
}
|
|
631
|
+
// prompt_cache_key is an OpenAI-specific chat extension; strict backends (Groq,
|
|
632
|
+
// Cerebras, etc.) reject unknown fields. Only forward when the provider opts in.
|
|
633
|
+
if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) {
|
|
634
|
+
body.prompt_cache_key = parsed.options.promptCacheKey;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (tools) {
|
|
638
|
+
// Default-ON for chat-completions providers (user decision 260709): the buffered
|
|
639
|
+
// parser assembles multi-call streams safely, so `parallelToolCalls: false` is the
|
|
640
|
+
// only per-provider opt-out; Codex's request bit can still force false per request.
|
|
641
|
+
// Rationale + provider evidence: devlog/_plan/260709_parallel_tool_calls.
|
|
642
|
+
body.parallel_tool_calls = provider.parallelToolCalls === false
|
|
643
|
+
? false
|
|
644
|
+
: parsed.options.parallelToolCalls !== false;
|
|
645
|
+
}
|
|
646
|
+
if (parsed.stream) {
|
|
647
|
+
body.stream_options = { include_usage: true };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const url = `${provider.baseUrl}/chat/completions`;
|
|
651
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
652
|
+
// Precedence preserved from pre-#128 behavior: apiKey Authorization first, then
|
|
653
|
+
// provider.headers may override (user/registry-configured headers win). Registry
|
|
654
|
+
// staticHeaders (e.g. opencode-free x-opencode-client) flow in via derive.ts and
|
|
655
|
+
// never carry Authorization, so keyless providers are unaffected.
|
|
656
|
+
if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`;
|
|
657
|
+
if (provider.headers) Object.assign(headers, provider.headers);
|
|
658
|
+
|
|
659
|
+
const bodyJson = JSON.stringify(body);
|
|
660
|
+
// Never log pathname/query — tenant-scoped hosts (e.g. Cloudflare
|
|
661
|
+
// /accounts/<account_id>/ai/v1) would otherwise leak account identifiers (#452).
|
|
662
|
+
if (isDebugEnabled()) {
|
|
663
|
+
let host = "upstream";
|
|
664
|
+
try { host = new URL(url).host; } catch { /* keep fallback */ }
|
|
665
|
+
debugProviderDiagnostic("openai-chat", "request", {
|
|
666
|
+
host,
|
|
667
|
+
model: body.model,
|
|
668
|
+
stream: parsed.stream,
|
|
669
|
+
messageCount: Array.isArray(messages) ? messages.length : 0,
|
|
670
|
+
toolCount: Array.isArray(tools) ? tools.length : 0,
|
|
671
|
+
hasCredential,
|
|
672
|
+
bodyBytes: new TextEncoder().encode(bodyJson).length,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return {
|
|
677
|
+
url,
|
|
678
|
+
method: "POST",
|
|
679
|
+
headers,
|
|
680
|
+
body: bodyJson,
|
|
681
|
+
...(reasoningLog ? { reasoningLog } : {}),
|
|
682
|
+
};
|
|
683
|
+
},
|
|
684
|
+
|
|
685
|
+
async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
|
|
686
|
+
if (!response.body) {
|
|
687
|
+
yield { type: "error", message: "No response body" };
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const reader = response.body.getReader();
|
|
692
|
+
const decoder = new TextDecoder();
|
|
693
|
+
const budgetEncoder = new TextEncoder();
|
|
694
|
+
let buffer = "";
|
|
695
|
+
let bufferBytes = 0;
|
|
696
|
+
// Streamed tool calls are BUFFERED until a terminal signal, then flushed as atomic
|
|
697
|
+
// start/delta/end sequences. The bridge treats text/reasoning deltas as barriers that
|
|
698
|
+
// close an open tool-call item (bridge.ts closeCurrentToolCall on text_delta), so
|
|
699
|
+
// emitting calls incrementally would orphan later argument deltas whenever a provider
|
|
700
|
+
// interleaves content — and parallel tool calls (multiple ids, index-keyed continuation
|
|
701
|
+
// chunks, whole-chunk calls) cannot be represented live without overlapping sequences.
|
|
702
|
+
// Keyed by `index` (OpenAI wire standard), falling back to `id`, falling back to the
|
|
703
|
+
// last-seen call for providers that omit both on continuation chunks.
|
|
704
|
+
interface PendingToolCall { key: string; id: string; name: string; args: string; argsBytes: number }
|
|
705
|
+
const pendingToolCalls: PendingToolCall[] = [];
|
|
706
|
+
let toolCallSeq = 0;
|
|
707
|
+
const flushToolCalls = function* (): Generator<AdapterEvent> {
|
|
708
|
+
// Do not treat flushed tool calls as user-facing output for the finish-less EOF
|
|
709
|
+
// fallback — incomplete tool args must stay on the truncation path.
|
|
710
|
+
for (const call of pendingToolCalls) {
|
|
711
|
+
if (!call.id) call.id = `call_${++toolCallSeq}`;
|
|
712
|
+
yield { type: "tool_call_start", id: call.id, name: call.name };
|
|
713
|
+
if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args };
|
|
714
|
+
yield { type: "tool_call_end" };
|
|
715
|
+
budget.closeCall(call.key);
|
|
716
|
+
}
|
|
717
|
+
pendingToolCalls.length = 0;
|
|
718
|
+
};
|
|
719
|
+
let pendingUsage: OcxUsage | undefined;
|
|
720
|
+
// Track terminal signals so a socket EOF without any terminator can fail closed instead of
|
|
721
|
+
// being reported as a clean completion (silent truncation). A graceful close is either an
|
|
722
|
+
// explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
|
|
723
|
+
// OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
|
|
724
|
+
let finishReason: string | undefined;
|
|
725
|
+
// Only answer text enables the finish-less EOF fallback. Reasoning-only streams can be
|
|
726
|
+
// suppressed by hideThinkingSummary and must not complete as empty successful turns.
|
|
727
|
+
let sawUserFacingOutput = false;
|
|
728
|
+
|
|
729
|
+
// Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
|
|
730
|
+
// a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
|
|
731
|
+
// Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that
|
|
732
|
+
// must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state.
|
|
733
|
+
const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
|
|
734
|
+
if (!line.startsWith("data: ")) return "continue";
|
|
735
|
+
const payload = line.slice(6).trim();
|
|
736
|
+
if (payload === "[DONE]") {
|
|
737
|
+
yield* flushToolCalls();
|
|
738
|
+
const stopReason = finishReason === "length"
|
|
739
|
+
? "max_tokens"
|
|
740
|
+
: finishReason === "content_filter"
|
|
741
|
+
? "content_filter"
|
|
742
|
+
: undefined;
|
|
743
|
+
yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
|
|
744
|
+
return "terminate";
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
let chunk: Record<string, unknown>;
|
|
748
|
+
try {
|
|
749
|
+
chunk = JSON.parse(payload) as Record<string, unknown>;
|
|
750
|
+
} catch {
|
|
751
|
+
yield { type: "error", message: "malformed upstream SSE data frame" };
|
|
752
|
+
return "terminate";
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// A 200/OK chat-completions stream may carry an inline provider error envelope
|
|
756
|
+
// instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
|
|
757
|
+
// classified response.failed (bridge case "error") — never a truncated completion.
|
|
758
|
+
if (chunk.error) {
|
|
759
|
+
const err = chunk.error as { message?: string; code?: string; type?: string; status?: number } | undefined;
|
|
760
|
+
const message = err?.message ?? "upstream error";
|
|
761
|
+
debugProviderDiagnostic("openai-chat", "stream-error", { message });
|
|
762
|
+
yield* flushToolCalls();
|
|
763
|
+
yield {
|
|
764
|
+
type: "error",
|
|
765
|
+
message,
|
|
766
|
+
...(typeof err?.code === "string" ? { code: err.code } : {}),
|
|
767
|
+
...(typeof err?.type === "string" ? { errorType: err.type } : {}),
|
|
768
|
+
...(isCyberPolicyCode(err?.code)
|
|
769
|
+
? { status: 400 }
|
|
770
|
+
: typeof err?.status === "number" && Number.isInteger(err.status)
|
|
771
|
+
? { status: err.status }
|
|
772
|
+
: {}),
|
|
773
|
+
};
|
|
774
|
+
return "terminate";
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (chunk.usage) {
|
|
778
|
+
// Record usage but keep parsing: some providers send usage and the final content
|
|
779
|
+
// delta in the SAME chunk; a bail here would drop that content. The choices
|
|
780
|
+
// guard below no-ops a usage-only chunk.
|
|
781
|
+
pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const choices = chunk.choices as { delta?: Record<string, unknown>; finish_reason?: string }[] | undefined;
|
|
785
|
+
if (!choices || choices.length === 0) return "continue";
|
|
786
|
+
// Observe the terminator BEFORE the delta guard: a finish-only chunk (finish_reason set,
|
|
787
|
+
// no delta) is a graceful close and must record finishReason even though we skip it below.
|
|
788
|
+
if (typeof choices[0].finish_reason === "string" && choices[0].finish_reason) {
|
|
789
|
+
finishReason = choices[0].finish_reason;
|
|
790
|
+
}
|
|
791
|
+
const delta = choices[0].delta;
|
|
792
|
+
if (delta) {
|
|
793
|
+
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
|
|
794
|
+
yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
|
|
795
|
+
}
|
|
796
|
+
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
797
|
+
sawUserFacingOutput = true;
|
|
798
|
+
yield { type: "text_delta", text: delta.content };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const toolCalls = delta.tool_calls as { index?: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
|
|
802
|
+
if (toolCalls) {
|
|
803
|
+
for (const tc of toolCalls) {
|
|
804
|
+
const key = typeof tc.index === "number"
|
|
805
|
+
? `i:${tc.index}`
|
|
806
|
+
: tc.id
|
|
807
|
+
? `id:${tc.id}`
|
|
808
|
+
: pendingToolCalls[pendingToolCalls.length - 1]?.key;
|
|
809
|
+
let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined;
|
|
810
|
+
// Mixed keying rescue: a call opened under an index key must still absorb an
|
|
811
|
+
// id-only continuation for the same provider id (and vice versa) instead of
|
|
812
|
+
// splitting into two calls that share one call_id downstream.
|
|
813
|
+
if (!call && tc.id) call = pendingToolCalls.find(c => c.id === tc.id);
|
|
814
|
+
if (!call) {
|
|
815
|
+
call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "", argsBytes: 0 };
|
|
816
|
+
pendingToolCalls.push(call);
|
|
817
|
+
budget.openCall(call.key);
|
|
818
|
+
}
|
|
819
|
+
if (tc.id && !call.id) call.id = tc.id;
|
|
820
|
+
if (tc.function?.name && !call.name) call.name = tc.function.name;
|
|
821
|
+
if (tc.function?.arguments) {
|
|
822
|
+
const previousBytes = call.argsBytes;
|
|
823
|
+
const nextBytes = previousBytes + budgetEncoder.encode(tc.function.arguments).byteLength;
|
|
824
|
+
const scope = { kind: "tool_args" as const, callId: call.key };
|
|
825
|
+
const reservation = budget.reserveTransient(nextBytes, scope);
|
|
826
|
+
try {
|
|
827
|
+
call.args += tc.function.arguments;
|
|
828
|
+
reservation.commitRetained();
|
|
829
|
+
budget.releaseRetained(previousBytes, scope);
|
|
830
|
+
call.argsBytes = nextBytes;
|
|
831
|
+
} catch (error) {
|
|
832
|
+
reservation.release();
|
|
833
|
+
throw error;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Any non-empty finish_reason ends the generation: flush assembled tool calls as
|
|
841
|
+
// atomic sequences (covers "tool_calls" AND providers that close tool turns with "stop").
|
|
842
|
+
if (typeof choices[0].finish_reason === "string" && choices[0].finish_reason) {
|
|
843
|
+
yield* flushToolCalls();
|
|
844
|
+
}
|
|
845
|
+
return "continue";
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
try {
|
|
849
|
+
while (true) {
|
|
850
|
+
const { done, value } = await reader.read();
|
|
851
|
+
if (done) break;
|
|
852
|
+
const decoded = decoder.decode(value, { stream: true });
|
|
853
|
+
const nextBufferBytes = bufferBytes + budgetEncoder.encode(decoded).byteLength;
|
|
854
|
+
if (nextBufferBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) {
|
|
855
|
+
throw new Error(`translation SSE event exceeded ${TRANSLATOR_MAX_SSE_EVENT_BYTES} bytes`, {
|
|
856
|
+
cause: { code: "translation_buffer_limit" },
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
const appendReservation = budget.reserveTransient(nextBufferBytes, { kind: "live_transient" });
|
|
860
|
+
try {
|
|
861
|
+
buffer += decoded;
|
|
862
|
+
appendReservation.commitRetained();
|
|
863
|
+
budget.releaseRetained(bufferBytes, { kind: "live_transient" });
|
|
864
|
+
} catch (error) {
|
|
865
|
+
appendReservation.release();
|
|
866
|
+
throw error;
|
|
867
|
+
}
|
|
868
|
+
bufferBytes = nextBufferBytes;
|
|
869
|
+
|
|
870
|
+
const lines = buffer.split("\n");
|
|
871
|
+
buffer = lines.pop() ?? "";
|
|
872
|
+
const residualBytes = budgetEncoder.encode(buffer).byteLength;
|
|
873
|
+
const residualReservation = budget.reserveTransient(residualBytes, { kind: "live_transient" });
|
|
874
|
+
residualReservation.commitRetained();
|
|
875
|
+
budget.releaseRetained(bufferBytes, { kind: "live_transient" });
|
|
876
|
+
bufferBytes = residualBytes;
|
|
877
|
+
|
|
878
|
+
for (const line of lines) {
|
|
879
|
+
if ((yield* handleDataLine(line)) === "terminate") return;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Some providers send the terminal `data:` frame (carrying the final delta, finish_reason,
|
|
884
|
+
// and/or usage) WITHOUT a trailing newline before closing the socket, so it never crosses
|
|
885
|
+
// the split("\n") boundary and stays in `buffer`. Run it through the SAME handler so its
|
|
886
|
+
// content/tool-calls are emitted and its terminal signal observed — otherwise a genuinely
|
|
887
|
+
// complete stream loses its last frame and may be falsely failed below.
|
|
888
|
+
if (buffer.length > 0) {
|
|
889
|
+
if ((yield* handleDataLine(buffer)) === "terminate") return;
|
|
890
|
+
}
|
|
891
|
+
// Reader EOF. Prefer failing closed before flushing pending tool calls so the bridge
|
|
892
|
+
// never sees a fabricated tool_call_end on a truncated mid-assembly stream.
|
|
893
|
+
//
|
|
894
|
+
// Checked BEFORE flushToolCalls(), because that helper emits tool_call_end and there is no
|
|
895
|
+
// taking it back: a half-assembled argument string would reach the client as a completed
|
|
896
|
+
// call. Tool calls are buffered here (unlike the Anthropic adapter, which forwards
|
|
897
|
+
// fragments live), so this adapter can still decide.
|
|
898
|
+
const sawFinish = finishReason !== undefined;
|
|
899
|
+
if (!sawFinish && pendingToolCalls.length > 0) {
|
|
900
|
+
debugProviderDiagnostic("openai-chat", "stream-truncated", {
|
|
901
|
+
finishReason: null,
|
|
902
|
+
hadUsage: pendingUsage !== undefined,
|
|
903
|
+
pendingToolCalls: pendingToolCalls.length,
|
|
904
|
+
});
|
|
905
|
+
yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
// Finish-less EOF is only safe when answer text was emitted. Reasoning-only / usage-only
|
|
909
|
+
// truncations must stay on the error path (hideThinkingSummary can suppress reasoning).
|
|
910
|
+
// Trailing usage alone is not a terminal signal for this adapter (#735 / restore #773).
|
|
911
|
+
if (!sawFinish && !sawUserFacingOutput) {
|
|
912
|
+
debugProviderDiagnostic("openai-chat", "stream-truncated", {
|
|
913
|
+
finishReason: finishReason ?? null,
|
|
914
|
+
hadUsage: pendingUsage !== undefined,
|
|
915
|
+
});
|
|
916
|
+
yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
yield* flushToolCalls();
|
|
920
|
+
// Graceful close that omitted [DONE] but delivered finish_reason and/or answer text.
|
|
921
|
+
const stopReason = finishReason === "length"
|
|
922
|
+
? "max_tokens"
|
|
923
|
+
: finishReason === "content_filter"
|
|
924
|
+
? "content_filter"
|
|
925
|
+
: undefined;
|
|
926
|
+
yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
|
|
927
|
+
} catch (error) {
|
|
928
|
+
if (isTranslatorBudgetExceededError(error)
|
|
929
|
+
|| (error instanceof Error && (error.cause as { code?: unknown } | undefined)?.code === "translation_buffer_limit")) {
|
|
930
|
+
yield {
|
|
931
|
+
type: "error",
|
|
932
|
+
status: 502,
|
|
933
|
+
errorType: "upstream_error",
|
|
934
|
+
code: "translation_buffer_limit",
|
|
935
|
+
message: "upstream translation buffer exceeded the safe limit",
|
|
936
|
+
};
|
|
937
|
+
try { await reader.cancel(error); } catch { /* already closed */ }
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
throw error;
|
|
941
|
+
} finally {
|
|
942
|
+
budget.releaseRetained(bufferBytes, { kind: "live_transient" });
|
|
943
|
+
for (const call of pendingToolCalls) budget.closeCall(call.key);
|
|
944
|
+
reader.releaseLock();
|
|
945
|
+
}
|
|
946
|
+
},
|
|
947
|
+
|
|
948
|
+
async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
|
|
949
|
+
const json = await response.json() as Record<string, unknown>;
|
|
950
|
+
const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength;
|
|
951
|
+
budget.chargeRetained(responseBytes, { kind: "retained_collectors" });
|
|
952
|
+
try {
|
|
953
|
+
if (json.error) {
|
|
954
|
+
const upstreamError = json.error as { message?: unknown; code?: unknown; type?: unknown; status?: unknown };
|
|
955
|
+
const message = typeof upstreamError.message === "string" ? upstreamError.message : "upstream error";
|
|
956
|
+
const code = typeof upstreamError.code === "string" ? upstreamError.code : undefined;
|
|
957
|
+
const errorType = typeof upstreamError.type === "string" ? upstreamError.type : undefined;
|
|
958
|
+
const status = isCyberPolicyCode(code)
|
|
959
|
+
? 400
|
|
960
|
+
: typeof upstreamError.status === "number" && Number.isInteger(upstreamError.status)
|
|
961
|
+
? upstreamError.status
|
|
962
|
+
: undefined;
|
|
963
|
+
return [{
|
|
964
|
+
type: "error",
|
|
965
|
+
message,
|
|
966
|
+
...(code !== undefined ? { code } : {}),
|
|
967
|
+
...(errorType !== undefined ? { errorType } : {}),
|
|
968
|
+
...(status !== undefined ? { status } : {}),
|
|
969
|
+
}];
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const events: AdapterEvent[] = [];
|
|
973
|
+
const choices = json.choices as { message?: Record<string, unknown> }[] | undefined;
|
|
974
|
+
if (!Array.isArray(choices) || choices.length === 0 || !choices[0].message) {
|
|
975
|
+
return [{ type: "error", message: "upstream response contained no choices" }];
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const msg = choices[0].message;
|
|
979
|
+
if (typeof msg.reasoning_content === "string" && msg.reasoning_content.length > 0) {
|
|
980
|
+
events.push({ type: "reasoning_raw_delta", text: msg.reasoning_content });
|
|
981
|
+
}
|
|
982
|
+
if (typeof msg.content === "string") {
|
|
983
|
+
events.push({ type: "text_delta", text: msg.content });
|
|
984
|
+
}
|
|
985
|
+
const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined;
|
|
986
|
+
if (toolCalls) {
|
|
987
|
+
for (const tc of toolCalls) {
|
|
988
|
+
events.push({ type: "tool_call_start", id: tc.id, name: tc.function.name });
|
|
989
|
+
events.push({ type: "tool_call_delta", arguments: tc.function.arguments });
|
|
990
|
+
events.push({ type: "tool_call_end" });
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
const usage = json.usage as Record<string, unknown> | undefined;
|
|
994
|
+
events.push({
|
|
995
|
+
type: "done",
|
|
996
|
+
usage: usageFromOpenAIChat(usage),
|
|
997
|
+
});
|
|
998
|
+
retainTranslatedEventBatch(events, budget);
|
|
999
|
+
return events;
|
|
1000
|
+
} finally {
|
|
1001
|
+
budget.releaseRetained(responseBytes, { kind: "retained_collectors" });
|
|
1002
|
+
}
|
|
1003
|
+
},
|
|
1004
|
+
};
|
|
1005
|
+
}
|