From 5083fe13423c84e74ef9b13c9f4f0af5af6eea1f Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 26 Jul 2026 10:05:18 +0800 Subject: [PATCH 1/7] refactor: clarify module ownership and remove dead seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repository-wide structure and naming audit, round 1. Every directory and every named concept was reviewed independently, and the surviving changes are the ones that leave observable behavior identical while making the tree read the way it would if written today. Ownership moves. `FileProvider` becomes `FileStore` and the channel-broker contract joins it in `@floway-dev/platform`, so the runtime seam is declared in one package instead of two. GitHub device-flow and quota wire helpers move out of the control plane into `provider-copilot`, leaving the gateway with the routes and none of the vendor knowledge. Session-token generation moves under `repo/`, chat attempt helpers gain a coherent home, and orphaned tests rejoin their subjects. Dead surface removed. The orphan Responses item views, the obsolete interceptor context type, unreachable env/Azure/Copilot/Codex helpers, stale export-map subpaths, and inert JSX compiler options are gone. `capabilities` in `protocols` is renamed to `endpoints`, matching what it actually enumerates, and the telemetry view is renamed to the usage view it became. Test discovery repaired. The root Vitest config listed projects by hand and had drifted, so `provider-ollama`'s suites never ran; projects are now discovered by glob. Documentation corrected. Both pricing skills described a retired schema and a cost formula off by a factor of a million — following them against production D1 would have written wrong rates. AGENTS.md and README.md now describe the architecture that exists. --- .../skills/audit-copilot-workarounds/SKILL.md | 62 +- .../skills/backfill-model-pricing/SKILL.md | 75 ++- .../skills/fetching-models-pricing/SKILL.md | 62 +- .agents/skills/probing-copilot/SKILL.md | 51 +- AGENTS.md | 587 +++++++++--------- README.md | 37 +- apps/platform-cloudflare/package.json | 1 - apps/platform-cloudflare/src/bootstrap.ts | 8 +- .../src/do-channel-broker.ts | 6 +- .../src/do-channel-broker_test.ts | 4 +- .../{r2-file-provider.ts => r2-file-store.ts} | 4 +- ...provider_test.ts => r2-file-store_test.ts} | 6 +- apps/platform-cloudflare/tsconfig.json | 4 - apps/platform-node/package.json | 1 - apps/platform-node/src/bootstrap.ts | 8 +- .../src/event-target-channel-broker.ts | 6 +- .../src/event-target-channel-broker_test.ts | 4 +- .../{fs-file-provider.ts => fs-file-store.ts} | 12 +- ...provider_test.ts => fs-file-store_test.ts} | 32 +- docs/RESOLUTION.md | 6 +- package.json | 1 - packages/gateway/package.json | 14 +- .../src/control-plane/auth/routes_test.ts | 15 - .../src/control-plane/data-transfer/routes.ts | 6 +- .../src/control-plane/performance/routes.ts | 2 +- .../src/control-plane/pricing/types.ts | 1 - packages/gateway/src/control-plane/routes.ts | 2 - .../src/control-plane/search-usage/routes.ts | 5 +- .../src/control-plane/shared/key-to-user.ts | 7 + .../key-to-user_test.ts} | 4 +- .../src/control-plane/token-usage/routes.ts | 5 +- .../src/control-plane/upstreams/routes.ts | 60 +- .../{telemetry-view.ts => usage-view.ts} | 17 +- .../chat/chat-completions/attempt.ts | 6 +- .../data-plane/chat/chat-completions/http.ts | 6 +- .../chat/chat-completions/respond.ts | 16 +- .../src/data-plane/chat/gemini/attempt.ts | 2 +- .../src/data-plane/chat/gemini/http.ts | 10 +- .../src/data-plane/chat/gemini/respond.ts | 20 +- .../data-plane/chat/gemini/respond_test.ts | 2 +- .../src/data-plane/chat/messages/attempt.ts | 6 +- .../src/data-plane/chat/messages/http.ts | 8 +- .../src/data-plane/chat/messages/respond.ts | 19 +- .../data-plane/chat/messages/respond_test.ts | 4 +- .../src/data-plane/chat/responses/attempt.ts | 7 +- .../src/data-plane/chat/responses/http.ts | 3 +- .../data-plane/chat/responses/websocket.ts | 3 +- .../shared/alias-rules.ts} | 0 .../shared/alias-rules_test.ts} | 2 +- .../chat/shared/provider-stream-result.ts | 30 + .../shared/provider-stream-result_test.ts | 69 ++ .../data-plane/chat/shared/target-picker.ts | 40 ++ .../shared/target-picker_test.ts} | 82 +-- .../gateway/src/data-plane/codex/routes.ts | 2 +- .../{images_test.ts => routes_images_test.ts} | 0 .../completions/{serve.ts => http.ts} | 0 .../{serve_test.ts => http_test.ts} | 0 .../embeddings/{serve.ts => http.ts} | 0 .../{serve_test.ts => http_test.ts} | 0 .../data-plane/images/{serve.ts => http.ts} | 0 .../images/{serve_test.ts => http_test.ts} | 0 ...ovider_test.ts => custom-provider_test.ts} | 2 +- .../data-plane/providers/endpoint-union.ts | 17 +- .../src/data-plane/providers/flags_test.ts | 44 -- .../src/data-plane/providers/registry.ts | 4 +- .../gateway/src/data-plane/rerank/serve.ts | 3 +- packages/gateway/src/data-plane/routes.ts | 6 +- .../data-plane/shared/iterate-candidates.ts | 2 +- .../data-plane/shared/passthrough-attempt.ts | 3 +- .../shared/telemetry/attempt-helpers.ts | 105 ---- .../shared/telemetry/attribution.ts | 26 + .../shared/telemetry/attribution_test.ts | 17 + .../shared/upstream-call-options.ts | 16 + .../src/data-plane/tools/web-search/usage.ts | 3 +- packages/gateway/src/dump/broker.ts | 2 +- packages/gateway/src/dump/codec.ts | 4 +- packages/gateway/src/dump/store-contract.ts | 2 +- packages/gateway/src/index.ts | 1 - packages/gateway/src/repo/dump-store.ts | 10 +- packages/gateway/src/repo/dump-store_test.ts | 44 +- .../src/repo/expiration-sweeps_test.ts | 16 +- packages/gateway/src/repo/memory.ts | 5 +- .../model-aliases_test.ts} | 8 +- .../gateway/src/repo/responses-items_test.ts | 36 +- .../gateway/src/repo/responses-payload.ts | 6 +- .../src/repo/responses-payload_test.ts | 14 +- .../src/{shared => repo}/session-tokens.ts | 0 .../{shared => repo}/session-tokens_test.ts | 0 packages/gateway/src/repo/spilled-files.ts | 4 +- packages/gateway/src/repo/sql.ts | 5 +- packages/gateway/src/repo/types.ts | 1 - packages/gateway/src/scheduled_test.ts | 8 +- .../src/shared/upstream/model-config_test.ts | 167 ----- packages/gateway/src/test-helpers.ts | 4 +- packages/gateway/tsconfig.json | 6 +- packages/gateway/vitest.setup.ts | 6 +- packages/http/package.json | 1 - packages/http/tsconfig.json | 2 +- packages/interceptor/src/index.ts | 7 - .../src/channel-broker.ts} | 2 +- packages/platform/src/env.ts | 16 +- packages/platform/src/file-provider_test.ts | 37 -- .../src/{file-provider.ts => file-store.ts} | 16 +- packages/platform/src/file-store_test.ts | 37 ++ packages/platform/src/index.ts | 3 +- packages/platform/tsconfig.json | 4 +- .../src/chat-completions/reassemble.ts | 2 +- packages/protocols/src/common/decimal_test.ts | 2 +- .../common/{capabilities.ts => endpoints.ts} | 10 +- ...capabilities_test.ts => endpoints_test.ts} | 4 +- packages/protocols/src/common/index.ts | 2 +- packages/protocols/src/common/models.ts | 6 +- packages/protocols/src/common/models_test.ts | 2 +- .../src/common/openai-stream_test.ts | 2 +- .../src/completions/reassemble_test.ts | 2 +- .../src/responses/from-result_test.ts | 2 +- .../protocols/src/responses/index_test.ts | 2 +- packages/protocols/src/test-assert.ts | 60 -- packages/protocols/tsconfig.json | 4 - packages/provider-azure/package.json | 1 - packages/provider-azure/src/config.ts | 8 - packages/provider-azure/src/index.ts | 5 +- .../src}/provider_test.ts | 2 +- .../provider-codex/src/access-token-cache.ts | 13 - .../src/access-token-cache_test.ts | 28 - packages/provider-copilot/package.json | 3 +- packages/provider-copilot/src/defaults.ts | 4 +- .../src}/github-device-flow.ts | 10 +- packages/provider-copilot/src/index.ts | 2 + .../messages/boundary-chain_test.ts | 3 +- .../src/interceptors/messages/index.ts | 2 +- .../messages/set-claude-agent-headers.ts | 8 +- .../messages/set-claude-agent-headers_test.ts | 3 +- .../messages/set-compact-headers.ts | 10 +- packages/provider-copilot/src/pricing.ts | 13 +- packages/provider-copilot/src/pricing_test.ts | 13 +- .../provider-copilot/src/provider_test.ts | 7 +- packages/provider-copilot/src/quota.ts | 33 + packages/provider-custom/package.json | 1 - packages/provider-ollama/package.json | 3 +- packages/provider-ollama/src/config_test.ts | 38 -- packages/provider-ollama/src/pricing_test.ts | 41 ++ packages/provider/package.json | 1 - .../src/flags_test.ts} | 42 +- .../upstream => provider/src}/join_test.ts | 2 +- packages/provider/src/model-config_test.ts | 163 +++++ packages/proxy/package.json | 1 - packages/proxy/tsconfig.json | 2 +- packages/test-utils/package.json | 1 - packages/translate/package.json | 6 +- .../src/canonicalize-responses-payload.ts | 67 ++ .../canonicalize-responses-payload_test.ts | 88 +++ .../events-protocol_test.ts | 2 +- .../events_test.ts | 2 +- .../chat-completions-via-messages/request.ts | 2 +- .../request_test.ts | 2 +- .../chat-completions-via-responses/events.ts | 4 +- .../events_test.ts | 2 +- .../request_test.ts | 2 +- .../events_test.ts | 2 +- .../request_test.ts | 2 +- .../src/gemini-via-messages/events_test.ts | 2 +- .../src/gemini-via-messages/request_test.ts | 2 +- .../src/gemini-via-responses/events_test.ts | 2 +- .../src/gemini-via-responses/request_test.ts | 2 +- packages/translate/src/index.ts | 1 + .../events-protocol_test.ts | 2 +- .../events_test.ts | 2 +- .../messages-via-chat-completions/request.ts | 12 +- .../request_test.ts | 2 +- .../translate.ts | 2 +- .../events-protocol_test.ts | 2 +- .../src/messages-via-responses/events.ts | 4 +- .../src/messages-via-responses/events_test.ts | 2 +- .../src/messages-via-responses/request.ts | 12 +- .../messages-via-responses/request_test.ts | 2 +- .../src/messages-via-responses/translate.ts | 2 +- .../events_test.ts | 2 +- .../responses-via-chat-completions/request.ts | 2 +- .../request_test.ts | 2 +- .../events-protocol_test.ts | 2 +- .../src/responses-via-messages/events_test.ts | 2 +- .../src/responses-via-messages/request.ts | 4 +- .../responses-via-messages/request_test.ts | 2 +- packages/translate/src/shared/AGENTS.md | 24 +- .../reasoning.ts | 5 +- .../messages-and-responses/reasoning.ts | 26 +- .../src/shared/messages-via/client-tools.ts | 6 + .../context-window-error.ts | 2 - .../context-window-error_test.ts | 5 +- .../structured-output.ts | 0 .../responses-via/custom-tool-wrap_test.ts | 2 +- .../responses-via/programmatic-tooling.ts | 16 - .../via-messages/cache-breakpoints_test.ts | 2 +- .../tool-arguments.ts | 0 .../shared/via-responses/responses-items.ts | 287 --------- .../via-responses/responses-items_test.ts | 267 -------- packages/translate/src/test-assert.ts | 60 -- pnpm-lock.yaml | 37 +- vitest.config.ts | 20 +- 200 files changed, 1551 insertions(+), 2195 deletions(-) rename apps/platform-cloudflare/src/{r2-file-provider.ts => r2-file-store.ts} (90%) rename apps/platform-cloudflare/src/{r2-file-provider_test.ts => r2-file-store_test.ts} (84%) rename apps/platform-node/src/{fs-file-provider.ts => fs-file-store.ts} (84%) rename apps/platform-node/src/{fs-file-provider_test.ts => fs-file-store_test.ts} (50%) delete mode 100644 packages/gateway/src/control-plane/pricing/types.ts create mode 100644 packages/gateway/src/control-plane/shared/key-to-user.ts rename packages/gateway/src/control-plane/{telemetry-view_test.ts => shared/key-to-user_test.ts} (91%) rename packages/gateway/src/control-plane/{telemetry-view.ts => usage-view.ts} (62%) rename packages/gateway/src/data-plane/{model-aliases/apply-rules.ts => chat/shared/alias-rules.ts} (100%) rename packages/gateway/src/data-plane/{model-aliases/apply-rules_test.ts => chat/shared/alias-rules_test.ts} (99%) create mode 100644 packages/gateway/src/data-plane/chat/shared/provider-stream-result.ts create mode 100644 packages/gateway/src/data-plane/chat/shared/provider-stream-result_test.ts create mode 100644 packages/gateway/src/data-plane/chat/shared/target-picker.ts rename packages/gateway/src/data-plane/{shared/telemetry/attempt-helpers_test.ts => chat/shared/target-picker_test.ts} (54%) rename packages/gateway/src/data-plane/codex/{images_test.ts => routes_images_test.ts} (100%) rename packages/gateway/src/data-plane/completions/{serve.ts => http.ts} (100%) rename packages/gateway/src/data-plane/completions/{serve_test.ts => http_test.ts} (100%) rename packages/gateway/src/data-plane/embeddings/{serve.ts => http.ts} (100%) rename packages/gateway/src/data-plane/embeddings/{serve_test.ts => http_test.ts} (100%) rename packages/gateway/src/data-plane/images/{serve.ts => http.ts} (100%) rename packages/gateway/src/data-plane/images/{serve_test.ts => http_test.ts} (100%) rename packages/gateway/src/data-plane/providers/{custom/provider_test.ts => custom-provider_test.ts} (99%) delete mode 100644 packages/gateway/src/data-plane/providers/flags_test.ts delete mode 100644 packages/gateway/src/data-plane/shared/telemetry/attempt-helpers.ts create mode 100644 packages/gateway/src/data-plane/shared/telemetry/attribution.ts create mode 100644 packages/gateway/src/data-plane/shared/telemetry/attribution_test.ts create mode 100644 packages/gateway/src/data-plane/shared/upstream-call-options.ts rename packages/gateway/src/{control-plane/model-aliases/repo_test.ts => repo/model-aliases_test.ts} (97%) rename packages/gateway/src/{shared => repo}/session-tokens.ts (100%) rename packages/gateway/src/{shared => repo}/session-tokens_test.ts (100%) delete mode 100644 packages/gateway/src/shared/upstream/model-config_test.ts rename packages/{gateway/src/runtime/channel-broker-contract.ts => platform/src/channel-broker.ts} (92%) delete mode 100644 packages/platform/src/file-provider_test.ts rename packages/platform/src/{file-provider.ts => file-store.ts} (57%) create mode 100644 packages/platform/src/file-store_test.ts rename packages/protocols/src/common/{capabilities.ts => endpoints.ts} (82%) rename packages/protocols/src/common/{capabilities_test.ts => endpoints_test.ts} (90%) delete mode 100644 packages/protocols/src/test-assert.ts rename packages/{gateway/src/data-plane/providers/azure => provider-azure/src}/provider_test.ts (99%) rename packages/{gateway/src/control-plane/auth => provider-copilot/src}/github-device-flow.ts (93%) create mode 100644 packages/provider-copilot/src/quota.ts create mode 100644 packages/provider-ollama/src/pricing_test.ts rename packages/{gateway/src/data-plane/providers/flags-resolve_test.ts => provider/src/flags_test.ts} (57%) rename packages/{gateway/src/shared/upstream => provider/src}/join_test.ts (95%) create mode 100644 packages/translate/src/canonicalize-responses-payload.ts create mode 100644 packages/translate/src/canonicalize-responses-payload_test.ts create mode 100644 packages/translate/src/shared/messages-via/client-tools.ts rename packages/translate/src/shared/{messages => messages-via}/context-window-error.ts (97%) rename packages/translate/src/shared/{messages => messages-via}/context-window-error_test.ts (95%) rename packages/translate/src/shared/{messages => messages-via}/structured-output.ts (100%) rename packages/translate/src/shared/{messages => via-messages}/tool-arguments.ts (100%) delete mode 100644 packages/translate/src/shared/via-responses/responses-items.ts delete mode 100644 packages/translate/src/shared/via-responses/responses-items_test.ts delete mode 100644 packages/translate/src/test-assert.ts diff --git a/.agents/skills/audit-copilot-workarounds/SKILL.md b/.agents/skills/audit-copilot-workarounds/SKILL.md index 90cf72eecb..07ebe686a8 100644 --- a/.agents/skills/audit-copilot-workarounds/SKILL.md +++ b/.agents/skills/audit-copilot-workarounds/SKILL.md @@ -1,38 +1,50 @@ --- name: audit-copilot-workarounds -description: Use periodically to verify each documented workaround is still - needed against current Copilot upstream. Inventories drift, dispatches - parallel cluster audits, runs live probes, and produces deletion commits - with experimental justification. +description: Use periodically to verify each Copilot workaround against the + current upstream. Inventories provider registrations and reference URLs, + dispatches parallel cluster audits, runs live probes, and produces focused + deletion commits with experimental justification. --- # Audit Copilot Workarounds -Workarounds rot. Re-validate them against current Copilot upstream. +Workarounds rot. Revalidate them against the current Copilot upstream. ## Flow -1. Inventory drift between `index.ts` registrations and AGENTS.md - "Data Plane Workarounds". -2. Dispatch parallel read-only audits, one per source/target × API cluster. -3. Loop further agent rounds until remaining open questions are only - "needs live probe" or "needs human decision". -4. Run live probes for the former. -5. Land deletion + doc commits. Hand the human the rest. +1. Build the inventory from + `packages/provider-copilot/src/interceptors/{chat-completions,messages,responses}/index.ts` + and `packages/provider-copilot/src/defaults.ts`. Follow every registered + interceptor and default-enabled shim to its implementation. The provider code + and the reference URLs beside each workaround are the inventory; there is no + separate documentation list to reconcile. +2. Group the inventory by source API, target API, and behavior so independent + clusters can be investigated without overlapping edits. +3. Dispatch parallel read-only audits, one per cluster. Recheck the cited + upstream or prior-art source, inspect current Copilot behavior, and record the + exact code path that would be deleted if the workaround is obsolete. +4. Continue audit rounds until every open question requires either a live probe + or a human policy decision. +5. Run the required live probes, then land each proven deletion with its tests + and any provider-code reference cleanup. Hand unresolved policy decisions to + the human. ## Extra constraints - **Live probes follow `probing-copilot`** — credential discovery, token - exchange, headers, and direct upstream calls all live there. Don't ask the - human for credentials and don't route probes through our gateway. -- **Full-matrix evidence.** Test every applicable model from `GET /models`, - on every account in D1 (different account types may diverge). One model on - one account is never enough to delete. -- **One workaround per deletion commit.** Never bundle. -- **Each deletion commit message must contain the live experiment - conclusion** that justified it: which models tested, which values, - exact upstream error text when relevant, and the originating commit - sha being reverted. -- **When a policy value (threshold, floor, retry count) has no official - upstream basis, the comment must say so explicitly** in addition to - citing prior-art permalinks. + exchange, headers, and direct upstream calls all live there. Do not ask the + human for credentials and do not route probes through Floway. +- **Full-matrix evidence.** Test every applicable model from `GET /models` on + every account in D1; different accounts can diverge. One model on one account + is never enough to delete a workaround. +- **Source references are leads, not proof.** A still-valid URL explains why a + workaround exists; only current upstream behavior proves whether it remains + necessary. +- **One workaround per deletion commit.** Never bundle independent removals. +- **Each deletion commit message must contain the live experiment conclusion** + that justified it: accounts and models tested, values exercised, exact + upstream error text when relevant, and the originating commit SHA being + reverted. +- **When a policy value has no official upstream basis, say so in code.** + Thresholds, floors, and retry counts must explicitly identify an empirical or + prior-art basis and include the relevant permalink. diff --git a/.agents/skills/backfill-model-pricing/SKILL.md b/.agents/skills/backfill-model-pricing/SKILL.md index 9fad6b443a..e3f76e932d 100644 --- a/.agents/skills/backfill-model-pricing/SKILL.md +++ b/.agents/skills/backfill-model-pricing/SKILL.md @@ -5,39 +5,66 @@ description: Write or rewrite usage.unit_price for a selected slice of live D1 u # Backfill Model Pricing -`usage` stores one row per -`(key_id, model, upstream, model_key, hour, pricing_selector, dimension)`. -`tokens` is the count and `unit_price` is the request-time USD-per-million- -token rate snapshot. `pricing_selector` is canonical selector JSON; `{}` -is the base coordinate. +`usage` stores one metric row per unique +`(key_id, model, COALESCE(upstream, ''), model_key, hour, pricing_selector, metric)`. +`quantity` is a canonical non-negative decimal string. `unit_price` is either +NULL or a canonical non-negative decimal string containing USD per one base +unit of that metric. `pricing_selector` is canonical selector JSON; `{}` is the +Base coordinate. + +The seven metrics established by +`packages/gateway/migrations/0062_usage_billing_metrics.sql` are: + +- `input_tokens` +- `input_cache_read_tokens` +- `input_cache_write_tokens` +- `input_cache_write_1h_tokens` +- `input_image_tokens` +- `output_tokens` +- `output_image_tokens` + +Realized cost is `SUM(quantity * unit_price)`. Both operands are decimal +strings in storage, and there is no additional scaling step. ## Procedure 1. Announce the environment. Default to production (`--remote`). -2. Establish the exact model, upstream, hour range, timezone, dimensions, and - write mode: +2. Before planning or running an UPDATE, re-read the current implementations in + `packages/gateway/src/repo/sql.ts` (`SqlUsageRepo` and usage row assembly) + and `packages/gateway/src/control-plane/token-usage/aggregate.ts` (cost + aggregation). They are the authority if this procedure and the runtime ever + diverge. +3. Establish the exact model, upstream, hour range, timezone, metrics, and write + mode: - fill only rows where `unit_price IS NULL`; or - overwrite the selected range. -3. If intent is incomplete, show enabled upstreams and grouped NULL-rate rows - by `(upstream, model_key, pricing_selector, dimension)`, including count - and `MIN/MAX(hour)`. Do not guess. -4. Read the current provider rate source or the upstream's +4. If intent is incomplete, show enabled upstreams and grouped NULL-price rows + by `(upstream, model_key, pricing_selector, metric)`, including count and + `MIN/MAX(hour)`. Do not guess. +5. Read the current provider rate source or the upstream's `config_json.models[].pricing`. Resolve one `ModelPricing` per `(upstream, model_key)`. -5. Match the stored `pricing_selector` exactly against - `ModelPricing.entries` using canonical selector JSON. +6. Match the stored `pricing_selector` exactly against `ModelPricing.entries` + using canonical selector JSON. - Current runtime selector misses are stored as `{}` with Base rates. - A historical non-Base selector absent from today's catalog indicates catalog drift; stop and investigate rather than guessing its old rates. - - Read only `entry.rates[dimension]`. - - A missing dimension is unpriced; there is no cache, image, or other + - Read only `entry.rates[metric]`. These runtime rates are already USD per + base metric unit. + - When a provider source uses `tokenPricingEntry` or `tokenBasePricing`, its + source literals are published token rates and the helper applies + `perMillionTokenRates`; apply the same conversion rather than copying a + source literal into `unit_price`. + - A missing metric is unpriced; there is no cache, image, or other field-by-field fallback. -6. Preview the affected count and representative rows. -7. Execute one UPDATE per exact - `(slice, pricing_selector, dimension)`. Include - `unit_price IS NULL` only in fill mode. -8. Re-query every slice and report the selector, dimension, rate, rows updated, - and remaining NULL count. +7. Preview the affected count and representative rows, including the current + and proposed decimal-string `unit_price`. +8. Execute one UPDATE per exact `(slice, pricing_selector, metric)`. Include + `unit_price IS NULL` only in fill mode, preserve NULL upstream matching with + `COALESCE(upstream, '')`, and bind the new rate as a decimal string. +9. Re-query every slice and report the selector, metric, rate, rows updated, and + remaining NULL count. Independently validate the realized-cost expression on + representative rows. Use the local Wrangler dependency and read the D1 database name from `wrangler.jsonc`. Never ask the human for credentials already available to @@ -49,8 +76,8 @@ Wrangler. - Do not write a JSON rate vector into `unit_price`; it is one scalar. - Do not map an obsolete selector to a newer “closest” threshold. - Leave rows NULL when the current catalog has no exact entry or explicit - dimension rate. -- Realized cost is `SUM(tokens * unit_price) / 1e6`; validate each scalar - before writing. + metric rate. +- Validate decimal-string multiplication without converting through JavaScript + numbers or SQL floating-point arithmetic. - Writing today's documented rate into historical rows is intentional unless the human explicitly supplies price-at-the-time data. diff --git a/.agents/skills/fetching-models-pricing/SKILL.md b/.agents/skills/fetching-models-pricing/SKILL.md index 7e9fbda368..bd2c8dd16a 100644 --- a/.agents/skills/fetching-models-pricing/SKILL.md +++ b/.agents/skills/fetching-models-pricing/SKILL.md @@ -17,6 +17,13 @@ Maintain the notional per-token rate cards in: These providers are subscription-backed or self-hosted. Floway records notional API-equivalent value so the usage dashboard remains comparable. +`ModelPricing.entries[].rates` stores decimal-string USD prices per one base +`BillingMetric` unit. The token metrics established by +`packages/gateway/migrations/0062_usage_billing_metrics.sql` are +`input_tokens`, `input_cache_read_tokens`, `input_cache_write_tokens`, +`input_cache_write_1h_tokens`, `input_image_tokens`, `output_tokens`, and +`output_image_tokens`. + ## Procedure 1. Fetch the provider's live catalog and diff its ids against the table's @@ -24,7 +31,7 @@ notional API-equivalent value so the usage dashboard remains comparable. 2. Find a defensible rate source for every new id: - Prefer the model vendor's first-party API. - For open weights with no vendor API, use the cheapest credible commodity - host that publishes the required dimensions. + host that publishes the required metrics. - For retired versions, use a permalink or dated archive from when that version was current. 3. Cross-check at least two sources. models.dev remains useful as an independent @@ -36,25 +43,49 @@ notional API-equivalent value so the usage dashboard remains comparable. OpenRouter prices below first-party rates are usually mirror-host prices, not the canonical vendor rate. -4. Author one `ModelPricing` with `modelPricing` and `pricingEntry`: +4. Author pricing with the token helpers and decimal strings: ```ts - modelPricing( - pricingEntry({ input: 2.5, input_cache_read: 0.25, output: 15 }), - pricingEntry( - { input: 5, input_cache_read: 0.5, output: 22.5 }, - { inputTokens: { operator: 'gt', value: 272000 } }, - ), - ) + import { + tokenBasePricing, + tokenModelPricing, + tokenPricingEntry, + type PriceVector, + } from '@floway-dev/protocols/common'; + + const PUBLISHED_BASE_RATES = { + input_tokens: '2.5', + input_cache_read_tokens: '0.25', + output_tokens: '15', + } satisfies PriceVector; + + const PUBLISHED_PRIORITY_RATES = { + input_tokens: '5', + input_cache_read_tokens: '0.5', + output_tokens: '30', + } satisfies PriceVector; + + export const BASE_ONLY_PRICING = tokenBasePricing(PUBLISHED_BASE_RATES); + + export const TIERED_PRICING = tokenModelPricing( + tokenPricingEntry(PUBLISHED_BASE_RATES), + tokenPricingEntry(PUBLISHED_PRIORITY_RATES, { serviceTier: 'priority' }), + ); ``` - Every entry is one exact selector coordinate plus explicit USD-per-million- - token rates. Follow these invariants: + Published token rate cards are normally USD per million tokens. + `tokenPricingEntry` and `tokenBasePricing` use the existing + `perMillionTokenRates` conversion, so their resulting `PriceVector` values + are USD per base token. Do not divide manually or pass number literals. + Follow `packages/provider-codex/src/pricing.ts` for a complete production + example instead of copying a rate vector into this skill. + + Follow these invariants: - Declare exactly one Base entry without a selector. - - Give every entry the same rate dimensions as Base. + - Give every entry the same metrics as Base. - Never merge entries or inherit individual cache/image rates from another - dimension. A dimension absent from Base is unpriced everywhere. + metric. A metric absent from Base is unpriced everywhere. - Treat `serviceTier` as an open-string equality coordinate. - Treat `inputTokens` `gt` / `gte` thresholds as whole-request bands, not marginal token buckets. @@ -71,11 +102,12 @@ notional API-equivalent value so the usage dashboard remains comparable. is serialized inside cached `ProviderModel` rows; a mismatch makes every older row cold before TTL evaluation. 6. Add boundary tests for exact ids, aliases, dated releases, RegExp coverage, - threshold edges, and Base fallback through `priceRequest`. + threshold edges, Base fallback, and the per-base-unit result through + `priceRequest`. 7. Run all affected provider tests, typecheck, lint, and the full test suite. 8. If an existing rate changed, use `backfill-model-pricing` for the intended historical usage slice. Catalog revisioning changes future snapshots; it - does not rewrite recorded unit prices. + does not rewrite recorded `unit_price` values. ## Catalog Revision Policy diff --git a/.agents/skills/probing-copilot/SKILL.md b/.agents/skills/probing-copilot/SKILL.md index 33acbed19a..5b2675267e 100644 --- a/.agents/skills/probing-copilot/SKILL.md +++ b/.agents/skills/probing-copilot/SKILL.md @@ -2,9 +2,9 @@ name: probing-copilot description: Use when probing GitHub Copilot upstream behavior directly. Pulls a usable Copilot credential from D1, exchanges the PAT for a short-lived Copilot - token, and calls api.*.githubcopilot.com with the headers Copilot Chat sends. - Never routes through our gateway; never asks the human for credentials. - Mid-task probes belong in a subagent. + token and its data-plane endpoint, and calls that endpoint with the headers + Copilot Chat sends. Never routes through our gateway; never asks the human for + credentials. Mid-task probes belong in a subagent. --- # Probing Copilot @@ -16,7 +16,7 @@ already own. 1. Read `` from `wrangler.jsonc` (`d1_databases[0].database_name`). -2. Query enabled copilot upstreams against production +2. Query enabled Copilot upstreams against production (`pnpm wrangler d1 execute --remote --command "..."`). Production is the default because we want to mirror the real account, including its proxy chain. Only fall back to local D1 when production is unreachable @@ -28,7 +28,6 @@ already own. ```sql SELECT u.id, u.name, - json_extract(u.config_json, '$.accountType') AS account_type, json_extract(u.config_json, '$.githubToken') AS github_token, (SELECT p.url FROM proxies p, json_each(u.proxy_fallback_list_json) j WHERE json_extract(j.value, '$.id') = p.id @@ -38,11 +37,8 @@ already own. WHERE u.provider = 'copilot' AND u.enabled = 1; ``` -3. Pick any returned row — order doesn't matter unless the probe needs a - specific account type, in which case filter - `json_extract(config_json, '$.accountType')` against - `individual` / `business` / `enterprise`. Don't ask the human. - +3. Pick any returned row unless the probe needs a specific upstream, in which + case select it by `id` or `name`. Don't ask the human. 4. Treat the PAT as a secret: do not echo it into commit messages, code comments, or the chat transcript. @@ -60,25 +56,24 @@ mismatched IP reputations. out in the probe report so the human knows the probe doesn't share egress with production. -Token exchange is not bound to the upstream host; the same `-x` applies +Token exchange is not bound to the data-plane host; the same `-x` applies to the `api.github.com` call. ## Exchange the PAT `GET https://api.github.com/copilot_internal/v2/token` with -`authorization: token ` returns `{ token, expires_at, refresh_in }`. The -returned token is short-lived (~30 min); re-exchange when it expires. The -method is GET, not POST — POST returns 404 from this endpoint. - -## Call the upstream +`authorization: token ` returns +`{ token, expires_at, refresh_in, endpoints: { api } }`. The method is GET, +not POST — POST returns 404 from this endpoint. -Base URL by account type: +Use `endpoints.api` from that response as the data-plane base URL. Keep it +with the exchanged token and refresh both together when the token expires +(usually after about 30 minutes); do not infer or hardcode the host. -- `individual` → `https://api.individual.githubcopilot.com` -- `business` → `https://api.business.githubcopilot.com` -- `enterprise` → `https://api.enterprise.githubcopilot.com` +## Call the upstream -Paths (host root, no API prefix): +Append one of these paths to the `endpoints.api` base URL (host root, no API +prefix): - `/models` - `/chat/completions` (OpenAI Chat) @@ -94,26 +89,28 @@ Authorization: Bearer Content-Type: application/json editor-version: vscode/ editor-plugin-version: copilot-chat/ +editor-device-id: # stable for the probe process user-agent: GitHubCopilotChat/ x-github-api-version: x-vscode-user-agent-library-version: electron-fetch -x-request-id: # same UUID for both ids; real -x-agent-task-id: # VSCode regenerates per request +x-request-id: # same UUID for both request ids +x-agent-task-id: # regenerate the pair per request copilot-integration-id: vscode-chat openai-intent: conversation-agent x-interaction-type: conversation-agent ``` `packages/provider-copilot/src/auth.ts` is the source of truth for the -version constants, the per-request header set, and the account-type→base-URL -map. Read the current values from there rather than hardcoding them in -probe scripts. For Messages probes needing Claude beta features, also send +version constants, the per-request header set, extraction of `endpoints.api`, +and data-plane dispatch in `copilotAuthedFetch`. Read the current values and +flow from there rather than hardcoding them in probe scripts. For Messages +probes needing Claude beta features, also send `anthropic-beta: `. ## Constraints - **Never go through our gateway.** No `pnpm run dev`, no deployed Worker. - Hit `api.*.githubcopilot.com` directly. + Hit the token-advertised Copilot data-plane endpoint directly. - **Don't write probe code into the repo** unless the human asks. One-shot `curl` (or a throwaway script piped through `jq`) is enough. - **Mid-task probes use a subagent.** Probes dump noisy request/response diff --git a/AGENTS.md b/AGENTS.md index efc20fc700..2b945aa2cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,92 +40,87 @@ dependencies remain targeted at their predecessor branches and remain drafts. Floway is an LLM API gateway. It exposes OpenAI Completions, Anthropic Messages, OpenAI Responses, OpenAI Chat Completions, Embeddings, OpenAI Images, OpenAI Audio Transcriptions, Cohere/Jina/Voyage-compatible Rerank, -and Google Gemini-compatible APIs over a unified upstream -model. Provider kinds are -`copilot`, `custom`, `azure`, `codex` (ChatGPT subscription via the -Codex CLI's OAuth client), `claude-code` (Claude.ai Pro/Max subscription -via the Claude Code CLI's OAuth client), and `ollama` (any Ollama- -compatible HTTP server — ollama.com by default, or a self-hosted daemon). - -The product name is **Floway** — capitalized in all prose, comments, -test names, assertion messages, and log output. Lowercase `floway` only -appears inside technical identifiers that are part of an existing -contract: the `@floway-dev/*` npm scope, `FLOWAY_*` env vars, the -`x-floway-session` HTTP header, CSS class names, storage keys, fake test -fixtures, and user-facing file/volume names. Never write `` `floway` `` -as a name for the project itself. +and Google Gemini-compatible APIs over a unified upstream model. Provider +kinds are `copilot`, `custom`, `azure`, `codex` (ChatGPT subscription via the +Codex CLI's OAuth client), `claude-code` (Claude.ai Pro, Max, Team, or +Enterprise subscription via the Claude Code CLI's OAuth client), and `ollama` +(any Ollama-compatible HTTP server — ollama.com by default, or a self-hosted +daemon). + +The product name is **Floway** — capitalized in all prose, comments, test +names, assertion messages, and log output. Lowercase `floway` only appears +inside technical identifiers that are part of an existing contract: the +`@floway-dev/*` npm scope, `FLOWAY_*` env vars, the `x-floway-session` HTTP +header, CSS class names, storage keys, fake test fixtures, and user-facing +file/volume names. Never write `` `floway` `` as a name for the project itself. As a gateway, preserve upstream status, headers, and body as directly as possible; surface internal failures with stack traces rather than masking them. Code-level rules about error handling, comments, and style live in the -global agent instructions and in ESLint config — read those, not a copy -here. +global agent instructions and in ESLint config — read those, not a copy here. ## Design Principle: Upstream Models And Field Values Are Opaque -Floway assumes each upstream speaks the protocol declared for it. The -model catalog and the enum values in open-string protocol slots are -upstream-owned; Floway must not silently collapse either onto a fixed -vendor family. +Floway assumes each upstream speaks the protocol declared for it. The model +catalog and the enum values in open-string protocol slots are upstream-owned; +Floway must not silently collapse either onto a fixed vendor family. Allowed: - **Identified-model special cases** — `if (model.id === 'X')`, - `if (isOpus47Plus(id))`, `if (isClaudeFamily(id))`. Vendor knowledge - lives in the code that talks to that vendor. + `if (isOpus47Plus(id))`, `if (isClaudeFamily(id))`. Vendor knowledge lives + in the code that talks to that vendor. - **Provider-wide uniform defaults on a bounded scope** — e.g., `provider-ollama` advertising `reasoning.effort: { supported: ['low', - 'medium', 'high'] }` for every thinking-capable Ollama model. The - scope is bounded by the provider itself. -- **Metadata-first id-inference fallbacks.** Endpoint capability comes - from upstream metadata first (Copilot `supported_endpoints`, a - Floway-shaped upstream's `kind`, capabilities blocks, operator - override); a name-token or prefix fallback that fires AFTER the - metadata check is silent is fine, provided it lives in the provider - package that owns the workaround and — for upstream-bug workarounds - — carries a reference URL and a listing in the - `audit-copilot-workarounds` skill or equivalent. -- **Client-tool-compat name filters.** Dashboard helpers that build a - config for a CLI which itself expects a name family (Claude Code CLI - expects `claude-*`, Codex CLI expects `gpt-5-*`) MAY filter that - picker by the same pattern. Mirroring the CLI's own expectation, not - Floway asserting an endpoint mapping. Scope must be the CLI setup - helper; general model pickers still read `endpoints` from the DTO. -- **Per-provider pricing tables** (`pricing.ts`) — return null for - unknown keys. + 'medium', 'high'] }` for every thinking-capable Ollama model. The scope is + bounded by the provider itself. +- **Metadata-first id-inference fallbacks.** Endpoint capability comes from + upstream metadata first (Copilot `supported_endpoints`, a Floway-shaped + upstream's `kind`, capability blocks, operator override); a name-token or + prefix fallback that fires AFTER the metadata check is silent is fine, + provided it lives in the provider package that owns the workaround and — + for an upstream-bug workaround — carries a reference URL on the workaround + itself. +- **Client-tool-compat name filters.** Dashboard helpers that build a config + for a CLI which itself expects a name family (Claude Code CLI expects + `claude-*`, Codex CLI expects `gpt-5-*`) MAY filter that picker by the same + pattern. Mirroring the CLI's own expectation is not Floway asserting an + endpoint mapping. Scope must be the CLI setup helper; general model pickers + still read `endpoints` from the DTO. +- **Per-provider pricing tables** (`pricing.ts`) — return null for unknown + keys. - **Provider config discriminators naming the OWN kind** — `kind: 'claude-code'`. - **Vendor-locked provider packages** (`provider-claude-code`, - `provider-codex`) doing fixed-catalog request/header mimicry captured - verbatim from a live wire probe with a reference URL. - -Forbidden — silent narrowing at wire / translate / control-plane -boundaries. Open-string fields declared `| (string & {})` or bare -`string` in `packages/protocols/` (`reasoning_effort`, `verbosity`, -`service_tier`, `reasoning.summary`, `thinkingLevel`, `speed`, Messages -`thinking.display`, …) MUST be forwarded verbatim: `z.string()` in -control-plane schemas, direct pass-through in translators, no `switch` -default that drops unknown values. The upstream owns the accept/reject -decision. Cross-protocol synthesis between different shapes — Gemini -`includeThoughts: true` ↔ Responses `summary`, Messages -`thinking.type: 'enabled'` (no effort) ↔ Chat `reasoning_effort` — is + `provider-codex`) doing request/header mimicry captured verbatim from a live + wire probe while fetching their model catalogs live from the vendor. + +Forbidden — silent narrowing at wire / translate / control-plane boundaries. +Open-string fields declared `| (string & {})` or bare `string` in +`packages/protocols/` (`reasoning_effort`, `verbosity`, `service_tier`, +`reasoning.summary`, `thinkingLevel`, `speed`, Messages `thinking.display`, +…) MUST be forwarded verbatim: `z.string()` in control-plane schemas, direct +pass-through in translators, no `switch` default that drops unknown values. +The upstream owns the accept/reject decision. Cross-protocol synthesis between +different shapes — Gemini `includeThoughts: true` ↔ Responses `summary`, +Messages `thinking.type: 'enabled'` (no effort) ↔ Chat `reasoning_effort` — is legit translation, distinct from within-protocol enum gating. -**Every vendor constant needs a reference URL** — image caps, effort→ -budget bin edges, canonical enum values, header sets, protocol quirks. -Prose like "per Anthropic's vision docs" without a permalink doesn't -count. +**Every vendor constant needs a reference URL** — image caps, effort→budget +bin edges, canonical enum values, header sets, protocol quirks. Prose like +"per Anthropic's vision docs" without a permalink doesn't count. ## Architecture -Stack: Hono on Web APIs, TypeScript, pnpm, Vitest. The dashboard is a -Vue + Vite SPA. Cloudflare Workers is the production deployment target; -Node.js (`node:sqlite` + `sharp` + filesystem) is a parallel deployment -target with the same Hono app and the same `packages/gateway/migrations` -SQL. The `@floway-dev/platform` package owns the abstract runtime -contracts (`FileProvider`, `ImageProcessor`, `ExternalResourceFetcher`, -`SqlDatabase`, `BackgroundScheduler`, `EnvGetter`, `SocketDial`); each -`apps/platform-*` app supplies the concrete impls and its own entry. +Stack: Hono on Web APIs, TypeScript, pnpm, Vitest. The dashboard is a Vue + +Vite SPA. Cloudflare Workers is the production deployment target; Node.js +(`node:sqlite` + `sharp` + filesystem) is a parallel deployment target with +the same Hono app and the same `packages/gateway/migrations` SQL. The +`@floway-dev/platform` package owns the abstract runtime contracts +(`FileStore`, `ChannelBroker`, `ImageCacheStore`, `RuntimeKind`, +`ImageProcessor`, `ExternalResourceFetcher`, `SqlDatabase`, +`BackgroundScheduler`, `EnvGetter`, `SocketDial`); each `apps/platform-*` app +supplies the concrete implementations and its own entry. ## Workspace Layout @@ -137,12 +132,12 @@ Floway/ │ ├── http/ # @floway-dev/http — HTTP/1.1 + userspace TLS + WebSocket upgrade over a duplex byte stream │ ├── interceptor/ # @floway-dev/interceptor — generic interceptor framework │ ├── platform/ # @floway-dev/platform — runtime contracts + portable helpers -│ ├── protocols/ # @floway-dev/protocols — protocol type defs +│ ├── protocols/ # @floway-dev/protocols — protocol types, codecs, stream helpers, pricing and decimal utilities │ ├── provider/ # @floway-dev/provider — upstream provider contracts -│ ├── provider-azure/ # @floway-dev/provider-azure — Azure OpenAI provider +│ ├── provider-azure/ # @floway-dev/provider-azure — Azure AI resource and Foundry project provider │ ├── provider-claude-code/ # @floway-dev/provider-claude-code — Claude Code (Claude.ai subscription) provider │ ├── provider-codex/ # @floway-dev/provider-codex — ChatGPT Codex (subscription) provider -│ ├── provider-copilot/ # @floway-dev/provider-copilot — GitHub Copilot provider +│ ├── provider-copilot/ # @floway-dev/provider-copilot — GitHub Copilot provider, device OAuth, and quota wire helpers │ ├── provider-custom/ # @floway-dev/provider-custom — configurable multi-protocol HTTP provider │ ├── provider-ollama/ # @floway-dev/provider-ollama — Ollama (ollama.com or self-hosted) │ ├── proxy/ # @floway-dev/proxy — proxy URI parsing, per-protocol byte-stream dialers, proxy-backed and direct request runners @@ -150,64 +145,83 @@ Floway/ │ ├── translate/ # @floway-dev/translate — cross-protocol translation pairs │ └── ui/ # @floway-dev/ui — internal Vue component library └── apps/ - ├── platform-cloudflare/ # @floway-dev/platform-cloudflare — CF impls + Worker entry - ├── platform-node/ # @floway-dev/platform-node — Node impls + node-server entry + ├── platform-cloudflare/ # @floway-dev/platform-cloudflare — Cloudflare implementations + Worker entry + ├── platform-node/ # @floway-dev/platform-node — Node implementations + node-server entry └── web/ # @floway-dev/web — Vue + Vite SPA dashboard ``` Dependency direction is strict. The leaf-most packages are `protocols`, `interceptor`, and `http`, none of which have runtime dependencies. -`translate` depends on `protocols`. `agent-setup` depends only on -`hono` / `zod` / `@hono/zod-validator`; it never imports the gateway or any -app, and knows nothing of databases, HTTP auth/CORS/logging, host mount -paths, or runtimes. `proxy` depends on `http`; all its dialers — including -`vless-ws`, which layers `wsUpgradeAndFrame` over the runtime's TLS-wrapped -duplex — stay runtime-agnostic by taking the raw TCP `socketDial` primitive -through `DialOptions`, so they never import `@floway-dev/platform`. -`provider` depends on `platform` + `protocols` + `interceptor`; the -per-vendor `provider-*` packages depend on `provider`. -`gateway` depends on `platform` + `protocols` + `translate` + `http` + -`proxy` + `agent-setup` + all `provider-*`, and is the runtime-agnostic -gateway core; it threads `getSocketDial()` from `@floway-dev/platform` into -the proxy library at the dial-layer composition root, and supplies the SQL / -in-memory `AgentSetupRepository` implementations, the auth-derived user id, -and the single host-owned route path that mounts both setup surfaces and -project public script URLs ahead of logger / CORS / auth middleware. -`apps/platform-*` depend on `platform` + `gateway` plus their target's -runtime libraries (`@cloudflare/workers-types`; `sharp` + -`@hono/node-server`); they are the only places runtime-specific symbols -(D1, R2, Images, KV, ExecutionContext, sharp, node:sqlite, fs) appear. -`apps/web` depends on `ui` + `proxy` (the latter only via its `/url`, -`/url-kind`, `/proxy-config`, and `/constants` subpath exports — chosen so -the dashboard's proxy editor reuses URI parse/format and config types -without pulling dialers, userspace TLS, or Node `crypto` into the SPA -bundle), and type-imports `@floway-dev/gateway/app-type` for Hono RPC -client typing. It does not depend on `@floway-dev/agent-setup` — the -dashboard derives the Agent Setup configuration type from the RPC client — -and ESLint blocks a runtime import of that package from `apps/web`. - -ESLint forbids any workspace file from importing `@floway-dev/platform-*` -by package name, plus a `no-restricted-paths` zone forbidding the -platform-target apps from reaching into each other via relative paths. -Each `apps/platform-*` ships with no `exports`/`main` field, so deep -imports also fail at module resolution. Each platform-target app's -`entry.ts` reaches its impls only via local relative imports. +`translate` depends on `protocols`. `agent-setup` depends only on `hono` / +`zod` / `@hono/zod-validator`; it never imports the gateway or any app, and +knows nothing of databases, HTTP auth/CORS/logging, host mount paths, or +runtimes. `platform` owns runtime-neutral contracts and portable helpers. +`proxy` depends on `http`; all its dialers — including `vless-ws`, which +layers `wsUpgradeAndFrame` over the runtime's TLS-wrapped duplex — stay +runtime-agnostic by taking the raw TCP `socketDial` primitive through +`DialOptions`, so they never import `@floway-dev/platform`. + +The base `provider` package depends on `platform` + `protocols`. Azure, Custom, +and Ollama depend on `provider` + `protocols`; Claude Code and Codex add +`interceptor`; Copilot adds both `interceptor` and `platform`. `test-utils` +depends on `provider` and is consumed only as a test dependency. + +`gateway` depends on `agent-setup` + `http` + `interceptor` + `platform` + +`protocols` + `provider` + every `provider-*` package + `proxy` + `translate`. +It is the runtime-agnostic gateway core: it threads `getSocketDial()` from +`@floway-dev/platform` into the proxy library at the dial-layer composition +root, supplies the SQL / in-memory `AgentSetupRepository` implementations and +the auth-derived user id, and owns the single route path that mounts both setup +surfaces and public script URLs ahead of logger / CORS / auth middleware. +Vendor credential and wire knowledge stays in the vendor package: for example, +`provider-copilot` owns GitHub device-flow and quota fetch helpers, while the +gateway owns the control-plane routes and maps their results onto Floway HTTP +responses. + +Both `apps/platform-*` apps depend on `gateway` + `http` + `platform`. The +Cloudflare app declares only the runtime surfaces it uses in local ambient +files (`apps/platform-cloudflare/src/cloudflare-workers.d.ts`, +`cloudflare-sockets.d.ts`, and `cf-websocket.d.ts`) instead of depending on +`@cloudflare/workers-types`. The Node app supplies `node:sqlite`, filesystem, +`sharp`, WebSocket, and `@hono/node-server` implementations. These apps are the +composition roots for runtime-specific bindings and services. + +`apps/web` depends at runtime on `ui`, `protocols`, `provider`, and `proxy`. +Its protocol imports use `/common`, `/chat-completions`, `/completions`, +`/messages`, `/responses`, `/gemini`, and `/rerank`; its provider imports use +the root, `/flags`, `/model`, and `/model-prefix`; and its proxy imports are +restricted to `/url`, `/url-kind`, `/proxy-config`, and `/constants` so the +SPA does not pull dialers, userspace TLS, or Node `crypto` into its bundle. It +type-imports gateway contracts through `/app-type`, `/dump-types`, +`/control-plane/performance/aggregate`, and +`/control-plane/proxies/serialize`. It does not depend on +`@floway-dev/agent-setup` — the dashboard derives the Agent Setup configuration +type from the RPC client — and ESLint blocks a runtime import of that package +from `apps/web`. + +ESLint forbids any workspace file from importing `@floway-dev/platform-*` by +package name, plus a `no-restricted-paths` zone forbidding the platform-target +apps from reaching into each other via relative paths. Each `apps/platform-*` +ships with no `exports`/`main` field, so deep imports also fail at module +resolution. Each platform-target app's `entry.ts` reaches its implementations +only via local relative imports. Each package's public surface is its `exports` map. Deep imports -(`@floway-dev//src/...`) are banned by ESLint; cross-package code must -use declared subpath exports. Tests are co-located as `*_test.ts`; each -package has its own `vitest.config.ts`, and the root config aggregates them -through `test.projects`. +(`@floway-dev//src/...`) are banned by ESLint; cross-package code must use +declared subpath exports. Tests are co-located as `*_test.ts`; each tested +package has its own `vitest.config.ts`, and the root config discovers projects +with `packages/*/vitest.config.ts` and `apps/*/vitest.config.ts`. Client-carried affinity is a source-protocol membrane. Shared codec, routing, and request context live under `data-plane/chat/shared/affinity`; each source protocol owns its `affinity/ingress.ts` and `affinity/egress.ts`. Wire behavior -lives in `docs/AFFINITY.md`, and candidate ordering lives in `docs/RESOLUTION.md`. +lives in `docs/AFFINITY.md`, and candidate ordering lives in +`docs/RESOLUTION.md`. Everything else — provider interfaces, request execution flow, interceptor -shapes, control-plane route surface, flag resolution, pricing — lives in -the code and its comments. Translation pair layout, model resolution, and -affinity wire behavior have dedicated specs under `docs/`. +shapes, control-plane route surface, flag resolution, pricing — lives in the +code and its comments. Translation pair layout, model resolution, and affinity +wire behavior have dedicated specs under `docs/`. ## Verification @@ -219,8 +233,8 @@ pnpm run test:agent-setup-installers # assembled Agent Setup scripts vs. fake C ``` To work on a single package, use pnpm filters (e.g. -`pnpm --filter @floway-dev/translate run typecheck`). Wrangler commands -go through the local dependency with `pnpm wrangler` or package scripts. +`pnpm --filter @floway-dev/translate run typecheck`). Wrangler commands go +through the local dependency with `pnpm wrangler` or package scripts. ## Development @@ -233,137 +247,130 @@ pnpm run db:migrate:remote # production D1 ``` `dev` runs the Worker on `http://127.0.0.1:8788` and the SPA on -`http://localhost:5174`. For frontend development open the Vite SPA -(5174): Vite proxies the gateway's HTTP paths to the Worker (see the -canonical list in `apps/web/vite.config.ts`'s `wranglerProxiedPaths`), -so relative-URL fetches in `apps/web` work identically in dev and prod. -The Worker port serves the last built `apps/web/dist` via Workers Static -Assets; direct SPA routes (e.g. `/login`, `/dashboard/...`) require -`assets.not_found_handling: "single-page-application"` plus the -backend-only `assets.run_worker_first` route list in the gitignored -`wrangler.jsonc` (see `wrangler.example.jsonc`). - -`dev:node` boots the Node deployment target. Configure via -`FLOWAY_DB_PATH` (sqlite file path), `FLOWAY_FILES_DIR` (filesystem -store root), `ADMIN_KEY` (admin secret; see below), `PORT`, and -optionally `RUNTIME_LOCATION` (instance tag used as the perf-telemetry -`runtimeLocation` dimension and the dial-time colo-whitelist key — -uppercased on read, defaults to `LOCAL` when unset). The Node entry runs -`applyMigrations` against `packages/gateway/migrations/*.sql` at boot, -then serves the same Hono app through `@hono/node-server`. Static-asset -serving is Workers-only; the Node target serves no SPA. +`http://localhost:5174`. For frontend development open the Vite SPA (5174): +Vite proxies the gateway's HTTP paths to the Worker (see the canonical list in +`apps/web/vite.config.ts`'s `wranglerProxiedPaths`), so relative-URL fetches in +`apps/web` work identically in dev and prod. The Worker port serves the last +built `apps/web/dist` via Workers Static Assets; direct SPA routes (e.g. +`/login`, `/dashboard/...`) require +`assets.not_found_handling: "single-page-application"` plus the backend-only +`assets.run_worker_first` route list in the gitignored `wrangler.jsonc` (see +`wrangler.example.jsonc`). + +`dev:node` boots the Node deployment target. Configure via `FLOWAY_DB_PATH` +(sqlite file path), `FLOWAY_FILES_DIR` (filesystem store root), `ADMIN_KEY` +(admin secret; see below), `PORT`, and optionally `RUNTIME_LOCATION` (instance +tag used as the perf-telemetry `runtimeLocation` dimension and the dial-time +colo-whitelist key — uppercased on read, defaults to `LOCAL` when unset). The +Node entry runs `applyMigrations` against +`packages/gateway/migrations/*.sql` at boot, then serves the same Hono app +through `@hono/node-server`. It exposes Floway's data-plane and control-plane +APIs but no SPA; static-asset serving is Workers-only. The public Agent Setup installers are composed from the checked-in -`packages/agent-setup/installers/{bash,powershell}/common/` fragments -and the adjacent `{claude,codex}.{sh,ps1}` agent fragments. Each source -fragment is embedded verbatim into -`packages/agent-setup/src/script-assets.generated.ts`; regenerate with -`pnpm --filter @floway-dev/agent-setup run generate-assets` (pass -`--check` to fail on drift) after editing any fragment. - -`ADMIN_KEY` is optional on dev instances so a fresh checkout is usable -without any secret setup: with the env var unset (which is the default -once `.dev.vars` is deleted), the login page grants seed-admin access to -a blank username + any password. Real deployments must set it — the Node -entry refuses to boot under `NODE_ENV=production` with an empty -`ADMIN_KEY`, and the Cloudflare-side request handler refuses passwordless -logins whenever the request carries a `CF-Ray` header (workerd's local -inbound used by `wrangler dev` never writes CF-Ray; only Cloudflare's -edge does). It is not a data-plane credential; its only purpose is to let -an operator who lost the admin password log in via `POST /auth/login`. - -For manual data-plane validation, log into the dashboard with the -`ADMIN_KEY` backdoor (or, on a dev instance, the passwordless shortcut) -or with your own user, then create or pick an API key under your account -and use it as `x-api-key`. +`packages/agent-setup/installers/{bash,powershell}/common/` fragments and the +adjacent `{claude,codex}.{sh,ps1}` agent fragments. Each source fragment is +embedded verbatim into `packages/agent-setup/src/script-assets.generated.ts`; +regenerate with +`pnpm --filter @floway-dev/agent-setup run generate-assets` (pass `--check` to +fail on drift) after editing any fragment. + +`ADMIN_KEY` is optional on dev instances so a fresh checkout is usable without +any secret setup: with the env var unset (which is the default once `.dev.vars` +is deleted), the login page grants seed-admin access to a blank username + any +password. Real deployments must set it — the Node entry refuses to boot under +`NODE_ENV=production` with an empty `ADMIN_KEY`, and the Cloudflare-side +request handler refuses passwordless logins whenever the request carries a +`CF-Ray` header (workerd's local inbound used by `wrangler dev` never writes +CF-Ray; only Cloudflare's edge does). It is not a data-plane credential; its +only purpose is to let an operator who lost the admin password log in via +`POST /auth/login`. + +For manual data-plane validation, log into the dashboard with the `ADMIN_KEY` +backdoor (or, on a dev instance, the passwordless shortcut) or with your own +user, then create or pick an API key under your account and use it as +`x-api-key`. ## Deployment -A production deploy can disconnect the agent that triggers it, especially -when the deploy includes a D1 migration and the live schema briefly does -not match the code that the same agent is still running against. That -window is hard to avoid, so every production deploy must be a deliberate, -announced step. - -Tell the user once, before Step 1 begins. If the user already asked for -the deploy up front, you do not need to re-ask, but you still explicitly -announce that the deploy is starting. That announcement is the only place -during a deploy where the agent talks *to* the user instead of running the -next tool. - -After that announcement the deploy is autonomous and must not stop — -except at Step 2 when breaking changes require user confirmation. Each -turn ends on a tool call; the only legitimate reasons to stop are: the -Worker is live and Step 4 succeeded, Step 2 is awaiting user -confirmation of breaking changes, or a tool exited non-zero and the -failure genuinely requires human judgement. - -When the user's request is the deploy itself — the human asked to deploy -and not to deploy as the tail of a wider piece of work — git is read-only -for the duration of the deploy flow. This constraint covers git only; -code and config edits are not bound by it and remain a per-situation -judgement call. Inspection commands such as `git branch`, `git status`, -`git log`, `git diff`, and `git show` are fine and are often needed to -gather state for Steps 1 and 2. Anything that mutates repository -state is forbidden: `git stash`, `git reset`, `git checkout` of files or -branches, `git commit`, `git rebase`, `git merge`, `git pull`, -`git push`, and any branch or tag creation/deletion. - -Substitute `` (top-level `name`) and `` (the D1 -binding's `database_name`) from `wrangler.jsonc` wherever those -placeholders appear below. - -**Step 1 — gather current state.** Read `wrangler.jsonc` for `` -and ``, then chain: +A production deploy can disconnect the agent that triggers it, especially when +the deploy includes a D1 migration and the live schema briefly does not match +the code that the same agent is still running against. That window is hard to +avoid, so every production deploy must be a deliberate, announced step. + +Tell the user once, before Step 1 begins. If the user already asked for the +deploy up front, you do not need to re-ask, but you still explicitly announce +that the deploy is starting. That announcement is the only place during a +deploy where the agent talks *to* the user instead of running the next tool. + +After that announcement the deploy is autonomous and must not stop — except at +Step 2 when breaking changes require user confirmation. Each turn ends on a +tool call; the only legitimate reasons to stop are: the Worker is live and Step +4 succeeded, Step 2 is awaiting user confirmation of breaking changes, or a +tool exited non-zero and the failure genuinely requires human judgement. + +When the user's request is the deploy itself — the human asked to deploy and +not to deploy as the tail of a wider piece of work — git is read-only for the +duration of the deploy flow. This constraint covers git only; code and config +edits are not bound by it and remain a per-situation judgement call. Inspection +commands such as `git branch`, `git status`, `git log`, `git diff`, and `git +show` are fine and are often needed to gather state for Steps 1 and 2. Anything +that mutates repository state is forbidden: `git stash`, `git reset`, `git +checkout` of files or branches, `git commit`, `git rebase`, `git merge`, `git +pull`, `git push`, and any branch or tag creation/deletion. + +Substitute `` (top-level `name`) and `` (the D1 binding's +`database_name`) from `wrangler.jsonc` wherever those placeholders appear +below. + +**Step 1 — gather current state.** Read `wrangler.jsonc` for `` and +``, then chain: ```bash pnpm wrangler deployments list \ && pnpm wrangler d1 migrations list --remote ``` -`deployments list` shows recent deployments with their version ids and -marks the currently active one — that gives both the active deployment -timestamp, the version id you would later roll back to, and the deploy -message (which records the commit revision of that deployment). -`d1 migrations list --remote` prints applied migrations and the pending -diff this deploy would apply. +`deployments list` shows recent deployments with their version ids and marks +the currently active one — that gives both the active deployment timestamp, +the version id you would later roll back to, and the deploy message (which +records the commit revision of that deployment). `d1 migrations list --remote` +prints applied migrations and the pending diff this deploy would apply. -**Step 2 — declare breaking changes and collect recommended actions.** -Extract the deploy message of the currently active deployment from Step 1's -output. The message is the short commit revision the deploy script stamped. -Use it to diff `CHANGELOG.md` between that revision and the current working -tree: +**Step 2 — declare breaking changes and collect recommended actions.** Extract +the deploy message of the currently active deployment from Step 1's output. The +message is the short commit revision the deploy script stamped. Use it to diff +`CHANGELOG.md` between that revision and the current working tree: ```bash git diff -- CHANGELOG.md ``` -If the active deployment has no message, or its message is not a -recognizable commit revision (i.e. it predates the introduction of this -workflow), and the database shows applied migrations (confirming Floway -is already running in production), treat the entire content of -`CHANGELOG.md` as potentially new to the user. +If the active deployment has no message, or its message is not a recognizable +commit revision (i.e. it predates the introduction of this workflow), and the +database shows applied migrations (confirming Floway is already running in +production), treat the entire content of `CHANGELOG.md` as potentially new to +the user. -Classify every new entry by its heading. `hard` and `minor` entries are -breaking changes; summarize their combined user-facing impact. When the same -area was broken by consecutive entries, synthesize the net effect instead of -enumerating intermediate states. Tell the user that all listed breaking -changes are intentional, describe their impact, and ask the user to confirm -before proceeding. This is the **only** point in the deploy flow where the -agent pauses before deployment. +Classify every new entry by its heading. `hard` and `minor` entries are breaking +changes; summarize their combined user-facing impact. When the same area was +broken by consecutive entries, synthesize the net effect instead of enumerating +intermediate states. Tell the user that all listed breaking changes are +intentional, describe their impact, and ask the user to confirm before +proceeding. This is the **only** point in the deploy flow where the agent pauses +before deployment. `advisory` entries do not trigger confirmation. Recommended operations may appear in `hard`, `minor`, or `advisory` entries; collect all of them for the post-deploy report. A note is information, not authority to mutate state. -When there are no new `hard` or `minor` entries, or when `CHANGELOG.md` does -not exist at the previous revision and is empty now, skip confirmation and -proceed to Step 3 immediately. +When there are no new `hard` or `minor` entries, or when `CHANGELOG.md` does not +exist at the previous revision and is empty now, skip confirmation and proceed +to Step 3 immediately. -**Step 3 — report findings and stage the rollback.** Tell the user the -active version id, the active deployment timestamp, the latest applied -migration, and the migrations this deploy will apply (or that there are -none). +**Step 3 — report findings and stage the rollback.** Tell the user the active +version id, the active deployment timestamp, the latest applied migration, and +the migrations this deploy will apply (or that there are none). If migrations are pending, capture a Time Travel bookmark of the current database state so a rollback can restore to that exact point: @@ -372,75 +379,70 @@ database state so a rollback can restore to that exact point: pnpm wrangler d1 time-travel info --json ``` -The output is `{ "bookmark": "..." }`; that bookmark string is the -restore target. Nothing leaves Cloudflare, and D1 retains bookmarks for -30 days. +The output is `{ "bookmark": "..." }`; that bookmark string is the restore +target. Nothing leaves Cloudflare, and D1 retains bookmarks for 30 days. -Report the captured bookmark, then give the user two rollback commands, -in this order: +Report the captured bookmark, then give the user two rollback commands, in this +order: -- Restore the database: `CI=1 pnpm wrangler d1 time-travel restore - --bookmark `. -- Roll back the Worker code: - `CI=1 pnpm wrangler rollback -m "Emergency rollback"`. +- Restore the database: `CI=1 pnpm wrangler d1 time-travel restore + --bookmark `. +- Roll back the Worker code: `CI=1 pnpm wrangler rollback + -m "Emergency rollback"`. -Both commands must be paste-and-run during an incident, so they are -prefixed with `CI=1` to make wrangler treat them as non-interactive — it -otherwise prompts to confirm the restore and to enter a rollback -message. The `-m` flag on `wrangler rollback` supplies that message -directly, because wrangler's documented `-y/--yes` flag is not actually -honored by the rollback handler. +Both commands must be paste-and-run during an incident, so they are prefixed +with `CI=1` to make wrangler treat them as non-interactive — it otherwise +prompts to confirm the restore and to enter a rollback message. The `-m` flag on +`wrangler rollback` supplies that message directly, because wrangler's +documented `-y/--yes` flag is not actually honored by the rollback handler. If no migrations are pending, skip the bookmark capture and the -database-rollback command; give only the code-rollback command and -proceed straight to Step 4. +database-rollback command; give only the code-rollback command and proceed +straight to Step 4. -**Step 4 — deploy with one chained command.** Migrate (when needed) and -publish in the same command so the system spends as little time as -possible in an inconsistent state. `pnpm run deploy` stamps the deploy -message with the short commit revision of HEAD: +**Step 4 — deploy with one chained command.** Migrate (when needed) and publish +in the same command so the system spends as little time as possible in an +inconsistent state. `pnpm run deploy` stamps the deploy message with the short +commit revision of HEAD: ```bash pnpm run db:migrate:remote && pnpm run deploy ``` -Print this exact command before running it, and tell the user that if the -deploy stops halfway they can rerun the same command to recover — -`wrangler d1 migrations apply --remote` is idempotent on already-applied -migrations and `wrangler deploy` always publishes the current code. When -there are no pending migrations, the command reduces to `pnpm run deploy`. -Never pass `--dry-run`. +Print this exact command before running it, and tell the user that if the deploy +stops halfway they can rerun the same command to recover — `wrangler d1 +migrations apply --remote` is idempotent on already-applied migrations and +`wrangler deploy` always publishes the current code. When there are no pending +migrations, the command reduces to `pnpm run deploy`. Never pass `--dry-run`. -After the Worker is live, report every recommended operation collected from -the new Deployment Notes. Perform read-only checks directly when they are -within scope. For any state-changing operation that was not already explicitly +After the Worker is live, report every recommended operation collected from the +new Deployment Notes. Perform read-only checks directly when they are within +scope. For any state-changing operation that was not already explicitly authorized, explain the recommendation and ask the user before doing it; never fold it silently into deployment automation. The deployment itself is complete even when a recommended follow-up remains for a later user turn. -Worker rollback by version id (`pnpm wrangler rollback `) -works across the 100 most recent versions, but Cloudflare blocks rollback -when intervening deployments changed Durable Object migrations or removed -referenced KV/R2/Queue bindings. The Worker's bindings (D1, R2, Images, -KV) only ever grow, never shrink — `pnpm run deploy` runs -`pnpm install --frozen-lockfile` first (so a fast-forward that introduced -a new workspace package wires its symlinks before the build runs) then -`scripts/check-wrangler.ts` and refuses to publish if `wrangler.jsonc` -drifts from `wrangler.example.jsonc` in either direction — every key, -value, and binding in the example must appear in the real config, and -the real config must not carry anything the example doesn't pin (aside -from `account_id`, the one personal-only key the gate allowlists). So -plain code rollback stays safe; D1 state is rolled back separately as -above. +Worker rollback by version id (`pnpm wrangler rollback `) works +across the 100 most recent versions, but Cloudflare blocks rollback when +intervening deployments changed Durable Object migrations or removed referenced +KV/R2/Queue bindings. The Worker's bindings (D1, R2, Images, KV) only ever grow, +never shrink — `pnpm run deploy` runs `pnpm install --frozen-lockfile` first +(so a fast-forward that introduced a new workspace package wires its symlinks +before the build runs) then `scripts/check-wrangler.ts` and refuses to publish +if `wrangler.jsonc` drifts from `wrangler.example.jsonc` in either direction — +every key, value, and binding in the example must appear in the real config, and +the real config must not carry anything the example doesn't pin (aside from +`account_id`, the one personal-only key the gate allowlists). So plain code +rollback stays safe; D1 state is rolled back separately as above. A complete deploy without `hard` or `minor` notes fits in a strict turn budget: -**three agent turns when migrations are pending** (Step 1 = gather, -Step 3 = bookmark + report + two rollback commands, Step 4 = deploy) -and **two agent turns when no migrations are pending** (Step 3 collapses -into Turn 1: gather + report + single code-rollback command; Turn 2 = -deploy). Step 2 adds one turn only when new `hard` or `minor` entries exist. -Reporting recommended operations after deploy does not add a deployment turn; -executing one may require a separate authorization turn. +**three agent turns when migrations are pending** (Step 1 = gather, Step 3 = +bookmark + report + two rollback commands, Step 4 = deploy) and **two agent +turns when no migrations are pending** (Step 3 collapses into Turn 1: gather + +report + single code-rollback command; Turn 2 = deploy). Step 2 adds one turn +only when new `hard` or `minor` entries exist. Reporting recommended operations +after deploy does not add a deployment turn; executing one may require a +separate authorization turn. ## Deployment Notes (CHANGELOG.md) @@ -459,24 +461,23 @@ Each entry carries one of three levels: creates or reveals a condition for which an agent or operator should consider a concrete follow-up action. -The date heading format is `## YYYY-MM-DD · hard`, -`## YYYY-MM-DD · minor`, or `## YYYY-MM-DD · advisory`, followed by a -`### Short title` heading naming the change and then its description. Unlike -this file, `CHANGELOG.md` is not hard-wrapped: each paragraph is one line. -Recommended operations may appear in any level; they do not need a separate -advisory entry when they belong to the same hard or minor change. +The date heading format is `## YYYY-MM-DD · hard`, `## YYYY-MM-DD · minor`, or +`## YYYY-MM-DD · advisory`, followed by a `### Short title` heading naming the +change and then its description. Unlike this file, `CHANGELOG.md` is not +hard-wrapped: each paragraph is one line. Recommended operations may appear in +any level; they do not need a separate advisory entry when they belong to the +same hard or minor change. A change qualifies as a breaking change when it causes previously working -user-facing behavior to stop working or behave differently in a way -users must be aware of. Examples: +user-facing behavior to stop working or behave differently in a way users must +be aware of. Examples: -- Affinity or routing redesigns that invalidate existing conversation - context, causing requests to route to unexpected upstreams. -- Dropping stored state (Responses items, snapshots) that clients may - reference by id. -- Removing or renaming fields from public API responses (`/models`, - data-plane output) that downstream consumers or cascaded Floway - instances read. +- Affinity or routing redesigns that invalidate existing conversation context, + causing requests to route to unexpected upstreams. +- Dropping stored state (Responses items, snapshots) that clients may reference + by id. +- Removing or renaming fields from public API responses (`/models`, data-plane + output) that downstream consumers or cascaded Floway instances read. An advisory qualifies only when there is a concrete deployment-related action to report. The following must not appear by themselves: @@ -486,8 +487,8 @@ to report. The following must not appear by themselves: - Export version bumps, internal refactors, and new features that neither alter existing behavior nor require an operator action. -When working on a change and it is unclear whether it constitutes a -`hard` or `minor` breaking change, do not classify it unilaterally — ask the -user to make the call. The user declares what is breaking; the agent records -it. An advisory must state the recommended action, its reason, and enough scope -to avoid accidentally applying it to unrelated state. +When working on a change and it is unclear whether it constitutes a `hard` or +`minor` breaking change, do not classify it unilaterally — ask the user to make +the call. The user declares what is breaking; the agent records it. An advisory +must state the recommended action, its reason, and enough scope to avoid +accidentally applying it to unrelated state. diff --git a/README.md b/README.md index b65a20c4cc..af2e7d24f1 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,13 @@ then routes each model through the API shape the client already speaks. ## Highlights -- Use GitHub Copilot, ChatGPT subscriptions, Claude.ai subscriptions, Azure - OpenAI, custom OpenAI- or Anthropic-compatible providers, and Ollama from one +- Use GitHub Copilot, ChatGPT subscriptions, Claude.ai subscriptions, Azure AI, + custom OpenAI- or Anthropic-compatible providers, and Ollama from one deployment. - Serve OpenAI, Anthropic, Gemini-compatible, audio transcription, and rerank - APIs with - cross-protocol translation where needed. + APIs with cross-protocol translation where needed. +- Discover vendor model catalogs live while retaining manual model configuration + for providers that require or permit it. - Manage upstreams, routing order, model aliases, API keys, and web search from a dashboard. - Generate one-command Claude Code and Codex configurations from an API key. @@ -36,8 +37,8 @@ the password. Then: 3. Give that key to a client as a bearer token or `x-api-key`, or use **Agent Setup** to configure Claude Code or Codex. -The gateway API is also exposed directly at . SQLite and -uploaded files persist in the `floway-data` volume. +The data-plane API is also exposed directly at . SQLite +and uploaded files persist in the `floway-data` volume. ## Compatibility @@ -69,14 +70,14 @@ responses retain their upstream wire shape. ### Upstreams -| Provider | Authentication | -| --- | --- | -| GitHub Copilot | GitHub device OAuth | -| Codex | ChatGPT subscription through the Codex CLI OAuth client | -| Claude Code | Claude.ai Pro or Max subscription through the Claude Code CLI OAuth client | -| Custom | OpenAI- or Anthropic-compatible endpoint and credential | -| Azure | Azure OpenAI endpoint, API key, and deployments | -| Ollama | ollama.com or a self-hosted Ollama-compatible server | +| Provider | Connection | Model catalog | +| --- | --- | --- | +| GitHub Copilot | GitHub device OAuth | Fetched live from Copilot | +| Codex | ChatGPT subscription through the Codex CLI OAuth client | Fetched live from the Codex backend | +| Claude Code | Claude.ai Pro, Max, Team, or Enterprise subscription through the Claude Code CLI OAuth client | Fetched live from Anthropic | +| Custom | OpenAI- or Anthropic-compatible endpoint and credential | Live `/models`, manual models, or both | +| Azure | Azure AI resource or Foundry project endpoint and API key | Configured models | +| Ollama | ollama.com or a self-hosted Ollama-compatible server | Fetched live from Ollama, with optional manual overrides | ## Other Deployment Options @@ -114,8 +115,8 @@ pnpm install ADMIN_KEY='replace-with-a-secret' pnpm run dev:node ``` -It serves the gateway and control-plane APIs but not the dashboard. Use Docker -Compose for the complete self-hosted UI, or serve the web app separately. +It serves the data-plane and control-plane APIs but not the dashboard. Use +Docker Compose for the complete self-hosted UI, or serve the web app separately. Production Node.js deployments must set both `NODE_ENV=production` and a non-empty `ADMIN_KEY`. @@ -132,8 +133,8 @@ pnpm run lint pnpm run typecheck ``` -More detail lives in [AGENTS.md](./AGENTS.md) — architecture, workspace -layout, verification, and contributor rules. +More detail lives in [AGENTS.md](./AGENTS.md) — architecture, workspace layout, +verification, and contributor rules. ## License diff --git a/apps/platform-cloudflare/package.json b/apps/platform-cloudflare/package.json index 6648752f9b..57d354da79 100644 --- a/apps/platform-cloudflare/package.json +++ b/apps/platform-cloudflare/package.json @@ -10,7 +10,6 @@ "@floway-dev/gateway": "workspace:*", "@floway-dev/http": "workspace:*", "@floway-dev/platform": "workspace:*", - "@floway-dev/protocols": "workspace:*", "hono": "^4" }, "devDependencies": { diff --git a/apps/platform-cloudflare/src/bootstrap.ts b/apps/platform-cloudflare/src/bootstrap.ts index e195bf4b79..1a2bcc1c6d 100644 --- a/apps/platform-cloudflare/src/bootstrap.ts +++ b/apps/platform-cloudflare/src/bootstrap.ts @@ -2,7 +2,7 @@ import { DurableObjectChannelBroker, type BroadcastNamespace } from './do-channe import { createCloudflareExternalResourceFetcher } from './external-resource-fetcher.ts'; import { createCloudflareImageProcessor, type ImagesBinding } from './image-processor.ts'; import { KvImageCache, type KvNamespace } from './kv-image-cache.ts'; -import { R2FileProvider, type R2BucketLike } from './r2-file-provider.ts'; +import { R2FileStore, type R2BucketLike } from './r2-file-store.ts'; import { cloudflareSocketDial } from './socket-dial.ts'; import { cloudflareRuntimeRootCAs } from './tls-trust.ts'; import { FileDumpStore, initDumpBroker, initDumpStore } from '@floway-dev/gateway'; @@ -13,7 +13,7 @@ import { IMAGE_CACHE_POLICY, initEnv, initExternalResourceFetcher, - initFileProvider, + initFileStore, initImageCacheStore, initImageProcessor, initRuntimeKind, @@ -53,8 +53,8 @@ export const bootstrapCloudflarePlatform = (env: CloudflareEnv): { db: SqlDataba }); initRuntimeKind('cloudflare'); initExternalResourceFetcher(createCloudflareExternalResourceFetcher()); - const files = new R2FileProvider(env.FILES); - initFileProvider(files); + const files = new R2FileStore(env.FILES); + initFileStore(files); initImageCacheStore(new KvImageCache(env.KV, IMAGE_CACHE_POLICY)); initImageProcessor(createCloudflareImageProcessor(env.IMAGES)); initSocketDial(cloudflareSocketDial); diff --git a/apps/platform-cloudflare/src/do-channel-broker.ts b/apps/platform-cloudflare/src/do-channel-broker.ts index 82bb4a7768..6e3fd688e7 100644 --- a/apps/platform-cloudflare/src/do-channel-broker.ts +++ b/apps/platform-cloudflare/src/do-channel-broker.ts @@ -1,4 +1,4 @@ -import type { ChannelBroker, Codec } from '@floway-dev/gateway/channel-broker'; +import type { ChannelBroker, ChannelCodec } from '@floway-dev/platform'; // Minimal namespace surface for BROADCAST_DO — declared locally so this // file stays off `@cloudflare/workers-types`. @@ -16,7 +16,7 @@ interface BroadcastStub { export class DurableObjectChannelBroker implements ChannelBroker { constructor( private readonly namespace: BroadcastNamespace, - private readonly codec: Codec, + private readonly codec: ChannelCodec, ) {} private stub(channelId: string): BroadcastStub { @@ -42,7 +42,7 @@ export class DurableObjectChannelBroker implements ChannelBroker { const iterateFromBroadcastSocket = ( stub: BroadcastStub, signal: AbortSignal, - codec: Codec, + codec: ChannelCodec, ): AsyncIterable => { const queue: T[] = []; let resolveNext: ((value: IteratorResult) => void) | null = null; diff --git a/apps/platform-cloudflare/src/do-channel-broker_test.ts b/apps/platform-cloudflare/src/do-channel-broker_test.ts index 358361b2d3..492ac2a13e 100644 --- a/apps/platform-cloudflare/src/do-channel-broker_test.ts +++ b/apps/platform-cloudflare/src/do-channel-broker_test.ts @@ -1,14 +1,14 @@ import { test } from 'vitest'; import { DurableObjectChannelBroker, type BroadcastNamespace } from './do-channel-broker.ts'; -import type { Codec } from '@floway-dev/gateway/channel-broker'; +import type { ChannelCodec } from '@floway-dev/platform'; import { assertEquals } from '@floway-dev/test-utils'; // String codec: encode passes through, decode rejects payloads prefixed with // `bad:` so the parse-fail path has a deterministic trigger. Every test below // drives the generic broker through this codec, so the broker's typing flows // without any reference to a higher-level payload shape. -const stringCodec: Codec = { +const stringCodec: ChannelCodec = { encode: value => value, decode: payload => { if (payload.startsWith('bad:')) { diff --git a/apps/platform-cloudflare/src/r2-file-provider.ts b/apps/platform-cloudflare/src/r2-file-store.ts similarity index 90% rename from apps/platform-cloudflare/src/r2-file-provider.ts rename to apps/platform-cloudflare/src/r2-file-store.ts index 0f9306348a..ab1c018e16 100644 --- a/apps/platform-cloudflare/src/r2-file-provider.ts +++ b/apps/platform-cloudflare/src/r2-file-store.ts @@ -1,4 +1,4 @@ -import type { FileProvider } from '@floway-dev/platform'; +import type { FileStore } from '@floway-dev/platform'; export interface R2BucketLike { put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null): Promise; @@ -11,7 +11,7 @@ export interface R2BucketLike { // https://developers.cloudflare.com/r2/api/workers/workers-api-reference/#delete const R2_BATCH_LIMIT = 1000; -export class R2FileProvider implements FileProvider { +export class R2FileStore implements FileStore { constructor(private readonly bucket: R2BucketLike) {} async put(key: string, body: Uint8Array): Promise { diff --git a/apps/platform-cloudflare/src/r2-file-provider_test.ts b/apps/platform-cloudflare/src/r2-file-store_test.ts similarity index 84% rename from apps/platform-cloudflare/src/r2-file-provider_test.ts rename to apps/platform-cloudflare/src/r2-file-store_test.ts index ecc89bb669..b0894a09e0 100644 --- a/apps/platform-cloudflare/src/r2-file-provider_test.ts +++ b/apps/platform-cloudflare/src/r2-file-store_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { R2FileProvider, type R2BucketLike } from './r2-file-provider.ts'; +import { R2FileStore, type R2BucketLike } from './r2-file-store.ts'; import { assertEquals } from '@floway-dev/test-utils'; class FakeR2Bucket implements R2BucketLike { @@ -27,12 +27,12 @@ class FakeR2Bucket implements R2BucketLike { } -test('R2FileProvider deletes exact keys in one R2 batch', async () => { +test('R2FileStore deletes exact keys in one R2 batch', async () => { const bucket = new FakeR2Bucket(); await bucket.put('drop/a', new Uint8Array([1])); await bucket.put('drop/ab', new Uint8Array([2])); - await new R2FileProvider(bucket).deleteKeys(['drop/a', 'missing']); + await new R2FileStore(bucket).deleteKeys(['drop/a', 'missing']); assertEquals([...bucket.store.keys()], ['drop/ab']); assertEquals(bucket.deleteCalls, [['drop/a', 'missing']]); diff --git a/apps/platform-cloudflare/tsconfig.json b/apps/platform-cloudflare/tsconfig.json index 0360b0b8cd..ad1eaa839f 100644 --- a/apps/platform-cloudflare/tsconfig.json +++ b/apps/platform-cloudflare/tsconfig.json @@ -1,8 +1,4 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { - "jsx": "react-jsx", - "jsxImportSource": "hono/jsx" - }, "include": ["entry.ts", "vitest.config.ts", "src/**/*.ts", "test/**/*.ts"] } diff --git a/apps/platform-node/package.json b/apps/platform-node/package.json index fe7005eff2..3c6642b237 100644 --- a/apps/platform-node/package.json +++ b/apps/platform-node/package.json @@ -14,7 +14,6 @@ "@floway-dev/gateway": "workspace:*", "@floway-dev/http": "workspace:*", "@floway-dev/platform": "workspace:*", - "@floway-dev/protocols": "workspace:*", "@hono/node-server": "^2.0.4", "hono": "^4", "sharp": "^0.35.3", diff --git a/apps/platform-node/src/bootstrap.ts b/apps/platform-node/src/bootstrap.ts index ac826c48a5..1da7bd412f 100644 --- a/apps/platform-node/src/bootstrap.ts +++ b/apps/platform-node/src/bootstrap.ts @@ -1,6 +1,6 @@ import { EventTargetChannelBroker } from './event-target-channel-broker.ts'; import { createNodeExternalResourceFetcher } from './external-resource-fetcher.ts'; -import { FsFileProvider } from './fs-file-provider.ts'; +import { FsFileStore } from './fs-file-store.ts'; import { createNodeSqliteDatabase } from './node-sqlite-database.ts'; import { createSharpImageProcessor } from './sharp-image-processor.ts'; import { nodeSocketDial } from './socket-dial.ts'; @@ -15,7 +15,7 @@ import { IMAGE_CACHE_POLICY, initEnv, initExternalResourceFetcher, - initFileProvider, + initFileStore, initImageCacheStore, initImageProcessor, initRuntimeKind, @@ -31,8 +31,8 @@ export const bootstrapNodePlatform = (): { db: SqlDatabase } => { const filesDir = getEnvOptional('FLOWAY_FILES_DIR', './data/files'); const dbPath = getEnvOptional('FLOWAY_DB_PATH', './data/floway.db'); - const files = new FsFileProvider(filesDir); - initFileProvider(files); + const files = new FsFileStore(filesDir); + initFileStore(files); initSocketDial(nodeSocketDial); addTrustedRootCAs(nodeRuntimeRootCAs); const db = createNodeSqliteDatabase(dbPath); diff --git a/apps/platform-node/src/event-target-channel-broker.ts b/apps/platform-node/src/event-target-channel-broker.ts index f7ef969dc4..7e23be4867 100644 --- a/apps/platform-node/src/event-target-channel-broker.ts +++ b/apps/platform-node/src/event-target-channel-broker.ts @@ -1,4 +1,4 @@ -import type { ChannelBroker, Codec } from '@floway-dev/gateway/channel-broker'; +import type { ChannelBroker, ChannelCodec } from '@floway-dev/platform'; // In-process per-channel fan-out backed by EventTarget. The Node deployment // target only ever runs one worker process per gateway instance, so a Map of @@ -6,7 +6,7 @@ import type { ChannelBroker, Codec } from '@floway-dev/gateway/channel-broker'; export class EventTargetChannelBroker implements ChannelBroker { private readonly targets = new Map(); - constructor(private readonly codec: Codec) {} + constructor(private readonly codec: ChannelCodec) {} private targetFor(channelId: string): EventTarget { let target = this.targets.get(channelId); @@ -41,7 +41,7 @@ export class EventTargetChannelBroker implements ChannelBroker { const iterateFromTarget = ( target: EventTarget, signal: AbortSignal, - codec: Codec, + codec: ChannelCodec, ): AsyncIterable => { const queue: T[] = []; let resolveNext: ((value: IteratorResult) => void) | null = null; diff --git a/apps/platform-node/src/event-target-channel-broker_test.ts b/apps/platform-node/src/event-target-channel-broker_test.ts index dfa3812873..53dcee9e7c 100644 --- a/apps/platform-node/src/event-target-channel-broker_test.ts +++ b/apps/platform-node/src/event-target-channel-broker_test.ts @@ -1,13 +1,13 @@ import { test } from 'vitest'; import { EventTargetChannelBroker } from './event-target-channel-broker.ts'; -import type { Codec } from '@floway-dev/gateway/channel-broker'; +import type { ChannelCodec } from '@floway-dev/platform'; import { assertEquals } from '@floway-dev/test-utils'; // String codec: encode passes through, decode is identity. Every test below // drives the generic broker through this codec, so the broker's typing flows // without any reference to a higher-level payload shape. -const stringCodec: Codec = { +const stringCodec: ChannelCodec = { encode: value => value, decode: payload => payload, }; diff --git a/apps/platform-node/src/fs-file-provider.ts b/apps/platform-node/src/fs-file-store.ts similarity index 84% rename from apps/platform-node/src/fs-file-provider.ts rename to apps/platform-node/src/fs-file-store.ts index ce827e0503..7560d16202 100644 --- a/apps/platform-node/src/fs-file-provider.ts +++ b/apps/platform-node/src/fs-file-store.ts @@ -2,9 +2,9 @@ import { mkdirSync } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute, resolve, sep } from 'node:path'; -import type { FileProvider } from '@floway-dev/platform'; +import type { FileStore } from '@floway-dev/platform'; -// Filesystem-backed FileProvider. Every key resolves to a path under `root`. +// Filesystem-backed FileStore. Every key resolves to a path under `root`. // Keys use forward-slash POSIX separators (matching R2's surface) and are // translated to native path segments on the way in/out so the same key reads // identically on Windows and POSIX hosts. @@ -18,7 +18,7 @@ import type { FileProvider } from '@floway-dev/platform'; // dedicated user). The dashboard redacts sensitive headers at render time // for human display, but the on-disk record stays untouched so an operator // can replay or diff against upstream byte-for-byte. -export class FsFileProvider implements FileProvider { +export class FsFileStore implements FileStore { private readonly root: string; constructor(root: string) { @@ -49,14 +49,14 @@ export class FsFileProvider implements FileProvider { } // Resolve a key against `root` and reject paths that escape it. Even though - // the FileProvider contract treats keys as opaque, callers are not required + // the FileStore contract treats keys as opaque, callers are not required // to scrub user-controlled segments and a `..`-laden key would otherwise // walk to arbitrary host paths under R2 it would simply be a strange key. private pathFor(key: string): string { - if (isAbsolute(key)) throw new Error(`FsFileProvider: absolute keys are not supported (${key})`); + if (isAbsolute(key)) throw new Error(`FsFileStore: absolute keys are not supported (${key})`); const path = resolve(this.root, ...key.split('/')); if (path !== this.root && !path.startsWith(this.root + sep)) { - throw new Error(`FsFileProvider: key escapes root (${key})`); + throw new Error(`FsFileStore: key escapes root (${key})`); } return path; } diff --git a/apps/platform-node/src/fs-file-provider_test.ts b/apps/platform-node/src/fs-file-store_test.ts similarity index 50% rename from apps/platform-node/src/fs-file-provider_test.ts rename to apps/platform-node/src/fs-file-store_test.ts index 248f389f15..155bfac130 100644 --- a/apps/platform-node/src/fs-file-provider_test.ts +++ b/apps/platform-node/src/fs-file-store_test.ts @@ -4,11 +4,11 @@ import { join } from 'node:path'; import { test } from 'vitest'; -import { FsFileProvider } from './fs-file-provider.ts'; +import { FsFileStore } from './fs-file-store.ts'; import { assertEquals } from '@floway-dev/test-utils'; const withTempRoot = async (fn: (root: string) => Promise): Promise => { - const root = await mkdtemp(join(tmpdir(), 'fs-file-provider-')); + const root = await mkdtemp(join(tmpdir(), 'fs-file-store-')); try { await fn(root); } finally { @@ -17,33 +17,33 @@ const withTempRoot = async (fn: (root: string) => Promise): Promise }; test('put then get round-trips binary content', () => withTempRoot(async root => { - const provider = new FsFileProvider(root); + const store = new FsFileStore(root); const bytes = new Uint8Array([0, 1, 2, 0xff, 0xfe, 0x80]); - await provider.put('blobs/a.bin', bytes); - const read = await provider.get('blobs/a.bin'); + await store.put('blobs/a.bin', bytes); + const read = await store.get('blobs/a.bin'); assertEquals(read, bytes); })); test('get returns null for missing keys', () => withTempRoot(async root => { - const provider = new FsFileProvider(root); - const read = await provider.get('missing'); + const store = new FsFileStore(root); + const read = await store.get('missing'); assertEquals(read, null); })); test('deleteKeys removes exact files and ignores missing keys', () => withTempRoot(async root => { - const provider = new FsFileProvider(root); - await provider.put('cleanup/a.bin', new Uint8Array([1])); - await provider.put('cleanup/ab.bin', new Uint8Array([2])); + const store = new FsFileStore(root); + await store.put('cleanup/a.bin', new Uint8Array([1])); + await store.put('cleanup/ab.bin', new Uint8Array([2])); - await provider.deleteKeys(['cleanup/a.bin', 'missing.bin']); + await store.deleteKeys(['cleanup/a.bin', 'missing.bin']); - assertEquals(await provider.get('cleanup/a.bin'), null); - assertEquals(await provider.get('cleanup/ab.bin'), new Uint8Array([2])); + assertEquals(await store.get('cleanup/a.bin'), null); + assertEquals(await store.get('cleanup/ab.bin'), new Uint8Array([2])); })); test('put creates intermediate directories', () => withTempRoot(async root => { - const provider = new FsFileProvider(root); - await provider.put('deeply/nested/path/file.bin', new Uint8Array([42])); - const read = await provider.get('deeply/nested/path/file.bin'); + const store = new FsFileStore(root); + await store.put('deeply/nested/path/file.bin', new Uint8Array([42])); + const read = await store.get('deeply/nested/path/file.bin'); assertEquals(read, new Uint8Array([42])); })); diff --git a/docs/RESOLUTION.md b/docs/RESOLUTION.md index 971746b6b5..f14328c95b 100644 --- a/docs/RESOLUTION.md +++ b/docs/RESOLUTION.md @@ -228,7 +228,7 @@ The rule overlay rides on the `ModelCandidate.rules` field. Dispatch reads it in each attempt's terminal wire call, right before destructuring `payload.model` out of the body, via `applyRulesToUpstream{ChatCompletions,Responses,Messages}` in -`data-plane/model-aliases/apply-rules.ts`. Passthrough seams thread +`data-plane/chat/shared/alias-rules.ts`. Passthrough seams thread alias-origin candidates through the same iteration but never observe non-empty rules (non-chat alias kinds — `embedding`, `image`, `rerank`, `transcription` — carry @@ -249,8 +249,8 @@ The upstream response's `model` field reports the model that actually served the request, so a client that wants to attribute a response to a particular target can compare that against the id it sent. Alias listing behavior on `/v1/models`, -`/v1beta/models`, and the Codex catalog is covered in the alias -implementation notes under `data-plane/model-aliases/`. +`/v1beta/models`, and the Codex catalog is implemented in +`data-plane/shared/listing/alias.ts`. ## Candidate Shape diff --git a/package.json b/package.json index c2d3d942fd..3973e9a325 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "husky": "^9.1.7", "jiti": "2.6.1", "jsonc-parser": "^3.3.1", - "sql.js": "^1.14.1", "typescript": "^5.9.3", "vitest": "4.0.18", "vue-eslint-parser": "^10.4.0", diff --git a/packages/gateway/package.json b/packages/gateway/package.json index 478c9a44ad..4741ef149a 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -11,23 +11,12 @@ "./app-type": { "types": "./src/app.ts" }, - "./channel-broker": { - "import": "./src/runtime/channel-broker-contract.ts", - "types": "./src/runtime/channel-broker-contract.ts" - }, "./control-plane/proxies/serialize": { "types": "./src/control-plane/proxies/serialize.ts" }, - "./control-plane/pricing/types": { - "types": "./src/control-plane/pricing/types.ts" - }, "./control-plane/performance/aggregate": { "types": "./src/control-plane/performance/aggregate.ts" }, - "./data-plane/tools/web-search/types": { - "import": "./src/data-plane/tools/web-search/types.ts", - "types": "./src/data-plane/tools/web-search/types.ts" - }, "./dump-codec": { "import": "./src/dump/codec.ts", "types": "./src/dump/codec.ts" @@ -62,6 +51,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@floway-dev/test-utils": "workspace:*" + "@floway-dev/test-utils": "workspace:*", + "sql.js": "^1.14.1" } } diff --git a/packages/gateway/src/control-plane/auth/routes_test.ts b/packages/gateway/src/control-plane/auth/routes_test.ts index 9b0b398566..8deeaa558c 100644 --- a/packages/gateway/src/control-plane/auth/routes_test.ts +++ b/packages/gateway/src/control-plane/auth/routes_test.ts @@ -278,21 +278,6 @@ test('/auth/me reports viaApiKey:true and the API key metadata when authed via x assertEquals(body.apiKey.name, apiKey.name); }); -test('old /auth GitHub management routes are removed', async () => { - const { repo } = await setupAppTest(); - const session = await repo.sessions.create(1); - - const start = await requestApp('/auth/github', { method: 'GET', headers: { 'x-floway-session': session.id } }); - const order = await requestApp('/auth/github/order', { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-floway-session': session.id }, - body: JSON.stringify({ user_ids: [1] }), - }); - - assertEquals(start.status, 404); - assertEquals(order.status, 404); -}); - test('/api/upstreams/copilot/oauth/device-login/start starts GitHub device flow', async () => { const { adminSession } = await setupAppTest(); diff --git a/packages/gateway/src/control-plane/data-transfer/routes.ts b/packages/gateway/src/control-plane/data-transfer/routes.ts index 859eea6974..8209140c5f 100644 --- a/packages/gateway/src/control-plane/data-transfer/routes.ts +++ b/packages/gateway/src/control-plane/data-transfer/routes.ts @@ -11,7 +11,7 @@ import type { Context } from 'hono'; import { fetchUpstreamModelsCached } from '../../data-plane/providers/models-cache.ts'; -import { createProviderInstance } from '../../data-plane/providers/registry.ts'; +import { createProvider } from '../../data-plane/providers/registry.ts'; import { parseSearchConfigDefault, parseSearchConfigStrict } from '../../data-plane/tools/web-search/search-config.ts'; import type { SearchConfig } from '../../data-plane/tools/web-search/types.ts'; import { createPerRequestFetcher } from '../../dial/per-request.ts'; @@ -658,10 +658,10 @@ const parsePerformanceRecords = (value: unknown): { type: 'ok'; records: Perform // genuine misconfiguration and are not swallowed. const warmModelsCache = async (record: UpstreamRecord, c: Context): Promise => { const scheduler = backgroundSchedulerFromContext(c); - const instance = createProviderInstance(record); + const provider = createProvider(record); const fetcher = (await createPerRequestFetcher(getRuntimeLocation(c.req.raw)))(record.id); try { - await fetchUpstreamModelsCached(instance, { scheduler, fetcher, force: true }); + await fetchUpstreamModelsCached(provider, { scheduler, fetcher, force: true }); } catch {} }; diff --git a/packages/gateway/src/control-plane/performance/routes.ts b/packages/gateway/src/control-plane/performance/routes.ts index edca7c14f0..cd42e30d35 100644 --- a/packages/gateway/src/control-plane/performance/routes.ts +++ b/packages/gateway/src/control-plane/performance/routes.ts @@ -21,7 +21,7 @@ import { type CtxWithQuery } from '../../middleware/zod-validator.ts'; import { getRepo } from '../../repo/index.ts'; import type { PerformanceTelemetryRecord } from '../../repo/types.ts'; import type { performanceQuery } from '../schemas.ts'; -import { buildKeyToUserMap } from '../telemetry-view.ts'; +import { buildKeyToUserMap } from '../shared/key-to-user.ts'; type Ctx = CtxWithQuery; diff --git a/packages/gateway/src/control-plane/pricing/types.ts b/packages/gateway/src/control-plane/pricing/types.ts deleted file mode 100644 index 2233d3e28b..0000000000 --- a/packages/gateway/src/control-plane/pricing/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type { BillingMetric, ModelPricing } from '@floway-dev/protocols/common'; diff --git a/packages/gateway/src/control-plane/routes.ts b/packages/gateway/src/control-plane/routes.ts index e051f258de..df8364c7bf 100644 --- a/packages/gateway/src/control-plane/routes.ts +++ b/packages/gateway/src/control-plane/routes.ts @@ -111,5 +111,3 @@ export const controlPlaneRoutes = new Hono<{ Variables: AuthVars }>() .post('/search-config/test', zValidator('json', searchConfigSchema), testSearchConfigRoute) .get('/export', zValidator('query', exportQuery), exportData) .post('/import', zValidator('json', importBody), importData)); - -export type ControlPlaneRoutes = typeof controlPlaneRoutes; diff --git a/packages/gateway/src/control-plane/search-usage/routes.ts b/packages/gateway/src/control-plane/search-usage/routes.ts index f5433fa8aa..07e4d25ef9 100644 --- a/packages/gateway/src/control-plane/search-usage/routes.ts +++ b/packages/gateway/src/control-plane/search-usage/routes.ts @@ -11,7 +11,8 @@ import { type CtxWithQuery } from '../../middleware/zod-validator.ts'; import { getRepo } from '../../repo/index.ts'; import { isWebSearchProviderName } from '../../shared/web-search-providers.ts'; import type { searchUsageQuery } from '../schemas.ts'; -import { resolveTelemetryView, buildKeyToUserMap } from '../telemetry-view.ts'; +import { buildKeyToUserMap } from '../shared/key-to-user.ts'; +import { resolveUsageView } from '../usage-view.ts'; export const searchUsage = async (c: CtxWithQuery) => { const query = c.req.valid('query'); @@ -25,7 +26,7 @@ export const searchUsage = async (c: CtxWithQuery) => { return c.json({ error: "provider must be 'tavily' or 'microsoft-grounding'" }, 400); } - const resolved = resolveTelemetryView(c, query.view, query.key_id); + const resolved = resolveUsageView(c, query.view, query.key_id); if ('error' in resolved) { return c.json({ error: resolved.message }, resolved.error === 'forbidden' ? 403 : 400); } diff --git a/packages/gateway/src/control-plane/shared/key-to-user.ts b/packages/gateway/src/control-plane/shared/key-to-user.ts new file mode 100644 index 0000000000..f234a1cc1c --- /dev/null +++ b/packages/gateway/src/control-plane/shared/key-to-user.ts @@ -0,0 +1,7 @@ +import type { ApiKey } from '../../repo/types.ts'; + +// Usage and performance rows carry a key id, not a user id — this join +// attributes them to a user. +export const buildKeyToUserMap = ( + keys: readonly ApiKey[], +): ReadonlyMap => new Map(keys.map(k => [k.id, k.userId] as const)); diff --git a/packages/gateway/src/control-plane/telemetry-view_test.ts b/packages/gateway/src/control-plane/shared/key-to-user_test.ts similarity index 91% rename from packages/gateway/src/control-plane/telemetry-view_test.ts rename to packages/gateway/src/control-plane/shared/key-to-user_test.ts index 3f29392eaa..f5fda06836 100644 --- a/packages/gateway/src/control-plane/telemetry-view_test.ts +++ b/packages/gateway/src/control-plane/shared/key-to-user_test.ts @@ -1,7 +1,7 @@ import { describe, test } from 'vitest'; -import { buildKeyToUserMap } from './telemetry-view.ts'; -import type { ApiKey } from '../repo/types.ts'; +import { buildKeyToUserMap } from './key-to-user.ts'; +import type { ApiKey } from '../../repo/types.ts'; import { assertEquals } from '@floway-dev/test-utils'; // Zero-value ApiKey defaults so a case only names what it exercises. diff --git a/packages/gateway/src/control-plane/token-usage/routes.ts b/packages/gateway/src/control-plane/token-usage/routes.ts index 1297a73980..3bb7e44c49 100644 --- a/packages/gateway/src/control-plane/token-usage/routes.ts +++ b/packages/gateway/src/control-plane/token-usage/routes.ts @@ -8,7 +8,8 @@ import { aggregateUsageByUserForDisplay, aggregateUsageForDisplay } from './aggr import { type CtxWithQuery } from '../../middleware/zod-validator.ts'; import { getRepo } from '../../repo/index.ts'; import type { tokenUsageQuery } from '../schemas.ts'; -import { buildKeyToUserMap, resolveTelemetryView } from '../telemetry-view.ts'; +import { buildKeyToUserMap } from '../shared/key-to-user.ts'; +import { resolveUsageView } from '../usage-view.ts'; export const tokenUsage = async (c: CtxWithQuery) => { const query = c.req.valid('query'); @@ -17,7 +18,7 @@ export const tokenUsage = async (c: CtxWithQuery) => { } const { start, end } = query; - const resolved = resolveTelemetryView(c, query.view, query.key_id); + const resolved = resolveUsageView(c, query.view, query.key_id); if ('error' in resolved) { return c.json({ error: resolved.message }, resolved.error === 'forbidden' ? 403 : 400); } diff --git a/packages/gateway/src/control-plane/upstreams/routes.ts b/packages/gateway/src/control-plane/upstreams/routes.ts index b733dbde5a..f96656d501 100644 --- a/packages/gateway/src/control-plane/upstreams/routes.ts +++ b/packages/gateway/src/control-plane/upstreams/routes.ts @@ -4,7 +4,7 @@ import { resolveControlPlaneFetcher } from './proxy-resolution.ts'; import { blueprintUpstreamRecord, upstreamRecordToFullJson, upstreamRecordToJson, type SerializedUpstreamRecord } from './serialize.ts'; import { MODEL_LISTING_FAILURE_MESSAGE } from '../../data-plane/models/shared.ts'; import { fetchUpstreamModelsCached } from '../../data-plane/providers/models-cache.ts'; -import { createProviderInstance } from '../../data-plane/providers/registry.ts'; +import { createProvider } from '../../data-plane/providers/registry.ts'; import { createPerRequestFetcher } from '../../dial/per-request.ts'; import { type AuthedContext, userFromContext } from '../../middleware/auth.ts'; import { type CtxWithJson } from '../../middleware/zod-validator.ts'; @@ -13,7 +13,6 @@ import { isDirectFallbackId, normalizeProxyFallbackList } from '../../repo/proxy import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; import { shortId } from '../../shared/short-id.ts'; -import { fetchGitHubUser, pollGitHubDeviceFlow, startGitHubDeviceFlow, type GitHubUser } from '../auth/github-device-flow.ts'; import type { claudeCodeOauthAuthorizeUrlBody, claudeCodeOauthExchangeBody, claudeCodeOauthRefreshBody, claudeCodeProbeBody, claudeCodeSetupTokenAuthorizeUrlBody, claudeCodeSetupTokenExchangeBody, codexOauthAuthorizeUrlBody, codexOauthExchangeBody, codexOauthRefreshBody, copilotOauthDeviceLoginPollBody, copilotQuotaBody, createUpstreamBody, listModelsBody, updateUpstreamBody } from '../schemas.ts'; import { copilotConfigField, type CopilotUpstreamConfig, isRecord } from '../shared/field-validators.ts'; import { @@ -61,7 +60,20 @@ import { importCodexFromCallback, mintCodexAccessToken, } from '@floway-dev/provider-codex'; -import { clearInProcessCopilotTokenCache, emptyCopilotUpstreamState, exchangeCopilotToken, githubHeaders, readCopilotUpstreamState, type CopilotTokenEntry, type CopilotUpstreamState } from '@floway-dev/provider-copilot'; +import { + clearInProcessCopilotTokenCache, + emptyCopilotUpstreamState, + exchangeCopilotToken, + fetchCopilotUsage, + fetchGitHubUser, + pollGitHubDeviceFlow, + readCopilotUpstreamState, + startGitHubDeviceFlow, + type CopilotTokenEntry, + type CopilotUpstreamState, + type CopilotUpstreamUser, + type CopilotUsageResponse, +} from '@floway-dev/provider-copilot'; import { assertCustomUpstreamRecord, fetchCustomModels } from '@floway-dev/provider-custom'; import { assertOllamaUpstreamRecord, createOllamaProvider } from '@floway-dev/provider-ollama'; @@ -179,10 +191,10 @@ const normalizeModelPrefixField = (input: unknown): ValidationResult => { const scheduler = backgroundSchedulerFromContext(c); - const instance = createProviderInstance(record); + const provider = createProvider(record); const fetcher = (await createPerRequestFetcher(getRuntimeLocation(c.req.raw)))(record.id); try { - await fetchUpstreamModelsCached(instance, { scheduler, fetcher, force: true }); + await fetchUpstreamModelsCached(provider, { scheduler, fetcher, force: true }); } catch (e) { // runFetch persists upstream failures to the row's `lastError`; anything // reaching here is a Floway-side fault (lastError write itself failed, @@ -425,7 +437,7 @@ export const copilotOauthDeviceLoginPoll = async (c: CtxWithJson) => { if (!githubToken) return c.json({ error: 'Copilot upstream has no GitHub token' }, 400); const fetcher = await resolveControlPlaneFetcher({ override: record.proxy_fallback_list, runtimeLocation: getRuntimeLocation(c.req.raw) }); - const resp = await fetcher('https://api.github.com/copilot_internal/user', { headers: githubHeaders(githubToken) }); + const resp = await fetchCopilotUsage(githubToken, fetcher); if (!resp.ok) { const text = await resp.text(); @@ -1008,10 +992,10 @@ export const listModels = async (c: CtxWithJson) => { // Force through the SWR cache when the record is persisted so the // side-effect refresh keeps the data-plane cache in step; otherwise // live-fetch without any caching. - const instance = createProviderInstance(synthRecord); + const provider = createProvider(synthRecord); const models = record.id !== '' - ? await fetchUpstreamModelsCached(instance, { scheduler, fetcher, force: true }) - : await instance.instance.getProvidedModels(fetcher); + ? await fetchUpstreamModelsCached(provider, { scheduler, fetcher, force: true }) + : await provider.instance.getProvidedModels(fetcher); return c.json({ data: models.map(reshapeModelForDashboard) }); } catch (e) { if (e instanceof ProviderModelsUnavailableError) { diff --git a/packages/gateway/src/control-plane/telemetry-view.ts b/packages/gateway/src/control-plane/usage-view.ts similarity index 62% rename from packages/gateway/src/control-plane/telemetry-view.ts rename to packages/gateway/src/control-plane/usage-view.ts index f52045096e..094ef53a76 100644 --- a/packages/gateway/src/control-plane/telemetry-view.ts +++ b/packages/gateway/src/control-plane/usage-view.ts @@ -1,19 +1,18 @@ import { type AuthedContext, userFromContext } from '../middleware/auth.ts'; -import type { ApiKey } from '../repo/types.ts'; // The two shapes the usage endpoints answer in. -type TelemetryView = 'all-by-user' | 'self-by-key'; +type UsageView = 'all-by-user' | 'self-by-key'; // Discriminated union so callers narrow scopeUserId without non-null assertions. -type ResolvedTelemetryView = +type ResolvedUsageView = | { view: 'self-by-key'; scopeUserId: number } | { view: 'all-by-user' }; -export const resolveTelemetryView = ( +export const resolveUsageView = ( c: AuthedContext, - view: TelemetryView, + view: UsageView, rawKeyId: string | undefined, -): ResolvedTelemetryView | { error: 'forbidden' | 'bad_request'; message: string } => { +): ResolvedUsageView | { error: 'forbidden' | 'bad_request'; message: string } => { const user = userFromContext(c); if (view === 'self-by-key') return { view: 'self-by-key', scopeUserId: user.id }; @@ -33,9 +32,3 @@ export const resolveTelemetryView = ( } return { view: 'all-by-user' }; }; - -// Telemetry rows carry a key id, not a user id — this is the join that -// attributes them to a user. -export const buildKeyToUserMap = ( - keys: readonly ApiKey[], -): ReadonlyMap => new Map(keys.map(k => [k.id, k.userId] as const)); diff --git a/packages/gateway/src/data-plane/chat/chat-completions/attempt.ts b/packages/gateway/src/data-plane/chat/chat-completions/attempt.ts index 074d0b74c5..6969066ea3 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/attempt.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/attempt.ts @@ -1,11 +1,13 @@ import { chatCompletionsInterceptors } from './interceptors/index.ts'; import type { ChatCompletionsInvocation } from './interceptors/types.ts'; -import { applyRulesToUpstreamChatCompletions } from '../../model-aliases/apply-rules.ts'; -import { providerStreamResultToExecuteResult, buildUpstreamCallOptions, chatTargetPicker } from '../../shared/telemetry/attempt-helpers.ts'; +import { buildUpstreamCallOptions } from '../../shared/upstream-call-options.ts'; import { messagesAttempt } from '../messages/attempt.ts'; import { responsesAttempt } from '../responses/attempt.ts'; +import { applyRulesToUpstreamChatCompletions } from '../shared/alias-rules.ts'; import { createExternalImageLoader } from '../shared/external-image-loader.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; +import { providerStreamResultToExecuteResult } from '../shared/provider-stream-result.ts'; +import { chatTargetPicker } from '../shared/target-picker.ts'; import { traverseTranslation } from '../shared/translate-traverse.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { ChatCompletionsPayload, ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; diff --git a/packages/gateway/src/data-plane/chat/chat-completions/http.ts b/packages/gateway/src/data-plane/chat/chat-completions/http.ts index 277090a9b4..8d8a0a209f 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/http.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/http.ts @@ -27,7 +27,7 @@ const respondWithInternalError = async (c: AuthedContext, error: unknown, reques if (verbatim !== null) return verbatim; const effectiveCtx = ctx ?? createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); const result = internalErrorResult(502, toInternalDebugError(error), effectiveCtx.attempt.telemetry); - const { response } = await respondChatCompletions(c, result, false, false, effectiveCtx); + const response = await respondChatCompletions(c, result, false, false, effectiveCtx); return finalizeGatewayResponse(effectiveCtx, response); }; @@ -37,7 +37,7 @@ const respondWithInternalError = async (c: AuthedContext, error: unknown, reques const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: RequestBody, ctx?: GatewayCtx): Promise => { if (!(error instanceof TranslatorInputError)) return await respondWithInternalError(c, error, requestBody, ctx); const effectiveCtx = ctx ?? createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - const { response } = await respondChatCompletions(c, translatorInputErrorResult(error, effectiveCtx.attempt.telemetry), false, false, effectiveCtx); + const response = await respondChatCompletions(c, translatorInputErrorResult(error, effectiveCtx.attempt.telemetry), false, false, effectiveCtx); return (effectiveCtx.dump?.finalize(response) ?? response); }; @@ -56,7 +56,7 @@ export const chatCompletionsHttp = { const includeUsageChunk = payload.stream_options?.include_usage === true; ctx = createChatGatewayCtxFromHono(c, { wantsStream, requestBody: takeRequestBody(requestBody), model: payload.model, backgroundScheduler: backgroundSchedulerFromContext(c) }, apiKey => createNonResponsesSourceStore(apiKey.id)); const result = await chatCompletionsServe.generate({ payload, ctx, headers: inboundHeadersForUpstream(c) }); - const { response } = await respondChatCompletions(c, result, wantsStream, includeUsageChunk, ctx); + const response = await respondChatCompletions(c, result, wantsStream, includeUsageChunk, ctx); return finalizeGatewayResponse(ctx, response); } catch (error) { return await respondToThrow(c, error, requestBody, ctx); diff --git a/packages/gateway/src/data-plane/chat/chat-completions/respond.ts b/packages/gateway/src/data-plane/chat/chat-completions/respond.ts index a384083640..d0382ab56b 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/respond.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/respond.ts @@ -22,24 +22,24 @@ export const respondChatCompletions = async ( wantsStream: boolean, includeUsageChunk: boolean, ctx: GatewayCtx, -): Promise<{ success: boolean; response: Response }> => { +): Promise => { if (result.type === 'api-error') { recordFailedRequest(ctx, result.performance); ctx.dump?.error(result.source, result.upstream); - return { success: false, response: apiErrorToResponse(result) }; + return apiErrorToResponse(result); } if (result.type === 'internal-error') { recordFailedRequest(ctx, result.performance); ctx.dump?.failed(result.error.message); - return { success: false, response: internalChatCompletionsErrorResponse(result.status, result.error) }; + return internalChatCompletionsErrorResponse(result.status, result.error); } if (result.type === 'plain') { if (result.status >= 400) { ctx.dump?.error(result.upstream !== undefined ? 'upstream' : 'gateway', result.upstream); } - return { success: true, response: plainResultToResponse(result) }; + return plainResultToResponse(result); } const state = new SourceStreamState(); @@ -53,16 +53,16 @@ export const respondChatCompletions = async ( const usage = response.usage ? tokenUsageFromChatCompletionsUsage(response.usage, response.service_tier) : null; ctx.dump?.success(metadata.modelIdentity, usage); settle(ctx, metadata.performance, metadata.modelIdentity, usage, state.failed); - return { success: true, response: Response.json(response, { headers: mergeForwardedUpstreamHeaders(undefined, result.headers) }) }; + return Response.json(response, { headers: mergeForwardedUpstreamHeaders(undefined, result.headers) }); } catch (error) { recordFailedRequest(ctx, result.performance); ctx.dump?.failed(error); - return { success: false, response: internalChatCompletionsErrorResponse(502, toInternalDebugError(error)) }; + return internalChatCompletionsErrorResponse(502, toInternalDebugError(error)); } } forwardUpstreamHeaders(c, result.headers); - const response = streamSSE(c, async stream => { + return streamSSE(c, async stream => { let completion: StreamCompletion = 'error'; try { completion = await writeSSEFrames(stream, chatCompletionsSseFrames(frames, includeUsageChunk, state), { @@ -80,8 +80,6 @@ export const respondChatCompletions = async ( settle(ctx, metadata.performance, metadata.modelIdentity, state.usage, failed); } }); - - return { success: true, response }; }; // --- error rendering --- diff --git a/packages/gateway/src/data-plane/chat/gemini/attempt.ts b/packages/gateway/src/data-plane/chat/gemini/attempt.ts index 2a3a11cd31..dfdea42c31 100644 --- a/packages/gateway/src/data-plane/chat/gemini/attempt.ts +++ b/packages/gateway/src/data-plane/chat/gemini/attempt.ts @@ -2,11 +2,11 @@ import { geminiStatusForHttpStatus } from './errors.ts'; import { geminiCountTokensInterceptors, geminiInterceptors } from './interceptors/index.ts'; import { stripUnsupportedPartFieldsFromPayload } from './interceptors/strip-unsupported-part-fields.ts'; import { stripUnsupportedToolsFromPayload } from './interceptors/strip-unsupported-tools.ts'; -import { chatTargetPicker } from '../../shared/telemetry/attempt-helpers.ts'; import { chatCompletionsAttempt } from '../chat-completions/attempt.ts'; import { messagesAttempt } from '../messages/attempt.ts'; import { responsesAttempt } from '../responses/attempt.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; +import { chatTargetPicker } from '../shared/target-picker.ts'; import { traverseTranslation } from '../shared/translate-traverse.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/gateway/src/data-plane/chat/gemini/http.ts b/packages/gateway/src/data-plane/chat/gemini/http.ts index 849c5893d7..8ab493c98b 100644 --- a/packages/gateway/src/data-plane/chat/gemini/http.ts +++ b/packages/gateway/src/data-plane/chat/gemini/http.ts @@ -64,7 +64,7 @@ const respondWithGeminiError = async ( wantsStream: boolean, ): Promise => { if (error instanceof TranslatorInputError) { - const { response } = await respondGemini(c, translatorInputErrorResult(error, ctx.attempt.telemetry), wantsStream, ctx); + const response = await respondGemini(c, translatorInputErrorResult(error, ctx.attempt.telemetry), wantsStream, ctx); return (ctx.dump?.finalize(response) ?? response); } if (error instanceof ProviderModelsUnavailableError && error.httpResponse) { @@ -76,11 +76,11 @@ const respondWithGeminiError = async ( headers: new Headers(headers), body: new TextEncoder().encode(body), }; - const { response } = await respondGemini(c, apiErrorResult, wantsStream, ctx); + const response = await respondGemini(c, apiErrorResult, wantsStream, ctx); return finalizeGatewayResponse(ctx, response); } const internalResult = internalErrorResult(500, toInternalDebugError(error), ctx.attempt.telemetry); - const { response } = await respondGemini(c, internalResult, wantsStream, ctx); + const response = await respondGemini(c, internalResult, wantsStream, ctx); return finalizeGatewayResponse(ctx, response); }; @@ -106,7 +106,7 @@ const runGeminiGenerate = async (c: AuthedContext, model: string, wantsStream: b const ctx = createChatGatewayCtxFromHono(c, { wantsStream, requestBody: takeRequestBody(requestBody), model, backgroundScheduler: backgroundSchedulerFromContext(c) }, apiKey => createNonResponsesSourceStore(apiKey.id)); try { const result = await geminiServe.generate({ payload, ctx, model, headers: inboundHeadersForUpstream(c) }); - const { response } = await respondGemini(c, result, wantsStream, ctx); + const response = await respondGemini(c, result, wantsStream, ctx); return finalizeGatewayResponse(ctx, response); } catch (error) { return await respondWithGeminiError(c, error, ctx, wantsStream); @@ -121,7 +121,7 @@ const runGeminiCountTokens = async (c: AuthedContext, model: string): Promise createNonResponsesSourceStore(apiKey.id)); try { const result = await geminiServe.countTokens({ payload, ctx, model, headers: inboundHeadersForUpstream(c) }); - const { response } = await respondGemini(c, result, false, ctx); + const response = await respondGemini(c, result, false, ctx); return finalizeGatewayResponse(ctx, response); } catch (error) { return await respondWithGeminiError(c, error, ctx, false); diff --git a/packages/gateway/src/data-plane/chat/gemini/respond.ts b/packages/gateway/src/data-plane/chat/gemini/respond.ts index d572f86394..32de7ffe43 100644 --- a/packages/gateway/src/data-plane/chat/gemini/respond.ts +++ b/packages/gateway/src/data-plane/chat/gemini/respond.ts @@ -19,32 +19,30 @@ import { type ExecuteResult, type PlainResult, type ApiErrorResult, type Interna // Renders an upstream Gemini result into the client HTTP/SSE response, in the // Google-RPC error envelope. An error-typed result is a pre-stream failure and // always answers as HTTP; an events result drains to one JSON body -// (non-streaming) or is proxied frame by frame (streaming). `success` reports -// whether a non-streaming body was produced, so the orchestrator knows whether -// to flush stored items. +// (non-streaming) or is proxied frame by frame (streaming). export const respondGemini = async ( c: Context, result: ExecuteResult> | PlainResult, wantsStream: boolean, ctx: GatewayCtx, -): Promise<{ success: boolean; response: Response }> => { +): Promise => { if (result.type === 'api-error') { recordFailedRequest(ctx, result.performance); ctx.dump?.error(result.source, result.upstream); - return { success: false, response: geminiApiErrorResponse(result) }; + return geminiApiErrorResponse(result); } if (result.type === 'internal-error') { recordFailedRequest(ctx, result.performance); ctx.dump?.failed(result.error.message); - return { success: false, response: geminiErrorResponse(result.status, result.error.message, internalDebugFields(result.error)) }; + return geminiErrorResponse(result.status, result.error.message, internalDebugFields(result.error)); } if (result.type === 'plain') { if (result.status >= 400) { ctx.dump?.error(result.upstream !== undefined ? 'upstream' : 'gateway', result.upstream); } - return { success: true, response: plainResultToResponse(result) }; + return plainResultToResponse(result); } const state = new SourceStreamState(); @@ -58,16 +56,16 @@ export const respondGemini = async ( const usage = tokenUsageFromGeminiResponse(response); ctx.dump?.success(metadata.modelIdentity, usage); settle(ctx, metadata.performance, metadata.modelIdentity, usage, state.failed); - return { success: true, response: Response.json(response, { headers: mergeForwardedUpstreamHeaders(undefined, result.headers) }) }; + return Response.json(response, { headers: mergeForwardedUpstreamHeaders(undefined, result.headers) }); } catch (error) { recordFailedRequest(ctx, result.performance); ctx.dump?.failed(error); - return { success: false, response: geminiCollectErrorResponse(error) }; + return geminiCollectErrorResponse(error); } } forwardUpstreamHeaders(c, result.headers); - const response = streamSSE(c, async stream => { + return streamSSE(c, async stream => { let completion: StreamCompletion = 'error'; try { completion = await writeSSEFrames(stream, geminiSseFrames(frames, state), { @@ -85,8 +83,6 @@ export const respondGemini = async ( settle(ctx, metadata.performance, metadata.modelIdentity, state.usage, failed); } }); - - return { success: true, response }; }; const tokenUsageFromGeminiResponse = (r: GeminiResult) => (r.usageMetadata ? tokenUsageFromGeminiUsageMetadata(r.usageMetadata) : null); diff --git a/packages/gateway/src/data-plane/chat/gemini/respond_test.ts b/packages/gateway/src/data-plane/chat/gemini/respond_test.ts index 8b20dc5882..fccb69d965 100644 --- a/packages/gateway/src/data-plane/chat/gemini/respond_test.ts +++ b/packages/gateway/src/data-plane/chat/gemini/respond_test.ts @@ -22,7 +22,7 @@ const ctx = () => mockChatGatewayCtx(); const requestGeminiResponse = async (result: ExecuteResult>): Promise => { const app = new Hono(); - app.get('/', async c => (await respondGemini(c, result, false, ctx())).response); + app.get('/', async c => await respondGemini(c, result, false, ctx())); return await app.request('/'); }; diff --git a/packages/gateway/src/data-plane/chat/messages/attempt.ts b/packages/gateway/src/data-plane/chat/messages/attempt.ts index 6565a0557e..c17bff5634 100644 --- a/packages/gateway/src/data-plane/chat/messages/attempt.ts +++ b/packages/gateway/src/data-plane/chat/messages/attempt.ts @@ -1,11 +1,13 @@ import { messagesInterceptors, messagesCountTokensInterceptors } from './interceptors/index.ts'; import type { MessagesInvocation } from './interceptors/types.ts'; -import { applyRulesToUpstreamMessages } from '../../model-aliases/apply-rules.ts'; -import { providerStreamResultToExecuteResult, buildUpstreamCallOptions, chatTargetPicker } from '../../shared/telemetry/attempt-helpers.ts'; +import { buildUpstreamCallOptions } from '../../shared/upstream-call-options.ts'; import { chatCompletionsAttempt } from '../chat-completions/attempt.ts'; import { responsesAttempt } from '../responses/attempt.ts'; +import { applyRulesToUpstreamMessages } from '../shared/alias-rules.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; +import { providerStreamResultToExecuteResult } from '../shared/provider-stream-result.ts'; import { plainResultFromResponse } from '../shared/respond.ts'; +import { chatTargetPicker } from '../shared/target-picker.ts'; import { traverseTranslation } from '../shared/translate-traverse.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/gateway/src/data-plane/chat/messages/http.ts b/packages/gateway/src/data-plane/chat/messages/http.ts index f31a55055f..43e935c655 100644 --- a/packages/gateway/src/data-plane/chat/messages/http.ts +++ b/packages/gateway/src/data-plane/chat/messages/http.ts @@ -47,7 +47,7 @@ const respondWithInternalError = async (c: AuthedContext, error: unknown, reques if (verbatim !== null) return verbatim; const effectiveCtx = ctx ?? createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); const result = internalErrorResult(502, toInternalDebugError(error), effectiveCtx.attempt.telemetry); - const { response } = await respondMessages(c, result, false, effectiveCtx); + const response = await respondMessages(c, result, false, effectiveCtx); return finalizeGatewayResponse(effectiveCtx, response); }; @@ -57,7 +57,7 @@ const respondWithInternalError = async (c: AuthedContext, error: unknown, reques const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: RequestBody, ctx?: GatewayCtx): Promise => { if (!(error instanceof TranslatorInputError)) return await respondWithInternalError(c, error, requestBody, ctx); const effectiveCtx = ctx ?? createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - const { response } = await respondMessages(c, translatorInputErrorResult(error, effectiveCtx.attempt.telemetry), false, effectiveCtx); + const response = await respondMessages(c, translatorInputErrorResult(error, effectiveCtx.attempt.telemetry), false, effectiveCtx); return (effectiveCtx.dump?.finalize(response) ?? response); }; @@ -76,7 +76,7 @@ export const messagesHttp = { const wantsStream = payload.stream === true; ctx = createChatGatewayCtxFromHono(c, { wantsStream, requestBody: takeRequestBody(requestBody), model: payload.model, backgroundScheduler: backgroundSchedulerFromContext(c) }, apiKey => createNonResponsesSourceStore(apiKey.id)); const result = await messagesServe.generate({ payload, ctx, headers: inboundHeadersForUpstream(c) }); - const { response } = await respondMessages(c, result, wantsStream, ctx); + const response = await respondMessages(c, result, wantsStream, ctx); return finalizeGatewayResponse(ctx, response); } catch (error) { return await respondToThrow(c, error, requestBody, ctx); @@ -93,7 +93,7 @@ export const messagesHttp = { ctx = createChatGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), model: payload.model, backgroundScheduler: backgroundSchedulerFromContext(c) }, apiKey => createNonResponsesSourceStore(apiKey.id)); const result = await messagesServe.countTokens({ payload, ctx, headers: inboundHeadersForUpstream(c) }); - const { response } = await respondMessages(c, result, false, ctx); + const response = await respondMessages(c, result, false, ctx); return finalizeGatewayResponse(ctx, response); } catch (error) { return await respondToThrow(c, error, requestBody, ctx); diff --git a/packages/gateway/src/data-plane/chat/messages/respond.ts b/packages/gateway/src/data-plane/chat/messages/respond.ts index eccea2d3ea..49c1a48fda 100644 --- a/packages/gateway/src/data-plane/chat/messages/respond.ts +++ b/packages/gateway/src/data-plane/chat/messages/respond.ts @@ -21,31 +21,30 @@ type MessagesUsageLike = MessagesUsage | NonNullable> | PlainResult, wantsStream: boolean, ctx: GatewayCtx, -): Promise<{ success: boolean; response: Response }> => { +): Promise => { if (result.type === 'api-error') { recordFailedRequest(ctx, result.performance); ctx.dump?.error(result.source, result.upstream); - return { success: false, response: apiErrorToResponse(result) }; + return apiErrorToResponse(result); } if (result.type === 'internal-error') { recordFailedRequest(ctx, result.performance); ctx.dump?.failed(result.error.message); - return { success: false, response: internalMessagesErrorResponse(result.status, result.error) }; + return internalMessagesErrorResponse(result.status, result.error); } if (result.type === 'plain') { if (result.status >= 400) { ctx.dump?.error(result.upstream !== undefined ? 'upstream' : 'gateway', result.upstream); } - return { success: true, response: plainResultToResponse(result) }; + return plainResultToResponse(result); } const state = new SourceStreamState(); @@ -60,16 +59,16 @@ export const respondMessages = async ( const usage = tokenUsageFromMessagesUsage(response.usage); ctx.dump?.success(metadata.modelIdentity, usage); settle(ctx, metadata.performance, metadata.modelIdentity, usage, state.failed); - return { success: true, response: Response.json(response, { headers: mergeForwardedUpstreamHeaders(undefined, result.headers) }) }; + return Response.json(response, { headers: mergeForwardedUpstreamHeaders(undefined, result.headers) }); } catch (error) { recordFailedRequest(ctx, result.performance); ctx.dump?.failed(error); - return { success: false, response: internalMessagesErrorResponse(502, toInternalDebugError(error)) }; + return internalMessagesErrorResponse(502, toInternalDebugError(error)); } } forwardUpstreamHeaders(c, result.headers); - const response = streamSSE(c, async stream => { + return streamSSE(c, async stream => { let completion: StreamCompletion = 'error'; try { completion = await writeSSEFrames(stream, messagesSseFrames(frames, state), { @@ -87,8 +86,6 @@ export const respondMessages = async ( settle(ctx, metadata.performance, metadata.modelIdentity, state.usage, failed); } }); - - return { success: true, response }; }; // Anthropic already reports disjoint token counts: input_tokens excludes the diff --git a/packages/gateway/src/data-plane/chat/messages/respond_test.ts b/packages/gateway/src/data-plane/chat/messages/respond_test.ts index 4c4ed018e6..8938b7538e 100644 --- a/packages/gateway/src/data-plane/chat/messages/respond_test.ts +++ b/packages/gateway/src/data-plane/chat/messages/respond_test.ts @@ -601,7 +601,7 @@ const callRespond = async (wantsStream: boolean): Promise => { testTelemetryModelIdentity, { headers: forwardedHeadersFixture() }, ); - const { response } = await respondMessages(c, result, wantsStream, makeRespondCtx()); + const response = await respondMessages(c, result, wantsStream, makeRespondCtx()); captured = response; return response; }); @@ -700,7 +700,7 @@ test('respondMessages records the last observed message_delta usage when the cli ); const downstreamAbortController = new AbortController(); const ctx: ChatGatewayCtx = { ...makeRespondCtx(), wantsStream: true, downstreamAbortController }; - return respondMessages(c, result, true, ctx).then(({ response }) => response); + return respondMessages(c, result, true, ctx); }); const response = await app.request('/'); const reader = response.body!.getReader(); diff --git a/packages/gateway/src/data-plane/chat/responses/attempt.ts b/packages/gateway/src/data-plane/chat/responses/attempt.ts index b665207451..70e67f1fd2 100644 --- a/packages/gateway/src/data-plane/chat/responses/attempt.ts +++ b/packages/gateway/src/data-plane/chat/responses/attempt.ts @@ -3,12 +3,15 @@ import type { ResponsesAttemptResult, ResponsesInvocation } from './interceptors import { normalizeAssistantInputText } from './items/normalize-assistant-content.ts'; import { syntheticEventsFromResult } from './items/output.ts'; import { tokenUsageFromResponsesResult } from './usage.ts'; -import { applyRulesToUpstreamResponses } from '../../model-aliases/apply-rules.ts'; -import { providerStreamResultToExecuteResult, buildUpstreamCallOptions, telemetryModelIdentity, chatTargetPicker, upstreamPerformanceContext } from '../../shared/telemetry/attempt-helpers.ts'; +import { telemetryModelIdentity, upstreamPerformanceContext } from '../../shared/telemetry/attribution.ts'; +import { buildUpstreamCallOptions } from '../../shared/upstream-call-options.ts'; import { chatCompletionsAttempt } from '../chat-completions/attempt.ts'; import { messagesAttempt } from '../messages/attempt.ts'; +import { applyRulesToUpstreamResponses } from '../shared/alias-rules.ts'; import { createExternalImageLoader } from '../shared/external-image-loader.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; +import { providerStreamResultToExecuteResult } from '../shared/provider-stream-result.ts'; +import { chatTargetPicker } from '../shared/target-picker.ts'; import { traverseTranslation } from '../shared/translate-traverse.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/gateway/src/data-plane/chat/responses/http.ts b/packages/gateway/src/data-plane/chat/responses/http.ts index b5a9f51108..5c885e2237 100644 --- a/packages/gateway/src/data-plane/chat/responses/http.ts +++ b/packages/gateway/src/data-plane/chat/responses/http.ts @@ -12,8 +12,7 @@ import { readRequestBody, takeRequestBody, type RequestBody } from '../shared/re import { providerModelsUnavailableResponse } from '../shared/upstream-models-error.ts'; import type { CanonicalResponsesPayload, ResponsesRequestPayload } from '@floway-dev/protocols/responses'; import { internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; -import { TranslatorInputError } from '@floway-dev/translate'; -import { canonicalizeResponsesPayload } from '@floway-dev/translate/via-responses/responses-items'; +import { canonicalizeResponsesPayload, TranslatorInputError } from '@floway-dev/translate'; // OpenAI's verbatim previous_response_not_found envelope. Codex compares this // body byte-for-byte against upstream — see the cross-references on diff --git a/packages/gateway/src/data-plane/chat/responses/websocket.ts b/packages/gateway/src/data-plane/chat/responses/websocket.ts index ebb765cb2d..f48c2ce800 100644 --- a/packages/gateway/src/data-plane/chat/responses/websocket.ts +++ b/packages/gateway/src/data-plane/chat/responses/websocket.ts @@ -21,8 +21,7 @@ import { RESPONSES_MISSING_TERMINAL_MESSAGE } from '@floway-dev/protocols/respon import { isResponsesTerminalEvent, type CanonicalResponsesPayload, type ResponsesRequestPayload, type ResponsesStreamEvent } from '@floway-dev/protocols/responses'; import type { ExecuteResult } from '@floway-dev/provider'; import { toInternalDebugError } from '@floway-dev/provider'; -import { TranslatorInputError } from '@floway-dev/translate'; -import { canonicalizeResponsesPayload } from '@floway-dev/translate/via-responses/responses-items'; +import { canonicalizeResponsesPayload, TranslatorInputError } from '@floway-dev/translate'; interface WorkerWebSocket extends WebSocket { accept(): void; diff --git a/packages/gateway/src/data-plane/model-aliases/apply-rules.ts b/packages/gateway/src/data-plane/chat/shared/alias-rules.ts similarity index 100% rename from packages/gateway/src/data-plane/model-aliases/apply-rules.ts rename to packages/gateway/src/data-plane/chat/shared/alias-rules.ts diff --git a/packages/gateway/src/data-plane/model-aliases/apply-rules_test.ts b/packages/gateway/src/data-plane/chat/shared/alias-rules_test.ts similarity index 99% rename from packages/gateway/src/data-plane/model-aliases/apply-rules_test.ts rename to packages/gateway/src/data-plane/chat/shared/alias-rules_test.ts index ebb98fbda0..75dc5fe148 100644 --- a/packages/gateway/src/data-plane/model-aliases/apply-rules_test.ts +++ b/packages/gateway/src/data-plane/chat/shared/alias-rules_test.ts @@ -6,7 +6,7 @@ import { test } from 'vitest'; -import { applyRulesToUpstreamChatCompletions, applyRulesToUpstreamMessages, applyRulesToUpstreamResponses } from './apply-rules.ts'; +import { applyRulesToUpstreamChatCompletions, applyRulesToUpstreamMessages, applyRulesToUpstreamResponses } from './alias-rules.ts'; import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; import type { MessagesPayload } from '@floway-dev/protocols/messages'; import type { ResponsesPayload } from '@floway-dev/protocols/responses'; diff --git a/packages/gateway/src/data-plane/chat/shared/provider-stream-result.ts b/packages/gateway/src/data-plane/chat/shared/provider-stream-result.ts new file mode 100644 index 0000000000..bbdd7e01f8 --- /dev/null +++ b/packages/gateway/src/data-plane/chat/shared/provider-stream-result.ts @@ -0,0 +1,30 @@ +import { isFirstOutputTokenFrame } from './first-output-token.ts'; +import type { GatewayCtx } from './gateway-ctx.ts'; +import { telemetryModelIdentity, upstreamPerformanceContext } from '../../shared/telemetry/attribution.ts'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; +import { eventResult, readUpstreamApiError, type ChatTargetApi, type ExecuteResult, type ModelCandidate, type ProviderStreamResult } from '@floway-dev/provider'; + +export const providerStreamResultToExecuteResult = async ( + providerResult: ProviderStreamResult, + candidate: ModelCandidate, + targetApi: ChatTargetApi, + ctx: GatewayCtx, +): Promise>> => { + const context = upstreamPerformanceContext(ctx, candidate, 'chat'); + if (!providerResult.ok) { + return { ...(await readUpstreamApiError(providerResult.response, candidate.provider.upstream)), performance: context }; + } + const stampedEvents = (async function* () { + for await (const frame of providerResult.events) { + if (ctx.attempt.firstOutputTokenAt === null && isFirstOutputTokenFrame(frame, targetApi)) { + ctx.attempt.firstOutputTokenAt = performance.now(); + } + yield frame; + } + })(); + return eventResult( + stampedEvents, + telemetryModelIdentity(candidate, providerResult.modelKey), + { performance: context, headers: providerResult.headers }, + ); +}; diff --git a/packages/gateway/src/data-plane/chat/shared/provider-stream-result_test.ts b/packages/gateway/src/data-plane/chat/shared/provider-stream-result_test.ts new file mode 100644 index 0000000000..8ef84507f7 --- /dev/null +++ b/packages/gateway/src/data-plane/chat/shared/provider-stream-result_test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'vitest'; + +import { providerStreamResultToExecuteResult } from './provider-stream-result.ts'; +import { mockGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; +import type { ProviderStreamResult } from '@floway-dev/provider'; +import { stubModelCandidate } from '@floway-dev/test-utils'; + +const iter = (items: readonly T[]): AsyncIterable => ({ + async *[Symbol.asyncIterator]() { for (const item of items) yield item; }, +}); + +const okStreamResult = (events: AsyncIterable>): ProviderStreamResult => ({ + ok: true, + events, + modelKey: 'test-model-key', +}); + +const drainEvents = async (result: Awaited>>): Promise[]> => { + if (result.type !== 'events') throw new Error(`expected events result, got ${result.type}`); + const collected: ProtocolFrame[] = []; + for await (const frame of result.events) collected.push(frame); + return collected; +}; + +describe('providerStreamResultToExecuteResult (first-output-token stamping)', () => { + test('stamps firstOutputTokenAt on the first generated-token frame (messages thinking_delta)', async () => { + const ctx = mockGatewayCtx(); + const frames: ProtocolFrame[] = [ + { type: 'event', event: { type: 'message_start' } }, + { type: 'event', event: { type: 'content_block_delta', delta: { type: 'thinking_delta', thinking: '...' } } }, + { type: 'event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'hi' } } }, + { type: 'event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: ' there' } } }, + ]; + const result = await providerStreamResultToExecuteResult(okStreamResult(iter(frames)), stubModelCandidate(), 'messages', ctx); + const collected = await drainEvents(result); + expect(collected).toEqual(frames); + expect(ctx.attempt.firstOutputTokenAt).not.toBe(null); + }); + + test('leaves firstOutputTokenAt null when only envelope frames appear', async () => { + const ctx = mockGatewayCtx(); + const frames: ProtocolFrame[] = [ + { type: 'event', event: { type: 'response.created' } }, + { type: 'event', event: { type: 'response.output_item.added' } }, + ]; + const result = await providerStreamResultToExecuteResult(okStreamResult(iter(frames)), stubModelCandidate(), 'responses', ctx); + await drainEvents(result); + expect(ctx.attempt.firstOutputTokenAt).toBe(null); + }); + + test('stamps at most once even for many output-content frames', async () => { + const ctx = mockGatewayCtx(); + const frames: ProtocolFrame[] = [ + { type: 'event', event: { choices: [{ delta: { content: 'a' } }] } }, + { type: 'event', event: { choices: [{ delta: { content: 'b' } }] } }, + { type: 'event', event: { choices: [{ delta: { content: 'c' } }] } }, + ]; + const result = await providerStreamResultToExecuteResult(okStreamResult(iter(frames)), stubModelCandidate(), 'chat-completions', ctx); + if (result.type !== 'events') throw new Error(`expected events result, got ${result.type}`); + const stampsAfterEachFrame: (number | null)[] = []; + for await (const _ of result.events) stampsAfterEachFrame.push(ctx.attempt.firstOutputTokenAt); + expect(stampsAfterEachFrame[0]).not.toBe(null); + // The subsequent frames must observe the exact same stamp — the stamping + // hook never overwrites once firstOutputTokenAt has been set. + expect(stampsAfterEachFrame[1]).toBe(stampsAfterEachFrame[0]); + expect(stampsAfterEachFrame[2]).toBe(stampsAfterEachFrame[0]); + }); +}); diff --git a/packages/gateway/src/data-plane/chat/shared/target-picker.ts b/packages/gateway/src/data-plane/chat/shared/target-picker.ts new file mode 100644 index 0000000000..c4bae02611 --- /dev/null +++ b/packages/gateway/src/data-plane/chat/shared/target-picker.ts @@ -0,0 +1,40 @@ +import type { ModelEndpoints } from '@floway-dev/protocols/common'; +import type { ChatTargetApi } from '@floway-dev/provider'; + +// Build a picker from an ordered preference list of chat-target keys. The +// preference encodes which upstream wire the source protocol prefers to +// translate to, in order. The first preference whose endpoint key exists +// on the candidate wins. Serve calls `canServe` to filter candidates whose +// upstream wire cannot satisfy any preferred target; attempt calls `pick` +// once it has a viable candidate to choose the dispatch wire. `pick` is +// contractually total — a null return would mean the serve-side filter +// was bypassed. `canServe` is a 1-bit projection of `pick`. +export const chatTargetPicker = (preference: readonly ChatTargetApi[]): { + canServe: (endpoints: ModelEndpoints) => boolean; + pick: (endpoints: ModelEndpoints) => ChatTargetApi; +} => { + const find = (endpoints: ModelEndpoints): ChatTargetApi | null => { + for (const key of preference) { + switch (key) { + case 'messages': + if (endpoints.messages) return 'messages'; + break; + case 'responses': + if (endpoints.responses) return 'responses'; + break; + case 'chat-completions': + if (endpoints.chatCompletions) return 'chat-completions'; + break; + } + } + return null; + }; + return { + canServe: endpoints => find(endpoints) !== null, + pick: endpoints => { + const out = find(endpoints); + if (out === null) throw new Error('chatTargetPicker.pick called on a candidate the picker rejects — serve must filter via canServe first'); + return out; + }, + }; +}; diff --git a/packages/gateway/src/data-plane/shared/telemetry/attempt-helpers_test.ts b/packages/gateway/src/data-plane/chat/shared/target-picker_test.ts similarity index 54% rename from packages/gateway/src/data-plane/shared/telemetry/attempt-helpers_test.ts rename to packages/gateway/src/data-plane/chat/shared/target-picker_test.ts index 4154f1e673..c2a229652f 100644 --- a/packages/gateway/src/data-plane/shared/telemetry/attempt-helpers_test.ts +++ b/packages/gateway/src/data-plane/chat/shared/target-picker_test.ts @@ -1,12 +1,11 @@ import { describe, expect, test } from 'vitest'; -import { chatTargetPicker, providerStreamResultToExecuteResult, telemetryModelIdentity } from './attempt-helpers.ts'; -import { mockGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import { chatTargetPicker } from './target-picker.ts'; import { setupAppTest } from '../../../test-helpers.ts'; import { enumerateModelCandidates } from '../../providers/registry.ts'; -import type { ModelEndpoints, ProtocolFrame } from '@floway-dev/protocols/common'; -import type { ProviderStreamResult, UpstreamRecord } from '@floway-dev/provider'; -import { assertEquals, stubModelCandidate } from '@floway-dev/test-utils'; +import type { ModelEndpoints } from '@floway-dev/protocols/common'; +import type { UpstreamRecord } from '@floway-dev/provider'; +import { assertEquals } from '@floway-dev/test-utils'; // Drains SWR background revalidate so a rejection surfaces in the runner // instead of being swallowed. @@ -117,76 +116,3 @@ describe('enumerateModelCandidates + chatTargetPicker', () => { assertEquals(chatCompletionsPicker.pick(candidates[0].model.endpoints), 'chat-completions'); }); }); - -const iter = (items: readonly T[]): AsyncIterable => ({ - async *[Symbol.asyncIterator]() { for (const item of items) yield item; }, -}); - -const okStreamResult = (events: AsyncIterable>): ProviderStreamResult => ({ - ok: true, - events, - modelKey: 'test-model-key', -}); - -const drainEvents = async (result: Awaited>>): Promise[]> => { - if (result.type !== 'events') throw new Error(`expected events result, got ${result.type}`); - const collected: ProtocolFrame[] = []; - for await (const frame of result.events) collected.push(frame); - return collected; -}; - -describe('providerStreamResultToExecuteResult (first-output-token stamping)', () => { - test('captures pricing from the exact dispatched provider model', () => { - const pricing = { entries: [{ rates: { input_tokens: '3', output_tokens: '12' } }] }; - const candidate = stubModelCandidate({ model: { pricing } }); - expect(telemetryModelIdentity(candidate, 'raw-model')).toEqual({ - model: 'test-model', - upstream: 'test-upstream', - modelKey: 'raw-model', - pricing, - }); - }); - - test('stamps firstOutputTokenAt on the first generated-token frame (messages thinking_delta)', async () => { - const ctx = mockGatewayCtx(); - const frames: ProtocolFrame[] = [ - { type: 'event', event: { type: 'message_start' } }, - { type: 'event', event: { type: 'content_block_delta', delta: { type: 'thinking_delta', thinking: '...' } } }, - { type: 'event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'hi' } } }, - { type: 'event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: ' there' } } }, - ]; - const result = await providerStreamResultToExecuteResult(okStreamResult(iter(frames)), stubModelCandidate(), 'messages', ctx); - const collected = await drainEvents(result); - expect(collected).toEqual(frames); - expect(ctx.attempt.firstOutputTokenAt).not.toBe(null); - }); - - test('leaves firstOutputTokenAt null when only envelope frames appear', async () => { - const ctx = mockGatewayCtx(); - const frames: ProtocolFrame[] = [ - { type: 'event', event: { type: 'response.created' } }, - { type: 'event', event: { type: 'response.output_item.added' } }, - ]; - const result = await providerStreamResultToExecuteResult(okStreamResult(iter(frames)), stubModelCandidate(), 'responses', ctx); - await drainEvents(result); - expect(ctx.attempt.firstOutputTokenAt).toBe(null); - }); - - test('stamps at most once even for many output-content frames', async () => { - const ctx = mockGatewayCtx(); - const frames: ProtocolFrame[] = [ - { type: 'event', event: { choices: [{ delta: { content: 'a' } }] } }, - { type: 'event', event: { choices: [{ delta: { content: 'b' } }] } }, - { type: 'event', event: { choices: [{ delta: { content: 'c' } }] } }, - ]; - const result = await providerStreamResultToExecuteResult(okStreamResult(iter(frames)), stubModelCandidate(), 'chat-completions', ctx); - if (result.type !== 'events') throw new Error(`expected events result, got ${result.type}`); - const stampsAfterEachFrame: (number | null)[] = []; - for await (const _ of result.events) stampsAfterEachFrame.push(ctx.attempt.firstOutputTokenAt); - expect(stampsAfterEachFrame[0]).not.toBe(null); - // The subsequent frames must observe the exact same stamp — the stamping - // hook never overwrites once firstOutputTokenAt has been set. - expect(stampsAfterEachFrame[1]).toBe(stampsAfterEachFrame[0]); - expect(stampsAfterEachFrame[2]).toBe(stampsAfterEachFrame[0]); - }); -}); diff --git a/packages/gateway/src/data-plane/codex/routes.ts b/packages/gateway/src/data-plane/codex/routes.ts index 5bff194a20..a9e3a3783e 100644 --- a/packages/gateway/src/data-plane/codex/routes.ts +++ b/packages/gateway/src/data-plane/codex/routes.ts @@ -25,7 +25,7 @@ import type { AuthVars } from '../../middleware/auth.ts'; import { mountAlphaSearchRoute } from '../alpha-search/routes.ts'; import { responsesHttp } from '../chat/responses/http.ts'; import { responsesWebSocket } from '../chat/responses/websocket.ts'; -import { imagesEdits, imagesGenerations } from '../images/serve.ts'; +import { imagesEdits, imagesGenerations } from '../images/http.ts'; import { serveModels } from '../models/serve.ts'; const CODEX_BASE_PATH = '/azure-api.codex'; diff --git a/packages/gateway/src/data-plane/codex/images_test.ts b/packages/gateway/src/data-plane/codex/routes_images_test.ts similarity index 100% rename from packages/gateway/src/data-plane/codex/images_test.ts rename to packages/gateway/src/data-plane/codex/routes_images_test.ts diff --git a/packages/gateway/src/data-plane/completions/serve.ts b/packages/gateway/src/data-plane/completions/http.ts similarity index 100% rename from packages/gateway/src/data-plane/completions/serve.ts rename to packages/gateway/src/data-plane/completions/http.ts diff --git a/packages/gateway/src/data-plane/completions/serve_test.ts b/packages/gateway/src/data-plane/completions/http_test.ts similarity index 100% rename from packages/gateway/src/data-plane/completions/serve_test.ts rename to packages/gateway/src/data-plane/completions/http_test.ts diff --git a/packages/gateway/src/data-plane/embeddings/serve.ts b/packages/gateway/src/data-plane/embeddings/http.ts similarity index 100% rename from packages/gateway/src/data-plane/embeddings/serve.ts rename to packages/gateway/src/data-plane/embeddings/http.ts diff --git a/packages/gateway/src/data-plane/embeddings/serve_test.ts b/packages/gateway/src/data-plane/embeddings/http_test.ts similarity index 100% rename from packages/gateway/src/data-plane/embeddings/serve_test.ts rename to packages/gateway/src/data-plane/embeddings/http_test.ts diff --git a/packages/gateway/src/data-plane/images/serve.ts b/packages/gateway/src/data-plane/images/http.ts similarity index 100% rename from packages/gateway/src/data-plane/images/serve.ts rename to packages/gateway/src/data-plane/images/http.ts diff --git a/packages/gateway/src/data-plane/images/serve_test.ts b/packages/gateway/src/data-plane/images/http_test.ts similarity index 100% rename from packages/gateway/src/data-plane/images/serve_test.ts rename to packages/gateway/src/data-plane/images/http_test.ts diff --git a/packages/gateway/src/data-plane/providers/custom/provider_test.ts b/packages/gateway/src/data-plane/providers/custom-provider_test.ts similarity index 99% rename from packages/gateway/src/data-plane/providers/custom/provider_test.ts rename to packages/gateway/src/data-plane/providers/custom-provider_test.ts index 5dbc57fc05..d72112fa6e 100644 --- a/packages/gateway/src/data-plane/providers/custom/provider_test.ts +++ b/packages/gateway/src/data-plane/providers/custom-provider_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { buildCustomUpstreamRecord, setupAppTest } from '../../../test-helpers.ts'; +import { buildCustomUpstreamRecord, setupAppTest } from '../../test-helpers.ts'; import { directFetcher } from '@floway-dev/provider'; import type { UpstreamRecord } from '@floway-dev/provider'; import { createCustomProvider } from '@floway-dev/provider-custom'; diff --git a/packages/gateway/src/data-plane/providers/endpoint-union.ts b/packages/gateway/src/data-plane/providers/endpoint-union.ts index 5b968deed2..fb50c5431a 100644 --- a/packages/gateway/src/data-plane/providers/endpoint-union.ts +++ b/packages/gateway/src/data-plane/providers/endpoint-union.ts @@ -1,13 +1,14 @@ import type { ModelEndpointKey, ModelEndpoints } from '@floway-dev/protocols/common'; -// Union N endpoint maps: a key appears in the result whenever ANY input -// declares it, and its sub-capability flags are OR-ed so a sub-cap -// advertised by any contributor survives. Used at two layers — the catalog -// merge collapses multiple upstream surfaces of the same public id into one -// row, and the alias listing advertises the union across an alias's -// available targets. The request-time pool narrows to whatever subset -// actually serves the inbound endpoint, so every endpoint surfaced through -// the union remains reachable. +// Merge N endpoint maps. For each repeated key, shallow-spread the prior and +// incoming sub-capabilities, so colliding fields are last-wins. Those objects +// are empty today, making this equivalent to a union by key presence; future +// non-empty shapes require an explicit merge policy. Used at two layers — the +// catalog merge collapses multiple upstream surfaces of the same public id +// into one row, and the alias listing advertises the merged endpoints across +// an alias's available targets. The request-time pool narrows to whatever +// subset actually serves the inbound endpoint, so every surfaced endpoint +// remains reachable. export const unionEndpoints = (endpointsList: readonly ModelEndpoints[]): ModelEndpoints => { const result: ModelEndpoints = {}; for (const endpoints of endpointsList) { diff --git a/packages/gateway/src/data-plane/providers/flags_test.ts b/packages/gateway/src/data-plane/providers/flags_test.ts deleted file mode 100644 index c2bd3b809c..0000000000 --- a/packages/gateway/src/data-plane/providers/flags_test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { test } from 'vitest'; - -import { isKnownFlagId, OPTIONAL_FLAGS } from '@floway-dev/provider'; -import { assertEquals } from '@floway-dev/test-utils'; - -test('provider flags: catalog ids are unique', () => { - const ids = new Set(); - for (const entry of OPTIONAL_FLAGS) { - assertEquals(ids.has(entry.id), false); - ids.add(entry.id); - } -}); - -test('provider flags: every catalog entry has a non-empty label', () => { - for (const entry of OPTIONAL_FLAGS) { - assertEquals(typeof entry.label, 'string'); - assertEquals(entry.label.length > 0, true); - } -}); - -test('provider flags: isKnownFlagId agrees with catalog', () => { - for (const entry of OPTIONAL_FLAGS) { - assertEquals(isKnownFlagId(entry.id), true); - } - assertEquals(isKnownFlagId('nonexistent-flag'), false); -}); - -const FLAG_ID_PATTERN = /^[a-z][a-z0-9-]+$/; - -test('provider flags: every catalog id is kebab-case', () => { - for (const entry of OPTIONAL_FLAGS) { - assertEquals(FLAG_ID_PATTERN.test(entry.id), true, `id ${entry.id} must be kebab-case`); - } -}); - -test('provider flags: every catalog entry has id, label, description string fields', () => { - for (const entry of OPTIONAL_FLAGS) { - assertEquals(typeof entry.id, 'string'); - assertEquals(entry.id.length > 0, true); - assertEquals(typeof entry.label, 'string'); - assertEquals(typeof entry.description, 'string'); - assertEquals(entry.description.length > 0, true); - } -}); diff --git a/packages/gateway/src/data-plane/providers/registry.ts b/packages/gateway/src/data-plane/providers/registry.ts index 2abac8da83..3b590b5019 100644 --- a/packages/gateway/src/data-plane/providers/registry.ts +++ b/packages/gateway/src/data-plane/providers/registry.ts @@ -39,7 +39,7 @@ const providersByKind: Record = { ollama: ollamaProvider, }; -export const createProviderInstance = (record: UpstreamRecord): Provider => +export const createProvider = (record: UpstreamRecord): Provider => providersByKind[record.kind].create(record); export const flagDefaultsForKind = (kind: UpstreamProviderKind): FlagDefaults => @@ -82,7 +82,7 @@ export const listModelProviders = async ( selection = [...enabledById.values()]; } - return selection.map(createProviderInstance); + return selection.map(createProvider); }; // Lift a provider-emitted `ProviderModel` into an `InternalModel`, seeding diff --git a/packages/gateway/src/data-plane/rerank/serve.ts b/packages/gateway/src/data-plane/rerank/serve.ts index 14cb9a3d38..d7ff922221 100644 --- a/packages/gateway/src/data-plane/rerank/serve.ts +++ b/packages/gateway/src/data-plane/rerank/serve.ts @@ -9,9 +9,10 @@ import { enumerateModelCandidates } from '../providers/registry.ts'; import { appendFailedUpstreams } from '../shared/failed-upstreams.ts'; import { inboundHeadersForUpstream } from '../shared/inbound-headers.ts'; import { iterateCandidates } from '../shared/iterate-candidates.ts'; -import { buildUpstreamCallOptions, telemetryModelIdentity, upstreamPerformanceContext } from '../shared/telemetry/attempt-helpers.ts'; +import { telemetryModelIdentity, upstreamPerformanceContext } from '../shared/telemetry/attribution.ts'; import { recordFailedRequest, recordPerformance, type PerformanceTelemetryContext } from '../shared/telemetry/performance.ts'; import { recordUsage } from '../shared/telemetry/usage.ts'; +import { buildUpstreamCallOptions } from '../shared/upstream-call-options.ts'; import { forwardUpstreamResponse } from '../shared/upstream-response.ts'; import { canonicalDecimalString, type RerankSourceProtocol, type RerankTarget } from '@floway-dev/protocols/common'; import { parseRerankRequest, parseRerankResponse, parseRerankUsage, renderRerankResponse, rerankRequestIncompatibility, type CanonicalRerankRequest, type CanonicalRerankResponse, type ParsedRerankRequest } from '@floway-dev/protocols/rerank'; diff --git a/packages/gateway/src/data-plane/routes.ts b/packages/gateway/src/data-plane/routes.ts index dc36bf8da1..2f8e1dc217 100644 --- a/packages/gateway/src/data-plane/routes.ts +++ b/packages/gateway/src/data-plane/routes.ts @@ -4,9 +4,9 @@ import { mountAlphaSearchRoutes } from './alpha-search/routes.ts'; import { audioTranscriptions } from './audio/transcriptions.ts'; import { mountChatRoutes } from './chat/routes.ts'; import { mountCodexRoutes } from './codex/routes.ts'; -import { completions } from './completions/serve.ts'; -import { embeddings } from './embeddings/serve.ts'; -import { imagesEdits, imagesGenerations } from './images/serve.ts'; +import { completions } from './completions/http.ts'; +import { embeddings } from './embeddings/http.ts'; +import { imagesEdits, imagesGenerations } from './images/http.ts'; import { serveGeminiModelInfo, serveGeminiModels } from './models/gemini.ts'; import { serveModels } from './models/serve.ts'; import { rerank } from './rerank/serve.ts'; diff --git a/packages/gateway/src/data-plane/shared/iterate-candidates.ts b/packages/gateway/src/data-plane/shared/iterate-candidates.ts index 3822631d1a..cb7115d582 100644 --- a/packages/gateway/src/data-plane/shared/iterate-candidates.ts +++ b/packages/gateway/src/data-plane/shared/iterate-candidates.ts @@ -1,4 +1,4 @@ -import { upstreamPerformanceContext } from './telemetry/attempt-helpers.ts'; +import { upstreamPerformanceContext } from './telemetry/attribution.ts'; import type { GatewayCtx } from '../chat/shared/gateway-ctx.ts'; import type { ModelCandidate, PerformanceOperation } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/shared/passthrough-attempt.ts b/packages/gateway/src/data-plane/shared/passthrough-attempt.ts index 444625506c..265467c902 100644 --- a/packages/gateway/src/data-plane/shared/passthrough-attempt.ts +++ b/packages/gateway/src/data-plane/shared/passthrough-attempt.ts @@ -11,8 +11,9 @@ // forwards the winning attempt (2xx) or the last failure (exhausted). import { inboundHeadersForUpstream } from './inbound-headers.ts'; -import { buildUpstreamCallOptions, telemetryModelIdentity, upstreamPerformanceContext } from './telemetry/attempt-helpers.ts'; +import { telemetryModelIdentity, upstreamPerformanceContext } from './telemetry/attribution.ts'; import type { PerformanceTelemetryContext } from './telemetry/performance.ts'; +import { buildUpstreamCallOptions } from './upstream-call-options.ts'; import type { AuthedContext } from '../../middleware/auth.ts'; import type { GatewayCtx } from '../chat/shared/gateway-ctx.ts'; import { providerModelOf } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/shared/telemetry/attempt-helpers.ts b/packages/gateway/src/data-plane/shared/telemetry/attempt-helpers.ts deleted file mode 100644 index aba08f2b64..0000000000 --- a/packages/gateway/src/data-plane/shared/telemetry/attempt-helpers.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { isFirstOutputTokenFrame } from '../../chat/shared/first-output-token.ts'; -import type { GatewayCtx } from '../../chat/shared/gateway-ctx.ts'; -import { stampUpstreamCallStart } from '../../chat/shared/gateway-ctx.ts'; -import type { ModelEndpoints, ProtocolFrame } from '@floway-dev/protocols/common'; -import { eventResult, providerModelOf, readUpstreamApiError, type ChatTargetApi, type ExecuteResult, type ModelCandidate, type PerformanceOperation, type PerformanceTelemetryContext, type ProviderStreamResult, type TelemetryModelIdentity, type UpstreamCallOptions } from '@floway-dev/provider'; - -export const upstreamPerformanceContext = ( - ctx: GatewayCtx, - candidate: ModelCandidate, - operation: PerformanceOperation, -): PerformanceTelemetryContext => ({ - keyId: ctx.apiKeyId, - model: candidate.model.id, - upstream: candidate.provider.upstream, - operation, - runtimeLocation: ctx.runtimeLocation, -}); - -// Build a picker from an ordered preference list of chat-target keys. The -// preference encodes which upstream wire the source protocol prefers to -// translate to, in order. The first preference whose endpoint key exists -// on the candidate wins. Serve calls `canServe` to filter candidates whose -// upstream wire cannot satisfy any preferred target; attempt calls `pick` -// once it has a viable candidate to choose the dispatch wire. `pick` is -// contractually total — a null return would mean the serve-side filter -// was bypassed. `canServe` is a 1-bit projection of `pick`. -export const chatTargetPicker = (preference: readonly ChatTargetApi[]): { - canServe: (endpoints: ModelEndpoints) => boolean; - pick: (endpoints: ModelEndpoints) => ChatTargetApi; -} => { - const find = (endpoints: ModelEndpoints): ChatTargetApi | null => { - for (const key of preference) { - switch (key) { - case 'messages': - if (endpoints.messages) return 'messages'; - break; - case 'responses': - if (endpoints.responses) return 'responses'; - break; - case 'chat-completions': - if (endpoints.chatCompletions) return 'chat-completions'; - break; - } - } - return null; - }; - return { - canServe: endpoints => find(endpoints) !== null, - pick: endpoints => { - const out = find(endpoints); - if (out === null) throw new Error('chatTargetPicker.pick called on a candidate the picker rejects — serve must filter via canServe first'); - return out; - }, - }; -}; - -// `model` is the upstream-facing bare id (`candidate.model.id`, -// e.g. `gpt-4o`) regardless of which surface form the client called -// (`or/gpt-4o` or `gpt-4o`). Usage and performance aggregates therefore key on -// the canonical upstream id, and a dashboard slice over `model` rolls up both -// surfaces of the same upstream model under one row. -export const telemetryModelIdentity = (candidate: ModelCandidate, modelKey: string): TelemetryModelIdentity => ({ - model: candidate.model.id, - upstream: candidate.provider.upstream, - modelKey, - pricing: providerModelOf(candidate).pricing ?? null, -}); - -// See UpstreamCallOptions in `@floway-dev/provider` for the contract on each -// field, especially header ownership. -export const buildUpstreamCallOptions = ( - candidate: ModelCandidate, - ctx: GatewayCtx, - headers: Headers, -): UpstreamCallOptions => ({ - fetcher: candidate.fetcher, - waitUntil: ctx.backgroundScheduler, - headers, - wrapUpstreamCall: stampUpstreamCallStart(ctx.attempt), -}); - -export const providerStreamResultToExecuteResult = async ( - providerResult: ProviderStreamResult, - candidate: ModelCandidate, - targetApi: ChatTargetApi, - ctx: GatewayCtx, -): Promise>> => { - const context = upstreamPerformanceContext(ctx, candidate, 'chat'); - if (!providerResult.ok) { - return { ...(await readUpstreamApiError(providerResult.response, candidate.provider.upstream)), performance: context }; - } - const stampedEvents = (async function* () { - for await (const frame of providerResult.events) { - if (ctx.attempt.firstOutputTokenAt === null && isFirstOutputTokenFrame(frame, targetApi)) { - ctx.attempt.firstOutputTokenAt = performance.now(); - } - yield frame; - } - })(); - return eventResult( - stampedEvents, - telemetryModelIdentity(candidate, providerResult.modelKey), - { performance: context, headers: providerResult.headers }, - ); -}; diff --git a/packages/gateway/src/data-plane/shared/telemetry/attribution.ts b/packages/gateway/src/data-plane/shared/telemetry/attribution.ts new file mode 100644 index 0000000000..3c00e4d4ab --- /dev/null +++ b/packages/gateway/src/data-plane/shared/telemetry/attribution.ts @@ -0,0 +1,26 @@ +import type { GatewayCtx } from '../../chat/shared/gateway-ctx.ts'; +import { providerModelOf, type ModelCandidate, type PerformanceOperation, type PerformanceTelemetryContext, type TelemetryModelIdentity } from '@floway-dev/provider'; + +export const upstreamPerformanceContext = ( + ctx: GatewayCtx, + candidate: ModelCandidate, + operation: PerformanceOperation, +): PerformanceTelemetryContext => ({ + keyId: ctx.apiKeyId, + model: candidate.model.id, + upstream: candidate.provider.upstream, + operation, + runtimeLocation: ctx.runtimeLocation, +}); + +// `model` is the upstream-facing bare id (`candidate.model.id`, +// e.g. `gpt-4o`) regardless of which surface form the client called +// (`or/gpt-4o` or `gpt-4o`). Usage and performance aggregates therefore key on +// the canonical upstream id, and a dashboard slice over `model` rolls up both +// surfaces of the same upstream model under one row. +export const telemetryModelIdentity = (candidate: ModelCandidate, modelKey: string): TelemetryModelIdentity => ({ + model: candidate.model.id, + upstream: candidate.provider.upstream, + modelKey, + pricing: providerModelOf(candidate).pricing ?? null, +}); diff --git a/packages/gateway/src/data-plane/shared/telemetry/attribution_test.ts b/packages/gateway/src/data-plane/shared/telemetry/attribution_test.ts new file mode 100644 index 0000000000..fc7aa7757b --- /dev/null +++ b/packages/gateway/src/data-plane/shared/telemetry/attribution_test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from 'vitest'; + +import { telemetryModelIdentity } from './attribution.ts'; +import { stubModelCandidate } from '@floway-dev/test-utils'; + +describe('telemetryModelIdentity', () => { + test('captures pricing from the exact dispatched provider model', () => { + const pricing = { entries: [{ rates: { input_tokens: '3', output_tokens: '12' } }] }; + const candidate = stubModelCandidate({ model: { pricing } }); + expect(telemetryModelIdentity(candidate, 'raw-model')).toEqual({ + model: 'test-model', + upstream: 'test-upstream', + modelKey: 'raw-model', + pricing, + }); + }); +}); diff --git a/packages/gateway/src/data-plane/shared/upstream-call-options.ts b/packages/gateway/src/data-plane/shared/upstream-call-options.ts new file mode 100644 index 0000000000..b91bcea8bd --- /dev/null +++ b/packages/gateway/src/data-plane/shared/upstream-call-options.ts @@ -0,0 +1,16 @@ +import type { GatewayCtx } from '../chat/shared/gateway-ctx.ts'; +import { stampUpstreamCallStart } from '../chat/shared/gateway-ctx.ts'; +import type { ModelCandidate, UpstreamCallOptions } from '@floway-dev/provider'; + +// See UpstreamCallOptions in `@floway-dev/provider` for the contract on each +// field, especially header ownership. +export const buildUpstreamCallOptions = ( + candidate: ModelCandidate, + ctx: GatewayCtx, + headers: Headers, +): UpstreamCallOptions => ({ + fetcher: candidate.fetcher, + waitUntil: ctx.backgroundScheduler, + headers, + wrapUpstreamCall: stampUpstreamCallStart(ctx.attempt), +}); diff --git a/packages/gateway/src/data-plane/tools/web-search/usage.ts b/packages/gateway/src/data-plane/tools/web-search/usage.ts index 1177949f13..14629cc21e 100644 --- a/packages/gateway/src/data-plane/tools/web-search/usage.ts +++ b/packages/gateway/src/data-plane/tools/web-search/usage.ts @@ -1,8 +1,7 @@ import type { WebSearchProviderName } from './types.ts'; import { getRepo } from '../../../repo/index.ts'; import type { SearchUsageAction } from '../../../repo/types.ts'; - -const currentHour = (): string => new Date().toISOString().slice(0, 13); +import { currentHour } from '../../shared/telemetry/hour.ts'; // Records a single usage row. Hour is computed at write time; `requests` // defaults to 1. Throws if the repo write fails — callers wrap this in diff --git a/packages/gateway/src/dump/broker.ts b/packages/gateway/src/dump/broker.ts index af33123b8b..067f8c46a8 100644 --- a/packages/gateway/src/dump/broker.ts +++ b/packages/gateway/src/dump/broker.ts @@ -1,5 +1,5 @@ import type { DumpMetadata } from './types.ts'; -import type { ChannelBroker } from '../runtime/channel-broker-contract.ts'; +import type { ChannelBroker } from '@floway-dev/platform'; export type DumpBroker = ChannelBroker; diff --git a/packages/gateway/src/dump/codec.ts b/packages/gateway/src/dump/codec.ts index cf5bfe241c..d3f8a91449 100644 --- a/packages/gateway/src/dump/codec.ts +++ b/packages/gateway/src/dump/codec.ts @@ -1,5 +1,5 @@ import type { DumpMetadata } from './types.ts'; -import type { Codec } from '../runtime/channel-broker-contract.ts'; +import type { ChannelCodec } from '@floway-dev/platform'; const APPENDED_EVENT = 'appended'; @@ -8,7 +8,7 @@ interface AppendedFrame { data: DumpMetadata; } -export const dumpCodec: Codec = { +export const dumpCodec: ChannelCodec = { encode: meta => JSON.stringify({ event: APPENDED_EVENT, data: meta } satisfies AppendedFrame), decode: text => { const parsed = JSON.parse(text) as { event: unknown; data: unknown }; diff --git a/packages/gateway/src/dump/store-contract.ts b/packages/gateway/src/dump/store-contract.ts index 7bc7ddd7f1..8f6f922419 100644 --- a/packages/gateway/src/dump/store-contract.ts +++ b/packages/gateway/src/dump/store-contract.ts @@ -1,7 +1,7 @@ import type { DumpMetadata, DumpRecordId, DumpWriteRecord, PreparedDumpRequestBody, StoredDumpRecord } from './types.ts'; // Per-API-key request dump storage contract: metadata in SQL, bodies in the -// FileProvider. Request bytes are prepared before the terminal write; reads +// FileStore. Request bytes are prepared before the terminal write; reads // always rehydrate raw bytes for the control plane. export interface DumpListOptions { diff --git a/packages/gateway/src/index.ts b/packages/gateway/src/index.ts index a818927bef..0e5749a869 100644 --- a/packages/gateway/src/index.ts +++ b/packages/gateway/src/index.ts @@ -4,7 +4,6 @@ export { FileDumpStore } from './repo/dump-store.ts'; export { SqlRepo } from './repo/sql.ts'; export { initBackgroundSchedulerResolver } from './runtime/background.ts'; export { initDumpBroker, initDumpStore } from './dump/registry.ts'; -export type { DumpBroker } from './dump/broker.ts'; export type { DumpStore } from './dump/store-contract.ts'; export { initResponsesWebSocketUpgradeResolver, type ResponsesWebSocketEvents } from './data-plane/chat/responses/websocket.ts'; export { runScheduledMaintenance } from './scheduled.ts'; diff --git a/packages/gateway/src/repo/dump-store.ts b/packages/gateway/src/repo/dump-store.ts index aca41c7e5b..9fa6888d69 100644 --- a/packages/gateway/src/repo/dump-store.ts +++ b/packages/gateway/src/repo/dump-store.ts @@ -13,7 +13,7 @@ import type { StoredDumpResponse, StoredDumpResponseBody, } from '../dump/types.ts'; -import type { FileProvider, SqlDatabase } from '@floway-dev/platform'; +import type { FileStore, SqlDatabase } from '@floway-dev/platform'; // Bodies live at `dumps/v1/{keyId}/{YYYYMMDDHH}/{recordId}-{uniqueSuffix}.{req|resp}.gz`. // The hour segment remains useful for operator inspection; lifecycle and @@ -81,7 +81,7 @@ const gunzip = async (bytes: Uint8Array): Promise => { }; const putRawBody = async ( - files: FileProvider, + files: FileStore, key: string, rawBytes: Uint8Array, type: 'bytes' | 'events', @@ -92,7 +92,7 @@ const putRawBody = async ( }; const putPreparedBody = async ( - files: FileProvider, + files: FileStore, key: string, prepared: PreparedDumpRequestBody, ): Promise => { @@ -101,14 +101,14 @@ const putPreparedBody = async ( return { key, type: 'bytes' }; }; -const fetchBody = async (files: FileProvider, descriptor: BodyDescriptor): Promise => { +const fetchBody = async (files: FileStore, descriptor: BodyDescriptor): Promise => { const gz = await files.get(descriptor.key); if (!gz) throw new Error(`dump body missing for key=${descriptor.key}`); return await gunzip(gz); }; export class FileDumpStore implements DumpStore { - constructor(private readonly db: SqlDatabase, private readonly files: FileProvider) {} + constructor(private readonly db: SqlDatabase, private readonly files: FileStore) {} async prepareRequestBody(body: Uint8Array): Promise { return { diff --git a/packages/gateway/src/repo/dump-store_test.ts b/packages/gateway/src/repo/dump-store_test.ts index 0e0f61f509..926e5fdeb5 100644 --- a/packages/gateway/src/repo/dump-store_test.ts +++ b/packages/gateway/src/repo/dump-store_test.ts @@ -10,8 +10,8 @@ import { collectSpilledFiles } from './spilled-files.ts'; import { SqlRepo } from './sql.ts'; import { createSqliteTestDb } from './test-sqlite.ts'; import type { DumpWriteRecord } from '../dump/types.ts'; -import { initFileProvider, MemoryFileProvider } from '@floway-dev/platform'; -import type { FileProvider, SqlDatabase } from '@floway-dev/platform'; +import { initFileStore, MemoryFileStore } from '@floway-dev/platform'; +import type { FileStore, SqlDatabase } from '@floway-dev/platform'; import { assertEquals, assertExists } from '@floway-dev/test-utils'; const openDb = async (): Promise => { @@ -55,7 +55,7 @@ const baseRecord = (id: string, completedAt: number): DumpWriteRecord => ({ test('FileDumpStore prepares request gzip before terminal persistence', async () => { const db = await openDb(); - const files = new MemoryFileProvider(); + const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); const raw = utf8(`{"content":"${'repeatable '.repeat(4096)}"}`); const prepared = await store.prepareRequestBody(raw); @@ -82,7 +82,7 @@ test('FileDumpStore prepares request gzip before terminal persistence', async () test('FileDumpStore round-trips a JSON record through gzip', async () => { const db = await openDb(); - const files = new MemoryFileProvider(); + const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); const record = baseRecord('01HZZ0000000000000000000A1', Date.UTC(2026, 5, 1, 12, 0, 0)); @@ -97,7 +97,7 @@ test('FileDumpStore round-trips a JSON record through gzip', async () => { test('FileDumpStore preserves the original content-type header on binary bodies', async () => { const db = await openDb(); - const files = new MemoryFileProvider(); + const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); const pngMagic = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); const record: DumpWriteRecord = { @@ -120,7 +120,7 @@ test('FileDumpStore preserves the original content-type header on binary bodies' test('FileDumpStore preserves the bytes discriminator on an empty-body response', async () => { const db = await openDb(); - const files = new MemoryFileProvider(); + const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); // 204-style: real upstream response with status + headers but a zero-length // body. Persistence drops the body file (nothing to gzip), but headers are @@ -144,7 +144,7 @@ test('FileDumpStore preserves the bytes discriminator on an empty-body response' test('FileDumpStore round-trips an SSE record as a stream events array', async () => { const db = await openDb(); - const files = new MemoryFileProvider(); + const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); const record: DumpWriteRecord = { ...baseRecord('01HZZ0000000000000000000A2', Date.UTC(2026, 5, 1, 12, 0, 0)), @@ -170,7 +170,7 @@ test('FileDumpStore round-trips an SSE record as a stream events array', async ( test('FileDumpStore.list paginates newest-first with the (createdAt, id) cursor', async () => { const db = await openDb(); - const files = new MemoryFileProvider(); + const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); const base = Date.UTC(2026, 5, 1, 12, 0, 0); for (let i = 0; i < 5; i++) { @@ -186,8 +186,8 @@ test('FileDumpStore applies retention immediately and retires exact expired file const db = await openDb(); const repo = new SqlRepo(db); initRepo(repo); - const files = new MemoryFileProvider(); - initFileProvider(files); + const files = new MemoryFileStore(); + initFileStore(files); const store = new FileDumpStore(db, files); const now = Date.UTC(2026, 5, 1, 12, 0, 0); // Old bucket 9:xx, current bucket 12:xx. @@ -218,7 +218,7 @@ test('FileDumpStore applies retention immediately and retires exact expired file test('growing dump retention can reveal a row not yet physically deleted', async () => { const db = await openDb(); const repo = new SqlRepo(db); - const files = new MemoryFileProvider(); + const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); const now = Date.UTC(2026, 5, 1, 12); await store.put('key_x', baseRecord('01HZZ0000000000000000000A4', now - 3 * 3600_000)); @@ -238,8 +238,8 @@ test('FileDumpStore retires every dump record when retention is disabled and col const db = await openDb(); const repo = new SqlRepo(db); initRepo(repo); - const files = new MemoryFileProvider(); - initFileProvider(files); + const files = new MemoryFileStore(); + initFileStore(files); const store = new FileDumpStore(db, files); await store.put('key_x', baseRecord('01HZZ0000000000000000000A1', Date.UTC(2026, 5, 1, 9, 0, 0))); await store.put('key_x', baseRecord('01HZZ0000000000000000000A2', Date.UTC(2026, 5, 1, 12, 0, 0))); @@ -255,8 +255,8 @@ test('a record-ID race leaves only the losing write\'s uniquely keyed files coll const db = await openDb(); const repo = new SqlRepo(db); initRepo(repo); - const files = new MemoryFileProvider(); - initFileProvider(files); + const files = new MemoryFileStore(); + initFileStore(files); const store = new FileDumpStore(db, files); const record = baseRecord('01HZZ0000000000000000000A3', Date.now()); await store.put('key_x', record); @@ -273,20 +273,20 @@ test('a record-ID race leaves only the losing write\'s uniquely keyed files coll test('FileDumpStore expiration against a never-written key resolves without throwing', async () => { const db = await openDb(); - const files = new MemoryFileProvider(); + const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); assertEquals((await store.list('never_written_key', { limit: 10 })).length, 0); assertEquals(await store.deleteExpiredBatch('never_written_key', Date.now(), 100), 0); assertEquals((await db.prepare("SELECT COUNT(*) AS count FROM dump_records WHERE key_id = 'never_written_key'").first<{ count: number }>())?.count, 0); }); -// Smoke test: drive FileDumpStore against a real-filesystem FileProvider so a -// regression where the store leans on MemoryFileProvider's stricter ordering / -// instant durability surfaces here. The inline FileProvider mirrors the shape -// of the Node platform-target app's `FsFileProvider` — keeping this test in +// Smoke test: drive FileDumpStore against a real-filesystem FileStore so a +// regression where the store leans on MemoryFileStore's stricter ordering / +// instant durability surfaces here. The inline FileStore mirrors the shape +// of the Node platform-target app's `FsFileStore` — keeping this test in // gateway, not in apps/platform-node, is what lets that app's src/ tree stay // free of business-domain knowledge. -class TmpDirFileProvider implements FileProvider { +class TmpDirFileStore implements FileStore { constructor(private readonly root: string) {} async put(key: string, body: Uint8Array): Promise { const path = this.pathFor(key); @@ -313,7 +313,7 @@ test('FileDumpStore: put + get round-trips through real-filesystem IO', async () const root = await mkdtemp(join(tmpdir(), 'dump-store-')); try { const db = await openDb(); - const store = new FileDumpStore(db, new TmpDirFileProvider(join(root, 'files'))); + const store = new FileDumpStore(db, new TmpDirFileStore(join(root, 'files'))); const record = baseRecord('01HZZ0000000000000000000A1', Date.UTC(2026, 5, 1, 12, 0, 0)); await store.put('key_x', record); diff --git a/packages/gateway/src/repo/expiration-sweeps_test.ts b/packages/gateway/src/repo/expiration-sweeps_test.ts index 26fe60c235..805a85d3c4 100644 --- a/packages/gateway/src/repo/expiration-sweeps_test.ts +++ b/packages/gateway/src/repo/expiration-sweeps_test.ts @@ -11,7 +11,7 @@ import { createSqliteTestDb, migrationSqlByFilename } from './test-sqlite.ts'; import type { ApiKey, StoredResponsesItem } from './types.ts'; import { initDumpStore } from '../dump/registry.ts'; import type { DumpWriteRecord } from '../dump/types.ts'; -import { initFileProvider, MemoryFileProvider } from '@floway-dev/platform'; +import { initFileStore, MemoryFileStore } from '@floway-dev/platform'; afterEach(() => vi.useRealTimers()); @@ -71,8 +71,8 @@ test('one fair driver drains bounded Responses and dump backlogs', async () => { const db = await createSqliteTestDb(); const repo = new SqlRepo(db); initRepo(repo); - const files = new MemoryFileProvider(); - initFileProvider(files); + const files = new MemoryFileStore(); + initFileStore(files); const dumps = new FileDumpStore(db, files); initDumpStore(dumps); await repo.apiKeys.save({ ...key(now), dumpRetentionSeconds: 7200, responsesRetentionSeconds: 2 * RESPONSES_RETENTION_SECONDS }); @@ -109,7 +109,7 @@ test('a partial hot key yields the current tick to another due key', async () => const db = await createSqliteTestDb(); const repo = new SqlRepo(db); initRepo(repo); - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); await repo.apiKeys.save({ ...key(now), id: 'a-hot', key: 'raw-a-hot', serverSecret: '77'.repeat(32), responsesRetentionSeconds: 2 * RESPONSES_RETENTION_SECONDS }); await repo.apiKeys.save({ ...key(now), id: 'b-small', key: 'raw-b-small', serverSecret: '88'.repeat(32), responsesRetentionSeconds: 2 * RESPONSES_RETENTION_SECONDS }); const expiredAt = now - 2 * RESPONSES_REFRESH_GRANULARITY_MS - 1; @@ -149,7 +149,7 @@ test('a later Responses row inserted during a claim prevents queue deletion', as vi.setSystemTime(now); const db = await createSqliteTestDb(); const repo = new SqlRepo(db); - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); await repo.apiKeys.save(key(now)); await repo.expirationSweeps.schedule('responses', 'key-a', 0); const claim = await repo.expirationSweeps.claim('claim-row-race', now, 0); @@ -170,7 +170,7 @@ test('partial completion yields even when a concurrent Responses row bumps the r vi.setSystemTime(now); const db = await createSqliteTestDb(); const repo = new SqlRepo(db); - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); await repo.apiKeys.save(key(now)); await repo.expirationSweeps.schedule('responses', 'key-a', 0); const claim = await repo.expirationSweeps.claim('claim-partial-race', now, 0); @@ -347,7 +347,7 @@ test('bounded cleanup backfill skips API keys without stored state', async () => vi.setSystemTime(now); const db = await createSqliteTestDb(); const repo = new SqlRepo(db); - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); await repo.apiKeys.save(key(now)); await repo.apiKeys.save({ ...key(now), id: 'key-empty', key: 'raw-empty', serverSecret: '99'.repeat(32) }); await repo.responsesItems.insertMany([responseItem('msg-owned', now)], 0); @@ -370,7 +370,7 @@ test('in-memory Responses rows enter the same expiration driver', async () => { vi.setSystemTime(now); const repo = new InMemoryRepo(); initRepo(repo); - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); await repo.apiKeys.save(key(now)); const item = responseItem('msg-memory', now); await repo.responsesItems.insertMany([item], 0); diff --git a/packages/gateway/src/repo/memory.ts b/packages/gateway/src/repo/memory.ts index b9a97ccf88..db66ff0025 100644 --- a/packages/gateway/src/repo/memory.ts +++ b/packages/gateway/src/repo/memory.ts @@ -9,6 +9,7 @@ import { scopedResponsesKey, } from './responses-clone.ts'; import { quantizeResponsesRefreshedAt, RESPONSES_REFRESH_GRANULARITY_MS, responsesStateCutoff } from './responses-retention.ts'; +import { generateSessionToken } from './session-tokens.ts'; import type { ApiKey, ApiKeyRepo, @@ -39,7 +40,6 @@ import type { ResponsesItemsRepo, ResponsesSnapshotsRepo, SpilledFilesRepo, - SearchConfig, SearchConfigRepo, SearchUsageRecord, SearchUsageRepo, @@ -56,8 +56,7 @@ import type { import { serializeStoredState } from './upstream-json.ts'; import { usageMetricRows } from './usage-metrics.ts'; import { bucketForTtftMs, bucketForTpotUs } from '../shared/performance-histogram.ts'; -import { generateSessionToken } from '../shared/session-tokens.ts'; -import { assertWebSearchProviderName } from '../shared/web-search-providers.ts'; +import { assertWebSearchProviderName, type SearchConfig } from '../shared/web-search-providers.ts'; import { AgentSetupTokenCollisionError } from '@floway-dev/agent-setup'; import { addDecimalStrings, canonicalPricingSelectorKey, canonicalizePricingSelector, type BillingMetric, type DecimalString, type PricingSelector } from '@floway-dev/protocols/common'; import type { ProviderModel, UpstreamRecord } from '@floway-dev/provider'; diff --git a/packages/gateway/src/control-plane/model-aliases/repo_test.ts b/packages/gateway/src/repo/model-aliases_test.ts similarity index 97% rename from packages/gateway/src/control-plane/model-aliases/repo_test.ts rename to packages/gateway/src/repo/model-aliases_test.ts index 16564e5ab7..f5ca6dc141 100644 --- a/packages/gateway/src/control-plane/model-aliases/repo_test.ts +++ b/packages/gateway/src/repo/model-aliases_test.ts @@ -4,10 +4,10 @@ import { test } from 'vitest'; -import { InMemoryRepo } from '../../repo/memory.ts'; -import { SqlRepo } from '../../repo/sql.ts'; -import { createSqliteTestDb } from '../../repo/test-sqlite.ts'; -import type { ModelAliasRecord, Repo } from '../../repo/types.ts'; +import { InMemoryRepo } from './memory.ts'; +import { SqlRepo } from './sql.ts'; +import { createSqliteTestDb } from './test-sqlite.ts'; +import type { ModelAliasRecord, Repo } from './types.ts'; import { assertEquals, assertExists, assertRejects } from '@floway-dev/test-utils'; const REPO_BACKENDS: Array Promise]> = [ diff --git a/packages/gateway/src/repo/responses-items_test.ts b/packages/gateway/src/repo/responses-items_test.ts index 942a2f293c..4a87df8372 100644 --- a/packages/gateway/src/repo/responses-items_test.ts +++ b/packages/gateway/src/repo/responses-items_test.ts @@ -10,7 +10,7 @@ import { collectSpilledFiles } from './spilled-files.ts'; import { SqlRepo } from './sql.ts'; import { createSqliteTestDb, migrationSqlByFilename } from './test-sqlite.ts'; import type { ApiKey, Repo, StoredResponsesItem } from './types.ts'; -import { initFileProvider, MemoryFileProvider } from '@floway-dev/platform'; +import { initFileStore, MemoryFileStore } from '@floway-dev/platform'; const RETENTION_SECONDS = 24 * 60 * 60; const DAY_MS = RETENTION_SECONDS * 1000; @@ -56,7 +56,7 @@ describe.each(backends)('%s Responses state repository', (_backend, makeRepo) => test('scopes exact and content-hash reads by key and rolling cutoff', async () => { vi.useFakeTimers(); vi.setSystemTime(atDay(4)); - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const repo = await makeRepo(); await repo.apiKeys.save(apiKey(7 * RETENTION_SECONDS)); await repo.apiKeys.save({ ...apiKey(7 * RETENTION_SECONDS), id: 'key-b', key: 'raw-key-b', serverSecret: '22'.repeat(32) }); @@ -73,7 +73,7 @@ describe.each(backends)('%s Responses state repository', (_backend, makeRepo) => test('rejects a live producer-ID collision but replaces an expired row', async () => { vi.useFakeTimers(); vi.setSystemTime(atDay(11, DAY_MS / 2)); - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const repo = await makeRepo(); await repo.apiKeys.save(apiKey()); const original = storedItem('msg-collision', atDay(10), 'original'); @@ -89,7 +89,7 @@ describe.each(backends)('%s Responses state repository', (_backend, makeRepo) => test('refreshes only active rows and never lowers their timestamp', async () => { vi.useFakeTimers(); vi.setSystemTime(atDay(11, DAY_MS / 2)); - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const repo = await makeRepo(); await repo.apiKeys.save(apiKey()); const item = storedItem('msg-refresh', atDay(10)); @@ -102,7 +102,7 @@ describe.each(backends)('%s Responses state repository', (_backend, makeRepo) => }); test('deletes rows outside each key current rolling policy', async () => { - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const repo = await makeRepo(); const now = atDay(10); vi.useFakeTimers(); @@ -144,7 +144,7 @@ describe.each(backends)('%s Responses state repository', (_backend, makeRepo) => }); test('a concurrent shrink does not change an in-flight request retention snapshot', async () => { - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const repo = await makeRepo(); const now = Date.now(); const thirtyDays = 30 * 24 * 60 * 60; @@ -161,7 +161,7 @@ describe.each(backends)('%s Responses state repository', (_backend, makeRepo) => }); test('a concurrent grow does not widen an in-flight request retention snapshot', async () => { - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const repo = await makeRepo(); const now = Date.now(); const thirtyDays = 30 * 24 * 60 * 60; @@ -179,7 +179,7 @@ describe.each(backends)('%s Responses state repository', (_backend, makeRepo) => }); test('an in-flight request reuses its narrower retention snapshot after a concurrent grow', async () => { - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const repo = await makeRepo(); const now = atDay(40, DAY_MS / 2); vi.useFakeTimers(); @@ -200,7 +200,7 @@ describe.each(backends)('%s Responses state repository', (_backend, makeRepo) => }); test('growing retention reveals a surviving row inside the wider window', async () => { - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const repo = await makeRepo(); const now = Date.now(); const thirtyDays = 30 * 24 * 60 * 60; @@ -217,7 +217,7 @@ describe.each(backends)('%s Responses state repository', (_backend, makeRepo) => ]); }); test('a concurrent disable does not cancel a captured durable writer', async () => { - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const repo = await makeRepo(); const now = Date.now(); await repo.apiKeys.save(apiKey(RETENTION_SECONDS)); @@ -231,7 +231,7 @@ describe.each(backends)('%s Responses state repository', (_backend, makeRepo) => }); test('same-day reuse keeps the request snapshot after a concurrent disable', async () => { - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const repo = await makeRepo(); const now = atDay(10, DAY_MS / 2); vi.useFakeTimers(); @@ -248,7 +248,7 @@ describe.each(backends)('%s Responses state repository', (_backend, makeRepo) => }); test('an old request cannot refresh a replacement payload under a reused ID', async () => { - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const repo = await makeRepo(); const now = atDay(10, DAY_MS / 2); vi.useFakeTimers(); @@ -269,7 +269,7 @@ describe.each(backends)('%s Responses state repository', (_backend, makeRepo) => }); test('missing keys reject writes while soft-deleted keys preserve captured requests', async () => { - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const repo = await makeRepo(); const now = Date.now(); const missing = storedItem('msg-missing-key', now); @@ -289,8 +289,8 @@ test('SQL spill ownership is first-class and the shared collector reclaims retir const db = await createSqliteTestDb(); const repo = new SqlRepo(db); initRepo(repo); - const files = new MemoryFileProvider(); - initFileProvider(files); + const files = new MemoryFileStore(); + initFileStore(files); const now = atDay(10, DAY_MS / 2); vi.useFakeTimers(); vi.setSystemTime(now); @@ -322,7 +322,7 @@ test('SQL performs no item or snapshot mutation after an earlier refresh in the vi.setSystemTime(atDay(10, DAY_MS / 4)); const db = await createSqliteTestDb(); const repo = new SqlRepo(db); - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); await repo.apiKeys.save(apiKey()); const item = storedItem('msg-daily-refresh', atDay(10, 1_000)); const snapshot = { id: 'resp-daily-refresh', apiKeyId: 'key-a', itemIds: [item.id], refreshedAt: atDay(10, 1_000) }; @@ -350,8 +350,8 @@ test('SQL hydration retries with every current item identity column after a repl vi.setSystemTime(atDay(11)); const db = await createSqliteTestDb(); const repo = new SqlRepo(db); - const files = new MemoryFileProvider(); - initFileProvider(files); + const files = new MemoryFileStore(); + initFileStore(files); await repo.apiKeys.save(apiKey()); const original = storedItem('msg-hydration-race', atDay(10), largeContent()); const replacement = storedItem(original.id, atDay(11), 'replacement'); diff --git a/packages/gateway/src/repo/responses-payload.ts b/packages/gateway/src/repo/responses-payload.ts index 5337634601..2cdd7c2a44 100644 --- a/packages/gateway/src/repo/responses-payload.ts +++ b/packages/gateway/src/repo/responses-payload.ts @@ -1,5 +1,5 @@ import type { StoredResponsesItemPayload } from './types.ts'; -import { getFileProvider, sha256Hex } from '@floway-dev/platform'; +import { getFileStore, sha256Hex } from '@floway-dev/platform'; type StoredResponsesPayloadJson = | { @@ -75,7 +75,7 @@ export const prepareStoredResponsesPayload = async ( }; export const writePreparedStoredResponsesPayload = async (prepared: PreparedStoredResponsesPayload): Promise => { - if (prepared.file !== null) await getFileProvider().put(prepared.file.key, prepared.file.body); + if (prepared.file !== null) await getFileStore().put(prepared.file.key, prepared.file.body); }; export const parseStoredResponsesPayload = async ( @@ -90,7 +90,7 @@ export const parseStoredResponsesPayload = async ( } if (fileKey === null) throw new Error(`Stored Responses payload file key missing for id=${id}`); - const body = await getFileProvider().get(fileKey); + const body = await getFileStore().get(fileKey); if (body === null) throw new Error(`Stored Responses payload file missing for id=${id}`); if (body.byteLength !== descriptor.byteLength) { throw new Error(`Stored Responses payload file size mismatch for id=${id}`); diff --git a/packages/gateway/src/repo/responses-payload_test.ts b/packages/gateway/src/repo/responses-payload_test.ts index 4398ec3e35..649a771bbe 100644 --- a/packages/gateway/src/repo/responses-payload_test.ts +++ b/packages/gateway/src/repo/responses-payload_test.ts @@ -5,7 +5,7 @@ import { prepareStoredResponsesPayload, writePreparedStoredResponsesPayload, } from './responses-payload.ts'; -import { initFileProvider, MemoryFileProvider } from '@floway-dev/platform'; +import { initFileStore, MemoryFileStore } from '@floway-dev/platform'; const payload = (content: string) => ({ item: { type: 'message', id: 'msg_payload', role: 'assistant', content }, @@ -15,7 +15,7 @@ const payload = (content: string) => ({ const largeContent = (): string => Array.from({ length: 4_096 }, () => crypto.randomUUID()).join(''); test('small Responses payloads stay inline without a file relation', async () => { - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const expected = payload('small'); const prepared = await prepareStoredResponsesPayload('msg_payload', 'key-a', expected); @@ -24,8 +24,8 @@ test('small Responses payloads stay inline without a file relation', async () => }); test('large Responses payloads use an external file whose key is not embedded in payload JSON', async () => { - const files = new MemoryFileProvider(); - initFileProvider(files); + const files = new MemoryFileStore(); + initFileStore(files); const expected = payload(largeContent()); const prepared = await prepareStoredResponsesPayload('msg_payload', 'key-a', expected); if (prepared.file === null) throw new Error('expected payload to spill'); @@ -38,7 +38,7 @@ test('large Responses payloads use an external file whose key is not embedded in }); test('each prepared spill uses a unique object key', async () => { - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); const expected = payload(largeContent()); const first = await prepareStoredResponsesPayload('msg_payload', 'key-a', expected); const second = await prepareStoredResponsesPayload('msg_payload', 'key-a', expected); @@ -48,8 +48,8 @@ test('each prepared spill uses a unique object key', async () => { }); test('spilled payload reads verify file integrity', async () => { - const files = new MemoryFileProvider(); - initFileProvider(files); + const files = new MemoryFileStore(); + initFileStore(files); const prepared = await prepareStoredResponsesPayload( 'msg_payload', 'key-a', diff --git a/packages/gateway/src/shared/session-tokens.ts b/packages/gateway/src/repo/session-tokens.ts similarity index 100% rename from packages/gateway/src/shared/session-tokens.ts rename to packages/gateway/src/repo/session-tokens.ts diff --git a/packages/gateway/src/shared/session-tokens_test.ts b/packages/gateway/src/repo/session-tokens_test.ts similarity index 100% rename from packages/gateway/src/shared/session-tokens_test.ts rename to packages/gateway/src/repo/session-tokens_test.ts diff --git a/packages/gateway/src/repo/spilled-files.ts b/packages/gateway/src/repo/spilled-files.ts index 1267afe7b3..b7dd6bc05f 100644 --- a/packages/gateway/src/repo/spilled-files.ts +++ b/packages/gateway/src/repo/spilled-files.ts @@ -1,5 +1,5 @@ import { getRepo } from './index.ts'; -import { getFileProvider } from '@floway-dev/platform'; +import { getFileStore } from '@floway-dev/platform'; const CLAIM_TIMEOUT_MS = 60 * 60 * 1000; const FILE_DELETE_BATCH_SIZE = 1_000; @@ -14,7 +14,7 @@ export const collectSpilledFiles = async (now: number): Promise => { FILE_DELETE_BATCH_SIZE, ); if (keys.length === 0) return; - await getFileProvider().deleteKeys(keys); + await getFileStore().deleteKeys(keys); const acknowledged = await repo.spilledFiles.acknowledge(token); if (acknowledged !== keys.length) { throw new Error(`Spilled-file collection acknowledged ${acknowledged} of ${keys.length} claimed files`); diff --git a/packages/gateway/src/repo/sql.ts b/packages/gateway/src/repo/sql.ts index cba81f7dc4..f455b4835f 100644 --- a/packages/gateway/src/repo/sql.ts +++ b/packages/gateway/src/repo/sql.ts @@ -3,6 +3,7 @@ import { SqlExpirationSweepsRepo } from './expiration-sweeps-sql.ts'; import { normalizeFlagOverrides } from './flag-overrides.ts'; import { normalizeProxyFallbackList } from './proxy-fallback-list.ts'; import { SqlResponsesItemsRepo, SqlResponsesSnapshotsRepo } from './responses-state-sql.ts'; +import { generateSessionToken } from './session-tokens.ts'; import { SqlSpilledFilesRepo } from './spilled-files-sql.ts'; import type { ApiKey, @@ -31,7 +32,6 @@ import type { ResponsesItemsRepo, ResponsesSnapshotsRepo, SpilledFilesRepo, - SearchConfig, SearchConfigRepo, SearchUsageRecord, SearchUsageRepo, @@ -48,8 +48,7 @@ import { parseUpstreamColor, parseUpstreamKind } from './upstream-parse.ts'; import { usageMetricRows } from './usage-metrics.ts'; import { bucketForTtftMs, bucketForTpotUs } from '../shared/performance-histogram.ts'; import { parseServerSecret } from '../shared/server-secret.ts'; -import { generateSessionToken } from '../shared/session-tokens.ts'; -import { assertWebSearchProviderName } from '../shared/web-search-providers.ts'; +import { assertWebSearchProviderName, type SearchConfig } from '../shared/web-search-providers.ts'; import { AgentSetupTokenCollisionError } from '@floway-dev/agent-setup'; import type { SqlDatabase, SqlPreparedStatement, SqlResult } from '@floway-dev/platform'; import { addDecimalStrings, canonicalPricingSelectorKey, parseBillingMetric, parseModelKind, parseNonNegativeDecimalString, parsePricingSelectorKey, type AliasSelection, type AliasTarget, type AnnouncedMetadata } from '@floway-dev/protocols/common'; diff --git a/packages/gateway/src/repo/types.ts b/packages/gateway/src/repo/types.ts index 66be27817f..08ccc049bb 100644 --- a/packages/gateway/src/repo/types.ts +++ b/packages/gateway/src/repo/types.ts @@ -1,5 +1,4 @@ import type { SearchConfig, WebSearchProviderName } from '../shared/web-search-providers.ts'; -export type { SearchConfig } from '../shared/web-search-providers.ts'; import type { AgentSetupRepository } from '@floway-dev/agent-setup'; import type { AliasSelection, AliasTarget, AnnouncedMetadata, BillingMetric, DecimalString, ModelKind, PricingSelector } from '@floway-dev/protocols/common'; import type { PerformanceTelemetryContext, ProviderModel, UpstreamRecord } from '@floway-dev/provider'; diff --git a/packages/gateway/src/scheduled_test.ts b/packages/gateway/src/scheduled_test.ts index cdbe0645e5..d34ac24c64 100644 --- a/packages/gateway/src/scheduled_test.ts +++ b/packages/gateway/src/scheduled_test.ts @@ -2,11 +2,11 @@ import { expect, test, vi } from 'vitest'; import { runScheduledMaintenance } from './scheduled.ts'; import { setupAppTest } from './test-helpers.ts'; -import { initFileProvider, initImageCacheStore, MemoryFileProvider } from '@floway-dev/platform'; +import { initFileStore, initImageCacheStore, MemoryFileStore } from '@floway-dev/platform'; test('scheduled maintenance isolates the shared expiration driver from later collectors', async () => { const { repo } = await setupAppTest(); - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); let imageSwept = false; initImageCacheStore({ async get() { return null; }, @@ -27,8 +27,8 @@ test('scheduled maintenance isolates the shared expiration driver from later col test('scheduled maintenance collects exact spilled files after expiration work', async () => { const { repo } = await setupAppTest(); - const files = new MemoryFileProvider(); - initFileProvider(files); + const files = new MemoryFileStore(); + initFileStore(files); initImageCacheStore({ async get() { return null; }, async put() {}, async sweepExpired() {} }); vi.spyOn(repo.expirationSweeps, 'claim').mockResolvedValue(null); const key = 'spilled/retired.gz'; diff --git a/packages/gateway/src/shared/upstream/model-config_test.ts b/packages/gateway/src/shared/upstream/model-config_test.ts deleted file mode 100644 index 2964e3db85..0000000000 --- a/packages/gateway/src/shared/upstream/model-config_test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { test } from 'vitest'; - -import { modelsField } from '@floway-dev/provider'; -import { assertEquals, assertThrows } from '@floway-dev/test-utils'; - -test('modelsField parses a full model entry', () => { - const models = modelsField( - [ - { - upstreamModelId: 'gpt-prod', - publicModelId: 'gpt-5', - endpoints: { chatCompletions: {}, responses: {} }, - display_name: 'GPT Prod', - limits: { max_context_window_tokens: 128000, max_output_tokens: 4096 }, - pricing: { entries: [{ rates: { input_tokens: '2.5', output_tokens: '15', input_cache_read_tokens: '0.25', input_cache_write_tokens: '3.75' } }] }, - flagOverrides: { 'vendor-deepseek': false }, - }, - ], - 'azure', - ); - - assertEquals(models, [ - { - upstreamModelId: 'gpt-prod', - publicModelId: 'gpt-5', - kind: 'chat', - endpoints: { chatCompletions: {}, responses: {} }, - display_name: 'GPT Prod', - limits: { max_context_window_tokens: 128000, max_output_tokens: 4096 }, - pricing: { entries: [{ rates: { input_tokens: '2.5', output_tokens: '15', input_cache_read_tokens: '0.25', input_cache_write_tokens: '3.75' } }] }, - flagOverrides: { 'vendor-deepseek': false }, - }, - ]); -}); - -test('modelsField parses a minimal model entry', () => { - const models = modelsField( - [{ upstreamModelId: 'gpt-prod', endpoints: { chatCompletions: {} } }], - 'custom', - ); - - assertEquals(models, [{ upstreamModelId: 'gpt-prod', kind: 'chat', endpoints: { chatCompletions: {} } }]); -}); - -test('modelsField rejects a missing upstreamModelId', () => { - assertThrows( - () => modelsField([{ endpoints: { chatCompletions: {} } }], 'azure'), - Error, - 'Malformed azure models[0].upstreamModelId: must be a non-empty string', - ); -}); - -test('modelsField returns an empty array for an empty list', () => { - assertEquals(modelsField([], 'custom'), []); -}); - -test('modelsField rejects a non-array', () => { - assertThrows( - () => modelsField({}, 'custom'), - Error, - 'Malformed custom upstream config: models must be an array', - ); -}); - -test('modelsField rejects a non-object entry', () => { - assertThrows( - () => modelsField(['not-an-object'], 'azure'), - Error, - 'Malformed azure models[0]: must be an object', - ); -}); - -test('modelsField rejects an empty endpoints object', () => { - assertThrows( - () => modelsField([{ upstreamModelId: 'gpt-prod', endpoints: { } }], 'azure'), - Error, - 'Malformed azure models[0].endpoints: must declare at least one endpoint', - ); -}); - -test('modelsField rejects an unsupported endpoint key', () => { - assertThrows( - () => modelsField([{ upstreamModelId: 'gpt-prod', endpoints: { bogus: {} } }], 'azure'), - Error, - 'Malformed azure models[0].endpoints: unsupported endpoint bogus', - ); -}); - -test('modelsField derives kind from endpoints when omitted', () => { - const [embedding] = modelsField([{ upstreamModelId: 'e', endpoints: { embeddings: {} } }], 'custom'); - assertEquals(embedding.kind, 'embedding'); - const [image] = modelsField([{ upstreamModelId: 'i', endpoints: { imagesGenerations: {}, imagesEdits: {} } }], 'custom'); - assertEquals(image.kind, 'image'); - const [audio] = modelsField([{ upstreamModelId: 'a', endpoints: { audioTranscriptions: {} } }], 'custom'); - assertEquals(audio.kind, 'transcription'); - const [chat] = modelsField([{ upstreamModelId: 'c', endpoints: { responses: {} } }], 'custom'); - assertEquals(chat.kind, 'chat'); -}); - -test('modelsField accepts a valid kind and rejects an unknown one', () => { - const models = modelsField( - [{ upstreamModelId: 'm', kind: 'embedding', endpoints: { embeddings: {} } }], - 'custom', - ); - assertEquals(models[0].kind, 'embedding'); - assertThrows( - () => modelsField([{ upstreamModelId: 'm', kind: 'bogus', endpoints: { chatCompletions: {} } }], 'custom'), - Error, - 'Malformed custom models[0].kind: must be one of chat, embedding, image, rerank, transcription', - ); -}); - -test('modelsField accepts pricing with only a subset of metrics set', () => { - const models = modelsField( - [{ upstreamModelId: 'gpt-prod', endpoints: { chatCompletions: {} }, pricing: { entries: [{ rates: { input_tokens: '2.5' } }] } }], - 'azure', - ); - assertEquals(models[0].pricing, { entries: [{ rates: { input_tokens: '2.5' } }] }); -}); - -test('modelsField rejects pricing with a negative input', () => { - assertThrows( - () => - modelsField( - [{ upstreamModelId: 'gpt-prod', endpoints: { chatCompletions: {} }, pricing: { entries: [{ rates: { input_tokens: '-1', output_tokens: '1' } }] } }], - 'azure', - ), - Error, - 'pricing.entries[0].rates.input_tokens must be non-negative', - ); -}); - -test('modelsField rejects a non-object flagOverrides', () => { - assertThrows( - () => - modelsField( - [ - { - upstreamModelId: 'gpt-prod', - endpoints: { chatCompletions: {} }, - flagOverrides: 'not-an-object', - }, - ], - 'azure', - ), - Error, - 'Malformed azure models[0].flagOverrides: must be an object', - ); -}); - -test('modelsField rejects flagOverrides with an unknown flag id', () => { - assertThrows( - () => - modelsField( - [ - { - upstreamModelId: 'gpt-prod', - endpoints: { chatCompletions: {} }, - flagOverrides: { 'made-up-flag': true }, - }, - ], - 'azure', - ), - Error, - 'Malformed azure models[0].flagOverrides: unknown flag ids: made-up-flag', - ); -}); diff --git a/packages/gateway/src/test-helpers.ts b/packages/gateway/src/test-helpers.ts index 26d1933fbd..1bf7469bed 100644 --- a/packages/gateway/src/test-helpers.ts +++ b/packages/gateway/src/test-helpers.ts @@ -6,7 +6,7 @@ import { InMemoryRepo } from './repo/memory.ts'; import type { ApiKey } from './repo/types.ts'; import { initBackgroundSchedulerResolver } from './runtime/background.ts'; import { trackBackground } from './test-helpers/background-tracker.ts'; -import { createInMemoryImageProcessor, initEnv, initExternalResourceFetcher, initFileProvider, initImageProcessor, MemoryFileProvider } from '@floway-dev/platform'; +import { createInMemoryImageProcessor, initEnv, initExternalResourceFetcher, initFileStore, initImageProcessor, MemoryFileStore } from '@floway-dev/platform'; import type { UpstreamRecord } from '@floway-dev/provider'; import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; @@ -107,7 +107,7 @@ export async function setupAppTest(options: SetupOptions = {}): Promise Promise.resolve(new Response(null, { status: 404 }))); - initFileProvider(new MemoryFileProvider()); + initFileStore(new MemoryFileStore()); initImageProcessor(createInMemoryImageProcessor()); // Route background promises through the shared tracker so flushBackground() // can deterministically await them — see test-helpers/background-tracker.ts. diff --git a/packages/gateway/tsconfig.json b/packages/gateway/tsconfig.json index 0438981fc8..1ac5c930ee 100644 --- a/packages/gateway/tsconfig.json +++ b/packages/gateway/tsconfig.json @@ -1,8 +1,4 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { - "jsx": "react-jsx", - "jsxImportSource": "hono/jsx" - }, - "include": ["vitest.config.ts", "vitest.setup.ts", "src/**/*.ts", "src/**/*.tsx"] + "include": ["vitest.config.ts", "vitest.setup.ts", "src/**/*.ts"] } diff --git a/packages/gateway/vitest.setup.ts b/packages/gateway/vitest.setup.ts index d737a875a4..f2b9d37a0d 100644 --- a/packages/gateway/vitest.setup.ts +++ b/packages/gateway/vitest.setup.ts @@ -6,9 +6,9 @@ import { initBackgroundSchedulerResolver } from './src/runtime/background.ts'; import { trackBackground } from './src/test-helpers/background-tracker.ts'; import { initEnv, initRuntimeKind } from '@floway-dev/platform'; -// Production always initializes env at boot, so getEnv() never throws in a -// live request. Mirror that here with a neutral default; tests needing real -// values (RUNTIME_LOCATION, ADMIN_KEY, …) re-init with their own getter. +// Production always initializes the environment getter at boot. Mirror that +// here with a neutral default; tests needing real values (RUNTIME_LOCATION, +// ADMIN_KEY, …) re-init with their own getter. initEnv(() => ''); // Tests run as 'node' by default. The few tests that exercise CF-specific // runtime behaviour re-init this with 'cloudflare'. diff --git a/packages/http/package.json b/packages/http/package.json index 05fca9c53f..9a8eaa6100 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -17,7 +17,6 @@ "@reclaimprotocol/tls": "0.1.2" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20251215.0", "@types/node": "^22", "typescript": "^5.9.3" } diff --git a/packages/http/tsconfig.json b/packages/http/tsconfig.json index 8bcf47a7c9..824d57c3c4 100644 --- a/packages/http/tsconfig.json +++ b/packages/http/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "types": ["@cloudflare/workers-types", "node"] + "types": ["node"] }, "include": ["src/**/*.ts"] } diff --git a/packages/interceptor/src/index.ts b/packages/interceptor/src/index.ts index 32a557fac6..bcea09dbfd 100644 --- a/packages/interceptor/src/index.ts +++ b/packages/interceptor/src/index.ts @@ -39,10 +39,3 @@ export const runInterceptors = async ( const run = (index: number): Promise => (index < interceptors.length ? interceptors[index](ctx, request, () => run(index + 1)) : terminal()); return await run(0); }; - -// The minimal context shape interceptors read. Concrete invocation types in -// the consuming application structurally satisfy this — interceptors never -// require more than this baseline. -export interface InterceptorContext { - readonly enabledFlags: ReadonlySet; -} diff --git a/packages/gateway/src/runtime/channel-broker-contract.ts b/packages/platform/src/channel-broker.ts similarity index 92% rename from packages/gateway/src/runtime/channel-broker-contract.ts rename to packages/platform/src/channel-broker.ts index 947b798871..182d321284 100644 --- a/packages/gateway/src/runtime/channel-broker-contract.ts +++ b/packages/platform/src/channel-broker.ts @@ -1,7 +1,7 @@ // Per-channel publish/subscribe. The codec is supplied at construction so // the channel transport stays unaware of the payload shape. -export interface Codec { +export interface ChannelCodec { encode(value: T): string; decode(payload: string): T; } diff --git a/packages/platform/src/env.ts b/packages/platform/src/env.ts index 8ae8053484..fa5c23a618 100644 --- a/packages/platform/src/env.ts +++ b/packages/platform/src/env.ts @@ -6,18 +6,10 @@ export const initEnv = (fn: EnvGetter): void => { _getEnv = fn; }; -export const getEnv = (name: string): string => { - if (!_getEnv) throw new Error('Env not initialized — call initEnv() first'); - const value = _getEnv(name); - if (value === undefined) throw new Error(`Missing required env var: ${name}`); - return value; -}; - -// Same lookup as `getEnv`, but returns `defaultValue` for variables the -// operator is allowed to leave unset. The contract is "missing → undefined" -// for the EnvGetter; any other failure (malformed value, binding lookup -// failure) propagates so we never silently default through an unexpected -// throw. +// Returns `defaultValue` for variables the operator is allowed to leave unset. +// The EnvGetter contract is "missing → undefined"; any other failure (malformed +// value, binding lookup failure) propagates so we never silently default through +// an unexpected throw. export const getEnvOptional = (name: string, defaultValue: string): string => { if (!_getEnv) throw new Error('Env not initialized — call initEnv() first'); return _getEnv(name) ?? defaultValue; diff --git a/packages/platform/src/file-provider_test.ts b/packages/platform/src/file-provider_test.ts deleted file mode 100644 index 71daab8e74..0000000000 --- a/packages/platform/src/file-provider_test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { test } from 'vitest'; - -import { getFileProvider, initFileProvider, MemoryFileProvider } from './file-provider.ts'; -import { assertEquals } from '@floway-dev/test-utils'; - -test('MemoryFileProvider clones at the provider boundary', async () => { - const provider = new MemoryFileProvider(); - const body = new Uint8Array([1, 2, 3]); - - await provider.put('k', body); - body[0] = 9; - - const first = await provider.get('k'); - assertEquals(first ? [...first] : null, [1, 2, 3]); - first![1] = 8; - - assertEquals([...(await provider.get('k'))!], [1, 2, 3]); -}); - -test('runtime exposes one initialized FileProvider instance', async () => { - const provider = new MemoryFileProvider(); - initFileProvider(provider); - - await getFileProvider().put('k', new Uint8Array([4])); - assertEquals([...(await provider.get('k'))!], [4]); -}); - -test('MemoryFileProvider deletes exact keys without treating them as prefixes', async () => { - const provider = new MemoryFileProvider(); - await provider.put('drop/a', new Uint8Array([1])); - await provider.put('drop/ab', new Uint8Array([2])); - - await provider.deleteKeys(['drop/a', 'missing']); - - assertEquals(await provider.get('drop/a'), null); - assertEquals(await provider.get('drop/ab'), new Uint8Array([2])); -}); diff --git a/packages/platform/src/file-provider.ts b/packages/platform/src/file-store.ts similarity index 57% rename from packages/platform/src/file-provider.ts rename to packages/platform/src/file-store.ts index ab87124aa8..13bb33c2ce 100644 --- a/packages/platform/src/file-provider.ts +++ b/packages/platform/src/file-store.ts @@ -1,21 +1,21 @@ -export interface FileProvider { +export interface FileStore { put(key: string, body: Uint8Array): Promise; get(key: string): Promise; deleteKeys(keys: readonly string[]): Promise; } -let fileProvider: FileProvider | null = null; +let fileStore: FileStore | null = null; -export const initFileProvider = (provider: FileProvider): void => { - fileProvider = provider; +export const initFileStore = (store: FileStore): void => { + fileStore = store; }; -export const getFileProvider = (): FileProvider => { - if (!fileProvider) throw new Error('FileProvider not initialized - call initFileProvider() first'); - return fileProvider; +export const getFileStore = (): FileStore => { + if (!fileStore) throw new Error('FileStore not initialized - call initFileStore() first'); + return fileStore; }; -export class MemoryFileProvider implements FileProvider { +export class MemoryFileStore implements FileStore { private readonly files = new Map(); async put(key: string, body: Uint8Array): Promise { diff --git a/packages/platform/src/file-store_test.ts b/packages/platform/src/file-store_test.ts new file mode 100644 index 0000000000..014ee70c4f --- /dev/null +++ b/packages/platform/src/file-store_test.ts @@ -0,0 +1,37 @@ +import { test } from 'vitest'; + +import { getFileStore, initFileStore, MemoryFileStore } from './file-store.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +test('MemoryFileStore clones at the store boundary', async () => { + const store = new MemoryFileStore(); + const body = new Uint8Array([1, 2, 3]); + + await store.put('k', body); + body[0] = 9; + + const first = await store.get('k'); + assertEquals(first ? [...first] : null, [1, 2, 3]); + first![1] = 8; + + assertEquals([...(await store.get('k'))!], [1, 2, 3]); +}); + +test('runtime exposes one initialized FileStore instance', async () => { + const store = new MemoryFileStore(); + initFileStore(store); + + await getFileStore().put('k', new Uint8Array([4])); + assertEquals([...(await store.get('k'))!], [4]); +}); + +test('MemoryFileStore deletes exact keys without treating them as prefixes', async () => { + const store = new MemoryFileStore(); + await store.put('drop/a', new Uint8Array([1])); + await store.put('drop/ab', new Uint8Array([2])); + + await store.deleteKeys(['drop/a', 'missing']); + + assertEquals(await store.get('drop/a'), null); + assertEquals(await store.get('drop/ab'), new Uint8Array([2])); +}); diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index 2852aeb2f0..7115a4c689 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -1,7 +1,8 @@ export * from './background.ts'; +export * from './channel-broker.ts'; export * from './env.ts'; export * from './external-resource-fetcher.ts'; -export * from './file-provider.ts'; +export * from './file-store.ts'; export * from './image-cache-store.ts'; export * from './image-processor.ts'; export * from './runtime-kind.ts'; diff --git a/packages/platform/tsconfig.json b/packages/platform/tsconfig.json index bea50c8aa9..b6bc955715 100644 --- a/packages/platform/tsconfig.json +++ b/packages/platform/tsconfig.json @@ -1,9 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "resolveJsonModule": true, - "jsx": "react-jsx", - "jsxImportSource": "hono/jsx" + "resolveJsonModule": true }, "include": ["vitest.config.ts", "src/**/*.ts"] } diff --git a/packages/protocols/src/chat-completions/reassemble.ts b/packages/protocols/src/chat-completions/reassemble.ts index 83d65ce042..2a51d213a5 100644 --- a/packages/protocols/src/chat-completions/reassemble.ts +++ b/packages/protocols/src/chat-completions/reassemble.ts @@ -1,4 +1,4 @@ -import { chatCompletionsErrorPayloadMessage } from './index.ts'; +import { chatCompletionsErrorPayloadMessage } from './errors.ts'; import type { ChatCompletionsChoiceNonStreaming, ChatCompletionsDelta, ChatCompletionsResult, ChatCompletionsStreamEvent, ChatCompletionsReasoningItem, ChatCompletionsToolCall } from './index.ts'; import { captureExtras } from '../common/reassemble-extras.ts'; diff --git a/packages/protocols/src/common/decimal_test.ts b/packages/protocols/src/common/decimal_test.ts index 429db2c96a..ee2fa1e57e 100644 --- a/packages/protocols/src/common/decimal_test.ts +++ b/packages/protocols/src/common/decimal_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { addDecimalStrings, canonicalDecimalString, divideDecimalString, multiplyDecimalStrings, parseNonNegativeDecimalString } from './decimal.ts'; -import { assertEquals, assertThrows } from '../test-assert.ts'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; test('decimal strings canonicalize without floating-point conversion', () => { assertEquals(canonicalDecimalString('001.2300'), '1.23'); diff --git a/packages/protocols/src/common/capabilities.ts b/packages/protocols/src/common/endpoints.ts similarity index 82% rename from packages/protocols/src/common/capabilities.ts rename to packages/protocols/src/common/endpoints.ts index dec1b9ee4f..704222b4ad 100644 --- a/packages/protocols/src/common/capabilities.ts +++ b/packages/protocols/src/common/endpoints.ts @@ -1,18 +1,18 @@ -// Protocol-level model capability types and their intrinsic kind projection. +// Protocol-level model endpoint types and their intrinsic kind projection. // Provider projection and endpoint dispatch live in packages/gateway/src/data-plane/. import type { ModelKind } from './models.ts'; -// Structured per-endpoint capability map. A key being present means the model -// is served by that endpoint; its value object carries that endpoint's -// sub-capabilities, if any. Sub-paths derived from a base endpoint +// Structured endpoint map. A key being present means the model is served by +// that endpoint; its value object carries endpoint-specific metadata, if any. +// Sub-paths derived from a base endpoint // (`/messages/count_tokens` from `messages`, `/responses/compact` from // `responses`) are not modeled separately — presence of the base endpoint // implies them. export interface ModelEndpoints { // OpenAI text completions (`/v1/completions`). Passthrough only — we // never translate it to or from the three chat endpoints below, so it has - // no sub-capability surface. Orthogonal to `chatCompletions`: a model can + // no endpoint-specific metadata. Orthogonal to `chatCompletions`: a model can // declare any non-empty subset. completions?: {}; chatCompletions?: {}; diff --git a/packages/protocols/src/common/capabilities_test.ts b/packages/protocols/src/common/endpoints_test.ts similarity index 90% rename from packages/protocols/src/common/capabilities_test.ts rename to packages/protocols/src/common/endpoints_test.ts index 279f7c6954..1d0efd1c7a 100644 --- a/packages/protocols/src/common/capabilities_test.ts +++ b/packages/protocols/src/common/endpoints_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; -import { kindForEndpoints } from './capabilities.ts'; -import { assertEquals } from '../test-assert.ts'; +import { kindForEndpoints } from './endpoints.ts'; +import { assertEquals } from '@floway-dev/test-utils'; test('kindForEndpoints returns image when either images endpoint is present', () => { assertEquals(kindForEndpoints({ imagesGenerations: {} }), 'image'); diff --git a/packages/protocols/src/common/index.ts b/packages/protocols/src/common/index.ts index 10ce31ded5..9f1fa44b3b 100644 --- a/packages/protocols/src/common/index.ts +++ b/packages/protocols/src/common/index.ts @@ -1,5 +1,5 @@ export * from './aliases.ts'; -export * from './capabilities.ts'; +export * from './endpoints.ts'; export * from './decimal.ts'; export * from './models.ts'; export * from './usage.ts'; diff --git a/packages/protocols/src/common/models.ts b/packages/protocols/src/common/models.ts index c17da3ca2a..afccb95924 100644 --- a/packages/protocols/src/common/models.ts +++ b/packages/protocols/src/common/models.ts @@ -1,6 +1,6 @@ import type { AliasSelection, AliasTarget } from './aliases.ts'; -import type { ModelEndpoints } from './capabilities.ts'; import { divideDecimalString, parseNonNegativeDecimalString, type DecimalString } from './decimal.ts'; +import type { ModelEndpoints } from './endpoints.ts'; import { billableServiceTier } from './usage.ts'; // Disjoint billing metrics a single request can be charged on. Every count @@ -31,10 +31,6 @@ export const parseBillingMetric = (value: unknown, label = 'billing metric'): Bi throw new TypeError(`${label} is invalid: ${JSON.stringify(value)}`); }; -// The input-side token metrics. Their disjoint sum is a request's total prompt -// size, which projects the request onto the declared inputTokens thresholds. -export const INPUT_TOKEN_METRICS: readonly BillingMetric[] = ['input_tokens', 'input_cache_read_tokens', 'input_cache_write_tokens', 'input_cache_write_1h_tokens', 'input_image_tokens', 'input_audio_tokens']; - // USD per one base metric unit for one pricing entry. export type PriceVector = Partial>; diff --git a/packages/protocols/src/common/models_test.ts b/packages/protocols/src/common/models_test.ts index d49aa121c9..fc17fec127 100644 --- a/packages/protocols/src/common/models_test.ts +++ b/packages/protocols/src/common/models_test.ts @@ -15,7 +15,7 @@ import { type ModelPricing, type PricingSelector, } from './models.ts'; -import { assertEquals, assertThrows } from '../test-assert.ts'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; test('parseModelKind accepts the current model families and rejects unknown storage values', () => { for (const kind of ['chat', 'embedding', 'image', 'rerank', 'transcription'] as const) assertEquals(parseModelKind(kind), kind); diff --git a/packages/protocols/src/common/openai-stream_test.ts b/packages/protocols/src/common/openai-stream_test.ts index a2f886c308..e5a7d9080a 100644 --- a/packages/protocols/src/common/openai-stream_test.ts +++ b/packages/protocols/src/common/openai-stream_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { isOpenAIUsageOnlyEventShape } from './openai-stream.ts'; -import { assertEquals } from '../test-assert.ts'; +import { assertEquals } from '@floway-dev/test-utils'; test('isOpenAIUsageOnlyEventShape identifies the OpenAI / vanilla-vLLM shape (empty choices + usage)', () => { assertEquals(isOpenAIUsageOnlyEventShape({ choices: [], usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 } }), true); diff --git a/packages/protocols/src/completions/reassemble_test.ts b/packages/protocols/src/completions/reassemble_test.ts index 051eee7195..519c1fb492 100644 --- a/packages/protocols/src/completions/reassemble_test.ts +++ b/packages/protocols/src/completions/reassemble_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import type { CompletionsStreamEvent } from './index.ts'; import { reassembleCompletionsEvents } from './reassemble.ts'; -import { assertEquals } from '../test-assert.ts'; +import { assertEquals } from '@floway-dev/test-utils'; const chunk = (text: string, finish_reason: string | null = null, extra: Partial = {}): CompletionsStreamEvent => ({ id: 'cmpl_test', diff --git a/packages/protocols/src/responses/from-result_test.ts b/packages/protocols/src/responses/from-result_test.ts index f1acb646f7..ed1d3ba8a7 100644 --- a/packages/protocols/src/responses/from-result_test.ts +++ b/packages/protocols/src/responses/from-result_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { responsesResultToEvents } from './from-result.ts'; import type { ResponsesOutputItem, ResponsesResult } from './index.ts'; -import { assertEquals, assertFalse, assertThrows } from '../test-assert.ts'; +import { assertEquals, assertFalse, assertThrows } from '@floway-dev/test-utils'; const completedResponse: ResponsesResult = { id: 'resp_completed', diff --git a/packages/protocols/src/responses/index_test.ts b/packages/protocols/src/responses/index_test.ts index 4187e8acd3..fae4070a38 100644 --- a/packages/protocols/src/responses/index_test.ts +++ b/packages/protocols/src/responses/index_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { toCompactPayloadShape } from './index.ts'; -import { assertEquals } from '../test-assert.ts'; +import { assertEquals } from '@floway-dev/test-utils'; test('toCompactPayloadShape preserves compact cache controls', () => { assertEquals(toCompactPayloadShape({ diff --git a/packages/protocols/src/test-assert.ts b/packages/protocols/src/test-assert.ts deleted file mode 100644 index 62421bf289..0000000000 --- a/packages/protocols/src/test-assert.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect } from 'vitest'; - -type ErrorConstructor = new (...args: never[]) => Error; - -export function assert(value: unknown, message?: string): asserts value { - expect(Boolean(value), message).toBe(true); -} - -export function assertEquals(actual: unknown, expected: unknown, message?: string): void { - expect(actual, message).toEqual(expected); -} - -export function assertFalse(value: unknown, message?: string): void { - expect(Boolean(value), message).toBe(false); -} - -export function assertExists(value: T, message?: string): asserts value is NonNullable { - expect(value, message).not.toBeNull(); - expect(value, message).not.toBeUndefined(); -} - -export function assertStringIncludes(actual: string, expected: string, message?: string): void { - expect(actual, message).toContain(expected); -} - -export function assertAlmostEquals(actual: number, expected: number, tolerance = 1e-7, message?: string): void { - expect(Math.abs(actual - expected), message).toBeLessThanOrEqual(tolerance); -} - -export function assertThrows(fn: () => unknown, errorClass?: ErrorConstructor, messageIncludes?: string, message?: string): Error { - try { - fn(); - } catch (error) { - assertExpectedError(error, errorClass, messageIncludes, message); - return error as Error; - } - - throw new Error(message ?? 'Expected function to throw'); -} - -export async function assertRejects(fn: () => Promise | unknown, errorClass?: ErrorConstructor, messageIncludes?: string, message?: string): Promise { - try { - await fn(); - } catch (error) { - assertExpectedError(error, errorClass, messageIncludes, message); - return error as Error; - } - - throw new Error(message ?? 'Expected promise to reject'); -} - -const assertExpectedError = (error: unknown, errorClass?: ErrorConstructor, messageIncludes?: string, message?: string): void => { - if (errorClass !== undefined) { - expect(error, message).toBeInstanceOf(errorClass); - } - - if (messageIncludes !== undefined) { - expect(error instanceof Error ? error.message : String(error), message).toContain(messageIncludes); - } -}; diff --git a/packages/protocols/tsconfig.json b/packages/protocols/tsconfig.json index edf3151821..3b64db1d69 100644 --- a/packages/protocols/tsconfig.json +++ b/packages/protocols/tsconfig.json @@ -1,8 +1,4 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { - "jsx": "react-jsx", - "jsxImportSource": "hono/jsx" - }, "include": ["vitest.config.ts", "src/**/*.ts"] } diff --git a/packages/provider-azure/package.json b/packages/provider-azure/package.json index 2944619f6a..ea08b1ce37 100644 --- a/packages/provider-azure/package.json +++ b/packages/provider-azure/package.json @@ -10,7 +10,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@floway-dev/interceptor": "workspace:*", "@floway-dev/protocols": "workspace:*", "@floway-dev/provider": "workspace:*" }, diff --git a/packages/provider-azure/src/config.ts b/packages/provider-azure/src/config.ts index 752c261556..f0aa5aae86 100644 --- a/packages/provider-azure/src/config.ts +++ b/packages/provider-azure/src/config.ts @@ -1,4 +1,3 @@ -import type { ModelEndpoints } from '@floway-dev/protocols/common'; import { type UpstreamModelConfig, type UpstreamRecord, isRecord, modelsField, nonEmptyStringField } from '@floway-dev/provider'; export interface AzureUpstreamConfig { @@ -83,10 +82,3 @@ export const assertAzureUpstreamRecord = (record: UpstreamRecord): AzureUpstream config, }; }; - -// The union of every model's declared endpoints. Azure always carries explicit -// per-model endpoints, so this upstream-level map is informational only (the -// per-model fallback never fires); sub-capabilities are dropped since only -// presence matters here. -export const configuredEndpoints = (config: AzureUpstreamConfig): ModelEndpoints => - config.models.reduce((acc, model) => ({ ...acc, ...model.endpoints }), {}); diff --git a/packages/provider-azure/src/index.ts b/packages/provider-azure/src/index.ts index 86e8f5fe6f..4a2f1a066a 100644 --- a/packages/provider-azure/src/index.ts +++ b/packages/provider-azure/src/index.ts @@ -8,7 +8,4 @@ export const azureProvider: ProviderModule = { }; export { createAzureProvider } from './provider.ts'; -export { - assertAzureUpstreamRecord, - configuredEndpoints, -} from './config.ts'; +export { assertAzureUpstreamRecord } from './config.ts'; diff --git a/packages/gateway/src/data-plane/providers/azure/provider_test.ts b/packages/provider-azure/src/provider_test.ts similarity index 99% rename from packages/gateway/src/data-plane/providers/azure/provider_test.ts rename to packages/provider-azure/src/provider_test.ts index d65f4c20f1..58d587641e 100644 --- a/packages/gateway/src/data-plane/providers/azure/provider_test.ts +++ b/packages/provider-azure/src/provider_test.ts @@ -1,8 +1,8 @@ import { test } from 'vitest'; +import { createAzureProvider } from './provider.ts'; import type { UpstreamRecord } from '@floway-dev/provider'; import { directFetcher } from '@floway-dev/provider'; -import { createAzureProvider } from '@floway-dev/provider-azure'; import { assertEquals, noopUpstreamCallOptions, sseResponse, withMockedFetch } from '@floway-dev/test-utils'; const azureRecord = (overrides: Partial = {}): UpstreamRecord => { diff --git a/packages/provider-codex/src/access-token-cache.ts b/packages/provider-codex/src/access-token-cache.ts index 3d10185672..b68ea69e50 100644 --- a/packages/provider-codex/src/access-token-cache.ts +++ b/packages/provider-codex/src/access-token-cache.ts @@ -24,19 +24,6 @@ const replaceAccountAccessToken = ( accounts: state.accounts.map((account, i) => (i === index ? { ...account, accessToken: entry } : account)), }); -export const getCodexAccessToken = async ( - upstreamId: string, - accountId: string, -): Promise => { - const fresh = await getProviderRepo().upstreams.getById(upstreamId); - if (!fresh) return null; - const state = readCodexUpstreamState(fresh.state); - const account = state.accounts.find(a => a.chatgptAccountId === accountId); - if (!account?.accessToken) return null; - if (!isAccessTokenFresh(account.accessToken)) return null; - return account.accessToken; -}; - // A losing CAS is not an error — saveState reports it via `updated: false`, // and the next call re-reads state and refreshes if needed. Genuine storage // failures propagate so the request path surfaces them rather than silently diff --git a/packages/provider-codex/src/access-token-cache_test.ts b/packages/provider-codex/src/access-token-cache_test.ts index d3c2395308..97b0e88a69 100644 --- a/packages/provider-codex/src/access-token-cache_test.ts +++ b/packages/provider-codex/src/access-token-cache_test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { ensureCodexAccessToken, - getCodexAccessToken, invalidateCodexAccessToken, putCodexAccessToken, type CodexAccessTokenEntry, @@ -62,33 +61,6 @@ beforeEach(() => { afterEach(() => vi.restoreAllMocks()); -describe('getCodexAccessToken', () => { - test('returns null when the upstream row is missing', async () => { - current = null; - expect(await getCodexAccessToken(upstreamId, accountId)).toBeNull(); - }); - - test('returns null when the account has no cached access token', async () => { - expect(await getCodexAccessToken(upstreamId, accountId)).toBeNull(); - }); - - test('returns null when the cached token is within the refresh skew window', async () => { - const expiresSoon = Date.now() + 60 * 1000; - current = makeRecord({ accounts: [{ ...baseAccount, accessToken: { token: 'at_old', expiresAt: expiresSoon, refreshedAt: '2026-06-01T00:00:00.000Z' } }] }); - expect(await getCodexAccessToken(upstreamId, accountId)).toBeNull(); - }); - - test('returns the cached token when still fresh', async () => { - const entry: CodexAccessTokenEntry = { token: 'at_x', expiresAt: farFutureMs, refreshedAt: '2026-06-01T00:00:00.000Z' }; - current = makeRecord({ accounts: [{ ...baseAccount, accessToken: entry }] }); - expect(await getCodexAccessToken(upstreamId, accountId)).toEqual(entry); - }); - - test('returns null when the requested account is not in the pool', async () => { - expect(await getCodexAccessToken(upstreamId, 'acc_other')).toBeNull(); - }); -}); - describe('putCodexAccessToken', () => { test('persists the entry into the account slot via saveState', async () => { const entry: CodexAccessTokenEntry = { token: 'at_new', expiresAt: farFutureMs, refreshedAt: '2026-06-01T00:00:00.000Z' }; diff --git a/packages/provider-copilot/package.json b/packages/provider-copilot/package.json index d872746da7..37fe336c0f 100644 --- a/packages/provider-copilot/package.json +++ b/packages/provider-copilot/package.json @@ -4,8 +4,7 @@ "version": "0.0.0", "type": "module", "exports": { - ".": { "import": "./src/index.ts", "types": "./src/index.ts" }, - "./compaction": { "import": "./src/compaction.ts", "types": "./src/compaction.ts" } + ".": { "import": "./src/index.ts", "types": "./src/index.ts" } }, "scripts": { "typecheck": "tsc --noEmit" diff --git a/packages/provider-copilot/src/defaults.ts b/packages/provider-copilot/src/defaults.ts index 22d130dfe9..f31ec6d7ab 100644 --- a/packages/provider-copilot/src/defaults.ts +++ b/packages/provider-copilot/src/defaults.ts @@ -14,7 +14,9 @@ export const COPILOT_DEFAULT_FLAGS: FlagDefaults = { 'messages-web-search-shim': true, 'responses-web-search-shim': true, 'responses-image-generation-shim': true, - // Copilot exposes a native /responses/compact wire. + // Copilot has no native compact endpoint. The provider replays + // `RemoteCompactionV2` through `/responses` with `stream: false` and a + // trailing `compaction_trigger`, so the gateway compact shim stays disabled. 'responses-compact-shim': false, 'disable-reasoning-on-forced-tool-choice': false, // Upstream default is off; Claude models below 4.8 flip it on via the diff --git a/packages/gateway/src/control-plane/auth/github-device-flow.ts b/packages/provider-copilot/src/github-device-flow.ts similarity index 93% rename from packages/gateway/src/control-plane/auth/github-device-flow.ts rename to packages/provider-copilot/src/github-device-flow.ts index 5358e988ae..d7c010ea2f 100644 --- a/packages/gateway/src/control-plane/auth/github-device-flow.ts +++ b/packages/provider-copilot/src/github-device-flow.ts @@ -1,12 +1,6 @@ +import type { CopilotUpstreamUser } from './config.ts'; import { directFetcher, type Fetcher } from '@floway-dev/provider'; -export interface GitHubUser { - login: string; - avatar_url: string; - name: string | null; - id: number; -} - const GITHUB_CLIENT_ID = 'Iv1.b507a08c87ecfe98'; const GITHUB_SCOPES = 'read:user'; @@ -76,5 +70,5 @@ export const fetchGitHubUser = async (githubToken: string, fetcher: Fetcher = di }); if (!userResp.ok) throw new Error(`GitHub user lookup failed: ${userResp.status} ${await userResp.text()}`); - return (await userResp.json()) as GitHubUser; + return (await userResp.json()) as CopilotUpstreamUser; }; diff --git a/packages/provider-copilot/src/index.ts b/packages/provider-copilot/src/index.ts index be9329edeb..e1f2327cb3 100644 --- a/packages/provider-copilot/src/index.ts +++ b/packages/provider-copilot/src/index.ts @@ -13,6 +13,8 @@ export { exchangeCopilotToken, githubHeaders, } from './auth.ts'; +export { fetchGitHubUser, pollGitHubDeviceFlow, startGitHubDeviceFlow } from './github-device-flow.ts'; +export { fetchCopilotUsage, type CopilotQuotaDetail, type CopilotUsageResponse } from './quota.ts'; export { assertCopilotUpstreamRecord, type CopilotUpstreamConfig, diff --git a/packages/provider-copilot/src/interceptors/messages/boundary-chain_test.ts b/packages/provider-copilot/src/interceptors/messages/boundary-chain_test.ts index 2689776f8b..0626129f6a 100644 --- a/packages/provider-copilot/src/interceptors/messages/boundary-chain_test.ts +++ b/packages/provider-copilot/src/interceptors/messages/boundary-chain_test.ts @@ -59,7 +59,8 @@ test('Claude Code SDK compact request: Claude-agent overrides compact intent, bo assertEquals(ctx.headers.get('x-interaction-type'), 'messages-proxy'); assertEquals(ctx.headers.get('openai-intent'), 'messages-proxy'); assertEquals(ctx.headers.get('user-agent'), CLAUDE_AGENT_USER_AGENT); - // Empty-string sentinel: copilotFetch will delete the base header. + // Empty-string sentinel: `copilotAuthedFetch` in + // `packages/provider-copilot/src/auth.ts` deletes the base header. assertEquals(ctx.headers.get('copilot-integration-id'), ''); // SHA-256-then-UUIDv4 of 'sess-1' (matches caozhiyuan's getUUID). assertEquals(ctx.headers.get('x-interaction-id'), 'abe633f3-a47a-4758-974e-abe9160daf36'); diff --git a/packages/provider-copilot/src/interceptors/messages/index.ts b/packages/provider-copilot/src/interceptors/messages/index.ts index f2b0093aae..fdacedd2a9 100644 --- a/packages/provider-copilot/src/interceptors/messages/index.ts +++ b/packages/provider-copilot/src/interceptors/messages/index.ts @@ -54,7 +54,7 @@ import type { CopilotMessagesBoundaryInterceptor, CopilotMessagesCountTokensBoun // `withMessagesWebSearchShim` is intentionally NOT registered here. It runs // in the gateway's `messagesInterceptors` (filtered by enabled flags); the // Copilot provider opts in by listing `messages-web-search-shim` in its -// default flag set (see COPILOT_DEFAULT_FLAGS in ../../provider.ts). +// default flag set (see COPILOT_DEFAULT_FLAGS in ../../defaults.ts). export const COPILOT_MESSAGES_BOUNDARY = [ rewriteContextWindowError, withCompactHeadersSet, diff --git a/packages/provider-copilot/src/interceptors/messages/set-claude-agent-headers.ts b/packages/provider-copilot/src/interceptors/messages/set-claude-agent-headers.ts index d9db26715c..ef0066c7e4 100644 --- a/packages/provider-copilot/src/interceptors/messages/set-claude-agent-headers.ts +++ b/packages/provider-copilot/src/interceptors/messages/set-claude-agent-headers.ts @@ -14,10 +14,10 @@ import { CLAUDE_AGENT_USER_AGENT } from '../../auth.ts'; * the messages-proxy intent to ordinary chat traffic that happens to share * one half of the legacy regex. * - * Sentinel: an empty-string value tells `copilotFetch` to delete the named - * base header — see the loop comment in shared/copilot.ts. We use it to clear - * `copilot-integration-id` because VSCode Copilot Chat omits that header on - * Claude Code SDK proxy traffic. + * Sentinel: an empty-string value tells `copilotAuthedFetch` in + * `packages/provider-copilot/src/auth.ts` to delete the named base header. We + * use it to clear `copilot-integration-id` because VSCode Copilot Chat omits + * that header on Claude Code SDK proxy traffic. * * Do not put this identity on translated Chat Completions / Responses targets. * The real VS Code path forces a Messages API request, and caozhiyuan's gateway diff --git a/packages/provider-copilot/src/interceptors/messages/set-claude-agent-headers_test.ts b/packages/provider-copilot/src/interceptors/messages/set-claude-agent-headers_test.ts index 9d6122a244..c428d37423 100644 --- a/packages/provider-copilot/src/interceptors/messages/set-claude-agent-headers_test.ts +++ b/packages/provider-copilot/src/interceptors/messages/set-claude-agent-headers_test.ts @@ -35,7 +35,8 @@ test('Claude agent headers set for the legacy fingerprint with both halves', asy assertEquals(ctx.headers.get('x-interaction-type'), 'messages-proxy'); assertEquals(ctx.headers.get('openai-intent'), 'messages-proxy'); assertEquals(ctx.headers.get('user-agent'), CLAUDE_AGENT_USER_AGENT); - // Empty-string sentinel: copilotFetch deletes the base copilot-integration-id. + // Empty-string sentinel: `copilotAuthedFetch` in + // `packages/provider-copilot/src/auth.ts` deletes the base integration id. assertEquals(ctx.headers.get('copilot-integration-id'), ''); }); diff --git a/packages/provider-copilot/src/interceptors/messages/set-compact-headers.ts b/packages/provider-copilot/src/interceptors/messages/set-compact-headers.ts index cce7a79521..f36a8b496b 100644 --- a/packages/provider-copilot/src/interceptors/messages/set-compact-headers.ts +++ b/packages/provider-copilot/src/interceptors/messages/set-compact-headers.ts @@ -110,13 +110,13 @@ export const withCompactHeadersSet: CopilotMessagesBoundaryInterceptor = async ( if (kind === 'compact-request') { ctx.headers.set('x-initiator', 'agent'); ctx.headers.set('x-interaction-type', 'conversation-compaction'); - // openai-intent stays at copilotFetch's `conversation-agent` default — that - // is the same value caozhiyuan/copilot-api re-pins inside prepareForCompact, - // so explicitly setting it here would be a no-op. + // openai-intent stays at `copilotAuthedFetch`'s `conversation-agent` + // default from `../../auth.ts` — the same value caozhiyuan/copilot-api + // re-pins inside prepareForCompact, so setting it here would be a no-op. } else if (kind === 'auto-continue') { // Auto-continue gets only the agent-initiator tag; interaction-type stays - // at copilotFetch's `conversation-agent` default. This mirrors - // prepareForCompact's behavior when compactType === COMPACT_AUTO_CONTINUE: + // at `copilotAuthedFetch`'s `conversation-agent` default from `../../auth.ts`. + // This mirrors prepareForCompact when compactType === COMPACT_AUTO_CONTINUE: // it sets x-initiator: agent and leaves x-interaction-type untouched. ctx.headers.set('x-initiator', 'agent'); } diff --git a/packages/provider-copilot/src/pricing.ts b/packages/provider-copilot/src/pricing.ts index b10be88278..efa9c365b2 100644 --- a/packages/provider-copilot/src/pricing.ts +++ b/packages/provider-copilot/src/pricing.ts @@ -1,16 +1,12 @@ // Per-public-model pricing table used by the Copilot provider. Keys target // the public model id that survives Claude variant merging (e.g. -// `claude-opus-4-7`, `gpt-5.4`). `pricingForCopilotModelKey` strips raw-id -// variant suffixes (`-high`, `-xhigh`, `-1m`, `-1m-internal`, trailing date) -// using the same rules as `copilotPublicModelId` in model-name.ts so it can be -// fed the modelKey persisted in `usage.model_key`. Every entry carries -// explicit USD-per-million-token rates for its selector coordinate. +// `claude-opus-4-7`, `gpt-5.4`). Every entry carries explicit +// USD-per-million-token rates for its selector coordinate. // // Source of truth for Copilot pricing updates: // https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing // After changing this table, run the unit-price backfill for existing rows. // Refresh procedure: .agents/skills/fetching-models-pricing/. -import { copilotPublicModelId } from './model-name.ts'; import { tokenBasePricing, tokenModelPricing, tokenPricingEntry as pricingEntry, type ModelPricing } from '@floway-dev/protocols/common'; type PricingRule = readonly [key: string | RegExp, pricing: ModelPricing]; @@ -110,8 +106,3 @@ const matchPricing = (publicName: string): ModelPricing | null => { // Lookup by post-variant-merge public id (e.g. `claude-opus-4-7`). export const pricingForCopilotPublicModelId = (publicName: string): ModelPricing | null => matchPricing(publicName); - -// Lookup by raw upstream model id (e.g. `claude-opus-4-7-xhigh`, -// `claude-opus-4-5-20251101`). Variant suffix and date are stripped to derive -// the public id, then matched against the table. -export const pricingForCopilotModelKey = (modelKey: string): ModelPricing | null => matchPricing(copilotPublicModelId(modelKey)); diff --git a/packages/provider-copilot/src/pricing_test.ts b/packages/provider-copilot/src/pricing_test.ts index 7c76b0b142..d89f21169b 100644 --- a/packages/provider-copilot/src/pricing_test.ts +++ b/packages/provider-copilot/src/pricing_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { pricingForCopilotModelKey, pricingForCopilotPublicModelId } from './pricing.ts'; +import { pricingForCopilotPublicModelId } from './pricing.ts'; import { perMillionTokenRates, priceRequest, type PriceVector } from '@floway-dev/protocols/common'; import { assertEquals } from '@floway-dev/test-utils'; @@ -50,14 +50,3 @@ test('Copilot pricing resolves exact and regex model families', () => { assertEquals(priceRequest(pricingForCopilotPublicModelId('text-embedding-3-small'), { inputTokens: 0 }).rates, published({ input_tokens: '0.02', output_tokens: '0' })); assertEquals(pricingForCopilotPublicModelId('totally-made-up-model'), null); }); - -test('pricingForCopilotModelKey strips Claude variant suffixes before lookup', () => { - for (const id of ['claude-opus-4-7-high', 'claude-opus-4-7-xhigh', 'claude-opus-4-7-1m', 'claude-opus-4-7-1m-internal', 'claude-opus-4-7-20251101']) { - assertEquals(priceRequest(pricingForCopilotModelKey(id), { inputTokens: 0 }).rates, OPUS_BASE); - } - assertEquals(priceRequest(pricingForCopilotModelKey('claude-opus-4-7-fast'), { serviceTier: 'fast', inputTokens: 0 }).rates, published({ input_tokens: '30', input_cache_read_tokens: '3', input_cache_write_tokens: '37.5', output_tokens: '150' })); - for (const id of ['claude-opus-5', 'claude-opus-5-high', 'claude-opus-5-xhigh', 'claude-opus-5-1m']) { - assertEquals(priceRequest(pricingForCopilotModelKey(id), { inputTokens: 0 }).rates, OPUS_BASE); - } - assertEquals(priceRequest(pricingForCopilotModelKey('claude-opus-5-fast'), { serviceTier: 'fast', inputTokens: 0 }).rates, published({ input_tokens: '10', input_cache_read_tokens: '1', input_cache_write_tokens: '12.5', output_tokens: '50' })); -}); diff --git a/packages/provider-copilot/src/provider_test.ts b/packages/provider-copilot/src/provider_test.ts index 0519313c19..c00f38299b 100644 --- a/packages/provider-copilot/src/provider_test.ts +++ b/packages/provider-copilot/src/provider_test.ts @@ -701,9 +701,10 @@ test('Copilot Messages boundary chain does NOT fire on the Chat Completions wire }, ); - // The chat-completions wire defaults to `conversation-agent` - // (set by copilotFetch). The Messages-boundary `withClaudeAgentHeadersSet` - // would overwrite it to `messages-proxy` if it had run — its absence is the + // The chat-completions wire defaults to `conversation-agent` (set by + // `copilotAuthedFetch` in `packages/provider-copilot/src/auth.ts`). The + // Messages-boundary `withClaudeAgentHeadersSet` would overwrite it to + // `messages-proxy` if it had run — its absence is the // proof that the Messages boundary chain did NOT fire on this wire. assertEquals(observedInteractionType, ['conversation-agent']); }); diff --git a/packages/provider-copilot/src/quota.ts b/packages/provider-copilot/src/quota.ts new file mode 100644 index 0000000000..6beee1b1aa --- /dev/null +++ b/packages/provider-copilot/src/quota.ts @@ -0,0 +1,33 @@ +import { githubHeaders } from './auth.ts'; +import type { Fetcher } from '@floway-dev/provider'; + +export interface CopilotQuotaDetail { + entitlement: number; + overage_count: number; + overage_permitted: boolean; + percent_remaining: number; + quota_id: string; + quota_remaining: number; + remaining: number; + unlimited: boolean; +} + +export interface CopilotUsageResponse { + access_type_sku: string; + analytics_tracking_id: string; + assigned_date: string; + can_signup_for_limited: boolean; + chat_enabled: boolean; + copilot_plan: string; + organization_login_list: unknown[]; + organization_list: unknown[]; + quota_reset_date: string; + quota_snapshots: { + chat: CopilotQuotaDetail; + completions: CopilotQuotaDetail; + premium_interactions: CopilotQuotaDetail; + }; +} + +export const fetchCopilotUsage = (githubToken: string, fetcher: Fetcher): Promise => + fetcher('https://api.github.com/copilot_internal/user', { headers: githubHeaders(githubToken) }); diff --git a/packages/provider-custom/package.json b/packages/provider-custom/package.json index 793fa69e6f..c143ba4c70 100644 --- a/packages/provider-custom/package.json +++ b/packages/provider-custom/package.json @@ -10,7 +10,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@floway-dev/interceptor": "workspace:*", "@floway-dev/protocols": "workspace:*", "@floway-dev/provider": "workspace:*" }, diff --git a/packages/provider-ollama/package.json b/packages/provider-ollama/package.json index 1f4c1972db..9a7a53e15e 100644 --- a/packages/provider-ollama/package.json +++ b/packages/provider-ollama/package.json @@ -4,8 +4,7 @@ "version": "0.0.0", "type": "module", "exports": { - ".": { "import": "./src/index.ts", "types": "./src/index.ts" }, - "./pricing": { "import": "./src/pricing.ts", "types": "./src/pricing.ts" } + ".": { "import": "./src/index.ts", "types": "./src/index.ts" } }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/packages/provider-ollama/src/config_test.ts b/packages/provider-ollama/src/config_test.ts index 3f73fd1309..77ee896f45 100644 --- a/packages/provider-ollama/src/config_test.ts +++ b/packages/provider-ollama/src/config_test.ts @@ -1,8 +1,6 @@ import { test } from 'vitest'; import { assertOllamaUpstreamRecord } from './config.ts'; -import { pricingForOllamaModelKey } from './pricing.ts'; -import { priceRequest } from '@floway-dev/protocols/common'; import type { UpstreamRecord } from '@floway-dev/provider'; import { assertEquals, assertThrows } from '@floway-dev/test-utils'; @@ -89,39 +87,3 @@ test('assertOllamaUpstreamRecord rejects rerank models', () => { 'rerank models require a custom upstream', ); }); - -test('pricingForOllamaModelKey returns table rates for known model ids', () => { - const gptOss = pricingForOllamaModelKey('gpt-oss:120b'); - assertEquals(gptOss?.entries[0]?.rates.input_tokens, '0.00000015'); - assertEquals(gptOss?.entries[0]?.rates.output_tokens, '0.0000006'); -}); - -test('pricingForOllamaModelKey matches regex-keyed families', () => { - // GLM 5 split: bare `glm-5` is cheaper than `glm-5.1` / `glm-5.2`. - assertEquals(pricingForOllamaModelKey('glm-5')?.entries[0]?.rates.input_tokens, '0.000001'); - assertEquals(pricingForOllamaModelKey('glm-5')?.entries[0]?.rates.output_tokens, '0.0000032'); - assertEquals(pricingForOllamaModelKey('glm-5.1')?.entries[0]?.rates.input_tokens, '0.0000014'); - assertEquals(pricingForOllamaModelKey('glm-5.2')?.entries[0]?.rates.output_tokens, '0.0000044'); - - // MiniMax split: m2 / m2.1 / m2.5 carry cache_read 0.03; m2.7 / m3 carry - // cache_read 0.06. Input/output are identical across both branches. - assertEquals(pricingForOllamaModelKey('minimax-m2.1')?.entries[0]?.rates.input_cache_read_tokens, '0.00000003'); - assertEquals(pricingForOllamaModelKey('minimax-m2.5')?.entries[0]?.rates.input_cache_read_tokens, '0.00000003'); - assertEquals(pricingForOllamaModelKey('minimax-m2.7')?.entries[0]?.rates.input_cache_read_tokens, '0.00000006'); - const m3 = pricingForOllamaModelKey('minimax-m3'); - assertEquals(priceRequest(m3, { inputTokens: 512000 }).rates, { input_tokens: '0.0000003', input_cache_read_tokens: '0.00000006', output_tokens: '0.0000012' }); - assertEquals(priceRequest(m3, { inputTokens: 512001 }).rates, { input_tokens: '0.0000006', input_cache_read_tokens: '0.00000012', output_tokens: '0.0000024' }); -}); - -test('pricingForOllamaModelKey returns null for ids without a defensible reference', () => { - // Mistral Labs free tier — deliberately omitted; no commercial per-token - // rate published. - assertEquals(pricingForOllamaModelKey('devstral-small-2:24b'), null); - // Version that does not map to any upstream release. - assertEquals(pricingForOllamaModelKey('qwen3.5'), null); - // Gemma — Vertex sells per-token only for gemma-4-26b-a4b-it (not on - // Ollama Cloud); every Ollama Gemma tag is self-host-on-Vertex GPU-hour, - // not per-token, so deliberately unpriced. - assertEquals(pricingForOllamaModelKey('gemma3:27b'), null); - assertEquals(pricingForOllamaModelKey('gemma4:31b'), null); -}); diff --git a/packages/provider-ollama/src/pricing_test.ts b/packages/provider-ollama/src/pricing_test.ts new file mode 100644 index 0000000000..ba1984f1f7 --- /dev/null +++ b/packages/provider-ollama/src/pricing_test.ts @@ -0,0 +1,41 @@ +import { test } from 'vitest'; + +import { pricingForOllamaModelKey } from './pricing.ts'; +import { priceRequest } from '@floway-dev/protocols/common'; +import { assertEquals } from '@floway-dev/test-utils'; + +test('pricingForOllamaModelKey returns table rates for known model ids', () => { + const gptOss = pricingForOllamaModelKey('gpt-oss:120b'); + assertEquals(gptOss?.entries[0]?.rates.input_tokens, '0.00000015'); + assertEquals(gptOss?.entries[0]?.rates.output_tokens, '0.0000006'); +}); + +test('pricingForOllamaModelKey matches regex-keyed families', () => { + // GLM 5 split: bare `glm-5` is cheaper than `glm-5.1` / `glm-5.2`. + assertEquals(pricingForOllamaModelKey('glm-5')?.entries[0]?.rates.input_tokens, '0.000001'); + assertEquals(pricingForOllamaModelKey('glm-5')?.entries[0]?.rates.output_tokens, '0.0000032'); + assertEquals(pricingForOllamaModelKey('glm-5.1')?.entries[0]?.rates.input_tokens, '0.0000014'); + assertEquals(pricingForOllamaModelKey('glm-5.2')?.entries[0]?.rates.output_tokens, '0.0000044'); + + // MiniMax split: m2 / m2.1 / m2.5 carry cache_read 0.03; m2.7 / m3 carry + // cache_read 0.06. Input/output are identical across both branches. + assertEquals(pricingForOllamaModelKey('minimax-m2.1')?.entries[0]?.rates.input_cache_read_tokens, '0.00000003'); + assertEquals(pricingForOllamaModelKey('minimax-m2.5')?.entries[0]?.rates.input_cache_read_tokens, '0.00000003'); + assertEquals(pricingForOllamaModelKey('minimax-m2.7')?.entries[0]?.rates.input_cache_read_tokens, '0.00000006'); + const m3 = pricingForOllamaModelKey('minimax-m3'); + assertEquals(priceRequest(m3, { inputTokens: 512000 }).rates, { input_tokens: '0.0000003', input_cache_read_tokens: '0.00000006', output_tokens: '0.0000012' }); + assertEquals(priceRequest(m3, { inputTokens: 512001 }).rates, { input_tokens: '0.0000006', input_cache_read_tokens: '0.00000012', output_tokens: '0.0000024' }); +}); + +test('pricingForOllamaModelKey returns null for ids without a defensible reference', () => { + // Mistral Labs free tier — deliberately omitted; no commercial per-token + // rate published. + assertEquals(pricingForOllamaModelKey('devstral-small-2:24b'), null); + // Version that does not map to any upstream release. + assertEquals(pricingForOllamaModelKey('qwen3.5'), null); + // Gemma — Vertex sells per-token only for gemma-4-26b-a4b-it (not on + // Ollama Cloud); every Ollama Gemma tag is self-host-on-Vertex GPU-hour, + // not per-token, so deliberately unpriced. + assertEquals(pricingForOllamaModelKey('gemma3:27b'), null); + assertEquals(pricingForOllamaModelKey('gemma4:31b'), null); +}); diff --git a/packages/provider/package.json b/packages/provider/package.json index 410168eb2a..afa716debc 100644 --- a/packages/provider/package.json +++ b/packages/provider/package.json @@ -13,7 +13,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@floway-dev/interceptor": "workspace:*", "@floway-dev/platform": "workspace:*", "@floway-dev/protocols": "workspace:*" }, diff --git a/packages/gateway/src/data-plane/providers/flags-resolve_test.ts b/packages/provider/src/flags_test.ts similarity index 57% rename from packages/gateway/src/data-plane/providers/flags-resolve_test.ts rename to packages/provider/src/flags_test.ts index cb9ede8225..22f7e6eb06 100644 --- a/packages/gateway/src/data-plane/providers/flags-resolve_test.ts +++ b/packages/provider/src/flags_test.ts @@ -1,8 +1,48 @@ import { test } from 'vitest'; -import { resolveEffectiveFlags } from '@floway-dev/provider'; +import { isKnownFlagId, OPTIONAL_FLAGS, resolveEffectiveFlags } from './flags.ts'; import { assertEquals } from '@floway-dev/test-utils'; +test('provider flags: catalog ids are unique', () => { + const ids = new Set(); + for (const entry of OPTIONAL_FLAGS) { + assertEquals(ids.has(entry.id), false); + ids.add(entry.id); + } +}); + +test('provider flags: every catalog entry has a non-empty label', () => { + for (const entry of OPTIONAL_FLAGS) { + assertEquals(typeof entry.label, 'string'); + assertEquals(entry.label.length > 0, true); + } +}); + +test('provider flags: isKnownFlagId agrees with catalog', () => { + for (const entry of OPTIONAL_FLAGS) { + assertEquals(isKnownFlagId(entry.id), true); + } + assertEquals(isKnownFlagId('nonexistent-flag'), false); +}); + +const FLAG_ID_PATTERN = /^[a-z][a-z0-9-]+$/; + +test('provider flags: every catalog id is kebab-case', () => { + for (const entry of OPTIONAL_FLAGS) { + assertEquals(FLAG_ID_PATTERN.test(entry.id), true, `id ${entry.id} must be kebab-case`); + } +}); + +test('provider flags: every catalog entry has id, label, description string fields', () => { + for (const entry of OPTIONAL_FLAGS) { + assertEquals(typeof entry.id, 'string'); + assertEquals(entry.id.length > 0, true); + assertEquals(typeof entry.label, 'string'); + assertEquals(typeof entry.description, 'string'); + assertEquals(entry.description.length > 0, true); + } +}); + test('flags-resolve: no layers → empty set', () => { const set = resolveEffectiveFlags([]); assertEquals([...set].sort(), []); diff --git a/packages/gateway/src/shared/upstream/join_test.ts b/packages/provider/src/join_test.ts similarity index 95% rename from packages/gateway/src/shared/upstream/join_test.ts rename to packages/provider/src/join_test.ts index 67d18a6c54..ec7f6bf75f 100644 --- a/packages/gateway/src/shared/upstream/join_test.ts +++ b/packages/provider/src/join_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { joinBaseAndPath, validateUpstreamPath } from '@floway-dev/provider'; +import { joinBaseAndPath, validateUpstreamPath } from './join.ts'; import { assertEquals, assertFalse } from '@floway-dev/test-utils'; test('validateUpstreamPath accepts a leading-slash absolute path', () => { diff --git a/packages/provider/src/model-config_test.ts b/packages/provider/src/model-config_test.ts index 67d92f6aab..089ea4932d 100644 --- a/packages/provider/src/model-config_test.ts +++ b/packages/provider/src/model-config_test.ts @@ -278,3 +278,166 @@ describe('modelsField rerank targets', () => { expect(model.rerankTarget).toEqual({ protocol: 'cohere-v2' }); }); }); + +test('modelsField parses a full model entry', () => { + const models = modelsField( + [ + { + upstreamModelId: 'gpt-prod', + publicModelId: 'gpt-5', + endpoints: { chatCompletions: {}, responses: {} }, + display_name: 'GPT Prod', + limits: { max_context_window_tokens: 128000, max_output_tokens: 4096 }, + pricing: { entries: [{ rates: { input_tokens: '2.5', output_tokens: '15', input_cache_read_tokens: '0.25', input_cache_write_tokens: '3.75' } }] }, + flagOverrides: { 'vendor-deepseek': false }, + }, + ], + 'azure', + ); + + assertEquals(models, [ + { + upstreamModelId: 'gpt-prod', + publicModelId: 'gpt-5', + kind: 'chat', + endpoints: { chatCompletions: {}, responses: {} }, + display_name: 'GPT Prod', + limits: { max_context_window_tokens: 128000, max_output_tokens: 4096 }, + pricing: { entries: [{ rates: { input_tokens: '2.5', output_tokens: '15', input_cache_read_tokens: '0.25', input_cache_write_tokens: '3.75' } }] }, + flagOverrides: { 'vendor-deepseek': false }, + }, + ]); +}); + +test('modelsField parses a minimal model entry', () => { + const models = modelsField( + [{ upstreamModelId: 'gpt-prod', endpoints: { chatCompletions: {} } }], + 'custom', + ); + + assertEquals(models, [{ upstreamModelId: 'gpt-prod', kind: 'chat', endpoints: { chatCompletions: {} } }]); +}); + +test('modelsField rejects a missing upstreamModelId', () => { + assertThrows( + () => modelsField([{ endpoints: { chatCompletions: {} } }], 'azure'), + Error, + 'Malformed azure models[0].upstreamModelId: must be a non-empty string', + ); +}); + +test('modelsField returns an empty array for an empty list', () => { + assertEquals(modelsField([], 'custom'), []); +}); + +test('modelsField rejects a non-array', () => { + assertThrows( + () => modelsField({}, 'custom'), + Error, + 'Malformed custom upstream config: models must be an array', + ); +}); + +test('modelsField rejects a non-object entry', () => { + assertThrows( + () => modelsField(['not-an-object'], 'azure'), + Error, + 'Malformed azure models[0]: must be an object', + ); +}); + +test('modelsField rejects an empty endpoints object', () => { + assertThrows( + () => modelsField([{ upstreamModelId: 'gpt-prod', endpoints: { } }], 'azure'), + Error, + 'Malformed azure models[0].endpoints: must declare at least one endpoint', + ); +}); + +test('modelsField rejects an unsupported endpoint key', () => { + assertThrows( + () => modelsField([{ upstreamModelId: 'gpt-prod', endpoints: { bogus: {} } }], 'azure'), + Error, + 'Malformed azure models[0].endpoints: unsupported endpoint bogus', + ); +}); + +test('modelsField derives kind from endpoints when omitted', () => { + const [embedding] = modelsField([{ upstreamModelId: 'e', endpoints: { embeddings: {} } }], 'custom'); + assertEquals(embedding.kind, 'embedding'); + const [image] = modelsField([{ upstreamModelId: 'i', endpoints: { imagesGenerations: {}, imagesEdits: {} } }], 'custom'); + assertEquals(image.kind, 'image'); + const [audio] = modelsField([{ upstreamModelId: 'a', endpoints: { audioTranscriptions: {} } }], 'custom'); + assertEquals(audio.kind, 'transcription'); + const [chat] = modelsField([{ upstreamModelId: 'c', endpoints: { responses: {} } }], 'custom'); + assertEquals(chat.kind, 'chat'); +}); + +test('modelsField accepts a valid kind and rejects an unknown one', () => { + const models = modelsField( + [{ upstreamModelId: 'm', kind: 'embedding', endpoints: { embeddings: {} } }], + 'custom', + ); + assertEquals(models[0].kind, 'embedding'); + assertThrows( + () => modelsField([{ upstreamModelId: 'm', kind: 'bogus', endpoints: { chatCompletions: {} } }], 'custom'), + Error, + 'Malformed custom models[0].kind: must be one of chat, embedding, image, rerank, transcription', + ); +}); + +test('modelsField accepts pricing with only a subset of metrics set', () => { + const models = modelsField( + [{ upstreamModelId: 'gpt-prod', endpoints: { chatCompletions: {} }, pricing: { entries: [{ rates: { input_tokens: '2.5' } }] } }], + 'azure', + ); + assertEquals(models[0].pricing, { entries: [{ rates: { input_tokens: '2.5' } }] }); +}); + +test('modelsField rejects pricing with a negative input', () => { + assertThrows( + () => + modelsField( + [{ upstreamModelId: 'gpt-prod', endpoints: { chatCompletions: {} }, pricing: { entries: [{ rates: { input_tokens: '-1', output_tokens: '1' } }] } }], + 'azure', + ), + Error, + 'pricing.entries[0].rates.input_tokens must be non-negative', + ); +}); + +test('modelsField rejects a non-object flagOverrides', () => { + assertThrows( + () => + modelsField( + [ + { + upstreamModelId: 'gpt-prod', + endpoints: { chatCompletions: {} }, + flagOverrides: 'not-an-object', + }, + ], + 'azure', + ), + Error, + 'Malformed azure models[0].flagOverrides: must be an object', + ); +}); + +test('modelsField rejects flagOverrides with an unknown flag id', () => { + assertThrows( + () => + modelsField( + [ + { + upstreamModelId: 'gpt-prod', + endpoints: { chatCompletions: {} }, + flagOverrides: { 'made-up-flag': true }, + }, + ], + 'azure', + ), + Error, + 'Malformed azure models[0].flagOverrides: unknown flag ids: made-up-flag', + ); +}); diff --git a/packages/proxy/package.json b/packages/proxy/package.json index af06e05e71..04ac4f73f5 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -35,7 +35,6 @@ "@reclaimprotocol/tls": "0.1.2" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20251215.0", "@types/node": "^22", "typescript": "^5.9.3" } diff --git a/packages/proxy/tsconfig.json b/packages/proxy/tsconfig.json index 8bcf47a7c9..824d57c3c4 100644 --- a/packages/proxy/tsconfig.json +++ b/packages/proxy/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "types": ["@cloudflare/workers-types", "node"] + "types": ["node"] }, "include": ["src/**/*.ts"] } diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 5ebbbefd00..be7bac2563 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -10,7 +10,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@floway-dev/protocols": "workspace:*", "@floway-dev/provider": "workspace:*" } } diff --git a/packages/translate/package.json b/packages/translate/package.json index 462bf2d1cf..694806f1fe 100644 --- a/packages/translate/package.json +++ b/packages/translate/package.json @@ -4,13 +4,15 @@ "version": "0.0.0", "type": "module", "exports": { - ".": { "import": "./src/index.ts", "types": "./src/index.ts" }, - "./via-responses/responses-items": { "import": "./src/shared/via-responses/responses-items.ts", "types": "./src/shared/via-responses/responses-items.ts" } + ".": { "import": "./src/index.ts", "types": "./src/index.ts" } }, "scripts": { "typecheck": "tsc --noEmit" }, "dependencies": { "@floway-dev/protocols": "workspace:*" + }, + "devDependencies": { + "@floway-dev/test-utils": "workspace:*" } } diff --git a/packages/translate/src/canonicalize-responses-payload.ts b/packages/translate/src/canonicalize-responses-payload.ts new file mode 100644 index 0000000000..59def7a8ca --- /dev/null +++ b/packages/translate/src/canonicalize-responses-payload.ts @@ -0,0 +1,67 @@ +import { TranslatorInputError } from './translator-input-error.ts'; +import type { CanonicalResponsesPayload, ResponsesEasyInputMessage, ResponsesInputItem, ResponsesRequestPayload } from '@floway-dev/protocols/responses'; + +// Wire `ResponsesRequestPayload.input` accepts a bare string and EasyInputMessage +// objects whose `type: "message"` discriminator is omitted. The gateway's +// canonical internal shape is an explicitly discriminated item array: every +// consumer past HTTP / WS entry normalization or cross-protocol translation +// sees `type: "message"` on every message. +// Lifts a wire `ResponsesRequestPayload` to canonical form. Called at every wire +// boundary that produces a payload destined for internal use and by direct +// Responses-source translators; cross-protocol translators already construct +// `CanonicalResponsesPayload` with explicit message discriminators. +export function canonicalizeResponsesPayload(value: unknown): CanonicalResponsesPayload { + const hasValidPromptCacheBreakpoint = (content: Record): boolean => { + const breakpoint = content.prompt_cache_breakpoint; + if (breakpoint === undefined || breakpoint === null) return true; + return typeof breakpoint === 'object' + && typeof (breakpoint as Record).mode === 'string'; + }; + + const isImplicitEasyInputMessage = (item: unknown): item is ResponsesEasyInputMessage & { type?: undefined } => { + if (typeof item !== 'object' || item === null) return false; + const message = item as Record; + if (message.type !== undefined) return false; + if (message.role !== 'user' && message.role !== 'assistant' && message.role !== 'system' && message.role !== 'developer') return false; + if (message.phase !== undefined && message.phase !== null && typeof message.phase !== 'string') return false; + return typeof message.content === 'string' + || (Array.isArray(message.content) && message.content.every(part => { + if (typeof part !== 'object' || part === null) return false; + const content = part as Record; + switch (content.type) { + case 'input_text': + case 'output_text': + return typeof content.text === 'string' && hasValidPromptCacheBreakpoint(content); + case 'input_image': + return (typeof content.image_url === 'string' || typeof content.file_id === 'string') + && typeof content.detail === 'string' + && hasValidPromptCacheBreakpoint(content); + case 'input_file': + return hasValidPromptCacheBreakpoint(content); + default: + return false; + } + })); + }; + + if (typeof value !== 'object' || value === null) { + throw new TranslatorInputError('Responses payload must be an object.'); + } + const payload = value as ResponsesRequestPayload; + const input: unknown = payload.input; + if (typeof input !== 'string' && !Array.isArray(input)) { + throw new TranslatorInputError('Responses input must be a string or an array.', { param: 'input' }); + } + return { + ...payload, + input: typeof input === 'string' + ? [{ type: 'message', role: 'user', content: input }] + : input.map((item, index) => { + if (isImplicitEasyInputMessage(item)) return { ...item, type: 'message' }; + if (typeof item !== 'object' || item === null || (item as { type?: unknown }).type === undefined) { + throw new TranslatorInputError('Untyped Responses input items require a valid role and content.', { param: `input[${index}]` }); + } + return item as ResponsesInputItem; + }), + }; +} diff --git a/packages/translate/src/canonicalize-responses-payload_test.ts b/packages/translate/src/canonicalize-responses-payload_test.ts new file mode 100644 index 0000000000..087a6419ac --- /dev/null +++ b/packages/translate/src/canonicalize-responses-payload_test.ts @@ -0,0 +1,88 @@ +import { test } from 'vitest'; + +import { canonicalizeResponsesPayload } from './canonicalize-responses-payload.ts'; +import { TranslatorInputError } from './translator-input-error.ts'; +import type { ResponsesPayload } from '@floway-dev/protocols/responses'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; + +test('canonicalizes string and implicit-message wire inputs', () => { + assertEquals(canonicalizeResponsesPayload({ model: 'gpt-test', input: 'hello' }), { + model: 'gpt-test', + input: [{ type: 'message', role: 'user', content: 'hello' }], + }); + + assertEquals(canonicalizeResponsesPayload({ + model: 'gpt-test', + input: [ + { role: 'system', content: 'rules', phase: 'future_phase' }, + { + role: 'user', + content: [ + { type: 'input_text', text: 'look', prompt_cache_breakpoint: { mode: 'future_mode' } }, + { type: 'input_image', file_id: 'file_1', detail: 'original', prompt_cache_breakpoint: { mode: 'explicit' } }, + { type: 'input_file', file_id: 'file_2', prompt_cache_breakpoint: { mode: 'explicit' } }, + ], + }, + { type: 'message', role: 'user', content: 'hello' }, + { type: 'function_call_output', call_id: 'call_1', output: 'result' }, + ], + }), { + model: 'gpt-test', + input: [ + { type: 'message', role: 'system', content: 'rules', phase: 'future_phase' }, + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'look', prompt_cache_breakpoint: { mode: 'future_mode' } }, + { type: 'input_image', file_id: 'file_1', detail: 'original', prompt_cache_breakpoint: { mode: 'explicit' } }, + { type: 'input_file', file_id: 'file_2', prompt_cache_breakpoint: { mode: 'explicit' } }, + ], + }, + { type: 'message', role: 'user', content: 'hello' }, + { type: 'function_call_output', call_id: 'call_1', output: 'result' }, + ], + }); +}); + +test('rejects malformed untyped input items at the canonical boundary', () => { + for (const malformed of [ + null, + 42, + { content: 'missing role' }, + { role: 'unknown', content: 'invalid role' }, + { role: 'user', content: [null] }, + { role: 'user', content: [{}] }, + { role: 'user', content: [{ type: 'input_text' }] }, + { role: 'user', content: [{ type: 'input_text', text: 'invalid breakpoint', prompt_cache_breakpoint: {} }] }, + { role: 'user', content: 'invalid phase', phase: 42 }, + ]) { + const error = assertThrows( + () => canonicalizeResponsesPayload({ + model: 'gpt-test', + input: [malformed] as unknown as ResponsesPayload['input'], + }), + TranslatorInputError, + 'valid role and content', + ) as TranslatorInputError; + assertEquals(error.param, 'input[0]'); + } +}); + +test('canonicalizeResponsesPayload preserves reasoning.context verbatim, including future modes', () => { + const canonicalCurrent = canonicalizeResponsesPayload({ + model: 'gpt-test', + input: [{ type: 'message', role: 'user', content: 'hi' }], + reasoning: { effort: 'high', context: 'current_turn' }, + }); + assertEquals(canonicalCurrent.reasoning, { effort: 'high', context: 'current_turn' }); + + // An unknown/future context string rides through the wire→canonical boundary + // untouched — the upstream owns the accept/reject decision. + const canonicalFuture = canonicalizeResponsesPayload({ + model: 'gpt-test', + input: 'hi', + reasoning: { context: 'future_mode' }, + }); + assertEquals(canonicalFuture.reasoning, { context: 'future_mode' }); +}); diff --git a/packages/translate/src/chat-completions-via-messages/events-protocol_test.ts b/packages/translate/src/chat-completions-via-messages/events-protocol_test.ts index ef573879e1..98327448a8 100644 --- a/packages/translate/src/chat-completions-via-messages/events-protocol_test.ts +++ b/packages/translate/src/chat-completions-via-messages/events-protocol_test.ts @@ -1,9 +1,9 @@ import { test } from 'vitest'; import { translateToSourceEvents } from './events.ts'; -import { assertRejects } from '../test-assert.ts'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; +import { assertRejects } from '@floway-dev/test-utils'; const drain = async (frames: AsyncIterable): Promise => { for await (const _frame of frames) { diff --git a/packages/translate/src/chat-completions-via-messages/events_test.ts b/packages/translate/src/chat-completions-via-messages/events_test.ts index 9bed05fddc..e1f8957601 100644 --- a/packages/translate/src/chat-completions-via-messages/events_test.ts +++ b/packages/translate/src/chat-completions-via-messages/events_test.ts @@ -1,10 +1,10 @@ import { test } from 'vitest'; import { createMessagesToChatCompletionsStreamState, translateMessagesEventToChatCompletionsChunks } from './events.ts'; -import { assertEquals } from '../test-assert.ts'; import type { ChatCompletionsStreamEvent, ChatCompletionsDelta } from '@floway-dev/protocols/chat-completions'; import { USAGE_BILLING } from '@floway-dev/protocols/common'; import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; +import { assertEquals } from '@floway-dev/test-utils'; // ── Helpers ── diff --git a/packages/translate/src/chat-completions-via-messages/request.ts b/packages/translate/src/chat-completions-via-messages/request.ts index 880216b623..e88f96857d 100644 --- a/packages/translate/src/chat-completions-via-messages/request.ts +++ b/packages/translate/src/chat-completions-via-messages/request.ts @@ -1,7 +1,7 @@ import { messagesThinkingBlockFromChatCompletionsScalarReasoning } from '../shared/chat-completions-and-messages/reasoning.ts'; -import { parseToolArgumentsObject } from '../shared/messages/tool-arguments.ts'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../shared/via-messages/cache-breakpoints.ts'; import { type RemoteImageLoader, resolveImageUrlToMessagesImage, unavailableRemoteImageLoader } from '../shared/via-messages/remote-images.ts'; +import { parseToolArgumentsObject } from '../shared/via-messages/tool-arguments.ts'; import { TranslatorInputError } from '../translator-input-error.ts'; import type { ChatCompletionsPayload, ChatCompletionsMessage, ChatCompletionsTool } from '@floway-dev/protocols/chat-completions'; import { MESSAGES_FALLBACK_MAX_TOKENS, type MessagesAssistantContentBlock, type MessagesMessage, type MessagesPayload, type MessagesTextBlock, type MessagesUserContentBlock } from '@floway-dev/protocols/messages'; diff --git a/packages/translate/src/chat-completions-via-messages/request_test.ts b/packages/translate/src/chat-completions-via-messages/request_test.ts index 5ecab62c67..d7ec259015 100644 --- a/packages/translate/src/chat-completions-via-messages/request_test.ts +++ b/packages/translate/src/chat-completions-via-messages/request_test.ts @@ -2,7 +2,6 @@ import { test } from 'vitest'; import { translateChatCompletionsToMessages } from './request.ts'; import type { RemoteImageLoader } from '../shared/via-messages/remote-images.ts'; -import { assertEquals, assertExists, assertFalse, assertRejects } from '../test-assert.ts'; import type { ChatCompletionsMessage, ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; import { MESSAGES_FALLBACK_MAX_TOKENS, @@ -16,6 +15,7 @@ import { type MessagesToolUseBlock, type MessagesUserContentBlock, } from '@floway-dev/protocols/messages'; +import { assertEquals, assertExists, assertFalse, assertRejects } from '@floway-dev/test-utils'; // ── Helpers ── diff --git a/packages/translate/src/chat-completions-via-responses/events.ts b/packages/translate/src/chat-completions-via-responses/events.ts index 6b91873048..30e40a3541 100644 --- a/packages/translate/src/chat-completions-via-responses/events.ts +++ b/packages/translate/src/chat-completions-via-responses/events.ts @@ -1,4 +1,4 @@ -import { toChatCompletionsReasoningItem } from '../shared/chat-completions-and-responses/reasoning.ts'; +import { hasReadableSummary, toChatCompletionsReasoningItem } from '../shared/chat-completions-and-responses/reasoning.ts'; import { createResponsesOutputOrderState, recordResponsesOutputOrderEvent, type ResponsesOutputOrderState, shouldDeferForEarlierResponsesOutput } from '../shared/via-responses/responses-stream-order.ts'; import { type ResponsesEvent, responsesPartKey } from '../shared/via-responses/responses-stream.ts'; import type { ChatCompletionsStreamEvent, ChatCompletionsResult, ChatCompletionsReasoningItem, ChatCompletionsDelta } from '@floway-dev/protocols/chat-completions'; @@ -68,8 +68,6 @@ export const createResponsesToChatCompletionsStreamState = (): ResponsesToChatCo const trackReasoningOutputItem = (item: ResponsesOutputItem): boolean => item.type === 'reasoning'; -const hasReadableSummary = (item: ChatCompletionsReasoningItem): boolean => item.summary?.some(part => part.text) === true; - const flushPendingReasoningChunks = (state: ResponsesToChatCompletionsStreamState): ChatCompletionsStreamEvent[] => { if (state.reasoningItems.length === 0) return []; diff --git a/packages/translate/src/chat-completions-via-responses/events_test.ts b/packages/translate/src/chat-completions-via-responses/events_test.ts index 241b226d7c..4177c1441d 100644 --- a/packages/translate/src/chat-completions-via-responses/events_test.ts +++ b/packages/translate/src/chat-completions-via-responses/events_test.ts @@ -1,10 +1,10 @@ import { test } from 'vitest'; import { translateToSourceEvents } from './events.ts'; -import { assertEquals, assertRejects } from '../test-assert.ts'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { eventFrame, type ProtocolFrame, type SseFrame, sseFrame } from '@floway-dev/protocols/common'; import { responsesResultToEvents, type ResponsesResult, type ResponsesStreamEvent } from '@floway-dev/protocols/responses'; +import { assertEquals, assertRejects } from '@floway-dev/test-utils'; // Inlined copy of the gateway's chatCompletionsProtocolFrameToSSEFrame: kept here so this // translate-package test does not deep-import into packages/gateway. The behavior diff --git a/packages/translate/src/chat-completions-via-responses/request_test.ts b/packages/translate/src/chat-completions-via-responses/request_test.ts index 1f9bfec7c5..889c011e80 100644 --- a/packages/translate/src/chat-completions-via-responses/request_test.ts +++ b/packages/translate/src/chat-completions-via-responses/request_test.ts @@ -2,9 +2,9 @@ import { expect, test } from 'vitest'; import { translateChatCompletionsToResponses } from './request.ts'; import { createChatCompletionsToResponsesStreamState, flushChatCompletionsToResponsesEvents, translateChatCompletionsChunkToResponsesEvents } from '../responses-via-chat-completions/events.ts'; -import { assertEquals, assertFalse, assertThrows } from '../test-assert.ts'; import type { ChatCompletionsMessage, ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import type { ResponsesInputReasoning, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; +import { assertEquals, assertFalse, assertThrows } from '@floway-dev/test-utils'; type ResponsesOutputItemDoneEvent = Extract; diff --git a/packages/translate/src/gemini-via-chat-completions/events_test.ts b/packages/translate/src/gemini-via-chat-completions/events_test.ts index 372bce1efa..28a7a6394d 100644 --- a/packages/translate/src/gemini-via-chat-completions/events_test.ts +++ b/packages/translate/src/gemini-via-chat-completions/events_test.ts @@ -1,10 +1,10 @@ import { test } from 'vitest'; import { translateToSourceEvents } from './events.ts'; -import { assertEquals, assertRejects } from '../test-assert.ts'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { doneFrame, eventFrame, USAGE_BILLING, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiStreamEvent } from '@floway-dev/protocols/gemini'; +import { assertEquals, assertRejects } from '@floway-dev/test-utils'; const chunk = ( delta: ChatCompletionsStreamEvent['choices'][0]['delta'], diff --git a/packages/translate/src/gemini-via-chat-completions/request_test.ts b/packages/translate/src/gemini-via-chat-completions/request_test.ts index 1745dffe8e..32e4bb5ebe 100644 --- a/packages/translate/src/gemini-via-chat-completions/request_test.ts +++ b/packages/translate/src/gemini-via-chat-completions/request_test.ts @@ -1,8 +1,8 @@ import { test } from 'vitest'; import { buildTargetRequest } from './request.ts'; -import { assertEquals, assertThrows } from '../test-assert.ts'; import type { GeminiContent, GeminiPayload } from '@floway-dev/protocols/gemini'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; test('buildTargetRequest forwards an empty thinkingLevel verbatim', () => { const request = buildTargetRequest({ diff --git a/packages/translate/src/gemini-via-messages/events_test.ts b/packages/translate/src/gemini-via-messages/events_test.ts index 64af3f0183..bd46516520 100644 --- a/packages/translate/src/gemini-via-messages/events_test.ts +++ b/packages/translate/src/gemini-via-messages/events_test.ts @@ -1,10 +1,10 @@ import { test } from 'vitest'; import { translateToSourceEvents } from './events.ts'; -import { assertEquals, assertRejects } from '../test-assert.ts'; import { doneFrame, eventFrame, USAGE_BILLING, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiStreamEvent } from '@floway-dev/protocols/gemini'; import type { MessagesResult, MessagesStreamEvent } from '@floway-dev/protocols/messages'; +import { assertEquals, assertRejects } from '@floway-dev/test-utils'; const messageStart = (usage: MessagesResult['usage'] = { input_tokens: 0, output_tokens: 0 }): MessagesStreamEvent => ({ type: 'message_start', diff --git a/packages/translate/src/gemini-via-messages/request_test.ts b/packages/translate/src/gemini-via-messages/request_test.ts index bbfb581a47..f3c3416074 100644 --- a/packages/translate/src/gemini-via-messages/request_test.ts +++ b/packages/translate/src/gemini-via-messages/request_test.ts @@ -1,9 +1,9 @@ import { test } from 'vitest'; import { buildTargetRequest } from './request.ts'; -import { assertEquals, assertThrows } from '../test-assert.ts'; import type { GeminiContent, GeminiPayload } from '@floway-dev/protocols/gemini'; import { MESSAGES_FALLBACK_MAX_TOKENS } from '@floway-dev/protocols/messages'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; const noOptions = {}; diff --git a/packages/translate/src/gemini-via-responses/events_test.ts b/packages/translate/src/gemini-via-responses/events_test.ts index 42cdf76243..6838dc2cde 100644 --- a/packages/translate/src/gemini-via-responses/events_test.ts +++ b/packages/translate/src/gemini-via-responses/events_test.ts @@ -1,10 +1,10 @@ import { test } from 'vitest'; import { translateToSourceEvents } from './events.ts'; -import { assertEquals, assertRejects } from '../test-assert.ts'; import { doneFrame, eventFrame, USAGE_BILLING, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiStreamEvent } from '@floway-dev/protocols/gemini'; import type { ResponsesResult, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; +import { assertEquals, assertRejects } from '@floway-dev/test-utils'; const response = (status: ResponsesResult['status'], extra: Partial = {}): ResponsesResult => ({ id: 'resp_1', diff --git a/packages/translate/src/gemini-via-responses/request_test.ts b/packages/translate/src/gemini-via-responses/request_test.ts index 9333b12cd0..c6bfe15d6f 100644 --- a/packages/translate/src/gemini-via-responses/request_test.ts +++ b/packages/translate/src/gemini-via-responses/request_test.ts @@ -1,8 +1,8 @@ import { test } from 'vitest'; import { buildTargetRequest } from './request.ts'; -import { assertEquals, assertFalse, assertThrows } from '../test-assert.ts'; import type { GeminiContent, GeminiPayload } from '@floway-dev/protocols/gemini'; +import { assertEquals, assertFalse, assertThrows } from '@floway-dev/test-utils'; test('buildTargetRequest forwards an empty thinkingLevel verbatim', () => { const request = buildTargetRequest({ diff --git a/packages/translate/src/index.ts b/packages/translate/src/index.ts index 7987027f7f..78dce4fa6c 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -8,6 +8,7 @@ export { translateGeminiViaMessages } from './gemini-via-messages/translate.ts'; export { translateGeminiViaResponses } from './gemini-via-responses/translate.ts'; export { translateGeminiViaChatCompletions } from './gemini-via-chat-completions/translate.ts'; +export { canonicalizeResponsesPayload } from './canonicalize-responses-payload.ts'; export type { TranslatedApiError, TranslationContext } from './types.ts'; export type { RemoteImageData, RemoteImageLoader } from './shared/via-messages/remote-images.ts'; export { TranslatorInputError } from './translator-input-error.ts'; diff --git a/packages/translate/src/messages-via-chat-completions/events-protocol_test.ts b/packages/translate/src/messages-via-chat-completions/events-protocol_test.ts index c5a546ec18..5bd0784140 100644 --- a/packages/translate/src/messages-via-chat-completions/events-protocol_test.ts +++ b/packages/translate/src/messages-via-chat-completions/events-protocol_test.ts @@ -1,9 +1,9 @@ import { test } from 'vitest'; import { translateToSourceEvents } from './events.ts'; -import { assertRejects } from '../test-assert.ts'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { eventFrame } from '@floway-dev/protocols/common'; +import { assertRejects } from '@floway-dev/test-utils'; const drain = async (frames: AsyncIterable): Promise => { for await (const _frame of frames) { diff --git a/packages/translate/src/messages-via-chat-completions/events_test.ts b/packages/translate/src/messages-via-chat-completions/events_test.ts index 785ec93a62..d78ba4866e 100644 --- a/packages/translate/src/messages-via-chat-completions/events_test.ts +++ b/packages/translate/src/messages-via-chat-completions/events_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { createChatCompletionsToMessagesStreamState, flushChatCompletionsToMessagesEvents, mapChatCompletionsUsageToMessagesUsage, translateChatCompletionsChunkToMessagesEvents } from './events.ts'; -import { assertEquals, assertFalse } from '../test-assert.ts'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; +import { assertEquals, assertFalse } from '@floway-dev/test-utils'; const chunk = (delta: ChatCompletionsStreamEvent['choices'][0]['delta'], finishReason: ChatCompletionsStreamEvent['choices'][0]['finish_reason'] = null): ChatCompletionsStreamEvent => ({ id: 'chatcmpl_test', diff --git a/packages/translate/src/messages-via-chat-completions/request.ts b/packages/translate/src/messages-via-chat-completions/request.ts index a20ce973ba..966301532d 100644 --- a/packages/translate/src/messages-via-chat-completions/request.ts +++ b/packages/translate/src/messages-via-chat-completions/request.ts @@ -1,6 +1,7 @@ import { type ChatCompletionsScalarReasoning, chatCompletionsScalarReasoningFromMessagesBlock } from '../shared/chat-completions-and-messages/reasoning.ts'; -import { openAiJsonSchemaCoreFromMessagesFormat } from '../shared/messages/structured-output.ts'; +import { filterMessagesClientTools } from '../shared/messages-via/client-tools.ts'; import { resolveMessagesReasoningEffort } from '../shared/messages-via/reasoning-effort.ts'; +import { openAiJsonSchemaCoreFromMessagesFormat } from '../shared/messages-via/structured-output.ts'; import { normalizeMessagesToolInputSchema } from '../shared/messages-via/tool-schema.ts'; import { TranslatorInputError } from '../translator-input-error.ts'; import type { ChatCompletionsPayload, ChatCompletionsContentPart, ChatCompletionsMessage, ChatCompletionsTool, ChatCompletionsToolCall } from '@floway-dev/protocols/chat-completions'; @@ -99,7 +100,7 @@ const flushPendingAssistantMessage = (messages: ChatCompletionsMessage[], pendin ...(reasoning ? { reasoning_text: reasoning.reasoningText, - reasoning_opaque: reasoning.hasReasoningOpaque ? reasoning.reasoningOpaque : null, + reasoning_opaque: reasoning.reasoningOpaque, } : {}), }); @@ -109,11 +110,6 @@ const flushPendingAssistantMessage = (messages: ChatCompletionsMessage[], pendin pending.scalarReasoning = null; }; -const getClientTools = (tools?: MessagesPayload['tools']): MessagesClientTool[] | undefined => { - const clientTools = tools?.filter((tool): tool is MessagesClientTool => tool.type === undefined || tool.type === 'custom'); - return clientTools?.length ? clientTools : undefined; -}; - const translateMessagesUser = (message: MessagesUserMessage, messageIdx: number): ChatCompletionsMessage[] => { if (!Array.isArray(message.content)) { return [ @@ -278,7 +274,7 @@ const translateMessagesToolChoice = (toolChoice?: MessagesPayload['tool_choice'] }; export const translateMessagesToChatCompletions = (payload: MessagesPayload): ChatCompletionsPayload => { - const clientTools = getClientTools(payload.tools); + const clientTools = filterMessagesClientTools(payload.tools); // Pass effort through verbatim; per-upstream enum acceptance (e.g. some // backends rejecting `xhigh`/`max`) is the target interceptor's concern. const reasoningEffort = resolveMessagesReasoningEffort(payload); diff --git a/packages/translate/src/messages-via-chat-completions/request_test.ts b/packages/translate/src/messages-via-chat-completions/request_test.ts index a8e8aef912..96445d064f 100644 --- a/packages/translate/src/messages-via-chat-completions/request_test.ts +++ b/packages/translate/src/messages-via-chat-completions/request_test.ts @@ -1,8 +1,8 @@ import { test } from 'vitest'; import { translateMessagesToChatCompletions } from './request.ts'; -import { assertEquals, assertFalse, assertThrows } from '../test-assert.ts'; import type { MessagesAssistantContentBlock, MessagesUserContentBlock } from '@floway-dev/protocols/messages'; +import { assertEquals, assertFalse, assertThrows } from '@floway-dev/test-utils'; test('translateMessagesToChatCompletions maps thinking.disabled to reasoning_effort none', () => { const result = translateMessagesToChatCompletions({ diff --git a/packages/translate/src/messages-via-chat-completions/translate.ts b/packages/translate/src/messages-via-chat-completions/translate.ts index d42259c01b..0eb3efdd34 100644 --- a/packages/translate/src/messages-via-chat-completions/translate.ts +++ b/packages/translate/src/messages-via-chat-completions/translate.ts @@ -1,6 +1,6 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import { rewriteContextExceededToPromptTooLong } from '../shared/messages/context-window-error.ts'; +import { rewriteContextExceededToPromptTooLong } from '../shared/messages-via/context-window-error.ts'; import type { TranslateTrip } from '../types.ts'; import type { ChatCompletionsStreamEvent, ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; diff --git a/packages/translate/src/messages-via-responses/events-protocol_test.ts b/packages/translate/src/messages-via-responses/events-protocol_test.ts index 54715859ab..998781fe1b 100644 --- a/packages/translate/src/messages-via-responses/events-protocol_test.ts +++ b/packages/translate/src/messages-via-responses/events-protocol_test.ts @@ -1,10 +1,10 @@ import { test } from 'vitest'; import { translateToSourceEvents } from './events.ts'; -import { assertEquals, assertRejects } from '../test-assert.ts'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; import { responsesResultToEvents, type ResponsesResult, type ResponsesStreamEvent } from '@floway-dev/protocols/responses'; +import { assertEquals, assertRejects } from '@floway-dev/test-utils'; const makeResponse = (status: ResponsesResult['status']): ResponsesResult => ({ id: 'resp_123', diff --git a/packages/translate/src/messages-via-responses/events.ts b/packages/translate/src/messages-via-responses/events.ts index a7ec1d0b72..7af69248e7 100644 --- a/packages/translate/src/messages-via-responses/events.ts +++ b/packages/translate/src/messages-via-responses/events.ts @@ -1,9 +1,9 @@ -import { isContextExceededError, PROMPT_TOO_LONG_MESSAGE } from '../shared/messages/context-window-error.ts'; import { packReasoningSignature } from '../shared/messages-and-responses/reasoning.ts'; +import { isContextExceededError } from '../shared/messages-via/context-window-error.ts'; import { createResponsesOutputOrderState, recordResponsesOutputOrderEvent, type ResponsesOutputOrderState, shouldDeferForEarlierResponsesOutput } from '../shared/via-responses/responses-stream-order.ts'; import { type ResponsesEvent, responsesPartKey } from '../shared/via-responses/responses-stream.ts'; import { eventFrame, splitCacheWriteTokens, splitInclusiveInputTokens, USAGE_BILLING, type ProtocolFrame } from '@floway-dev/protocols/common'; -import type { MessagesResult, MessagesStreamEvent, MessagesUsage } from '@floway-dev/protocols/messages'; +import { PROMPT_TOO_LONG_MESSAGE, type MessagesResult, type MessagesStreamEvent, type MessagesUsage } from '@floway-dev/protocols/messages'; import type { ResponsesResult, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; const mapResponsesStopReason = (response: ResponsesResult): MessagesResult['stop_reason'] => { diff --git a/packages/translate/src/messages-via-responses/events_test.ts b/packages/translate/src/messages-via-responses/events_test.ts index 1f703eea49..09c4613c37 100644 --- a/packages/translate/src/messages-via-responses/events_test.ts +++ b/packages/translate/src/messages-via-responses/events_test.ts @@ -2,9 +2,9 @@ import { test } from 'vitest'; import { createResponsesToMessagesStreamState, translateResponsesStreamEventToMessagesEvents } from './events.ts'; import { packReasoningSignature } from '../shared/messages-and-responses/reasoning.ts'; -import { assertEquals, assertThrows } from '../test-assert.ts'; import type { MessagesMessageDeltaEvent } from '@floway-dev/protocols/messages'; import type { ResponsesResult } from '@floway-dev/protocols/responses'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; test('Responses reasoning stream without readable summary emits a redacted_thinking carrier', () => { const state = createResponsesToMessagesStreamState(); diff --git a/packages/translate/src/messages-via-responses/request.ts b/packages/translate/src/messages-via-responses/request.ts index aebe9cb6e8..3b747dfc1a 100644 --- a/packages/translate/src/messages-via-responses/request.ts +++ b/packages/translate/src/messages-via-responses/request.ts @@ -1,6 +1,7 @@ -import { openAiJsonSchemaCoreFromMessagesFormat } from '../shared/messages/structured-output.ts'; import { messagesReasoningBlockToResponsesReasoning } from '../shared/messages-and-responses/reasoning.ts'; +import { filterMessagesClientTools } from '../shared/messages-via/client-tools.ts'; import { resolveMessagesReasoningEffort } from '../shared/messages-via/reasoning-effort.ts'; +import { openAiJsonSchemaCoreFromMessagesFormat } from '../shared/messages-via/structured-output.ts'; import { normalizeMessagesToolInputSchema } from '../shared/messages-via/tool-schema.ts'; import { TranslatorInputError } from '../translator-input-error.ts'; import { @@ -70,13 +71,6 @@ const toResponsesStructuredToolOutput = (block: MessagesWebSearchToolResultBlock status: Array.isArray(block.content) ? 'completed' : 'incomplete', }); -const getClientTools = (tools?: MessagesPayload['tools']): MessagesClientTool[] | undefined => { - if (!tools || tools.length === 0) return undefined; - - const clientTools = tools.filter((tool): tool is MessagesClientTool => tool.type === undefined || tool.type === 'custom'); - return clientTools.length > 0 ? clientTools : undefined; -}; - const translateUserMessage = (message: MessagesUserMessage, messageIdx: number): ResponsesInputItem[] => { if (typeof message.content === 'string') { return [{ type: 'message', role: 'user', content: message.content }]; @@ -235,7 +229,7 @@ export const translateMessagesToResponses = (payload: MessagesPayload): Canonica // target-side validation to the selected upstream endpoint. const effort = resolveMessagesReasoningEffort(payload); const reasoning = effort ? { effort } : undefined; - const clientTools = getClientTools(payload.tools); + const clientTools = filterMessagesClientTools(payload.tools); const { instructions, prependItems } = placeMessagesSystem(payload.system); const jsonSchema = openAiJsonSchemaCoreFromMessagesFormat(payload.output_config?.format); const text = jsonSchema ? { format: { type: 'json_schema' as const, ...jsonSchema } } : undefined; diff --git a/packages/translate/src/messages-via-responses/request_test.ts b/packages/translate/src/messages-via-responses/request_test.ts index fde510ba22..1a8f759b7c 100644 --- a/packages/translate/src/messages-via-responses/request_test.ts +++ b/packages/translate/src/messages-via-responses/request_test.ts @@ -2,9 +2,9 @@ import { expect, test } from 'vitest'; import { translateMessagesToResponses } from './request.ts'; import { packReasoningSignature } from '../shared/messages-and-responses/reasoning.ts'; -import { assertEquals, assertFalse, assertThrows } from '../test-assert.ts'; import type { MessagesAssistantContentBlock, MessagesUserContentBlock } from '@floway-dev/protocols/messages'; import type { ResponsesFunctionTool, ResponsesInputReasoning } from '@floway-dev/protocols/responses'; +import { assertEquals, assertFalse, assertThrows } from '@floway-dev/test-utils'; test('translateMessagesToResponses preserves a native thinking signature as encrypted_content with a synthesized id', () => { const result = translateMessagesToResponses({ diff --git a/packages/translate/src/messages-via-responses/translate.ts b/packages/translate/src/messages-via-responses/translate.ts index b6993e3e5b..c015e87976 100644 --- a/packages/translate/src/messages-via-responses/translate.ts +++ b/packages/translate/src/messages-via-responses/translate.ts @@ -1,6 +1,6 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import { rewriteContextExceededToPromptTooLong } from '../shared/messages/context-window-error.ts'; +import { rewriteContextExceededToPromptTooLong } from '../shared/messages-via/context-window-error.ts'; import type { TranslateTrip } from '../types.ts'; import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; import type { CanonicalResponsesPayload, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; diff --git a/packages/translate/src/responses-via-chat-completions/events_test.ts b/packages/translate/src/responses-via-chat-completions/events_test.ts index 1249e3fb5b..be7c84d89a 100644 --- a/packages/translate/src/responses-via-chat-completions/events_test.ts +++ b/packages/translate/src/responses-via-chat-completions/events_test.ts @@ -1,10 +1,10 @@ import { expect, test } from 'vitest'; import { createChatCompletionsToResponsesStreamState, flushChatCompletionsToResponsesEvents, translateChatCompletionsChunkToResponsesEvents, translateToSourceEvents } from './events.ts'; -import { assertEquals, assertRejects } from '../test-assert.ts'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { eventFrame } from '@floway-dev/protocols/common'; import type { ResponsesStreamEvent } from '@floway-dev/protocols/responses'; +import { assertEquals, assertRejects } from '@floway-dev/test-utils'; type ResponsesCompletedEvent = Extract; diff --git a/packages/translate/src/responses-via-chat-completions/request.ts b/packages/translate/src/responses-via-chat-completions/request.ts index 942e3fe972..3231543ffc 100644 --- a/packages/translate/src/responses-via-chat-completions/request.ts +++ b/packages/translate/src/responses-via-chat-completions/request.ts @@ -1,8 +1,8 @@ +import { canonicalizeResponsesPayload } from '../canonicalize-responses-payload.ts'; import { responsesContentToChatCompletionsContent, responsesContentToText } from '../shared/chat-completions-and-responses/content.ts'; import { addResponsesReasoningToChatCompletionsProjection, type ChatCompletionsReasoningProjection, chatCompletionsReasoningProjectionFields, createChatCompletionsReasoningProjection } from '../shared/chat-completions-and-responses/reasoning.ts'; import { buildCustomToolInputSchema } from '../shared/responses-via/custom-tool-wrap.ts'; import { rejectProgramCaller, rejectProgrammaticResponsesPayload } from '../shared/responses-via/programmatic-tooling.ts'; -import { canonicalizeResponsesPayload } from '../shared/via-responses/responses-items.ts'; import { TranslatorInputError } from '../translator-input-error.ts'; import type { ChatCompletionsContentPart, ChatCompletionsPayload, ChatCompletionsMessage, ChatCompletionsTool, ChatCompletionsToolCall } from '@floway-dev/protocols/chat-completions'; import type { ResponsesFunctionCallOutputItem, ResponsesInputImage, ResponsesInputText, ResponsesPayload, ResponsesRequestPayload, ResponsesTool, ResponsesToolChoice } from '@floway-dev/protocols/responses'; diff --git a/packages/translate/src/responses-via-chat-completions/request_test.ts b/packages/translate/src/responses-via-chat-completions/request_test.ts index 39524f8710..6e68db598a 100644 --- a/packages/translate/src/responses-via-chat-completions/request_test.ts +++ b/packages/translate/src/responses-via-chat-completions/request_test.ts @@ -2,8 +2,8 @@ import { test } from 'vitest'; import { translateResponsesToChatCompletions } from './request.ts'; import { createResponsesToChatCompletionsStreamState, translateResponsesEventToChatCompletionsChunks } from '../chat-completions-via-responses/events.ts'; -import { assertEquals, assertThrows } from '../test-assert.ts'; import type { ResponsesAgentMessageContent, ResponsesInputMultiAgentCallOutputItem, ResponsesTool, ResponsesToolChoice } from '@floway-dev/protocols/responses'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; test('translateResponsesToChatCompletions accepts an implicit message discriminator', () => { const result = translateResponsesToChatCompletions({ diff --git a/packages/translate/src/responses-via-messages/events-protocol_test.ts b/packages/translate/src/responses-via-messages/events-protocol_test.ts index 8738967292..8a10c22f74 100644 --- a/packages/translate/src/responses-via-messages/events-protocol_test.ts +++ b/packages/translate/src/responses-via-messages/events-protocol_test.ts @@ -1,9 +1,9 @@ import { test } from 'vitest'; import { translateToSourceEvents } from './events.ts'; -import { assertEquals, assertRejects } from '../test-assert.ts'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; +import { assertEquals, assertRejects } from '@floway-dev/test-utils'; const drain = async (frames: AsyncIterable): Promise => { for await (const _frame of frames) { diff --git a/packages/translate/src/responses-via-messages/events_test.ts b/packages/translate/src/responses-via-messages/events_test.ts index 39d7122abf..87a6efb776 100644 --- a/packages/translate/src/responses-via-messages/events_test.ts +++ b/packages/translate/src/responses-via-messages/events_test.ts @@ -1,10 +1,10 @@ import { expect, test } from 'vitest'; import { createMessagesToResponsesStreamState, translateMessagesEventToResponsesEvents } from './events.ts'; -import { assertEquals } from '../test-assert.ts'; import { USAGE_BILLING } from '@floway-dev/protocols/common'; import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; import type { ResponsesResult, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; +import { assertEquals } from '@floway-dev/test-utils'; type ResponsesOutputItemAddedEvent = Extract; diff --git a/packages/translate/src/responses-via-messages/request.ts b/packages/translate/src/responses-via-messages/request.ts index 1e4cb57150..e5a42adcb8 100644 --- a/packages/translate/src/responses-via-messages/request.ts +++ b/packages/translate/src/responses-via-messages/request.ts @@ -1,10 +1,10 @@ -import { parseToolArgumentsObject } from '../shared/messages/tool-arguments.ts'; +import { canonicalizeResponsesPayload } from '../canonicalize-responses-payload.ts'; import { responsesReasoningToMessagesUpstreamBlock } from '../shared/messages-and-responses/reasoning.ts'; import { buildCustomToolInputSchema } from '../shared/responses-via/custom-tool-wrap.ts'; import { rejectProgramCaller, rejectProgrammaticResponsesPayload } from '../shared/responses-via/programmatic-tooling.ts'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../shared/via-messages/cache-breakpoints.ts'; import { type RemoteImageLoader, resolveImageUrlToMessagesImage, unavailableRemoteImageLoader } from '../shared/via-messages/remote-images.ts'; -import { canonicalizeResponsesPayload } from '../shared/via-responses/responses-items.ts'; +import { parseToolArgumentsObject } from '../shared/via-messages/tool-arguments.ts'; import { TranslatorInputError } from '../translator-input-error.ts'; import { MESSAGES_FALLBACK_MAX_TOKENS, diff --git a/packages/translate/src/responses-via-messages/request_test.ts b/packages/translate/src/responses-via-messages/request_test.ts index 38fdf58931..999837e9b4 100644 --- a/packages/translate/src/responses-via-messages/request_test.ts +++ b/packages/translate/src/responses-via-messages/request_test.ts @@ -1,9 +1,9 @@ import { test } from 'vitest'; import { translateResponsesToMessages } from './request.ts'; -import { assert, assertEquals, assertFalse, assertRejects } from '../test-assert.ts'; import { MESSAGES_FALLBACK_MAX_TOKENS, type MessagesClientTool, type MessagesToolResultBlock, type MessagesUserContentBlock } from '@floway-dev/protocols/messages'; import type { ResponsesAgentMessageContent, ResponsesInputMultiAgentCallOutputItem, ResponsesTool } from '@floway-dev/protocols/responses'; +import { assert, assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; const stubRemoteImageLoader = (result: { mediaType: string | null; data: Uint8Array } | null) => () => Promise.resolve(result); diff --git a/packages/translate/src/shared/AGENTS.md b/packages/translate/src/shared/AGENTS.md index 4ee0922e2c..4939ea9cc3 100644 --- a/packages/translate/src/shared/AGENTS.md +++ b/packages/translate/src/shared/AGENTS.md @@ -15,27 +15,29 @@ flat `.ts` files at the top level of `shared/`. as the target. Example: `via-messages/` is consumed only by `*-via-messages` pairs. 4. **One-protocol-bidirectional, `

/`** — helpers used wherever protocol `P` - appears as either source or target. Example: `messages/tool-arguments.ts` - parses tool-call argument JSON wherever Messages appears. + appears as either source or target. 5. **Two-protocol-bidirectional, `-and-/`** — helpers used by both - `A-via-B` AND `B-via-A`. Example: `chat-completions-and-responses/reasoning.ts` - runs both directions of the Chat Completions ↔ Responses reasoning round trip. + `A-via-B` and `B-via-A`. Example: + `chat-completions-and-responses/reasoning.ts` runs both directions of the + Chat Completions ↔ Responses reasoning round trip. ## Current subdirectories -- `messages/` — helpers used wherever Messages appears (source or target). - `chat-completions-and-responses/` — helpers used by both - `chat-completions-via-responses` AND `responses-via-chat-completions`. + `chat-completions-via-responses` and `responses-via-chat-completions`. - `chat-completions-and-messages/` — helpers used by both - `chat-completions-via-messages` AND `messages-via-chat-completions`. -- `messages-and-responses/` — helpers used by both `messages-via-responses` AND + `chat-completions-via-messages` and `messages-via-chat-completions`. +- `messages-and-responses/` — helpers used by both `messages-via-responses` and `responses-via-messages`. -- `via-responses/` — helpers used by all `*-via-responses` pairs - (target-locked). -- `via-messages/` — helpers used by all `*-via-messages` pairs (target-locked). +- `messages-via/` — helpers used by all `messages-via-*` pairs + (source-locked). - `responses-via/` — helpers used by all `responses-via-*` pairs (source-locked). - `gemini-via/` — helpers used by all `gemini-via-*` pairs (source-locked). +- `via-messages/` — helpers used by all `*-via-messages` pairs + (target-locked). +- `via-responses/` — helpers used by all `*-via-responses` pairs + (target-locked). ## Rules diff --git a/packages/translate/src/shared/chat-completions-and-messages/reasoning.ts b/packages/translate/src/shared/chat-completions-and-messages/reasoning.ts index c2179b3b75..058911f92c 100644 --- a/packages/translate/src/shared/chat-completions-and-messages/reasoning.ts +++ b/packages/translate/src/shared/chat-completions-and-messages/reasoning.ts @@ -3,7 +3,6 @@ import type { MessagesAssistantContentBlock, MessagesRedactedThinkingBlock, Mess export interface ChatCompletionsScalarReasoning { reasoningText: string | null; reasoningOpaque: string | null; - hasReasoningOpaque: boolean; } export const messagesThinkingBlockFromChatCompletionsScalarReasoning = ( @@ -25,8 +24,7 @@ export const chatCompletionsScalarReasoningFromMessagesBlock = (block: MessagesA if (block.type === 'thinking') { return { reasoningText: block.thinking || null, - reasoningOpaque: Object.hasOwn(block, 'signature') ? block.signature ?? null : null, - hasReasoningOpaque: Object.hasOwn(block, 'signature'), + reasoningOpaque: block.signature ?? null, }; } @@ -34,7 +32,6 @@ export const chatCompletionsScalarReasoningFromMessagesBlock = (block: MessagesA ? { reasoningText: null, reasoningOpaque: block.data, - hasReasoningOpaque: true, } : null; }; diff --git a/packages/translate/src/shared/messages-and-responses/reasoning.ts b/packages/translate/src/shared/messages-and-responses/reasoning.ts index 57b3ce6496..80f501da20 100644 --- a/packages/translate/src/shared/messages-and-responses/reasoning.ts +++ b/packages/translate/src/shared/messages-and-responses/reasoning.ts @@ -84,29 +84,9 @@ export const messagesReasoningBlockToResponsesReasoning = (block: MessagesReason /** * Project a Responses reasoning item into a Messages reasoning carrier bound - * for a downstream Messages CLIENT. The id and opaque content are packed into - * the carrier so they survive the round trip. Placement follows readable text: - * readable summary text → `thinking` with the packed value in `signature`; no - * readable text → `redacted_thinking` with the packed value in `data` (Copilot - * rejects `thinking: null` / empty `thinking`). - */ -export const responsesReasoningToMessagesBlock = (item: ResponsesReasoningItem): MessagesReasoningBlock => { - const thinking = item.summary?.length - ? item.summary - .map(part => part.text) - .join('') - .trim() - : ''; - const packed = packReasoningSignature(item.id, item.encrypted_content ?? ''); - - return thinking ? { type: 'thinking', thinking, signature: packed } : { type: 'redacted_thinking', data: packed }; -}; - -/** - * Project a Responses reasoning item into a Messages reasoning carrier bound - * for a real Messages UPSTREAM. Unlike {@link responsesReasoningToMessagesBlock} - * this sends the GENUINE signature only — the upstream owns and validates that - * field, so we never wrap it in a gateway envelope. The opaque + * for a real Messages UPSTREAM. This sends the GENUINE signature only — the + * upstream owns and validates that field, so we never wrap it in a gateway + * envelope. The opaque * `encrypted_content` rides verbatim: as `thinking.signature` when there is * readable text, else as `redacted_thinking.data`. * diff --git a/packages/translate/src/shared/messages-via/client-tools.ts b/packages/translate/src/shared/messages-via/client-tools.ts new file mode 100644 index 0000000000..e83bc0bbc3 --- /dev/null +++ b/packages/translate/src/shared/messages-via/client-tools.ts @@ -0,0 +1,6 @@ +import type { MessagesClientTool, MessagesPayload } from '@floway-dev/protocols/messages'; + +export const filterMessagesClientTools = (tools: MessagesPayload['tools'] | undefined): MessagesClientTool[] | undefined => { + const clientTools = tools?.filter((tool): tool is MessagesClientTool => tool.type === undefined || tool.type === 'custom'); + return clientTools?.length ? clientTools : undefined; +}; diff --git a/packages/translate/src/shared/messages/context-window-error.ts b/packages/translate/src/shared/messages-via/context-window-error.ts similarity index 97% rename from packages/translate/src/shared/messages/context-window-error.ts rename to packages/translate/src/shared/messages-via/context-window-error.ts index aefab6788d..43f08053e3 100644 --- a/packages/translate/src/shared/messages/context-window-error.ts +++ b/packages/translate/src/shared/messages-via/context-window-error.ts @@ -1,8 +1,6 @@ import type { TranslatedApiError } from '../../types.ts'; import { buildPromptTooLongBody } from '@floway-dev/protocols/messages'; -export { PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/messages'; - // Structural + textual detector for context-exceeded error bodies coming from // any OpenAI-shaped upstream. Codes take precedence; message substrings are a // fallback for shapes where the code was renamed or omitted. diff --git a/packages/translate/src/shared/messages/context-window-error_test.ts b/packages/translate/src/shared/messages-via/context-window-error_test.ts similarity index 95% rename from packages/translate/src/shared/messages/context-window-error_test.ts rename to packages/translate/src/shared/messages-via/context-window-error_test.ts index 1bee1b4599..7930a09a59 100644 --- a/packages/translate/src/shared/messages/context-window-error_test.ts +++ b/packages/translate/src/shared/messages-via/context-window-error_test.ts @@ -2,11 +2,10 @@ import { test } from 'vitest'; import { isContextExceededError, - PROMPT_TOO_LONG_MESSAGE, rewriteContextExceededToPromptTooLong, } from './context-window-error.ts'; -import { assert, assertEquals, assertFalse } from '../../test-assert.ts'; -import { buildPromptTooLongBody } from '@floway-dev/protocols/messages'; +import { buildPromptTooLongBody, PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/messages'; +import { assert, assertEquals, assertFalse } from '@floway-dev/test-utils'; test('isContextExceededError — recognizes canonical code strings', () => { assert(isContextExceededError({ code: 'context_length_exceeded' })); diff --git a/packages/translate/src/shared/messages/structured-output.ts b/packages/translate/src/shared/messages-via/structured-output.ts similarity index 100% rename from packages/translate/src/shared/messages/structured-output.ts rename to packages/translate/src/shared/messages-via/structured-output.ts diff --git a/packages/translate/src/shared/responses-via/custom-tool-wrap_test.ts b/packages/translate/src/shared/responses-via/custom-tool-wrap_test.ts index 93636bddca..4871b91b71 100644 --- a/packages/translate/src/shared/responses-via/custom-tool-wrap_test.ts +++ b/packages/translate/src/shared/responses-via/custom-tool-wrap_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { buildCustomToolInputSchema, unwrapCustomToolInput } from './custom-tool-wrap.ts'; -import { assertEquals } from '../../test-assert.ts'; +import { assertEquals } from '@floway-dev/test-utils'; // ── buildCustomToolInputSchema ── diff --git a/packages/translate/src/shared/responses-via/programmatic-tooling.ts b/packages/translate/src/shared/responses-via/programmatic-tooling.ts index b30bc9c5b1..45a5e5c4d7 100644 --- a/packages/translate/src/shared/responses-via/programmatic-tooling.ts +++ b/packages/translate/src/shared/responses-via/programmatic-tooling.ts @@ -1,22 +1,6 @@ import { TranslatorInputError } from '../../translator-input-error.ts'; import type { ResponsesInputItem, ResponsesPayload } from '@floway-dev/protocols/responses'; -export const requiresNativeResponses = (payload: ResponsesPayload): boolean => { - const toolChoice = payload.tool_choice; - return Array.isArray(payload.input) && payload.input.some(item => - item.type === 'additional_tools' - || item.type === 'program' - || item.type === 'program_output' - || item.type === 'agent_message' - || item.type === 'multi_agent_call' - || item.type === 'multi_agent_call_output' - || item.type === 'context_compaction' - || isProgramCaller(item)) - || payload.tools?.some(hasProgrammaticCaller) === true - || payload.tools?.some(hasDeferredTool) === true - || toolChoice !== null && typeof toolChoice === 'object' && toolChoice.type === 'programmatic_tool_calling'; -}; - export const rejectProgrammaticResponsesPayload = (payload: ResponsesPayload, target: string): void => { const toolChoice = payload.tool_choice; if (payload.tools?.some(hasProgrammaticCaller) === true || (toolChoice !== null && typeof toolChoice === 'object' && toolChoice.type === 'programmatic_tool_calling')) { diff --git a/packages/translate/src/shared/via-messages/cache-breakpoints_test.ts b/packages/translate/src/shared/via-messages/cache-breakpoints_test.ts index 13e151ee96..a76c206aa0 100644 --- a/packages/translate/src/shared/via-messages/cache-breakpoints_test.ts +++ b/packages/translate/src/shared/via-messages/cache-breakpoints_test.ts @@ -1,8 +1,8 @@ import { test } from 'vitest'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from './cache-breakpoints.ts'; -import { assert, assertEquals } from '../../test-assert.ts'; import type { MessagesAssistantMessage, MessagesMessage, MessagesTextBlock, MessagesTool, MessagesUserMessage } from '@floway-dev/protocols/messages'; +import { assert, assertEquals } from '@floway-dev/test-utils'; const cacheControlOf = (value: unknown): unknown => (value as { cache_control?: unknown }).cache_control; diff --git a/packages/translate/src/shared/messages/tool-arguments.ts b/packages/translate/src/shared/via-messages/tool-arguments.ts similarity index 100% rename from packages/translate/src/shared/messages/tool-arguments.ts rename to packages/translate/src/shared/via-messages/tool-arguments.ts diff --git a/packages/translate/src/shared/via-responses/responses-items.ts b/packages/translate/src/shared/via-responses/responses-items.ts deleted file mode 100644 index 226a8303a6..0000000000 --- a/packages/translate/src/shared/via-responses/responses-items.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { TranslatorInputError } from '../../translator-input-error.ts'; -import { parseToolArgumentsObject } from '../messages/tool-arguments.ts'; -import { responsesReasoningToMessagesBlock, unpackReasoningSignature } from '../messages-and-responses/reasoning.ts'; -import type { ChatCompletionsReasoningItem, ChatCompletionsMessage } from '@floway-dev/protocols/chat-completions'; -import type { GeminiContent } from '@floway-dev/protocols/gemini'; -import type { MessagesAssistantContentBlock, MessagesMessage } from '@floway-dev/protocols/messages'; -import type { CanonicalResponsesPayload, ResponsesEasyInputMessage, ResponsesInputItem, ResponsesRequestPayload } from '@floway-dev/protocols/responses'; - -// Wire `ResponsesRequestPayload.input` accepts a bare string and EasyInputMessage -// objects whose `type: "message"` discriminator is omitted. The gateway's -// canonical internal shape is an explicitly discriminated item array: every -// consumer past HTTP / WS entry normalization or cross-protocol translation -// sees `type: "message"` on every message. -// Lifts a wire `ResponsesRequestPayload` to canonical form. Called at every wire -// boundary that produces a payload destined for internal use and by direct -// Responses-source translators; cross-protocol translators already construct -// `CanonicalResponsesPayload` with explicit message discriminators. -export function canonicalizeResponsesPayload(value: unknown): CanonicalResponsesPayload { - const hasValidPromptCacheBreakpoint = (content: Record): boolean => { - const breakpoint = content.prompt_cache_breakpoint; - if (breakpoint === undefined || breakpoint === null) return true; - return typeof breakpoint === 'object' - && typeof (breakpoint as Record).mode === 'string'; - }; - - const isImplicitEasyInputMessage = (item: unknown): item is ResponsesEasyInputMessage & { type?: undefined } => { - if (typeof item !== 'object' || item === null) return false; - const message = item as Record; - if (message.type !== undefined) return false; - if (message.role !== 'user' && message.role !== 'assistant' && message.role !== 'system' && message.role !== 'developer') return false; - if (message.phase !== undefined && message.phase !== null && typeof message.phase !== 'string') return false; - return typeof message.content === 'string' - || (Array.isArray(message.content) && message.content.every(part => { - if (typeof part !== 'object' || part === null) return false; - const content = part as Record; - switch (content.type) { - case 'input_text': - case 'output_text': - return typeof content.text === 'string' && hasValidPromptCacheBreakpoint(content); - case 'input_image': - return (typeof content.image_url === 'string' || typeof content.file_id === 'string') - && typeof content.detail === 'string' - && hasValidPromptCacheBreakpoint(content); - case 'input_file': - return hasValidPromptCacheBreakpoint(content); - default: - return false; - } - })); - }; - - if (typeof value !== 'object' || value === null) { - throw new TranslatorInputError('Responses payload must be an object.'); - } - const payload = value as ResponsesRequestPayload; - const input: unknown = payload.input; - if (typeof input !== 'string' && !Array.isArray(input)) { - throw new TranslatorInputError('Responses input must be a string or an array.', { param: 'input' }); - } - return { - ...payload, - input: typeof input === 'string' - ? [{ type: 'message', role: 'user', content: input }] - : input.map((item, index) => { - if (isImplicitEasyInputMessage(item)) return { ...item, type: 'message' }; - if (typeof item !== 'object' || item === null || (item as { type?: unknown }).type === undefined) { - throw new TranslatorInputError('Untyped Responses input items require a valid role and content.', { param: `input[${index}]` }); - } - return item as ResponsesInputItem; - }), - }; -} - -export type ResponsesItemMapper = ( - item: ResponsesInputItem, -) => ResponsesInputItem | null | Promise; - -export type ResponsesItemVisitor = (item: ResponsesInputItem) => void | Promise; - -// A view onto a source protocol that projects Responses items in and out -// of the source's payload. Visit is read-only iteration; map is 1-to-1 -// rewrite or 1-to-null drop. -// -// `mapAsResponsesItems` ownership invariant: callers pass items they own — -// the per-attempt `structuredClone` of the payload is the sole isolation — -// so the mapper builds fresh container arrays/objects but reuses input -// elements directly and must not defensively deep-clone them. -// -// The mapped form of a source-items type is always its source minus the -// top-level `readonly`: the view owns the per-attempt payload clone, so it -// hands back a freely-mutable container. The mapped type is therefore derived -// rather than carried as a second generic. -type Mutable = T extends readonly (infer E)[] ? E[] : T; - -export interface ResponsesItemsView { - visitAsResponsesItems(sourceItems: TSourceItems, visitor: ResponsesItemVisitor): Promise; - mapAsResponsesItems(sourceItems: TSourceItems, mapper: ResponsesItemMapper): Promise>; -} - -// --------------------------------------------------------------------------- -// Responses source -// --------------------------------------------------------------------------- - -export const responsesItemsView = { - visitAsResponsesItems: async ( - input: readonly ResponsesInputItem[], - visitor: ResponsesItemVisitor, - ): Promise => { - for (const item of input) await visitor(item); - }, - mapAsResponsesItems: async ( - input: readonly ResponsesInputItem[], - mapper: ResponsesItemMapper, - ): Promise => { - const out: ResponsesInputItem[] = []; - for (const item of input) { - const mapped = await mapper(item); - if (mapped !== null) out.push(mapped); - } - return out; - }, -} satisfies ResponsesItemsView; - -// --------------------------------------------------------------------------- -// Messages source -// --------------------------------------------------------------------------- - -export const messagesViaResponsesItemsView = { - visitAsResponsesItems: async ( - messages: readonly MessagesMessage[], - visitor: ResponsesItemVisitor, - ): Promise => { - for (const message of messages) { - if (message.role !== 'assistant' || !Array.isArray(message.content)) continue; - - for (const block of message.content) { - const carrier = reasoningCarrier(block); - if (carrier === null) continue; - - await visitor({ - type: 'reasoning', - id: carrier.id, - summary: carrier.thinking ? [{ type: 'summary_text', text: carrier.thinking }] : [], - ...(carrier.encryptedContent ? { encrypted_content: carrier.encryptedContent } : {}), - }); - } - } - }, - mapAsResponsesItems: async ( - messages: readonly MessagesMessage[], - mapper: ResponsesItemMapper, - ): Promise => { - const out: MessagesMessage[] = []; - for (const message of messages) { - if (message.role !== 'assistant' || !Array.isArray(message.content)) { - out.push(message); - continue; - } - - const content: MessagesAssistantContentBlock[] = []; - for (const block of message.content) { - const carrier = reasoningCarrier(block); - if (carrier === null) { - content.push(block); - continue; - } - - const mapped = await mapper({ - type: 'reasoning', - id: carrier.id, - summary: carrier.thinking ? [{ type: 'summary_text', text: carrier.thinking }] : [], - ...(carrier.encryptedContent ? { encrypted_content: carrier.encryptedContent } : {}), - }); - if (mapped === null) continue; - const projected = responsesItemToMessagesAssistantBlock(mapped); - if (projected !== null) content.push(projected); - } - - out.push({ role: 'assistant', content }); - } - return out; - }, -} satisfies ResponsesItemsView; - -// A reasoning block echoed back by a Messages client carries the packed -// `${encrypted_content}@${id}` value in `thinking.signature` or -// `redacted_thinking.data`. Native signatures without `@` expose no ID and are -// left untouched. -const reasoningCarrier = (block: MessagesAssistantContentBlock): { id: string; encryptedContent: string; thinking: string } | null => { - const carrier = block.type === 'thinking' ? block.signature : block.type === 'redacted_thinking' ? block.data : undefined; - if (carrier === undefined) return null; - - const { id, encryptedContent } = unpackReasoningSignature(carrier); - if (id === null) return null; - - return { id, encryptedContent, thinking: block.type === 'thinking' ? block.thinking : '' }; -}; - -const responsesItemToMessagesAssistantBlock = (item: ResponsesInputItem): MessagesAssistantContentBlock | null => { - switch (item.type) { - case 'reasoning': - return responsesReasoningToMessagesBlock(item); - case 'message': { - if (item.role !== 'assistant') return null; - const text = typeof item.content === 'string' - ? item.content - : item.content.filter((part): part is Extract => 'text' in part).map(part => part.text).join(''); - return text ? { type: 'text', text } : null; - } - case 'function_call': - return { type: 'tool_use', id: item.call_id, name: item.name, input: parseToolArgumentsObject(item.arguments) }; - case 'custom_tool_call': - return { type: 'tool_use', id: item.call_id, name: item.name, input: { input: item.input } }; - default: - throw new Error(`Cannot project Responses ${item.type} item into a Messages assistant content block`); - } -}; - -// --------------------------------------------------------------------------- -// Chat Completions source -// --------------------------------------------------------------------------- - -export const chatCompletionsViaResponsesItemsView = { - visitAsResponsesItems: async ( - messages: readonly ChatCompletionsMessage[], - visitor: ResponsesItemVisitor, - ): Promise => { - for (const message of messages) { - if (message.role !== 'assistant' || !message.reasoning_items?.length) continue; - - for (const item of message.reasoning_items) { - if (!item.id) continue; - await visitor({ type: 'reasoning', id: item.id, summary: item.summary ?? [] }); - } - } - }, - mapAsResponsesItems: async ( - messages: readonly ChatCompletionsMessage[], - mapper: ResponsesItemMapper, - ): Promise => { - const out: ChatCompletionsMessage[] = []; - for (const message of messages) { - if (message.role !== 'assistant' || !message.reasoning_items?.length) { - out.push(message); - continue; - } - - const reasoningItems: ChatCompletionsReasoningItem[] = []; - for (const item of message.reasoning_items) { - if (!item.id) { - reasoningItems.push(item); - continue; - } - const mapped = await mapper({ type: 'reasoning', id: item.id, summary: item.summary ?? [] }); - if (mapped === null) continue; - if (mapped.type !== 'reasoning') throw new Error(`Cannot project Responses ${mapped.type} item into Chat reasoning_items`); - reasoningItems.push({ type: 'reasoning', id: mapped.id, summary: mapped.summary }); - } - - out.push({ - ...message, - reasoning_items: reasoningItems.length > 0 ? reasoningItems : null, - }); - } - return out; - }, -} satisfies ResponsesItemsView; - -// --------------------------------------------------------------------------- -// Gemini source -// --------------------------------------------------------------------------- - -// Placeholder view. Gemini does not yet have a reasoning-id / signature -// carrier in its protocol, so there is nothing to project. The empty -// implementations let `gemini/serve.ts` go through the uniform stored-items -// ceremony without branching on protocol; when Gemini gains signature -// support, fill these in and the rest of the pipeline keeps working. -export const geminiViaResponsesItemsView = { - visitAsResponsesItems: async ( - _contents: readonly GeminiContent[], - _visitor: ResponsesItemVisitor, - ): Promise => {}, - mapAsResponsesItems: async ( - contents: readonly GeminiContent[], - _mapper: ResponsesItemMapper, - ): Promise => [...contents], -} satisfies ResponsesItemsView; diff --git a/packages/translate/src/shared/via-responses/responses-items_test.ts b/packages/translate/src/shared/via-responses/responses-items_test.ts deleted file mode 100644 index f689b7b5e9..0000000000 --- a/packages/translate/src/shared/via-responses/responses-items_test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { test } from 'vitest'; - -import { chatCompletionsViaResponsesItemsView, canonicalizeResponsesPayload, geminiViaResponsesItemsView, messagesViaResponsesItemsView, responsesItemsView } from './responses-items.ts'; -import { assertEquals, assertThrows } from '../../test-assert.ts'; -import { TranslatorInputError } from '../../translator-input-error.ts'; -import { packReasoningSignature } from '../messages-and-responses/reasoning.ts'; -import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; -import type { GeminiPayload } from '@floway-dev/protocols/gemini'; -import type { MessagesPayload } from '@floway-dev/protocols/messages'; -import type { ResponsesInputItem, ResponsesPayload } from '@floway-dev/protocols/responses'; - -test('canonicalizes string and implicit-message wire inputs', () => { - assertEquals(canonicalizeResponsesPayload({ model: 'gpt-test', input: 'hello' }), { - model: 'gpt-test', - input: [{ type: 'message', role: 'user', content: 'hello' }], - }); - - assertEquals(canonicalizeResponsesPayload({ - model: 'gpt-test', - input: [ - { role: 'system', content: 'rules', phase: 'future_phase' }, - { - role: 'user', - content: [ - { type: 'input_text', text: 'look', prompt_cache_breakpoint: { mode: 'future_mode' } }, - { type: 'input_image', file_id: 'file_1', detail: 'original', prompt_cache_breakpoint: { mode: 'explicit' } }, - { type: 'input_file', file_id: 'file_2', prompt_cache_breakpoint: { mode: 'explicit' } }, - ], - }, - { type: 'message', role: 'user', content: 'hello' }, - { type: 'function_call_output', call_id: 'call_1', output: 'result' }, - ], - }), { - model: 'gpt-test', - input: [ - { type: 'message', role: 'system', content: 'rules', phase: 'future_phase' }, - { - type: 'message', - role: 'user', - content: [ - { type: 'input_text', text: 'look', prompt_cache_breakpoint: { mode: 'future_mode' } }, - { type: 'input_image', file_id: 'file_1', detail: 'original', prompt_cache_breakpoint: { mode: 'explicit' } }, - { type: 'input_file', file_id: 'file_2', prompt_cache_breakpoint: { mode: 'explicit' } }, - ], - }, - { type: 'message', role: 'user', content: 'hello' }, - { type: 'function_call_output', call_id: 'call_1', output: 'result' }, - ], - }); -}); - -test('rejects malformed untyped input items at the canonical boundary', () => { - for (const malformed of [ - null, - 42, - { content: 'missing role' }, - { role: 'unknown', content: 'invalid role' }, - { role: 'user', content: [null] }, - { role: 'user', content: [{}] }, - { role: 'user', content: [{ type: 'input_text' }] }, - { role: 'user', content: [{ type: 'input_text', text: 'invalid breakpoint', prompt_cache_breakpoint: {} }] }, - { role: 'user', content: 'invalid phase', phase: 42 }, - ]) { - const error = assertThrows( - () => canonicalizeResponsesPayload({ - model: 'gpt-test', - input: [malformed] as unknown as ResponsesPayload['input'], - }), - TranslatorInputError, - 'valid role and content', - ) as TranslatorInputError; - assertEquals(error.param, 'input[0]'); - } -}); - -test('mapAsResponsesItems maps Responses input items through the callback', async () => { - const payload = canonicalizeResponsesPayload({ - model: 'gpt-test', - input: [ - { type: 'item_reference', id: 'msg_stored' }, - { type: 'reasoning', id: 'rs_stored', summary: [{ type: 'summary_text', text: 'trace' }] }, - { type: 'function_call', call_id: 'call_stored', name: 'lookup', arguments: '{}', status: 'completed' }, - ], - }); - - const mapped = await responsesItemsView.mapAsResponsesItems(payload.input, item => { - if (item.type === 'item_reference') return { type: 'message', role: 'user', content: 'expanded' }; - if (item.type === 'reasoning') return { ...item, id: 'rs_next' }; - if (item.type === 'function_call') return null; - return item; - }); - - assertEquals(mapped, [ - { type: 'message', role: 'user', content: 'expanded' }, - { type: 'reasoning', id: 'rs_next', summary: [{ type: 'summary_text', text: 'trace' }] }, - ]); - assertEquals(payload.input[0], { type: 'item_reference', id: 'msg_stored' }); -}); - -test('mapAsResponsesItems maps only Messages thinking blocks with gateway reasoning signatures', async () => { - const payload: MessagesPayload = { - model: 'claude-test', - max_tokens: 256, - messages: [ - { - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'trace', signature: packReasoningSignature('rs_stored', '') }, - { type: 'thinking', thinking: 'ordinary', signature: 'provider-signature' }, - { type: 'text', text: 'visible' }, - ], - }, - ], - }; - - const mapped = await messagesViaResponsesItemsView.mapAsResponsesItems(payload.messages, item => { - if (item.type !== 'reasoning') return item; - return { ...item, id: 'rs_next', summary: [{ type: 'summary_text', text: 'rewritten' }] }; - }); - - assertEquals(mapped, [ - { - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'rewritten', signature: packReasoningSignature('rs_next', '') }, - { type: 'thinking', thinking: 'ordinary', signature: 'provider-signature' }, - { type: 'text', text: 'visible' }, - ], - }, - ]); - assertEquals(payload.messages[0], { - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'trace', signature: packReasoningSignature('rs_stored', '') }, - { type: 'thinking', thinking: 'ordinary', signature: 'provider-signature' }, - { type: 'text', text: 'visible' }, - ], - }); -}); - -test('visitAsResponsesItems scans Messages carriers without rebuilding source messages', async () => { - const messages: MessagesPayload['messages'] = [ - { - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'trace', signature: packReasoningSignature('rs_stored', '') }, - { type: 'thinking', thinking: 'ordinary', signature: 'provider-signature' }, - { type: 'text', text: 'visible' }, - ], - }, - ]; - const visited: ResponsesInputItem[] = []; - - const result = await messagesViaResponsesItemsView.visitAsResponsesItems(messages, item => { - visited.push(item); - }); - - assertEquals(result, undefined); - assertEquals(visited, [ - { type: 'reasoning', id: 'rs_stored', summary: [{ type: 'summary_text', text: 'trace' }] }, - ]); - assertEquals(messages[0], { - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'trace', signature: packReasoningSignature('rs_stored', '') }, - { type: 'thinking', thinking: 'ordinary', signature: 'provider-signature' }, - { type: 'text', text: 'visible' }, - ], - }); -}); - -test('mapAsResponsesItems can drop carried Messages reasoning without touching other content', async () => { - const messages: MessagesPayload['messages'] = [ - { - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'trace', signature: packReasoningSignature('rs_stored', '') }, - { type: 'text', text: 'visible' }, - ], - }, - ]; - - const mapped = await messagesViaResponsesItemsView.mapAsResponsesItems(messages, item => (item.type === 'reasoning' ? null : item)); - - assertEquals(mapped, [ - { - role: 'assistant', - content: [{ type: 'text', text: 'visible' }], - }, - ]); -}); - -test('mapAsResponsesItems maps Chat reasoning_items and leaves non-carriers unchanged', async () => { - const payload: ChatCompletionsPayload = { - model: 'gpt-test', - messages: [ - { role: 'system', content: 'keep system' }, - { - role: 'assistant', - content: null, - reasoning_items: [{ type: 'reasoning', id: 'rs_stored', summary: [{ type: 'summary_text', text: 'trace' }] }], - tool_calls: [{ id: 'call_stored', type: 'function', function: { name: 'lookup', arguments: '{}' } }], - }, - { role: 'tool', tool_call_id: 'call_stored', content: '42' }, - ], - }; - - const mapped = await chatCompletionsViaResponsesItemsView.mapAsResponsesItems(payload.messages, item => { - if (item.type !== 'reasoning') return item; - return { ...item, id: 'rs_next', summary: [{ type: 'summary_text', text: 'next' }] }; - }); - - assertEquals(mapped, [ - { role: 'system', content: 'keep system' }, - { - role: 'assistant', - content: null, - reasoning_items: [ - { type: 'reasoning', id: 'rs_next', summary: [{ type: 'summary_text', text: 'next' }] }, - ], - tool_calls: [{ id: 'call_stored', type: 'function', function: { name: 'lookup', arguments: '{}' } }], - }, - { role: 'tool', tool_call_id: 'call_stored', content: '42' }, - ]); -}); - -test('mapAsResponsesItems does not treat Gemini thought signatures as Responses carriers', async () => { - const payload: GeminiPayload = { - contents: [ - { - role: 'model', - parts: [ - { text: 'trace', thought: true, thoughtSignature: packReasoningSignature('rs_not_supported', '') }, - { functionCall: { id: 'call_stored', name: 'lookup', args: { q: 'x' } } }, - ], - }, - ], - }; - - let calls = 0; - const mapped = await geminiViaResponsesItemsView.mapAsResponsesItems(payload.contents!, item => { - calls += 1; - return item; - }); - - assertEquals(calls, 0); - assertEquals(mapped, payload.contents); - assertEquals(mapped === payload.contents, false); -}); - -test('canonicalizeResponsesPayload preserves reasoning.context verbatim, including future modes', () => { - const canonicalCurrent = canonicalizeResponsesPayload({ - model: 'gpt-test', - input: [{ type: 'message', role: 'user', content: 'hi' }], - reasoning: { effort: 'high', context: 'current_turn' }, - }); - assertEquals(canonicalCurrent.reasoning, { effort: 'high', context: 'current_turn' }); - - // An unknown/future context string rides through the wire→canonical boundary - // untouched — the upstream owns the accept/reject decision. - const canonicalFuture = canonicalizeResponsesPayload({ - model: 'gpt-test', - input: 'hi', - reasoning: { context: 'future_mode' }, - }); - assertEquals(canonicalFuture.reasoning, { context: 'future_mode' }); -}); diff --git a/packages/translate/src/test-assert.ts b/packages/translate/src/test-assert.ts deleted file mode 100644 index 62421bf289..0000000000 --- a/packages/translate/src/test-assert.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect } from 'vitest'; - -type ErrorConstructor = new (...args: never[]) => Error; - -export function assert(value: unknown, message?: string): asserts value { - expect(Boolean(value), message).toBe(true); -} - -export function assertEquals(actual: unknown, expected: unknown, message?: string): void { - expect(actual, message).toEqual(expected); -} - -export function assertFalse(value: unknown, message?: string): void { - expect(Boolean(value), message).toBe(false); -} - -export function assertExists(value: T, message?: string): asserts value is NonNullable { - expect(value, message).not.toBeNull(); - expect(value, message).not.toBeUndefined(); -} - -export function assertStringIncludes(actual: string, expected: string, message?: string): void { - expect(actual, message).toContain(expected); -} - -export function assertAlmostEquals(actual: number, expected: number, tolerance = 1e-7, message?: string): void { - expect(Math.abs(actual - expected), message).toBeLessThanOrEqual(tolerance); -} - -export function assertThrows(fn: () => unknown, errorClass?: ErrorConstructor, messageIncludes?: string, message?: string): Error { - try { - fn(); - } catch (error) { - assertExpectedError(error, errorClass, messageIncludes, message); - return error as Error; - } - - throw new Error(message ?? 'Expected function to throw'); -} - -export async function assertRejects(fn: () => Promise | unknown, errorClass?: ErrorConstructor, messageIncludes?: string, message?: string): Promise { - try { - await fn(); - } catch (error) { - assertExpectedError(error, errorClass, messageIncludes, message); - return error as Error; - } - - throw new Error(message ?? 'Expected promise to reject'); -} - -const assertExpectedError = (error: unknown, errorClass?: ErrorConstructor, messageIncludes?: string, message?: string): void => { - if (errorClass !== undefined) { - expect(error, message).toBeInstanceOf(errorClass); - } - - if (messageIncludes !== undefined) { - expect(error instanceof Error ? error.message : String(error), message).toContain(messageIncludes); - } -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0435dd2ce1..d2da335931 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,9 +55,6 @@ importers: jsonc-parser: specifier: ^3.3.1 version: 3.3.1 - sql.js: - specifier: ^1.14.1 - version: 1.14.1 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -82,9 +79,6 @@ importers: '@floway-dev/platform': specifier: workspace:* version: link:../../packages/platform - '@floway-dev/protocols': - specifier: workspace:* - version: link:../../packages/protocols hono: specifier: ^4 version: 4.12.22 @@ -104,9 +98,6 @@ importers: '@floway-dev/platform': specifier: workspace:* version: link:../../packages/platform - '@floway-dev/protocols': - specifier: workspace:* - version: link:../../packages/protocols '@hono/node-server': specifier: ^2.0.4 version: 2.0.4(hono@4.12.22) @@ -324,6 +315,9 @@ importers: '@floway-dev/test-utils': specifier: workspace:* version: link:../test-utils + sql.js: + specifier: ^1.14.1 + version: 1.14.1 packages/http: dependencies: @@ -334,9 +328,6 @@ importers: specifier: 0.1.2 version: 0.1.2(patch_hash=8ed07af54e914cbcc2cea19ce7109635092cbbe35b9d2ad1bf55e3dfef1b8fd6) devDependencies: - '@cloudflare/workers-types': - specifier: ^4.20251215.0 - version: 4.20260606.1 '@types/node': specifier: ^22 version: 22.19.19 @@ -368,9 +359,6 @@ importers: packages/provider: dependencies: - '@floway-dev/interceptor': - specifier: workspace:* - version: link:../interceptor '@floway-dev/platform': specifier: workspace:* version: link:../platform @@ -384,9 +372,6 @@ importers: packages/provider-azure: dependencies: - '@floway-dev/interceptor': - specifier: workspace:* - version: link:../interceptor '@floway-dev/protocols': specifier: workspace:* version: link:../protocols @@ -454,9 +439,6 @@ importers: packages/provider-custom: dependencies: - '@floway-dev/interceptor': - specifier: workspace:* - version: link:../interceptor '@floway-dev/protocols': specifier: workspace:* version: link:../protocols @@ -496,9 +478,6 @@ importers: specifier: 0.1.2 version: 0.1.2(patch_hash=8ed07af54e914cbcc2cea19ce7109635092cbbe35b9d2ad1bf55e3dfef1b8fd6) devDependencies: - '@cloudflare/workers-types': - specifier: ^4.20251215.0 - version: 4.20260606.1 '@types/node': specifier: ^22 version: 22.19.19 @@ -508,9 +487,6 @@ importers: packages/test-utils: dependencies: - '@floway-dev/protocols': - specifier: workspace:* - version: link:../protocols '@floway-dev/provider': specifier: workspace:* version: link:../provider @@ -520,6 +496,10 @@ importers: '@floway-dev/protocols': specifier: workspace:* version: link:../protocols + devDependencies: + '@floway-dev/test-utils': + specifier: workspace:* + version: link:../test-utils packages/ui: dependencies: @@ -3996,7 +3976,8 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260405.1': optional: true - '@cloudflare/workers-types@4.20260606.1': {} + '@cloudflare/workers-types@4.20260606.1': + optional: true '@cspotcode/source-map-support@0.8.1': dependencies: diff --git a/vitest.config.ts b/vitest.config.ts index 6f1f437172..f903894d2c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,24 +2,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - projects: [ - 'apps/platform-cloudflare/vitest.config.ts', - 'apps/platform-node/vitest.config.ts', - 'apps/web/vitest.config.ts', - 'packages/agent-setup/vitest.config.ts', - 'packages/gateway/vitest.config.ts', - 'packages/http/vitest.config.ts', - 'packages/platform/vitest.config.ts', - 'packages/protocols/vitest.config.ts', - 'packages/provider/vitest.config.ts', - 'packages/proxy/vitest.config.ts', - 'packages/translate/vitest.config.ts', - 'packages/interceptor/vitest.config.ts', - 'packages/provider-azure/vitest.config.ts', - 'packages/provider-claude-code/vitest.config.ts', - 'packages/provider-codex/vitest.config.ts', - 'packages/provider-copilot/vitest.config.ts', - 'packages/provider-custom/vitest.config.ts', - ], + projects: ['packages/*/vitest.config.ts', 'apps/*/vitest.config.ts'], }, }); From c1709a4ae102d2236da91181e1443964e2124bf5 Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 27 Jul 2026 00:18:15 +0800 Subject: [PATCH 2/7] refactor: recompose packages around their actual owners Second audit round over the backend tree (directory structure, concept naming, identifier names). Every directory and concept was reviewed independently by two models; a decider merged the reports into eleven work items partitioned by exclusive file ownership, and one implementer executed each. Behavior is unchanged. Wire protocols, HTTP status/headers/bodies, routing outcomes, persisted names, migrations, affinity bytes, provider account state, and the frontend contract are all untouched; the round rejected every proposal that could not prove that property. What moved: - Backend scripts, package Vitest configs, and Agent Setup installer fragments gain checked sources of truth. The fragment inventory lives only in the generator, and the installer harness now executes the same bytes the routes serve instead of rebuilding a second ordering. - The gateway core is recomposed around its consumers: the provider registry splits into composition, catalog, and resolution; the request context moves out of the chat subtree; affinity absorbs candidate narrowing; scheduled cleanup leaves the repository layer. - Protocols own their vocabulary and codecs, with the pricing engine in its own module and the identity pricing aliases removed. Providers take back their vendor wire knowledge. The interceptor's second slot is named for what it carries. - Platform implementations are named after the contracts they implement. - Architecture specifications are rewritten against the resulting tree. --- .../skills/audit-copilot-workarounds/SKILL.md | 59 +- .../skills/backfill-model-pricing/SKILL.md | 57 +- .../skills/fetching-models-pricing/SKILL.md | 24 +- .agents/skills/probing-copilot/SKILL.md | 144 +- AGENTS.md | 220 +-- README.md | 23 +- apps/platform-cloudflare/src/bootstrap.ts | 12 +- .../src/broadcast-do_test.ts | 5 +- .../platform-cloudflare/src/cf-websocket.d.ts | 4 + ...er.ts => durable-object-channel-broker.ts} | 2 +- ... => durable-object-channel-broker_test.ts} | 4 +- ...image-cache.ts => kv-image-cache-store.ts} | 2 +- ...e_test.ts => kv-image-cache-store_test.ts} | 14 +- .../src/{tls-trust.ts => runtime-root-cas.ts} | 0 ...trust_test.ts => runtime-root-cas_test.ts} | 2 +- apps/platform-cloudflare/vitest.config.ts | 1 - apps/platform-node/src/bootstrap.ts | 6 +- apps/platform-node/src/migrate.ts | 9 +- apps/platform-node/src/migrate_test.ts | 41 +- .../src/{tls-trust.ts => runtime-root-cas.ts} | 4 +- ...trust_test.ts => runtime-root-cas_test.ts} | 2 +- ...e-cache.ts => sqlite-image-cache-store.ts} | 2 +- ...st.ts => sqlite-image-cache-store_test.ts} | 8 +- apps/platform-node/vitest.config.ts | 1 - docker/Dockerfile | 1 - docs/AFFINITY.md | 116 +- docs/RESOLUTION.md | 800 +++++------ docs/TRANSLATION.md | 687 ++++----- eslint.config.ts | 8 +- package.json | 9 +- .../agent-setup/installers/bash/common/cli.sh | 70 + .../installers/bash/common/helpers.sh | 237 ---- .../agent-setup/installers/bash/common/jq.sh | 79 ++ .../installers/bash/common/main.sh | 11 + .../installers/bash/common/managed-file.sh | 32 + .../installers/bash/common/output.sh | 12 - .../installers/bash/common/process.sh | 53 + .../installers/powershell/common/cli.ps1 | 94 ++ .../installers/powershell/common/helpers.ps1 | 269 ---- .../powershell/common/json-document.ps1 | 16 + .../installers/powershell/common/main.ps1 | 6 + .../powershell/common/managed-file.ps1 | 62 + .../installers/powershell/common/platform.ps1 | 19 + .../installers/powershell/common/process.ps1 | 68 + packages/agent-setup/package.json | 5 +- .../agent-setup/scripts/generate-assets.ts | 142 +- .../agent-setup/scripts/test-installers.ts | 112 +- .../agent-setup/src/configuration_test.ts | 121 ++ packages/agent-setup/src/render.ts | 3 +- packages/agent-setup/src/render_test.ts | 127 +- packages/agent-setup/src/routes_test.ts | 33 +- .../src/script-assets.generated.ts | 71 +- packages/agent-setup/src/script-assets.ts | 19 +- .../agent-setup/src/script-assets_test.ts | 11 + packages/agent-setup/src/wire_test.ts | 44 + packages/agent-setup/tsconfig.scripts.json | 7 + packages/gateway/package.json | 5 +- packages/gateway/src/app-control_test.ts | 181 +-- .../src/control-plane/agent-setup_test.ts | 2 +- .../src/control-plane/api-keys/routes.ts | 7 +- .../src/control-plane/api-keys/routes_test.ts | 2 +- .../gateway/src/control-plane/auth/routes.ts | 5 +- .../src/control-plane/auth/routes_test.ts | 247 +--- .../src/control-plane/data-transfer/routes.ts | 87 +- .../data-transfer/routes_test.ts | 106 +- .../gateway/src/control-plane/dump_test.ts | 6 +- .../src/control-plane/model-aliases/routes.ts | 5 +- .../model-aliases/routes_test.ts | 2 +- .../src/control-plane/models/routes.ts | 15 +- .../src/control-plane/models/routes_test.ts | 2 +- .../control-plane/performance/routes_test.ts | 2 +- .../src/control-plane/proxies/routes_test.ts | 2 +- packages/gateway/src/control-plane/routes.ts | 36 +- packages/gateway/src/control-plane/schemas.ts | 18 +- .../src/control-plane/search-config/routes.ts | 16 +- .../search-config/routes_test.ts | 6 +- .../control-plane/search-usage/aggregate.ts | 18 +- .../src/control-plane/search-usage/routes.ts | 27 +- .../control-plane/search-usage/routes_test.ts | 22 +- .../src/control-plane/shared/sort-order.ts | 2 + .../{api-keys => shared}/upstream-ids.ts | 19 +- .../control-plane/{ => shared}/usage-view.ts | 2 +- .../control-plane/shared/warm-models-cache.ts | 26 + .../src/control-plane/token-usage/routes.ts | 2 +- .../control-plane/token-usage/routes_test.ts | 178 ++- .../control-plane/upstreams/claude-code.ts | 268 ++++ .../src/control-plane/upstreams/codex.ts | 156 +++ .../upstreams/copilot-device-login_test.ts | 245 ++++ .../src/control-plane/upstreams/copilot.ts | 142 ++ .../src/control-plane/upstreams/models.ts | 109 ++ .../upstreams/proxy-resolution.ts | 20 +- .../src/control-plane/upstreams/routes.ts | 707 +--------- .../control-plane/upstreams/routes_test.ts | 2 +- .../src/control-plane/upstreams/shared.ts | 6 + .../gateway/src/control-plane/users/routes.ts | 14 +- .../src/control-plane/users/routes_test.ts | 2 +- .../src/data-plane/alpha-search/routes.ts | 10 +- .../data-plane/alpha-search/routes_test.ts | 40 +- .../audio/{transcriptions.ts => http.ts} | 4 +- .../{transcriptions_test.ts => http_test.ts} | 2 +- .../gateway/src/data-plane/audio/respond.ts | 17 +- .../gateway/src/data-plane/audio/usage.ts | 100 ++ .../src/data-plane/audio/usage_test.ts | 91 ++ .../chat/chat-completions/affinity/ingress.ts | 2 +- .../chat-completions/affinity/ingress_test.ts | 6 +- .../chat/chat-completions/attempt_test.ts | 4 +- .../chat/chat-completions/errors.ts | 24 +- .../data-plane/chat/chat-completions/http.ts | 7 +- .../chat/chat-completions/http_test.ts | 6 +- .../apply-role-compatibility_test.ts | 2 +- ...le-reasoning-on-forced-tool-choice_test.ts | 2 +- .../include-usage-stream-options_test.ts | 2 +- .../chat-completions/interceptors/index.ts | 4 +- .../interceptors/normalize-usage_test.ts | 2 +- .../strip-prompt-cache-key_test.ts | 2 +- .../chat-completions/interceptors/types.ts | 2 +- .../interceptors/vendor-deepseek-normalize.ts | 2 +- .../vendor-deepseek-normalize_test.ts | 32 +- .../vendor-kimi-normalize_test.ts | 2 +- .../vendor-qwen-normalize_test.ts | 2 +- .../chat/chat-completions/respond.ts | 8 +- .../data-plane/chat/chat-completions/serve.ts | 12 +- .../chat/chat-completions/serve_test.ts | 10 +- .../data-plane/chat/gemini/affinity/egress.ts | 14 +- .../chat/gemini/affinity/ingress.ts | 2 +- .../chat/gemini/affinity/ingress_test.ts | 4 +- .../src/data-plane/chat/gemini/attempt.ts | 4 +- .../data-plane/chat/gemini/attempt_test.ts | 4 +- .../src/data-plane/chat/gemini/errors.ts | 2 - .../src/data-plane/chat/gemini/http.ts | 7 +- .../src/data-plane/chat/gemini/http_test.ts | 6 +- .../strip-safety-settings_test.ts | 2 +- .../strip-unsupported-part-fields_test.ts | 2 +- .../strip-unsupported-tools_test.ts | 2 +- .../suppress-thought-parts_test.ts | 2 +- .../chat/gemini/interceptors/types.ts | 2 +- .../src/data-plane/chat/gemini/respond.ts | 8 +- .../data-plane/chat/gemini/respond_test.ts | 2 +- .../src/data-plane/chat/gemini/serve.ts | 20 +- .../src/data-plane/chat/gemini/serve_test.ts | 10 +- .../chat/messages/affinity/ingress.ts | 2 +- .../chat/messages/affinity/ingress_test.ts | 4 +- .../src/data-plane/chat/messages/attempt.ts | 2 +- .../data-plane/chat/messages/attempt_test.ts | 4 +- .../src/data-plane/chat/messages/errors.ts | 2 - .../src/data-plane/chat/messages/http.ts | 7 +- .../src/data-plane/chat/messages/http_test.ts | 6 +- .../apply-role-compatibility_test.ts | 2 +- ...le-reasoning-on-forced-tool-choice_test.ts | 2 +- .../strip-billing-attribution_test.ts | 2 +- .../chat/messages/interceptors/types.ts | 2 +- .../messages/interceptors/web-search-shim.ts | 10 +- .../interceptors/web-search-shim_test.ts | 10 +- .../src/data-plane/chat/messages/respond.ts | 78 +- .../data-plane/chat/messages/respond_test.ts | 540 +------ .../src/data-plane/chat/messages/serve.ts | 20 +- .../data-plane/chat/messages/serve_test.ts | 12 +- .../src/data-plane/chat/messages/usage.ts | 66 + .../data-plane/chat/messages/usage_test.ts | 542 ++++++++ .../affinity/copilot-roundtrip_test.ts | 8 +- .../chat/responses/affinity/ingress.ts | 4 +- .../chat/responses/affinity/ingress_test.ts | 14 +- .../chat/responses/affinity/roundtrip_test.ts | 12 +- .../src/data-plane/chat/responses/attempt.ts | 2 +- .../data-plane/chat/responses/attempt_test.ts | 12 +- .../chat/responses/client-output.ts | 3 +- .../src/data-plane/chat/responses/errors.ts | 24 +- .../data-plane/chat/responses/errors_test.ts | 11 +- .../src/data-plane/chat/responses/http.ts | 5 +- .../data-plane/chat/responses/http_test.ts | 6 +- .../apply-role-compatibility_test.ts | 2 +- .../interceptors/compact-shim_test.ts | 2 +- ...disable-reasoning-on-forced-tool-choice.ts | 2 +- ...le-reasoning-on-forced-tool-choice_test.ts | 2 +- .../chat/responses/interceptors/index.ts | 4 +- .../interceptors/retry-cyber-policy.ts | 2 +- .../interceptors/retry-cyber-policy_test.ts | 2 +- .../interceptors/server-tool-shim.ts | 2 +- .../interceptors/server-tool-shim_test.ts | 18 +- .../image-generation-integration_test.ts | 6 +- .../server-tools/image-generation.ts | 8 +- .../server-tools/image-generation_test.ts | 2 +- .../interceptors/server-tools/web-search.ts | 12 +- .../server-tools/web-search_test.ts | 34 +- .../interceptors/strip-prompt-cache-key.ts | 2 +- .../strip-prompt-cache-key_test.ts | 2 +- .../interceptors/vendor-deepseek-normalize.ts | 8 +- .../vendor-deepseek-normalize_test.ts | 10 +- .../interceptors/vendor-qwen-normalize.ts | 2 +- .../vendor-qwen-normalize_test.ts | 2 +- .../chat/responses/items/identity.ts | 2 +- .../chat/responses/items/identity_test.ts | 8 +- .../data-plane/chat/responses/items/output.ts | 4 +- .../data-plane/chat/responses/items/store.ts | 20 +- .../chat/responses/items/store_test.ts | 10 +- .../src/data-plane/chat/responses/respond.ts | 8 +- .../data-plane/chat/responses/serve-prep.ts | 16 +- .../data-plane/chat/responses/serve_test.ts | 10 +- .../data-plane/chat/responses/websocket.ts | 8 +- .../chat/responses/websocket_test.ts | 11 +- .../chat/shared/affinity/candidate_test.ts | 57 +- .../data-plane/chat/shared/affinity/index.ts | 35 +- .../src/data-plane/chat/shared/errors.ts | 33 +- .../src/data-plane/chat/shared/errors_test.ts | 1 - .../src/data-plane/chat/shared/gateway-ctx.ts | 113 +- .../chat/shared/provider-stream-result.ts | 4 +- .../shared/provider-stream-result_test.ts | 2 +- .../src/data-plane/chat/shared/respond.ts | 2 +- .../data-plane/chat/shared/respond_test.ts | 188 +-- .../src/data-plane/chat/shared/routing.ts | 9 - .../chat/shared/target-picker_test.ts | 4 +- .../gateway/src/data-plane/codex/routes.ts | 2 +- .../data-plane/codex/routes_images_test.ts | 2 +- .../src/data-plane/codex/routes_test.ts | 2 +- .../data-plane/codex/routes_websocket_test.ts | 2 +- .../src/data-plane/completions/http.ts | 4 +- .../src/data-plane/completions/http_test.ts | 2 +- .../gateway/src/data-plane/embeddings/http.ts | 6 +- .../src/data-plane/embeddings/http_test.ts | 2 +- .../src/data-plane/embeddings/usage.ts | 10 + .../gateway/src/data-plane/images/http.ts | 4 +- .../src/data-plane/images/http_test.ts | 2 +- .../src/data-plane/models/gemini_test.ts | 2 +- .../data-plane/models/{serve.ts => http.ts} | 0 .../models/{serve_test.ts => http_test.ts} | 4 +- .../src/data-plane/providers/catalog.ts | 219 +++ .../src/data-plane/providers/catalog_test.ts | 632 +++++++++ .../providers/custom-provider_test.ts | 446 ------ .../src/data-plane/providers/models-cache.ts | 2 +- .../data-plane/providers/models-cache_test.ts | 2 +- .../src/data-plane/providers/registry.ts | 495 +------ .../src/data-plane/providers/registry_test.ts | 1236 +---------------- .../src/data-plane/providers/resolution.ts | 244 ++++ .../data-plane/providers/resolution_test.ts | 606 ++++++++ .../gateway/src/data-plane/rerank/attempt.ts | 42 + .../gateway/src/data-plane/rerank/serve.ts | 50 +- .../src/data-plane/rerank/serve_test.ts | 2 +- packages/gateway/src/data-plane/routes.ts | 4 +- .../src/data-plane/shared/gateway-ctx.ts | 111 ++ .../{chat => }/shared/gateway-ctx_test.ts | 4 +- .../data-plane/shared/iterate-candidates.ts | 10 +- .../shared/iterate-candidates_test.ts | 6 +- .../data-plane/shared/listing/addressable.ts | 13 +- .../shared/listing/addressable_test.ts | 2 +- .../src/data-plane/shared/listing/alias.ts | 4 +- .../data-plane/shared/passthrough-attempt.ts | 2 +- .../data-plane/shared/passthrough-serve.ts | 12 +- .../shared/passthrough-serve_test.ts | 2 +- .../{chat => }/shared/request-body.ts | 0 .../{chat => }/shared/request-body_test.ts | 0 .../{chat/shared/stream => shared}/sse.ts | 0 .../shared/stream => shared}/sse_test.ts | 2 +- .../shared/telemetry/attribution.ts | 6 +- .../shared/telemetry/performance.ts | 2 +- .../shared/telemetry/performance_test.ts | 94 +- .../src/data-plane/shared/telemetry/settle.ts | 2 +- .../shared/telemetry/settle_test.ts | 105 ++ .../src/data-plane/shared/telemetry/usage.ts | 95 +- .../data-plane/shared/telemetry/usage_test.ts | 91 +- .../src/data-plane/{chat => }/shared/text.ts | 0 .../src/data-plane/shared/text_test.ts | 33 + .../shared/upstream-call-options.ts | 4 +- .../tools/web-search/alpha-search/upstream.ts | 8 +- .../{search-config.ts => config.ts} | 24 +- .../{search-config_test.ts => config_test.ts} | 68 +- .../data-plane/tools/web-search/fetch-page.ts | 4 +- .../tools/web-search/fetch-page_test.ts | 12 +- .../data-plane/tools/web-search/operations.ts | 6 +- .../data-plane/tools/web-search/provider.ts | 22 +- .../tools/web-search/provider_test.ts | 40 +- .../tools/web-search/providers/jina.ts | 30 +- .../providers/microsoft-grounding.ts | 37 +- .../tools/web-search/providers/shared.ts | 27 +- .../tools/web-search/providers/tavily.ts | 18 +- .../src/data-plane/tools/web-search/search.ts | 6 +- .../tools/web-search/search_test.ts | 28 +- .../src/data-plane/tools/web-search/types.ts | 10 +- .../src/data-plane/tools/web-search/usage.ts | 10 +- packages/gateway/src/dial/fetcher.ts | 166 +-- packages/gateway/src/dial/fetcher_test.ts | 3 +- packages/gateway/src/dial/per-request.ts | 25 +- packages/gateway/src/dial/proxy-catalog.ts | 39 + .../gateway/src/dial/replayable-request.ts | 151 ++ packages/gateway/src/dump/accumulator.ts | 2 +- packages/gateway/src/index.ts | 3 +- packages/gateway/src/middleware/auth_test.ts | 2 +- packages/gateway/src/migrations-dir.ts | 1 + packages/gateway/src/repo/dump-store_test.ts | 2 +- packages/gateway/src/repo/memory.ts | 43 +- packages/gateway/src/repo/migrations_test.ts | 26 + .../gateway/src/repo/model-aliases_test.ts | 59 +- .../src/repo/model-pricing-migration_test.ts | 259 ---- .../gateway/src/repo/responses-items_test.ts | 2 +- .../gateway/src/repo/responses-state-sql.ts | 9 +- .../gateway/src/repo/search-usage_test.ts | 42 +- packages/gateway/src/repo/seed-admin.ts | 1 + packages/gateway/src/repo/sql-batch.ts | 10 + packages/gateway/src/repo/sql.ts | 51 +- packages/gateway/src/repo/types.ts | 30 +- packages/gateway/src/repo/upstreams_test.ts | 302 +++- .../is-production-request.ts | 0 packages/gateway/src/scheduled.ts | 4 +- .../{repo => scheduled}/expiration-sweeps.ts | 6 +- .../expiration-sweeps_test.ts | 14 +- .../src/{repo => scheduled}/spilled-files.ts | 2 +- packages/gateway/src/scheduled_test.ts | 2 +- .../src/shared/web-search-providers.ts | 2 +- .../{test-helpers.ts => test-utils/app.ts} | 24 +- .../background-tracker.ts | 0 .../gateway-ctx.ts | 3 +- packages/gateway/vitest.config.ts | 1 - packages/gateway/vitest.setup.ts | 2 +- packages/http/package.json | 4 - packages/http/src/parser.ts | 50 +- packages/http/src/parser_test.ts | 16 - packages/http/src/read-head-section.ts | 40 + packages/http/src/read-head-section_test.ts | 47 + packages/http/src/ws-upgrade.ts | 45 +- packages/http/tsconfig.json | 5 +- packages/interceptor/src/index.ts | 34 +- packages/interceptor/src/index_test.ts | 12 +- packages/interceptor/tsconfig.json | 2 +- packages/platform/src/background.ts | 6 +- packages/platform/src/image-processor.ts | 11 +- packages/platform/tsconfig.json | 3 - packages/protocols/package.json | 1 - packages/protocols/src/audio/index.ts | 37 +- .../protocols/src/chat-completions/stream.ts | 2 +- packages/protocols/src/common/aliases.ts | 3 +- packages/protocols/src/common/endpoints.ts | 33 +- .../protocols/src/common/endpoints_test.ts | 10 +- packages/protocols/src/common/index.ts | 3 +- packages/protocols/src/common/models.ts | 428 +----- .../protocols/src/common/opaque-value_test.ts | 43 + .../src/common/{stream => }/parse-sse.ts | 2 +- .../src/common/{stream => }/parse-sse_test.ts | 0 packages/protocols/src/common/pricing.ts | 401 ++++++ .../{models_test.ts => pricing_test.ts} | 17 +- packages/protocols/src/gemini/field-keys.ts | 7 + packages/protocols/src/gemini/index.ts | 1 + packages/protocols/src/gemini/reassemble.ts | 18 +- .../protocols/src/gemini/reassemble_test.ts | 139 ++ .../protocols/src/gemini/to-result_test.ts | 147 +- packages/protocols/src/images/index.ts | 28 +- packages/protocols/src/messages/index.ts | 28 +- packages/protocols/src/messages/reassemble.ts | 6 +- packages/protocols/src/messages/stream.ts | 2 +- packages/protocols/src/messages/usage.ts | 24 + .../protocols/src/rerank/default-paths.ts | 18 + .../src/rerank/default-paths_test.ts | 14 + packages/protocols/src/rerank/index.ts | 50 +- packages/protocols/src/rerank/translate.ts | 19 +- .../protocols/src/rerank/translate_test.ts | 13 +- packages/protocols/src/rerank/types.ts | 40 - packages/protocols/src/responses/compact.ts | 50 + .../{index_test.ts => compact_test.ts} | 2 +- packages/protocols/src/responses/index.ts | 53 +- packages/protocols/src/responses/stream.ts | 2 +- packages/provider-azure/src/config.ts | 50 +- packages/provider-azure/src/config_test.ts | 2 +- packages/provider-azure/src/endpoint.ts | 80 ++ packages/provider-azure/src/fetch.ts | 36 +- packages/provider-azure/src/index.ts | 4 +- packages/provider-azure/src/provider.ts | 2 +- packages/provider-azure/src/provider_test.ts | 2 +- packages/provider-azure/tsconfig.json | 2 +- ...{access-token-cache.ts => access-token.ts} | 2 +- ...ken-cache_test.ts => access-token_test.ts} | 4 +- .../src/auth/identity_test.ts | 4 +- .../src/auth/import_test.ts | 4 +- .../provider-claude-code/src/auth/oauth.ts | 12 +- .../provider-claude-code/src/detection.ts | 10 +- .../src/detection_test.ts | 37 +- packages/provider-claude-code/src/fetch.ts | 16 +- .../provider-claude-code/src/fetch_test.ts | 11 +- packages/provider-claude-code/src/index.ts | 12 +- .../messages/backfill-required-fields.ts | 6 +- .../messages/backfill-required-fields_test.ts | 4 +- .../messages/hoist-user-system-to-messages.ts | 6 +- .../hoist-user-system-to-messages_test.ts | 4 +- .../src/interceptors/messages/index.ts | 8 +- .../messages/inject-billing-block.ts | 8 +- .../messages/inject-billing-block_test.ts | 4 +- .../messages/inject-default-template.ts | 8 +- .../messages/inject-default-template_test.ts | 6 +- .../messages/inject-identity-block.ts | 8 +- .../messages/inject-identity-block_test.ts | 6 +- .../messages/synthesize-metadata-user-id.ts | 6 +- .../synthesize-metadata-user-id_test.ts | 8 +- .../messages}/system-blocks.ts | 2 +- .../messages}/system-blocks_test.ts | 2 +- .../src/interceptors/messages/types.ts | 2 +- packages/provider-claude-code/src/models.ts | 5 +- .../provider-claude-code/src/models_test.ts | 2 +- packages/provider-claude-code/src/pricing.ts | 4 +- packages/provider-claude-code/src/provider.ts | 22 +- .../provider-claude-code/src/provider_test.ts | 2 +- packages/provider-claude-code/src/state.ts | 10 +- packages/provider-claude-code/src/types.ts | 9 - .../src/{auth => }/usage-probe.ts | 2 +- .../src/{auth => }/usage-probe_test.ts | 0 packages/provider-claude-code/tsconfig.json | 2 +- ...{access-token-cache.ts => access-token.ts} | 18 +- ...ken-cache_test.ts => access-token_test.ts} | 2 +- packages/provider-codex/src/auth/import.ts | 2 +- packages/provider-codex/src/auth/oauth.ts | 23 +- .../provider-codex/src/auth/oauth_test.ts | 8 +- packages/provider-codex/src/fetch.ts | 7 +- packages/provider-codex/src/index.ts | 4 +- .../responses/action-pivot_test.ts | 2 +- .../responses/inject-default-instructions.ts | 2 +- .../responses/strip-unsupported-fields.ts | 2 +- packages/provider-codex/src/pricing.ts | 52 +- packages/provider-codex/src/provider.ts | 24 +- packages/provider-codex/src/provider_test.ts | 2 +- packages/provider-codex/src/quota.ts | 18 +- packages/provider-codex/src/state.ts | 18 +- packages/provider-codex/tsconfig.json | 2 +- packages/provider-copilot/src/auth.ts | 25 - .../src/compaction.ts | 6 +- .../provider-copilot/src/compaction_test.ts | 2 +- packages/provider-copilot/src/index.ts | 8 +- .../abort-on-tool-argument-whitespace.ts | 2 +- .../attach-cache-control-markers.ts | 2 +- .../chat-completions/compress-images.ts | 5 +- .../interceptors/chat-completions/index.ts | 2 +- .../chat-completions/set-initiator-header.ts | 2 +- .../chat-completions/set-vision-header.ts | 2 +- .../src/interceptors/image-compression.ts | 53 + .../messages/align-context-management-beta.ts | 9 +- .../messages/apply-top-level-cache-control.ts | 2 +- .../interceptors/messages/compress-images.ts | 10 +- .../messages/filter-anthropic-beta-header.ts | 14 +- .../messages/handle-speed-fast.ts | 2 +- .../src/interceptors/messages/index.ts | 20 +- .../messages/promote-thinking-display.ts | 2 +- .../messages/rewrite-context-window-error.ts | 2 +- .../messages/set-claude-agent-headers.ts | 2 +- .../messages/set-compact-headers.ts | 2 +- .../messages/set-initiator-header.ts | 10 +- .../messages/set-interaction-id-header.ts | 2 +- .../messages/set-vision-header.ts | 12 +- .../strip-cache-control-extensions.ts | 2 +- .../messages/strip-eager-input-streaming.ts | 2 +- .../strip-structured-output-format.ts | 2 +- .../messages/strip-tool-strict.ts | 2 +- .../src/interceptors/messages/types.ts | 11 +- .../abort-on-tool-argument-whitespace.ts | 2 +- .../responses/action-pivot_test.ts | 2 +- .../interceptors/responses/compress-images.ts | 5 +- .../responses/force-store-false.ts | 2 +- .../responses/item-id-membrane.ts | 12 +- .../responses/set-initiator-header.ts | 2 +- .../responses/set-vision-header.ts | 2 +- .../responses/strip-image-generation.ts | 2 +- .../responses/strip-service-tier.ts | 2 +- packages/provider-copilot/src/model-name.ts | 7 +- .../provider-copilot/src/model-selection.ts | 5 +- packages/provider-copilot/src/pricing.ts | 56 +- packages/provider-copilot/src/provider.ts | 15 +- .../provider-copilot/src/provider_test.ts | 4 +- packages/provider-copilot/tsconfig.json | 2 +- packages/provider-custom/src/config.ts | 8 +- packages/provider-custom/src/defaults.ts | 2 +- packages/provider-custom/src/fetch.ts | 29 +- packages/provider-custom/src/fetch_test.ts | 86 +- packages/provider-custom/src/index.ts | 2 +- .../src/infer-endpoints_test.ts | 43 +- packages/provider-custom/src/provider.ts | 2 +- packages/provider-custom/src/provider_test.ts | 174 ++- packages/provider-custom/tsconfig.json | 2 +- packages/provider-ollama/src/fetch-models.ts | 21 +- .../provider-ollama/src/fetch-models_test.ts | 6 - packages/provider-ollama/src/index.ts | 2 +- packages/provider-ollama/src/pricing.ts | 8 +- packages/provider-ollama/src/provider.ts | 2 +- packages/provider-ollama/tsconfig.json | 2 +- packages/provider/package.json | 1 - packages/provider/src/image-helpers.ts | 57 - packages/provider/src/index.ts | 17 +- packages/provider/src/invocation.ts | 10 +- packages/provider/src/invocation_test.ts | 2 +- packages/provider/src/model-config.ts | 4 +- packages/provider/src/model.ts | 47 +- packages/provider/src/provider.ts | 2 +- packages/provider/src/result.ts | 27 +- packages/provider/src/telemetry.ts | 40 + packages/provider/tsconfig.json | 2 +- packages/proxy/package.json | 4 - packages/proxy/src/bytes.ts | 8 +- packages/proxy/src/dial-target.ts | 82 ++ packages/proxy/src/dialer.ts | 3 +- packages/proxy/src/protocols/http-connect.ts | 2 +- packages/proxy/src/protocols/reality.ts | 2 +- .../proxy/src/protocols/shadowsocks-2022.ts | 2 +- packages/proxy/src/protocols/shadowsocks.ts | 2 +- packages/proxy/src/protocols/socks5.ts | 2 +- packages/proxy/src/protocols/trojan.ts | 2 +- packages/proxy/src/protocols/vless.ts | 2 +- packages/proxy/src/types.ts | 86 +- packages/proxy/src/url-kind_test.ts | 5 +- packages/proxy/tsconfig.json | 5 +- packages/test-utils/src/assert.ts | 4 - packages/test-utils/src/index.ts | 1 - packages/test-utils/src/stubs.ts | 4 +- .../chat-completions-via-messages/events.ts | 12 +- .../chat-completions-via-messages/request.ts | 21 +- .../request_test.ts | 166 +-- .../translate.ts | 3 +- .../chat-completions-via-responses/events.ts | 38 +- .../events_test.ts | 907 +++++++++++- .../chat-completions-via-responses/request.ts | 4 +- .../request_test.ts | 275 +--- .../src/gemini-via-messages/events.ts | 11 +- .../src/gemini-via-messages/request.ts | 73 +- .../src/gemini-via-responses/events.ts | 30 +- .../src/gemini-via-responses/request.ts | 3 +- packages/translate/src/index.ts | 3 +- .../messages-via-chat-completions/request.ts | 27 +- .../request_test.ts | 130 +- .../src/messages-via-responses/events.ts | 52 +- .../src/messages-via-responses/request.ts | 27 +- .../messages-via-responses/request_test.ts | 146 +- .../events_test.ts | 206 +++ .../responses-via-chat-completions/request.ts | 6 +- .../request_test.ts | 1084 +-------------- .../src/responses-via-messages/events.ts | 11 +- .../src/responses-via-messages/request.ts | 24 +- .../responses-via-messages/request_test.ts | 213 ++- .../src/responses-via-messages/translate.ts | 3 +- packages/translate/src/shared/AGENTS.md | 66 +- .../translate/src/shared/gemini-via/gemini.ts | 2 - .../src/shared/messages-via/service-tier.ts | 8 + .../src/shared/messages-via/tool-result.ts | 14 + .../programmatic-tooling_test.ts | 78 ++ .../shared/via-messages/cache-breakpoints.ts | 13 +- .../src/shared/via-messages/remote-images.ts | 8 +- .../src/shared/via-messages/service-tier.ts | 15 + .../src/shared/via-messages/usage.ts | 25 + .../shared/via-responses/responses-stream.ts | 4 - packages/translate/src/types.ts | 7 + pnpm-lock.yaml | 23 +- scripts/check-wrangler.ts | 3 +- tsconfig.base.json | 3 +- tsconfig.scripts.json | 7 + wrangler.example.jsonc | 10 +- 546 files changed, 11401 insertions(+), 11593 deletions(-) rename apps/platform-cloudflare/src/{do-channel-broker.ts => durable-object-channel-broker.ts} (98%) rename apps/platform-cloudflare/src/{do-channel-broker_test.ts => durable-object-channel-broker_test.ts} (98%) rename apps/platform-cloudflare/src/{kv-image-cache.ts => kv-image-cache-store.ts} (97%) rename apps/platform-cloudflare/src/{kv-image-cache_test.ts => kv-image-cache-store_test.ts} (89%) rename apps/platform-cloudflare/src/{tls-trust.ts => runtime-root-cas.ts} (100%) rename apps/platform-cloudflare/src/{tls-trust_test.ts => runtime-root-cas_test.ts} (77%) rename apps/platform-node/src/{tls-trust.ts => runtime-root-cas.ts} (60%) rename apps/platform-node/src/{tls-trust_test.ts => runtime-root-cas_test.ts} (87%) rename apps/platform-node/src/{sqlite-image-cache.ts => sqlite-image-cache-store.ts} (96%) rename apps/platform-node/src/{sqlite-image-cache_test.ts => sqlite-image-cache-store_test.ts} (93%) create mode 100644 packages/agent-setup/installers/bash/common/cli.sh delete mode 100644 packages/agent-setup/installers/bash/common/helpers.sh create mode 100644 packages/agent-setup/installers/bash/common/jq.sh create mode 100644 packages/agent-setup/installers/bash/common/managed-file.sh create mode 100644 packages/agent-setup/installers/bash/common/process.sh create mode 100644 packages/agent-setup/installers/powershell/common/cli.ps1 delete mode 100644 packages/agent-setup/installers/powershell/common/helpers.ps1 create mode 100644 packages/agent-setup/installers/powershell/common/json-document.ps1 create mode 100644 packages/agent-setup/installers/powershell/common/managed-file.ps1 create mode 100644 packages/agent-setup/installers/powershell/common/platform.ps1 create mode 100644 packages/agent-setup/installers/powershell/common/process.ps1 create mode 100644 packages/agent-setup/src/configuration_test.ts create mode 100644 packages/agent-setup/src/script-assets_test.ts create mode 100644 packages/agent-setup/src/wire_test.ts create mode 100644 packages/agent-setup/tsconfig.scripts.json create mode 100644 packages/gateway/src/control-plane/shared/sort-order.ts rename packages/gateway/src/control-plane/{api-keys => shared}/upstream-ids.ts (56%) rename packages/gateway/src/control-plane/{ => shared}/usage-view.ts (92%) create mode 100644 packages/gateway/src/control-plane/shared/warm-models-cache.ts create mode 100644 packages/gateway/src/control-plane/upstreams/claude-code.ts create mode 100644 packages/gateway/src/control-plane/upstreams/codex.ts create mode 100644 packages/gateway/src/control-plane/upstreams/copilot-device-login_test.ts create mode 100644 packages/gateway/src/control-plane/upstreams/copilot.ts create mode 100644 packages/gateway/src/control-plane/upstreams/models.ts create mode 100644 packages/gateway/src/control-plane/upstreams/shared.ts rename packages/gateway/src/data-plane/audio/{transcriptions.ts => http.ts} (96%) rename packages/gateway/src/data-plane/audio/{transcriptions_test.ts => http_test.ts} (99%) create mode 100644 packages/gateway/src/data-plane/audio/usage.ts create mode 100644 packages/gateway/src/data-plane/audio/usage_test.ts create mode 100644 packages/gateway/src/data-plane/chat/messages/usage.ts create mode 100644 packages/gateway/src/data-plane/chat/messages/usage_test.ts delete mode 100644 packages/gateway/src/data-plane/chat/shared/routing.ts create mode 100644 packages/gateway/src/data-plane/embeddings/usage.ts rename packages/gateway/src/data-plane/models/{serve.ts => http.ts} (100%) rename packages/gateway/src/data-plane/models/{serve_test.ts => http_test.ts} (99%) create mode 100644 packages/gateway/src/data-plane/providers/catalog.ts create mode 100644 packages/gateway/src/data-plane/providers/catalog_test.ts delete mode 100644 packages/gateway/src/data-plane/providers/custom-provider_test.ts create mode 100644 packages/gateway/src/data-plane/providers/resolution.ts create mode 100644 packages/gateway/src/data-plane/providers/resolution_test.ts create mode 100644 packages/gateway/src/data-plane/rerank/attempt.ts create mode 100644 packages/gateway/src/data-plane/shared/gateway-ctx.ts rename packages/gateway/src/data-plane/{chat => }/shared/gateway-ctx_test.ts (98%) rename packages/gateway/src/data-plane/{chat => }/shared/request-body.ts (100%) rename packages/gateway/src/data-plane/{chat => }/shared/request-body_test.ts (100%) rename packages/gateway/src/data-plane/{chat/shared/stream => shared}/sse.ts (100%) rename packages/gateway/src/data-plane/{chat/shared/stream => shared}/sse_test.ts (99%) create mode 100644 packages/gateway/src/data-plane/shared/telemetry/settle_test.ts rename packages/gateway/src/data-plane/{chat => }/shared/text.ts (100%) create mode 100644 packages/gateway/src/data-plane/shared/text_test.ts rename packages/gateway/src/data-plane/tools/web-search/{search-config.ts => config.ts} (77%) rename packages/gateway/src/data-plane/tools/web-search/{search-config_test.ts => config_test.ts} (71%) create mode 100644 packages/gateway/src/dial/proxy-catalog.ts create mode 100644 packages/gateway/src/dial/replayable-request.ts create mode 100644 packages/gateway/src/migrations-dir.ts create mode 100644 packages/gateway/src/repo/migrations_test.ts delete mode 100644 packages/gateway/src/repo/model-pricing-migration_test.ts create mode 100644 packages/gateway/src/repo/seed-admin.ts create mode 100644 packages/gateway/src/repo/sql-batch.ts rename packages/gateway/src/{shared => runtime}/is-production-request.ts (100%) rename packages/gateway/src/{repo => scheduled}/expiration-sweeps.ts (95%) rename packages/gateway/src/{repo => scheduled}/expiration-sweeps_test.ts (97%) rename packages/gateway/src/{repo => scheduled}/spilled-files.ts (94%) rename packages/gateway/src/{test-helpers.ts => test-utils/app.ts} (94%) rename packages/gateway/src/{test-helpers => test-utils}/background-tracker.ts (100%) rename packages/gateway/src/{test-helpers => test-utils}/gateway-ctx.ts (92%) create mode 100644 packages/http/src/read-head-section.ts create mode 100644 packages/http/src/read-head-section_test.ts create mode 100644 packages/protocols/src/common/opaque-value_test.ts rename packages/protocols/src/common/{stream => }/parse-sse.ts (97%) rename packages/protocols/src/common/{stream => }/parse-sse_test.ts (100%) create mode 100644 packages/protocols/src/common/pricing.ts rename packages/protocols/src/common/{models_test.ts => pricing_test.ts} (95%) create mode 100644 packages/protocols/src/gemini/field-keys.ts create mode 100644 packages/protocols/src/gemini/reassemble_test.ts create mode 100644 packages/protocols/src/rerank/default-paths.ts create mode 100644 packages/protocols/src/rerank/default-paths_test.ts delete mode 100644 packages/protocols/src/rerank/types.ts create mode 100644 packages/protocols/src/responses/compact.ts rename packages/protocols/src/responses/{index_test.ts => compact_test.ts} (94%) create mode 100644 packages/provider-azure/src/endpoint.ts rename packages/provider-claude-code/src/{access-token-cache.ts => access-token.ts} (99%) rename packages/provider-claude-code/src/{access-token-cache_test.ts => access-token_test.ts} (99%) rename packages/provider-claude-code/src/{ => interceptors/messages}/system-blocks.ts (99%) rename packages/provider-claude-code/src/{ => interceptors/messages}/system-blocks_test.ts (98%) delete mode 100644 packages/provider-claude-code/src/types.ts rename packages/provider-claude-code/src/{auth => }/usage-probe.ts (99%) rename packages/provider-claude-code/src/{auth => }/usage-probe_test.ts (100%) rename packages/provider-codex/src/{access-token-cache.ts => access-token.ts} (93%) rename packages/provider-codex/src/{access-token-cache_test.ts => access-token_test.ts} (99%) rename packages/{provider => provider-copilot}/src/compaction.ts (94%) create mode 100644 packages/provider-copilot/src/interceptors/image-compression.ts create mode 100644 packages/provider/src/telemetry.ts create mode 100644 packages/proxy/src/dial-target.ts create mode 100644 packages/translate/src/shared/messages-via/service-tier.ts create mode 100644 packages/translate/src/shared/messages-via/tool-result.ts create mode 100644 packages/translate/src/shared/responses-via/programmatic-tooling_test.ts create mode 100644 packages/translate/src/shared/via-messages/service-tier.ts create mode 100644 packages/translate/src/shared/via-messages/usage.ts create mode 100644 tsconfig.scripts.json diff --git a/.agents/skills/audit-copilot-workarounds/SKILL.md b/.agents/skills/audit-copilot-workarounds/SKILL.md index 07ebe686a8..20a70612e2 100644 --- a/.agents/skills/audit-copilot-workarounds/SKILL.md +++ b/.agents/skills/audit-copilot-workarounds/SKILL.md @@ -3,37 +3,60 @@ name: audit-copilot-workarounds description: Use periodically to verify each Copilot workaround against the current upstream. Inventories provider registrations and reference URLs, dispatches parallel cluster audits, runs live probes, and produces focused - deletion commits with experimental justification. + deletion or maintenance commits with experimental justification. --- # Audit Copilot Workarounds -Workarounds rot. Revalidate them against the current Copilot upstream. +Workarounds and pinned wire mimicry rot. Revalidate them against the current +Copilot upstream. -## Flow +## Build the inventory -1. Build the inventory from +The provider code and the reference URLs beside each workaround are the +inventory; there is no separate documentation list to reconcile. + +1. Start from `packages/provider-copilot/src/interceptors/{chat-completions,messages,responses}/index.ts` and `packages/provider-copilot/src/defaults.ts`. Follow every registered - interceptor and default-enabled shim to its implementation. The provider code - and the reference URLs beside each workaround are the inventory; there is no - separate documentation list to reconcile. -2. Group the inventory by source API, target API, and behavior so independent + interceptor and default-enabled shim to its implementation and tests. +2. Sweep the rest of `packages/provider-copilot/src` for non-pricing reference + URLs and for vendor constants, thresholds, timeouts, retries, and pinned wire + values that require a reference but may be missing one. Pricing citations + belong to `fetching-models-pricing`; everything else remains in this audit. +3. Include provider-level request/result shaping and catalog shaping even when + they are not interceptors. In particular, inspect `provider.ts`, + `fetch-models.ts`, `known-models.ts`, `model-selection.ts`, and + `merge-claude-variants.ts` together with their imports and tests. +4. Include the authentication fingerprint and management/data-plane behavior in + `auth.ts`, plus Responses item identity and replay handling rooted at + `interceptors/responses/item-id-membrane.ts`. Follow adjacent carrier and + compaction modules rather than assuming the interceptor registry contains the + whole workaround. +5. Record each item's owning module, reference URLs, affected source and target + APIs, models, account scope, default flag state, tests, and exit condition: + delete an obsolete workaround, refresh pinned mimicry, or retain it with + current evidence. + +## Audit flow + +1. Group the inventory by source API, target API, and behavior so independent clusters can be investigated without overlapping edits. -3. Dispatch parallel read-only audits, one per cluster. Recheck the cited +2. Dispatch parallel read-only audits, one per cluster. Recheck the cited upstream or prior-art source, inspect current Copilot behavior, and record the - exact code path that would be deleted if the workaround is obsolete. -4. Continue audit rounds until every open question requires either a live probe + exact code path that would be deleted or refreshed. +3. Continue audit rounds until every open question requires either a live probe or a human policy decision. -5. Run the required live probes, then land each proven deletion with its tests - and any provider-code reference cleanup. Hand unresolved policy decisions to - the human. +4. Run the required live probes, then land each proven deletion or maintenance + update with its tests and reference cleanup. Hand unresolved policy decisions + to the human. ## Extra constraints - **Live probes follow `probing-copilot`** — credential discovery, token - exchange, headers, and direct upstream calls all live there. Do not ask the - human for credentials and do not route probes through Floway. + exchange, headers, proxy fallback order, and direct upstream calls all live + there. Do not ask the human for credentials and do not route probes through + Floway. - **Full-matrix evidence.** Test every applicable model from `GET /models` on every account in D1; different accounts can diverge. One model on one account is never enough to delete a workaround. @@ -46,5 +69,5 @@ Workarounds rot. Revalidate them against the current Copilot upstream. upstream error text when relevant, and the originating commit SHA being reverted. - **When a policy value has no official upstream basis, say so in code.** - Thresholds, floors, and retry counts must explicitly identify an empirical or - prior-art basis and include the relevant permalink. + Thresholds, floors, timeouts, retry counts, and pinned fingerprints must + identify an empirical or prior-art basis and include the relevant permalink. diff --git a/.agents/skills/backfill-model-pricing/SKILL.md b/.agents/skills/backfill-model-pricing/SKILL.md index e3f76e932d..ad79eec395 100644 --- a/.agents/skills/backfill-model-pricing/SKILL.md +++ b/.agents/skills/backfill-model-pricing/SKILL.md @@ -12,26 +12,25 @@ NULL or a canonical non-negative decimal string containing USD per one base unit of that metric. `pricing_selector` is canonical selector JSON; `{}` is the Base coordinate. -The seven metrics established by -`packages/gateway/migrations/0062_usage_billing_metrics.sql` are: +`BILLING_METRICS` in `packages/protocols/src/common/pricing.ts` owns the +complete metric domain, and `BillingMetric` is derived from it. Read that array +before every operation and enumerate the metrics present in the +selected database slice; do not maintain another metric list in this procedure. +The repository read path rejects stored metric values outside that domain. -- `input_tokens` -- `input_cache_read_tokens` -- `input_cache_write_tokens` -- `input_cache_write_1h_tokens` -- `input_image_tokens` -- `output_tokens` -- `output_image_tokens` - -Realized cost is `SUM(quantity * unit_price)`. Both operands are decimal -strings in storage, and there is no additional scaling step. +Realized cost is the sum of `quantity * unit_price` for priced metric rows. Both +operands are decimal strings in storage, and there is no additional scaling +step. Aggregation skips NULL-price rows: cost is NULL only when no metric row was +priced, while a non-NULL cost may still be partial when other metric rows remain +unpriced. ## Procedure 1. Announce the environment. Default to production (`--remote`). 2. Before planning or running an UPDATE, re-read the current implementations in - `packages/gateway/src/repo/sql.ts` (`SqlUsageRepo` and usage row assembly) - and `packages/gateway/src/control-plane/token-usage/aggregate.ts` (cost + `packages/gateway/src/repo/sql.ts` (`SqlUsageRepo` and usage row assembly), + `packages/gateway/src/repo/types.ts` (the usage contracts), and + `packages/gateway/src/control-plane/token-usage/aggregate.ts` (cost aggregation). They are the authority if this procedure and the runtime ever diverge. 3. Establish the exact model, upstream, hour range, timezone, metrics, and write @@ -46,24 +45,30 @@ strings in storage, and there is no additional scaling step. `(upstream, model_key)`. 6. Match the stored `pricing_selector` exactly against `ModelPricing.entries` using canonical selector JSON. - - Current runtime selector misses are stored as `{}` with Base rates. - - A historical non-Base selector absent from today's catalog indicates - catalog drift; stop and investigate rather than guessing its old rates. - - Read only `entry.rates[metric]`. These runtime rates are already USD per - base metric unit. - - When a provider source uses `tokenPricingEntry` or `tokenBasePricing`, its - source literals are published token rates and the helper applies - `perMillionTokenRates`; apply the same conversion rather than copying a - source literal into `unit_price`. - - A missing metric is unpriced; there is no cache, image, or other - field-by-field fallback. + - An exact selector hit uses that entry. A selector miss in a catalog with a + Base entry is recorded as `{}` with the whole Base vector. + - A non-Base selector on an unpriced row is ordinary when no `ModelPricing` + existed: runtime facts form the selector before rate lookup, and it is + retained when no Base rates exist. It is not catalog drift by itself. + - A priced sibling row for the same `(upstream, model_key)` proves that a + catalog existed. If such a slice also contains an unpriced non-Base selector + absent from today's catalog, stop and investigate historical catalog drift. + Without a priced sibling, resolve today's catalog normally but never infer + historical rates. + - Read only the evaluated `entry.rates[metric]`; those values are already USD + per base metric unit. Never transcribe a numeric literal from a provider + `pricing.ts` into `unit_price`. + - A missing metric is unpriced; there is no cache, image, audio, rerank, or + other field-by-field fallback. 7. Preview the affected count and representative rows, including the current and proposed decimal-string `unit_price`. 8. Execute one UPDATE per exact `(slice, pricing_selector, metric)`. Include `unit_price IS NULL` only in fill mode, preserve NULL upstream matching with `COALESCE(upstream, '')`, and bind the new rate as a decimal string. 9. Re-query every slice and report the selector, metric, rate, rows updated, and - remaining NULL count. Independently validate the realized-cost expression on + remaining NULL count per metric. Compare those NULL counts with the expected + metric set; a non-NULL aggregate cost does not prove the slice is fully + priced. Independently validate decimal-string multiplication on representative rows. Use the local Wrangler dependency and read the D1 database name from diff --git a/.agents/skills/fetching-models-pricing/SKILL.md b/.agents/skills/fetching-models-pricing/SKILL.md index bd2c8dd16a..e0a7e77f36 100644 --- a/.agents/skills/fetching-models-pricing/SKILL.md +++ b/.agents/skills/fetching-models-pricing/SKILL.md @@ -18,11 +18,11 @@ These providers are subscription-backed or self-hosted. Floway records notional API-equivalent value so the usage dashboard remains comparable. `ModelPricing.entries[].rates` stores decimal-string USD prices per one base -`BillingMetric` unit. The token metrics established by -`packages/gateway/migrations/0062_usage_billing_metrics.sql` are -`input_tokens`, `input_cache_read_tokens`, `input_cache_write_tokens`, -`input_cache_write_1h_tokens`, `input_image_tokens`, `output_tokens`, and -`output_image_tokens`. +`BillingMetric` unit. The ten-member `BILLING_METRICS` array in +`packages/protocols/src/common/pricing.ts` owns the complete metric domain, and +`BillingMetric` is derived from it. Read the array rather than copying its +members into this procedure; each provider table may price only the defensible +subset for a model. ## Procedure @@ -43,12 +43,12 @@ notional API-equivalent value so the usage dashboard remains comparable. OpenRouter prices below first-party rates are usually mirror-host prices, not the canonical vendor rate. -4. Author pricing with the token helpers and decimal strings: +4. Author pricing with the token-rate conversion helpers and decimal strings: ```ts import { + modelPricing, tokenBasePricing, - tokenModelPricing, tokenPricingEntry, type PriceVector, } from '@floway-dev/protocols/common'; @@ -67,20 +67,24 @@ notional API-equivalent value so the usage dashboard remains comparable. export const BASE_ONLY_PRICING = tokenBasePricing(PUBLISHED_BASE_RATES); - export const TIERED_PRICING = tokenModelPricing( + export const TIERED_PRICING = modelPricing( tokenPricingEntry(PUBLISHED_BASE_RATES), tokenPricingEntry(PUBLISHED_PRIORITY_RATES, { serviceTier: 'priority' }), ); ``` Published token rate cards are normally USD per million tokens. - `tokenPricingEntry` and `tokenBasePricing` use the existing + `tokenPricingEntry` and `tokenBasePricing` apply the existing `perMillionTokenRates` conversion, so their resulting `PriceVector` values are USD per base token. Do not divide manually or pass number literals. Follow `packages/provider-codex/src/pricing.ts` for a complete production example instead of copying a rate vector into this skill. - Follow these invariants: + `collectModelPricingIssues` in + `packages/protocols/src/common/pricing.ts` enforces the structural invariants + below, including Base count, matching rate metrics, selector uniqueness, and + threshold-operator consistency. The source-quality rules still require + human judgment. - Declare exactly one Base entry without a selector. - Give every entry the same metrics as Base. diff --git a/.agents/skills/probing-copilot/SKILL.md b/.agents/skills/probing-copilot/SKILL.md index 5b2675267e..7ffb2d6ac4 100644 --- a/.agents/skills/probing-copilot/SKILL.md +++ b/.agents/skills/probing-copilot/SKILL.md @@ -12,63 +12,99 @@ description: Use when probing GitHub Copilot upstream behavior directly. Pulls a Calls the Copilot upstream the way Copilot Chat does, against an account we already own. -## Pick a credential +## Pick a credential and egress candidate 1. Read `` from `wrangler.jsonc` (`d1_databases[0].database_name`). 2. Query enabled Copilot upstreams against production (`pnpm wrangler d1 execute --remote --command "..."`). Production - is the default because we want to mirror the real account, including its - proxy chain. Only fall back to local D1 when production is unreachable - or the probe is specifically validating a local-only seed. + is the default because the probe must mirror the real account and its ordered + proxy fallback list. Only fall back to local D1 when production is + unreachable or the probe is specifically validating a local-only seed. - The query also pulls the first proxy URL from the upstream's - `proxy_fallback_list_json` so the probe can route through the same egress - production uses for this account: + Return one row per fallback entry instead of selecting the first proxy row + that happens to join. An empty persisted list is expanded to the runtime's + implicit `direct_fetch` candidate so direct egress is always visible: ```sql - SELECT u.id, u.name, + SELECT u.id, + u.name, json_extract(u.config_json, '$.githubToken') AS github_token, - (SELECT p.url FROM proxies p, json_each(u.proxy_fallback_list_json) j - WHERE json_extract(j.value, '$.id') = p.id - ORDER BY j.key - LIMIT 1) AS proxy_url + CAST(j.key AS INTEGER) AS fallback_index, + json_extract(j.value, '$.id') AS fallback_id, + json_extract(j.value, '$.colos') AS fallback_colos, + p.url AS proxy_url, + b.expires_at AS active_backoff_expires_at FROM upstreams u - WHERE u.provider = 'copilot' AND u.enabled = 1; + JOIN json_each( + CASE + WHEN json_array_length(u.proxy_fallback_list_json) = 0 + THEN '[{"id":"direct_fetch"}]' + ELSE u.proxy_fallback_list_json + END + ) AS j + LEFT JOIN proxies p + ON p.id = json_extract(j.value, '$.id') + LEFT JOIN proxy_upstream_backoffs b + ON b.proxy_id = json_extract(j.value, '$.id') + AND b.upstream_id = u.id + AND b.expires_at > CAST(strftime('%s', 'now') AS INTEGER) + WHERE u.provider = 'copilot' AND u.enabled = 1 + ORDER BY u.sort_order, u.id, CAST(j.key AS INTEGER); ``` -3. Pick any returned row unless the probe needs a specific upstream, in which - case select it by `id` or `name`. Don't ask the human. -4. Treat the PAT as a secret: do not echo it into commit messages, code - comments, or the chat transcript. +3. Pick any returned upstream unless the probe needs a specific one, in which + case select it by `id` or `name`. For the runtime location being mirrored, + discard entries whose `fallback_colos` excludes that location. Production + attempts the remaining rows with NULL `active_backoff_expires_at` in fallback + order, then retries the active-backoff rows in fallback order. If every + persisted entry is excluded, record that production collapses the list to an + implicit `direct_fetch` before probing. +4. Treat the PAT as a secret: do not echo it into commit messages, code comments, + or the chat transcript. + +## Route through the selected fallback + +Use the same selected fallback for both the GitHub token exchange and the +Copilot data-plane call. If an attempt fails and the probe is meant to exercise +fallback behavior, advance in the same order; never substitute direct egress +unless the selected entry is explicitly `direct_fetch` or `direct_connect`. + +- `direct_fetch`, `direct_connect` — direct egress is intentional and visible in + the query result. +- `http://`, `https://`, `socks5://` — curl-native; use + `curl -x "$proxy_url" …`. +- `ss://`, `trojan://`, `vless://` — curl cannot speak these. Use a throwaway + script outside the repository with the current `@floway-dev/proxy` dialer, or + report that a faithful probe is blocked. Do not go direct. +- A non-built-in `fallback_id` with NULL `proxy_url` is a missing proxy record; + stop and report it rather than going direct. -## Route through the upstream's proxy +## Exchange the PAT -If `proxy_url` came back non-null, pass it as curl's `-x` so both the -token exchange and the upstream call traverse the same egress production -uses. `api.github.com` is not geo-restricted for token exchange, but -keeping a single egress path makes the probe a faithful mirror and avoids -mismatched IP reputations. +Always exchange against the fixed GitHub management-plane endpoint: -- `http://`, `https://`, `socks5://` — curl-native; use `curl -x "$proxy_url" …`. -- `ss://`, `trojan://`, `vless://` — only our `@floway-dev/proxy` dialers - speak these; curl cannot. Skip the proxy and go direct, and call that - out in the probe report so the human knows the probe doesn't share - egress with production. +`GET https://api.github.com/copilot_internal/v2/token` -Token exchange is not bound to the data-plane host; the same `-x` applies -to the `api.github.com` call. +Do not append the exchange path to a Copilot data-plane host. Send the headers +from `githubHeaders` in `packages/provider-copilot/src/auth.ts`: -## Exchange the PAT +``` +authorization: token +accept: application/json +user-agent: GitHubCopilotChat/ +x-github-api-version: +x-vscode-user-agent-library-version: electron-fetch +``` -`GET https://api.github.com/copilot_internal/v2/token` with -`authorization: token ` returns -`{ token, expires_at, refresh_in, endpoints: { api } }`. The method is GET, -not POST — POST returns 404 from this endpoint. +The management-plane `x-github-api-version` is GitHub's REST version, not the +Copilot data-plane version. The exchange returns +`{ token, expires_at, refresh_in, endpoints: { api } }`. The method is GET, not +POST — POST returns 404 from this endpoint. -Use `endpoints.api` from that response as the data-plane base URL. Keep it -with the exchanged token and refresh both together when the token expires -(usually after about 30 minutes); do not infer or hardcode the host. +Use `endpoints.api` only as the data-plane base URL. Keep it with the exchanged +token and refresh both together when the token expires; do not infer or hardcode +the host. ## Call the upstream @@ -81,8 +117,8 @@ prefix): - `/v1/messages`, `/v1/messages/count_tokens` (Anthropic-shaped) - `/embeddings` -Required headers — matching VSCode Copilot Chat. Diverging makes the probe -non-representative; missing them produces opaque 400/403s. +Required data-plane headers — matching VSCode Copilot Chat. Diverging makes the +probe non-representative; missing them produces opaque 400/403s. ``` Authorization: Bearer @@ -100,22 +136,22 @@ openai-intent: conversation-agent x-interaction-type: conversation-agent ``` -`packages/provider-copilot/src/auth.ts` is the source of truth for the -version constants, the per-request header set, extraction of `endpoints.api`, -and data-plane dispatch in `copilotAuthedFetch`. Read the current values and -flow from there rather than hardcoding them in probe scripts. For Messages -probes needing Claude beta features, also send -`anthropic-beta: `. +`packages/provider-copilot/src/auth.ts` is the source of truth for both header +sets, their distinct version constants, extraction of `endpoints.api`, and +data-plane dispatch in `copilotAuthedFetch`. Read the current values and flow +from there rather than hardcoding them in probe scripts. For Messages probes +needing Claude beta features, also send `anthropic-beta: `. ## Constraints -- **Never go through our gateway.** No `pnpm run dev`, no deployed Worker. - Hit the token-advertised Copilot data-plane endpoint directly. +- **Never go through our gateway.** No `pnpm run dev`, no deployed Worker. Hit + the token-advertised Copilot data-plane endpoint directly. - **Don't write probe code into the repo** unless the human asks. One-shot - `curl` (or a throwaway script piped through `jq`) is enough. -- **Mid-task probes use a subagent.** Probes dump noisy request/response - bodies; dispatch a read-only subagent and have it report only the - observation that answers the question. -- **Token cache.** The gateway caches the exchanged token (in-process + KV); - a direct probe doesn't share that cache, so each fresh probe pays one + `curl` or a throwaway script outside the working tree is enough. +- **Mid-task probes use a subagent.** Probes dump noisy request/response bodies; + dispatch a read-only subagent and have it report only the observation that + answers the question. +- **Token cache.** The gateway uses a 60-second in-process memo keyed by upstream + id, backed by the per-upstream `state_json.copilotToken` value in `upstreams`. + A direct probe shares neither layer, so each fresh probe pays one `/copilot_internal/v2/token` round-trip. diff --git a/AGENTS.md b/AGENTS.md index 2b945aa2cf..1e67e0118c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,8 +92,9 @@ Allowed: - **Provider config discriminators naming the OWN kind** — `kind: 'claude-code'`. - **Vendor-locked provider packages** (`provider-claude-code`, - `provider-codex`) doing request/header mimicry captured verbatim from a live - wire probe while fetching their model catalogs live from the vendor. + `provider-codex`) owning request/header mimicry grounded in captured client + traffic and pinned upstream or prior-art references, while fetching their + model catalogs live from the vendor. Forbidden — silent narrowing at wire / translate / control-plane boundaries. Open-string fields declared `| (string & {})` or bare `string` in @@ -114,121 +115,147 @@ bin edges, canonical enum values, header sets, protocol quirks. Prose like Stack: Hono on Web APIs, TypeScript, pnpm, Vitest. The dashboard is a Vue + Vite SPA. Cloudflare Workers is the production deployment target; Node.js -(`node:sqlite` + `sharp` + filesystem) is a parallel deployment target with -the same Hono app and the same `packages/gateway/migrations` SQL. The -`@floway-dev/platform` package owns the abstract runtime contracts +(`node:sqlite` + `sharp` + filesystem) is a parallel target running the same +Hono app and the same `packages/gateway/migrations` SQL. + +The gateway has two HTTP planes. The **control plane** is the dashboard and +operator surface for authentication, users, API keys, upstreams, aliases, +proxies, Agent Setup, telemetry views, and data transfer. Its routes live under +`packages/gateway/src/control-plane/`, principally at `/api/*` and `/auth/*`. +The **data plane** is the client-facing inference and model-discovery surface; +it resolves public model ids, selects and calls upstreams, translates protocol +shapes, and returns client-protocol responses. Its routes live under +`packages/gateway/src/data-plane/`. + +Hono middleware is the HTTP request boundary: logger, CORS, authentication, +validation, and top-level error shaping live under `packages/gateway/src/middleware/` +or are registered on the Hono app. `@floway-dev/interceptor` is different: its +`Interceptor` callbacks receive `(ctx, env, run)` around a +typed invocation inside chat protocol and provider calls. Interceptors can +transform typed payloads, events, headers, and results; they are not Hono +middleware and do not receive a Hono `Context`/`Next` pair. + +The `@floway-dev/platform` package owns abstract runtime contracts (`FileStore`, `ChannelBroker`, `ImageCacheStore`, `RuntimeKind`, `ImageProcessor`, `ExternalResourceFetcher`, `SqlDatabase`, -`BackgroundScheduler`, `EnvGetter`, `SocketDial`); each `apps/platform-*` app -supplies the concrete implementations and its own entry. +`BackgroundScheduler`, `EnvGetter`, `SocketDial`) and portable helpers. Each +`apps/platform-*` app supplies concrete implementations and its own entry. ## Workspace Layout ```text Floway/ ├── packages/ -│ ├── agent-setup/ # @floway-dev/agent-setup — Agent Setup domain: config schema, installers, route factories, lease repository contract -│ ├── gateway/ # @floway-dev/gateway — Hono app, control/data planes, repo, migrations -│ ├── http/ # @floway-dev/http — HTTP/1.1 + userspace TLS + WebSocket upgrade over a duplex byte stream -│ ├── interceptor/ # @floway-dev/interceptor — generic interceptor framework -│ ├── platform/ # @floway-dev/platform — runtime contracts + portable helpers -│ ├── protocols/ # @floway-dev/protocols — protocol types, codecs, stream helpers, pricing and decimal utilities -│ ├── provider/ # @floway-dev/provider — upstream provider contracts +│ ├── agent-setup/ # @floway-dev/agent-setup — setup config, installers, route factories, lease repository contract +│ ├── gateway/ # @floway-dev/gateway — Hono app, control/data planes, repositories, migrations +│ ├── http/ # @floway-dev/http — HTTP/1.1, userspace TLS, WebSocket framing over duplex byte streams +│ ├── interceptor/ # @floway-dev/interceptor — typed around-call interceptor envelopes +│ ├── platform/ # @floway-dev/platform — runtime contracts and portable helpers +│ ├── protocols/ # @floway-dev/protocols — protocol types, codecs, stream helpers, pricing and decimals +│ ├── provider/ # @floway-dev/provider — provider, model, invocation, and result contracts │ ├── provider-azure/ # @floway-dev/provider-azure — Azure AI resource and Foundry project provider -│ ├── provider-claude-code/ # @floway-dev/provider-claude-code — Claude Code (Claude.ai subscription) provider -│ ├── provider-codex/ # @floway-dev/provider-codex — ChatGPT Codex (subscription) provider -│ ├── provider-copilot/ # @floway-dev/provider-copilot — GitHub Copilot provider, device OAuth, and quota wire helpers +│ ├── provider-claude-code/ # @floway-dev/provider-claude-code — Claude Code subscription provider +│ ├── provider-codex/ # @floway-dev/provider-codex — ChatGPT Codex subscription provider +│ ├── provider-copilot/ # @floway-dev/provider-copilot — GitHub Copilot provider │ ├── provider-custom/ # @floway-dev/provider-custom — configurable multi-protocol HTTP provider -│ ├── provider-ollama/ # @floway-dev/provider-ollama — Ollama (ollama.com or self-hosted) -│ ├── proxy/ # @floway-dev/proxy — proxy URI parsing, per-protocol byte-stream dialers, proxy-backed and direct request runners -│ ├── test-utils/ # @floway-dev/test-utils — shared Vitest fixtures and stubs (test-only) -│ ├── translate/ # @floway-dev/translate — cross-protocol translation pairs +│ ├── provider-ollama/ # @floway-dev/provider-ollama — Ollama-compatible provider +│ ├── proxy/ # @floway-dev/proxy — proxy URIs, protocol dialers, request runners +│ ├── test-utils/ # @floway-dev/test-utils — shared Vitest fixtures and stubs +│ ├── translate/ # @floway-dev/translate — direct cross-protocol translation pairs │ └── ui/ # @floway-dev/ui — internal Vue component library └── apps/ - ├── platform-cloudflare/ # @floway-dev/platform-cloudflare — Cloudflare implementations + Worker entry - ├── platform-node/ # @floway-dev/platform-node — Node implementations + node-server entry - └── web/ # @floway-dev/web — Vue + Vite SPA dashboard + ├── platform-cloudflare/ # Cloudflare runtime implementations and Worker entry + ├── platform-node/ # Node runtime implementations and node-server entry + └── web/ # Vue + Vite dashboard SPA ``` -Dependency direction is strict. The leaf-most packages are `protocols`, -`interceptor`, and `http`, none of which have runtime dependencies. -`translate` depends on `protocols`. `agent-setup` depends only on `hono` / -`zod` / `@hono/zod-validator`; it never imports the gateway or any app, and -knows nothing of databases, HTTP auth/CORS/logging, host mount paths, or -runtimes. `platform` owns runtime-neutral contracts and portable helpers. -`proxy` depends on `http`; all its dialers — including `vless-ws`, which -layers `wsUpgradeAndFrame` over the runtime's TLS-wrapped duplex — stay -runtime-agnostic by taking the raw TCP `socketDial` primitive through -`DialOptions`, so they never import `@floway-dev/platform`. - -The base `provider` package depends on `platform` + `protocols`. Azure, Custom, -and Ollama depend on `provider` + `protocols`; Claude Code and Codex add -`interceptor`; Copilot adds both `interceptor` and `platform`. `test-utils` -depends on `provider` and is consumed only as a test dependency. +Dependency direction is strict. `protocols` and `interceptor` have no runtime +workspace dependencies. `http` is also independent of other workspace +packages; it owns HTTP/1.1 framing and userspace TLS. `translate` depends on +`protocols`. `agent-setup` depends only on Hono and Zod at runtime; it knows +nothing of gateway databases, auth/CORS/logging, mount paths, or deployment +runtimes. `platform` owns runtime-neutral contracts and helpers. `proxy` +depends on `http`; its dialers take raw socket primitives through +`DialOptions`, so the package never imports `@floway-dev/platform`. + +The base `provider` package depends only on `protocols`. Azure, Custom, and +Ollama depend on `provider` + `protocols`; Claude Code and Codex add +`interceptor`; Copilot adds `interceptor` + `platform`. `test-utils` depends on +`provider` and is consumed as a test dependency by the rest of the workspace. +Vendor credentials, catalog projection, and wire behavior stay in the vendor +packages. The gateway owns the control-plane handlers that call those vendor +APIs and maps their results onto Floway HTTP responses. `gateway` depends on `agent-setup` + `http` + `interceptor` + `platform` + `protocols` + `provider` + every `provider-*` package + `proxy` + `translate`. -It is the runtime-agnostic gateway core: it threads `getSocketDial()` from -`@floway-dev/platform` into the proxy library at the dial-layer composition -root, supplies the SQL / in-memory `AgentSetupRepository` implementations and -the auth-derived user id, and owns the single route path that mounts both setup -surfaces and public script URLs ahead of logger / CORS / auth middleware. -Vendor credential and wire knowledge stays in the vendor package: for example, -`provider-copilot` owns GitHub device-flow and quota fetch helpers, while the -gateway owns the control-plane routes and maps their results onto Floway HTTP -responses. +It is the runtime-agnostic application core and composition root for providers, +repositories, model catalog/resolution, proxy-bound fetchers, protocol routes, +Stateful Responses, affinity, telemetry, and scheduled work. Shared data-plane +request context and candidate iteration live under `data-plane/shared/`; +provider composition, catalog assembly, and request-time resolution live under +`data-plane/providers/{registry,catalog,resolution}.ts`; scheduled expiration +and spilled-file workers live under `scheduled/`. The package exports the +migration corpus location through `@floway-dev/gateway/migrations-dir` and the +dashboard's dump contracts through the types-only `./dump-types` subpath. Both `apps/platform-*` apps depend on `gateway` + `http` + `platform`. The -Cloudflare app declares only the runtime surfaces it uses in local ambient -files (`apps/platform-cloudflare/src/cloudflare-workers.d.ts`, -`cloudflare-sockets.d.ts`, and `cf-websocket.d.ts`) instead of depending on -`@cloudflare/workers-types`. The Node app supplies `node:sqlite`, filesystem, -`sharp`, WebSocket, and `@hono/node-server` implementations. These apps are the -composition roots for runtime-specific bindings and services. +Cloudflare app declares only the workerd surfaces it uses in local ambient +files (`cloudflare-workers.d.ts`, `cloudflare-sockets.d.ts`, and +`cf-websocket.d.ts`) and supplies D1, R2, Images, KV, Durable Object, socket, +and runtime-root-CA implementations. The Node app supplies `node:sqlite`, +filesystem, `sharp`, WebSocket, socket, and runtime-root-CA implementations; +its migrator consumes the gateway's exported migration directory. These apps +are the only deployment-target composition roots. `apps/web` depends at runtime on `ui`, `protocols`, `provider`, and `proxy`. Its protocol imports use `/common`, `/chat-completions`, `/completions`, `/messages`, `/responses`, `/gemini`, and `/rerank`; its provider imports use -the root, `/flags`, `/model`, and `/model-prefix`; and its proxy imports are -restricted to `/url`, `/url-kind`, `/proxy-config`, and `/constants` so the -SPA does not pull dialers, userspace TLS, or Node `crypto` into its bundle. It -type-imports gateway contracts through `/app-type`, `/dump-types`, +the root, `/flags`, `/model`, and `/model-prefix`; its proxy imports are +restricted to `/url`, `/url-kind`, `/proxy-config`, and `/constants` so the SPA +does not pull in dialers, userspace TLS, or Node `crypto`. It type-imports +gateway contracts through `/app-type`, `/dump-types`, `/control-plane/performance/aggregate`, and `/control-plane/proxies/serialize`. It does not depend on -`@floway-dev/agent-setup` — the dashboard derives the Agent Setup configuration -type from the RPC client — and ESLint blocks a runtime import of that package -from `apps/web`. - -ESLint forbids any workspace file from importing `@floway-dev/platform-*` by -package name, plus a `no-restricted-paths` zone forbidding the platform-target -apps from reaching into each other via relative paths. Each `apps/platform-*` -ships with no `exports`/`main` field, so deep imports also fail at module -resolution. Each platform-target app's `entry.ts` reaches its implementations -only via local relative imports. - -Each package's public surface is its `exports` map. Deep imports -(`@floway-dev//src/...`) are banned by ESLint; cross-package code must use -declared subpath exports. Tests are co-located as `*_test.ts`; each tested -package has its own `vitest.config.ts`, and the root config discovers projects -with `packages/*/vitest.config.ts` and `apps/*/vitest.config.ts`. - -Client-carried affinity is a source-protocol membrane. Shared codec, routing, -and request context live under `data-plane/chat/shared/affinity`; each source -protocol owns its `affinity/ingress.ts` and `affinity/egress.ts`. Wire behavior -lives in `docs/AFFINITY.md`, and candidate ordering lives in -`docs/RESOLUTION.md`. - -Everything else — provider interfaces, request execution flow, interceptor -shapes, control-plane route surface, flag resolution, pricing — lives in the -code and its comments. Translation pair layout, model resolution, and affinity -wire behavior have dedicated specs under `docs/`. +`@floway-dev/agent-setup`; the dashboard derives Agent Setup types from the RPC +client, and ESLint blocks a runtime import of that package from `apps/web`. + +ESLint forbids workspace imports of `@floway-dev/platform-*` by package name +and relative cross-imports between platform-target apps. Each +`apps/platform-*` package has no `exports` or `main`, and its `entry.ts` reaches +implementations only through local relative imports. Every cross-package +runtime import must use a declared `exports` entry; deep +`@floway-dev//src/...` imports are banned. + +Tests are co-located as `*_test.ts`. Every tested package owns a +`vitest.config.ts`, and the root Vitest config discovers +`packages/*/vitest.config.ts` and `apps/*/vitest.config.ts`. Package TypeScript +projects include their Vitest configs. Root `scripts/**/*.ts` and +`packages/agent-setup/scripts/**/*.ts` have Node-typed script projects; the +base config sets `types: []` so ambient types enter only projects that request +them. ESLint checks both script trees and all Vitest configs. + +Client-carried affinity is a source-protocol membrane. Shared codec, candidate +narrowing, and affinity request context live under +`data-plane/chat/shared/affinity/`; each chat source protocol owns +`affinity/ingress.ts` and `affinity/egress.ts`. Native Responses state is a +separate source-edge membrane under `data-plane/chat/responses/items/`. +Affinity wire behavior and its relationship to Stateful Responses and +Copilot's provider-private item-id membrane live in `docs/AFFINITY.md`. +Candidate resolution, target selection, and iteration live in +`docs/RESOLUTION.md`; direct chat-family pairs and rerank translation live in +`docs/TRANSLATION.md`. + +Everything else — provider interfaces, route details, flag resolution, and +wire workarounds — lives in the owning code and its comments. The +`audit-copilot-workarounds` skill builds the Copilot inventory from provider +registrations, defaults, model/auth/item-id modules, and their reference URLs. ## Verification ```bash pnpm run test # vitest across all packages pnpm run lint # eslint across the workspace -pnpm run typecheck # tsc --noEmit per package +pnpm run typecheck # tsc --noEmit per package and root script project pnpm run test:agent-setup-installers # assembled Agent Setup scripts vs. fake CLIs/installers (not in `test`) ``` @@ -262,18 +289,21 @@ built `apps/web/dist` via Workers Static Assets; direct SPA routes (e.g. (admin secret; see below), `PORT`, and optionally `RUNTIME_LOCATION` (instance tag used as the perf-telemetry `runtimeLocation` dimension and the dial-time colo-whitelist key — uppercased on read, defaults to `LOCAL` when unset). The -Node entry runs `applyMigrations` against -`packages/gateway/migrations/*.sql` at boot, then serves the same Hono app -through `@hono/node-server`. It exposes Floway's data-plane and control-plane +Node entry runs `applyMigrations` at boot against the gateway-exported +`packages/gateway/migrations/*.sql` corpus, then serves the same Hono app through +`@hono/node-server`. It exposes Floway's data-plane and control-plane APIs but no SPA; static-asset serving is Workers-only. -The public Agent Setup installers are composed from the checked-in -`packages/agent-setup/installers/{bash,powershell}/common/` fragments and the -adjacent `{claude,codex}.{sh,ps1}` agent fragments. Each source fragment is -embedded verbatim into `packages/agent-setup/src/script-assets.generated.ts`; -regenerate with +The public Agent Setup installers are composed from checked-in source files. +Bash common responsibilities live in `output.sh`, `main.sh`, `process.sh`, +`jq.sh`, `cli.sh`, and `managed-file.sh`; PowerShell uses `output.ps1`, +`main.ps1`, `platform.ps1`, `process.ps1`, `cli.ps1`, `managed-file.ps1`, and +`json-document.ps1`. The adjacent `{claude,codex}.{sh,ps1}` files supply the +agent-specific bodies. Fragment inventory, section boundaries, and byte order +live only in `packages/agent-setup/scripts/generate-assets.ts`, which embeds the +prejoined served bodies in `src/script-assets.generated.ts`. Regenerate with `pnpm --filter @floway-dev/agent-setup run generate-assets` (pass `--check` to -fail on drift) after editing any fragment. +fail on drift) after editing a source fragment. `ADMIN_KEY` is optional on dev instances so a fresh checkout is usable without any secret setup: with the env var unset (which is the default once `.dev.vars` diff --git a/README.md b/README.md index af2e7d24f1..d394e7cc84 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,7 @@ then routes each model through the API shape the client already speaks. ## Highlights - Use GitHub Copilot, ChatGPT subscriptions, Claude.ai subscriptions, Azure AI, - custom OpenAI- or Anthropic-compatible providers, and Ollama from one - deployment. + configurable multi-protocol HTTP providers, and Ollama from one deployment. - Serve OpenAI, Anthropic, Gemini-compatible, audio transcription, and rerank APIs with cross-protocol translation where needed. - Discover vendor model catalogs live while retaining manual model configuration @@ -37,8 +36,14 @@ the password. Then: 3. Give that key to a client as a bearer token or `x-api-key`, or use **Agent Setup** to configure Claude Code or Codex. -The data-plane API is also exposed directly at . SQLite -and uploaded files persist in the `floway-data` volume. +The data-plane API is also exposed directly at . SQLite, +file-backed dump bodies, and oversized Stateful Responses item payloads persist +in the `floway-data` volume. + +The dashboard uses Floway's control plane to manage users, keys, upstreams, +routing, and telemetry. Coding agents and API clients call the data plane, +which performs model resolution, upstream dispatch, and any required protocol +translation. Both planes are served by the same gateway process. ## Compatibility @@ -52,14 +57,18 @@ and uploaded files persist in the `floway-data` volume. | OpenAI Embeddings | `POST /v1/embeddings` | | OpenAI Images | `POST /v1/images/generations`, `POST /v1/images/edits` | | OpenAI Audio Transcriptions | `POST /v1/audio/transcriptions` | -| OpenAI Models | `GET /v1/models` | +| OpenAI Models | `GET /v1/models`, `GET /models` | | Anthropic Messages | `POST /v1/messages`, `POST /v1/messages/count_tokens` | -| Google Gemini | `POST /v1beta/models/...` | +| Google Gemini | `GET /v1beta/models`, `GET /v1beta/models/{model}`, `POST /v1beta/models/{model}:generateContent`, `POST /v1beta/models/{model}:streamGenerateContent`, `POST /v1beta/models/{model}:countTokens` | | Cohere Rerank v1 | `POST /v1/rerank` | | Cohere Rerank v2 | `POST /v2/rerank` | | Jina Rerank | `POST /jina/v1/rerank` | | Voyage Rerank | `POST /voyage/v1/rerank` | +`/v1/models` and `/models` return Floway's public model superset to ordinary +callers and select the Codex or Claude Code discovery shape for those clients' +User-Agent. + Rerank models are manual Custom models. Each model selects its outbound Cohere, Jina, Voyage, DashScope-compatible, or DashScope-native protocol and may override that protocol's canonical path; there is no upstream-wide rerank path. @@ -75,7 +84,7 @@ responses retain their upstream wire shape. | GitHub Copilot | GitHub device OAuth | Fetched live from Copilot | | Codex | ChatGPT subscription through the Codex CLI OAuth client | Fetched live from the Codex backend | | Claude Code | Claude.ai Pro, Max, Team, or Enterprise subscription through the Claude Code CLI OAuth client | Fetched live from Anthropic | -| Custom | OpenAI- or Anthropic-compatible endpoint and credential | Live `/models`, manual models, or both | +| Custom | Configurable multi-protocol HTTP endpoint and credential | Live OpenAI-compatible `/models`, manual models, or both | | Azure | Azure AI resource or Foundry project endpoint and API key | Configured models | | Ollama | ollama.com or a self-hosted Ollama-compatible server | Fetched live from Ollama, with optional manual overrides | diff --git a/apps/platform-cloudflare/src/bootstrap.ts b/apps/platform-cloudflare/src/bootstrap.ts index 1a2bcc1c6d..a7a3a50c35 100644 --- a/apps/platform-cloudflare/src/bootstrap.ts +++ b/apps/platform-cloudflare/src/bootstrap.ts @@ -1,10 +1,10 @@ -import { DurableObjectChannelBroker, type BroadcastNamespace } from './do-channel-broker.ts'; +import { DurableObjectChannelBroker, type BroadcastNamespace } from './durable-object-channel-broker.ts'; import { createCloudflareExternalResourceFetcher } from './external-resource-fetcher.ts'; import { createCloudflareImageProcessor, type ImagesBinding } from './image-processor.ts'; -import { KvImageCache, type KvNamespace } from './kv-image-cache.ts'; +import { KvImageCacheStore, type KvNamespace } from './kv-image-cache-store.ts'; import { R2FileStore, type R2BucketLike } from './r2-file-store.ts'; +import { cloudflareRuntimeRootCAs } from './runtime-root-cas.ts'; import { cloudflareSocketDial } from './socket-dial.ts'; -import { cloudflareRuntimeRootCAs } from './tls-trust.ts'; import { FileDumpStore, initDumpBroker, initDumpStore } from '@floway-dev/gateway'; import { dumpCodec } from '@floway-dev/gateway/dump-codec'; import type { DumpMetadata } from '@floway-dev/gateway/dump-types'; @@ -31,8 +31,8 @@ export interface CloudflareEnv { } // Every binding declared on `CloudflareEnv` is load-bearing — D1 holds all -// config and telemetry, R2 holds spilled payloads, Images compresses inline -// images, KV memoises compressed image results. A missing binding means +// config and telemetry, R2 stores file-backed response payloads and dump bodies, +// Images re-encodes images, and KV memoises the results. A missing binding means // wrangler.jsonc drifted from the code, so we refuse to initialise rather // than 503 on first use of the absent binding. const REQUIRED_BINDINGS = ['DB', 'FILES', 'IMAGES', 'KV', 'BROADCAST_DO'] as const; @@ -55,7 +55,7 @@ export const bootstrapCloudflarePlatform = (env: CloudflareEnv): { db: SqlDataba initExternalResourceFetcher(createCloudflareExternalResourceFetcher()); const files = new R2FileStore(env.FILES); initFileStore(files); - initImageCacheStore(new KvImageCache(env.KV, IMAGE_CACHE_POLICY)); + initImageCacheStore(new KvImageCacheStore(env.KV, IMAGE_CACHE_POLICY)); initImageProcessor(createCloudflareImageProcessor(env.IMAGES)); initSocketDial(cloudflareSocketDial); addTrustedRootCAs(cloudflareRuntimeRootCAs); diff --git a/apps/platform-cloudflare/src/broadcast-do_test.ts b/apps/platform-cloudflare/src/broadcast-do_test.ts index 70876eba32..fba24502e2 100644 --- a/apps/platform-cloudflare/src/broadcast-do_test.ts +++ b/apps/platform-cloudflare/src/broadcast-do_test.ts @@ -133,7 +133,7 @@ test('BroadcastDO.fetch upgrades to a WebSocket and registers the server side', class StubResponse { readonly status: number; readonly webSocket: WebSocket | undefined; - constructor(_body: BodyInit | null, init?: ResponseInit & { webSocket?: WebSocket }) { + constructor(_body: BodyInit | null, init?: ResponseInit) { this.status = init?.status ?? 200; this.webSocket = init?.webSocket; } @@ -147,8 +147,7 @@ test('BroadcastDO.fetch upgrades to a WebSocket and registers the server side', const response = await actor.fetch(new Request('https://broadcast.do/subscribe')); assertEquals(response.status, 101); - const responseWithSocket = response as Response & { webSocket?: WebSocket }; - assertEquals(responseWithSocket.webSocket !== undefined, true); + assertEquals(response.webSocket !== undefined, true); assertEquals(state.sockets.length, 1); } finally { globalThis.Response = realResponse; diff --git a/apps/platform-cloudflare/src/cf-websocket.d.ts b/apps/platform-cloudflare/src/cf-websocket.d.ts index 99e8b4ce37..946a77ae0b 100644 --- a/apps/platform-cloudflare/src/cf-websocket.d.ts +++ b/apps/platform-cloudflare/src/cf-websocket.d.ts @@ -10,6 +10,10 @@ declare global { webSocket?: WebSocket; } + interface Response { + readonly webSocket?: WebSocket | null; + } + interface WebSocket { // The server side of a WebSocketPair must opt into receiving frames. accept(): void; diff --git a/apps/platform-cloudflare/src/do-channel-broker.ts b/apps/platform-cloudflare/src/durable-object-channel-broker.ts similarity index 98% rename from apps/platform-cloudflare/src/do-channel-broker.ts rename to apps/platform-cloudflare/src/durable-object-channel-broker.ts index 6e3fd688e7..01f8d127c3 100644 --- a/apps/platform-cloudflare/src/do-channel-broker.ts +++ b/apps/platform-cloudflare/src/durable-object-channel-broker.ts @@ -66,7 +66,7 @@ const iterateFromBroadcastSocket = ( if (response.status !== 101) { throw new Error(`BroadcastDO subscribe returned HTTP ${response.status} instead of 101`); } - const socket = (response as Response & { webSocket?: WebSocket }).webSocket; + const socket = response.webSocket; if (!socket) throw new Error('BroadcastDO returned 101 without a webSocket'); socket.accept(); socket.addEventListener('message', event => { diff --git a/apps/platform-cloudflare/src/do-channel-broker_test.ts b/apps/platform-cloudflare/src/durable-object-channel-broker_test.ts similarity index 98% rename from apps/platform-cloudflare/src/do-channel-broker_test.ts rename to apps/platform-cloudflare/src/durable-object-channel-broker_test.ts index 492ac2a13e..764f086a06 100644 --- a/apps/platform-cloudflare/src/do-channel-broker_test.ts +++ b/apps/platform-cloudflare/src/durable-object-channel-broker_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { DurableObjectChannelBroker, type BroadcastNamespace } from './do-channel-broker.ts'; +import { DurableObjectChannelBroker, type BroadcastNamespace } from './durable-object-channel-broker.ts'; import type { ChannelCodec } from '@floway-dev/platform'; import { assertEquals } from '@floway-dev/test-utils'; @@ -58,7 +58,7 @@ const buildNamespace = (socket: FakeServerSocket, broadcasts: string[] = [], clo // fact. The broker only reads `status` and `webSocket`. const response = new Response(null, { status: 200 }); Object.defineProperty(response, 'status', { value: 101, configurable: true }); - (response as Response & { webSocket?: unknown }).webSocket = socket; + Object.defineProperty(response, 'webSocket', { value: socket, configurable: true }); return response; }, }; diff --git a/apps/platform-cloudflare/src/kv-image-cache.ts b/apps/platform-cloudflare/src/kv-image-cache-store.ts similarity index 97% rename from apps/platform-cloudflare/src/kv-image-cache.ts rename to apps/platform-cloudflare/src/kv-image-cache-store.ts index e4be713785..a570b22f27 100644 --- a/apps/platform-cloudflare/src/kv-image-cache.ts +++ b/apps/platform-cloudflare/src/kv-image-cache-store.ts @@ -31,7 +31,7 @@ const KV_MIN_TTL_SECONDS = 60; const ttlSeconds = (ttlMs: number): number => Math.max(KV_MIN_TTL_SECONDS, Math.ceil(ttlMs / 1000)); -export class KvImageCache implements ImageCacheStore { +export class KvImageCacheStore implements ImageCacheStore { constructor(private readonly kv: KvNamespace, private readonly policy: ImageCachePolicy) {} async get(key: string): Promise { diff --git a/apps/platform-cloudflare/src/kv-image-cache_test.ts b/apps/platform-cloudflare/src/kv-image-cache-store_test.ts similarity index 89% rename from apps/platform-cloudflare/src/kv-image-cache_test.ts rename to apps/platform-cloudflare/src/kv-image-cache-store_test.ts index cee6029aec..892fdcb275 100644 --- a/apps/platform-cloudflare/src/kv-image-cache_test.ts +++ b/apps/platform-cloudflare/src/kv-image-cache-store_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { KvImageCache, type KvNamespace } from './kv-image-cache.ts'; +import { KvImageCacheStore, type KvNamespace } from './kv-image-cache-store.ts'; import type { ImageCachePolicy } from '@floway-dev/platform'; import { assert, assertEquals } from '@floway-dev/test-utils'; @@ -37,7 +37,7 @@ const recordingKv = (initial?: { value: Uint8Array; metadata: { writtenAt: numbe test('put stamps the entry with the current writtenAt', async () => { const { kv, puts } = recordingKv(); - const cache = new KvImageCache(kv, POLICY); + const cache = new KvImageCacheStore(kv, POLICY); const before = Date.now(); await cache.put('k', new Uint8Array([1, 2])); @@ -53,7 +53,7 @@ test('put stamps the entry with the current writtenAt', async () => { test('get hit younger than the refresh threshold returns bytes without writing', async () => { const { kv, puts } = recordingKv({ value: new Uint8Array([7]), metadata: { writtenAt: Date.now() - 1000 } }); - const cache = new KvImageCache(kv, POLICY); + const cache = new KvImageCacheStore(kv, POLICY); const hit = await cache.get('k'); @@ -64,7 +64,7 @@ test('get hit younger than the refresh threshold returns bytes without writing', test('get hit older than the refresh threshold rewrites the entry with a fresh writtenAt', async () => { const aged = Date.now() - 20 * 60 * 60 * 1000; const { kv, puts } = recordingKv({ value: new Uint8Array([9]), metadata: { writtenAt: aged } }); - const cache = new KvImageCache(kv, POLICY); + const cache = new KvImageCacheStore(kv, POLICY); const before = Date.now(); const hit = await cache.get('k'); @@ -79,7 +79,7 @@ test('get hit older than the refresh threshold rewrites the entry with a fresh w test('get hit on a pre-rework entry without metadata self-heals by stamping a fresh writtenAt', async () => { const { kv, puts } = recordingKv({ value: new Uint8Array([3]), metadata: null }); - const cache = new KvImageCache(kv, POLICY); + const cache = new KvImageCacheStore(kv, POLICY); const hit = await cache.get('k'); @@ -90,7 +90,7 @@ test('get hit on a pre-rework entry without metadata self-heals by stamping a fr test('get miss returns null and does not write', async () => { const { kv, puts } = recordingKv(); - const cache = new KvImageCache(kv, POLICY); + const cache = new KvImageCacheStore(kv, POLICY); const miss = await cache.get('k'); @@ -100,7 +100,7 @@ test('get miss returns null and does not write', async () => { test('put rounds tiny TTLs up to the KV 60-second floor', async () => { const { kv, puts } = recordingKv(); - const cache = new KvImageCache(kv, { ttlMs: 1000, refreshIfOlderThanMs: 500 }); + const cache = new KvImageCacheStore(kv, { ttlMs: 1000, refreshIfOlderThanMs: 500 }); await cache.put('k', new Uint8Array([1])); diff --git a/apps/platform-cloudflare/src/tls-trust.ts b/apps/platform-cloudflare/src/runtime-root-cas.ts similarity index 100% rename from apps/platform-cloudflare/src/tls-trust.ts rename to apps/platform-cloudflare/src/runtime-root-cas.ts diff --git a/apps/platform-cloudflare/src/tls-trust_test.ts b/apps/platform-cloudflare/src/runtime-root-cas_test.ts similarity index 77% rename from apps/platform-cloudflare/src/tls-trust_test.ts rename to apps/platform-cloudflare/src/runtime-root-cas_test.ts index 9d21a62d09..9c36d86ad1 100644 --- a/apps/platform-cloudflare/src/tls-trust_test.ts +++ b/apps/platform-cloudflare/src/runtime-root-cas_test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { cloudflareRuntimeRootCAs } from './tls-trust.ts'; +import { cloudflareRuntimeRootCAs } from './runtime-root-cas.ts'; describe('cloudflareRuntimeRootCAs', () => { it('is empty because workerd exposes no runtime trust store', () => { diff --git a/apps/platform-cloudflare/vitest.config.ts b/apps/platform-cloudflare/vitest.config.ts index b09f9dda7b..2cdce38f69 100644 --- a/apps/platform-cloudflare/vitest.config.ts +++ b/apps/platform-cloudflare/vitest.config.ts @@ -4,7 +4,6 @@ export default defineConfig({ test: { environment: 'node', include: ['src/**/*_test.ts'], - restoreMocks: false, testTimeout: 10_000, }, resolve: { diff --git a/apps/platform-node/src/bootstrap.ts b/apps/platform-node/src/bootstrap.ts index 1da7bd412f..cef6ced420 100644 --- a/apps/platform-node/src/bootstrap.ts +++ b/apps/platform-node/src/bootstrap.ts @@ -2,10 +2,10 @@ import { EventTargetChannelBroker } from './event-target-channel-broker.ts'; import { createNodeExternalResourceFetcher } from './external-resource-fetcher.ts'; import { FsFileStore } from './fs-file-store.ts'; import { createNodeSqliteDatabase } from './node-sqlite-database.ts'; +import { nodeRuntimeRootCAs } from './runtime-root-cas.ts'; import { createSharpImageProcessor } from './sharp-image-processor.ts'; import { nodeSocketDial } from './socket-dial.ts'; -import { SqliteImageCache } from './sqlite-image-cache.ts'; -import { nodeRuntimeRootCAs } from './tls-trust.ts'; +import { SqliteImageCacheStore } from './sqlite-image-cache-store.ts'; import { FileDumpStore, initDumpBroker, initDumpStore } from '@floway-dev/gateway'; import { dumpCodec } from '@floway-dev/gateway/dump-codec'; import type { DumpMetadata } from '@floway-dev/gateway/dump-types'; @@ -36,7 +36,7 @@ export const bootstrapNodePlatform = (): { db: SqlDatabase } => { initSocketDial(nodeSocketDial); addTrustedRootCAs(nodeRuntimeRootCAs); const db = createNodeSqliteDatabase(dbPath); - initImageCacheStore(new SqliteImageCache(db, IMAGE_CACHE_POLICY)); + initImageCacheStore(new SqliteImageCacheStore(db, IMAGE_CACHE_POLICY)); initImageProcessor(createSharpImageProcessor()); initDumpStore(new FileDumpStore(db, files)); initDumpBroker(new EventTargetChannelBroker(dumpCodec)); diff --git a/apps/platform-node/src/migrate.ts b/apps/platform-node/src/migrate.ts index 2cafd6a423..53b1f95709 100644 --- a/apps/platform-node/src/migrate.ts +++ b/apps/platform-node/src/migrate.ts @@ -1,14 +1,11 @@ import { readdir, readFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { migrationsDir } from '@floway-dev/gateway/migrations-dir'; import type { SqlDatabase } from '@floway-dev/platform'; -// Resolve packages/gateway/migrations/ relative to this file's location in the -// workspace. The Node deployment target runs under tsx against the source -// tree, so the workspace layout is the source of truth. -const HERE = dirname(fileURLToPath(import.meta.url)); -const DEFAULT_MIGRATIONS_DIR = join(HERE, '..', '..', '..', 'packages', 'gateway', 'migrations'); +const DEFAULT_MIGRATIONS_DIR = fileURLToPath(migrationsDir); // Applies every pending migration, recording each one's name in a // `_migrations` table so reruns are no-ops. Each file's full contents go diff --git a/apps/platform-node/src/migrate_test.ts b/apps/platform-node/src/migrate_test.ts index 5e61a6ae85..c7a56e934c 100644 --- a/apps/platform-node/src/migrate_test.ts +++ b/apps/platform-node/src/migrate_test.ts @@ -1,7 +1,6 @@ -import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; import { test } from 'vitest'; @@ -9,11 +8,6 @@ import { applyMigrations } from './migrate.ts'; import { createNodeSqliteDatabase } from './node-sqlite-database.ts'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; -const REAL_MIGRATIONS_DIR = join( - dirname(fileURLToPath(import.meta.url)), - '..', '..', '..', 'packages', 'gateway', 'migrations', -); - const withTemp = async (fn: (dir: string) => Promise): Promise => { const dir = await mkdtemp(join(tmpdir(), 'migrate-test-')); try { @@ -39,37 +33,6 @@ test('applies all real migration files against a fresh sqlite', () => withTemp(a assertEquals(recorded !== null && recorded.n > 0, true); })); -// Migration filenames must start with a unique NNNN_ prefix so that the -// lexical apply order is unambiguous, both here and in `wrangler d1 -// migrations apply` (which sorts the same way). A duplicate prefix means two -// branches independently picked the same number — the second one to merge -// must be renumbered before it is applied anywhere. -// -// The two collisions below predate this check and are already applied to -// production D1. Renaming them now would orphan the recorded entry in -// `d1_migrations` and trick wrangler into re-running each file under its new -// name — both DROP/CREATE pairs would error against an already-mutated -// schema. They are grandfathered; every new migration must keep this list -// empty. -const KNOWN_DUPLICATE_PREFIXES: ReadonlySet = new Set(['0011', '0025']); - -test('every migration file has a unique numeric prefix', async () => { - const files = (await readdir(REAL_MIGRATIONS_DIR)).filter(f => f.endsWith('.sql')); - const byPrefix = new Map(); - for (const file of files) { - const match = /^(\d{4})_/.exec(file); - assertEquals(match !== null, true, `migration filename must start with NNNN_: ${file}`); - const prefix = match![1]; - const bucket = byPrefix.get(prefix) ?? []; - bucket.push(file); - byPrefix.set(prefix, bucket); - } - const collisions = [...byPrefix.entries()] - .filter(([prefix, bucket]) => bucket.length > 1 && !KNOWN_DUPLICATE_PREFIXES.has(prefix)) - .map(([, bucket]) => bucket); - assertEquals(collisions, [], `duplicate migration numbers: ${JSON.stringify(collisions)}`); -}); - test('rerun is a no-op once all migrations are applied', () => withTemp(async dir => { const db = createNodeSqliteDatabase(join(dir, 'idempotent.db')); await applyMigrations(db); diff --git a/apps/platform-node/src/tls-trust.ts b/apps/platform-node/src/runtime-root-cas.ts similarity index 60% rename from apps/platform-node/src/tls-trust.ts rename to apps/platform-node/src/runtime-root-cas.ts index 0694590475..bf0f73d925 100644 --- a/apps/platform-node/src/tls-trust.ts +++ b/apps/platform-node/src/runtime-root-cas.ts @@ -1,6 +1,6 @@ import tls from 'node:tls'; // `tls.rootCertificates` is Node's bundled Mozilla CA list, shipped in -// lockstep with the Node release, plus anything Node folded in from -// `NODE_EXTRA_CA_CERTS` at process startup. +// lockstep with the Node release. It excludes certificates loaded through +// `NODE_EXTRA_CA_CERTS`. export const nodeRuntimeRootCAs: readonly string[] = tls.rootCertificates; diff --git a/apps/platform-node/src/tls-trust_test.ts b/apps/platform-node/src/runtime-root-cas_test.ts similarity index 87% rename from apps/platform-node/src/tls-trust_test.ts rename to apps/platform-node/src/runtime-root-cas_test.ts index 41db0f4237..c8de04071a 100644 --- a/apps/platform-node/src/tls-trust_test.ts +++ b/apps/platform-node/src/runtime-root-cas_test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { nodeRuntimeRootCAs } from './tls-trust.ts'; +import { nodeRuntimeRootCAs } from './runtime-root-cas.ts'; describe('nodeRuntimeRootCAs', () => { it('exposes Node\'s bundled root certificate PEMs', () => { diff --git a/apps/platform-node/src/sqlite-image-cache.ts b/apps/platform-node/src/sqlite-image-cache-store.ts similarity index 96% rename from apps/platform-node/src/sqlite-image-cache.ts rename to apps/platform-node/src/sqlite-image-cache-store.ts index 343cfd74e6..32680fc57f 100644 --- a/apps/platform-node/src/sqlite-image-cache.ts +++ b/apps/platform-node/src/sqlite-image-cache-store.ts @@ -1,6 +1,6 @@ import type { ImageCachePolicy, ImageCacheStore, SqlDatabase } from '@floway-dev/platform'; -export class SqliteImageCache implements ImageCacheStore { +export class SqliteImageCacheStore implements ImageCacheStore { constructor(private readonly db: SqlDatabase, private readonly policy: ImageCachePolicy) {} async get(key: string): Promise { diff --git a/apps/platform-node/src/sqlite-image-cache_test.ts b/apps/platform-node/src/sqlite-image-cache-store_test.ts similarity index 93% rename from apps/platform-node/src/sqlite-image-cache_test.ts rename to apps/platform-node/src/sqlite-image-cache-store_test.ts index 8aab1db2da..fae388b841 100644 --- a/apps/platform-node/src/sqlite-image-cache_test.ts +++ b/apps/platform-node/src/sqlite-image-cache-store_test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { test } from 'vitest'; import { createNodeSqliteDatabase } from './node-sqlite-database.ts'; -import { SqliteImageCache } from './sqlite-image-cache.ts'; +import { SqliteImageCacheStore } from './sqlite-image-cache-store.ts'; import type { ImageCachePolicy } from '@floway-dev/platform'; import { assert, assertEquals } from '@floway-dev/test-utils'; @@ -14,8 +14,8 @@ const POLICY: ImageCachePolicy = { refreshIfOlderThanMs: 18 * 60 * 60 * 1000, }; -const withCache = async (fn: (cache: SqliteImageCache, db: ReturnType) => Promise): Promise => { - const dir = await mkdtemp(join(tmpdir(), 'sqlite-image-cache-')); +const withCache = async (fn: (cache: SqliteImageCacheStore, db: ReturnType) => Promise): Promise => { + const dir = await mkdtemp(join(tmpdir(), 'sqlite-image-cache-store-')); try { const db = createNodeSqliteDatabase(join(dir, 'test.db')); await db.exec( @@ -26,7 +26,7 @@ const withCache = async (fn: (cache: SqliteImageCache, db: ReturnType: "` so the dashboard tells - the operator which upstream a prefixed id came from. `providerData` is - preserved by the clone — the per-provider wire-call still reads the - real upstream model id from there. - -Operator-disabled public ids vanish for that upstream before the entries -are emitted, so a disabled `gpt-4o` hides both `gpt-4o` and -`gpt-4o` from the upstream's contribution. The disable does not -cascade to other upstreams. - -When two upstreams emit an entry under the same public id, the first wins -for metadata and the later one **endpoint-unions** into it. The merged -`endpoints` is the OR of the participants' endpoint capability flags, and -`kind` is recomputed from the union. The same `endpoints` field carries -different values at different scopes: a per-candidate row's `endpoints` -declares one upstream's wire reach (the row `enumerateRealModelCandidates` -produces carries a single-entry `providerModels` map), while the merged -catalog row's `endpoints` is the gateway-wide reach. Per-request dispatch -always reads the per-upstream `ProviderModel` off the chosen candidate via -`providerModelOf(candidate)`. - -Catalog assembly returns two artefacts together: - -- `models: InternalModel[]` — public-id-keyed metadata (id, kind, limits, - pricing, plus the merged `endpoints`). `toPublicModel` projects each row - onto the wire DTO at `/v1/models` and `/models`. -- `upstreamsByPublicId: Map` — every - upstream instance that emitted an entry under the given public id, in - enumeration order. The control-plane catalog endpoint reads this to - render per-model upstream chips without re-walking the catalog. - -Output ordering: the public-facing list is sorted by `compareModelIds` -before it crosses any gateway boundary — `/v1/models`, `/models`, -`/v1beta/models`, and the control-plane catalog endpoint. - -Failed-upstream surfacing during listing: a catalog fetch that rejects -with `AbortError` propagates so the per-request abort signal cannot be -masked by a slow upstream. Any other rejection is captured into the -assembly's `failedUpstreams: string[]` — but listing and per-request -resolution take separate code paths through the SWR cache, so this list -is local to the listing artefact and does not feed back into resolution. - -## Addressable Surfaces - -`modelPrefix.addressable` controls which inbound id forms an upstream -**accepts** at resolution time, independent of which forms it `listed` at -catalog assembly time: - -- `[unprefixed]` — the inbound id is looked up verbatim against the - upstream's catalog. -- `[prefixed]` — the inbound id is accepted only if it starts with the - configured prefix, and the lookup uses `inbound.slice(prefix.length)`. -- `[unprefixed, prefixed]` — both branches are evaluated against the same - catalog fetch; the unprefixed branch is checked first, so when both - branches' lookups succeed the unprefixed match wins ordering ties. - -A single inbound id can therefore produce **two candidates from the same -upstream** when both branches are addressable, the inbound id literally -starts with the configured prefix, and the catalog lists both the bare -and prefixed forms. Each branch is its own catalog lookup; no -deduplication is performed. - -An upstream with no `modelPrefix` is implicitly fully unprefixed. - -## Resolution - -The per-request resolver runs once per serve invocation and produces the -candidate list every dispatch layer reads. - -Inputs: - -- `model` — the inbound id verbatim as the client sent it. -- `upstreamIds` — the caller's effective upstream cap (`null` = - unrestricted; empty list = no providers visible). The cap is the - intersection of per-user and per-api-key allow-lists; unknown ids raise - a configuration error rather than silently narrowing. -- `kind` — `chat` / `embedding` / `image` / `rerank` / `transcription`, - determined by the inbound - endpoint, not by the inbound payload. `/v1/completions` reuses the - `chat` kind and narrows further via its endpoint-key predicate - (`endpoints.completions !== undefined`). - -The resolver is a two-branch chain — an inline alias check at the top, -otherwise the real-catalog walk: +This document follows an inbound model id from catalog discovery to the one +candidate result returned to the client. The stages are deliberately separate: + +- **Catalog assembly** merges provider-emitted models into stable public listing + rows and an upstream reverse index. +- **Model resolution** matches the inbound `model` string and endpoint family to + an ordered list of `(provider, model)` candidates. It does not choose a chat + target protocol. +- **Affinity narrowing** may reorder or restrict that existing candidate list; + it never creates candidates. +- **Target selection** reads one candidate's endpoint map and chooses the + upstream wire protocol for the current source route/action. +- **Candidate iteration** attempts viable candidates sequentially until a + shipped result class counts as success, or returns the last failure. +- **Pricing and usage** select the candidate's rate vector, record request count + separately from measured metric rows, and aggregate realized cost later. + +## Catalog assembly + +`data-plane/providers/registry.ts` constructs enabled provider instances. +`catalog.ts` assembles their models, while `resolution.ts` performs request-time +matching. Both paths use each upstream's SWR-cached `getProvidedModels` result +and an upstream-scoped proxy-aware fetcher. + +For every provider model, `modelPrefix.listed` determines its public catalog +surface: + +- With no prefix policy, the bare provider model id is listed. +- With a prefix policy, each listed `unprefixed` or `prefixed` form becomes a + row. A prefixed row gets the public id `` and display name + `: `. +- Operator-disabled public ids are removed before either form is emitted. The + disable is per upstream and does not hide the same id from other upstreams. + +The prefixed row is a shallow `ProviderModel` clone. `providerData` is preserved +as opaque provider-private invocation data; it is not a universal upstream-id +field. Copilot uses it for raw variants, Claude Code for a dated wire id, and +other providers may omit it or carry a different private shape. Dispatch always +returns the exact provider's own emitted `ProviderModel` to its `call*` method. + +Rows collide by public id. The first contribution wins ordinary display/limit/ +pricing metadata; later contributions union their `endpoints`, recompute `kind` +from that union, and add their own `ProviderModel` to `providerModels` under the +upstream id. Consequently: + +- a merged listing row's `endpoints` describes gateway-wide reach and its + `providerModels` map can contain several upstreams; +- a request candidate is rebuilt from one provider emission, so its + `providerModels` map and `endpoints` describe only that upstream. + +`getModelsFromProviders` returns the merged `InternalModel[]` and +`upstreamsByPublicId`, preserving provider enumeration order in the reverse +index. Per-upstream fetches fan out concurrently. `AbortError` propagates; +other failures are collected while healthy upstreams still contribute rows. If +all catalog fetches fail, the last error surfaces. Listing currently does not +expose the partial-failure names. + +### Listing surfaces + +Listing routes enumerate addressable ids from the same real catalog. Real rows +are sorted with `compareModelIds`; visible aliases are synthesized afterward in +configured alias order, replacing a colliding real id. The control plane can +append addressable-but-unlisted rows after the listed slice. The same underlying +catalog feeds: + +- `GET /v1/models` and `GET /models` — Claude Code discovery and the Floway + public superset include visible aliases. A Codex User-Agent selects the Codex + catalog instead; that branch consumes listed real addressable rows only. +- `GET /v1beta/models` and `GET /v1beta/models/{model}` — chat-kind Gemini model + list and single-model lookup, including visible chat aliases. +- `GET /api/models` — the control-plane list, including visible aliases, + per-row upstream chips, and optional addressable-but-unlisted rows. + +`toPublicModel` projects an `InternalModel` onto the public DTO. Gemini uses its +own projection, Codex synthesizes its client-catalog shape, and the control plane +adds dashboard-only fields. The listing paths and request resolver are separate +consumers of the same SWR cache; listing failures do not feed state into +resolution. + +## Addressable surfaces + +`modelPrefix.addressable` controls which inbound id forms an upstream accepts, +independently of which forms it lists: + +- `[unprefixed]` looks up the inbound id verbatim. +- `[prefixed]` requires the configured prefix and looks up the suffix after it. +- `[unprefixed, prefixed]` evaluates both branches in declaration order against + the same provider catalog. + +An upstream without `modelPrefix` is implicitly unprefixed. A single inbound id +can produce two candidates from one upstream when both forms are addressable, +the id starts with the prefix, and both lookups find catalog rows. Resolution +does not deduplicate these branches. `enumerateAddressableModelIds` separately +adds addressable-but-not-listed forms for alias and control-plane pickers while +pointing them back to their canonical listed row. + +## Request-time model resolution + +`enumerateModelCandidates` receives: + +- the inbound `model` string unchanged; +- the effective upstream cap (`null` means unrestricted, an empty list means no + provider is visible); the cap is the intersection of user and API-key scopes; +- `kind`, derived from the source route: `chat`, `embedding`, `image`, `rerank`, + or `transcription`; +- the background scheduler and runtime-location tag needed by catalog fetch and + proxy selection. + +`/v1/completions` and `/completions` deliberately use `kind: 'chat'`, then +require the `completions` endpoint key. Resolution itself is endpoint-blind. + +The resolver has two top-level branches: +```text +enumerateModelCandidates + ├─ alias exists + │ └─ resolve every target in selection order + │ └─ real-catalog walk, including dated-suffix retry + └─ no alias + └─ real-catalog walk, including dated-suffix retry ``` -enumerateModelCandidates({upstreamIds, model, kind, ...}) ← entry - ├─ alias lookup: getRepo().modelAliases.getByName(model) - │ └─ if matched: walk EVERY target in selection-mode order, - │ delegating each to the real-catalog walk; tag each returned - │ candidate with that target's rule overlay; flatten across - │ targets and dedup by (model.id, upstream, rules) - └─ otherwise: real-catalog walk on the inbound id - └─ enumerateRealModelCandidates per provider (dated-suffix retry - if the first pass matched nothing) -``` -### `enumerateModelCandidates` — entry - -1. List the visible providers through `listModelProviders(upstreamIds)` in - configured `sort_order`. -2. Look the inbound id up in the alias repo. When it names an alias: - walk EVERY target in `selection`-mode order (`first-available` walks - declaration order; `random` shuffles); for each target, delegate to - the real-catalog walk (dated-suffix retry included) and tag each - returned candidate with that target's `rules` overlay. Flatten - across targets (target order preserved) and dedup by - `(model.id, provider.upstream, rules)` — same physical binding with - distinct rules stays as two candidates so both variants can be - attempted; identical triples collapse. The caller's `iterateCandidates` - loop then cascades across the flat list, so a target's upstreams all - failing over falls through into the next target's candidates instead - of hard-failing at the first target. When no target has kind-matching - candidates and no target was seen under any kind, the resolver returns - empty candidates + `sawModel: false`, which surfaces as the regular - model-missing 404 with the alias name in the wording. When targets - exist in catalogs under a different kind, `sawModel: true` propagates - and the caller returns a 400 (model exists but cannot serve this - endpoint). -3. When the inbound id is not an alias, run the real-catalog walk - directly. If the walk returns at least one candidate, OR its - `sawAnyId` is true (the id exists in some catalog under any kind), OR - the id does not match `/-\d{8}$/`, return that result verbatim. - Otherwise strip the trailing eight digits and run the real-catalog - walk once more; `failedUpstreams` from the two attempts is - deduplicated. - -A wrong-kind match (`sawAnyId=true, candidates=[]`) does **not** trigger -the dated-suffix retry — the suffix strip cannot turn a wrong-kind model -into a right-kind one; the empty candidate list surfaces as a 400 "model -exists but the inbound endpoint cannot serve it" instead of a 404. - -### `enumerateRealModelCandidates` — per-id walk - -For each visible upstream, evaluate the prefix and unprefixed branches -the upstream's `addressable` policy allows. Both branches are independent -lookups against the same SWR-cached catalog fetch: - -- Unprefixed branch (when allowed): look up `model.find(m => m.id === - modelId)`. -- Prefixed branch (when allowed AND the inbound id starts with the - upstream's prefix): look up `model.find(m => m.id === - modelId.slice(prefix.length))`. - -For each branch that found a match: - -- If the catalog match's `kind === inboundKind`, push a - `ModelCandidate { provider, model, fetcher }` into the result. -- If the match exists but `kind !== inboundKind`, set `sawAnyId = true` - but do not push. - -`sawAnyId` aggregates across upstreams: true whenever any branch in any -upstream found the lookup id in its catalog, regardless of kind. Operator- -disabled ids are not counted toward `sawAnyId` (they vanish from the -catalog before lookup). - -Per-upstream catalog fetches fan out concurrently so a slow upstream -cannot stall the rest. A catalog fetch that rejects with `AbortError` -propagates so the per-request abort signal cannot be masked. Other -rejections are captured into the per-id `failedUpstreams` list, which -`enumerateModelCandidates` deduplicates across the two attempts and the -caller's failure renderer inlines into 404 / 400 wording as a -parenthetical. - -### Why kind threads down - -A post-filter shape ("walk first, drop wrong-kind after") would entangle -the dated-suffix retry decision with the kind filter — the retry has to -distinguish "the inbound id was nowhere in any catalog" (worth retrying -on a stripped form) from "the id existed but only under the wrong kind" -(stripping cannot fix that). Threading `kind` into the per-upstream walk -keeps the candidate list clean of wrong-kind entries at every layer and -keeps the `sawAnyId` signal exact: it answers "did this id appear in -some catalog at all" regardless of kind. - -The dated-suffix fallback exists for clients that pin to a vendor's dated -release id (typical for Anthropic-style `claude-sonnet-4-5-20250929`) -against a catalog that only lists the base id. It deliberately operates -on the **full resolution flow**, not on a single upstream's catalog, so -the stripped id is tried against every visible upstream in its own -enumeration order. - -The resolver never mutates the inbound id on the request body. The -returned candidates carry an `InternalModel` whose `providerModels` map -holds the emitting upstream's `ProviderModel` (with `providerData` and -`enabledFlags`); the dispatch layer reads that entry via -`providerModelOf(candidate)`. - -## Alias Resolution - -The rule overlay rides on the `ModelCandidate.rules` field. Dispatch -reads it in each attempt's terminal wire call, right before destructuring -`payload.model` out of the body, via -`applyRulesToUpstream{ChatCompletions,Responses,Messages}` in -`data-plane/chat/shared/alias-rules.ts`. Passthrough seams thread -alias-origin candidates through the same iteration but never observe -non-empty rules (non-chat alias kinds — `embedding`, `image`, `rerank`, -`transcription` — carry -`{}` by schema; the apply-rules call is a no-op). - -By construction alias names never re-enter the alias layer: the target -id is a real model id, so the shadow pattern (an alias whose first -target matches its own name) resolves to the real model on the first -pass. - -The alias-resolved target id, not the alias name, is what dispatch -addresses upstream. When no target has kind-matching candidates and no -target was seen under any kind, the resolver returns empty candidates + -`sawModel: false`, and the caller renders the regular model-missing 404 -with the alias name (still on `payload.model`) in the wording. When -targets exist under a different kind, `sawModel: true` surfaces a 400. -The upstream response's `model` field -reports the model that actually served the request, so a client that -wants to attribute a response to a particular target can compare that -against the id it sent. Alias listing behavior on `/v1/models`, -`/v1beta/models`, and the Codex catalog is implemented in -`data-plane/shared/listing/alias.ts`. - -## Candidate Shape +### Real-catalog walk + +`enumerateRealModelCandidates` fans out across visible providers while +preserving provider order in its collected result. For each provider it builds +the permitted unprefixed/prefixed lookup ids and searches the cached catalog. +A found row sets `sawAnyId` regardless of kind; only a row whose `kind` matches +the source route becomes a candidate. Disabled ids are absent before this test. + +A provider catalog rejection contributes that upstream's display name to +`failedUpstreams`; `AbortError` propagates. The aggregate therefore separates: + +- `candidates` — kind-matching real rows; +- `sawAnyId` — the id existed under any kind; +- `failedUpstreams` — non-abort catalog failures. + +If the first real-catalog walk finds neither a candidate nor any id, and the +inbound id ends in `-\d{8}`, the resolver strips that suffix once and walks all +providers again. This supports dated client ids such as +`claude-sonnet-4-5-20250929` when a catalog lists only the base id. A wrong-kind +match suppresses the retry because changing the spelling cannot change its +kind. Failure names from the two walks are deduplicated. + +The inbound request body is never mutated by this fallback. Candidates carry +the real matched model id. + +### Alias walk + +When the inbound id names an alias, every target is resolved rather than only +the first target with a catalog match. `first-available` preserves declaration +order; `random` shuffles target order. Each target delegates to the complete +real-catalog flow and tags every returned candidate with that target's `rules`. +The results are flattened in target order and deduplicated by +`(model.id, upstream id, rules)`. The same physical binding remains twice when +its rule overlays differ. + +This flattening is what lets candidate iteration fall through all upstreams for +one target and then continue into the next target. When no target yields the +requested kind: + +- no target id seen under any kind gives `sawModel: false`, and callers render a + model-missing 404 using the original alias name; +- a target seen only under another kind gives `sawModel: true`, and callers + render a 400 because the model exists but cannot serve the source endpoint. + +Alias names never recurse. A target id is always resolved as a real id, so an +alias that shadows a same-named real model can target that real binding without +re-entering alias lookup. + +Alias rows are synthesized only for listings. The Floway/Claude Code shapes on +`/v1/models` and `/models`, both Gemini listing routes, and `/api/models` merge +aliases according to caller scope. The Codex User-Agent branch deliberately +uses listed real addressable rows without alias synthesis. The shared addressable +and alias implementations live under `data-plane/shared/listing/`. + +## Candidate shape ```ts interface ModelCandidate { @@ -263,93 +187,139 @@ interface ModelCandidate { } ``` -- `provider` is the resolved upstream provider instance — every wire call reads - its upstream id, upstream name, provider kind, and implementation from this - binding. -- `model` is the merged public row for this id, projected to a single - contributing upstream: `providerModels` carries exactly one entry keyed - on `provider.upstream`. That entry is the `ProviderModel` the upstream - emitted verbatim — its `providerData` carries the per-provider wire id, - its `enabledFlags` carries the operator's per-model flag set, and its - `pricing` carries the exact schedule for this candidate. Dispatch, telemetry, - and interceptor gates read the entry through `providerModelOf(candidate)`. -- `fetcher` is the per-request proxy-chain-bound `Fetcher` for the - candidate's upstream, minted once at resolution time and carried with - the candidate that dispatches. -- `rules` is present only on candidates minted by the alias walk — it - carries the picked target's rule overlay so each attempt's terminal - wire call can apply it against the target IR via - `applyRulesToUpstream{ChatCompletions,Responses,Messages}`. Absent - (undefined) on direct-resolution candidates; present (possibly `{}`) - on alias-origin candidates. - -A target protocol (e.g. `messages` / `responses` / `chat-completions`) is -deliberately **not** part of the candidate — see Endpoint Selection. - -## Endpoint Selection - -Resolution returns kind-matched candidates without consulting -`model.endpoints`. The actual target endpoint is chosen at attempt -dispatch, by a per-inbound-operation preference table that lives next to -the attempt code: - -- `/v1/messages` generate: `messages` > `responses` > `chat-completions`. -- `/v1/messages` countTokens: `messages` only. -- `/v1/responses` generate: `responses` > `messages` > `chat-completions`. -- `/v1/responses/compact`: `responses` > `messages` > `chat-completions`. - Non-responses targets are reached through the responses-compact-shim - interceptor, which pivots the action and synthesizes the compact - envelope from a generate-shaped turn. -- `/v1/chat/completions`: `chat-completions` > `messages` > `responses`. -- `/v1beta/models/{m}:generateContent` and `:streamGenerateContent`: - `chat-completions` > `messages` > `responses` (Gemini is always served - via translation). -- `/v1beta/models/{m}:countTokens`: `messages` only. - -Each preference table is wrapped by a `chatTargetPicker(preference)` -factory exposing two functions: - -- `canServe(endpoints): boolean` — true when at least one preferred - target's endpoint key is present on the candidate. Serve calls this to - filter out candidates whose upstream wire cannot serve the inbound - operation, so dispatch sees only viable candidates. -- `pick(endpoints): ChatTargetApi` — returns the first preferred target - whose endpoint key is set. Attempt calls this once it has a candidate - to choose which upstream wire the dispatch goes out on. `pick` is - contractually total — a call that returns null would mean serve let a - non-viable candidate through, which is a contract breach. - -The per-protocol picker definitions live in the attempt files -(`xTarget = chatTargetPicker([...])`), and both serve and attempt import -the same picker object — serve uses `.canServe`, attempt uses `.pick`. -The `targetApi` decision is therefore exclusively an attempt-time -concern; it is never carried on the candidate or threaded as an explicit -argument. - -Single-target endpoints (`/v1/embeddings`, `/v1/images/*`, `/v1/completions`, -`/v1/audio/transcriptions`, and the four rerank ingress routes) -follow the same rule with a single-key predicate -(`endpoints[endpointKey] !== undefined`) instead of a multi-target -preference list. The kind-filter at resolution time guarantees a -chat-kind candidate is never offered to a passthrough endpoint and vice -versa; the endpoint-key check at attempt time then narrows within the kind. -Rerank additionally requires the selected provider model to carry an explicit -`rerankTarget`; the target protocol and optional path are model metadata, not -an upstream-level default. - -Audio transcription filters on `audioTranscriptions`; its multipart body is -buffered and normalized before candidate iteration, then rebuilt with the -selected provider model id for each attempt. The successful upstream media -type selects raw JSON/text/subtitle forwarding or transcription SSE handling; -the route never translates through a chat protocol. - -## Pricing and Cost - -Model metadata uses `pricing?: ModelPricing`. Its `entries` form a symmetric -price schedule: exactly one Base entry has no selector, while every non-Base -entry declares the same metrics at an explicit coordinate. A metric identifies -both the measured quantity and its base unit, and every rate is the price of one -such unit. Prices are canonical non-negative decimal strings. +- `provider` is one configured upstream binding: upstream id/name, provider kind, + prefix policy, and concrete `ProviderInstance`. +- `model` is a real `InternalModel` narrowed to that upstream. Its sole + `providerModels[provider.upstreamId]` entry is the provider's original + `ProviderModel`, including opaque `providerData`, resolved `enabledFlags`, + optional per-model flag overrides, rerank target, and pricing schedule. + `providerModelOf(candidate)` is the only dispatch accessor. +- `fetcher` is the request's proxy-chain-bound fetcher for this upstream. +- `rules` is absent on direct candidates and present on alias candidates, + including `{}` for an alias target with no overlay. + +A candidate never carries the chosen target protocol. Model resolution answers +“which configured upstream/model bindings match?” Target selection answers +“which wire protocol should this attempt use?” + +## Target selection + +Each chat source route owns an ordered `chatTargetPicker`. Serve uses +`canServe(candidate.model.endpoints)` to remove candidates with no acceptable +target; attempt later calls `pick` on that same picker to choose the first +present endpoint key. A null pick is a contract violation because serve must +have filtered the candidate first. + +The shipped preferences are: + +- Messages generate: `messages` > `responses` > `chat-completions`. +- Messages count tokens: `messages` only. +- Responses generate and compact: `responses` > `messages` > + `chat-completions`. Compact reaches non-Responses targets through the compact + shim, which pivots to a generate-shaped turn and synthesizes the compact + envelope. +- Chat Completions: `chat-completions` > `messages` > `responses`. +- Gemini generate and stream-generate: `chat-completions` > `messages` > + `responses`. +- Gemini count tokens: `messages` only. + +The picker objects live beside their attempt implementations; source serve and +attempt import the same object. `targetApi` is attempt-local and is neither +part of `ModelCandidate` nor an output of resolution. + +Single-wire source routes use the same separation with one endpoint predicate: +OpenAI Completions, Embeddings, Images, and Audio Transcriptions, plus rerank. +The canonical `/v1/*` routes and their shipped bare aliases share the same +handlers where a bare alias exists. Rerank is not a passthrough protocol: after +the `rerank` endpoint check, its provider model must carry a `rerankTarget` that +selects one of the translated target dialects and optional path. + +Completions is the passthrough exception in alias-rule handling. It resolves as +`kind: 'chat'`, so a chat alias may carry non-empty rules, but the Completions +wire has no rule-application step and ignores them. Embeddings, Images, Audio +Transcriptions, and Rerank use non-chat alias kinds whose schemas require empty +rules. Chat source routes apply rules after target selection, on the selected +target protocol's native fields. + +Audio Transcriptions buffers and normalizes multipart input before iteration, +then rebuilds it with each attempted provider model id. Its successful media +type selects raw JSON/text/subtitle forwarding or transcription-SSE handling; +it never translates through a chat protocol. + +## Candidate ordering and affinity + +Before affinity, candidates are ordered: + +1. alias target order, when the source id is an alias; +2. configured provider `sort_order` within each target/real-id walk; +3. addressable-form order within one provider (normally unprefixed before + prefixed when configured that way). + +Chat-shaped ingress then calls `narrowCandidatesByAffinity`. Force evidence +restricts the list to one required upstream/model pair; preference evidence +moves the latest exact upstream/model/rules match to the front. Affinity never +adds a candidate. Carrier placement and restoration are specified in +[AFFINITY.md](./AFFINITY.md). + +## Sequential candidate iteration + +Serve passes the viable, affinity-narrowed list to `iterateCandidates`. Attempts +run sequentially, never concurrently. The iterator resets per-attempt timing, +stamps telemetry attribution for the current candidate before calling it, and +classifies the returned envelope exactly as follows: + +- `events` is success as soon as the SSE event stream is handed off; +- `result` is success for the non-streaming Responses compact envelope; +- `plain` is success only for a 2xx status; +- `api-error`, `internal-error`, and non-2xx `plain` are failures that fall + through to the next candidate. + +The first success is final. Once an `events` result opens, a later mid-stream +failure cannot start another upstream because the client has already consumed +part of the stream. If every attempt returns a classified failure, the iterator +returns the most recent one; the source renderer forwards that upstream-shaped +result or internal-debug envelope. Thus 4xx/429/5xx responses represented as +`api-error` or non-2xx `plain`, plus represented `internal-error` values, can +roll over while candidates remain. A thrown JavaScript exception is not a +result class: it exits iteration to the source's outer error handler and does +not advance. The final classified failure is never replaced with a synthetic +“all upstreams failed” response. + +An empty viable list never enters the iterator. Each source route renders its +own protocol-shaped 404/400 for missing, wrong-kind, or unsupported-endpoint +models. + +### Performance timing + +Each iteration clears `upstreamCallStartedAt` and `firstOutputTokenAt`. +Providers receive `wrapUpstreamCall`; invoking it stamps +`upstreamCallStartedAt` synchronously immediately before the outbound fetcher +runs. This is a pre-dial anchor: proxy selection already happened when the +candidate fetcher was constructed, but proxy connect/TLS/CONNECT handshakes +inside that fetcher are included because the client waits for them. Gateway +parsing, model resolution, affinity, translation, and interceptor work before +the provider dispatch are outside TTFT. + +The first emitted output token stamps `firstOutputTokenAt`. Chat TTFT is their +monotonic difference. A represented failure with no output records a zero-output +error; a successful path without both stamps records neutral performance. +Successful passthrough operations record neutral performance rather than +inventing a token TTFT; their failures remain zero-output errors. Attribution +belongs to the terminal candidate because the iterator replaces the attempt +context before every run. + +## Pricing, request counts, and metric rows + +`BILLING_METRICS` in `packages/protocols/src/common/pricing.ts` owns the metric +vocabulary. `ProviderModel.pricing` is a reusable `ModelPricing` schedule. Each +schedule has exactly one Base entry with no selector; every non-Base entry has +an explicit selector and exactly the same metric keys as Base. `PriceVector` +values are canonical non-negative decimal strings containing USD per one base +unit of the +named billing metric. Token helpers may accept vendor-published per-million +prices, but they divide during model construction: model metadata, selected +rates, and persisted `unit_price` are always per one token, second, or rerank +search as named by the metric. ```ts { @@ -360,94 +330,68 @@ such unit. Prices are canonical non-negative decimal strings. } ``` -Token metrics count individual tokens. General `input_tokens` and -`output_tokens` retain the upstream's meaning and are not assumed to be -text-only. Explicit modality metrics such as `input_image_tokens` are used only -when the upstream reports a disjoint counter. The dashboard displays token -rates as dollars per million tokens, but that scale exists only at the UI -boundary; model metadata and usage snapshots always store the per-token rate. -Measured usage is authoritative over pricing configuration. Each observed -metric looks up only its matching rate: `input_audio_seconds` is never converted -to tokens, and token metrics are never inferred from duration. A metric without -a configured rate remains present with `unit_price: null`, while the request is -still counted. A model may price seconds, audio tokens, general input tokens, -and output tokens simultaneously. - -```text -ModelPricing - → runtime facts (service tier, input-token count) - → exact PricingEntry - → PriceVector rates snapshot - → measured quantities × base-unit rates - → realized USD cost -``` - -`serviceTier` is an open-string equality axis. `inputTokens` thresholds reprice -the whole request rather than a marginal suffix. Threshold-only entries are -global; thresholds combined with equality coordinates apply only within that -scope. Runtime selects the highest matching global or scoped threshold and then -performs one exact selector lookup. A missing full coordinate selects the whole -Base vector, never a field-by-field merge or a lower threshold band. - -The naming boundary is enforced in code and on the wire: - -- `pricing` is reusable model metadata and operator-authored configuration; -- `rates` is the resolved `PriceVector` for one request; -- `metric` identifies a measured counter and its base unit; -- `quantity` is the persisted amount measured for one metric; -- `unit_price` is the persisted per-unit rate snapshot for that metric; -- `cost` is the aggregatable USD result exposed by usage views. - -Every settled request increments `usage_requests`, even when the upstream gives -no detailed breakdown. Detailed usage is stored separately as -`metric + quantity + unit_price` rows; request-only records therefore remain -visible while their metric and cost values are unknown. The SQL schema requires -a non-empty metric string but leaves its vocabulary to application validation, -so adding a metric does not require rebuilding the table. - -Telemetry snapshots the selected coordinate and rates from the exact dispatched -`ProviderModel`. Later catalog changes therefore cannot rewrite historical -usage, and bucket identity remains stable through canonical selector JSON plus -the metric name. Quantities, rates, and computed costs stay canonical decimal -strings through persistence and aggregation; numeric conversion occurs only at -the final chart-coordinate boundary. - -## Candidate Ordering - -Candidates are ordered before they reach dispatch: - -- Across upstreams, by configured `sort_order` (lower first). An upstream - with an explicit `sort_order` ahead of another upstream's gets first - shot at the inbound id. -- Within a single upstream, the unprefixed branch precedes the prefixed - one when both apply. - -Chat-shaped ingress authenticates client-carried affinity before dispatch. -Affinity only reorders or narrows candidates already produced by resolution; -it never adds new ones. Protocol placement and restoration are documented in -[AFFINITY.md](./AFFINITY.md). - -Serve dispatches the first candidate of the ordered list exactly once. -The attempt's non-throwing result — an SSE-stream event handoff (chat) or -a 2xx Response (passthrough), an upstream-shaped API error, or an -internal-debug failure — is the request's final answer; an upstream -4xx/5xx surfaces verbatim rather than rolling over to another candidate. - -## Known Edges - -- A catalog that disabled the inbound id under one upstream still serves - it from another that allows it; the operator's per-upstream disable - list is intentionally not cross-cutting. -- A `-\d{8}$` strip is the only inbound-id normalization the gateway - applies. Vendor variant suffixes (effort tiers, context-window - variants, fast-mode) are routed by request-body fields against a - catalog that lists only the base id; clients that send raw variant ids - receive a model-missing 404 unless their inbound id happens to match - another upstream's catalog entry verbatim. -- The catalog is SWR-cached per upstream. A model the operator just - enabled is visible to resolution as soon as the next cached refresh - lands; SWR-soft hits do not block the request. -- Dual-addressable surfaces (`[unprefixed, prefixed]`) intentionally - retain both candidate paths instead of deduping. The unprefixed - candidate precedes the prefix-stripped one in the ordered list, so it - is the one dispatched. +The dashboard may display token rates per million tokens, but that scaling is +UI-only. `unit_price` is not a rate vector and not a per-request total: each +persisted metric row carries one scalar USD rate for one base unit of that row's +`metric`. Realized cost is `quantity * unit_price` with no additional scaling. + +Observed quantities are authoritative. General `input_tokens` and +`output_tokens` preserve the upstream's unsplit counters; modality metrics such +as `input_image_tokens`, `input_audio_tokens`, `input_audio_seconds`, and +`output_image_tokens` are used only when the upstream reports disjoint values. +No duration/token or modality conversion is inferred. A measured metric with no +selected rate is still stored with `unit_price: null`. + +Runtime pricing facts currently have two axes: + +- `serviceTier` is open-string equality after base-tier markers are normalized; +- `inputTokens` selects whole-request threshold bands, not a marginal suffix. + +Threshold-only entries are global. Thresholds combined with equality +coordinates apply within that scope. Runtime chooses the highest matching band +across the applicable global and equality-scoped thresholds, then performs one +exact selector lookup. If the full coordinate has no entry, the whole Base +vector is selected; rates are never merged field-by-field or inherited from a +lower band. If the model has no pricing schedule, runtime retains the observed +equality selector and selects `rates: null`. + +For one terminal candidate, telemetry snapshots the selected selector and rates +from that exact provider model. Later catalog changes cannot rewrite historical +rows. The naming boundary is: + +- `pricing` — reusable model metadata; +- `selector` — the canonical runtime coordinate for one request bucket; +- `rates` — the selected `PriceVector`, or null when wholly unpriced; +- `metric` — one measured quantity vocabulary key and its base unit; +- `quantity` — the canonical decimal amount measured for that metric; +- `unit_price` — that row's canonical per-base-unit USD rate, or null; +- `cost` — the aggregated sum of priced `quantity * unit_price` products. + +Request count and metric rows are separate facts. Every terminal call that +reaches `settle` or `settleUsageMeasurement` increments +`usage_requests.requests` for its +`(key, public model, upstream, model key, hour, pricing selector)` bucket even +when the upstream supplies no usage breakdown. The `usage` table receives zero +or more rows keyed by that same bucket plus `metric`; each row aggregates its +own `quantity` and scalar `unit_price`. Repository reads assemble both tables +into one `UsageRecord`, so a request-only record has `requests > 0` and an empty +metric list rather than a fabricated zero-token row. + +Aggregation sums request counts independently of metrics. It skips null-price +rows when computing cost: cost is null only when no metric row was priced, and a +non-null cost may still be partial when sibling metrics are unpriced. Quantities, +rates, and costs remain canonical decimal strings through persistence and +aggregation; conversion to JavaScript numbers happens only at the final chart +coordinate boundary. + +## Known edges + +- Disabling an id on one upstream does not hide the same id on another. +- The `-\d{8}` retry is the only request-time model-id normalization. Vendor + effort/context/speed variants must be advertised or sent through request + fields; arbitrary suffixes are not rewritten. +- Catalogs are SWR-cached per upstream. Soft-fresh reads do not block on refresh. +- Dual-addressable forms intentionally remain separate candidates. Their order + follows the configured `addressable` array. +- A listing row's unioned endpoint map must never be used for dispatch; attempt + code reads the one-upstream candidate row through `providerModelOf`. diff --git a/docs/TRANSLATION.md b/docs/TRANSLATION.md index edfc2c325d..d7ada2cb79 100644 --- a/docs/TRANSLATION.md +++ b/docs/TRANSLATION.md @@ -1,64 +1,82 @@ # Data Plane Translation -This document describes the current translation behavior between the four -client-facing data-plane APIs: - -- Anthropic Messages: `POST /v1/messages` -- OpenAI Responses: `POST /v1/responses` -- OpenAI Chat Completions: `POST /v1/chat/completions` -- Google Gemini: `POST /v1beta/models/{model}:generateContent`, - `POST /v1beta/models/{model}:streamGenerateContent`, - `POST /v1beta/models/{model}:countTokens`, and `GET /v1beta/models` - -Route planning uses provider-owned model capability data from -`supported_endpoints`. Request translation is direct and pairwise; there is no -canonical internal request IR. Provider-specific quirks live in provider-owned -model projection or provider interceptor collections rather than inside pairwise -translators. +This document specifies Floway's translated data-plane protocols: + +- Anthropic Messages generation and token counting; +- OpenAI Responses generation and compaction; +- OpenAI Chat Completions; +- Google Gemini generation, token counting, and model projection; +- Cohere-, Jina-, Voyage-, and DashScope-shaped rerank requests. + +Chat-family route planning resolves a provider candidate first, then chooses a +target protocol from that candidate's `endpoints`; model resolution and target +selection are separate stages described in [RESOLUTION.md](./RESOLUTION.md). +Chat-family request/event translation is direct and pairwise and has no +canonical internal request IR. Rerank is different: its source dialects +normalize through `CanonicalRerankRequest` and a canonical result before being +rendered back to the source dialect. + +Translation pair names use **X Via Y**: X is the client/source protocol and Y is +the selected upstream/target protocol. The names match directories such as +`messages-via-responses/` and `responses-via-messages/`. Provider-specific wire +quirks stay in provider-owned projection, fetch, or interceptor modules rather +than in pairwise translators. ## Boundary Rules -- Pairwise translators preserve source semantics where the target API has a - natural counterpart. +- Pairwise translators preserve source semantics where the target protocol has + a natural counterpart. Fields with no target meaning are omitted rather than + hidden in private wire bridges. - Responses wire input accepts OpenAI's EasyInputMessage shorthand without a - `type` field. HTTP, WebSocket, and direct Responses-source translator - boundaries normalize it to an explicit `type: "message"` before storage, - interception, or translation. Malformed untyped items are rejected as caller - input errors at the same boundary. -- Responses create and compact request shapes model open-string - `prompt_cache_options` and `prompt_cache_retention`. Native compact projection - forwards both controls verbatim; provider-specific rejection remains a - boundary workaround (Codex strips `prompt_cache_retention`). + `type`. HTTP, WebSocket, and direct Responses-source translator boundaries + normalize it to `type: "message"` before storage, interception, or + translation; malformed untyped items are caller errors. +- Responses create and compact model open-string `prompt_cache_options` and + `prompt_cache_retention`. Native compact projection forwards them unchanged; + a provider that rejects one owns that wire-boundary policy. - Explicit `prompt_cache_breakpoint` metadata on text, image, and file content survives canonicalization and retained-message compaction. -- Translators do not synthesize defaults merely to satisfy a target shape. - Examples: no translated-only `temperature: 1`, `store: false`, +- Translators do not invent defaults merely to satisfy a target shape. They do + not add translated-only `temperature: 1`, `store: false`, `parallel_tool_calls: true`, or `reasoning.summary: "detailed"`. -- Fields with no natural target-side meaning are omitted instead of encoded - into private bridges. -- Each protocol has one gateway-side interceptor list that runs once when the - request enters the gateway in that protocol's shape. A Messages interceptor - sees a Messages request and Messages result/events whether Messages is the - source the client sent or the target the upstream serves; Responses, Chat - Completions, and Gemini follow the same rule. -- Role compatibility is target-only within those lists, so translator bullets - describe the intermediate target shape rather than an unconditional final - wire role. Chat Completions and Responses apply enabled role rewrites in the - fixed order system-to-developer, developer-to-system, then interleaved - system-to-user. Messages can demote every inline system message to user - because its only first-position system slot is the top-level `system` field. -- Each provider runs its own boundary interceptor chain inside its `call*` - method, after the gateway-side chain and immediately before the wire. The - boundary chain owns provider-specific quirks: image compression, header - shaping (`copilot-vision-request`, `x-initiator`, anthropic-beta filtering), - field stripping (Copilot Responses `service_tier`, `image_generation`, - `store: false` forcing), Copilot Messages `cache_control.scope` scrubbing, - and similar. -- The provider parses raw upstream frames into typed protocol events before - returning to the gateway, so every interceptor sees decoded events, not - SSE bytes. The HTTP / WebSocket adapter at the gateway boundary owns the - final wire shaping after the protocol events have been translated back to - the source-protocol shape. +- Gateway Hono middleware and protocol interceptors are separate abstractions. + Hono middleware owns HTTP auth, validation, logging, CORS, and top-level error + shaping. `Interceptor` callbacks receive `(ctx, env, run)` + around a typed protocol invocation; they can transform request payloads and + decoded result events but never receive Hono's `Context`/`Next` pair. +- A protocol's gateway interceptor list runs whenever an invocation enters that + protocol shape, whether it is the client source or a translated target. The + registration files live at + `packages/gateway/src/data-plane/chat//interceptors/index.ts`. +- Role compatibility is target-side within those lists, so pair sections below + describe the translator's intermediate target shape, not an unconditional + final wire role. Chat Completions and Responses apply enabled role rewrites + system-to-developer, developer-to-system, then interleaved system-to-user. + Messages can demote inline system messages because its only dedicated system + slot is top-level `system`. +- A provider may run another typed interceptor envelope inside `call*`, after + the gateway protocol envelope and immediately before its wire call. Provider + registration files and their referenced implementation comments are the + authority for wire workarounds; the provider parses upstream SSE into typed + protocol frames before returning them. The source HTTP/WebSocket adapter + performs final serialization only after translation back to the source. +- Claude Code Messages has two provider-owned paths. A request recognized as + Claude Code-shaped skips the re-mimicry envelope, but the fetch path still + allowlists its fingerprint headers, replaces Authorization, stamps the dated + provider model id, and forces streaming. Other clients and translated sources + run the ordered re-mimicry chain before that fetch path. See the + [provider branch](../packages/provider-claude-code/src/provider.ts), + [boundary registration](../packages/provider-claude-code/src/interceptors/messages/index.ts), + and [wire call](../packages/provider-claude-code/src/fetch.ts). +- Native Stateful Responses and affinity belong to the Responses source edge, + outside candidate attempts. A translation whose target is Responses receives + a no-backing scratchpad store and cannot take ownership of another source + protocol's durable item state. See [AFFINITY.md](./AFFINITY.md). + +Copilot audits build their inventory from provider registration/default code +and the `audit-copilot-workarounds` skill, including auth, model shaping, +compaction, and item-id behavior outside interceptor registries. Support +details that do not cross a translation boundary remain in those owners. ## Usage And Billing Facts @@ -74,223 +92,20 @@ buckets. Streaming `message_start` and `message_delta` usage is accumulated as one snapshot, including late input counts and atomic replacement of the `speed` / `service_tier` pair. -Some billing facts have no native field in every protocol. A symbol-keyed -`USAGE_BILLING` sidecar carries cache-write TTL detail and the served tier only -inside Floway's typed event pipeline. Translation and stream reassembly retain -it, while JSON serialization omits it from client responses. Consequently a -fact may survive a Chat, Responses, Messages, or Gemini intermediate shape -without inventing a private wire field. +Some billing facts have no native field in every OpenAI/Gemini usage shape. A +symbol-keyed `USAGE_BILLING` sidecar can carry total cache-write tokens, the +1-hour cache-write subset, and the served tier on Chat Completions, Responses, +and Gemini usage objects inside Floway's typed event pipeline. Translation, +affinity cloning, and stream reassembly retain it, while JSON serialization +omits the symbol from client responses. Messages usage does not carry this +sidecar: its native cache-creation fields and TTL detail are already disjoint, +and translation reads or writes those fields directly. Response-side blank, `default`, and `standard` tier markers identify base service. Every other open-string tier is preserved byte-for-byte. Gemini candidate and thought counts remain disjoint in `usageMetadata`; thought tokens are billed as reasoning/output exactly once. -## Boundary Workarounds - -### Messages — gateway interceptors - -- rejects body-level `anthropic_beta` and `betas`; Anthropic beta flags are - accepted only from the `anthropic-beta` HTTP header and passed to Messages - providers as a separate parameter -- after planning, rewrites native Anthropic `web_search_*` server tools into a - gateway-executed client-tool shim when the selected provider/target requires - it, decodes shim-owned replay history back into upstream `search_result` - blocks, and rewrites shim-owned search results/citations back to native - Messages shape. The shim is enabled by default for Copilot Messages targets - too, because Copilot search is executed by the gateway. `count_tokens` - performs the same request preparation without the generation-only response - stream rewrite. -- strips reserved `x-anthropic-billing-header` prompt-attribution lines and - `cch=` cache markers that some clients inline into the `system` - prompt; these are opaque to every upstream and poison prompt-cache prefix - hashes -- strips stray `[DONE]` sentinels from Anthropic-shaped streams - -Messages generation and `count_tokens` apply billing-attribution stripping, -forced-tool reasoning compatibility, inline-system role compatibility, and -web-search request preparation in the same order. Token counts therefore see -the same gateway-level compatibility shape as generation; each provider still -owns any operation-specific wire-boundary transforms. - -### Messages — Copilot provider boundary chain - -- promotes upstream `thinking.display` during active thinking to avoid Copilot - Messages idle gaps, then preserves downstream omitted-thinking semantics -- whitelists supported `anthropic-beta` values on the wire -- auto-adds `interleaved-thinking-2025-05-14` when budget thinking requires it -- strips unsupported per-tool `eager_input_streaming` -- strips unsupported `cache_control.scope` before calling Copilot native - Messages. Custom Messages providers receive the caller's `cache_control` - object unchanged. -- rewrites Copilot context-window errors into the compact Messages error shape - -### Messages — Claude Code provider boundary chain - -Claude Code (Claude.ai subscription) bills `/v1/messages` requests against -the operator's plan only when the wire matches a real `claude-cli` session. -The boundary detects already-CC-shaped traffic up front and lets it pass -through verbatim, so the operator's own session fingerprint reaches -Anthropic untouched. Anything else — third-party Messages clients, other -adapters, translated Chat/Responses/Gemini sources — runs through the full -re-mimicry chain so the upstream still accepts and bills it as plan -traffic. - -Re-mimicry runs in this order: - -- backfills required `max_tokens` and `temperature` defaults so the rest of - the chain and the downstream fingerprint compute see the fully-formed CC - wire shape -- synthesizes `metadata.user_id` (legacy `user__account__session_` - or new JSON `{device_id, account_uuid, session_id}` shape, picked from the - inbound request) before system text is hoisted, so two conversations - sharing a system prompt do not collide on session id -- hoists the caller's `system` text into a synthetic user/assistant pair so - the next three injectors own `payload.system` -- injects `system[0]`: per-request CC billing/identity block carrying the - `cc_version` fingerprint and a `cch=` cache marker (sha256 + slice - algorithm, salt `59cf53e54c78`, indices `[4, 7, 20]` — verified unchanged - v2.1.10 → v2.1.181) -- injects `system[1]`: canonical CC identity text -- injects `system[2]`: cached boilerplate default template, marked - `cache_control: { type: "ephemeral" }`. Demoted to non-cached when the - caller is already at the cache-breakpoint cap. - -Header shaping (UA, `X-Stainless-*`, `anthropic-beta`) and the dated -upstream model id are set in the provider's fetch path, not as interceptor -steps. - -### Responses — gateway flow and interceptors - -- resolves `previous_response_id` and every `item_reference` through the - Responses store before candidate dispatch. Every reference is replaced with - the first durable client-facing item under its emitted ID before - affinity projects blobs for a candidate. Item IDs are opaque and never - reformatted or rewritten. A missing durable payload returns `item_not_found`, - and no provider receives an - `item_reference` carrier. - -- executes hosted `web_search` and `image_generation` through the server-tool - shim for translated targets and native Responses providers that opt in. Each - hosted family validates every declaration, selects the last complete alias - and configuration, injects one collision-resolved function, executes the - configured backend, and restores the selected hosted declaration plus a - matching hosted `tool_choice` in synthesized echoes. Azure and Copilot return - the same last-wins result for reversed web-search controls, matching Azure's - hosted image-generation behavior - ([probe evidence](https://github.com/Menci/Floway/pull/172#issuecomment-4971739422)). - Image edit sources are flattened in declaration order from message content, - function/custom tool output, and replayed image-generation results. Remote - HTTP(S) sources are downloaded once during request preparation through the - shared external-image loader, with manual redirect handling, bounded - streaming, public-address-only Node egress, and Azure-compatible errors for - download and image-format failures. The original URL remains visible to the - orchestrator while cached bytes are reused by the edit backend. Inline and - remote masks are materialized by the same path. A mask `file_id` remains an - explicit `unsupported_image_source` because it requires the owning - upstream's authenticated Files namespace. GIF sources are transcoded to WebP - for `/images/edits`, and a mask alone supplies edit context for `auto`/`edit`. -- removes unsupported `image_generation` Responses tool entries and forced - tool choices that targeted them before target request construction. Other - hosted/deferred Responses tools, including `web_search`, `tool_search`, and - `namespace`, remain visible to native Responses targets. Translated - Messages/Chat targets currently narrow tool conversion to `function` and - Freeform `custom` tools; the hosted/deferred translated semantics are - tracked separately. -- preserves Freeform `custom` tools: native Responses targets receive them - directly; translated targets wrap them as single-string function tools (see - "Responses Custom Tool Wrapping"). -- retries intermittent upstream `cyber_policy` failures before the failed - attempt reaches the source-shaped response - -### Responses — Copilot provider boundary chain - -The same boundary runs for both `/v1/responses` (streaming) and -`/v1/responses/compact` (non-streaming). - -- strips unsupported `service_tier` -- removes the `image_generation` tool entry (Copilot does not host it) -- forces `store: false` on the wire — the gateway always owns Responses - persistence; the original `store` is captured by the entry adapter before - the chain runs, so durable storage is unaffected -- compresses inline base64 image data URLs to WebP across canonical message, - function-output, and custom-output content; remote URLs and file IDs remain - unchanged -- injects `copilot-vision-request` when any of those canonical content arrays - carries an image, and derives `x-initiator` from the final canonical item - (missing/falsy roles and `assistant` are agent turns; other role-bearing - items are user turns) -- restores a raw Copilot item ID only when the post-affinity request blob - carries the provider's own plaintext `{version, origin, id}` trailer; - foreign blobs and items without blobs pass through unchanged -- allocates a stable type-correct random client-facing ID as soon as each - streaming output item is added, rewrites its ID-bearing child and later - frames to that ID, and appends each frame's matching raw ID behind every - available reasoning, compaction, program, or agent-message blob. Verified - shell-command child frames carry only `output_index` and pass through - unchanged. The compact value path applies the same rule to its generated - compaction item. Unknown Copilot output types fail closed before a raw ID can - reach the client - -### Responses — Codex provider boundary chain - -Codex (ChatGPT subscription) only serves Responses; Messages, Chat -Completions, and Gemini reach Codex through translation. The same boundary -runs for streaming `/v1/responses` and non-streaming `/v1/responses/compact`. -The compact action is narrowed to the compact request shape and dispatched -directly to the subscription backend's `/codex/responses/compact` endpoint. - -Codex enables `promote-system-to-developer` by default. While that effective -flag remains enabled, the target Responses interceptor rewrites input messages -from `role: "system"` to `role: "developer"`. It changes only the role; item -order, content-part boundaries, ids, and status remain intact. This also covers -a multi-block Messages `system` field after generic translation has preserved -it as one multi-part input message. Native Responses instructions, Gemini -`systemInstruction`, and a string or single-block Messages `system` stay in the -top-level `instructions` field; input messages are never folded into it. The -provider's default-instructions step below remains independent. The developer -representation matches the official Codex Responses Lite wire: -https://github.com/openai/codex/blob/1f17e7512f0e47625f2cad416f14870688a99814/codex-rs/core/src/client.rs#L829-L849 - -The Codex boundary then runs these steps: - -- injects a neutral default only when `instructions` is absent, `null`, or an - empty string. Other malformed external values pass through so the upstream - owns validation. Current ChatGPT-subscription catalog models reject empty or - missing instructions (implementation record: - https://github.com/im4codes/imcodes/blob/5f769d933dfd679e3a4d670183b0384a1baf62cd/src/agent/providers/codex-sdk.ts#L560-L579) -- strips fields the upstream rejects with `Unsupported parameter`: - `max_output_tokens`, `temperature`, `top_p`, `frequency_penalty`, - `presence_penalty`, `user`, `metadata`, `prompt_cache_retention`, - `safety_identifier`, `stream_options` -- injects a stable `session-id` header derived from - `(instructions + first user-message text)` so the upstream prompt cache - hits across turns of the same conversation (~88% input-token cache hit - measured against gpt-5.4) - -### Chat Completions — gateway interceptors - -- forces upstream streaming usage when needed for gateway usage telemetry. - The Chat source still only exposes final usage-only SSE chunks to clients - when the caller requested `stream_options.include_usage: true`. Hidden - upstream usage is preserved separately for gateway telemetry. - -### Gemini — gateway interceptors - -- removes unsupported `fileData`, `executableCode`, and `codeExecutionResult` - part fields before target request construction -- removes unsupported Gemini tool capabilities such as `googleSearch`, - `codeExecution`, URL context, file search, MCP servers, and maps, keeping - only function declarations -- drops `safetySettings`, which has no upstream target control -- hides `thought: true` summary parts by default; they are only returned - when `generationConfig.thinkingConfig.includeThoughts === true`. Opaque - `thoughtSignature` values are preserved when the target is Messages or - Chat (which carry them through `signature` / `reasoning_opaque`), but are - not translated into Responses reasoning state. -- shapes errors as Google RPC Status payloads while preserving internal - debug fields for gateway failures - ## Gemini Source Request mapping shared by the Gemini source translation pairs: @@ -365,14 +180,14 @@ Known losses: upstream target equivalent and are omitted. - `googleSearch` is currently dropped by the Gemini gateway interceptors; future work should route it through the existing web-search shim. -- `safetySettings` are omitted because the Copilot targets do not expose - equivalent safety controls. -- `candidateCount > 1` is not supported by the Copilot targets; the gateway - returns one candidate. +- `safetySettings` are omitted because the available chat target protocols have + no equivalent control. +- `candidateCount > 1` is not represented by the pairwise chat target paths; the + gateway returns one candidate. - Gemini response safety ratings, grounding metadata, and citation metadata are not synthesized from ordinary target output. -## Messages To Responses +## Messages Via Responses Request mapping: @@ -393,6 +208,9 @@ Request mapping: with a fresh random `rs_` id; it is never overwritten. - `max_tokens`, `temperature`, `top_p`, `metadata`, and `stream` pass through when present. +- `speed: "fast"` maps to `service_tier: "fast"`. When `speed` is absent, + Messages `service_tier` passes through verbatim; other `speed` values have no + OpenAI counterpart and are omitted. - `output_config.effort` maps directly to `reasoning.effort`; disabled thinking maps to `reasoning.effort: "none"`; enabled thinking without explicit effort is omitted. @@ -407,25 +225,27 @@ Response mapping: and any `encrypted_content` packed as `${encrypted_content}@${id}`: readable summary text yields a `thinking` block (packed value in `signature`); no readable text yields a `redacted_thinking` block (packed value in `data`), so - the id always round-trips to a downstream Messages client. -- assistant text becomes `message` output items and contributes to - `output_text`. -- assistant `tool_use` becomes `function_call` output items. -- `max_tokens` stop maps to `status: "incomplete"`; other normal stops map to - `status: "completed"`. -- cache reads and total cache writes map to Responses - `input_tokens_details`; 1-hour write detail remains in the internal billing - sidecar. -- Output item order follows the original assistant block order. + the id round-trips to a downstream Messages client. +- Responses message output text becomes Messages text blocks, and + `function_call` output becomes Messages `tool_use`. +- Output is emitted in Responses `output_index` order even when later text + arrives before an earlier reasoning/tool item completes. +- completed output with a function call maps to Messages `tool_use`; other + completed output maps to `end_turn`; max-output incomplete maps to + `max_tokens`. +- inclusive Responses input/cache usage is split into disjoint Messages input, + cache-read, and cache-write fields; 1-hour write detail is retained. Target + `service_tier: "fast"` maps to Messages `speed: "fast"`; other non-null tiers + map to Messages `service_tier`. Known losses: -- `stop_sequences`, `top_k`, and Messages `service_tier` have no Responses - request counterpart and are omitted. +- `stop_sequences`, `top_k`, and non-fast Messages `speed` values have no + Responses request counterpart and are omitted. - Anthropic `thinking: { type: "enabled" }` without explicit effort has no Responses request-side equivalent and is not emulated. -## Responses To Messages +## Responses Via Messages Request mapping: @@ -450,6 +270,8 @@ Request mapping: no opaque content becomes a `thinking` block with no signature. - `max_output_tokens`, `temperature`, `top_p`, and `stream` pass through when present. +- `service_tier: "fast"` maps to Messages `speed: "fast"`; every other defined + open-string tier passes through as Messages `service_tier`. - `reasoning.effort: "none"` maps to disabled thinking; any other explicit effort maps to `output_config.effort`. - Responses function tools become Messages tools, preserving explicit `strict`. @@ -462,32 +284,34 @@ Request mapping: `program`, `program_output`, program callers and tool declarations, deferred tools, and forced programmatic choice are rejected rather than projected lossily. Native Responses paths retain these items, caller metadata, and - opaque fingerprints whenever snapshot persistence is active; HTTP - `store: false` disables snapshots, while WebSocket `store: false` keeps them - only in the current session's memory. + opaque fingerprints whenever snapshot persistence is active. HTTP + `store: false` writes no new state. WebSocket `store: false` writes the new + snapshot only to session memory while still permitting durable reads. Response mapping: -- Responses output items are converted in output order. -- `reasoning` maps to a Messages thinking carrier; the upstream's genuine - `signature` (or `redacted_thinking` `data`) is carried verbatim as the - reasoning item's `encrypted_content`, with a fresh random `rs_` id. -- `message` content maps to text. `refusal` content is kept visible as text - because Messages has no local refusal block. -- `function_call` maps to `tool_use`. -- `completed` maps to `end_turn` or `tool_use`; max-output incomplete maps to - `max_tokens`. -- cached reads and writes are subtracted from Anthropic `input_tokens` and - exposed as `cache_read_input_tokens` and cache-creation usage, retaining - 1-hour write detail. +- Messages content blocks become Responses output items in source block order. +- Thinking maps to a Responses reasoning item; the upstream's genuine + `signature` (or redacted-thinking `data`) is carried verbatim as + `encrypted_content` under a fresh random `rs_` id. +- Messages text becomes Responses message output text, and `tool_use` becomes a + `function_call` output item. Structured Messages search citations become + Responses URL-citation annotations when they carry enough cited text to + anchor an output span. +- Messages `tool_use` stop produces a completed response with function calls; + `max_tokens` produces max-output incomplete; other normal stops complete. +- disjoint Messages cache counts are folded into inclusive Responses input + usage while the 1-hour subset remains in the billing sidecar. Messages + `speed: "fast"` returns as Responses `service_tier: "fast"`; otherwise + Messages `service_tier` passes through. Known losses: - generic Responses `metadata` is omitted; it is not coerced into `metadata.user_id`. -- Pure Responses-to-Messages translation does not own response-level state. - The API data plane expands `previous_response_id` and stored item ids before - invoking this translator. +- Responses Via Messages does not own response-level state. The native + Responses source edge expands `previous_response_id` and stored item ids + before invoking this translator. - Freeform `custom` tool `format.definition` is preserved as a `Lark grammar: ${definition}` description on the wrapped `input` parameter; other `format` fields are not preserved. @@ -496,7 +320,7 @@ Known losses: - `input_file` content and assistant-side images have no Messages counterpart and are rejected. -## Messages To Chat Completions +## Messages Via Chat Completions Request mapping: @@ -511,6 +335,8 @@ Request mapping: `reasoning_opaque`. - `max_tokens`, `stop_sequences` -> `stop`, `stream`, `temperature`, and `top_p` pass through when present. +- `speed: "fast"` maps to `service_tier: "fast"`; with no `speed`, Messages + `service_tier` passes through. Other `speed` values are omitted. - non-empty `output_config.effort` maps directly to `reasoning_effort`; disabled thinking maps to `reasoning_effort: "none"`; enabled thinking without explicit effort is omitted. @@ -522,25 +348,32 @@ Request mapping: Response mapping: -- assistant text blocks concatenate into Chat assistant `content`. -- `tool_use` blocks become `tool_calls`. -- only the first source-order reasoning group is projected into scalar Chat - reasoning fields. -- usage maps to Chat prompt/completion tokens; cache reads and total cache - writes use the OpenAI usage fields, with 1-hour write detail carried - internally. -- `tool_use` stop maps to `tool_calls`; `max_tokens` maps to `length`; other - normal stops map to `stop`. +- the first Chat choice becomes the Messages assistant stream; later choices are + dropped because Messages has no multi-candidate response shape. +- Chat scalar `reasoning_text` and `reasoning_opaque` become Messages thinking + or redacted-thinking blocks in source order. +- Chat content becomes Messages text blocks; tool calls become `tool_use` + blocks. Text interleaved inside streamed tool arguments is deferred until the + tool block closes so trailing argument fragments remain valid. +- inclusive Chat prompt usage is split into plain input, cache-read, and + cache-write Messages fields; 1-hour cache-write detail is retained. Target + `service_tier: "fast"` maps to Messages `speed: "fast"`; other non-null tiers + map to Messages `service_tier`. +- Chat `tool_calls` finish maps to Messages `tool_use`, `length` maps to + `max_tokens`, `content_filter` maps to `refusal`, and `stop` maps to + `end_turn`. Known losses: -- multiple Messages thinking blocks cannot be represented losslessly in legacy - Chat scalar fields. Later groups are omitted rather than aggregated or - mismatched. -- assistant-side images have no Chat counterpart and are omitted. -- `top_k`, `service_tier`, and other Messages-only fields are omitted. +- multiple Messages thinking blocks in request history cannot be represented + losslessly in legacy Chat scalar fields. Later groups are omitted rather than + aggregated or mismatched. +- assistant-side images have no Chat request counterpart and are omitted. +- `top_k`, non-fast `speed`, and other Messages-only request fields without Chat + counterparts are omitted. +- Chat response choices after index zero are omitted. -## Chat Completions To Messages +## Chat Completions Via Messages Request mapping: @@ -556,28 +389,39 @@ Request mapping: - Chat `tool` messages become Messages `tool_result` blocks. - `max_tokens`, `temperature`, `top_p`, `stop`, `stream`, tools, and tool choice map where representable. +- `service_tier: "fast"` maps to Messages `speed: "fast"`; every other defined + open-string tier passes through as Messages `service_tier`. - OpenAI function tools preserve explicit `strict`; omitted `strict` stays omitted. Response mapping: -- multiple Chat choices are merged into one Messages response. -- scalar reasoning blocks are emitted before text, and text before tool use. -- scalar opaque-only reasoning becomes `redacted_thinking` rather than fake - readable thinking. -- Chat usage maps to Messages usage; cached prompt reads and writes become the - corresponding disjoint Messages cache fields. +- Messages text deltas become Chat assistant `content`; `tool_use` blocks become + indexed Chat `tool_calls`. +- only the first Messages thinking/redacted-thinking block is projected into the + scalar Chat `reasoning_text` / `reasoning_opaque` fields. Opaque-only state + remains opaque rather than becoming fake readable reasoning. +- disjoint Messages input/cache usage is folded into inclusive Chat prompt + usage, with 1-hour cache-write detail retained internally. Messages + `speed: "fast"` returns as Chat `service_tier: "fast"`; otherwise Messages + `service_tier` passes through. +- Messages `tool_use` stop maps to Chat `tool_calls`, `max_tokens` maps to + `length`, and other terminal reasons map to `stop`. The Messages terminal + becomes the Chat `[DONE]` sentinel. Known losses: -- Chat `message.name`, legacy `user`, and generic Chat metadata are omitted on - translated Messages paths. +- Chat `message.name`, legacy `user`, generic metadata, and `n` have no Messages + request counterpart and are omitted. The returned Chat stream always uses + choice index zero. - Chat `reasoning_items[]` is not a Messages bridge; readable summaries in that - shape are only used for the Chat <-> Responses path. + shape are used only by Chat Completions Via Responses and Responses Via Chat + Completions. - Chat image `detail` is not represented in Messages. -- Multiple choices lose choice index and separation. +- Messages structured citation deltas and later thinking groups have no legacy + Chat response representation and are omitted. -## Chat Completions To Responses +## Chat Completions Via Responses Request mapping: @@ -603,22 +447,24 @@ Request mapping: Response mapping: -- Chat `reasoning_items[]` entries with readable summaries are preferred over - scalar reasoning and become Responses reasoning output items. -- scalar `reasoning_text` becomes one Responses reasoning output item when no - readable carrier is present; scalar `reasoning_opaque` is ignored. -- Chat content becomes one Responses `message` output item. -- Chat tool calls become Responses `function_call` output items. -- terminal Responses output is ordered by `output_index`, not completion time. -- `length` maps to `status: "incomplete"`; other finish reasons map to - `completed`. +- every readable Responses reasoning output item is preserved in Chat + `reasoning_items[]`; the first scalar-eligible group also projects to + `reasoning_text`. No Chat `reasoning_opaque` is synthesized. +- Responses message output text and refusal text become visible Chat assistant + content; function calls become Chat `tool_calls`. +- Responses output is held in `output_index` order when later visible output + finishes before earlier reasoning/tool output. +- max-output incomplete maps to Chat `finish_reason: "length"`; completed with + tool calls maps to `tool_calls`; other completed responses map to `stop`. Known losses: - Chat `stop` has no Responses request counterpart and is omitted. - legacy Chat `user` is omitted on translated Chat/Responses paths. +- opaque Responses reasoning state has no Chat output field; only readable + summaries survive the target response. -## Responses To Chat Completions +## Responses Via Chat Completions Request mapping: @@ -645,32 +491,31 @@ Request mapping: - Responses function tools become Chat function tools, preserving `strict`. Freeform `custom` tools are wrapped as single-string function tools; see "Responses Custom Tool Wrapping". -- Programmatic Tool Calling state handling is identical to Responses → +- Programmatic Tool Calling state handling is identical to Responses Via Messages (see above). Response mapping: -- Responses `message` output text becomes Chat assistant `content`; refusal text - is kept visible as text. -- Responses `function_call` output becomes Chat `tool_calls`. -- every Responses reasoning output item with readable summary text is preserved - in Chat `reasoning_items[]`. -- legacy scalar `reasoning_text` projects only the first scalar-eligible - reasoning group; no `reasoning_opaque` value is synthesized from Responses - reasoning. -- max-output incomplete maps to Chat `finish_reason: "length"`; completed with - tool calls maps to `tool_calls`; other completed responses map to `stop`. +- Chat `reasoning_items[]` entries with readable summaries are preferred and + become Responses reasoning output items. Without one, scalar + `reasoning_text` becomes one reasoning item; scalar `reasoning_opaque` is + ignored. +- Chat assistant content becomes one Responses message output item, and Chat + tool calls become Responses `function_call` output items. +- output items are emitted in source order by `output_index`. +- Chat `finish_reason: "length"` maps to Responses incomplete; other finish + reasons produce a completed response. Known losses: - Responses request-level `reasoning` has no Chat request counterpart except explicit effort. -- Pure Responses-to-Chat translation does not own response-level state. The API - data plane expands `previous_response_id` and stored item ids before invoking - this translator, with readable reasoning ids then carried through - `reasoning_items[]`. -- Freeform `custom` tool `format.definition` handling is identical to - Responses → Messages (see above). +- Responses Via Chat Completions does not own response-level state. The native + Responses source edge expands `previous_response_id` and stored item ids + before invoking this translator, with readable reasoning ids then carried + through `reasoning_items[]`. +- Freeform `custom` tool `format.definition` handling is identical to Responses + Via Messages (see below). - Lifting tool-output images into a user message changes their speaker role but keeps the visual bytes usable on Chat targets. - `input_file` message/tool-output content and assistant-side files or images @@ -683,34 +528,36 @@ Known losses: ## Responses Custom Tool Wrapping -Responses Freeform `custom` tools have no Anthropic or Chat Completions -counterpart. The Responses-to-Messages and Responses-to-Chat-Completions -translators wrap each `custom` tool as a single-string function tool with the -schema: +Responses Freeform `custom` tools have no Messages or Chat Completions +counterpart. Responses Via Messages and Responses Via Chat Completions wrap each +currently declared `custom` tool as a function tool whose only input is a +required string: ```json { "type": "object", "additionalProperties": false, "required": ["input"], "properties": { "input": { "type": "string" } } } ``` -When the source `custom` tool provides `format.definition` (the Lark grammar -source), the translator copies it into the `input` parameter's `description` -prefixed with `Lark grammar: ` so target models still see what shape the -freeform value should follow. Other `format` fields (`type`, `syntax`, ...) -are not preserved. Tool names get tracked per trip; the events translator -recognizes wrapped function calls coming back from the target by name, -unwraps the `input` field from the JSON arguments blob, and projects the -result as `custom_tool_call` output items plus -`response.custom_tool_call_input.delta` / `.done` events to the Responses -caller. Wrapped tool-call argument deltas are buffered and emitted as a single -input delta at stop time because freeform values cannot be safely split out of -partial JSON. Tool choice referencing a `custom` tool maps to the -function-shape choice for the wrapped target. Historical `custom_tool_call` / -`custom_tool_call_output` input items are projected into the wrapped -function-tool history shape so multi-turn conversations remain coherent. - -Native Responses targets continue to receive `custom` tools, tool choices, and -historical custom tool call items unchanged. +Chat Completions wrappers set `strict: false`; the Messages wrapper uses the +same input schema. If `format.definition` is a non-empty string, regardless of +other `format` fields, it becomes the `input` property's description prefixed +with `Lark grammar: `. Other format fields are not projected. + +The request translator returns the set of custom tool names declared on that +turn alongside the target payload. The matching events translator uses that +set to distinguish a wrapped function/tool call from an ordinary function +call. It buffers the complete JSON arguments, then extracts a string `input`; +invalid JSON, a missing field, or a non-string field falls back to the raw +arguments blob. At close it emits a `custom_tool_call` item, one +`response.custom_tool_call_input.delta` when the recovered input is non-empty, +and a `.done` event. Partial JSON cannot produce safe freeform deltas. + +A named custom tool choice maps to the target's named function/tool choice. +Historical `custom_tool_call` input becomes a wrapped call with +`{"input": }`, and string `custom_tool_call_output` becomes target +tool-result history. Multimodal custom outputs are rejected rather than +flattened. Native Responses targets receive custom declarations, choices, and +history unchanged. ## Streaming Semantics @@ -719,38 +566,42 @@ historical custom tool call items unchanged. usage-only chunk only when the caller requested it. - Responses-shaped streams use named Responses SSE events with monotonically increasing `sequence_number`. -- Chat -> Responses stream translation buffers scalar reasoning until it knows - whether `reasoning_items[]` will be used, avoiding orphan or duplicated - Responses reasoning items. -- Responses -> Chat and Responses -> Messages stream translation preserve output - order when later visible output arrives before earlier reasoning/tool output - is complete. -- Chat -> Messages stream translation keeps opaque-only reasoning in source - order and flushes pending final usage before `message_stop`. Chat - `reasoning_opaque` and Messages `signature_delta` values are replacement - snapshots, not string fragments to concatenate. +- Chat Completions Via Responses buffers scalar reasoning until it knows whether + `reasoning_items[]` will be used, avoiding orphan or duplicated Responses + reasoning items. +- Responses Via Chat Completions and Responses Via Messages preserve output order + when later visible output arrives before earlier reasoning/tool output is + complete. +- Chat Completions Via Messages keeps opaque-only reasoning in source order and + flushes pending final usage before `message_stop`. Chat `reasoning_opaque` and + Messages `signature_delta` values are replacement snapshots, not string + fragments to concatenate. - Tool/function argument streams guard against infinite whitespace in generated arguments and emit an error rather than continuing a degenerate stream. ## Reasoning Policy -- Translated Responses paths keep readable reasoning summaries and omit opaque - encrypted reasoning state. -- Chat `reasoning_items[]` carries readable Responses reasoning summaries when - Chat is the fallback protocol. -- legacy Chat scalar reasoning fields represent exactly one readable scalar - group on Chat <-> Responses paths: `reasoning_text` only. -- Messages <-> Chat may still carry Anthropic opaque thinking through Chat - `reasoning_opaque`, because that is a Messages/Chat compatibility surface and - not a Responses encrypted-reasoning bridge. +- Messages Via Responses and Responses Via Messages preserve genuine opaque + signature/encrypted-content carriers alongside readable reasoning where the + two protocols provide matching replay slots. +- Chat Completions Via Responses and Responses Via Chat Completions preserve + readable summaries only. Chat `reasoning_items[]` carries every readable + Responses group; legacy scalar `reasoning_text` represents the first eligible + group. No Responses opaque state is projected through Chat. +- Gemini Via Responses ignores opaque signatures and carries readable thought + summaries only. Gemini Via Messages and Gemini Via Chat Completions may use + their native `thoughtSignature` compatibility slots. +- Messages Via Chat Completions and Chat Completions Via Messages may carry + Anthropic opaque thinking through Chat `reasoning_opaque`; that is the + Messages/Chat compatibility surface, not a Responses bridge. - Floway affinity and native Responses persistence remain outside pure translators; their source-boundary behavior is documented in [AFFINITY.md](./AFFINITY.md). ## Standard OpenAI Field Policy -For translated Chat <-> Responses paths, same-purpose OpenAI fields pass through -directly where both APIs define them: +For Chat Completions Via Responses and Responses Via Chat Completions, +same-purpose OpenAI fields pass through directly where both APIs define them: - `metadata` - `store` @@ -758,6 +609,7 @@ directly where both APIs define them: - `response_format` / `text.format` - `prompt_cache_key` - `safety_identifier` +- `service_tier` - explicit `reasoning_effort` / `reasoning.effort` These fields are not bridged through Anthropic Messages-only paths unless the @@ -765,38 +617,33 @@ Messages API has an explicit equivalent. ## Alias Rule Application -Alias rules apply **post-translate**, on the target IR, at the terminal -wire call. Each chat target has one `applyRulesToUpstream` helper -(`applyRulesToUpstreamChatCompletions`, `applyRulesToUpstreamMessages`, -`applyRulesToUpstreamResponses`) that reads `ctx.aliasRules` and writes -each rule onto the target protocol's native slot before dispatch. Gemini -is inbound-only — Gemini requests translate to a chat target, and the -rules apply on that chosen target. - -Cross-protocol translation itself is pure native ↔ native; the -translators never lift or lower alias rules. A rule that has no native -slot on the chosen target is silently dropped by design — the wire has -nowhere to put it, and forcing the rule through a nearby field would -mean lying about what the operator asked for. +Alias rules live on `ModelCandidate.rules`. After target selection and any +translation, the terminal chat wire call passes that exact overlay to one +`applyRulesToUpstream` helper. The helper mutates the selected target +protocol's native payload immediately before provider dispatch. Gemini is a +source only, so its rules land on the selected Chat Completions, Messages, or +Responses target. -The mapping from `AliasRules` fields onto target-protocol slots: +Pairwise translators remain source-native Via target-native and never carry +alias extensions. A rule with no native target slot is dropped; projecting it +onto a merely similar field would misstate the operator's intent. -| rule | -> Chat Completions | -> Messages | -> Responses | +| rule | Chat Completions target | Messages target | Responses target | |---|---|---|---| | `reasoning.effort` | `reasoning_effort` | `output_config.effort` | `reasoning.effort` | | `reasoning.budget_tokens` | dropped | `thinking.budget_tokens` + `thinking.type: 'enabled'` | dropped | -| `reasoning.adaptive` | dropped | `thinking.type: 'adaptive'` | dropped | -| `reasoning.summary` | dropped | `thinking.display` (`summarized` / `omitted`) | `reasoning.summary` | +| `reasoning.adaptive` | dropped | `thinking.type: 'adaptive'` when true | dropped | +| `reasoning.summary` | dropped | mapped to `thinking.display`; `auto` omits the override | `reasoning.summary` | | `verbosity` | `verbosity` | dropped | `text.verbosity` | -| `serviceTier` | `service_tier` | `speed: 'fast'` for `'fast'`, else `service_tier` | `service_tier` | +| `serviceTier` | `service_tier` | `speed: 'fast'` for `fast`, otherwise `service_tier` | `service_tier` | -Passthrough endpoints (`/v1/embeddings`, `/v1/images/*`, -`/v1/audio/transcriptions`, `/v1/completions`) and rerank have no -rule-application step; a non-chat alias -must be seeded with empty rules (enforced at write time by a zod -refinement on the alias schema). +Embeddings, Images, Audio Transcriptions, and Rerank have no rule-application +step and their non-chat alias schemas require empty rules. OpenAI Completions +also has no application step, but it resolves `kind: 'chat'`; a chat alias can +therefore carry non-empty rules that Completions intentionally ignores. See +[RESOLUTION.md](./RESOLUTION.md) for candidate and endpoint selection. -## Rerank translation +## Rerank Translation Rerank is non-streaming JSON but not a passthrough protocol: four strict source routes normalize into `CanonicalRerankRequest`, and each custom manual diff --git a/eslint.config.ts b/eslint.config.ts index 5632818506..88c32a1d7d 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -8,10 +8,12 @@ import vueParser from 'vue-eslint-parser'; import type { Linter } from 'eslint'; const projectList = [ + './tsconfig.scripts.json', './apps/platform-cloudflare/tsconfig.json', './apps/platform-node/tsconfig.json', './apps/web/tsconfig.json', './packages/agent-setup/tsconfig.json', + './packages/agent-setup/tsconfig.scripts.json', './packages/gateway/tsconfig.json', './packages/http/tsconfig.json', './packages/interceptor/tsconfig.json', @@ -274,13 +276,9 @@ const config: Linter.Config[] = [ '**/dist/**', '**/build/**', '**/coverage/**', - // Workspace-root configs (live outside any package's TS project). + // Workspace-root configs (live outside any checked TS project). 'eslint.config.ts', 'vitest.config.ts', - 'packages/*/vitest.config.ts', - 'scripts/**', - // jiti-run build/test scripts, run outside any package's TS project. - 'packages/agent-setup/scripts/**', ], }, ]; diff --git a/package.json b/package.json index 3973e9a325..a63d85985d 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,11 @@ "deploy": "pnpm install --frozen-lockfile && jiti scripts/check-wrangler.ts && pnpm run build:web && wrangler deploy --message \"$(git rev-parse --short HEAD)\"", "db:migrate": "wrangler d1 migrations apply $(node -p \"require('jsonc-parser').parse(require('fs').readFileSync('wrangler.jsonc','utf8')).d1_databases[0].database_name\")", "db:migrate:remote": "wrangler d1 migrations apply $(node -p \"require('jsonc-parser').parse(require('fs').readFileSync('wrangler.jsonc','utf8')).d1_databases[0].database_name\") --remote", - "lint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint --cache .", - "lint:fix": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint --cache . --fix", + "lint": "cross-env NODE_OPTIONS=\"--max-old-space-size=12288\" eslint --cache .", + "lint:fix": "cross-env NODE_OPTIONS=\"--max-old-space-size=12288\" eslint --cache . --fix", "test": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" vitest run", - "test:agent-setup-installers": "jiti packages/agent-setup/scripts/test-installers.ts", - "typecheck": "pnpm -r run typecheck", + "test:agent-setup-installers": "pnpm --filter @floway-dev/agent-setup run test:installers", + "typecheck": "pnpm -r run typecheck && tsc --noEmit -p tsconfig.scripts.json", "prepare": "husky" }, "devDependencies": { @@ -23,6 +23,7 @@ "@stylistic/eslint-plugin": "5.6.1", "@typescript-eslint/eslint-plugin": "8.48.1", "@typescript-eslint/parser": "8.48.1", + "@types/node": "^22", "concurrently": "^9.2.1", "cross-env": "^10.1.0", "eslint": "9.39.1", diff --git a/packages/agent-setup/installers/bash/common/cli.sh b/packages/agent-setup/installers/bash/common/cli.sh new file mode 100644 index 0000000000..282e8742ed --- /dev/null +++ b/packages/agent-setup/installers/bash/common/cli.sh @@ -0,0 +1,70 @@ +# Download an installer to the private working directory, refuse anything that +# is not a shell script (region blocks and captive portals serve HTML in place +# of the real installer), then execute it without sudo. +_download_and_run_installer() { + _dri_url=$1 + _dri_file=$(mktemp "$SETUP_TMPDIR/install.XXXXXX") || return 1 + if ! curl -fsSL --connect-timeout 10 --max-time 120 -o "$_dri_file" "$_dri_url"; then + out_error "could not download the installer from $_dri_url" + rm -f "$_dri_file" + return 1 + fi + # Reject common HTML responses while allowing official shell content with or + # without a shebang (some installer CDNs prepend comments). + if awk ' + NR <= 20 { + line = tolower($0) + if (line ~ /^[[:space:]]*(])|])|]))/) found = 1 + } + END { exit found ? 0 : 1 } + ' "$_dri_file"; then + out_error 'the installer download was HTML, not an executable script (a login or region-block page?).' + rm -f "$_dri_file" + return 1 + fi + if ! awk 'NF { found = 1 } END { exit found ? 0 : 1 }' "$_dri_file"; then + out_error 'the installer download was empty.' + rm -f "$_dri_file" + return 1 + fi + _dri_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-120} + _run_with_timeout "$_dri_timeout" env -u SETUP_API_KEY bash "$_dri_file" /dev/null || true) + if [ -n "$DISCOVERED_BIN" ]; then + DISCOVERED_COUNT=1 + else + DISCOVERED_COUNT=0 + fi + for _dc_candidate in "$@"; do + [ -x "$_dc_candidate" ] || continue + [ "$_dc_candidate" = "$DISCOVERED_BIN" ] && continue + DISCOVERED_COUNT=$((DISCOVERED_COUNT + 1)) + if [ -z "$DISCOVERED_BIN" ]; then + DISCOVERED_BIN=$_dc_candidate + fi + done +} + +_install_brew_cask() { + _ibc_cask=$1 + if ! command -v brew >/dev/null 2>&1; then + out_error 'Homebrew is required to install agent CLIs on macOS.' + return 1 + fi + _ibc_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-600} + _run_with_timeout "$_ibc_timeout" env -u SETUP_API_KEY brew install --cask "$_ibc_cask" /dev/null 2>&1; then - timeout "$_rwt_secs" "$@" - return $? - fi - if command -v gtimeout >/dev/null 2>&1; then - gtimeout "$_rwt_secs" "$@" - return $? - fi - - _rwt_marker=$(mktemp "$SETUP_TMPDIR/timeout.XXXXXX") || return 1 - rm -f "$_rwt_marker" - if [ -n "${AGENT_SETUP_TEST_TRACE_TIMEOUT:-}" ]; then - printf 'Agent Setup test: timeout fallback: process-tree\n' - fi - set -m - "$@" & - _rwt_pid=$! - set +m - ( - # The watchdog must not retain the installer's stdout/stderr descriptors - # after its parent shell is killed; otherwise a pipe consumer waits for the - # orphaned sleep to exit before receiving EOF. - exec /dev/null 2>&1 - sleep "$_rwt_secs" - if kill -0 "$_rwt_pid" 2>/dev/null; then - : > "$_rwt_marker" - kill -TERM -- "-$_rwt_pid" 2>/dev/null || true - sleep 1 - kill -KILL -- "-$_rwt_pid" 2>/dev/null || true - fi - ) & - _rwt_watchdog=$! - wait "$_rwt_pid" - _rwt_status=$? - if [ -e "$_rwt_marker" ]; then - # Let TERM→KILL escalation finish before reporting the timeout. - wait "$_rwt_watchdog" 2>/dev/null || true - rm -f "$_rwt_marker" - return 124 - fi - kill "$_rwt_watchdog" 2>/dev/null || true - wait "$_rwt_watchdog" 2>/dev/null || true - rm -f "$_rwt_marker" - return $_rwt_status -} - -# jq handle, resolved by ensure_jq before any configuration file is touched. -JQ="" - -# Download the pinned official jq build for this platform into the private -# working directory and verify its hard-coded SHA-256 before use. Fails on an -# unsupported platform, a download error, a missing hashing tool, or a checksum -# mismatch — always before any configuration file is touched. -_bootstrap_jq() { - _bj_os=$(uname -s) - _bj_arch=$(uname -m) - case "$_bj_os" in - Darwin) _bj_os_part=macos ;; - Linux) _bj_os_part=linux ;; - *) out_error "no pinned jq build for OS $_bj_os."; return 1 ;; - esac - case "$_bj_arch" in - x86_64 | amd64) _bj_arch_part=amd64 ;; - arm64 | aarch64) _bj_arch_part=arm64 ;; - *) out_error "no pinned jq build for architecture $_bj_arch."; return 1 ;; - esac - _bj_asset="jq-$_bj_os_part-$_bj_arch_part" - # Pinned to jqlang/jq release jq-1.8.2. Each digest was verified against the - # release sha256sum.txt and the Sigstore build attestation - # (signer: jqlang/jq .github/workflows/ci.yml@refs/tags/jq-1.8.2). - # Ref: https://github.com/jqlang/jq/releases/tag/jq-1.8.2 - case "$_bj_asset" in - jq-macos-amd64) _bj_sha=e94b266e3c26690550006abe63152b782280f4e14374accdf04cbde844f00bc0 ;; - jq-macos-arm64) _bj_sha=2d75340ba57a4b4b4c8708a21c2dc8e958a48aaa8bba13b27f77f6e4c0eca07e ;; - jq-linux-amd64) _bj_sha=b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f ;; - jq-linux-arm64) _bj_sha=8b85c817833814ddca00a144c33705546355afccf0cf39b188f3cdb48b852309 ;; - *) return 1 ;; - esac - _bj_url="https://github.com/jqlang/jq/releases/download/jq-1.8.2/$_bj_asset" - _bj_dest="$SETUP_TMPDIR/$_bj_asset" - out_warn 'jq not found on PATH; fetching the pinned jq-1.8.2 build' - if ! curl -fsSL --connect-timeout 10 --max-time 120 -o "$_bj_dest" "$_bj_url"; then - out_error "failed to download jq from $_bj_url" - rm -f "$_bj_dest" - return 1 - fi - if command -v sha256sum >/dev/null 2>&1; then - _bj_actual=$(sha256sum "$_bj_dest" | awk '{ print $1 }') - elif command -v shasum >/dev/null 2>&1; then - _bj_actual=$(shasum -a 256 "$_bj_dest" | awk '{ print $1 }') - elif command -v openssl >/dev/null 2>&1; then - _bj_actual=$(openssl dgst -sha256 "$_bj_dest" | awk '{ print $NF }') - else - _bj_actual="" - fi - if [ -z "$_bj_actual" ]; then - out_error 'no SHA-256 tool available to verify the jq download.' - rm -f "$_bj_dest" - return 1 - fi - if [ "$_bj_actual" != "$_bj_sha" ]; then - out_error 'jq checksum mismatch; refusing to use the download.' - rm -f "$_bj_dest" - return 1 - fi - if ! chmod 700 "$_bj_dest"; then - rm -f "$_bj_dest" - return 1 - fi - JQ="$_bj_dest" -} - -# Resolve a usable jq: prefer PATH, else provision the pinned build. The -# AGENT_SETUP_TEST_NO_JQ_DOWNLOAD hook lets the test harness assert the -# fail-before-mutation path without reaching the network. -ensure_jq() { - if command -v jq >/dev/null 2>&1; then - JQ=jq - return 0 - fi - if [ -n "${AGENT_SETUP_TEST_NO_JQ_DOWNLOAD:-}" ]; then - return 1 - fi - _bootstrap_jq -} - -# Download an installer to the private working directory, refuse anything that -# is not a shell script (region blocks and captive portals serve HTML in place -# of the real installer), then execute it without sudo. -_download_and_run_installer() { - _dri_url=$1 - _dri_file=$(mktemp "$SETUP_TMPDIR/install.XXXXXX") || return 1 - if ! curl -fsSL --connect-timeout 10 --max-time 120 -o "$_dri_file" "$_dri_url"; then - out_error "could not download the installer from $_dri_url" - rm -f "$_dri_file" - return 1 - fi - # Reject common HTML responses while allowing official shell content with or - # without a shebang (some installer CDNs prepend comments). - if awk ' - NR <= 20 { - line = tolower($0) - if (line ~ /^[[:space:]]*(])|])|]))/) found = 1 - } - END { exit found ? 0 : 1 } - ' "$_dri_file"; then - out_error 'the installer download was HTML, not an executable script (a login or region-block page?).' - rm -f "$_dri_file" - return 1 - fi - if ! awk 'NF { found = 1 } END { exit found ? 0 : 1 }' "$_dri_file"; then - out_error 'the installer download was empty.' - rm -f "$_dri_file" - return 1 - fi - _dri_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-120} - _run_with_timeout "$_dri_timeout" env -u SETUP_API_KEY bash "$_dri_file" /dev/null || true) - if [ -n "$DISCOVERED_BIN" ]; then - DISCOVERED_COUNT=1 - else - DISCOVERED_COUNT=0 - fi - for _dc_candidate in "$@"; do - [ -x "$_dc_candidate" ] || continue - [ "$_dc_candidate" = "$DISCOVERED_BIN" ] && continue - DISCOVERED_COUNT=$((DISCOVERED_COUNT + 1)) - if [ -z "$DISCOVERED_BIN" ]; then - DISCOVERED_BIN=$_dc_candidate - fi - done -} - -# Rollback retains a backup when restoration fails so manual recovery remains -# possible. Callers keep separate transaction boundaries and aggregate failures. -_restore_managed_file() { - _rmf_existed=$1 - _rmf_backup=$2 - _rmf_path=$3 - _rmf_original_label=$4 - _rmf_created_label=$5 - if [ "$_rmf_existed" -eq 1 ]; then - if [ -n "$_rmf_backup" ] && [ -e "$_rmf_backup" ] && ! mv "$_rmf_backup" "$_rmf_path" 2>/dev/null; then - out_warn "could not restore $_rmf_path from its backup; your original $_rmf_original_label is preserved at $_rmf_backup — restore it by hand." - return 1 - fi - elif ! rm -f "$_rmf_path" 2>/dev/null; then - out_warn "could not remove the $_rmf_created_label this run created at $_rmf_path — remove it by hand." - return 1 - fi - return 0 -} - -_prune_managed_backups() { - _pmb_path=$1 - _pmb_keep=$2 - for _pmb_backup in "$_pmb_path".floway-backup.*; do - [ -e "$_pmb_backup" ] || continue - [ "$_pmb_backup" = "$_pmb_keep" ] && continue - if ! rm -f "$_pmb_backup"; then - out_error "could not remove obsolete backup $_pmb_backup" - return 1 - fi - done -} - -_install_brew_cask() { - _ibc_cask=$1 - if ! command -v brew >/dev/null 2>&1; then - out_error 'Homebrew is required to install agent CLIs on macOS.' - return 1 - fi - _ibc_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-600} - _run_with_timeout "$_ibc_timeout" env -u SETUP_API_KEY brew install --cask "$_ibc_cask" /dev/null 2>&1; then + _bj_actual=$(sha256sum "$_bj_dest" | awk '{ print $1 }') + elif command -v shasum >/dev/null 2>&1; then + _bj_actual=$(shasum -a 256 "$_bj_dest" | awk '{ print $1 }') + elif command -v openssl >/dev/null 2>&1; then + _bj_actual=$(openssl dgst -sha256 "$_bj_dest" | awk '{ print $NF }') + else + _bj_actual="" + fi + if [ -z "$_bj_actual" ]; then + out_error 'no SHA-256 tool available to verify the jq download.' + rm -f "$_bj_dest" + return 1 + fi + if [ "$_bj_actual" != "$_bj_sha" ]; then + out_error 'jq checksum mismatch; refusing to use the download.' + rm -f "$_bj_dest" + return 1 + fi + if ! chmod 700 "$_bj_dest"; then + rm -f "$_bj_dest" + return 1 + fi + JQ="$_bj_dest" +} + +# Resolve a usable jq: prefer PATH, else provision the pinned build. The +# AGENT_SETUP_TEST_NO_JQ_DOWNLOAD hook lets the test harness assert the +# fail-before-mutation path without reaching the network. +ensure_jq() { + if command -v jq >/dev/null 2>&1; then + JQ=jq + return 0 + fi + if [ -n "${AGENT_SETUP_TEST_NO_JQ_DOWNLOAD:-}" ]; then + return 1 + fi + _bootstrap_jq +} diff --git a/packages/agent-setup/installers/bash/common/main.sh b/packages/agent-setup/installers/bash/common/main.sh index 35f907efcf..78c2343e26 100644 --- a/packages/agent-setup/installers/bash/common/main.sh +++ b/packages/agent-setup/installers/bash/common/main.sh @@ -1,3 +1,14 @@ +SETUP_TMPDIR="" +_cleanup() { + if [ -n "$SETUP_TMPDIR" ]; then + rm -rf "$SETUP_TMPDIR" 2>/dev/null || true + fi +} +# EXIT owns cleanup. INT/TERM only translate the signal into the conventional +# exit status (130 = 128+SIGINT, 143 = 128+SIGTERM) and let that exit fire the +# EXIT trap. Cleaning up directly inside the INT/TERM handlers would delete the +# working directory and then let the interrupted script resume into the next +# agent's configuration; exiting instead stops all further agent work. # --- run -------------------------------------------------------------------- main() { diff --git a/packages/agent-setup/installers/bash/common/managed-file.sh b/packages/agent-setup/installers/bash/common/managed-file.sh new file mode 100644 index 0000000000..5b8803331f --- /dev/null +++ b/packages/agent-setup/installers/bash/common/managed-file.sh @@ -0,0 +1,32 @@ +# Rollback retains a backup when restoration fails so manual recovery remains +# possible. Callers keep separate transaction boundaries and aggregate failures. +_restore_managed_file() { + _rmf_existed=$1 + _rmf_backup=$2 + _rmf_path=$3 + _rmf_original_label=$4 + _rmf_created_label=$5 + if [ "$_rmf_existed" -eq 1 ]; then + if [ -n "$_rmf_backup" ] && [ -e "$_rmf_backup" ] && ! mv "$_rmf_backup" "$_rmf_path" 2>/dev/null; then + out_warn "could not restore $_rmf_path from its backup; your original $_rmf_original_label is preserved at $_rmf_backup — restore it by hand." + return 1 + fi + elif ! rm -f "$_rmf_path" 2>/dev/null; then + out_warn "could not remove the $_rmf_created_label this run created at $_rmf_path — remove it by hand." + return 1 + fi + return 0 +} + +_prune_managed_backups() { + _pmb_path=$1 + _pmb_keep=$2 + for _pmb_backup in "$_pmb_path".floway-backup.*; do + [ -e "$_pmb_backup" ] || continue + [ "$_pmb_backup" = "$_pmb_keep" ] && continue + if ! rm -f "$_pmb_backup"; then + out_error "could not remove obsolete backup $_pmb_backup" + return 1 + fi + done +} diff --git a/packages/agent-setup/installers/bash/common/output.sh b/packages/agent-setup/installers/bash/common/output.sh index df822267d3..0795b40a7c 100644 --- a/packages/agent-setup/installers/bash/common/output.sh +++ b/packages/agent-setup/installers/bash/common/output.sh @@ -65,15 +65,3 @@ out_metadata() { _emit_line 1 '' "$1: $2"; } out_info() { _emit_line 1 '' "$1"; } out_warn() { _emit_diagnostic "$_C_YELLOW" 'Warning' "$1"; } out_error() { _emit_diagnostic "$_C_RED" 'Error' "$1"; } - -SETUP_TMPDIR="" -_cleanup() { - if [ -n "$SETUP_TMPDIR" ]; then - rm -rf "$SETUP_TMPDIR" 2>/dev/null || true - fi -} -# EXIT owns cleanup. INT/TERM only translate the signal into the conventional -# exit status (130 = 128+SIGINT, 143 = 128+SIGTERM) and let that exit fire the -# EXIT trap. Cleaning up directly inside the INT/TERM handlers would delete the -# working directory and then let the interrupted script resume into the next -# agent's configuration; exiting instead stops all further agent work. diff --git a/packages/agent-setup/installers/bash/common/process.sh b/packages/agent-setup/installers/bash/common/process.sh new file mode 100644 index 0000000000..2619c94b78 --- /dev/null +++ b/packages/agent-setup/installers/bash/common/process.sh @@ -0,0 +1,53 @@ +# Run a command under a wall-clock limit. macOS ships no `timeout`, so the +# Bash-3.2 fallback enables job control for one launch, placing the command and +# all ordinary descendants in a dedicated process group. The watchdog signals +# that group with TERM then KILL, retains its process-group id across root exit, +# and the parent waits for escalation to finish before returning 124. +_run_with_timeout() { + _rwt_secs=$1 + shift + if command -v timeout >/dev/null 2>&1; then + timeout "$_rwt_secs" "$@" + return $? + fi + if command -v gtimeout >/dev/null 2>&1; then + gtimeout "$_rwt_secs" "$@" + return $? + fi + + _rwt_marker=$(mktemp "$SETUP_TMPDIR/timeout.XXXXXX") || return 1 + rm -f "$_rwt_marker" + if [ -n "${AGENT_SETUP_TEST_TRACE_TIMEOUT:-}" ]; then + printf 'Agent Setup test: timeout fallback: process-tree\n' + fi + set -m + "$@" & + _rwt_pid=$! + set +m + ( + # The watchdog must not retain the installer's stdout/stderr descriptors + # after its parent shell is killed; otherwise a pipe consumer waits for the + # orphaned sleep to exit before receiving EOF. + exec /dev/null 2>&1 + sleep "$_rwt_secs" + if kill -0 "$_rwt_pid" 2>/dev/null; then + : > "$_rwt_marker" + kill -TERM -- "-$_rwt_pid" 2>/dev/null || true + sleep 1 + kill -KILL -- "-$_rwt_pid" 2>/dev/null || true + fi + ) & + _rwt_watchdog=$! + wait "$_rwt_pid" + _rwt_status=$? + if [ -e "$_rwt_marker" ]; then + # Let TERM→KILL escalation finish before reporting the timeout. + wait "$_rwt_watchdog" 2>/dev/null || true + rm -f "$_rwt_marker" + return 124 + fi + kill "$_rwt_watchdog" 2>/dev/null || true + wait "$_rwt_watchdog" 2>/dev/null || true + rm -f "$_rwt_marker" + return $_rwt_status +} diff --git a/packages/agent-setup/installers/powershell/common/cli.ps1 b/packages/agent-setup/installers/powershell/common/cli.ps1 new file mode 100644 index 0000000000..42bc4a10d8 --- /dev/null +++ b/packages/agent-setup/installers/powershell/common/cli.ps1 @@ -0,0 +1,94 @@ +function Install-SetupHomebrewCask { + param([string]$Cask) + $brew = Get-Command brew -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $brew) { Stop-Setup 'Homebrew is required to install agent CLIs on macOS.' } + $timeoutSeconds = Get-SetupTimeoutSeconds 600 + Invoke-SetupLiveProcess -Exe $brew.Source -Arguments @('install', '--cask', $Cask) -TimeoutSeconds $timeoutSeconds +} + +# npm on Windows is commonly a .cmd launcher, which ProcessStartInfo cannot +# execute directly with UseShellExecute disabled. A fresh copy of the current +# PowerShell host resolves that launcher while preserving inherited terminal +# output and the same process-tree timeout. +function Install-SetupNpmPackage { + param([string]$Package) + $npm = Get-Command npm -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $npm) { Stop-Setup 'npm was selected for installation but is no longer available.' } + $hostCommand = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + $hostExe = if ($hostCommand) { $hostCommand.Source } else { [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName } + $npmLiteral = "'" + $npm.Source.Replace("'", "''") + "'" + $packageLiteral = "'" + $Package.Replace("'", "''") + "'" + $command = "& $npmLiteral install --global $packageLiteral; exit `$LASTEXITCODE" + $timeoutSeconds = Get-SetupTimeoutSeconds 600 + Invoke-SetupLiveProcess -Exe $hostExe -Arguments @('-NoProfile', '-NonInteractive', '-Command', $command) -TimeoutSeconds $timeoutSeconds +} + +# Execute a downloaded installer in a fresh interpreter. The script travels +# through stdin, while the API key exists only as a variable in this parent +# process and its identically named environment variables were removed. The +# official installer therefore cannot read the credential. +function Invoke-SetupInterpreterBody { + param([string]$Body, [int]$TimeoutSeconds, [string]$Exe, [string]$Arguments) + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $Exe + $startInfo.Arguments = $Arguments + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $false + $startInfo.RedirectStandardInput = $true + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { Stop-Setup "failed to start the installer interpreter." } + $process.StandardInput.Write($Body) + $process.StandardInput.WriteLine() + $process.StandardInput.Close() + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + Stop-SetupProcessTree $process + $process.WaitForExit() + Stop-Setup "the installer timed out after $TimeoutSeconds seconds." + } + if ($process.ExitCode -ne 0) { Stop-Setup "the installer exited with status $($process.ExitCode)." } +} + +function Invoke-SetupPowerShellBody { + param([string]$Body, [int]$TimeoutSeconds, [switch]$BypassExecutionPolicy) + $pwsh = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + $exe = if ($pwsh) { $pwsh.Source } else { [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName } + $executionPolicy = if ($BypassExecutionPolicy) { '-ExecutionPolicy Bypass ' } else { '' } + Invoke-SetupInterpreterBody -Body $Body -TimeoutSeconds $TimeoutSeconds -Exe $exe -Arguments "-NoProfile -NonInteractive ${executionPolicy}-Command -" +} + +function Invoke-SetupShellBody { + param([string]$Body, [int]$TimeoutSeconds) + $bash = Get-Command bash -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $bash) { Stop-Setup 'bash is required to run the official installer on macOS and Linux.' } + Invoke-SetupInterpreterBody -Body $Body -TimeoutSeconds $TimeoutSeconds -Exe $bash.Source -Arguments '-s' +} + +# Download an installer, refuse anything that is not a script (region blocks and +# captive portals serve HTML in place of the installer), then run it. +function Invoke-SetupRemoteInstaller { + param([string]$Uri, [switch]$BypassExecutionPolicy, [switch]$Shell) + $response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -TimeoutSec 60 + $body = [string]$response.Content + $contentType = [string]$response.Headers['Content-Type'] + $looksLikeHtml = $contentType -match '(?i)^text/html(?:;|$)' -or $body -match '(?is)^\s*(?:))' + if ([string]::IsNullOrWhiteSpace($body) -or $looksLikeHtml) { + Stop-Setup "the installer download was HTML or empty, not an executable script (a login or region-block page?)." + } + $timeoutSeconds = Get-SetupTimeoutSeconds 120 + if ($Shell) { Invoke-SetupShellBody -Body $body -TimeoutSeconds $timeoutSeconds } + else { Invoke-SetupPowerShellBody -Body $body -TimeoutSeconds $timeoutSeconds -BypassExecutionPolicy:$BypassExecutionPolicy } +} + +function Get-SetupCliExe { + param([string]$Name, [string]$Label, [string[]]$Candidates) + $found = New-Object System.Collections.Generic.List[string] + $command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { $found.Add($command.Source) } + foreach ($candidate in $Candidates) { + if ((Test-Path -LiteralPath $candidate) -and (-not $found.Contains($candidate))) { $found.Add($candidate) } + } + if ($found.Count -eq 0) { return $null } + if ($found.Count -gt 1) { Write-SetupWarn "multiple $Label installations detected; using $($found[0])" } + return $found[0] +} diff --git a/packages/agent-setup/installers/powershell/common/helpers.ps1 b/packages/agent-setup/installers/powershell/common/helpers.ps1 deleted file mode 100644 index b0e4846f4f..0000000000 --- a/packages/agent-setup/installers/powershell/common/helpers.ps1 +++ /dev/null @@ -1,269 +0,0 @@ -# Windows PowerShell 5.1 only runs on Windows and has no $IsWindows automatic -# variable; PowerShell 6+ exposes it on every platform. -function Test-SetupIsWindows { - ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows -} - -# The AGENT_SETUP_TEST_TIMEOUT_SECONDS hook, read from the ambient environment -# and never emitted by the gateway, lets the harness shorten every wall-clock -# limit; otherwise the caller-supplied default applies. -function Get-SetupTimeoutSeconds { - param([int]$Default) - if ($env:AGENT_SETUP_TEST_TIMEOUT_SECONDS) { [int]$env:AGENT_SETUP_TEST_TIMEOUT_SECONDS } else { $Default } -} - -function Set-SetupProp { - param($Target, [string]$Name, $Value) - if ($Target.PSObject.Properties.Name -contains $Name) { $Target.$Name = $Value } - else { $Target | Add-Member -NotePropertyName $Name -NotePropertyValue $Value } -} - -function Remove-SetupProp { - param($Target, [string]$Name) - if ($Target.PSObject.Properties.Name -contains $Name) { $Target.PSObject.Properties.Remove($Name) } -} - -# A null optional value means "remove this managed key"; any other value is set. -function Set-SetupOptionalProp { - param($Target, [string]$Name, $Value) - if ($null -eq $Value) { Remove-SetupProp $Target $Name } else { Set-SetupProp $Target $Name $Value } -} - -# Redact every occurrence of the API key from text before it is surfaced. -function Protect-SetupSecret { - param([string]$Text) - return ($Text -replace [regex]::Escape($SetupApiKey), '***') -} - -# Restrict a file to the current user: chmod 0600 on Unix, an inheritance-free -# owner-only ACL on Windows. -function Protect-SetupFile { - param([string]$Path) - if (-not (Test-SetupIsWindows)) { - & chmod 600 $Path - if ($LASTEXITCODE -ne 0) { Stop-Setup "could not restrict $Path to owner-only access." } - return - } - # Set-Acl routes through the PowerShell filesystem provider and may persist - # the untouched SACL, demanding SeSecurityPrivilege from a normal user. The - # direct .NET APIs write only this descriptor's modified DACL. - # https://github.com/PowerShell/PowerShell/blob/0c226762e2580cd7853c058dd03fc32638a73971/src/System.Management.Automation/namespaces/FileSystemSecurity.cs#L130-L200 - # https://github.com/dotnet/runtime/blob/f94898a9b55df07348434e86915c7405962427b6/src/libraries/System.IO.FileSystem.AccessControl/src/System/Security/AccessControl/FileSystemSecurity.cs#L103-L125 - $acl = New-Object System.Security.AccessControl.FileSecurity - $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User - $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'Allow') - $acl.SetAccessRuleProtection($true, $false) - $acl.AddAccessRule($rule) - if ($PSVersionTable.PSVersion.Major -lt 6) { - [System.IO.File]::SetAccessControl($Path, $acl) - } else { - [System.IO.FileSystemAclExtensions]::SetAccessControl([System.IO.FileInfo]::new($Path), $acl) - } -} - -# Terminate a process and its descendants. PowerShell 7's runtime exposes the -# tree-aware Kill(bool) overload; Windows PowerShell 5.1 uses taskkill /T. -function Stop-SetupProcessTree { - param([System.Diagnostics.Process]$Process) - $runningOnWindows = Test-SetupIsWindows - if ($runningOnWindows) { - & taskkill.exe /PID $Process.Id /T /F *> $null - if ($LASTEXITCODE -ne 0 -and (-not $Process.HasExited)) { - Stop-Setup "taskkill could not terminate process tree $($Process.Id)." - } - return - } - try { - $Process.Kill($true) - } catch { - if (-not $Process.HasExited) { Stop-Setup "could not terminate process tree $($Process.Id)." } - } -} - -function Get-SetupPlatform { - if (Test-SetupIsWindows) { return 'windows' } - if ($IsMacOS) { return 'macos' } - return 'linux' -} - -# Run a fixed package-manager command with inherited stdout/stderr. The child -# remains attached to the real terminal, so progress updates and ANSI control -# sequences render in real time without a lossy line-prefix filter. -function Invoke-SetupLiveProcess { - param([string]$Exe, [string[]]$Arguments, [int]$TimeoutSeconds) - $startInfo = New-Object System.Diagnostics.ProcessStartInfo - $startInfo.FileName = $Exe - $startInfo.Arguments = ($Arguments | ForEach-Object { '"' + $_.Replace('"', '\"') + '"' }) -join ' ' - $startInfo.UseShellExecute = $false - $startInfo.CreateNoWindow = $false - $process = New-Object System.Diagnostics.Process - $process.StartInfo = $startInfo - if (-not $process.Start()) { Stop-Setup "failed to start $Exe." } - if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { - Stop-SetupProcessTree $process - $process.WaitForExit() - Stop-Setup "$Exe timed out after $TimeoutSeconds seconds." - } - if ($process.ExitCode -ne 0) { Stop-Setup "$Exe exited with status $($process.ExitCode)." } -} - -function Install-SetupHomebrewCask { - param([string]$Cask) - $brew = Get-Command brew -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 - if (-not $brew) { Stop-Setup 'Homebrew is required to install agent CLIs on macOS.' } - $timeoutSeconds = Get-SetupTimeoutSeconds 600 - Invoke-SetupLiveProcess -Exe $brew.Source -Arguments @('install', '--cask', $Cask) -TimeoutSeconds $timeoutSeconds -} - -# npm on Windows is commonly a .cmd launcher, which ProcessStartInfo cannot -# execute directly with UseShellExecute disabled. A fresh copy of the current -# PowerShell host resolves that launcher while preserving inherited terminal -# output and the same process-tree timeout. -function Install-SetupNpmPackage { - param([string]$Package) - $npm = Get-Command npm -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 - if (-not $npm) { Stop-Setup 'npm was selected for installation but is no longer available.' } - $hostCommand = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 - $hostExe = if ($hostCommand) { $hostCommand.Source } else { [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName } - $npmLiteral = "'" + $npm.Source.Replace("'", "''") + "'" - $packageLiteral = "'" + $Package.Replace("'", "''") + "'" - $command = "& $npmLiteral install --global $packageLiteral; exit `$LASTEXITCODE" - $timeoutSeconds = Get-SetupTimeoutSeconds 600 - Invoke-SetupLiveProcess -Exe $hostExe -Arguments @('-NoProfile', '-NonInteractive', '-Command', $command) -TimeoutSeconds $timeoutSeconds -} - -# Execute a downloaded installer in a fresh interpreter. The script travels -# through stdin, while the API key exists only as a variable in this parent -# process and its identically named environment variables were removed. The -# official installer therefore cannot read the credential. -function Invoke-SetupInterpreterBody { - param([string]$Body, [int]$TimeoutSeconds, [string]$Exe, [string]$Arguments) - $startInfo = New-Object System.Diagnostics.ProcessStartInfo - $startInfo.FileName = $Exe - $startInfo.Arguments = $Arguments - $startInfo.UseShellExecute = $false - $startInfo.CreateNoWindow = $false - $startInfo.RedirectStandardInput = $true - $process = New-Object System.Diagnostics.Process - $process.StartInfo = $startInfo - if (-not $process.Start()) { Stop-Setup "failed to start the installer interpreter." } - $process.StandardInput.Write($Body) - $process.StandardInput.WriteLine() - $process.StandardInput.Close() - if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { - Stop-SetupProcessTree $process - $process.WaitForExit() - Stop-Setup "the installer timed out after $TimeoutSeconds seconds." - } - if ($process.ExitCode -ne 0) { Stop-Setup "the installer exited with status $($process.ExitCode)." } -} - -function Invoke-SetupPowerShellBody { - param([string]$Body, [int]$TimeoutSeconds, [switch]$BypassExecutionPolicy) - $pwsh = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 - $exe = if ($pwsh) { $pwsh.Source } else { [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName } - $executionPolicy = if ($BypassExecutionPolicy) { '-ExecutionPolicy Bypass ' } else { '' } - Invoke-SetupInterpreterBody -Body $Body -TimeoutSeconds $TimeoutSeconds -Exe $exe -Arguments "-NoProfile -NonInteractive ${executionPolicy}-Command -" -} - -function Invoke-SetupShellBody { - param([string]$Body, [int]$TimeoutSeconds) - $bash = Get-Command bash -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 - if (-not $bash) { Stop-Setup 'bash is required to run the official installer on macOS and Linux.' } - Invoke-SetupInterpreterBody -Body $Body -TimeoutSeconds $TimeoutSeconds -Exe $bash.Source -Arguments '-s' -} - -# Download an installer, refuse anything that is not a script (region blocks and -# captive portals serve HTML in place of the installer), then run it. -function Invoke-SetupRemoteInstaller { - param([string]$Uri, [switch]$BypassExecutionPolicy, [switch]$Shell) - $response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -TimeoutSec 60 - $body = [string]$response.Content - $contentType = [string]$response.Headers['Content-Type'] - $looksLikeHtml = $contentType -match '(?i)^text/html(?:;|$)' -or $body -match '(?is)^\s*(?:))' - if ([string]::IsNullOrWhiteSpace($body) -or $looksLikeHtml) { - Stop-Setup "the installer download was HTML or empty, not an executable script (a login or region-block page?)." - } - $timeoutSeconds = Get-SetupTimeoutSeconds 120 - if ($Shell) { Invoke-SetupShellBody -Body $body -TimeoutSeconds $timeoutSeconds } - else { Invoke-SetupPowerShellBody -Body $body -TimeoutSeconds $timeoutSeconds -BypassExecutionPolicy:$BypassExecutionPolicy } -} - -function Get-SetupCliExe { - param([string]$Name, [string]$Label, [string[]]$Candidates) - $found = New-Object System.Collections.Generic.List[string] - $command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($command) { $found.Add($command.Source) } - foreach ($candidate in $Candidates) { - if ((Test-Path -LiteralPath $candidate) -and (-not $found.Contains($candidate))) { $found.Add($candidate) } - } - if ($found.Count -eq 0) { return $null } - if ($found.Count -gt 1) { Write-SetupWarn "multiple $Label installations detected; using $($found[0])" } - return $found[0] -} - -# Rollback retains a backup when restoration fails so manual recovery remains -# possible, warning with the preserved path and the action to take — matching -# the Bash installer. The AGENT_SETUP_TEST_FAIL_RESTORE hook, read from the -# ambient environment and never emitted by the gateway, forces the restore -# rename to fail so the harness can assert that guidance. -function Restore-SetupManagedFile { - param([bool]$Existed, [string]$Backup, [string]$Path, [string]$OriginalLabel, [string]$CreatedLabel) - if ($Existed) { - if ($Backup -and (Test-Path -LiteralPath $Backup)) { - try { - if ($env:AGENT_SETUP_TEST_FAIL_RESTORE) { throw 'test-injected restore failure' } - # Secret-bearing backups were already owner-only before any mutation. - # Moving one back preserves that protection without a second operation - # that could fail after the backup path has been consumed. - Move-Item -LiteralPath $Backup -Destination $Path -Force - } catch { - Write-SetupWarn "could not restore $Path from its backup; your original $OriginalLabel is preserved at $Backup — restore it by hand." - } - } - } elseif (Test-Path -LiteralPath $Path) { - try { - Remove-Item -LiteralPath $Path -Force - } catch { - Write-SetupWarn "could not remove the $CreatedLabel this run created at $Path — remove it by hand." - } - } -} - -function Remove-SetupOlderBackups { - param([string]$Path, [string]$Keep) - $directory = Split-Path -Parent $Path - $prefix = [System.IO.Path]::GetFileName($Path) + '.floway-backup.' - Get-ChildItem -LiteralPath $directory -File -ErrorAction Stop | - Where-Object { $_.Name.StartsWith($prefix, [System.StringComparison]::Ordinal) -and $_.FullName -ne $Keep } | - Remove-Item -Force -ErrorAction Stop -} - -# Run a child process with captured output under a deadline, terminating its -# whole process tree and throwing on timeout. -function Invoke-SetupProcess { - param([string]$Exe, [string[]]$Arguments, [int]$TimeoutSeconds, [string]$TimeoutMessage) - $startInfo = New-Object System.Diagnostics.ProcessStartInfo - $startInfo.FileName = $Exe - $startInfo.UseShellExecute = $false - $startInfo.CreateNoWindow = $true - $startInfo.RedirectStandardOutput = $true - $startInfo.RedirectStandardError = $true - # ArgumentList is unavailable in Windows PowerShell 5.1. These arguments are - # fixed internal tokens, so quoting them with ProcessStartInfo.Arguments is - # safe and keeps external input out of the child command line. - $startInfo.Arguments = ($Arguments | ForEach-Object { '"' + $_.Replace('"', '\"') + '"' }) -join ' ' - $process = New-Object System.Diagnostics.Process - $process.StartInfo = $startInfo - if (-not $process.Start()) { Stop-Setup "failed to start $Exe." } - $stdoutTask = $process.StandardOutput.ReadToEndAsync() - $stderrTask = $process.StandardError.ReadToEndAsync() - if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { - Stop-SetupProcessTree $process - $process.WaitForExit() - Stop-Setup $(if ($TimeoutMessage) { $TimeoutMessage } else { "$Exe timed out after $TimeoutSeconds seconds." }) - } - $stdout = $stdoutTask.GetAwaiter().GetResult() - $stderr = $stderrTask.GetAwaiter().GetResult() - [PSCustomObject]@{ ExitCode = $process.ExitCode; Output = ($stdout + $stderr) } -} diff --git a/packages/agent-setup/installers/powershell/common/json-document.ps1 b/packages/agent-setup/installers/powershell/common/json-document.ps1 new file mode 100644 index 0000000000..7cb68bbe24 --- /dev/null +++ b/packages/agent-setup/installers/powershell/common/json-document.ps1 @@ -0,0 +1,16 @@ +function Set-SetupProp { + param($Target, [string]$Name, $Value) + if ($Target.PSObject.Properties.Name -contains $Name) { $Target.$Name = $Value } + else { $Target | Add-Member -NotePropertyName $Name -NotePropertyValue $Value } +} + +function Remove-SetupProp { + param($Target, [string]$Name) + if ($Target.PSObject.Properties.Name -contains $Name) { $Target.PSObject.Properties.Remove($Name) } +} + +# A null optional value means "remove this managed key"; any other value is set. +function Set-SetupOptionalProp { + param($Target, [string]$Name, $Value) + if ($null -eq $Value) { Remove-SetupProp $Target $Name } else { Set-SetupProp $Target $Name $Value } +} diff --git a/packages/agent-setup/installers/powershell/common/main.ps1 b/packages/agent-setup/installers/powershell/common/main.ps1 index 6efe10f615..5653dfc784 100644 --- a/packages/agent-setup/installers/powershell/common/main.ps1 +++ b/packages/agent-setup/installers/powershell/common/main.ps1 @@ -1,3 +1,9 @@ +# Redact every occurrence of the API key from text before it is surfaced. +function Protect-SetupSecret { + param([string]$Text) + return ($Text -replace [regex]::Escape($SetupApiKey), '***') +} + # --- run -------------------------------------------------------------------- function Main { diff --git a/packages/agent-setup/installers/powershell/common/managed-file.ps1 b/packages/agent-setup/installers/powershell/common/managed-file.ps1 new file mode 100644 index 0000000000..8e2f5cdfb1 --- /dev/null +++ b/packages/agent-setup/installers/powershell/common/managed-file.ps1 @@ -0,0 +1,62 @@ +# Restrict a file to the current user: chmod 0600 on Unix, an inheritance-free +# owner-only ACL on Windows. +function Protect-SetupFile { + param([string]$Path) + if (-not (Test-SetupIsWindows)) { + & chmod 600 $Path + if ($LASTEXITCODE -ne 0) { Stop-Setup "could not restrict $Path to owner-only access." } + return + } + # Set-Acl routes through the PowerShell filesystem provider and may persist + # the untouched SACL, demanding SeSecurityPrivilege from a normal user. The + # direct .NET APIs write only this descriptor's modified DACL. + # https://github.com/PowerShell/PowerShell/blob/0c226762e2580cd7853c058dd03fc32638a73971/src/System.Management.Automation/namespaces/FileSystemSecurity.cs#L130-L200 + # https://github.com/dotnet/runtime/blob/f94898a9b55df07348434e86915c7405962427b6/src/libraries/System.IO.FileSystem.AccessControl/src/System/Security/AccessControl/FileSystemSecurity.cs#L103-L125 + $acl = New-Object System.Security.AccessControl.FileSecurity + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'Allow') + $acl.SetAccessRuleProtection($true, $false) + $acl.AddAccessRule($rule) + if ($PSVersionTable.PSVersion.Major -lt 6) { + [System.IO.File]::SetAccessControl($Path, $acl) + } else { + [System.IO.FileSystemAclExtensions]::SetAccessControl([System.IO.FileInfo]::new($Path), $acl) + } +} + +# Rollback retains a backup when restoration fails so manual recovery remains +# possible, warning with the preserved path and the action to take — matching +# the Bash installer. The AGENT_SETUP_TEST_FAIL_RESTORE hook, read from the +# ambient environment and never emitted by the gateway, forces the restore +# rename to fail so the harness can assert that guidance. +function Restore-SetupManagedFile { + param([bool]$Existed, [string]$Backup, [string]$Path, [string]$OriginalLabel, [string]$CreatedLabel) + if ($Existed) { + if ($Backup -and (Test-Path -LiteralPath $Backup)) { + try { + if ($env:AGENT_SETUP_TEST_FAIL_RESTORE) { throw 'test-injected restore failure' } + # Secret-bearing backups were already owner-only before any mutation. + # Moving one back preserves that protection without a second operation + # that could fail after the backup path has been consumed. + Move-Item -LiteralPath $Backup -Destination $Path -Force + } catch { + Write-SetupWarn "could not restore $Path from its backup; your original $OriginalLabel is preserved at $Backup — restore it by hand." + } + } + } elseif (Test-Path -LiteralPath $Path) { + try { + Remove-Item -LiteralPath $Path -Force + } catch { + Write-SetupWarn "could not remove the $CreatedLabel this run created at $Path — remove it by hand." + } + } +} + +function Remove-SetupOlderBackups { + param([string]$Path, [string]$Keep) + $directory = Split-Path -Parent $Path + $prefix = [System.IO.Path]::GetFileName($Path) + '.floway-backup.' + Get-ChildItem -LiteralPath $directory -File -ErrorAction Stop | + Where-Object { $_.Name.StartsWith($prefix, [System.StringComparison]::Ordinal) -and $_.FullName -ne $Keep } | + Remove-Item -Force -ErrorAction Stop +} diff --git a/packages/agent-setup/installers/powershell/common/platform.ps1 b/packages/agent-setup/installers/powershell/common/platform.ps1 new file mode 100644 index 0000000000..3548634989 --- /dev/null +++ b/packages/agent-setup/installers/powershell/common/platform.ps1 @@ -0,0 +1,19 @@ +# Windows PowerShell 5.1 only runs on Windows and has no $IsWindows automatic +# variable; PowerShell 6+ exposes it on every platform. +function Test-SetupIsWindows { + ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows +} + +# The AGENT_SETUP_TEST_TIMEOUT_SECONDS hook, read from the ambient environment +# and never emitted by the gateway, lets the harness shorten every wall-clock +# limit; otherwise the caller-supplied default applies. +function Get-SetupTimeoutSeconds { + param([int]$Default) + if ($env:AGENT_SETUP_TEST_TIMEOUT_SECONDS) { [int]$env:AGENT_SETUP_TEST_TIMEOUT_SECONDS } else { $Default } +} + +function Get-SetupPlatform { + if (Test-SetupIsWindows) { return 'windows' } + if ($IsMacOS) { return 'macos' } + return 'linux' +} diff --git a/packages/agent-setup/installers/powershell/common/process.ps1 b/packages/agent-setup/installers/powershell/common/process.ps1 new file mode 100644 index 0000000000..54d56a797a --- /dev/null +++ b/packages/agent-setup/installers/powershell/common/process.ps1 @@ -0,0 +1,68 @@ +# Terminate a process and its descendants. PowerShell 7's runtime exposes the +# tree-aware Kill(bool) overload; Windows PowerShell 5.1 uses taskkill /T. +function Stop-SetupProcessTree { + param([System.Diagnostics.Process]$Process) + $runningOnWindows = Test-SetupIsWindows + if ($runningOnWindows) { + & taskkill.exe /PID $Process.Id /T /F *> $null + if ($LASTEXITCODE -ne 0 -and (-not $Process.HasExited)) { + Stop-Setup "taskkill could not terminate process tree $($Process.Id)." + } + return + } + try { + $Process.Kill($true) + } catch { + if (-not $Process.HasExited) { Stop-Setup "could not terminate process tree $($Process.Id)." } + } +} + +# Run a fixed package-manager command with inherited stdout/stderr. The child +# remains attached to the real terminal, so progress updates and ANSI control +# sequences render in real time without a lossy line-prefix filter. +function Invoke-SetupLiveProcess { + param([string]$Exe, [string[]]$Arguments, [int]$TimeoutSeconds) + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $Exe + $startInfo.Arguments = ($Arguments | ForEach-Object { '"' + $_.Replace('"', '\"') + '"' }) -join ' ' + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $false + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { Stop-Setup "failed to start $Exe." } + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + Stop-SetupProcessTree $process + $process.WaitForExit() + Stop-Setup "$Exe timed out after $TimeoutSeconds seconds." + } + if ($process.ExitCode -ne 0) { Stop-Setup "$Exe exited with status $($process.ExitCode)." } +} + +# Run a child process with captured output under a deadline, terminating its +# whole process tree and throwing on timeout. +function Invoke-SetupProcess { + param([string]$Exe, [string[]]$Arguments, [int]$TimeoutSeconds, [string]$TimeoutMessage) + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $Exe + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + # ArgumentList is unavailable in Windows PowerShell 5.1. These arguments are + # fixed internal tokens, so quoting them with ProcessStartInfo.Arguments is + # safe and keeps external input out of the child command line. + $startInfo.Arguments = ($Arguments | ForEach-Object { '"' + $_.Replace('"', '\"') + '"' }) -join ' ' + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { Stop-Setup "failed to start $Exe." } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + Stop-SetupProcessTree $process + $process.WaitForExit() + Stop-Setup $(if ($TimeoutMessage) { $TimeoutMessage } else { "$Exe timed out after $TimeoutSeconds seconds." }) + } + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + [PSCustomObject]@{ ExitCode = $process.ExitCode; Output = ($stdout + $stderr) } +} diff --git a/packages/agent-setup/package.json b/packages/agent-setup/package.json index c061e29167..c16a134aee 100644 --- a/packages/agent-setup/package.json +++ b/packages/agent-setup/package.json @@ -7,7 +7,7 @@ ".": { "import": "./src/index.ts", "types": "./src/index.ts" } }, "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json", "test": "vitest run", "generate-assets": "jiti scripts/generate-assets.ts", "test:installers": "jiti scripts/test-installers.ts" @@ -18,6 +18,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@floway-dev/test-utils": "workspace:*" + "@floway-dev/test-utils": "workspace:*", + "@types/node": "^22" } } diff --git a/packages/agent-setup/scripts/generate-assets.ts b/packages/agent-setup/scripts/generate-assets.ts index 2c52f82e19..44e3a6a7b9 100644 --- a/packages/agent-setup/scripts/generate-assets.ts +++ b/packages/agent-setup/scripts/generate-assets.ts @@ -1,7 +1,7 @@ // Embeds the canonical Agent Setup fragments as runtime-neutral string -// constants in `src/script-assets.generated.ts`. Runtime TypeScript composes a -// platform common fragment with exactly one agent fragment, so public scripts -// never carry the other agent's implementation. +// constants in `src/script-assets.generated.ts`. The ordered manifest here also +// prejoins each platform's common body, so runtime code and the installer +// harness execute the same artifact. // // Run `pnpm --filter @floway-dev/agent-setup run generate-assets` to rewrite // the generated module; pass `--check` to fail when checked-in output drifts. @@ -13,34 +13,130 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = resolve(HERE, '..'); const GENERATED_PATH = resolve(PACKAGE_ROOT, 'src/script-assets.generated.ts'); -const fragments = [ - ['SETUP_BASH_COMMON_OUTPUT', 'installers/bash/common/output.sh'], - ['SETUP_BASH_COMMON_HELPERS', 'installers/bash/common/helpers.sh'], - ['SETUP_BASH_COMMON_MAIN', 'installers/bash/common/main.sh'], - ['SETUP_BASH_CLAUDE', 'installers/bash/claude.sh'], - ['SETUP_BASH_CODEX', 'installers/bash/codex.sh'], - ['SETUP_POWERSHELL_COMMON_OUTPUT', 'installers/powershell/common/output.ps1'], - ['SETUP_POWERSHELL_COMMON_HELPERS', 'installers/powershell/common/helpers.ps1'], - ['SETUP_POWERSHELL_COMMON_MAIN', 'installers/powershell/common/main.ps1'], - ['SETUP_POWERSHELL_CLAUDE', 'installers/powershell/claude.ps1'], - ['SETUP_POWERSHELL_CODEX', 'installers/powershell/codex.ps1'], -] as const; +interface SourceSection { + name: string; + file: string; + start?: string; + end?: string; + append?: string; +} + +interface PlatformSources { + common: readonly SourceSection[]; + agents: readonly SourceSection[]; +} -const sources = await Promise.all(fragments.map(async ([name, file]) => ({ +// Source files are grouped by responsibility, while these section boundaries +// preserve the public script's established byte order. +const scriptSources = { + bash: { + common: [ + { name: 'SETUP_BASH_COMMON_OUTPUT', file: 'installers/bash/common/output.sh', append: '\n' }, + { name: 'SETUP_BASH_COMMON_MAIN', file: 'installers/bash/common/main.sh', end: '# --- run' }, + { name: 'SETUP_BASH_COMMON_PROCESS', file: 'installers/bash/common/process.sh', append: '\n' }, + { name: 'SETUP_BASH_COMMON_JQ', file: 'installers/bash/common/jq.sh', append: '\n' }, + { name: 'SETUP_BASH_COMMON_CLI', file: 'installers/bash/common/cli.sh', end: '_install_brew_cask() {' }, + { name: 'SETUP_BASH_COMMON_MANAGED_FILE', file: 'installers/bash/common/managed-file.sh', append: '\n' }, + { name: 'SETUP_BASH_COMMON_CLI', file: 'installers/bash/common/cli.sh', start: '_install_brew_cask() {' }, + { name: 'SETUP_BASH_COMMON_MAIN', file: 'installers/bash/common/main.sh', start: '# --- run' }, + ], + agents: [ + { name: 'SETUP_BASH_CLAUDE', file: 'installers/bash/claude.sh' }, + { name: 'SETUP_BASH_CODEX', file: 'installers/bash/codex.sh' }, + ], + }, + powershell: { + common: [ + { name: 'SETUP_POWERSHELL_COMMON_OUTPUT', file: 'installers/powershell/common/output.ps1' }, + { name: 'SETUP_POWERSHELL_COMMON_PLATFORM', file: 'installers/powershell/common/platform.ps1', end: 'function Get-SetupPlatform' }, + { name: 'SETUP_POWERSHELL_COMMON_JSON_DOCUMENT', file: 'installers/powershell/common/json-document.ps1', append: '\n' }, + { name: 'SETUP_POWERSHELL_COMMON_MAIN', file: 'installers/powershell/common/main.ps1', end: '# --- run' }, + { name: 'SETUP_POWERSHELL_COMMON_MANAGED_FILE', file: 'installers/powershell/common/managed-file.ps1', end: '# Rollback retains' }, + { name: 'SETUP_POWERSHELL_COMMON_PROCESS', file: 'installers/powershell/common/process.ps1', end: '# Run a fixed package-manager' }, + { name: 'SETUP_POWERSHELL_COMMON_PLATFORM', file: 'installers/powershell/common/platform.ps1', start: 'function Get-SetupPlatform', append: '\n' }, + { + name: 'SETUP_POWERSHELL_COMMON_PROCESS', + file: 'installers/powershell/common/process.ps1', + start: '# Run a fixed package-manager', + end: '# Run a child process with captured output', + }, + { name: 'SETUP_POWERSHELL_COMMON_CLI', file: 'installers/powershell/common/cli.ps1', append: '\n' }, + { name: 'SETUP_POWERSHELL_COMMON_MANAGED_FILE', file: 'installers/powershell/common/managed-file.ps1', start: '# Rollback retains', append: '\n' }, + { name: 'SETUP_POWERSHELL_COMMON_PROCESS', file: 'installers/powershell/common/process.ps1', start: '# Run a child process with captured output' }, + { name: 'SETUP_POWERSHELL_COMMON_MAIN', file: 'installers/powershell/common/main.ps1', start: '# --- run' }, + ], + agents: [ + { name: 'SETUP_POWERSHELL_CLAUDE', file: 'installers/powershell/claude.ps1' }, + { name: 'SETUP_POWERSHELL_CODEX', file: 'installers/powershell/codex.ps1' }, + ], + }, +} as const satisfies Record; + +const allSections = Object.values(scriptSources).flatMap(({ common, agents }) => [...common, ...agents]); +const sourceFiles = new Map(); +for (const { name, file } of allSections) { + const existing = sourceFiles.get(name); + if (existing !== undefined && existing !== file) throw new Error(`${name} maps to both ${existing} and ${file}`); + sourceFiles.set(name, file); +} + +const sourceByName = new Map(await Promise.all([...sourceFiles].map(async ([name, file]) => [ name, - file, - source: await readFile(resolve(PACKAGE_ROOT, file), 'utf8'), -}))); -const constants = sources.map(({ name, source }) => `export const ${name} = ${JSON.stringify(source)};`).join('\n\n'); -const fileList = sources.map(({ file }) => `// - ${file}`).join('\n'); + await readFile(resolve(PACKAGE_ROOT, file), 'utf8'), +] as const))); + +const findBoundary = (source: string, boundary: string, from: number, name: string): number => { + const index = source.indexOf(boundary, from); + if (index === -1) throw new Error(`${name} does not contain boundary ${JSON.stringify(boundary)}`); + return index; +}; + +const renderSection = (section: SourceSection): string => { + const source = sourceByName.get(section.name); + if (source === undefined) throw new Error(`source not loaded for ${section.name}`); + const start = section.start === undefined ? 0 : findBoundary(source, section.start, 0, section.name); + const end = section.end === undefined ? source.length : findBoundary(source, section.end, start, section.name); + return source.slice(start, end) + (section.append ?? ''); +}; + +const typescriptString = (value: string): string => `'${[...value].map(character => { + if (character === '\\') return '\\\\'; + if (character === '\'') return '\\\''; + if (character === '\b') return '\\b'; + if (character === '\f') return '\\f'; + if (character === '\n') return '\\n'; + if (character === '\r') return '\\r'; + if (character === '\t') return '\\t'; + const codePoint = character.charCodeAt(0); + if (codePoint < 0x20 || codePoint === 0x2028 || codePoint === 0x2029) { + return `\\u${codePoint.toString(16).padStart(4, '0')}`; + } + return character; +}).join('')}'`; + +const sourceConstants = [...sourceFiles].map(([name]) => { + const source = sourceByName.get(name); + if (source === undefined) throw new Error(`source not loaded for ${name}`); + return `export const ${name} = ${typescriptString(source)};`; +}).join('\n\n'); +const commonConstants = Object.entries(scriptSources).map(([platform, { common }]) => + `export const SETUP_${platform.toUpperCase()}_COMMON = ${typescriptString(common.map(renderSection).join(''))};`).join('\n\n'); +const sourceFragments = [...sourceFiles].map(([name, file]) => ` [${typescriptString(file)}, ${name}],`).join('\n'); +const fileList = [...sourceFiles.values()].map(file => `// - ${file}`).join('\n'); const expected = `// GENERATED by scripts/generate-assets.ts — do not edit by hand. // -// Canonical installer fragments embedded verbatim from: +// Canonical installer source files embedded verbatim from: ${fileList} // Regenerate after editing a fragment: // \`pnpm --filter @floway-dev/agent-setup run generate-assets\`. -${constants} +${sourceConstants} + +${commonConstants} + +export const SETUP_SCRIPT_SOURCE_FRAGMENTS = [ +${sourceFragments} +] as const; `; if (process.argv.includes('--check')) { diff --git a/packages/agent-setup/scripts/test-installers.ts b/packages/agent-setup/scripts/test-installers.ts index 5960f83085..1f7ab793ec 100644 --- a/packages/agent-setup/scripts/test-installers.ts +++ b/packages/agent-setup/scripts/test-installers.ts @@ -21,34 +21,29 @@ import { spawn, spawnSync } from 'node:child_process'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; import { createServer, type Server } from 'node:http'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; import type { AgentSetupConfiguration } from '../src/configuration.ts'; import { renderPowerShellPrefix, renderShellPrefix } from '../src/render.ts'; +import { + SETUP_BASH_CLAUDE, + SETUP_BASH_CODEX, + SETUP_BASH_COMMON, + SETUP_POWERSHELL_CLAUDE, + SETUP_POWERSHELL_CODEX, + SETUP_POWERSHELL_COMMON, +} from '../src/script-assets.generated.ts'; +import { type ScriptAgent, SETUP_SCRIPT_BODIES } from '../src/script-assets.ts'; const powerShellLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`; -const HERE = dirname(fileURLToPath(import.meta.url)); -const INSTALLERS_DIR = join(HERE, '..', 'installers'); -const BASH_COMMON = ['output.sh', 'helpers.sh', 'main.sh'] - .map(file => readFileSync(join(INSTALLERS_DIR, 'bash/common', file), 'utf8')) - .join(''); -const BASH_CLAUDE = readFileSync(join(INSTALLERS_DIR, 'bash/claude.sh'), 'utf8'); -const BASH_CODEX = readFileSync(join(INSTALLERS_DIR, 'bash/codex.sh'), 'utf8'); -const POWERSHELL_COMMON = ['output.ps1', 'helpers.ps1', 'main.ps1'] - .map(file => readFileSync(join(INSTALLERS_DIR, 'powershell/common', file), 'utf8')) - .join(''); -const POWERSHELL_CLAUDE = readFileSync(join(INSTALLERS_DIR, 'powershell/claude.ps1'), 'utf8'); -const POWERSHELL_CODEX = readFileSync(join(INSTALLERS_DIR, 'powershell/codex.ps1'), 'utf8'); -type SetupAgent = 'claude' | 'codex'; -const AGENT_NAMES: Record = { claude: 'Claude Code', codex: 'Codex' }; -const shellEntry = (agent: SetupAgent): string => `main '${AGENT_NAMES[agent]}' "$@"`; -const powerShellEntry = (agent: SetupAgent): string => `$global:LASTEXITCODE = Main '${AGENT_NAMES[agent]}'`; -const shellBody = (agent: SetupAgent): string => BASH_COMMON + (agent === 'claude' ? BASH_CLAUDE : BASH_CODEX); -const powerShellBody = (agent: SetupAgent): string => POWERSHELL_COMMON + (agent === 'claude' ? POWERSHELL_CLAUDE : POWERSHELL_CODEX); -const ALL_BASH_FRAGMENTS = BASH_COMMON + BASH_CLAUDE + BASH_CODEX; -const ALL_POWERSHELL_FRAGMENTS = POWERSHELL_COMMON + POWERSHELL_CLAUDE + POWERSHELL_CODEX; +const AGENT_NAMES: Record = { claude: 'Claude Code', codex: 'Codex' }; +const shellEntry = (agent: ScriptAgent): string => `main '${AGENT_NAMES[agent]}' "$@"`; +const powerShellEntry = (agent: ScriptAgent): string => `$global:LASTEXITCODE = Main '${AGENT_NAMES[agent]}'`; +const shellBody = (agent: ScriptAgent): string => SETUP_SCRIPT_BODIES[agent].sh; +const powerShellBody = (agent: ScriptAgent): string => SETUP_SCRIPT_BODIES[agent].ps1; +const ALL_BASH_FRAGMENTS = SETUP_BASH_COMMON + SETUP_BASH_CLAUDE + SETUP_BASH_CODEX; +const ALL_POWERSHELL_FRAGMENTS = SETUP_POWERSHELL_COMMON + SETUP_POWERSHELL_CLAUDE + SETUP_POWERSHELL_CODEX; // A fixed, highly greppable fake credential. Every test asserts this string // never reaches the installer's stdout/stderr, so a real leak is unmistakable. @@ -57,7 +52,7 @@ const SENTINEL_KEY = 'sk-floway-SENTINEL-Do-Not-Log-9f3c1a7b2e4d6058'; // --- tiny test runner ------------------------------------------------------- class SkipError extends Error {} -const skip = (reason: string): never => { throw new SkipError(reason); }; +const skip: (reason: string) => never = reason => { throw new SkipError(reason); }; interface Assert { ok(cond: boolean, message: string): void; @@ -80,9 +75,9 @@ const makeAssert = (): Assert => ({ }); type TestFn = (t: Assert) => void | Promise; -interface Case { agent: SetupAgent; name: string; fn: TestFn; } +interface Case { agent: ScriptAgent; name: string; fn: TestFn } const cases: Case[] = []; -const test = (agent: SetupAgent, name: string, fn: TestFn): void => { cases.push({ agent, name, fn }); }; +const test = (agent: ScriptAgent, name: string, fn: TestFn): void => { cases.push({ agent, name, fn }); }; // --- shared fixtures -------------------------------------------------------- @@ -429,7 +424,7 @@ const startModelServer = async (): Promise => { // --- workspace + runner ----------------------------------------------------- -interface Workspace { root: string; home: string; binDir: string; } +interface Workspace { root: string; home: string; binDir: string } const makeWorkspace = (): Workspace => { const root = mkdtempSync(join(HARNESS_ROOT, 'ws.')); const home = join(root, 'home'); @@ -563,7 +558,7 @@ interface RunOptions { const targetAgent = (configuration: InstallerTestConfiguration, agent?: 'claude' | 'codex'): 'claude' | 'codex' => agent ?? configuration.testAgent; -interface RunResult { code: number; stdout: string; stderr: string; combined: string; } +interface RunResult { code: number; stdout: string; stderr: string; combined: string } // Environment shared by the shell run helpers: Codex fake-binary knobs, the // install hook, and CODEX_HOME. Callers merge this over the Claude environment @@ -654,7 +649,7 @@ const runShellInstaller = (options: RunOptions): Promise => { } const signal = options.signalDuringInstall; - return new Promise((resolve) => { + return new Promise(resolve => { const child = spawn('/bin/bash', [scriptPath], { env, detached: signal !== undefined }); let stdout = ''; let stderr = ''; @@ -698,7 +693,7 @@ const runShellInstallerWithAmbientKey = (options: RunOptions): Promise((resolve) => { + return new Promise(resolve => { const child = spawn('/bin/bash', [scriptPath], { env }); let stdout = ''; let stderr = ''; @@ -807,7 +802,7 @@ const runPowerShellInstaller = (options: RunOptions): Promise => { if (options.noColor) env.NO_COLOR = '1'; if (options.failRestore) env.AGENT_SETUP_TEST_FAIL_RESTORE = '1'; - return new Promise((resolve) => { + return new Promise(resolve => { const child = spawn(hostPwsh!, ['-NoProfile', '-File', invocationPath], { env }); let stdout = ''; let stderr = ''; @@ -1105,7 +1100,7 @@ test('claude', 'PowerShell installer body parses without syntax errors', async t const body = powerShellBody('claude'); const entry = powerShellEntry('claude'); t.ok(body.trimEnd().endsWith(entry), 'the downloaded script starts execution only from its final line'); - t.ok(body.lastIndexOf(entry) > body.indexOf('function Set-SetupAgent {'), 'the entry call follows every agent function'); + t.ok(body.lastIndexOf(entry) > body.indexOf('function Set-ScriptAgent {'), 'the entry call follows every agent function'); const script = renderPowerShellPrefix({ agent: 'claude', apiKey: SENTINEL_KEY, @@ -1300,9 +1295,11 @@ test('claude', 'PowerShell stages secret data only after protection and hardens }); test('claude', 'PowerShell Windows file protection writes only an owner DACL', t => { - const helperStart = POWERSHELL_COMMON.indexOf('function Protect-SetupFile'); - const helperEnd = POWERSHELL_COMMON.indexOf('function Stop-SetupProcessTree', helperStart); - const helper = POWERSHELL_COMMON.slice(helperStart, helperEnd); + const helperStart = SETUP_POWERSHELL_COMMON.indexOf('function Protect-SetupFile'); + const helperEnd = SETUP_POWERSHELL_COMMON.indexOf('\nfunction ', helperStart); + t.ok(helperStart >= 0, 'Protect-SetupFile function marker exists'); + t.ok(helperEnd >= 0, 'the next function marker exists after Protect-SetupFile'); + const helper = SETUP_POWERSHELL_COMMON.slice(helperStart, helperEnd); t.includes(helper, 'New-Object System.Security.AccessControl.FileSecurity', 'a fresh descriptor carries no prior access rules'); t.includes(helper, "FileSystemAccessRule($identity, 'FullControl', 'Allow')", 'the current user receives the sole allow rule'); t.includes(helper, '[System.IO.File]::SetAccessControl($Path, $acl)', 'Windows PowerShell 5.1 writes the descriptor directly'); @@ -1429,7 +1426,6 @@ test('claude', 'PowerShell claude --version is bounded', async t => { t.ok(!existsSync(settingsPathFor(ws)), 'configuration does not begin after a version timeout'); }); - test('claude', 'PowerShell removes an ambient exported API key before installer and CLI subprocesses', async t => { if (!hostPwsh) skip('no PowerShell interpreter on this host'); const ws = makeWorkspace(); @@ -1457,23 +1453,23 @@ test('claude', 'PowerShell keeps the API key out of output and performs no gatew test('claude', 'platform installers prefer Homebrew then npm on macOS and npm then direct scripts elsewhere', t => { t.includes(ALL_BASH_FRAGMENTS, 'brew install --cask', 'the Bash installer uses Homebrew on macOS'); - t.includes(ALL_BASH_FRAGMENTS, "npm install --global \"$_inp_package\"", 'the Bash installer can install global npm packages'); - t.includes(BASH_CLAUDE, "'@anthropic-ai/claude-code'", 'the Claude fragment names its official npm package'); - t.excludes(BASH_CLAUDE, '@openai/codex', 'the Claude fragment excludes Codex'); - t.includes(BASH_CODEX, "'@openai/codex'", 'the Codex fragment names its official npm package'); - t.excludes(BASH_CODEX, '@anthropic-ai/claude-code', 'the Codex fragment excludes Claude Code'); - t.includes(BASH_CLAUDE, 'https://downloads.claude.ai/claude-code-releases/bootstrap.sh', 'Claude Linux uses the direct release bootstrap'); - t.includes(BASH_CODEX, 'https://raw.githubusercontent.com/openai/codex/refs/heads/main/scripts/install/install.sh', 'Codex Linux uses the GitHub source installer'); - const shClaude = BASH_CLAUDE.slice(BASH_CLAUDE.indexOf('claude_ensure_installed()'), BASH_CLAUDE.indexOf('claude_write_settings()')); + t.includes(ALL_BASH_FRAGMENTS, 'npm install --global "$_inp_package"', 'the Bash installer can install global npm packages'); + t.includes(SETUP_BASH_CLAUDE, "'@anthropic-ai/claude-code'", 'the Claude fragment names its official npm package'); + t.excludes(SETUP_BASH_CLAUDE, '@openai/codex', 'the Claude fragment excludes Codex'); + t.includes(SETUP_BASH_CODEX, "'@openai/codex'", 'the Codex fragment names its official npm package'); + t.excludes(SETUP_BASH_CODEX, '@anthropic-ai/claude-code', 'the Codex fragment excludes Claude Code'); + t.includes(SETUP_BASH_CLAUDE, 'https://downloads.claude.ai/claude-code-releases/bootstrap.sh', 'Claude Linux uses the direct release bootstrap'); + t.includes(SETUP_BASH_CODEX, 'https://raw.githubusercontent.com/openai/codex/refs/heads/main/scripts/install/install.sh', 'Codex Linux uses the GitHub source installer'); + const shClaude = SETUP_BASH_CLAUDE.slice(SETUP_BASH_CLAUDE.indexOf('claude_ensure_installed()'), SETUP_BASH_CLAUDE.indexOf('claude_write_settings()')); t.ok(shClaude.indexOf('command -v brew') < shClaude.indexOf('command -v npm'), 'Claude on macOS checks Homebrew before npm'); t.ok(shClaude.indexOf('command -v npm') < shClaude.indexOf('bootstrap.sh'), 'Claude checks npm before the direct script'); - const shCodex = BASH_CODEX.slice(BASH_CODEX.indexOf('codex_ensure_installed()'), BASH_CODEX.indexOf('codex_backup_files()')); + const shCodex = SETUP_BASH_CODEX.slice(SETUP_BASH_CODEX.indexOf('codex_ensure_installed()'), SETUP_BASH_CODEX.indexOf('codex_backup_files()')); t.ok(shCodex.indexOf('command -v brew') < shCodex.indexOf('command -v npm'), 'Codex on macOS checks Homebrew before npm'); t.ok(shCodex.indexOf('command -v npm') < shCodex.indexOf('install.sh'), 'Codex checks npm before the direct script'); - t.includes(POWERSHELL_CLAUDE, "Install-SetupNpmPackage -Package '@anthropic-ai/claude-code'", 'PowerShell can install Claude Code with npm'); - t.includes(POWERSHELL_CODEX, "Install-SetupNpmPackage -Package '@openai/codex'", 'PowerShell can install Codex with npm'); - t.includes(POWERSHELL_CLAUDE, 'https://downloads.claude.ai/claude-code-releases/bootstrap.ps1', 'Claude Windows uses the direct release bootstrap'); - t.includes(POWERSHELL_CODEX, 'https://raw.githubusercontent.com/openai/codex/refs/heads/main/scripts/install/install.ps1', 'Codex Windows uses the GitHub source installer'); + t.includes(SETUP_POWERSHELL_CLAUDE, "Install-SetupNpmPackage -Package '@anthropic-ai/claude-code'", 'PowerShell can install Claude Code with npm'); + t.includes(SETUP_POWERSHELL_CODEX, "Install-SetupNpmPackage -Package '@openai/codex'", 'PowerShell can install Codex with npm'); + t.includes(SETUP_POWERSHELL_CLAUDE, 'https://downloads.claude.ai/claude-code-releases/bootstrap.ps1', 'Claude Windows uses the direct release bootstrap'); + t.includes(SETUP_POWERSHELL_CODEX, 'https://raw.githubusercontent.com/openai/codex/refs/heads/main/scripts/install/install.ps1', 'Codex Windows uses the GitHub source installer'); t.includes(ALL_POWERSHELL_FRAGMENTS, 'Get-Command pwsh', 'downloaded PowerShell scripts prefer pwsh when it is installed'); }); @@ -1514,7 +1510,7 @@ test('claude', 'a download that ends before the final main call performs no setu // `export SETUP_ENDPOINT` / `$SetupEndpoint` injection and the `| bash` / `| iex` // pipeline scoping are verified end to end rather than assumed. const runCommandLine = (exe: string, args: string[], command: string): Promise => - new Promise((resolve) => { + new Promise(resolve => { const child = spawn(exe, [...args, command], { env: { PATH: `${SHIM_BIN}:${process.env.PATH ?? ''}` } }); let stdout = ''; let stderr = ''; @@ -2100,11 +2096,10 @@ test('codex', 'PowerShell: okOverridden counts as success and reports non-secret t.excludes(run.combined, 'shadow-model', 'the overridden effective value is not echoed'); }); - test('codex', 'PowerShell: Windows provider-token replacement and rollback preserve owner-only ACL ordering', async t => { - const tokenFnStart = POWERSHELL_CODEX.indexOf('function Write-SetupCodexToken'); - const tokenFnEnd = POWERSHELL_CODEX.indexOf('function Write-SetupCodexVersion', tokenFnStart); - const tokenBody = POWERSHELL_CODEX.slice(tokenFnStart, tokenFnEnd); + const tokenFnStart = SETUP_POWERSHELL_CODEX.indexOf('function Write-SetupCodexToken'); + const tokenFnEnd = SETUP_POWERSHELL_CODEX.indexOf('function Write-SetupCodexVersion', tokenFnStart); + const tokenBody = SETUP_POWERSHELL_CODEX.slice(tokenFnStart, tokenFnEnd); const createStage = tokenBody.indexOf('[System.IO.File]::Create($stage).Dispose()'); const protectStage = tokenBody.indexOf('Protect-SetupFile $stage', createStage); const writeSecret = tokenBody.indexOf('[System.IO.File]::WriteAllText($stage, $SetupApiKey', protectStage); @@ -2121,17 +2116,17 @@ test('codex', 'PowerShell: Windows provider-token replacement and rollback prese t.ok(protectStage < writeSecret, 'Codex provider-token stage is protected before the secret is written'); t.ok(protectTarget < replaceTarget, 'existing Windows provider-token target is hardened before File.Replace'); - const restoreHelperStart = POWERSHELL_COMMON.indexOf('function Restore-SetupManagedFile'); - const restoreHelperEnd = POWERSHELL_COMMON.indexOf('# --- run', restoreHelperStart); - const restoreHelperBody = POWERSHELL_COMMON.slice(restoreHelperStart, restoreHelperEnd); + const restoreHelperStart = SETUP_POWERSHELL_COMMON.indexOf('function Restore-SetupManagedFile'); + const restoreHelperEnd = SETUP_POWERSHELL_COMMON.indexOf('# --- run', restoreHelperStart); + const restoreHelperBody = SETUP_POWERSHELL_COMMON.slice(restoreHelperStart, restoreHelperEnd); const restoreMove = restoreHelperBody.indexOf('Move-Item -LiteralPath $Backup -Destination $Path -Force'); t.ok(restoreHelperStart >= 0, 'Restore-SetupManagedFile marker exists'); t.ok(restoreHelperEnd >= 0, 'common run marker exists after restore helper'); t.ok(restoreMove >= 0, 'managed rollback move marker exists'); t.excludes(restoreHelperBody, 'Protect-SetupFile $Path', 'rollback keeps the already-protected backup inode instead of adding a fallible post-move step'); - const restoreStart = POWERSHELL_CODEX.indexOf('function Restore-SetupCodexFiles'); - const restoreEnd = POWERSHELL_CODEX.indexOf('function Invoke-SetupCodexAppServerBatchWrite', restoreStart); + const restoreStart = SETUP_POWERSHELL_CODEX.indexOf('function Restore-SetupCodexFiles'); + const restoreEnd = SETUP_POWERSHELL_CODEX.indexOf('function Invoke-SetupCodexAppServerBatchWrite', restoreStart); t.ok(restoreStart >= 0, 'Restore-SetupCodexFiles marker exists'); t.ok(restoreEnd >= 0, 'app-server function marker exists after restore function'); }); @@ -2494,7 +2489,6 @@ test('claude', 'PowerShell surfaces one primary error without a double wrapper', t.excludes(run.stdout, 'is not valid JSON', 'the error stays off stdout'); }); - test('codex', 'PowerShell rollback restore failure preserves the Codex provider-token backup', async t => { if (!hostPwsh) skip('no PowerShell interpreter on this host'); const ws = makeWorkspace(); diff --git a/packages/agent-setup/src/configuration_test.ts b/packages/agent-setup/src/configuration_test.ts new file mode 100644 index 0000000000..57ac629e69 --- /dev/null +++ b/packages/agent-setup/src/configuration_test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from 'vitest'; + +import { + agentSetupConfigurationSchema, + defaultAgentSetupConfiguration, + type AgentSetupConfiguration, +} from './configuration.ts'; + +const fullConfiguration: AgentSetupConfiguration = { + apiKeyId: 'key-a', + claudeCode: { + model: 'claude-opus-4-6[1m]', + defaultOpusModel: 'claude-opus-4-5', + defaultSonnetModel: 'claude-sonnet-4-5', + defaultHaikuModel: null, + effortLevel: 'high', + cleanupPeriodDays: 365, + optOutAiAttribution: true, + modelDiscovery: true, + }, + codex: { + model: 'gpt-5.6-terra', + reasoningEffort: 'xhigh', + }, +}; + +describe('agentSetupConfigurationSchema', () => { + test('accepts a fully-specified configuration', () => { + expect(agentSetupConfigurationSchema.safeParse(fullConfiguration).success).toBe(true); + }); + + test('accepts nulls for every optional Claude field and an open Codex effort', () => { + expect(agentSetupConfigurationSchema.safeParse({ + apiKeyId: 'key-a', + claudeCode: { + model: null, defaultOpusModel: null, defaultSonnetModel: null, + defaultHaikuModel: null, effortLevel: null, cleanupPeriodDays: null, optOutAiAttribution: false, modelDiscovery: false, + }, + codex: { model: null, reasoningEffort: 'vendor-tier' }, + }).success).toBe(true); + }); + + test('accepts every Claude effort enum value', () => { + for (const effortLevel of ['low', 'medium', 'high', 'xhigh'] as const) { + expect(agentSetupConfigurationSchema.safeParse({ + ...fullConfiguration, + claudeCode: { ...fullConfiguration.claudeCode, effortLevel }, + }).success).toBe(true); + } + }); + + test('rejects an effort value outside the Claude enum', () => { + expect(agentSetupConfigurationSchema.safeParse({ + ...fullConfiguration, + claudeCode: { ...fullConfiguration.claudeCode, effortLevel: 'minimal' }, + }).success).toBe(false); + }); + + test('accepts only the offered Claude cleanup periods or null', () => { + for (const cleanupPeriodDays of [180, 365, 99999, null] as const) { + expect(agentSetupConfigurationSchema.safeParse({ + ...fullConfiguration, + claudeCode: { ...fullConfiguration.claudeCode, cleanupPeriodDays }, + }).success).toBe(true); + } + expect(agentSetupConfigurationSchema.safeParse({ + ...fullConfiguration, + claudeCode: { ...fullConfiguration.claudeCode, cleanupPeriodDays: 30 }, + }).success).toBe(false); + }); + + test('requires the Claude attribution opt-out flag to be boolean', () => { + expect(agentSetupConfigurationSchema.safeParse({ + ...fullConfiguration, + claudeCode: { ...fullConfiguration.claudeCode, optOutAiAttribution: false }, + }).success).toBe(true); + expect(agentSetupConfigurationSchema.safeParse({ + ...fullConfiguration, + claudeCode: { ...fullConfiguration.claudeCode, optOutAiAttribution: 'yes' }, + }).success).toBe(false); + }); + + test('rejects an empty-string optional model (absence is null, not "")', () => { + expect(agentSetupConfigurationSchema.safeParse({ + ...fullConfiguration, + claudeCode: { ...fullConfiguration.claudeCode, model: '' }, + }).success).toBe(false); + }); + + test('rejects a NUL character in an opaque optional string', () => { + expect(agentSetupConfigurationSchema.safeParse({ + ...fullConfiguration, + codex: { ...fullConfiguration.codex, reasoningEffort: 'bad\0value' }, + }).success).toBe(false); + }); + + test('rejects unknown keys in nested objects', () => { + expect(agentSetupConfigurationSchema.safeParse({ + ...fullConfiguration, + codex: { ...fullConfiguration.codex, unexpected: true }, + }).success).toBe(false); + }); +}); + +describe('defaultAgentSetupConfiguration', () => { + test('sets the given key, enables both agents, nulls overrides, enables discovery', () => { + expect(defaultAgentSetupConfiguration('key-a')).toEqual({ + apiKeyId: 'key-a', + claudeCode: { + model: null, defaultOpusModel: null, defaultSonnetModel: null, + defaultHaikuModel: null, effortLevel: null, cleanupPeriodDays: null, optOutAiAttribution: false, modelDiscovery: true, + }, + codex: { model: null, reasoningEffort: null }, + }); + }); + + test('produces a value the schema accepts', () => { + const config = defaultAgentSetupConfiguration('key-a'); + expect(agentSetupConfigurationSchema.safeParse(config).success).toBe(true); + }); +}); diff --git a/packages/agent-setup/src/render.ts b/packages/agent-setup/src/render.ts index 2adf83c029..f313ce25f8 100644 --- a/packages/agent-setup/src/render.ts +++ b/packages/agent-setup/src/render.ts @@ -7,9 +7,10 @@ // the executing shell, and the fixed installer body reads it from there. import type { AgentSetupConfiguration } from './configuration.ts'; +import type { ScriptAgent } from './script-assets.ts'; export interface RenderPrefixInput { - agent: 'claude' | 'codex'; + agent: ScriptAgent; apiKey: string; apiKeyName: string; configuration: AgentSetupConfiguration; diff --git a/packages/agent-setup/src/render_test.ts b/packages/agent-setup/src/render_test.ts index 5932086409..6ddfa3fdfb 100644 --- a/packages/agent-setup/src/render_test.ts +++ b/packages/agent-setup/src/render_test.ts @@ -1,12 +1,7 @@ import { describe, expect, test } from 'vitest'; -import { - agentSetupConfigurationSchema, - defaultAgentSetupConfiguration, - type AgentSetupConfiguration, -} from './configuration.ts'; +import type { AgentSetupConfiguration } from './configuration.ts'; import { renderPowerShellPrefix, renderShellPrefix } from './render.ts'; -import { agentSetupHeartbeatBody, agentSetupUpdateBody } from './wire.ts'; const fullConfiguration: AgentSetupConfiguration = { apiKeyId: 'key-a', @@ -26,102 +21,6 @@ const fullConfiguration: AgentSetupConfiguration = { }, }; -describe('agentSetupConfigurationSchema', () => { - test('accepts a fully-specified configuration', () => { - expect(agentSetupConfigurationSchema.safeParse(fullConfiguration).success).toBe(true); - }); - - test('accepts nulls for every optional Claude field and an open Codex effort', () => { - expect(agentSetupConfigurationSchema.safeParse({ - apiKeyId: 'key-a', - claudeCode: { - model: null, defaultOpusModel: null, defaultSonnetModel: null, - defaultHaikuModel: null, effortLevel: null, cleanupPeriodDays: null, optOutAiAttribution: false, modelDiscovery: false, - }, - codex: { model: null, reasoningEffort: 'vendor-tier' }, - }).success).toBe(true); - }); - - test('accepts every Claude effort enum value', () => { - for (const effortLevel of ['low', 'medium', 'high', 'xhigh'] as const) { - expect(agentSetupConfigurationSchema.safeParse({ - ...fullConfiguration, - claudeCode: { ...fullConfiguration.claudeCode, effortLevel }, - }).success).toBe(true); - } - }); - - test('rejects an effort value outside the Claude enum', () => { - expect(agentSetupConfigurationSchema.safeParse({ - ...fullConfiguration, - claudeCode: { ...fullConfiguration.claudeCode, effortLevel: 'minimal' }, - }).success).toBe(false); - }); - - test('accepts only the offered Claude cleanup periods or null', () => { - for (const cleanupPeriodDays of [180, 365, 99999, null] as const) { - expect(agentSetupConfigurationSchema.safeParse({ - ...fullConfiguration, - claudeCode: { ...fullConfiguration.claudeCode, cleanupPeriodDays }, - }).success).toBe(true); - } - expect(agentSetupConfigurationSchema.safeParse({ - ...fullConfiguration, - claudeCode: { ...fullConfiguration.claudeCode, cleanupPeriodDays: 30 }, - }).success).toBe(false); - }); - - test('requires the Claude attribution opt-out flag to be boolean', () => { - expect(agentSetupConfigurationSchema.safeParse({ - ...fullConfiguration, - claudeCode: { ...fullConfiguration.claudeCode, optOutAiAttribution: false }, - }).success).toBe(true); - expect(agentSetupConfigurationSchema.safeParse({ - ...fullConfiguration, - claudeCode: { ...fullConfiguration.claudeCode, optOutAiAttribution: 'yes' }, - }).success).toBe(false); - }); - - test('rejects an empty-string optional model (absence is null, not "")', () => { - expect(agentSetupConfigurationSchema.safeParse({ - ...fullConfiguration, - claudeCode: { ...fullConfiguration.claudeCode, model: '' }, - }).success).toBe(false); - }); - - test('rejects a NUL character in an opaque optional string', () => { - expect(agentSetupConfigurationSchema.safeParse({ - ...fullConfiguration, - codex: { ...fullConfiguration.codex, reasoningEffort: 'bad\0value' }, - }).success).toBe(false); - }); - - test('rejects unknown keys in nested objects', () => { - expect(agentSetupConfigurationSchema.safeParse({ - ...fullConfiguration, - codex: { ...fullConfiguration.codex, unexpected: true }, - }).success).toBe(false); - }); -}); - -describe('defaultAgentSetupConfiguration', () => { - test('sets the given key, enables both agents, nulls overrides, enables discovery', () => { - expect(defaultAgentSetupConfiguration('key-a')).toEqual({ - apiKeyId: 'key-a', - claudeCode: { - model: null, defaultOpusModel: null, defaultSonnetModel: null, - defaultHaikuModel: null, effortLevel: null, cleanupPeriodDays: null, optOutAiAttribution: false, modelDiscovery: true, - }, - codex: { model: null, reasoningEffort: null }, - }); - }); - - test('produces a value the schema accepts', () => { - const config = defaultAgentSetupConfiguration('key-a'); - expect(agentSetupConfigurationSchema.safeParse(config).success).toBe(true); - }); -}); - describe('renderShellPrefix', () => { test('renders every assignment through the encoder and ends with a newline', () => { const prefix = renderShellPrefix({ @@ -163,7 +62,7 @@ describe('renderShellPrefix', () => { }); test('flattens control characters in the API key label before it reaches terminal metadata', () => { - const prefix = renderShellPrefix({ agent: 'claude', apiKey: 'key', apiKeyName: 'CI\n\u001b[2J', configuration: fullConfiguration }); + const prefix = renderShellPrefix({ agent: 'claude', apiKey: 'key', apiKeyName: 'CI\n', configuration: fullConfiguration }); expect(prefix).toContain("SETUP_API_KEY_NAME='CI [2J'"); }); @@ -268,25 +167,3 @@ describe('renderPowerShellPrefix', () => { expect(prefix).toContain('$SetupClaudeOptOutAiAttribution = $true'); }); }); - -describe('agent setup request bodies', () => { - test('agentSetupUpdateBody accepts a token, configuration, and expected revision', () => { - expect(agentSetupUpdateBody.safeParse({ - token: 'token-a', - configuration: fullConfiguration, - expectedRevision: 3, - }).success).toBe(true); - }); - - test('agentSetupUpdateBody rejects an invalid inner configuration', () => { - expect(agentSetupUpdateBody.safeParse({ - token: 'token-a', - configuration: { ...fullConfiguration, claudeCode: { ...fullConfiguration.claudeCode, model: '' } }, - expectedRevision: 3, - }).success).toBe(false); - }); - - test('agentSetupHeartbeatBody accepts a bare token', () => { - expect(agentSetupHeartbeatBody.safeParse({ token: 'token-a' }).success).toBe(true); - }); -}); diff --git a/packages/agent-setup/src/routes_test.ts b/packages/agent-setup/src/routes_test.ts index fd53ee9ff0..51b0044b1d 100644 --- a/packages/agent-setup/src/routes_test.ts +++ b/packages/agent-setup/src/routes_test.ts @@ -17,22 +17,16 @@ import { import { SETUP_BASH_CLAUDE, SETUP_BASH_CODEX, - SETUP_BASH_COMMON_HELPERS, - SETUP_BASH_COMMON_MAIN, - SETUP_BASH_COMMON_OUTPUT, + SETUP_BASH_COMMON, SETUP_POWERSHELL_CLAUDE, SETUP_POWERSHELL_CODEX, - SETUP_POWERSHELL_COMMON_HELPERS, - SETUP_POWERSHELL_COMMON_MAIN, - SETUP_POWERSHELL_COMMON_OUTPUT, + SETUP_POWERSHELL_COMMON, } from './script-assets.generated.ts'; import { SETUP_SCRIPT_BODIES } from './script-assets.ts'; import { assertEquals } from '@floway-dev/test-utils'; const RAW_KEY = 'raw-key'; const USER_ID = 2; -const BASH_COMMON = SETUP_BASH_COMMON_OUTPUT + SETUP_BASH_COMMON_HELPERS + SETUP_BASH_COMMON_MAIN; -const POWERSHELL_COMMON = SETUP_POWERSHELL_COMMON_OUTPUT + SETUP_POWERSHELL_COMMON_HELPERS + SETUP_POWERSHELL_COMMON_MAIN; // A faithful multi-row fake: token is the key, rows accrete, latest-by-user is // deterministic, and insert sweeps only the same user's already-expired rows. @@ -415,7 +409,7 @@ test('GET serves the shell prefix + common and target-agent fragments with harde expect(prefix).not.toContain('SETUP_CODEX_'); expect(prefix).not.toContain('SETUP_ENDPOINT'); expect(text).toContain(body); - expect(body).toContain(BASH_COMMON); + expect(body).toContain(SETUP_BASH_COMMON); expect(body).toContain(SETUP_BASH_CLAUDE); expect(body).not.toContain(SETUP_BASH_CODEX); }); @@ -430,7 +424,7 @@ test('GET serves the PowerShell prefix + common and target-agent fragments', asy expect(prefix).toContain('$SetupCodex'); expect(prefix).not.toContain('$SetupClaude'); expect(text).toContain(body); - expect(body).toContain(POWERSHELL_COMMON); + expect(body).toContain(SETUP_POWERSHELL_COMMON); expect(body).toContain(SETUP_POWERSHELL_CODEX); expect(body).not.toContain(SETUP_POWERSHELL_CLAUDE); }); @@ -532,22 +526,3 @@ test('a public serve failure is sealed to an opaque 500 that leaks neither token expect(joined).not.toContain(lease.token); expect(joined).not.toContain('forced failure'); }); - -test('generated fragments match the checked-in canonical installers byte for byte', async () => { - const { readFile } = await import('node:fs/promises'); - const fixtures = [ - [SETUP_BASH_COMMON_OUTPUT, '../installers/bash/common/output.sh'], - [SETUP_BASH_COMMON_HELPERS, '../installers/bash/common/helpers.sh'], - [SETUP_BASH_COMMON_MAIN, '../installers/bash/common/main.sh'], - [SETUP_BASH_CLAUDE, '../installers/bash/claude.sh'], - [SETUP_BASH_CODEX, '../installers/bash/codex.sh'], - [SETUP_POWERSHELL_COMMON_OUTPUT, '../installers/powershell/common/output.ps1'], - [SETUP_POWERSHELL_COMMON_HELPERS, '../installers/powershell/common/helpers.ps1'], - [SETUP_POWERSHELL_COMMON_MAIN, '../installers/powershell/common/main.ps1'], - [SETUP_POWERSHELL_CLAUDE, '../installers/powershell/claude.ps1'], - [SETUP_POWERSHELL_CODEX, '../installers/powershell/codex.ps1'], - ] as const; - for (const [generated, file] of fixtures) { - assertEquals(generated, await readFile(new URL(file, import.meta.url), 'utf8')); - } -}); diff --git a/packages/agent-setup/src/script-assets.generated.ts b/packages/agent-setup/src/script-assets.generated.ts index ad725c2048..36bdbb676c 100644 --- a/packages/agent-setup/src/script-assets.generated.ts +++ b/packages/agent-setup/src/script-assets.generated.ts @@ -1,35 +1,80 @@ // GENERATED by scripts/generate-assets.ts — do not edit by hand. // -// Canonical installer fragments embedded verbatim from: +// Canonical installer source files embedded verbatim from: // - installers/bash/common/output.sh -// - installers/bash/common/helpers.sh // - installers/bash/common/main.sh +// - installers/bash/common/process.sh +// - installers/bash/common/jq.sh +// - installers/bash/common/cli.sh +// - installers/bash/common/managed-file.sh // - installers/bash/claude.sh // - installers/bash/codex.sh // - installers/powershell/common/output.ps1 -// - installers/powershell/common/helpers.ps1 +// - installers/powershell/common/platform.ps1 +// - installers/powershell/common/json-document.ps1 // - installers/powershell/common/main.ps1 +// - installers/powershell/common/managed-file.ps1 +// - installers/powershell/common/process.ps1 +// - installers/powershell/common/cli.ps1 // - installers/powershell/claude.ps1 // - installers/powershell/codex.ps1 // Regenerate after editing a fragment: // `pnpm --filter @floway-dev/agent-setup run generate-assets`. -export const SETUP_BASH_COMMON_OUTPUT = "# Floway Agent Setup common installer fragment (Bash 3.2+). TypeScript prepends\n# the language-native assignment prefix and appends one agent fragment.\n#\n# Each served script targets exactly one agent. Errexit stays disabled because\n# Bash suppresses it inside guarded calls; failures are checked explicitly and\n# the selected agent's configuration is rolled back as one transaction.\n\n# --- output layer -----------------------------------------------------------\n#\n# Setup-owned output follows Homebrew's compact visual language: blue `==>`\n# notices introduce major phases, while warnings and errors color only their\n# labels. Phase details remain subordinate instead of competing for attention.\n# Native package managers inherit the terminal directly, so their ANSI colors,\n# carriage-return progress, buffering, and cursor behavior remain intact.\n#\n# Color is emitted only for an interactive terminal with NO_COLOR unset, probed\n# per stream so a redirected capture on either stdout or stderr stays free of\n# escape sequences. Agent notices and informational lines go to stdout;\n# warnings, errors, and rollback notices go to stderr.\n_stream_color() {\n [ -z \"${NO_COLOR:-}\" ] || return 1\n [ -n \"${AGENT_SETUP_TEST_FORCE_COLOR:-}\" ] && return 0\n [ -t \"$1\" ]\n}\n_init_output() {\n if _stream_color 1; then _OUT_COLOR=1; else _OUT_COLOR=0; fi\n if _stream_color 2; then _ERR_COLOR=1; else _ERR_COLOR=0; fi\n _C_BLUE=$'\\033[34m'\n _C_BOLD=$'\\033[1m'\n _C_YELLOW=$'\\033[93m'\n _C_RED=$'\\033[91m'\n _C_RESET=$'\\033[0m'\n}\n\n_emit_notice() {\n if [ \"$_OUT_COLOR\" -eq 1 ]; then\n printf '%s==>%s %s%s%s\\n' \"$_C_BLUE\" \"$_C_RESET\" \"$_C_BOLD\" \"$1\" \"$_C_RESET\"\n else\n printf '==> %s\\n' \"$1\"\n fi\n}\n\n# Homebrew colors the diagnostic label rather than the whole message, keeping\n# paths and remediation text readable in the terminal's native foreground.\n_emit_diagnostic() {\n if [ \"$_ERR_COLOR\" -eq 1 ]; then\n printf '%s%s:%s %s\\n' \"$1\" \"$2\" \"$_C_RESET\" \"$3\" >&2\n else\n printf '%s: %s\\n' \"$2\" \"$3\" >&2\n fi\n}\n\n# Default-color detail lines stay uncolored rather than carrying a bare reset.\n# $1 stream (1|2), $2 color, $3 text.\n_emit_line() {\n if [ \"$1\" -eq 1 ]; then\n if [ \"$_OUT_COLOR\" -eq 1 ] && [ -n \"$2\" ]; then printf '%s%s%s\\n' \"$2\" \"$3\" \"$_C_RESET\"; else printf '%s\\n' \"$3\"; fi\n else\n if [ \"$_ERR_COLOR\" -eq 1 ] && [ -n \"$2\" ]; then printf '%s%s%s\\n' \"$2\" \"$3\" \"$_C_RESET\" >&2; else printf '%s\\n' \"$3\" >&2; fi\n fi\n}\n\nout_agent_notice() { _emit_notice \"$1: $2\"; }\nout_metadata() { _emit_line 1 '' \"$1: $2\"; }\nout_info() { _emit_line 1 '' \"$1\"; }\nout_warn() { _emit_diagnostic \"$_C_YELLOW\" 'Warning' \"$1\"; }\nout_error() { _emit_diagnostic \"$_C_RED\" 'Error' \"$1\"; }\n\nSETUP_TMPDIR=\"\"\n_cleanup() {\n if [ -n \"$SETUP_TMPDIR\" ]; then\n rm -rf \"$SETUP_TMPDIR\" 2>/dev/null || true\n fi\n}\n# EXIT owns cleanup. INT/TERM only translate the signal into the conventional\n# exit status (130 = 128+SIGINT, 143 = 128+SIGTERM) and let that exit fire the\n# EXIT trap. Cleaning up directly inside the INT/TERM handlers would delete the\n# working directory and then let the interrupted script resume into the next\n# agent's configuration; exiting instead stops all further agent work.\n"; +export const SETUP_BASH_COMMON_OUTPUT = '# Floway Agent Setup common installer fragment (Bash 3.2+). TypeScript prepends\n# the language-native assignment prefix and appends one agent fragment.\n#\n# Each served script targets exactly one agent. Errexit stays disabled because\n# Bash suppresses it inside guarded calls; failures are checked explicitly and\n# the selected agent\'s configuration is rolled back as one transaction.\n\n# --- output layer -----------------------------------------------------------\n#\n# Setup-owned output follows Homebrew\'s compact visual language: blue `==>`\n# notices introduce major phases, while warnings and errors color only their\n# labels. Phase details remain subordinate instead of competing for attention.\n# Native package managers inherit the terminal directly, so their ANSI colors,\n# carriage-return progress, buffering, and cursor behavior remain intact.\n#\n# Color is emitted only for an interactive terminal with NO_COLOR unset, probed\n# per stream so a redirected capture on either stdout or stderr stays free of\n# escape sequences. Agent notices and informational lines go to stdout;\n# warnings, errors, and rollback notices go to stderr.\n_stream_color() {\n [ -z "${NO_COLOR:-}" ] || return 1\n [ -n "${AGENT_SETUP_TEST_FORCE_COLOR:-}" ] && return 0\n [ -t "$1" ]\n}\n_init_output() {\n if _stream_color 1; then _OUT_COLOR=1; else _OUT_COLOR=0; fi\n if _stream_color 2; then _ERR_COLOR=1; else _ERR_COLOR=0; fi\n _C_BLUE=$\'\\033[34m\'\n _C_BOLD=$\'\\033[1m\'\n _C_YELLOW=$\'\\033[93m\'\n _C_RED=$\'\\033[91m\'\n _C_RESET=$\'\\033[0m\'\n}\n\n_emit_notice() {\n if [ "$_OUT_COLOR" -eq 1 ]; then\n printf \'%s==>%s %s%s%s\\n\' "$_C_BLUE" "$_C_RESET" "$_C_BOLD" "$1" "$_C_RESET"\n else\n printf \'==> %s\\n\' "$1"\n fi\n}\n\n# Homebrew colors the diagnostic label rather than the whole message, keeping\n# paths and remediation text readable in the terminal\'s native foreground.\n_emit_diagnostic() {\n if [ "$_ERR_COLOR" -eq 1 ]; then\n printf \'%s%s:%s %s\\n\' "$1" "$2" "$_C_RESET" "$3" >&2\n else\n printf \'%s: %s\\n\' "$2" "$3" >&2\n fi\n}\n\n# Default-color detail lines stay uncolored rather than carrying a bare reset.\n# $1 stream (1|2), $2 color, $3 text.\n_emit_line() {\n if [ "$1" -eq 1 ]; then\n if [ "$_OUT_COLOR" -eq 1 ] && [ -n "$2" ]; then printf \'%s%s%s\\n\' "$2" "$3" "$_C_RESET"; else printf \'%s\\n\' "$3"; fi\n else\n if [ "$_ERR_COLOR" -eq 1 ] && [ -n "$2" ]; then printf \'%s%s%s\\n\' "$2" "$3" "$_C_RESET" >&2; else printf \'%s\\n\' "$3" >&2; fi\n fi\n}\n\nout_agent_notice() { _emit_notice "$1: $2"; }\nout_metadata() { _emit_line 1 \'\' "$1: $2"; }\nout_info() { _emit_line 1 \'\' "$1"; }\nout_warn() { _emit_diagnostic "$_C_YELLOW" \'Warning\' "$1"; }\nout_error() { _emit_diagnostic "$_C_RED" \'Error\' "$1"; }\n'; -export const SETUP_BASH_COMMON_HELPERS = "# Run a command under a wall-clock limit. macOS ships no `timeout`, so the\n# Bash-3.2 fallback enables job control for one launch, placing the command and\n# all ordinary descendants in a dedicated process group. The watchdog signals\n# that group with TERM then KILL, retains its process-group id across root exit,\n# and the parent waits for escalation to finish before returning 124.\n_run_with_timeout() {\n _rwt_secs=$1\n shift\n if command -v timeout >/dev/null 2>&1; then\n timeout \"$_rwt_secs\" \"$@\"\n return $?\n fi\n if command -v gtimeout >/dev/null 2>&1; then\n gtimeout \"$_rwt_secs\" \"$@\"\n return $?\n fi\n\n _rwt_marker=$(mktemp \"$SETUP_TMPDIR/timeout.XXXXXX\") || return 1\n rm -f \"$_rwt_marker\"\n if [ -n \"${AGENT_SETUP_TEST_TRACE_TIMEOUT:-}\" ]; then\n printf 'Agent Setup test: timeout fallback: process-tree\\n'\n fi\n set -m\n \"$@\" &\n _rwt_pid=$!\n set +m\n (\n # The watchdog must not retain the installer's stdout/stderr descriptors\n # after its parent shell is killed; otherwise a pipe consumer waits for the\n # orphaned sleep to exit before receiving EOF.\n exec /dev/null 2>&1\n sleep \"$_rwt_secs\"\n if kill -0 \"$_rwt_pid\" 2>/dev/null; then\n : > \"$_rwt_marker\"\n kill -TERM -- \"-$_rwt_pid\" 2>/dev/null || true\n sleep 1\n kill -KILL -- \"-$_rwt_pid\" 2>/dev/null || true\n fi\n ) &\n _rwt_watchdog=$!\n wait \"$_rwt_pid\"\n _rwt_status=$?\n if [ -e \"$_rwt_marker\" ]; then\n # Let TERM→KILL escalation finish before reporting the timeout.\n wait \"$_rwt_watchdog\" 2>/dev/null || true\n rm -f \"$_rwt_marker\"\n return 124\n fi\n kill \"$_rwt_watchdog\" 2>/dev/null || true\n wait \"$_rwt_watchdog\" 2>/dev/null || true\n rm -f \"$_rwt_marker\"\n return $_rwt_status\n}\n\n# jq handle, resolved by ensure_jq before any configuration file is touched.\nJQ=\"\"\n\n# Download the pinned official jq build for this platform into the private\n# working directory and verify its hard-coded SHA-256 before use. Fails on an\n# unsupported platform, a download error, a missing hashing tool, or a checksum\n# mismatch — always before any configuration file is touched.\n_bootstrap_jq() {\n _bj_os=$(uname -s)\n _bj_arch=$(uname -m)\n case \"$_bj_os\" in\n Darwin) _bj_os_part=macos ;;\n Linux) _bj_os_part=linux ;;\n *) out_error \"no pinned jq build for OS $_bj_os.\"; return 1 ;;\n esac\n case \"$_bj_arch\" in\n x86_64 | amd64) _bj_arch_part=amd64 ;;\n arm64 | aarch64) _bj_arch_part=arm64 ;;\n *) out_error \"no pinned jq build for architecture $_bj_arch.\"; return 1 ;;\n esac\n _bj_asset=\"jq-$_bj_os_part-$_bj_arch_part\"\n # Pinned to jqlang/jq release jq-1.8.2. Each digest was verified against the\n # release sha256sum.txt and the Sigstore build attestation\n # (signer: jqlang/jq .github/workflows/ci.yml@refs/tags/jq-1.8.2).\n # Ref: https://github.com/jqlang/jq/releases/tag/jq-1.8.2\n case \"$_bj_asset\" in\n jq-macos-amd64) _bj_sha=e94b266e3c26690550006abe63152b782280f4e14374accdf04cbde844f00bc0 ;;\n jq-macos-arm64) _bj_sha=2d75340ba57a4b4b4c8708a21c2dc8e958a48aaa8bba13b27f77f6e4c0eca07e ;;\n jq-linux-amd64) _bj_sha=b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f ;;\n jq-linux-arm64) _bj_sha=8b85c817833814ddca00a144c33705546355afccf0cf39b188f3cdb48b852309 ;;\n *) return 1 ;;\n esac\n _bj_url=\"https://github.com/jqlang/jq/releases/download/jq-1.8.2/$_bj_asset\"\n _bj_dest=\"$SETUP_TMPDIR/$_bj_asset\"\n out_warn 'jq not found on PATH; fetching the pinned jq-1.8.2 build'\n if ! curl -fsSL --connect-timeout 10 --max-time 120 -o \"$_bj_dest\" \"$_bj_url\"; then\n out_error \"failed to download jq from $_bj_url\"\n rm -f \"$_bj_dest\"\n return 1\n fi\n if command -v sha256sum >/dev/null 2>&1; then\n _bj_actual=$(sha256sum \"$_bj_dest\" | awk '{ print $1 }')\n elif command -v shasum >/dev/null 2>&1; then\n _bj_actual=$(shasum -a 256 \"$_bj_dest\" | awk '{ print $1 }')\n elif command -v openssl >/dev/null 2>&1; then\n _bj_actual=$(openssl dgst -sha256 \"$_bj_dest\" | awk '{ print $NF }')\n else\n _bj_actual=\"\"\n fi\n if [ -z \"$_bj_actual\" ]; then\n out_error 'no SHA-256 tool available to verify the jq download.'\n rm -f \"$_bj_dest\"\n return 1\n fi\n if [ \"$_bj_actual\" != \"$_bj_sha\" ]; then\n out_error 'jq checksum mismatch; refusing to use the download.'\n rm -f \"$_bj_dest\"\n return 1\n fi\n if ! chmod 700 \"$_bj_dest\"; then\n rm -f \"$_bj_dest\"\n return 1\n fi\n JQ=\"$_bj_dest\"\n}\n\n# Resolve a usable jq: prefer PATH, else provision the pinned build. The\n# AGENT_SETUP_TEST_NO_JQ_DOWNLOAD hook lets the test harness assert the\n# fail-before-mutation path without reaching the network.\nensure_jq() {\n if command -v jq >/dev/null 2>&1; then\n JQ=jq\n return 0\n fi\n if [ -n \"${AGENT_SETUP_TEST_NO_JQ_DOWNLOAD:-}\" ]; then\n return 1\n fi\n _bootstrap_jq\n}\n\n# Download an installer to the private working directory, refuse anything that\n# is not a shell script (region blocks and captive portals serve HTML in place\n# of the real installer), then execute it without sudo.\n_download_and_run_installer() {\n _dri_url=$1\n _dri_file=$(mktemp \"$SETUP_TMPDIR/install.XXXXXX\") || return 1\n if ! curl -fsSL --connect-timeout 10 --max-time 120 -o \"$_dri_file\" \"$_dri_url\"; then\n out_error \"could not download the installer from $_dri_url\"\n rm -f \"$_dri_file\"\n return 1\n fi\n # Reject common HTML responses while allowing official shell content with or\n # without a shebang (some installer CDNs prepend comments).\n if awk '\n NR <= 20 {\n line = tolower($0)\n if (line ~ /^[[:space:]]*(])|])|]))/) found = 1\n }\n END { exit found ? 0 : 1 }\n ' \"$_dri_file\"; then\n out_error 'the installer download was HTML, not an executable script (a login or region-block page?).'\n rm -f \"$_dri_file\"\n return 1\n fi\n if ! awk 'NF { found = 1 } END { exit found ? 0 : 1 }' \"$_dri_file\"; then\n out_error 'the installer download was empty.'\n rm -f \"$_dri_file\"\n return 1\n fi\n _dri_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-120}\n _run_with_timeout \"$_dri_timeout\" env -u SETUP_API_KEY bash \"$_dri_file\" /dev/null || true)\n if [ -n \"$DISCOVERED_BIN\" ]; then\n DISCOVERED_COUNT=1\n else\n DISCOVERED_COUNT=0\n fi\n for _dc_candidate in \"$@\"; do\n [ -x \"$_dc_candidate\" ] || continue\n [ \"$_dc_candidate\" = \"$DISCOVERED_BIN\" ] && continue\n DISCOVERED_COUNT=$((DISCOVERED_COUNT + 1))\n if [ -z \"$DISCOVERED_BIN\" ]; then\n DISCOVERED_BIN=$_dc_candidate\n fi\n done\n}\n\n# Rollback retains a backup when restoration fails so manual recovery remains\n# possible. Callers keep separate transaction boundaries and aggregate failures.\n_restore_managed_file() {\n _rmf_existed=$1\n _rmf_backup=$2\n _rmf_path=$3\n _rmf_original_label=$4\n _rmf_created_label=$5\n if [ \"$_rmf_existed\" -eq 1 ]; then\n if [ -n \"$_rmf_backup\" ] && [ -e \"$_rmf_backup\" ] && ! mv \"$_rmf_backup\" \"$_rmf_path\" 2>/dev/null; then\n out_warn \"could not restore $_rmf_path from its backup; your original $_rmf_original_label is preserved at $_rmf_backup — restore it by hand.\"\n return 1\n fi\n elif ! rm -f \"$_rmf_path\" 2>/dev/null; then\n out_warn \"could not remove the $_rmf_created_label this run created at $_rmf_path — remove it by hand.\"\n return 1\n fi\n return 0\n}\n\n_prune_managed_backups() {\n _pmb_path=$1\n _pmb_keep=$2\n for _pmb_backup in \"$_pmb_path\".floway-backup.*; do\n [ -e \"$_pmb_backup\" ] || continue\n [ \"$_pmb_backup\" = \"$_pmb_keep\" ] && continue\n if ! rm -f \"$_pmb_backup\"; then\n out_error \"could not remove obsolete backup $_pmb_backup\"\n return 1\n fi\n done\n}\n\n_install_brew_cask() {\n _ibc_cask=$1\n if ! command -v brew >/dev/null 2>&1; then\n out_error 'Homebrew is required to install agent CLIs on macOS.'\n return 1\n fi\n _ibc_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-600}\n _run_with_timeout \"$_ibc_timeout\" env -u SETUP_API_KEY brew install --cask \"$_ibc_cask\" /dev/null || true\n\n # Neutralize identically named exported variables inherited from the caller.\n # jq receives the API key only on the exact invocations that need it; package\n # managers and CLIs never inherit the credential.\n export -n SETUP_API_KEY SETUP_API_KEY_NAME 2>/dev/null || true\n\n _init_output\n out_agent_notice \'Agent Setup\' "$1"\n\n if [ -z "${SETUP_ENDPOINT:-}" ]; then\n out_error \'SETUP_ENDPOINT must be set to this gateway origin (e.g. https://gateway.example).\'\n return 1\n fi\n case "$SETUP_ENDPOINT" in\n http://?* | https://?*) ;;\n *) out_error "SETUP_ENDPOINT must be an http(s) origin, got $SETUP_ENDPOINT"; return 1 ;;\n esac\n out_metadata \'Endpoint\' "$SETUP_ENDPOINT"\n out_metadata \'API Key\' "$SETUP_API_KEY_NAME"\n export -n SETUP_ENDPOINT 2>/dev/null || true\n\n SETUP_TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/agent-setup.XXXXXX") || {\n out_error \'could not create a private working directory.\'\n return 1\n }\n chmod 700 "$SETUP_TMPDIR" 2>/dev/null || true\n trap _cleanup EXIT\n trap \'exit 130\' INT\n trap \'exit 143\' TERM\n\n configure_agent\n}\n'; -export const SETUP_BASH_COMMON_MAIN = "# --- run --------------------------------------------------------------------\n\nmain() {\n set -u\n umask 077\n set -o pipefail 2>/dev/null || true\n\n # Neutralize identically named exported variables inherited from the caller.\n # jq receives the API key only on the exact invocations that need it; package\n # managers and CLIs never inherit the credential.\n export -n SETUP_API_KEY SETUP_API_KEY_NAME 2>/dev/null || true\n\n _init_output\n out_agent_notice 'Agent Setup' \"$1\"\n\n if [ -z \"${SETUP_ENDPOINT:-}\" ]; then\n out_error 'SETUP_ENDPOINT must be set to this gateway origin (e.g. https://gateway.example).'\n return 1\n fi\n case \"$SETUP_ENDPOINT\" in\n http://?* | https://?*) ;;\n *) out_error \"SETUP_ENDPOINT must be an http(s) origin, got $SETUP_ENDPOINT\"; return 1 ;;\n esac\n out_metadata 'Endpoint' \"$SETUP_ENDPOINT\"\n out_metadata 'API Key' \"$SETUP_API_KEY_NAME\"\n export -n SETUP_ENDPOINT 2>/dev/null || true\n\n SETUP_TMPDIR=$(mktemp -d \"${TMPDIR:-/tmp}/agent-setup.XXXXXX\") || {\n out_error 'could not create a private working directory.'\n return 1\n }\n chmod 700 \"$SETUP_TMPDIR\" 2>/dev/null || true\n trap _cleanup EXIT\n trap 'exit 130' INT\n trap 'exit 143' TERM\n\n configure_agent\n}\n"; +export const SETUP_BASH_COMMON_PROCESS = '# Run a command under a wall-clock limit. macOS ships no `timeout`, so the\n# Bash-3.2 fallback enables job control for one launch, placing the command and\n# all ordinary descendants in a dedicated process group. The watchdog signals\n# that group with TERM then KILL, retains its process-group id across root exit,\n# and the parent waits for escalation to finish before returning 124.\n_run_with_timeout() {\n _rwt_secs=$1\n shift\n if command -v timeout >/dev/null 2>&1; then\n timeout "$_rwt_secs" "$@"\n return $?\n fi\n if command -v gtimeout >/dev/null 2>&1; then\n gtimeout "$_rwt_secs" "$@"\n return $?\n fi\n\n _rwt_marker=$(mktemp "$SETUP_TMPDIR/timeout.XXXXXX") || return 1\n rm -f "$_rwt_marker"\n if [ -n "${AGENT_SETUP_TEST_TRACE_TIMEOUT:-}" ]; then\n printf \'Agent Setup test: timeout fallback: process-tree\\n\'\n fi\n set -m\n "$@" &\n _rwt_pid=$!\n set +m\n (\n # The watchdog must not retain the installer\'s stdout/stderr descriptors\n # after its parent shell is killed; otherwise a pipe consumer waits for the\n # orphaned sleep to exit before receiving EOF.\n exec /dev/null 2>&1\n sleep "$_rwt_secs"\n if kill -0 "$_rwt_pid" 2>/dev/null; then\n : > "$_rwt_marker"\n kill -TERM -- "-$_rwt_pid" 2>/dev/null || true\n sleep 1\n kill -KILL -- "-$_rwt_pid" 2>/dev/null || true\n fi\n ) &\n _rwt_watchdog=$!\n wait "$_rwt_pid"\n _rwt_status=$?\n if [ -e "$_rwt_marker" ]; then\n # Let TERM→KILL escalation finish before reporting the timeout.\n wait "$_rwt_watchdog" 2>/dev/null || true\n rm -f "$_rwt_marker"\n return 124\n fi\n kill "$_rwt_watchdog" 2>/dev/null || true\n wait "$_rwt_watchdog" 2>/dev/null || true\n rm -f "$_rwt_marker"\n return $_rwt_status\n}\n'; -export const SETUP_BASH_CLAUDE = "# Claude Code Agent Setup fragment.\n\n# Managed-key merge applied to the existing Claude settings document. Only the\n# keys the setup owns are touched; every unrelated key and env var is preserved.\n# An empty optional value means \"remove that managed key\". The API key is read\n# from the environment (`env.SETUP_API_KEY`) so it stays out of argv.\n# Refs: https://docs.claude.com/en/docs/claude-code/env-vars\n# https://docs.claude.com/en/docs/claude-code/model-config#environment-variables\n# https://docs.claude.com/en/docs/claude-code/settings\n# https://code.claude.com/docs/en/settings#attribution-settings\nCLAUDE_MERGE_PROGRAM='\n if type != \"object\" then error(\"root is not a JSON object\")\n elif (has(\"env\") and ((.env | type) != \"object\")) then error(\"env is not a JSON object\")\n elif (has(\"attribution\") and ((.attribution | type) != \"object\")) then error(\"attribution is not a JSON object\")\n else . end\n | (if (has(\"env\") | not) then .env = {} else . end)\n | .env.ANTHROPIC_BASE_URL = $baseUrl\n | .env.ANTHROPIC_AUTH_TOKEN = env.SETUP_API_KEY\n | (if $model == \"\" then del(.env.ANTHROPIC_MODEL) else .env.ANTHROPIC_MODEL = $model end)\n | (if $opus == \"\" then del(.env.ANTHROPIC_DEFAULT_OPUS_MODEL) else .env.ANTHROPIC_DEFAULT_OPUS_MODEL = $opus end)\n | (if $sonnet == \"\" then del(.env.ANTHROPIC_DEFAULT_SONNET_MODEL) else .env.ANTHROPIC_DEFAULT_SONNET_MODEL = $sonnet end)\n | (if $haiku == \"\" then del(.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) else .env.ANTHROPIC_DEFAULT_HAIKU_MODEL = $haiku end)\n | (if $discovery == \"1\" then .env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = \"1\" else del(.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY) end)\n | (if $effort == \"\" then del(.effortLevel) else .effortLevel = $effort end)\n | (if $cleanup == \"\" then del(.cleanupPeriodDays) else .cleanupPeriodDays = ($cleanup | tonumber) end)\n | (if $optOutAttribution == \"1\" then\n .attribution = ((.attribution // {}) + { \"commit\": \"\", \"pr\": \"\", \"sessionUrl\": false })\n else\n del(.attribution.commit, .attribution.pr, .attribution.sessionUrl)\n | (if .attribution == {} then del(.attribution) else . end)\n end)\n'\n\n# Refs:\n# https://code.claude.com/docs/en/setup\n# https://github.com/anthropics/claude-code/blob/c39cb0f14bfe8bb519bae5bfc55add6867c5e2ab/README.md#L13-L44\nclaude_ensure_installed() {\n _discover_cli claude \\\n \"$HOME/.local/bin/claude\" \\\n \"$HOME/.claude/local/claude\" \\\n \"$HOME/.bun/bin/claude\" \\\n \"/opt/homebrew/bin/claude\" \\\n \"/usr/local/bin/claude\"\n CLAUDE_BIN=$DISCOVERED_BIN\n if [ \"$DISCOVERED_COUNT\" -gt 1 ]; then\n out_warn \"multiple Claude Code installations detected; using $CLAUDE_BIN\"\n fi\n if [ \"$DISCOVERED_COUNT\" -ge 1 ]; then\n out_info 'Claude Code is already installed.'\n return 0\n fi\n\n if [ -n \"${AGENT_SETUP_TEST_INSTALL_CLAUDE_SCRIPT:-}\" ]; then\n out_info 'Claude Code CLI not found; running the test installer'\n _ic_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-120}\n _run_with_timeout \"$_ic_timeout\" env -u SETUP_API_KEY bash \"$AGENT_SETUP_TEST_INSTALL_CLAUDE_SCRIPT\" /dev/null 2>&1; then\n out_info 'Claude Code CLI not found; installing with Homebrew'\n _install_brew_cask claude-code || return 1\n elif command -v npm >/dev/null 2>&1; then\n out_info 'Claude Code CLI not found; installing with npm'\n _install_npm_package '@anthropic-ai/claude-code' || return 1\n else\n out_info 'Claude Code CLI not found; installing from downloads.claude.ai'\n _download_and_run_installer 'https://downloads.claude.ai/claude-code-releases/bootstrap.sh' || return 1\n fi\n ;;\n Linux)\n if command -v npm >/dev/null 2>&1; then\n out_info 'Claude Code CLI not found; installing with npm'\n _install_npm_package '@anthropic-ai/claude-code' || return 1\n else\n out_info 'Claude Code CLI not found; installing from downloads.claude.ai'\n _download_and_run_installer 'https://downloads.claude.ai/claude-code-releases/bootstrap.sh' || return 1\n fi\n ;;\n *)\n out_error 'automatic Claude Code installation supports macOS and Linux only in the Bash installer.'\n return 1\n ;;\n esac\n fi\n hash -r 2>/dev/null || true\n _discover_cli claude \\\n \"$HOME/.local/bin/claude\" \\\n \"$HOME/.claude/local/claude\" \\\n \"$HOME/.bun/bin/claude\" \\\n \"/opt/homebrew/bin/claude\" \\\n \"/usr/local/bin/claude\"\n CLAUDE_BIN=$DISCOVERED_BIN\n [ \"$DISCOVERED_COUNT\" -ge 1 ]\n}\n\nclaude_rollback_settings() {\n _restore_managed_file \\\n \"${CLAUDE_SETTINGS_EXISTED:-0}\" \"${CLAUDE_SETTINGS_BACKUP:-}\" \"$CLAUDE_SETTINGS_PATH\" \\\n \"file\" \"Claude settings\"\n}\n\n# Same-directory staging keeps the mode-0600 replacement rename atomic.\nclaude_write_settings() {\n _cw_dir=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"\n CLAUDE_SETTINGS_PATH=\"$_cw_dir/settings.json\"\n CLAUDE_SETTINGS_BACKUP=\"\"\n CLAUDE_SETTINGS_EXISTED=0\n\n if ! mkdir -p \"$_cw_dir\"; then\n out_error \"could not create $_cw_dir\"\n return 1\n fi\n\n if [ -e \"$CLAUDE_SETTINGS_PATH\" ]; then\n CLAUDE_SETTINGS_EXISTED=1\n if ! \"$JQ\" '\n if type != \"object\" then error(\"root is not a JSON object\")\n elif (has(\"env\") and ((.env | type) != \"object\")) then error(\"env is not a JSON object\")\n elif (has(\"attribution\") and ((.attribution | type) != \"object\")) then error(\"attribution is not a JSON object\")\n else . end\n ' \"$CLAUDE_SETTINGS_PATH\" >/dev/null 2>&1; then\n out_error \"$CLAUDE_SETTINGS_PATH is not valid Claude settings; leaving it untouched.\"\n return 1\n fi\n _cw_base=$(cat \"$CLAUDE_SETTINGS_PATH\")\n CLAUDE_SETTINGS_BACKUP=\"$CLAUDE_SETTINGS_PATH.floway-backup.$(date +%Y%m%d%H%M%S).$$\"\n if ! cp \"$CLAUDE_SETTINGS_PATH\" \"$CLAUDE_SETTINGS_BACKUP\"; then\n out_error \"could not back up $CLAUDE_SETTINGS_PATH\"\n return 1\n fi\n else\n _cw_base='{}'\n fi\n\n _cw_stage=\"$CLAUDE_SETTINGS_PATH.floway-stage.$$\"\n if ! printf '%s' \"$_cw_base\" | SETUP_API_KEY=\"$SETUP_API_KEY\" \"$JQ\" \\\n --arg baseUrl \"$SETUP_ENDPOINT\" \\\n --arg model \"$SETUP_CLAUDE_MODEL\" \\\n --arg opus \"$SETUP_CLAUDE_DEFAULT_OPUS_MODEL\" \\\n --arg sonnet \"$SETUP_CLAUDE_DEFAULT_SONNET_MODEL\" \\\n --arg haiku \"$SETUP_CLAUDE_DEFAULT_HAIKU_MODEL\" \\\n --arg discovery \"$SETUP_CLAUDE_MODEL_DISCOVERY\" \\\n --arg effort \"$SETUP_CLAUDE_EFFORT_LEVEL\" \\\n --arg cleanup \"$SETUP_CLAUDE_CLEANUP_PERIOD_DAYS\" \\\n --arg optOutAttribution \"$SETUP_CLAUDE_OPT_OUT_AI_ATTRIBUTION\" \\\n \"$CLAUDE_MERGE_PROGRAM\" > \"$_cw_stage\"; then\n out_error 'failed to construct updated Claude settings.'\n rm -f \"$_cw_stage\"\n claude_rollback_settings\n return 1\n fi\n\n if ! SETUP_API_KEY=\"$SETUP_API_KEY\" \"$JQ\" -e --arg baseUrl \"$SETUP_ENDPOINT\" '\n (type == \"object\")\n and ((.env | type) == \"object\")\n and (.env.ANTHROPIC_BASE_URL == $baseUrl)\n and (.env.ANTHROPIC_AUTH_TOKEN == env.SETUP_API_KEY)\n ' \"$_cw_stage\" >/dev/null 2>&1; then\n out_error 'staged Claude settings failed validation.'\n rm -f \"$_cw_stage\"\n claude_rollback_settings\n return 1\n fi\n\n if ! chmod 600 \"$_cw_stage\"; then\n rm -f \"$_cw_stage\"\n claude_rollback_settings\n return 1\n fi\n\n if ! mv \"$_cw_stage\" \"$CLAUDE_SETTINGS_PATH\"; then\n out_error \"could not replace $CLAUDE_SETTINGS_PATH\"\n rm -f \"$_cw_stage\"\n claude_rollback_settings\n return 1\n fi\n if ! _prune_managed_backups \"$CLAUDE_SETTINGS_PATH\" \"$CLAUDE_SETTINGS_BACKUP\"; then\n claude_rollback_settings\n return 1\n fi\n}\n\nclaude_write_version() {\n _cv_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-30}\n _cv_version_file=\"$SETUP_TMPDIR/claude-version.out\"\n if _run_with_timeout \"$_cv_timeout\" \"$CLAUDE_BIN\" --version > \"$_cv_version_file\" 2>&1; then\n _cv_version=$(cat \"$_cv_version_file\")\n else\n _cv_version_status=$?\n if [ \"$_cv_version_status\" -eq 124 ]; then\n out_error '`claude --version` timed out.'\n else\n out_error '`claude --version` failed.'\n fi\n return 1\n fi\n out_info \"Claude Code version: $_cv_version\"\n}\n\n# Install, then configure Claude Code as one transactional settings write. A\n# freshly installed CLI is never uninstalled when configuration fails.\nconfigure_agent() {\n out_agent_notice 'Installing' 'Claude Code'\n if ! claude_ensure_installed; then\n out_error 'Claude Code CLI is unavailable and could not be installed.'\n return 1\n fi\n if ! claude_write_version; then\n return 1\n fi\n\n out_agent_notice 'Configuring' 'Claude Code'\n if ! ensure_jq; then\n out_error 'jq is required to configure Claude Code but is unavailable and could not be provisioned for this platform. Install jq and re-run.'\n return 1\n fi\n if ! claude_write_settings; then\n return 1\n fi\n out_info \"Written to \\`$CLAUDE_SETTINGS_PATH\\`.\"\n out_agent_notice 'Completed Agent Setup' 'Claude Code'\n}\n\n\nmain 'Claude Code' \"$@\"\n"; +export const SETUP_BASH_COMMON_JQ = '# jq handle, resolved by ensure_jq before any configuration file is touched.\nJQ=""\n\n# Download the pinned official jq build for this platform into the private\n# working directory and verify its hard-coded SHA-256 before use. Fails on an\n# unsupported platform, a download error, a missing hashing tool, or a checksum\n# mismatch — always before any configuration file is touched.\n_bootstrap_jq() {\n _bj_os=$(uname -s)\n _bj_arch=$(uname -m)\n case "$_bj_os" in\n Darwin) _bj_os_part=macos ;;\n Linux) _bj_os_part=linux ;;\n *) out_error "no pinned jq build for OS $_bj_os."; return 1 ;;\n esac\n case "$_bj_arch" in\n x86_64 | amd64) _bj_arch_part=amd64 ;;\n arm64 | aarch64) _bj_arch_part=arm64 ;;\n *) out_error "no pinned jq build for architecture $_bj_arch."; return 1 ;;\n esac\n _bj_asset="jq-$_bj_os_part-$_bj_arch_part"\n # Pinned to jqlang/jq release jq-1.8.2. Each digest was verified against the\n # release sha256sum.txt and the Sigstore build attestation\n # (signer: jqlang/jq .github/workflows/ci.yml@refs/tags/jq-1.8.2).\n # Ref: https://github.com/jqlang/jq/releases/tag/jq-1.8.2\n case "$_bj_asset" in\n jq-macos-amd64) _bj_sha=e94b266e3c26690550006abe63152b782280f4e14374accdf04cbde844f00bc0 ;;\n jq-macos-arm64) _bj_sha=2d75340ba57a4b4b4c8708a21c2dc8e958a48aaa8bba13b27f77f6e4c0eca07e ;;\n jq-linux-amd64) _bj_sha=b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f ;;\n jq-linux-arm64) _bj_sha=8b85c817833814ddca00a144c33705546355afccf0cf39b188f3cdb48b852309 ;;\n *) return 1 ;;\n esac\n _bj_url="https://github.com/jqlang/jq/releases/download/jq-1.8.2/$_bj_asset"\n _bj_dest="$SETUP_TMPDIR/$_bj_asset"\n out_warn \'jq not found on PATH; fetching the pinned jq-1.8.2 build\'\n if ! curl -fsSL --connect-timeout 10 --max-time 120 -o "$_bj_dest" "$_bj_url"; then\n out_error "failed to download jq from $_bj_url"\n rm -f "$_bj_dest"\n return 1\n fi\n if command -v sha256sum >/dev/null 2>&1; then\n _bj_actual=$(sha256sum "$_bj_dest" | awk \'{ print $1 }\')\n elif command -v shasum >/dev/null 2>&1; then\n _bj_actual=$(shasum -a 256 "$_bj_dest" | awk \'{ print $1 }\')\n elif command -v openssl >/dev/null 2>&1; then\n _bj_actual=$(openssl dgst -sha256 "$_bj_dest" | awk \'{ print $NF }\')\n else\n _bj_actual=""\n fi\n if [ -z "$_bj_actual" ]; then\n out_error \'no SHA-256 tool available to verify the jq download.\'\n rm -f "$_bj_dest"\n return 1\n fi\n if [ "$_bj_actual" != "$_bj_sha" ]; then\n out_error \'jq checksum mismatch; refusing to use the download.\'\n rm -f "$_bj_dest"\n return 1\n fi\n if ! chmod 700 "$_bj_dest"; then\n rm -f "$_bj_dest"\n return 1\n fi\n JQ="$_bj_dest"\n}\n\n# Resolve a usable jq: prefer PATH, else provision the pinned build. The\n# AGENT_SETUP_TEST_NO_JQ_DOWNLOAD hook lets the test harness assert the\n# fail-before-mutation path without reaching the network.\nensure_jq() {\n if command -v jq >/dev/null 2>&1; then\n JQ=jq\n return 0\n fi\n if [ -n "${AGENT_SETUP_TEST_NO_JQ_DOWNLOAD:-}" ]; then\n return 1\n fi\n _bootstrap_jq\n}\n'; -export const SETUP_BASH_CODEX = "# Codex Agent Setup fragment.\n\n# Track upstream's maintained installer so release-metadata fixes arrive without\n# waiting for a Floway update. Reviewed sources:\n# https://github.com/openai/codex/blob/d3fc1950a920f98e7fa9f11056667cdf911c38df/README.md#L18-L37\n# https://github.com/openai/codex/blob/d3fc1950a920f98e7fa9f11056667cdf911c38df/scripts/install/install.sh\ncodex_ensure_installed() {\n _discover_cli codex \\\n \"$HOME/.local/bin/codex\" \\\n \"/opt/homebrew/bin/codex\" \\\n \"/usr/local/bin/codex\"\n CODEX_BIN=$DISCOVERED_BIN\n if [ \"$DISCOVERED_COUNT\" -gt 1 ]; then\n out_warn \"multiple Codex installations detected; using $CODEX_BIN\"\n fi\n if [ \"$DISCOVERED_COUNT\" -ge 1 ]; then\n out_info 'Codex is already installed.'\n return 0\n fi\n\n if [ -n \"${AGENT_SETUP_TEST_INSTALL_CODEX_SCRIPT:-}\" ]; then\n out_info 'Codex CLI not found; running the test installer'\n _icx_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-120}\n _run_with_timeout \"$_icx_timeout\" env -u SETUP_API_KEY CODEX_NON_INTERACTIVE=true bash \"$AGENT_SETUP_TEST_INSTALL_CODEX_SCRIPT\" /dev/null 2>&1; then\n out_info 'Codex CLI not found; installing with Homebrew'\n _install_brew_cask codex || return 1\n elif command -v npm >/dev/null 2>&1; then\n out_info 'Codex CLI not found; installing with npm'\n _install_npm_package '@openai/codex' || return 1\n else\n out_info 'Codex CLI not found; installing from GitHub'\n CODEX_NON_INTERACTIVE=true _download_and_run_installer 'https://raw.githubusercontent.com/openai/codex/refs/heads/main/scripts/install/install.sh' || return 1\n fi\n fi\n hash -r 2>/dev/null || true\n _discover_cli codex \\\n \"$HOME/.local/bin/codex\" \\\n \"/opt/homebrew/bin/codex\" \\\n \"/usr/local/bin/codex\"\n CODEX_BIN=$DISCOVERED_BIN\n [ \"$DISCOVERED_COUNT\" -ge 1 ]\n}\n\n# Back up the config and provider token before any mutation, recording the\n# absence of each so rollback can distinguish \"restore\" from \"remove\". The\n# token backup must be owner-only before the transaction can continue.\ncodex_backup_files() {\n CODEX_CONFIG_EXISTED=0\n CODEX_TOKEN_EXISTED=0\n CODEX_CONFIG_BACKUP=\"\"\n CODEX_TOKEN_BACKUP=\"\"\n _cbf_stamp=$(date +%Y%m%d%H%M%S).$$\n if [ -e \"$CODEX_CONFIG_PATH\" ]; then\n CODEX_CONFIG_EXISTED=1\n CODEX_CONFIG_BACKUP=\"$CODEX_CONFIG_PATH.floway-backup.$_cbf_stamp\"\n if ! cp \"$CODEX_CONFIG_PATH\" \"$CODEX_CONFIG_BACKUP\"; then\n out_error \"could not back up $CODEX_CONFIG_PATH\"\n return 1\n fi\n fi\n if [ -e \"$CODEX_TOKEN_PATH\" ]; then\n CODEX_TOKEN_EXISTED=1\n CODEX_TOKEN_BACKUP=\"$CODEX_TOKEN_PATH.floway-backup.$_cbf_stamp\"\n if ! cp \"$CODEX_TOKEN_PATH\" \"$CODEX_TOKEN_BACKUP\"; then\n out_error \"could not back up $CODEX_TOKEN_PATH\"\n return 1\n fi\n if ! chmod 600 \"$CODEX_TOKEN_BACKUP\"; then\n rm -f \"$CODEX_TOKEN_BACKUP\"\n CODEX_TOKEN_BACKUP=\"\"\n out_error \"could not protect the backup of $CODEX_TOKEN_PATH\"\n return 1\n fi\n fi\n}\n\n# Both restores are attempted even when the first fails.\ncodex_rollback() {\n _cxr_rc=0\n _restore_managed_file \\\n \"${CODEX_CONFIG_EXISTED:-0}\" \"${CODEX_CONFIG_BACKUP:-}\" \"$CODEX_CONFIG_PATH\" \\\n \"file\" \"Codex config\" || _cxr_rc=1\n _restore_managed_file \\\n \"${CODEX_TOKEN_EXISTED:-0}\" \"${CODEX_TOKEN_BACKUP:-}\" \"$CODEX_TOKEN_PATH\" \\\n \"provider token\" \"Codex provider token\" || _cxr_rc=1\n return \"$_cxr_rc\"\n}\n\ncodex_commit_files() {\n _prune_managed_backups \"$CODEX_CONFIG_PATH\" \"$CODEX_CONFIG_BACKUP\" || return 1\n _prune_managed_backups \"$CODEX_TOKEN_PATH\" \"$CODEX_TOKEN_BACKUP\" || return 1\n if [ -n \"$CODEX_TOKEN_BACKUP\" ] && ! rm -f \"$CODEX_TOKEN_BACKUP\"; then\n out_error \"could not remove provider-token backup $CODEX_TOKEN_BACKUP\"\n return 1\n fi\n CODEX_TOKEN_BACKUP=\"\"\n}\n\n# Terminate the app-server process group, giving a child whose stdin was just\n# closed a brief moment to exit on its own before escalating TERM then KILL. The\n# child is launched under job control so the whole descendant tree shares one\n# group. The natural-exit grace uses sub-second polling so a clean handshake\n# adds negligible latency.\n_codex_kill_group() {\n _ckg_pid=$1\n _ckg_n=0\n while kill -0 \"$_ckg_pid\" 2>/dev/null && [ \"$_ckg_n\" -lt 5 ]; do\n sleep 0.2\n _ckg_n=$((_ckg_n + 1))\n done\n if kill -0 \"$_ckg_pid\" 2>/dev/null; then\n kill -TERM -- \"-$_ckg_pid\" 2>/dev/null || kill -TERM \"$_ckg_pid\" 2>/dev/null || true\n sleep 0.5\n kill -KILL -- \"-$_ckg_pid\" 2>/dev/null || kill -KILL \"$_ckg_pid\" 2>/dev/null || true\n fi\n wait \"$_ckg_pid\" 2>/dev/null || true\n}\n\n# Read newline-delimited JSON-RPC from fd 4 until a response whose id matches\n# $1 arrives, demultiplexing unrelated notifications. Bounded by the absolute\n# CODEX_APPSERVER_DEADLINE. Returns 0 with the line in CODEX_APPSERVER_RESPONSE,\n# 124 on deadline, 1 on a premature stream EOF, 2 on a malformed (unparseable)\n# line, and 3 on a matching JSON-RPC error response.\n_codex_read_response() {\n _crr_id=$1\n while :; do\n _crr_left=$(( CODEX_APPSERVER_DEADLINE - $(date +%s) ))\n if [ \"$_crr_left\" -le 0 ]; then\n return 124\n fi\n if IFS= read -r -t \"$_crr_left\" _crr_line <&4; then\n [ -n \"$_crr_line\" ] || continue\n _crr_kind=$(printf '%s\\n' \"$_crr_line\" | \"$JQ\" -r --argjson want \"$_crr_id\" '\n if (.id == $want) then (if has(\"error\") then \"error\" elif has(\"result\") then \"result\" else \"pending\" end) else \"skip\" end\n ' 2>/dev/null)\n if [ -z \"$_crr_kind\" ]; then\n return 2\n fi\n case \"$_crr_kind\" in\n result) CODEX_APPSERVER_RESPONSE=$_crr_line; return 0 ;;\n error) CODEX_APPSERVER_RESPONSE=$_crr_line; return 3 ;;\n *) continue ;;\n esac\n else\n _crr_rc=$?\n if [ \"$_crr_rc\" -gt 128 ]; then\n return 124\n fi\n return 1\n fi\n done\n}\n\n# Drive `codex app-server` over two private FIFOs: initialize -> initialized ->\n# config/batchWrite. stdin is kept open (fd 3) until the batch response arrives\n# on fd 4, so a server that answers after a delay still completes. The child\n# runs in its own process group for tree-wide termination; trap-invoked cleanup\n# removes the working directory. On success the raw batchWrite result JSON is the\n# only thing written to stdout (progress and errors go to stderr).\ncodex_app_server_batch_write() {\n _cas_edits=$1\n _cas_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-60}\n _cas_dir=$(mktemp -d \"$SETUP_TMPDIR/codex-appserver.XXXXXX\") || return 1\n _cas_req=\"$_cas_dir/req\"\n _cas_res=\"$_cas_dir/res\"\n if ! mkfifo \"$_cas_req\" \"$_cas_res\"; then\n rm -rf \"$_cas_dir\"\n return 1\n fi\n\n set -m\n \"$CODEX_BIN\" app-server --listen stdio:// <\"$_cas_req\" >\"$_cas_res\" 2>\"$_cas_dir/stderr\" &\n _cas_pid=$!\n set +m\n\n # Open the write end of req first (this unblocks the child's stdin open), then\n # the read end of res. This ordering is what keeps a FIFO pair from deadlocking.\n exec 3>\"$_cas_req\"\n exec 4<\"$_cas_res\"\n\n CODEX_APPSERVER_DEADLINE=$(( $(date +%s) + _cas_timeout ))\n CODEX_APPSERVER_RESPONSE=\"\"\n _cas_status=0\n\n _cas_init=$(\"$JQ\" -cn '{jsonrpc:\"2.0\",id:1,method:\"initialize\",params:{clientInfo:{name:\"floway-setup\",title:null,version:\"1\"},capabilities:null}}')\n printf '%s\\n' \"$_cas_init\" >&3 2>/dev/null || _cas_status=1\n if [ \"$_cas_status\" -eq 0 ]; then\n _codex_read_response 1\n _cas_status=$?\n fi\n if [ \"$_cas_status\" -eq 0 ]; then\n printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"method\":\"initialized\"}' >&3 2>/dev/null || _cas_status=1\n fi\n if [ \"$_cas_status\" -eq 0 ]; then\n _cas_batch=$(\"$JQ\" -cn --argjson edits \"$_cas_edits\" '{jsonrpc:\"2.0\",id:2,method:\"config/batchWrite\",params:{edits:$edits}}')\n printf '%s\\n' \"$_cas_batch\" >&3 2>/dev/null || _cas_status=1\n fi\n _cas_result=\"\"\n if [ \"$_cas_status\" -eq 0 ]; then\n _codex_read_response 2\n _cas_status=$?\n _cas_result=$CODEX_APPSERVER_RESPONSE\n fi\n\n exec 3>&- 2>/dev/null || true\n exec 4<&- 2>/dev/null || true\n _codex_kill_group \"$_cas_pid\"\n rm -rf \"$_cas_dir\"\n\n if [ \"$_cas_status\" -ne 0 ]; then\n return \"$_cas_status\"\n fi\n printf '%s' \"$_cas_result\"\n}\n\n# Build the base-config edit batch and write it through the app-server. Model\n# and effort are opaque, forwarded verbatim, and cleared with JSON null when\n# unset. A batch status of `ok` or `okOverridden` confirms the intended base\n# config; `okOverridden` is reported with its non-secret layer metadata.\ncodex_write_config() {\n _cwc_base=\"${SETUP_ENDPOINT%/}/azure-api.codex\"\n # Command auth opts a provider into online model refresh. The actor marker\n # enables Codex's client-owned search and image extensions for this provider.\n # https://github.com/openai/codex/blob/1bbdb32789e1f79932df44941236ea3658f6e965/codex-rs/models-manager/src/manager.rs#L413-L415\n # https://github.com/openai/codex/blob/1bbdb32789e1f79932df44941236ea3658f6e965/codex-rs/model-provider-info/src/lib.rs#L396-L408\n # standalone_web_search is under development, so its explicit opt-in is\n # paired with the top-level warning suppression instead of warning every run.\n # https://github.com/openai/codex/blob/24e9b849fad8f506971dfa0313dbdea8abd90112/codex-rs/features/src/lib.rs#L901-L905\n # https://github.com/openai/codex/blob/24e9b849fad8f506971dfa0313dbdea8abd90112/codex-rs/features/src/lib.rs#L1393-L1439\n _cwc_edits=$(\"$JQ\" -cn \\\n --arg base \"$_cwc_base\" \\\n --arg model \"$SETUP_CODEX_MODEL\" \\\n --arg effort \"$SETUP_CODEX_REASONING_EFFORT\" '\n [\n {keyPath:\"model_provider\",mergeStrategy:\"replace\",value:\"floway\"},\n {keyPath:\"suppress_unstable_features_warning\",mergeStrategy:\"replace\",value:true},\n {keyPath:\"model_providers.floway.name\",mergeStrategy:\"replace\",value:\"Floway\"},\n {keyPath:\"model_providers.floway.base_url\",mergeStrategy:\"replace\",value:$base},\n {keyPath:\"model_providers.floway.auth\",mergeStrategy:\"replace\",value:{command:\"sh\",args:[\"-c\",\"cat \\\"${CODEX_HOME:-$HOME/.codex}/floway-token\\\"\"]}},\n {keyPath:\"model_providers.floway.wire_api\",mergeStrategy:\"replace\",value:\"responses\"},\n {keyPath:\"model_providers.floway.supports_websockets\",mergeStrategy:\"replace\",value:true},\n {keyPath:\"model_providers.floway.http_headers\",mergeStrategy:\"replace\",value:{\"x-openai-actor-authorization\":\"1\"}},\n {keyPath:\"features.apps\",mergeStrategy:\"replace\",value:false},\n {keyPath:\"features.standalone_web_search\",mergeStrategy:\"replace\",value:true},\n {keyPath:\"model\",mergeStrategy:\"replace\",value:(if $model == \"\" then null else $model end)},\n {keyPath:\"model_reasoning_effort\",mergeStrategy:\"replace\",value:(if $effort == \"\" then null else $effort end)}\n ]') || {\n out_error 'could not build the Codex configuration edits.'\n return 1\n }\n\n _cwc_result=$(codex_app_server_batch_write \"$_cwc_edits\")\n _cwc_rc=$?\n if [ \"$_cwc_rc\" -ne 0 ]; then\n case \"$_cwc_rc\" in\n 124) out_error 'the Codex app-server timed out before confirming the configuration.' ;;\n 3) out_error 'the Codex app-server reported an error writing the configuration.' ;;\n 2) out_error 'the Codex app-server returned a malformed response.' ;;\n 1) out_error 'the Codex app-server exited before confirming the configuration.' ;;\n *) out_error 'the Codex app-server configuration failed.' ;;\n esac\n return 1\n fi\n\n _cwc_status=$(printf '%s' \"$_cwc_result\" | \"$JQ\" -r '.result.status // empty' 2>/dev/null)\n case \"$_cwc_status\" in\n ok) ;;\n okOverridden)\n _cwc_msg=$(printf '%s' \"$_cwc_result\" | \"$JQ\" -r '.result.overriddenMetadata.message // \"an override layer applies\"' 2>/dev/null)\n _cwc_layer=$(printf '%s' \"$_cwc_result\" | \"$JQ\" -r '.result.overriddenMetadata.overridingLayer.name.type // \"unknown\"' 2>/dev/null)\n out_warn \"Codex configuration is overridden by a higher-precedence layer ($_cwc_msg; layer: $_cwc_layer).\"\n ;;\n *)\n out_error \"the Codex app-server did not confirm the configuration (status: ${_cwc_status:-none}).\"\n return 1\n ;;\n esac\n CODEX_WRITTEN_CONFIG_PATH=$(printf '%s' \"$_cwc_result\" | \"$JQ\" -r '.result.filePath // empty' 2>/dev/null)\n if [ -z \"$CODEX_WRITTEN_CONFIG_PATH\" ]; then\n out_error 'the Codex app-server did not report the written config path.'\n return 1\n fi\n}\n\n# Store the selected API key as a provider-scoped command-auth token. The private\n# stage is validated byte-for-byte, then atomically renamed. auth.json is an\n# account-owned Codex file and is never read or changed here.\ncodex_stage_token() {\n _cst_stage=\"$CODEX_TOKEN_PATH.floway-stage.$$\"\n if ! (umask 077 && : > \"$_cst_stage\"); then\n out_error 'could not create the Codex provider-token stage.'\n return 1\n fi\n if ! printf '%s' \"$SETUP_API_KEY\" > \"$_cst_stage\"; then\n out_error 'could not write the Codex provider-token stage.'\n rm -f \"$_cst_stage\"\n return 1\n fi\n if ! cmp -s \"$_cst_stage\" <(printf '%s' \"$SETUP_API_KEY\"); then\n out_error 'staged Codex provider token failed validation.'\n rm -f \"$_cst_stage\"\n return 1\n fi\n if ! chmod 600 \"$_cst_stage\"; then\n rm -f \"$_cst_stage\"\n return 1\n fi\n if ! mv \"$_cst_stage\" \"$CODEX_TOKEN_PATH\"; then\n out_error \"could not replace $CODEX_TOKEN_PATH\"\n rm -f \"$_cst_stage\"\n return 1\n fi\n}\n\ncodex_write_version() {\n _cv_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-30}\n _cv_version_file=\"$SETUP_TMPDIR/codex-version.out\"\n if _run_with_timeout \"$_cv_timeout\" \"$CODEX_BIN\" --version > \"$_cv_version_file\" 2>&1; then\n out_info \"Codex version: $(cat \"$_cv_version_file\")\"\n else\n _cv_version_status=$?\n if [ \"$_cv_version_status\" -eq 124 ]; then\n out_error '`codex --version` timed out.'\n else\n out_error '`codex --version` failed.'\n fi\n return 1\n fi\n}\n\n# Install, then configure Codex as one transactional config/token write. A\n# freshly installed CLI is never uninstalled when configuration fails.\nconfigure_agent() {\n out_agent_notice 'Installing' 'Codex'\n if ! codex_ensure_installed; then\n out_error 'Codex CLI is unavailable and could not be installed.'\n return 1\n fi\n if ! codex_write_version; then\n return 1\n fi\n\n out_agent_notice 'Configuring' 'Codex'\n if ! ensure_jq; then\n out_error 'jq is required to configure Codex but is unavailable and could not be provisioned for this platform. Install jq and re-run.'\n return 1\n fi\n CODEX_HOME_DIR=\"${CODEX_HOME:-$HOME/.codex}\"\n CODEX_CONFIG_PATH=\"$CODEX_HOME_DIR/config.toml\"\n CODEX_TOKEN_PATH=\"$CODEX_HOME_DIR/floway-token\"\n if ! mkdir -p \"$CODEX_HOME_DIR\"; then\n out_error \"could not create $CODEX_HOME_DIR\"\n return 1\n fi\n if ! codex_backup_files; then\n return 1\n fi\n if ! codex_stage_token; then\n out_warn 'Codex provider-token staging failed; rolling back configuration and token.'\n codex_rollback\n return 1\n fi\n if ! codex_write_config; then\n out_warn 'Codex configuration failed; rolling back configuration and token.'\n codex_rollback\n return 1\n fi\n if ! codex_commit_files; then\n out_warn 'Codex backup cleanup failed; rolling back configuration and token.'\n codex_rollback\n return 1\n fi\n out_info \"Written to \\`$CODEX_WRITTEN_CONFIG_PATH\\`.\"\n out_info \"Written to \\`$CODEX_TOKEN_PATH\\`.\"\n out_agent_notice 'Completed Agent Setup' 'Codex'\n}\n\n\nmain 'Codex' \"$@\"\n"; +export const SETUP_BASH_COMMON_CLI = '# Download an installer to the private working directory, refuse anything that\n# is not a shell script (region blocks and captive portals serve HTML in place\n# of the real installer), then execute it without sudo.\n_download_and_run_installer() {\n _dri_url=$1\n _dri_file=$(mktemp "$SETUP_TMPDIR/install.XXXXXX") || return 1\n if ! curl -fsSL --connect-timeout 10 --max-time 120 -o "$_dri_file" "$_dri_url"; then\n out_error "could not download the installer from $_dri_url"\n rm -f "$_dri_file"\n return 1\n fi\n # Reject common HTML responses while allowing official shell content with or\n # without a shebang (some installer CDNs prepend comments).\n if awk \'\n NR <= 20 {\n line = tolower($0)\n if (line ~ /^[[:space:]]*(])|])|]))/) found = 1\n }\n END { exit found ? 0 : 1 }\n \' "$_dri_file"; then\n out_error \'the installer download was HTML, not an executable script (a login or region-block page?).\'\n rm -f "$_dri_file"\n return 1\n fi\n if ! awk \'NF { found = 1 } END { exit found ? 0 : 1 }\' "$_dri_file"; then\n out_error \'the installer download was empty.\'\n rm -f "$_dri_file"\n return 1\n fi\n _dri_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-120}\n _run_with_timeout "$_dri_timeout" env -u SETUP_API_KEY bash "$_dri_file" /dev/null || true)\n if [ -n "$DISCOVERED_BIN" ]; then\n DISCOVERED_COUNT=1\n else\n DISCOVERED_COUNT=0\n fi\n for _dc_candidate in "$@"; do\n [ -x "$_dc_candidate" ] || continue\n [ "$_dc_candidate" = "$DISCOVERED_BIN" ] && continue\n DISCOVERED_COUNT=$((DISCOVERED_COUNT + 1))\n if [ -z "$DISCOVERED_BIN" ]; then\n DISCOVERED_BIN=$_dc_candidate\n fi\n done\n}\n\n_install_brew_cask() {\n _ibc_cask=$1\n if ! command -v brew >/dev/null 2>&1; then\n out_error \'Homebrew is required to install agent CLIs on macOS.\'\n return 1\n fi\n _ibc_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-600}\n _run_with_timeout "$_ibc_timeout" env -u SETUP_API_KEY brew install --cask "$_ibc_cask" $Text\"; return }\n if ($script:SetupOutAnsi) {\n Write-Host \"$($script:SetupEsc)[34m==>$($script:SetupEsc)[0m $($script:SetupEsc)[1m$Text$($script:SetupEsc)[0m\"\n return\n }\n Write-Host '==>' -ForegroundColor Blue -NoNewline\n Write-Host \" $Text\" -ForegroundColor White\n}\n\n# Console.Error is used directly so diagnostics remain on stderr while only the\n# Homebrew-style label receives color.\nfunction Write-SetupDiagnostic {\n param([string]$Label, [string]$Text, [System.ConsoleColor]$Color, [string]$TestAnsiCode)\n if ($script:SetupErrColor) {\n $previous = [Console]::ForegroundColor\n try {\n [Console]::ForegroundColor = $Color\n [Console]::Error.Write(\"${Label}:\")\n [Console]::ForegroundColor = $previous\n [Console]::Error.WriteLine(\" $Text\")\n } finally {\n [Console]::ForegroundColor = $previous\n }\n } elseif ($script:SetupForceColor -and (-not $script:SetupNoColor)) {\n [Console]::Error.WriteLine(\"$($script:SetupEsc)[${TestAnsiCode}m${Label}:$($script:SetupEsc)[0m $Text\")\n } else {\n [Console]::Error.WriteLine(\"${Label}: $Text\")\n }\n}\n\nfunction Write-SetupAgentNotice { param([string]$Label, [string]$AgentName) Write-SetupNotice \"${Label}: $AgentName\" }\nfunction Write-SetupMetadata { param([string]$Label, [string]$Value) Write-Host \"${Label}: $Value\" }\nfunction Write-SetupInfo { param([string]$Text) Write-SetupHostLine $Text -Plain }\nfunction Write-SetupWarn { param([string]$Text) Write-SetupDiagnostic 'Warning' $Text Yellow '93' }\nfunction Write-SetupError { param([string]$Text) Write-SetupDiagnostic 'Error' $Text Red '91' }\n\n# Report a primary error to stderr and unwind. The agent boundary recognizes the\n# 'setup-handled' marker as already reported, so no line is ever duplicated.\nfunction Stop-Setup { param([string]$Message) Write-SetupError $Message; throw 'setup-handled' }\n"; +export const SETUP_BASH_COMMON_MANAGED_FILE = '# Rollback retains a backup when restoration fails so manual recovery remains\n# possible. Callers keep separate transaction boundaries and aggregate failures.\n_restore_managed_file() {\n _rmf_existed=$1\n _rmf_backup=$2\n _rmf_path=$3\n _rmf_original_label=$4\n _rmf_created_label=$5\n if [ "$_rmf_existed" -eq 1 ]; then\n if [ -n "$_rmf_backup" ] && [ -e "$_rmf_backup" ] && ! mv "$_rmf_backup" "$_rmf_path" 2>/dev/null; then\n out_warn "could not restore $_rmf_path from its backup; your original $_rmf_original_label is preserved at $_rmf_backup — restore it by hand."\n return 1\n fi\n elif ! rm -f "$_rmf_path" 2>/dev/null; then\n out_warn "could not remove the $_rmf_created_label this run created at $_rmf_path — remove it by hand."\n return 1\n fi\n return 0\n}\n\n_prune_managed_backups() {\n _pmb_path=$1\n _pmb_keep=$2\n for _pmb_backup in "$_pmb_path".floway-backup.*; do\n [ -e "$_pmb_backup" ] || continue\n [ "$_pmb_backup" = "$_pmb_keep" ] && continue\n if ! rm -f "$_pmb_backup"; then\n out_error "could not remove obsolete backup $_pmb_backup"\n return 1\n fi\n done\n}\n'; -export const SETUP_POWERSHELL_COMMON_HELPERS = "# Windows PowerShell 5.1 only runs on Windows and has no $IsWindows automatic\n# variable; PowerShell 6+ exposes it on every platform.\nfunction Test-SetupIsWindows {\n ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows\n}\n\n# The AGENT_SETUP_TEST_TIMEOUT_SECONDS hook, read from the ambient environment\n# and never emitted by the gateway, lets the harness shorten every wall-clock\n# limit; otherwise the caller-supplied default applies.\nfunction Get-SetupTimeoutSeconds {\n param([int]$Default)\n if ($env:AGENT_SETUP_TEST_TIMEOUT_SECONDS) { [int]$env:AGENT_SETUP_TEST_TIMEOUT_SECONDS } else { $Default }\n}\n\nfunction Set-SetupProp {\n param($Target, [string]$Name, $Value)\n if ($Target.PSObject.Properties.Name -contains $Name) { $Target.$Name = $Value }\n else { $Target | Add-Member -NotePropertyName $Name -NotePropertyValue $Value }\n}\n\nfunction Remove-SetupProp {\n param($Target, [string]$Name)\n if ($Target.PSObject.Properties.Name -contains $Name) { $Target.PSObject.Properties.Remove($Name) }\n}\n\n# A null optional value means \"remove this managed key\"; any other value is set.\nfunction Set-SetupOptionalProp {\n param($Target, [string]$Name, $Value)\n if ($null -eq $Value) { Remove-SetupProp $Target $Name } else { Set-SetupProp $Target $Name $Value }\n}\n\n# Redact every occurrence of the API key from text before it is surfaced.\nfunction Protect-SetupSecret {\n param([string]$Text)\n return ($Text -replace [regex]::Escape($SetupApiKey), '***')\n}\n\n# Restrict a file to the current user: chmod 0600 on Unix, an inheritance-free\n# owner-only ACL on Windows.\nfunction Protect-SetupFile {\n param([string]$Path)\n if (-not (Test-SetupIsWindows)) {\n & chmod 600 $Path\n if ($LASTEXITCODE -ne 0) { Stop-Setup \"could not restrict $Path to owner-only access.\" }\n return\n }\n # Set-Acl routes through the PowerShell filesystem provider and may persist\n # the untouched SACL, demanding SeSecurityPrivilege from a normal user. The\n # direct .NET APIs write only this descriptor's modified DACL.\n # https://github.com/PowerShell/PowerShell/blob/0c226762e2580cd7853c058dd03fc32638a73971/src/System.Management.Automation/namespaces/FileSystemSecurity.cs#L130-L200\n # https://github.com/dotnet/runtime/blob/f94898a9b55df07348434e86915c7405962427b6/src/libraries/System.IO.FileSystem.AccessControl/src/System/Security/AccessControl/FileSystemSecurity.cs#L103-L125\n $acl = New-Object System.Security.AccessControl.FileSecurity\n $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User\n $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'Allow')\n $acl.SetAccessRuleProtection($true, $false)\n $acl.AddAccessRule($rule)\n if ($PSVersionTable.PSVersion.Major -lt 6) {\n [System.IO.File]::SetAccessControl($Path, $acl)\n } else {\n [System.IO.FileSystemAclExtensions]::SetAccessControl([System.IO.FileInfo]::new($Path), $acl)\n }\n}\n\n# Terminate a process and its descendants. PowerShell 7's runtime exposes the\n# tree-aware Kill(bool) overload; Windows PowerShell 5.1 uses taskkill /T.\nfunction Stop-SetupProcessTree {\n param([System.Diagnostics.Process]$Process)\n $runningOnWindows = Test-SetupIsWindows\n if ($runningOnWindows) {\n & taskkill.exe /PID $Process.Id /T /F *> $null\n if ($LASTEXITCODE -ne 0 -and (-not $Process.HasExited)) {\n Stop-Setup \"taskkill could not terminate process tree $($Process.Id).\"\n }\n return\n }\n try {\n $Process.Kill($true)\n } catch {\n if (-not $Process.HasExited) { Stop-Setup \"could not terminate process tree $($Process.Id).\" }\n }\n}\n\nfunction Get-SetupPlatform {\n if (Test-SetupIsWindows) { return 'windows' }\n if ($IsMacOS) { return 'macos' }\n return 'linux'\n}\n\n# Run a fixed package-manager command with inherited stdout/stderr. The child\n# remains attached to the real terminal, so progress updates and ANSI control\n# sequences render in real time without a lossy line-prefix filter.\nfunction Invoke-SetupLiveProcess {\n param([string]$Exe, [string[]]$Arguments, [int]$TimeoutSeconds)\n $startInfo = New-Object System.Diagnostics.ProcessStartInfo\n $startInfo.FileName = $Exe\n $startInfo.Arguments = ($Arguments | ForEach-Object { '\"' + $_.Replace('\"', '\\\"') + '\"' }) -join ' '\n $startInfo.UseShellExecute = $false\n $startInfo.CreateNoWindow = $false\n $process = New-Object System.Diagnostics.Process\n $process.StartInfo = $startInfo\n if (-not $process.Start()) { Stop-Setup \"failed to start $Exe.\" }\n if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {\n Stop-SetupProcessTree $process\n $process.WaitForExit()\n Stop-Setup \"$Exe timed out after $TimeoutSeconds seconds.\"\n }\n if ($process.ExitCode -ne 0) { Stop-Setup \"$Exe exited with status $($process.ExitCode).\" }\n}\n\nfunction Install-SetupHomebrewCask {\n param([string]$Cask)\n $brew = Get-Command brew -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if (-not $brew) { Stop-Setup 'Homebrew is required to install agent CLIs on macOS.' }\n $timeoutSeconds = Get-SetupTimeoutSeconds 600\n Invoke-SetupLiveProcess -Exe $brew.Source -Arguments @('install', '--cask', $Cask) -TimeoutSeconds $timeoutSeconds\n}\n\n# npm on Windows is commonly a .cmd launcher, which ProcessStartInfo cannot\n# execute directly with UseShellExecute disabled. A fresh copy of the current\n# PowerShell host resolves that launcher while preserving inherited terminal\n# output and the same process-tree timeout.\nfunction Install-SetupNpmPackage {\n param([string]$Package)\n $npm = Get-Command npm -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if (-not $npm) { Stop-Setup 'npm was selected for installation but is no longer available.' }\n $hostCommand = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n $hostExe = if ($hostCommand) { $hostCommand.Source } else { [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName }\n $npmLiteral = \"'\" + $npm.Source.Replace(\"'\", \"''\") + \"'\"\n $packageLiteral = \"'\" + $Package.Replace(\"'\", \"''\") + \"'\"\n $command = \"& $npmLiteral install --global $packageLiteral; exit `$LASTEXITCODE\"\n $timeoutSeconds = Get-SetupTimeoutSeconds 600\n Invoke-SetupLiveProcess -Exe $hostExe -Arguments @('-NoProfile', '-NonInteractive', '-Command', $command) -TimeoutSeconds $timeoutSeconds\n}\n\n# Execute a downloaded installer in a fresh interpreter. The script travels\n# through stdin, while the API key exists only as a variable in this parent\n# process and its identically named environment variables were removed. The\n# official installer therefore cannot read the credential.\nfunction Invoke-SetupInterpreterBody {\n param([string]$Body, [int]$TimeoutSeconds, [string]$Exe, [string]$Arguments)\n $startInfo = New-Object System.Diagnostics.ProcessStartInfo\n $startInfo.FileName = $Exe\n $startInfo.Arguments = $Arguments\n $startInfo.UseShellExecute = $false\n $startInfo.CreateNoWindow = $false\n $startInfo.RedirectStandardInput = $true\n $process = New-Object System.Diagnostics.Process\n $process.StartInfo = $startInfo\n if (-not $process.Start()) { Stop-Setup \"failed to start the installer interpreter.\" }\n $process.StandardInput.Write($Body)\n $process.StandardInput.WriteLine()\n $process.StandardInput.Close()\n if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {\n Stop-SetupProcessTree $process\n $process.WaitForExit()\n Stop-Setup \"the installer timed out after $TimeoutSeconds seconds.\"\n }\n if ($process.ExitCode -ne 0) { Stop-Setup \"the installer exited with status $($process.ExitCode).\" }\n}\n\nfunction Invoke-SetupPowerShellBody {\n param([string]$Body, [int]$TimeoutSeconds, [switch]$BypassExecutionPolicy)\n $pwsh = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n $exe = if ($pwsh) { $pwsh.Source } else { [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName }\n $executionPolicy = if ($BypassExecutionPolicy) { '-ExecutionPolicy Bypass ' } else { '' }\n Invoke-SetupInterpreterBody -Body $Body -TimeoutSeconds $TimeoutSeconds -Exe $exe -Arguments \"-NoProfile -NonInteractive ${executionPolicy}-Command -\"\n}\n\nfunction Invoke-SetupShellBody {\n param([string]$Body, [int]$TimeoutSeconds)\n $bash = Get-Command bash -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if (-not $bash) { Stop-Setup 'bash is required to run the official installer on macOS and Linux.' }\n Invoke-SetupInterpreterBody -Body $Body -TimeoutSeconds $TimeoutSeconds -Exe $bash.Source -Arguments '-s'\n}\n\n# Download an installer, refuse anything that is not a script (region blocks and\n# captive portals serve HTML in place of the installer), then run it.\nfunction Invoke-SetupRemoteInstaller {\n param([string]$Uri, [switch]$BypassExecutionPolicy, [switch]$Shell)\n $response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -TimeoutSec 60\n $body = [string]$response.Content\n $contentType = [string]$response.Headers['Content-Type']\n $looksLikeHtml = $contentType -match '(?i)^text/html(?:;|$)' -or $body -match '(?is)^\\s*(?:))'\n if ([string]::IsNullOrWhiteSpace($body) -or $looksLikeHtml) {\n Stop-Setup \"the installer download was HTML or empty, not an executable script (a login or region-block page?).\"\n }\n $timeoutSeconds = Get-SetupTimeoutSeconds 120\n if ($Shell) { Invoke-SetupShellBody -Body $body -TimeoutSeconds $timeoutSeconds }\n else { Invoke-SetupPowerShellBody -Body $body -TimeoutSeconds $timeoutSeconds -BypassExecutionPolicy:$BypassExecutionPolicy }\n}\n\nfunction Get-SetupCliExe {\n param([string]$Name, [string]$Label, [string[]]$Candidates)\n $found = New-Object System.Collections.Generic.List[string]\n $command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if ($command) { $found.Add($command.Source) }\n foreach ($candidate in $Candidates) {\n if ((Test-Path -LiteralPath $candidate) -and (-not $found.Contains($candidate))) { $found.Add($candidate) }\n }\n if ($found.Count -eq 0) { return $null }\n if ($found.Count -gt 1) { Write-SetupWarn \"multiple $Label installations detected; using $($found[0])\" }\n return $found[0]\n}\n\n# Rollback retains a backup when restoration fails so manual recovery remains\n# possible, warning with the preserved path and the action to take — matching\n# the Bash installer. The AGENT_SETUP_TEST_FAIL_RESTORE hook, read from the\n# ambient environment and never emitted by the gateway, forces the restore\n# rename to fail so the harness can assert that guidance.\nfunction Restore-SetupManagedFile {\n param([bool]$Existed, [string]$Backup, [string]$Path, [string]$OriginalLabel, [string]$CreatedLabel)\n if ($Existed) {\n if ($Backup -and (Test-Path -LiteralPath $Backup)) {\n try {\n if ($env:AGENT_SETUP_TEST_FAIL_RESTORE) { throw 'test-injected restore failure' }\n # Secret-bearing backups were already owner-only before any mutation.\n # Moving one back preserves that protection without a second operation\n # that could fail after the backup path has been consumed.\n Move-Item -LiteralPath $Backup -Destination $Path -Force\n } catch {\n Write-SetupWarn \"could not restore $Path from its backup; your original $OriginalLabel is preserved at $Backup — restore it by hand.\"\n }\n }\n } elseif (Test-Path -LiteralPath $Path) {\n try {\n Remove-Item -LiteralPath $Path -Force\n } catch {\n Write-SetupWarn \"could not remove the $CreatedLabel this run created at $Path — remove it by hand.\"\n }\n }\n}\n\nfunction Remove-SetupOlderBackups {\n param([string]$Path, [string]$Keep)\n $directory = Split-Path -Parent $Path\n $prefix = [System.IO.Path]::GetFileName($Path) + '.floway-backup.'\n Get-ChildItem -LiteralPath $directory -File -ErrorAction Stop |\n Where-Object { $_.Name.StartsWith($prefix, [System.StringComparison]::Ordinal) -and $_.FullName -ne $Keep } |\n Remove-Item -Force -ErrorAction Stop\n}\n\n# Run a child process with captured output under a deadline, terminating its\n# whole process tree and throwing on timeout.\nfunction Invoke-SetupProcess {\n param([string]$Exe, [string[]]$Arguments, [int]$TimeoutSeconds, [string]$TimeoutMessage)\n $startInfo = New-Object System.Diagnostics.ProcessStartInfo\n $startInfo.FileName = $Exe\n $startInfo.UseShellExecute = $false\n $startInfo.CreateNoWindow = $true\n $startInfo.RedirectStandardOutput = $true\n $startInfo.RedirectStandardError = $true\n # ArgumentList is unavailable in Windows PowerShell 5.1. These arguments are\n # fixed internal tokens, so quoting them with ProcessStartInfo.Arguments is\n # safe and keeps external input out of the child command line.\n $startInfo.Arguments = ($Arguments | ForEach-Object { '\"' + $_.Replace('\"', '\\\"') + '\"' }) -join ' '\n $process = New-Object System.Diagnostics.Process\n $process.StartInfo = $startInfo\n if (-not $process.Start()) { Stop-Setup \"failed to start $Exe.\" }\n $stdoutTask = $process.StandardOutput.ReadToEndAsync()\n $stderrTask = $process.StandardError.ReadToEndAsync()\n if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {\n Stop-SetupProcessTree $process\n $process.WaitForExit()\n Stop-Setup $(if ($TimeoutMessage) { $TimeoutMessage } else { \"$Exe timed out after $TimeoutSeconds seconds.\" })\n }\n $stdout = $stdoutTask.GetAwaiter().GetResult()\n $stderr = $stderrTask.GetAwaiter().GetResult()\n [PSCustomObject]@{ ExitCode = $process.ExitCode; Output = ($stdout + $stderr) }\n}\n"; +export const SETUP_BASH_CLAUDE = '# Claude Code Agent Setup fragment.\n\n# Managed-key merge applied to the existing Claude settings document. Only the\n# keys the setup owns are touched; every unrelated key and env var is preserved.\n# An empty optional value means "remove that managed key". The API key is read\n# from the environment (`env.SETUP_API_KEY`) so it stays out of argv.\n# Refs: https://docs.claude.com/en/docs/claude-code/env-vars\n# https://docs.claude.com/en/docs/claude-code/model-config#environment-variables\n# https://docs.claude.com/en/docs/claude-code/settings\n# https://code.claude.com/docs/en/settings#attribution-settings\nCLAUDE_MERGE_PROGRAM=\'\n if type != "object" then error("root is not a JSON object")\n elif (has("env") and ((.env | type) != "object")) then error("env is not a JSON object")\n elif (has("attribution") and ((.attribution | type) != "object")) then error("attribution is not a JSON object")\n else . end\n | (if (has("env") | not) then .env = {} else . end)\n | .env.ANTHROPIC_BASE_URL = $baseUrl\n | .env.ANTHROPIC_AUTH_TOKEN = env.SETUP_API_KEY\n | (if $model == "" then del(.env.ANTHROPIC_MODEL) else .env.ANTHROPIC_MODEL = $model end)\n | (if $opus == "" then del(.env.ANTHROPIC_DEFAULT_OPUS_MODEL) else .env.ANTHROPIC_DEFAULT_OPUS_MODEL = $opus end)\n | (if $sonnet == "" then del(.env.ANTHROPIC_DEFAULT_SONNET_MODEL) else .env.ANTHROPIC_DEFAULT_SONNET_MODEL = $sonnet end)\n | (if $haiku == "" then del(.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) else .env.ANTHROPIC_DEFAULT_HAIKU_MODEL = $haiku end)\n | (if $discovery == "1" then .env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1" else del(.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY) end)\n | (if $effort == "" then del(.effortLevel) else .effortLevel = $effort end)\n | (if $cleanup == "" then del(.cleanupPeriodDays) else .cleanupPeriodDays = ($cleanup | tonumber) end)\n | (if $optOutAttribution == "1" then\n .attribution = ((.attribution // {}) + { "commit": "", "pr": "", "sessionUrl": false })\n else\n del(.attribution.commit, .attribution.pr, .attribution.sessionUrl)\n | (if .attribution == {} then del(.attribution) else . end)\n end)\n\'\n\n# Refs:\n# https://code.claude.com/docs/en/setup\n# https://github.com/anthropics/claude-code/blob/c39cb0f14bfe8bb519bae5bfc55add6867c5e2ab/README.md#L13-L44\nclaude_ensure_installed() {\n _discover_cli claude \\\n "$HOME/.local/bin/claude" \\\n "$HOME/.claude/local/claude" \\\n "$HOME/.bun/bin/claude" \\\n "/opt/homebrew/bin/claude" \\\n "/usr/local/bin/claude"\n CLAUDE_BIN=$DISCOVERED_BIN\n if [ "$DISCOVERED_COUNT" -gt 1 ]; then\n out_warn "multiple Claude Code installations detected; using $CLAUDE_BIN"\n fi\n if [ "$DISCOVERED_COUNT" -ge 1 ]; then\n out_info \'Claude Code is already installed.\'\n return 0\n fi\n\n if [ -n "${AGENT_SETUP_TEST_INSTALL_CLAUDE_SCRIPT:-}" ]; then\n out_info \'Claude Code CLI not found; running the test installer\'\n _ic_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-120}\n _run_with_timeout "$_ic_timeout" env -u SETUP_API_KEY bash "$AGENT_SETUP_TEST_INSTALL_CLAUDE_SCRIPT" /dev/null 2>&1; then\n out_info \'Claude Code CLI not found; installing with Homebrew\'\n _install_brew_cask claude-code || return 1\n elif command -v npm >/dev/null 2>&1; then\n out_info \'Claude Code CLI not found; installing with npm\'\n _install_npm_package \'@anthropic-ai/claude-code\' || return 1\n else\n out_info \'Claude Code CLI not found; installing from downloads.claude.ai\'\n _download_and_run_installer \'https://downloads.claude.ai/claude-code-releases/bootstrap.sh\' || return 1\n fi\n ;;\n Linux)\n if command -v npm >/dev/null 2>&1; then\n out_info \'Claude Code CLI not found; installing with npm\'\n _install_npm_package \'@anthropic-ai/claude-code\' || return 1\n else\n out_info \'Claude Code CLI not found; installing from downloads.claude.ai\'\n _download_and_run_installer \'https://downloads.claude.ai/claude-code-releases/bootstrap.sh\' || return 1\n fi\n ;;\n *)\n out_error \'automatic Claude Code installation supports macOS and Linux only in the Bash installer.\'\n return 1\n ;;\n esac\n fi\n hash -r 2>/dev/null || true\n _discover_cli claude \\\n "$HOME/.local/bin/claude" \\\n "$HOME/.claude/local/claude" \\\n "$HOME/.bun/bin/claude" \\\n "/opt/homebrew/bin/claude" \\\n "/usr/local/bin/claude"\n CLAUDE_BIN=$DISCOVERED_BIN\n [ "$DISCOVERED_COUNT" -ge 1 ]\n}\n\nclaude_rollback_settings() {\n _restore_managed_file \\\n "${CLAUDE_SETTINGS_EXISTED:-0}" "${CLAUDE_SETTINGS_BACKUP:-}" "$CLAUDE_SETTINGS_PATH" \\\n "file" "Claude settings"\n}\n\n# Same-directory staging keeps the mode-0600 replacement rename atomic.\nclaude_write_settings() {\n _cw_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"\n CLAUDE_SETTINGS_PATH="$_cw_dir/settings.json"\n CLAUDE_SETTINGS_BACKUP=""\n CLAUDE_SETTINGS_EXISTED=0\n\n if ! mkdir -p "$_cw_dir"; then\n out_error "could not create $_cw_dir"\n return 1\n fi\n\n if [ -e "$CLAUDE_SETTINGS_PATH" ]; then\n CLAUDE_SETTINGS_EXISTED=1\n if ! "$JQ" \'\n if type != "object" then error("root is not a JSON object")\n elif (has("env") and ((.env | type) != "object")) then error("env is not a JSON object")\n elif (has("attribution") and ((.attribution | type) != "object")) then error("attribution is not a JSON object")\n else . end\n \' "$CLAUDE_SETTINGS_PATH" >/dev/null 2>&1; then\n out_error "$CLAUDE_SETTINGS_PATH is not valid Claude settings; leaving it untouched."\n return 1\n fi\n _cw_base=$(cat "$CLAUDE_SETTINGS_PATH")\n CLAUDE_SETTINGS_BACKUP="$CLAUDE_SETTINGS_PATH.floway-backup.$(date +%Y%m%d%H%M%S).$$"\n if ! cp "$CLAUDE_SETTINGS_PATH" "$CLAUDE_SETTINGS_BACKUP"; then\n out_error "could not back up $CLAUDE_SETTINGS_PATH"\n return 1\n fi\n else\n _cw_base=\'{}\'\n fi\n\n _cw_stage="$CLAUDE_SETTINGS_PATH.floway-stage.$$"\n if ! printf \'%s\' "$_cw_base" | SETUP_API_KEY="$SETUP_API_KEY" "$JQ" \\\n --arg baseUrl "$SETUP_ENDPOINT" \\\n --arg model "$SETUP_CLAUDE_MODEL" \\\n --arg opus "$SETUP_CLAUDE_DEFAULT_OPUS_MODEL" \\\n --arg sonnet "$SETUP_CLAUDE_DEFAULT_SONNET_MODEL" \\\n --arg haiku "$SETUP_CLAUDE_DEFAULT_HAIKU_MODEL" \\\n --arg discovery "$SETUP_CLAUDE_MODEL_DISCOVERY" \\\n --arg effort "$SETUP_CLAUDE_EFFORT_LEVEL" \\\n --arg cleanup "$SETUP_CLAUDE_CLEANUP_PERIOD_DAYS" \\\n --arg optOutAttribution "$SETUP_CLAUDE_OPT_OUT_AI_ATTRIBUTION" \\\n "$CLAUDE_MERGE_PROGRAM" > "$_cw_stage"; then\n out_error \'failed to construct updated Claude settings.\'\n rm -f "$_cw_stage"\n claude_rollback_settings\n return 1\n fi\n\n if ! SETUP_API_KEY="$SETUP_API_KEY" "$JQ" -e --arg baseUrl "$SETUP_ENDPOINT" \'\n (type == "object")\n and ((.env | type) == "object")\n and (.env.ANTHROPIC_BASE_URL == $baseUrl)\n and (.env.ANTHROPIC_AUTH_TOKEN == env.SETUP_API_KEY)\n \' "$_cw_stage" >/dev/null 2>&1; then\n out_error \'staged Claude settings failed validation.\'\n rm -f "$_cw_stage"\n claude_rollback_settings\n return 1\n fi\n\n if ! chmod 600 "$_cw_stage"; then\n rm -f "$_cw_stage"\n claude_rollback_settings\n return 1\n fi\n\n if ! mv "$_cw_stage" "$CLAUDE_SETTINGS_PATH"; then\n out_error "could not replace $CLAUDE_SETTINGS_PATH"\n rm -f "$_cw_stage"\n claude_rollback_settings\n return 1\n fi\n if ! _prune_managed_backups "$CLAUDE_SETTINGS_PATH" "$CLAUDE_SETTINGS_BACKUP"; then\n claude_rollback_settings\n return 1\n fi\n}\n\nclaude_write_version() {\n _cv_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-30}\n _cv_version_file="$SETUP_TMPDIR/claude-version.out"\n if _run_with_timeout "$_cv_timeout" "$CLAUDE_BIN" --version > "$_cv_version_file" 2>&1; then\n _cv_version=$(cat "$_cv_version_file")\n else\n _cv_version_status=$?\n if [ "$_cv_version_status" -eq 124 ]; then\n out_error \'`claude --version` timed out.\'\n else\n out_error \'`claude --version` failed.\'\n fi\n return 1\n fi\n out_info "Claude Code version: $_cv_version"\n}\n\n# Install, then configure Claude Code as one transactional settings write. A\n# freshly installed CLI is never uninstalled when configuration fails.\nconfigure_agent() {\n out_agent_notice \'Installing\' \'Claude Code\'\n if ! claude_ensure_installed; then\n out_error \'Claude Code CLI is unavailable and could not be installed.\'\n return 1\n fi\n if ! claude_write_version; then\n return 1\n fi\n\n out_agent_notice \'Configuring\' \'Claude Code\'\n if ! ensure_jq; then\n out_error \'jq is required to configure Claude Code but is unavailable and could not be provisioned for this platform. Install jq and re-run.\'\n return 1\n fi\n if ! claude_write_settings; then\n return 1\n fi\n out_info "Written to \\`$CLAUDE_SETTINGS_PATH\\`."\n out_agent_notice \'Completed Agent Setup\' \'Claude Code\'\n}\n\n\nmain \'Claude Code\' "$@"\n'; -export const SETUP_POWERSHELL_COMMON_MAIN = "# --- run --------------------------------------------------------------------\n\nfunction Main {\n param([string]$AgentName)\n $ErrorActionPreference = 'Stop'\n # Keep native command failures from auto-throwing on PowerShell 7.3+ so the\n # explicit exit-code checks remain authoritative across versions.\n $PSNativeCommandUseErrorActionPreference = $false\n\n Remove-Item Env:SETUP_API_KEY -ErrorAction SilentlyContinue\n\n try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) } catch { }\n $script:SetupNoColor = [bool]$env:NO_COLOR\n $script:SetupForceColor = [bool]$env:AGENT_SETUP_TEST_FORCE_COLOR\n $script:SetupErrColor = (-not [Console]::IsErrorRedirected) -and (-not $script:SetupNoColor)\n $script:SetupEsc = [char]27\n $supportsVt = try { [bool]$Host.UI.SupportsVirtualTerminal } catch { $false }\n $script:SetupOutAnsi = $supportsVt -and (-not [Console]::IsOutputRedirected) -and (-not $script:SetupNoColor)\n\n Write-SetupAgentNotice 'Agent Setup' $AgentName\n if ([string]::IsNullOrWhiteSpace($SetupEndpoint)) {\n Write-SetupError \"`$SetupEndpoint must be set to this gateway origin (e.g. https://gateway.example).\"\n return 1\n }\n if ($SetupEndpoint -notmatch '^https?://.+') {\n Write-SetupError \"`$SetupEndpoint must be an http(s) origin, got $SetupEndpoint\"\n return 1\n }\n Write-SetupMetadata 'Endpoint' $SetupEndpoint\n Write-SetupMetadata 'API Key' $SetupApiKeyName\n\n # Detection sites rethrow reported failures as the `setup-handled` sentinel;\n # only unexpected exceptions are reported again here after redaction.\n try {\n Set-SetupAgent\n } catch {\n if ($_.Exception.Message -ne 'setup-handled') { Write-SetupError (Protect-SetupSecret ([string]$_.Exception.Message)) }\n return 1\n }\n return 0\n}\n"; +export const SETUP_BASH_CODEX = '# Codex Agent Setup fragment.\n\n# Track upstream\'s maintained installer so release-metadata fixes arrive without\n# waiting for a Floway update. Reviewed sources:\n# https://github.com/openai/codex/blob/d3fc1950a920f98e7fa9f11056667cdf911c38df/README.md#L18-L37\n# https://github.com/openai/codex/blob/d3fc1950a920f98e7fa9f11056667cdf911c38df/scripts/install/install.sh\ncodex_ensure_installed() {\n _discover_cli codex \\\n "$HOME/.local/bin/codex" \\\n "/opt/homebrew/bin/codex" \\\n "/usr/local/bin/codex"\n CODEX_BIN=$DISCOVERED_BIN\n if [ "$DISCOVERED_COUNT" -gt 1 ]; then\n out_warn "multiple Codex installations detected; using $CODEX_BIN"\n fi\n if [ "$DISCOVERED_COUNT" -ge 1 ]; then\n out_info \'Codex is already installed.\'\n return 0\n fi\n\n if [ -n "${AGENT_SETUP_TEST_INSTALL_CODEX_SCRIPT:-}" ]; then\n out_info \'Codex CLI not found; running the test installer\'\n _icx_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-120}\n _run_with_timeout "$_icx_timeout" env -u SETUP_API_KEY CODEX_NON_INTERACTIVE=true bash "$AGENT_SETUP_TEST_INSTALL_CODEX_SCRIPT" /dev/null 2>&1; then\n out_info \'Codex CLI not found; installing with Homebrew\'\n _install_brew_cask codex || return 1\n elif command -v npm >/dev/null 2>&1; then\n out_info \'Codex CLI not found; installing with npm\'\n _install_npm_package \'@openai/codex\' || return 1\n else\n out_info \'Codex CLI not found; installing from GitHub\'\n CODEX_NON_INTERACTIVE=true _download_and_run_installer \'https://raw.githubusercontent.com/openai/codex/refs/heads/main/scripts/install/install.sh\' || return 1\n fi\n fi\n hash -r 2>/dev/null || true\n _discover_cli codex \\\n "$HOME/.local/bin/codex" \\\n "/opt/homebrew/bin/codex" \\\n "/usr/local/bin/codex"\n CODEX_BIN=$DISCOVERED_BIN\n [ "$DISCOVERED_COUNT" -ge 1 ]\n}\n\n# Back up the config and provider token before any mutation, recording the\n# absence of each so rollback can distinguish "restore" from "remove". The\n# token backup must be owner-only before the transaction can continue.\ncodex_backup_files() {\n CODEX_CONFIG_EXISTED=0\n CODEX_TOKEN_EXISTED=0\n CODEX_CONFIG_BACKUP=""\n CODEX_TOKEN_BACKUP=""\n _cbf_stamp=$(date +%Y%m%d%H%M%S).$$\n if [ -e "$CODEX_CONFIG_PATH" ]; then\n CODEX_CONFIG_EXISTED=1\n CODEX_CONFIG_BACKUP="$CODEX_CONFIG_PATH.floway-backup.$_cbf_stamp"\n if ! cp "$CODEX_CONFIG_PATH" "$CODEX_CONFIG_BACKUP"; then\n out_error "could not back up $CODEX_CONFIG_PATH"\n return 1\n fi\n fi\n if [ -e "$CODEX_TOKEN_PATH" ]; then\n CODEX_TOKEN_EXISTED=1\n CODEX_TOKEN_BACKUP="$CODEX_TOKEN_PATH.floway-backup.$_cbf_stamp"\n if ! cp "$CODEX_TOKEN_PATH" "$CODEX_TOKEN_BACKUP"; then\n out_error "could not back up $CODEX_TOKEN_PATH"\n return 1\n fi\n if ! chmod 600 "$CODEX_TOKEN_BACKUP"; then\n rm -f "$CODEX_TOKEN_BACKUP"\n CODEX_TOKEN_BACKUP=""\n out_error "could not protect the backup of $CODEX_TOKEN_PATH"\n return 1\n fi\n fi\n}\n\n# Both restores are attempted even when the first fails.\ncodex_rollback() {\n _cxr_rc=0\n _restore_managed_file \\\n "${CODEX_CONFIG_EXISTED:-0}" "${CODEX_CONFIG_BACKUP:-}" "$CODEX_CONFIG_PATH" \\\n "file" "Codex config" || _cxr_rc=1\n _restore_managed_file \\\n "${CODEX_TOKEN_EXISTED:-0}" "${CODEX_TOKEN_BACKUP:-}" "$CODEX_TOKEN_PATH" \\\n "provider token" "Codex provider token" || _cxr_rc=1\n return "$_cxr_rc"\n}\n\ncodex_commit_files() {\n _prune_managed_backups "$CODEX_CONFIG_PATH" "$CODEX_CONFIG_BACKUP" || return 1\n _prune_managed_backups "$CODEX_TOKEN_PATH" "$CODEX_TOKEN_BACKUP" || return 1\n if [ -n "$CODEX_TOKEN_BACKUP" ] && ! rm -f "$CODEX_TOKEN_BACKUP"; then\n out_error "could not remove provider-token backup $CODEX_TOKEN_BACKUP"\n return 1\n fi\n CODEX_TOKEN_BACKUP=""\n}\n\n# Terminate the app-server process group, giving a child whose stdin was just\n# closed a brief moment to exit on its own before escalating TERM then KILL. The\n# child is launched under job control so the whole descendant tree shares one\n# group. The natural-exit grace uses sub-second polling so a clean handshake\n# adds negligible latency.\n_codex_kill_group() {\n _ckg_pid=$1\n _ckg_n=0\n while kill -0 "$_ckg_pid" 2>/dev/null && [ "$_ckg_n" -lt 5 ]; do\n sleep 0.2\n _ckg_n=$((_ckg_n + 1))\n done\n if kill -0 "$_ckg_pid" 2>/dev/null; then\n kill -TERM -- "-$_ckg_pid" 2>/dev/null || kill -TERM "$_ckg_pid" 2>/dev/null || true\n sleep 0.5\n kill -KILL -- "-$_ckg_pid" 2>/dev/null || kill -KILL "$_ckg_pid" 2>/dev/null || true\n fi\n wait "$_ckg_pid" 2>/dev/null || true\n}\n\n# Read newline-delimited JSON-RPC from fd 4 until a response whose id matches\n# $1 arrives, demultiplexing unrelated notifications. Bounded by the absolute\n# CODEX_APPSERVER_DEADLINE. Returns 0 with the line in CODEX_APPSERVER_RESPONSE,\n# 124 on deadline, 1 on a premature stream EOF, 2 on a malformed (unparseable)\n# line, and 3 on a matching JSON-RPC error response.\n_codex_read_response() {\n _crr_id=$1\n while :; do\n _crr_left=$(( CODEX_APPSERVER_DEADLINE - $(date +%s) ))\n if [ "$_crr_left" -le 0 ]; then\n return 124\n fi\n if IFS= read -r -t "$_crr_left" _crr_line <&4; then\n [ -n "$_crr_line" ] || continue\n _crr_kind=$(printf \'%s\\n\' "$_crr_line" | "$JQ" -r --argjson want "$_crr_id" \'\n if (.id == $want) then (if has("error") then "error" elif has("result") then "result" else "pending" end) else "skip" end\n \' 2>/dev/null)\n if [ -z "$_crr_kind" ]; then\n return 2\n fi\n case "$_crr_kind" in\n result) CODEX_APPSERVER_RESPONSE=$_crr_line; return 0 ;;\n error) CODEX_APPSERVER_RESPONSE=$_crr_line; return 3 ;;\n *) continue ;;\n esac\n else\n _crr_rc=$?\n if [ "$_crr_rc" -gt 128 ]; then\n return 124\n fi\n return 1\n fi\n done\n}\n\n# Drive `codex app-server` over two private FIFOs: initialize -> initialized ->\n# config/batchWrite. stdin is kept open (fd 3) until the batch response arrives\n# on fd 4, so a server that answers after a delay still completes. The child\n# runs in its own process group for tree-wide termination; trap-invoked cleanup\n# removes the working directory. On success the raw batchWrite result JSON is the\n# only thing written to stdout (progress and errors go to stderr).\ncodex_app_server_batch_write() {\n _cas_edits=$1\n _cas_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-60}\n _cas_dir=$(mktemp -d "$SETUP_TMPDIR/codex-appserver.XXXXXX") || return 1\n _cas_req="$_cas_dir/req"\n _cas_res="$_cas_dir/res"\n if ! mkfifo "$_cas_req" "$_cas_res"; then\n rm -rf "$_cas_dir"\n return 1\n fi\n\n set -m\n "$CODEX_BIN" app-server --listen stdio:// <"$_cas_req" >"$_cas_res" 2>"$_cas_dir/stderr" &\n _cas_pid=$!\n set +m\n\n # Open the write end of req first (this unblocks the child\'s stdin open), then\n # the read end of res. This ordering is what keeps a FIFO pair from deadlocking.\n exec 3>"$_cas_req"\n exec 4<"$_cas_res"\n\n CODEX_APPSERVER_DEADLINE=$(( $(date +%s) + _cas_timeout ))\n CODEX_APPSERVER_RESPONSE=""\n _cas_status=0\n\n _cas_init=$("$JQ" -cn \'{jsonrpc:"2.0",id:1,method:"initialize",params:{clientInfo:{name:"floway-setup",title:null,version:"1"},capabilities:null}}\')\n printf \'%s\\n\' "$_cas_init" >&3 2>/dev/null || _cas_status=1\n if [ "$_cas_status" -eq 0 ]; then\n _codex_read_response 1\n _cas_status=$?\n fi\n if [ "$_cas_status" -eq 0 ]; then\n printf \'%s\\n\' \'{"jsonrpc":"2.0","method":"initialized"}\' >&3 2>/dev/null || _cas_status=1\n fi\n if [ "$_cas_status" -eq 0 ]; then\n _cas_batch=$("$JQ" -cn --argjson edits "$_cas_edits" \'{jsonrpc:"2.0",id:2,method:"config/batchWrite",params:{edits:$edits}}\')\n printf \'%s\\n\' "$_cas_batch" >&3 2>/dev/null || _cas_status=1\n fi\n _cas_result=""\n if [ "$_cas_status" -eq 0 ]; then\n _codex_read_response 2\n _cas_status=$?\n _cas_result=$CODEX_APPSERVER_RESPONSE\n fi\n\n exec 3>&- 2>/dev/null || true\n exec 4<&- 2>/dev/null || true\n _codex_kill_group "$_cas_pid"\n rm -rf "$_cas_dir"\n\n if [ "$_cas_status" -ne 0 ]; then\n return "$_cas_status"\n fi\n printf \'%s\' "$_cas_result"\n}\n\n# Build the base-config edit batch and write it through the app-server. Model\n# and effort are opaque, forwarded verbatim, and cleared with JSON null when\n# unset. A batch status of `ok` or `okOverridden` confirms the intended base\n# config; `okOverridden` is reported with its non-secret layer metadata.\ncodex_write_config() {\n _cwc_base="${SETUP_ENDPOINT%/}/azure-api.codex"\n # Command auth opts a provider into online model refresh. The actor marker\n # enables Codex\'s client-owned search and image extensions for this provider.\n # https://github.com/openai/codex/blob/1bbdb32789e1f79932df44941236ea3658f6e965/codex-rs/models-manager/src/manager.rs#L413-L415\n # https://github.com/openai/codex/blob/1bbdb32789e1f79932df44941236ea3658f6e965/codex-rs/model-provider-info/src/lib.rs#L396-L408\n # standalone_web_search is under development, so its explicit opt-in is\n # paired with the top-level warning suppression instead of warning every run.\n # https://github.com/openai/codex/blob/24e9b849fad8f506971dfa0313dbdea8abd90112/codex-rs/features/src/lib.rs#L901-L905\n # https://github.com/openai/codex/blob/24e9b849fad8f506971dfa0313dbdea8abd90112/codex-rs/features/src/lib.rs#L1393-L1439\n _cwc_edits=$("$JQ" -cn \\\n --arg base "$_cwc_base" \\\n --arg model "$SETUP_CODEX_MODEL" \\\n --arg effort "$SETUP_CODEX_REASONING_EFFORT" \'\n [\n {keyPath:"model_provider",mergeStrategy:"replace",value:"floway"},\n {keyPath:"suppress_unstable_features_warning",mergeStrategy:"replace",value:true},\n {keyPath:"model_providers.floway.name",mergeStrategy:"replace",value:"Floway"},\n {keyPath:"model_providers.floway.base_url",mergeStrategy:"replace",value:$base},\n {keyPath:"model_providers.floway.auth",mergeStrategy:"replace",value:{command:"sh",args:["-c","cat \\"${CODEX_HOME:-$HOME/.codex}/floway-token\\""]}},\n {keyPath:"model_providers.floway.wire_api",mergeStrategy:"replace",value:"responses"},\n {keyPath:"model_providers.floway.supports_websockets",mergeStrategy:"replace",value:true},\n {keyPath:"model_providers.floway.http_headers",mergeStrategy:"replace",value:{"x-openai-actor-authorization":"1"}},\n {keyPath:"features.apps",mergeStrategy:"replace",value:false},\n {keyPath:"features.standalone_web_search",mergeStrategy:"replace",value:true},\n {keyPath:"model",mergeStrategy:"replace",value:(if $model == "" then null else $model end)},\n {keyPath:"model_reasoning_effort",mergeStrategy:"replace",value:(if $effort == "" then null else $effort end)}\n ]\') || {\n out_error \'could not build the Codex configuration edits.\'\n return 1\n }\n\n _cwc_result=$(codex_app_server_batch_write "$_cwc_edits")\n _cwc_rc=$?\n if [ "$_cwc_rc" -ne 0 ]; then\n case "$_cwc_rc" in\n 124) out_error \'the Codex app-server timed out before confirming the configuration.\' ;;\n 3) out_error \'the Codex app-server reported an error writing the configuration.\' ;;\n 2) out_error \'the Codex app-server returned a malformed response.\' ;;\n 1) out_error \'the Codex app-server exited before confirming the configuration.\' ;;\n *) out_error \'the Codex app-server configuration failed.\' ;;\n esac\n return 1\n fi\n\n _cwc_status=$(printf \'%s\' "$_cwc_result" | "$JQ" -r \'.result.status // empty\' 2>/dev/null)\n case "$_cwc_status" in\n ok) ;;\n okOverridden)\n _cwc_msg=$(printf \'%s\' "$_cwc_result" | "$JQ" -r \'.result.overriddenMetadata.message // "an override layer applies"\' 2>/dev/null)\n _cwc_layer=$(printf \'%s\' "$_cwc_result" | "$JQ" -r \'.result.overriddenMetadata.overridingLayer.name.type // "unknown"\' 2>/dev/null)\n out_warn "Codex configuration is overridden by a higher-precedence layer ($_cwc_msg; layer: $_cwc_layer)."\n ;;\n *)\n out_error "the Codex app-server did not confirm the configuration (status: ${_cwc_status:-none})."\n return 1\n ;;\n esac\n CODEX_WRITTEN_CONFIG_PATH=$(printf \'%s\' "$_cwc_result" | "$JQ" -r \'.result.filePath // empty\' 2>/dev/null)\n if [ -z "$CODEX_WRITTEN_CONFIG_PATH" ]; then\n out_error \'the Codex app-server did not report the written config path.\'\n return 1\n fi\n}\n\n# Store the selected API key as a provider-scoped command-auth token. The private\n# stage is validated byte-for-byte, then atomically renamed. auth.json is an\n# account-owned Codex file and is never read or changed here.\ncodex_stage_token() {\n _cst_stage="$CODEX_TOKEN_PATH.floway-stage.$$"\n if ! (umask 077 && : > "$_cst_stage"); then\n out_error \'could not create the Codex provider-token stage.\'\n return 1\n fi\n if ! printf \'%s\' "$SETUP_API_KEY" > "$_cst_stage"; then\n out_error \'could not write the Codex provider-token stage.\'\n rm -f "$_cst_stage"\n return 1\n fi\n if ! cmp -s "$_cst_stage" <(printf \'%s\' "$SETUP_API_KEY"); then\n out_error \'staged Codex provider token failed validation.\'\n rm -f "$_cst_stage"\n return 1\n fi\n if ! chmod 600 "$_cst_stage"; then\n rm -f "$_cst_stage"\n return 1\n fi\n if ! mv "$_cst_stage" "$CODEX_TOKEN_PATH"; then\n out_error "could not replace $CODEX_TOKEN_PATH"\n rm -f "$_cst_stage"\n return 1\n fi\n}\n\ncodex_write_version() {\n _cv_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-30}\n _cv_version_file="$SETUP_TMPDIR/codex-version.out"\n if _run_with_timeout "$_cv_timeout" "$CODEX_BIN" --version > "$_cv_version_file" 2>&1; then\n out_info "Codex version: $(cat "$_cv_version_file")"\n else\n _cv_version_status=$?\n if [ "$_cv_version_status" -eq 124 ]; then\n out_error \'`codex --version` timed out.\'\n else\n out_error \'`codex --version` failed.\'\n fi\n return 1\n fi\n}\n\n# Install, then configure Codex as one transactional config/token write. A\n# freshly installed CLI is never uninstalled when configuration fails.\nconfigure_agent() {\n out_agent_notice \'Installing\' \'Codex\'\n if ! codex_ensure_installed; then\n out_error \'Codex CLI is unavailable and could not be installed.\'\n return 1\n fi\n if ! codex_write_version; then\n return 1\n fi\n\n out_agent_notice \'Configuring\' \'Codex\'\n if ! ensure_jq; then\n out_error \'jq is required to configure Codex but is unavailable and could not be provisioned for this platform. Install jq and re-run.\'\n return 1\n fi\n CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}"\n CODEX_CONFIG_PATH="$CODEX_HOME_DIR/config.toml"\n CODEX_TOKEN_PATH="$CODEX_HOME_DIR/floway-token"\n if ! mkdir -p "$CODEX_HOME_DIR"; then\n out_error "could not create $CODEX_HOME_DIR"\n return 1\n fi\n if ! codex_backup_files; then\n return 1\n fi\n if ! codex_stage_token; then\n out_warn \'Codex provider-token staging failed; rolling back configuration and token.\'\n codex_rollback\n return 1\n fi\n if ! codex_write_config; then\n out_warn \'Codex configuration failed; rolling back configuration and token.\'\n codex_rollback\n return 1\n fi\n if ! codex_commit_files; then\n out_warn \'Codex backup cleanup failed; rolling back configuration and token.\'\n codex_rollback\n return 1\n fi\n out_info "Written to \\`$CODEX_WRITTEN_CONFIG_PATH\\`."\n out_info "Written to \\`$CODEX_TOKEN_PATH\\`."\n out_agent_notice \'Completed Agent Setup\' \'Codex\'\n}\n\n\nmain \'Codex\' "$@"\n'; -export const SETUP_POWERSHELL_CLAUDE = "# Claude Code Agent Setup fragment.\n\n# Install the official Claude Code package. The\n# AGENT_SETUP_TEST_INSTALL_CLAUDE_SCRIPT hook — read from the ambient\n# environment, never emitted by the gateway — substitutes a fake installer\n# under test.\nfunction Install-SetupClaude {\n if ($env:AGENT_SETUP_TEST_INSTALL_CLAUDE_SCRIPT) {\n Write-SetupInfo 'Claude Code CLI not found; running the test installer'\n $timeoutSeconds = Get-SetupTimeoutSeconds 120\n $installer = Invoke-SetupProcess -Exe $env:AGENT_SETUP_TEST_INSTALL_CLAUDE_SCRIPT -Arguments @() -TimeoutSeconds $timeoutSeconds\n if ($installer.ExitCode -ne 0) { Stop-Setup \"the test installer hook failed.\" }\n return\n }\n if ($env:AGENT_SETUP_TEST_CLAUDE_URL) {\n Write-SetupInfo 'Claude Code CLI not found; running the test installer download'\n Invoke-SetupRemoteInstaller -Uri $env:AGENT_SETUP_TEST_CLAUDE_URL\n return\n }\n $platform = Get-SetupPlatform\n $npm = Get-Command npm -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n switch ($platform) {\n 'macos' {\n $brew = Get-Command brew -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if ($brew) {\n Write-SetupInfo 'Claude Code CLI not found; installing with Homebrew'\n Install-SetupHomebrewCask -Cask 'claude-code'\n } elseif ($npm) {\n Write-SetupInfo 'Claude Code CLI not found; installing with npm'\n Install-SetupNpmPackage -Package '@anthropic-ai/claude-code'\n } else {\n # Ref: https://code.claude.com/docs/en/setup\n Write-SetupInfo 'Claude Code CLI not found; installing from downloads.claude.ai'\n Invoke-SetupRemoteInstaller -Uri 'https://downloads.claude.ai/claude-code-releases/bootstrap.sh' -Shell\n }\n }\n 'windows' {\n if ($npm) {\n Write-SetupInfo 'Claude Code CLI not found; installing with npm'\n Install-SetupNpmPackage -Package '@anthropic-ai/claude-code'\n } else {\n # Ref: https://code.claude.com/docs/en/setup\n Write-SetupInfo 'Claude Code CLI not found; installing from downloads.claude.ai'\n Invoke-SetupRemoteInstaller -Uri 'https://downloads.claude.ai/claude-code-releases/bootstrap.ps1'\n }\n }\n 'linux' {\n if ($npm) {\n Write-SetupInfo 'Claude Code CLI not found; installing with npm'\n Install-SetupNpmPackage -Package '@anthropic-ai/claude-code'\n } else {\n # Ref: https://code.claude.com/docs/en/setup\n Write-SetupInfo 'Claude Code CLI not found; installing from downloads.claude.ai'\n Invoke-SetupRemoteInstaller -Uri 'https://downloads.claude.ai/claude-code-releases/bootstrap.sh' -Shell\n }\n }\n }\n}\n\n# Surgically merge the managed keys into the Claude settings file: validate the\n# existing document, back it up, construct and validate the replacement in the\n# same directory, then atomically rename it into place with owner-only access.\nfunction Write-SetupClaudeSettings {\n $configDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME '.claude' }\n $script:ClaudeSettingsPath = Join-Path $configDir 'settings.json'\n $script:ClaudeSettingsBackup = $null\n $script:ClaudeSettingsExisted = $false\n if (-not (Test-Path -LiteralPath $configDir)) {\n New-Item -ItemType Directory -Path $configDir -Force | Out-Null\n }\n\n if (Test-Path -LiteralPath $script:ClaudeSettingsPath) {\n $script:ClaudeSettingsExisted = $true\n $raw = Get-Content -Raw -LiteralPath $script:ClaudeSettingsPath\n try { $document = $raw | ConvertFrom-Json } catch { Stop-Setup \"$($script:ClaudeSettingsPath) is not valid JSON; leaving it untouched.\" }\n if ($document -isnot [System.Management.Automation.PSCustomObject]) { Stop-Setup \"existing Claude settings root is not a JSON object.\" }\n if (($document.PSObject.Properties.Name -contains 'env') -and ($document.env -isnot [System.Management.Automation.PSCustomObject])) {\n Stop-Setup \"existing Claude settings env is not a JSON object.\"\n }\n if (($document.PSObject.Properties.Name -contains 'attribution') -and ($document.attribution -isnot [System.Management.Automation.PSCustomObject])) {\n Stop-Setup \"existing Claude settings attribution is not a JSON object.\"\n }\n # DateTimeOffset.ToUnixTimeMilliseconds is unavailable on the .NET\n # Framework version bundled with the Windows PowerShell 5.1 baseline.\n $stamp = [long]([DateTimeOffset]::UtcNow - [DateTimeOffset]'1970-01-01T00:00:00Z').TotalMilliseconds\n $script:ClaudeSettingsBackup = \"$($script:ClaudeSettingsPath).floway-backup.$stamp.$PID\"\n try {\n Copy-Item -LiteralPath $script:ClaudeSettingsPath -Destination $script:ClaudeSettingsBackup\n Protect-SetupFile $script:ClaudeSettingsBackup\n } catch {\n if (Test-Path -LiteralPath $script:ClaudeSettingsBackup) {\n Remove-Item -LiteralPath $script:ClaudeSettingsBackup -Force\n }\n $script:ClaudeSettingsBackup = $null\n throw\n }\n } else {\n $document = [PSCustomObject]@{}\n }\n\n if ($document.PSObject.Properties.Name -notcontains 'env') {\n $document | Add-Member -NotePropertyName env -NotePropertyValue ([PSCustomObject]@{})\n }\n # Refs: https://docs.claude.com/en/docs/claude-code/env-vars\n # https://docs.claude.com/en/docs/claude-code/model-config#environment-variables\n # https://docs.claude.com/en/docs/claude-code/settings\n # https://code.claude.com/docs/en/settings#attribution-settings\n Set-SetupProp $document.env 'ANTHROPIC_BASE_URL' $SetupEndpoint\n Set-SetupProp $document.env 'ANTHROPIC_AUTH_TOKEN' $SetupApiKey\n Set-SetupOptionalProp $document.env 'ANTHROPIC_MODEL' $SetupClaudeModel\n Set-SetupOptionalProp $document.env 'ANTHROPIC_DEFAULT_OPUS_MODEL' $SetupClaudeDefaultOpusModel\n Set-SetupOptionalProp $document.env 'ANTHROPIC_DEFAULT_SONNET_MODEL' $SetupClaudeDefaultSonnetModel\n Set-SetupOptionalProp $document.env 'ANTHROPIC_DEFAULT_HAIKU_MODEL' $SetupClaudeDefaultHaikuModel\n if ($SetupClaudeModelDiscovery) { Set-SetupProp $document.env 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY' '1' }\n else { Remove-SetupProp $document.env 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY' }\n Set-SetupOptionalProp $document 'effortLevel' $SetupClaudeEffortLevel\n Set-SetupOptionalProp $document 'cleanupPeriodDays' $SetupClaudeCleanupPeriodDays\n if ($SetupClaudeOptOutAiAttribution) {\n if ($document.PSObject.Properties.Name -notcontains 'attribution') {\n $document | Add-Member -NotePropertyName attribution -NotePropertyValue ([PSCustomObject]@{})\n }\n Set-SetupProp $document.attribution 'commit' ''\n Set-SetupProp $document.attribution 'pr' ''\n Set-SetupProp $document.attribution 'sessionUrl' $false\n } elseif ($document.PSObject.Properties.Name -contains 'attribution') {\n Remove-SetupProp $document.attribution 'commit'\n Remove-SetupProp $document.attribution 'pr'\n Remove-SetupProp $document.attribution 'sessionUrl'\n if ($document.attribution.PSObject.Properties.Count -eq 0) { Remove-SetupProp $document 'attribution' }\n }\n\n $stage = \"$($script:ClaudeSettingsPath).floway-stage.$PID\"\n try {\n # The stage exists and is owner-only before any secret JSON is written.\n [System.IO.File]::Create($stage).Dispose()\n Protect-SetupFile $stage\n $json = $document | ConvertTo-Json -Depth 100\n # Write UTF-8 without a BOM on every PowerShell version so downstream JSON\n # parsers accept the file.\n [System.IO.File]::WriteAllText($stage, $json, (New-Object System.Text.UTF8Encoding($false)))\n $check = Get-Content -Raw -LiteralPath $stage | ConvertFrom-Json\n if (($check.env.ANTHROPIC_BASE_URL -cne $SetupEndpoint) -or ($check.env.ANTHROPIC_AUTH_TOKEN -cne $SetupApiKey)) {\n Stop-Setup \"staged Claude settings failed validation.\"\n }\n $runningOnWindows = Test-SetupIsWindows\n if ($script:ClaudeSettingsExisted -and $runningOnWindows) {\n # File.Replace preserves the destination ACL, so tighten it first rather\n # than letting a permissive historical DACL survive the atomic replace.\n Protect-SetupFile $script:ClaudeSettingsPath\n # PowerShell binds ordinary $null to String.Empty for a .NET string\n # parameter; NullString passes an actual null backup path.\n # https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.language.nullstring\n [System.IO.File]::Replace($stage, $script:ClaudeSettingsPath, [System.Management.Automation.Language.NullString]::Value)\n } else {\n # Move-Item is an atomic same-filesystem rename on Unix and creates a new\n # target on Windows. Windows replacing an existing target uses File.Replace.\n Move-Item -LiteralPath $stage -Destination $script:ClaudeSettingsPath -Force\n }\n Remove-SetupOlderBackups -Path $script:ClaudeSettingsPath -Keep $script:ClaudeSettingsBackup\n } catch {\n if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Force }\n Restore-SetupManagedFile -Existed $script:ClaudeSettingsExisted -Backup $script:ClaudeSettingsBackup -Path $script:ClaudeSettingsPath -OriginalLabel 'file' -CreatedLabel 'Claude settings'\n throw\n }\n}\n\nfunction Write-SetupClaudeVersion {\n param([string]$Exe)\n $timeoutSeconds = Get-SetupTimeoutSeconds 30\n $version = Invoke-SetupProcess -Exe $Exe -Arguments @('--version') -TimeoutSeconds $timeoutSeconds -TimeoutMessage '``claude --version`` timed out.'\n if ($version.ExitCode -ne 0) { Stop-Setup \"``claude --version`` failed.\" }\n Write-SetupInfo \"Claude Code version: $($version.Output.Trim())\"\n}\n\n# Install, then configure Claude Code as one transactional settings write. A\n# freshly installed CLI is never uninstalled when configuration fails.\nfunction Set-SetupAgent {\n Write-SetupAgentNotice 'Installing' 'Claude Code'\n # Ref: https://docs.claude.com/en/docs/claude-code/troubleshoot-install\n $candidates = @(\n (Join-Path $HOME '.local/bin/claude'),\n (Join-Path $HOME '.local/bin/claude.exe'),\n (Join-Path $HOME '.claude/local/claude')\n )\n if ($env:USERPROFILE) { $candidates += (Join-Path $env:USERPROFILE '.local\\bin\\claude.exe') }\n $exe = Get-SetupCliExe -Name claude -Label 'Claude Code' -Candidates $candidates\n if (-not $exe) {\n Install-SetupClaude\n $exe = Get-SetupCliExe -Name claude -Label 'Claude Code' -Candidates $candidates\n if (-not $exe) { Stop-Setup \"Claude Code CLI is unavailable and could not be installed.\" }\n } else {\n Write-SetupInfo 'Claude Code is already installed.'\n }\n Write-SetupClaudeVersion -Exe $exe\n\n Write-SetupAgentNotice 'Configuring' 'Claude Code'\n Write-SetupClaudeSettings\n Write-SetupInfo ('Written to `' + $script:ClaudeSettingsPath + '`.')\n Write-SetupAgentNotice 'Completed Agent Setup' 'Claude Code'\n}\n\n\n$global:LASTEXITCODE = Main 'Claude Code'\n"; +export const SETUP_POWERSHELL_COMMON_OUTPUT = '# Floway Agent Setup common installer fragment (PowerShell). TypeScript prepends\n# the language-native assignment prefix and appends one agent fragment.\n#\n# Each served script targets exactly one agent and rolls back that agent\'s\n# configuration as one transaction on failure.\n\n# --- output layer -----------------------------------------------------------\n#\n# Setup-owned output follows Homebrew\'s compact visual language: blue `==>`\n# notices introduce major phases, while warnings and errors color only their\n# labels. Phase details remain subordinate instead of competing for attention.\n# Native package managers inherit the terminal directly, preserving their ANSI\n# colors, carriage-return progress, buffering, and cursor behavior.\n#\n# stdout color rides the host: `Write-Host -ForegroundColor` colors an\n# interactive console yet writes no escape sequences when redirected/captured,\n# so it is the correct stdout mechanism on both Windows PowerShell 5.1 and\n# PowerShell 7. stderr goes through [Console]::Error, colored with ANSI only for\n# an interactive error stream with NO_COLOR unset — a redirected capture stays\n# escape-free. UTF-8 output keeps the status glyphs portable to 5.1.\nfunction Write-SetupHostLine {\n param([string]$Text, [System.ConsoleColor]$Color, [switch]$Plain)\n if ($Plain -or $script:SetupNoColor) { Write-Host $Text } else { Write-Host $Text -ForegroundColor $Color }\n}\n\nfunction Write-SetupNotice {\n param([string]$Text)\n if ($script:SetupNoColor) { Write-Host "==> $Text"; return }\n if ($script:SetupOutAnsi) {\n Write-Host "$($script:SetupEsc)[34m==>$($script:SetupEsc)[0m $($script:SetupEsc)[1m$Text$($script:SetupEsc)[0m"\n return\n }\n Write-Host \'==>\' -ForegroundColor Blue -NoNewline\n Write-Host " $Text" -ForegroundColor White\n}\n\n# Console.Error is used directly so diagnostics remain on stderr while only the\n# Homebrew-style label receives color.\nfunction Write-SetupDiagnostic {\n param([string]$Label, [string]$Text, [System.ConsoleColor]$Color, [string]$TestAnsiCode)\n if ($script:SetupErrColor) {\n $previous = [Console]::ForegroundColor\n try {\n [Console]::ForegroundColor = $Color\n [Console]::Error.Write("${Label}:")\n [Console]::ForegroundColor = $previous\n [Console]::Error.WriteLine(" $Text")\n } finally {\n [Console]::ForegroundColor = $previous\n }\n } elseif ($script:SetupForceColor -and (-not $script:SetupNoColor)) {\n [Console]::Error.WriteLine("$($script:SetupEsc)[${TestAnsiCode}m${Label}:$($script:SetupEsc)[0m $Text")\n } else {\n [Console]::Error.WriteLine("${Label}: $Text")\n }\n}\n\nfunction Write-SetupAgentNotice { param([string]$Label, [string]$AgentName) Write-SetupNotice "${Label}: $AgentName" }\nfunction Write-SetupMetadata { param([string]$Label, [string]$Value) Write-Host "${Label}: $Value" }\nfunction Write-SetupInfo { param([string]$Text) Write-SetupHostLine $Text -Plain }\nfunction Write-SetupWarn { param([string]$Text) Write-SetupDiagnostic \'Warning\' $Text Yellow \'93\' }\nfunction Write-SetupError { param([string]$Text) Write-SetupDiagnostic \'Error\' $Text Red \'91\' }\n\n# Report a primary error to stderr and unwind. The agent boundary recognizes the\n# \'setup-handled\' marker as already reported, so no line is ever duplicated.\nfunction Stop-Setup { param([string]$Message) Write-SetupError $Message; throw \'setup-handled\' }\n'; -export const SETUP_POWERSHELL_CODEX = "# Codex Agent Setup fragment.\n\n# Install the official Codex package. CODEX_NON_INTERACTIVE keeps the direct\n# installer from prompting. We track upstream's maintained scripts so release-\n# metadata fixes arrive without waiting for a Floway update. Reviewed sources:\n# https://github.com/openai/codex/blob/d3fc1950a920f98e7fa9f11056667cdf911c38df/README.md#L18-L37\n# https://github.com/openai/codex/blob/d3fc1950a920f98e7fa9f11056667cdf911c38df/scripts/install/install.sh\n# https://github.com/openai/codex/blob/d3fc1950a920f98e7fa9f11056667cdf911c38df/scripts/install/install.ps1\n# The AGENT_SETUP_TEST_INSTALL_CODEX_SCRIPT hook —\n# read from the ambient environment, never emitted by the gateway — substitutes\n# a fake installer under test.\nfunction Install-SetupCodex {\n $hadNonInteractive = Test-Path Env:CODEX_NON_INTERACTIVE\n $previousNonInteractive = $env:CODEX_NON_INTERACTIVE\n try {\n $env:CODEX_NON_INTERACTIVE = 'true'\n if ($env:AGENT_SETUP_TEST_INSTALL_CODEX_SCRIPT) {\n Write-SetupInfo 'Codex CLI not found; running the test installer'\n $timeoutSeconds = Get-SetupTimeoutSeconds 120\n $installer = Invoke-SetupProcess -Exe $env:AGENT_SETUP_TEST_INSTALL_CODEX_SCRIPT -Arguments @() -TimeoutSeconds $timeoutSeconds\n if ($installer.ExitCode -ne 0) { Stop-Setup \"the test codex installer hook failed.\" }\n return\n }\n if ($env:AGENT_SETUP_TEST_CODEX_URL) {\n Write-SetupInfo 'Codex CLI not found; running the test installer download'\n Invoke-SetupRemoteInstaller -Uri $env:AGENT_SETUP_TEST_CODEX_URL -BypassExecutionPolicy\n return\n }\n $platform = Get-SetupPlatform\n $npm = Get-Command npm -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n $brew = if ($platform -eq 'macos') { Get-Command brew -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 } else { $null }\n if ($brew) {\n Write-SetupInfo 'Codex CLI not found; installing with Homebrew'\n Install-SetupHomebrewCask -Cask 'codex'\n } elseif ($npm) {\n Write-SetupInfo 'Codex CLI not found; installing with npm'\n Install-SetupNpmPackage -Package '@openai/codex'\n } elseif ($platform -eq 'windows') {\n Write-SetupInfo 'Codex CLI not found; installing from GitHub'\n Invoke-SetupRemoteInstaller -Uri 'https://raw.githubusercontent.com/openai/codex/refs/heads/main/scripts/install/install.ps1'\n } else {\n Write-SetupInfo 'Codex CLI not found; installing from GitHub'\n Invoke-SetupRemoteInstaller -Uri 'https://raw.githubusercontent.com/openai/codex/refs/heads/main/scripts/install/install.sh' -Shell\n }\n } finally {\n if ($hadNonInteractive) { $env:CODEX_NON_INTERACTIVE = $previousNonInteractive }\n else { Remove-Item Env:CODEX_NON_INTERACTIVE -ErrorAction SilentlyContinue }\n }\n}\n\n# Back up the config and provider token before any mutation, recording the\n# absence of each so rollback can distinguish \"restore\" from \"remove\".\nfunction Backup-SetupCodexFiles {\n $script:CodexConfigExisted = $false\n $script:CodexTokenExisted = $false\n $script:CodexConfigBackup = $null\n $script:CodexTokenBackup = $null\n # DateTimeOffset.ToUnixTimeMilliseconds is unavailable on the .NET Framework\n # version bundled with the Windows PowerShell 5.1 baseline.\n $stamp = [long]([DateTimeOffset]::UtcNow - [DateTimeOffset]'1970-01-01T00:00:00Z').TotalMilliseconds\n if (Test-Path -LiteralPath $script:CodexConfigPath) {\n $script:CodexConfigExisted = $true\n $script:CodexConfigBackup = \"$($script:CodexConfigPath).floway-backup.$stamp.$PID\"\n Copy-Item -LiteralPath $script:CodexConfigPath -Destination $script:CodexConfigBackup\n }\n if (Test-Path -LiteralPath $script:CodexTokenPath) {\n $script:CodexTokenExisted = $true\n $script:CodexTokenBackup = \"$($script:CodexTokenPath).floway-backup.$stamp.$PID\"\n try {\n Copy-Item -LiteralPath $script:CodexTokenPath -Destination $script:CodexTokenBackup\n Protect-SetupFile $script:CodexTokenBackup\n } catch {\n if (Test-Path -LiteralPath $script:CodexTokenBackup) {\n Remove-Item -LiteralPath $script:CodexTokenBackup -Force\n }\n $script:CodexTokenBackup = $null\n throw\n }\n }\n}\n\nfunction Restore-SetupCodexFiles {\n Restore-SetupManagedFile -Existed $script:CodexConfigExisted -Backup $script:CodexConfigBackup -Path $script:CodexConfigPath -OriginalLabel 'file' -CreatedLabel 'Codex config'\n Restore-SetupManagedFile -Existed $script:CodexTokenExisted -Backup $script:CodexTokenBackup -Path $script:CodexTokenPath -OriginalLabel 'provider token' -CreatedLabel 'Codex provider token'\n}\n\nfunction Complete-SetupCodexFiles {\n Remove-SetupOlderBackups -Path $script:CodexConfigPath -Keep $script:CodexConfigBackup\n Remove-SetupOlderBackups -Path $script:CodexTokenPath -Keep $script:CodexTokenBackup\n if ($script:CodexTokenBackup -and (Test-Path -LiteralPath $script:CodexTokenBackup)) {\n Remove-Item -LiteralPath $script:CodexTokenBackup -Force -ErrorAction Stop\n }\n $script:CodexTokenBackup = $null\n}\n\n# Drive `codex app-server` over redirected stdin/stdout/stderr: initialize ->\n# initialized -> config/batchWrite. stderr is drained asynchronously so a chatty\n# server cannot fill the pipe buffer and deadlock. Each response read is bounded\n# by the remaining deadline; a timeout terminates the process tree. Unrelated\n# notifications are demultiplexed by id. Returns the batchWrite result object.\nfunction Invoke-SetupCodexAppServerBatchWrite {\n param([string]$Exe, $Edits, [int]$TimeoutSeconds)\n $startInfo = New-Object System.Diagnostics.ProcessStartInfo\n $startInfo.FileName = $Exe\n $startInfo.Arguments = 'app-server --listen stdio://'\n $startInfo.UseShellExecute = $false\n $startInfo.CreateNoWindow = $true\n $startInfo.RedirectStandardInput = $true\n $startInfo.RedirectStandardOutput = $true\n $startInfo.RedirectStandardError = $true\n $process = New-Object System.Diagnostics.Process\n $process.StartInfo = $startInfo\n if (-not $process.Start()) { Stop-Setup \"failed to start the Codex app-server.\" }\n $stderrTask = $process.StandardError.ReadToEndAsync()\n $watch = [System.Diagnostics.Stopwatch]::StartNew()\n $budgetMs = $TimeoutSeconds * 1000\n $result = $null\n try {\n $readMatching = {\n param([int]$WantId)\n while ($true) {\n $remaining = $budgetMs - $watch.ElapsedMilliseconds\n if ($remaining -le 0) { Stop-Setup \"the Codex app-server timed out before confirming the configuration.\" }\n $task = $process.StandardOutput.ReadLineAsync()\n if (-not $task.Wait([int]$remaining)) { Stop-Setup \"the Codex app-server timed out before confirming the configuration.\" }\n $line = $task.GetAwaiter().GetResult()\n if ($null -eq $line) { Stop-Setup \"the Codex app-server exited before confirming the configuration.\" }\n if ([string]::IsNullOrWhiteSpace($line)) { continue }\n try { $msg = $line | ConvertFrom-Json } catch { Stop-Setup \"the Codex app-server returned a malformed response.\" }\n if ($msg.id -ne $WantId) { continue }\n if ($null -ne $msg.error) { Stop-Setup \"the Codex app-server reported an error writing the configuration.\" }\n return $msg.result\n }\n }\n $initReq = @{ jsonrpc = '2.0'; id = 1; method = 'initialize'; params = @{ clientInfo = @{ name = 'floway-setup'; title = $null; version = '1' }; capabilities = $null } } | ConvertTo-Json -Depth 10 -Compress\n $process.StandardInput.WriteLine($initReq)\n [void](& $readMatching 1)\n $process.StandardInput.WriteLine('{\"jsonrpc\":\"2.0\",\"method\":\"initialized\"}')\n $batchReq = @{ jsonrpc = '2.0'; id = 2; method = 'config/batchWrite'; params = @{ edits = $Edits } } | ConvertTo-Json -Depth 10 -Compress\n $process.StandardInput.WriteLine($batchReq)\n $result = (& $readMatching 2)\n } finally {\n try { $process.StandardInput.Close() } catch { }\n if (-not $process.WaitForExit(1000)) {\n Stop-SetupProcessTree $process\n $process.WaitForExit()\n }\n $null = $stderrTask.GetAwaiter().GetResult()\n }\n return $result\n}\n\n# Build the base-config edit batch and write it through the app-server. Model\n# and effort are opaque, forwarded verbatim, and cleared with JSON null ($null)\n# when unset. A batch status of `ok` or `okOverridden` confirms the intended\n# base config; `okOverridden` is reported with its non-secret layer metadata.\nfunction Write-SetupCodexConfig {\n param([string]$Exe)\n $codexBase = ($SetupEndpoint.TrimEnd('/')) + '/azure-api.codex'\n $runningOnWindows = Test-SetupIsWindows\n $auth = if ($runningOnWindows) {\n [ordered]@{\n command = 'powershell'\n args = @('-NoProfile', '-Command', '$h = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $HOME ''.codex'' }; [IO.File]::ReadAllText((Join-Path $h ''floway-token''))')\n }\n } else {\n [ordered]@{\n command = 'sh'\n args = @('-c', 'cat \"${CODEX_HOME:-$HOME/.codex}/floway-token\"')\n }\n }\n # Command auth opts a provider into online model refresh. The actor marker\n # enables Codex's client-owned search and image extensions for this provider.\n # https://github.com/openai/codex/blob/1bbdb32789e1f79932df44941236ea3658f6e965/codex-rs/models-manager/src/manager.rs#L413-L415\n # https://github.com/openai/codex/blob/1bbdb32789e1f79932df44941236ea3658f6e965/codex-rs/model-provider-info/src/lib.rs#L396-L408\n # standalone_web_search is under development, so its explicit opt-in is\n # paired with the top-level warning suppression instead of warning every run.\n # https://github.com/openai/codex/blob/24e9b849fad8f506971dfa0313dbdea8abd90112/codex-rs/features/src/lib.rs#L901-L905\n # https://github.com/openai/codex/blob/24e9b849fad8f506971dfa0313dbdea8abd90112/codex-rs/features/src/lib.rs#L1393-L1439\n $edits = @(\n @{ keyPath = 'model_provider'; mergeStrategy = 'replace'; value = 'floway' },\n @{ keyPath = 'suppress_unstable_features_warning'; mergeStrategy = 'replace'; value = $true },\n @{ keyPath = 'model_providers.floway.name'; mergeStrategy = 'replace'; value = 'Floway' },\n @{ keyPath = 'model_providers.floway.base_url'; mergeStrategy = 'replace'; value = $codexBase },\n @{ keyPath = 'model_providers.floway.auth'; mergeStrategy = 'replace'; value = $auth },\n @{ keyPath = 'model_providers.floway.wire_api'; mergeStrategy = 'replace'; value = 'responses' },\n @{ keyPath = 'model_providers.floway.supports_websockets'; mergeStrategy = 'replace'; value = $true },\n @{ keyPath = 'model_providers.floway.http_headers'; mergeStrategy = 'replace'; value = @{ 'x-openai-actor-authorization' = '1' } },\n @{ keyPath = 'features.apps'; mergeStrategy = 'replace'; value = $false },\n @{ keyPath = 'features.standalone_web_search'; mergeStrategy = 'replace'; value = $true },\n @{ keyPath = 'model'; mergeStrategy = 'replace'; value = $SetupCodexModel },\n @{ keyPath = 'model_reasoning_effort'; mergeStrategy = 'replace'; value = $SetupCodexReasoningEffort }\n )\n $timeoutSeconds = Get-SetupTimeoutSeconds 60\n $result = Invoke-SetupCodexAppServerBatchWrite -Exe $Exe -Edits $edits -TimeoutSeconds $timeoutSeconds\n $status = [string]$result.status\n if ($status -eq 'okOverridden') {\n $message = if ($result.overriddenMetadata -and $result.overriddenMetadata.message) { [string]$result.overriddenMetadata.message } else { 'an override layer applies' }\n $layer = 'unknown'\n if ($result.overriddenMetadata -and $result.overriddenMetadata.overridingLayer -and $result.overriddenMetadata.overridingLayer.name) {\n $layer = [string]$result.overriddenMetadata.overridingLayer.name.type\n }\n Write-SetupWarn \"Codex configuration is overridden by a higher-precedence layer ($message; layer: $layer).\"\n } elseif ($status -ne 'ok') {\n Stop-Setup \"the Codex app-server did not confirm the configuration (status: $status).\"\n }\n $filePath = [string]$result.filePath\n if ([string]::IsNullOrWhiteSpace($filePath)) {\n Stop-Setup \"the Codex app-server did not report the written config path.\"\n }\n return $filePath\n}\n\n# Store the selected API key as a provider-scoped command-auth token. The private\n# stage is validated byte-for-byte, then atomically replaced. auth.json is an\n# account-owned Codex file and is never read or changed here.\nfunction Write-SetupCodexToken {\n $stage = \"$($script:CodexTokenPath).floway-stage.$PID\"\n try {\n [System.IO.File]::Create($stage).Dispose()\n Protect-SetupFile $stage\n [System.IO.File]::WriteAllText($stage, $SetupApiKey, (New-Object System.Text.UTF8Encoding($false)))\n if ([System.IO.File]::ReadAllText($stage) -cne $SetupApiKey) {\n Stop-Setup \"staged Codex provider token failed validation.\"\n }\n $runningOnWindows = Test-SetupIsWindows\n if ($script:CodexTokenExisted -and $runningOnWindows) {\n # File.Replace preserves the destination ACL, so tighten it first.\n Protect-SetupFile $script:CodexTokenPath\n # PowerShell binds ordinary $null to String.Empty for a .NET string\n # parameter; NullString passes an actual null backup path.\n # https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.language.nullstring\n [System.IO.File]::Replace($stage, $script:CodexTokenPath, [System.Management.Automation.Language.NullString]::Value)\n } else {\n Move-Item -LiteralPath $stage -Destination $script:CodexTokenPath -Force\n }\n } catch {\n if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Force }\n throw\n }\n}\n\nfunction Write-SetupCodexVersion {\n param([string]$Exe)\n $timeoutSeconds = Get-SetupTimeoutSeconds 30\n $version = Invoke-SetupProcess -Exe $Exe -Arguments @('--version') -TimeoutSeconds $timeoutSeconds -TimeoutMessage '``codex --version`` timed out.'\n if ($version.ExitCode -ne 0) { Stop-Setup \"``codex --version`` failed.\" }\n Write-SetupInfo \"Codex version: $($version.Output.Trim())\"\n}\n\n# Install, then configure Codex as one transactional config/token write. A\n# freshly installed CLI is never uninstalled when configuration fails.\nfunction Set-SetupAgent {\n Write-SetupAgentNotice 'Installing' 'Codex'\n # Upstream installs into these user-local candidates by default:\n # https://github.com/openai/codex/blob/d3fc1950a920f98e7fa9f11056667cdf911c38df/scripts/install/install.sh\n $candidates = @(\n (Join-Path $HOME '.local/bin/codex'),\n (Join-Path $HOME '.local/bin/codex.exe')\n )\n if ($env:USERPROFILE) { $candidates += (Join-Path $env:USERPROFILE '.local\\bin\\codex.exe') }\n $exe = Get-SetupCliExe -Name codex -Label Codex -Candidates $candidates\n if (-not $exe) {\n Install-SetupCodex\n $exe = Get-SetupCliExe -Name codex -Label Codex -Candidates $candidates\n if (-not $exe) { Stop-Setup \"Codex CLI is unavailable and could not be installed.\" }\n } else {\n Write-SetupInfo 'Codex is already installed.'\n }\n Write-SetupCodexVersion -Exe $exe\n\n Write-SetupAgentNotice 'Configuring' 'Codex'\n $script:CodexHomeDir = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $HOME '.codex' }\n $script:CodexConfigPath = Join-Path $script:CodexHomeDir 'config.toml'\n $script:CodexTokenPath = Join-Path $script:CodexHomeDir 'floway-token'\n if (-not (Test-Path -LiteralPath $script:CodexHomeDir)) {\n New-Item -ItemType Directory -Path $script:CodexHomeDir -Force | Out-Null\n }\n Backup-SetupCodexFiles\n try {\n Write-SetupCodexToken\n } catch {\n Write-SetupWarn \"Codex provider-token staging failed; rolling back configuration and token.\"\n Restore-SetupCodexFiles\n throw\n }\n try {\n $writtenConfigPath = Write-SetupCodexConfig -Exe $exe\n } catch {\n Write-SetupWarn \"Codex configuration failed; rolling back configuration and token.\"\n Restore-SetupCodexFiles\n throw\n }\n try {\n Complete-SetupCodexFiles\n } catch {\n Write-SetupWarn \"Codex backup cleanup failed; rolling back configuration and token.\"\n Restore-SetupCodexFiles\n throw\n }\n Write-SetupInfo ('Written to `' + $writtenConfigPath + '`.')\n Write-SetupInfo ('Written to `' + $script:CodexTokenPath + '`.')\n Write-SetupAgentNotice 'Completed Agent Setup' 'Codex'\n}\n\n\n$global:LASTEXITCODE = Main 'Codex'\n"; +export const SETUP_POWERSHELL_COMMON_PLATFORM = '# Windows PowerShell 5.1 only runs on Windows and has no $IsWindows automatic\n# variable; PowerShell 6+ exposes it on every platform.\nfunction Test-SetupIsWindows {\n ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows\n}\n\n# The AGENT_SETUP_TEST_TIMEOUT_SECONDS hook, read from the ambient environment\n# and never emitted by the gateway, lets the harness shorten every wall-clock\n# limit; otherwise the caller-supplied default applies.\nfunction Get-SetupTimeoutSeconds {\n param([int]$Default)\n if ($env:AGENT_SETUP_TEST_TIMEOUT_SECONDS) { [int]$env:AGENT_SETUP_TEST_TIMEOUT_SECONDS } else { $Default }\n}\n\nfunction Get-SetupPlatform {\n if (Test-SetupIsWindows) { return \'windows\' }\n if ($IsMacOS) { return \'macos\' }\n return \'linux\'\n}\n'; + +export const SETUP_POWERSHELL_COMMON_JSON_DOCUMENT = 'function Set-SetupProp {\n param($Target, [string]$Name, $Value)\n if ($Target.PSObject.Properties.Name -contains $Name) { $Target.$Name = $Value }\n else { $Target | Add-Member -NotePropertyName $Name -NotePropertyValue $Value }\n}\n\nfunction Remove-SetupProp {\n param($Target, [string]$Name)\n if ($Target.PSObject.Properties.Name -contains $Name) { $Target.PSObject.Properties.Remove($Name) }\n}\n\n# A null optional value means "remove this managed key"; any other value is set.\nfunction Set-SetupOptionalProp {\n param($Target, [string]$Name, $Value)\n if ($null -eq $Value) { Remove-SetupProp $Target $Name } else { Set-SetupProp $Target $Name $Value }\n}\n'; + +export const SETUP_POWERSHELL_COMMON_MAIN = '# Redact every occurrence of the API key from text before it is surfaced.\nfunction Protect-SetupSecret {\n param([string]$Text)\n return ($Text -replace [regex]::Escape($SetupApiKey), \'***\')\n}\n\n# --- run --------------------------------------------------------------------\n\nfunction Main {\n param([string]$AgentName)\n $ErrorActionPreference = \'Stop\'\n # Keep native command failures from auto-throwing on PowerShell 7.3+ so the\n # explicit exit-code checks remain authoritative across versions.\n $PSNativeCommandUseErrorActionPreference = $false\n\n Remove-Item Env:SETUP_API_KEY -ErrorAction SilentlyContinue\n\n try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) } catch { }\n $script:SetupNoColor = [bool]$env:NO_COLOR\n $script:SetupForceColor = [bool]$env:AGENT_SETUP_TEST_FORCE_COLOR\n $script:SetupErrColor = (-not [Console]::IsErrorRedirected) -and (-not $script:SetupNoColor)\n $script:SetupEsc = [char]27\n $supportsVt = try { [bool]$Host.UI.SupportsVirtualTerminal } catch { $false }\n $script:SetupOutAnsi = $supportsVt -and (-not [Console]::IsOutputRedirected) -and (-not $script:SetupNoColor)\n\n Write-SetupAgentNotice \'Agent Setup\' $AgentName\n if ([string]::IsNullOrWhiteSpace($SetupEndpoint)) {\n Write-SetupError "`$SetupEndpoint must be set to this gateway origin (e.g. https://gateway.example)."\n return 1\n }\n if ($SetupEndpoint -notmatch \'^https?://.+\') {\n Write-SetupError "`$SetupEndpoint must be an http(s) origin, got $SetupEndpoint"\n return 1\n }\n Write-SetupMetadata \'Endpoint\' $SetupEndpoint\n Write-SetupMetadata \'API Key\' $SetupApiKeyName\n\n # Detection sites rethrow reported failures as the `setup-handled` sentinel;\n # only unexpected exceptions are reported again here after redaction.\n try {\n Set-SetupAgent\n } catch {\n if ($_.Exception.Message -ne \'setup-handled\') { Write-SetupError (Protect-SetupSecret ([string]$_.Exception.Message)) }\n return 1\n }\n return 0\n}\n'; + +export const SETUP_POWERSHELL_COMMON_MANAGED_FILE = '# Restrict a file to the current user: chmod 0600 on Unix, an inheritance-free\n# owner-only ACL on Windows.\nfunction Protect-SetupFile {\n param([string]$Path)\n if (-not (Test-SetupIsWindows)) {\n & chmod 600 $Path\n if ($LASTEXITCODE -ne 0) { Stop-Setup "could not restrict $Path to owner-only access." }\n return\n }\n # Set-Acl routes through the PowerShell filesystem provider and may persist\n # the untouched SACL, demanding SeSecurityPrivilege from a normal user. The\n # direct .NET APIs write only this descriptor\'s modified DACL.\n # https://github.com/PowerShell/PowerShell/blob/0c226762e2580cd7853c058dd03fc32638a73971/src/System.Management.Automation/namespaces/FileSystemSecurity.cs#L130-L200\n # https://github.com/dotnet/runtime/blob/f94898a9b55df07348434e86915c7405962427b6/src/libraries/System.IO.FileSystem.AccessControl/src/System/Security/AccessControl/FileSystemSecurity.cs#L103-L125\n $acl = New-Object System.Security.AccessControl.FileSecurity\n $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User\n $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, \'FullControl\', \'Allow\')\n $acl.SetAccessRuleProtection($true, $false)\n $acl.AddAccessRule($rule)\n if ($PSVersionTable.PSVersion.Major -lt 6) {\n [System.IO.File]::SetAccessControl($Path, $acl)\n } else {\n [System.IO.FileSystemAclExtensions]::SetAccessControl([System.IO.FileInfo]::new($Path), $acl)\n }\n}\n\n# Rollback retains a backup when restoration fails so manual recovery remains\n# possible, warning with the preserved path and the action to take — matching\n# the Bash installer. The AGENT_SETUP_TEST_FAIL_RESTORE hook, read from the\n# ambient environment and never emitted by the gateway, forces the restore\n# rename to fail so the harness can assert that guidance.\nfunction Restore-SetupManagedFile {\n param([bool]$Existed, [string]$Backup, [string]$Path, [string]$OriginalLabel, [string]$CreatedLabel)\n if ($Existed) {\n if ($Backup -and (Test-Path -LiteralPath $Backup)) {\n try {\n if ($env:AGENT_SETUP_TEST_FAIL_RESTORE) { throw \'test-injected restore failure\' }\n # Secret-bearing backups were already owner-only before any mutation.\n # Moving one back preserves that protection without a second operation\n # that could fail after the backup path has been consumed.\n Move-Item -LiteralPath $Backup -Destination $Path -Force\n } catch {\n Write-SetupWarn "could not restore $Path from its backup; your original $OriginalLabel is preserved at $Backup — restore it by hand."\n }\n }\n } elseif (Test-Path -LiteralPath $Path) {\n try {\n Remove-Item -LiteralPath $Path -Force\n } catch {\n Write-SetupWarn "could not remove the $CreatedLabel this run created at $Path — remove it by hand."\n }\n }\n}\n\nfunction Remove-SetupOlderBackups {\n param([string]$Path, [string]$Keep)\n $directory = Split-Path -Parent $Path\n $prefix = [System.IO.Path]::GetFileName($Path) + \'.floway-backup.\'\n Get-ChildItem -LiteralPath $directory -File -ErrorAction Stop |\n Where-Object { $_.Name.StartsWith($prefix, [System.StringComparison]::Ordinal) -and $_.FullName -ne $Keep } |\n Remove-Item -Force -ErrorAction Stop\n}\n'; + +export const SETUP_POWERSHELL_COMMON_PROCESS = '# Terminate a process and its descendants. PowerShell 7\'s runtime exposes the\n# tree-aware Kill(bool) overload; Windows PowerShell 5.1 uses taskkill /T.\nfunction Stop-SetupProcessTree {\n param([System.Diagnostics.Process]$Process)\n $runningOnWindows = Test-SetupIsWindows\n if ($runningOnWindows) {\n & taskkill.exe /PID $Process.Id /T /F *> $null\n if ($LASTEXITCODE -ne 0 -and (-not $Process.HasExited)) {\n Stop-Setup "taskkill could not terminate process tree $($Process.Id)."\n }\n return\n }\n try {\n $Process.Kill($true)\n } catch {\n if (-not $Process.HasExited) { Stop-Setup "could not terminate process tree $($Process.Id)." }\n }\n}\n\n# Run a fixed package-manager command with inherited stdout/stderr. The child\n# remains attached to the real terminal, so progress updates and ANSI control\n# sequences render in real time without a lossy line-prefix filter.\nfunction Invoke-SetupLiveProcess {\n param([string]$Exe, [string[]]$Arguments, [int]$TimeoutSeconds)\n $startInfo = New-Object System.Diagnostics.ProcessStartInfo\n $startInfo.FileName = $Exe\n $startInfo.Arguments = ($Arguments | ForEach-Object { \'"\' + $_.Replace(\'"\', \'\\"\') + \'"\' }) -join \' \'\n $startInfo.UseShellExecute = $false\n $startInfo.CreateNoWindow = $false\n $process = New-Object System.Diagnostics.Process\n $process.StartInfo = $startInfo\n if (-not $process.Start()) { Stop-Setup "failed to start $Exe." }\n if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {\n Stop-SetupProcessTree $process\n $process.WaitForExit()\n Stop-Setup "$Exe timed out after $TimeoutSeconds seconds."\n }\n if ($process.ExitCode -ne 0) { Stop-Setup "$Exe exited with status $($process.ExitCode)." }\n}\n\n# Run a child process with captured output under a deadline, terminating its\n# whole process tree and throwing on timeout.\nfunction Invoke-SetupProcess {\n param([string]$Exe, [string[]]$Arguments, [int]$TimeoutSeconds, [string]$TimeoutMessage)\n $startInfo = New-Object System.Diagnostics.ProcessStartInfo\n $startInfo.FileName = $Exe\n $startInfo.UseShellExecute = $false\n $startInfo.CreateNoWindow = $true\n $startInfo.RedirectStandardOutput = $true\n $startInfo.RedirectStandardError = $true\n # ArgumentList is unavailable in Windows PowerShell 5.1. These arguments are\n # fixed internal tokens, so quoting them with ProcessStartInfo.Arguments is\n # safe and keeps external input out of the child command line.\n $startInfo.Arguments = ($Arguments | ForEach-Object { \'"\' + $_.Replace(\'"\', \'\\"\') + \'"\' }) -join \' \'\n $process = New-Object System.Diagnostics.Process\n $process.StartInfo = $startInfo\n if (-not $process.Start()) { Stop-Setup "failed to start $Exe." }\n $stdoutTask = $process.StandardOutput.ReadToEndAsync()\n $stderrTask = $process.StandardError.ReadToEndAsync()\n if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {\n Stop-SetupProcessTree $process\n $process.WaitForExit()\n Stop-Setup $(if ($TimeoutMessage) { $TimeoutMessage } else { "$Exe timed out after $TimeoutSeconds seconds." })\n }\n $stdout = $stdoutTask.GetAwaiter().GetResult()\n $stderr = $stderrTask.GetAwaiter().GetResult()\n [PSCustomObject]@{ ExitCode = $process.ExitCode; Output = ($stdout + $stderr) }\n}\n'; + +export const SETUP_POWERSHELL_COMMON_CLI = 'function Install-SetupHomebrewCask {\n param([string]$Cask)\n $brew = Get-Command brew -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if (-not $brew) { Stop-Setup \'Homebrew is required to install agent CLIs on macOS.\' }\n $timeoutSeconds = Get-SetupTimeoutSeconds 600\n Invoke-SetupLiveProcess -Exe $brew.Source -Arguments @(\'install\', \'--cask\', $Cask) -TimeoutSeconds $timeoutSeconds\n}\n\n# npm on Windows is commonly a .cmd launcher, which ProcessStartInfo cannot\n# execute directly with UseShellExecute disabled. A fresh copy of the current\n# PowerShell host resolves that launcher while preserving inherited terminal\n# output and the same process-tree timeout.\nfunction Install-SetupNpmPackage {\n param([string]$Package)\n $npm = Get-Command npm -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if (-not $npm) { Stop-Setup \'npm was selected for installation but is no longer available.\' }\n $hostCommand = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n $hostExe = if ($hostCommand) { $hostCommand.Source } else { [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName }\n $npmLiteral = "\'" + $npm.Source.Replace("\'", "\'\'") + "\'"\n $packageLiteral = "\'" + $Package.Replace("\'", "\'\'") + "\'"\n $command = "& $npmLiteral install --global $packageLiteral; exit `$LASTEXITCODE"\n $timeoutSeconds = Get-SetupTimeoutSeconds 600\n Invoke-SetupLiveProcess -Exe $hostExe -Arguments @(\'-NoProfile\', \'-NonInteractive\', \'-Command\', $command) -TimeoutSeconds $timeoutSeconds\n}\n\n# Execute a downloaded installer in a fresh interpreter. The script travels\n# through stdin, while the API key exists only as a variable in this parent\n# process and its identically named environment variables were removed. The\n# official installer therefore cannot read the credential.\nfunction Invoke-SetupInterpreterBody {\n param([string]$Body, [int]$TimeoutSeconds, [string]$Exe, [string]$Arguments)\n $startInfo = New-Object System.Diagnostics.ProcessStartInfo\n $startInfo.FileName = $Exe\n $startInfo.Arguments = $Arguments\n $startInfo.UseShellExecute = $false\n $startInfo.CreateNoWindow = $false\n $startInfo.RedirectStandardInput = $true\n $process = New-Object System.Diagnostics.Process\n $process.StartInfo = $startInfo\n if (-not $process.Start()) { Stop-Setup "failed to start the installer interpreter." }\n $process.StandardInput.Write($Body)\n $process.StandardInput.WriteLine()\n $process.StandardInput.Close()\n if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {\n Stop-SetupProcessTree $process\n $process.WaitForExit()\n Stop-Setup "the installer timed out after $TimeoutSeconds seconds."\n }\n if ($process.ExitCode -ne 0) { Stop-Setup "the installer exited with status $($process.ExitCode)." }\n}\n\nfunction Invoke-SetupPowerShellBody {\n param([string]$Body, [int]$TimeoutSeconds, [switch]$BypassExecutionPolicy)\n $pwsh = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n $exe = if ($pwsh) { $pwsh.Source } else { [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName }\n $executionPolicy = if ($BypassExecutionPolicy) { \'-ExecutionPolicy Bypass \' } else { \'\' }\n Invoke-SetupInterpreterBody -Body $Body -TimeoutSeconds $TimeoutSeconds -Exe $exe -Arguments "-NoProfile -NonInteractive ${executionPolicy}-Command -"\n}\n\nfunction Invoke-SetupShellBody {\n param([string]$Body, [int]$TimeoutSeconds)\n $bash = Get-Command bash -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if (-not $bash) { Stop-Setup \'bash is required to run the official installer on macOS and Linux.\' }\n Invoke-SetupInterpreterBody -Body $Body -TimeoutSeconds $TimeoutSeconds -Exe $bash.Source -Arguments \'-s\'\n}\n\n# Download an installer, refuse anything that is not a script (region blocks and\n# captive portals serve HTML in place of the installer), then run it.\nfunction Invoke-SetupRemoteInstaller {\n param([string]$Uri, [switch]$BypassExecutionPolicy, [switch]$Shell)\n $response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -TimeoutSec 60\n $body = [string]$response.Content\n $contentType = [string]$response.Headers[\'Content-Type\']\n $looksLikeHtml = $contentType -match \'(?i)^text/html(?:;|$)\' -or $body -match \'(?is)^\\s*(?:))\'\n if ([string]::IsNullOrWhiteSpace($body) -or $looksLikeHtml) {\n Stop-Setup "the installer download was HTML or empty, not an executable script (a login or region-block page?)."\n }\n $timeoutSeconds = Get-SetupTimeoutSeconds 120\n if ($Shell) { Invoke-SetupShellBody -Body $body -TimeoutSeconds $timeoutSeconds }\n else { Invoke-SetupPowerShellBody -Body $body -TimeoutSeconds $timeoutSeconds -BypassExecutionPolicy:$BypassExecutionPolicy }\n}\n\nfunction Get-SetupCliExe {\n param([string]$Name, [string]$Label, [string[]]$Candidates)\n $found = New-Object System.Collections.Generic.List[string]\n $command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if ($command) { $found.Add($command.Source) }\n foreach ($candidate in $Candidates) {\n if ((Test-Path -LiteralPath $candidate) -and (-not $found.Contains($candidate))) { $found.Add($candidate) }\n }\n if ($found.Count -eq 0) { return $null }\n if ($found.Count -gt 1) { Write-SetupWarn "multiple $Label installations detected; using $($found[0])" }\n return $found[0]\n}\n'; + +export const SETUP_POWERSHELL_CLAUDE = '# Claude Code Agent Setup fragment.\n\n# Install the official Claude Code package. The\n# AGENT_SETUP_TEST_INSTALL_CLAUDE_SCRIPT hook — read from the ambient\n# environment, never emitted by the gateway — substitutes a fake installer\n# under test.\nfunction Install-SetupClaude {\n if ($env:AGENT_SETUP_TEST_INSTALL_CLAUDE_SCRIPT) {\n Write-SetupInfo \'Claude Code CLI not found; running the test installer\'\n $timeoutSeconds = Get-SetupTimeoutSeconds 120\n $installer = Invoke-SetupProcess -Exe $env:AGENT_SETUP_TEST_INSTALL_CLAUDE_SCRIPT -Arguments @() -TimeoutSeconds $timeoutSeconds\n if ($installer.ExitCode -ne 0) { Stop-Setup "the test installer hook failed." }\n return\n }\n if ($env:AGENT_SETUP_TEST_CLAUDE_URL) {\n Write-SetupInfo \'Claude Code CLI not found; running the test installer download\'\n Invoke-SetupRemoteInstaller -Uri $env:AGENT_SETUP_TEST_CLAUDE_URL\n return\n }\n $platform = Get-SetupPlatform\n $npm = Get-Command npm -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n switch ($platform) {\n \'macos\' {\n $brew = Get-Command brew -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if ($brew) {\n Write-SetupInfo \'Claude Code CLI not found; installing with Homebrew\'\n Install-SetupHomebrewCask -Cask \'claude-code\'\n } elseif ($npm) {\n Write-SetupInfo \'Claude Code CLI not found; installing with npm\'\n Install-SetupNpmPackage -Package \'@anthropic-ai/claude-code\'\n } else {\n # Ref: https://code.claude.com/docs/en/setup\n Write-SetupInfo \'Claude Code CLI not found; installing from downloads.claude.ai\'\n Invoke-SetupRemoteInstaller -Uri \'https://downloads.claude.ai/claude-code-releases/bootstrap.sh\' -Shell\n }\n }\n \'windows\' {\n if ($npm) {\n Write-SetupInfo \'Claude Code CLI not found; installing with npm\'\n Install-SetupNpmPackage -Package \'@anthropic-ai/claude-code\'\n } else {\n # Ref: https://code.claude.com/docs/en/setup\n Write-SetupInfo \'Claude Code CLI not found; installing from downloads.claude.ai\'\n Invoke-SetupRemoteInstaller -Uri \'https://downloads.claude.ai/claude-code-releases/bootstrap.ps1\'\n }\n }\n \'linux\' {\n if ($npm) {\n Write-SetupInfo \'Claude Code CLI not found; installing with npm\'\n Install-SetupNpmPackage -Package \'@anthropic-ai/claude-code\'\n } else {\n # Ref: https://code.claude.com/docs/en/setup\n Write-SetupInfo \'Claude Code CLI not found; installing from downloads.claude.ai\'\n Invoke-SetupRemoteInstaller -Uri \'https://downloads.claude.ai/claude-code-releases/bootstrap.sh\' -Shell\n }\n }\n }\n}\n\n# Surgically merge the managed keys into the Claude settings file: validate the\n# existing document, back it up, construct and validate the replacement in the\n# same directory, then atomically rename it into place with owner-only access.\nfunction Write-SetupClaudeSettings {\n $configDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME \'.claude\' }\n $script:ClaudeSettingsPath = Join-Path $configDir \'settings.json\'\n $script:ClaudeSettingsBackup = $null\n $script:ClaudeSettingsExisted = $false\n if (-not (Test-Path -LiteralPath $configDir)) {\n New-Item -ItemType Directory -Path $configDir -Force | Out-Null\n }\n\n if (Test-Path -LiteralPath $script:ClaudeSettingsPath) {\n $script:ClaudeSettingsExisted = $true\n $raw = Get-Content -Raw -LiteralPath $script:ClaudeSettingsPath\n try { $document = $raw | ConvertFrom-Json } catch { Stop-Setup "$($script:ClaudeSettingsPath) is not valid JSON; leaving it untouched." }\n if ($document -isnot [System.Management.Automation.PSCustomObject]) { Stop-Setup "existing Claude settings root is not a JSON object." }\n if (($document.PSObject.Properties.Name -contains \'env\') -and ($document.env -isnot [System.Management.Automation.PSCustomObject])) {\n Stop-Setup "existing Claude settings env is not a JSON object."\n }\n if (($document.PSObject.Properties.Name -contains \'attribution\') -and ($document.attribution -isnot [System.Management.Automation.PSCustomObject])) {\n Stop-Setup "existing Claude settings attribution is not a JSON object."\n }\n # DateTimeOffset.ToUnixTimeMilliseconds is unavailable on the .NET\n # Framework version bundled with the Windows PowerShell 5.1 baseline.\n $stamp = [long]([DateTimeOffset]::UtcNow - [DateTimeOffset]\'1970-01-01T00:00:00Z\').TotalMilliseconds\n $script:ClaudeSettingsBackup = "$($script:ClaudeSettingsPath).floway-backup.$stamp.$PID"\n try {\n Copy-Item -LiteralPath $script:ClaudeSettingsPath -Destination $script:ClaudeSettingsBackup\n Protect-SetupFile $script:ClaudeSettingsBackup\n } catch {\n if (Test-Path -LiteralPath $script:ClaudeSettingsBackup) {\n Remove-Item -LiteralPath $script:ClaudeSettingsBackup -Force\n }\n $script:ClaudeSettingsBackup = $null\n throw\n }\n } else {\n $document = [PSCustomObject]@{}\n }\n\n if ($document.PSObject.Properties.Name -notcontains \'env\') {\n $document | Add-Member -NotePropertyName env -NotePropertyValue ([PSCustomObject]@{})\n }\n # Refs: https://docs.claude.com/en/docs/claude-code/env-vars\n # https://docs.claude.com/en/docs/claude-code/model-config#environment-variables\n # https://docs.claude.com/en/docs/claude-code/settings\n # https://code.claude.com/docs/en/settings#attribution-settings\n Set-SetupProp $document.env \'ANTHROPIC_BASE_URL\' $SetupEndpoint\n Set-SetupProp $document.env \'ANTHROPIC_AUTH_TOKEN\' $SetupApiKey\n Set-SetupOptionalProp $document.env \'ANTHROPIC_MODEL\' $SetupClaudeModel\n Set-SetupOptionalProp $document.env \'ANTHROPIC_DEFAULT_OPUS_MODEL\' $SetupClaudeDefaultOpusModel\n Set-SetupOptionalProp $document.env \'ANTHROPIC_DEFAULT_SONNET_MODEL\' $SetupClaudeDefaultSonnetModel\n Set-SetupOptionalProp $document.env \'ANTHROPIC_DEFAULT_HAIKU_MODEL\' $SetupClaudeDefaultHaikuModel\n if ($SetupClaudeModelDiscovery) { Set-SetupProp $document.env \'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY\' \'1\' }\n else { Remove-SetupProp $document.env \'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY\' }\n Set-SetupOptionalProp $document \'effortLevel\' $SetupClaudeEffortLevel\n Set-SetupOptionalProp $document \'cleanupPeriodDays\' $SetupClaudeCleanupPeriodDays\n if ($SetupClaudeOptOutAiAttribution) {\n if ($document.PSObject.Properties.Name -notcontains \'attribution\') {\n $document | Add-Member -NotePropertyName attribution -NotePropertyValue ([PSCustomObject]@{})\n }\n Set-SetupProp $document.attribution \'commit\' \'\'\n Set-SetupProp $document.attribution \'pr\' \'\'\n Set-SetupProp $document.attribution \'sessionUrl\' $false\n } elseif ($document.PSObject.Properties.Name -contains \'attribution\') {\n Remove-SetupProp $document.attribution \'commit\'\n Remove-SetupProp $document.attribution \'pr\'\n Remove-SetupProp $document.attribution \'sessionUrl\'\n if ($document.attribution.PSObject.Properties.Count -eq 0) { Remove-SetupProp $document \'attribution\' }\n }\n\n $stage = "$($script:ClaudeSettingsPath).floway-stage.$PID"\n try {\n # The stage exists and is owner-only before any secret JSON is written.\n [System.IO.File]::Create($stage).Dispose()\n Protect-SetupFile $stage\n $json = $document | ConvertTo-Json -Depth 100\n # Write UTF-8 without a BOM on every PowerShell version so downstream JSON\n # parsers accept the file.\n [System.IO.File]::WriteAllText($stage, $json, (New-Object System.Text.UTF8Encoding($false)))\n $check = Get-Content -Raw -LiteralPath $stage | ConvertFrom-Json\n if (($check.env.ANTHROPIC_BASE_URL -cne $SetupEndpoint) -or ($check.env.ANTHROPIC_AUTH_TOKEN -cne $SetupApiKey)) {\n Stop-Setup "staged Claude settings failed validation."\n }\n $runningOnWindows = Test-SetupIsWindows\n if ($script:ClaudeSettingsExisted -and $runningOnWindows) {\n # File.Replace preserves the destination ACL, so tighten it first rather\n # than letting a permissive historical DACL survive the atomic replace.\n Protect-SetupFile $script:ClaudeSettingsPath\n # PowerShell binds ordinary $null to String.Empty for a .NET string\n # parameter; NullString passes an actual null backup path.\n # https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.language.nullstring\n [System.IO.File]::Replace($stage, $script:ClaudeSettingsPath, [System.Management.Automation.Language.NullString]::Value)\n } else {\n # Move-Item is an atomic same-filesystem rename on Unix and creates a new\n # target on Windows. Windows replacing an existing target uses File.Replace.\n Move-Item -LiteralPath $stage -Destination $script:ClaudeSettingsPath -Force\n }\n Remove-SetupOlderBackups -Path $script:ClaudeSettingsPath -Keep $script:ClaudeSettingsBackup\n } catch {\n if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Force }\n Restore-SetupManagedFile -Existed $script:ClaudeSettingsExisted -Backup $script:ClaudeSettingsBackup -Path $script:ClaudeSettingsPath -OriginalLabel \'file\' -CreatedLabel \'Claude settings\'\n throw\n }\n}\n\nfunction Write-SetupClaudeVersion {\n param([string]$Exe)\n $timeoutSeconds = Get-SetupTimeoutSeconds 30\n $version = Invoke-SetupProcess -Exe $Exe -Arguments @(\'--version\') -TimeoutSeconds $timeoutSeconds -TimeoutMessage \'``claude --version`` timed out.\'\n if ($version.ExitCode -ne 0) { Stop-Setup "``claude --version`` failed." }\n Write-SetupInfo "Claude Code version: $($version.Output.Trim())"\n}\n\n# Install, then configure Claude Code as one transactional settings write. A\n# freshly installed CLI is never uninstalled when configuration fails.\nfunction Set-SetupAgent {\n Write-SetupAgentNotice \'Installing\' \'Claude Code\'\n # Ref: https://docs.claude.com/en/docs/claude-code/troubleshoot-install\n $candidates = @(\n (Join-Path $HOME \'.local/bin/claude\'),\n (Join-Path $HOME \'.local/bin/claude.exe\'),\n (Join-Path $HOME \'.claude/local/claude\')\n )\n if ($env:USERPROFILE) { $candidates += (Join-Path $env:USERPROFILE \'.local\\bin\\claude.exe\') }\n $exe = Get-SetupCliExe -Name claude -Label \'Claude Code\' -Candidates $candidates\n if (-not $exe) {\n Install-SetupClaude\n $exe = Get-SetupCliExe -Name claude -Label \'Claude Code\' -Candidates $candidates\n if (-not $exe) { Stop-Setup "Claude Code CLI is unavailable and could not be installed." }\n } else {\n Write-SetupInfo \'Claude Code is already installed.\'\n }\n Write-SetupClaudeVersion -Exe $exe\n\n Write-SetupAgentNotice \'Configuring\' \'Claude Code\'\n Write-SetupClaudeSettings\n Write-SetupInfo (\'Written to `\' + $script:ClaudeSettingsPath + \'`.\')\n Write-SetupAgentNotice \'Completed Agent Setup\' \'Claude Code\'\n}\n\n\n$global:LASTEXITCODE = Main \'Claude Code\'\n'; + +export const SETUP_POWERSHELL_CODEX = '# Codex Agent Setup fragment.\n\n# Install the official Codex package. CODEX_NON_INTERACTIVE keeps the direct\n# installer from prompting. We track upstream\'s maintained scripts so release-\n# metadata fixes arrive without waiting for a Floway update. Reviewed sources:\n# https://github.com/openai/codex/blob/d3fc1950a920f98e7fa9f11056667cdf911c38df/README.md#L18-L37\n# https://github.com/openai/codex/blob/d3fc1950a920f98e7fa9f11056667cdf911c38df/scripts/install/install.sh\n# https://github.com/openai/codex/blob/d3fc1950a920f98e7fa9f11056667cdf911c38df/scripts/install/install.ps1\n# The AGENT_SETUP_TEST_INSTALL_CODEX_SCRIPT hook —\n# read from the ambient environment, never emitted by the gateway — substitutes\n# a fake installer under test.\nfunction Install-SetupCodex {\n $hadNonInteractive = Test-Path Env:CODEX_NON_INTERACTIVE\n $previousNonInteractive = $env:CODEX_NON_INTERACTIVE\n try {\n $env:CODEX_NON_INTERACTIVE = \'true\'\n if ($env:AGENT_SETUP_TEST_INSTALL_CODEX_SCRIPT) {\n Write-SetupInfo \'Codex CLI not found; running the test installer\'\n $timeoutSeconds = Get-SetupTimeoutSeconds 120\n $installer = Invoke-SetupProcess -Exe $env:AGENT_SETUP_TEST_INSTALL_CODEX_SCRIPT -Arguments @() -TimeoutSeconds $timeoutSeconds\n if ($installer.ExitCode -ne 0) { Stop-Setup "the test codex installer hook failed." }\n return\n }\n if ($env:AGENT_SETUP_TEST_CODEX_URL) {\n Write-SetupInfo \'Codex CLI not found; running the test installer download\'\n Invoke-SetupRemoteInstaller -Uri $env:AGENT_SETUP_TEST_CODEX_URL -BypassExecutionPolicy\n return\n }\n $platform = Get-SetupPlatform\n $npm = Get-Command npm -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n $brew = if ($platform -eq \'macos\') { Get-Command brew -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 } else { $null }\n if ($brew) {\n Write-SetupInfo \'Codex CLI not found; installing with Homebrew\'\n Install-SetupHomebrewCask -Cask \'codex\'\n } elseif ($npm) {\n Write-SetupInfo \'Codex CLI not found; installing with npm\'\n Install-SetupNpmPackage -Package \'@openai/codex\'\n } elseif ($platform -eq \'windows\') {\n Write-SetupInfo \'Codex CLI not found; installing from GitHub\'\n Invoke-SetupRemoteInstaller -Uri \'https://raw.githubusercontent.com/openai/codex/refs/heads/main/scripts/install/install.ps1\'\n } else {\n Write-SetupInfo \'Codex CLI not found; installing from GitHub\'\n Invoke-SetupRemoteInstaller -Uri \'https://raw.githubusercontent.com/openai/codex/refs/heads/main/scripts/install/install.sh\' -Shell\n }\n } finally {\n if ($hadNonInteractive) { $env:CODEX_NON_INTERACTIVE = $previousNonInteractive }\n else { Remove-Item Env:CODEX_NON_INTERACTIVE -ErrorAction SilentlyContinue }\n }\n}\n\n# Back up the config and provider token before any mutation, recording the\n# absence of each so rollback can distinguish "restore" from "remove".\nfunction Backup-SetupCodexFiles {\n $script:CodexConfigExisted = $false\n $script:CodexTokenExisted = $false\n $script:CodexConfigBackup = $null\n $script:CodexTokenBackup = $null\n # DateTimeOffset.ToUnixTimeMilliseconds is unavailable on the .NET Framework\n # version bundled with the Windows PowerShell 5.1 baseline.\n $stamp = [long]([DateTimeOffset]::UtcNow - [DateTimeOffset]\'1970-01-01T00:00:00Z\').TotalMilliseconds\n if (Test-Path -LiteralPath $script:CodexConfigPath) {\n $script:CodexConfigExisted = $true\n $script:CodexConfigBackup = "$($script:CodexConfigPath).floway-backup.$stamp.$PID"\n Copy-Item -LiteralPath $script:CodexConfigPath -Destination $script:CodexConfigBackup\n }\n if (Test-Path -LiteralPath $script:CodexTokenPath) {\n $script:CodexTokenExisted = $true\n $script:CodexTokenBackup = "$($script:CodexTokenPath).floway-backup.$stamp.$PID"\n try {\n Copy-Item -LiteralPath $script:CodexTokenPath -Destination $script:CodexTokenBackup\n Protect-SetupFile $script:CodexTokenBackup\n } catch {\n if (Test-Path -LiteralPath $script:CodexTokenBackup) {\n Remove-Item -LiteralPath $script:CodexTokenBackup -Force\n }\n $script:CodexTokenBackup = $null\n throw\n }\n }\n}\n\nfunction Restore-SetupCodexFiles {\n Restore-SetupManagedFile -Existed $script:CodexConfigExisted -Backup $script:CodexConfigBackup -Path $script:CodexConfigPath -OriginalLabel \'file\' -CreatedLabel \'Codex config\'\n Restore-SetupManagedFile -Existed $script:CodexTokenExisted -Backup $script:CodexTokenBackup -Path $script:CodexTokenPath -OriginalLabel \'provider token\' -CreatedLabel \'Codex provider token\'\n}\n\nfunction Complete-SetupCodexFiles {\n Remove-SetupOlderBackups -Path $script:CodexConfigPath -Keep $script:CodexConfigBackup\n Remove-SetupOlderBackups -Path $script:CodexTokenPath -Keep $script:CodexTokenBackup\n if ($script:CodexTokenBackup -and (Test-Path -LiteralPath $script:CodexTokenBackup)) {\n Remove-Item -LiteralPath $script:CodexTokenBackup -Force -ErrorAction Stop\n }\n $script:CodexTokenBackup = $null\n}\n\n# Drive `codex app-server` over redirected stdin/stdout/stderr: initialize ->\n# initialized -> config/batchWrite. stderr is drained asynchronously so a chatty\n# server cannot fill the pipe buffer and deadlock. Each response read is bounded\n# by the remaining deadline; a timeout terminates the process tree. Unrelated\n# notifications are demultiplexed by id. Returns the batchWrite result object.\nfunction Invoke-SetupCodexAppServerBatchWrite {\n param([string]$Exe, $Edits, [int]$TimeoutSeconds)\n $startInfo = New-Object System.Diagnostics.ProcessStartInfo\n $startInfo.FileName = $Exe\n $startInfo.Arguments = \'app-server --listen stdio://\'\n $startInfo.UseShellExecute = $false\n $startInfo.CreateNoWindow = $true\n $startInfo.RedirectStandardInput = $true\n $startInfo.RedirectStandardOutput = $true\n $startInfo.RedirectStandardError = $true\n $process = New-Object System.Diagnostics.Process\n $process.StartInfo = $startInfo\n if (-not $process.Start()) { Stop-Setup "failed to start the Codex app-server." }\n $stderrTask = $process.StandardError.ReadToEndAsync()\n $watch = [System.Diagnostics.Stopwatch]::StartNew()\n $budgetMs = $TimeoutSeconds * 1000\n $result = $null\n try {\n $readMatching = {\n param([int]$WantId)\n while ($true) {\n $remaining = $budgetMs - $watch.ElapsedMilliseconds\n if ($remaining -le 0) { Stop-Setup "the Codex app-server timed out before confirming the configuration." }\n $task = $process.StandardOutput.ReadLineAsync()\n if (-not $task.Wait([int]$remaining)) { Stop-Setup "the Codex app-server timed out before confirming the configuration." }\n $line = $task.GetAwaiter().GetResult()\n if ($null -eq $line) { Stop-Setup "the Codex app-server exited before confirming the configuration." }\n if ([string]::IsNullOrWhiteSpace($line)) { continue }\n try { $msg = $line | ConvertFrom-Json } catch { Stop-Setup "the Codex app-server returned a malformed response." }\n if ($msg.id -ne $WantId) { continue }\n if ($null -ne $msg.error) { Stop-Setup "the Codex app-server reported an error writing the configuration." }\n return $msg.result\n }\n }\n $initReq = @{ jsonrpc = \'2.0\'; id = 1; method = \'initialize\'; params = @{ clientInfo = @{ name = \'floway-setup\'; title = $null; version = \'1\' }; capabilities = $null } } | ConvertTo-Json -Depth 10 -Compress\n $process.StandardInput.WriteLine($initReq)\n [void](& $readMatching 1)\n $process.StandardInput.WriteLine(\'{"jsonrpc":"2.0","method":"initialized"}\')\n $batchReq = @{ jsonrpc = \'2.0\'; id = 2; method = \'config/batchWrite\'; params = @{ edits = $Edits } } | ConvertTo-Json -Depth 10 -Compress\n $process.StandardInput.WriteLine($batchReq)\n $result = (& $readMatching 2)\n } finally {\n try { $process.StandardInput.Close() } catch { }\n if (-not $process.WaitForExit(1000)) {\n Stop-SetupProcessTree $process\n $process.WaitForExit()\n }\n $null = $stderrTask.GetAwaiter().GetResult()\n }\n return $result\n}\n\n# Build the base-config edit batch and write it through the app-server. Model\n# and effort are opaque, forwarded verbatim, and cleared with JSON null ($null)\n# when unset. A batch status of `ok` or `okOverridden` confirms the intended\n# base config; `okOverridden` is reported with its non-secret layer metadata.\nfunction Write-SetupCodexConfig {\n param([string]$Exe)\n $codexBase = ($SetupEndpoint.TrimEnd(\'/\')) + \'/azure-api.codex\'\n $runningOnWindows = Test-SetupIsWindows\n $auth = if ($runningOnWindows) {\n [ordered]@{\n command = \'powershell\'\n args = @(\'-NoProfile\', \'-Command\', \'$h = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $HOME \'\'.codex\'\' }; [IO.File]::ReadAllText((Join-Path $h \'\'floway-token\'\'))\')\n }\n } else {\n [ordered]@{\n command = \'sh\'\n args = @(\'-c\', \'cat "${CODEX_HOME:-$HOME/.codex}/floway-token"\')\n }\n }\n # Command auth opts a provider into online model refresh. The actor marker\n # enables Codex\'s client-owned search and image extensions for this provider.\n # https://github.com/openai/codex/blob/1bbdb32789e1f79932df44941236ea3658f6e965/codex-rs/models-manager/src/manager.rs#L413-L415\n # https://github.com/openai/codex/blob/1bbdb32789e1f79932df44941236ea3658f6e965/codex-rs/model-provider-info/src/lib.rs#L396-L408\n # standalone_web_search is under development, so its explicit opt-in is\n # paired with the top-level warning suppression instead of warning every run.\n # https://github.com/openai/codex/blob/24e9b849fad8f506971dfa0313dbdea8abd90112/codex-rs/features/src/lib.rs#L901-L905\n # https://github.com/openai/codex/blob/24e9b849fad8f506971dfa0313dbdea8abd90112/codex-rs/features/src/lib.rs#L1393-L1439\n $edits = @(\n @{ keyPath = \'model_provider\'; mergeStrategy = \'replace\'; value = \'floway\' },\n @{ keyPath = \'suppress_unstable_features_warning\'; mergeStrategy = \'replace\'; value = $true },\n @{ keyPath = \'model_providers.floway.name\'; mergeStrategy = \'replace\'; value = \'Floway\' },\n @{ keyPath = \'model_providers.floway.base_url\'; mergeStrategy = \'replace\'; value = $codexBase },\n @{ keyPath = \'model_providers.floway.auth\'; mergeStrategy = \'replace\'; value = $auth },\n @{ keyPath = \'model_providers.floway.wire_api\'; mergeStrategy = \'replace\'; value = \'responses\' },\n @{ keyPath = \'model_providers.floway.supports_websockets\'; mergeStrategy = \'replace\'; value = $true },\n @{ keyPath = \'model_providers.floway.http_headers\'; mergeStrategy = \'replace\'; value = @{ \'x-openai-actor-authorization\' = \'1\' } },\n @{ keyPath = \'features.apps\'; mergeStrategy = \'replace\'; value = $false },\n @{ keyPath = \'features.standalone_web_search\'; mergeStrategy = \'replace\'; value = $true },\n @{ keyPath = \'model\'; mergeStrategy = \'replace\'; value = $SetupCodexModel },\n @{ keyPath = \'model_reasoning_effort\'; mergeStrategy = \'replace\'; value = $SetupCodexReasoningEffort }\n )\n $timeoutSeconds = Get-SetupTimeoutSeconds 60\n $result = Invoke-SetupCodexAppServerBatchWrite -Exe $Exe -Edits $edits -TimeoutSeconds $timeoutSeconds\n $status = [string]$result.status\n if ($status -eq \'okOverridden\') {\n $message = if ($result.overriddenMetadata -and $result.overriddenMetadata.message) { [string]$result.overriddenMetadata.message } else { \'an override layer applies\' }\n $layer = \'unknown\'\n if ($result.overriddenMetadata -and $result.overriddenMetadata.overridingLayer -and $result.overriddenMetadata.overridingLayer.name) {\n $layer = [string]$result.overriddenMetadata.overridingLayer.name.type\n }\n Write-SetupWarn "Codex configuration is overridden by a higher-precedence layer ($message; layer: $layer)."\n } elseif ($status -ne \'ok\') {\n Stop-Setup "the Codex app-server did not confirm the configuration (status: $status)."\n }\n $filePath = [string]$result.filePath\n if ([string]::IsNullOrWhiteSpace($filePath)) {\n Stop-Setup "the Codex app-server did not report the written config path."\n }\n return $filePath\n}\n\n# Store the selected API key as a provider-scoped command-auth token. The private\n# stage is validated byte-for-byte, then atomically replaced. auth.json is an\n# account-owned Codex file and is never read or changed here.\nfunction Write-SetupCodexToken {\n $stage = "$($script:CodexTokenPath).floway-stage.$PID"\n try {\n [System.IO.File]::Create($stage).Dispose()\n Protect-SetupFile $stage\n [System.IO.File]::WriteAllText($stage, $SetupApiKey, (New-Object System.Text.UTF8Encoding($false)))\n if ([System.IO.File]::ReadAllText($stage) -cne $SetupApiKey) {\n Stop-Setup "staged Codex provider token failed validation."\n }\n $runningOnWindows = Test-SetupIsWindows\n if ($script:CodexTokenExisted -and $runningOnWindows) {\n # File.Replace preserves the destination ACL, so tighten it first.\n Protect-SetupFile $script:CodexTokenPath\n # PowerShell binds ordinary $null to String.Empty for a .NET string\n # parameter; NullString passes an actual null backup path.\n # https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.language.nullstring\n [System.IO.File]::Replace($stage, $script:CodexTokenPath, [System.Management.Automation.Language.NullString]::Value)\n } else {\n Move-Item -LiteralPath $stage -Destination $script:CodexTokenPath -Force\n }\n } catch {\n if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Force }\n throw\n }\n}\n\nfunction Write-SetupCodexVersion {\n param([string]$Exe)\n $timeoutSeconds = Get-SetupTimeoutSeconds 30\n $version = Invoke-SetupProcess -Exe $Exe -Arguments @(\'--version\') -TimeoutSeconds $timeoutSeconds -TimeoutMessage \'``codex --version`` timed out.\'\n if ($version.ExitCode -ne 0) { Stop-Setup "``codex --version`` failed." }\n Write-SetupInfo "Codex version: $($version.Output.Trim())"\n}\n\n# Install, then configure Codex as one transactional config/token write. A\n# freshly installed CLI is never uninstalled when configuration fails.\nfunction Set-SetupAgent {\n Write-SetupAgentNotice \'Installing\' \'Codex\'\n # Upstream installs into these user-local candidates by default:\n # https://github.com/openai/codex/blob/d3fc1950a920f98e7fa9f11056667cdf911c38df/scripts/install/install.sh\n $candidates = @(\n (Join-Path $HOME \'.local/bin/codex\'),\n (Join-Path $HOME \'.local/bin/codex.exe\')\n )\n if ($env:USERPROFILE) { $candidates += (Join-Path $env:USERPROFILE \'.local\\bin\\codex.exe\') }\n $exe = Get-SetupCliExe -Name codex -Label Codex -Candidates $candidates\n if (-not $exe) {\n Install-SetupCodex\n $exe = Get-SetupCliExe -Name codex -Label Codex -Candidates $candidates\n if (-not $exe) { Stop-Setup "Codex CLI is unavailable and could not be installed." }\n } else {\n Write-SetupInfo \'Codex is already installed.\'\n }\n Write-SetupCodexVersion -Exe $exe\n\n Write-SetupAgentNotice \'Configuring\' \'Codex\'\n $script:CodexHomeDir = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $HOME \'.codex\' }\n $script:CodexConfigPath = Join-Path $script:CodexHomeDir \'config.toml\'\n $script:CodexTokenPath = Join-Path $script:CodexHomeDir \'floway-token\'\n if (-not (Test-Path -LiteralPath $script:CodexHomeDir)) {\n New-Item -ItemType Directory -Path $script:CodexHomeDir -Force | Out-Null\n }\n Backup-SetupCodexFiles\n try {\n Write-SetupCodexToken\n } catch {\n Write-SetupWarn "Codex provider-token staging failed; rolling back configuration and token."\n Restore-SetupCodexFiles\n throw\n }\n try {\n $writtenConfigPath = Write-SetupCodexConfig -Exe $exe\n } catch {\n Write-SetupWarn "Codex configuration failed; rolling back configuration and token."\n Restore-SetupCodexFiles\n throw\n }\n try {\n Complete-SetupCodexFiles\n } catch {\n Write-SetupWarn "Codex backup cleanup failed; rolling back configuration and token."\n Restore-SetupCodexFiles\n throw\n }\n Write-SetupInfo (\'Written to `\' + $writtenConfigPath + \'`.\')\n Write-SetupInfo (\'Written to `\' + $script:CodexTokenPath + \'`.\')\n Write-SetupAgentNotice \'Completed Agent Setup\' \'Codex\'\n}\n\n\n$global:LASTEXITCODE = Main \'Codex\'\n'; + +export const SETUP_BASH_COMMON = '# Floway Agent Setup common installer fragment (Bash 3.2+). TypeScript prepends\n# the language-native assignment prefix and appends one agent fragment.\n#\n# Each served script targets exactly one agent. Errexit stays disabled because\n# Bash suppresses it inside guarded calls; failures are checked explicitly and\n# the selected agent\'s configuration is rolled back as one transaction.\n\n# --- output layer -----------------------------------------------------------\n#\n# Setup-owned output follows Homebrew\'s compact visual language: blue `==>`\n# notices introduce major phases, while warnings and errors color only their\n# labels. Phase details remain subordinate instead of competing for attention.\n# Native package managers inherit the terminal directly, so their ANSI colors,\n# carriage-return progress, buffering, and cursor behavior remain intact.\n#\n# Color is emitted only for an interactive terminal with NO_COLOR unset, probed\n# per stream so a redirected capture on either stdout or stderr stays free of\n# escape sequences. Agent notices and informational lines go to stdout;\n# warnings, errors, and rollback notices go to stderr.\n_stream_color() {\n [ -z "${NO_COLOR:-}" ] || return 1\n [ -n "${AGENT_SETUP_TEST_FORCE_COLOR:-}" ] && return 0\n [ -t "$1" ]\n}\n_init_output() {\n if _stream_color 1; then _OUT_COLOR=1; else _OUT_COLOR=0; fi\n if _stream_color 2; then _ERR_COLOR=1; else _ERR_COLOR=0; fi\n _C_BLUE=$\'\\033[34m\'\n _C_BOLD=$\'\\033[1m\'\n _C_YELLOW=$\'\\033[93m\'\n _C_RED=$\'\\033[91m\'\n _C_RESET=$\'\\033[0m\'\n}\n\n_emit_notice() {\n if [ "$_OUT_COLOR" -eq 1 ]; then\n printf \'%s==>%s %s%s%s\\n\' "$_C_BLUE" "$_C_RESET" "$_C_BOLD" "$1" "$_C_RESET"\n else\n printf \'==> %s\\n\' "$1"\n fi\n}\n\n# Homebrew colors the diagnostic label rather than the whole message, keeping\n# paths and remediation text readable in the terminal\'s native foreground.\n_emit_diagnostic() {\n if [ "$_ERR_COLOR" -eq 1 ]; then\n printf \'%s%s:%s %s\\n\' "$1" "$2" "$_C_RESET" "$3" >&2\n else\n printf \'%s: %s\\n\' "$2" "$3" >&2\n fi\n}\n\n# Default-color detail lines stay uncolored rather than carrying a bare reset.\n# $1 stream (1|2), $2 color, $3 text.\n_emit_line() {\n if [ "$1" -eq 1 ]; then\n if [ "$_OUT_COLOR" -eq 1 ] && [ -n "$2" ]; then printf \'%s%s%s\\n\' "$2" "$3" "$_C_RESET"; else printf \'%s\\n\' "$3"; fi\n else\n if [ "$_ERR_COLOR" -eq 1 ] && [ -n "$2" ]; then printf \'%s%s%s\\n\' "$2" "$3" "$_C_RESET" >&2; else printf \'%s\\n\' "$3" >&2; fi\n fi\n}\n\nout_agent_notice() { _emit_notice "$1: $2"; }\nout_metadata() { _emit_line 1 \'\' "$1: $2"; }\nout_info() { _emit_line 1 \'\' "$1"; }\nout_warn() { _emit_diagnostic "$_C_YELLOW" \'Warning\' "$1"; }\nout_error() { _emit_diagnostic "$_C_RED" \'Error\' "$1"; }\n\nSETUP_TMPDIR=""\n_cleanup() {\n if [ -n "$SETUP_TMPDIR" ]; then\n rm -rf "$SETUP_TMPDIR" 2>/dev/null || true\n fi\n}\n# EXIT owns cleanup. INT/TERM only translate the signal into the conventional\n# exit status (130 = 128+SIGINT, 143 = 128+SIGTERM) and let that exit fire the\n# EXIT trap. Cleaning up directly inside the INT/TERM handlers would delete the\n# working directory and then let the interrupted script resume into the next\n# agent\'s configuration; exiting instead stops all further agent work.\n# Run a command under a wall-clock limit. macOS ships no `timeout`, so the\n# Bash-3.2 fallback enables job control for one launch, placing the command and\n# all ordinary descendants in a dedicated process group. The watchdog signals\n# that group with TERM then KILL, retains its process-group id across root exit,\n# and the parent waits for escalation to finish before returning 124.\n_run_with_timeout() {\n _rwt_secs=$1\n shift\n if command -v timeout >/dev/null 2>&1; then\n timeout "$_rwt_secs" "$@"\n return $?\n fi\n if command -v gtimeout >/dev/null 2>&1; then\n gtimeout "$_rwt_secs" "$@"\n return $?\n fi\n\n _rwt_marker=$(mktemp "$SETUP_TMPDIR/timeout.XXXXXX") || return 1\n rm -f "$_rwt_marker"\n if [ -n "${AGENT_SETUP_TEST_TRACE_TIMEOUT:-}" ]; then\n printf \'Agent Setup test: timeout fallback: process-tree\\n\'\n fi\n set -m\n "$@" &\n _rwt_pid=$!\n set +m\n (\n # The watchdog must not retain the installer\'s stdout/stderr descriptors\n # after its parent shell is killed; otherwise a pipe consumer waits for the\n # orphaned sleep to exit before receiving EOF.\n exec /dev/null 2>&1\n sleep "$_rwt_secs"\n if kill -0 "$_rwt_pid" 2>/dev/null; then\n : > "$_rwt_marker"\n kill -TERM -- "-$_rwt_pid" 2>/dev/null || true\n sleep 1\n kill -KILL -- "-$_rwt_pid" 2>/dev/null || true\n fi\n ) &\n _rwt_watchdog=$!\n wait "$_rwt_pid"\n _rwt_status=$?\n if [ -e "$_rwt_marker" ]; then\n # Let TERM→KILL escalation finish before reporting the timeout.\n wait "$_rwt_watchdog" 2>/dev/null || true\n rm -f "$_rwt_marker"\n return 124\n fi\n kill "$_rwt_watchdog" 2>/dev/null || true\n wait "$_rwt_watchdog" 2>/dev/null || true\n rm -f "$_rwt_marker"\n return $_rwt_status\n}\n\n# jq handle, resolved by ensure_jq before any configuration file is touched.\nJQ=""\n\n# Download the pinned official jq build for this platform into the private\n# working directory and verify its hard-coded SHA-256 before use. Fails on an\n# unsupported platform, a download error, a missing hashing tool, or a checksum\n# mismatch — always before any configuration file is touched.\n_bootstrap_jq() {\n _bj_os=$(uname -s)\n _bj_arch=$(uname -m)\n case "$_bj_os" in\n Darwin) _bj_os_part=macos ;;\n Linux) _bj_os_part=linux ;;\n *) out_error "no pinned jq build for OS $_bj_os."; return 1 ;;\n esac\n case "$_bj_arch" in\n x86_64 | amd64) _bj_arch_part=amd64 ;;\n arm64 | aarch64) _bj_arch_part=arm64 ;;\n *) out_error "no pinned jq build for architecture $_bj_arch."; return 1 ;;\n esac\n _bj_asset="jq-$_bj_os_part-$_bj_arch_part"\n # Pinned to jqlang/jq release jq-1.8.2. Each digest was verified against the\n # release sha256sum.txt and the Sigstore build attestation\n # (signer: jqlang/jq .github/workflows/ci.yml@refs/tags/jq-1.8.2).\n # Ref: https://github.com/jqlang/jq/releases/tag/jq-1.8.2\n case "$_bj_asset" in\n jq-macos-amd64) _bj_sha=e94b266e3c26690550006abe63152b782280f4e14374accdf04cbde844f00bc0 ;;\n jq-macos-arm64) _bj_sha=2d75340ba57a4b4b4c8708a21c2dc8e958a48aaa8bba13b27f77f6e4c0eca07e ;;\n jq-linux-amd64) _bj_sha=b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f ;;\n jq-linux-arm64) _bj_sha=8b85c817833814ddca00a144c33705546355afccf0cf39b188f3cdb48b852309 ;;\n *) return 1 ;;\n esac\n _bj_url="https://github.com/jqlang/jq/releases/download/jq-1.8.2/$_bj_asset"\n _bj_dest="$SETUP_TMPDIR/$_bj_asset"\n out_warn \'jq not found on PATH; fetching the pinned jq-1.8.2 build\'\n if ! curl -fsSL --connect-timeout 10 --max-time 120 -o "$_bj_dest" "$_bj_url"; then\n out_error "failed to download jq from $_bj_url"\n rm -f "$_bj_dest"\n return 1\n fi\n if command -v sha256sum >/dev/null 2>&1; then\n _bj_actual=$(sha256sum "$_bj_dest" | awk \'{ print $1 }\')\n elif command -v shasum >/dev/null 2>&1; then\n _bj_actual=$(shasum -a 256 "$_bj_dest" | awk \'{ print $1 }\')\n elif command -v openssl >/dev/null 2>&1; then\n _bj_actual=$(openssl dgst -sha256 "$_bj_dest" | awk \'{ print $NF }\')\n else\n _bj_actual=""\n fi\n if [ -z "$_bj_actual" ]; then\n out_error \'no SHA-256 tool available to verify the jq download.\'\n rm -f "$_bj_dest"\n return 1\n fi\n if [ "$_bj_actual" != "$_bj_sha" ]; then\n out_error \'jq checksum mismatch; refusing to use the download.\'\n rm -f "$_bj_dest"\n return 1\n fi\n if ! chmod 700 "$_bj_dest"; then\n rm -f "$_bj_dest"\n return 1\n fi\n JQ="$_bj_dest"\n}\n\n# Resolve a usable jq: prefer PATH, else provision the pinned build. The\n# AGENT_SETUP_TEST_NO_JQ_DOWNLOAD hook lets the test harness assert the\n# fail-before-mutation path without reaching the network.\nensure_jq() {\n if command -v jq >/dev/null 2>&1; then\n JQ=jq\n return 0\n fi\n if [ -n "${AGENT_SETUP_TEST_NO_JQ_DOWNLOAD:-}" ]; then\n return 1\n fi\n _bootstrap_jq\n}\n\n# Download an installer to the private working directory, refuse anything that\n# is not a shell script (region blocks and captive portals serve HTML in place\n# of the real installer), then execute it without sudo.\n_download_and_run_installer() {\n _dri_url=$1\n _dri_file=$(mktemp "$SETUP_TMPDIR/install.XXXXXX") || return 1\n if ! curl -fsSL --connect-timeout 10 --max-time 120 -o "$_dri_file" "$_dri_url"; then\n out_error "could not download the installer from $_dri_url"\n rm -f "$_dri_file"\n return 1\n fi\n # Reject common HTML responses while allowing official shell content with or\n # without a shebang (some installer CDNs prepend comments).\n if awk \'\n NR <= 20 {\n line = tolower($0)\n if (line ~ /^[[:space:]]*(])|])|]))/) found = 1\n }\n END { exit found ? 0 : 1 }\n \' "$_dri_file"; then\n out_error \'the installer download was HTML, not an executable script (a login or region-block page?).\'\n rm -f "$_dri_file"\n return 1\n fi\n if ! awk \'NF { found = 1 } END { exit found ? 0 : 1 }\' "$_dri_file"; then\n out_error \'the installer download was empty.\'\n rm -f "$_dri_file"\n return 1\n fi\n _dri_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-120}\n _run_with_timeout "$_dri_timeout" env -u SETUP_API_KEY bash "$_dri_file" /dev/null || true)\n if [ -n "$DISCOVERED_BIN" ]; then\n DISCOVERED_COUNT=1\n else\n DISCOVERED_COUNT=0\n fi\n for _dc_candidate in "$@"; do\n [ -x "$_dc_candidate" ] || continue\n [ "$_dc_candidate" = "$DISCOVERED_BIN" ] && continue\n DISCOVERED_COUNT=$((DISCOVERED_COUNT + 1))\n if [ -z "$DISCOVERED_BIN" ]; then\n DISCOVERED_BIN=$_dc_candidate\n fi\n done\n}\n\n# Rollback retains a backup when restoration fails so manual recovery remains\n# possible. Callers keep separate transaction boundaries and aggregate failures.\n_restore_managed_file() {\n _rmf_existed=$1\n _rmf_backup=$2\n _rmf_path=$3\n _rmf_original_label=$4\n _rmf_created_label=$5\n if [ "$_rmf_existed" -eq 1 ]; then\n if [ -n "$_rmf_backup" ] && [ -e "$_rmf_backup" ] && ! mv "$_rmf_backup" "$_rmf_path" 2>/dev/null; then\n out_warn "could not restore $_rmf_path from its backup; your original $_rmf_original_label is preserved at $_rmf_backup — restore it by hand."\n return 1\n fi\n elif ! rm -f "$_rmf_path" 2>/dev/null; then\n out_warn "could not remove the $_rmf_created_label this run created at $_rmf_path — remove it by hand."\n return 1\n fi\n return 0\n}\n\n_prune_managed_backups() {\n _pmb_path=$1\n _pmb_keep=$2\n for _pmb_backup in "$_pmb_path".floway-backup.*; do\n [ -e "$_pmb_backup" ] || continue\n [ "$_pmb_backup" = "$_pmb_keep" ] && continue\n if ! rm -f "$_pmb_backup"; then\n out_error "could not remove obsolete backup $_pmb_backup"\n return 1\n fi\n done\n}\n\n_install_brew_cask() {\n _ibc_cask=$1\n if ! command -v brew >/dev/null 2>&1; then\n out_error \'Homebrew is required to install agent CLIs on macOS.\'\n return 1\n fi\n _ibc_timeout=${AGENT_SETUP_TEST_TIMEOUT_SECONDS:-600}\n _run_with_timeout "$_ibc_timeout" env -u SETUP_API_KEY brew install --cask "$_ibc_cask" /dev/null || true\n\n # Neutralize identically named exported variables inherited from the caller.\n # jq receives the API key only on the exact invocations that need it; package\n # managers and CLIs never inherit the credential.\n export -n SETUP_API_KEY SETUP_API_KEY_NAME 2>/dev/null || true\n\n _init_output\n out_agent_notice \'Agent Setup\' "$1"\n\n if [ -z "${SETUP_ENDPOINT:-}" ]; then\n out_error \'SETUP_ENDPOINT must be set to this gateway origin (e.g. https://gateway.example).\'\n return 1\n fi\n case "$SETUP_ENDPOINT" in\n http://?* | https://?*) ;;\n *) out_error "SETUP_ENDPOINT must be an http(s) origin, got $SETUP_ENDPOINT"; return 1 ;;\n esac\n out_metadata \'Endpoint\' "$SETUP_ENDPOINT"\n out_metadata \'API Key\' "$SETUP_API_KEY_NAME"\n export -n SETUP_ENDPOINT 2>/dev/null || true\n\n SETUP_TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/agent-setup.XXXXXX") || {\n out_error \'could not create a private working directory.\'\n return 1\n }\n chmod 700 "$SETUP_TMPDIR" 2>/dev/null || true\n trap _cleanup EXIT\n trap \'exit 130\' INT\n trap \'exit 143\' TERM\n\n configure_agent\n}\n'; + +export const SETUP_POWERSHELL_COMMON = '# Floway Agent Setup common installer fragment (PowerShell). TypeScript prepends\n# the language-native assignment prefix and appends one agent fragment.\n#\n# Each served script targets exactly one agent and rolls back that agent\'s\n# configuration as one transaction on failure.\n\n# --- output layer -----------------------------------------------------------\n#\n# Setup-owned output follows Homebrew\'s compact visual language: blue `==>`\n# notices introduce major phases, while warnings and errors color only their\n# labels. Phase details remain subordinate instead of competing for attention.\n# Native package managers inherit the terminal directly, preserving their ANSI\n# colors, carriage-return progress, buffering, and cursor behavior.\n#\n# stdout color rides the host: `Write-Host -ForegroundColor` colors an\n# interactive console yet writes no escape sequences when redirected/captured,\n# so it is the correct stdout mechanism on both Windows PowerShell 5.1 and\n# PowerShell 7. stderr goes through [Console]::Error, colored with ANSI only for\n# an interactive error stream with NO_COLOR unset — a redirected capture stays\n# escape-free. UTF-8 output keeps the status glyphs portable to 5.1.\nfunction Write-SetupHostLine {\n param([string]$Text, [System.ConsoleColor]$Color, [switch]$Plain)\n if ($Plain -or $script:SetupNoColor) { Write-Host $Text } else { Write-Host $Text -ForegroundColor $Color }\n}\n\nfunction Write-SetupNotice {\n param([string]$Text)\n if ($script:SetupNoColor) { Write-Host "==> $Text"; return }\n if ($script:SetupOutAnsi) {\n Write-Host "$($script:SetupEsc)[34m==>$($script:SetupEsc)[0m $($script:SetupEsc)[1m$Text$($script:SetupEsc)[0m"\n return\n }\n Write-Host \'==>\' -ForegroundColor Blue -NoNewline\n Write-Host " $Text" -ForegroundColor White\n}\n\n# Console.Error is used directly so diagnostics remain on stderr while only the\n# Homebrew-style label receives color.\nfunction Write-SetupDiagnostic {\n param([string]$Label, [string]$Text, [System.ConsoleColor]$Color, [string]$TestAnsiCode)\n if ($script:SetupErrColor) {\n $previous = [Console]::ForegroundColor\n try {\n [Console]::ForegroundColor = $Color\n [Console]::Error.Write("${Label}:")\n [Console]::ForegroundColor = $previous\n [Console]::Error.WriteLine(" $Text")\n } finally {\n [Console]::ForegroundColor = $previous\n }\n } elseif ($script:SetupForceColor -and (-not $script:SetupNoColor)) {\n [Console]::Error.WriteLine("$($script:SetupEsc)[${TestAnsiCode}m${Label}:$($script:SetupEsc)[0m $Text")\n } else {\n [Console]::Error.WriteLine("${Label}: $Text")\n }\n}\n\nfunction Write-SetupAgentNotice { param([string]$Label, [string]$AgentName) Write-SetupNotice "${Label}: $AgentName" }\nfunction Write-SetupMetadata { param([string]$Label, [string]$Value) Write-Host "${Label}: $Value" }\nfunction Write-SetupInfo { param([string]$Text) Write-SetupHostLine $Text -Plain }\nfunction Write-SetupWarn { param([string]$Text) Write-SetupDiagnostic \'Warning\' $Text Yellow \'93\' }\nfunction Write-SetupError { param([string]$Text) Write-SetupDiagnostic \'Error\' $Text Red \'91\' }\n\n# Report a primary error to stderr and unwind. The agent boundary recognizes the\n# \'setup-handled\' marker as already reported, so no line is ever duplicated.\nfunction Stop-Setup { param([string]$Message) Write-SetupError $Message; throw \'setup-handled\' }\n# Windows PowerShell 5.1 only runs on Windows and has no $IsWindows automatic\n# variable; PowerShell 6+ exposes it on every platform.\nfunction Test-SetupIsWindows {\n ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows\n}\n\n# The AGENT_SETUP_TEST_TIMEOUT_SECONDS hook, read from the ambient environment\n# and never emitted by the gateway, lets the harness shorten every wall-clock\n# limit; otherwise the caller-supplied default applies.\nfunction Get-SetupTimeoutSeconds {\n param([int]$Default)\n if ($env:AGENT_SETUP_TEST_TIMEOUT_SECONDS) { [int]$env:AGENT_SETUP_TEST_TIMEOUT_SECONDS } else { $Default }\n}\n\nfunction Set-SetupProp {\n param($Target, [string]$Name, $Value)\n if ($Target.PSObject.Properties.Name -contains $Name) { $Target.$Name = $Value }\n else { $Target | Add-Member -NotePropertyName $Name -NotePropertyValue $Value }\n}\n\nfunction Remove-SetupProp {\n param($Target, [string]$Name)\n if ($Target.PSObject.Properties.Name -contains $Name) { $Target.PSObject.Properties.Remove($Name) }\n}\n\n# A null optional value means "remove this managed key"; any other value is set.\nfunction Set-SetupOptionalProp {\n param($Target, [string]$Name, $Value)\n if ($null -eq $Value) { Remove-SetupProp $Target $Name } else { Set-SetupProp $Target $Name $Value }\n}\n\n# Redact every occurrence of the API key from text before it is surfaced.\nfunction Protect-SetupSecret {\n param([string]$Text)\n return ($Text -replace [regex]::Escape($SetupApiKey), \'***\')\n}\n\n# Restrict a file to the current user: chmod 0600 on Unix, an inheritance-free\n# owner-only ACL on Windows.\nfunction Protect-SetupFile {\n param([string]$Path)\n if (-not (Test-SetupIsWindows)) {\n & chmod 600 $Path\n if ($LASTEXITCODE -ne 0) { Stop-Setup "could not restrict $Path to owner-only access." }\n return\n }\n # Set-Acl routes through the PowerShell filesystem provider and may persist\n # the untouched SACL, demanding SeSecurityPrivilege from a normal user. The\n # direct .NET APIs write only this descriptor\'s modified DACL.\n # https://github.com/PowerShell/PowerShell/blob/0c226762e2580cd7853c058dd03fc32638a73971/src/System.Management.Automation/namespaces/FileSystemSecurity.cs#L130-L200\n # https://github.com/dotnet/runtime/blob/f94898a9b55df07348434e86915c7405962427b6/src/libraries/System.IO.FileSystem.AccessControl/src/System/Security/AccessControl/FileSystemSecurity.cs#L103-L125\n $acl = New-Object System.Security.AccessControl.FileSecurity\n $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User\n $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, \'FullControl\', \'Allow\')\n $acl.SetAccessRuleProtection($true, $false)\n $acl.AddAccessRule($rule)\n if ($PSVersionTable.PSVersion.Major -lt 6) {\n [System.IO.File]::SetAccessControl($Path, $acl)\n } else {\n [System.IO.FileSystemAclExtensions]::SetAccessControl([System.IO.FileInfo]::new($Path), $acl)\n }\n}\n\n# Terminate a process and its descendants. PowerShell 7\'s runtime exposes the\n# tree-aware Kill(bool) overload; Windows PowerShell 5.1 uses taskkill /T.\nfunction Stop-SetupProcessTree {\n param([System.Diagnostics.Process]$Process)\n $runningOnWindows = Test-SetupIsWindows\n if ($runningOnWindows) {\n & taskkill.exe /PID $Process.Id /T /F *> $null\n if ($LASTEXITCODE -ne 0 -and (-not $Process.HasExited)) {\n Stop-Setup "taskkill could not terminate process tree $($Process.Id)."\n }\n return\n }\n try {\n $Process.Kill($true)\n } catch {\n if (-not $Process.HasExited) { Stop-Setup "could not terminate process tree $($Process.Id)." }\n }\n}\n\nfunction Get-SetupPlatform {\n if (Test-SetupIsWindows) { return \'windows\' }\n if ($IsMacOS) { return \'macos\' }\n return \'linux\'\n}\n\n# Run a fixed package-manager command with inherited stdout/stderr. The child\n# remains attached to the real terminal, so progress updates and ANSI control\n# sequences render in real time without a lossy line-prefix filter.\nfunction Invoke-SetupLiveProcess {\n param([string]$Exe, [string[]]$Arguments, [int]$TimeoutSeconds)\n $startInfo = New-Object System.Diagnostics.ProcessStartInfo\n $startInfo.FileName = $Exe\n $startInfo.Arguments = ($Arguments | ForEach-Object { \'"\' + $_.Replace(\'"\', \'\\"\') + \'"\' }) -join \' \'\n $startInfo.UseShellExecute = $false\n $startInfo.CreateNoWindow = $false\n $process = New-Object System.Diagnostics.Process\n $process.StartInfo = $startInfo\n if (-not $process.Start()) { Stop-Setup "failed to start $Exe." }\n if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {\n Stop-SetupProcessTree $process\n $process.WaitForExit()\n Stop-Setup "$Exe timed out after $TimeoutSeconds seconds."\n }\n if ($process.ExitCode -ne 0) { Stop-Setup "$Exe exited with status $($process.ExitCode)." }\n}\n\nfunction Install-SetupHomebrewCask {\n param([string]$Cask)\n $brew = Get-Command brew -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if (-not $brew) { Stop-Setup \'Homebrew is required to install agent CLIs on macOS.\' }\n $timeoutSeconds = Get-SetupTimeoutSeconds 600\n Invoke-SetupLiveProcess -Exe $brew.Source -Arguments @(\'install\', \'--cask\', $Cask) -TimeoutSeconds $timeoutSeconds\n}\n\n# npm on Windows is commonly a .cmd launcher, which ProcessStartInfo cannot\n# execute directly with UseShellExecute disabled. A fresh copy of the current\n# PowerShell host resolves that launcher while preserving inherited terminal\n# output and the same process-tree timeout.\nfunction Install-SetupNpmPackage {\n param([string]$Package)\n $npm = Get-Command npm -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if (-not $npm) { Stop-Setup \'npm was selected for installation but is no longer available.\' }\n $hostCommand = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n $hostExe = if ($hostCommand) { $hostCommand.Source } else { [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName }\n $npmLiteral = "\'" + $npm.Source.Replace("\'", "\'\'") + "\'"\n $packageLiteral = "\'" + $Package.Replace("\'", "\'\'") + "\'"\n $command = "& $npmLiteral install --global $packageLiteral; exit `$LASTEXITCODE"\n $timeoutSeconds = Get-SetupTimeoutSeconds 600\n Invoke-SetupLiveProcess -Exe $hostExe -Arguments @(\'-NoProfile\', \'-NonInteractive\', \'-Command\', $command) -TimeoutSeconds $timeoutSeconds\n}\n\n# Execute a downloaded installer in a fresh interpreter. The script travels\n# through stdin, while the API key exists only as a variable in this parent\n# process and its identically named environment variables were removed. The\n# official installer therefore cannot read the credential.\nfunction Invoke-SetupInterpreterBody {\n param([string]$Body, [int]$TimeoutSeconds, [string]$Exe, [string]$Arguments)\n $startInfo = New-Object System.Diagnostics.ProcessStartInfo\n $startInfo.FileName = $Exe\n $startInfo.Arguments = $Arguments\n $startInfo.UseShellExecute = $false\n $startInfo.CreateNoWindow = $false\n $startInfo.RedirectStandardInput = $true\n $process = New-Object System.Diagnostics.Process\n $process.StartInfo = $startInfo\n if (-not $process.Start()) { Stop-Setup "failed to start the installer interpreter." }\n $process.StandardInput.Write($Body)\n $process.StandardInput.WriteLine()\n $process.StandardInput.Close()\n if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {\n Stop-SetupProcessTree $process\n $process.WaitForExit()\n Stop-Setup "the installer timed out after $TimeoutSeconds seconds."\n }\n if ($process.ExitCode -ne 0) { Stop-Setup "the installer exited with status $($process.ExitCode)." }\n}\n\nfunction Invoke-SetupPowerShellBody {\n param([string]$Body, [int]$TimeoutSeconds, [switch]$BypassExecutionPolicy)\n $pwsh = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n $exe = if ($pwsh) { $pwsh.Source } else { [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName }\n $executionPolicy = if ($BypassExecutionPolicy) { \'-ExecutionPolicy Bypass \' } else { \'\' }\n Invoke-SetupInterpreterBody -Body $Body -TimeoutSeconds $TimeoutSeconds -Exe $exe -Arguments "-NoProfile -NonInteractive ${executionPolicy}-Command -"\n}\n\nfunction Invoke-SetupShellBody {\n param([string]$Body, [int]$TimeoutSeconds)\n $bash = Get-Command bash -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if (-not $bash) { Stop-Setup \'bash is required to run the official installer on macOS and Linux.\' }\n Invoke-SetupInterpreterBody -Body $Body -TimeoutSeconds $TimeoutSeconds -Exe $bash.Source -Arguments \'-s\'\n}\n\n# Download an installer, refuse anything that is not a script (region blocks and\n# captive portals serve HTML in place of the installer), then run it.\nfunction Invoke-SetupRemoteInstaller {\n param([string]$Uri, [switch]$BypassExecutionPolicy, [switch]$Shell)\n $response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -TimeoutSec 60\n $body = [string]$response.Content\n $contentType = [string]$response.Headers[\'Content-Type\']\n $looksLikeHtml = $contentType -match \'(?i)^text/html(?:;|$)\' -or $body -match \'(?is)^\\s*(?:))\'\n if ([string]::IsNullOrWhiteSpace($body) -or $looksLikeHtml) {\n Stop-Setup "the installer download was HTML or empty, not an executable script (a login or region-block page?)."\n }\n $timeoutSeconds = Get-SetupTimeoutSeconds 120\n if ($Shell) { Invoke-SetupShellBody -Body $body -TimeoutSeconds $timeoutSeconds }\n else { Invoke-SetupPowerShellBody -Body $body -TimeoutSeconds $timeoutSeconds -BypassExecutionPolicy:$BypassExecutionPolicy }\n}\n\nfunction Get-SetupCliExe {\n param([string]$Name, [string]$Label, [string[]]$Candidates)\n $found = New-Object System.Collections.Generic.List[string]\n $command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n if ($command) { $found.Add($command.Source) }\n foreach ($candidate in $Candidates) {\n if ((Test-Path -LiteralPath $candidate) -and (-not $found.Contains($candidate))) { $found.Add($candidate) }\n }\n if ($found.Count -eq 0) { return $null }\n if ($found.Count -gt 1) { Write-SetupWarn "multiple $Label installations detected; using $($found[0])" }\n return $found[0]\n}\n\n# Rollback retains a backup when restoration fails so manual recovery remains\n# possible, warning with the preserved path and the action to take — matching\n# the Bash installer. The AGENT_SETUP_TEST_FAIL_RESTORE hook, read from the\n# ambient environment and never emitted by the gateway, forces the restore\n# rename to fail so the harness can assert that guidance.\nfunction Restore-SetupManagedFile {\n param([bool]$Existed, [string]$Backup, [string]$Path, [string]$OriginalLabel, [string]$CreatedLabel)\n if ($Existed) {\n if ($Backup -and (Test-Path -LiteralPath $Backup)) {\n try {\n if ($env:AGENT_SETUP_TEST_FAIL_RESTORE) { throw \'test-injected restore failure\' }\n # Secret-bearing backups were already owner-only before any mutation.\n # Moving one back preserves that protection without a second operation\n # that could fail after the backup path has been consumed.\n Move-Item -LiteralPath $Backup -Destination $Path -Force\n } catch {\n Write-SetupWarn "could not restore $Path from its backup; your original $OriginalLabel is preserved at $Backup — restore it by hand."\n }\n }\n } elseif (Test-Path -LiteralPath $Path) {\n try {\n Remove-Item -LiteralPath $Path -Force\n } catch {\n Write-SetupWarn "could not remove the $CreatedLabel this run created at $Path — remove it by hand."\n }\n }\n}\n\nfunction Remove-SetupOlderBackups {\n param([string]$Path, [string]$Keep)\n $directory = Split-Path -Parent $Path\n $prefix = [System.IO.Path]::GetFileName($Path) + \'.floway-backup.\'\n Get-ChildItem -LiteralPath $directory -File -ErrorAction Stop |\n Where-Object { $_.Name.StartsWith($prefix, [System.StringComparison]::Ordinal) -and $_.FullName -ne $Keep } |\n Remove-Item -Force -ErrorAction Stop\n}\n\n# Run a child process with captured output under a deadline, terminating its\n# whole process tree and throwing on timeout.\nfunction Invoke-SetupProcess {\n param([string]$Exe, [string[]]$Arguments, [int]$TimeoutSeconds, [string]$TimeoutMessage)\n $startInfo = New-Object System.Diagnostics.ProcessStartInfo\n $startInfo.FileName = $Exe\n $startInfo.UseShellExecute = $false\n $startInfo.CreateNoWindow = $true\n $startInfo.RedirectStandardOutput = $true\n $startInfo.RedirectStandardError = $true\n # ArgumentList is unavailable in Windows PowerShell 5.1. These arguments are\n # fixed internal tokens, so quoting them with ProcessStartInfo.Arguments is\n # safe and keeps external input out of the child command line.\n $startInfo.Arguments = ($Arguments | ForEach-Object { \'"\' + $_.Replace(\'"\', \'\\"\') + \'"\' }) -join \' \'\n $process = New-Object System.Diagnostics.Process\n $process.StartInfo = $startInfo\n if (-not $process.Start()) { Stop-Setup "failed to start $Exe." }\n $stdoutTask = $process.StandardOutput.ReadToEndAsync()\n $stderrTask = $process.StandardError.ReadToEndAsync()\n if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {\n Stop-SetupProcessTree $process\n $process.WaitForExit()\n Stop-Setup $(if ($TimeoutMessage) { $TimeoutMessage } else { "$Exe timed out after $TimeoutSeconds seconds." })\n }\n $stdout = $stdoutTask.GetAwaiter().GetResult()\n $stderr = $stderrTask.GetAwaiter().GetResult()\n [PSCustomObject]@{ ExitCode = $process.ExitCode; Output = ($stdout + $stderr) }\n}\n# --- run --------------------------------------------------------------------\n\nfunction Main {\n param([string]$AgentName)\n $ErrorActionPreference = \'Stop\'\n # Keep native command failures from auto-throwing on PowerShell 7.3+ so the\n # explicit exit-code checks remain authoritative across versions.\n $PSNativeCommandUseErrorActionPreference = $false\n\n Remove-Item Env:SETUP_API_KEY -ErrorAction SilentlyContinue\n\n try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) } catch { }\n $script:SetupNoColor = [bool]$env:NO_COLOR\n $script:SetupForceColor = [bool]$env:AGENT_SETUP_TEST_FORCE_COLOR\n $script:SetupErrColor = (-not [Console]::IsErrorRedirected) -and (-not $script:SetupNoColor)\n $script:SetupEsc = [char]27\n $supportsVt = try { [bool]$Host.UI.SupportsVirtualTerminal } catch { $false }\n $script:SetupOutAnsi = $supportsVt -and (-not [Console]::IsOutputRedirected) -and (-not $script:SetupNoColor)\n\n Write-SetupAgentNotice \'Agent Setup\' $AgentName\n if ([string]::IsNullOrWhiteSpace($SetupEndpoint)) {\n Write-SetupError "`$SetupEndpoint must be set to this gateway origin (e.g. https://gateway.example)."\n return 1\n }\n if ($SetupEndpoint -notmatch \'^https?://.+\') {\n Write-SetupError "`$SetupEndpoint must be an http(s) origin, got $SetupEndpoint"\n return 1\n }\n Write-SetupMetadata \'Endpoint\' $SetupEndpoint\n Write-SetupMetadata \'API Key\' $SetupApiKeyName\n\n # Detection sites rethrow reported failures as the `setup-handled` sentinel;\n # only unexpected exceptions are reported again here after redaction.\n try {\n Set-SetupAgent\n } catch {\n if ($_.Exception.Message -ne \'setup-handled\') { Write-SetupError (Protect-SetupSecret ([string]$_.Exception.Message)) }\n return 1\n }\n return 0\n}\n'; + +export const SETUP_SCRIPT_SOURCE_FRAGMENTS = [ + ['installers/bash/common/output.sh', SETUP_BASH_COMMON_OUTPUT], + ['installers/bash/common/main.sh', SETUP_BASH_COMMON_MAIN], + ['installers/bash/common/process.sh', SETUP_BASH_COMMON_PROCESS], + ['installers/bash/common/jq.sh', SETUP_BASH_COMMON_JQ], + ['installers/bash/common/cli.sh', SETUP_BASH_COMMON_CLI], + ['installers/bash/common/managed-file.sh', SETUP_BASH_COMMON_MANAGED_FILE], + ['installers/bash/claude.sh', SETUP_BASH_CLAUDE], + ['installers/bash/codex.sh', SETUP_BASH_CODEX], + ['installers/powershell/common/output.ps1', SETUP_POWERSHELL_COMMON_OUTPUT], + ['installers/powershell/common/platform.ps1', SETUP_POWERSHELL_COMMON_PLATFORM], + ['installers/powershell/common/json-document.ps1', SETUP_POWERSHELL_COMMON_JSON_DOCUMENT], + ['installers/powershell/common/main.ps1', SETUP_POWERSHELL_COMMON_MAIN], + ['installers/powershell/common/managed-file.ps1', SETUP_POWERSHELL_COMMON_MANAGED_FILE], + ['installers/powershell/common/process.ps1', SETUP_POWERSHELL_COMMON_PROCESS], + ['installers/powershell/common/cli.ps1', SETUP_POWERSHELL_COMMON_CLI], + ['installers/powershell/claude.ps1', SETUP_POWERSHELL_CLAUDE], + ['installers/powershell/codex.ps1', SETUP_POWERSHELL_CODEX], +] as const; diff --git a/packages/agent-setup/src/script-assets.ts b/packages/agent-setup/src/script-assets.ts index b91030d678..ef41feef22 100644 --- a/packages/agent-setup/src/script-assets.ts +++ b/packages/agent-setup/src/script-assets.ts @@ -1,29 +1,22 @@ import { SETUP_BASH_CLAUDE, SETUP_BASH_CODEX, - SETUP_BASH_COMMON_HELPERS, - SETUP_BASH_COMMON_MAIN, - SETUP_BASH_COMMON_OUTPUT, + SETUP_BASH_COMMON, SETUP_POWERSHELL_CLAUDE, SETUP_POWERSHELL_CODEX, - SETUP_POWERSHELL_COMMON_HELPERS, - SETUP_POWERSHELL_COMMON_MAIN, - SETUP_POWERSHELL_COMMON_OUTPUT, + SETUP_POWERSHELL_COMMON, } from './script-assets.generated.ts'; export type ScriptAgent = 'claude' | 'codex'; export type ScriptLanguage = 'sh' | 'ps1'; -const bashCommon = SETUP_BASH_COMMON_OUTPUT + SETUP_BASH_COMMON_HELPERS + SETUP_BASH_COMMON_MAIN; -const powerShellCommon = SETUP_POWERSHELL_COMMON_OUTPUT + SETUP_POWERSHELL_COMMON_HELPERS + SETUP_POWERSHELL_COMMON_MAIN; - export const SETUP_SCRIPT_BODIES = { claude: { - sh: bashCommon + SETUP_BASH_CLAUDE, - ps1: powerShellCommon + SETUP_POWERSHELL_CLAUDE, + sh: SETUP_BASH_COMMON + SETUP_BASH_CLAUDE, + ps1: SETUP_POWERSHELL_COMMON + SETUP_POWERSHELL_CLAUDE, }, codex: { - sh: bashCommon + SETUP_BASH_CODEX, - ps1: powerShellCommon + SETUP_POWERSHELL_CODEX, + sh: SETUP_BASH_COMMON + SETUP_BASH_CODEX, + ps1: SETUP_POWERSHELL_COMMON + SETUP_POWERSHELL_CODEX, }, } as const satisfies Record>; diff --git a/packages/agent-setup/src/script-assets_test.ts b/packages/agent-setup/src/script-assets_test.ts new file mode 100644 index 0000000000..55ebbe2132 --- /dev/null +++ b/packages/agent-setup/src/script-assets_test.ts @@ -0,0 +1,11 @@ +import { test } from 'vitest'; + +import { SETUP_SCRIPT_SOURCE_FRAGMENTS } from './script-assets.generated.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +test('generated installer sources match the checked-in canonical fragments byte for byte', async () => { + const { readFile } = await import('node:fs/promises'); + for (const [file, generated] of SETUP_SCRIPT_SOURCE_FRAGMENTS) { + assertEquals(generated, await readFile(new URL(`../${file}`, import.meta.url), 'utf8')); + } +}); diff --git a/packages/agent-setup/src/wire_test.ts b/packages/agent-setup/src/wire_test.ts new file mode 100644 index 0000000000..6fe8c468c7 --- /dev/null +++ b/packages/agent-setup/src/wire_test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'vitest'; + +import type { AgentSetupConfiguration } from './configuration.ts'; +import { agentSetupHeartbeatBody, agentSetupUpdateBody } from './wire.ts'; + +const fullConfiguration: AgentSetupConfiguration = { + apiKeyId: 'key-a', + claudeCode: { + model: 'claude-opus-4-6[1m]', + defaultOpusModel: 'claude-opus-4-5', + defaultSonnetModel: 'claude-sonnet-4-5', + defaultHaikuModel: null, + effortLevel: 'high', + cleanupPeriodDays: 365, + optOutAiAttribution: true, + modelDiscovery: true, + }, + codex: { + model: 'gpt-5.6-terra', + reasoningEffort: 'xhigh', + }, +}; + +describe('agent setup request bodies', () => { + test('agentSetupUpdateBody accepts a token, configuration, and expected revision', () => { + expect(agentSetupUpdateBody.safeParse({ + token: 'token-a', + configuration: fullConfiguration, + expectedRevision: 3, + }).success).toBe(true); + }); + + test('agentSetupUpdateBody rejects an invalid inner configuration', () => { + expect(agentSetupUpdateBody.safeParse({ + token: 'token-a', + configuration: { ...fullConfiguration, claudeCode: { ...fullConfiguration.claudeCode, model: '' } }, + expectedRevision: 3, + }).success).toBe(false); + }); + + test('agentSetupHeartbeatBody accepts a bare token', () => { + expect(agentSetupHeartbeatBody.safeParse({ token: 'token-a' }).success).toBe(true); + }); +}); diff --git a/packages/agent-setup/tsconfig.scripts.json b/packages/agent-setup/tsconfig.scripts.json new file mode 100644 index 0000000000..61529f7615 --- /dev/null +++ b/packages/agent-setup/tsconfig.scripts.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["scripts/**/*.ts", "src/**/*.ts"] +} diff --git a/packages/gateway/package.json b/packages/gateway/package.json index 4741ef149a..d63b7615f2 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -22,8 +22,11 @@ "types": "./src/dump/codec.ts" }, "./dump-types": { - "import": "./src/dump/types.ts", "types": "./src/dump/types.ts" + }, + "./migrations-dir": { + "import": "./src/migrations-dir.ts", + "types": "./src/migrations-dir.ts" } }, "scripts": { diff --git a/packages/gateway/src/app-control_test.ts b/packages/gateway/src/app-control_test.ts index b6204faf3a..7aac846897 100644 --- a/packages/gateway/src/app-control_test.ts +++ b/packages/gateway/src/app-control_test.ts @@ -1,13 +1,9 @@ import { test } from 'vitest'; -import { DEFAULT_SEARCH_CONFIG } from './data-plane/tools/web-search/search-config.ts'; -import { tokenUsageMetrics } from './repo/usage-metrics.ts'; -import { requestApp, setupAppTest } from './test-helpers.ts'; +import { DEFAULT_WEB_SEARCH_CONFIG } from './data-plane/tools/web-search/config.ts'; +import { requestApp, setupAppTest } from './test-utils/app.ts'; import { assertEquals, assertExists } from '@floway-dev/test-utils'; -const displayQuantity = (record: { metrics: Array<{ metric: string; quantity: string }> }, tokenCategory: string) => - record.metrics.find(row => row.metric === `${tokenCategory}_tokens`)?.quantity; - test('session token grants control-plane access but is rejected on data-plane', async () => { const { adminSession } = await setupAppTest(); @@ -91,126 +87,13 @@ test('API key users cannot mutate /api/search-config routes', async () => { 'content-type': 'application/json', 'x-api-key': apiKey.key, }, - body: JSON.stringify(DEFAULT_SEARCH_CONFIG), + body: JSON.stringify(DEFAULT_WEB_SEARCH_CONFIG), }); assertEquals(response.status, 403); assertEquals(await response.json(), { error: 'Admin privileges required' }); }); -test('/api/token-usage scopes to the actor\'s keys when called with an API key', async () => { - const { repo, apiKey } = await setupAppTest(); - await repo.apiKeys.save({ - id: 'key_other', - userId: 1, - name: 'Other key', - key: 'raw_other_key', - serverSecret: '00'.repeat(32), - createdAt: '2026-03-15T00:00:00.000Z', - upstreamIds: null, - deletedAt: null, - dumpRetentionSeconds: null, - responsesRetentionSeconds: 0, - }); - await repo.usage.set({ - keyId: apiKey.id, - model: 'claude-sonnet-4', - upstream: null, - modelKey: 'claude-sonnet-4', - hour: '2026-03-15T10', - pricingSelector: {}, - requests: 2, - metrics: tokenUsageMetrics({ input: 10, output: 5, input_cache_read: 4, input_cache_write: 1 }, null), - }); - await repo.usage.set({ - keyId: 'key_other', - model: 'gpt-5', - upstream: null, - modelKey: 'gpt-5', - hour: '2026-03-15T11', - pricingSelector: {}, - requests: 1, - metrics: tokenUsageMetrics({ input: 20, output: 8, input_cache_read: 6, input_cache_write: 2 }, null), - }); - - const response = await requestApp('/api/token-usage?start=2026-03-15T00&end=2026-03-16T00&view=self-by-key', { - headers: { 'x-api-key': apiKey.key }, - }); - - assertEquals(response.status, 200); - const body = await response.json(); - // Non-admin actor sees only their own key's rows; the other user's row is excluded. - assertEquals(body.length, 1); - assertEquals(body[0].keyId, apiKey.id); - assertEquals(body[0].keyName, 'Primary key'); - assertEquals(displayQuantity(body[0], 'input_cache_read'), '4'); - assertEquals(displayQuantity(body[0], 'input_cache_write'), '1'); -}); - -test('/api/token-usage in self-by-key mode includes per-key metadata for the actor only', async () => { - const { repo, apiKey } = await setupAppTest(); - // Add a second key under the same user; they should both surface. - await repo.apiKeys.save({ - id: 'key_actor_secondary', - userId: apiKey.userId, - name: 'Actor secondary', - key: 'raw_actor_secondary', - serverSecret: '00'.repeat(32), - createdAt: '2026-03-16T00:00:00.000Z', - upstreamIds: null, - deletedAt: null, - dumpRetentionSeconds: null, - responsesRetentionSeconds: 0, - }); - await repo.usage.set({ - keyId: 'key_actor_secondary', - model: 'gpt-5', - upstream: null, - modelKey: 'gpt-5', - hour: '2026-03-16T10', - pricingSelector: {}, - requests: 1, - metrics: tokenUsageMetrics({ input: 20, output: 8 }, null), - }); - - const response = await requestApp('/api/token-usage?start=2026-03-16T00&end=2026-03-17T00&include_key_metadata=1&view=self-by-key', { - headers: { 'x-api-key': apiKey.key }, - }); - - assertEquals(response.status, 200); - const body = await response.json(); - assertEquals(body.records.length, 1); - assertEquals(body.records[0].keyId, 'key_actor_secondary'); - assertEquals(body.keys, [ - { id: apiKey.id, name: apiKey.name, createdAt: apiKey.createdAt }, - { id: 'key_actor_secondary', name: 'Actor secondary', createdAt: '2026-03-16T00:00:00.000Z' }, - ]); -}); - -test('/api/token-usage all-by-user view aggregates across keys per user', async () => { - const { repo, adminSession, apiKey } = await setupAppTest(); - await repo.usage.set({ - keyId: apiKey.id, - model: 'gpt-5', - upstream: null, - modelKey: 'gpt-5', - hour: '2026-03-15T10', - pricingSelector: {}, - requests: 1, - metrics: tokenUsageMetrics({ input: 10, output: 5 }, null), - }); - - const response = await requestApp( - '/api/token-usage?start=2026-03-15T00&end=2026-03-16T00&view=all-by-user', - { headers: { 'x-floway-session': adminSession } }, - ); - assertEquals(response.status, 200); - const body = await response.json(); - assertEquals(body.length, 1); - assertEquals(body[0].userId, apiKey.userId); - assertEquals(displayQuantity(body[0], 'input'), '10'); -}); - test('usage endpoints require an explicit view', async () => { const { apiKey } = await setupAppTest(); const paths = [ @@ -223,61 +106,3 @@ test('usage endpoints require an explicit view', async () => { assertEquals(await response.json(), { error: "view must be 'all-by-user' or 'self-by-key'" }, path); } }); - -test('/api/token-usage rejects all-by-user from a non-admin user', async () => { - const { apiKey } = await setupAppTest(); - const response = await requestApp( - '/api/token-usage?start=2026-03-15T00&end=2026-03-16T00&view=all-by-user', - { headers: { 'x-api-key': apiKey.key } }, - ); - assertEquals(response.status, 403); -}); - -test('/api/token-usage merges Claude variants into backend base model records', async () => { - const { repo, apiKey } = await setupAppTest(); - const shared = { - keyId: apiKey.id, - hour: '2026-03-17T10', - upstream: 'copilot:1', - pricingSelector: {}, - requests: 1, - metrics: tokenUsageMetrics({ input: 10, output: 5, input_cache_read: 2, input_cache_write: 1 }, null), - }; - - await repo.usage.set({ - ...shared, - model: 'claude-opus-4-7', - modelKey: 'claude-opus-4.7', - }); - await repo.usage.set({ - ...shared, - model: 'claude-opus-4-7', - modelKey: 'claude-opus-4.7-xhigh', - }); - await repo.usage.set({ - ...shared, - model: 'claude-opus-4-7', - modelKey: 'claude-opus-4.7-1m-internal', - }); - await repo.usage.set({ - ...shared, - model: 'gpt-5.3-codex', - modelKey: 'gpt-5.3-codex', - metrics: tokenUsageMetrics({ input: 3, output: 4 }, null), - }); - - const response = await requestApp('/api/token-usage?start=2026-03-17T00&end=2026-03-18T00&view=self-by-key', { headers: { 'x-api-key': apiKey.key } }); - - assertEquals(response.status, 200); - const body = await response.json(); - assertEquals(body.length, 2); - const opus = body.find((record: { model: string }) => record.model === 'claude-opus-4-7'); - const gpt = body.find((record: { model: string }) => record.model === 'gpt-5.3-codex'); - assertExists(opus); - assertExists(gpt); - assertEquals(opus.requests, 3); - assertEquals(displayQuantity(opus, 'input'), '30'); - assertEquals(displayQuantity(opus, 'output'), '15'); - assertEquals(displayQuantity(opus, 'input_cache_read'), '6'); - assertEquals(displayQuantity(opus, 'input_cache_write'), '3'); -}); diff --git a/packages/gateway/src/control-plane/agent-setup_test.ts b/packages/gateway/src/control-plane/agent-setup_test.ts index 661ce83eea..0b65d8f74e 100644 --- a/packages/gateway/src/control-plane/agent-setup_test.ts +++ b/packages/gateway/src/control-plane/agent-setup_test.ts @@ -7,7 +7,7 @@ import { expect, test, vi } from 'vitest'; import { getRepo } from '../repo/index.ts'; import type { ApiKey } from '../repo/types.ts'; -import { requestApp, setupAppTest } from '../test-helpers.ts'; +import { requestApp, setupAppTest } from '../test-utils/app.ts'; import { assertEquals } from '@floway-dev/test-utils'; const RAW_KEY = 'raw-key'; diff --git a/packages/gateway/src/control-plane/api-keys/routes.ts b/packages/gateway/src/control-plane/api-keys/routes.ts index 6d5b8e9529..185d072f5f 100644 --- a/packages/gateway/src/control-plane/api-keys/routes.ts +++ b/packages/gateway/src/control-plane/api-keys/routes.ts @@ -7,6 +7,7 @@ import { CUSTOM_API_KEY_MAX_LENGTH, generateApiKeyToken, type KeySource } from ' import { generateServerSecret } from '../../shared/server-secret.ts'; import type { createKeyBody, rotateKeyBody, updateKeyBody } from '../schemas.ts'; import { ownedKeyOr404 } from '../shared/owned-key.ts'; +import { validateUpstreamIdsExist } from '../shared/upstream-ids.ts'; const GENERATED_KEY_RETRIES = 5; @@ -92,11 +93,9 @@ const validateUpstreamIdsAgainstUserCap = async ( c: AuthedContext, proposed: readonly string[] | null, ): Promise => { + const unknownUpstreamError = await validateUpstreamIdsExist(proposed); + if (unknownUpstreamError !== null) return unknownUpstreamError; if (proposed === null) return null; - const upstreams = await getRepo().upstreams.list(); - const known = new Set(upstreams.map(u => u.id)); - const unknown = proposed.filter(id => !known.has(id)); - if (unknown.length) return `Unknown upstream(s): ${unknown.join(', ')}`; const userCap = userUpstreamIdsFromContext(c); if (userCap === null) return null; diff --git a/packages/gateway/src/control-plane/api-keys/routes_test.ts b/packages/gateway/src/control-plane/api-keys/routes_test.ts index 6d84dcfa98..03df00c787 100644 --- a/packages/gateway/src/control-plane/api-keys/routes_test.ts +++ b/packages/gateway/src/control-plane/api-keys/routes_test.ts @@ -2,7 +2,7 @@ import { test, vi } from 'vitest'; import { initDumpBroker, initDumpStore } from '../../dump/registry.ts'; import { installDumpStubs } from '../../dump/test-fixtures.ts'; -import { buildCustomUpstreamRecord, requestApp, setupAppTest } from '../../test-helpers.ts'; +import { buildCustomUpstreamRecord, requestApp, setupAppTest } from '../../test-utils/app.ts'; import { assertEquals, assertExists } from '@floway-dev/test-utils'; const ownerPatch = (id: string, body: unknown, rawKey: string) => diff --git a/packages/gateway/src/control-plane/auth/routes.ts b/packages/gateway/src/control-plane/auth/routes.ts index 8c9f728fc8..de12ed1131 100644 --- a/packages/gateway/src/control-plane/auth/routes.ts +++ b/packages/gateway/src/control-plane/auth/routes.ts @@ -1,8 +1,9 @@ import { type AuthedContext, sessionIdFromContext, userFromContext } from '../../middleware/auth.ts'; import { type CtxWithJson } from '../../middleware/zod-validator.ts'; import { getRepo } from '../../repo/index.ts'; +import { SEED_ADMIN_USER_ID } from '../../repo/seed-admin.ts'; import type { User } from '../../repo/types.ts'; -import { isProductionRequest } from '../../shared/is-production-request.ts'; +import { isProductionRequest } from '../../runtime/is-production-request.ts'; import { dummyPasswordHash, timingSafeEqual, verifyPassword } from '../../shared/passwords.ts'; import type { authLoginBody } from '../schemas.ts'; import { userToSessionWire } from '../users/wire.ts'; @@ -24,7 +25,7 @@ const resolveLoginUser = async (c: CtxWithJson): Promise ({ - fetchUpstreamModelsCached: () => Promise.resolve([]), - clearInFlightForTesting: () => {}, -})); - import { hashPassword } from '../../shared/passwords.ts'; -import { buildCopilotUpstreamRecord, requestApp, setupAppTest } from '../../test-helpers.ts'; +import { requestApp, setupAppTest } from '../../test-utils/app.ts'; import { initRuntimeKind } from '@floway-dev/platform'; -import { assertEquals, assertStringIncludes, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; +import { assertEquals } from '@floway-dev/test-utils'; // vitest.setup pins the runtime kind to 'node'; the CF-side tests below // re-init and this restores the default so they don't leak. @@ -22,13 +12,6 @@ afterEach(() => { vi.unstubAllEnvs(); }); -const githubUser = { - id: 777, - login: 'octo-auth', - name: 'Octo Auth', - avatar_url: 'https://example.com/octo-auth.png', -}; - test('/auth/login with blank username + ADMIN_KEY logs in as user 1', async () => { const { adminKey } = await setupAppTest(); const response = await requestApp('/auth/login', { @@ -277,229 +260,3 @@ test('/auth/me reports viaApiKey:true and the API key metadata when authed via x assertEquals(body.apiKey.id, apiKey.id); assertEquals(body.apiKey.name, apiKey.name); }); - -test('/api/upstreams/copilot/oauth/device-login/start starts GitHub device flow', async () => { - const { adminSession } = await setupAppTest(); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'github.com' && url.pathname === '/login/device/code') { - return jsonResponse({ device_code: 'device', user_code: 'ABCD', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestApp('/api/upstreams/copilot/oauth/device-login/start', { method: 'POST', headers: { 'x-floway-session': adminSession } }); - assertEquals(response.status, 200); - assertEquals(await response.json(), { device_code: 'device', user_code: 'ABCD', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 }); - }, - ); -}); - -// The blueprint envelope shape the SPA sends when the operator has not yet -// saved a Copilot row. Matches `blueprintUpstreamRecord('copilot')` on the -// wire — the exchange endpoint only reads `id`, `kind`, and -// `proxy_fallback_list` from the envelope, so a minimal literal keeps the -// test focused on the exchange semantics. -const copilotBlueprintEnvelope = { id: '', kind: 'copilot', config: null, state: null }; - -test('/api/upstreams/copilot/oauth/device-login/poll returns a config+state patch and identity from the token exchange', async () => { - const { repo, adminSession } = await setupAppTest(); - await repo.upstreams.deleteAll(); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'github.com' && url.pathname === '/login/oauth/access_token') return jsonResponse({ access_token: 'ghu_new' }); - if (url.hostname === 'api.github.com' && url.pathname === '/user') return jsonResponse(githubUser); - if (url.hostname === 'api.github.com' && url.pathname === '/copilot_internal/v2/token') { - return jsonResponse({ - token: 'ct_new', - expires_at: Math.floor(Date.now() / 1000) + 1500, - refresh_in: 1200, - endpoints: { api: 'https://api.enterprise.githubcopilot.com' }, - }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestApp('/api/upstreams/copilot/oauth/device-login/poll', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-floway-session': adminSession, - }, - body: JSON.stringify({ record: copilotBlueprintEnvelope, deviceCode: 'device' }), - }); - - assertEquals(response.status, 200); - const body = (await response.json()) as { status: string; user: { id: number }; patch: { config: { githubToken: string; user: { id: number } }; state: { copilotToken: { token: string; baseUrl: string } } } }; - assertEquals(body.status, 'complete'); - assertEquals(body.user.id, githubUser.id); - // Create-flow returns the raw patch — no DB write happens here; the - // SPA merges it into the draft and calls POST /api/upstreams to save. - assertEquals(body.patch.config.githubToken, 'ghu_new'); - assertEquals(body.patch.config.user.id, githubUser.id); - assertEquals(body.patch.state.copilotToken.token, 'ct_new'); - assertEquals(body.patch.state.copilotToken.baseUrl, 'https://api.enterprise.githubcopilot.com'); - }, - ); - - // No DB write during create-flow poll — persistence is the caller's - // subsequent POST /api/upstreams. - assertEquals(await repo.upstreams.list(), []); -}); - -test('/api/upstreams/copilot/oauth/device-login/poll rejects failed GitHub user lookup with 502 and no side-effect', async () => { - const { repo, adminSession } = await setupAppTest(); - await repo.upstreams.deleteAll(); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'github.com' && url.pathname === '/login/oauth/access_token') return jsonResponse({ access_token: 'ghu_no_user' }); - if (url.hostname === 'api.github.com' && url.pathname === '/user') return jsonResponse({ message: 'bad credentials' }, 401); - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestApp('/api/upstreams/copilot/oauth/device-login/poll', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-floway-session': adminSession, - }, - body: JSON.stringify({ record: copilotBlueprintEnvelope, deviceCode: 'device' }), - }); - - assertEquals(response.status, 502); - const body = (await response.json()) as { error: string }; - assertStringIncludes(body.error, 'GitHub user lookup failed: 401'); - assertStringIncludes(body.error, 'bad credentials'); - }, - ); - - assertEquals(await repo.upstreams.list(), []); -}); - -test('/api/upstreams/copilot/oauth/device-login/poll rejects a failed token exchange with 502 and no side-effect', async () => { - const { repo, adminSession } = await setupAppTest(); - await repo.upstreams.deleteAll(); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'github.com' && url.pathname === '/login/oauth/access_token') return jsonResponse({ access_token: 'ghu_no_seat' }); - if (url.hostname === 'api.github.com' && url.pathname === '/user') return jsonResponse(githubUser); - if (url.hostname === 'api.github.com' && url.pathname === '/copilot_internal/v2/token') return jsonResponse({ message: 'no copilot seat' }, 403); - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestApp('/api/upstreams/copilot/oauth/device-login/poll', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-floway-session': adminSession, - }, - body: JSON.stringify({ record: copilotBlueprintEnvelope, deviceCode: 'device' }), - }); - - assertEquals(response.status, 502); - const body = (await response.json()) as { error: string }; - assertStringIncludes(body.error, 'Copilot token fetch failed: 403'); - assertStringIncludes(body.error, 'no copilot seat'); - }, - ); - - assertEquals(await repo.upstreams.list(), []); -}); - -test('/api/upstreams/copilot/oauth/device-login/poll rejects a token-exchange response missing endpoints.api with 502 and no side-effect', async () => { - const { repo, adminSession } = await setupAppTest(); - await repo.upstreams.deleteAll(); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'github.com' && url.pathname === '/login/oauth/access_token') return jsonResponse({ access_token: 'ghu_no_endpoint' }); - if (url.hostname === 'api.github.com' && url.pathname === '/user') return jsonResponse(githubUser); - if (url.hostname === 'api.github.com' && url.pathname === '/copilot_internal/v2/token') { - return jsonResponse({ token: 'ct_no_endpoint', expires_at: Math.floor(Date.now() / 1000) + 1500, refresh_in: 1200 }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestApp('/api/upstreams/copilot/oauth/device-login/poll', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-floway-session': adminSession, - }, - body: JSON.stringify({ record: copilotBlueprintEnvelope, deviceCode: 'device' }), - }); - - assertEquals(response.status, 502); - const body = (await response.json()) as { error: string }; - assertStringIncludes(body.error, 'endpoints.api'); - }, - ); - - assertEquals(await repo.upstreams.list(), []); -}); - -test('/api/upstreams/copilot/oauth/device-login/poll targeted-patches config+state on the row identified by record.id', async () => { - const { repo, adminSession, githubAccount } = await setupAppTest({ - githubAccount: { - token: 'ghu_old', - user: githubUser, - }, - }); - const existing = buildCopilotUpstreamRecord(githubAccount, { id: 'up_existing_copilot', name: 'Pinned Copilot', sortOrder: 9 }); - await repo.upstreams.deleteAll(); - await repo.upstreams.save(existing); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'github.com' && url.pathname === '/login/oauth/access_token') return jsonResponse({ access_token: 'ghu_refreshed' }); - if (url.hostname === 'api.github.com' && url.pathname === '/user') return jsonResponse(githubUser); - if (url.hostname === 'api.github.com' && url.pathname === '/copilot_internal/v2/token') { - return jsonResponse({ - token: 'ct_refreshed', - expires_at: Math.floor(Date.now() / 1000) + 1500, - refresh_in: 1200, - endpoints: { api: 'https://api.business.githubcopilot.com' }, - }); - } - // Warmup probes /models on the per-tier host — return an empty catalog - // so the post-persist warm completes without waiting on a real fetch. - if (url.hostname === 'api.business.githubcopilot.com') return jsonResponse({ data: [] }); - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestApp('/api/upstreams/copilot/oauth/device-login/poll', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-floway-session': adminSession, - }, - body: JSON.stringify({ record: { id: 'up_existing_copilot', kind: 'copilot', config: null, state: null }, deviceCode: 'device' }), - }); - assertEquals(response.status, 200); - const body = (await response.json()) as { status: string; patch: { config: { githubToken: string } } }; - assertEquals(body.status, 'complete'); - assertEquals(body.patch.config.githubToken, 'ghu_refreshed'); - }, - ); - - const rows = await repo.upstreams.list(); - assertEquals(rows.length, 1); - // The row-metadata fields (id, name, sortOrder) survive; only config + - // state are overwritten by the credential patch. - assertEquals(rows[0].id, 'up_existing_copilot'); - assertEquals(rows[0].name, 'Pinned Copilot'); - assertEquals(rows[0].sortOrder, 9); - assertEquals((rows[0].config as Record).githubToken, 'ghu_refreshed'); - const persistedState = rows[0].state as { copilotToken: { baseUrl: string } | null } | null; - assertEquals(persistedState?.copilotToken?.baseUrl, 'https://api.business.githubcopilot.com'); -}); diff --git a/packages/gateway/src/control-plane/data-transfer/routes.ts b/packages/gateway/src/control-plane/data-transfer/routes.ts index 8209140c5f..3ed68ce178 100644 --- a/packages/gateway/src/control-plane/data-transfer/routes.ts +++ b/packages/gateway/src/control-plane/data-transfer/routes.ts @@ -8,29 +8,24 @@ // credential-bearing proxy URIs. The endpoint is admin-only; handle the file // with the same care as a DB backup. -import type { Context } from 'hono'; - -import { fetchUpstreamModelsCached } from '../../data-plane/providers/models-cache.ts'; -import { createProvider } from '../../data-plane/providers/registry.ts'; -import { parseSearchConfigDefault, parseSearchConfigStrict } from '../../data-plane/tools/web-search/search-config.ts'; -import type { SearchConfig } from '../../data-plane/tools/web-search/types.ts'; -import { createPerRequestFetcher } from '../../dial/per-request.ts'; +import { parseWebSearchConfigDefault, parseWebSearchConfigStrict } from '../../data-plane/tools/web-search/config.ts'; +import type { WebSearchConfig } from '../../data-plane/tools/web-search/types.ts'; import { notifyDisabledBestEffort } from '../../dump/registry.ts'; import { type CtxWithJson, type CtxWithQuery } from '../../middleware/zod-validator.ts'; import { parseDisabledPublicModelIdsWire } from '../../repo/disabled-public-models.ts'; import { getRepo } from '../../repo/index.ts'; import { DIRECT_FALLBACK_IDS, isDirectFallbackId, normalizeProxyFallbackList } from '../../repo/proxy-fallback-list.ts'; import { isResponsesRetentionSeconds, RESPONSES_RETENTION_MAX_SECONDS, RESPONSES_RETENTION_MIN_SECONDS } from '../../repo/responses-retention.ts'; -import type { ApiKey, PerformanceBucketRow, PerformanceMetric, PerformanceTelemetryRecord, SearchUsageRecord, UsageMetricRecord, UsageRecord, User } from '../../repo/types.ts'; -import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; +import { SEED_ADMIN_USER_ID } from '../../repo/seed-admin.ts'; +import type { ApiKey, PerformanceBucketRow, PerformanceMetric, PerformanceTelemetryRecord, WebSearchUsageRecord, UsageMetricRecord, UsageRecord, User } from '../../repo/types.ts'; import { PASSWORD_HASH_SCHEME } from '../../shared/passwords.ts'; import { RETENTION_MAX_SECONDS } from '../../shared/retention.ts'; import { parseServerSecret } from '../../shared/server-secret.ts'; import { isWebSearchProviderName } from '../../shared/web-search-providers.ts'; -import { parseUpstreamIdsValue } from '../api-keys/upstream-ids.ts'; import { USERNAME_PATTERN, type exportQuery, type importBody } from '../schemas.ts'; import { copilotConfigField, isRecord, nonEmptyStringField } from '../shared/field-validators.ts'; +import { parseUpstreamIdsValue } from '../shared/upstream-ids.ts'; +import { warmModelsCache } from '../shared/warm-models-cache.ts'; import { type SerializedUpstreamRecord, upstreamRecordToFullJson } from '../upstreams/serialize.ts'; import { BILLING_METRICS, canonicalizePricingSelector, type BillingMetric, parseNonNegativeDecimalString, type PricingSelector } from '@floway-dev/protocols/common'; import { ALL_PROVIDER_KINDS, normalizeModelPrefix, normalizeUpstreamColor, parseFlagOverridesWire, parsePerformanceOperation, type ProxyFallbackEntry, type UpstreamProviderKind, type UpstreamRecord } from '@floway-dev/provider'; @@ -59,10 +54,10 @@ interface ExportPayload { upstreams: SerializedUpstreamRecord[]; proxies: SerializedProxy[]; usage: UsageRecord[]; - searchUsage: SearchUsageRecord[]; + searchUsage: WebSearchUsageRecord[]; performance?: PerformanceTelemetryRecord[]; performanceIncluded: boolean; - searchConfig: SearchConfig; + searchConfig: WebSearchConfig; }; } @@ -468,10 +463,10 @@ const parseUsageRecords = (value: unknown): { type: 'ok'; records: UsageRecord[] return { type: 'ok', records }; }; -const parseSearchUsageRecords = (value: unknown): { type: 'ok'; records: SearchUsageRecord[] } | { type: 'invalid'; index: number; error: string } => { +const parseWebSearchUsageRecords = (value: unknown): { type: 'ok'; records: WebSearchUsageRecord[] } | { type: 'invalid'; index: number; error: string } => { if (!Array.isArray(value)) return { type: 'invalid', index: -1, error: 'searchUsage must be an array' }; - const records: SearchUsageRecord[] = []; + const records: WebSearchUsageRecord[] = []; for (let i = 0; i < value.length; i++) { const record = value[i]; if (!record || typeof record !== 'object') return { type: 'invalid', index: i, error: 'record must be an object' }; @@ -494,13 +489,13 @@ const parseSearchUsageRecords = (value: unknown): { type: 'ok'; records: SearchU return { type: 'ok', records }; }; -const parseSearchConfig = (value: unknown): { type: 'ok'; config: SearchConfig } | { type: 'invalid'; error: string } => { +const parseWebSearchConfig = (value: unknown): { type: 'ok'; config: WebSearchConfig } | { type: 'invalid'; error: string } => { // Delegate to the shared strict parser so the import layer and the // load/save helpers cannot drift on what counts as a valid stored // config. The strict parser throws a descriptive Error; we map that // back into the route's structured invalid envelope here. try { - return { type: 'ok', config: parseSearchConfigStrict(value) }; + return { type: 'ok', config: parseWebSearchConfigStrict(value) }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { type: 'invalid', error: message }; @@ -645,37 +640,17 @@ const parsePerformanceRecords = (value: unknown): { type: 'ok'; records: Perform return { type: 'ok', records }; }; -// Synchronously populate the SWR models cache for each saved upstream so the -// dashboard's next navigation lands on a populated row. In merge mode the -// upstreams.save above is an ON CONFLICT UPDATE that does not touch the -// models_cache row through any FK cascade, so without this call a re-import -// that changes an upstream's config would keep serving the prior cached -// model list until SWR's soft window expired. Replace mode wiped the table -// before the loop; warming there is a no-op population. Per-upstream warm -// failures (network blip, dead credential among many) must not abort the -// import — the cache layer persists `lastError` on the row for the dashboard -// to surface. Provider-instance and fetcher construction errors signal -// genuine misconfiguration and are not swallowed. -const warmModelsCache = async (record: UpstreamRecord, c: Context): Promise => { - const scheduler = backgroundSchedulerFromContext(c); - const provider = createProvider(record); - const fetcher = (await createPerRequestFetcher(getRuntimeLocation(c.req.raw)))(record.id); - try { - await fetchUpstreamModelsCached(provider, { scheduler, fetcher, force: true }); - } catch {} -}; - export const exportData = async (c: CtxWithQuery) => { const repo = getRepo(); const includePerformance = c.req.valid('query').include_performance === '1'; - const [users, apiKeys, usage, searchUsage, performance, rawSearchConfig, upstreams, proxies] = await Promise.all([ + const [users, apiKeys, usage, webSearchUsage, performance, rawWebSearchConfig, upstreams, proxies] = await Promise.all([ repo.users.listIncludingDeleted(), repo.apiKeys.listIncludingDeleted(), repo.usage.listAll(), - repo.searchUsage.listAll(), + repo.webSearchUsage.listAll(), includePerformance ? repo.performance.listAll() : Promise.resolve([]), - repo.searchConfig.get(), + repo.webSearchConfig.get(), repo.upstreams.list(), repo.proxies.list(), ]); @@ -689,9 +664,9 @@ export const exportData = async (c: CtxWithQuery) => { upstreams: upstreams.map(upstreamRecordToFullJson), proxies: proxies.map(p => ({ id: p.id, name: p.name, url: p.url, dial_timeout_seconds: p.dialTimeoutSeconds })), usage, - searchUsage, + searchUsage: webSearchUsage, performanceIncluded: includePerformance, - searchConfig: rawSearchConfig === null ? parseSearchConfigDefault() : parseSearchConfigStrict(rawSearchConfig), + searchConfig: rawWebSearchConfig === null ? parseWebSearchConfigDefault() : parseWebSearchConfigStrict(rawWebSearchConfig), }, }; if (includePerformance) payload.data.performance = performance; @@ -718,7 +693,7 @@ export const importData = async (c: CtxWithJson) => { return c.json({ error: `invalid users${location}: ${usersResult.error}` }, 400); } const users = usersResult.records; - if (!users.some(u => u.id === 1)) { + if (!users.some(user => user.id === SEED_ADMIN_USER_ID)) { return c.json({ error: 'invalid users: payload must include user 1 (the seed admin)' }, 400); } const known = new Set(users.map(u => u.id)); @@ -752,18 +727,18 @@ export const importData = async (c: CtxWithJson) => { const proxyIdentityError = validateProxyIdentities(proxies); if (proxyIdentityError) return c.json({ error: `invalid proxies: ${proxyIdentityError}` }, 400); - const searchUsageResult = parseSearchUsageRecords(data.searchUsage); - if (searchUsageResult.type === 'invalid') { - const location = searchUsageResult.index >= 0 ? ` at index ${searchUsageResult.index}` : ''; - return c.json({ error: `invalid searchUsage${location}: ${searchUsageResult.error}` }, 400); + const webSearchUsageResult = parseWebSearchUsageRecords(data.searchUsage); + if (webSearchUsageResult.type === 'invalid') { + const location = webSearchUsageResult.index >= 0 ? ` at index ${webSearchUsageResult.index}` : ''; + return c.json({ error: `invalid searchUsage${location}: ${webSearchUsageResult.error}` }, 400); } - const searchUsage = searchUsageResult.records; + const webSearchUsage = webSearchUsageResult.records; - const searchConfigResult = parseSearchConfig(data.searchConfig); - if (searchConfigResult.type === 'invalid') { - return c.json({ error: `invalid searchConfig: ${searchConfigResult.error}` }, 400); + const webSearchConfigResult = parseWebSearchConfig(data.searchConfig); + if (webSearchConfigResult.type === 'invalid') { + return c.json({ error: `invalid searchConfig: ${webSearchConfigResult.error}` }, 400); } - const searchConfig = searchConfigResult.config; + const webSearchConfig = webSearchConfigResult.config; const performanceIncludedResult = parsePerformanceIncluded(data); if (performanceIncludedResult.type === 'invalid') { @@ -806,7 +781,7 @@ export const importData = async (c: CtxWithJson) => { repo.sessions.deleteAll(), repo.apiKeys.deleteAll(), repo.usage.deleteAll(), - repo.searchUsage.deleteAll(), + repo.webSearchUsage.deleteAll(), repo.upstreams.deleteAll(), repo.proxies.deleteAll(), // proxy_upstream_backoffs is per-deployment runtime state keyed on @@ -845,11 +820,11 @@ export const importData = async (c: CtxWithJson) => { } } for (const record of usage) await repo.usage.set(record); - for (const record of searchUsage) await repo.searchUsage.set(record); + for (const record of webSearchUsage) await repo.webSearchUsage.set(record); for (const upstream of upstreams) await repo.upstreams.save(upstream); await Promise.all(upstreams.map(upstream => warmModelsCache(upstream, c))); for (const record of performance) await repo.performance.set(record); - await repo.searchConfig.save(searchConfig); + await repo.webSearchConfig.save(webSearchConfig); return c.json({ ok: true, @@ -859,7 +834,7 @@ export const importData = async (c: CtxWithJson) => { upstreams: upstreams.length, proxies: proxies.length, usage: usage.length, - searchUsage: searchUsage.length, + searchUsage: webSearchUsage.length, performance: performance.length, }, }); diff --git a/packages/gateway/src/control-plane/data-transfer/routes_test.ts b/packages/gateway/src/control-plane/data-transfer/routes_test.ts index a8b0364807..193debd6ff 100644 --- a/packages/gateway/src/control-plane/data-transfer/routes_test.ts +++ b/packages/gateway/src/control-plane/data-transfer/routes_test.ts @@ -12,13 +12,13 @@ vi.mock('../../data-plane/providers/models-cache.ts', () => ({ })); import { exportData, importData } from './routes.ts'; -import { DEFAULT_SEARCH_CONFIG } from '../../data-plane/tools/web-search/search-config.ts'; +import { DEFAULT_WEB_SEARCH_CONFIG } from '../../data-plane/tools/web-search/config.ts'; import { initDumpBroker, initDumpStore } from '../../dump/registry.ts'; import { installDumpStubs } from '../../dump/test-fixtures.ts'; import { zValidator } from '../../middleware/zod-validator.ts'; import { initRepo } from '../../repo/index.ts'; import { InMemoryRepo } from '../../repo/memory.ts'; -import type { ApiKey, PerformanceTelemetryRecord, SearchUsageRecord, StoredResponsesItem, UsageRecord, User } from '../../repo/types.ts'; +import type { ApiKey, PerformanceTelemetryRecord, WebSearchUsageRecord, StoredResponsesItem, UsageRecord, User } from '../../repo/types.ts'; import { tokenUsageMetrics } from '../../repo/usage-metrics.ts'; import { exportQuery, importBody } from '../schemas.ts'; import { upstreamRecordToFullJson } from '../upstreams/serialize.ts'; @@ -209,7 +209,7 @@ const USAGE_2: UsageRecord = { metrics: tokenUsageMetrics({ input: 2000, output: 800, input_cache_read: 200, input_cache_write: 50 }, null), }; -const SEARCH_USAGE_1: SearchUsageRecord = { +const WEB_SEARCH_USAGE_1: WebSearchUsageRecord = { provider: 'tavily', keyId: 'key-a', action: 'search', @@ -217,7 +217,7 @@ const SEARCH_USAGE_1: SearchUsageRecord = { requests: 2, }; -const SEARCH_USAGE_2: SearchUsageRecord = { +const WEB_SEARCH_USAGE_2: WebSearchUsageRecord = { provider: 'microsoft-grounding', keyId: 'key-b', action: 'fetch_page', @@ -306,7 +306,7 @@ const latestImportData = (overrides: Record = {}) => ({ usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, ...overrides, }); @@ -344,7 +344,7 @@ test('export emits the v17 envelope with users and upstreams', async () => { assertEquals(result.data.searchUsage, []); assertEquals(result.data.performanceIncluded, false); assertEquals(hasOwn(result.data, 'performance'), false); - assertEquals(result.data.searchConfig, DEFAULT_SEARCH_CONFIG); + assertEquals(result.data.searchConfig, DEFAULT_WEB_SEARCH_CONFIG); assertEquals(hasOwn(result.data, 'githubAccounts'), false); assertEquals(hasOwn(result.data, 'upstreamConfigs'), false); }); @@ -356,9 +356,9 @@ test('export includes full upstream configs and omits performance by default', a await repo.upstreams.save(CUSTOM_UPSTREAM); await repo.upstreams.save(AZURE_UPSTREAM); await repo.usage.set(USAGE_1); - await repo.searchUsage.set(SEARCH_USAGE_1); + await repo.webSearchUsage.set(WEB_SEARCH_USAGE_1); await repo.performance.set(PERFORMANCE_1); - await repo.searchConfig.save({ + await repo.webSearchConfig.save({ provider: 'tavily', tavily: { apiKey: 'tvly-test' }, microsoftGrounding: { apiKey: 'ms-test' }, @@ -374,7 +374,7 @@ test('export includes full upstream configs and omits performance by default', a assertEquals(result.data.upstreams.find((upstream: any) => upstream.id === 'up_copilot_a').config.githubToken, 'ghu-alice'); assertEquals(result.data.upstreams.find((upstream: any) => upstream.id === 'up_azure_a').config.apiKey, 'az-key'); assertEquals(result.data.usage, [USAGE_1]); - assertEquals(result.data.searchUsage, [SEARCH_USAGE_1]); + assertEquals(result.data.searchUsage, [WEB_SEARCH_USAGE_1]); assertEquals(result.data.performanceIncluded, false); assertEquals(hasOwn(result.data, 'performance'), false); assertEquals(result.data.searchConfig.provider, 'tavily'); @@ -424,9 +424,9 @@ test('import replace writes upstreams and clears replaced collections', async () await repo.apiKeys.save({ ...KEY_A, responsesRetentionSeconds: 24 * 60 * 60 }); await repo.upstreams.save(CUSTOM_UPSTREAM); await repo.usage.set(USAGE_1); - await repo.searchUsage.set(SEARCH_USAGE_1); + await repo.webSearchUsage.set(WEB_SEARCH_USAGE_1); await repo.responsesItems.insertMany([STORED_RESPONSES_ITEM], 0); - await repo.searchConfig.save({ + await repo.webSearchConfig.save({ provider: 'tavily', tavily: { apiKey: 'old' }, microsoftGrounding: { apiKey: '' }, @@ -439,7 +439,7 @@ test('import replace writes upstreams and clears replaced collections', async () apiKeys: [KEY_B], upstreams: [upstreamRecordToFullJson(AZURE_UPSTREAM)], usage: [USAGE_2], - searchUsage: [SEARCH_USAGE_2], + searchUsage: [WEB_SEARCH_USAGE_2], performanceIncluded: false, searchConfig: { provider: 'microsoft-grounding', @@ -457,9 +457,9 @@ test('import replace writes upstreams and clears replaced collections', async () assertEquals(restoredKey, KEY_B); assertEquals(await repo.upstreams.list(), [AZURE_UPSTREAM]); assertEquals(await repo.usage.listAll(), [USAGE_2]); - assertEquals(await repo.searchUsage.listAll(), [SEARCH_USAGE_2]); + assertEquals(await repo.webSearchUsage.listAll(), [WEB_SEARCH_USAGE_2]); assertEquals(await repo.responsesItems.lookupMany('key-a', [STORED_RESPONSES_ITEM.id], 0), []); - assertEquals(await repo.searchConfig.get(), { + assertEquals(await repo.webSearchConfig.get(), { provider: 'microsoft-grounding', tavily: { apiKey: '' }, microsoftGrounding: { apiKey: 'ms-new' }, @@ -474,7 +474,7 @@ test('replace import preserves API-key IDs and imported references', async () => const result = await doImport(app, 'replace', latestImportData({ apiKeys: [KEY_A], usage: [USAGE_1], - searchUsage: [SEARCH_USAGE_1], + searchUsage: [WEB_SEARCH_USAGE_1], performanceIncluded: true, performance: [PERFORMANCE_1], })); @@ -484,7 +484,7 @@ test('replace import preserves API-key IDs and imported references', async () => if (restored === null) throw new Error('restored key missing'); assertEquals(restored.id, KEY_A.id); assertEquals((await repo.usage.listAll())[0].keyId, KEY_A.id); - assertEquals((await repo.searchUsage.listAll())[0].keyId, KEY_A.id); + assertEquals((await repo.webSearchUsage.listAll())[0].keyId, KEY_A.id); assertEquals((await repo.performance.listAll())[0].keyId, KEY_A.id); }); @@ -493,14 +493,14 @@ test('import merge upserts by repository key without clearing unrelated rows', a await repo.apiKeys.save(KEY_A); await repo.upstreams.save(CUSTOM_UPSTREAM); await repo.usage.set({ ...USAGE_1, requests: 10 }); - await repo.searchUsage.set({ ...SEARCH_USAGE_1, requests: 10 }); + await repo.webSearchUsage.set({ ...WEB_SEARCH_USAGE_1, requests: 10 }); const updatedCustom = { ...CUSTOM_UPSTREAM, name: 'Custom Updated', updatedAt: '2026-03-01T00:00:00.000Z' } satisfies UpstreamRecord; const result = await doImport(app, 'merge', latestImportData({ apiKeys: [{ ...KEY_A, name: 'Alice Updated' }, KEY_B], upstreams: [upstreamRecordToFullJson(updatedCustom), upstreamRecordToFullJson(COPILOT_UPSTREAM)], usage: [USAGE_1], - searchUsage: [SEARCH_USAGE_1], + searchUsage: [WEB_SEARCH_USAGE_1], })); assertEquals(result.status, 200); @@ -510,7 +510,7 @@ test('import merge upserts by repository key without clearing unrelated rows', a ['up_custom_a', 'Custom Updated'], ]); assertEquals(await repo.usage.listAll(), [USAGE_1]); - assertEquals(await repo.searchUsage.listAll(), [SEARCH_USAGE_1]); + assertEquals(await repo.webSearchUsage.listAll(), [WEB_SEARCH_USAGE_1]); }); test('import replace handles performance inclusion explicitly', async () => { @@ -529,7 +529,7 @@ test('import replace handles performance inclusion explicitly', async () => { searchUsage: [], performanceIncluded: true, performance: [PERFORMANCE_2], - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }); assertEquals(replace.status, 200); @@ -659,7 +659,7 @@ test('import rejects missing upstreams before clearing existing data', async () usage: [USAGE_2], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }); assertEquals(result.status, 400); @@ -672,7 +672,7 @@ test('import rejects missing upstreams before clearing existing data', async () test('codex upstreams export and import round-trip with state intact', async () => { const { app, repo } = setup(); await repo.upstreams.save(CODEX_UPSTREAM); - await repo.searchConfig.save(DEFAULT_SEARCH_CONFIG); + await repo.webSearchConfig.save(DEFAULT_WEB_SEARCH_CONFIG); const result = await doExport(app); const exportedCodex = result.data.upstreams.find((upstream: any) => upstream.id === 'up_codex_a'); @@ -686,7 +686,7 @@ test('codex upstreams export and import round-trip with state intact', async () usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }); assertEquals(replaceResult.status, 200); assertEquals(await repo.upstreams.list(), [CODEX_UPSTREAM]); @@ -702,7 +702,7 @@ test('codex import rejects when state is missing', async () => { usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }); assertEquals(result.status, 400); assertEquals(result.body.error.includes('codex upstream is missing state'), true); @@ -718,7 +718,7 @@ test('codex import rejects unknown keys in state', async () => { usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }); assertEquals(result.status, 400); assertEquals(result.body.error.includes('unexpected key'), true); @@ -768,7 +768,7 @@ test('import rejects invalid records before clearing existing data', async () => const { app, repo } = setup(); await repo.apiKeys.save(KEY_A); await repo.upstreams.save(CUSTOM_UPSTREAM); - await repo.searchUsage.set(SEARCH_USAGE_1); + await repo.webSearchUsage.set(WEB_SEARCH_USAGE_1); const badApiKeys = await doImport(app, 'replace', { users: [SEED_ADMIN], @@ -777,7 +777,7 @@ test('import rejects invalid records before clearing existing data', async () => usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }); const badUsage = await doImport(app, 'replace', { users: [SEED_ADMIN], @@ -786,7 +786,7 @@ test('import rejects invalid records before clearing existing data', async () => usage: [{ ...USAGE_2, requests: -1 }], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }); const badUpstream = await doImport(app, 'replace', { users: [SEED_ADMIN], @@ -795,7 +795,7 @@ test('import rejects invalid records before clearing existing data', async () => usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }); const badFixes = await doImport(app, 'replace', { users: [SEED_ADMIN], @@ -804,16 +804,16 @@ test('import rejects invalid records before clearing existing data', async () => usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }); - const badSearchUsage = await doImport(app, 'replace', { + const badWebSearchUsage = await doImport(app, 'replace', { users: [SEED_ADMIN], apiKeys: [], upstreams: [], usage: [], searchUsage: [{ provider: 'not-real', keyId: 'key-a', hour: '2026-01-01T10', requests: 1 }], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }); assertEquals(badApiKeys.status, 400); @@ -824,11 +824,11 @@ test('import rejects invalid records before clearing existing data', async () => assertEquals(String(badUpstream.body.error).includes('invalid upstreams at index 0'), true); assertEquals(badFixes.status, 400); assertEquals(badFixes.body.error, 'invalid upstreams at index 0: Unknown flag_overrides ids: made-up-fix'); - assertEquals(badSearchUsage.status, 400); - assertEquals(badSearchUsage.body.error, 'invalid searchUsage at index 0: invalid provider'); + assertEquals(badWebSearchUsage.status, 400); + assertEquals(badWebSearchUsage.body.error, 'invalid searchUsage at index 0: invalid provider'); assertEquals(await repo.apiKeys.list(), [KEY_A]); assertEquals(await repo.upstreams.list(), [CUSTOM_UPSTREAM]); - assertEquals(await repo.searchUsage.listAll(), [SEARCH_USAGE_1]); + assertEquals(await repo.webSearchUsage.listAll(), [WEB_SEARCH_USAGE_1]); }); test('import rejects api key unique identity conflicts before mutating', async () => { @@ -992,22 +992,22 @@ test('import rejects missing latest-v17 arrays before clearing existing data', a await repo.apiKeys.save(KEY_A); await repo.upstreams.save(CUSTOM_UPSTREAM); await repo.usage.set(USAGE_1); - await repo.searchUsage.set(SEARCH_USAGE_1); + await repo.webSearchUsage.set(WEB_SEARCH_USAGE_1); const missingApiKeys = await doImport(app, 'replace', latestImportData({ apiKeys: undefined })); const missingUsage = await doImport(app, 'replace', latestImportData({ usage: undefined })); - const missingSearchUsage = await doImport(app, 'replace', latestImportData({ searchUsage: undefined })); + const missingWebSearchUsage = await doImport(app, 'replace', latestImportData({ searchUsage: undefined })); assertEquals(missingApiKeys.status, 400); assertEquals(missingApiKeys.body.error, 'invalid apiKeys: apiKeys must be an array'); assertEquals(missingUsage.status, 400); assertEquals(missingUsage.body.error, 'invalid usage: usage must be an array'); - assertEquals(missingSearchUsage.status, 400); - assertEquals(missingSearchUsage.body.error, 'invalid searchUsage: searchUsage must be an array'); + assertEquals(missingWebSearchUsage.status, 400); + assertEquals(missingWebSearchUsage.body.error, 'invalid searchUsage: searchUsage must be an array'); assertEquals(await repo.apiKeys.list(), [KEY_A]); assertEquals(await repo.upstreams.list(), [CUSTOM_UPSTREAM]); assertEquals(await repo.usage.listAll(), [USAGE_1]); - assertEquals(await repo.searchUsage.listAll(), [SEARCH_USAGE_1]); + assertEquals(await repo.webSearchUsage.listAll(), [WEB_SEARCH_USAGE_1]); }); test('import validates mode and data before mutating', async () => { @@ -1205,7 +1205,7 @@ test('v17 import rejects api_keys whose user_id does not appear in the payload', usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }, 17); assertEquals(result.status, 400); @@ -1222,7 +1222,7 @@ test('v17 import rejects malformed users (bad username, bad password_hash)', asy usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }, 17); assertEquals(badUsername.status, 400); assertEquals(String(badUsername.body.error).startsWith('invalid users at index 0:'), true); @@ -1234,7 +1234,7 @@ test('v17 import rejects malformed users (bad username, bad password_hash)', asy usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }, 17); assertEquals(badHash.status, 400); assertEquals(String(badHash.body.error).includes('passwordHash'), true); @@ -1253,7 +1253,7 @@ test('import rejects a pre-accounts v3 export instead of coercing its legacy api usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }, 3); assertEquals(result.status, 400); @@ -1277,7 +1277,7 @@ test('replace-mode import clears sessions before writing users', async () => { usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }, 17); assertEquals(result.status, 200); @@ -1296,7 +1296,7 @@ test('v17 import rejects users[i].upstreamIds === undefined', async () => { usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }, 17); assertEquals(result.status, 400); expect(result.body.error).toMatch(/upstreamIds/); @@ -1311,7 +1311,7 @@ test('v17 import rejects users[i].deletedAt of non-string non-null type', async usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }, 17); assertEquals(result.status, 400); expect(result.body.error).toMatch(/deletedAt/); @@ -1326,7 +1326,7 @@ test('v17 replace import refuses payload missing user 1', async () => { usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }, 17); assertEquals(result.status, 400); expect(result.body.error).toMatch(/user 1/); @@ -1344,8 +1344,8 @@ test('a full v17 export re-imports verbatim — the export→import round trip i await repo.upstreams.save(CODEX_UPSTREAM); await repo.usage.set(USAGE_1); await repo.usage.set(USAGE_2); - await repo.searchUsage.set(SEARCH_USAGE_1); - await repo.searchUsage.set(SEARCH_USAGE_2); + await repo.webSearchUsage.set(WEB_SEARCH_USAGE_1); + await repo.webSearchUsage.set(WEB_SEARCH_USAGE_2); await repo.performance.set(PERFORMANCE_1); await repo.performance.set(PERFORMANCE_2); const config = { @@ -1355,7 +1355,7 @@ test('a full v17 export re-imports verbatim — the export→import round trip i jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }; - await repo.searchConfig.save(config); + await repo.webSearchConfig.save(config); const exported = await doExport(app, true); assertEquals(exported.version, 17); @@ -1375,7 +1375,7 @@ test('a full v17 export re-imports verbatim — the export→import round trip i if (restoredKeyA === null) throw new Error('restored key A missing'); assertEquals((await repo.usage.listAll()).find(u => u.keyId === restoredKeyA.id && u.hour === USAGE_1.hour), { ...USAGE_1, keyId: restoredKeyA.id }); assertEquals((await repo.performance.listAll()).find(p => p.keyId === restoredKeyA.id && p.hour === PERFORMANCE_1.hour), { ...PERFORMANCE_1, keyId: restoredKeyA.id }); - assertEquals(await repo.searchConfig.get(), config); + assertEquals(await repo.webSearchConfig.get(), config); }); test('any data bearing a historical version is rejected on the version gate, before mutating', async () => { @@ -1393,7 +1393,7 @@ test('any data bearing a historical version is rejected on the version gate, bef usage: [], searchUsage: [], performanceIncluded: false, - searchConfig: DEFAULT_SEARCH_CONFIG, + searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }; for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]) { diff --git a/packages/gateway/src/control-plane/dump_test.ts b/packages/gateway/src/control-plane/dump_test.ts index 031206db52..c8a11d02d7 100644 --- a/packages/gateway/src/control-plane/dump_test.ts +++ b/packages/gateway/src/control-plane/dump_test.ts @@ -1,10 +1,10 @@ import { test } from 'vitest'; import { initDumpBroker, initDumpStore } from '../dump/registry.ts'; +import type { DumpStore } from '../dump/store-contract.ts'; import { fakeMeta as baseFakeMeta, fakeRecord as baseFakeRecord, installDumpStubs } from '../dump/test-fixtures.ts'; -import { requestApp, setupAppTest } from '../test-helpers.ts'; -import type { DumpStore } from '@floway-dev/gateway'; -import type { DumpMetadata, DumpRecord, StoredDumpRecord } from '@floway-dev/gateway/dump-types'; +import type { DumpMetadata, DumpRecord, StoredDumpRecord } from '../dump/types.ts'; +import { requestApp, setupAppTest } from '../test-utils/app.ts'; import { assertEquals, assertExists } from '@floway-dev/test-utils'; const fakeMeta = (id: string, completedAt: number): DumpMetadata => diff --git a/packages/gateway/src/control-plane/model-aliases/routes.ts b/packages/gateway/src/control-plane/model-aliases/routes.ts index 5c7c9ca9a9..86376d94b6 100644 --- a/packages/gateway/src/control-plane/model-aliases/routes.ts +++ b/packages/gateway/src/control-plane/model-aliases/routes.ts @@ -7,11 +7,8 @@ import type { Context } from 'hono'; import { recordToWire, wireToRecord } from './serialize.ts'; import { type CtxWithJson } from '../../middleware/zod-validator.ts'; import { getRepo } from '../../repo/index.ts'; -import type { ModelAliasRecord } from '../../repo/types.ts'; import type { createAliasBody, updateAliasBody } from '../schemas.ts'; - -const nextSortOrder = (existing: readonly ModelAliasRecord[]): number => - existing.reduce((acc, record) => Math.max(acc, record.sortOrder), -1) + 1; +import { nextSortOrder } from '../shared/sort-order.ts'; // Both D1 and node:sqlite raise a UNIQUE-constraint error naming the column; // the repo layer's alias INSERT / rename UPDATE lets that bubble up so the diff --git a/packages/gateway/src/control-plane/model-aliases/routes_test.ts b/packages/gateway/src/control-plane/model-aliases/routes_test.ts index fb7936d49e..c7a8236792 100644 --- a/packages/gateway/src/control-plane/model-aliases/routes_test.ts +++ b/packages/gateway/src/control-plane/model-aliases/routes_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { requestApp, setupAppTest } from '../../test-helpers.ts'; +import { requestApp, setupAppTest } from '../../test-utils/app.ts'; import type { ModelAlias } from '@floway-dev/protocols/common'; import { assertEquals, assertExists } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/control-plane/models/routes.ts b/packages/gateway/src/control-plane/models/routes.ts index eb857ac54b..984ebdb3e4 100644 --- a/packages/gateway/src/control-plane/models/routes.ts +++ b/packages/gateway/src/control-plane/models/routes.ts @@ -1,14 +1,14 @@ -import type { Context } from 'hono'; - import { toPublicModel } from '../../data-plane/models/load.ts'; import { MODEL_LISTING_FAILURE_MESSAGE } from '../../data-plane/models/shared.ts'; import { type AddressableIdEntry, enumerateAddressableModelIds, listedRealModels } from '../../data-plane/shared/listing/addressable.ts'; import { mergeAliasesIntoModels } from '../../data-plane/shared/listing/alias.ts'; import { createPerRequestFetcher } from '../../dial/per-request.ts'; import { effectiveUpstreamIdsFromContext, userFromContext } from '../../middleware/auth.ts'; +import type { CtxWithQuery } from '../../middleware/zod-validator.ts'; import { getRepo } from '../../repo/index.ts'; import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; +import type { modelsQuery } from '../schemas.ts'; import type { PublicModel, PublicModelsResponse } from '@floway-dev/protocols/common'; import { ProviderModelsUnavailableError } from '@floway-dev/provider'; import type { InternalModel, Provider, UpstreamColor, UpstreamProviderKind } from '@floway-dev/provider'; @@ -37,9 +37,9 @@ const toControlPlaneModel = ( ...toPublicModel(model), upstreams: instances.map(instance => ({ kind: instance.kind, - id: instance.upstream, + id: instance.upstreamId, name: instance.name, - color: colorByUpstream.get(instance.upstream) ?? null, + color: colorByUpstream.get(instance.upstreamId) ?? null, })), }); @@ -60,10 +60,11 @@ const toUnlistedControlPlaneModel = ( unlisted: true, }); -export const controlPlaneModels = async (c: Context) => { +export const controlPlaneModels = async (c: CtxWithQuery) => { try { - const includeAliases = c.req.query('aliases') !== 'false'; - const includeUnlisted = c.req.query('include_unlisted') === 'true'; + const { aliases: aliasesValue, include_unlisted: includeUnlistedValue } = c.req.valid('query'); + const includeAliases = aliasesValue !== 'false'; + const includeUnlisted = includeUnlistedValue === 'true'; // Admin sessions see the entire gateway: editor surfaces (alias edit, // upstream edit) need to configure models on upstreams the admin may // have self-restricted out of their own data-plane access, and the diff --git a/packages/gateway/src/control-plane/models/routes_test.ts b/packages/gateway/src/control-plane/models/routes_test.ts index fa86f342f1..c6ba6e71bd 100644 --- a/packages/gateway/src/control-plane/models/routes_test.ts +++ b/packages/gateway/src/control-plane/models/routes_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { buildCustomUpstreamRecord, copilotModels, requestApp, setupAppTest } from '../../test-helpers.ts'; +import { buildCustomUpstreamRecord, copilotModels, requestApp, setupAppTest } from '../../test-utils/app.ts'; import type { UpstreamRecord } from '@floway-dev/provider'; import { assert, assertEquals, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/control-plane/performance/routes_test.ts b/packages/gateway/src/control-plane/performance/routes_test.ts index e484c3ac68..e2adf14c8f 100644 --- a/packages/gateway/src/control-plane/performance/routes_test.ts +++ b/packages/gateway/src/control-plane/performance/routes_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { requestApp, setupAppTest } from '../../test-helpers.ts'; +import { requestApp, setupAppTest } from '../../test-utils/app.ts'; import { assertEquals } from '@floway-dev/test-utils'; test('/api/performance/overview modelRows carry backend-aggregated base-model percentiles', async () => { diff --git a/packages/gateway/src/control-plane/proxies/routes_test.ts b/packages/gateway/src/control-plane/proxies/routes_test.ts index 84932f4ba5..7b2e40eb45 100644 --- a/packages/gateway/src/control-plane/proxies/routes_test.ts +++ b/packages/gateway/src/control-plane/proxies/routes_test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, test } from 'vitest'; import type { SerializedBackoffRow, SerializedProxyRecord } from './serialize.ts'; -import { requestApp, setupAppTest } from '../../test-helpers.ts'; +import { requestApp, setupAppTest } from '../../test-utils/app.ts'; import { initSocketDial, resetSocketDialForTesting, type SocketDial } from '@floway-dev/platform'; import { assertEquals, assertExists } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/control-plane/routes.ts b/packages/gateway/src/control-plane/routes.ts index df8364c7bf..165f5fc8c7 100644 --- a/packages/gateway/src/control-plane/routes.ts +++ b/packages/gateway/src/control-plane/routes.ts @@ -9,11 +9,15 @@ import { createAlias, deleteAlias, listAliases, updateAlias } from './model-alia import { controlPlaneModels } from './models/routes.ts'; import { performanceOverview } from './performance/routes.ts'; import { createProxy, deleteProxy, listAllBackoffs, listProxies, listProxyBackoffs, resetProxyBackoffs, testProxy, updateProxy } from './proxies/routes.ts'; -import { authLoginBody, changeOwnPasswordBody, claudeCodeOauthAuthorizeUrlBody, claudeCodeOauthExchangeBody, claudeCodeOauthRefreshBody, claudeCodeProbeBody, claudeCodeSetupTokenAuthorizeUrlBody, claudeCodeSetupTokenExchangeBody, codexOauthAuthorizeUrlBody, codexOauthExchangeBody, codexOauthRefreshBody, copilotOauthDeviceLoginPollBody, copilotQuotaBody, createAliasBody, createKeyBody, createProxyBody, createUpstreamBody, createUserBody, exportQuery, importBody, listModelsBody, modelsQuery, performanceQuery, resetBackoffBody, rotateKeyBody, searchConfigSchema, searchUsageQuery, testProxyBody, tokenUsageQuery, updateAliasBody, updateKeyBody, updateProxyBody, updateUpstreamBody, updateUserBody } from './schemas.ts'; -import { getSearchConfigRoute, putSearchConfigRoute, testSearchConfigRoute } from './search-config/routes.ts'; -import { searchUsage } from './search-usage/routes.ts'; +import { authLoginBody, changeOwnPasswordBody, claudeCodeOAuthAuthorizeUrlBody, claudeCodeOAuthExchangeBody, claudeCodeOAuthRefreshBody, claudeCodeProbeBody, claudeCodeSetupTokenAuthorizeUrlBody, claudeCodeSetupTokenExchangeBody, codexOAuthAuthorizeUrlBody, codexOAuthExchangeBody, codexOAuthRefreshBody, copilotOAuthDeviceLoginPollBody, copilotQuotaBody, createAliasBody, createKeyBody, createProxyBody, createUpstreamBody, createUserBody, exportQuery, importBody, listModelsBody, modelsQuery, performanceQuery, resetBackoffBody, rotateKeyBody, webSearchConfigSchema, webSearchUsageQuery, testProxyBody, tokenUsageQuery, updateAliasBody, updateKeyBody, updateProxyBody, updateUpstreamBody, updateUserBody } from './schemas.ts'; +import { getWebSearchConfigRoute, putWebSearchConfigRoute, testWebSearchConfigRoute } from './search-config/routes.ts'; +import { webSearchUsage } from './search-usage/routes.ts'; import { tokenUsage } from './token-usage/routes.ts'; -import { claudeCodeOauthAuthorizeUrl, claudeCodeOauthExchange, claudeCodeOauthRefresh, claudeCodeProbe, claudeCodeSetupTokenAuthorizeUrl, claudeCodeSetupTokenExchange, codexOauthAuthorizeUrl, codexOauthExchange, codexOauthRefresh, copilotOauthDeviceLoginPoll, copilotOauthDeviceLoginStart, copilotQuota, createUpstream, deleteUpstream, getUpstream, getUpstreamBlueprint, listModels, listOptionalFlags, listUpstreamOptions, listUpstreams, updateUpstream } from './upstreams/routes.ts'; +import { claudeCodeOAuthAuthorizeUrl, claudeCodeOAuthExchange, claudeCodeOAuthRefresh, claudeCodeProbe, claudeCodeSetupTokenAuthorizeUrl, claudeCodeSetupTokenExchange } from './upstreams/claude-code.ts'; +import { codexOAuthAuthorizeUrl, codexOAuthExchange, codexOAuthRefresh } from './upstreams/codex.ts'; +import { copilotOAuthDeviceLoginPoll, copilotOAuthDeviceLoginStart, copilotQuota } from './upstreams/copilot.ts'; +import { listModels } from './upstreams/models.ts'; +import { createUpstream, deleteUpstream, getUpstream, getUpstreamBlueprint, listOptionalFlags, listUpstreamOptions, listUpstreams, updateUpstream } from './upstreams/routes.ts'; import { changeOwnPassword, createUser, deleteUser, listUsers, updateUser } from './users/routes.ts'; import { type AuthedContext, type AuthVars, userFromContext } from '../middleware/auth.ts'; import { zValidator } from '../middleware/zod-validator.ts'; @@ -46,7 +50,7 @@ export const controlPlaneRoutes = new Hono<{ Variables: AuthVars }>() .patch('/api/keys/:id', zValidator('json', updateKeyBody), updateKey) .delete('/api/keys/:id', deleteKey) .get('/api/token-usage', zValidator('query', tokenUsageQuery), tokenUsage) - .get('/api/search-usage', zValidator('query', searchUsageQuery), searchUsage) + .get('/api/search-usage', zValidator('query', webSearchUsageQuery), webSearchUsage) .get('/api/performance/overview', zValidator('query', performanceQuery), performanceOverview) .get('/api/models', zValidator('query', modelsQuery), controlPlaneModels) // Minimal upstream picker exposed to non-admin users so they can scope a key @@ -73,15 +77,15 @@ export const controlPlaneRoutes = new Hono<{ Variables: AuthVars }>() .get('/upstreams', listUpstreams) .get('/upstreams/blueprint', getUpstreamBlueprint) .get('/upstreams/flags', listOptionalFlags) - .post('/upstreams/copilot/oauth/device-login/start', copilotOauthDeviceLoginStart) - .post('/upstreams/copilot/oauth/device-login/poll', zValidator('json', copilotOauthDeviceLoginPollBody), copilotOauthDeviceLoginPoll) + .post('/upstreams/copilot/oauth/device-login/start', copilotOAuthDeviceLoginStart) + .post('/upstreams/copilot/oauth/device-login/poll', zValidator('json', copilotOAuthDeviceLoginPollBody), copilotOAuthDeviceLoginPoll) .post('/upstreams/copilot/quota', zValidator('json', copilotQuotaBody), copilotQuota) - .post('/upstreams/codex/oauth/authorize-url', zValidator('json', codexOauthAuthorizeUrlBody), codexOauthAuthorizeUrl) - .post('/upstreams/codex/oauth/exchange', zValidator('json', codexOauthExchangeBody), codexOauthExchange) - .post('/upstreams/codex/oauth/refresh', zValidator('json', codexOauthRefreshBody), codexOauthRefresh) - .post('/upstreams/claude-code/oauth/authorize-url', zValidator('json', claudeCodeOauthAuthorizeUrlBody), claudeCodeOauthAuthorizeUrl) - .post('/upstreams/claude-code/oauth/exchange', zValidator('json', claudeCodeOauthExchangeBody), claudeCodeOauthExchange) - .post('/upstreams/claude-code/oauth/refresh', zValidator('json', claudeCodeOauthRefreshBody), claudeCodeOauthRefresh) + .post('/upstreams/codex/oauth/authorize-url', zValidator('json', codexOAuthAuthorizeUrlBody), codexOAuthAuthorizeUrl) + .post('/upstreams/codex/oauth/exchange', zValidator('json', codexOAuthExchangeBody), codexOAuthExchange) + .post('/upstreams/codex/oauth/refresh', zValidator('json', codexOAuthRefreshBody), codexOAuthRefresh) + .post('/upstreams/claude-code/oauth/authorize-url', zValidator('json', claudeCodeOAuthAuthorizeUrlBody), claudeCodeOAuthAuthorizeUrl) + .post('/upstreams/claude-code/oauth/exchange', zValidator('json', claudeCodeOAuthExchangeBody), claudeCodeOAuthExchange) + .post('/upstreams/claude-code/oauth/refresh', zValidator('json', claudeCodeOAuthRefreshBody), claudeCodeOAuthRefresh) .post('/upstreams/claude-code/setup-token/authorize-url', zValidator('json', claudeCodeSetupTokenAuthorizeUrlBody), claudeCodeSetupTokenAuthorizeUrl) .post('/upstreams/claude-code/setup-token/exchange', zValidator('json', claudeCodeSetupTokenExchangeBody), claudeCodeSetupTokenExchange) .post('/upstreams/claude-code/probe', zValidator('json', claudeCodeProbeBody), claudeCodeProbe) @@ -106,8 +110,8 @@ export const controlPlaneRoutes = new Hono<{ Variables: AuthVars }>() .post('/aliases', zValidator('json', createAliasBody), createAlias) .put('/aliases/:name', zValidator('json', updateAliasBody), updateAlias) .delete('/aliases/:name', deleteAlias) - .get('/search-config', getSearchConfigRoute) - .put('/search-config', zValidator('json', searchConfigSchema), putSearchConfigRoute) - .post('/search-config/test', zValidator('json', searchConfigSchema), testSearchConfigRoute) + .get('/search-config', getWebSearchConfigRoute) + .put('/search-config', zValidator('json', webSearchConfigSchema), putWebSearchConfigRoute) + .post('/search-config/test', zValidator('json', webSearchConfigSchema), testWebSearchConfigRoute) .get('/export', zValidator('query', exportQuery), exportData) .post('/import', zValidator('json', importBody), importData)); diff --git a/packages/gateway/src/control-plane/schemas.ts b/packages/gateway/src/control-plane/schemas.ts index acc740559b..dc8bd2696c 100644 --- a/packages/gateway/src/control-plane/schemas.ts +++ b/packages/gateway/src/control-plane/schemas.ts @@ -418,7 +418,7 @@ export const upstreamRecordEnvelope = z.object({ // beyond `record` (refresh, probe, quota, list-models) shares this shape. const recordOnlyBody = z.object({ record: upstreamRecordEnvelope }); -export const copilotOauthDeviceLoginPollBody = z.object({ +export const copilotOAuthDeviceLoginPollBody = z.object({ record: upstreamRecordEnvelope, deviceCode: z.string().min(1), }); @@ -433,13 +433,13 @@ export const copilotQuotaBody = recordOnlyBody; // them into the upstream's authorize URL. The server never sees the // verifier until the callback comes back as `{code, verifier}` on exchange. -export const codexOauthAuthorizeUrlBody = z.object({ +export const codexOAuthAuthorizeUrlBody = z.object({ record: upstreamRecordEnvelope, challenge: z.string().min(1), state: z.string().min(1), }); -export const codexOauthExchangeBody = z.object({ +export const codexOAuthExchangeBody = z.object({ record: upstreamRecordEnvelope, auth_json: z.string().min(1).optional(), callback: z.object({ @@ -451,7 +451,7 @@ export const codexOauthExchangeBody = z.object({ { message: 'Provide exactly one of auth_json or callback' }, ); -export const codexOauthRefreshBody = recordOnlyBody; +export const codexOAuthRefreshBody = recordOnlyBody; // --- claude-code OAuth + setup-token + probe (record-body contract) --- @@ -462,13 +462,13 @@ const oauthCallbackSchema = z.object({ state: z.string().min(1), }); -export const claudeCodeOauthAuthorizeUrlBody = z.object({ +export const claudeCodeOAuthAuthorizeUrlBody = z.object({ record: upstreamRecordEnvelope, challenge: z.string().min(1), state: z.string().min(1), }); -export const claudeCodeOauthExchangeBody = z.object({ +export const claudeCodeOAuthExchangeBody = z.object({ record: upstreamRecordEnvelope, credentials_json: z.string().min(1).optional(), callback: oauthCallbackSchema.optional(), @@ -477,7 +477,7 @@ export const claudeCodeOauthExchangeBody = z.object({ { message: 'Provide exactly one of credentials_json or callback' }, ); -export const claudeCodeOauthRefreshBody = recordOnlyBody; +export const claudeCodeOAuthRefreshBody = recordOnlyBody; export const claudeCodeSetupTokenAuthorizeUrlBody = z.object({ record: upstreamRecordEnvelope, @@ -557,7 +557,7 @@ export const resetBackoffBody = z.object({ // --- search config --- -export const searchConfigSchema = z.object({ +export const webSearchConfigSchema = z.object({ provider: z.enum(['disabled', 'tavily', 'microsoft-grounding', 'jina']), tavily: z.object({ apiKey: z.string() }), microsoftGrounding: z.object({ apiKey: z.string() }), @@ -744,7 +744,7 @@ export const modelsQuery = z.object({ include_unlisted: z.enum(['true', 'false']).optional(), }); -export const searchUsageQuery = z.object({ +export const webSearchUsageQuery = z.object({ ...usageBaseQuery, provider: z.string().optional(), }); diff --git a/packages/gateway/src/control-plane/search-config/routes.ts b/packages/gateway/src/control-plane/search-config/routes.ts index 4305c9772e..2f2802560a 100644 --- a/packages/gateway/src/control-plane/search-config/routes.ts +++ b/packages/gateway/src/control-plane/search-config/routes.ts @@ -1,18 +1,18 @@ import type { Context } from 'hono'; -import { testSearchConfigConnection } from '../../data-plane/tools/web-search/provider.ts'; -import { loadSearchConfig, parseSearchConfigStrict, saveSearchConfig } from '../../data-plane/tools/web-search/search-config.ts'; +import { loadWebSearchConfig, parseWebSearchConfigStrict, saveWebSearchConfig } from '../../data-plane/tools/web-search/config.ts'; +import { testWebSearchConfigConnection } from '../../data-plane/tools/web-search/provider.ts'; import { type CtxWithJson } from '../../middleware/zod-validator.ts'; -import type { searchConfigSchema } from '../schemas.ts'; +import type { webSearchConfigSchema } from '../schemas.ts'; -export const getSearchConfigRoute = async (c: Context) => c.json(await loadSearchConfig()); +export const getWebSearchConfigRoute = async (c: Context) => c.json(await loadWebSearchConfig()); -export const putSearchConfigRoute = async (c: CtxWithJson) => { - const config = await saveSearchConfig(c.req.valid('json')); +export const putWebSearchConfigRoute = async (c: CtxWithJson) => { + const config = await saveWebSearchConfig(c.req.valid('json')); return c.json(config); }; -export const testSearchConfigRoute = async (c: CtxWithJson) => { - const result = await testSearchConfigConnection(parseSearchConfigStrict(c.req.valid('json'))); +export const testWebSearchConfigRoute = async (c: CtxWithJson) => { + const result = await testWebSearchConfigConnection(parseWebSearchConfigStrict(c.req.valid('json'))); return c.json(result, result.ok ? 200 : 400); }; diff --git a/packages/gateway/src/control-plane/search-config/routes_test.ts b/packages/gateway/src/control-plane/search-config/routes_test.ts index ca234cb10a..8d09d67dda 100644 --- a/packages/gateway/src/control-plane/search-config/routes_test.ts +++ b/packages/gateway/src/control-plane/search-config/routes_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; -import { DEFAULT_SEARCH_CONFIG } from '../../data-plane/tools/web-search/search-config.ts'; -import { requestApp, setupAppTest } from '../../test-helpers.ts'; +import { DEFAULT_WEB_SEARCH_CONFIG } from '../../data-plane/tools/web-search/config.ts'; +import { requestApp, setupAppTest } from '../../test-utils/app.ts'; import { assertEquals, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; test('/api/search-config GET returns the default disabled config for admin', async () => { @@ -12,7 +12,7 @@ test('/api/search-config GET returns the default disabled config for admin', asy }); assertEquals(response.status, 200); - assertEquals(await response.json(), DEFAULT_SEARCH_CONFIG); + assertEquals(await response.json(), DEFAULT_WEB_SEARCH_CONFIG); }); test('/api/search-config PUT persists config and POST /test returns preview', async () => { diff --git a/packages/gateway/src/control-plane/search-usage/aggregate.ts b/packages/gateway/src/control-plane/search-usage/aggregate.ts index bb51640b67..d1b8c75eb6 100644 --- a/packages/gateway/src/control-plane/search-usage/aggregate.ts +++ b/packages/gateway/src/control-plane/search-usage/aggregate.ts @@ -3,25 +3,25 @@ // fetch_page into a single `requests` count per (provider, keyId, hour) or // (provider, userId, hour). -import type { SearchUsageRecord } from '../../repo/types.ts'; +import type { WebSearchUsageRecord } from '../../repo/types.ts'; import type { WebSearchProviderName } from '../../shared/web-search-providers.ts'; -export interface DisplaySearchUsageByKeyRecord { +export interface DisplayWebSearchUsageByKeyRecord { provider: WebSearchProviderName; keyId: string; hour: string; requests: number; } -export interface DisplaySearchUsageByUserRecord { +export interface DisplayWebSearchUsageByUserRecord { provider: WebSearchProviderName; userId: number; hour: string; requests: number; } -export const aggregateSearchUsageByKey = (records: readonly SearchUsageRecord[]): DisplaySearchUsageByKeyRecord[] => { - const grouped = new Map(); +export const aggregateWebSearchUsageByKey = (records: readonly WebSearchUsageRecord[]): DisplayWebSearchUsageByKeyRecord[] => { + const grouped = new Map(); for (const r of records) { const key = JSON.stringify([r.provider, r.keyId, r.hour]); const existing = grouped.get(key); @@ -38,11 +38,11 @@ export const aggregateSearchUsageByKey = (records: readonly SearchUsageRecord[]) // deleted directly in the DB) collapse into a synthetic userId 0 so the // dashboard can still surface the lost rows; the keyToUser map is populated // from active + soft-deleted api_keys, so a normal soft delete still resolves. -export const aggregateSearchUsageByUser = ( - records: readonly SearchUsageRecord[], +export const aggregateWebSearchUsageByUser = ( + records: readonly WebSearchUsageRecord[], keyToUser: ReadonlyMap, -): DisplaySearchUsageByUserRecord[] => { - const grouped = new Map(); +): DisplayWebSearchUsageByUserRecord[] => { + const grouped = new Map(); for (const r of records) { const userId = keyToUser.get(r.keyId) ?? 0; const key = JSON.stringify([r.provider, userId, r.hour]); diff --git a/packages/gateway/src/control-plane/search-usage/routes.ts b/packages/gateway/src/control-plane/search-usage/routes.ts index 07e4d25ef9..ab1a520dfa 100644 --- a/packages/gateway/src/control-plane/search-usage/routes.ts +++ b/packages/gateway/src/control-plane/search-usage/routes.ts @@ -4,17 +4,16 @@ // between `self-by-key` (the actor's own keys) and `all-by-user` (cross-user // aggregate, administrators only). -import { aggregateSearchUsageByKey, aggregateSearchUsageByUser } from './aggregate.ts'; -import { loadSearchConfig } from '../../data-plane/tools/web-search/search-config.ts'; -import { queryWebSearchUsage } from '../../data-plane/tools/web-search/usage.ts'; +import { aggregateWebSearchUsageByKey, aggregateWebSearchUsageByUser } from './aggregate.ts'; +import { loadWebSearchConfig } from '../../data-plane/tools/web-search/config.ts'; import { type CtxWithQuery } from '../../middleware/zod-validator.ts'; import { getRepo } from '../../repo/index.ts'; import { isWebSearchProviderName } from '../../shared/web-search-providers.ts'; -import type { searchUsageQuery } from '../schemas.ts'; +import type { webSearchUsageQuery } from '../schemas.ts'; import { buildKeyToUserMap } from '../shared/key-to-user.ts'; -import { resolveUsageView } from '../usage-view.ts'; +import { resolveUsageView } from '../shared/usage-view.ts'; -export const searchUsage = async (c: CtxWithQuery) => { +export const webSearchUsage = async (c: CtxWithQuery) => { const query = c.req.valid('query'); if (!query.start || !query.end) { return c.json({ error: 'start and end query parameters are required (e.g. 2026-03-09T00)' }, 400); @@ -35,21 +34,21 @@ export const searchUsage = async (c: CtxWithQuery) => { if (resolved.view === 'all-by-user') { const [rawRecords, users, keys] = await Promise.all([ - queryWebSearchUsage({ provider, start, end }), + repo.webSearchUsage.query({ provider, start, end }), repo.users.listIncludingDeleted(), repo.apiKeys.listIncludingDeleted(), ]); - const records = aggregateSearchUsageByUser(rawRecords, buildKeyToUserMap(keys)); + const records = aggregateWebSearchUsageByUser(rawRecords, buildKeyToUserMap(keys)); if (query.include_user_metadata !== '1') return c.json(records); const userMetadata = users .map(u => ({ id: u.id, username: u.username })) .sort((a, b) => a.id - b.id); - const searchConfig = await loadSearchConfig(); + const webSearchConfig = await loadWebSearchConfig(); return c.json({ records, users: userMetadata, - activeProvider: searchConfig.provider, + activeProvider: webSearchConfig.provider, }); } @@ -61,14 +60,14 @@ export const searchUsage = async (c: CtxWithQuery) => { return c.json({ error: 'Unknown key_id' }, 404); } - const rawRecords = await queryWebSearchUsage({ + const rawRecords = await repo.webSearchUsage.query({ provider, keyId: explicitKeyId, start, end, }); const filtered = explicitKeyId ? rawRecords : rawRecords.filter(r => ownedSet.has(r.keyId)); - const aggregated = aggregateSearchUsageByKey(filtered); + const aggregated = aggregateWebSearchUsageByKey(filtered); // Aggregated-records-only callers (CI, automation) skip the active-provider // read and the sorted key-name/createdAt block via include_key_metadata=0. @@ -76,7 +75,7 @@ export const searchUsage = async (c: CtxWithQuery) => { // rows and cannot be elided. if (query.include_key_metadata !== '1') return c.json(aggregated); - const searchConfig = await loadSearchConfig(); + const webSearchConfig = await loadWebSearchConfig(); const keyMap = new Map(keys.map(k => [k.id, k])); const recordsWithKeyMetadata = aggregated.map(r => { const k = keyMap.get(r.keyId); @@ -88,6 +87,6 @@ export const searchUsage = async (c: CtxWithQuery) => { return c.json({ records: recordsWithKeyMetadata, keys: keyMetadata, - activeProvider: searchConfig.provider, + activeProvider: webSearchConfig.provider, }); }; diff --git a/packages/gateway/src/control-plane/search-usage/routes_test.ts b/packages/gateway/src/control-plane/search-usage/routes_test.ts index ff6bcee1bc..97fedc93a8 100644 --- a/packages/gateway/src/control-plane/search-usage/routes_test.ts +++ b/packages/gateway/src/control-plane/search-usage/routes_test.ts @@ -1,9 +1,9 @@ import { test } from 'vitest'; -import { requestApp, setupAppTest } from '../../test-helpers.ts'; +import { requestApp, setupAppTest } from '../../test-utils/app.ts'; import { assertEquals } from '@floway-dev/test-utils'; -const seedSearchUsage = async (repo: import('../../repo/memory.ts').InMemoryRepo, primaryKeyId: string) => { +const seedWebSearchUsage = async (repo: import('../../repo/memory.ts').InMemoryRepo, primaryKeyId: string) => { await repo.apiKeys.save({ id: 'key_other', userId: 1, @@ -17,14 +17,14 @@ const seedSearchUsage = async (repo: import('../../repo/memory.ts').InMemoryRepo responsesRetentionSeconds: 0, }); - await repo.searchUsage.set({ provider: 'tavily', keyId: primaryKeyId, action: 'search', hour: '2026-03-15T10', requests: 2 }); - await repo.searchUsage.set({ provider: 'tavily', keyId: primaryKeyId, action: 'fetch_page', hour: '2026-03-15T10', requests: 3 }); - await repo.searchUsage.set({ provider: 'microsoft-grounding', keyId: 'key_other', action: 'search', hour: '2026-03-15T11', requests: 4 }); + await repo.webSearchUsage.set({ provider: 'tavily', keyId: primaryKeyId, action: 'search', hour: '2026-03-15T10', requests: 2 }); + await repo.webSearchUsage.set({ provider: 'tavily', keyId: primaryKeyId, action: 'fetch_page', hour: '2026-03-15T10', requests: 3 }); + await repo.webSearchUsage.set({ provider: 'microsoft-grounding', keyId: 'key_other', action: 'search', hour: '2026-03-15T11', requests: 4 }); }; test('/api/search-usage scopes to the actor\'s keys when called with an API key', async () => { const { repo, apiKey } = await setupAppTest(); - await seedSearchUsage(repo, apiKey.id); + await seedWebSearchUsage(repo, apiKey.id); const response = await requestApp('/api/search-usage?start=2026-03-15T00&end=2026-03-16T00&view=self-by-key', { headers: { 'x-api-key': apiKey.key }, @@ -45,8 +45,8 @@ test('/api/search-usage scopes to the actor\'s keys when called with an API key' test('/api/search-usage in self-by-key mode includes per-key metadata for the actor only', async () => { const { repo, apiKey } = await setupAppTest(); - await seedSearchUsage(repo, apiKey.id); - await repo.searchConfig.save({ + await seedWebSearchUsage(repo, apiKey.id); + await repo.webSearchConfig.save({ provider: 'microsoft-grounding', tavily: { apiKey: 'tvly-test' }, microsoftGrounding: { apiKey: 'ms-test' }, @@ -78,7 +78,7 @@ test('/api/search-usage in self-by-key mode includes per-key metadata for the ac test('/api/search-usage all-by-user view aggregates across keys per user', async () => { const { repo, adminSession, apiKey } = await setupAppTest(); - await seedSearchUsage(repo, apiKey.id); + await seedWebSearchUsage(repo, apiKey.id); const response = await requestApp('/api/search-usage?start=2026-03-15T00&end=2026-03-16T00&view=all-by-user', { headers: { 'x-floway-session': adminSession }, @@ -121,7 +121,7 @@ test('/api/search-usage rejects all-by-user from a non-admin user', async () => test('/api/search-usage filters by provider and rejects invalid provider', async () => { const { repo, apiKey } = await setupAppTest(); - await seedSearchUsage(repo, apiKey.id); + await seedWebSearchUsage(repo, apiKey.id); const filtered = await requestApp('/api/search-usage?start=2026-03-15T00&end=2026-03-16T00&provider=tavily&view=self-by-key', { headers: { 'x-api-key': apiKey.key }, @@ -154,7 +154,7 @@ test('/api/search-usage all-by-user attributes soft-deleted keys to their origin // Seed a usage row, then soft-delete the originating key. The aggregator // must still resolve the row to apiKey.userId — not the synthetic userId 0 // it falls back to when the key→user lookup misses. - await repo.searchUsage.set({ provider: 'tavily', keyId: apiKey.id, action: 'search', hour: '2026-03-15T10', requests: 7 }); + await repo.webSearchUsage.set({ provider: 'tavily', keyId: apiKey.id, action: 'search', hour: '2026-03-15T10', requests: 7 }); await repo.apiKeys.softDelete(apiKey.id); const response = await requestApp('/api/search-usage?start=2026-03-15T00&end=2026-03-16T00&view=all-by-user', { diff --git a/packages/gateway/src/control-plane/shared/sort-order.ts b/packages/gateway/src/control-plane/shared/sort-order.ts new file mode 100644 index 0000000000..12aee68de5 --- /dev/null +++ b/packages/gateway/src/control-plane/shared/sort-order.ts @@ -0,0 +1,2 @@ +export const nextSortOrder = (existing: readonly { sortOrder: number }[]): number => + existing.reduce((highest, record) => Math.max(highest, record.sortOrder), -1) + 1; diff --git a/packages/gateway/src/control-plane/api-keys/upstream-ids.ts b/packages/gateway/src/control-plane/shared/upstream-ids.ts similarity index 56% rename from packages/gateway/src/control-plane/api-keys/upstream-ids.ts rename to packages/gateway/src/control-plane/shared/upstream-ids.ts index 53f64360b3..e8c7cfcda1 100644 --- a/packages/gateway/src/control-plane/api-keys/upstream-ids.ts +++ b/packages/gateway/src/control-plane/shared/upstream-ids.ts @@ -1,12 +1,13 @@ -export type UpstreamIdsValue = string[] | null; +import { getRepo } from '../../repo/index.ts'; -export type ParseUpstreamIdsResult = +type UpstreamIdsValue = string[] | null; + +type ParseUpstreamIdsResult = | { ok: true; value: UpstreamIdsValue } | { ok: false; error: string }; -// Shared by the PATCH route and the import/export round-trip so the rules cannot drift. -// Empty array is rejected: a key that allows zero upstreams cannot serve any model, -// and the UI has no affordance to express that intent. +// Empty arrays are rejected: a key that allows zero upstreams cannot serve any +// model, and the UI has no affordance to express that intent. export const parseUpstreamIdsValue = (raw: unknown): ParseUpstreamIdsResult => { if (raw === null) return { ok: true, value: null }; if (!Array.isArray(raw)) return { ok: false, error: 'upstream_ids must be null or an array of upstream ids' }; @@ -22,3 +23,11 @@ export const parseUpstreamIdsValue = (raw: unknown): ParseUpstreamIdsResult => { } return { ok: true, value: ids }; }; + +export const validateUpstreamIdsExist = async (ids: readonly string[] | null): Promise => { + if (ids === null) return null; + const upstreams = await getRepo().upstreams.list(); + const known = new Set(upstreams.map(upstream => upstream.id)); + const unknown = ids.filter(id => !known.has(id)); + return unknown.length ? `Unknown upstream(s): ${unknown.join(', ')}` : null; +}; diff --git a/packages/gateway/src/control-plane/usage-view.ts b/packages/gateway/src/control-plane/shared/usage-view.ts similarity index 92% rename from packages/gateway/src/control-plane/usage-view.ts rename to packages/gateway/src/control-plane/shared/usage-view.ts index 094ef53a76..9c8697df26 100644 --- a/packages/gateway/src/control-plane/usage-view.ts +++ b/packages/gateway/src/control-plane/shared/usage-view.ts @@ -1,4 +1,4 @@ -import { type AuthedContext, userFromContext } from '../middleware/auth.ts'; +import { type AuthedContext, userFromContext } from '../../middleware/auth.ts'; // The two shapes the usage endpoints answer in. type UsageView = 'all-by-user' | 'self-by-key'; diff --git a/packages/gateway/src/control-plane/shared/warm-models-cache.ts b/packages/gateway/src/control-plane/shared/warm-models-cache.ts new file mode 100644 index 0000000000..fb9648e78c --- /dev/null +++ b/packages/gateway/src/control-plane/shared/warm-models-cache.ts @@ -0,0 +1,26 @@ +import type { Context } from 'hono'; + +import { fetchUpstreamModelsCached } from '../../data-plane/providers/models-cache.ts'; +import { createProvider } from '../../data-plane/providers/registry.ts'; +import { createPerRequestFetcher } from '../../dial/per-request.ts'; +import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; +import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; +import type { UpstreamRecord } from '@floway-dev/provider'; +import { logInfo } from '@floway-dev/provider-claude-code'; + +const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); + +// Populate the SWR model cache synchronously after saving an upstream so the +// next dashboard read sees the new catalog. The cache layer persists upstream +// fetch failures in `lastError`; errors escaping that layer are internal and +// must remain observable without aborting the surrounding control-plane write. +export const warmModelsCache = async (record: UpstreamRecord, c: Context): Promise => { + const scheduler = backgroundSchedulerFromContext(c); + const provider = createProvider(record); + const fetcher = (await createPerRequestFetcher(getRuntimeLocation(c.req.raw)))(record.id); + try { + await fetchUpstreamModelsCached(provider, { scheduler, fetcher, force: true }); + } catch (error) { + logInfo('warm_models_cache_failed', { upstream_id: record.id, error: errorMessage(error) }); + } +}; diff --git a/packages/gateway/src/control-plane/token-usage/routes.ts b/packages/gateway/src/control-plane/token-usage/routes.ts index 3bb7e44c49..60312f35f1 100644 --- a/packages/gateway/src/control-plane/token-usage/routes.ts +++ b/packages/gateway/src/control-plane/token-usage/routes.ts @@ -9,7 +9,7 @@ import { type CtxWithQuery } from '../../middleware/zod-validator.ts'; import { getRepo } from '../../repo/index.ts'; import type { tokenUsageQuery } from '../schemas.ts'; import { buildKeyToUserMap } from '../shared/key-to-user.ts'; -import { resolveUsageView } from '../usage-view.ts'; +import { resolveUsageView } from '../shared/usage-view.ts'; export const tokenUsage = async (c: CtxWithQuery) => { const query = c.req.valid('query'); diff --git a/packages/gateway/src/control-plane/token-usage/routes_test.ts b/packages/gateway/src/control-plane/token-usage/routes_test.ts index c9eb4d8760..3a59dd65b0 100644 --- a/packages/gateway/src/control-plane/token-usage/routes_test.ts +++ b/packages/gateway/src/control-plane/token-usage/routes_test.ts @@ -1,8 +1,11 @@ import { test } from 'vitest'; import { tokenUsageMetrics } from '../../repo/usage-metrics.ts'; -import { requestApp, setupAppTest } from '../../test-helpers.ts'; -import { assertEquals } from '@floway-dev/test-utils'; +import { requestApp, setupAppTest } from '../../test-utils/app.ts'; +import { assertEquals, assertExists } from '@floway-dev/test-utils'; + +const displayQuantity = (record: { metrics: Array<{ metric: string; quantity: string }> }, tokenCategory: string) => + record.metrics.find(row => row.metric === `${tokenCategory}_tokens`)?.quantity; const seedUsage = async ( repo: import('../../repo/memory.ts').InMemoryRepo, @@ -75,3 +78,174 @@ test('/api/token-usage self-by-key surfaces soft-deleted keys metadata to their const matched = body.records.find((r: { keyId: string }) => r.keyId === apiKey.id); assertEquals(matched?.keyName, apiKey.name); }); + +test('/api/token-usage scopes to the actor\'s keys when called with an API key', async () => { + const { repo, apiKey } = await setupAppTest(); + await repo.apiKeys.save({ + id: 'key_other', + userId: 1, + name: 'Other key', + key: 'raw_other_key', + serverSecret: '00'.repeat(32), + createdAt: '2026-03-15T00:00:00.000Z', + upstreamIds: null, + deletedAt: null, + dumpRetentionSeconds: null, + responsesRetentionSeconds: 0, + }); + await repo.usage.set({ + keyId: apiKey.id, + model: 'claude-sonnet-4', + upstream: null, + modelKey: 'claude-sonnet-4', + hour: '2026-03-15T10', + pricingSelector: {}, + requests: 2, + metrics: tokenUsageMetrics({ input: 10, output: 5, input_cache_read: 4, input_cache_write: 1 }, null), + }); + await repo.usage.set({ + keyId: 'key_other', + model: 'gpt-5', + upstream: null, + modelKey: 'gpt-5', + hour: '2026-03-15T11', + pricingSelector: {}, + requests: 1, + metrics: tokenUsageMetrics({ input: 20, output: 8, input_cache_read: 6, input_cache_write: 2 }, null), + }); + + const response = await requestApp('/api/token-usage?start=2026-03-15T00&end=2026-03-16T00&view=self-by-key', { + headers: { 'x-api-key': apiKey.key }, + }); + + assertEquals(response.status, 200); + const body = await response.json(); + // Non-admin actor sees only their own key's rows; the other user's row is excluded. + assertEquals(body.length, 1); + assertEquals(body[0].keyId, apiKey.id); + assertEquals(body[0].keyName, 'Primary key'); + assertEquals(displayQuantity(body[0], 'input_cache_read'), '4'); + assertEquals(displayQuantity(body[0], 'input_cache_write'), '1'); +}); + +test('/api/token-usage in self-by-key mode includes per-key metadata for the actor only', async () => { + const { repo, apiKey } = await setupAppTest(); + // Add a second key under the same user; they should both surface. + await repo.apiKeys.save({ + id: 'key_actor_secondary', + userId: apiKey.userId, + name: 'Actor secondary', + key: 'raw_actor_secondary', + serverSecret: '00'.repeat(32), + createdAt: '2026-03-16T00:00:00.000Z', + upstreamIds: null, + deletedAt: null, + dumpRetentionSeconds: null, + responsesRetentionSeconds: 0, + }); + await repo.usage.set({ + keyId: 'key_actor_secondary', + model: 'gpt-5', + upstream: null, + modelKey: 'gpt-5', + hour: '2026-03-16T10', + pricingSelector: {}, + requests: 1, + metrics: tokenUsageMetrics({ input: 20, output: 8 }, null), + }); + + const response = await requestApp('/api/token-usage?start=2026-03-16T00&end=2026-03-17T00&include_key_metadata=1&view=self-by-key', { + headers: { 'x-api-key': apiKey.key }, + }); + + assertEquals(response.status, 200); + const body = await response.json(); + assertEquals(body.records.length, 1); + assertEquals(body.records[0].keyId, 'key_actor_secondary'); + assertEquals(body.keys, [ + { id: apiKey.id, name: apiKey.name, createdAt: apiKey.createdAt }, + { id: 'key_actor_secondary', name: 'Actor secondary', createdAt: '2026-03-16T00:00:00.000Z' }, + ]); +}); + +test('/api/token-usage all-by-user view aggregates across keys per user', async () => { + const { repo, adminSession, apiKey } = await setupAppTest(); + await repo.usage.set({ + keyId: apiKey.id, + model: 'gpt-5', + upstream: null, + modelKey: 'gpt-5', + hour: '2026-03-15T10', + pricingSelector: {}, + requests: 1, + metrics: tokenUsageMetrics({ input: 10, output: 5 }, null), + }); + + const response = await requestApp( + '/api/token-usage?start=2026-03-15T00&end=2026-03-16T00&view=all-by-user', + { headers: { 'x-floway-session': adminSession } }, + ); + assertEquals(response.status, 200); + const body = await response.json(); + assertEquals(body.length, 1); + assertEquals(body[0].userId, apiKey.userId); + assertEquals(displayQuantity(body[0], 'input'), '10'); +}); + +test('/api/token-usage rejects all-by-user from a non-admin user', async () => { + const { apiKey } = await setupAppTest(); + const response = await requestApp( + '/api/token-usage?start=2026-03-15T00&end=2026-03-16T00&view=all-by-user', + { headers: { 'x-api-key': apiKey.key } }, + ); + assertEquals(response.status, 403); +}); + +test('/api/token-usage merges Claude variants into backend base model records', async () => { + const { repo, apiKey } = await setupAppTest(); + const shared = { + keyId: apiKey.id, + hour: '2026-03-17T10', + upstream: 'copilot:1', + pricingSelector: {}, + requests: 1, + metrics: tokenUsageMetrics({ input: 10, output: 5, input_cache_read: 2, input_cache_write: 1 }, null), + }; + + await repo.usage.set({ + ...shared, + model: 'claude-opus-4-7', + modelKey: 'claude-opus-4.7', + }); + await repo.usage.set({ + ...shared, + model: 'claude-opus-4-7', + modelKey: 'claude-opus-4.7-xhigh', + }); + await repo.usage.set({ + ...shared, + model: 'claude-opus-4-7', + modelKey: 'claude-opus-4.7-1m-internal', + }); + await repo.usage.set({ + ...shared, + model: 'gpt-5.3-codex', + modelKey: 'gpt-5.3-codex', + metrics: tokenUsageMetrics({ input: 3, output: 4 }, null), + }); + + const response = await requestApp('/api/token-usage?start=2026-03-17T00&end=2026-03-18T00&view=self-by-key', { headers: { 'x-api-key': apiKey.key } }); + + assertEquals(response.status, 200); + const body = await response.json(); + assertEquals(body.length, 2); + const opus = body.find((record: { model: string }) => record.model === 'claude-opus-4-7'); + const gpt = body.find((record: { model: string }) => record.model === 'gpt-5.3-codex'); + assertExists(opus); + assertExists(gpt); + assertEquals(opus.requests, 3); + assertEquals(displayQuantity(opus, 'input'), '30'); + assertEquals(displayQuantity(opus, 'output'), '15'); + assertEquals(displayQuantity(opus, 'input_cache_read'), '6'); + assertEquals(displayQuantity(opus, 'input_cache_write'), '3'); +}); diff --git a/packages/gateway/src/control-plane/upstreams/claude-code.ts b/packages/gateway/src/control-plane/upstreams/claude-code.ts new file mode 100644 index 0000000000..90f52d897a --- /dev/null +++ b/packages/gateway/src/control-plane/upstreams/claude-code.ts @@ -0,0 +1,268 @@ +import { resolveControlPlaneFetcher } from './proxy-resolution.ts'; +import { upstreamErrorMessage as errorMessage } from './shared.ts'; +import { userFromContext } from '../../middleware/auth.ts'; +import type { CtxWithJson } from '../../middleware/zod-validator.ts'; +import { getRepo } from '../../repo/index.ts'; +import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; +import type { claudeCodeOAuthAuthorizeUrlBody, claudeCodeOAuthExchangeBody, claudeCodeOAuthRefreshBody, claudeCodeProbeBody, claudeCodeSetupTokenAuthorizeUrlBody, claudeCodeSetupTokenExchangeBody } from '../schemas.ts'; +import { warmModelsCache } from '../shared/warm-models-cache.ts'; +import type { Fetcher, UpstreamRecord } from '@floway-dev/provider'; +import { + type ClaudeCodeAccountCredential, + type ClaudeCodeUpstreamConfig, + type ClaudeCodeUpstreamState, + ClaudeCodeOAuthSessionTerminatedError, + buildClaudeCodeAuthorizeUrl, + ensureClaudeCodeAccessToken, + fetchClaudeCodeUsageProbe, + importClaudeCodeFromCallback, + importClaudeCodeFromCredentialsJson, + importClaudeCodeFromSetupTokenCallback, + logInfo, + readClaudeCodeUpstreamState, +} from '@floway-dev/provider-claude-code'; + +// Claude Code OAuth + setup-token + probe endpoints under the unified +// record-body contract. Create and edit share one endpoint each: the +// caller posts the draft record; when `record.id !== ''` the produced +// patch is targeted-persisted, otherwise only returned for the +// front-end to merge into its draft. + +export const claudeCodeOAuthAuthorizeUrl = async (c: CtxWithJson) => { + const { challenge, state } = c.req.valid('json'); + const authorize_url = buildClaudeCodeAuthorizeUrl({ state, codeChallenge: challenge, kind: 'oauth' }); + return c.json({ authorize_url }); +}; + +export const claudeCodeSetupTokenAuthorizeUrl = async (c: CtxWithJson) => { + const { challenge, state } = c.req.valid('json'); + const authorize_url = buildClaudeCodeAuthorizeUrl({ state, codeChallenge: challenge, kind: 'setup-token' }); + return c.json({ authorize_url }); +}; + +export const claudeCodeOAuthExchange = async (c: CtxWithJson) => { + const body = c.req.valid('json'); + const { record } = body; + if (record.kind !== 'claude-code') return c.json({ error: 'Upstream is not a Claude Code upstream' }, 400); + + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ + override: record.proxy_fallback_list, + upstreamId: record.id || undefined, + runtimeLocation: getRuntimeLocation(c.req.raw), + }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + + let ingestion: { config: ClaudeCodeUpstreamConfig; state: ClaudeCodeUpstreamState }; + try { + if (body.credentials_json !== undefined) { + ingestion = await importClaudeCodeFromCredentialsJson(body.credentials_json, fetcher); + } else { + const cb = body.callback!; + ingestion = await importClaudeCodeFromCallback({ code: cb.code, pkceVerifier: cb.verifier, state: cb.state, fetcher }); + } + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + + if (record.id !== '') { + const dbRecord = await getRepo().upstreams.getById(record.id); + if (!dbRecord) return c.json({ error: 'Upstream not found' }, 404); + if (dbRecord.kind !== 'claude-code') return c.json({ error: 'Upstream is not a Claude Code upstream' }, 400); + const next: UpstreamRecord = { + ...dbRecord, + config: ingestion.config, + state: ingestion.state, + updatedAt: new Date().toISOString(), + }; + await getRepo().upstreams.save(next); + await warmModelsCache(next, c); + } + + return c.json({ patch: { config: ingestion.config, state: ingestion.state } }); +}; + +export const claudeCodeSetupTokenExchange = async (c: CtxWithJson) => { + const { record, callback } = c.req.valid('json'); + if (record.kind !== 'claude-code') return c.json({ error: 'Upstream is not a Claude Code upstream' }, 400); + + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ + override: record.proxy_fallback_list, + upstreamId: record.id || undefined, + runtimeLocation: getRuntimeLocation(c.req.raw), + }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + + let ingestion: { config: ClaudeCodeUpstreamConfig; state: ClaudeCodeUpstreamState }; + try { + ingestion = await importClaudeCodeFromSetupTokenCallback({ + code: callback.code, + pkceVerifier: callback.verifier, + state: callback.state, + fetcher, + }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + + if (record.id !== '') { + const dbRecord = await getRepo().upstreams.getById(record.id); + if (!dbRecord) return c.json({ error: 'Upstream not found' }, 404); + if (dbRecord.kind !== 'claude-code') return c.json({ error: 'Upstream is not a Claude Code upstream' }, 400); + const next: UpstreamRecord = { + ...dbRecord, + config: ingestion.config, + state: ingestion.state, + updatedAt: new Date().toISOString(), + }; + await getRepo().upstreams.save(next); + await warmModelsCache(next, c); + } + + return c.json({ patch: { config: ingestion.config, state: ingestion.state } }); +}; + +export const claudeCodeOAuthRefresh = async (c: CtxWithJson) => { + const { record } = c.req.valid('json'); + if (record.kind !== 'claude-code') return c.json({ error: 'Upstream is not a Claude Code upstream' }, 400); + // Refresh delegates to the data plane's `ensureClaudeCodeAccessToken` + // with `force: true` so operator clicks and data-plane requests share + // the same rotation + sibling-race recovery path (no duplicated CAS + // logic, no divergence). Create-state refresh has no target — the + // just-completed OAuth exchange handed the client a brand-new + // refresh_token that has no reason to rotate yet. + if (record.id === '') return c.json({ error: 'refresh requires a persisted upstream' }, 400); + + const parsedState = readClaudeCodeUpstreamState(record.state); + const account = parsedState.accounts[0]; + if (account.state !== 'active') { + return c.json({ error: `Claude Code upstream is ${account.state}; re-run OAuth exchange to recover` }, 400); + } + if (account.tokenKind === 'setup-token') { + return c.json({ error: 'Setup-token credentials cannot be refreshed; re-run setup-token exchange to rotate' }, 400); + } + + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ + override: record.proxy_fallback_list, + upstreamId: record.id, + runtimeLocation: getRuntimeLocation(c.req.raw), + }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + + try { + // `ensureClaudeCodeAccessToken` handles the whole flow: read state, + // CAS-write the rotated refresh_token alongside the fresh access + // token, and flip the row to refresh_failed on a terminal OAuth + // error. All this handler contributes is the HTTP framing. + await ensureClaudeCodeAccessToken({ upstreamId: record.id, repo: getRepo().upstreams, fetcher, force: true }); + } catch (err) { + if (err instanceof ClaudeCodeOAuthSessionTerminatedError) { + return c.json({ error: `Claude Code refresh failed: ${err.upstreamMessage}. Re-run OAuth exchange to recover.` }, 400); + } + return c.json({ error: errorMessage(err) }, 502); + } + + const updated = await getRepo().upstreams.getById(record.id); + if (!updated) return c.json({ error: 'Upstream not found' }, 404); + return c.json({ patch: { state: updated.state } }); +}; + +export const claudeCodeProbe = async (c: CtxWithJson) => { + const { record } = c.req.valid('json'); + if (record.kind !== 'claude-code') return c.json({ error: 'Quota probe is only supported for claude-code upstreams' }, 400); + const actor = userFromContext(c).id; + + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ + override: record.proxy_fallback_list, + upstreamId: record.id || undefined, + runtimeLocation: getRuntimeLocation(c.req.raw), + }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + + // Resolving a fresh access token demands DB access (the token cache + // and CAS-guarded refresh live there), so probe on a create-state + // record requires that the caller has ensured a fresh access_token + // sits in draft.state.accounts[0].accessToken from the OAuth + // exchange step. In edit state, we can call the standard cache + // helper that reads / refreshes from DB. + let accessToken: string; + try { + if (record.id !== '') { + const access = await ensureClaudeCodeAccessToken({ + upstreamId: record.id, + repo: getRepo().upstreams, + fetcher, + }); + accessToken = access.entry.token; + } else { + const parsedState = readClaudeCodeUpstreamState(record.state); + const account = parsedState.accounts[0]; + if (!account.accessToken?.token) { + return c.json({ error: 'Draft account has no fresh access token; run OAuth refresh first' }, 400); + } + accessToken = account.accessToken.token; + } + } catch (err) { + logInfo('claude_code_admin_action', { upstream_id: record.id, action: 'quota_probe', actor, outcome: 'error', error: errorMessage(err) }); + if (err instanceof ClaudeCodeOAuthSessionTerminatedError) { + return c.json({ error: `Claude Code refresh failed: ${err.upstreamMessage}` }, 503); + } + return c.json({ error: errorMessage(err) }, 502); + } + + let probe; + try { + probe = await fetchClaudeCodeUsageProbe(accessToken, fetcher); + } catch (err) { + logInfo('claude_code_admin_action', { upstream_id: record.id, action: 'quota_probe', actor, outcome: 'error', error: errorMessage(err) }); + return c.json({ error: errorMessage(err) }, 502); + } + + const snapshotPatch = { + usageProbeSnapshot: { fetchedAt: Date.parse(probe.fetched_at), data: probe.body }, + }; + const mergeSnapshotInto = (state: ClaudeCodeUpstreamState): ClaudeCodeUpstreamState => ({ + ...state, + accounts: state.accounts.map((a, i): ClaudeCodeAccountCredential => i === 0 ? { ...a, ...snapshotPatch } : a), + }); + + // Merge the freshly-fetched snapshot into the caller's draft state so the + // response carries a whole state slot the caller can hand to its uniform + // patch merger — the wire contract stays symmetric with refresh/exchange + // instead of asking the client to hand-merge into accounts[0]. + const merged = mergeSnapshotInto(readClaudeCodeUpstreamState(record.state)); + + if (record.id !== '') { + // Best-effort CAS persist against the currently-stored state — a losing + // race means a concurrent rotation wrote newer state that supersedes + // ours, which is fine (the snapshot rides on top of that new state on + // the next probe). + const fresh = await getRepo().upstreams.getById(record.id); + if (fresh) { + const freshMerged = mergeSnapshotInto(readClaudeCodeUpstreamState(fresh.state)); + await getRepo().upstreams.saveState(record.id, freshMerged, { expectedState: fresh.state }); + } + } + + logInfo('claude_code_admin_action', { upstream_id: record.id, action: 'quota_probe', actor, outcome: 'ok' }); + return c.json({ + fetched_at: probe.fetched_at, + body: probe.body, + patch: { state: merged }, + }); +}; diff --git a/packages/gateway/src/control-plane/upstreams/codex.ts b/packages/gateway/src/control-plane/upstreams/codex.ts new file mode 100644 index 0000000000..c3043dfe9c --- /dev/null +++ b/packages/gateway/src/control-plane/upstreams/codex.ts @@ -0,0 +1,156 @@ +import { resolveControlPlaneFetcher } from './proxy-resolution.ts'; +import { upstreamErrorMessage as errorMessage } from './shared.ts'; +import type { CtxWithJson } from '../../middleware/zod-validator.ts'; +import { getRepo } from '../../repo/index.ts'; +import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; +import type { codexOAuthAuthorizeUrlBody, codexOAuthExchangeBody, codexOAuthRefreshBody } from '../schemas.ts'; +import { warmModelsCache } from '../shared/warm-models-cache.ts'; +import type { Fetcher, UpstreamRecord } from '@floway-dev/provider'; +import { + buildCodexAuthorizeUrl, + type CodexUpstreamConfig, + type CodexUpstreamState, + CodexOAuthSessionTerminatedError, + assertCodexUpstreamState, + ensureCodexAccessToken, + importCodexFromAuthJson, + importCodexFromCallback, + mintCodexAccessToken, +} from '@floway-dev/provider-codex'; + +// Codex OAuth under the unified record-body contract. Create and edit +// share one endpoint each: the caller posts the draft record; when +// `record.id !== ''` the produced patch is targeted-persisted, otherwise +// it is only returned for the front-end to merge into its draft. +export const codexOAuthAuthorizeUrl = async (c: CtxWithJson) => { + const { challenge, state } = c.req.valid('json'); + return c.json({ authorize_url: buildCodexAuthorizeUrl({ state, codeChallenge: challenge }) }); +}; + +export const codexOAuthExchange = async (c: CtxWithJson) => { + const body = c.req.valid('json'); + const { record } = body; + if (record.kind !== 'codex') return c.json({ error: 'Upstream is not a Codex upstream' }, 400); + + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ + override: record.proxy_fallback_list, + upstreamId: record.id || undefined, + runtimeLocation: getRuntimeLocation(c.req.raw), + }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + + let ingestion: { config: CodexUpstreamConfig; state: CodexUpstreamState }; + try { + if (body.auth_json !== undefined) { + ingestion = await importCodexFromAuthJson(body.auth_json); + } else { + const cb = body.callback!; + ingestion = await importCodexFromCallback({ code: cb.code, codeVerifier: cb.verifier, fetcher }); + } + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + + // Edit state: overwrite the credential slice of the stored record. + // Single-account convention — exchange REPLACES accounts[0], no append. + if (record.id !== '') { + const dbRecord = await getRepo().upstreams.getById(record.id); + if (!dbRecord) return c.json({ error: 'Upstream not found' }, 404); + if (dbRecord.kind !== 'codex') return c.json({ error: 'Upstream is not a Codex upstream' }, 400); + const next: UpstreamRecord = { + ...dbRecord, + config: ingestion.config, + state: ingestion.state, + updatedAt: new Date().toISOString(), + }; + await getRepo().upstreams.save(next); + await warmModelsCache(next, c); + } + + return c.json({ + patch: { + config: ingestion.config, + state: ingestion.state, + }, + }); +}; + +export const codexOAuthRefresh = async (c: CtxWithJson) => { + const { record } = c.req.valid('json'); + if (record.kind !== 'codex') return c.json({ error: 'Upstream is not a Codex upstream' }, 400); + // Refresh is a stateful action on a persisted row — it delegates to + // `ensureCodexAccessToken` which reads state from DB, mints, and + // CAS-writes back with sibling-rotation recovery. Create-state refresh + // has no target: the just-completed OAuth exchange handed the client a + // brand-new refresh_token that has no reason to rotate yet, and the + // front-end does not surface the button until Save lands the row. + if (record.id === '') return c.json({ error: 'refresh requires a persisted upstream' }, 400); + assertCodexUpstreamState(record.state); + const account = record.state.accounts[0]; + if (account.state !== 'active') { + return c.json({ error: `Codex upstream is ${account.state}; re-run OAuth exchange to recover` }, 400); + } + + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ + override: record.proxy_fallback_list, + upstreamId: record.id, + runtimeLocation: getRuntimeLocation(c.req.raw), + }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + + // Persist callback shape matches `createCodexProvider` — a rotated + // refresh_token CAS-writes back into the account slot with the just-read + // state as the expected value. A losing CAS is not an error here: the + // sibling that won the race already persisted a newer refresh_token, and + // `ensureCodexAccessToken`'s `recoverFromRefreshRace` picks up the + // sibling's fresh access token when our mint gets `invalid_grant`. + const persistRefreshTokenRotation = async (newRefreshToken: string): Promise => { + const fresh = await getRepo().upstreams.getById(record.id); + if (!fresh) return; + assertCodexUpstreamState(fresh.state); + const next: CodexUpstreamState = { + accounts: fresh.state.accounts.map(a => a.chatgptAccountId === account.chatgptAccountId + ? { ...a, refresh_token: newRefreshToken, state_updated_at: new Date().toISOString() } + : a), + }; + await getRepo().upstreams.saveState(record.id, next, { expectedState: fresh.state }); + }; + + try { + await ensureCodexAccessToken(record.id, account.chatgptAccountId, + refreshToken => mintCodexAccessToken(refreshToken, fetcher, persistRefreshTokenRotation), + true); + } catch (err) { + if (err instanceof CodexOAuthSessionTerminatedError) { + // Terminal flip mirrors `createCodexProvider.persistTerminalState`: + // clear the cached access token, mark the account refresh_failed so + // the dashboard renders the red badge and prompts a re-import. + // Best-effort — a losing CAS means a concurrent rotation already + // wrote newer state that supersedes ours. + const fresh = await getRepo().upstreams.getById(record.id); + if (fresh) { + assertCodexUpstreamState(fresh.state); + const next: CodexUpstreamState = { + accounts: fresh.state.accounts.map(a => a.chatgptAccountId === account.chatgptAccountId + ? { ...a, state: 'refresh_failed' as const, state_message: err.upstreamMessage, state_updated_at: new Date().toISOString(), accessToken: null } + : a), + }; + await getRepo().upstreams.saveState(record.id, next, { expectedState: fresh.state }); + } + return c.json({ error: `Codex refresh failed: ${err.upstreamMessage}. Re-run OAuth exchange to recover.` }, 400); + } + return c.json({ error: errorMessage(err) }, 502); + } + + const updated = await getRepo().upstreams.getById(record.id); + if (!updated) return c.json({ error: 'Upstream not found' }, 404); + return c.json({ patch: { state: updated.state } }); +}; diff --git a/packages/gateway/src/control-plane/upstreams/copilot-device-login_test.ts b/packages/gateway/src/control-plane/upstreams/copilot-device-login_test.ts new file mode 100644 index 0000000000..c1a15d6a7e --- /dev/null +++ b/packages/gateway/src/control-plane/upstreams/copilot-device-login_test.ts @@ -0,0 +1,245 @@ +import { test, vi } from 'vitest'; + +// Copilot OAuth poll handlers warm the model cache after rotating the PAT. The +// cache behavior has dedicated coverage; these route tests isolate credential +// exchange and persistence. +vi.mock('../../data-plane/providers/models-cache.ts', () => ({ + fetchUpstreamModelsCached: () => Promise.resolve([]), + clearInFlightForTesting: () => {}, +})); + +import { buildCopilotUpstreamRecord, requestApp, setupAppTest } from '../../test-utils/app.ts'; +import { assertEquals, assertStringIncludes, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; + +const githubUser = { + id: 777, + login: 'octo-auth', + name: 'Octo Auth', + avatar_url: 'https://example.com/octo-auth.png', +}; + +test('/api/upstreams/copilot/oauth/device-login/start starts GitHub device flow', async () => { + const { adminSession } = await setupAppTest(); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'github.com' && url.pathname === '/login/device/code') { + return jsonResponse({ device_code: 'device', user_code: 'ABCD', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const response = await requestApp('/api/upstreams/copilot/oauth/device-login/start', { method: 'POST', headers: { 'x-floway-session': adminSession } }); + assertEquals(response.status, 200); + assertEquals(await response.json(), { device_code: 'device', user_code: 'ABCD', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 }); + }, + ); +}); + +// The blueprint envelope shape the SPA sends when the operator has not yet +// saved a Copilot row. Matches `blueprintUpstreamRecord('copilot')` on the +// wire — the exchange endpoint only reads `id`, `kind`, and +// `proxy_fallback_list` from the envelope, so a minimal literal keeps the +// test focused on the exchange semantics. +const copilotBlueprintEnvelope = { id: '', kind: 'copilot', config: null, state: null }; + +test('/api/upstreams/copilot/oauth/device-login/poll returns a config+state patch and identity from the token exchange', async () => { + const { repo, adminSession } = await setupAppTest(); + await repo.upstreams.deleteAll(); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'github.com' && url.pathname === '/login/oauth/access_token') return jsonResponse({ access_token: 'ghu_new' }); + if (url.hostname === 'api.github.com' && url.pathname === '/user') return jsonResponse(githubUser); + if (url.hostname === 'api.github.com' && url.pathname === '/copilot_internal/v2/token') { + return jsonResponse({ + token: 'ct_new', + expires_at: Math.floor(Date.now() / 1000) + 1500, + refresh_in: 1200, + endpoints: { api: 'https://api.enterprise.githubcopilot.com' }, + }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const response = await requestApp('/api/upstreams/copilot/oauth/device-login/poll', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-floway-session': adminSession, + }, + body: JSON.stringify({ record: copilotBlueprintEnvelope, deviceCode: 'device' }), + }); + + assertEquals(response.status, 200); + const body = (await response.json()) as { status: string; user: { id: number }; patch: { config: { githubToken: string; user: { id: number } }; state: { copilotToken: { token: string; baseUrl: string } } } }; + assertEquals(body.status, 'complete'); + assertEquals(body.user.id, githubUser.id); + // Create-flow returns the raw patch — no DB write happens here; the + // SPA merges it into the draft and calls POST /api/upstreams to save. + assertEquals(body.patch.config.githubToken, 'ghu_new'); + assertEquals(body.patch.config.user.id, githubUser.id); + assertEquals(body.patch.state.copilotToken.token, 'ct_new'); + assertEquals(body.patch.state.copilotToken.baseUrl, 'https://api.enterprise.githubcopilot.com'); + }, + ); + + // No DB write during create-flow poll — persistence is the caller's + // subsequent POST /api/upstreams. + assertEquals(await repo.upstreams.list(), []); +}); + +test('/api/upstreams/copilot/oauth/device-login/poll rejects failed GitHub user lookup with 502 and no side-effect', async () => { + const { repo, adminSession } = await setupAppTest(); + await repo.upstreams.deleteAll(); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'github.com' && url.pathname === '/login/oauth/access_token') return jsonResponse({ access_token: 'ghu_no_user' }); + if (url.hostname === 'api.github.com' && url.pathname === '/user') return jsonResponse({ message: 'bad credentials' }, 401); + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const response = await requestApp('/api/upstreams/copilot/oauth/device-login/poll', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-floway-session': adminSession, + }, + body: JSON.stringify({ record: copilotBlueprintEnvelope, deviceCode: 'device' }), + }); + + assertEquals(response.status, 502); + const body = (await response.json()) as { error: string }; + assertStringIncludes(body.error, 'GitHub user lookup failed: 401'); + assertStringIncludes(body.error, 'bad credentials'); + }, + ); + + assertEquals(await repo.upstreams.list(), []); +}); + +test('/api/upstreams/copilot/oauth/device-login/poll rejects a failed token exchange with 502 and no side-effect', async () => { + const { repo, adminSession } = await setupAppTest(); + await repo.upstreams.deleteAll(); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'github.com' && url.pathname === '/login/oauth/access_token') return jsonResponse({ access_token: 'ghu_no_seat' }); + if (url.hostname === 'api.github.com' && url.pathname === '/user') return jsonResponse(githubUser); + if (url.hostname === 'api.github.com' && url.pathname === '/copilot_internal/v2/token') return jsonResponse({ message: 'no copilot seat' }, 403); + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const response = await requestApp('/api/upstreams/copilot/oauth/device-login/poll', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-floway-session': adminSession, + }, + body: JSON.stringify({ record: copilotBlueprintEnvelope, deviceCode: 'device' }), + }); + + assertEquals(response.status, 502); + const body = (await response.json()) as { error: string }; + assertStringIncludes(body.error, 'Copilot token fetch failed: 403'); + assertStringIncludes(body.error, 'no copilot seat'); + }, + ); + + assertEquals(await repo.upstreams.list(), []); +}); + +test('/api/upstreams/copilot/oauth/device-login/poll rejects a token-exchange response missing endpoints.api with 502 and no side-effect', async () => { + const { repo, adminSession } = await setupAppTest(); + await repo.upstreams.deleteAll(); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'github.com' && url.pathname === '/login/oauth/access_token') return jsonResponse({ access_token: 'ghu_no_endpoint' }); + if (url.hostname === 'api.github.com' && url.pathname === '/user') return jsonResponse(githubUser); + if (url.hostname === 'api.github.com' && url.pathname === '/copilot_internal/v2/token') { + return jsonResponse({ token: 'ct_no_endpoint', expires_at: Math.floor(Date.now() / 1000) + 1500, refresh_in: 1200 }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const response = await requestApp('/api/upstreams/copilot/oauth/device-login/poll', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-floway-session': adminSession, + }, + body: JSON.stringify({ record: copilotBlueprintEnvelope, deviceCode: 'device' }), + }); + + assertEquals(response.status, 502); + const body = (await response.json()) as { error: string }; + assertStringIncludes(body.error, 'endpoints.api'); + }, + ); + + assertEquals(await repo.upstreams.list(), []); +}); + +test('/api/upstreams/copilot/oauth/device-login/poll targeted-patches config+state on the row identified by record.id', async () => { + const { repo, adminSession, githubAccount } = await setupAppTest({ + githubAccount: { + token: 'ghu_old', + user: githubUser, + }, + }); + const existing = buildCopilotUpstreamRecord(githubAccount, { id: 'up_existing_copilot', name: 'Pinned Copilot', sortOrder: 9 }); + await repo.upstreams.deleteAll(); + await repo.upstreams.save(existing); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'github.com' && url.pathname === '/login/oauth/access_token') return jsonResponse({ access_token: 'ghu_refreshed' }); + if (url.hostname === 'api.github.com' && url.pathname === '/user') return jsonResponse(githubUser); + if (url.hostname === 'api.github.com' && url.pathname === '/copilot_internal/v2/token') { + return jsonResponse({ + token: 'ct_refreshed', + expires_at: Math.floor(Date.now() / 1000) + 1500, + refresh_in: 1200, + endpoints: { api: 'https://api.business.githubcopilot.com' }, + }); + } + // Warmup probes /models on the per-tier host — return an empty catalog + // so the post-persist warm completes without waiting on a real fetch. + if (url.hostname === 'api.business.githubcopilot.com') return jsonResponse({ data: [] }); + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const response = await requestApp('/api/upstreams/copilot/oauth/device-login/poll', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-floway-session': adminSession, + }, + body: JSON.stringify({ record: { id: 'up_existing_copilot', kind: 'copilot', config: null, state: null }, deviceCode: 'device' }), + }); + assertEquals(response.status, 200); + const body = (await response.json()) as { status: string; patch: { config: { githubToken: string } } }; + assertEquals(body.status, 'complete'); + assertEquals(body.patch.config.githubToken, 'ghu_refreshed'); + }, + ); + + const rows = await repo.upstreams.list(); + assertEquals(rows.length, 1); + // The row-metadata fields (id, name, sortOrder) survive; only config + + // state are overwritten by the credential patch. + assertEquals(rows[0].id, 'up_existing_copilot'); + assertEquals(rows[0].name, 'Pinned Copilot'); + assertEquals(rows[0].sortOrder, 9); + assertEquals((rows[0].config as Record).githubToken, 'ghu_refreshed'); + const persistedState = rows[0].state as { copilotToken: { baseUrl: string } | null } | null; + assertEquals(persistedState?.copilotToken?.baseUrl, 'https://api.business.githubcopilot.com'); +}); diff --git a/packages/gateway/src/control-plane/upstreams/copilot.ts b/packages/gateway/src/control-plane/upstreams/copilot.ts new file mode 100644 index 0000000000..498c56354a --- /dev/null +++ b/packages/gateway/src/control-plane/upstreams/copilot.ts @@ -0,0 +1,142 @@ +import type { Context } from 'hono'; + +import { resolveControlPlaneFetcher } from './proxy-resolution.ts'; +import { upstreamErrorMessage as errorMessage } from './shared.ts'; +import type { CtxWithJson } from '../../middleware/zod-validator.ts'; +import { getRepo } from '../../repo/index.ts'; +import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; +import type { copilotOAuthDeviceLoginPollBody, copilotQuotaBody } from '../schemas.ts'; +import { isRecord } from '../shared/field-validators.ts'; +import { warmModelsCache } from '../shared/warm-models-cache.ts'; +import type { Fetcher, UpstreamRecord } from '@floway-dev/provider'; +import { + clearInProcessCopilotTokenCache, + emptyCopilotUpstreamState, + exchangeCopilotToken, + fetchCopilotUsage, + fetchGitHubUser, + pollGitHubDeviceFlow, + readCopilotUpstreamState, + startGitHubDeviceFlow, + type CopilotTokenEntry, + type CopilotUpstreamConfig, + type CopilotUpstreamState, + type CopilotUpstreamUser, + type CopilotUsageResponse, +} from '@floway-dev/provider-copilot'; + +export const copilotOAuthDeviceLoginStart = async (c: Context) => { + try { + const result = await startGitHubDeviceFlow(); + if (!result.ok) return c.json({ error: result.error }, 502); + return c.json(result.data); + } catch (e: unknown) { + const msg = errorMessage(e); + return c.json({ error: msg }, 502); + } +}; + +// Unified device-login poll under the record-body action contract. The +// GitHub device flow is inherently stateless; this handler exchanges the +// device_code for a GitHub PAT + user info + Copilot access token, and +// returns them as a patch to merge into the caller's draft record. When +// the caller supplies a persisted `record.id`, the same patch is +// simultaneously applied to the stored record so the live data plane +// picks up the fresh credential immediately. +export const copilotOAuthDeviceLoginPoll = async (c: CtxWithJson) => { + const { record, deviceCode } = c.req.valid('json'); + + // Config-validation errors (e.g. unknown proxy id in the override) surface + // as 400 — they belong to the caller, not to the upstream. + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ override: record.proxy_fallback_list, runtimeLocation: getRuntimeLocation(c.req.raw) }); + } catch (err) { + return c.json({ status: 'error' as const, error: errorMessage(err) }, 400); + } + + // Upstream-facing calls (GitHub device poll + user lookup + Copilot token + // exchange) can legitimately 502 the caller when GitHub / Copilot is + // unhealthy. DB ops below run OUTSIDE this catch so that a repo `.save()` + // or scheduler failure surfaces as a 500 with a stack, not as a + // misleading "upstream error" 502. + type UpstreamCred = { user: CopilotUpstreamUser; tokenEntry: CopilotTokenEntry; accessToken: string }; + let cred: UpstreamCred; + try { + const data = await pollGitHubDeviceFlow(deviceCode, fetcher); + + if (data.error === 'authorization_pending') return c.json({ status: 'pending' as const }); + if (data.error === 'slow_down') return c.json({ status: 'slow_down' as const, interval: data.interval }); + if (data.error) return c.json({ status: 'error' as const, error: data.error_description ?? data.error }, 400); + if (!data.access_token) return c.json({ status: 'error' as const, error: 'Unknown response' }, 500); + + // Validates the PAT + seeds a fresh Copilot access token so the data + // plane and dashboard `endpoints.api` calls work immediately without + // a follow-up exchange round trip. + const user = await fetchGitHubUser(data.access_token, fetcher); + const tokenEntry = await exchangeCopilotToken(data.access_token, fetcher); + cred = { user, tokenEntry, accessToken: data.access_token }; + } catch (e: unknown) { + return c.json({ status: 'error' as const, error: errorMessage(e) }, 502); + } + + const configPatch: CopilotUpstreamConfig = { githubToken: cred.accessToken, user: cred.user }; + + // Return the fully-merged state slot instead of a partial `{ copilotToken }` + // patch. Frontend `applyPatch` does whole-slot replacement on state, so a + // partial slot would clobber any sibling field (e.g. draft.state.knownModels + // hydrated by an earlier fetch). Edit state seeds the merge from the stored + // record; create state seeds from an empty slot so the reply is uniformly a + // full slot regardless of caller path. + let nextState: CopilotUpstreamState; + if (record.id !== '') { + const dbRecord = await getRepo().upstreams.getById(record.id); + if (!dbRecord) return c.json({ status: 'error' as const, error: 'Upstream not found' }, 404); + if (dbRecord.kind !== 'copilot') return c.json({ status: 'error' as const, error: 'Upstream is not a Copilot upstream' }, 400); + const prevState = readCopilotUpstreamState(dbRecord.state); + nextState = { ...prevState, copilotToken: cred.tokenEntry }; + const next: UpstreamRecord = { ...dbRecord, config: configPatch, state: nextState, updatedAt: new Date().toISOString() }; + await getRepo().upstreams.save(next); + clearInProcessCopilotTokenCache(); + await warmModelsCache(next, c); + } else { + nextState = { ...emptyCopilotUpstreamState(), copilotToken: cred.tokenEntry }; + } + + return c.json({ + status: 'complete' as const, + user: cred.user, + patch: { + config: configPatch, + state: nextState, + }, + }); +}; + +// Look up GitHub Copilot quota for the draft's github token. Pure query — +// no DB touch, no patch — because the response is a live snapshot that +// the dashboard renders in place. Works uniformly in create and edit +// state (draft.config.githubToken is the sole input). +export const copilotQuota = async (c: CtxWithJson) => { + try { + const { record } = c.req.valid('json'); + if (record.kind !== 'copilot') return c.json({ error: 'Upstream is not a Copilot upstream' }, 400); + const config = isRecord(record.config) ? record.config : null; + const githubToken = config && typeof config.githubToken === 'string' ? config.githubToken : ''; + if (!githubToken) return c.json({ error: 'Copilot upstream has no GitHub token' }, 400); + + const fetcher = await resolveControlPlaneFetcher({ override: record.proxy_fallback_list, runtimeLocation: getRuntimeLocation(c.req.raw) }); + const resp = await fetchCopilotUsage(githubToken, fetcher); + + if (!resp.ok) { + const text = await resp.text(); + const status = resp.status === 401 || resp.status === 403 ? 502 : resp.status; + return c.json({ error: `GitHub API error: ${resp.status} ${text}` }, status as 400 | 404 | 500 | 502); + } + + const data = (await resp.json()) as CopilotUsageResponse; + return c.json(data); + } catch (e: unknown) { + return c.json({ error: errorMessage(e) }, 502); + } +}; diff --git a/packages/gateway/src/control-plane/upstreams/models.ts b/packages/gateway/src/control-plane/upstreams/models.ts new file mode 100644 index 0000000000..b0667ce8f5 --- /dev/null +++ b/packages/gateway/src/control-plane/upstreams/models.ts @@ -0,0 +1,109 @@ +import { resolveControlPlaneFetcher } from './proxy-resolution.ts'; +import { isValidProviderKind, upstreamErrorMessage as errorMessage } from './shared.ts'; +import { MODEL_LISTING_FAILURE_MESSAGE } from '../../data-plane/models/shared.ts'; +import { fetchUpstreamModelsCached } from '../../data-plane/providers/models-cache.ts'; +import { createProvider } from '../../data-plane/providers/registry.ts'; +import type { CtxWithJson } from '../../middleware/zod-validator.ts'; +import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; +import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; +import type { listModelsBody } from '../schemas.ts'; +import { ProviderModelsUnavailableError, type Fetcher, type ProviderModel, type ProxyFallbackEntry, type UpstreamRecord } from '@floway-dev/provider'; +import { assertCustomUpstreamRecord, fetchCustomModels } from '@floway-dev/provider-custom'; +import { assertOllamaUpstreamRecord, createOllamaProvider } from '@floway-dev/provider-ollama'; + +// `upstreamModelId` is the wire-side identifier the provider will send when +// a caller invokes the public `model.id` — claude-code exposes +// `claude-sonnet-4-5` publicly while sending `claude-sonnet-4-5-20250929` +// on the wire, and other providers may distinguish similarly through their +// opaque `providerData` blob. +const reshapeModelForDashboard = (model: ProviderModel): Record => { + const providerData = typeof model.providerData === 'object' && model.providerData !== null ? model.providerData as { upstreamModelId?: unknown } : null; + const wireId = typeof providerData?.upstreamModelId === 'string' && providerData.upstreamModelId.length > 0 ? providerData.upstreamModelId : model.id; + return { + upstreamModelId: wireId, + publicModelId: model.id, + kind: model.kind, + endpoints: model.endpoints, + ...(model.display_name !== undefined ? { display_name: model.display_name } : {}), + ...(Object.keys(model.limits).length > 0 ? { limits: model.limits } : {}), + ...(model.pricing ? { pricing: model.pricing } : {}), + ...(model.chat ? { chat: model.chat } : {}), + ...(model.flagOverrides ? { flagOverrides: model.flagOverrides } : {}), + }; +}; + +// Unified model catalog fetch for both draft preview and saved-record +// refresh. Always live-fetches on the control plane; when +// record.id !== '' the request also warms/refreshes the SWR cache via +// `fetchUpstreamModelsCached` so a subsequent data-plane call picks up +// the fresh catalog. Custom's response stays the raw upstream row shape +// (dashboard translates through the draft's endpoints); every other +// kind returns UpstreamModelConfig-shaped rows. +export const listModels = async (c: CtxWithJson) => { + const { record } = c.req.valid('json'); + if (!isValidProviderKind(record.kind)) { + return c.json({ error: { message: `Invalid kind: ${record.kind}`, type: 'invalid_request_error' } }, 400); + } + const kind = record.kind; + + const scheduler = backgroundSchedulerFromContext(c); + const now = new Date().toISOString(); + const synthRecord: UpstreamRecord = { + id: record.id || 'draft', + kind, + name: 'draft', + enabled: true, + sortOrder: 0, + createdAt: now, + updatedAt: now, + flagOverrides: {}, + disabledPublicModelIds: [], + proxyFallbackList: (record.proxy_fallback_list ?? []) as ProxyFallbackEntry[], + modelPrefix: null, + color: null, + config: record.config, + state: record.state, + }; + + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ + override: record.proxy_fallback_list, + upstreamId: record.id || undefined, + runtimeLocation: getRuntimeLocation(c.req.raw), + }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + + try { + if (kind === 'custom') { + const assertedConfig = assertCustomUpstreamRecord(synthRecord).config; + const result = await fetchCustomModels(assertedConfig, fetcher); + return c.json(result); + } + if (kind === 'ollama') { + assertOllamaUpstreamRecord(synthRecord); + const instance = createOllamaProvider(synthRecord); + const models = await instance.instance.getProvidedModels(fetcher); + return c.json({ data: models.map(reshapeModelForDashboard) }); + } + // Copilot / codex / claude-code / azure — use the provider factory. + // Force through the SWR cache when the record is persisted so the + // side-effect refresh keeps the data-plane cache in step; otherwise + // live-fetch without any caching. + const provider = createProvider(synthRecord); + const models = record.id !== '' + ? await fetchUpstreamModelsCached(provider, { scheduler, fetcher, force: true }) + : await provider.instance.getProvidedModels(fetcher); + return c.json({ data: models.map(reshapeModelForDashboard) }); + } catch (e) { + if (e instanceof ProviderModelsUnavailableError) { + return c.json({ error: { message: MODEL_LISTING_FAILURE_MESSAGE, type: 'api_error' } }, 502); + } + if (e instanceof Error && /Malformed .* upstream config/.test(e.message)) { + return c.json({ error: errorMessage(e) }, 400); + } + throw e; + } +}; diff --git a/packages/gateway/src/control-plane/upstreams/proxy-resolution.ts b/packages/gateway/src/control-plane/upstreams/proxy-resolution.ts index 6f44f74d67..c5379d0d76 100644 --- a/packages/gateway/src/control-plane/upstreams/proxy-resolution.ts +++ b/packages/gateway/src/control-plane/upstreams/proxy-resolution.ts @@ -1,10 +1,11 @@ -import { createFetcher, type ProxyEntry } from '../../dial/fetcher.ts'; +import { createFetcher } from '../../dial/fetcher.ts'; import { createPerRequestFetcher } from '../../dial/per-request.ts'; +import { loadProxyCatalog } from '../../dial/proxy-catalog.ts'; import { getRepo } from '../../repo/index.ts'; import { DIRECT_CONNECT_ID, isDirectFallbackId, normalizeProxyFallbackList } from '../../repo/proxy-fallback-list.ts'; import { getSocketDial } from '@floway-dev/platform'; import { directFetcher, type Fetcher, type ProxyFallbackEntry } from '@floway-dev/provider'; -import { parseProxyUri, type ProxyUriError, runDirectConnectRequest, runProxiedRequest } from '@floway-dev/proxy'; +import { runDirectConnectRequest, runProxiedRequest } from '@floway-dev/proxy'; // Fetcher resolution for control-plane operations that fire from the // dashboard edit form, where the in-progress proxy_fallback_list must take @@ -37,20 +38,7 @@ const buildOverrideFetcher = async ( } const repo = getRepo(); - const proxies = await repo.proxies.list(); - const proxyById = new Map(); - const parseErrors = new Map(); - for (const p of proxies) { - if (!referenced.has(p.id)) continue; - try { - proxyById.set(p.id, { - config: parseProxyUri(p.url), - dialTimeoutMs: p.dialTimeoutSeconds === null ? null : p.dialTimeoutSeconds * 1000, - }); - } catch (err) { - parseErrors.set(p.id, err as ProxyUriError); - } - } + const { proxyById, parseErrors } = await loadProxyCatalog(repo, referenced); const unknown = list.find(entry => !isDirectFallbackId(entry.id) && !proxyById.has(entry.id) && !parseErrors.has(entry.id)); if (unknown !== undefined) { diff --git a/packages/gateway/src/control-plane/upstreams/routes.ts b/packages/gateway/src/control-plane/upstreams/routes.ts index f96656d501..608a82081b 100644 --- a/packages/gateway/src/control-plane/upstreams/routes.ts +++ b/packages/gateway/src/control-plane/upstreams/routes.ts @@ -1,81 +1,31 @@ import type { Context } from 'hono'; -import { resolveControlPlaneFetcher } from './proxy-resolution.ts'; import { blueprintUpstreamRecord, upstreamRecordToFullJson, upstreamRecordToJson, type SerializedUpstreamRecord } from './serialize.ts'; -import { MODEL_LISTING_FAILURE_MESSAGE } from '../../data-plane/models/shared.ts'; -import { fetchUpstreamModelsCached } from '../../data-plane/providers/models-cache.ts'; -import { createProvider } from '../../data-plane/providers/registry.ts'; -import { createPerRequestFetcher } from '../../dial/per-request.ts'; -import { type AuthedContext, userFromContext } from '../../middleware/auth.ts'; +import { isValidProviderKind, upstreamErrorMessage as errorMessage } from './shared.ts'; +import { type AuthedContext } from '../../middleware/auth.ts'; import { type CtxWithJson } from '../../middleware/zod-validator.ts'; import { getRepo } from '../../repo/index.ts'; import { isDirectFallbackId, normalizeProxyFallbackList } from '../../repo/proxy-fallback-list.ts'; -import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; import { shortId } from '../../shared/short-id.ts'; -import type { claudeCodeOauthAuthorizeUrlBody, claudeCodeOauthExchangeBody, claudeCodeOauthRefreshBody, claudeCodeProbeBody, claudeCodeSetupTokenAuthorizeUrlBody, claudeCodeSetupTokenExchangeBody, codexOauthAuthorizeUrlBody, codexOauthExchangeBody, codexOauthRefreshBody, copilotOauthDeviceLoginPollBody, copilotQuotaBody, createUpstreamBody, listModelsBody, updateUpstreamBody } from '../schemas.ts'; -import { copilotConfigField, type CopilotUpstreamConfig, isRecord } from '../shared/field-validators.ts'; +import type { createUpstreamBody, updateUpstreamBody } from '../schemas.ts'; +import { copilotConfigField, isRecord } from '../shared/field-validators.ts'; +import { nextSortOrder } from '../shared/sort-order.ts'; +import { warmModelsCache } from '../shared/warm-models-cache.ts'; import { normalizeModelPrefix, OPTIONAL_FLAGS, - ProviderModelsUnavailableError, ALL_PROVIDER_KINDS, - type Fetcher, type ModelPrefixConfig, - type ProviderModel, type ProxyFallbackEntry, type UpstreamProviderKind, type UpstreamRecord, } from '@floway-dev/provider'; import { assertAzureUpstreamRecord } from '@floway-dev/provider-azure'; -import { - type ClaudeCodeAccountCredential, - type ClaudeCodeUpstreamConfig, - type ClaudeCodeUpstreamState, - ClaudeCodeOAuthSessionTerminatedError, - assertClaudeCodeUpstreamRecord, - buildClaudeCodeAuthorizeUrl, - ensureClaudeCodeAccessToken, - fetchClaudeCodeUsageProbe, - importClaudeCodeFromCallback, - importClaudeCodeFromCredentialsJson, - importClaudeCodeFromSetupTokenCallback, - logInfo, - readClaudeCodeUpstreamState, -} from '@floway-dev/provider-claude-code'; -import { - type CodexQuotaSnapshotMap, - type CodexUpstreamConfig, - type CodexUpstreamState, - CODEX_AUTHORIZE_URL, - CODEX_CLIENT_ID, - CODEX_OAUTH_SCOPE, - CODEX_REDIRECT_URI, - CodexOAuthSessionTerminatedError, - assertCodexUpstreamRecord, - assertCodexUpstreamState, - ensureCodexAccessToken, - getCodexQuota, - importCodexFromAuthJson, - importCodexFromCallback, - mintCodexAccessToken, -} from '@floway-dev/provider-codex'; -import { - clearInProcessCopilotTokenCache, - emptyCopilotUpstreamState, - exchangeCopilotToken, - fetchCopilotUsage, - fetchGitHubUser, - pollGitHubDeviceFlow, - readCopilotUpstreamState, - startGitHubDeviceFlow, - type CopilotTokenEntry, - type CopilotUpstreamState, - type CopilotUpstreamUser, - type CopilotUsageResponse, -} from '@floway-dev/provider-copilot'; -import { assertCustomUpstreamRecord, fetchCustomModels } from '@floway-dev/provider-custom'; -import { assertOllamaUpstreamRecord, createOllamaProvider } from '@floway-dev/provider-ollama'; +import { assertClaudeCodeUpstreamRecord, readClaudeCodeUpstreamState } from '@floway-dev/provider-claude-code'; +import { type CodexQuotaSnapshotMap, assertCodexUpstreamRecord, assertCodexUpstreamState, getCodexQuota } from '@floway-dev/provider-codex'; +import { readCopilotUpstreamState } from '@floway-dev/provider-copilot'; +import { assertCustomUpstreamRecord } from '@floway-dev/provider-custom'; +import { assertOllamaUpstreamRecord } from '@floway-dev/provider-ollama'; type CodexQuotaProjection = { codex_quota?: CodexQuotaSnapshotMap | null }; @@ -120,8 +70,6 @@ const serializeForResponse = async ( type ValidationResult = { ok: true; value: T } | { ok: false; error: string }; -const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); - // Run the per-provider invariant asserts on a freshly-built or freshly-merged // record before it hits the repo. Request-time zod schemas only validate JSON // shape; these helpers enforce the URL / endpoint-mix / path-override rules @@ -183,28 +131,6 @@ const normalizeModelPrefixField = (input: unknown): ValidationResult => { - const scheduler = backgroundSchedulerFromContext(c); - const provider = createProvider(record); - const fetcher = (await createPerRequestFetcher(getRuntimeLocation(c.req.raw)))(record.id); - try { - await fetchUpstreamModelsCached(provider, { scheduler, fetcher, force: true }); - } catch (e) { - // runFetch persists upstream failures to the row's `lastError`; anything - // reaching here is a Floway-side fault (lastError write itself failed, - // scheduler blew up, sqlite ran out of space, etc.). Log so the failure - // is observable — the dashboard would otherwise silently show a stale - // cache with no explanation. - logInfo('warm_models_cache_failed', { upstream_id: record.id, error: errorMessage(e) }); - } -}; - // Built-in direct transports are always valid entry ids; every other id must // reference an existing proxy row. List order matters at dial time (see // createFetcher), and persistence layers dedupe before storing. @@ -245,9 +171,6 @@ export const listUpstreamOptions = async (c: Context) => { export const listOptionalFlags = (c: Context) => c.json(OPTIONAL_FLAGS); -const isValidProviderKind = (value: unknown): value is UpstreamProviderKind => - typeof value === 'string' && (ALL_PROVIDER_KINDS as readonly string[]).includes(value); - // Serve a shape-complete blank SerializedUpstreamRecord for the requested // kind. The create page's loader calls this so it can render the same // UpstreamEditPage component edit uses, treating a fresh upstream as an @@ -301,7 +224,7 @@ export const createUpstream = async (c: CtxWithJson) kind: body.kind, name: body.name, enabled: body.enabled ?? true, - sortOrder: body.sort_order ?? existing.reduce((acc, u) => Math.max(acc, u.sortOrder), -1) + 1, + sortOrder: body.sort_order ?? nextSortOrder(existing), createdAt: now, updatedAt: now, flagOverrides: body.flag_overrides ?? {}, @@ -401,609 +324,3 @@ export const deleteUpstream = async (c: AuthedContext<'/:id'>) => { await repo.proxyBackoffs.resetForUpstream(id); return c.json({ ok: true }); }; - -export const copilotOauthDeviceLoginStart = async (c: Context) => { - try { - const result = await startGitHubDeviceFlow(); - if (!result.ok) return c.json({ error: result.error }, 502); - return c.json(result.data); - } catch (e: unknown) { - const msg = errorMessage(e); - return c.json({ error: msg }, 502); - } -}; - -// Unified device-login poll under the record-body action contract. The -// GitHub device flow is inherently stateless; this handler exchanges the -// device_code for a GitHub PAT + user info + Copilot access token, and -// returns them as a patch to merge into the caller's draft record. When -// the caller supplies a persisted `record.id`, the same patch is -// simultaneously applied to the stored record so the live data plane -// picks up the fresh credential immediately. -export const copilotOauthDeviceLoginPoll = async (c: CtxWithJson) => { - const { record, deviceCode } = c.req.valid('json'); - - // Config-validation errors (e.g. unknown proxy id in the override) surface - // as 400 — they belong to the caller, not to the upstream. - let fetcher: Fetcher; - try { - fetcher = await resolveControlPlaneFetcher({ override: record.proxy_fallback_list, runtimeLocation: getRuntimeLocation(c.req.raw) }); - } catch (err) { - return c.json({ status: 'error' as const, error: errorMessage(err) }, 400); - } - - // Upstream-facing calls (GitHub device poll + user lookup + Copilot token - // exchange) can legitimately 502 the caller when GitHub / Copilot is - // unhealthy. DB ops below run OUTSIDE this catch so that a repo `.save()` - // or scheduler failure surfaces as a 500 with a stack, not as a - // misleading "upstream error" 502. - type UpstreamCred = { user: CopilotUpstreamUser; tokenEntry: CopilotTokenEntry; accessToken: string }; - let cred: UpstreamCred; - try { - const data = await pollGitHubDeviceFlow(deviceCode, fetcher); - - if (data.error === 'authorization_pending') return c.json({ status: 'pending' as const }); - if (data.error === 'slow_down') return c.json({ status: 'slow_down' as const, interval: data.interval }); - if (data.error) return c.json({ status: 'error' as const, error: data.error_description ?? data.error }, 400); - if (!data.access_token) return c.json({ status: 'error' as const, error: 'Unknown response' }, 500); - - // Validates the PAT + seeds a fresh Copilot access token so the data - // plane and dashboard `endpoints.api` calls work immediately without - // a follow-up exchange round trip. - const user = await fetchGitHubUser(data.access_token, fetcher); - const tokenEntry = await exchangeCopilotToken(data.access_token, fetcher); - cred = { user, tokenEntry, accessToken: data.access_token }; - } catch (e: unknown) { - return c.json({ status: 'error' as const, error: errorMessage(e) }, 502); - } - - const configPatch: CopilotUpstreamConfig = { githubToken: cred.accessToken, user: cred.user }; - - // Return the fully-merged state slot instead of a partial `{ copilotToken }` - // patch. Frontend `applyPatch` does whole-slot replacement on state, so a - // partial slot would clobber any sibling field (e.g. draft.state.knownModels - // hydrated by an earlier fetch). Edit state seeds the merge from the stored - // record; create state seeds from an empty slot so the reply is uniformly a - // full slot regardless of caller path. - let nextState: CopilotUpstreamState; - if (record.id !== '') { - const dbRecord = await getRepo().upstreams.getById(record.id); - if (!dbRecord) return c.json({ status: 'error' as const, error: 'Upstream not found' }, 404); - if (dbRecord.kind !== 'copilot') return c.json({ status: 'error' as const, error: 'Upstream is not a Copilot upstream' }, 400); - const prevState = readCopilotUpstreamState(dbRecord.state); - nextState = { ...prevState, copilotToken: cred.tokenEntry }; - const next: UpstreamRecord = { ...dbRecord, config: configPatch, state: nextState, updatedAt: new Date().toISOString() }; - await getRepo().upstreams.save(next); - clearInProcessCopilotTokenCache(); - await warmModelsCache(next, c); - } else { - nextState = { ...emptyCopilotUpstreamState(), copilotToken: cred.tokenEntry }; - } - - return c.json({ - status: 'complete' as const, - user: cred.user, - patch: { - config: configPatch, - state: nextState, - }, - }); -}; - -// Look up GitHub Copilot quota for the draft's github token. Pure query — -// no DB touch, no patch — because the response is a live snapshot that -// the dashboard renders in place. Works uniformly in create and edit -// state (draft.config.githubToken is the sole input). -export const copilotQuota = async (c: CtxWithJson) => { - try { - const { record } = c.req.valid('json'); - if (record.kind !== 'copilot') return c.json({ error: 'Upstream is not a Copilot upstream' }, 400); - const config = isRecord(record.config) ? record.config : null; - const githubToken = config && typeof config.githubToken === 'string' ? config.githubToken : ''; - if (!githubToken) return c.json({ error: 'Copilot upstream has no GitHub token' }, 400); - - const fetcher = await resolveControlPlaneFetcher({ override: record.proxy_fallback_list, runtimeLocation: getRuntimeLocation(c.req.raw) }); - const resp = await fetchCopilotUsage(githubToken, fetcher); - - if (!resp.ok) { - const text = await resp.text(); - const status = resp.status === 401 || resp.status === 403 ? 502 : resp.status; - return c.json({ error: `GitHub API error: ${resp.status} ${text}` }, status as 400 | 404 | 500 | 502); - } - - const data = (await resp.json()) as CopilotUsageResponse; - return c.json(data); - } catch (e: unknown) { - return c.json({ error: errorMessage(e) }, 502); - } -}; - -// Codex OAuth under the unified record-body contract. Create and edit -// share one endpoint each: the caller posts the draft record; when -// `record.id !== ''` the produced patch is targeted-persisted, otherwise -// it is only returned for the front-end to merge into its draft. -export const codexOauthAuthorizeUrl = async (c: CtxWithJson) => { - const { challenge, state } = c.req.valid('json'); - const url = new URL(CODEX_AUTHORIZE_URL); - url.searchParams.set('response_type', 'code'); - url.searchParams.set('client_id', CODEX_CLIENT_ID); - url.searchParams.set('redirect_uri', CODEX_REDIRECT_URI); - url.searchParams.set('scope', CODEX_OAUTH_SCOPE); - url.searchParams.set('state', state); - url.searchParams.set('code_challenge', challenge); - url.searchParams.set('code_challenge_method', 'S256'); - url.searchParams.set('id_token_add_organizations', 'true'); - url.searchParams.set('codex_cli_simplified_flow', 'true'); - url.searchParams.set('originator', 'codex_cli_rs'); - return c.json({ authorize_url: url.toString() }); -}; - -export const codexOauthExchange = async (c: CtxWithJson) => { - const body = c.req.valid('json'); - const { record } = body; - if (record.kind !== 'codex') return c.json({ error: 'Upstream is not a Codex upstream' }, 400); - - let fetcher: Fetcher; - try { - fetcher = await resolveControlPlaneFetcher({ - override: record.proxy_fallback_list, - upstreamId: record.id || undefined, - runtimeLocation: getRuntimeLocation(c.req.raw), - }); - } catch (err) { - return c.json({ error: errorMessage(err) }, 400); - } - - let ingestion: { config: CodexUpstreamConfig; state: CodexUpstreamState }; - try { - if (body.auth_json !== undefined) { - ingestion = await importCodexFromAuthJson(body.auth_json); - } else { - const cb = body.callback!; - ingestion = await importCodexFromCallback({ code: cb.code, codeVerifier: cb.verifier, fetcher }); - } - } catch (err) { - return c.json({ error: errorMessage(err) }, 400); - } - - // Edit state: overwrite the credential slice of the stored record. - // Single-account convention — exchange REPLACES accounts[0], no append. - if (record.id !== '') { - const dbRecord = await getRepo().upstreams.getById(record.id); - if (!dbRecord) return c.json({ error: 'Upstream not found' }, 404); - if (dbRecord.kind !== 'codex') return c.json({ error: 'Upstream is not a Codex upstream' }, 400); - const next: UpstreamRecord = { - ...dbRecord, - config: ingestion.config, - state: ingestion.state, - updatedAt: new Date().toISOString(), - }; - await getRepo().upstreams.save(next); - await warmModelsCache(next, c); - } - - return c.json({ - patch: { - config: ingestion.config, - state: ingestion.state, - }, - }); -}; - -export const codexOauthRefresh = async (c: CtxWithJson) => { - const { record } = c.req.valid('json'); - if (record.kind !== 'codex') return c.json({ error: 'Upstream is not a Codex upstream' }, 400); - // Refresh is a stateful action on a persisted row — it delegates to - // `ensureCodexAccessToken` which reads state from DB, mints, and - // CAS-writes back with sibling-rotation recovery. Create-state refresh - // has no target: the just-completed OAuth exchange handed the client a - // brand-new refresh_token that has no reason to rotate yet, and the - // front-end does not surface the button until Save lands the row. - if (record.id === '') return c.json({ error: 'refresh requires a persisted upstream' }, 400); - assertCodexUpstreamState(record.state); - const account = record.state.accounts[0]; - if (account.state !== 'active') { - return c.json({ error: `Codex upstream is ${account.state}; re-run OAuth exchange to recover` }, 400); - } - - let fetcher: Fetcher; - try { - fetcher = await resolveControlPlaneFetcher({ - override: record.proxy_fallback_list, - upstreamId: record.id, - runtimeLocation: getRuntimeLocation(c.req.raw), - }); - } catch (err) { - return c.json({ error: errorMessage(err) }, 400); - } - - // Persist callback shape matches `createCodexProvider` — a rotated - // refresh_token CAS-writes back into the account slot with the just-read - // state as the expected value. A losing CAS is not an error here: the - // sibling that won the race already persisted a newer refresh_token, and - // `ensureCodexAccessToken`'s `recoverFromRefreshRace` picks up the - // sibling's fresh access token when our mint gets `invalid_grant`. - const persistRefreshTokenRotation = async (newRefreshToken: string): Promise => { - const fresh = await getRepo().upstreams.getById(record.id); - if (!fresh) return; - assertCodexUpstreamState(fresh.state); - const next: CodexUpstreamState = { - accounts: fresh.state.accounts.map(a => a.chatgptAccountId === account.chatgptAccountId - ? { ...a, refresh_token: newRefreshToken, state_updated_at: new Date().toISOString() } - : a), - }; - await getRepo().upstreams.saveState(record.id, next, { expectedState: fresh.state }); - }; - - try { - await ensureCodexAccessToken(record.id, account.chatgptAccountId, - refreshToken => mintCodexAccessToken(refreshToken, fetcher, persistRefreshTokenRotation), - true); - } catch (err) { - if (err instanceof CodexOAuthSessionTerminatedError) { - // Terminal flip mirrors `createCodexProvider.persistTerminalState`: - // clear the cached access token, mark the account refresh_failed so - // the dashboard renders the red badge and prompts a re-import. - // Best-effort — a losing CAS means a concurrent rotation already - // wrote newer state that supersedes ours. - const fresh = await getRepo().upstreams.getById(record.id); - if (fresh) { - assertCodexUpstreamState(fresh.state); - const next: CodexUpstreamState = { - accounts: fresh.state.accounts.map(a => a.chatgptAccountId === account.chatgptAccountId - ? { ...a, state: 'refresh_failed' as const, state_message: err.upstreamMessage, state_updated_at: new Date().toISOString(), accessToken: null } - : a), - }; - await getRepo().upstreams.saveState(record.id, next, { expectedState: fresh.state }); - } - return c.json({ error: `Codex refresh failed: ${err.upstreamMessage}. Re-run OAuth exchange to recover.` }, 400); - } - return c.json({ error: errorMessage(err) }, 502); - } - - const updated = await getRepo().upstreams.getById(record.id); - if (!updated) return c.json({ error: 'Upstream not found' }, 404); - return c.json({ patch: { state: updated.state } }); -}; - -// Claude Code OAuth + setup-token + probe endpoints under the unified -// record-body contract. Create and edit share one endpoint each: the -// caller posts the draft record; when `record.id !== ''` the produced -// patch is targeted-persisted, otherwise only returned for the -// front-end to merge into its draft. - -export const claudeCodeOauthAuthorizeUrl = async (c: CtxWithJson) => { - const { challenge, state } = c.req.valid('json'); - const authorize_url = buildClaudeCodeAuthorizeUrl({ state, codeChallenge: challenge, kind: 'oauth' }); - return c.json({ authorize_url }); -}; - -export const claudeCodeSetupTokenAuthorizeUrl = async (c: CtxWithJson) => { - const { challenge, state } = c.req.valid('json'); - const authorize_url = buildClaudeCodeAuthorizeUrl({ state, codeChallenge: challenge, kind: 'setup-token' }); - return c.json({ authorize_url }); -}; - -export const claudeCodeOauthExchange = async (c: CtxWithJson) => { - const body = c.req.valid('json'); - const { record } = body; - if (record.kind !== 'claude-code') return c.json({ error: 'Upstream is not a Claude Code upstream' }, 400); - - let fetcher: Fetcher; - try { - fetcher = await resolveControlPlaneFetcher({ - override: record.proxy_fallback_list, - upstreamId: record.id || undefined, - runtimeLocation: getRuntimeLocation(c.req.raw), - }); - } catch (err) { - return c.json({ error: errorMessage(err) }, 400); - } - - let ingestion: { config: ClaudeCodeUpstreamConfig; state: ClaudeCodeUpstreamState }; - try { - if (body.credentials_json !== undefined) { - ingestion = await importClaudeCodeFromCredentialsJson(body.credentials_json, fetcher); - } else { - const cb = body.callback!; - ingestion = await importClaudeCodeFromCallback({ code: cb.code, pkceVerifier: cb.verifier, state: cb.state, fetcher }); - } - } catch (err) { - return c.json({ error: errorMessage(err) }, 400); - } - - if (record.id !== '') { - const dbRecord = await getRepo().upstreams.getById(record.id); - if (!dbRecord) return c.json({ error: 'Upstream not found' }, 404); - if (dbRecord.kind !== 'claude-code') return c.json({ error: 'Upstream is not a Claude Code upstream' }, 400); - const next: UpstreamRecord = { - ...dbRecord, - config: ingestion.config, - state: ingestion.state, - updatedAt: new Date().toISOString(), - }; - await getRepo().upstreams.save(next); - await warmModelsCache(next, c); - } - - return c.json({ patch: { config: ingestion.config, state: ingestion.state } }); -}; - -export const claudeCodeSetupTokenExchange = async (c: CtxWithJson) => { - const { record, callback } = c.req.valid('json'); - if (record.kind !== 'claude-code') return c.json({ error: 'Upstream is not a Claude Code upstream' }, 400); - - let fetcher: Fetcher; - try { - fetcher = await resolveControlPlaneFetcher({ - override: record.proxy_fallback_list, - upstreamId: record.id || undefined, - runtimeLocation: getRuntimeLocation(c.req.raw), - }); - } catch (err) { - return c.json({ error: errorMessage(err) }, 400); - } - - let ingestion: { config: ClaudeCodeUpstreamConfig; state: ClaudeCodeUpstreamState }; - try { - ingestion = await importClaudeCodeFromSetupTokenCallback({ - code: callback.code, - pkceVerifier: callback.verifier, - state: callback.state, - fetcher, - }); - } catch (err) { - return c.json({ error: errorMessage(err) }, 400); - } - - if (record.id !== '') { - const dbRecord = await getRepo().upstreams.getById(record.id); - if (!dbRecord) return c.json({ error: 'Upstream not found' }, 404); - if (dbRecord.kind !== 'claude-code') return c.json({ error: 'Upstream is not a Claude Code upstream' }, 400); - const next: UpstreamRecord = { - ...dbRecord, - config: ingestion.config, - state: ingestion.state, - updatedAt: new Date().toISOString(), - }; - await getRepo().upstreams.save(next); - await warmModelsCache(next, c); - } - - return c.json({ patch: { config: ingestion.config, state: ingestion.state } }); -}; - -export const claudeCodeOauthRefresh = async (c: CtxWithJson) => { - const { record } = c.req.valid('json'); - if (record.kind !== 'claude-code') return c.json({ error: 'Upstream is not a Claude Code upstream' }, 400); - // Refresh delegates to the data plane's `ensureClaudeCodeAccessToken` - // with `force: true` so operator clicks and data-plane requests share - // the same rotation + sibling-race recovery path (no duplicated CAS - // logic, no divergence). Create-state refresh has no target — the - // just-completed OAuth exchange handed the client a brand-new - // refresh_token that has no reason to rotate yet. - if (record.id === '') return c.json({ error: 'refresh requires a persisted upstream' }, 400); - - const parsedState = readClaudeCodeUpstreamState(record.state); - const account = parsedState.accounts[0]; - if (account.state !== 'active') { - return c.json({ error: `Claude Code upstream is ${account.state}; re-run OAuth exchange to recover` }, 400); - } - if (account.tokenKind === 'setup-token') { - return c.json({ error: 'Setup-token credentials cannot be refreshed; re-run setup-token exchange to rotate' }, 400); - } - - let fetcher: Fetcher; - try { - fetcher = await resolveControlPlaneFetcher({ - override: record.proxy_fallback_list, - upstreamId: record.id, - runtimeLocation: getRuntimeLocation(c.req.raw), - }); - } catch (err) { - return c.json({ error: errorMessage(err) }, 400); - } - - try { - // `ensureClaudeCodeAccessToken` handles the whole flow: read state, - // CAS-write the rotated refresh_token alongside the fresh access - // token, and flip the row to refresh_failed on a terminal OAuth - // error. All this handler contributes is the HTTP framing. - await ensureClaudeCodeAccessToken({ upstreamId: record.id, repo: getRepo().upstreams, fetcher, force: true }); - } catch (err) { - if (err instanceof ClaudeCodeOAuthSessionTerminatedError) { - return c.json({ error: `Claude Code refresh failed: ${err.upstreamMessage}. Re-run OAuth exchange to recover.` }, 400); - } - return c.json({ error: errorMessage(err) }, 502); - } - - const updated = await getRepo().upstreams.getById(record.id); - if (!updated) return c.json({ error: 'Upstream not found' }, 404); - return c.json({ patch: { state: updated.state } }); -}; - -export const claudeCodeProbe = async (c: CtxWithJson) => { - const { record } = c.req.valid('json'); - if (record.kind !== 'claude-code') return c.json({ error: 'Quota probe is only supported for claude-code upstreams' }, 400); - const actor = userFromContext(c).id; - - let fetcher: Fetcher; - try { - fetcher = await resolveControlPlaneFetcher({ - override: record.proxy_fallback_list, - upstreamId: record.id || undefined, - runtimeLocation: getRuntimeLocation(c.req.raw), - }); - } catch (err) { - return c.json({ error: errorMessage(err) }, 400); - } - - // Resolving a fresh access token demands DB access (the token cache - // and CAS-guarded refresh live there), so probe on a create-state - // record requires that the caller has ensured a fresh access_token - // sits in draft.state.accounts[0].accessToken from the OAuth - // exchange step. In edit state, we can call the standard cache - // helper that reads / refreshes from DB. - let accessToken: string; - try { - if (record.id !== '') { - const access = await ensureClaudeCodeAccessToken({ - upstreamId: record.id, - repo: getRepo().upstreams, - fetcher, - }); - accessToken = access.entry.token; - } else { - const parsedState = readClaudeCodeUpstreamState(record.state); - const account = parsedState.accounts[0]; - if (!account.accessToken?.token) { - return c.json({ error: 'Draft account has no fresh access token; run OAuth refresh first' }, 400); - } - accessToken = account.accessToken.token; - } - } catch (err) { - logInfo('claude_code_admin_action', { upstream_id: record.id, action: 'quota_probe', actor, outcome: 'error', error: errorMessage(err) }); - if (err instanceof ClaudeCodeOAuthSessionTerminatedError) { - return c.json({ error: `Claude Code refresh failed: ${err.upstreamMessage}` }, 503); - } - return c.json({ error: errorMessage(err) }, 502); - } - - let probe; - try { - probe = await fetchClaudeCodeUsageProbe(accessToken, fetcher); - } catch (err) { - logInfo('claude_code_admin_action', { upstream_id: record.id, action: 'quota_probe', actor, outcome: 'error', error: errorMessage(err) }); - return c.json({ error: errorMessage(err) }, 502); - } - - const snapshotPatch = { - usageProbeSnapshot: { fetchedAt: Date.parse(probe.fetched_at), data: probe.body }, - }; - const mergeSnapshotInto = (state: ClaudeCodeUpstreamState): ClaudeCodeUpstreamState => ({ - ...state, - accounts: state.accounts.map((a, i): ClaudeCodeAccountCredential => i === 0 ? { ...a, ...snapshotPatch } : a), - }); - - // Merge the freshly-fetched snapshot into the caller's draft state so the - // response carries a whole state slot the caller can hand to its uniform - // patch merger — the wire contract stays symmetric with refresh/exchange - // instead of asking the client to hand-merge into accounts[0]. - const merged = mergeSnapshotInto(readClaudeCodeUpstreamState(record.state)); - - if (record.id !== '') { - // Best-effort CAS persist against the currently-stored state — a losing - // race means a concurrent rotation wrote newer state that supersedes - // ours, which is fine (the snapshot rides on top of that new state on - // the next probe). - const fresh = await getRepo().upstreams.getById(record.id); - if (fresh) { - const freshMerged = mergeSnapshotInto(readClaudeCodeUpstreamState(fresh.state)); - await getRepo().upstreams.saveState(record.id, freshMerged, { expectedState: fresh.state }); - } - } - - logInfo('claude_code_admin_action', { upstream_id: record.id, action: 'quota_probe', actor, outcome: 'ok' }); - return c.json({ - fetched_at: probe.fetched_at, - body: probe.body, - patch: { state: merged }, - }); -}; - -// `upstreamModelId` is the wire-side identifier the provider will send when -// a caller invokes the public `model.id` — claude-code exposes -// `claude-sonnet-4-5` publicly while sending `claude-sonnet-4-5-20250929` -// on the wire, and other providers may distinguish similarly through their -// opaque `providerData` blob. -const reshapeModelForDashboard = (model: ProviderModel): Record => { - const providerData = typeof model.providerData === 'object' && model.providerData !== null ? model.providerData as { upstreamModelId?: unknown } : null; - const wireId = typeof providerData?.upstreamModelId === 'string' && providerData.upstreamModelId.length > 0 ? providerData.upstreamModelId : model.id; - return { - upstreamModelId: wireId, - publicModelId: model.id, - kind: model.kind, - endpoints: model.endpoints, - ...(model.display_name !== undefined ? { display_name: model.display_name } : {}), - ...(Object.keys(model.limits).length > 0 ? { limits: model.limits } : {}), - ...(model.pricing ? { pricing: model.pricing } : {}), - ...(model.chat ? { chat: model.chat } : {}), - ...(model.flagOverrides ? { flagOverrides: model.flagOverrides } : {}), - }; -}; - -// Unified model catalog fetch for both draft preview and saved-record -// refresh. Always live-fetches on the control plane; when -// record.id !== '' the request also warms/refreshes the SWR cache via -// `fetchUpstreamModelsCached` so a subsequent data-plane call picks up -// the fresh catalog. Custom's response stays the raw upstream row shape -// (dashboard translates through the draft's endpoints); every other -// kind returns UpstreamModelConfig-shaped rows. -export const listModels = async (c: CtxWithJson) => { - const { record } = c.req.valid('json'); - if (!isValidProviderKind(record.kind)) { - return c.json({ error: { message: `Invalid kind: ${record.kind}`, type: 'invalid_request_error' } }, 400); - } - const kind = record.kind; - - const scheduler = backgroundSchedulerFromContext(c); - const now = new Date().toISOString(); - const synthRecord: UpstreamRecord = { - id: record.id || 'draft', - kind, - name: 'draft', - enabled: true, - sortOrder: 0, - createdAt: now, - updatedAt: now, - flagOverrides: {}, - disabledPublicModelIds: [], - proxyFallbackList: (record.proxy_fallback_list ?? []) as ProxyFallbackEntry[], - modelPrefix: null, - color: null, - config: record.config, - state: record.state, - }; - - let fetcher: Fetcher; - try { - fetcher = await resolveControlPlaneFetcher({ - override: record.proxy_fallback_list, - upstreamId: record.id || undefined, - runtimeLocation: getRuntimeLocation(c.req.raw), - }); - } catch (err) { - return c.json({ error: errorMessage(err) }, 400); - } - - try { - if (kind === 'custom') { - const assertedConfig = assertCustomUpstreamRecord(synthRecord).config; - const result = await fetchCustomModels(assertedConfig, fetcher); - return c.json(result); - } - if (kind === 'ollama') { - assertOllamaUpstreamRecord(synthRecord); - const instance = createOllamaProvider(synthRecord); - const models = await instance.instance.getProvidedModels(fetcher); - return c.json({ data: models.map(reshapeModelForDashboard) }); - } - // Copilot / codex / claude-code / azure — use the provider factory. - // Force through the SWR cache when the record is persisted so the - // side-effect refresh keeps the data-plane cache in step; otherwise - // live-fetch without any caching. - const provider = createProvider(synthRecord); - const models = record.id !== '' - ? await fetchUpstreamModelsCached(provider, { scheduler, fetcher, force: true }) - : await provider.instance.getProvidedModels(fetcher); - return c.json({ data: models.map(reshapeModelForDashboard) }); - } catch (e) { - if (e instanceof ProviderModelsUnavailableError) { - return c.json({ error: { message: MODEL_LISTING_FAILURE_MESSAGE, type: 'api_error' } }, 502); - } - if (e instanceof Error && /Malformed .* upstream config/.test(e.message)) { - return c.json({ error: errorMessage(e) }, 400); - } - throw e; - } -}; diff --git a/packages/gateway/src/control-plane/upstreams/routes_test.ts b/packages/gateway/src/control-plane/upstreams/routes_test.ts index c20c67d361..f811e42467 100644 --- a/packages/gateway/src/control-plane/upstreams/routes_test.ts +++ b/packages/gateway/src/control-plane/upstreams/routes_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { blueprintUpstreamRecord, upstreamRecordToFullJson } from './serialize.ts'; import { MODEL_CATALOG_REVISION } from '../../data-plane/providers/models-cache.ts'; -import { requestApp, setupAppTest } from '../../test-helpers.ts'; +import { requestApp, setupAppTest } from '../../test-utils/app.ts'; import type { UpstreamProviderKind, UpstreamRecord } from '@floway-dev/provider'; import { assertEquals, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/control-plane/upstreams/shared.ts b/packages/gateway/src/control-plane/upstreams/shared.ts new file mode 100644 index 0000000000..f2a00e388b --- /dev/null +++ b/packages/gateway/src/control-plane/upstreams/shared.ts @@ -0,0 +1,6 @@ +import { ALL_PROVIDER_KINDS, type UpstreamProviderKind } from '@floway-dev/provider'; + +export const upstreamErrorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); + +export const isValidProviderKind = (value: unknown): value is UpstreamProviderKind => + typeof value === 'string' && (ALL_PROVIDER_KINDS as readonly string[]).includes(value); diff --git a/packages/gateway/src/control-plane/users/routes.ts b/packages/gateway/src/control-plane/users/routes.ts index 6fd51efab6..769af81994 100644 --- a/packages/gateway/src/control-plane/users/routes.ts +++ b/packages/gateway/src/control-plane/users/routes.ts @@ -3,19 +3,13 @@ import { notifyDisabledBestEffort } from '../../dump/registry.ts'; import { type AuthedContext, sessionIdFromContext, userFromContext } from '../../middleware/auth.ts'; import { type CtxWithJson } from '../../middleware/zod-validator.ts'; import { getRepo } from '../../repo/index.ts'; +import { SEED_ADMIN_USER_ID } from '../../repo/seed-admin.ts'; import type { ApiKey, User } from '../../repo/types.ts'; import { generateApiKeyToken } from '../../shared/api-key-tokens.ts'; import { hashPassword, verifyPassword } from '../../shared/passwords.ts'; import { generateServerSecret } from '../../shared/server-secret.ts'; import type { changeOwnPasswordBody, createUserBody, updateUserBody } from '../schemas.ts'; - -const validateUpstreamIdsExist = async (ids: readonly string[] | null): Promise => { - if (ids === null) return null; - const upstreams = await getRepo().upstreams.list(); - const known = new Set(upstreams.map(u => u.id)); - const unknown = ids.filter(id => !known.has(id)); - return unknown.length ? `Unknown upstream(s): ${unknown.join(', ')}` : null; -}; +import { validateUpstreamIdsExist } from '../shared/upstream-ids.ts'; const parseUserId = (raw: string): number | null => { const n = Number(raw); @@ -75,7 +69,7 @@ export const updateUser = async (c: CtxWithJson) => { const existing = await repo.users.getById(id); if (!existing) return c.json({ error: 'user not found' }, 404); - if (id === 1 && body.isAdmin === false) return c.json({ error: 'user 1 cannot be demoted' }, 400); + if (id === SEED_ADMIN_USER_ID && body.isAdmin === false) return c.json({ error: 'user 1 cannot be demoted' }, 400); if (id === actorId && body.isAdmin === false) { return c.json({ error: 'cannot demote yourself' }, 400); } @@ -109,7 +103,7 @@ export const deleteUser = async (c: AuthedContext) => { const id = parseUserId(c.req.param('id')!); if (id === null) return c.json({ error: 'invalid user id' }, 400); const actorId = userFromContext(c).id; - if (id === 1) return c.json({ error: 'user 1 cannot be deleted' }, 400); + if (id === SEED_ADMIN_USER_ID) return c.json({ error: 'user 1 cannot be deleted' }, 400); if (id === actorId) return c.json({ error: 'cannot delete yourself' }, 400); const repo = getRepo(); diff --git a/packages/gateway/src/control-plane/users/routes_test.ts b/packages/gateway/src/control-plane/users/routes_test.ts index 9d8e2148b8..ffdfc3263c 100644 --- a/packages/gateway/src/control-plane/users/routes_test.ts +++ b/packages/gateway/src/control-plane/users/routes_test.ts @@ -3,7 +3,7 @@ import { expect, test } from 'vitest'; import { initDumpBroker, initDumpStore } from '../../dump/registry.ts'; import { installDumpStubs } from '../../dump/test-fixtures.ts'; import { hashPassword } from '../../shared/passwords.ts'; -import { requestApp, setupAppTest } from '../../test-helpers.ts'; +import { requestApp, setupAppTest } from '../../test-utils/app.ts'; import { assertEquals } from '@floway-dev/test-utils'; const adminPost = (sessionId: string, body: unknown) => requestApp('/api/users', { diff --git a/packages/gateway/src/data-plane/alpha-search/routes.ts b/packages/gateway/src/data-plane/alpha-search/routes.ts index 83c71de761..d510653e4a 100644 --- a/packages/gateway/src/data-plane/alpha-search/routes.ts +++ b/packages/gateway/src/data-plane/alpha-search/routes.ts @@ -24,9 +24,9 @@ import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; import { relayFetchedResponse } from '../tools/web-search/alpha-search/relay-response.ts'; import { resolveAlphaSearchDispatcher } from '../tools/web-search/alpha-search/upstream.ts'; +import { loadWebSearchConfig } from '../tools/web-search/config.ts'; import { executeOperationToText, maxResultsForContextSize, parseWebSearchOperations, startBatchFetch, type WebSearchExecutionSession, type WebSearchFilters } from '../tools/web-search/operations.ts'; import { resolveConfiguredWebSearchProvider } from '../tools/web-search/provider.ts'; -import { loadSearchConfig } from '../tools/web-search/search-config.ts'; import type { ConfiguredWebSearchProvider } from '../tools/web-search/types.ts'; const domainListSchema = z.array(z.string()); @@ -81,10 +81,10 @@ const filtersFromSettings = (settings: AlphaSearchRequest['settings']): WebSearc const alphaSearch = async (c: CtxWithJson): Promise => { const body = c.req.valid('json'); - const searchConfig = await loadSearchConfig(); - if (searchConfig.passthroughOpenAiSearch.enabled) { + const webSearchConfig = await loadWebSearchConfig(); + if (webSearchConfig.passthroughOpenAiSearch.enabled) { const dispatcher = await resolveAlphaSearchDispatcher({ - config: searchConfig.passthroughOpenAiSearch, + config: webSearchConfig.passthroughOpenAiSearch, upstreamIds: effectiveUpstreamIdsFromContext(c), scheduler: backgroundSchedulerFromContext(c), runtimeLocation: getRuntimeLocation(c.req.raw), @@ -99,7 +99,7 @@ const alphaSearch = async (c: CtxWithJson): Pro let configuredProvider: Promise | undefined; const session: WebSearchExecutionSession = { getProvider: () => { - configuredProvider ??= Promise.resolve(resolveConfiguredWebSearchProvider(searchConfig)); + configuredProvider ??= Promise.resolve(resolveConfiguredWebSearchProvider(webSearchConfig)); return configuredProvider; }, filters: filtersFromSettings(body.settings), diff --git a/packages/gateway/src/data-plane/alpha-search/routes_test.ts b/packages/gateway/src/data-plane/alpha-search/routes_test.ts index 7fbda60a18..98bec2bc3c 100644 --- a/packages/gateway/src/data-plane/alpha-search/routes_test.ts +++ b/packages/gateway/src/data-plane/alpha-search/routes_test.ts @@ -3,20 +3,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mountAlphaSearchRoutes } from './routes.ts'; import { type AuthVars, authMiddleware } from '../../middleware/auth.ts'; -import { buildCustomUpstreamRecord, setupAppTest } from '../../test-helpers.ts'; +import { buildCustomUpstreamRecord, setupAppTest } from '../../test-utils/app.ts'; import { resolveConfiguredWebSearchProvider } from '../tools/web-search/provider.ts'; -import type { SearchConfig, WebSearchFetchPageRequest, WebSearchFetchPageResult, WebSearchProvider, WebSearchProviderRequest, WebSearchProviderResult } from '../tools/web-search/types.ts'; +import type { WebSearchConfig, WebSearchFetchPageRequest, WebSearchFetchPageResult, WebSearchProvider, WebSearchProviderRequest, WebSearchProviderResult } from '../tools/web-search/types.ts'; import { withMockedFetch } from '@floway-dev/test-utils'; // Real provider construction (`createTavilyWebSearchProvider` etc.) hits the // network; replace the resolver so tests drive a stub backend instead. A -// SearchConfig row is still seeded so `loadSearchConfig` returns a real +// WebSearchConfig row is still seeded so `loadWebSearchConfig` returns a real // value; the mock ignores it and returns the configured state each test // wants. vi.mock('../tools/web-search/provider.ts'); const mockResolveConfigured = vi.mocked(resolveConfiguredWebSearchProvider); -const TAVILY_CONFIG: SearchConfig = { +const TAVILY_CONFIG: WebSearchConfig = { provider: 'tavily', tavily: { apiKey: 'test-key' }, microsoftGrounding: { apiKey: '' }, @@ -95,7 +95,7 @@ beforeEach(() => { describe('/alpha/search data plane', () => { describe('routing and auth', () => { it.each(SEARCH_PATHS)('serves the same handler at %s', async path => { - const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const { apiKey } = await setupAppTest({ webSearchConfig: TAVILY_CONFIG }); const stub = makeStubProvider(); mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); const app = buildAlphaSearchApp(); @@ -123,21 +123,21 @@ describe('/alpha/search data plane', () => { describe('schema validation', () => { it.each(SEARCH_PATHS)('rejects non-object `commands` at %s', async path => { - const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const { apiKey } = await setupAppTest({ webSearchConfig: TAVILY_CONFIG }); const app = buildAlphaSearchApp(); const response = await postSearch(app, apiKey.key, { commands: [] }, path); expect(response.status).toBe(400); }); it.each(SEARCH_PATHS)('rejects unknown search_context_size at %s', async path => { - const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const { apiKey } = await setupAppTest({ webSearchConfig: TAVILY_CONFIG }); const app = buildAlphaSearchApp(); const response = await postSearch(app, apiKey.key, { settings: { search_context_size: 'huge' } }, path); expect(response.status).toBe(400); }); it('accepts and ignores the model/id/reasoning/input/max_output_tokens fields codex always sends', async () => { - const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const { apiKey } = await setupAppTest({ webSearchConfig: TAVILY_CONFIG }); const stub = makeStubProvider(); mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); const app = buildAlphaSearchApp(); @@ -155,11 +155,11 @@ describe('/alpha/search data plane', () => { describe('command execution', () => { it('raw-passthrough mode dispatches to the selected custom upstream and preserves its response', async () => { - const searchConfig: SearchConfig = { + const webSearchConfig: WebSearchConfig = { ...TAVILY_CONFIG, passthroughOpenAiSearch: { enabled: true, upstreamId: 'up_alpha', model: 'gpt-search' }, }; - const { apiKey, repo } = await setupAppTest({ searchConfig }); + const { apiKey, repo } = await setupAppTest({ webSearchConfig }); await repo.upstreams.deleteAll(); await repo.upstreams.save(buildCustomUpstreamRecord({ id: 'up_alpha', @@ -206,7 +206,7 @@ describe('/alpha/search data plane', () => { }); it('runs a search_query and returns rendered results as `output`', async () => { - const { apiKey, repo } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const { apiKey, repo } = await setupAppTest({ webSearchConfig: TAVILY_CONFIG }); const stub = makeStubProvider(); mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); const app = buildAlphaSearchApp(); @@ -226,13 +226,13 @@ describe('/alpha/search data plane', () => { expect((stub.calls[0].request as WebSearchProviderRequest).query).toBe('react hooks'); // Usage accounted against the caller's key. - const usage = await repo.searchUsage.listAll(); + const usage = await repo.webSearchUsage.listAll(); expect(usage).toHaveLength(1); expect(usage[0]).toMatchObject({ provider: 'tavily', keyId: apiKey.id, action: 'search', requests: 1 }); }); it('opens a page and returns its body text; accounts one fetch_page usage row', async () => { - const { apiKey, repo } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const { apiKey, repo } = await setupAppTest({ webSearchConfig: TAVILY_CONFIG }); const stub = makeStubProvider(); mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); const app = buildAlphaSearchApp(); @@ -242,13 +242,13 @@ describe('/alpha/search data plane', () => { const body = await response.json() as SearchResponseBody; expect(body.output).toContain('body of https://example.com/doc'); - const usage = await repo.searchUsage.listAll(); + const usage = await repo.webSearchUsage.listAll(); expect(usage).toHaveLength(1); expect(usage[0]).toMatchObject({ provider: 'tavily', keyId: apiKey.id, action: 'fetch_page', requests: 1 }); }); it('finds a pattern inside an opened page and renders the matches', async () => { - const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const { apiKey } = await setupAppTest({ webSearchConfig: TAVILY_CONFIG }); const stub = makeStubProvider({ fetchPage: req => ({ type: 'ok', @@ -266,7 +266,7 @@ describe('/alpha/search data plane', () => { }); it('concatenates multiple commands in order with a blank-line separator', async () => { - const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const { apiKey } = await setupAppTest({ webSearchConfig: TAVILY_CONFIG }); const stub = makeStubProvider(); mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); const app = buildAlphaSearchApp(); @@ -289,7 +289,7 @@ describe('/alpha/search data plane', () => { }); it('renders unimplemented command kinds as deterministic text without hitting the provider', async () => { - const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const { apiKey } = await setupAppTest({ webSearchConfig: TAVILY_CONFIG }); const stub = makeStubProvider(); mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); const app = buildAlphaSearchApp(); @@ -305,7 +305,7 @@ describe('/alpha/search data plane', () => { }); it('returns a helpful message when no commands are provided', async () => { - const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const { apiKey } = await setupAppTest({ webSearchConfig: TAVILY_CONFIG }); const app = buildAlphaSearchApp(); const response = await postSearch(app, apiKey.key, { commands: {} }); expect(response.status).toBe(200); @@ -314,7 +314,7 @@ describe('/alpha/search data plane', () => { }); it('blocks an open URL outside the allowed_domains filter', async () => { - const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const { apiKey } = await setupAppTest({ webSearchConfig: TAVILY_CONFIG }); const stub = makeStubProvider(); mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); const app = buildAlphaSearchApp(); @@ -343,7 +343,7 @@ describe('/alpha/search data plane', () => { expect(body.encrypted_output).toBeNull(); expect(body.output).toContain('Web search provider is not configured on this gateway.'); // Nothing was billed because no backend ran. - expect(await repo.searchUsage.listAll()).toHaveLength(0); + expect(await repo.webSearchUsage.listAll()).toHaveLength(0); }); it('surfaces a missing provider credential as in-band output text', async () => { diff --git a/packages/gateway/src/data-plane/audio/transcriptions.ts b/packages/gateway/src/data-plane/audio/http.ts similarity index 96% rename from packages/gateway/src/data-plane/audio/transcriptions.ts rename to packages/gateway/src/data-plane/audio/http.ts index f615982924..d413432e4f 100644 --- a/packages/gateway/src/data-plane/audio/transcriptions.ts +++ b/packages/gateway/src/data-plane/audio/http.ts @@ -8,9 +8,9 @@ import type { Context } from 'hono'; import { respondAudioTranscription } from './respond.ts'; import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../chat/shared/gateway-ctx.ts'; -import { readRequestBody, takeRequestBody } from '../chat/shared/request-body.ts'; +import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; import { passthroughApiError, passthroughServe } from '../shared/passthrough-serve.ts'; +import { readRequestBody, takeRequestBody } from '../shared/request-body.ts'; import type { AudioTranscriptionFormEntry } from '@floway-dev/provider'; type PreparedTranscription = diff --git a/packages/gateway/src/data-plane/audio/transcriptions_test.ts b/packages/gateway/src/data-plane/audio/http_test.ts similarity index 99% rename from packages/gateway/src/data-plane/audio/transcriptions_test.ts rename to packages/gateway/src/data-plane/audio/http_test.ts index 1e1164807d..d1f519cf97 100644 --- a/packages/gateway/src/data-plane/audio/transcriptions_test.ts +++ b/packages/gateway/src/data-plane/audio/http_test.ts @@ -1,7 +1,7 @@ import { test, vi } from 'vitest'; import type { InMemoryRepo } from '../../repo/memory.ts'; -import { flushAsyncWork, requestApp, setupAppTest } from '../../test-helpers.ts'; +import { flushAsyncWork, requestApp, setupAppTest } from '../../test-utils/app.ts'; import type { ModelPricing } from '@floway-dev/protocols/common'; import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; import { withMockedFetch, assertEquals, assertExists } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/audio/respond.ts b/packages/gateway/src/data-plane/audio/respond.ts index 84b19e5900..5d0d5481c7 100644 --- a/packages/gateway/src/data-plane/audio/respond.ts +++ b/packages/gateway/src/data-plane/audio/respond.ts @@ -1,26 +1,15 @@ import { streamSSE } from 'hono/streaming'; -import { type StreamCompletion, writeSSEFrames } from '../chat/shared/stream/sse.ts'; +import { measureAudioUsage } from './usage.ts'; import { passthroughApiError } from '../shared/passthrough-serve.ts'; import type { PassthroughResponseStrategyContext } from '../shared/passthrough-serve.ts'; +import { type StreamCompletion, writeSSEFrames } from '../shared/sse.ts'; import { settleUsageMeasurement } from '../shared/telemetry/settle.ts'; -import { audioTranscriptionUsageMeasurement, requestOnlyUsageMeasurement } from '../shared/telemetry/usage.ts'; +import { requestOnlyUsageMeasurement } from '../shared/telemetry/usage.ts'; import { forwardUpstreamHeaders, forwardUpstreamResponse } from '../shared/upstream-response.ts'; import { isAudioTranscriptionDoneEvent } from '@floway-dev/protocols/audio'; import { eventFrame, parseSSEStream, sseCommentFrame } from '@floway-dev/protocols/common'; -const measureAudioUsage = (value: unknown, sourceApi: string) => { - try { - return audioTranscriptionUsageMeasurement(value); - } catch (error) { - console.warn( - `audio-transcription: invalid usage in 2xx upstream response for ${sourceApi}; usage row will be request-only`, - error instanceof Error ? error.message : String(error), - ); - return requestOnlyUsageMeasurement(); - } -}; - const respondNonStreaming = async ({ ctx, sourceApi, response, performance, identity }: PassthroughResponseStrategyContext): Promise => { let measurement = requestOnlyUsageMeasurement(); const contentType = response.headers.get('content-type')?.replace(/;.*$/u, '').trim().toLowerCase(); diff --git a/packages/gateway/src/data-plane/audio/usage.ts b/packages/gateway/src/data-plane/audio/usage.ts new file mode 100644 index 0000000000..b4e95ca97e --- /dev/null +++ b/packages/gateway/src/data-plane/audio/usage.ts @@ -0,0 +1,100 @@ +import type { UsageQuantities } from '../../repo/types.ts'; +import { requestOnlyUsageMeasurement, tokenUsage, type UsageMeasurement } from '../shared/telemetry/usage.ts'; +import { canonicalDecimalString } from '@floway-dev/protocols/common'; + +// OpenAI transcription responses discriminate usage by `type`. Token-based +// models split input_token_details into text and audio metrics; without that +// optional split, the aggregate stays on the general input metric. Duration- +// based usage exposes seconds, while Whisper verbose JSON reports the same +// quantity as a top-level `duration`. Unknown breakdowns record the request +// only, while malformed fields under a known discriminator remain observable. +// https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L36378-L36562 +const audioDurationMeasurement = (seconds: unknown, label: string): UsageMeasurement => { + if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) { + throw new Error(`Audio transcription ${label} must be a finite non-negative number`); + } + return { + quantities: { input_audio_seconds: canonicalDecimalString(String(seconds)) }, + pricingFacts: {}, + dumpTokenUsage: null, + }; +}; + +export const audioTranscriptionUsageMeasurement = (body: unknown): UsageMeasurement => { + if (!body || typeof body !== 'object') return requestOnlyUsageMeasurement(); + if (!Object.hasOwn(body, 'usage')) { + if (!Object.hasOwn(body, 'duration')) return requestOnlyUsageMeasurement(); + return audioDurationMeasurement((body as { duration: unknown }).duration, 'duration'); + } + const usage = (body as { usage: unknown }).usage; + if (!usage || typeof usage !== 'object' || Array.isArray(usage)) { + throw new Error('Audio transcription usage must be an object'); + } + const metric = usage as { type?: unknown; seconds?: unknown; input_tokens?: unknown; input_token_details?: unknown; output_tokens?: unknown; total_tokens?: unknown }; + + if (metric.type === 'duration') { + return audioDurationMeasurement(metric.seconds, 'duration usage.seconds'); + } + + if (metric.type !== 'tokens') return requestOnlyUsageMeasurement(); + for (const [field, value] of [ + ['input_tokens', metric.input_tokens], + ['output_tokens', metric.output_tokens], + ['total_tokens', metric.total_tokens], + ] as const) { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`Audio transcription token usage.${field} must be a non-negative safe integer`); + } + } + const inputTokens = metric.input_tokens as number; + const outputTokens = metric.output_tokens as number; + const totalTokens = metric.total_tokens as number; + if (totalTokens !== inputTokens + outputTokens) { + throw new Error('Audio transcription token usage.total_tokens must equal input_tokens plus output_tokens'); + } + + let inputQuantities: UsageQuantities = { input_tokens: canonicalDecimalString(String(inputTokens)) }; + if (metric.input_token_details !== undefined) { + if (!metric.input_token_details || typeof metric.input_token_details !== 'object' || Array.isArray(metric.input_token_details)) { + throw new Error('Audio transcription token usage.input_token_details must be an object'); + } + const details = metric.input_token_details as { text_tokens?: unknown; audio_tokens?: unknown }; + for (const [field, value] of [ + ['text_tokens', details.text_tokens], + ['audio_tokens', details.audio_tokens], + ] as const) { + if (value !== undefined && (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)) { + throw new Error(`Audio transcription token usage.input_token_details.${field} must be a non-negative safe integer`); + } + } + const textTokens = details.text_tokens as number | undefined; + const audioTokens = details.audio_tokens as number | undefined; + if ((textTokens ?? 0) + (audioTokens ?? 0) > inputTokens) { + throw new Error('Audio transcription token usage.input_token_details must not exceed input_tokens'); + } + inputQuantities = { + input_tokens: canonicalDecimalString(String(inputTokens - (audioTokens ?? 0))), + ...(audioTokens === undefined ? {} : { input_audio_tokens: canonicalDecimalString(String(audioTokens)) }), + }; + } + return { + quantities: { + ...inputQuantities, + output_tokens: canonicalDecimalString(String(outputTokens)), + }, + pricingFacts: { inputTokens }, + dumpTokenUsage: tokenUsage({ input: inputTokens, output: outputTokens }), + }; +}; + +export const measureAudioUsage = (value: unknown, sourceApi: string): UsageMeasurement => { + try { + return audioTranscriptionUsageMeasurement(value); + } catch (error) { + console.warn( + `audio-transcription: invalid usage in 2xx upstream response for ${sourceApi}; usage row will be request-only`, + error instanceof Error ? error.message : String(error), + ); + return requestOnlyUsageMeasurement(); + } +}; diff --git a/packages/gateway/src/data-plane/audio/usage_test.ts b/packages/gateway/src/data-plane/audio/usage_test.ts new file mode 100644 index 0000000000..9371f69158 --- /dev/null +++ b/packages/gateway/src/data-plane/audio/usage_test.ts @@ -0,0 +1,91 @@ +import { test } from 'vitest'; + +import { audioTranscriptionUsageMeasurement } from './usage.ts'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; + +test('audio transcription usage preserves duration seconds as a base-unit metric', () => { + assertEquals(audioTranscriptionUsageMeasurement({ + usage: { type: 'duration', seconds: 91 }, + duration: 91.8, + }), { + quantities: { input_audio_seconds: '91' }, + pricingFacts: {}, + dumpTokenUsage: null, + }); +}); + +test('audio transcription usage reads Whisper verbose JSON duration', () => { + assertEquals(audioTranscriptionUsageMeasurement({ + task: 'transcribe', + duration: 91.8, + text: 'hello', + }), { + quantities: { input_audio_seconds: '91.8' }, + pricingFacts: {}, + dumpTokenUsage: null, + }); +}); + +test('audio transcription usage maps text and audio input token details to disjoint metrics', () => { + assertEquals(audioTranscriptionUsageMeasurement({ + usage: { + type: 'tokens', + input_tokens: 14, + input_token_details: { text_tokens: 4, audio_tokens: 10 }, + output_tokens: 45, + total_tokens: 59, + }, + }), { + quantities: { input_tokens: '4', input_audio_tokens: '10', output_tokens: '45' }, + pricingFacts: { inputTokens: 14 }, + dumpTokenUsage: { input: 14, output: 45 }, + }); +}); + +test('audio transcription usage keeps aggregate input tokens general when details are absent', () => { + assertEquals(audioTranscriptionUsageMeasurement({ + usage: { type: 'tokens', input_tokens: 14, output_tokens: 45, total_tokens: 59 }, + }).quantities, { input_tokens: '14', output_tokens: '45' }); +}); + +test('audio transcription usage accepts partial details and leaves unclassified input general', () => { + for (const [input_token_details, quantities] of [ + [{}, { input_tokens: '14', output_tokens: '45' }], + [{ text_tokens: 4 }, { input_tokens: '14', output_tokens: '45' }], + [{ audio_tokens: 10 }, { input_tokens: '4', input_audio_tokens: '10', output_tokens: '45' }], + ] as const) { + assertEquals(audioTranscriptionUsageMeasurement({ + usage: { type: 'tokens', input_tokens: 14, input_token_details, output_tokens: 45, total_tokens: 59 }, + }).quantities, quantities); + } +}); + +test('audio transcription usage without a recognized metric is request-only', () => { + for (const body of [ + { usage: { seconds: 10 } }, + { usage: { type: 'future_metric', samples: 10 } }, + ]) { + assertEquals(audioTranscriptionUsageMeasurement(body), { + quantities: {}, pricingFacts: {}, dumpTokenUsage: null, + }); + } +}); + +test('audio transcription usage rejects malformed declared metrics', () => { + for (const [body, message] of [ + [{ duration: '10' }, 'duration must be'], + [{ usage: null }, 'usage must be an object'], + [{ usage: 'tokens' }, 'usage must be an object'], + [{ usage: { type: 'duration' } }, 'duration usage.seconds'], + [{ usage: { type: 'duration', seconds: '10' } }, 'duration usage.seconds'], + [{ usage: { type: 'tokens', input_tokens: -1, output_tokens: 45, total_tokens: 44 } }, 'token usage.input_tokens'], + [{ usage: { type: 'tokens', input_tokens: 14, output_tokens: Number.NaN, total_tokens: 59 } }, 'token usage.output_tokens'], + [{ usage: { type: 'tokens', input_tokens: 14, output_tokens: 45, total_tokens: '59' } }, 'token usage.total_tokens'], + [{ usage: { type: 'tokens', input_tokens: 14, output_tokens: 45, total_tokens: 58 } }, 'total_tokens must equal'], + [{ usage: { type: 'tokens', input_tokens: 14, input_token_details: null, output_tokens: 45, total_tokens: 59 } }, 'input_token_details must be an object'], + [{ usage: { type: 'tokens', input_tokens: 14, input_token_details: { text_tokens: 4, audio_tokens: '10' }, output_tokens: 45, total_tokens: 59 } }, 'audio_tokens must be'], + [{ usage: { type: 'tokens', input_tokens: 14, input_token_details: { text_tokens: 6, audio_tokens: 9 }, output_tokens: 45, total_tokens: 59 } }, 'input_token_details must not exceed'], + ] as const) { + assertThrows(() => audioTranscriptionUsageMeasurement(body), Error, message); + } +}); diff --git a/packages/gateway/src/data-plane/chat/chat-completions/affinity/ingress.ts b/packages/gateway/src/data-plane/chat/chat-completions/affinity/ingress.ts index 22e8f28a2f..7a8650b799 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/affinity/ingress.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/affinity/ingress.ts @@ -12,7 +12,7 @@ export const prepareChatCompletionsAffinity = async ( } return { - routingEvidence: preferredAffinityEvidence(decoded.values()), + narrowingEvidence: preferredAffinityEvidence(decoded.values()), payloadForCandidate: candidate => { const candidatePayload = structuredClone(payload); for (const [index, blob] of decoded) { diff --git a/packages/gateway/src/data-plane/chat/chat-completions/affinity/ingress_test.ts b/packages/gateway/src/data-plane/chat/chat-completions/affinity/ingress_test.ts index 573c53f245..3929f5b801 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/affinity/ingress_test.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/affinity/ingress_test.ts @@ -10,13 +10,13 @@ const codec = new AffinityCodec('22'.repeat(32)); const candidate = (upstream: string): ModelCandidate => { const base = stubModelCandidate(); return stubModelCandidate({ - provider: { ...base.provider, upstream }, + provider: { ...base.provider, upstreamId: upstream }, model: { id: 'model' }, }); }; const targetFor = (value: ModelCandidate): AffinityTarget => ({ - upstreamId: value.provider.upstream, + upstreamId: value.provider.upstreamId, modelId: value.model.id, ...(value.rules !== undefined ? { rules: value.rules } : {}), }); @@ -30,7 +30,7 @@ test('restores owned opaque state only for its exact candidate', async () => { messages: [{ role: 'assistant', content: 'answer', reasoning_opaque: carrier }], }, codec); - expect(prepared.routingEvidence).toEqual([{ target: targetFor(candidateA), mode: 'prefer' }]); + expect(prepared.narrowingEvidence).toEqual([{ target: targetFor(candidateA), mode: 'prefer' }]); expect(prepared.payloadForCandidate(candidateA).messages[0]).toMatchObject({ reasoning_opaque: 'upstream-signature' }); expect(prepared.payloadForCandidate(candidateB).messages[0]).not.toHaveProperty('reasoning_opaque'); }); diff --git a/packages/gateway/src/data-plane/chat/chat-completions/attempt_test.ts b/packages/gateway/src/data-plane/chat/chat-completions/attempt_test.ts index ad4318c66e..6874f7cb8e 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/attempt_test.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/attempt_test.ts @@ -3,7 +3,7 @@ import { test, vi } from 'vitest'; import { chatCompletionsAttempt } from './attempt.ts'; import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; -import { mockChatGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; import { initExternalResourceFetcher } from '@floway-dev/platform'; import type { ChatCompletionsPayload, ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { doneFrame, eventFrame, type ModelEndpoints, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -76,7 +76,7 @@ const makeCandidate = (overrides: { }); return { provider: { - upstream, kind: 'custom', name: upstream, + upstreamId: upstream, kind: 'custom', name: upstream, disabledPublicModelIds: [], modelPrefix: null, instance: provider, }, model: stubInternalModel({ diff --git a/packages/gateway/src/data-plane/chat/chat-completions/errors.ts b/packages/gateway/src/data-plane/chat/chat-completions/errors.ts index 038a601059..7944f6b24b 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/errors.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/errors.ts @@ -1,30 +1,10 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import type { ChatServeFailure } from '../shared/errors.ts'; +import { openAiErrorResult, type ChatServeFailure } from '../shared/errors.ts'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; import type { TranslatorInputError } from '@floway-dev/translate'; -// OpenAI error envelope. `param`/`code` reproduce OpenAI's native fields; a -// stored-item miss must byte-match OpenAI's own "not found" body. The -// envelope is gateway-synthesized — `source: 'gateway'` so the dump labels -// it as such. -const openAiErrorResult = ( - status: number, - message: string, - extra?: { param: string; code: string | null }, - performance?: PerformanceTelemetryContext, -): ExecuteResult> => ({ - type: 'api-error', - source: 'gateway', - status, - headers: new Headers({ 'content-type': 'application/json' }), - body: new TextEncoder().encode(JSON.stringify({ - error: { message, type: 'invalid_request_error', ...extra }, - })), - ...(performance ? { performance } : {}), -}); - // Translator surfaced a caller-input violation. Render as a 400 // invalid_request_error so the caller sees a protocol-shaped failure // instead of the internal-error 502 envelope. `param` falls back to @@ -42,8 +22,6 @@ export const renderChatCompletionsFailure = ( failure: ChatServeFailure, ): ExecuteResult> => { switch (failure.kind) { - case 'item-not-found': - return openAiErrorResult(404, `Item with id '${failure.itemId}' not found.`, { param: 'input', code: null }); case 'routing-unavailable': return openAiErrorResult(400, failure.message, { param: 'input', code: 'responses_item_routing_unavailable' }); case 'model-missing': diff --git a/packages/gateway/src/data-plane/chat/chat-completions/http.ts b/packages/gateway/src/data-plane/chat/chat-completions/http.ts index 8d8a0a209f..1afc518a1f 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/http.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/http.ts @@ -3,10 +3,11 @@ import { respondChatCompletions } from './respond.ts'; import { chatCompletionsServe } from './serve.ts'; import type { AuthedContext } from '../../../middleware/auth.ts'; import { backgroundSchedulerFromContext } from '../../../runtime/background.ts'; +import { createGatewayCtxFromHono, finalizeGatewayResponse, type GatewayCtx } from '../../shared/gateway-ctx.ts'; import { inboundHeadersForUpstream } from '../../shared/inbound-headers.ts'; +import { readRequestBody, takeRequestBody, type RequestBody } from '../../shared/request-body.ts'; import { createNonResponsesSourceStore } from '../responses/items/store.ts'; -import { createChatGatewayCtxFromHono, createGatewayCtxFromHono, finalizeGatewayResponse, type ChatGatewayCtx, type GatewayCtx } from '../shared/gateway-ctx.ts'; -import { readRequestBody, takeRequestBody, type RequestBody } from '../shared/request-body.ts'; +import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerModelsUnavailableResponse } from '../shared/upstream-models-error.ts'; import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; import { internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; @@ -38,7 +39,7 @@ const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: Req if (!(error instanceof TranslatorInputError)) return await respondWithInternalError(c, error, requestBody, ctx); const effectiveCtx = ctx ?? createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); const response = await respondChatCompletions(c, translatorInputErrorResult(error, effectiveCtx.attempt.telemetry), false, false, effectiveCtx); - return (effectiveCtx.dump?.finalize(response) ?? response); + return finalizeGatewayResponse(effectiveCtx, response); }; export const chatCompletionsHttp = { diff --git a/packages/gateway/src/data-plane/chat/chat-completions/http_test.ts b/packages/gateway/src/data-plane/chat/chat-completions/http_test.ts index c85c711944..a21706dc05 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/http_test.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/http_test.ts @@ -11,8 +11,8 @@ import { type ModelCandidate, directFetcher, type ProviderStreamResult, type Ups import { assert, assertEquals, stubProvider, stubInternalModel } from '@floway-dev/test-utils'; const candidatesQueue: { readonly candidates: readonly ModelCandidate[]; readonly sawModel: boolean; readonly failedUpstreams: readonly string[] }[] = []; -vi.mock('../../providers/registry.ts', async importOriginal => { - const original = await importOriginal(); +vi.mock('../../providers/resolution.ts', async importOriginal => { + const original = await importOriginal(); return { ...original, enumerateModelCandidates: vi.fn(async () => { @@ -108,7 +108,7 @@ const makeCandidate = (overrides: { const provider = stubProvider({ callChatCompletions: overrides.callChatCompletions }); return { provider: { - upstream, kind: 'custom', name: upstream, + upstreamId: upstream, kind: 'custom', name: upstream, disabledPublicModelIds: [], modelPrefix: null, instance: provider, }, model: stubInternalModel(overrides.endpoints ? { endpoints: overrides.endpoints } : {}, upstream), diff --git a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/apply-role-compatibility_test.ts b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/apply-role-compatibility_test.ts index 91f7db1d66..3159873d50 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/apply-role-compatibility_test.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/apply-role-compatibility_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { withRoleCompatibilityApplied } from './apply-role-compatibility.ts'; import type { ChatCompletionsInvocation } from './types.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ChatCompletionsMessage, ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; import { eventResult, type FlagId } from '@floway-dev/provider'; import { assert, assertEquals, stubModelCandidate, testTelemetryModelIdentity } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/disable-reasoning-on-forced-tool-choice_test.ts b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/disable-reasoning-on-forced-tool-choice_test.ts index 230a4f48e6..69c066593b 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/disable-reasoning-on-forced-tool-choice_test.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/disable-reasoning-on-forced-tool-choice_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { withReasoningDisabledOnForcedToolChoice } from './disable-reasoning-on-forced-tool-choice.ts'; import type { ChatCompletionsInvocation } from './types.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; import { eventResult, type FlagId } from '@floway-dev/provider'; import { assertEquals, stubModelCandidate, testTelemetryModelIdentity } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/include-usage-stream-options_test.ts b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/include-usage-stream-options_test.ts index b3dabc895c..812179f04b 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/include-usage-stream-options_test.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/include-usage-stream-options_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { withUsageStreamOptionsIncluded } from './include-usage-stream-options.ts'; import type { ChatCompletionsInvocation } from './types.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; import { eventResult } from '@floway-dev/provider'; import { assertEquals, stubModelCandidate, testTelemetryModelIdentity } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/index.ts b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/index.ts index d39558db8c..c95c3c6381 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/index.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/index.ts @@ -4,7 +4,7 @@ import { withUsageStreamOptionsIncluded } from './include-usage-stream-options.t import { withUsageNormalized } from './normalize-usage.ts'; import { withPromptCacheKeyStripped } from './strip-prompt-cache-key.ts'; import type { ChatCompletionsInterceptor } from './types.ts'; -import { withVendorDeepseekChatCompletionsNormalize } from './vendor-deepseek-normalize.ts'; +import { withVendorDeepSeekChatCompletionsNormalize } from './vendor-deepseek-normalize.ts'; import { withVendorKimiChatCompletionsNormalize } from './vendor-kimi-normalize.ts'; import { withVendorQwenChatCompletionsNormalize } from './vendor-qwen-normalize.ts'; @@ -43,7 +43,7 @@ export const chatCompletionsInterceptors: readonly ChatCompletionsInterceptor[] withReasoningDisabledOnForcedToolChoice, withRoleCompatibilityApplied, withPromptCacheKeyStripped, - withVendorDeepseekChatCompletionsNormalize, + withVendorDeepSeekChatCompletionsNormalize, withVendorQwenChatCompletionsNormalize, withVendorKimiChatCompletionsNormalize, ]; diff --git a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/normalize-usage_test.ts b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/normalize-usage_test.ts index 13a2569e59..24053fdb0a 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/normalize-usage_test.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/normalize-usage_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { withUsageNormalized } from './normalize-usage.ts'; import type { ChatCompletionsInvocation } from './types.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ChatCompletionsPayload, ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { type ExecuteResult, eventResult } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/strip-prompt-cache-key_test.ts b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/strip-prompt-cache-key_test.ts index 0a1538e82c..36f1955acd 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/strip-prompt-cache-key_test.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/strip-prompt-cache-key_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { withPromptCacheKeyStripped } from './strip-prompt-cache-key.ts'; import type { ChatCompletionsInvocation } from './types.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; import { eventResult, type FlagId } from '@floway-dev/provider'; import { assertEquals, stubModelCandidate, testTelemetryModelIdentity } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/types.ts b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/types.ts index 7fff903bd1..d6d5368047 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/types.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/types.ts @@ -1,4 +1,4 @@ -import type { GatewayCtx } from '../../shared/gateway-ctx.ts'; +import type { GatewayCtx } from '../../../shared/gateway-ctx.ts'; import type { Interceptor } from '@floway-dev/interceptor'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-deepseek-normalize.ts b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-deepseek-normalize.ts index 71edf2c04b..308c3d9052 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-deepseek-normalize.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-deepseek-normalize.ts @@ -119,7 +119,7 @@ const rewriteInboundUsage = (chunk: ChatCompletionsStreamEvent): ChatCompletions return { ...chunk, usage: next as unknown as ChatCompletionsStreamEvent['usage'] }; }; -export const withVendorDeepseekChatCompletionsNormalize: ChatCompletionsInterceptor = async (ctx, _gatewayCtx, run) => { +export const withVendorDeepSeekChatCompletionsNormalize: ChatCompletionsInterceptor = async (ctx, _gatewayCtx, run) => { if (!providerModelOf(ctx.candidate).enabledFlags.has('vendor-deepseek')) return await run(); ctx.payload = rewriteOutboundPayload(ctx.payload); diff --git a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-deepseek-normalize_test.ts b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-deepseek-normalize_test.ts index 5f6b3b2655..30cd726608 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-deepseek-normalize_test.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-deepseek-normalize_test.ts @@ -1,14 +1,14 @@ import { test } from 'vitest'; import type { ChatCompletionsInvocation } from './types.ts'; -import { withVendorDeepseekChatCompletionsNormalize } from './vendor-deepseek-normalize.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { withVendorDeepSeekChatCompletionsNormalize } from './vendor-deepseek-normalize.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ChatCompletionsPayload, ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { type ExecuteResult, eventResult, type FlagId } from '@floway-dev/provider'; import { assertEquals, stubModelCandidate, testTelemetryModelIdentity } from '@floway-dev/test-utils'; -type DeepseekReasoningDelta = ChatCompletionsStreamEvent['choices'][number]['delta'] & { +type DeepSeekReasoningDelta = ChatCompletionsStreamEvent['choices'][number]['delta'] & { reasoning_content?: string; }; @@ -57,7 +57,7 @@ test('renames outbound reasoning_text to reasoning_content on assistant messages const ctx = invocation(baseRequest()); let observed: ChatCompletionsPayload | null = null; - await withVendorDeepseekChatCompletionsNormalize(ctx, stubCtx, () => { + await withVendorDeepSeekChatCompletionsNormalize(ctx, stubCtx, () => { observed = ctx.payload; return okEvents(); }); @@ -95,7 +95,7 @@ test('synthesizes reasoning_content from reasoning_items when reasoning_text is }); let observed: ChatCompletionsPayload | null = null; - await withVendorDeepseekChatCompletionsNormalize(ctx, stubCtx, () => { + await withVendorDeepSeekChatCompletionsNormalize(ctx, stubCtx, () => { observed = ctx.payload; return okEvents(); }); @@ -122,7 +122,7 @@ test('strips reasoning_items even when no summaries are available', async () => }); let observed: ChatCompletionsPayload | null = null; - await withVendorDeepseekChatCompletionsNormalize(ctx, stubCtx, () => { + await withVendorDeepSeekChatCompletionsNormalize(ctx, stubCtx, () => { observed = ctx.payload; return okEvents(); }); @@ -144,7 +144,7 @@ test("translates canonical reasoning_effort: 'none' into top-level thinking:{typ }); let observed: ChatCompletionsPayload | null = null; - await withVendorDeepseekChatCompletionsNormalize(ctx, stubCtx, () => { + await withVendorDeepSeekChatCompletionsNormalize(ctx, stubCtx, () => { observed = ctx.payload; return okEvents(); }); @@ -162,7 +162,7 @@ test('leaves a real reasoning_effort value untouched (only the none sentinel tri }); let observed: ChatCompletionsPayload | null = null; - await withVendorDeepseekChatCompletionsNormalize(ctx, stubCtx, () => { + await withVendorDeepSeekChatCompletionsNormalize(ctx, stubCtx, () => { observed = ctx.payload; return okEvents(); }); @@ -189,7 +189,7 @@ test('downgrades response_format json_schema to json_object (schema body dropped }); let observed: ChatCompletionsPayload | null = null; - await withVendorDeepseekChatCompletionsNormalize(ctx, stubCtx, () => { + await withVendorDeepSeekChatCompletionsNormalize(ctx, stubCtx, () => { observed = ctx.payload; return okEvents(); }); @@ -205,7 +205,7 @@ test('leaves an already-json_object response_format untouched', async () => { }); let observed: ChatCompletionsPayload | null = null; - await withVendorDeepseekChatCompletionsNormalize(ctx, stubCtx, () => { + await withVendorDeepSeekChatCompletionsNormalize(ctx, stubCtx, () => { observed = ctx.payload; return okEvents(); }); @@ -225,13 +225,13 @@ test('renames inbound protocol reasoning_content deltas to reasoning_text', asyn choices: [ { index: 0, - delta: { reasoning_content: 'thinking...' } as DeepseekReasoningDelta, + delta: { reasoning_content: 'thinking...' } as DeepSeekReasoningDelta, finish_reason: null, }, ], }; - const result = await withVendorDeepseekChatCompletionsNormalize(ctx, stubCtx, () => + const result = await withVendorDeepSeekChatCompletionsNormalize(ctx, stubCtx, () => Promise.resolve(eventResult( (async function* () { yield eventFrame(upstreamChunk); })(), testTelemetryModelIdentity, @@ -258,7 +258,7 @@ test('preserves reasoning_content from non-stream JSON responses', async () => { choices: [{ index: 0, delta, finish_reason }], }); - const result = await withVendorDeepseekChatCompletionsNormalize(ctx, stubCtx, () => + const result = await withVendorDeepSeekChatCompletionsNormalize(ctx, stubCtx, () => Promise.resolve(eventResult( (async function* () { yield eventFrame(chunk({ role: 'assistant' })); @@ -279,7 +279,7 @@ test('preserves reasoning_content from non-stream JSON responses', async () => { test('rewrites prompt_cache_hit_tokens/prompt_cache_miss_tokens into prompt_tokens_details.cached_tokens', async () => { const ctx = invocation(baseRequest()); - const result = await withVendorDeepseekChatCompletionsNormalize(ctx, stubCtx, () => + const result = await withVendorDeepSeekChatCompletionsNormalize(ctx, stubCtx, () => Promise.resolve(eventResult( (async function* () { yield eventFrame({ @@ -317,7 +317,7 @@ test('leaves protocol done frames untouched', async () => { const ctx = invocation(baseRequest()); const done = doneFrame(); - const result = await withVendorDeepseekChatCompletionsNormalize(ctx, stubCtx, () => + const result = await withVendorDeepSeekChatCompletionsNormalize(ctx, stubCtx, () => Promise.resolve(eventResult( (async function* () { yield done; })(), testTelemetryModelIdentity, @@ -337,7 +337,7 @@ test('early-returns when its flag is not set on the candidate', async () => { ); let observed: ChatCompletionsPayload | null = null; - await withVendorDeepseekChatCompletionsNormalize(ctx, stubCtx, () => { + await withVendorDeepSeekChatCompletionsNormalize(ctx, stubCtx, () => { observed = ctx.payload; return okEvents(); }); diff --git a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-kimi-normalize_test.ts b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-kimi-normalize_test.ts index 15c75fc2d0..879103a8c5 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-kimi-normalize_test.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-kimi-normalize_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import type { ChatCompletionsInvocation } from './types.ts'; import { withVendorKimiChatCompletionsNormalize } from './vendor-kimi-normalize.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ChatCompletionsPayload, ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { type ExecuteResult, eventResult, type FlagId } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-qwen-normalize_test.ts b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-qwen-normalize_test.ts index db8c20d70d..1d01f1bc45 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-qwen-normalize_test.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/interceptors/vendor-qwen-normalize_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import type { ChatCompletionsInvocation } from './types.ts'; import { withVendorQwenChatCompletionsNormalize } from './vendor-qwen-normalize.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; import { eventResult, type FlagId } from '@floway-dev/provider'; import { assertEquals, stubModelCandidate, testTelemetryModelIdentity } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/chat/chat-completions/respond.ts b/packages/gateway/src/data-plane/chat/chat-completions/respond.ts index d0382ab56b..5a133bdde7 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/respond.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/respond.ts @@ -3,13 +3,13 @@ import { streamSSE } from 'hono/streaming'; import { wrapChatCompletionsAffinityEgress } from './affinity/egress.ts'; import { tokenUsageFromChatCompletionsUsage } from './usage.ts'; +import type { GatewayCtx } from '../../shared/gateway-ctx.ts'; +import { type StreamCompletion, writeSSEFrames } from '../../shared/sse.ts'; import { recordFailedRequest } from '../../shared/telemetry/performance.ts'; import { settle } from '../../shared/telemetry/settle.ts'; import { forwardUpstreamHeaders, mergeForwardedUpstreamHeaders } from '../../shared/upstream-response.ts'; import { affinityEgressOptions } from '../shared/affinity/index.ts'; -import type { GatewayCtx } from '../shared/gateway-ctx.ts'; import { SourceStreamState, eventResultMetadata, plainResultToResponse } from '../shared/respond.ts'; -import { type StreamCompletion, writeSSEFrames } from '../shared/stream/sse.ts'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { chatCompletionsProtocolFrameToSSEFrame, CHAT_COMPLETIONS_MISSING_TERMINAL_MESSAGE, collectChatCompletionsProtocolEventsToResult, chatCompletionsErrorPayloadMessage } from '@floway-dev/protocols/chat-completions'; import { type ProtocolFrame, sseCommentFrame, sseFrame } from '@floway-dev/protocols/common'; @@ -25,7 +25,7 @@ export const respondChatCompletions = async ( ): Promise => { if (result.type === 'api-error') { recordFailedRequest(ctx, result.performance); - ctx.dump?.error(result.source, result.upstream); + ctx.dump?.error(result.source, result.upstreamId); return apiErrorToResponse(result); } @@ -37,7 +37,7 @@ export const respondChatCompletions = async ( if (result.type === 'plain') { if (result.status >= 400) { - ctx.dump?.error(result.upstream !== undefined ? 'upstream' : 'gateway', result.upstream); + ctx.dump?.error(result.upstreamId !== undefined ? 'upstream' : 'gateway', result.upstreamId); } return plainResultToResponse(result); } diff --git a/packages/gateway/src/data-plane/chat/chat-completions/serve.ts b/packages/gateway/src/data-plane/chat/chat-completions/serve.ts index 57439162ca..495e00d0bc 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/serve.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/serve.ts @@ -1,9 +1,9 @@ import { prepareChatCompletionsAffinity } from './affinity/ingress.ts'; import { chatCompletionsAttempt, chatCompletionsTarget } from './attempt.ts'; import { renderChatCompletionsFailure } from './errors.ts'; -import { enumerateModelCandidates } from '../../providers/registry.ts'; +import { enumerateModelCandidates } from '../../providers/resolution.ts'; import { iterateCandidates } from '../../shared/iterate-candidates.ts'; -import { routeCandidatesByAffinity } from '../shared/affinity/index.ts'; +import { narrowCandidatesByAffinity } from '../shared/affinity/index.ts'; import { noViableCandidateFailure } from '../shared/errors.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import type { ChatCompletionsPayload, ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; @@ -28,9 +28,9 @@ export const chatCompletionsServe = { runtimeLocation: ctx.runtimeLocation, }); const viable = enumerated.filter(c => chatCompletionsTarget.canServe(c.model.endpoints)); - const decision = routeCandidatesByAffinity(viable, prepared.routingEvidence); - if (decision.kind === 'failure') return renderChatCompletionsFailure(decision.failure); - if (decision.candidates.length === 0) return renderChatCompletionsFailure(noViableCandidateFailure(sawModel, payload.model, failedUpstreams)); + const narrowed = narrowCandidatesByAffinity(viable, prepared.narrowingEvidence); + if ('kind' in narrowed) return renderChatCompletionsFailure(narrowed); + if (narrowed.length === 0) return renderChatCompletionsFailure(noViableCandidateFailure(sawModel, payload.model, failedUpstreams)); // Try each narrowed candidate in order. A successful attempt (SSE // stream opened) is the final answer; an api-error or internal-error @@ -41,7 +41,7 @@ export const chatCompletionsServe = { // stamps its private payload clone with the candidate's canonical model id // so aliases and prefixed ids resolve without mutating the caller payload. return await iterateCandidates( - decision.candidates, + narrowed, 'chatCompletionsServe.generate', ctx, 'chat', diff --git a/packages/gateway/src/data-plane/chat/chat-completions/serve_test.ts b/packages/gateway/src/data-plane/chat/chat-completions/serve_test.ts index 50fd16e71b..77765789fd 100644 --- a/packages/gateway/src/data-plane/chat/chat-completions/serve_test.ts +++ b/packages/gateway/src/data-plane/chat/chat-completions/serve_test.ts @@ -2,7 +2,7 @@ import { afterEach, test, vi } from 'vitest'; import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; -import { mockChatGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; import type { ChatCompletionsPayload, ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { type AliasRules, doneFrame, eventFrame, type ModelEndpoints, type ProtocolFrame } from '@floway-dev/protocols/common'; import { type ModelCandidate, directFetcher, type ProviderStreamResult, type UpstreamCallOptions } from '@floway-dev/provider'; @@ -17,8 +17,8 @@ interface QueuedResolution { } const resolutionsQueue: QueuedResolution[] = []; const lastResolveCall: { model?: string } = {}; -vi.mock('../../providers/registry.ts', async importOriginal => { - const original = await importOriginal(); +vi.mock('../../providers/resolution.ts', async importOriginal => { + const original = await importOriginal(); return { ...original, enumerateModelCandidates: vi.fn(async ({ model }: { model: string }) => { @@ -94,7 +94,7 @@ const makeCandidate = (overrides: { }); return { provider: { - upstream, kind: 'custom', name: upstream, + upstreamId: upstream, kind: 'custom', name: upstream, disabledPublicModelIds: [], modelPrefix: null, instance: provider, }, model: stubInternalModel({ @@ -207,7 +207,7 @@ test('generate falls through to the next candidate when the first yields an upst }); // A mid-attempt throw (interceptor bug / translation error / provider-layer -// JS exception bypassing tryCatchChatServeFailure) must attribute the perf +// JS exception not represented as a ChatServeFailure) must attribute the perf // error row to the throwing candidate, not the previous one that already // failed cleanly with a 5xx. test('mid-attempt throw stamps telemetry with the throwing candidate, not the previous one', async () => { diff --git a/packages/gateway/src/data-plane/chat/gemini/affinity/egress.ts b/packages/gateway/src/data-plane/chat/gemini/affinity/egress.ts index 2c024d2915..ddcc4356ed 100644 --- a/packages/gateway/src/data-plane/chat/gemini/affinity/egress.ts +++ b/packages/gateway/src/data-plane/chat/gemini/affinity/egress.ts @@ -1,9 +1,7 @@ import type { AffinityEgressOptions } from '../../shared/affinity/index.ts'; import { captureExtras, eventFrame, type ProtocolFrame, USAGE_BILLING } from '@floway-dev/protocols/common'; import type { GeminiCandidate, GeminiPart, GeminiResult, GeminiStreamEvent } from '@floway-dev/protocols/gemini'; - -const KNOWN_EVENT_KEYS = new Set(['candidates', 'usageMetadata', 'modelVersion', 'responseId']); -const KNOWN_CANDIDATE_KEYS = new Set(['index', 'content', 'finishReason']); +import { GEMINI_CANDIDATE_KEYS, GEMINI_RESULT_KEYS } from '@floway-dev/protocols/gemini'; // Gemini pays one upstream event of TTFT/inter-event latency. Within one event // repeated snapshots collapse to one signature on the element's first @@ -377,8 +375,8 @@ const mergeEventMetadata = ( target: GeminiResult, ): void => { const extras: Record = {}; - captureExtras(earlier as unknown as Record, KNOWN_EVENT_KEYS, extras); - captureExtras(later as unknown as Record, KNOWN_EVENT_KEYS, extras); + captureExtras(earlier as unknown as Record, GEMINI_RESULT_KEYS, extras); + captureExtras(later as unknown as Record, GEMINI_RESULT_KEYS, extras); const usageMetadata = later.usageMetadata ?? earlier.usageMetadata; const modelVersion = later.modelVersion ?? earlier.modelVersion; const responseId = later.responseId ?? earlier.responseId; @@ -401,10 +399,10 @@ const mergeCandidateExtras = ( target: GeminiCandidate, ): void => { const extras: Record = {}; - captureExtras(earlier as unknown as Record, KNOWN_CANDIDATE_KEYS, extras); - captureExtras(later as unknown as Record, KNOWN_CANDIDATE_KEYS, extras); + captureExtras(earlier as unknown as Record, GEMINI_CANDIDATE_KEYS, extras); + captureExtras(later as unknown as Record, GEMINI_CANDIDATE_KEYS, extras); for (const key of Object.keys(target)) { - if (!KNOWN_CANDIDATE_KEYS.has(key)) delete (target as unknown as Record)[key]; + if (!GEMINI_CANDIDATE_KEYS.has(key as keyof GeminiCandidate)) delete (target as unknown as Record)[key]; } Object.assign(target, extras); }; diff --git a/packages/gateway/src/data-plane/chat/gemini/affinity/ingress.ts b/packages/gateway/src/data-plane/chat/gemini/affinity/ingress.ts index 8f86ee00a6..eb9aa3397e 100644 --- a/packages/gateway/src/data-plane/chat/gemini/affinity/ingress.ts +++ b/packages/gateway/src/data-plane/chat/gemini/affinity/ingress.ts @@ -26,7 +26,7 @@ export const prepareGeminiAffinity = async ( } return { - routingEvidence: preferredAffinityEvidence(locations.map(location => location.decoded)), + narrowingEvidence: preferredAffinityEvidence(locations.map(location => location.decoded)), payloadForCandidate: candidate => { const candidatePayload = structuredClone(payload); if (candidatePayload.contents === undefined) return candidatePayload; diff --git a/packages/gateway/src/data-plane/chat/gemini/affinity/ingress_test.ts b/packages/gateway/src/data-plane/chat/gemini/affinity/ingress_test.ts index c5fc5e0cc7..bab53b05d2 100644 --- a/packages/gateway/src/data-plane/chat/gemini/affinity/ingress_test.ts +++ b/packages/gateway/src/data-plane/chat/gemini/affinity/ingress_test.ts @@ -10,13 +10,13 @@ const codec = new AffinityCodec('22'.repeat(32)); const candidate = (upstream: string): ModelCandidate => { const base = stubModelCandidate(); return stubModelCandidate({ - provider: { ...base.provider, upstream }, + provider: { ...base.provider, upstreamId: upstream }, model: { id: 'model' }, }); }; const targetFor = (value: ModelCandidate): AffinityTarget => ({ - upstreamId: value.provider.upstream, + upstreamId: value.provider.upstreamId, modelId: value.model.id, ...(value.rules !== undefined ? { rules: value.rules } : {}), }); diff --git a/packages/gateway/src/data-plane/chat/gemini/attempt.ts b/packages/gateway/src/data-plane/chat/gemini/attempt.ts index dfdea42c31..6d473bda44 100644 --- a/packages/gateway/src/data-plane/chat/gemini/attempt.ts +++ b/packages/gateway/src/data-plane/chat/gemini/attempt.ts @@ -124,7 +124,7 @@ const reshapeMessagesCountAsGemini = (messagesResult: PlainResult): PlainResult if (messagesResult.status !== 200) { // Empty upstream bodies fall back to a fixed message so the Google-RPC envelope is never empty. const text = new TextDecoder().decode(messagesResult.body); - return geminiErrorPlainResult(messagesResult.status, text || 'Upstream token counting request failed.', messagesResult.upstream); + return geminiErrorPlainResult(messagesResult.status, text || 'Upstream token counting request failed.', messagesResult.upstreamId); } let decoded: unknown; try { decoded = JSON.parse(new TextDecoder().decode(messagesResult.body)); } catch {} @@ -143,7 +143,7 @@ const reshapeMessagesCountAsGemini = (messagesResult: PlainResult): PlainResult 200, new Headers({ 'content-type': 'application/json' }), new TextEncoder().encode(JSON.stringify({ totalTokens })), - messagesResult.upstream, + messagesResult.upstreamId, ); }; diff --git a/packages/gateway/src/data-plane/chat/gemini/attempt_test.ts b/packages/gateway/src/data-plane/chat/gemini/attempt_test.ts index a3b0c5da0f..4a93a2287d 100644 --- a/packages/gateway/src/data-plane/chat/gemini/attempt_test.ts +++ b/packages/gateway/src/data-plane/chat/gemini/attempt_test.ts @@ -3,7 +3,7 @@ import { test, vi } from 'vitest'; import { geminiAttempt } from './attempt.ts'; import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; -import { mockChatGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { doneFrame, eventFrame, type ModelEndpoints, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiPayload } from '@floway-dev/protocols/gemini'; @@ -88,7 +88,7 @@ const makeCandidate = (overrides: { }); return { provider: { - upstream, kind: 'custom', name: upstream, + upstreamId: upstream, kind: 'custom', name: upstream, disabledPublicModelIds: [], modelPrefix: null, instance: provider, }, model: stubInternalModel(overrides.endpoints ? { endpoints: overrides.endpoints } : {}, upstream), diff --git a/packages/gateway/src/data-plane/chat/gemini/errors.ts b/packages/gateway/src/data-plane/chat/gemini/errors.ts index 7cdd72699d..8b94d63a5b 100644 --- a/packages/gateway/src/data-plane/chat/gemini/errors.ts +++ b/packages/gateway/src/data-plane/chat/gemini/errors.ts @@ -59,8 +59,6 @@ export const renderGeminiFailure = ( endpoint: 'generate' | 'countTokens', ): ExecuteResult> => { switch (failure.kind) { - case 'item-not-found': - return geminiRpcErrorResult(404, `Item with id '${failure.itemId}' not found.`); case 'routing-unavailable': return geminiRpcErrorResult(400, failure.message); case 'model-missing': diff --git a/packages/gateway/src/data-plane/chat/gemini/http.ts b/packages/gateway/src/data-plane/chat/gemini/http.ts index 8ab493c98b..5231b49c25 100644 --- a/packages/gateway/src/data-plane/chat/gemini/http.ts +++ b/packages/gateway/src/data-plane/chat/gemini/http.ts @@ -3,10 +3,11 @@ import { geminiInternalRpcErrorResponse, geminiRpcErrorResponse, respondGemini } import { geminiServe } from './serve.ts'; import type { AuthedContext } from '../../../middleware/auth.ts'; import { backgroundSchedulerFromContext } from '../../../runtime/background.ts'; +import { finalizeGatewayResponse } from '../../shared/gateway-ctx.ts'; import { inboundHeadersForUpstream } from '../../shared/inbound-headers.ts'; +import { readRequestBody, takeRequestBody, type RequestBody } from '../../shared/request-body.ts'; import { createNonResponsesSourceStore } from '../responses/items/store.ts'; -import { createChatGatewayCtxFromHono, finalizeGatewayResponse, type ChatGatewayCtx } from '../shared/gateway-ctx.ts'; -import { readRequestBody, takeRequestBody, type RequestBody } from '../shared/request-body.ts'; +import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import type { GeminiContent, GeminiPayload } from '@floway-dev/protocols/gemini'; import { internalErrorResult, ProviderModelsUnavailableError, toInternalDebugError } from '@floway-dev/provider'; import { TranslatorInputError } from '@floway-dev/translate'; @@ -65,7 +66,7 @@ const respondWithGeminiError = async ( ): Promise => { if (error instanceof TranslatorInputError) { const response = await respondGemini(c, translatorInputErrorResult(error, ctx.attempt.telemetry), wantsStream, ctx); - return (ctx.dump?.finalize(response) ?? response); + return finalizeGatewayResponse(ctx, response); } if (error instanceof ProviderModelsUnavailableError && error.httpResponse) { const { status, headers, body } = error.httpResponse; diff --git a/packages/gateway/src/data-plane/chat/gemini/http_test.ts b/packages/gateway/src/data-plane/chat/gemini/http_test.ts index a4b0506593..cea0691515 100644 --- a/packages/gateway/src/data-plane/chat/gemini/http_test.ts +++ b/packages/gateway/src/data-plane/chat/gemini/http_test.ts @@ -12,8 +12,8 @@ import { type ModelCandidate, directFetcher, type ProviderCallResult, type Provi import { assert, assertEquals, stubProvider, stubInternalModel } from '@floway-dev/test-utils'; const candidatesQueue: { readonly candidates: readonly ModelCandidate[]; readonly sawModel: boolean; readonly failedUpstreams: readonly string[] }[] = []; -vi.mock('../../providers/registry.ts', async importOriginal => { - const original = await importOriginal(); +vi.mock('../../providers/resolution.ts', async importOriginal => { + const original = await importOriginal(); return { ...original, enumerateModelCandidates: vi.fn(async () => { @@ -117,7 +117,7 @@ const makeCandidate = (overrides: { }); return { provider: { - upstream, kind: 'custom', name: upstream, + upstreamId: upstream, kind: 'custom', name: upstream, disabledPublicModelIds: [], modelPrefix: null, instance: provider, }, model: stubInternalModel({ endpoints }, upstream), diff --git a/packages/gateway/src/data-plane/chat/gemini/interceptors/strip-safety-settings_test.ts b/packages/gateway/src/data-plane/chat/gemini/interceptors/strip-safety-settings_test.ts index 27b56b755e..b1a321d71c 100644 --- a/packages/gateway/src/data-plane/chat/gemini/interceptors/strip-safety-settings_test.ts +++ b/packages/gateway/src/data-plane/chat/gemini/interceptors/strip-safety-settings_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { stripSafetySettings } from './strip-safety-settings.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiPayload, GeminiStreamEvent } from '@floway-dev/protocols/gemini'; import { type ExecuteResult, eventResult, type GeminiInvocation } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/gemini/interceptors/strip-unsupported-part-fields_test.ts b/packages/gateway/src/data-plane/chat/gemini/interceptors/strip-unsupported-part-fields_test.ts index 30d1cdda2c..4219877740 100644 --- a/packages/gateway/src/data-plane/chat/gemini/interceptors/strip-unsupported-part-fields_test.ts +++ b/packages/gateway/src/data-plane/chat/gemini/interceptors/strip-unsupported-part-fields_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { stripUnsupportedPartFields } from './strip-unsupported-part-fields.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiPayload, GeminiStreamEvent } from '@floway-dev/protocols/gemini'; import { type ExecuteResult, eventResult, type GeminiInvocation } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/gemini/interceptors/strip-unsupported-tools_test.ts b/packages/gateway/src/data-plane/chat/gemini/interceptors/strip-unsupported-tools_test.ts index 640125bf3b..085d2b8b1f 100644 --- a/packages/gateway/src/data-plane/chat/gemini/interceptors/strip-unsupported-tools_test.ts +++ b/packages/gateway/src/data-plane/chat/gemini/interceptors/strip-unsupported-tools_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { stripUnsupportedTools } from './strip-unsupported-tools.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiPayload, GeminiStreamEvent } from '@floway-dev/protocols/gemini'; import { type ExecuteResult, eventResult, type GeminiInvocation } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/gemini/interceptors/suppress-thought-parts_test.ts b/packages/gateway/src/data-plane/chat/gemini/interceptors/suppress-thought-parts_test.ts index 1d483e0581..9daca9131d 100644 --- a/packages/gateway/src/data-plane/chat/gemini/interceptors/suppress-thought-parts_test.ts +++ b/packages/gateway/src/data-plane/chat/gemini/interceptors/suppress-thought-parts_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { suppressThoughtParts } from './suppress-thought-parts.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiPayload, GeminiStreamEvent } from '@floway-dev/protocols/gemini'; import { type ExecuteResult, eventResult, type GeminiInvocation } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/gemini/interceptors/types.ts b/packages/gateway/src/data-plane/chat/gemini/interceptors/types.ts index 97bb678e21..612414fbac 100644 --- a/packages/gateway/src/data-plane/chat/gemini/interceptors/types.ts +++ b/packages/gateway/src/data-plane/chat/gemini/interceptors/types.ts @@ -1,4 +1,4 @@ -import type { GatewayCtx } from '../../shared/gateway-ctx.ts'; +import type { GatewayCtx } from '../../../shared/gateway-ctx.ts'; import type { Interceptor } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiStreamEvent } from '@floway-dev/protocols/gemini'; diff --git a/packages/gateway/src/data-plane/chat/gemini/respond.ts b/packages/gateway/src/data-plane/chat/gemini/respond.ts index 32de7ffe43..9ec8a5d4ec 100644 --- a/packages/gateway/src/data-plane/chat/gemini/respond.ts +++ b/packages/gateway/src/data-plane/chat/gemini/respond.ts @@ -4,13 +4,13 @@ import { streamSSE } from 'hono/streaming'; import { wrapGeminiAffinityEgress } from './affinity/egress.ts'; import { geminiStatusForHttpStatus } from './errors.ts'; import { tokenUsageFromGeminiUsageMetadata } from './usage.ts'; +import type { GatewayCtx } from '../../shared/gateway-ctx.ts'; +import { type StreamCompletion, writeSSEFrames } from '../../shared/sse.ts'; import { recordFailedRequest } from '../../shared/telemetry/performance.ts'; import { settle } from '../../shared/telemetry/settle.ts'; import { forwardUpstreamHeaders, mergeForwardedUpstreamHeaders } from '../../shared/upstream-response.ts'; import { affinityEgressOptions } from '../shared/affinity/index.ts'; -import type { GatewayCtx } from '../shared/gateway-ctx.ts'; import { SourceStreamState, eventResultMetadata, plainResultToResponse } from '../shared/respond.ts'; -import { type StreamCompletion, writeSSEFrames } from '../shared/stream/sse.ts'; import { type ProtocolFrame, sseCommentFrame, sseFrame } from '@floway-dev/protocols/common'; import { geminiProtocolFrameToSSEFrame, GEMINI_MISSING_TERMINAL_MESSAGE, isGeminiErrorEvent, isGeminiTerminalEvent, collectGeminiProtocolEventsToResult } from '@floway-dev/protocols/gemini'; import type { GeminiErrorResponse, GeminiResult, GeminiStreamEvent } from '@floway-dev/protocols/gemini'; @@ -28,7 +28,7 @@ export const respondGemini = async ( ): Promise => { if (result.type === 'api-error') { recordFailedRequest(ctx, result.performance); - ctx.dump?.error(result.source, result.upstream); + ctx.dump?.error(result.source, result.upstreamId); return geminiApiErrorResponse(result); } @@ -40,7 +40,7 @@ export const respondGemini = async ( if (result.type === 'plain') { if (result.status >= 400) { - ctx.dump?.error(result.upstream !== undefined ? 'upstream' : 'gateway', result.upstream); + ctx.dump?.error(result.upstreamId !== undefined ? 'upstream' : 'gateway', result.upstreamId); } return plainResultToResponse(result); } diff --git a/packages/gateway/src/data-plane/chat/gemini/respond_test.ts b/packages/gateway/src/data-plane/chat/gemini/respond_test.ts index fccb69d965..ae92665981 100644 --- a/packages/gateway/src/data-plane/chat/gemini/respond_test.ts +++ b/packages/gateway/src/data-plane/chat/gemini/respond_test.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono'; import { test } from 'vitest'; import { respondGemini } from './respond.ts'; -import { mockChatGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { eventFrame } from '@floway-dev/protocols/common'; import type { GeminiErrorResponse } from '@floway-dev/protocols/gemini'; diff --git a/packages/gateway/src/data-plane/chat/gemini/serve.ts b/packages/gateway/src/data-plane/chat/gemini/serve.ts index a5c35439f2..db64dacf65 100644 --- a/packages/gateway/src/data-plane/chat/gemini/serve.ts +++ b/packages/gateway/src/data-plane/chat/gemini/serve.ts @@ -1,9 +1,9 @@ import { prepareGeminiAffinity } from './affinity/ingress.ts'; import { geminiAttempt, geminiCountTokensTarget, geminiGenerateTarget } from './attempt.ts'; import { renderGeminiFailure } from './errors.ts'; -import { enumerateModelCandidates } from '../../providers/registry.ts'; +import { enumerateModelCandidates } from '../../providers/resolution.ts'; import { iterateCandidates } from '../../shared/iterate-candidates.ts'; -import { routeCandidatesByAffinity } from '../shared/affinity/index.ts'; +import { narrowCandidatesByAffinity } from '../shared/affinity/index.ts'; import { noViableCandidateFailure } from '../shared/errors.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; @@ -39,14 +39,14 @@ export const geminiServe = { runtimeLocation: ctx.runtimeLocation, }); const viable = enumerated.filter(c => geminiGenerateTarget.canServe(c.model.endpoints)); - const decision = routeCandidatesByAffinity(viable, prepared.routingEvidence); - if (decision.kind === 'failure') return renderGeminiFailure(decision.failure, 'generate'); - if (decision.candidates.length === 0) return renderGeminiFailure(noViableCandidateFailure(sawModel, model, failedUpstreams), 'generate'); + const narrowed = narrowCandidatesByAffinity(viable, prepared.narrowingEvidence); + if ('kind' in narrowed) return renderGeminiFailure(narrowed, 'generate'); + if (narrowed.length === 0) return renderGeminiFailure(noViableCandidateFailure(sawModel, model, failedUpstreams), 'generate'); // Gemini carries the requested model in its URL, so affinity preparation // owns each candidate payload while dispatch uses the candidate's canonical model. return await iterateCandidates( - decision.candidates, + narrowed, 'geminiServe.generate', ctx, 'chat', @@ -69,12 +69,12 @@ export const geminiServe = { runtimeLocation: ctx.runtimeLocation, }); const viable = enumerated.filter(c => geminiCountTokensTarget.canServe(c.model.endpoints)); - const decision = routeCandidatesByAffinity(viable, prepared.routingEvidence); - if (decision.kind === 'failure') return renderGeminiFailure(decision.failure, 'countTokens'); - if (decision.candidates.length === 0) return renderGeminiFailure(noViableCandidateFailure(sawModel, model, failedUpstreams), 'countTokens'); + const narrowed = narrowCandidatesByAffinity(viable, prepared.narrowingEvidence); + if ('kind' in narrowed) return renderGeminiFailure(narrowed, 'countTokens'); + if (narrowed.length === 0) return renderGeminiFailure(noViableCandidateFailure(sawModel, model, failedUpstreams), 'countTokens'); return await iterateCandidates( - decision.candidates, + narrowed, 'geminiServe.countTokens', ctx, 'chat', diff --git a/packages/gateway/src/data-plane/chat/gemini/serve_test.ts b/packages/gateway/src/data-plane/chat/gemini/serve_test.ts index b91de0c1b2..c45b25ccb8 100644 --- a/packages/gateway/src/data-plane/chat/gemini/serve_test.ts +++ b/packages/gateway/src/data-plane/chat/gemini/serve_test.ts @@ -2,7 +2,7 @@ import { afterEach, test, vi } from 'vitest'; import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; -import { mockChatGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { type AliasRules, doneFrame, eventFrame, type ModelEndpoints, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiPayload } from '@floway-dev/protocols/gemini'; @@ -20,8 +20,8 @@ interface QueuedResolution { } const resolutionsQueue: QueuedResolution[] = []; const lastResolveCall: { model?: string } = {}; -vi.mock('../../providers/registry.ts', async importOriginal => { - const original = await importOriginal(); +vi.mock('../../providers/resolution.ts', async importOriginal => { + const original = await importOriginal(); return { ...original, enumerateModelCandidates: vi.fn(async ({ model }: { model: string }) => { @@ -138,7 +138,7 @@ const makeCandidate = (overrides: { }); return { provider: { - upstream, kind: 'custom', name: upstream, + upstreamId: upstream, kind: 'custom', name: upstream, disabledPublicModelIds: [], modelPrefix: null, instance: provider, }, model: stubInternalModel({ endpoints }, upstream), @@ -259,7 +259,7 @@ test('generate falls through to the next candidate when the first yields an upst }); // A mid-attempt throw (interceptor bug / translation error / provider-layer -// JS exception bypassing tryCatchChatServeFailure) must attribute the perf +// JS exception not represented as a ChatServeFailure) must attribute the perf // error row to the throwing candidate, not the previous one that already // failed cleanly with a 5xx. test('mid-attempt throw stamps telemetry with the throwing candidate, not the previous one', async () => { diff --git a/packages/gateway/src/data-plane/chat/messages/affinity/ingress.ts b/packages/gateway/src/data-plane/chat/messages/affinity/ingress.ts index fa84c0e6f2..7573c075a6 100644 --- a/packages/gateway/src/data-plane/chat/messages/affinity/ingress.ts +++ b/packages/gateway/src/data-plane/chat/messages/affinity/ingress.ts @@ -25,7 +25,7 @@ export const prepareMessagesAffinity = async ( } return { - routingEvidence: preferredAffinityEvidence(locations.map(location => location.decoded)), + narrowingEvidence: preferredAffinityEvidence(locations.map(location => location.decoded)), payloadForCandidate: candidate => { const candidatePayload = structuredClone(payload); const byMessage = Map.groupBy(locations, location => location.messageIndex); diff --git a/packages/gateway/src/data-plane/chat/messages/affinity/ingress_test.ts b/packages/gateway/src/data-plane/chat/messages/affinity/ingress_test.ts index b21acf1917..e1af6ff0d4 100644 --- a/packages/gateway/src/data-plane/chat/messages/affinity/ingress_test.ts +++ b/packages/gateway/src/data-plane/chat/messages/affinity/ingress_test.ts @@ -10,13 +10,13 @@ const codec = new AffinityCodec('22'.repeat(32)); const candidate = (upstream: string): ModelCandidate => { const base = stubModelCandidate(); return stubModelCandidate({ - provider: { ...base.provider, upstream }, + provider: { ...base.provider, upstreamId: upstream }, model: { id: 'model' }, }); }; const targetFor = (value: ModelCandidate): AffinityTarget => ({ - upstreamId: value.provider.upstream, + upstreamId: value.provider.upstreamId, modelId: value.model.id, ...(value.rules !== undefined ? { rules: value.rules } : {}), }); diff --git a/packages/gateway/src/data-plane/chat/messages/attempt.ts b/packages/gateway/src/data-plane/chat/messages/attempt.ts index c17bff5634..a4646fc36f 100644 --- a/packages/gateway/src/data-plane/chat/messages/attempt.ts +++ b/packages/gateway/src/data-plane/chat/messages/attempt.ts @@ -102,6 +102,6 @@ export const messagesAttempt = { ); return response; }); - return await plainResultFromResponse(response, candidate.provider.upstream); + return await plainResultFromResponse(response, candidate.provider.upstreamId); }, }; diff --git a/packages/gateway/src/data-plane/chat/messages/attempt_test.ts b/packages/gateway/src/data-plane/chat/messages/attempt_test.ts index a2f24f2d2c..b75bbbf955 100644 --- a/packages/gateway/src/data-plane/chat/messages/attempt_test.ts +++ b/packages/gateway/src/data-plane/chat/messages/attempt_test.ts @@ -3,7 +3,7 @@ import { test, vi } from 'vitest'; import { messagesAttempt } from './attempt.ts'; import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; -import { mockChatGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { doneFrame, eventFrame, type ModelEndpoints, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { MessagesClientTool, MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; @@ -68,7 +68,7 @@ const makeCandidate = (overrides: { }); return { provider: { - upstream, kind: 'custom', name: upstream, + upstreamId: upstream, kind: 'custom', name: upstream, disabledPublicModelIds: [], modelPrefix: null, instance: provider, }, model: stubInternalModel({ diff --git a/packages/gateway/src/data-plane/chat/messages/errors.ts b/packages/gateway/src/data-plane/chat/messages/errors.ts index 1632507409..852c3109ad 100644 --- a/packages/gateway/src/data-plane/chat/messages/errors.ts +++ b/packages/gateway/src/data-plane/chat/messages/errors.ts @@ -57,8 +57,6 @@ export const renderMessagesFailure = ( ): ExecuteResult> => { const endpointPath = endpoint === 'countTokens' ? '/messages/count_tokens' : '/messages'; switch (failure.kind) { - case 'item-not-found': - return anthropicErrorResult(400, 'invalid_request_error', `Item with id '${failure.itemId}' not found.`); case 'routing-unavailable': return anthropicErrorResult(400, 'invalid_request_error', failure.message); case 'model-missing': diff --git a/packages/gateway/src/data-plane/chat/messages/http.ts b/packages/gateway/src/data-plane/chat/messages/http.ts index 43e935c655..9958897217 100644 --- a/packages/gateway/src/data-plane/chat/messages/http.ts +++ b/packages/gateway/src/data-plane/chat/messages/http.ts @@ -3,10 +3,11 @@ import { respondMessages } from './respond.ts'; import { messagesServe } from './serve.ts'; import type { AuthedContext } from '../../../middleware/auth.ts'; import { backgroundSchedulerFromContext } from '../../../runtime/background.ts'; +import { createGatewayCtxFromHono, finalizeGatewayResponse, type GatewayCtx } from '../../shared/gateway-ctx.ts'; import { inboundHeadersForUpstream } from '../../shared/inbound-headers.ts'; +import { readRequestBody, takeRequestBody, type RequestBody } from '../../shared/request-body.ts'; import { createNonResponsesSourceStore } from '../responses/items/store.ts'; -import { createChatGatewayCtxFromHono, createGatewayCtxFromHono, finalizeGatewayResponse, type ChatGatewayCtx, type GatewayCtx } from '../shared/gateway-ctx.ts'; -import { readRequestBody, takeRequestBody, type RequestBody } from '../shared/request-body.ts'; +import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerModelsUnavailableResponse } from '../shared/upstream-models-error.ts'; import type { MessagesPayload } from '@floway-dev/protocols/messages'; import { internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; @@ -58,7 +59,7 @@ const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: Req if (!(error instanceof TranslatorInputError)) return await respondWithInternalError(c, error, requestBody, ctx); const effectiveCtx = ctx ?? createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); const response = await respondMessages(c, translatorInputErrorResult(error, effectiveCtx.attempt.telemetry), false, effectiveCtx); - return (effectiveCtx.dump?.finalize(response) ?? response); + return finalizeGatewayResponse(effectiveCtx, response); }; const parsePayload = (requestBody: RequestBody): MessagesPayload => diff --git a/packages/gateway/src/data-plane/chat/messages/http_test.ts b/packages/gateway/src/data-plane/chat/messages/http_test.ts index 7bb27b72d5..dd78655d67 100644 --- a/packages/gateway/src/data-plane/chat/messages/http_test.ts +++ b/packages/gateway/src/data-plane/chat/messages/http_test.ts @@ -11,8 +11,8 @@ import { type ModelCandidate, directFetcher, type ProviderCallResult, type Provi import { assert, assertEquals, stubProvider, stubInternalModel } from '@floway-dev/test-utils'; const candidatesQueue: { readonly candidates: readonly ModelCandidate[]; readonly sawModel: boolean; readonly failedUpstreams: readonly string[] }[] = []; -vi.mock('../../providers/registry.ts', async importOriginal => { - const original = await importOriginal(); +vi.mock('../../providers/resolution.ts', async importOriginal => { + const original = await importOriginal(); return { ...original, enumerateModelCandidates: vi.fn(async () => { @@ -113,7 +113,7 @@ const makeCandidate = (overrides: { }); return { provider: { - upstream, kind: 'custom', name: upstream, + upstreamId: upstream, kind: 'custom', name: upstream, disabledPublicModelIds: [], modelPrefix: null, instance: provider, }, model: stubInternalModel(overrides.endpoints ? { endpoints: overrides.endpoints } : {}, upstream), diff --git a/packages/gateway/src/data-plane/chat/messages/interceptors/apply-role-compatibility_test.ts b/packages/gateway/src/data-plane/chat/messages/interceptors/apply-role-compatibility_test.ts index a513c8cbfc..2e042ebcf7 100644 --- a/packages/gateway/src/data-plane/chat/messages/interceptors/apply-role-compatibility_test.ts +++ b/packages/gateway/src/data-plane/chat/messages/interceptors/apply-role-compatibility_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { withRoleCompatibilityApplied } from './apply-role-compatibility.ts'; import type { MessagesInvocation } from './types.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { MessagesMessage, MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; import { type ExecuteResult, eventResult, type FlagId } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/messages/interceptors/disable-reasoning-on-forced-tool-choice_test.ts b/packages/gateway/src/data-plane/chat/messages/interceptors/disable-reasoning-on-forced-tool-choice_test.ts index 8649de1d96..2220fdebe8 100644 --- a/packages/gateway/src/data-plane/chat/messages/interceptors/disable-reasoning-on-forced-tool-choice_test.ts +++ b/packages/gateway/src/data-plane/chat/messages/interceptors/disable-reasoning-on-forced-tool-choice_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { withReasoningDisabledOnForcedToolChoice } from './disable-reasoning-on-forced-tool-choice.ts'; import type { MessagesInvocation } from './types.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; import { type ExecuteResult, eventResult, type FlagId } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/messages/interceptors/strip-billing-attribution_test.ts b/packages/gateway/src/data-plane/chat/messages/interceptors/strip-billing-attribution_test.ts index 756d391eb7..5cd8bcf4da 100644 --- a/packages/gateway/src/data-plane/chat/messages/interceptors/strip-billing-attribution_test.ts +++ b/packages/gateway/src/data-plane/chat/messages/interceptors/strip-billing-attribution_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { stripBillingAttribution } from './strip-billing-attribution.ts'; import type { MessagesInvocation } from './types.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; import { type ExecuteResult, eventResult } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/messages/interceptors/types.ts b/packages/gateway/src/data-plane/chat/messages/interceptors/types.ts index 57f37a9d9d..02dbe412ff 100644 --- a/packages/gateway/src/data-plane/chat/messages/interceptors/types.ts +++ b/packages/gateway/src/data-plane/chat/messages/interceptors/types.ts @@ -1,4 +1,4 @@ -import type { GatewayCtx } from '../../shared/gateway-ctx.ts'; +import type { GatewayCtx } from '../../../shared/gateway-ctx.ts'; import type { Interceptor, InterceptorRun } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; diff --git a/packages/gateway/src/data-plane/chat/messages/interceptors/web-search-shim.ts b/packages/gateway/src/data-plane/chat/messages/interceptors/web-search-shim.ts index 426a314f16..8bc9ce1951 100644 --- a/packages/gateway/src/data-plane/chat/messages/interceptors/web-search-shim.ts +++ b/packages/gateway/src/data-plane/chat/messages/interceptors/web-search-shim.ts @@ -2,9 +2,9 @@ import type { MessagesCountTokensInterceptor, MessagesInterceptor, MessagesInvocation } from './types.ts'; import { decodeBase64UrlJson, encodeBase64UrlJson } from '../../../../shared/base64url-json.ts'; import { isJsonObject } from '../../../../shared/json-helpers.ts'; +import { loadWebSearchConfig } from '../../../tools/web-search/config.ts'; import { resolveConfiguredWebSearchProvider } from '../../../tools/web-search/provider.ts'; -import { loadSearchConfig } from '../../../tools/web-search/search-config.ts'; -import { searchWebAndRecordUsage } from '../../../tools/web-search/search.ts'; +import { runWebSearchAndRecordUsage } from '../../../tools/web-search/search.ts'; import type { WebSearchProvider, WebSearchProviderName, WebSearchProviderRequest, WebSearchProviderResult } from '../../../tools/web-search/types.ts'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { @@ -681,7 +681,7 @@ const runWebSearchStopHandler = async function* ( blockedDomains: state.blockedDomains, userLocation: state.userLocation, }; - const providerResult = await searchWebAndRecordUsage({ provider: provider.impl, providerName: provider.providerName, keyId: provider.apiKeyId, request }); + const providerResult = await runWebSearchAndRecordUsage({ provider: provider.impl, providerName: provider.providerName, keyId: provider.apiKeyId, request }); return buildNativeWebSearchResultBlockFromProviderResult(providerResult, block.upstreamToolUseId); } catch { // TODO: Add gateway-side recent web-search error-log storage so operators can inspect detailed provider/runtime failures even though the client-visible native error intentionally collapses them to `unavailable`. @@ -858,8 +858,8 @@ const buildInvalidRequestResponse = (message: string): Response => Response.json(messagesWebSearchInvalidRequestBody(message), { status: 400 }); const resolveActiveMessagesWebSearchProvider = async (apiKeyId: string): Promise<{ type: 'ok'; provider: ActiveMessagesWebSearchProvider } | ReturnType> => { - const searchConfig = await loadSearchConfig(); - const configuredProvider = resolveConfiguredWebSearchProvider(searchConfig); + const webSearchConfig = await loadWebSearchConfig(); + const configuredProvider = resolveConfiguredWebSearchProvider(webSearchConfig); if (configuredProvider.type === 'enabled') { return { diff --git a/packages/gateway/src/data-plane/chat/messages/interceptors/web-search-shim_test.ts b/packages/gateway/src/data-plane/chat/messages/interceptors/web-search-shim_test.ts index f63ea25c5d..ce8763f571 100644 --- a/packages/gateway/src/data-plane/chat/messages/interceptors/web-search-shim_test.ts +++ b/packages/gateway/src/data-plane/chat/messages/interceptors/web-search-shim_test.ts @@ -14,8 +14,8 @@ import { } from './web-search-shim.ts'; import { initRepo } from '../../../../repo/index.ts'; import { InMemoryRepo } from '../../../../repo/memory.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; -import { DEFAULT_SEARCH_CONFIG } from '../../../tools/web-search/search-config.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; +import { DEFAULT_WEB_SEARCH_CONFIG } from '../../../tools/web-search/config.ts'; import type { WebSearchProvider, WebSearchProviderResult } from '../../../tools/web-search/types.ts'; import { type ProtocolFrame, eventFrame } from '@floway-dev/protocols/common'; import { messagesProtocolFrameToSSEFrame } from '@floway-dev/protocols/messages'; @@ -568,14 +568,14 @@ test('prepareMessagesWebSearchShimRequest creates a separate user tool_result me const initDisabledSearchRepo = async (): Promise => { const repo = new InMemoryRepo(); initRepo(repo); - await repo.searchConfig.save(DEFAULT_SEARCH_CONFIG); + await repo.webSearchConfig.save(DEFAULT_WEB_SEARCH_CONFIG); }; const initEnabledSearchRepo = async (): Promise => { const repo = new InMemoryRepo(); initRepo(repo); - await repo.searchConfig.save({ - ...DEFAULT_SEARCH_CONFIG, + await repo.webSearchConfig.save({ + ...DEFAULT_WEB_SEARCH_CONFIG, provider: 'tavily', tavily: { apiKey: 'test-key' }, }); diff --git a/packages/gateway/src/data-plane/chat/messages/respond.ts b/packages/gateway/src/data-plane/chat/messages/respond.ts index 49c1a48fda..68c65bc0c9 100644 --- a/packages/gateway/src/data-plane/chat/messages/respond.ts +++ b/packages/gateway/src/data-plane/chat/messages/respond.ts @@ -2,22 +2,20 @@ import type { Context } from 'hono'; import { streamSSE } from 'hono/streaming'; import { wrapMessagesAffinityEgress } from './affinity/egress.ts'; +import { createMessagesStreamUsageState, tokenUsageFromMessagesFrame, tokenUsageFromMessagesUsage, type MessagesStreamUsageState } from './usage.ts'; +import type { GatewayCtx } from '../../shared/gateway-ctx.ts'; +import { type StreamCompletion, writeSSEFrames } from '../../shared/sse.ts'; import { recordFailedRequest } from '../../shared/telemetry/performance.ts'; import { settle } from '../../shared/telemetry/settle.ts'; -import { tokenUsage } from '../../shared/telemetry/usage.ts'; import { forwardUpstreamHeaders, mergeForwardedUpstreamHeaders } from '../../shared/upstream-response.ts'; import { affinityEgressOptions } from '../shared/affinity/index.ts'; -import type { GatewayCtx } from '../shared/gateway-ctx.ts'; import { SourceStreamState, eventResultMetadata, plainResultToResponse } from '../shared/respond.ts'; -import { type StreamCompletion, writeSSEFrames } from '../shared/stream/sse.ts'; -import { billableServiceTier, type ProtocolFrame, sseFrame } from '@floway-dev/protocols/common'; -import { messagesProtocolFrameToSSEFrame, MESSAGES_MISSING_TERMINAL_MESSAGE, collectMessagesProtocolEventsToResult, mergeMessagesUsageSnapshot, messagesUsageSnapshot, splitMessagesCacheCreationTokens } from '@floway-dev/protocols/messages'; -import type { MessagesMessageDeltaEvent, MessagesStreamEvent, MessagesUsage } from '@floway-dev/protocols/messages'; +import { type ProtocolFrame, sseFrame } from '@floway-dev/protocols/common'; +import { messagesProtocolFrameToSSEFrame, MESSAGES_MISSING_TERMINAL_MESSAGE, collectMessagesProtocolEventsToResult } from '@floway-dev/protocols/messages'; +import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; import { type ExecuteResult, type PlainResult, type InternalDebugError, toInternalDebugError } from '@floway-dev/provider'; import { apiErrorToResponse } from '@floway-dev/provider'; -type MessagesUsageLike = MessagesUsage | NonNullable; - // Renders an upstream Messages result into the client HTTP/SSE response. An // error-typed result is a pre-stream failure and always answers as HTTP; an // events result drains to one JSON body (non-streaming) or is proxied frame by @@ -30,7 +28,7 @@ export const respondMessages = async ( ): Promise => { if (result.type === 'api-error') { recordFailedRequest(ctx, result.performance); - ctx.dump?.error(result.source, result.upstream); + ctx.dump?.error(result.source, result.upstreamId); return apiErrorToResponse(result); } @@ -42,7 +40,7 @@ export const respondMessages = async ( if (result.type === 'plain') { if (result.status >= 400) { - ctx.dump?.error(result.upstream !== undefined ? 'upstream' : 'gateway', result.upstream); + ctx.dump?.error(result.upstreamId !== undefined ? 'upstream' : 'gateway', result.upstreamId); } return plainResultToResponse(result); } @@ -88,66 +86,6 @@ export const respondMessages = async ( }); }; -// Anthropic already reports disjoint token counts: input_tokens excludes the -// cache figures. Map them straight onto the billing metrics without -// summing. When the upstream emits the `cache_creation` sub-object -// (extended-cache-ttl-2025-04-11), split the per-TTL counts onto the 5m and -// 1h metrics; the flat `cache_creation_input_tokens` is the sum and is -// only consulted when the sub-object is absent. -// -// Response usage carries two server-stamped tier fields: `speed` (fast mode) -// and `service_tier` (capacity assignment). Fast mode is documented as -// unavailable with Priority Tier and the Batch API, so at most one -// non-`standard` value lands on a single response — prefer `speed` first -// (the only multi-x override today) then fall through to `service_tier`. -// `standard` on either side collapses to null so per-tier rows aggregate -// with base; unknown values flow through verbatim so a future Anthropic -// release does not silently bill at base. -// * https://docs.claude.com/en/build-with-claude/fast-mode -// * https://docs.claude.com/en/api/service-tiers -const tokenUsageFromMessagesUsage = (u: MessagesUsageLike) => { - const { cacheWrite, cacheWrite1h } = splitMessagesCacheCreationTokens(u); - const tier = billableServiceTier(u.speed) ?? billableServiceTier(u.service_tier); - return tokenUsage({ - input: u.input_tokens ?? 0, - input_cache_read: u.cache_read_input_tokens ?? 0, - input_cache_write: cacheWrite, - input_cache_write_1h: cacheWrite1h, - output: u.output_tokens, - tier, - }); -}; - -export const createMessagesStreamUsageState = () => ({ - raw: messagesUsageSnapshot(), - current: tokenUsage({}), -}); - -type MessagesStreamUsageState = ReturnType; - -// Returns a snapshot of the running usage on every frame that revises it, not -// only on `message_stop`, so the respond layer can checkpoint billing state -// into `SourceStreamState.usage` as the stream progresses. A client disconnect -// that races the terminal frame would otherwise discard the last -// `message_delta`'s output count. Each call returns a fresh object so the -// snapshot stored in `SourceStreamState.usage` does not silently mutate when -// the next delta lands. -export const tokenUsageFromMessagesFrame = (frame: ProtocolFrame, state: MessagesStreamUsageState) => { - if (frame.type !== 'event') return null; - const { event } = frame; - if (event.type === 'message_start') { - state.raw = messagesUsageSnapshot(event.message.usage); - state.current = tokenUsageFromMessagesUsage(state.raw); - return { ...state.current }; - } - if (event.type === 'message_delta' && event.usage) { - state.raw = mergeMessagesUsageSnapshot(state.raw, event.usage); - state.current = tokenUsageFromMessagesUsage(state.raw); - return { ...state.current }; - } - return event.type === 'message_stop' ? { ...state.current } : null; -}; - const internalMessagesErrorPayload = (error: InternalDebugError) => ({ type: 'error', error: { diff --git a/packages/gateway/src/data-plane/chat/messages/respond_test.ts b/packages/gateway/src/data-plane/chat/messages/respond_test.ts index 8938b7538e..03f740329c 100644 --- a/packages/gateway/src/data-plane/chat/messages/respond_test.ts +++ b/packages/gateway/src/data-plane/chat/messages/respond_test.ts @@ -1,553 +1,17 @@ import { Hono } from 'hono'; import { test } from 'vitest'; -import { createMessagesStreamUsageState, respondMessages, tokenUsageFromMessagesFrame } from './respond.ts'; +import { respondMessages } from './respond.ts'; import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; import { tokenCountsFromUsage } from '../../../repo/usage-metrics.ts'; -import { mockChatGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; import { eventResult, type ExecuteResult } from '@floway-dev/provider'; import { assert, assertEquals, testTelemetryModelIdentity } from '@floway-dev/test-utils'; -const stop = () => eventFrame({ type: 'message_stop' } satisfies MessagesStreamEvent); - -test('Messages stream usage keeps start input and delta output', () => { - const state = createMessagesStreamUsageState(); - - // Every revising frame returns the running snapshot so the observer can - // checkpoint partial usage into SourceStreamState before the terminal - // message_stop — required for billing fidelity when the client disconnects - // mid-stream. - assertEquals( - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-test', - stop_reason: null, - stop_sequence: null, - usage: { - input_tokens: 12, - output_tokens: 1, - cache_creation_input_tokens: 4, - cache_read_input_tokens: 3, - }, - }, - } satisfies MessagesStreamEvent), - state, - ), - { - input: 12, - input_cache_read: 3, - input_cache_write: 4, - output: 1, - }, - ); - assertEquals( - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_delta', - delta: {}, - usage: { output_tokens: 7 }, - } satisfies MessagesStreamEvent), - state, - ), - { - input: 12, - input_cache_read: 3, - input_cache_write: 4, - output: 7, - }, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 12, - input_cache_read: 3, - input_cache_write: 4, - output: 7, - }); -}); - -test('Messages stream usage can recover input from delta', () => { - const state = createMessagesStreamUsageState(); - - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-test', - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 0, output_tokens: 0 }, - }, - } satisfies MessagesStreamEvent), - state, - ); - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_delta', - delta: {}, - usage: { - input_tokens: 11, - output_tokens: 2, - cache_creation_input_tokens: 7, - cache_read_input_tokens: 5, - }, - } satisfies MessagesStreamEvent), - state, - ); - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_delta', - delta: {}, - usage: { output_tokens: 6 }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 11, - input_cache_read: 5, - input_cache_write: 7, - output: 6, - }); -}); - -test('Messages stream usage keeps cache-only start when a later delta carries input', () => { - // A fully cache-hit prompt: message_start reports bare input 0 but non-zero - // cache reads. A subsequent delta carries input_tokens, which must not cause - // the start's cache counts to be dropped. - const state = createMessagesStreamUsageState(); - - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-test', - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 0, output_tokens: 1, cache_read_input_tokens: 1000 }, - }, - } satisfies MessagesStreamEvent), - state, - ); - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_delta', - delta: {}, - usage: { input_tokens: 0, output_tokens: 50 }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input_cache_read: 1000, - output: 50, - }); -}); - -test('Messages stream usage splits cache_creation per-TTL when the sub-object is present', () => { - const state = createMessagesStreamUsageState(); - - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-opus-4-8', - stop_reason: null, - stop_sequence: null, - usage: { - input_tokens: 12, - output_tokens: 1, - // The flat field is the sum of both sub-buckets and is consulted - // only as a fallback. With the sub-object present the per-TTL split - // must take precedence — otherwise this row would double-count. - cache_creation_input_tokens: 9, - cache_creation: { ephemeral_5m_input_tokens: 4, ephemeral_1h_input_tokens: 5 }, - cache_read_input_tokens: 3, - }, - }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 12, - input_cache_read: 3, - input_cache_write: 4, - input_cache_write_1h: 5, - output: 1, - }); -}); - -test('Messages stream usage falls back to the rolled-up cache_creation when the sub-object is absent', () => { - const state = createMessagesStreamUsageState(); - - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-sonnet-4-6', - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 12, output_tokens: 1, cache_creation_input_tokens: 9, cache_read_input_tokens: 3 }, - }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 12, - input_cache_read: 3, - input_cache_write: 9, - output: 1, - }); -}); - -test('Messages stream usage applies a TTL breakdown restamped by message_delta', () => { - const state = createMessagesStreamUsageState(); - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-test', - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 12, output_tokens: 0, cache_creation_input_tokens: 9 }, - }, - } satisfies MessagesStreamEvent), - state, - ); - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_delta', - delta: { stop_reason: 'end_turn' }, - usage: { - output_tokens: 2, - cache_creation: { ephemeral_1h_input_tokens: 5 }, - }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 12, - input_cache_write: 4, - input_cache_write_1h: 5, - output: 2, - }); -}); - -test('Messages stream usage captures speed=fast as tier=fast', () => { - const state = createMessagesStreamUsageState(); - - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-opus-4-8', - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 5, output_tokens: 0, speed: 'fast' }, - }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 5, - tier: 'fast', - }); -}); - -test('Messages stream usage leaves tier unset when speed is standard', () => { - const state = createMessagesStreamUsageState(); - - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-opus-4-8', - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 5, output_tokens: 0, speed: 'standard' }, - }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 5, - }); -}); - -test('Messages stream usage forwards service_tier=priority verbatim', () => { - const state = createMessagesStreamUsageState(); - - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-sonnet-4-6', - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 5, output_tokens: 0, service_tier: 'priority' }, - }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 5, - tier: 'priority', - }); -}); - -test('Messages stream usage forwards service_tier=batch verbatim', () => { - const state = createMessagesStreamUsageState(); - - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-sonnet-4-6', - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 5, output_tokens: 0, service_tier: 'batch' }, - }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 5, - tier: 'batch', - }); -}); - -test('Messages stream usage forwards an unknown non-standard tier verbatim (forward-compat)', () => { - // A future Anthropic value the SDK has not minted yet must reach the - // billing record so the operator can backfill a pricing override for it - // rather than have it silently fold into the base bucket. - const state = createMessagesStreamUsageState(); - - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-opus-4-8', - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 5, output_tokens: 0, speed: 'turbo' }, - }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 5, - tier: 'turbo', - }); -}); - -test('Messages stream usage prefers speed=fast over service_tier=standard', () => { - // Anthropic stamps both fields on a Priority-Tier-aware account; fast mode - // is mutually exclusive with priority/batch per docs, so a `fast` row will - // always pair with `service_tier: 'standard'`. The non-standard signal - // wins; the redundant 'standard' must not clobber it. - const state = createMessagesStreamUsageState(); - - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-opus-4-8', - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 5, output_tokens: 0, speed: 'fast', service_tier: 'standard' }, - }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 5, - tier: 'fast', - }); -}); - -test('Messages stream usage carries tier forward when a fully cache-hit start is followed by a delta that re-supplies input', () => { - // A fully cache-hit prompt: message_start reports bare input 0 and tier 'fast', - // and a later delta carries input_tokens without re-stamping the tier fields. - // The delta replaces state.current (gotInputFromStart was false), so without - // explicit carry-forward the fast tier would be dropped — and the row would - // bill at base. - const state = createMessagesStreamUsageState(); - - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-opus-4-8', - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 0, output_tokens: 0, speed: 'fast' }, - }, - } satisfies MessagesStreamEvent), - state, - ); - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_delta', - delta: {}, - usage: { input_tokens: 11, output_tokens: 2, cache_read_input_tokens: 5 }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 11, - input_cache_read: 5, - output: 2, - tier: 'fast', - }); -}); - -test('Messages stream usage lets a delta-stamped tier win over message_start on the cache-hit-prompt path', () => { - // The wire schema permits message_delta.usage to carry service_tier/speed - // (packages/protocols/src/messages/index.ts). If a future upstream reassigns - // the served tier between message_start and message_delta — or starts - // stamping the served tier only on the delta — the delta value describes - // the billing bucket and must replace the start-stamped one. - const state = createMessagesStreamUsageState(); - - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-opus-4-8', - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 0, output_tokens: 0, speed: 'fast' }, - }, - } satisfies MessagesStreamEvent), - state, - ); - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_delta', - delta: {}, - usage: { input_tokens: 11, output_tokens: 2, service_tier: 'priority' }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 11, - output: 2, - tier: 'priority', - }); -}); - -test('Messages stream usage lets a delta-stamped tier win on the normal output-only path', () => { - // Symmetric to the cache-hit branch: when message_start already carried the - // real input accounting (gotInputFromStart === true), the delta normally - // just updates the running output. The wire schema still permits the delta - // to (re)stamp service_tier/speed, and that signal describes this billing - // bucket — must replace what start stamped, not be silently dropped. - const state = createMessagesStreamUsageState(); - - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_start', - message: { - id: 'msg_1', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-opus-4-8', - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 50, output_tokens: 0, service_tier: 'standard' }, - }, - } satisfies MessagesStreamEvent), - state, - ); - tokenUsageFromMessagesFrame( - eventFrame({ - type: 'message_delta', - delta: {}, - usage: { output_tokens: 7, service_tier: 'priority' }, - } satisfies MessagesStreamEvent), - state, - ); - - assertEquals(tokenUsageFromMessagesFrame(stop(), state), { - input: 50, - output: 7, - tier: 'priority', - }); -}); - // --- header forwarding --- const forwardedHeadersFixture = (): Headers => new Headers({ diff --git a/packages/gateway/src/data-plane/chat/messages/serve.ts b/packages/gateway/src/data-plane/chat/messages/serve.ts index dd386d2176..758b6dffd9 100644 --- a/packages/gateway/src/data-plane/chat/messages/serve.ts +++ b/packages/gateway/src/data-plane/chat/messages/serve.ts @@ -1,9 +1,9 @@ import { prepareMessagesAffinity } from './affinity/ingress.ts'; import { messagesAttempt, messagesGenerateTarget, messagesCountTokensTarget } from './attempt.ts'; import { renderMessagesFailure } from './errors.ts'; -import { enumerateModelCandidates } from '../../providers/registry.ts'; +import { enumerateModelCandidates } from '../../providers/resolution.ts'; import { iterateCandidates } from '../../shared/iterate-candidates.ts'; -import { routeCandidatesByAffinity } from '../shared/affinity/index.ts'; +import { narrowCandidatesByAffinity } from '../shared/affinity/index.ts'; import { noViableCandidateFailure } from '../shared/errors.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; @@ -34,9 +34,9 @@ export const messagesServe = { runtimeLocation: ctx.runtimeLocation, }); const viable = enumerated.filter(c => messagesGenerateTarget.canServe(c.model.endpoints)); - const decision = routeCandidatesByAffinity(viable, prepared.routingEvidence); - if (decision.kind === 'failure') return renderMessagesFailure(decision.failure, 'generate'); - if (decision.candidates.length === 0) return renderMessagesFailure(noViableCandidateFailure(sawModel, payload.model, failedUpstreams), 'generate'); + const narrowed = narrowCandidatesByAffinity(viable, prepared.narrowingEvidence); + if ('kind' in narrowed) return renderMessagesFailure(narrowed, 'generate'); + if (narrowed.length === 0) return renderMessagesFailure(noViableCandidateFailure(sawModel, payload.model, failedUpstreams), 'generate'); // Try each narrowed candidate in order. A successful attempt (SSE // stream opened) is the final answer; an api-error or internal-error @@ -45,7 +45,7 @@ export const messagesServe = { // most recent failure is forwarded verbatim. Each attempt stamps its // private payload clone with the candidate's canonical model id. return await iterateCandidates( - decision.candidates, + narrowed, 'messagesServe.generate', ctx, 'chat', @@ -68,12 +68,12 @@ export const messagesServe = { runtimeLocation: ctx.runtimeLocation, }); const viable = enumerated.filter(c => messagesCountTokensTarget.canServe(c.model.endpoints)); - const decision = routeCandidatesByAffinity(viable, prepared.routingEvidence); - if (decision.kind === 'failure') return renderMessagesFailure(decision.failure, 'countTokens'); - if (decision.candidates.length === 0) return renderMessagesFailure(noViableCandidateFailure(sawModel, payload.model, failedUpstreams), 'countTokens'); + const narrowed = narrowCandidatesByAffinity(viable, prepared.narrowingEvidence); + if ('kind' in narrowed) return renderMessagesFailure(narrowed, 'countTokens'); + if (narrowed.length === 0) return renderMessagesFailure(noViableCandidateFailure(sawModel, payload.model, failedUpstreams), 'countTokens'); return await iterateCandidates( - decision.candidates, + narrowed, 'messagesServe.countTokens', ctx, 'chat', diff --git a/packages/gateway/src/data-plane/chat/messages/serve_test.ts b/packages/gateway/src/data-plane/chat/messages/serve_test.ts index 4705da4ee0..b5a4e19e91 100644 --- a/packages/gateway/src/data-plane/chat/messages/serve_test.ts +++ b/packages/gateway/src/data-plane/chat/messages/serve_test.ts @@ -2,7 +2,7 @@ import { afterEach, test, vi } from 'vitest'; import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; -import { mockChatGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; import { type AliasRules, doneFrame, eventFrame, type ModelEndpoints, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; import type { ResponsesResult, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; @@ -18,8 +18,8 @@ interface QueuedResolution { } const resolutionsQueue: QueuedResolution[] = []; const lastResolveCall: { model?: string } = {}; -vi.mock('../../providers/registry.ts', async importOriginal => { - const original = await importOriginal(); +vi.mock('../../providers/resolution.ts', async importOriginal => { + const original = await importOriginal(); return { ...original, enumerateModelCandidates: vi.fn(async ({ model }: { model: string }) => { @@ -142,7 +142,7 @@ const makeCandidate = (overrides: { }); return { provider: { - upstream, kind, name: upstream, + upstreamId: upstream, kind, name: upstream, disabledPublicModelIds: [], modelPrefix: null, instance: provider, }, model: stubInternalModel({ @@ -632,7 +632,7 @@ test('countTokens failover preserves billing blocks for a strip-off candidate', assertEquals(payload.messages, expectedMessages); }); -test('alias resolution swaps the inbound model id for the target and overlays rules onto the Messages IR', async () => { +test('alias resolution swaps the inbound model id for the target and overlays rules onto the Messages payload', async () => { installRepo(); const capturedBodies: MessagesPayload[] = []; const observedModelIds: string[] = []; @@ -689,7 +689,7 @@ test('alias whose targets have no kind-matching binding surfaces as the regular }); // A mid-attempt throw (interceptor bug / translation error / provider-layer JS -// exception bypassing tryCatchChatServeFailure) must attribute the perf error +// exception not represented as a ChatServeFailure) must attribute the perf error // row to the throwing candidate, not the previous one. The serve stamps // `ctx.attempt.telemetry` synchronously in the iterateCandidates // callback so the http.ts catch can build an internal-error result carrying diff --git a/packages/gateway/src/data-plane/chat/messages/usage.ts b/packages/gateway/src/data-plane/chat/messages/usage.ts new file mode 100644 index 0000000000..563889ad1a --- /dev/null +++ b/packages/gateway/src/data-plane/chat/messages/usage.ts @@ -0,0 +1,66 @@ +import { tokenUsage } from '../../shared/telemetry/usage.ts'; +import { billableServiceTier, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { mergeMessagesUsageSnapshot, messagesUsageSnapshot, splitMessagesCacheCreationTokens } from '@floway-dev/protocols/messages'; +import type { MessagesMessageDeltaEvent, MessagesStreamEvent, MessagesUsage } from '@floway-dev/protocols/messages'; + +type MessagesUsageLike = MessagesUsage | NonNullable; + +// Anthropic already reports disjoint token counts: input_tokens excludes the +// cache figures. Map them straight onto the billing metrics without +// summing. When the upstream emits the `cache_creation` sub-object +// (extended-cache-ttl-2025-04-11), split the per-TTL counts onto the 5m and +// 1h metrics; the flat `cache_creation_input_tokens` is the sum and is +// only consulted when the sub-object is absent. +// +// Response usage carries two server-stamped tier fields: `speed` (fast mode) +// and `service_tier` (capacity assignment). Fast mode is documented as +// unavailable with Priority Tier and the Batch API, so at most one +// non-`standard` value lands on a single response — prefer `speed` first +// (the only multi-x override today) then fall through to `service_tier`. +// `standard` on either side collapses to null so per-tier rows aggregate +// with base; unknown values flow through verbatim so a future Anthropic +// release does not silently bill at base. +// * https://docs.claude.com/en/build-with-claude/fast-mode +// * https://docs.claude.com/en/api/service-tiers +export const tokenUsageFromMessagesUsage = (usage: MessagesUsageLike) => { + const { cacheWrite, cacheWrite1h } = splitMessagesCacheCreationTokens(usage); + const tier = billableServiceTier(usage.speed) ?? billableServiceTier(usage.service_tier); + return tokenUsage({ + input: usage.input_tokens ?? 0, + input_cache_read: usage.cache_read_input_tokens ?? 0, + input_cache_write: cacheWrite, + input_cache_write_1h: cacheWrite1h, + output: usage.output_tokens, + tier, + }); +}; + +export const createMessagesStreamUsageState = () => ({ + raw: messagesUsageSnapshot(), + current: tokenUsage({}), +}); + +export type MessagesStreamUsageState = ReturnType; + +// Returns a snapshot of the running usage on every frame that revises it, not +// only on `message_stop`, so the respond layer can checkpoint billing state +// into `SourceStreamState.usage` as the stream progresses. A client disconnect +// that races the terminal frame would otherwise discard the last +// `message_delta`'s output count. Each call returns a fresh object so the +// snapshot stored in `SourceStreamState.usage` does not silently mutate when +// the next delta lands. +export const tokenUsageFromMessagesFrame = (frame: ProtocolFrame, state: MessagesStreamUsageState) => { + if (frame.type !== 'event') return null; + const { event } = frame; + if (event.type === 'message_start') { + state.raw = messagesUsageSnapshot(event.message.usage); + state.current = tokenUsageFromMessagesUsage(state.raw); + return { ...state.current }; + } + if (event.type === 'message_delta' && event.usage) { + state.raw = mergeMessagesUsageSnapshot(state.raw, event.usage); + state.current = tokenUsageFromMessagesUsage(state.raw); + return { ...state.current }; + } + return event.type === 'message_stop' ? { ...state.current } : null; +}; diff --git a/packages/gateway/src/data-plane/chat/messages/usage_test.ts b/packages/gateway/src/data-plane/chat/messages/usage_test.ts new file mode 100644 index 0000000000..fec6424368 --- /dev/null +++ b/packages/gateway/src/data-plane/chat/messages/usage_test.ts @@ -0,0 +1,542 @@ +import { test } from 'vitest'; + +import { createMessagesStreamUsageState, tokenUsageFromMessagesFrame } from './usage.ts'; +import { eventFrame } from '@floway-dev/protocols/common'; +import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; +import { assertEquals } from '@floway-dev/test-utils'; + +const stop = () => eventFrame({ type: 'message_stop' } satisfies MessagesStreamEvent); + +test('Messages stream usage keeps start input and delta output', () => { + const state = createMessagesStreamUsageState(); + + // Every revising frame returns the running snapshot so the observer can + // checkpoint partial usage into SourceStreamState before the terminal + // message_stop — required for billing fidelity when the client disconnects + // mid-stream. + assertEquals( + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-test', + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 12, + output_tokens: 1, + cache_creation_input_tokens: 4, + cache_read_input_tokens: 3, + }, + }, + } satisfies MessagesStreamEvent), + state, + ), + { + input: 12, + input_cache_read: 3, + input_cache_write: 4, + output: 1, + }, + ); + assertEquals( + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_delta', + delta: {}, + usage: { output_tokens: 7 }, + } satisfies MessagesStreamEvent), + state, + ), + { + input: 12, + input_cache_read: 3, + input_cache_write: 4, + output: 7, + }, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 12, + input_cache_read: 3, + input_cache_write: 4, + output: 7, + }); +}); + +test('Messages stream usage can recover input from delta', () => { + const state = createMessagesStreamUsageState(); + + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-test', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + } satisfies MessagesStreamEvent), + state, + ); + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_delta', + delta: {}, + usage: { + input_tokens: 11, + output_tokens: 2, + cache_creation_input_tokens: 7, + cache_read_input_tokens: 5, + }, + } satisfies MessagesStreamEvent), + state, + ); + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_delta', + delta: {}, + usage: { output_tokens: 6 }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 11, + input_cache_read: 5, + input_cache_write: 7, + output: 6, + }); +}); + +test('Messages stream usage keeps cache-only start when a later delta carries input', () => { + // A fully cache-hit prompt: message_start reports bare input 0 but non-zero + // cache reads. A subsequent delta carries input_tokens, which must not cause + // the start's cache counts to be dropped. + const state = createMessagesStreamUsageState(); + + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-test', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 1, cache_read_input_tokens: 1000 }, + }, + } satisfies MessagesStreamEvent), + state, + ); + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_delta', + delta: {}, + usage: { input_tokens: 0, output_tokens: 50 }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input_cache_read: 1000, + output: 50, + }); +}); + +test('Messages stream usage splits cache_creation per-TTL when the sub-object is present', () => { + const state = createMessagesStreamUsageState(); + + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-opus-4-8', + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 12, + output_tokens: 1, + // The flat field is the sum of both sub-buckets and is consulted + // only as a fallback. With the sub-object present the per-TTL split + // must take precedence — otherwise this row would double-count. + cache_creation_input_tokens: 9, + cache_creation: { ephemeral_5m_input_tokens: 4, ephemeral_1h_input_tokens: 5 }, + cache_read_input_tokens: 3, + }, + }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 12, + input_cache_read: 3, + input_cache_write: 4, + input_cache_write_1h: 5, + output: 1, + }); +}); + +test('Messages stream usage falls back to the rolled-up cache_creation when the sub-object is absent', () => { + const state = createMessagesStreamUsageState(); + + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-6', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 12, output_tokens: 1, cache_creation_input_tokens: 9, cache_read_input_tokens: 3 }, + }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 12, + input_cache_read: 3, + input_cache_write: 9, + output: 1, + }); +}); + +test('Messages stream usage applies a TTL breakdown restamped by message_delta', () => { + const state = createMessagesStreamUsageState(); + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-test', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 12, output_tokens: 0, cache_creation_input_tokens: 9 }, + }, + } satisfies MessagesStreamEvent), + state, + ); + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { + output_tokens: 2, + cache_creation: { ephemeral_1h_input_tokens: 5 }, + }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 12, + input_cache_write: 4, + input_cache_write_1h: 5, + output: 2, + }); +}); + +test('Messages stream usage captures speed=fast as tier=fast', () => { + const state = createMessagesStreamUsageState(); + + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-opus-4-8', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 0, speed: 'fast' }, + }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 5, + tier: 'fast', + }); +}); + +test('Messages stream usage leaves tier unset when speed is standard', () => { + const state = createMessagesStreamUsageState(); + + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-opus-4-8', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 0, speed: 'standard' }, + }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 5, + }); +}); + +test('Messages stream usage forwards service_tier=priority verbatim', () => { + const state = createMessagesStreamUsageState(); + + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-6', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 0, service_tier: 'priority' }, + }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 5, + tier: 'priority', + }); +}); + +test('Messages stream usage forwards service_tier=batch verbatim', () => { + const state = createMessagesStreamUsageState(); + + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-6', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 0, service_tier: 'batch' }, + }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 5, + tier: 'batch', + }); +}); + +test('Messages stream usage forwards an unknown non-standard tier verbatim (forward-compat)', () => { + // A future Anthropic value the SDK has not minted yet must reach the + // billing record so the operator can backfill a pricing override for it + // rather than have it silently fold into the base bucket. + const state = createMessagesStreamUsageState(); + + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-opus-4-8', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 0, speed: 'turbo' }, + }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 5, + tier: 'turbo', + }); +}); + +test('Messages stream usage prefers speed=fast over service_tier=standard', () => { + // Anthropic stamps both fields on a Priority-Tier-aware account; fast mode + // is mutually exclusive with priority/batch per docs, so a `fast` row will + // always pair with `service_tier: 'standard'`. The non-standard signal + // wins; the redundant 'standard' must not clobber it. + const state = createMessagesStreamUsageState(); + + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-opus-4-8', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 0, speed: 'fast', service_tier: 'standard' }, + }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 5, + tier: 'fast', + }); +}); + +test('Messages stream usage carries tier forward when a fully cache-hit start is followed by a delta that re-supplies input', () => { + // A fully cache-hit prompt: message_start reports bare input 0 and tier 'fast', + // and a later delta carries input_tokens without re-stamping the tier fields. + // The delta replaces state.current (gotInputFromStart was false), so without + // explicit carry-forward the fast tier would be dropped — and the row would + // bill at base. + const state = createMessagesStreamUsageState(); + + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-opus-4-8', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0, speed: 'fast' }, + }, + } satisfies MessagesStreamEvent), + state, + ); + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_delta', + delta: {}, + usage: { input_tokens: 11, output_tokens: 2, cache_read_input_tokens: 5 }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 11, + input_cache_read: 5, + output: 2, + tier: 'fast', + }); +}); + +test('Messages stream usage lets a delta-stamped tier win over message_start on the cache-hit-prompt path', () => { + // The wire schema permits message_delta.usage to carry service_tier/speed + // (packages/protocols/src/messages/index.ts). If a future upstream reassigns + // the served tier between message_start and message_delta — or starts + // stamping the served tier only on the delta — the delta value describes + // the billing bucket and must replace the start-stamped one. + const state = createMessagesStreamUsageState(); + + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-opus-4-8', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0, speed: 'fast' }, + }, + } satisfies MessagesStreamEvent), + state, + ); + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_delta', + delta: {}, + usage: { input_tokens: 11, output_tokens: 2, service_tier: 'priority' }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 11, + output: 2, + tier: 'priority', + }); +}); + +test('Messages stream usage lets a delta-stamped tier win on the normal output-only path', () => { + // Symmetric to the cache-hit branch: when message_start already carried the + // real input accounting (gotInputFromStart === true), the delta normally + // just updates the running output. The wire schema still permits the delta + // to (re)stamp service_tier/speed, and that signal describes this billing + // bucket — must replace what start stamped, not be silently dropped. + const state = createMessagesStreamUsageState(); + + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-opus-4-8', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 50, output_tokens: 0, service_tier: 'standard' }, + }, + } satisfies MessagesStreamEvent), + state, + ); + tokenUsageFromMessagesFrame( + eventFrame({ + type: 'message_delta', + delta: {}, + usage: { output_tokens: 7, service_tier: 'priority' }, + } satisfies MessagesStreamEvent), + state, + ); + + assertEquals(tokenUsageFromMessagesFrame(stop(), state), { + input: 50, + output: 7, + tier: 'priority', + }); +}); diff --git a/packages/gateway/src/data-plane/chat/responses/affinity/copilot-roundtrip_test.ts b/packages/gateway/src/data-plane/chat/responses/affinity/copilot-roundtrip_test.ts index 7c416593e4..a430d4dfc4 100644 --- a/packages/gateway/src/data-plane/chat/responses/affinity/copilot-roundtrip_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/affinity/copilot-roundtrip_test.ts @@ -6,7 +6,7 @@ import { AffinityCodec } from '../../shared/affinity/index.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { CanonicalResponsesPayload, ResponsesOutputItem, ResponsesResult, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; import { initProviderRepo, providerModelOf, type UpstreamRecord } from '@floway-dev/provider'; -import { clearInProcessCopilotTokenCache, copilotProvider } from '@floway-dev/provider-copilot'; +import { clearInProcessCopilotTokenCache, copilotProviderModule } from '@floway-dev/provider-copilot'; import { noopUpstreamCallOptions, sseResponse, stubModelCandidate, stubProvider, withMockedFetch } from '@floway-dev/test-utils'; const upstream: UpstreamRecord = { @@ -67,7 +67,7 @@ test('Copilot item-id and generic affinity trailers compose and unwrap in bounda }, })); clearInProcessCopilotTokenCache(); - const provider = copilotProvider.create(upstream); + const provider = copilotProviderModule.create(upstream); const rawModel = { id: 'gpt-test', supported_endpoints: ['/responses'] }; const candidate = stubModelCandidate({ provider, @@ -76,7 +76,7 @@ test('Copilot item-id and generic affinity trailers compose and unwrap in bounda }); const otherCandidate = stubModelCandidate({ provider: { - upstream: 'up-other', + upstreamId: 'up-other', kind: 'custom', name: 'Other', disabledPublicModelIds: [], @@ -124,7 +124,7 @@ test('Copilot item-id and generic affinity trailers compose and unwrap in bounda const codec = new AffinityCodec('00'.repeat(32)); const publicEvents = await collectEvents(wrapResponsesAffinityEgress(first.events, { codec, - affinity: { upstreamId: provider.upstream, modelId: candidate.model.id }, + affinity: { upstreamId: provider.upstreamId, modelId: candidate.model.id }, })); const done = publicEvents.find(event => event.type === 'response.output_item.done'); if (done?.type !== 'response.output_item.done') throw new Error('expected public done item'); diff --git a/packages/gateway/src/data-plane/chat/responses/affinity/ingress.ts b/packages/gateway/src/data-plane/chat/responses/affinity/ingress.ts index 945b41ce9a..6e034b84e3 100644 --- a/packages/gateway/src/data-plane/chat/responses/affinity/ingress.ts +++ b/packages/gateway/src/data-plane/chat/responses/affinity/ingress.ts @@ -27,7 +27,7 @@ const blobRequiresForce = (item: ResponsesInputItem, decoded: DecodedAffinityBlo ? decoded.kind === 'owned' && decoded.value !== undefined : itemInheritsForce(item); -const routingEvidenceFrom = ( +const narrowingEvidenceFrom = ( items: readonly ResponsesInputItem[], locations: readonly ResponsesBlobLocation[], ): AffinityEvidence[] => { @@ -113,7 +113,7 @@ export const prepareResponsesAffinity = async ( const locations = await opaqueBlobLocations(payload.input, codec); return { - routingEvidence: routingEvidenceFrom(payload.input, locations), + narrowingEvidence: narrowingEvidenceFrom(payload.input, locations), payloadForCandidate: candidate => { const candidatePayload = structuredClone(payload); const byItem = Map.groupBy(locations, location => location.itemIndex); diff --git a/packages/gateway/src/data-plane/chat/responses/affinity/ingress_test.ts b/packages/gateway/src/data-plane/chat/responses/affinity/ingress_test.ts index 816c4c363e..4754338f4c 100644 --- a/packages/gateway/src/data-plane/chat/responses/affinity/ingress_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/affinity/ingress_test.ts @@ -13,13 +13,13 @@ const carrierDomain = (itemType: string, slot: string): string => `responses.${c const candidate = (upstream: string): ModelCandidate => { const base = stubModelCandidate(); return stubModelCandidate({ - provider: { ...base.provider, upstream }, + provider: { ...base.provider, upstreamId: upstream }, model: { id: 'model' }, }); }; const targetFor = (value: ModelCandidate): AffinityTarget => ({ - upstreamId: value.provider.upstream, + upstreamId: value.provider.upstreamId, modelId: value.model.id, ...(value.rules !== undefined ? { rules: value.rules } : {}), }); @@ -135,7 +135,7 @@ test('derives force routing from blob-less program state after the turn carrier' ], }, codec); - expect(prepared.routingEvidence).toEqual([ + expect(prepared.narrowingEvidence).toEqual([ { target: targetFor(candidateA), mode: 'prefer' }, { target: targetFor(candidateA), mode: 'force' }, ]); @@ -158,7 +158,7 @@ test('does not inherit force through a foreign program blob', async () => { ], }, codec); - expect(prepared.routingEvidence).toEqual([{ target: targetFor(candidateA), mode: 'prefer' }]); + expect(prepared.narrowingEvidence).toEqual([{ target: targetFor(candidateA), mode: 'prefer' }]); expect(prepared.payloadForCandidate(candidateA).input[0]).toMatchObject({ fingerprint: 'foreign' }); }); @@ -171,7 +171,7 @@ test('treats compaction_summary as force state across alias-rule variants', asyn const item = { type: 'compaction_summary', id: 'cmp_client', encrypted_content: carrier } as unknown as CanonicalResponsesPayload['input'][number]; const prepared = await prepareResponsesAffinity({ model: 'model', input: [item] }, codec); - expect(prepared.routingEvidence.map(evidence => evidence.mode)).toEqual(['prefer', 'force']); + expect(prepared.narrowingEvidence.map(evidence => evidence.mode)).toEqual(['prefer', 'force']); expect(prepared.payloadForCandidate({ ...candidateA, rules: {} }).input[0]).toMatchObject({ id: 'cmp_client', encrypted_content: 'opaque', @@ -186,12 +186,12 @@ test('keeps originless context compaction prefer-only while natural encrypted st encrypted_content: synthetic, } as unknown as CanonicalResponsesPayload['input'][number]; const syntheticPrepared = await prepareResponsesAffinity({ model: 'model', input: [originlessItem] }, codec); - expect(syntheticPrepared.routingEvidence).toEqual([{ target: targetFor(candidateA), mode: 'prefer' }]); + expect(syntheticPrepared.narrowingEvidence).toEqual([{ target: targetFor(candidateA), mode: 'prefer' }]); const natural = await codec.wrap('opaque', targetFor(candidateA), carrierDomain('context_compaction', 'encrypted_content')); const naturalPrepared = await prepareResponsesAffinity({ model: 'model', input: [{ ...originlessItem, encrypted_content: natural } as CanonicalResponsesPayload['input'][number]], }, codec); - expect(naturalPrepared.routingEvidence.map(evidence => evidence.mode)).toEqual(['prefer', 'force']); + expect(naturalPrepared.narrowingEvidence.map(evidence => evidence.mode)).toEqual(['prefer', 'force']); }); diff --git a/packages/gateway/src/data-plane/chat/responses/affinity/roundtrip_test.ts b/packages/gateway/src/data-plane/chat/responses/affinity/roundtrip_test.ts index eb756e9a48..a470be64a9 100644 --- a/packages/gateway/src/data-plane/chat/responses/affinity/roundtrip_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/affinity/roundtrip_test.ts @@ -16,7 +16,7 @@ import { stubModelCandidate } from '@floway-dev/test-utils'; const modelCandidate = (upstream: string) => { const base = stubModelCandidate(); return stubModelCandidate({ - provider: { ...base.provider, upstream }, + provider: { ...base.provider, upstreamId: upstream }, model: { id: 'model-a' }, }); }; @@ -59,7 +59,7 @@ test('affinity selects the route while item storage preserves the exact emitted }; const withAffinity = wrapResponsesAffinityEgress(source(), { codec, - affinity: { upstreamId: candidateA.provider.upstream, modelId: candidateA.model.id }, + affinity: { upstreamId: candidateA.provider.upstreamId, modelId: candidateA.model.id }, }); const client = wrapResponsesClientOutput(withAffinity, { store, @@ -79,7 +79,7 @@ test('affinity selects the route while item storage preserves the exact emitted await store.loadInputItems(input, input); const hydrated = hydrateResponsesPayload({ model: 'model-a', input }, store); const affinity = await prepareResponsesAffinity(hydrated.payload, codec); - expect(affinity.routingEvidence.map(evidence => evidence.mode)).toEqual(['prefer', 'force']); + expect(affinity.narrowingEvidence.map(evidence => evidence.mode)).toEqual(['prefer', 'force']); expect(affinity.payloadForCandidate(candidateA).input).toEqual([programOutput]); expect(affinity.payloadForCandidate(candidateB).input).toEqual([programOutput]); @@ -111,7 +111,7 @@ test('agent-message natural and originless nested carriers round-trip without ch let clientResponse: ResponsesResult | undefined; for await (const frame of wrapResponsesAffinityEgress(source(), { codec, - affinity: { upstreamId: candidate.provider.upstream, modelId: candidate.model.id }, + affinity: { upstreamId: candidate.provider.upstreamId, modelId: candidate.model.id }, })) if (frame.type === 'event' && frame.event.type === 'response.completed') clientResponse = frame.event.response; if (clientResponse === undefined) throw new Error('Expected completed client response'); @@ -145,7 +145,7 @@ test('compaction_summary carrier authenticates after alias canonicalization with let wrapped: string | undefined; for await (const frame of wrapResponsesAffinityEgress(source(), { codec, - affinity: { upstreamId: candidate.provider.upstream, modelId: candidate.model.id }, + affinity: { upstreamId: candidate.provider.upstreamId, modelId: candidate.model.id }, })) { if (frame.type === 'event' && frame.event.type === 'response.completed') { wrapped = (frame.event.response.output[0] as { encrypted_content?: string }).encrypted_content; @@ -155,7 +155,7 @@ test('compaction_summary carrier authenticates after alias canonicalization with const canonical = { type: 'compaction', id: 'cmp_public', encrypted_content: wrapped } as unknown as ResponsesInputItem; const prepared = await prepareResponsesAffinity({ model: 'model-a', input: [canonical] }, codec); - expect(prepared.routingEvidence.map(evidence => evidence.mode)).toEqual(['prefer', 'force']); + expect(prepared.narrowingEvidence.map(evidence => evidence.mode)).toEqual(['prefer', 'force']); expect(prepared.payloadForCandidate(candidate).input[0]).toMatchObject({ type: 'compaction', id: 'cmp_public', diff --git a/packages/gateway/src/data-plane/chat/responses/attempt.ts b/packages/gateway/src/data-plane/chat/responses/attempt.ts index 70e67f1fd2..9f88551b28 100644 --- a/packages/gateway/src/data-plane/chat/responses/attempt.ts +++ b/packages/gateway/src/data-plane/chat/responses/attempt.ts @@ -223,7 +223,7 @@ const providerResponsesResultToExecuteResult = async ( } const context = upstreamPerformanceContext(ctx, candidate, 'chat'); if (!providerResult.ok) { - return { ...(await readUpstreamApiError(providerResult.response, candidate.provider.upstream)), performance: context }; + return { ...(await readUpstreamApiError(providerResult.response, candidate.provider.upstreamId)), performance: context }; } return eventResult( syntheticEventsFromResult(providerResult.result), diff --git a/packages/gateway/src/data-plane/chat/responses/attempt_test.ts b/packages/gateway/src/data-plane/chat/responses/attempt_test.ts index fe06b1009c..f3729326c3 100644 --- a/packages/gateway/src/data-plane/chat/responses/attempt_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/attempt_test.ts @@ -9,7 +9,7 @@ import { TEST_RESPONSES_RETENTION_SECONDS, testResponsesStatePolicy } from './te import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; import type { StoredResponsesItem } from '../../../repo/types.ts'; -import { mockChatGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { initExternalResourceFetcher } from '@floway-dev/platform'; import type { ChatCompletionsPayload, ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; @@ -60,7 +60,7 @@ const makeCandidate = ( const upstream = 'up_test'; return { provider: { - upstream, + upstreamId: upstream, kind: 'custom', name: upstream, disabledPublicModelIds: [], @@ -168,7 +168,7 @@ test('generate treats a translated Responses payload as opaque to native affinit const carrier = await ctx.affinity.codec.wrap( undefined, { - upstreamId: candidate.provider.upstream, + upstreamId: candidate.provider.upstreamId, modelId: candidate.model.id, }, 'responses.reasoning.encrypted_content', @@ -285,7 +285,7 @@ test('generate defers role promotion until after translation to Chat Completions const endpoints = { chatCompletions: {} }; const candidate: ModelCandidate = { provider: { - upstream, + upstreamId: upstream, kind: 'custom', name: upstream, disabledPublicModelIds: [], @@ -440,7 +440,7 @@ test('generate inherits headers and injects external image loading across transl }); const candidate: ModelCandidate = { provider: { - upstream: 'up_test', kind: 'custom', name: 'up_test', + upstreamId: 'up_test', kind: 'custom', name: 'up_test', disabledPublicModelIds: [], modelPrefix: null, instance: messagesProvider, }, model: upstreamModel, @@ -541,7 +541,7 @@ test('generate seeds privatePayload before interceptors so the web-search shim r const carrier = await ctx.affinity.codec.wrap( undefined, { - upstreamId: candidate.provider.upstream, + upstreamId: candidate.provider.upstreamId, modelId: candidate.model.id, }, 'responses.reasoning.encrypted_content', diff --git a/packages/gateway/src/data-plane/chat/responses/client-output.ts b/packages/gateway/src/data-plane/chat/responses/client-output.ts index 4854038b5a..13db883fff 100644 --- a/packages/gateway/src/data-plane/chat/responses/client-output.ts +++ b/packages/gateway/src/data-plane/chat/responses/client-output.ts @@ -1,7 +1,8 @@ import { wrapResponsesAffinityEgress } from './affinity/egress.ts'; import { wrapResponsesClientOutput } from './items/output.ts'; import { createResponsesResponseId } from './response-id.ts'; -import type { ChatGatewayCtx, GatewayCtx } from '../shared/gateway-ctx.ts'; +import type { GatewayCtx } from '../../shared/gateway-ctx.ts'; +import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ResponsesStreamEvent } from '@floway-dev/protocols/responses'; diff --git a/packages/gateway/src/data-plane/chat/responses/errors.ts b/packages/gateway/src/data-plane/chat/responses/errors.ts index 4161c403e3..db8c45cb53 100644 --- a/packages/gateway/src/data-plane/chat/responses/errors.ts +++ b/packages/gateway/src/data-plane/chat/responses/errors.ts @@ -1,28 +1,10 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import type { ChatServeFailure } from '../shared/errors.ts'; +import { openAiErrorResult, type ChatServeFailure } from '../shared/errors.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ResponsesStreamEvent } from '@floway-dev/protocols/responses'; import type { ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; -// OpenAI error envelope. `param` / `code` reproduce OpenAI's native fields; a -// stored-item miss must byte-match OpenAI's own "not found" body — stateless -// clients (codex) compare the whole body verbatim. The envelope is -// gateway-synthesized — `source: 'gateway'` so the dump labels it as such. -const openAiErrorResult = ( - status: number, - message: string, - extra?: { readonly param: string; readonly code: string | null }, - performance?: PerformanceTelemetryContext, -): ExecuteResult> => ({ - type: 'api-error', - source: 'gateway', - status, - headers: new Headers({ 'content-type': 'application/json' }), - body: new TextEncoder().encode(JSON.stringify({ - error: { message, type: 'invalid_request_error', ...extra }, - })), - ...(performance ? { performance } : {}), -}); +export type ResponsesServeFailure = ChatServeFailure | { readonly kind: 'item-not-found'; readonly itemId: string }; // Caller-input violations discovered by translation or the source affinity // membrane share the Responses 400 envelope. `performance` retains candidate @@ -34,7 +16,7 @@ export const responsesInputErrorResult = ( openAiErrorResult(400, error.message, { param: error.param ?? 'input', code: null }, performance); export const renderResponsesFailure = ( - failure: ChatServeFailure, + failure: ResponsesServeFailure, ): ExecuteResult> => { switch (failure.kind) { case 'item-not-found': diff --git a/packages/gateway/src/data-plane/chat/responses/errors_test.ts b/packages/gateway/src/data-plane/chat/responses/errors_test.ts index 9eb6610524..b1367942d4 100644 --- a/packages/gateway/src/data-plane/chat/responses/errors_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/errors_test.ts @@ -1,14 +1,21 @@ import { test } from 'vitest'; -import { responsesInputErrorResult } from './errors.ts'; +import { responsesInputErrorResult, type ResponsesServeFailure } from './errors.ts'; +import { throwChatServeFailure, tryCatchChatServeFailure } from '../shared/errors.ts'; import type { ApiErrorResult } from '@floway-dev/provider'; -import { assertEquals } from '@floway-dev/test-utils'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; import { TranslatorInputError } from '@floway-dev/translate'; const apiErrorOf = (result: ReturnType): ApiErrorResult => result as ApiErrorResult; const bodyOf = (result: ReturnType): unknown => JSON.parse(new TextDecoder().decode(apiErrorOf(result).body)); +test('round-trips the Responses-only item-not-found failure through throw/catch', () => { + const failure: ResponsesServeFailure = { kind: 'item-not-found', itemId: 'msg_abc' }; + const error = assertThrows(() => throwChatServeFailure(failure)); + assertEquals(tryCatchChatServeFailure(error), failure); +}); + test('responsesInputErrorResult renders an OpenAI 400 invalid_request_error envelope with default `input` param', () => { const result = responsesInputErrorResult( new TranslatorInputError("Invalid input item type 'image_generation_call'."), diff --git a/packages/gateway/src/data-plane/chat/responses/http.ts b/packages/gateway/src/data-plane/chat/responses/http.ts index 5c885e2237..54044fd84c 100644 --- a/packages/gateway/src/data-plane/chat/responses/http.ts +++ b/packages/gateway/src/data-plane/chat/responses/http.ts @@ -5,10 +5,11 @@ import { PreviousResponseNotFoundError } from './serve-prep.ts'; import { responsesServe } from './serve.ts'; import type { AuthedContext } from '../../../middleware/auth.ts'; import { backgroundSchedulerFromContext } from '../../../runtime/background.ts'; +import { createGatewayCtxFromHono, finalizeGatewayResponse, type GatewayCtx } from '../../shared/gateway-ctx.ts'; import { inboundHeadersForUpstream } from '../../shared/inbound-headers.ts'; +import { readRequestBody, takeRequestBody, type RequestBody } from '../../shared/request-body.ts'; import { settle } from '../../shared/telemetry/settle.ts'; -import { createChatGatewayCtxFromHono, createGatewayCtxFromHono, finalizeGatewayResponse, type ChatGatewayCtx, type GatewayCtx } from '../shared/gateway-ctx.ts'; -import { readRequestBody, takeRequestBody, type RequestBody } from '../shared/request-body.ts'; +import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerModelsUnavailableResponse } from '../shared/upstream-models-error.ts'; import type { CanonicalResponsesPayload, ResponsesRequestPayload } from '@floway-dev/protocols/responses'; import { internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/responses/http_test.ts b/packages/gateway/src/data-plane/chat/responses/http_test.ts index b33a48956c..a3b4ad07e1 100644 --- a/packages/gateway/src/data-plane/chat/responses/http_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/http_test.ts @@ -21,8 +21,8 @@ interface QueuedResolution { } const resolutionsQueue: QueuedResolution[] = []; const lastSeenModel: { value: string | null } = { value: null }; -vi.mock('../../providers/registry.ts', async importOriginal => { - const original = await importOriginal(); +vi.mock('../../providers/resolution.ts', async importOriginal => { + const original = await importOriginal(); return { ...original, enumerateModelCandidates: vi.fn(async ({ model }: { model: string }) => { @@ -131,7 +131,7 @@ const makeCandidate = (overrides: { }); return { provider: { - upstream, + upstreamId: upstream, kind: 'custom', name: upstream, disabledPublicModelIds: [], diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/apply-role-compatibility_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/apply-role-compatibility_test.ts index 097d6d2941..c7f1d3e5e3 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/apply-role-compatibility_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/apply-role-compatibility_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { withRoleCompatibilityApplied } from './apply-role-compatibility.ts'; import type { ResponsesInvocation } from './types.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import { doneFrame } from '@floway-dev/protocols/common'; import type { ResponsesInputItem } from '@floway-dev/protocols/responses'; import { eventResult, type FlagId } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/compact-shim_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/compact-shim_test.ts index 8d72622b6f..20ae3167e7 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/compact-shim_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/compact-shim_test.ts @@ -3,7 +3,7 @@ import { test } from 'vitest'; import { SUMMARY_PREFIX, expandShimCompactionItems, withResponsesCompactShim } from './compact-shim.ts'; import type { ResponsesInvocation } from './types.ts'; import { encodeBase64UrlJson } from '../../../../shared/base64url-json.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { collectResponsesProtocolEventsToResult, type CanonicalResponsesPayload, type ResponsesInputItem, type ResponsesPayload, type ResponsesResult, type ResponsesStreamEvent } from '@floway-dev/protocols/responses'; import { eventResult, type ExecuteResult } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/disable-reasoning-on-forced-tool-choice.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/disable-reasoning-on-forced-tool-choice.ts index 48b9123f66..66ad9fc5ef 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/disable-reasoning-on-forced-tool-choice.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/disable-reasoning-on-forced-tool-choice.ts @@ -17,7 +17,7 @@ const hasForcedToolChoice = (payload: ResponsesPayload): boolean => { return true; }; -export const withReasoningDisabledOnForcedToolChoice: ResponsesInterceptor = async (ctx, _request, run) => { +export const withReasoningDisabledOnForcedToolChoice: ResponsesInterceptor = async (ctx, _gatewayCtx, run) => { if (!providerModelOf(ctx.candidate).enabledFlags.has('disable-reasoning-on-forced-tool-choice')) return await run(); if (!hasForcedToolChoice(ctx.payload)) return await run(); ctx.payload = { ...ctx.payload, reasoning: { effort: 'none' } }; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/disable-reasoning-on-forced-tool-choice_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/disable-reasoning-on-forced-tool-choice_test.ts index 1174d183d1..de40ee4dd8 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/disable-reasoning-on-forced-tool-choice_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/disable-reasoning-on-forced-tool-choice_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { withReasoningDisabledOnForcedToolChoice } from './disable-reasoning-on-forced-tool-choice.ts'; import type { ResponsesInvocation } from './types.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import { doneFrame } from '@floway-dev/protocols/common'; import type { CanonicalResponsesPayload } from '@floway-dev/protocols/responses'; import { eventResult, type FlagId } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/index.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/index.ts index b88465faa9..fa41737747 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/index.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/index.ts @@ -7,7 +7,7 @@ import { imageGenerationServerTool } from './server-tools/image-generation.ts'; import { webSearchServerTool } from './server-tools/web-search.ts'; import { withPromptCacheKeyStripped } from './strip-prompt-cache-key.ts'; import type { ResponsesInterceptor } from './types.ts'; -import { withVendorDeepseekResponsesNormalize } from './vendor-deepseek-normalize.ts'; +import { withVendorDeepSeekResponsesNormalize } from './vendor-deepseek-normalize.ts'; import { withVendorQwenResponsesNormalize } from './vendor-qwen-normalize.ts'; // Unified Responses interceptor list. All entries are attached to every @@ -50,6 +50,6 @@ export const responsesInterceptors: readonly ResponsesInterceptor[] = [ withReasoningDisabledOnForcedToolChoice, withRoleCompatibilityApplied, withPromptCacheKeyStripped, - withVendorDeepseekResponsesNormalize, + withVendorDeepSeekResponsesNormalize, withVendorQwenResponsesNormalize, ]; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/retry-cyber-policy.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/retry-cyber-policy.ts index 975beaa39b..e8afb4bcb5 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/retry-cyber-policy.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/retry-cyber-policy.ts @@ -1,7 +1,7 @@ import type { ResponsesInterceptor } from './types.ts'; import { isObjectLike } from '../../../../shared/json-helpers.ts'; -import type { GatewayCtx } from '../../shared/gateway-ctx.ts'; +import type { GatewayCtx } from '../../../shared/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ResponsesStreamEvent } from '@floway-dev/protocols/responses'; import { providerModelOf } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/retry-cyber-policy_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/retry-cyber-policy_test.ts index ffbd9e8c9b..13a63bde68 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/retry-cyber-policy_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/retry-cyber-policy_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { withCyberPolicyRetried } from './retry-cyber-policy.ts'; import type { ResponsesInvocation } from './types.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ChatGatewayCtx } from '../../shared/gateway-ctx.ts'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { CanonicalResponsesPayload, ResponsesResult, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tool-shim.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tool-shim.ts index 43a8f8573e..26607dd925 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tool-shim.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tool-shim.ts @@ -1,8 +1,8 @@ import { jsonrepair } from 'jsonrepair'; import type { ResponsesInterceptor, ResponsesInvocation } from './types.ts'; +import { truncatePreservingCodePoints } from '../../../shared/text.ts'; import type { ChatGatewayCtx } from '../../shared/gateway-ctx.ts'; -import { truncatePreservingCodePoints } from '../../shared/text.ts'; import type { StatefulResponsesStore } from '../items/store.ts'; import type { InterceptorRun } from '@floway-dev/interceptor'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tool-shim_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tool-shim_test.ts index 8c889c9b92..855417f03b 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tool-shim_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tool-shim_test.ts @@ -16,13 +16,13 @@ import { SHIM_TOOL_NAME, webSearchServerTool } from './server-tools/web-search.t import type { ResponsesInterceptor, ResponsesInvocation } from './types.ts'; import { getRepo, initRepo } from '../../../../repo/index.ts'; import { InMemoryRepo } from '../../../../repo/memory.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import { resolveAlphaSearchDispatcher } from '../../../tools/web-search/alpha-search/upstream.ts'; import type { AlphaSearchDispatcher } from '../../../tools/web-search/alpha-search/upstream.ts'; import { resolveConfiguredWebSearchProvider } from '../../../tools/web-search/provider.ts'; import type { ConfiguredWebSearchProvider, - SearchConfig, + WebSearchConfig, WebSearchFetchPageRequest, WebSearchFetchPageResult, WebSearchProvider, @@ -184,8 +184,8 @@ const mkReasoningDone = (outputIndex: number, reasoningId: string): ProtocolFram // We replace `resolveConfiguredWebSearchProvider` because real provider // construction (`createTavilyWebSearchProvider` etc.) would otherwise -// pull in network-hitting backend impls. Tests insert a SearchConfig -// row through the in-memory repo so `loadSearchConfig` returns +// pull in network-hitting backend impls. Tests insert a WebSearchConfig +// row through the in-memory repo so `loadWebSearchConfig` returns // non-default values; the mock then ignores the config and returns a // test stub. Tests that need a specific configured state set // `mockResolveConfigured.mockReturnValue(...)` per call. @@ -285,20 +285,20 @@ const mockResolveConfigured = vi.mocked(resolveConfiguredWebSearchProvider); const mockResolveAlpha = vi.mocked(resolveAlphaSearchDispatcher); // Seed a per-test InMemoryRepo and a default tavily search config so -// `loadSearchConfig()` returns a non-default value. The actual provider +// `loadWebSearchConfig()` returns a non-default value. The actual provider // construction is short-circuited by the module mock above; tests // override `mockResolveConfigured` to point at a stub backend. beforeEach(() => { mockResolveAlpha.mockReset(); const repo = new InMemoryRepo(); initRepo(repo); - void repo.searchConfig.save({ + void repo.webSearchConfig.save({ provider: 'tavily', tavily: { apiKey: 'test-key' }, microsoftGrounding: { apiKey: '' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, - } satisfies SearchConfig); + } satisfies WebSearchConfig); }); const makeStubDeps = (overrides: DepsOverrides = {}): { @@ -3942,13 +3942,13 @@ test('responses target with flag on: function_call_output is plain-text formatte test('responses target with OpenAI passthrough uses the selected alpha search dispatcher', async () => { makeStubDeps(); - await getRepo().searchConfig.save({ + await getRepo().webSearchConfig.save({ provider: 'tavily', tavily: { apiKey: 'test-key' }, microsoftGrounding: { apiKey: '' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: true, upstreamId: 'up_codex', model: 'gpt-search' }, - } satisfies SearchConfig); + } satisfies WebSearchConfig); const call = vi.fn(async () => new Response(JSON.stringify({ encrypted_output: null, output: 'alpha output', diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation-integration_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation-integration_test.ts index 82776e6b6c..b3928eb69d 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation-integration_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation-integration_test.ts @@ -2,7 +2,7 @@ import { beforeEach, test, vi } from 'vitest'; import { initRepo } from '../../../../../repo/index.ts'; import { InMemoryRepo } from '../../../../../repo/memory.ts'; -import { mockChatGatewayCtx } from '../../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../../test-utils/gateway-ctx.ts'; import type { ResponsesInvocation } from '../types.ts'; import { createInMemoryImageProcessor, initExternalResourceFetcher, initImageProcessor } from '@floway-dev/platform'; import { eventFrame } from '@floway-dev/protocols/common'; @@ -39,7 +39,7 @@ let repo: InMemoryRepo; const defaultCandidates = vi.hoisted(() => () => [{ provider: { - upstream: 'u', + upstreamId: 'u', kind: 'custom', name: 'mock-image', disabledPublicModelIds: [], @@ -74,7 +74,7 @@ const defaultCandidates = vi.hoisted(() => () => [{ fetcher: (request: Request) => fetch(request), }]); -vi.mock('../../../../providers/registry.ts', () => ({ +vi.mock('../../../../providers/resolution.ts', () => ({ enumerateModelCandidates: vi.fn(async () => { const override = stub.nextResolutionOverride; if (override !== null) { diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation.ts index 911ce272f2..3cad913342 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation.ts @@ -1,10 +1,10 @@ import { sleep } from '../../../../../shared/sleep.ts'; -import { enumerateModelCandidates } from '../../../../providers/registry.ts'; +import { enumerateModelCandidates } from '../../../../providers/resolution.ts'; import { appendFailedUpstreams } from '../../../../shared/failed-upstreams.ts'; +import { stampUpstreamCallStart, type AttemptState } from '../../../../shared/gateway-ctx.ts'; import { recordPerformance, type PerformanceTelemetryContext } from '../../../../shared/telemetry/performance.ts'; import { recordTokenUsage, tokenUsageFromImagesBody } from '../../../../shared/telemetry/usage.ts'; import { createExternalImageFetcher, type ExternalImageFetchResult } from '../../../shared/external-image-loader.ts'; -import { stampUpstreamCallStart, type AttemptState } from '../../../shared/gateway-ctx.ts'; import type { ServerToolLifecycleEvent, ServerToolOutputItem, ServerToolRegistration, ServerToolTerminal } from '../server-tool-shim.ts'; import { dimensionsFromBytes, getImageProcessor, type BackgroundScheduler } from '@floway-dev/platform'; import { parseSSEStream } from '@floway-dev/protocols/common'; @@ -911,7 +911,7 @@ const recordImageUsage = (state: ShimState, provider: Provider, model: ProviderM if (usage === null) return; const promise = recordTokenUsage(state.apiKeyId, { model: model.id, - upstream: provider.upstream, + upstream: provider.upstreamId, modelKey, pricing: model.pricing ?? null, }, usage).catch((error: unknown) => { @@ -1250,7 +1250,7 @@ const streamImageGeneration = ( const perfContext: PerformanceTelemetryContext = { keyId: state.apiKeyId, model: model.id, - upstream: provider.upstream, + upstream: provider.upstreamId, operation: isEdit ? 'image_edit' : 'image_generation', runtimeLocation: state.runtimeLocation, }; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation_test.ts index fd827dbae0..37a65d8ad4 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation_test.ts @@ -20,7 +20,7 @@ import { } from './image-generation.ts'; import { initRepo } from '../../../../../repo/index.ts'; import { InMemoryRepo } from '../../../../../repo/memory.ts'; -import { mockChatGatewayCtx } from '../../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../../test-utils/gateway-ctx.ts'; import type { ResponsesInvocation } from '../types.ts'; import { initExternalResourceFetcher } from '@floway-dev/platform'; import type { CanonicalResponsesPayload, ResponsesInputImage, ResponsesInputItem, ResponsesPayload, ResponsesTool } from '@floway-dev/protocols/responses'; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search.ts index 8336534254..41c323b78e 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search.ts @@ -1,6 +1,8 @@ import { shortId } from '../../../../../shared/short-id.ts'; +import { truncatePreservingCodePoints } from '../../../../shared/text.ts'; import { executeAlphaSearch } from '../../../../tools/web-search/alpha-search/execution.ts'; import { resolveAlphaSearchDispatcher } from '../../../../tools/web-search/alpha-search/upstream.ts'; +import { loadWebSearchConfig } from '../../../../tools/web-search/config.ts'; import { normalizeDomainEntry } from '../../../../tools/web-search/domain-normalize.ts'; import { actionSearchQueries, @@ -21,9 +23,7 @@ import { type WebSearchOperation, } from '../../../../tools/web-search/operations.ts'; import { resolveConfiguredWebSearchProvider } from '../../../../tools/web-search/provider.ts'; -import { loadSearchConfig } from '../../../../tools/web-search/search-config.ts'; import type { ConfiguredWebSearchProvider } from '../../../../tools/web-search/types.ts'; -import { truncatePreservingCodePoints } from '../../../shared/text.ts'; import { type ServerToolLoopState, type ServerToolOutputItem, type ServerToolRegistration } from '../server-tool-shim.ts'; import type { ResponsesFunctionTool, ResponsesFunctionToolCallItem, ResponsesHostedTool, ResponsesInputItem, ResponsesOutputWebSearchCall, ResponsesTool, ResponsesWebSearchAction } from '@floway-dev/protocols/responses'; import { createRandomResponsesItemId, WEB_SEARCH_HOSTED_TYPE_NAMES } from '@floway-dev/protocols/responses'; @@ -560,14 +560,14 @@ export const webSearchServerTool: ServerToolRegistration = async (invocation, ga } const { filters } = prepared; - const searchConfig = await loadSearchConfig(); + const webSearchConfig = await loadWebSearchConfig(); const includeArray = Array.isArray(invocation.payload.include) ? invocation.payload.include : []; let configuredProvider: Promise | undefined; const state: ShimState = { filters, pageCache: new Map(), getProvider: () => { - configuredProvider ??= Promise.resolve(resolveConfiguredWebSearchProvider(searchConfig)); + configuredProvider ??= Promise.resolve(resolveConfiguredWebSearchProvider(webSearchConfig)); return configuredProvider; }, apiKeyId: gatewayCtx.apiKeyId, @@ -575,9 +575,9 @@ export const webSearchServerTool: ServerToolRegistration = async (invocation, ga includeSearchActionSources: includeArray.includes('web_search_call.action.sources'), ...(gatewayCtx.abortSignal !== undefined ? { signal: gatewayCtx.abortSignal } : {}), }; - if (searchConfig.passthroughOpenAiSearch.enabled) { + if (webSearchConfig.passthroughOpenAiSearch.enabled) { const dispatcher = resolveAlphaSearchDispatcher({ - config: searchConfig.passthroughOpenAiSearch, + config: webSearchConfig.passthroughOpenAiSearch, upstreamIds: gatewayCtx.upstreamIds, scheduler: gatewayCtx.backgroundScheduler, runtimeLocation: gatewayCtx.runtimeLocation, diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search_test.ts index 810502a355..98d3f66eb3 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search_test.ts @@ -11,9 +11,8 @@ import { type WebSearchCallPrivatePayload, } from './web-search.ts'; import { findMatches, formatMatches, isUrlAllowed, parseWebSearchOperations, type WebSearchOperation } from '../../../../tools/web-search/operations.ts'; -import { truncatePreservingCodePoints } from '../../../shared/text.ts'; import type { ResponsesTool, ResponsesWebSearchAction, ResponsesWebSearchResult } from '@floway-dev/protocols/responses'; -import { assert, assertEquals, assertFalse } from '@floway-dev/test-utils'; +import { assert, assertEquals } from '@floway-dev/test-utils'; // ── Shim call argument parsing (parseWebSearchOperations) ── @@ -180,37 +179,6 @@ test('synthesizeWebSearchCallId produces unique canonical web-search ids', () => assert(a !== b); }); -// ── truncatePreservingCodePoints boundary cases ─────────────────────── - -test('truncatePreservingCodePoints: empty string is a no-op', () => { - assertEquals(truncatePreservingCodePoints('', 512), ''); -}); - -test('truncatePreservingCodePoints: string of exactly `max` length is unchanged (no ellipsis injected)', () => { - const s = 'a'.repeat(512); - assertEquals(truncatePreservingCodePoints(s, 512), s); -}); - -test('truncatePreservingCodePoints: high surrogate at position max-1 walks back to drop the orphan', () => { - // U+1F600 (grinning face) is a surrogate pair: high D83D + low DE00. - // Place the high surrogate at index max-1 (= 9) so a naive - // slice(0, max) would retain the orphan high surrogate. The helper - // must walk back one code unit and slice at max-1 (= 9), producing - // a 9-char string with no orphan. - const prefix = 'a'.repeat(9); // chars 0..8 - const emoji = '😀'; // chars 9..10 → high at 9, low at 10 - const suffix = 'b'; - const input = prefix + emoji + suffix; // length 12 - const out = truncatePreservingCodePoints(input, 10); - assertEquals(out.length, 9); - assertEquals(out, prefix); - // Sanity: no orphan high surrogate in the output. - for (let i = 0; i < out.length; i++) { - const code = out.charCodeAt(i); - assertFalse(code >= 0xD800 && code <= 0xDBFF); - } -}); - // ── Backend dispatch helpers (isUrlAllowed / findMatches / formatMatches) ── test('isUrlAllowed returns true when no filters set', () => { diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/strip-prompt-cache-key.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/strip-prompt-cache-key.ts index 2ccc2a5f5a..97dc36d20a 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/strip-prompt-cache-key.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/strip-prompt-cache-key.ts @@ -6,7 +6,7 @@ import { providerModelOf } from '@floway-dev/provider'; // request reaches the terminal. OpenAI-native and truly OpenAI-compatible // Responses upstreams accept it for prefix-cache attribution, so removal only // happens under the flag. -export const withPromptCacheKeyStripped: ResponsesInterceptor = async (ctx, _request, run) => { +export const withPromptCacheKeyStripped: ResponsesInterceptor = async (ctx, _gatewayCtx, run) => { if (!providerModelOf(ctx.candidate).enabledFlags.has('strip-prompt-cache-key')) return await run(); if (ctx.payload.prompt_cache_key === undefined) return await run(); const { prompt_cache_key: _stripped, ...rest } = ctx.payload; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/strip-prompt-cache-key_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/strip-prompt-cache-key_test.ts index 40237f753a..2c05c8294f 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/strip-prompt-cache-key_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/strip-prompt-cache-key_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { withPromptCacheKeyStripped } from './strip-prompt-cache-key.ts'; import type { ResponsesInvocation } from './types.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import { doneFrame } from '@floway-dev/protocols/common'; import type { CanonicalResponsesPayload } from '@floway-dev/protocols/responses'; import { eventResult, type FlagId } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-deepseek-normalize.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-deepseek-normalize.ts index d20a9c8a87..6efa2c2b80 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-deepseek-normalize.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-deepseek-normalize.ts @@ -22,20 +22,20 @@ import type { ResponsesInterceptor } from './types.ts'; import type { CanonicalResponsesPayload } from '@floway-dev/protocols/responses'; import { providerModelOf } from '@floway-dev/provider'; -interface DeepseekDisableField { +interface DeepSeekDisableField { thinking?: { type: 'disabled' }; } -type CanonicalResponsesPayloadWithDeepseekDisable = Omit & DeepseekDisableField; +type CanonicalResponsesPayloadWithDeepSeekDisable = Omit & DeepSeekDisableField; const stripCanonicalReasoningSentinel = (payload: CanonicalResponsesPayload): CanonicalResponsesPayload => { if (payload.reasoning?.effort !== 'none') return payload; const { reasoning: _stripped, ...rest } = payload; - const out: CanonicalResponsesPayloadWithDeepseekDisable = { ...rest, thinking: { type: 'disabled' } }; + const out: CanonicalResponsesPayloadWithDeepSeekDisable = { ...rest, thinking: { type: 'disabled' } }; return out as CanonicalResponsesPayload; }; -export const withVendorDeepseekResponsesNormalize: ResponsesInterceptor = async (ctx, _request, run) => { +export const withVendorDeepSeekResponsesNormalize: ResponsesInterceptor = async (ctx, _gatewayCtx, run) => { if (!providerModelOf(ctx.candidate).enabledFlags.has('vendor-deepseek')) return await run(); ctx.payload = stripCanonicalReasoningSentinel(ctx.payload); diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-deepseek-normalize_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-deepseek-normalize_test.ts index 35133ef61a..e062a1157b 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-deepseek-normalize_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-deepseek-normalize_test.ts @@ -1,8 +1,8 @@ import { test } from 'vitest'; import type { ResponsesInvocation } from './types.ts'; -import { withVendorDeepseekResponsesNormalize } from './vendor-deepseek-normalize.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { withVendorDeepSeekResponsesNormalize } from './vendor-deepseek-normalize.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import { doneFrame } from '@floway-dev/protocols/common'; import type { CanonicalResponsesPayload } from '@floway-dev/protocols/responses'; import { eventResult, type FlagId } from '@floway-dev/provider'; @@ -35,7 +35,7 @@ test("vendor-deepseek translates canonical reasoning.effort: 'none' into top-lev reasoning: { effort: 'none' }, }); - await withVendorDeepseekResponsesNormalize(input, stubCtx, okEvents); + await withVendorDeepSeekResponsesNormalize(input, stubCtx, okEvents); const out = input.payload as unknown as Record; assertEquals(out.reasoning, undefined); @@ -49,7 +49,7 @@ test('vendor-deepseek leaves a real reasoning.effort value untouched (only the n reasoning: { effort: 'high' }, }); - await withVendorDeepseekResponsesNormalize(input, stubCtx, okEvents); + await withVendorDeepSeekResponsesNormalize(input, stubCtx, okEvents); assertEquals(input.payload.reasoning, { effort: 'high' }); const out = input.payload as unknown as Record; @@ -59,7 +59,7 @@ test('vendor-deepseek leaves a real reasoning.effort value untouched (only the n test('vendor-deepseek early-returns when its flag is not set on the candidate', async () => { const input = invocation({ model: 'deepseek-reasoner', input: [{ type: 'message', role: 'user', content: 'hi' }], reasoning: { effort: 'none' } }, new Set()); - await withVendorDeepseekResponsesNormalize(input, stubCtx, okEvents); + await withVendorDeepSeekResponsesNormalize(input, stubCtx, okEvents); assertEquals(input.payload.reasoning, { effort: 'none' }); const out = input.payload as unknown as Record; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-qwen-normalize.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-qwen-normalize.ts index 02c86b3927..4ceb2d61ee 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-qwen-normalize.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-qwen-normalize.ts @@ -17,7 +17,7 @@ import type { ResponsesInterceptor } from './types.ts'; import type { CanonicalResponsesPayload } from '@floway-dev/protocols/responses'; import { providerModelOf } from '@floway-dev/provider'; -export const withVendorQwenResponsesNormalize: ResponsesInterceptor = async (ctx, _request, run) => { +export const withVendorQwenResponsesNormalize: ResponsesInterceptor = async (ctx, _gatewayCtx, run) => { if (!providerModelOf(ctx.candidate).enabledFlags.has('vendor-qwen')) return await run(); if (ctx.payload.reasoning?.effort === 'none') { diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-qwen-normalize_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-qwen-normalize_test.ts index c8e8a18dd1..bf92f8a5f2 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-qwen-normalize_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/vendor-qwen-normalize_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import type { ResponsesInvocation } from './types.ts'; import { withVendorQwenResponsesNormalize } from './vendor-qwen-normalize.ts'; -import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import { doneFrame } from '@floway-dev/protocols/common'; import type { CanonicalResponsesPayload } from '@floway-dev/protocols/responses'; import { eventResult, type FlagId } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/responses/items/identity.ts b/packages/gateway/src/data-plane/chat/responses/items/identity.ts index 24cd93b7ef..a55b096005 100644 --- a/packages/gateway/src/data-plane/chat/responses/items/identity.ts +++ b/packages/gateway/src/data-plane/chat/responses/items/identity.ts @@ -5,7 +5,7 @@ export const responsesItemId = (item: object): string | null => { return typeof id === 'string' && id.length > 0 ? id : null; }; -export const hashResponsesItemContent = async (item: unknown): Promise => +export const hashResponsesItem = async (item: unknown): Promise => await hashResponsesJson(item); export const createResponsesStorageKey = (): string => { diff --git a/packages/gateway/src/data-plane/chat/responses/items/identity_test.ts b/packages/gateway/src/data-plane/chat/responses/items/identity_test.ts index 72865deb35..68911b78f8 100644 --- a/packages/gateway/src/data-plane/chat/responses/items/identity_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/items/identity_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { createResponsesStorageKey, hashResponsesItemContent, responsesItemId } from './identity.ts'; +import { createResponsesStorageKey, hashResponsesItem, responsesItemId } from './identity.ts'; test('reads arbitrary non-empty item ids without format filtering', () => { expect(responsesItemId({ id: 'raw/provider:id' })).toBe('raw/provider:id'); @@ -16,9 +16,9 @@ test('creates collision-resistant internal keys for idless stored inputs', () => expect(second).not.toBe(first); }); -test('content hashing includes the item id', async () => { - const first = await hashResponsesItemContent({ type: 'message', id: 'msg_a', role: 'user', content: 'same' }); - const second = await hashResponsesItemContent({ type: 'message', id: 'msg_b', role: 'user', content: 'same' }); +test('item hashing includes the item id', async () => { + const first = await hashResponsesItem({ type: 'message', id: 'msg_a', role: 'user', content: 'same' }); + const second = await hashResponsesItem({ type: 'message', id: 'msg_b', role: 'user', content: 'same' }); expect(first).not.toBe(second); }); diff --git a/packages/gateway/src/data-plane/chat/responses/items/output.ts b/packages/gateway/src/data-plane/chat/responses/items/output.ts index a4a61a4473..9c6df167cb 100644 --- a/packages/gateway/src/data-plane/chat/responses/items/output.ts +++ b/packages/gateway/src/data-plane/chat/responses/items/output.ts @@ -1,4 +1,4 @@ -import { hashResponsesItemContent, responsesItemId } from './identity.ts'; +import { hashResponsesItem, responsesItemId } from './identity.ts'; import type { StatefulResponsesStore } from './store.ts'; import type { StoredResponsesItem } from '../../../../repo/types.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -36,7 +36,7 @@ export const wrapResponsesClientOutput = async function* ( item, ...(privatePayload !== undefined ? { private: privatePayload } : {}), }, - itemHash: await hashResponsesItemContent(item), + itemHash: await hashResponsesItem(item), refreshedAt: Date.now(), }; return row; diff --git a/packages/gateway/src/data-plane/chat/responses/items/store.ts b/packages/gateway/src/data-plane/chat/responses/items/store.ts index 81f23dd360..8ac7ef01c0 100644 --- a/packages/gateway/src/data-plane/chat/responses/items/store.ts +++ b/packages/gateway/src/data-plane/chat/responses/items/store.ts @@ -1,4 +1,4 @@ -import { createResponsesStorageKey, hashResponsesItemContent, responsesItemId } from './identity.ts'; +import { createResponsesStorageKey, hashResponsesItem, responsesItemId } from './identity.ts'; import { getRepo } from '../../../../repo/index.ts'; import { assertSameStoredResponsesItem, cloneStoredResponsesItem, cloneStoredResponsesSnapshot, compareResponsesItemsByFreshness, scopedResponsesKey } from '../../../../repo/responses-clone.ts'; import { quantizeResponsesRefreshedAt, responsesStateCutoff } from '../../../../repo/responses-retention.ts'; @@ -46,7 +46,7 @@ export interface StatefulResponsesStore { export class LayeredStatefulResponsesStore implements StatefulResponsesStore { private readonly loadedItems = new Map(); - private readonly loadedByContentHash = new Map(); + private readonly loadedByItemHash = new Map(); private readonly stagedInputItemIds: string[] = []; private previousSnapshotItemIds: string[] = []; private readonly committedItemIds = new Set(); @@ -101,7 +101,7 @@ export class LayeredStatefulResponsesStore implements StatefulResponsesStore { for (const item of this.writesState ? inputItemsToStage : []) { if (item.type === 'item_reference' || item.type === 'compaction_trigger') continue; if (responsesItemId(item) !== null) continue; - itemHashes.add(await hashResponsesItemContent(item)); + itemHashes.add(await hashResponsesItem(item)); } await this.loadItems({ ids: [...ids], itemHashes: [...itemHashes] }); } @@ -200,7 +200,7 @@ export class LayeredStatefulResponsesStore implements StatefulResponsesStore { id, apiKeyId: this.apiKeyId, payload: { item }, - itemHash: await hashResponsesItemContent(item), + itemHash: await hashResponsesItem(item), refreshedAt: quantizeResponsesRefreshedAt(Date.now()), }; this.stagedInputItemIds.push(id); @@ -208,8 +208,8 @@ export class LayeredStatefulResponsesStore implements StatefulResponsesStore { return; } - const itemHash = await hashResponsesItemContent(item); - const existing = this.loadedByContentHash.get(itemHash); + const itemHash = await hashResponsesItem(item); + const existing = this.loadedByItemHash.get(itemHash); if (existing !== undefined) { this.stagedInputItemIds.push(existing.id); return; @@ -231,9 +231,9 @@ export class LayeredStatefulResponsesStore implements StatefulResponsesStore { const existing = this.loadedItems.get(cloned.id); if (existing !== undefined && existing.refreshedAt >= cloned.refreshedAt) return; this.loadedItems.set(cloned.id, cloned); - const byHash = this.loadedByContentHash.get(cloned.itemHash); + const byHash = this.loadedByItemHash.get(cloned.itemHash); if (byHash === undefined || compareResponsesItemsByFreshness(cloned, byHash) < 0) { - this.loadedByContentHash.set(cloned.itemHash, cloned); + this.loadedByItemHash.set(cloned.itemHash, cloned); } } @@ -263,12 +263,12 @@ export class RepoStatefulResponsesBacking implements StatefulResponsesBacking { } async lookupItems(query: StatefulResponsesItemLookup): Promise { - const [byId, byContentHash] = await Promise.all([ + const [byId, byItemHash] = await Promise.all([ this.getRepo().responsesItems.lookupMany(query.apiKeyId, query.ids, this.earliestVisibleCutoff), this.getRepo().responsesItems.lookupManyByItemHash(query.apiKeyId, query.itemHashes, this.earliestVisibleCutoff), ]); const rows = new Map(); - for (const row of [...byId, ...byContentHash]) rows.set(scopedResponsesKey(row.apiKeyId, row.id), row); + for (const row of [...byId, ...byItemHash]) rows.set(scopedResponsesKey(row.apiKeyId, row.id), row); return [...rows.values()]; } diff --git a/packages/gateway/src/data-plane/chat/responses/items/store_test.ts b/packages/gateway/src/data-plane/chat/responses/items/store_test.ts index 7c1689e4e9..24cfbda7d5 100644 --- a/packages/gateway/src/data-plane/chat/responses/items/store_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/items/store_test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; -import { hashResponsesItemContent } from './identity.ts'; +import { hashResponsesItem } from './identity.ts'; import { createNonResponsesSourceStore, createResponsesHttpStore, createResponsesWsSession } from './store.ts'; import { initRepo } from '../../../../repo/index.ts'; import { InMemoryRepo } from '../../../../repo/memory.ts'; @@ -102,7 +102,7 @@ describe('StatefulResponsesStore', () => { await store.persistOutputItem(output); await store.commitSnapshot('resp_compact', 'replace', [output.id]); - expect(await repo.responsesItems.lookupManyByItemHash('key-a', [await hashResponsesItemContent(input)], 0)).toEqual([]); + expect(await repo.responsesItems.lookupManyByItemHash('key-a', [await hashResponsesItem(input)], 0)).toEqual([]); expect((await repo.responsesSnapshots.lookup('key-a', 'resp_compact', 0))?.itemIds).toEqual([output.id]); }); @@ -214,14 +214,14 @@ describe('StatefulResponsesStore', () => { id: directInput.id, apiKeyId: 'key-a', payload: { item: directInput }, - itemHash: await hashResponsesItemContent(directInput), + itemHash: await hashResponsesItem(directInput), refreshedAt: initialRefreshedAt, }; const hashedRow = { id: 'msg_hashed', apiKeyId: 'key-a', payload: { item: hashedInput }, - itemHash: await hashResponsesItemContent(hashedInput), + itemHash: await hashResponsesItem(hashedInput), refreshedAt: initialRefreshedAt, }; await repo.responsesItems.insertMany([directRow, hashedRow], 0); @@ -246,7 +246,7 @@ describe('StatefulResponsesStore', () => { id: 'msg_future', apiKeyId: 'key-a', payload: { item: input }, - itemHash: await hashResponsesItemContent(input), + itemHash: await hashResponsesItem(input), refreshedAt: futureRefreshedAt, }; await repo.responsesItems.insertMany([row], 0); diff --git a/packages/gateway/src/data-plane/chat/responses/respond.ts b/packages/gateway/src/data-plane/chat/responses/respond.ts index 0d74a71429..9bf79a2e3a 100644 --- a/packages/gateway/src/data-plane/chat/responses/respond.ts +++ b/packages/gateway/src/data-plane/chat/responses/respond.ts @@ -3,12 +3,12 @@ import { streamSSE } from 'hono/streaming'; import { wrapNativeResponsesClientOutput } from './client-output.ts'; import { tokenUsageFromResponsesResult } from './usage.ts'; +import type { GatewayCtx } from '../../shared/gateway-ctx.ts'; +import { type StreamCompletion, writeSSEFrames } from '../../shared/sse.ts'; import { recordFailedRequest } from '../../shared/telemetry/performance.ts'; import { settle } from '../../shared/telemetry/settle.ts'; import { forwardUpstreamHeaders, mergeForwardedUpstreamHeaders } from '../../shared/upstream-response.ts'; -import type { GatewayCtx } from '../shared/gateway-ctx.ts'; import { SourceStreamState, eventResultMetadata, plainResultToResponse } from '../shared/respond.ts'; -import { type StreamCompletion, writeSSEFrames } from '../shared/stream/sse.ts'; import { type ProtocolFrame, sseCommentFrame, sseFrame } from '@floway-dev/protocols/common'; import { responsesProtocolFrameToSSEFrame, RESPONSES_MISSING_TERMINAL_MESSAGE, collectResponsesProtocolEventsToResult } from '@floway-dev/protocols/responses'; import { isResponsesTerminalEvent, type ResponsesStreamEvent, responsesResultFromStreamEvent } from '@floway-dev/protocols/responses'; @@ -27,7 +27,7 @@ export const respondResponses = async ( ): Promise => { if (result.type === 'api-error') { recordFailedRequest(ctx, result.performance); - ctx.dump?.error(result.source, result.upstream); + ctx.dump?.error(result.source, result.upstreamId); return apiErrorToResponse(result); } @@ -39,7 +39,7 @@ export const respondResponses = async ( if (result.type === 'plain') { if (result.status >= 400) { - ctx.dump?.error(result.upstream !== undefined ? 'upstream' : 'gateway', result.upstream); + ctx.dump?.error(result.upstreamId !== undefined ? 'upstream' : 'gateway', result.upstreamId); } return plainResultToResponse(result); } diff --git a/packages/gateway/src/data-plane/chat/responses/serve-prep.ts b/packages/gateway/src/data-plane/chat/responses/serve-prep.ts index 2943150c37..375fef0e63 100644 --- a/packages/gateway/src/data-plane/chat/responses/serve-prep.ts +++ b/packages/gateway/src/data-plane/chat/responses/serve-prep.ts @@ -1,10 +1,10 @@ import { prepareResponsesAffinity } from './affinity/ingress.ts'; import { responsesTarget } from './attempt.ts'; -import { renderResponsesFailure } from './errors.ts'; +import { renderResponsesFailure, type ResponsesServeFailure } from './errors.ts'; import { hydrateResponsesPayload } from './items/hydrate.ts'; import type { StatefulResponsesStore } from './items/store.ts'; -import { enumerateModelCandidates } from '../../providers/registry.ts'; -import { type PreparedAffinityPayload, routeCandidatesByAffinity } from '../shared/affinity/index.ts'; +import { enumerateModelCandidates } from '../../providers/resolution.ts'; +import { type PreparedAffinityPayload, narrowCandidatesByAffinity } from '../shared/affinity/index.ts'; import { noViableCandidateFailure, tryCatchChatServeFailure } from '../shared/errors.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; @@ -96,13 +96,13 @@ export const prepareResponsesServePlan = async (args: { try { hydrated = hydrateResponsesPayload(prepared, store); } catch (error) { - const failure = tryCatchChatServeFailure(error); + const failure = tryCatchChatServeFailure(error); if (failure === null) throw error; return { kind: 'failure', result: renderResponsesFailure(failure) }; } const affinity = await prepareResponsesAffinity(hydrated.payload, ctx.affinity.codec); - const decision = routeCandidatesByAffinity(viable, affinity.routingEvidence); - if (decision.kind === 'failure') return { kind: 'failure', result: renderResponsesFailure(decision.failure) }; + const narrowed = narrowCandidatesByAffinity(viable, affinity.narrowingEvidence); + if ('kind' in narrowed) return { kind: 'failure', result: renderResponsesFailure(narrowed) }; // Stage the user-supplied input from the original payload — not the // expansion's `item_reference` prefix — so the next-turn snapshot picks // up the new user items in addition to the prior snapshot history. @@ -110,11 +110,11 @@ export const prepareResponsesServePlan = async (args: { // input has its target row loaded. await store.stageInputItems(payload.input); - if (decision.candidates.length === 0) { + if (narrowed.length === 0) { return { kind: 'failure', result: renderResponsesFailure(noViableCandidateFailure(sawModel, prepared.model, failedUpstreams)), }; } - return { kind: 'ready', affinity, privatePayloads: hydrated.privatePayloads, candidates: decision.candidates }; + return { kind: 'ready', affinity, privatePayloads: hydrated.privatePayloads, candidates: narrowed }; }; diff --git a/packages/gateway/src/data-plane/chat/responses/serve_test.ts b/packages/gateway/src/data-plane/chat/responses/serve_test.ts index 81053d75f6..f981976fe6 100644 --- a/packages/gateway/src/data-plane/chat/responses/serve_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/serve_test.ts @@ -5,7 +5,7 @@ import { TEST_RESPONSES_RETENTION_SECONDS, testResponsesStatePolicy } from './te import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; import type { StoredResponsesItem, StoredResponsesSnapshot } from '../../../repo/types.ts'; -import { mockChatGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import { mockChatGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { type AliasRules, doneFrame, eventFrame, type ModelEndpoints, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -26,8 +26,8 @@ interface QueuedResolution { } const resolutionsQueue: QueuedResolution[] = []; const lastResolveCall: { model?: string } = {}; -vi.mock('../../providers/registry.ts', async importOriginal => { - const original = await importOriginal(); +vi.mock('../../providers/resolution.ts', async importOriginal => { + const original = await importOriginal(); return { ...original, enumerateModelCandidates: vi.fn(async ({ model }: { model: string }) => { @@ -128,7 +128,7 @@ const makeCandidate = (overrides: { }); return { provider: { - upstream, + upstreamId: upstream, kind: 'custom', name: upstream, disabledPublicModelIds: [], @@ -269,7 +269,7 @@ test('generate falls through to the next candidate when the first yields an upst }); // A mid-attempt throw (interceptor bug / translation error / provider-layer -// JS exception bypassing tryCatchChatServeFailure) must attribute the perf +// JS exception not represented as a ChatServeFailure) must attribute the perf // error row to the throwing candidate, not the previous one that already // failed cleanly with a 5xx. test('mid-attempt throw stamps telemetry with the throwing candidate, not the previous one', async () => { diff --git a/packages/gateway/src/data-plane/chat/responses/websocket.ts b/packages/gateway/src/data-plane/chat/responses/websocket.ts index f48c2ce800..757c2a66f6 100644 --- a/packages/gateway/src/data-plane/chat/responses/websocket.ts +++ b/packages/gateway/src/data-plane/chat/responses/websocket.ts @@ -9,12 +9,12 @@ import type { DumpAccumulator } from '../../../dump/accumulator.ts'; import { apiKeyFromContext, authenticateApiKey, type AuthedContext } from '../../../middleware/auth.ts'; import { backgroundSchedulerFromContext } from '../../../runtime/background.ts'; import { inboundHeadersForUpstream } from '../../shared/inbound-headers.ts'; +import { takeRequestBody } from '../../shared/request-body.ts'; +import { DOWNSTREAM_KEEP_ALIVE_INTERVAL_MS, type StreamCompletion } from '../../shared/sse.ts'; import { recordFailedRequest } from '../../shared/telemetry/performance.ts'; import { settle } from '../../shared/telemetry/settle.ts'; import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gateway-ctx.ts'; -import { takeRequestBody } from '../shared/request-body.ts'; import { SourceStreamState, eventResultMetadata } from '../shared/respond.ts'; -import { DOWNSTREAM_KEEP_ALIVE_INTERVAL_MS, type StreamCompletion } from '../shared/stream/sse.ts'; import type { BackgroundScheduler } from '@floway-dev/platform'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { RESPONSES_MISSING_TERMINAL_MESSAGE } from '@floway-dev/protocols/responses'; @@ -286,7 +286,7 @@ const handleClientMessage = async ( sendError(socket, 500, serverErrorEnvelope(error), eventId, ctx?.dump); if (ctx !== undefined) { // Mid-attempt throws (interceptor bug, translation error, provider-layer JS - // exception that bypassed tryCatchChatServeFailure) never reach the + // exception not represented as a ChatServeFailure) never reach the // respondResponsesWebSocket result branches, so their `recordFailedRequest` // call would be skipped. Attribute the failure to the last upstream stamped // synchronously by `responsesServe.generate`, matching the HTTP transports. @@ -336,7 +336,7 @@ const respondResponsesWebSocket = async (input: { const { socket, eventId, signal, isClosed, result, ctx } = input; if (result.type === 'api-error') { recordFailedRequest(ctx, result.performance); - ctx.dump?.error(result.source, result.upstream); + ctx.dump?.error(result.source, result.upstreamId); sendError(socket, result.status, normalizeErrorBody(parseMaybeJson(result.body, result.headers), result.status), eventId, ctx.dump); ctx.dump?.finalize(result.status, []); return; diff --git a/packages/gateway/src/data-plane/chat/responses/websocket_test.ts b/packages/gateway/src/data-plane/chat/responses/websocket_test.ts index 51d952a153..639e34fda2 100644 --- a/packages/gateway/src/data-plane/chat/responses/websocket_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/websocket_test.ts @@ -1,14 +1,14 @@ import type { ExecutionContext } from 'hono'; import { test, vi } from 'vitest'; -import { hashResponsesItemContent } from './items/identity.ts'; +import { hashResponsesItem } from './items/identity.ts'; import { responsesServe } from './serve.ts'; import { app } from '../../../app.ts'; import { initDumpBroker, initDumpStore } from '../../../dump/registry.ts'; import { installDumpStubs } from '../../../dump/test-fixtures.ts'; -import { copilotModels, flushAsyncWork, setupAppTest, sseResponsesResponse } from '../../../test-helpers.ts'; import { FakeTime } from '../../../test-time.ts'; -import { DOWNSTREAM_KEEP_ALIVE_INTERVAL_MS } from '../shared/stream/sse.ts'; +import { copilotModels, flushAsyncWork, setupAppTest, sseResponsesResponse } from '../../../test-utils/app.ts'; +import { DOWNSTREAM_KEEP_ALIVE_INTERVAL_MS } from '../../shared/sse.ts'; import { assert, assertEquals, assertExists, assertStringIncludes, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; type WorkerResponseInit = ResponseInit & { readonly webSocket?: WebSocket }; @@ -768,7 +768,7 @@ test('Responses WebSocket store:false keeps session snapshots without durable re assert(firstOutput.item.id !== 'assistant_ws_store_false_1', 'expected Copilot to replace the raw message id'); assertEquals(await repo.responsesItems.lookupMany(apiKey.id, [firstOutput.item.id], 0), []); assertEquals( - await repo.responsesItems.lookupManyByItemHash(apiKey.id, [await hashResponsesItemContent({ type: 'message', role: 'user', content: 'first question' })], 0), + await repo.responsesItems.lookupManyByItemHash(apiKey.id, [await hashResponsesItem({ type: 'message', role: 'user', content: 'first question' })], 0), [], ); @@ -1159,8 +1159,7 @@ test('Responses WebSocket aborts the in-flight Responses request when the client }); // The four chat HTTP transports render a mid-attempt throw (interceptor -// bug, translation error, provider-layer JS exception that bypassed -// tryCatchChatServeFailure) through an +// bug, translation error, provider-layer JS exception not represented as a ChatServeFailure) through an // `internalErrorResult(..., ctx.attempt.telemetry)` envelope, // which internally reaches `recordFailedRequest` and lands an error row // attributed to the throwing candidate. The WS transport's outer catch diff --git a/packages/gateway/src/data-plane/chat/shared/affinity/candidate_test.ts b/packages/gateway/src/data-plane/chat/shared/affinity/candidate_test.ts index 9cea54e148..81ec301ac5 100644 --- a/packages/gateway/src/data-plane/chat/shared/affinity/candidate_test.ts +++ b/packages/gateway/src/data-plane/chat/shared/affinity/candidate_test.ts @@ -1,21 +1,21 @@ import { describe, expect, test } from 'vitest'; -import { routeCandidatesByAffinity } from './index.ts'; +import { narrowCandidatesByAffinity } from './index.ts'; import type { AffinityEvidence, AffinityTarget } from './index.ts'; import type { AliasRules } from '@floway-dev/protocols/common'; import { stubModelCandidate } from '@floway-dev/test-utils'; -const candidate = (upstream: string, model: string, rules?: AliasRules) => { +const candidate = (upstreamId: string, model: string, rules?: AliasRules) => { const base = stubModelCandidate(); const value = stubModelCandidate({ - provider: { ...base.provider, upstream }, + provider: { ...base.provider, upstreamId }, model: { id: model }, }); return rules === undefined ? value : { ...value, rules }; }; const targetFor = (value: ReturnType): AffinityTarget => ({ - upstreamId: value.provider.upstream, + upstreamId: value.provider.upstreamId, modelId: value.model.id, ...(value.rules !== undefined ? { rules: value.rules } : {}), }); @@ -25,33 +25,24 @@ const evidence = (value: ReturnType, mode: AffinityEvidence['m mode, }); -describe('client-carried affinity candidate routing', () => { +describe('client-carried affinity candidate narrowing', () => { test('treats empty alias rules as the direct no-overlay variant', () => { const direct = candidate('up-a', 'model-a'); const alias = candidate('up-a', 'model-a', {}); const overridden = candidate('up-a', 'model-a', { reasoning: { effort: 'low' } }); - expect(routeCandidatesByAffinity([alias, direct, overridden], [evidence(direct)])).toEqual({ - kind: 'success', - candidates: [alias, direct, overridden], - }); - expect(routeCandidatesByAffinity([direct, alias, overridden], [evidence(overridden)])).toEqual({ - kind: 'success', - candidates: [overridden, direct, alias], - }); + expect(narrowCandidatesByAffinity([alias, direct, overridden], [evidence(direct)])).toEqual([alias, direct, overridden]); + expect(narrowCandidatesByAffinity([direct, alias, overridden], [evidence(overridden)])).toEqual([overridden, direct, alias]); }); test('moves the latest available preferred target to the front', () => { const first = candidate('up-a', 'model'); const second = candidate('up-b', 'model'); - const decision = routeCandidatesByAffinity( + + expect(narrowCandidatesByAffinity( [first, second], [evidence(first), evidence(second)], - ); - - expect(decision.kind).toBe('success'); - if (decision.kind !== 'success') throw new Error('Expected successful routing'); - expect(decision.candidates).toEqual([second, first]); + )).toEqual([second, first]); }); test('keeps normal order when a preferred target is unavailable', () => { @@ -59,10 +50,7 @@ describe('client-carried affinity candidate routing', () => { const second = candidate('up-b', 'model'); const unavailable = candidate('up-c', 'model'); - expect(routeCandidatesByAffinity([first, second], [evidence(unavailable)])).toEqual({ - kind: 'success', - candidates: [first, second], - }); + expect(narrowCandidatesByAffinity([first, second], [evidence(unavailable)])).toEqual([first, second]); }); test('uses the latest preferred target that remains available', () => { @@ -70,43 +58,34 @@ describe('client-carried affinity candidate routing', () => { const second = candidate('up-b', 'model'); const unavailable = candidate('up-c', 'model'); - expect(routeCandidatesByAffinity([second, first], [evidence(first), evidence(unavailable)])).toEqual({ - kind: 'success', - candidates: [first, second], - }); + expect(narrowCandidatesByAffinity([second, first], [evidence(first), evidence(unavailable)])).toEqual([first, second]); }); test('force matches upstream and model without narrowing alias rules', () => { const direct = candidate('up-a', 'model'); const alias = candidate('up-a', 'model', {}); - expect(routeCandidatesByAffinity([direct, alias], [evidence(alias, 'force')])).toEqual({ - kind: 'success', - candidates: [direct, alias], - }); + expect(narrowCandidatesByAffinity([direct, alias], [evidence(alias, 'force')])).toEqual([direct, alias]); }); test('exact preference still orders rule variants inside a shared force target', () => { const direct = candidate('up-a', 'model'); const alias = candidate('up-a', 'model', { reasoning: { effort: 'low' } }); - expect(routeCandidatesByAffinity( + expect(narrowCandidatesByAffinity( [direct, alias], [evidence(direct, 'force'), evidence(alias, 'force'), evidence(alias)], - )).toEqual({ - kind: 'success', - candidates: [alias, direct], - }); + )).toEqual([alias, direct]); }); test('fails unavailable and conflicting force affinity', () => { const first = candidate('up-a', 'model'); const second = candidate('up-b', 'model'); - expect(routeCandidatesByAffinity([first], [evidence(second, 'force')])).toMatchObject({ kind: 'failure' }); - expect(routeCandidatesByAffinity([first, second], [ + expect(narrowCandidatesByAffinity([first], [evidence(second, 'force')])).toMatchObject({ kind: 'routing-unavailable' }); + expect(narrowCandidatesByAffinity([first, second], [ evidence(first, 'force'), evidence(second, 'force'), - ])).toMatchObject({ kind: 'failure' }); + ])).toMatchObject({ kind: 'routing-unavailable' }); }); }); diff --git a/packages/gateway/src/data-plane/chat/shared/affinity/index.ts b/packages/gateway/src/data-plane/chat/shared/affinity/index.ts index 222d8ac67f..6a62dd60cd 100644 --- a/packages/gateway/src/data-plane/chat/shared/affinity/index.ts +++ b/packages/gateway/src/data-plane/chat/shared/affinity/index.ts @@ -1,8 +1,9 @@ import { isEqual } from 'es-toolkit'; import { serverSecretBytes } from '../../../../shared/server-secret.ts'; -import type { ChatGatewayCtx, GatewayCtx } from '../gateway-ctx.ts'; -import type { RoutingDecision } from '../routing.ts'; +import type { GatewayCtx } from '../../../shared/gateway-ctx.ts'; +import type { ChatServeFailure } from '../errors.ts'; +import type { ChatGatewayCtx } from '../gateway-ctx.ts'; import { appendOpaqueTrailer, concatBytes, decodeOpaqueValue, encodeOpaqueValue, MAX_OPAQUE_TRAILER_BYTES, splitOpaqueTrailer, uint16be, type AliasRules, type OpaqueValueOrigin } from '@floway-dev/protocols/common'; import type { ModelCandidate } from '@floway-dev/provider'; @@ -30,7 +31,7 @@ export type DecodedAffinityBlob = | ({ kind: 'owned'; value?: string } & AffinityData); export interface PreparedAffinityPayload { - readonly routingEvidence: readonly AffinityEvidence[]; + readonly narrowingEvidence: readonly AffinityEvidence[]; readonly payloadForCandidate: (candidate: ModelCandidate) => T; } @@ -172,13 +173,13 @@ const sameForcedTarget = (left: AffinityTarget, right: AffinityTarget): boolean left.upstreamId === right.upstreamId && left.modelId === right.modelId; const affinityTargetForCandidate = (candidate: ModelCandidate): AffinityTarget => ({ - upstreamId: candidate.provider.upstream, + upstreamId: candidate.provider.upstreamId, modelId: candidate.model.id, ...(candidate.rules !== undefined ? { rules: candidate.rules } : {}), }); const candidateMatchesExactTarget = (candidate: ModelCandidate, affinity: AffinityTarget): boolean => - candidate.provider.upstream === affinity.upstreamId + candidate.provider.upstreamId === affinity.upstreamId && candidate.model.id === affinity.modelId // Alias targets always carry a rules object, while direct candidates omit // it. Both shapes describe the same no-overlay variant when the object is @@ -187,7 +188,7 @@ const candidateMatchesExactTarget = (candidate: ModelCandidate, affinity: Affini && isEqual(candidate.rules ?? {}, affinity.rules ?? {}); const candidateMatchesForcedTarget = (candidate: ModelCandidate, affinity: AffinityTarget): boolean => - candidate.provider.upstream === affinity.upstreamId && candidate.model.id === affinity.modelId; + candidate.provider.upstreamId === affinity.upstreamId && candidate.model.id === affinity.modelId; const reorderByLatestAvailablePreference = ( candidates: readonly T[], @@ -206,21 +207,18 @@ const reorderByLatestAvailablePreference = ( return candidates; }; -export const routeCandidatesByAffinity = ( - candidates: readonly T[], +export const narrowCandidatesByAffinity = ( + candidates: readonly ModelCandidate[], evidence: readonly AffinityEvidence[], -): RoutingDecision => { +): readonly ModelCandidate[] | ChatServeFailure => { const forcing: AffinityTarget[] = []; for (const item of evidence) { if (item.mode === 'force' && !forcing.some(existing => sameForcedTarget(existing, item.target))) forcing.push(item.target); } if (forcing.length > 1) { return { - kind: 'failure', - failure: { - kind: 'routing-unavailable', - message: `Client-carried state requires multiple incompatible targets: ${forcing.map(target => `'${target.upstreamId}/${target.modelId}'`).join(', ')}.`, - }, + kind: 'routing-unavailable', + message: `Client-carried state requires multiple incompatible targets: ${forcing.map(target => `'${target.upstreamId}/${target.modelId}'`).join(', ')}.`, }; } @@ -229,15 +227,12 @@ export const routeCandidatesByAffinity = ( : candidates.filter(candidate => candidateMatchesForcedTarget(candidate, forcing[0])); if (forcing.length === 1 && narrowed.length === 0) { return { - kind: 'failure', - failure: { - kind: 'routing-unavailable', - message: `Client-carried state requires unavailable target '${forcing[0].upstreamId}/${forcing[0].modelId}'.`, - }, + kind: 'routing-unavailable', + message: `Client-carried state requires unavailable target '${forcing[0].upstreamId}/${forcing[0].modelId}'.`, }; } - return { kind: 'success', candidates: reorderByLatestAvailablePreference(narrowed, evidence) as readonly T[] }; + return reorderByLatestAvailablePreference(narrowed, evidence); }; type CandidateBlob = diff --git a/packages/gateway/src/data-plane/chat/shared/errors.ts b/packages/gateway/src/data-plane/chat/shared/errors.ts index 8f3a9b0df3..65bb54c9f0 100644 --- a/packages/gateway/src/data-plane/chat/shared/errors.ts +++ b/packages/gateway/src/data-plane/chat/shared/errors.ts @@ -1,4 +1,6 @@ -// Failures a protocol can render before reaching an upstream; unexpected +import type { ApiErrorResult, PerformanceTelemetryContext } from '@floway-dev/provider'; + +// Failures a chat protocol can render before reaching an upstream; unexpected // throws bubble as-is. `failedUpstreams` on model-{missing,unsupported} // carries the upstream names whose catalog fetch threw during this // resolution — surfaced parenthetically so the caller can tell a genuine @@ -8,24 +10,39 @@ export type ChatServeFailure = | { readonly kind: 'model-missing'; readonly model: string; readonly failedUpstreams: readonly string[] } | { readonly kind: 'model-unsupported'; readonly model: string; readonly failedUpstreams: readonly string[] } - | { readonly kind: 'item-not-found'; readonly itemId: string } | { readonly kind: 'routing-unavailable'; readonly message: string }; -class ChatServeFailureError extends Error { - readonly failure: ChatServeFailure; +class ChatServeFailureError extends Error { + readonly failure: TFailure; - constructor(failure: ChatServeFailure) { + constructor(failure: TFailure) { super(`ChatServeFailure: ${failure.kind}`); this.failure = failure; } } -export const throwChatServeFailure = (failure: ChatServeFailure): never => { +export const throwChatServeFailure = (failure: TFailure): never => { throw new ChatServeFailureError(failure); }; -export const tryCatchChatServeFailure = (error: unknown): ChatServeFailure | null => - error instanceof ChatServeFailureError ? error.failure : null; +export const tryCatchChatServeFailure = (error: unknown): TFailure | null => + error instanceof ChatServeFailureError ? error.failure as TFailure : null; + +export const openAiErrorResult = ( + status: number, + message: string, + extra?: { readonly param: string; readonly code: string | null }, + performance?: PerformanceTelemetryContext, +): ApiErrorResult => ({ + type: 'api-error', + source: 'gateway', + status, + headers: new Headers({ 'content-type': 'application/json' }), + body: new TextEncoder().encode(JSON.stringify({ + error: { message, type: 'invalid_request_error', ...extra }, + })), + ...(performance ? { performance } : {}), +}) satisfies ApiErrorResult; // Builds the failure value every serve dispatches with after `canServe` has // dropped every candidate: `sawModel=true` means the inbound id exists in diff --git a/packages/gateway/src/data-plane/chat/shared/errors_test.ts b/packages/gateway/src/data-plane/chat/shared/errors_test.ts index 1cc7305190..313d4e42f6 100644 --- a/packages/gateway/src/data-plane/chat/shared/errors_test.ts +++ b/packages/gateway/src/data-plane/chat/shared/errors_test.ts @@ -8,7 +8,6 @@ const cases: readonly ChatServeFailure[] = [ { kind: 'model-missing', model: 'gpt-9', failedUpstreams: ['Azure prod'] }, { kind: 'model-unsupported', model: 'gpt-9', failedUpstreams: [] }, { kind: 'model-unsupported', model: 'gpt-9', failedUpstreams: ['Azure prod', 'Custom'] }, - { kind: 'item-not-found', itemId: 'msg_abc' }, { kind: 'routing-unavailable', message: 'no upstream can serve this' }, ]; diff --git a/packages/gateway/src/data-plane/chat/shared/gateway-ctx.ts b/packages/gateway/src/data-plane/chat/shared/gateway-ctx.ts index 189a3cfd0f..3912d43765 100644 --- a/packages/gateway/src/data-plane/chat/shared/gateway-ctx.ts +++ b/packages/gateway/src/data-plane/chat/shared/gateway-ctx.ts @@ -1,55 +1,8 @@ import { AffinityRequestContext } from './affinity/index.ts'; -import type { RequestBody } from './request-body.ts'; -import { type DumpAccumulator, openDumpAccumulator } from '../../../dump/accumulator.ts'; -import { apiKeyFromContext, type AuthedContext, effectiveUpstreamIdsFromContext } from '../../../middleware/auth.ts'; +import { apiKeyFromContext, type AuthedContext } from '../../../middleware/auth.ts'; import type { ApiKey } from '../../../repo/types.ts'; -import { getRuntimeLocation } from '../../../runtime/runtime-info.ts'; +import { createGatewayCtxFromHono, type CreateGatewayCtxOptions, type GatewayCtx } from '../../shared/gateway-ctx.ts'; import type { StatefulResponsesStore } from '../responses/items/store.ts'; -import type { BackgroundScheduler } from '@floway-dev/platform'; -import type { PerformanceTelemetryContext } from '@floway-dev/provider'; - -// Per-attempt performance state. Reset at the start of every -// iterateCandidates attempt so a candidate that short-circuits cannot inherit -// the prior attempt's slots. The numeric slots use `null` because a real -// timestamp of `0` would be ambiguous. -export interface AttemptState { - upstreamCallStartedAt: number | null; - firstOutputTokenAt: number | null; - telemetry: PerformanceTelemetryContext | undefined; -} - -// Stamps at dispatch entry — pre-dial by design. See -// UpstreamCallOptions.wrapUpstreamCall for why the interval includes proxy -// handshake time (the user waits for it too). -export const stampUpstreamCallStart = (attempt: AttemptState) => - (dispatch: () => Promise): Promise => { - attempt.upstreamCallStartedAt = performance.now(); - return dispatch(); - }; - -export interface GatewayCtx { - readonly apiKeyId: string; - readonly requestStartedAt: number; - readonly upstreamIds: readonly string[] | null; - readonly abortSignal?: AbortSignal; - readonly wantsStream: boolean; - readonly downstreamAbortController?: AbortController; - readonly backgroundScheduler: BackgroundScheduler; - readonly attempt: AttemptState; - // The deployment colo / region, used both as the `runtimeLocation` - // performance-telemetry dimension and as the dial-time colo whitelist key. - // Request-scoped, so it is resolved once here rather than at the - // provider-call boundary. - readonly runtimeLocation: string; - // Null when the api key has no retention configured, in which case - // `finalizeGatewayResponse` short-circuits the dump tee and returns the - // response untouched. - readonly dump: DumpAccumulator | null; - // Headers staged during request processing and written onto the - // outbound response by `finalizeGatewayResponse`, regardless of how - // the responder built the body. - readonly responseHeaders: Headers; -} // Chat-protocol ctx adds the affinity membrane and the Responses item store. // The store is present on every chat ctx: native Responses entries supply a @@ -64,68 +17,6 @@ export interface ChatGatewayCtx extends GatewayCtx { readonly store: StatefulResponsesStore; } -export interface CreateGatewayCtxOptions { - wantsStream: boolean; - // WebSocket-style call sites own the AbortController (so the upgrade - // handler can cancel mid-stream); HTTP call sites let the factory mint one - // when wantsStream is true. - downstreamAbortController?: AbortController; - // Already-buffered inbound request body bytes. HTTP handlers read them - // once via `readRequestBody` and pass them in so the dump accumulator's - // snapshot reflects the exact bytes the handler parsed. WebSocket - // upgrades carry no HTTP body — the WS Responses path passes the - // per-turn JSON message bytes here so the dump captures the turn's - // input verbatim. - requestBody: RequestBody; - // Override the HTTP method recorded on the dump's request snapshot. The - // WS Responses path uses `'WS'` so a dumped turn reads as - // `WS /v1/responses` in the dashboard rather than the upgrade's `GET`. - method?: string; - // The model id parsed from the request payload (or from the URL on - // Gemini's routes), stamped on the dump immediately so even an - // outright-error turn carries model attribution. Omit only on error - // fallback paths where payload parsing itself failed. - model?: string; - // Sink for every background task the ctx spawns (dump write, upstream - // telemetry, performance recording, usage recording). Provided by the - // call site so the correct lifetime binding is chosen: HTTP handlers - // pass `backgroundSchedulerFromContext(c)` (the runtime's fetch-scoped - // scheduler); the WS Responses transport builds a session-scoped - // scheduler backed by one lifetime `waitUntil` registered while the - // fetch handler is still active, so per-message tasks fired after the - // 101 upgrade has returned still complete. - backgroundScheduler: BackgroundScheduler; -} - -export const createGatewayCtxFromHono = (c: AuthedContext, opts: CreateGatewayCtxOptions): GatewayCtx => { - const controller = opts.downstreamAbortController ?? (opts.wantsStream ? new AbortController() : undefined); - const apiKey = apiKeyFromContext(c); - const upstreamIds = effectiveUpstreamIdsFromContext(c); - const dump = openDumpAccumulator(c, opts.method ?? c.req.method, apiKey, opts.requestBody, opts.backgroundScheduler); - if (opts.model !== undefined) dump?.requestedModel(opts.model); - return { - apiKeyId: apiKey.id, - requestStartedAt: Date.now(), - upstreamIds, - abortSignal: controller?.signal, - wantsStream: opts.wantsStream, - downstreamAbortController: controller, - backgroundScheduler: opts.backgroundScheduler, - attempt: { firstOutputTokenAt: null, upstreamCallStartedAt: null, telemetry: undefined }, - runtimeLocation: getRuntimeLocation(c.req.raw), - dump, - responseHeaders: new Headers(), - }; -}; - -// Run the dump-accumulator's finalize tee on the outgoing Response. Every -// inbound HTTP wrapper returns its response through this seam so the dump -// pipeline applies uniformly across happy-path, error, and passthrough paths. -export const finalizeGatewayResponse = (ctx: GatewayCtx, response: Response): Response => { - for (const [name, value] of ctx.responseHeaders) response.headers.set(name, value); - return ctx.dump?.finalize(response) ?? response; -}; - // Chat-protocol counterpart of `createGatewayCtxFromHono`. The factory receives // the authoritative API key. Native Responses HTTP and WebSocket entries // supply a persisting store factory; non-Responses sources supply diff --git a/packages/gateway/src/data-plane/chat/shared/provider-stream-result.ts b/packages/gateway/src/data-plane/chat/shared/provider-stream-result.ts index bbdd7e01f8..834f11a210 100644 --- a/packages/gateway/src/data-plane/chat/shared/provider-stream-result.ts +++ b/packages/gateway/src/data-plane/chat/shared/provider-stream-result.ts @@ -1,5 +1,5 @@ import { isFirstOutputTokenFrame } from './first-output-token.ts'; -import type { GatewayCtx } from './gateway-ctx.ts'; +import type { GatewayCtx } from '../../shared/gateway-ctx.ts'; import { telemetryModelIdentity, upstreamPerformanceContext } from '../../shared/telemetry/attribution.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { eventResult, readUpstreamApiError, type ChatTargetApi, type ExecuteResult, type ModelCandidate, type ProviderStreamResult } from '@floway-dev/provider'; @@ -12,7 +12,7 @@ export const providerStreamResultToExecuteResult = async ( ): Promise>> => { const context = upstreamPerformanceContext(ctx, candidate, 'chat'); if (!providerResult.ok) { - return { ...(await readUpstreamApiError(providerResult.response, candidate.provider.upstream)), performance: context }; + return { ...(await readUpstreamApiError(providerResult.response, candidate.provider.upstreamId)), performance: context }; } const stampedEvents = (async function* () { for await (const frame of providerResult.events) { diff --git a/packages/gateway/src/data-plane/chat/shared/provider-stream-result_test.ts b/packages/gateway/src/data-plane/chat/shared/provider-stream-result_test.ts index 8ef84507f7..4543e36bf0 100644 --- a/packages/gateway/src/data-plane/chat/shared/provider-stream-result_test.ts +++ b/packages/gateway/src/data-plane/chat/shared/provider-stream-result_test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import { providerStreamResultToExecuteResult } from './provider-stream-result.ts'; -import { mockGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import { mockGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ProviderStreamResult } from '@floway-dev/provider'; import { stubModelCandidate } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/chat/shared/respond.ts b/packages/gateway/src/data-plane/chat/shared/respond.ts index bc7ed7bb84..d4239b5fb0 100644 --- a/packages/gateway/src/data-plane/chat/shared/respond.ts +++ b/packages/gateway/src/data-plane/chat/shared/respond.ts @@ -1,5 +1,5 @@ -import type { StreamCompletion } from './stream/sse.ts'; import type { TokenUsage } from '../../../repo/types.ts'; +import type { StreamCompletion } from '../../shared/sse.ts'; import { hasTokenUsage } from '../../shared/telemetry/usage.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { plainResult } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/chat/shared/respond_test.ts b/packages/gateway/src/data-plane/chat/shared/respond_test.ts index d422ed5ebb..995e16f6e3 100644 --- a/packages/gateway/src/data-plane/chat/shared/respond_test.ts +++ b/packages/gateway/src/data-plane/chat/shared/respond_test.ts @@ -1,64 +1,7 @@ -import { beforeEach, expect, test } from 'vitest'; +import { test } from 'vitest'; -import type { GatewayCtx } from './gateway-ctx.ts'; import { SourceStreamState } from './respond.ts'; -import { initRepo } from '../../../repo/index.ts'; -import { InMemoryRepo } from '../../../repo/memory.ts'; -import { tokenCountsFromUsage } from '../../../repo/usage-metrics.ts'; -import { recordPerformance } from '../../shared/telemetry/performance.ts'; -import { settle } from '../../shared/telemetry/settle.ts'; -import type { TelemetryModelIdentity } from '@floway-dev/provider'; -import { assertEquals, mockPerfTelemetryContext } from '@floway-dev/test-utils'; - -const testTelemetryModelIdentity: TelemetryModelIdentity = { - model: 'claude-test', - upstream: 'copilot:1', - modelKey: 'claude-test-raw', - pricing: null, -}; - -const testPerformanceContext = mockPerfTelemetryContext({ - keyId: '', - model: 'claude-test', - upstream: 'copilot:1', - runtimeLocation: 'SJC', -}); - -interface Harness { - repo: InMemoryRepo; - background: Promise[]; - ctx: (overrides?: { - apiKeyId?: string; - firstOutputTokenAt?: number | null; - upstreamCallStartedAt?: number | null; - }) => GatewayCtx; -} - -const setup = (): Harness => { - const repo = new InMemoryRepo(); - initRepo(repo); - const background: Promise[] = []; - return { - repo, - background, - ctx: ({ apiKeyId = 'key_a', firstOutputTokenAt = null, upstreamCallStartedAt = null } = {}) => ({ - apiKeyId, - requestStartedAt: 0, - upstreamIds: null, - wantsStream: true, - runtimeLocation: 'TEST', - dump: null, - responseHeaders: new Headers(), - backgroundScheduler: promise => { background.push(promise); }, - attempt: { firstOutputTokenAt, upstreamCallStartedAt, telemetry: undefined }, - }), - }; -}; - -let harness: Harness; -beforeEach(() => { - harness = setup(); -}); +import { assertEquals } from '@floway-dev/test-utils'; // ── SourceStreamState classification ── @@ -114,130 +57,3 @@ test('SourceStreamState.rememberUsage keeps real usage and ignores zero figures' state.rememberUsage({ input: 0, output: 0 }); assertEquals(state.usage, { input: 50, output: 10 }); }); - -// ── recordPerformance ── - -test('recordPerformance records a full sample when success with upstreamCallStartedAt, firstOutputTokenAt, and outputTokens>=2', async () => { - recordPerformance(harness.ctx({ upstreamCallStartedAt: 50, firstOutputTokenAt: 100 }), testPerformanceContext, false, 50, 200); - await Promise.all(harness.background); - - const rows = await harness.repo.performance.listAll(); - assertEquals(rows.length, 1); - assertEquals(rows[0].ttftSamplesOk, 1); - assertEquals(rows[0].tpotSamples, 1); - assertEquals(rows[0].errorsWithOutput, 0); - assertEquals(rows[0].errorsNoOutput, 0); - assertEquals(rows[0].requests, 1); -}); - -test('recordPerformance records TTFT-only sample when outputTokens is zero but first-token stamp fired', async () => { - recordPerformance(harness.ctx({ upstreamCallStartedAt: 50, firstOutputTokenAt: 100 }), testPerformanceContext, false, 0, 200); - await Promise.all(harness.background); - - const rows = await harness.repo.performance.listAll(); - assertEquals(rows.length, 1); - assertEquals(rows[0].ttftSamplesOk, 1); - assertEquals(rows[0].tpotSamples, 0); - assertEquals(rows[0].errorsWithOutput, 0); - assertEquals(rows[0].errorsNoOutput, 0); - assertEquals(rows[0].requests, 1); -}); - -test('recordPerformance records neutral when success but firstOutputTokenAt is null', async () => { - recordPerformance(harness.ctx({ firstOutputTokenAt: null }), testPerformanceContext, false, 50, 200); - await Promise.all(harness.background); - - const rows = await harness.repo.performance.listAll(); - assertEquals(rows.length, 1); - assertEquals(rows[0].ttftSamplesOk, 0); - assertEquals(rows[0].tpotSamples, 0); - assertEquals(rows[0].neutral, 1); - assertEquals(rows[0].errorsWithOutput, 0); - assertEquals(rows[0].errorsNoOutput, 0); - assertEquals(rows[0].requests, 1); -}); - -test('recordPerformance records a zero-output error when failed without a real TTFT stamp', async () => { - recordPerformance(harness.ctx({ firstOutputTokenAt: 100 }), testPerformanceContext, true, 50, 200); - await Promise.all(harness.background); - - const rows = await harness.repo.performance.listAll(); - assertEquals(rows.length, 1); - assertEquals(rows[0].ttftSamplesOk, 0); - assertEquals(rows[0].tpotSamples, 0); - assertEquals(rows[0].errorsNoOutput, 1); - assertEquals(rows[0].errorsWithOutput, 0); - assertEquals(rows[0].requests, 1); -}); - -test('recordPerformance skips when performance context is absent', async () => { - recordPerformance(harness.ctx(), undefined, true, 0, 200); - await Promise.all(harness.background); - - assertEquals(await harness.repo.performance.listAll(), []); -}); - -// ── settle ── - -test('settle records a usage row when the figure carries a billable metric', async () => { - settle(harness.ctx(), testPerformanceContext, testTelemetryModelIdentity, { input: 10, output: 5 }, false); - await Promise.all(harness.background); - - const rows = await harness.repo.usage.listAll(); - assertEquals(rows.length, 1); - assertEquals(rows[0].keyId, 'key_a'); - assertEquals(tokenCountsFromUsage(rows[0]), { input: 10, output: 5 }); - assertEquals(rows[0].requests, 1); -}); - -test('settle records the request without metrics when usage is null', async () => { - settle(harness.ctx(), testPerformanceContext, testTelemetryModelIdentity, null, false); - await Promise.all(harness.background); - - const rows = await harness.repo.usage.listAll(); - assertEquals(rows.length, 1); - assertEquals(rows[0].requests, 1); - assertEquals(rows[0].metrics, []); -}); - -test('settle records the request when usage carries no billable metric', async () => { - settle(harness.ctx(), testPerformanceContext, testTelemetryModelIdentity, {}, false); - await Promise.all(harness.background); - - const rows = await harness.repo.usage.listAll(); - assertEquals(rows.length, 1); - assertEquals(rows[0].requests, 1); - assertEquals(rows[0].metrics, []); -}); - -// TPOT reflects the token stream, not the D1 write that follows it. -// `settle` fires the usage record through backgroundScheduler and records -// the perf sample synchronously — so a slow persistence path cannot leak -// its latency into `tpotUs`. Regressing this (turning the usage record -// back into an in-band await, or moving the perf record past the -// scheduler call) would fold persistence latency into every stream's -// per-token interval. -test('settle records the perf sample without waiting on the usage write', async () => { - const originalRecord = harness.repo.usage.record.bind(harness.repo.usage); - const persistenceDelayMs = 200; - harness.repo.usage.record = async row => { - await new Promise(resolve => setTimeout(resolve, persistenceDelayMs)); - await originalRecord(row); - }; - - const beforeSettle = performance.now(); - const ctx = harness.ctx({ upstreamCallStartedAt: beforeSettle - 10, firstOutputTokenAt: beforeSettle }); - - settle(ctx, testPerformanceContext, testTelemetryModelIdentity, { input: 5, output: 3 }, false); - await Promise.all(harness.background); - - const rows = await harness.repo.performance.listAll(); - assertEquals(rows.length, 1); - // TPOT = (requestFinishedAt - firstOutputTokenAt) * 1000 / (outputTokens - 1). - // Recorded synchronously at settle entry: tpotUs reflects only the - // sub-millisecond gap between ctx construction and settle. Fold the - // 200ms usage write into it (in-band await) and tpotUs would be - // ~100_000us (200ms / 2). 50_000us fences the regression while - // tolerating scheduler jitter. - expect(rows[0].tpotUsSum).toBeLessThan(50_000); -}); diff --git a/packages/gateway/src/data-plane/chat/shared/routing.ts b/packages/gateway/src/data-plane/chat/shared/routing.ts deleted file mode 100644 index d66896afbb..0000000000 --- a/packages/gateway/src/data-plane/chat/shared/routing.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ChatServeFailure } from './errors.ts'; -import type { ModelCandidate } from '@floway-dev/provider'; - -// Generic over the candidate type so call sites can narrow back to their -// concrete shape. The candidate filtering and ordering inside routing is -// shape-agnostic and preserves the concrete candidate objects it receives. -export type RoutingDecision = - | { readonly kind: 'success'; readonly candidates: readonly T[] } - | { readonly kind: 'failure'; readonly failure: ChatServeFailure }; diff --git a/packages/gateway/src/data-plane/chat/shared/target-picker_test.ts b/packages/gateway/src/data-plane/chat/shared/target-picker_test.ts index c2a229652f..e4bd8c622f 100644 --- a/packages/gateway/src/data-plane/chat/shared/target-picker_test.ts +++ b/packages/gateway/src/data-plane/chat/shared/target-picker_test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'vitest'; import { chatTargetPicker } from './target-picker.ts'; -import { setupAppTest } from '../../../test-helpers.ts'; -import { enumerateModelCandidates } from '../../providers/registry.ts'; +import { setupAppTest } from '../../../test-utils/app.ts'; +import { enumerateModelCandidates } from '../../providers/resolution.ts'; import type { ModelEndpoints } from '@floway-dev/protocols/common'; import type { UpstreamRecord } from '@floway-dev/provider'; import { assertEquals } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/codex/routes.ts b/packages/gateway/src/data-plane/codex/routes.ts index a9e3a3783e..3eb719e925 100644 --- a/packages/gateway/src/data-plane/codex/routes.ts +++ b/packages/gateway/src/data-plane/codex/routes.ts @@ -26,7 +26,7 @@ import { mountAlphaSearchRoute } from '../alpha-search/routes.ts'; import { responsesHttp } from '../chat/responses/http.ts'; import { responsesWebSocket } from '../chat/responses/websocket.ts'; import { imagesEdits, imagesGenerations } from '../images/http.ts'; -import { serveModels } from '../models/serve.ts'; +import { serveModels } from '../models/http.ts'; const CODEX_BASE_PATH = '/azure-api.codex'; diff --git a/packages/gateway/src/data-plane/codex/routes_images_test.ts b/packages/gateway/src/data-plane/codex/routes_images_test.ts index e1d2572613..e519892eeb 100644 --- a/packages/gateway/src/data-plane/codex/routes_images_test.ts +++ b/packages/gateway/src/data-plane/codex/routes_images_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import type { InMemoryRepo } from '../../repo/memory.ts'; -import { copilotModels, requestApp, setupAppTest } from '../../test-helpers.ts'; +import { copilotModels, requestApp, setupAppTest } from '../../test-utils/app.ts'; import { assertEquals, assertExists, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; const PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/wEAAAAASUVORK5CYII='; diff --git a/packages/gateway/src/data-plane/codex/routes_test.ts b/packages/gateway/src/data-plane/codex/routes_test.ts index 9f0c71c317..b182f39dd2 100644 --- a/packages/gateway/src/data-plane/codex/routes_test.ts +++ b/packages/gateway/src/data-plane/codex/routes_test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { mountCodexRoutes } from './routes.ts'; import { type AuthVars, authMiddleware } from '../../middleware/auth.ts'; -import { copilotModels, setupAppTest } from '../../test-helpers.ts'; +import { copilotModels, setupAppTest } from '../../test-utils/app.ts'; import { jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; const buildCodexApp = () => { diff --git a/packages/gateway/src/data-plane/codex/routes_websocket_test.ts b/packages/gateway/src/data-plane/codex/routes_websocket_test.ts index 10dbb38ac3..f20d79cdc2 100644 --- a/packages/gateway/src/data-plane/codex/routes_websocket_test.ts +++ b/packages/gateway/src/data-plane/codex/routes_websocket_test.ts @@ -2,7 +2,7 @@ import type { ExecutionContext } from 'hono'; import { expect, it } from 'vitest'; import { app as gatewayApp } from '../../app.ts'; -import { copilotModels, setupAppTest, sseResponsesResponse } from '../../test-helpers.ts'; +import { copilotModels, setupAppTest, sseResponsesResponse } from '../../test-utils/app.ts'; import { jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; type WorkerResponseInit = ResponseInit & { readonly webSocket?: WebSocket }; diff --git a/packages/gateway/src/data-plane/completions/http.ts b/packages/gateway/src/data-plane/completions/http.ts index 8c924d9ebf..280a275fd4 100644 --- a/packages/gateway/src/data-plane/completions/http.ts +++ b/packages/gateway/src/data-plane/completions/http.ts @@ -11,9 +11,9 @@ import type { Context } from 'hono'; import { tokenUsageFromCompletionsUsage } from './usage.ts'; import type { TokenUsage } from '../../repo/types.ts'; import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../chat/shared/gateway-ctx.ts'; -import { readRequestBody, takeRequestBody } from '../chat/shared/request-body.ts'; +import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; import { passthroughApiError, passthroughServe } from '../shared/passthrough-serve.ts'; +import { readRequestBody, takeRequestBody } from '../shared/request-body.ts'; import { isOpenAIUsageOnlyEventShape, type ProtocolFrame } from '@floway-dev/protocols/common'; interface CompletionsRequestBody { diff --git a/packages/gateway/src/data-plane/completions/http_test.ts b/packages/gateway/src/data-plane/completions/http_test.ts index afd0943be9..c5e0675d8f 100644 --- a/packages/gateway/src/data-plane/completions/http_test.ts +++ b/packages/gateway/src/data-plane/completions/http_test.ts @@ -3,7 +3,7 @@ import { test } from 'vitest'; import { initDumpBroker, initDumpStore } from '../../dump/registry.ts'; import { installDumpStubs } from '../../dump/test-fixtures.ts'; import { tokenCountsFromUsage } from '../../repo/usage-metrics.ts'; -import { buildCustomUpstreamRecord, flushAsyncWork, requestApp, setupAppTest } from '../../test-helpers.ts'; +import { buildCustomUpstreamRecord, flushAsyncWork, requestApp, setupAppTest } from '../../test-utils/app.ts'; import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; import { assertEquals, assertExists, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/embeddings/http.ts b/packages/gateway/src/data-plane/embeddings/http.ts index 96b9616546..4e1a9c282b 100644 --- a/packages/gateway/src/data-plane/embeddings/http.ts +++ b/packages/gateway/src/data-plane/embeddings/http.ts @@ -3,11 +3,11 @@ import type { Context } from 'hono'; +import { tokenUsageFromEmbeddingsBody } from './usage.ts'; import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../chat/shared/gateway-ctx.ts'; -import { readRequestBody, takeRequestBody } from '../chat/shared/request-body.ts'; +import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; import { passthroughApiError, passthroughServe } from '../shared/passthrough-serve.ts'; -import { tokenUsageFromEmbeddingsBody } from '../shared/telemetry/usage.ts'; +import { readRequestBody, takeRequestBody } from '../shared/request-body.ts'; interface EmbeddingsRequestBody { model?: unknown; diff --git a/packages/gateway/src/data-plane/embeddings/http_test.ts b/packages/gateway/src/data-plane/embeddings/http_test.ts index 8ff5784931..429e8269b2 100644 --- a/packages/gateway/src/data-plane/embeddings/http_test.ts +++ b/packages/gateway/src/data-plane/embeddings/http_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { tokenCountsFromUsage } from '../../repo/usage-metrics.ts'; -import { buildCustomUpstreamRecord, copilotModels, flushAsyncWork, requestApp, setupAppTest } from '../../test-helpers.ts'; +import { buildCustomUpstreamRecord, copilotModels, flushAsyncWork, requestApp, setupAppTest } from '../../test-utils/app.ts'; import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; import { jsonResponse, withMockedFetch, assertEquals, assertExists } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/embeddings/usage.ts b/packages/gateway/src/data-plane/embeddings/usage.ts new file mode 100644 index 0000000000..d3d3c7756d --- /dev/null +++ b/packages/gateway/src/data-plane/embeddings/usage.ts @@ -0,0 +1,10 @@ +import type { TokenUsage } from '../../repo/types.ts'; +import { tokenUsage } from '../shared/telemetry/usage.ts'; + +export const tokenUsageFromEmbeddingsBody = (body: unknown): TokenUsage | null => { + if (!body || typeof body !== 'object') return null; + const { usage } = body as { usage?: unknown }; + if (!usage || typeof usage !== 'object') return null; + const promptTokens = (usage as { prompt_tokens?: unknown }).prompt_tokens; + return typeof promptTokens === 'number' ? tokenUsage({ input: promptTokens }) : null; +}; diff --git a/packages/gateway/src/data-plane/images/http.ts b/packages/gateway/src/data-plane/images/http.ts index 9195424b4f..3da8dc4ecf 100644 --- a/packages/gateway/src/data-plane/images/http.ts +++ b/packages/gateway/src/data-plane/images/http.ts @@ -10,9 +10,9 @@ import type { Context } from 'hono'; import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../chat/shared/gateway-ctx.ts'; -import { readRequestBody, takeRequestBody, type RequestBody } from '../chat/shared/request-body.ts'; +import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; import { passthroughApiError, passthroughServe } from '../shared/passthrough-serve.ts'; +import { readRequestBody, takeRequestBody, type RequestBody } from '../shared/request-body.ts'; import { tokenUsageFromImagesBody } from '../shared/telemetry/usage.ts'; import type { ImageEditReference } from '@floway-dev/protocols/images'; import { isBase64ImageDataUrl, type ImagesEditsRequest, type ImagesEditsSource } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/images/http_test.ts b/packages/gateway/src/data-plane/images/http_test.ts index bd170a67bb..8eb730c80a 100644 --- a/packages/gateway/src/data-plane/images/http_test.ts +++ b/packages/gateway/src/data-plane/images/http_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { tokenCountsFromUsage } from '../../repo/usage-metrics.ts'; -import { buildCustomUpstreamRecord, copilotModels, flushAsyncWork, requestApp, setupAppTest } from '../../test-helpers.ts'; +import { buildCustomUpstreamRecord, copilotModels, flushAsyncWork, requestApp, setupAppTest } from '../../test-utils/app.ts'; import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; import { jsonResponse, withMockedFetch, assertEquals, assertExists } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/models/gemini_test.ts b/packages/gateway/src/data-plane/models/gemini_test.ts index a1b03799f9..0f88dfa2d4 100644 --- a/packages/gateway/src/data-plane/models/gemini_test.ts +++ b/packages/gateway/src/data-plane/models/gemini_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { buildCustomUpstreamRecord, copilotModels, requestApp, setupAppTest } from '../../test-helpers.ts'; +import { buildCustomUpstreamRecord, copilotModels, requestApp, setupAppTest } from '../../test-utils/app.ts'; import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; import { jsonResponse, withMockedFetch, assertEquals } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/models/serve.ts b/packages/gateway/src/data-plane/models/http.ts similarity index 100% rename from packages/gateway/src/data-plane/models/serve.ts rename to packages/gateway/src/data-plane/models/http.ts diff --git a/packages/gateway/src/data-plane/models/serve_test.ts b/packages/gateway/src/data-plane/models/http_test.ts similarity index 99% rename from packages/gateway/src/data-plane/models/serve_test.ts rename to packages/gateway/src/data-plane/models/http_test.ts index 793f2c4c82..623c86b642 100644 --- a/packages/gateway/src/data-plane/models/serve_test.ts +++ b/packages/gateway/src/data-plane/models/http_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { buildCopilotUpstreamRecord, buildCustomUpstreamRecord, copilotModels, requestApp, setupAppTest } from '../../test-helpers.ts'; +import { buildCopilotUpstreamRecord, buildCustomUpstreamRecord, copilotModels, requestApp, setupAppTest } from '../../test-utils/app.ts'; import type { ModelKind } from '@floway-dev/protocols/common'; import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; import { jsonResponse, withMockedFetch, assertEquals } from '@floway-dev/test-utils'; @@ -366,7 +366,7 @@ test('/v1/models hides upstream identity when a provider returns an invalid mode // A single upstream rejecting its catalog fetch must not poison the public // listing — the healthy upstream's models still surface with a 200. The -// `getModels` unit test covers the same property at the registry level; this +// catalog unit coverage pins the same property; this // pins it at the HTTP boundary so a regression in the listing renderer would // be caught. test('/v1/models surfaces healthy upstream models when another upstream catalog fetch fails', async () => { diff --git a/packages/gateway/src/data-plane/providers/catalog.ts b/packages/gateway/src/data-plane/providers/catalog.ts new file mode 100644 index 0000000000..e798f312bc --- /dev/null +++ b/packages/gateway/src/data-plane/providers/catalog.ts @@ -0,0 +1,219 @@ +import { unionEndpoints } from './endpoint-union.ts'; +import { fetchUpstreamModelsCached } from './models-cache.ts'; +import type { BackgroundScheduler } from '@floway-dev/platform'; +import { kindForEndpoints } from '@floway-dev/protocols/common'; +import { isAbortError, type Fetcher, type InternalModel, type Provider, type ProviderModel } from '@floway-dev/provider'; + +interface ProviderModelsResult { + models: InternalModel[]; + // Reverse index: every upstream instance that emitted an entry under the + // given public id, in enumeration order. The control-plane catalog + // endpoint reads this to render `upstreams: [{kind, id, name}]` per row; + // the alias listing reads it to project per-target upstream chips. + upstreamsByPublicId: Map; + sawSuccess: boolean; + lastError: unknown; + // Upstream names whose catalog fetch rejected this round, in the same + // order as the input `providers` list so the model-missing renderer can + // surface a stable, dashboard-aligned list. + failedUpstreams: string[]; +} + +// Lift a provider-emitted `ProviderModel` into an `InternalModel`, seeding +// `providerModels` with the sole entry keyed on the emitting upstream id. +// The provider model is stored verbatim under that entry so dispatch hands +// the same reference back to the provider's `callXxx`. +export const internalModelFromProviderModel = (providerModel: ProviderModel, upstreamId: string): InternalModel => { + const { providerData, enabledFlags, flagOverrides, rerankTarget, endpoints, ...metadata } = providerModel; + return { + ...metadata, + endpoints: { ...endpoints }, + providerModels: { [upstreamId]: providerModel }, + }; +}; + +// When multiple upstreams expose the same public model id, the first wins +// for `/models` metadata and later ones union-merge their endpoint capability +// map — the merged `endpoints` is the gateway-wide reach for that public id. +// `kind` is recomputed from the union so a chat-only id that later acquires +// an embedding-capable upstream gets correctly reclassified. Each contribution +// adds its own entry to `providerModels` keyed on the contributing upstream id +// with the emitted `ProviderModel` stored verbatim, so the same public id +// carrying data from N upstreams ends up with N entries. The reverse index +// `upstreamsByPublicId` accumulates every upstream that surfaced the id, in +// enumeration order, so the control plane can render its per-model upstream +// chips without re-walking the catalog. +const mergeIntoCatalog = ( + byId: Map, + upstreamsByPublicId: Map, + instance: Provider, + surfacedModel: ProviderModel, + publicId: string, +): void => { + const existing = byId.get(publicId); + if (!existing) { + byId.set(publicId, internalModelFromProviderModel(surfacedModel, instance.upstreamId)); + upstreamsByPublicId.set(publicId, [instance]); + return; + } + // The catalog only stores real (upstream-backed) rows; alias-synthesized + // rows join the caller-facing catalog downstream via `mergeAliasesIntoModels`. + // Narrow off the discriminated union so the merge below sees a concrete + // `providerModels` map. + if (existing.providerModels === undefined) { + throw new Error(`mergeIntoCatalog: catalog row for '${publicId}' unexpectedly carries aliasedFrom instead of providerModels`); + } + const endpoints = unionEndpoints([existing.endpoints, surfacedModel.endpoints]); + byId.set(publicId, { + ...existing, + endpoints, + kind: kindForEndpoints(endpoints), + providerModels: { + ...existing.providerModels, + [instance.upstreamId]: surfacedModel, + }, + }); + // We're on the merge branch (`existing !== undefined`), so the parallel + // `upstreamsByPublicId` entry was populated by the earlier insertion branch + // and must exist. + const instances = upstreamsByPublicId.get(publicId); + if (instances === undefined) throw new Error(`invariant broken: upstreamsByPublicId missing ${publicId}`); + instances.push(instance); +}; + +const collectProviderModels = async ( + providers: readonly Provider[], + fetcherForUpstream: (upstreamId: string) => Fetcher, + scheduler: BackgroundScheduler, +): Promise => { + const byId = new Map(); + const upstreamsByPublicId = new Map(); + let sawSuccess = false; + let lastError: unknown = null; + const failedUpstreams: string[] = []; + + // Fan out per-upstream so a slow provider does not stall the rest. The SWR + // cache layer dedupes concurrent in-flight fetches per upstream and serves + // the SOFT-fresh row without an upstream round trip, so the parallel walk + // is cheap on the warm path and bounded by `max(per-upstream fetch)` on + // the cold path. + const fetchOne = (instance: Provider) => + fetchUpstreamModelsCached(instance, { + scheduler, + fetcher: fetcherForUpstream(instance.upstreamId), + }).then(models => ({ instance, models })); + + const settled = await Promise.allSettled(providers.map(fetchOne)); + + for (const [index, result] of settled.entries()) { + if (result.status === 'rejected') { + // Caller-driven cancellation must propagate. Burying it in lastError + // and letting an earlier sawSuccess return a partially-populated + // model list would mask the abort and let the rest of the data-plane + // request build a Response against a stale catalog. `isAbortError` + // walks the cause chain so an AbortError wrapped inside + // ProviderModelsUnavailableError still surfaces here. + const error = result.reason; + if (isAbortError(error)) throw error; + lastError = error; + failedUpstreams.push(providers[index].name); + continue; + } + sawSuccess = true; + const { instance, models: providedModels } = result.value; + // Operator-disabled public model ids vanish entirely for this upstream: + // dropped before they reach the catalog map, so they appear in no /models + // listing and resolve to nothing for routing. The disable is per-upstream, + // so the same id can still surface from another upstream that allows it. + // The disable matches against the bare upstream id, so a disabled `gpt-4o` + // hides both `gpt-4o` and `gpt-4o` from this upstream's + // contribution. + const disabled = new Set(instance.disabledPublicModelIds); + for (const providerModel of providedModels) { + if (!providerModel.id) continue; + if (disabled.has(providerModel.id)) continue; + + // Each surface form the upstream chose to list becomes its own catalog + // entry. The unprefixed surface keeps the original ProviderModel; the + // prefixed surface uses a shallow clone with the rewritten id and a + // synthesized display_name that prepends the upstream name (so the + // dashboard tells the operator at a glance which upstream a prefixed + // model came from). `providerData` (where the per-provider call reads + // the real upstream model id) is untouched by the clone. + const cfg = instance.modelPrefix; + if (cfg !== null) { + for (const form of cfg.listed) { + const publicId = form === 'prefixed' ? `${cfg.prefix}${providerModel.id}` : providerModel.id; + const surfacedModel: ProviderModel = form === 'prefixed' + ? { ...providerModel, id: publicId, display_name: `${instance.name}: ${providerModel.display_name ?? providerModel.id}` } + : providerModel; + mergeIntoCatalog(byId, upstreamsByPublicId, instance, surfacedModel, publicId); + } + } else { + mergeIntoCatalog(byId, upstreamsByPublicId, instance, providerModel, providerModel.id); + } + } + } + + return { models: [...byId.values()], upstreamsByPublicId, sawSuccess, lastError, failedUpstreams }; +}; + +// Public-facing model-id ordering, applied to the real-model slice of the +// lists that cross a gateway boundary (data-plane /v1/models, /models, +// /v1beta/models and the control-plane /api/models that backs the dashboard +// models page). It orders that slice only: visible aliases are appended +// afterwards in alias `sortOrder`, and `/api/models?include_unlisted=true` +// appends the unlisted rows after the listed ones. +// Provider upstreams return models in arbitrary order; sorting here gives the +// dashboard and downstream clients a stable, family-grouped view. +// +// Sort keys, evaluated in order: +// 0. Whether the id contains a '/'. Slashed ids (Microsoft Foundry router +// model ids like "accounts/msft/routers/x") are pushed to the tail so +// the typical flat ids stay on top. +// 1. Leading [a-zA-Z]+ prefix, case-insensitive, ascending. Groups model +// families: "claude-haiku-4-5" -> "claude", "deepseek-v4-pro" -> +// "deepseek". +// 2. Array of isolated single digits (a digit surrounded on both sides by a +// non-digit, with start/end of string counting as non-digit), compared +// element by element as integers, DESCENDING — newer/larger versions +// first: "claude-opus-4-7" -> [4, 7] beats "claude-opus-4-5" -> [4, 5]; +// "gpt-5.5" -> [5, 5] beats "gpt-4o" -> [4]. Multi-digit runs (dates, +// "20300101") are intentionally not counted as version parts. +// 3. Full string lex order, DESCENDING, case-folded first then raw — keeps +// "GPT-4o" and "gpt-4o" adjacent while giving longer/later suffixes +// priority within an otherwise tied group. +export const compareModelIds = (a: string, b: string): number => { + const cmp = (x: T, y: T, dir = 1) => (x < y ? -dir : x > y ? dir : 0); + const prefix = (s: string) => /^[a-zA-Z]+/.exec(s)?.[0].toLowerCase() ?? ''; + const digits = (s: string) => [...s.matchAll(/(? +m[0]); + const [da, db] = [digits(a), digits(b)]; + return cmp(+a.includes('/'), +b.includes('/')) + || cmp(prefix(a), prefix(b)) + || (da.slice(0, Math.min(da.length, db.length)).map((v, i) => db[i] - v).find(d => d !== 0) ?? db.length - da.length) + || cmp(a.toLowerCase(), b.toLowerCase(), -1) + || cmp(a, b, -1); +}; + +// Catalog assembly against an already-resolved provider list. Callers that +// already paid the `listModelProviders` round-trip — the alias prelude +// shares its provider list across the alias resolver and the candidate +// walk — pass providers through to avoid the duplicate upstreams.list() +// DB query. +export const getModelsFromProviders = async ( + providers: readonly Provider[], + fetcherForUpstream: (upstreamId: string) => Fetcher, + scheduler: BackgroundScheduler, +): Promise<{ models: InternalModel[]; upstreamsByPublicId: Map; failedUpstreams: readonly string[] }> => { + if (providers.length === 0) { + throw new Error('No upstream provider configured — connect GitHub Copilot or add a Custom/Azure upstream in the dashboard'); + } + + const { models, upstreamsByPublicId, sawSuccess, lastError, failedUpstreams } = await collectProviderModels(providers, fetcherForUpstream, scheduler); + + // TODO: surface `failedUpstreams` on each listing endpoint's wire response + // so partial-listing failures reach clients. + if (sawSuccess) return { models: models.sort((a, b) => compareModelIds(a.id, b.id)), upstreamsByPublicId, failedUpstreams }; + if (lastError) throw lastError; + return { models: [], upstreamsByPublicId, failedUpstreams }; +}; diff --git a/packages/gateway/src/data-plane/providers/catalog_test.ts b/packages/gateway/src/data-plane/providers/catalog_test.ts new file mode 100644 index 0000000000..29970757dd --- /dev/null +++ b/packages/gateway/src/data-plane/providers/catalog_test.ts @@ -0,0 +1,632 @@ +import { describe, test } from 'vitest'; + +import { compareModelIds, getModelsFromProviders } from './catalog.ts'; +import { clearInFlightForTesting } from './models-cache.ts'; +import { listModelProviders } from './registry.ts'; +import { enumerateModelCandidates } from './resolution.ts'; +import { buildCustomUpstreamRecord, copilotModels, setupAppTest } from '../../test-utils/app.ts'; +import { directFetcher, type InternalModel, type ProviderModel } from '@floway-dev/provider'; +import { assertEquals, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; + +const realProviderModels = (model: InternalModel | undefined): Record => { + if (model?.providerModels === undefined) throw new Error(`expected real InternalModel with providerModels, got ${JSON.stringify(model)}`); + return model.providerModels; +}; + +const sortedIds = (ids: readonly string[]): string[] => [...ids].sort(compareModelIds); + +const testScheduler = (promise: Promise): void => { + promise.catch(err => console.error('[background]', err)); +}; + +test('compareModelIds pushes ids containing "/" to the tail', () => { + assertEquals(sortedIds(['accounts/msft/x', 'gpt-4o', 'accounts/msft/y', 'claude-opus-4-7']), [ + 'claude-opus-4-7', + 'gpt-4o', + // Within the slashed group, the remaining keys still apply: same alpha + // prefix "accounts", empty isolated-digit arrays, then descending lex. + 'accounts/msft/y', + 'accounts/msft/x', + ]); +}); + +test('compareModelIds groups by leading [a-zA-Z]+ prefix, case-insensitive ascending', () => { + // gpt and GPT collapse on key 1; their tied [4] digit array falls to + // descending lex (lowercased), so 'gpt-4o-mini' beats 'gpt-4o'. + assertEquals(sortedIds(['gpt-4o', 'claude-haiku-4-5', 'deepseek-v4-pro', 'GPT-4o-mini']), [ + 'claude-haiku-4-5', + 'deepseek-v4-pro', + 'GPT-4o-mini', + 'gpt-4o', + ]); +}); + +test('compareModelIds orders isolated single digits descending element by element', () => { + // Digit arrays: claude-opus-4-7 [4,7], claude-sonnet-4-6 [4,6], + // claude-opus-4-5 / claude-haiku-4-5 [4,5]. Within the [4,5] tie, lex + // descending picks 'claude-opus-4-5' over 'claude-haiku-4-5'. + assertEquals(sortedIds(['claude-opus-4-7', 'claude-opus-4-5', 'claude-haiku-4-5', 'claude-sonnet-4-6']), [ + 'claude-opus-4-7', + 'claude-sonnet-4-6', + 'claude-opus-4-5', + 'claude-haiku-4-5', + ]); +}); + +test('compareModelIds puts longer digit arrays before shorter ones (descending)', () => { + // [5,5] beats every [4]; within the tied-[4] group, descending lex on the + // full id puts 'gpt-4o' first, then 'gpt-4-turbo', then 'gpt-4' last. + assertEquals(sortedIds(['gpt-5.5', 'gpt-4', 'gpt-4o', 'gpt-4-turbo']), [ + 'gpt-5.5', + 'gpt-4o', + 'gpt-4-turbo', + 'gpt-4', + ]); +}); + +test('compareModelIds ignores multi-digit runs such as dates', () => { + // Both have digit array [4, 7]; descending lex tie-break puts the longer + // dated id first. + assertEquals(sortedIds(['claude-opus-4-7-20300101', 'claude-opus-4-7']), [ + 'claude-opus-4-7-20300101', + 'claude-opus-4-7', + ]); +}); + +test('compareModelIds sorts ids without a leading alpha prefix first', () => { + assertEquals(sortedIds(['gpt-4o', 'o1-mini', '128k-context-model']), [ + '128k-context-model', + 'gpt-4o', + 'o1-mini', + ]); +}); + +test('compareModelIds keeps case-only differences adjacent via lowercase tie-break', () => { + // All lowercase to 'gpt-4o' so case-folded lex ties; raw descending then + // picks lowercase letters before uppercase (g > G in ASCII). + assertEquals(sortedIds(['GPT-4o', 'gpt-4o', 'gpt-4O']), [ + 'gpt-4o', + 'gpt-4O', + 'GPT-4o', + ]); +}); + +test('catalog assembly returns the merged catalog plus the per-id upstream index', async () => { + const { repo } = await setupAppTest(); + + await repo.upstreams.save(buildCustomUpstreamRecord()); + await repo.upstreams.save(buildCustomUpstreamRecord({ id: 'up_disabled', enabled: false, sortOrder: 50 })); + + await withMockedFetch( + request => { + const url = new URL(request.url); + + if (url.hostname === 'update.code.visualstudio.com') { + return jsonResponse(['1.110.1']); + } + if (url.pathname === '/copilot_internal/v2/token') { + return jsonResponse({ + token: 'copilot-access-token', + expires_at: 4102444800, + refresh_in: 3600, + endpoints: { api: 'https://api.individual.githubcopilot.com' }, + }); + } + if (url.hostname === 'api.individual.githubcopilot.com' && url.pathname === '/models') { + return jsonResponse( + copilotModels([ + { + id: 'shared-model', + display_name: 'Shared Model', + supported_endpoints: ['/v1/messages'], + }, + ]), + ); + } + if (url.hostname === 'custom.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ + object: 'list', + data: [ + { + id: 'shared-model', + supported_endpoints: ['/chat/completions'], + }, + ], + }); + } + + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const { models, upstreamsByPublicId } = await getModelsFromProviders(await listModelProviders(null), () => directFetcher, testScheduler); + const model = models.find(candidate => candidate.id === 'shared-model'); + + assertEquals(model?.display_name, 'Shared Model'); + // The merged endpoint surface is the OR of both upstreams' endpoint maps. + assertEquals(model?.endpoints, { messages: {}, chatCompletions: {} }); + assertEquals(model?.kind, 'chat'); + // `providerData` (the per-provider wire id carrier) belongs to the + // provider-emitted ProviderModel, not the gateway-merged catalog row. + assertEquals(Object.hasOwn(model!, 'providerData'), false); + // The reverse index lists every upstream that surfaced this id, in + // enumeration order — copilot first, then custom. + assertEquals(upstreamsByPublicId.get('shared-model')?.map(p => p.upstreamId), ['up_copilot', 'up_custom']); + // Every contributing upstream keeps its own emitted `ProviderModel` + // verbatim under `providerModels[]` — merge unions the + // outer `endpoints` but never rewrites the per-upstream capability + // each provider originally advertised. + assertEquals(Object.keys(realProviderModels(model)).sort(), ['up_copilot', 'up_custom']); + assertEquals(realProviderModels(model)['up_copilot']?.endpoints, { messages: {} }); + assertEquals(realProviderModels(model)['up_custom']?.endpoints, { chatCompletions: {} }); + // `enabledFlags` is required on every ProviderModel — proves the + // stored value is the provider-emitted shape (not a projected + // subset). + assertEquals(realProviderModels(model)['up_copilot']?.enabledFlags instanceof Set, true); + assertEquals(realProviderModels(model)['up_custom']?.enabledFlags instanceof Set, true); + + const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'shared-model', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(resolved.candidates.map(m => m.provider.upstreamId), ['up_copilot', 'up_custom']); + // Each match carries its own per-provider endpoints — no merge. + assertEquals(resolved.candidates[0]?.model.endpoints, { messages: {} }); + assertEquals(resolved.candidates[1]?.model.endpoints, { chatCompletions: {} }); + // Each enumerated candidate seeds `providerModels[provider.upstreamId]` + // so `providerModelOf(candidate)` resolves at dispatch time. + assertEquals(Object.keys(realProviderModels(resolved.candidates[0]?.model)), ['up_copilot']); + assertEquals(Object.keys(realProviderModels(resolved.candidates[1]?.model)), ['up_custom']); + assertEquals(realProviderModels(resolved.candidates[0]?.model)['up_copilot']?.endpoints, { messages: {} }); + assertEquals(realProviderModels(resolved.candidates[1]?.model)['up_custom']?.endpoints, { chatCompletions: {} }); + }, + ); +}); + +test('disabledPublicModelIds hides models from the catalog and routing, per upstream', async () => { + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + + const azureUpstream = (over: { id: string; sortOrder: number; models: { upstreamModelId: string; publicModelId?: string }[]; disabledPublicModelIds: string[] }) => ({ + id: over.id, + kind: 'azure' as const, + name: over.id, + enabled: true, + sortOrder: over.sortOrder, + createdAt: '2026-05-21T00:00:00.000Z', + updatedAt: '2026-05-21T00:00:00.000Z', + config: { + endpoint: 'https://example.openai.azure.com', + apiKey: 'az-key', + models: over.models.map(m => ({ ...m, endpoints: { chatCompletions: {} } })), + }, + state: null, + flagOverrides: {}, + disabledPublicModelIds: over.disabledPublicModelIds, + proxyFallbackList: [], + modelPrefix: null, + color: null, + }); + + // up_a disables a solo model and a shared one (by public id, including a + // publicModelId override); up_b still serves the shared id, enabled. + await repo.upstreams.save(azureUpstream({ + id: 'up_a', + sortOrder: 1, + models: [ + { upstreamModelId: 'gpt-keep' }, + { upstreamModelId: 'gpt-solo' }, + { upstreamModelId: 'gpt-shared' }, + { upstreamModelId: 'dep-x', publicModelId: 'gpt-override' }, + ], + disabledPublicModelIds: ['gpt-solo', 'gpt-shared', 'gpt-override'], + })); + await repo.upstreams.save(azureUpstream({ + id: 'up_b', + sortOrder: 2, + models: [{ upstreamModelId: 'gpt-shared' }], + disabledPublicModelIds: [], + })); + + const catalog = (await getModelsFromProviders(await listModelProviders(null), () => directFetcher, testScheduler)).models; + assertEquals([...catalog.map(m => m.id)].sort(), ['gpt-keep', 'gpt-shared']); + + // The solo and override ids resolve to nothing (hidden + unroutable). + assertEquals((await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-solo', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' })).candidates.length, 0); + assertEquals((await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-override', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' })).candidates.length, 0); + + // The shared id survives because up_b allows it; only up_b binds it. + const shared = await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-shared', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(shared.candidates.map(m => m.provider.upstreamId), ['up_b']); + + // The untouched model still routes from up_a. + const keep = await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-keep', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(keep.candidates.map(m => m.provider.upstreamId), ['up_a']); +}); + +// Per-upstream catalog fetches fan out in parallel: total wall-clock time +// tracks the slowest upstream, not the sum. The bound is loose because CI +// timer noise eats into a tight `< sum` comparison; what matters is the +// ratio. +test('catalog assembly fans out per-upstream catalog fetches in parallel', async () => { + clearInFlightForTesting(); + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + + const FETCH_DELAY_MS = 60; + const upstreams = [ + { id: 'up_p1', host: 'p1.example.com', model: 'p1-model' }, + { id: 'up_p2', host: 'p2.example.com', model: 'p2-model' }, + { id: 'up_p3', host: 'p3.example.com', model: 'p3-model' }, + ]; + for (const [index, u] of upstreams.entries()) { + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: u.id, + name: u.id, + sortOrder: index, + config: { baseUrl: `https://${u.host}`, authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + })); + } + + await withMockedFetch( + async request => { + const url = new URL(request.url); + const match = upstreams.find(u => url.hostname === u.host); + if (match && url.pathname === '/v1/models') { + await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS)); + return jsonResponse({ object: 'list', data: [{ id: match.model, supported_endpoints: ['/chat/completions'] }] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const start = Date.now(); + const catalog = (await getModelsFromProviders(await listModelProviders(null), () => directFetcher, testScheduler)).models; + const elapsed = Date.now() - start; + + assertEquals([...catalog.map(m => m.id)].sort(), ['p1-model', 'p2-model', 'p3-model']); + // A serial walk would take >= 3 * FETCH_DELAY_MS; parallel is bounded by + // ~FETCH_DELAY_MS plus per-test overhead. Half the serial budget is the + // loosest threshold that still excludes any serial regression. + const serialBudget = upstreams.length * FETCH_DELAY_MS; + if (elapsed >= serialBudget / 2) { + throw new Error(`expected parallel walk (~${FETCH_DELAY_MS}ms) but took ${elapsed}ms (serial would be ${serialBudget}ms)`); + } + }, + ); +}); + +// A single upstream's catalog fetch failure is surfaced as `lastError` and +// recorded against `sawSuccess === true`; the public catalog still includes +// every successful upstream's models. +test('catalog assembly: a rejected provider does not block other providers', async () => { + clearInFlightForTesting(); + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_ok_1', + name: 'OK 1', + sortOrder: 1, + config: { baseUrl: 'https://ok1.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + })); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_broken', + name: 'Broken', + sortOrder: 2, + config: { baseUrl: 'https://broken.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + })); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_ok_2', + name: 'OK 2', + sortOrder: 3, + config: { baseUrl: 'https://ok2.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + })); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'ok1.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'ok-1-model', supported_endpoints: ['/chat/completions'] }] }); + } + if (url.hostname === 'ok2.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'ok-2-model', supported_endpoints: ['/chat/completions'] }] }); + } + if (url.hostname === 'broken.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ error: 'upstream went down' }, 502); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const catalog = (await getModelsFromProviders(await listModelProviders(null), () => directFetcher, testScheduler)).models; + assertEquals([...catalog.map(m => m.id)].sort(), ['ok-1-model', 'ok-2-model']); + }, + ); +}); + +// End-to-end listing checks for the prefix policy. The catalog walk goes +// through getModelsFromProviders, which threads custom upstreams' /v1/models +// responses through fetchUpstreamModelsCached just like production does. +describe('catalog listing under modelPrefix', () => { + test('null prefix lists bare ids only (today\'s behavior)', async () => { + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_plain', + sortOrder: 1, + config: { baseUrl: 'https://plain.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + })); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'plain.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const catalog = (await getModelsFromProviders(await listModelProviders(null), () => directFetcher, testScheduler)).models; + assertEquals(catalog.map(m => m.id), ['gpt-4o']); + }, + ); + }); + + test('listed=[prefixed] lists only the prefixed surface and routes the prefixed request to the upstream', async () => { + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_prefixed', + sortOrder: 1, + config: { baseUrl: 'https://prefixed.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + modelPrefix: { prefix: 'or/', addressable: ['prefixed'], listed: ['prefixed'] }, + })); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'prefixed.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const catalog = (await getModelsFromProviders(await listModelProviders(null), () => directFetcher, testScheduler)).models; + assertEquals(catalog.map(m => m.id), ['or/gpt-4o']); + // Prefixed surface gets a synthesized display_name prepending the + // upstream's display name so the dashboard tells the operator at a + // glance which upstream a prefixed entry came from. + assertEquals(catalog[0]?.display_name, 'Custom Provider: gpt-4o'); + + // Regression: with `listed: ['prefixed']` the catalog walk emits only + // the prefixed surface, so a byId-based routing lookup against the + // stripped bare id would miss. Routing must instead consult each + // scoped upstream's own catalog, where the bare id is always present. + const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'or/gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(resolved.candidates.map(m => m.provider.upstreamId), ['up_prefixed']); + assertEquals(resolved.candidates[0]?.model.id, 'gpt-4o'); + + // The bare-id request must NOT route to a prefix-only-addressable + // upstream, regardless of routing path. + const bare = await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(bare.candidates.length, 0); + }, + ); + }); + + test('addressable=[unprefixed, prefixed] + listed=[prefixed] routes both surface forms', async () => { + // The upstream is bare-id-addressable but only the prefixed form appears + // in /v1/models. Routing must still resolve a bare-id request via the + // upstream's own catalog (addressable=['unprefixed', 'prefixed'] keeps it + // in the candidate set), and a prefixed request via the prefix-strip + + // per-provider catalog lookup. + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_dual_addressable', + sortOrder: 1, + config: { baseUrl: 'https://dual.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + modelPrefix: { prefix: 'or/', addressable: ['unprefixed', 'prefixed'], listed: ['prefixed'] }, + })); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'dual.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const catalog = (await getModelsFromProviders(await listModelProviders(null), () => directFetcher, testScheduler)).models; + assertEquals(catalog.map(m => m.id), ['or/gpt-4o']); + + const bare = await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(bare.candidates.map(m => m.provider.upstreamId), ['up_dual_addressable']); + assertEquals(bare.candidates[0]?.model.id, 'gpt-4o'); + + // The prefixed request enumerates both forms against `up_dual_addressable`: + // the unprefixed lookup (`or/gpt-4o`) misses the upstream catalog, and + // the prefix-stripped lookup (`gpt-4o`) hits — yielding a single match. + const prefixed = await enumerateModelCandidates({ upstreamIds: null, model: 'or/gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(prefixed.candidates.map(m => m.provider.upstreamId), ['up_dual_addressable']); + assertEquals(prefixed.candidates[0]?.model.id, 'gpt-4o'); + }, + ); + }); + + test('listed=[unprefixed, prefixed] emits both surfaces, both upstreams enumerate on the shared bare id', async () => { + // up_plain has no prefix and lists `gpt-4o`. up_dual exposes both forms. + // The bare `gpt-4o` reaches both upstreams — the resolver enumerates + // candidates from every match; the `or/gpt-4o` surface belongs solely + // to up_dual because up_plain's catalog does not contain `or/gpt-4o`. + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_plain', + sortOrder: 1, + config: { baseUrl: 'https://plain.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + })); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_dual', + sortOrder: 2, + config: { baseUrl: 'https://dual.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + modelPrefix: { prefix: 'or/', addressable: ['unprefixed', 'prefixed'], listed: ['unprefixed', 'prefixed'] }, + })); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'plain.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }] }); + } + if (url.hostname === 'dual.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const catalog = (await getModelsFromProviders(await listModelProviders(null), () => directFetcher, testScheduler)).models; + assertEquals([...catalog.map(m => m.id)].sort(), ['gpt-4o', 'or/gpt-4o']); + + // Both upstreams enumerate against the bare id: up_plain via its only + // form, up_dual via its unprefixed-addressable branch. Order follows + // the configured sort_order across providers. + const bare = await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(bare.candidates.map(m => m.provider.upstreamId), ['up_plain', 'up_dual']); + + // The prefixed id resolves only against up_dual: up_plain's catalog + // does not contain `or/gpt-4o`, and up_dual's prefix-stripped lookup + // hits its catalog's bare `gpt-4o`. + const prefixed = await enumerateModelCandidates({ upstreamIds: null, model: 'or/gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(prefixed.candidates.map(m => m.provider.upstreamId), ['up_dual']); + }, + ); + }); + + test('dual-addressable upstream whose catalog literally lists both forms yields two candidates from one upstream', async () => { + // up_dual is `addressable: ['unprefixed', 'prefixed']` AND its catalog + // publishes both `gpt-4o` and `or/gpt-4o` as distinct entries. An + // inbound `or/gpt-4o` triggers BOTH branches at the same upstream: + // - unprefixed branch looks up `or/gpt-4o` → hits the literal `or/gpt-4o` + // catalog entry + // - prefixed branch looks up `gpt-4o` (after strip) → hits the `gpt-4o` + // catalog entry + // Both produce a candidate — no deduplication; the unprefixed branch + // pushes first, matching `cfg.addressable`'s `unprefixed`-before- + // `prefixed` iteration order (see `FORM_ORDER` in `model-prefix.ts`). + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_dual', + sortOrder: 1, + config: { baseUrl: 'https://dual.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + modelPrefix: { prefix: 'or/', addressable: ['unprefixed', 'prefixed'], listed: ['unprefixed', 'prefixed'] }, + })); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'dual.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ + object: 'list', + data: [ + { id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }, + { id: 'or/gpt-4o', supported_endpoints: ['/chat/completions'] }, + ], + }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'or/gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(resolved.candidates.length, 2); + assertEquals(resolved.candidates.map(c => c.provider.upstreamId), ['up_dual', 'up_dual']); + // The unprefixed branch hits the `or/gpt-4o` literal entry first; + // the prefixed branch's strip hits the bare `gpt-4o` entry. + assertEquals(resolved.candidates.map(c => c.model.id), ['or/gpt-4o', 'gpt-4o']); + }, + ); + }); + + test('disabledPublicModelIds hides both bare and prefixed forms from the originating upstream', async () => { + // up_dual exposes `gpt-4o` and `gpt-mini` under both forms, but the + // operator disabled `gpt-4o` on this upstream. Neither `gpt-4o` nor + // `or/gpt-4o` survives from up_dual; `gpt-mini` and `or/gpt-mini` stay. + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_dual', + sortOrder: 1, + config: { baseUrl: 'https://dual.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + modelPrefix: { prefix: 'or/', addressable: ['unprefixed', 'prefixed'], listed: ['unprefixed', 'prefixed'] }, + disabledPublicModelIds: ['gpt-4o'], + })); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'dual.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ + object: 'list', + data: [ + { id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }, + { id: 'gpt-mini', supported_endpoints: ['/chat/completions'] }, + ], + }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const catalog = (await getModelsFromProviders(await listModelProviders(null), () => directFetcher, testScheduler)).models; + assertEquals([...catalog.map(m => m.id)].sort(), ['gpt-mini', 'or/gpt-mini']); + }, + ); + }); + + // Regression for the three-upstream case the routing-primitive refactor was + // motivated by. The same public id `aa/bb/gpt-5` is reachable through three + // configured paths: an `aa/`-prefixed upstream whose catalog carries the id + // `bb/gpt-5`, a longer `aa/bb/`-prefixed upstream whose catalog carries the + // id `gpt-5`, and a bare upstream whose catalog literally carries + // `aa/bb/gpt-5`. Every upstream must enumerate as an independent match — + // an earlier iteration of the resolver returned only the first match and + // would have shadowed two of them. + test('three upstreams advertising the same public id via different paths all enumerate as matches', async () => { + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_short_prefix', + sortOrder: 1, + config: { baseUrl: 'https://short.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + modelPrefix: { prefix: 'aa/', addressable: ['prefixed'], listed: ['prefixed'] }, + })); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_long_prefix', + sortOrder: 2, + config: { baseUrl: 'https://long.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + modelPrefix: { prefix: 'aa/bb/', addressable: ['prefixed'], listed: ['prefixed'] }, + })); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_bare', + sortOrder: 3, + config: { baseUrl: 'https://bare.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + })); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'short.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'bb/gpt-5', supported_endpoints: ['/chat/completions'] }] }); + } + if (url.hostname === 'long.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'gpt-5', supported_endpoints: ['/chat/completions'] }] }); + } + if (url.hostname === 'bare.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'aa/bb/gpt-5', supported_endpoints: ['/chat/completions'] }] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'aa/bb/gpt-5', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(resolved.candidates.map(m => m.provider.upstreamId), ['up_short_prefix', 'up_long_prefix', 'up_bare']); + assertEquals(resolved.candidates.map(m => m.model.id), ['bb/gpt-5', 'gpt-5', 'aa/bb/gpt-5']); + }, + ); + }); +}); diff --git a/packages/gateway/src/data-plane/providers/custom-provider_test.ts b/packages/gateway/src/data-plane/providers/custom-provider_test.ts deleted file mode 100644 index d72112fa6e..0000000000 --- a/packages/gateway/src/data-plane/providers/custom-provider_test.ts +++ /dev/null @@ -1,446 +0,0 @@ -import { test } from 'vitest'; - -import { buildCustomUpstreamRecord, setupAppTest } from '../../test-helpers.ts'; -import { directFetcher } from '@floway-dev/provider'; -import type { UpstreamRecord } from '@floway-dev/provider'; -import { createCustomProvider } from '@floway-dev/provider-custom'; -import { jsonResponse, noopUpstreamCallOptions, sseResponse, withMockedFetch, assertEquals, assertExists } from '@floway-dev/test-utils'; - -const baseRecord = (overrides: Partial = {}): UpstreamRecord => ({ - id: 'up_custom_test', - kind: 'custom', - name: 'Custom Test', - enabled: true, - sortOrder: 0, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - flagOverrides: {}, - disabledPublicModelIds: [], - proxyFallbackList: [], - modelPrefix: null, - color: null, - config: { - baseUrl: 'https://custom.example.com', - authStyle: 'bearer', - apiKey: 'sk-test', - endpoints: { chatCompletions: {}, responses: {}, messages: {} }, - }, - state: null, - ...overrides, -}); - -test('Custom provider forces stream=true for streaming endpoints and leaves count-tokens/embeddings alone', async () => { - const instance = createCustomProvider(baseRecord()); - const provider = instance.instance; - const bodies: Record> = {}; - - await withMockedFetch( - async request => { - const url = new URL(request.url); - const path = url.pathname; - - if (path === '/v1/models') { - return jsonResponse({ - object: 'list', - data: [{ id: 'echo', object: 'model' }], - }); - } - - bodies[path] = (await request.json()) as Record; - - if (path === '/v1/chat/completions') { - return sseResponse(); - } - if (path === '/v1/responses') { - return sseResponse(); - } - if (path === '/v1/messages') { - return sseResponse(); - } - if (path === '/v1/messages/count_tokens') { - return jsonResponse({ input_tokens: 1 }); - } - if (path === '/v1/embeddings') { - return jsonResponse({ object: 'list', data: [], model: 'echo' }); - } - - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const [providerModel] = await provider.getProvidedModels(directFetcher); - assertEquals(providerModel.id, 'echo'); - const model = providerModel; const opts = noopUpstreamCallOptions(); - - await provider.callChatCompletions(model, { messages: [{ role: 'user', content: 'hi' }] }, undefined, opts); - await provider.callResponses(model, { input: [] }, 'generate', undefined, opts); - await provider.callMessages(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, opts); - await provider.callMessagesCountTokens(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, opts); - await provider.callEmbeddings(model, { input: 'hi' }, undefined, opts); - }, - ); - - assertEquals(bodies['/v1/chat/completions'].stream, true); - assertEquals(bodies['/v1/responses'].stream, true); - assertEquals(bodies['/v1/messages'].stream, true); - assertEquals('stream' in bodies['/v1/messages/count_tokens'], false); - assertEquals('stream' in bodies['/v1/embeddings'], false); -}); - -test('Custom provider uses configured endpoints regardless of per-model hints in the /models response', async () => { - await setupAppTest(); - - await withMockedFetch( - () => jsonResponse({ - object: 'list', - data: [{ id: 'm-1', supported_endpoints: ['/some/random/path'] }], - }), - async () => { - const provider = createCustomProvider(baseRecord({ - id: 'up_custom_endpoints', - config: { - baseUrl: 'https://custom.example.com', - authStyle: 'bearer', - apiKey: 'sk-test', - endpoints: { chatCompletions: {} }, - }, - })).instance; - const [model] = await provider.getProvidedModels(directFetcher); - assertEquals(model.endpoints, { chatCompletions: {} }); - assertEquals(model.kind, 'chat'); - }, - ); -}); - -test('Custom provider projects display_name / created / limits / pricing from a Floway-style /models response', async () => { - await setupAppTest(); - - await withMockedFetch( - () => jsonResponse({ - object: 'list', - data: [{ - id: 'm-rich', - type: 'model', - display_name: 'Rich Model', - created_at: '2026-04-01T00:00:00Z', - limits: { max_output_tokens: 8192, max_context_window_tokens: 200000 }, - pricing: { entries: [{ rates: { input_tokens: '3', output_tokens: '15', input_cache_read_tokens: '0.3' } }] }, - }], - }), - async () => { - const instance = createCustomProvider(baseRecord({ id: 'up_custom_rich' })); - const [model] = await instance.instance.getProvidedModels(directFetcher); - assertEquals(model.display_name, 'Rich Model'); - assertEquals(model.created, Math.floor(Date.parse('2026-04-01T00:00:00Z') / 1000)); - assertEquals(model.limits.max_output_tokens, 8192); - assertEquals(model.limits.max_context_window_tokens, 200000); - assertEquals(model.pricing?.entries[0]?.rates.input_tokens, '3'); - assertEquals(model.pricing?.entries[0]?.rates.output_tokens, '15'); - assertEquals(model.pricing?.entries[0]?.rates.input_cache_read_tokens, '0.3'); - }, - ); -}); - -test('Custom provider falls back to `name` when display_name is missing (loose OpenAI-compat upstreams)', async () => { - await setupAppTest(); - - await withMockedFetch( - () => jsonResponse({ object: 'list', data: [{ id: 'm-named', name: 'Named Model' }] }), - async () => { - const [model] = await createCustomProvider(baseRecord({ id: 'up_custom_named' })).instance.getProvidedModels(directFetcher); - assertEquals(model.display_name, 'Named Model'); - }, - ); -}); - -test('Custom provider projects gpt-image-* models with kind=image and both image endpoints', async () => { - await setupAppTest(); - const record = buildCustomUpstreamRecord({ - config: { baseUrl: 'https://custom.example.com', authStyle: 'bearer', apiKey: 'sk-custom', endpoints: { chatCompletions: {} } }, - }); - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.pathname === '/v1/models') { - return jsonResponse({ data: [{ id: 'gpt-image-2-2026-04-21' }] }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const provider = createCustomProvider(record).instance; - const models = await provider.getProvidedModels(directFetcher); - assertEquals(models.length, 1); - assertEquals(models[0].id, 'gpt-image-2-2026-04-21'); - assertEquals(models[0].kind, 'image'); - assertEquals(models[0].endpoints, { imagesGenerations: {}, imagesEdits: {} }); - }, - ); -}); - -test('Custom provider callImagesGenerations posts JSON with model re-injected', async () => { - await setupAppTest(); - const record = buildCustomUpstreamRecord({ - config: { baseUrl: 'https://custom.example.com', authStyle: 'bearer', apiKey: 'sk-custom', endpoints: { chatCompletions: {} } }, - }); - let forwarded: { url: string; body: { model?: unknown; prompt?: unknown } } | undefined; - await withMockedFetch( - async request => { - const url = new URL(request.url); - if (url.pathname === '/v1/models') return jsonResponse({ data: [{ id: 'gpt-image-2' }] }); - if (url.pathname === '/v1/images/generations') { - forwarded = { url: request.url, body: await request.json() as Record }; - return jsonResponse({ data: [{ b64_json: 'abc' }], usage: { input_tokens: 10, output_tokens: 50 } }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const provider = createCustomProvider(record); - const models = await provider.instance.getProvidedModels(directFetcher); - const model = models[0]; const opts = noopUpstreamCallOptions(); - const result = await provider.instance.callImagesGenerations(model, { prompt: 'hi' }, undefined, opts); - assertEquals(result.modelKey, 'gpt-image-2'); - assertEquals(result.response.status, 200); - }, - ); - assertExists(forwarded); - assertEquals(forwarded.body.model, 'gpt-image-2'); - assertEquals(forwarded.body.prompt, 'hi'); -}); - -test('Custom provider callImagesEdits forwards multipart body with model field appended', async () => { - await setupAppTest(); - const record = buildCustomUpstreamRecord({ - config: { baseUrl: 'https://custom.example.com', authStyle: 'bearer', apiKey: 'sk-custom', endpoints: { chatCompletions: {} } }, - }); - let forwarded: { url: string; form: FormData } | undefined; - await withMockedFetch( - async request => { - const url = new URL(request.url); - if (url.pathname === '/v1/models') return jsonResponse({ data: [{ id: 'gpt-image-2' }] }); - if (url.pathname === '/v1/images/edits') { - forwarded = { url: request.url, form: await request.formData() }; - return jsonResponse({ data: [{ b64_json: 'abc' }], usage: { input_tokens: 5, output_tokens: 20 } }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const provider = createCustomProvider(record); - const models = await provider.instance.getProvidedModels(directFetcher); - const model = models[0]; const opts = noopUpstreamCallOptions(); - const result = await provider.instance.callImagesEdits(model, { - parameters: { prompt: 'add a kite' }, - images: [{ - type: 'upload', - file: new File([new Uint8Array([1, 2, 3])], 'photo.png', { type: 'image/png' }), - }], - }, undefined, opts); - assertEquals(result.modelKey, 'gpt-image-2'); - assertEquals(result.response.status, 200); - }, - ); - assertExists(forwarded); - assertEquals(forwarded.form.get('model'), 'gpt-image-2'); - assertEquals(forwarded.form.get('prompt'), 'add a kite'); - assertEquals(forwarded.form.get('image') instanceof File, true); -}); - -test('Custom provider callAudioTranscriptions preserves multipart entries and honors the path override', async () => { - await setupAppTest(); - const record = buildCustomUpstreamRecord({ - config: { - baseUrl: 'https://custom.example.com', - authStyle: 'bearer', - apiKey: 'sk-custom', - endpoints: {}, - pathOverrides: { '/audio/transcriptions': '/speech/to-text' }, - modelsFetch: { enabled: false }, - models: [{ upstreamModelId: 'whisper-upstream', kind: 'transcription', endpoints: { audioTranscriptions: {} } }], - }, - }); - let forwarded: { url: string; form: FormData } | undefined; - await withMockedFetch( - async request => { - forwarded = { url: request.url, form: await request.formData() }; - return jsonResponse({ text: 'hello' }); - }, - async () => { - const provider = createCustomProvider(record); - const [model] = await provider.instance.getProvidedModels(directFetcher); - const result = await provider.instance.callAudioTranscriptions(model, { - entries: [ - { name: 'file', value: new File([new Uint8Array([7, 8])], 'voice.ogg', { type: 'audio/ogg' }) }, - { name: 'model', value: 'public-model' }, - { name: 'language', value: 'en' }, - ], - }, undefined, noopUpstreamCallOptions()); - assertEquals(result.modelKey, 'whisper-upstream'); - }, - ); - assertExists(forwarded); - assertEquals(forwarded.url, 'https://custom.example.com/speech/to-text'); - assertEquals(forwarded.form.get('model'), 'whisper-upstream'); - assertEquals(forwarded.form.get('language'), 'en'); - const file = forwarded.form.get('file'); - assertEquals(file instanceof File, true); - assertEquals((file as File).name, 'voice.ogg'); - assertEquals((file as File).type, 'audio/ogg'); -}); - -test('Custom provider callAlphaSearch posts JSON to /v1/alpha/search with the upstream model', async () => { - await setupAppTest(); - const record = buildCustomUpstreamRecord({ - config: { baseUrl: 'https://custom.example.com', authStyle: 'bearer', apiKey: 'sk-custom', endpoints: { responses: {} } }, - }); - let forwarded: { url: string; body: Record } | undefined; - await withMockedFetch( - async request => { - const url = new URL(request.url); - if (url.pathname === '/v1/models') return jsonResponse({ data: [{ id: 'gpt-search' }] }); - if (url.pathname === '/v1/alpha/search') { - forwarded = { url: request.url, body: await request.json() as Record }; - return jsonResponse({ encrypted_output: null, output: 'result', results: [] }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const provider = createCustomProvider(record); - const model = (await provider.instance.getProvidedModels(directFetcher))[0]; - const result = await provider.instance.callAlphaSearch( - model, - { id: 'search-session', commands: { search_query: [{ q: 'Floway' }] } }, - undefined, - noopUpstreamCallOptions(), - ); - assertEquals(result.response.status, 200); - assertEquals(result.modelKey, 'gpt-search'); - }, - ); - assertEquals(forwarded, { - url: 'https://custom.example.com/v1/alpha/search', - body: { - id: 'search-session', - commands: { search_query: [{ q: 'Floway' }] }, - model: 'gpt-search', - }, - }); -}); - -test('Custom provider with modelsFetch disabled serves only manual models and never fetches', async () => { - await setupAppTest(); - - await withMockedFetch( - () => { throw new Error('upstream /models must not be fetched when modelsFetch is disabled'); }, - async () => { - const provider = createCustomProvider(baseRecord({ - id: 'up_custom_manual_only', - config: { - baseUrl: 'https://custom.example.com', - authStyle: 'bearer', - apiKey: 'sk-test', - endpoints: { chatCompletions: {} }, - modelsFetch: { enabled: false }, - models: [ - { - upstreamModelId: 'pinned-chat', - publicModelId: 'pinned', - endpoints: { chatCompletions: {} }, - display_name: 'Pinned Chat', - limits: { max_output_tokens: 4096 }, - pricing: { entries: [{ rates: { input_tokens: '1', output_tokens: '2' } }] }, - }, - ], - }, - })).instance; - - const models = await provider.getProvidedModels(directFetcher); - assertEquals(models.length, 1); - assertEquals(models[0].id, 'pinned'); - assertEquals(models[0].kind, 'chat'); - assertEquals(models[0].endpoints, { chatCompletions: {} }); - assertEquals(models[0].display_name, 'Pinned Chat'); - assertEquals(models[0].limits.max_output_tokens, 4096); - assertEquals(models[0].pricing?.entries[0]?.rates.input_tokens, '1'); - - }, - ); -}); - -test('Custom provider with a manual override sharing an upstream id wins over the auto copy', async () => { - await setupAppTest(); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.pathname === '/v1/models') { - return jsonResponse({ - object: 'list', - data: [ - { id: 'shared', pricing: { entries: [{ rates: { input_tokens: '9', output_tokens: '9' } }] } }, - { id: 'auto-only' }, - ], - }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const provider = createCustomProvider(baseRecord({ - id: 'up_custom_override', - config: { - baseUrl: 'https://custom.example.com', - authStyle: 'bearer', - apiKey: 'sk-test', - endpoints: { chatCompletions: {} }, - modelsFetch: { enabled: true }, - models: [ - { - upstreamModelId: 'shared', - endpoints: { chatCompletions: {} }, - display_name: 'Manual Shared', - pricing: { entries: [{ rates: { input_tokens: '1', output_tokens: '2' } }] }, - }, - ], - }, - })).instance; - - const models = await provider.getProvidedModels(directFetcher); - // [manual, ...autoFiltered] — the upstream 'shared' copy is dropped. - assertEquals(models.map(m => m.id), ['shared', 'auto-only']); - const shared = models.find(m => m.id === 'shared'); - assertExists(shared); - assertEquals(shared.display_name, 'Manual Shared'); - assertEquals(shared.pricing?.entries[0]?.rates.input_tokens, '1'); - assertEquals(shared.pricing?.entries[0]?.rates.output_tokens, '2'); - assertEquals(models.find(model => model.id === 'auto-only')?.pricing, undefined); - }, - ); -}); - -test('Custom provider forwards inbound anthropic-beta header through opts.headers', async () => { - const instance = createCustomProvider(baseRecord()); - const provider = instance.instance; - const seen: Array = []; - - await withMockedFetch( - request => { - const path = new URL(request.url).pathname; - if (path === '/v1/models') return jsonResponse({ object: 'list', data: [{ id: 'echo', object: 'model' }] }); - seen.push(request.headers.get('anthropic-beta')); - if (path === '/v1/messages') return sseResponse(); - if (path === '/v1/messages/count_tokens') return jsonResponse({ input_tokens: 1 }); - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const [providerModel] = await provider.getProvidedModels(directFetcher); - const model = providerModel; const opts = noopUpstreamCallOptions(); - // The data plane plumbs `anthropic-beta` straight through `opts.headers`; - // custom upstreams register no filter interceptor, so whatever arrives on - // `opts.headers` is what the wire sees. - await provider.callMessages(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, { ...opts, headers: new Headers({ 'anthropic-beta': 'oauth-2025-04-20,interleaved-thinking-2025-05-14' }) }); - await provider.callMessagesCountTokens(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, { ...opts, headers: new Headers({ 'anthropic-beta': 'oauth-2025-04-20' }) }); - // Empty inbound headers must not emit an anthropic-beta header on the wire. - await provider.callMessages(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, opts); - await provider.callMessages(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, opts); - }, - ); - - assertEquals(seen, ['oauth-2025-04-20,interleaved-thinking-2025-05-14', 'oauth-2025-04-20', null, null]); -}); diff --git a/packages/gateway/src/data-plane/providers/models-cache.ts b/packages/gateway/src/data-plane/providers/models-cache.ts index 01fa98276d..62ab26f9c0 100644 --- a/packages/gateway/src/data-plane/providers/models-cache.ts +++ b/packages/gateway/src/data-plane/providers/models-cache.ts @@ -72,7 +72,7 @@ export const fetchUpstreamModelsCached = async ( opts: ModelsCacheFetchOptions, ): Promise => { const { scheduler, fetcher, force } = opts; - const key = instance.upstream; + const key = instance.upstreamId; const now = Date.now(); if (force) { diff --git a/packages/gateway/src/data-plane/providers/models-cache_test.ts b/packages/gateway/src/data-plane/providers/models-cache_test.ts index 98b3c3c685..fc363fd1f6 100644 --- a/packages/gateway/src/data-plane/providers/models-cache_test.ts +++ b/packages/gateway/src/data-plane/providers/models-cache_test.ts @@ -12,7 +12,7 @@ const stubInstance = ( upstreamId: string, fetchFn: () => Promise, ): Provider => ({ - upstream: upstreamId, + upstreamId, kind: 'custom', name: upstreamId, disabledPublicModelIds: [], diff --git a/packages/gateway/src/data-plane/providers/registry.ts b/packages/gateway/src/data-plane/providers/registry.ts index 3b590b5019..b901596777 100644 --- a/packages/gateway/src/data-plane/providers/registry.ts +++ b/packages/gateway/src/data-plane/providers/registry.ts @@ -1,42 +1,19 @@ -import { isEqual } from 'es-toolkit'; - -import { unionEndpoints } from './endpoint-union.ts'; -import { fetchUpstreamModelsCached } from './models-cache.ts'; -import { createPerRequestFetcher } from '../../dial/per-request.ts'; import { getRepo } from '../../repo/index.ts'; -import type { ModelAliasRecord } from '../../repo/types.ts'; -import type { BackgroundScheduler } from '@floway-dev/platform'; -import { type ModelKind, kindForEndpoints } from '@floway-dev/protocols/common'; -import { isAbortError, type Fetcher, type FlagDefaults, type InternalModel, type ModelCandidate, type Provider, type ProviderModel, type ProviderModule, type UpstreamProviderKind, type UpstreamRecord } from '@floway-dev/provider'; -import { azureProvider } from '@floway-dev/provider-azure'; -import { claudeCodeProvider } from '@floway-dev/provider-claude-code'; -import { codexProvider } from '@floway-dev/provider-codex'; -import { copilotProvider } from '@floway-dev/provider-copilot'; -import { customProvider } from '@floway-dev/provider-custom'; -import { ollamaProvider } from '@floway-dev/provider-ollama'; - -interface ProviderModelsResult { - models: InternalModel[]; - // Reverse index: every upstream instance that emitted an entry under the - // given public id, in enumeration order. The control-plane catalog - // endpoint reads this to render `upstreams: [{kind, id, name}]` per row; - // the alias listing reads it to project per-target upstream chips. - upstreamsByPublicId: Map; - sawSuccess: boolean; - lastError: unknown; - // Upstream names whose catalog fetch rejected this round, in the same - // order as the input `providers` list so the model-missing renderer can - // surface a stable, dashboard-aligned list. - failedUpstreams: string[]; -} +import type { FlagDefaults, Provider, ProviderModule, UpstreamProviderKind, UpstreamRecord } from '@floway-dev/provider'; +import { azureProviderModule } from '@floway-dev/provider-azure'; +import { claudeCodeProviderModule } from '@floway-dev/provider-claude-code'; +import { codexProviderModule } from '@floway-dev/provider-codex'; +import { copilotProviderModule } from '@floway-dev/provider-copilot'; +import { customProviderModule } from '@floway-dev/provider-custom'; +import { ollamaProviderModule } from '@floway-dev/provider-ollama'; const providersByKind: Record = { - copilot: copilotProvider, - custom: customProvider, - azure: azureProvider, - codex: codexProvider, - 'claude-code': claudeCodeProvider, - ollama: ollamaProvider, + copilot: copilotProviderModule, + custom: customProviderModule, + azure: azureProviderModule, + codex: codexProviderModule, + 'claude-code': claudeCodeProviderModule, + ollama: ollamaProviderModule, }; export const createProvider = (record: UpstreamRecord): Provider => @@ -45,8 +22,8 @@ export const createProvider = (record: UpstreamRecord): Provider => export const flagDefaultsForKind = (kind: UpstreamProviderKind): FlagDefaults => providersByKind[kind].defaultFlags; -// The upstream scope is a required argument across the catalog-assembly chain -// (this, getModels) so a caller can never omit it and silently receive the +// The upstream scope is a required argument across the provider-listing boundary +// this so a caller can never omit it and silently receive the // full, unscoped catalog — a missing scope is a compile error, not a runtime // leak. Pass `null` to deliberately request every enabled upstream. // @@ -84,445 +61,3 @@ export const listModelProviders = async ( return selection.map(createProvider); }; - -// Lift a provider-emitted `ProviderModel` into an `InternalModel`, seeding -// `providerModels` with the sole entry keyed on the emitting upstream id. -// The provider model is stored verbatim under that entry so dispatch hands -// the same reference back to the provider's `callXxx`. -const internalModelFromProviderModel = (providerModel: ProviderModel, upstreamId: string): InternalModel => { - const { providerData, enabledFlags, flagOverrides, rerankTarget, endpoints, ...metadata } = providerModel; - return { - ...metadata, - endpoints: { ...endpoints }, - providerModels: { [upstreamId]: providerModel }, - }; -}; - -// When multiple upstreams expose the same public model id, the first wins -// for `/models` metadata and later ones union-merge their endpoint capability -// map — the merged `endpoints` is the gateway-wide reach for that public id. -// `kind` is recomputed from the union so a chat-only id that later acquires -// an embedding-capable upstream gets correctly reclassified. Each contribution -// adds its own entry to `providerModels` keyed on the contributing upstream id -// with the emitted `ProviderModel` stored verbatim, so the same public id -// carrying data from N upstreams ends up with N entries. The reverse index -// `upstreamsByPublicId` accumulates every upstream that surfaced the id, in -// enumeration order, so the control plane can render its per-model upstream -// chips without re-walking the catalog. -const mergeIntoCatalog = ( - byId: Map, - upstreamsByPublicId: Map, - instance: Provider, - surfacedModel: ProviderModel, - publicId: string, -): void => { - const existing = byId.get(publicId); - if (!existing) { - byId.set(publicId, internalModelFromProviderModel(surfacedModel, instance.upstream)); - upstreamsByPublicId.set(publicId, [instance]); - return; - } - // The catalog only stores real (upstream-backed) rows; alias-synthesized - // rows join the caller-facing catalog downstream via `mergeAliasesIntoModels`. - // Narrow off the discriminated union so the merge below sees a concrete - // `providerModels` map. - if (existing.providerModels === undefined) { - throw new Error(`mergeIntoCatalog: catalog row for '${publicId}' unexpectedly carries aliasedFrom instead of providerModels`); - } - const endpoints = unionEndpoints([existing.endpoints, surfacedModel.endpoints]); - byId.set(publicId, { - ...existing, - endpoints, - kind: kindForEndpoints(endpoints), - providerModels: { - ...existing.providerModels, - [instance.upstream]: surfacedModel, - }, - }); - // We're on the merge branch (`existing !== undefined`), so the parallel - // `upstreamsByPublicId` entry was populated by the earlier insertion branch - // and must exist. - const instances = upstreamsByPublicId.get(publicId); - if (instances === undefined) throw new Error(`invariant broken: upstreamsByPublicId missing ${publicId}`); - instances.push(instance); -}; - -const collectProviderModels = async ( - providers: readonly Provider[], - fetcherForUpstream: (upstreamId: string) => Fetcher, - scheduler: BackgroundScheduler, -): Promise => { - const byId = new Map(); - const upstreamsByPublicId = new Map(); - let sawSuccess = false; - let lastError: unknown = null; - const failedUpstreams: string[] = []; - - // Fan out per-upstream so a slow provider does not stall the rest. The SWR - // cache layer dedupes concurrent in-flight fetches per upstream and serves - // the SOFT-fresh row without an upstream round trip, so the parallel walk - // is cheap on the warm path and bounded by `max(per-upstream fetch)` on - // the cold path. - const fetchOne = (instance: Provider) => - fetchUpstreamModelsCached(instance, { - scheduler, - fetcher: fetcherForUpstream(instance.upstream), - }).then(models => ({ instance, models })); - - const settled = await Promise.allSettled(providers.map(fetchOne)); - - for (const [index, result] of settled.entries()) { - if (result.status === 'rejected') { - // Caller-driven cancellation must propagate. Burying it in lastError - // and letting an earlier sawSuccess return a partially-populated - // model list would mask the abort and let the rest of the data-plane - // request build a Response against a stale catalog. `isAbortError` - // walks the cause chain so an AbortError wrapped inside - // ProviderModelsUnavailableError still surfaces here. - const error = result.reason; - if (isAbortError(error)) throw error; - lastError = error; - failedUpstreams.push(providers[index].name); - continue; - } - sawSuccess = true; - const { instance, models: providedModels } = result.value; - // Operator-disabled public model ids vanish entirely for this upstream: - // dropped before they reach the catalog map, so they appear in no /models - // listing and resolve to nothing for routing. The disable is per-upstream, - // so the same id can still surface from another upstream that allows it. - // The disable matches against the bare upstream id, so a disabled `gpt-4o` - // hides both `gpt-4o` and `gpt-4o` from this upstream's - // contribution. - const disabled = new Set(instance.disabledPublicModelIds); - for (const providerModel of providedModels) { - if (!providerModel.id) continue; - if (disabled.has(providerModel.id)) continue; - - // Each surface form the upstream chose to list becomes its own catalog - // entry. The unprefixed surface keeps the original ProviderModel; the - // prefixed surface uses a shallow clone with the rewritten id and a - // synthesized display_name that prepends the upstream name (so the - // dashboard tells the operator at a glance which upstream a prefixed - // model came from). `providerData` (where the per-provider call reads - // the real upstream model id) is untouched by the clone. - const cfg = instance.modelPrefix; - if (cfg !== null) { - for (const form of cfg.listed) { - const publicId = form === 'prefixed' ? `${cfg.prefix}${providerModel.id}` : providerModel.id; - const surfacedModel: ProviderModel = form === 'prefixed' - ? { ...providerModel, id: publicId, display_name: `${instance.name}: ${providerModel.display_name ?? providerModel.id}` } - : providerModel; - mergeIntoCatalog(byId, upstreamsByPublicId, instance, surfacedModel, publicId); - } - } else { - mergeIntoCatalog(byId, upstreamsByPublicId, instance, providerModel, providerModel.id); - } - } - } - - return { models: [...byId.values()], upstreamsByPublicId, sawSuccess, lastError, failedUpstreams }; -}; - -// Public-facing model-id ordering, applied in getModels() to every list that -// crosses a gateway boundary (data-plane /v1/models, /models, /v1beta/models -// and the control-plane /api/models that backs the dashboard models page). -// Provider upstreams return models in arbitrary order; sorting here gives the -// dashboard and downstream clients a stable, family-grouped view. -// -// Sort keys, evaluated in order: -// 0. Whether the id contains a '/'. Slashed ids (Microsoft Foundry router -// model ids like "accounts/msft/routers/x") are pushed to the tail so -// the typical flat ids stay on top. -// 1. Leading [a-zA-Z]+ prefix, case-insensitive, ascending. Groups model -// families: "claude-haiku-4-5" -> "claude", "deepseek-v4-pro" -> -// "deepseek". -// 2. Array of isolated single digits (a digit surrounded on both sides by a -// non-digit, with start/end of string counting as non-digit), compared -// element by element as integers, DESCENDING — newer/larger versions -// first: "claude-opus-4-7" -> [4, 7] beats "claude-opus-4-5" -> [4, 5]; -// "gpt-5.5" -> [5, 5] beats "gpt-4o" -> [4]. Multi-digit runs (dates, -// "20300101") are intentionally not counted as version parts. -// 3. Full string lex order, DESCENDING, case-folded first then raw — keeps -// "GPT-4o" and "gpt-4o" adjacent while giving longer/later suffixes -// priority within an otherwise tied group. -export const compareModelIds = (a: string, b: string): number => { - const cmp = (x: T, y: T, dir = 1) => (x < y ? -dir : x > y ? dir : 0); - const prefix = (s: string) => /^[a-zA-Z]+/.exec(s)?.[0].toLowerCase() ?? ''; - const digits = (s: string) => [...s.matchAll(/(? +m[0]); - const [da, db] = [digits(a), digits(b)]; - return cmp(+a.includes('/'), +b.includes('/')) - || cmp(prefix(a), prefix(b)) - || (da.slice(0, Math.min(da.length, db.length)).map((v, i) => db[i] - v).find(d => d !== 0) ?? db.length - da.length) - || cmp(a.toLowerCase(), b.toLowerCase(), -1) - || cmp(a, b, -1); -}; - -// Catalog assembly against an already-resolved provider list. Callers that -// already paid the `listModelProviders` round-trip — the alias prelude -// shares its provider list across the alias resolver and the candidate -// walk — pass providers through to avoid the duplicate upstreams.list() -// DB query. -export const getModelsFromProviders = async ( - providers: readonly Provider[], - fetcherForUpstream: (upstreamId: string) => Fetcher, - scheduler: BackgroundScheduler, -): Promise<{ models: InternalModel[]; upstreamsByPublicId: Map; failedUpstreams: readonly string[] }> => { - if (providers.length === 0) { - throw new Error('No upstream provider configured — connect GitHub Copilot or add a Custom/Azure upstream in the dashboard'); - } - - const { models, upstreamsByPublicId, sawSuccess, lastError, failedUpstreams } = await collectProviderModels(providers, fetcherForUpstream, scheduler); - - // TODO: surface `failedUpstreams` on each listing endpoint's wire response - // so partial-listing failures reach clients. - if (sawSuccess) return { models: models.sort((a, b) => compareModelIds(a.id, b.id)), upstreamsByPublicId, failedUpstreams }; - if (lastError) throw lastError; - return { models: [], upstreamsByPublicId, failedUpstreams }; -}; - -// `fetcherForUpstream` routes each upstream's catalog fetch through its -// per-upstream proxy chain. Returns the merged catalog together with the -// reverse `upstreamsByPublicId` map and the list of upstream names whose -// catalog fetch rejected during this assembly; callers that only want the -// bare metadata projection (`/v1/models`, `/models`, etc.) destructure -// `models` and ignore the rest. -export const getModels = async ( - upstreamFilter: readonly string[] | null, - fetcherForUpstream: (upstreamId: string) => Fetcher, - scheduler: BackgroundScheduler, -): Promise<{ models: InternalModel[]; upstreamsByPublicId: Map; failedUpstreams: readonly string[] }> => - await getModelsFromProviders(await listModelProviders(upstreamFilter), fetcherForUpstream, scheduler); - -// Resolve one inbound id against one upstream. The upstream's -// `modelPrefix.addressable` configuration decides which lookup branches -// apply: an `unprefixed`-addressable upstream is probed with the inbound id -// verbatim; a `prefixed`-addressable upstream is probed with the inbound id -// minus its configured prefix when (and only when) the inbound carries that -// prefix. Both branches are evaluated against the same SWR-cached catalog -// fetch — a single upstream typically contributes at most one candidate, -// but a catalog that publishes both the bare and prefixed forms can match -// twice and both go through. -// -// `kind` is threaded down here so a wrong-kind catalog entry never becomes -// a candidate. `sawAnyId` is true whenever the lookup id appeared in the -// catalog regardless of kind, so the caller can distinguish -// "id is unknown to this upstream" from "id exists but wrong kind". -const enumerateOneUpstreamCandidates = async ( - provider: Provider, - modelId: string, - kind: ModelKind, - fetcher: Fetcher, - scheduler: BackgroundScheduler, -): Promise<{ candidates: ModelCandidate[]; sawAnyId: boolean }> => { - const cfg = provider.modelPrefix; - const lookupIds: string[] = []; - if (cfg === null) { - lookupIds.push(modelId); - } else { - for (const form of cfg.addressable) { - if (form === 'unprefixed') lookupIds.push(modelId); - else if (form === 'prefixed' && modelId.startsWith(cfg.prefix)) lookupIds.push(modelId.slice(cfg.prefix.length)); - } - } - if (lookupIds.length === 0) return { candidates: [], sawAnyId: false }; - - const providedModels = await fetchUpstreamModelsCached(provider, { scheduler, fetcher }); - const disabled = new Set(provider.disabledPublicModelIds); - const candidates: ModelCandidate[] = []; - let sawAnyId = false; - for (const lookupId of lookupIds) { - const match = providedModels.find(m => m.id === lookupId && !disabled.has(m.id)); - if (!match) continue; - sawAnyId = true; - if (match.kind === kind) { - candidates.push({ provider, model: internalModelFromProviderModel(match, provider.upstream), fetcher }); - } - } - return { candidates, sawAnyId }; -}; - -// Walk every visible upstream, in configured order, and collect every -// (provider, model, fetcher) candidate the inbound id resolves against -// at the requested kind. Per-upstream catalog fetches fan out concurrently -// so a slow upstream cannot stall the rest. Cancellation (`AbortError`) -// propagates so the per-request abort signal cannot be masked by a slow -// upstream's rejection. -// -// `sawAnyId` aggregates the per-upstream signal: true when at least one -// upstream's catalog carried the inbound id under any kind. The caller -// uses it to decide whether to retry with a stripped dated suffix (no -// point retrying if the id matched but only under the wrong kind — the -// suffix strip cannot change kind). -export const enumerateRealModelCandidates = async ( - modelId: string, - kind: ModelKind, - providers: readonly Provider[], - fetcherForUpstream: (upstreamId: string) => Fetcher, - scheduler: BackgroundScheduler, -): Promise<{ - readonly candidates: readonly ModelCandidate[]; - readonly sawAnyId: boolean; - readonly failedUpstreams: readonly string[]; -}> => { - const settled = await Promise.allSettled(providers.map(provider => - enumerateOneUpstreamCandidates(provider, modelId, kind, fetcherForUpstream(provider.upstream), scheduler))); - - const failedUpstreams: string[] = []; - const candidates: ModelCandidate[] = []; - let sawAnyId = false; - for (const [index, result] of settled.entries()) { - if (result.status === 'rejected') { - const error = result.reason; - if (isAbortError(error)) throw error; - failedUpstreams.push(providers[index].name); - continue; - } - candidates.push(...result.value.candidates); - sawAnyId = sawAnyId || result.value.sawAnyId; - } - return { candidates, sawAnyId, failedUpstreams }; -}; - -// Vendor clients sometimes pin a model id to its release date -// (`claude-sonnet-4-5-20250929`) even though the gateway's merged catalog -// only carries the undated alias. When the inbound id matches no catalog -// entry, strip an 8-digit `-YYYYMMDD` suffix and try once more — failed -// catalog fetches across the two attempts dedupe into a single -// `failedUpstreams` list for the caller's renderer. -const DATED_SUFFIX = /-\d{8}$/; - -// Real-catalog resolution with the dated-suffix retry baked in. Used both -// directly (when we already hold the provider list) and by -// `enumerateModelCandidates` below, which lists providers and then delegates -// here — once for each alias target when the inbound id names an alias. -const resolveRealCandidates = async ( - modelId: string, - kind: ModelKind, - providers: readonly Provider[], - fetcherForUpstream: (upstreamId: string) => Fetcher, - scheduler: BackgroundScheduler, -): Promise<{ - readonly candidates: readonly ModelCandidate[]; - readonly sawModel: boolean; - readonly failedUpstreams: readonly string[]; -}> => { - const first = await enumerateRealModelCandidates(modelId, kind, providers, fetcherForUpstream, scheduler); - if (first.candidates.length > 0 || first.sawAnyId || !DATED_SUFFIX.test(modelId)) { - return { candidates: first.candidates, sawModel: first.sawAnyId, failedUpstreams: first.failedUpstreams }; - } - const stripped = modelId.replace(DATED_SUFFIX, ''); - const second = await enumerateRealModelCandidates(stripped, kind, providers, fetcherForUpstream, scheduler); - return { - candidates: second.candidates, - sawModel: second.sawAnyId, - failedUpstreams: [...new Set([...first.failedUpstreams, ...second.failedUpstreams])], - }; -}; - -// Target order for an alias walk: `first-available` yields declaration -// order; `random` shuffles so the outer walk distributes uniformly across -// targets. Within a single target's real-catalog walk the per-upstream -// order is always preserved (registry enumeration order); shuffling -// applies to the target list, not to a target's candidates. -const orderAliasTargets = (alias: ModelAliasRecord): readonly ModelAliasRecord['targets'][number][] => { - if (alias.selection === 'first-available') return alias.targets; - const shuffled = [...alias.targets]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - return shuffled; -}; - -// Per-request model resolution. Two-branch chain: -// -// 1. Look the inbound id up in the alias repo. When the id names an -// alias, walk every target in `selection`-mode order, delegate to the -// real-catalog resolver for each one, tag each returned candidate -// with that target's rule overlay, flatten across targets, and dedup -// by (modelId, upstreamId, rules) — same (model, upstream) with -// differing rules stays as distinct candidates so both variants can -// be dispatched. `iterateCandidates` at the serve layer then cascades -// across every kept candidate: a target's upstreams all failing over -// falls through into the next target's candidates instead of hard- -// failing at the first target. -// 2. Otherwise (no alias match at all) run the real-catalog resolver -// directly on the inbound id. -// -// The real-catalog resolver walks every visible upstream, filters by kind -// inside the walk (so wrong-kind entries never become candidates), and -// retries once with an eight-digit dated suffix stripped when the id -// matched nothing at all. `sawModel` reports whether the id was known to -// any upstream regardless of kind, so the caller can distinguish "model -// missing" (404) from "model wrong kind" (400). -// -// Endpoint-level narrowing — picking the chat target protocol from -// `model.endpoints`, or checking the specific `imagesEdits` / -// `imagesGenerations` / `audioTranscriptions` / `completions` endpoint key — is the caller's job. -// This function stays endpoint-blind so the same path serves chat, -// embeddings, image generation/edits, rerank, audio transcription, and legacy -// completions. -// -// The alias walk is a natural top-of-chain check: by construction an -// alias's target id is a real model id, so the shadow pattern (an alias -// whose first target matches its own name) resolves to the real model on -// the first pass; alias names never re-enter the alias layer. -export const enumerateModelCandidates = async ({ - upstreamIds, model, kind, scheduler, runtimeLocation, -}: { - // null = unrestricted; empty list = no providers visible. - upstreamIds: readonly string[] | null; - model: string; - kind: ModelKind; - // Threaded into `enumerateRealModelCandidates` so the per-upstream - // catalog lookup hits the SWR-cached `fetchUpstreamModelsCached` instead - // of round-tripping to the upstream on every request. - scheduler: BackgroundScheduler; - // Runtime location tag for this request — see GatewayCtx.runtimeLocation. - // Threaded into the per-request fetcher so colo-scoped fallback entries - // can be honoured at dial time. - runtimeLocation: string; -}): Promise<{ - readonly candidates: readonly ModelCandidate[]; - readonly sawModel: boolean; - readonly failedUpstreams: readonly string[]; -}> => { - const fetcherForUpstream = await createPerRequestFetcher(runtimeLocation); - const providers = await listModelProviders(upstreamIds); - - const alias = await getRepo().modelAliases.getByName(model); - if (alias === null) { - return await resolveRealCandidates(model, kind, providers, fetcherForUpstream, scheduler); - } - - // Walk every target, tag each returned candidate with the target's rule - // overlay, then flatten (target order preserved), and dedup by - // (modelId, upstreamId, rules). Different rules against the same - // (model, upstream) stay as distinct entries so the operator can pin the - // same physical binding under two rule variants. - const aggregatedFailed = new Set(); - let sawAny = false; - const flat: ModelCandidate[] = []; - for (const target of orderAliasTargets(alias)) { - const result = await resolveRealCandidates(target.target_model_id, kind, providers, fetcherForUpstream, scheduler); - for (const name of result.failedUpstreams) aggregatedFailed.add(name); - if (result.sawModel) sawAny = true; - for (const candidate of result.candidates) { - flat.push({ ...candidate, rules: target.rules }); - } - } - const deduped: ModelCandidate[] = []; - for (const candidate of flat) { - const duplicate = deduped.some(existing => - existing.model.id === candidate.model.id - && existing.provider.upstream === candidate.provider.upstream - && isEqual(existing.rules, candidate.rules)); - if (!duplicate) deduped.push(candidate); - } - return { - candidates: deduped, - sawModel: sawAny, - failedUpstreams: [...aggregatedFailed], - }; -}; diff --git a/packages/gateway/src/data-plane/providers/registry_test.ts b/packages/gateway/src/data-plane/providers/registry_test.ts index 9bcc689ca5..f734ba257e 100644 --- a/packages/gateway/src/data-plane/providers/registry_test.ts +++ b/packages/gateway/src/data-plane/providers/registry_test.ts @@ -1,99 +1,8 @@ -import { describe, expect, test } from 'vitest'; +import { expect, test } from 'vitest'; -import { clearInFlightForTesting } from './models-cache.ts'; -import { compareModelIds, enumerateModelCandidates, enumerateRealModelCandidates, getModels, listModelProviders } from './registry.ts'; -import { buildCopilotUpstreamRecord, buildCustomUpstreamRecord, copilotModels, setupAppTest } from '../../test-helpers.ts'; -import { directFetcher, type InternalModel, type ProviderModel } from '@floway-dev/provider'; -import { assertEquals, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; - -// Test-scoped narrowing: registry rows in these tests are always real -// (upstream-backed). This helper reads the `providerModels` map off the -// discriminated union without spraying non-null assertions across every -// assertion. -const realProviderModels = (model: InternalModel | undefined): Record => { - if (model?.providerModels === undefined) throw new Error(`expected real InternalModel with providerModels, got ${JSON.stringify(model)}`); - return model.providerModels; -}; - -const sortedIds = (ids: readonly string[]): string[] => [...ids].sort(compareModelIds); - -// Drains the background revalidate promise so its rejection surfaces in the -// test runner instead of being swallowed. -const testScheduler = (promise: Promise): void => { - promise.catch(err => console.error('[background]', err)); -}; - -test('compareModelIds pushes ids containing "/" to the tail', () => { - assertEquals(sortedIds(['accounts/msft/x', 'gpt-4o', 'accounts/msft/y', 'claude-opus-4-7']), [ - 'claude-opus-4-7', - 'gpt-4o', - // Within the slashed group, the remaining keys still apply: same alpha - // prefix "accounts", empty isolated-digit arrays, then descending lex. - 'accounts/msft/y', - 'accounts/msft/x', - ]); -}); - -test('compareModelIds groups by leading [a-zA-Z]+ prefix, case-insensitive ascending', () => { - // gpt and GPT collapse on key 1; their tied [4] digit array falls to - // descending lex (lowercased), so 'gpt-4o-mini' beats 'gpt-4o'. - assertEquals(sortedIds(['gpt-4o', 'claude-haiku-4-5', 'deepseek-v4-pro', 'GPT-4o-mini']), [ - 'claude-haiku-4-5', - 'deepseek-v4-pro', - 'GPT-4o-mini', - 'gpt-4o', - ]); -}); - -test('compareModelIds orders isolated single digits descending element by element', () => { - // Digit arrays: claude-opus-4-7 [4,7], claude-sonnet-4-6 [4,6], - // claude-opus-4-5 / claude-haiku-4-5 [4,5]. Within the [4,5] tie, lex - // descending picks 'claude-opus-4-5' over 'claude-haiku-4-5'. - assertEquals(sortedIds(['claude-opus-4-7', 'claude-opus-4-5', 'claude-haiku-4-5', 'claude-sonnet-4-6']), [ - 'claude-opus-4-7', - 'claude-sonnet-4-6', - 'claude-opus-4-5', - 'claude-haiku-4-5', - ]); -}); - -test('compareModelIds puts longer digit arrays before shorter ones (descending)', () => { - // [5,5] beats every [4]; within the tied-[4] group, descending lex on the - // full id puts 'gpt-4o' first, then 'gpt-4-turbo', then 'gpt-4' last. - assertEquals(sortedIds(['gpt-5.5', 'gpt-4', 'gpt-4o', 'gpt-4-turbo']), [ - 'gpt-5.5', - 'gpt-4o', - 'gpt-4-turbo', - 'gpt-4', - ]); -}); - -test('compareModelIds ignores multi-digit runs such as dates', () => { - // Both have digit array [4, 7]; descending lex tie-break puts the longer - // dated id first. - assertEquals(sortedIds(['claude-opus-4-7-20300101', 'claude-opus-4-7']), [ - 'claude-opus-4-7-20300101', - 'claude-opus-4-7', - ]); -}); - -test('compareModelIds sorts ids without a leading alpha prefix first', () => { - assertEquals(sortedIds(['gpt-4o', 'o1-mini', '128k-context-model']), [ - '128k-context-model', - 'gpt-4o', - 'o1-mini', - ]); -}); - -test('compareModelIds keeps case-only differences adjacent via lowercase tie-break', () => { - // All lowercase to 'gpt-4o' so case-folded lex ties; raw descending then - // picks lowercase letters before uppercase (g > G in ASCII). - assertEquals(sortedIds(['GPT-4o', 'gpt-4o', 'gpt-4O']), [ - 'gpt-4o', - 'gpt-4O', - 'GPT-4o', - ]); -}); +import { listModelProviders } from './registry.ts'; +import { buildCopilotUpstreamRecord, buildCustomUpstreamRecord, setupAppTest } from '../../test-utils/app.ts'; +import { assertEquals } from '@floway-dev/test-utils'; test('listModelProviders creates enabled provider instances with upstream row ids', async () => { const { githubAccount, repo } = await setupAppTest(); @@ -128,264 +37,7 @@ test('listModelProviders creates enabled provider instances with upstream row id await repo.upstreams.save(buildCustomUpstreamRecord({ id: 'up_disabled', enabled: false, sortOrder: 0 })); const providers = await listModelProviders(null); - assertEquals(providers.map(provider => provider.upstream), ['up_custom', 'up_azure', 'up_copilot']); -}); - -test('getModels returns the merged catalog plus the per-id upstream index', async () => { - const { repo } = await setupAppTest(); - - await repo.upstreams.save(buildCustomUpstreamRecord()); - await repo.upstreams.save(buildCustomUpstreamRecord({ id: 'up_disabled', enabled: false, sortOrder: 50 })); - - await withMockedFetch( - request => { - const url = new URL(request.url); - - if (url.hostname === 'update.code.visualstudio.com') { - return jsonResponse(['1.110.1']); - } - if (url.pathname === '/copilot_internal/v2/token') { - return jsonResponse({ - token: 'copilot-access-token', - expires_at: 4102444800, - refresh_in: 3600, - endpoints: { api: 'https://api.individual.githubcopilot.com' }, - }); - } - if (url.hostname === 'api.individual.githubcopilot.com' && url.pathname === '/models') { - return jsonResponse( - copilotModels([ - { - id: 'shared-model', - display_name: 'Shared Model', - supported_endpoints: ['/v1/messages'], - }, - ]), - ); - } - if (url.hostname === 'custom.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ - object: 'list', - data: [ - { - id: 'shared-model', - supported_endpoints: ['/chat/completions'], - }, - ], - }); - } - - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const { models, upstreamsByPublicId } = await getModels(null, () => directFetcher, testScheduler); - const model = models.find(candidate => candidate.id === 'shared-model'); - - assertEquals(model?.display_name, 'Shared Model'); - // The merged endpoint surface is the OR of both upstreams' endpoint maps. - assertEquals(model?.endpoints, { messages: {}, chatCompletions: {} }); - assertEquals(model?.kind, 'chat'); - // `providerData` (the per-provider wire id carrier) belongs to the - // provider-emitted ProviderModel, not the gateway-merged catalog row. - assertEquals(Object.hasOwn(model!, 'providerData'), false); - // The reverse index lists every upstream that surfaced this id, in - // enumeration order — copilot first, then custom. - assertEquals(upstreamsByPublicId.get('shared-model')?.map(p => p.upstream), ['up_copilot', 'up_custom']); - // Every contributing upstream keeps its own emitted `ProviderModel` - // verbatim under `providerModels[]` — merge unions the - // outer `endpoints` but never rewrites the per-upstream capability - // each provider originally advertised. - assertEquals(Object.keys(realProviderModels(model)).sort(), ['up_copilot', 'up_custom']); - assertEquals(realProviderModels(model)['up_copilot']?.endpoints, { messages: {} }); - assertEquals(realProviderModels(model)['up_custom']?.endpoints, { chatCompletions: {} }); - // `enabledFlags` is required on every ProviderModel — proves the - // stored value is the provider-emitted shape (not a projected - // subset). - assertEquals(realProviderModels(model)['up_copilot']?.enabledFlags instanceof Set, true); - assertEquals(realProviderModels(model)['up_custom']?.enabledFlags instanceof Set, true); - - const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'shared-model', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(resolved.candidates.map(m => m.provider.upstream), ['up_copilot', 'up_custom']); - // Each match carries its own per-provider endpoints — no merge. - assertEquals(resolved.candidates[0]?.model.endpoints, { messages: {} }); - assertEquals(resolved.candidates[1]?.model.endpoints, { chatCompletions: {} }); - // Each enumerated candidate seeds `providerModels[provider.upstream]` - // so `providerModelOf(candidate)` resolves at dispatch time. - assertEquals(Object.keys(realProviderModels(resolved.candidates[0]?.model)), ['up_copilot']); - assertEquals(Object.keys(realProviderModels(resolved.candidates[1]?.model)), ['up_custom']); - assertEquals(realProviderModels(resolved.candidates[0]?.model)['up_copilot']?.endpoints, { messages: {} }); - assertEquals(realProviderModels(resolved.candidates[1]?.model)['up_custom']?.endpoints, { chatCompletions: {} }); - }, - ); -}); - -test('enumerateModelCandidates strips an -YYYYMMDD suffix when nothing matched and retries across every visible upstream', async () => { - const { repo } = await setupAppTest(); - - await repo.upstreams.save( - buildCustomUpstreamRecord({ - config: { - baseUrl: 'https://custom.example.com', - authStyle: 'bearer', - apiKey: 'sk-custom', - endpoints: { messages: {} }, - }, - }), - ); - - await withMockedFetch( - request => { - const url = new URL(request.url); - - if (url.hostname === 'update.code.visualstudio.com') { - return jsonResponse(['1.110.1']); - } - if (url.pathname === '/copilot_internal/v2/token') { - return jsonResponse({ - token: 'copilot-access-token', - expires_at: 4102444800, - refresh_in: 3600, - endpoints: { api: 'https://api.individual.githubcopilot.com' }, - }); - } - if (url.hostname === 'api.individual.githubcopilot.com' && url.pathname === '/models') { - return jsonResponse(copilotModels([{ id: 'claude-opus-4.7', supported_endpoints: ['/v1/messages'] }])); - } - if (url.hostname === 'custom.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ - object: 'list', - data: [{ id: 'claude-opus-4-7' }], - }); - } - - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'claude-opus-4-7-20300101', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - - // No upstream's catalog literally lists `claude-opus-4-7-20300101`, - // so the resolver retries against the stripped `claude-opus-4-7`, - // which both upstreams expose. Both candidates end up in the match - // list in configured `sort_order`. - assertEquals(resolved.candidates.map(m => m.provider.upstream).sort(), ['up_copilot', 'up_custom'].sort()); - assertEquals(resolved.candidates.map(m => m.model.id), ['claude-opus-4-7', 'claude-opus-4-7']); - }, - ); -}); - -test('enumerateModelCandidates does not retry when the inbound id has no dated suffix', async () => { - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save( - buildCustomUpstreamRecord({ - config: { - baseUrl: 'https://custom.example.com', - authStyle: 'bearer', - apiKey: 'sk-custom', - endpoints: { messages: {} }, - }, - }), - ); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'custom.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'claude-opus-4-7' }] }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - // Plain typo / unknown id — no dated suffix, no retry. - const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'claude-opus-4-7-unknown', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(resolved.candidates.length, 0); - }, - ); -}); - -test('enumerateModelCandidates prefers the literal dated id over the stripped base when the catalog lists both', async () => { - // The dated suffix fallback is a SECOND attempt, gated on the first - // attempt finding nothing. When the upstream catalog already lists the - // dated id verbatim, the first attempt wins and the stripped form - // never enters the candidate list. - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save( - buildCustomUpstreamRecord({ - config: { - baseUrl: 'https://custom.example.com', - authStyle: 'bearer', - apiKey: 'sk-custom', - endpoints: { messages: {} }, - }, - }), - ); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'custom.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ - object: 'list', - data: [ - { id: 'claude-sonnet-4-5' }, - { id: 'claude-sonnet-4-5-20251101' }, - ], - }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'claude-sonnet-4-5-20251101', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(resolved.candidates.length, 1); - assertEquals(resolved.candidates[0]?.model.id, 'claude-sonnet-4-5-20251101'); - }, - ); -}); - -test('enumerateRealModelCandidates only loads the selected providers\' catalogs', async () => { - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_first', - name: 'First', - sortOrder: 0, - config: { baseUrl: 'https://first.example.com', authStyle: 'bearer', apiKey: 'sk-first', endpoints: { responses: {} } }, - })); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_second', - name: 'Second', - sortOrder: 100, - config: { baseUrl: 'https://second.example.com', authStyle: 'bearer', apiKey: 'sk-second', endpoints: { responses: {} } }, - })); - - const providers = await listModelProviders(null); - let secondModelsFetches = 0; - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'first.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ data: [{ id: 'target-model' }] }); - } - if (url.hostname === 'second.example.com' && url.pathname === '/v1/models') { - secondModelsFetches++; - return jsonResponse({ data: [{ id: 'target-model' }] }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const { candidates } = await enumerateRealModelCandidates('target-model', 'chat', [providers[0]], () => directFetcher, testScheduler); - - assertEquals(candidates[0]?.model.id, 'target-model'); - assertEquals(candidates[0]?.provider.upstream, 'up_first'); - // Every enumerated candidate seeds `providerModels[provider.upstream]` - // so `providerModelOf(candidate)` resolves at dispatch time. - assertEquals(Object.keys(realProviderModels(candidates[0]?.model)), ['up_first']); - }, - ); - - assertEquals(secondModelsFetches, 0); + assertEquals(providers.map(provider => provider.upstreamId), ['up_custom', 'up_azure', 'up_copilot']); }); test('listModelProviders without a filter returns global sort_order', async () => { @@ -396,7 +48,7 @@ test('listModelProviders without a filter returns global sort_order', async () = await repo.upstreams.save(buildCustomUpstreamRecord({ id: 'up_c', name: 'C', sortOrder: 30 })); const providers = await listModelProviders(null); - assertEquals(providers.map(p => p.upstream), ['up_a', 'up_b', 'up_c']); + assertEquals(providers.map(p => p.upstreamId), ['up_a', 'up_b', 'up_c']); }); test('listModelProviders honors a per-key whitelist with custom order', async () => { @@ -407,102 +59,7 @@ test('listModelProviders honors a per-key whitelist with custom order', async () await repo.upstreams.save(buildCustomUpstreamRecord({ id: 'up_c', name: 'C', sortOrder: 30 })); const providers = await listModelProviders(['up_c', 'up_a']); - assertEquals(providers.map(p => p.upstream), ['up_c', 'up_a']); -}); - -test('disabledPublicModelIds hides models from the catalog and routing, per upstream', async () => { - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - - const azureUpstream = (over: { id: string; sortOrder: number; models: { upstreamModelId: string; publicModelId?: string }[]; disabledPublicModelIds: string[] }) => ({ - id: over.id, - kind: 'azure' as const, - name: over.id, - enabled: true, - sortOrder: over.sortOrder, - createdAt: '2026-05-21T00:00:00.000Z', - updatedAt: '2026-05-21T00:00:00.000Z', - config: { - endpoint: 'https://example.openai.azure.com', - apiKey: 'az-key', - models: over.models.map(m => ({ ...m, endpoints: { chatCompletions: {} } })), - }, - state: null, - flagOverrides: {}, - disabledPublicModelIds: over.disabledPublicModelIds, - proxyFallbackList: [], - modelPrefix: null, - color: null, - }); - - // up_a disables a solo model and a shared one (by public id, including a - // publicModelId override); up_b still serves the shared id, enabled. - await repo.upstreams.save(azureUpstream({ - id: 'up_a', - sortOrder: 1, - models: [ - { upstreamModelId: 'gpt-keep' }, - { upstreamModelId: 'gpt-solo' }, - { upstreamModelId: 'gpt-shared' }, - { upstreamModelId: 'dep-x', publicModelId: 'gpt-override' }, - ], - disabledPublicModelIds: ['gpt-solo', 'gpt-shared', 'gpt-override'], - })); - await repo.upstreams.save(azureUpstream({ - id: 'up_b', - sortOrder: 2, - models: [{ upstreamModelId: 'gpt-shared' }], - disabledPublicModelIds: [], - })); - - const catalog = (await getModels(null, () => directFetcher, testScheduler)).models; - assertEquals([...catalog.map(m => m.id)].sort(), ['gpt-keep', 'gpt-shared']); - - // The solo and override ids resolve to nothing (hidden + unroutable). - assertEquals((await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-solo', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' })).candidates.length, 0); - assertEquals((await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-override', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' })).candidates.length, 0); - - // The shared id survives because up_b allows it; only up_b binds it. - const shared = await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-shared', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(shared.candidates.map(m => m.provider.upstream), ['up_b']); - - // The untouched model still routes from up_a. - const keep = await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-keep', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(keep.candidates.map(m => m.provider.upstream), ['up_a']); -}); - -test('enumerateRealModelCandidates rejects a model id disabled on that upstream (filter parity with the catalog)', async () => { - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save({ - id: 'up_x', - kind: 'azure', - name: 'X', - enabled: true, - sortOrder: 1, - createdAt: '2026-05-21T00:00:00.000Z', - updatedAt: '2026-05-21T00:00:00.000Z', - config: { - endpoint: 'https://example.openai.azure.com', - apiKey: 'az-key', - models: [ - { upstreamModelId: 'enabled-model', endpoints: { chatCompletions: {} } }, - { upstreamModelId: 'disabled-model', endpoints: { chatCompletions: {} } }, - ], - }, - flagOverrides: {}, - disabledPublicModelIds: ['disabled-model'], - proxyFallbackList: [], - modelPrefix: null, - color: null, - state: null, - }); - - const providers = await listModelProviders(null); - const enabled = await enumerateRealModelCandidates('enabled-model', 'chat', providers, () => directFetcher, testScheduler); - const disabled = await enumerateRealModelCandidates('disabled-model', 'chat', providers, () => directFetcher, testScheduler); - assertEquals(enabled.candidates[0]?.model.id, 'enabled-model'); - assertEquals(disabled.candidates.length, 0); + assertEquals(providers.map(p => p.upstreamId), ['up_c', 'up_a']); }); test('listModelProviders silently drops disabled upstreams from a whitelist', async () => { @@ -514,7 +71,7 @@ test('listModelProviders silently drops disabled upstreams from a whitelist', as await repo.upstreams.save(buildCustomUpstreamRecord({ id: 'up_b', name: 'B', sortOrder: 20, enabled: false })); const providers = await listModelProviders(['up_b', 'up_a']); - assertEquals(providers.map(p => p.upstream), ['up_a']); + assertEquals(providers.map(p => p.upstreamId), ['up_a']); }); test('listModelProviders throws on unknown upstream ids in the whitelist', async () => { @@ -526,780 +83,3 @@ test('listModelProviders throws on unknown upstream ids in the whitelist', async await expect(listModelProviders(['up_ghost', 'up_a'])).rejects.toThrow(/up_ghost/); }); - -// Per-upstream catalog fetches fan out in parallel: total wall-clock time -// tracks the slowest upstream, not the sum. The bound is loose because CI -// timer noise eats into a tight `< sum` comparison; what matters is the -// ratio. -test('getModels fans out per-upstream catalog fetches in parallel', async () => { - clearInFlightForTesting(); - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - - const FETCH_DELAY_MS = 60; - const upstreams = [ - { id: 'up_p1', host: 'p1.example.com', model: 'p1-model' }, - { id: 'up_p2', host: 'p2.example.com', model: 'p2-model' }, - { id: 'up_p3', host: 'p3.example.com', model: 'p3-model' }, - ]; - for (const [index, u] of upstreams.entries()) { - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: u.id, - name: u.id, - sortOrder: index, - config: { baseUrl: `https://${u.host}`, authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - })); - } - - await withMockedFetch( - async request => { - const url = new URL(request.url); - const match = upstreams.find(u => url.hostname === u.host); - if (match && url.pathname === '/v1/models') { - await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS)); - return jsonResponse({ object: 'list', data: [{ id: match.model, supported_endpoints: ['/chat/completions'] }] }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const start = Date.now(); - const catalog = (await getModels(null, () => directFetcher, testScheduler)).models; - const elapsed = Date.now() - start; - - assertEquals([...catalog.map(m => m.id)].sort(), ['p1-model', 'p2-model', 'p3-model']); - // A serial walk would take >= 3 * FETCH_DELAY_MS; parallel is bounded by - // ~FETCH_DELAY_MS plus per-test overhead. Half the serial budget is the - // loosest threshold that still excludes any serial regression. - const serialBudget = upstreams.length * FETCH_DELAY_MS; - if (elapsed >= serialBudget / 2) { - throw new Error(`expected parallel walk (~${FETCH_DELAY_MS}ms) but took ${elapsed}ms (serial would be ${serialBudget}ms)`); - } - }, - ); -}); - -// A single upstream's catalog fetch failure is surfaced as `lastError` and -// recorded against `sawSuccess === true`; the public catalog still includes -// every successful upstream's models. -test('getModels: a rejected provider does not block other providers', async () => { - clearInFlightForTesting(); - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_ok_1', - name: 'OK 1', - sortOrder: 1, - config: { baseUrl: 'https://ok1.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - })); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_broken', - name: 'Broken', - sortOrder: 2, - config: { baseUrl: 'https://broken.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - })); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_ok_2', - name: 'OK 2', - sortOrder: 3, - config: { baseUrl: 'https://ok2.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - })); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'ok1.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'ok-1-model', supported_endpoints: ['/chat/completions'] }] }); - } - if (url.hostname === 'ok2.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'ok-2-model', supported_endpoints: ['/chat/completions'] }] }); - } - if (url.hostname === 'broken.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ error: 'upstream went down' }, 502); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const catalog = (await getModels(null, () => directFetcher, testScheduler)).models; - assertEquals([...catalog.map(m => m.id)].sort(), ['ok-1-model', 'ok-2-model']); - }, - ); -}); - -// Regression: when an upstream's force re-fetch rejects past HARD, the call -// site asking for a model belonging to one of the *healthy* upstreams must -// still resolve. The broken upstream's display name flows back via -// `failedUpstreams` so the eventual error renderer can mention it. -test('enumerateModelCandidates: healthy upstream still resolves alongside a rejecting one, with failedUpstreams reported', async () => { - clearInFlightForTesting(); - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_broken', - name: 'Broken upstream', - sortOrder: 1, - config: { baseUrl: 'https://broken.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - })); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_ok', - name: 'Healthy upstream', - sortOrder: 2, - config: { baseUrl: 'https://ok.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - })); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'broken.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ error: 'upstream went down' }, 502); - } - if (url.hostname === 'ok.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'ok-model', supported_endpoints: ['/chat/completions'] }] }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const resolvedExisting = await enumerateModelCandidates({ upstreamIds: null, model: 'ok-model', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(resolvedExisting.candidates.map(m => m.provider.upstream), ['up_ok']); - assertEquals(resolvedExisting.candidates[0]?.model.id, 'ok-model'); - assertEquals(resolvedExisting.failedUpstreams, ['Broken upstream']); - - // A model nobody currently knows about must NOT rethrow the broken - // upstream's catalog error — the caller's failure renderer is the right - // place to surface that, parenthetically, alongside the model-missing - // body. - const resolvedMissing = await enumerateModelCandidates({ upstreamIds: null, model: 'unknown-model', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(resolvedMissing.candidates.length, 0); - assertEquals(resolvedMissing.failedUpstreams, ['Broken upstream']); - }, - ); -}); - -// End-to-end listing checks for the prefix policy. The catalog walk goes -// through getModels, which threads custom upstreams' /v1/models -// responses through fetchUpstreamModelsCached just like production does. -describe('catalog listing under modelPrefix', () => { - test('null prefix lists bare ids only (today\'s behavior)', async () => { - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_plain', - sortOrder: 1, - config: { baseUrl: 'https://plain.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - })); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'plain.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }] }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const catalog = (await getModels(null, () => directFetcher, testScheduler)).models; - assertEquals(catalog.map(m => m.id), ['gpt-4o']); - }, - ); - }); - - test('listed=[prefixed] lists only the prefixed surface and routes the prefixed request to the upstream', async () => { - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_prefixed', - sortOrder: 1, - config: { baseUrl: 'https://prefixed.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - modelPrefix: { prefix: 'or/', addressable: ['prefixed'], listed: ['prefixed'] }, - })); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'prefixed.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }] }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const catalog = (await getModels(null, () => directFetcher, testScheduler)).models; - assertEquals(catalog.map(m => m.id), ['or/gpt-4o']); - // Prefixed surface gets a synthesized display_name prepending the - // upstream's display name so the dashboard tells the operator at a - // glance which upstream a prefixed entry came from. - assertEquals(catalog[0]?.display_name, 'Custom Provider: gpt-4o'); - - // Regression: with `listed: ['prefixed']` the catalog walk emits only - // the prefixed surface, so a byId-based routing lookup against the - // stripped bare id would miss. Routing must instead consult each - // scoped upstream's own catalog, where the bare id is always present. - const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'or/gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(resolved.candidates.map(m => m.provider.upstream), ['up_prefixed']); - assertEquals(resolved.candidates[0]?.model.id, 'gpt-4o'); - - // The bare-id request must NOT route to a prefix-only-addressable - // upstream, regardless of routing path. - const bare = await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(bare.candidates.length, 0); - }, - ); - }); - - test('addressable=[unprefixed, prefixed] + listed=[prefixed] routes both surface forms', async () => { - // The upstream is bare-id-addressable but only the prefixed form appears - // in /v1/models. Routing must still resolve a bare-id request via the - // upstream's own catalog (addressable=['unprefixed', 'prefixed'] keeps it - // in the candidate set), and a prefixed request via the prefix-strip + - // per-provider catalog lookup. - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_dual_addressable', - sortOrder: 1, - config: { baseUrl: 'https://dual.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - modelPrefix: { prefix: 'or/', addressable: ['unprefixed', 'prefixed'], listed: ['prefixed'] }, - })); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'dual.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }] }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const catalog = (await getModels(null, () => directFetcher, testScheduler)).models; - assertEquals(catalog.map(m => m.id), ['or/gpt-4o']); - - const bare = await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(bare.candidates.map(m => m.provider.upstream), ['up_dual_addressable']); - assertEquals(bare.candidates[0]?.model.id, 'gpt-4o'); - - // The prefixed request enumerates both forms against `up_dual_addressable`: - // the unprefixed lookup (`or/gpt-4o`) misses the upstream catalog, and - // the prefix-stripped lookup (`gpt-4o`) hits — yielding a single match. - const prefixed = await enumerateModelCandidates({ upstreamIds: null, model: 'or/gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(prefixed.candidates.map(m => m.provider.upstream), ['up_dual_addressable']); - assertEquals(prefixed.candidates[0]?.model.id, 'gpt-4o'); - }, - ); - }); - - test('listed=[unprefixed, prefixed] emits both surfaces, both upstreams enumerate on the shared bare id', async () => { - // up_plain has no prefix and lists `gpt-4o`. up_dual exposes both forms. - // The bare `gpt-4o` reaches both upstreams — the resolver enumerates - // candidates from every match; the `or/gpt-4o` surface belongs solely - // to up_dual because up_plain's catalog does not contain `or/gpt-4o`. - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_plain', - sortOrder: 1, - config: { baseUrl: 'https://plain.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - })); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_dual', - sortOrder: 2, - config: { baseUrl: 'https://dual.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - modelPrefix: { prefix: 'or/', addressable: ['unprefixed', 'prefixed'], listed: ['unprefixed', 'prefixed'] }, - })); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'plain.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }] }); - } - if (url.hostname === 'dual.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }] }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const catalog = (await getModels(null, () => directFetcher, testScheduler)).models; - assertEquals([...catalog.map(m => m.id)].sort(), ['gpt-4o', 'or/gpt-4o']); - - // Both upstreams enumerate against the bare id: up_plain via its only - // form, up_dual via its unprefixed-addressable branch. Order follows - // the configured sort_order across providers. - const bare = await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(bare.candidates.map(m => m.provider.upstream), ['up_plain', 'up_dual']); - - // The prefixed id resolves only against up_dual: up_plain's catalog - // does not contain `or/gpt-4o`, and up_dual's prefix-stripped lookup - // hits its catalog's bare `gpt-4o`. - const prefixed = await enumerateModelCandidates({ upstreamIds: null, model: 'or/gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(prefixed.candidates.map(m => m.provider.upstream), ['up_dual']); - }, - ); - }); - - test('dual-addressable upstream whose catalog literally lists both forms yields two candidates from one upstream', async () => { - // up_dual is `addressable: ['unprefixed', 'prefixed']` AND its catalog - // publishes both `gpt-4o` and `or/gpt-4o` as distinct entries. An - // inbound `or/gpt-4o` triggers BOTH branches at the same upstream: - // - unprefixed branch looks up `or/gpt-4o` → hits the literal `or/gpt-4o` - // catalog entry - // - prefixed branch looks up `gpt-4o` (after strip) → hits the `gpt-4o` - // catalog entry - // Both produce a candidate — no deduplication; the unprefixed branch - // pushes first, matching `cfg.addressable`'s `unprefixed`-before- - // `prefixed` iteration order (see `FORM_ORDER` in `model-prefix.ts`). - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_dual', - sortOrder: 1, - config: { baseUrl: 'https://dual.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - modelPrefix: { prefix: 'or/', addressable: ['unprefixed', 'prefixed'], listed: ['unprefixed', 'prefixed'] }, - })); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'dual.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ - object: 'list', - data: [ - { id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }, - { id: 'or/gpt-4o', supported_endpoints: ['/chat/completions'] }, - ], - }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'or/gpt-4o', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(resolved.candidates.length, 2); - assertEquals(resolved.candidates.map(c => c.provider.upstream), ['up_dual', 'up_dual']); - // The unprefixed branch hits the `or/gpt-4o` literal entry first; - // the prefixed branch's strip hits the bare `gpt-4o` entry. - assertEquals(resolved.candidates.map(c => c.model.id), ['or/gpt-4o', 'gpt-4o']); - }, - ); - }); - - test('disabledPublicModelIds hides both bare and prefixed forms from the originating upstream', async () => { - // up_dual exposes `gpt-4o` and `gpt-mini` under both forms, but the - // operator disabled `gpt-4o` on this upstream. Neither `gpt-4o` nor - // `or/gpt-4o` survives from up_dual; `gpt-mini` and `or/gpt-mini` stay. - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_dual', - sortOrder: 1, - config: { baseUrl: 'https://dual.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - modelPrefix: { prefix: 'or/', addressable: ['unprefixed', 'prefixed'], listed: ['unprefixed', 'prefixed'] }, - disabledPublicModelIds: ['gpt-4o'], - })); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'dual.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ - object: 'list', - data: [ - { id: 'gpt-4o', supported_endpoints: ['/chat/completions'] }, - { id: 'gpt-mini', supported_endpoints: ['/chat/completions'] }, - ], - }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const catalog = (await getModels(null, () => directFetcher, testScheduler)).models; - assertEquals([...catalog.map(m => m.id)].sort(), ['gpt-mini', 'or/gpt-mini']); - }, - ); - }); - - // Regression for the three-upstream case the routing-primitive refactor was - // motivated by. The same public id `aa/bb/gpt-5` is reachable through three - // configured paths: an `aa/`-prefixed upstream whose catalog carries the id - // `bb/gpt-5`, a longer `aa/bb/`-prefixed upstream whose catalog carries the - // id `gpt-5`, and a bare upstream whose catalog literally carries - // `aa/bb/gpt-5`. Every upstream must enumerate as an independent match — - // an earlier iteration of the resolver returned only the first match and - // would have shadowed two of them. - test('three upstreams advertising the same public id via different paths all enumerate as matches', async () => { - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_short_prefix', - sortOrder: 1, - config: { baseUrl: 'https://short.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - modelPrefix: { prefix: 'aa/', addressable: ['prefixed'], listed: ['prefixed'] }, - })); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_long_prefix', - sortOrder: 2, - config: { baseUrl: 'https://long.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - modelPrefix: { prefix: 'aa/bb/', addressable: ['prefixed'], listed: ['prefixed'] }, - })); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_bare', - sortOrder: 3, - config: { baseUrl: 'https://bare.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - })); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'short.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'bb/gpt-5', supported_endpoints: ['/chat/completions'] }] }); - } - if (url.hostname === 'long.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'gpt-5', supported_endpoints: ['/chat/completions'] }] }); - } - if (url.hostname === 'bare.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'aa/bb/gpt-5', supported_endpoints: ['/chat/completions'] }] }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'aa/bb/gpt-5', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); - assertEquals(resolved.candidates.map(m => m.provider.upstream), ['up_short_prefix', 'up_long_prefix', 'up_bare']); - assertEquals(resolved.candidates.map(m => m.model.id), ['bb/gpt-5', 'gpt-5', 'aa/bb/gpt-5']); - }, - ); - }); -}); - -// A wrong-kind match (`sawAnyId=true, candidates=[]`) must short-circuit the -// dated-suffix retry — the suffix strip cannot turn a wrong-kind id into a -// right-kind one. The catalog carries the literal dated id as a chat model; -// requesting it with `kind: 'image'` produces sawAnyId=true on the first -// attempt, so the resolver returns immediately rather than walking the -// stripped form. -test('enumerateModelCandidates does NOT trigger the dated-suffix retry on a wrong-kind sawAnyId match', async () => { - clearInFlightForTesting(); - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_chat_only', - name: 'ChatOnly', - sortOrder: 1, - config: { baseUrl: 'https://chatonly.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - })); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'chatonly.example.com' && url.pathname === '/v1/models') { - // The dated form is literally present in the catalog (chat-kind). - return jsonResponse({ object: 'list', data: [{ id: 'claude-opus-4-7-20251231', supported_endpoints: ['/chat/completions'] }] }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const resolved = await enumerateModelCandidates({ - upstreamIds: null, - model: 'claude-opus-4-7-20251231', - kind: 'image', - scheduler: testScheduler, - runtimeLocation: 'TEST', - }); - assertEquals(resolved.candidates, []); - // `sawModel: true` pins that only the first attempt ran: the resolver - // assigns `sawModel: second.sawAnyId` after retry (overwrite, not OR), - // so a second walk against the stripped `claude-opus-4-7` (absent from - // this fixture's catalog) would flip sawModel to false. - assertEquals(resolved.sawModel, true); - assertEquals(resolved.failedUpstreams, []); - }, - ); -}); - -// failedUpstreams across the two retry attempts must dedupe: a single broken -// upstream that rejects both walks reports its name once, not twice. -test('enumerateModelCandidates deduplicates failedUpstreams across the dated-suffix retry attempts', async () => { - clearInFlightForTesting(); - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_broken', - name: 'Broken', - sortOrder: 1, - config: { baseUrl: 'https://broken.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - })); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'broken.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ error: 'upstream went down' }, 502); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const resolved = await enumerateModelCandidates({ - upstreamIds: null, - model: 'claude-opus-4-7-20251231', - kind: 'chat', - scheduler: testScheduler, - runtimeLocation: 'TEST', - }); - assertEquals(resolved.candidates.length, 0); - // The same broken upstream appears in both attempts' failedUpstreams; - // the outer resolver collapses the duplicate via a Set. - assertEquals(resolved.failedUpstreams.length, 1); - assertEquals(resolved.failedUpstreams[0], 'Broken'); - }, - ); -}); - -// AbortError must propagate end-to-end so the caller's per-request abort -// signal cannot be masked by a slow upstream. Burying it in failedUpstreams -// would let the rest of the data-plane request build a Response against a -// stale catalog. The provider's `fetchUpstreamModels` wraps the upstream -// fetch error in a ProviderModelsUnavailableError with the AbortError as -// its cause, so the resolver's detection walks the cause chain. -test('enumerateModelCandidates rethrows AbortError from a per-upstream catalog fetch', async () => { - clearInFlightForTesting(); - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_aborting', - name: 'Aborting', - sortOrder: 1, - config: { baseUrl: 'https://aborting.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, - })); - - const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' }); - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'aborting.example.com' && url.pathname === '/v1/models') { - throw abortError; - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - let thrown: unknown = null; - try { - await enumerateModelCandidates({ - upstreamIds: null, - model: 'any-model', - kind: 'chat', - scheduler: testScheduler, - runtimeLocation: 'TEST', - }); - } catch (e) { - thrown = e; - } - // The thrown error chains back to our injected AbortError via .cause. - const isAbortInChain = (err: unknown): boolean => { - for (let cur: unknown = err; cur != null; cur = (cur as { cause?: unknown }).cause) { - if (cur instanceof Error && cur.name === 'AbortError') return true; - } - return false; - }; - if (!isAbortInChain(thrown)) { - throw new Error(`expected rejection to carry an AbortError in its cause chain; got: ${thrown instanceof Error ? `${thrown.name}: ${thrown.message}` : String(thrown)}`); - } - }, - ); -}); - -// Empty visible upstream list: a caller cap pinned to an empty set yields -// `{candidates: [], sawModel: false, failedUpstreams: []}` without any -// upstream fetch. The failure renderer surfaces this as a model-missing 404 -// without re-deriving the empty-cap branch. -test('enumerateModelCandidates returns the empty triple when the visible upstream list is empty', async () => { - clearInFlightForTesting(); - const { repo } = await setupAppTest(); - await repo.upstreams.deleteAll(); - // Save one upstream so `listModelProviders([])` (empty filter) can return - // an empty selection without throwing on "unknown id". - await repo.upstreams.save(buildCustomUpstreamRecord({ id: 'up_a', name: 'A', sortOrder: 1 })); - - const resolved = await enumerateModelCandidates({ - upstreamIds: [], - model: 'any-model', - kind: 'chat', - scheduler: testScheduler, - runtimeLocation: 'TEST', - }); - assertEquals(resolved.candidates, []); - assertEquals(resolved.sawModel, false); - assertEquals(resolved.failedUpstreams, []); -}); - -// The alias walk visits every target, tags each real-catalog candidate -// with that target's rule overlay, flattens across targets in `selection` -// order, and dedups by (model, upstream, rules). Two targets pointing at -// the same real model with the same rules collapse; the same pair with -// distinct rules stays as two candidates so both can be attempted. -describe('enumerateModelCandidates alias walk (flat + dedup)', () => { - const aliasCommon = { - displayName: null, - visibleInModelsList: true, - announcedMetadata: null, - sortOrder: 1, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - } as const; - - const buildCatalogFetch = (byModel: Record) => (request: Request): Response => { - const url = new URL(request.url); - if (url.hostname === 'a.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: byModel.up_a.map(id => ({ id })) }); - } - if (url.hostname === 'b.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: byModel.up_b.map(id => ({ id })) }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }; - - const seedUpstreams = async (repo: Awaited>['repo']): Promise => { - await repo.upstreams.deleteAll(); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_a', name: 'A', sortOrder: 1, - config: { baseUrl: 'https://a.example.com', authStyle: 'bearer', apiKey: 'sk-a', endpoints: { chatCompletions: {} } }, - })); - await repo.upstreams.save(buildCustomUpstreamRecord({ - id: 'up_b', name: 'B', sortOrder: 2, - config: { baseUrl: 'https://b.example.com', authStyle: 'bearer', apiKey: 'sk-b', endpoints: { chatCompletions: {} } }, - })); - }; - - test('flattens across targets in declaration order for first-available', async () => { - clearInFlightForTesting(); - const { repo } = await setupAppTest(); - await seedUpstreams(repo); - await repo.modelAliases.insert({ - name: 'smart', kind: 'chat', selection: 'first-available', - targets: [ - { target_model_id: 'gpt-5', rules: {} }, - { target_model_id: 'claude', rules: {} }, - ], - ...aliasCommon, - }); - - await withMockedFetch( - buildCatalogFetch({ up_a: ['gpt-5'], up_b: ['claude'] }), - async () => { - const resolved = await enumerateModelCandidates({ - upstreamIds: null, model: 'smart', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST', - }); - assertEquals( - resolved.candidates.map(c => `${c.model.id}@${c.provider.upstream}`), - ['gpt-5@up_a', 'claude@up_b'], - ); - }, - ); - }); - - test('shuffles the outer walk for random selection but keeps intra-target order', async () => { - clearInFlightForTesting(); - const { repo } = await setupAppTest(); - await seedUpstreams(repo); - await repo.modelAliases.insert({ - name: 'random-alias', kind: 'chat', selection: 'random', - targets: [ - { target_model_id: 'gpt-5', rules: {} }, - { target_model_id: 'claude', rules: {} }, - ], - ...aliasCommon, - }); - - await withMockedFetch( - buildCatalogFetch({ up_a: ['gpt-5', 'claude'], up_b: ['gpt-5', 'claude'] }), - async () => { - const resolved = await enumerateModelCandidates({ - upstreamIds: null, model: 'random-alias', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST', - }); - // Each target contributes two candidates (up_a before up_b, the - // configured sort order). The two two-candidate blocks stay together - // regardless of the outer shuffle. - const grouped = [resolved.candidates.slice(0, 2), resolved.candidates.slice(2, 4)]; - for (const block of grouped) { - expect(block.map(c => c.provider.upstream)).toEqual(['up_a', 'up_b']); - } - const targetOrder = grouped.map(block => block[0]?.model.id); - expect(new Set(targetOrder)).toEqual(new Set(['gpt-5', 'claude'])); - }, - ); - }); - - test('dedups (model, upstream, rules) when two targets hit the same binding with identical rules', async () => { - clearInFlightForTesting(); - const { repo } = await setupAppTest(); - await seedUpstreams(repo); - await repo.modelAliases.insert({ - name: 'dup-alias', kind: 'chat', selection: 'first-available', - targets: [ - { target_model_id: 'gpt-5', rules: { reasoning: { effort: 'low' } } }, - { target_model_id: 'gpt-5', rules: { reasoning: { effort: 'low' } } }, - ], - ...aliasCommon, - }); - - await withMockedFetch( - buildCatalogFetch({ up_a: ['gpt-5'], up_b: [] }), - async () => { - const resolved = await enumerateModelCandidates({ - upstreamIds: null, model: 'dup-alias', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST', - }); - assertEquals(resolved.candidates.length, 1); - assertEquals(resolved.candidates[0]!.model.id, 'gpt-5'); - assertEquals(resolved.candidates[0]!.provider.upstream, 'up_a'); - }, - ); - }); - - test('keeps two entries for the same (model, upstream) with distinct rules', async () => { - clearInFlightForTesting(); - const { repo } = await setupAppTest(); - await seedUpstreams(repo); - await repo.modelAliases.insert({ - name: 'two-rules', kind: 'chat', selection: 'first-available', - targets: [ - { target_model_id: 'gpt-5', rules: { reasoning: { effort: 'low' } } }, - { target_model_id: 'gpt-5', rules: { reasoning: { effort: 'high' } } }, - ], - ...aliasCommon, - }); - - await withMockedFetch( - buildCatalogFetch({ up_a: ['gpt-5'], up_b: [] }), - async () => { - const resolved = await enumerateModelCandidates({ - upstreamIds: null, model: 'two-rules', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST', - }); - assertEquals(resolved.candidates.length, 2); - expect(resolved.candidates.map(c => c.rules?.reasoning?.effort)).toEqual(['low', 'high']); - }, - ); - }); - - test('falls through to a later target when an earlier one has no kind-matching binding', async () => { - clearInFlightForTesting(); - const { repo } = await setupAppTest(); - await seedUpstreams(repo); - await repo.modelAliases.insert({ - name: 'fallback', kind: 'chat', selection: 'first-available', - targets: [ - { target_model_id: 'missing', rules: { verbosity: 'low' } }, - { target_model_id: 'gpt-5', rules: { verbosity: 'high' } }, - ], - ...aliasCommon, - }); - - await withMockedFetch( - buildCatalogFetch({ up_a: ['gpt-5'], up_b: [] }), - async () => { - const resolved = await enumerateModelCandidates({ - upstreamIds: null, model: 'fallback', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST', - }); - // The `missing` target contributes nothing; the `gpt-5` target - // contributes one candidate carrying its own rule overlay. - assertEquals(resolved.candidates.length, 1); - assertEquals(resolved.candidates[0]!.rules?.verbosity, 'high'); - }, - ); - }); -}); diff --git a/packages/gateway/src/data-plane/providers/resolution.ts b/packages/gateway/src/data-plane/providers/resolution.ts new file mode 100644 index 0000000000..722fe448be --- /dev/null +++ b/packages/gateway/src/data-plane/providers/resolution.ts @@ -0,0 +1,244 @@ +import { isEqual } from 'es-toolkit'; + +import { internalModelFromProviderModel } from './catalog.ts'; +import { fetchUpstreamModelsCached } from './models-cache.ts'; +import { listModelProviders } from './registry.ts'; +import { createPerRequestFetcher } from '../../dial/per-request.ts'; +import { getRepo } from '../../repo/index.ts'; +import type { ModelAliasRecord } from '../../repo/types.ts'; +import type { BackgroundScheduler } from '@floway-dev/platform'; +import type { ModelKind } from '@floway-dev/protocols/common'; +import { isAbortError, type Fetcher, type ModelCandidate, type Provider } from '@floway-dev/provider'; + +// Resolve one inbound id against one upstream. The upstream's +// `modelPrefix.addressable` configuration decides which lookup branches +// apply: an `unprefixed`-addressable upstream is probed with the inbound id +// verbatim; a `prefixed`-addressable upstream is probed with the inbound id +// minus its configured prefix when (and only when) the inbound carries that +// prefix. Both branches are evaluated against the same SWR-cached catalog +// fetch — a single upstream typically contributes at most one candidate, +// but a catalog that publishes both the bare and prefixed forms can match +// twice and both go through. +// +// `kind` is threaded down here so a wrong-kind catalog entry never becomes +// a candidate. `sawAnyId` is true whenever the lookup id appeared in the +// catalog regardless of kind, so the caller can distinguish +// "id is unknown to this upstream" from "id exists but wrong kind". +const enumerateOneUpstreamCandidates = async ( + provider: Provider, + modelId: string, + kind: ModelKind, + fetcher: Fetcher, + scheduler: BackgroundScheduler, +): Promise<{ candidates: ModelCandidate[]; sawAnyId: boolean }> => { + const cfg = provider.modelPrefix; + const lookupIds: string[] = []; + if (cfg === null) { + lookupIds.push(modelId); + } else { + for (const form of cfg.addressable) { + if (form === 'unprefixed') lookupIds.push(modelId); + else if (form === 'prefixed' && modelId.startsWith(cfg.prefix)) lookupIds.push(modelId.slice(cfg.prefix.length)); + } + } + if (lookupIds.length === 0) return { candidates: [], sawAnyId: false }; + + const providedModels = await fetchUpstreamModelsCached(provider, { scheduler, fetcher }); + const disabled = new Set(provider.disabledPublicModelIds); + const candidates: ModelCandidate[] = []; + let sawAnyId = false; + for (const lookupId of lookupIds) { + const match = providedModels.find(m => m.id === lookupId && !disabled.has(m.id)); + if (!match) continue; + sawAnyId = true; + if (match.kind === kind) { + candidates.push({ provider, model: internalModelFromProviderModel(match, provider.upstreamId), fetcher }); + } + } + return { candidates, sawAnyId }; +}; + +// Walk every visible upstream, in configured order, and collect every +// (provider, model, fetcher) candidate the inbound id resolves against +// at the requested kind. Per-upstream catalog fetches fan out concurrently +// so a slow upstream cannot stall the rest. Cancellation (`AbortError`) +// propagates so the per-request abort signal cannot be masked by a slow +// upstream's rejection. +// +// `sawAnyId` aggregates the per-upstream signal: true when at least one +// upstream's catalog carried the inbound id under any kind. The caller +// uses it to decide whether to retry with a stripped dated suffix (no +// point retrying if the id matched but only under the wrong kind — the +// suffix strip cannot change kind). +export const enumerateRealModelCandidates = async ( + modelId: string, + kind: ModelKind, + providers: readonly Provider[], + fetcherForUpstream: (upstreamId: string) => Fetcher, + scheduler: BackgroundScheduler, +): Promise<{ + readonly candidates: readonly ModelCandidate[]; + readonly sawAnyId: boolean; + readonly failedUpstreams: readonly string[]; +}> => { + const settled = await Promise.allSettled(providers.map(provider => + enumerateOneUpstreamCandidates(provider, modelId, kind, fetcherForUpstream(provider.upstreamId), scheduler))); + + const failedUpstreams: string[] = []; + const candidates: ModelCandidate[] = []; + let sawAnyId = false; + for (const [index, result] of settled.entries()) { + if (result.status === 'rejected') { + const error = result.reason; + if (isAbortError(error)) throw error; + failedUpstreams.push(providers[index].name); + continue; + } + candidates.push(...result.value.candidates); + sawAnyId = sawAnyId || result.value.sawAnyId; + } + return { candidates, sawAnyId, failedUpstreams }; +}; + +// Vendor clients sometimes pin a model id to its release date +// (`claude-sonnet-4-5-20250929`) even though the gateway's merged catalog +// only carries the undated alias. When the inbound id matches no catalog +// entry, strip an 8-digit `-YYYYMMDD` suffix and try once more — failed +// catalog fetches across the two attempts dedupe into a single +// `failedUpstreams` list for the caller's renderer. +const DATED_SUFFIX = /-\d{8}$/; + +// Real-catalog resolution with the dated-suffix retry baked in. Used both +// directly (when we already hold the provider list) and by +// `enumerateModelCandidates` below, which lists providers and then delegates +// here — once for each alias target when the inbound id names an alias. +const resolveRealCandidates = async ( + modelId: string, + kind: ModelKind, + providers: readonly Provider[], + fetcherForUpstream: (upstreamId: string) => Fetcher, + scheduler: BackgroundScheduler, +): Promise<{ + readonly candidates: readonly ModelCandidate[]; + readonly sawModel: boolean; + readonly failedUpstreams: readonly string[]; +}> => { + const first = await enumerateRealModelCandidates(modelId, kind, providers, fetcherForUpstream, scheduler); + if (first.candidates.length > 0 || first.sawAnyId || !DATED_SUFFIX.test(modelId)) { + return { candidates: first.candidates, sawModel: first.sawAnyId, failedUpstreams: first.failedUpstreams }; + } + const stripped = modelId.replace(DATED_SUFFIX, ''); + const second = await enumerateRealModelCandidates(stripped, kind, providers, fetcherForUpstream, scheduler); + return { + candidates: second.candidates, + sawModel: second.sawAnyId, + failedUpstreams: [...new Set([...first.failedUpstreams, ...second.failedUpstreams])], + }; +}; + +// Target order for an alias walk: `first-available` yields declaration +// order; `random` shuffles so the outer walk distributes uniformly across +// targets. Within a single target's real-catalog walk the per-upstream +// order is always preserved (registry enumeration order); shuffling +// applies to the target list, not to a target's candidates. +const orderAliasTargets = (alias: ModelAliasRecord): readonly ModelAliasRecord['targets'][number][] => { + if (alias.selection === 'first-available') return alias.targets; + const shuffled = [...alias.targets]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +}; + +// Per-request model resolution. Two-branch chain: +// +// 1. Look the inbound id up in the alias repo. When the id names an +// alias, walk every target in `selection`-mode order, delegate to the +// real-catalog resolver for each one, tag each returned candidate +// with that target's rule overlay, flatten across targets, and dedup +// by (modelId, upstreamId, rules) — same (model, upstream) with +// differing rules stays as distinct candidates so both variants can +// be dispatched. `iterateCandidates` at the serve layer then cascades +// across every kept candidate: a target's upstreams all failing over +// falls through into the next target's candidates instead of hard- +// failing at the first target. +// 2. Otherwise (no alias match at all) run the real-catalog resolver +// directly on the inbound id. +// +// The real-catalog resolver walks every visible upstream, filters by kind +// inside the walk (so wrong-kind entries never become candidates), and +// retries once with an eight-digit dated suffix stripped when the id +// matched nothing at all. `sawModel` reports whether the id was known to +// any upstream regardless of kind, so the caller can distinguish "model +// missing" (404) from "model wrong kind" (400). +// +// Endpoint-level narrowing — picking the chat target protocol from +// `model.endpoints`, or checking the specific `imagesEdits` / +// `imagesGenerations` / `audioTranscriptions` / `completions` endpoint key — is the caller's job. +// This function stays endpoint-blind so the same path serves chat, +// embeddings, image generation/edits, rerank, audio transcription, and legacy +// completions. +// +// The alias walk is a natural top-of-chain check: by construction an +// alias's target id is a real model id, so the shadow pattern (an alias +// whose first target matches its own name) resolves to the real model on +// the first pass; alias names never re-enter the alias layer. +export const enumerateModelCandidates = async ({ + upstreamIds, model, kind, scheduler, runtimeLocation, +}: { + // null = unrestricted; empty list = no providers visible. + upstreamIds: readonly string[] | null; + model: string; + kind: ModelKind; + // Threaded into `enumerateRealModelCandidates` so the per-upstream + // catalog lookup hits the SWR-cached `fetchUpstreamModelsCached` instead + // of round-tripping to the upstream on every request. + scheduler: BackgroundScheduler; + // Runtime location tag for this request — see GatewayCtx.runtimeLocation. + // Threaded into the per-request fetcher so colo-scoped fallback entries + // can be honoured at dial time. + runtimeLocation: string; +}): Promise<{ + readonly candidates: readonly ModelCandidate[]; + readonly sawModel: boolean; + readonly failedUpstreams: readonly string[]; +}> => { + const fetcherForUpstream = await createPerRequestFetcher(runtimeLocation); + const providers = await listModelProviders(upstreamIds); + + const alias = await getRepo().modelAliases.getByName(model); + if (alias === null) { + return await resolveRealCandidates(model, kind, providers, fetcherForUpstream, scheduler); + } + + // Walk every target, tag each returned candidate with the target's rule + // overlay, then flatten (target order preserved), and dedup by + // (modelId, upstreamId, rules). Different rules against the same + // (model, upstream) stay as distinct entries so the operator can pin the + // same physical binding under two rule variants. + const aggregatedFailed = new Set(); + let sawAny = false; + const flat: ModelCandidate[] = []; + for (const target of orderAliasTargets(alias)) { + const result = await resolveRealCandidates(target.target_model_id, kind, providers, fetcherForUpstream, scheduler); + for (const name of result.failedUpstreams) aggregatedFailed.add(name); + if (result.sawModel) sawAny = true; + for (const candidate of result.candidates) { + flat.push({ ...candidate, rules: target.rules }); + } + } + const deduped: ModelCandidate[] = []; + for (const candidate of flat) { + const duplicate = deduped.some(existing => + existing.model.id === candidate.model.id + && existing.provider.upstreamId === candidate.provider.upstreamId + && isEqual(existing.rules, candidate.rules)); + if (!duplicate) deduped.push(candidate); + } + return { + candidates: deduped, + sawModel: sawAny, + failedUpstreams: [...aggregatedFailed], + }; +}; diff --git a/packages/gateway/src/data-plane/providers/resolution_test.ts b/packages/gateway/src/data-plane/providers/resolution_test.ts new file mode 100644 index 0000000000..5c497cdfbf --- /dev/null +++ b/packages/gateway/src/data-plane/providers/resolution_test.ts @@ -0,0 +1,606 @@ +import { describe, expect, test } from 'vitest'; + +import { clearInFlightForTesting } from './models-cache.ts'; +import { listModelProviders } from './registry.ts'; +import { enumerateModelCandidates, enumerateRealModelCandidates } from './resolution.ts'; +import { buildCustomUpstreamRecord, copilotModels, setupAppTest } from '../../test-utils/app.ts'; +import { directFetcher, type InternalModel, type ProviderModel } from '@floway-dev/provider'; +import { assertEquals, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; + +const realProviderModels = (model: InternalModel | undefined): Record => { + if (model?.providerModels === undefined) throw new Error(`expected real InternalModel with providerModels, got ${JSON.stringify(model)}`); + return model.providerModels; +}; + +const testScheduler = (promise: Promise): void => { + promise.catch(err => console.error('[background]', err)); +}; + +test('enumerateModelCandidates strips an -YYYYMMDD suffix when nothing matched and retries across every visible upstream', async () => { + const { repo } = await setupAppTest(); + + await repo.upstreams.save( + buildCustomUpstreamRecord({ + config: { + baseUrl: 'https://custom.example.com', + authStyle: 'bearer', + apiKey: 'sk-custom', + endpoints: { messages: {} }, + }, + }), + ); + + await withMockedFetch( + request => { + const url = new URL(request.url); + + if (url.hostname === 'update.code.visualstudio.com') { + return jsonResponse(['1.110.1']); + } + if (url.pathname === '/copilot_internal/v2/token') { + return jsonResponse({ + token: 'copilot-access-token', + expires_at: 4102444800, + refresh_in: 3600, + endpoints: { api: 'https://api.individual.githubcopilot.com' }, + }); + } + if (url.hostname === 'api.individual.githubcopilot.com' && url.pathname === '/models') { + return jsonResponse(copilotModels([{ id: 'claude-opus-4.7', supported_endpoints: ['/v1/messages'] }])); + } + if (url.hostname === 'custom.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ + object: 'list', + data: [{ id: 'claude-opus-4-7' }], + }); + } + + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'claude-opus-4-7-20300101', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + + // No upstream's catalog literally lists `claude-opus-4-7-20300101`, + // so the resolver retries against the stripped `claude-opus-4-7`, + // which both upstreams expose. Both candidates end up in the match + // list in configured `sort_order`. + assertEquals(resolved.candidates.map(m => m.provider.upstreamId).sort(), ['up_copilot', 'up_custom'].sort()); + assertEquals(resolved.candidates.map(m => m.model.id), ['claude-opus-4-7', 'claude-opus-4-7']); + }, + ); +}); + +test('enumerateModelCandidates does not retry when the inbound id has no dated suffix', async () => { + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save( + buildCustomUpstreamRecord({ + config: { + baseUrl: 'https://custom.example.com', + authStyle: 'bearer', + apiKey: 'sk-custom', + endpoints: { messages: {} }, + }, + }), + ); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'custom.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'claude-opus-4-7' }] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + // Plain typo / unknown id — no dated suffix, no retry. + const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'claude-opus-4-7-unknown', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(resolved.candidates.length, 0); + }, + ); +}); + +test('enumerateModelCandidates prefers the literal dated id over the stripped base when the catalog lists both', async () => { + // The dated suffix fallback is a SECOND attempt, gated on the first + // attempt finding nothing. When the upstream catalog already lists the + // dated id verbatim, the first attempt wins and the stripped form + // never enters the candidate list. + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save( + buildCustomUpstreamRecord({ + config: { + baseUrl: 'https://custom.example.com', + authStyle: 'bearer', + apiKey: 'sk-custom', + endpoints: { messages: {} }, + }, + }), + ); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'custom.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ + object: 'list', + data: [ + { id: 'claude-sonnet-4-5' }, + { id: 'claude-sonnet-4-5-20251101' }, + ], + }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'claude-sonnet-4-5-20251101', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(resolved.candidates.length, 1); + assertEquals(resolved.candidates[0]?.model.id, 'claude-sonnet-4-5-20251101'); + }, + ); +}); + +test('enumerateRealModelCandidates only loads the selected providers\' catalogs', async () => { + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_first', + name: 'First', + sortOrder: 0, + config: { baseUrl: 'https://first.example.com', authStyle: 'bearer', apiKey: 'sk-first', endpoints: { responses: {} } }, + })); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_second', + name: 'Second', + sortOrder: 100, + config: { baseUrl: 'https://second.example.com', authStyle: 'bearer', apiKey: 'sk-second', endpoints: { responses: {} } }, + })); + + const providers = await listModelProviders(null); + let secondModelsFetches = 0; + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'first.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ data: [{ id: 'target-model' }] }); + } + if (url.hostname === 'second.example.com' && url.pathname === '/v1/models') { + secondModelsFetches++; + return jsonResponse({ data: [{ id: 'target-model' }] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const { candidates } = await enumerateRealModelCandidates('target-model', 'chat', [providers[0]], () => directFetcher, testScheduler); + + assertEquals(candidates[0]?.model.id, 'target-model'); + assertEquals(candidates[0]?.provider.upstreamId, 'up_first'); + // Every enumerated candidate seeds `providerModels[provider.upstreamId]` + // so `providerModelOf(candidate)` resolves at dispatch time. + assertEquals(Object.keys(realProviderModels(candidates[0]?.model)), ['up_first']); + }, + ); + + assertEquals(secondModelsFetches, 0); +}); + +test('enumerateRealModelCandidates rejects a model id disabled on that upstream (filter parity with the catalog)', async () => { + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save({ + id: 'up_x', + kind: 'azure', + name: 'X', + enabled: true, + sortOrder: 1, + createdAt: '2026-05-21T00:00:00.000Z', + updatedAt: '2026-05-21T00:00:00.000Z', + config: { + endpoint: 'https://example.openai.azure.com', + apiKey: 'az-key', + models: [ + { upstreamModelId: 'enabled-model', endpoints: { chatCompletions: {} } }, + { upstreamModelId: 'disabled-model', endpoints: { chatCompletions: {} } }, + ], + }, + flagOverrides: {}, + disabledPublicModelIds: ['disabled-model'], + proxyFallbackList: [], + modelPrefix: null, + color: null, + state: null, + }); + + const providers = await listModelProviders(null); + const enabled = await enumerateRealModelCandidates('enabled-model', 'chat', providers, () => directFetcher, testScheduler); + const disabled = await enumerateRealModelCandidates('disabled-model', 'chat', providers, () => directFetcher, testScheduler); + assertEquals(enabled.candidates[0]?.model.id, 'enabled-model'); + assertEquals(disabled.candidates.length, 0); +}); + +// Regression: when an upstream's force re-fetch rejects past HARD, the call +// site asking for a model belonging to one of the *healthy* upstreams must +// still resolve. The broken upstream's display name flows back via +// `failedUpstreams` so the eventual error renderer can mention it. +test('enumerateModelCandidates: healthy upstream still resolves alongside a rejecting one, with failedUpstreams reported', async () => { + clearInFlightForTesting(); + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_broken', + name: 'Broken upstream', + sortOrder: 1, + config: { baseUrl: 'https://broken.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + })); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_ok', + name: 'Healthy upstream', + sortOrder: 2, + config: { baseUrl: 'https://ok.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + })); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'broken.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ error: 'upstream went down' }, 502); + } + if (url.hostname === 'ok.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'ok-model', supported_endpoints: ['/chat/completions'] }] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const resolvedExisting = await enumerateModelCandidates({ upstreamIds: null, model: 'ok-model', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(resolvedExisting.candidates.map(m => m.provider.upstreamId), ['up_ok']); + assertEquals(resolvedExisting.candidates[0]?.model.id, 'ok-model'); + assertEquals(resolvedExisting.failedUpstreams, ['Broken upstream']); + + // A model nobody currently knows about must NOT rethrow the broken + // upstream's catalog error — the caller's failure renderer is the right + // place to surface that, parenthetically, alongside the model-missing + // body. + const resolvedMissing = await enumerateModelCandidates({ upstreamIds: null, model: 'unknown-model', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(resolvedMissing.candidates.length, 0); + assertEquals(resolvedMissing.failedUpstreams, ['Broken upstream']); + }, + ); +}); + +// A wrong-kind match (`sawAnyId=true, candidates=[]`) must short-circuit the +// dated-suffix retry — the suffix strip cannot turn a wrong-kind id into a +// right-kind one. The catalog carries the literal dated id as a chat model; +// requesting it with `kind: 'image'` produces sawAnyId=true on the first +// attempt, so the resolver returns immediately rather than walking the +// stripped form. +test('enumerateModelCandidates does NOT trigger the dated-suffix retry on a wrong-kind sawAnyId match', async () => { + clearInFlightForTesting(); + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_chat_only', + name: 'ChatOnly', + sortOrder: 1, + config: { baseUrl: 'https://chatonly.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + })); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'chatonly.example.com' && url.pathname === '/v1/models') { + // The dated form is literally present in the catalog (chat-kind). + return jsonResponse({ object: 'list', data: [{ id: 'claude-opus-4-7-20251231', supported_endpoints: ['/chat/completions'] }] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const resolved = await enumerateModelCandidates({ + upstreamIds: null, + model: 'claude-opus-4-7-20251231', + kind: 'image', + scheduler: testScheduler, + runtimeLocation: 'TEST', + }); + assertEquals(resolved.candidates, []); + // `sawModel: true` pins that only the first attempt ran: the resolver + // assigns `sawModel: second.sawAnyId` after retry (overwrite, not OR), + // so a second walk against the stripped `claude-opus-4-7` (absent from + // this fixture's catalog) would flip sawModel to false. + assertEquals(resolved.sawModel, true); + assertEquals(resolved.failedUpstreams, []); + }, + ); +}); + +// failedUpstreams across the two retry attempts must dedupe: a single broken +// upstream that rejects both walks reports its name once, not twice. +test('enumerateModelCandidates deduplicates failedUpstreams across the dated-suffix retry attempts', async () => { + clearInFlightForTesting(); + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_broken', + name: 'Broken', + sortOrder: 1, + config: { baseUrl: 'https://broken.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + })); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'broken.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ error: 'upstream went down' }, 502); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const resolved = await enumerateModelCandidates({ + upstreamIds: null, + model: 'claude-opus-4-7-20251231', + kind: 'chat', + scheduler: testScheduler, + runtimeLocation: 'TEST', + }); + assertEquals(resolved.candidates.length, 0); + // The same broken upstream appears in both attempts' failedUpstreams; + // the outer resolver collapses the duplicate via a Set. + assertEquals(resolved.failedUpstreams.length, 1); + assertEquals(resolved.failedUpstreams[0], 'Broken'); + }, + ); +}); + +// AbortError must propagate end-to-end so the caller's per-request abort +// signal cannot be masked by a slow upstream. Burying it in failedUpstreams +// would let the rest of the data-plane request build a Response against a +// stale catalog. The provider's `fetchUpstreamModels` wraps the upstream +// fetch error in a ProviderModelsUnavailableError with the AbortError as +// its cause, so the resolver's detection walks the cause chain. +test('enumerateModelCandidates rethrows AbortError from a per-upstream catalog fetch', async () => { + clearInFlightForTesting(); + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_aborting', + name: 'Aborting', + sortOrder: 1, + config: { baseUrl: 'https://aborting.example.com', authStyle: 'bearer', apiKey: 'sk-x', endpoints: { chatCompletions: {} } }, + })); + + const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' }); + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'aborting.example.com' && url.pathname === '/v1/models') { + throw abortError; + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + let thrown: unknown = null; + try { + await enumerateModelCandidates({ + upstreamIds: null, + model: 'any-model', + kind: 'chat', + scheduler: testScheduler, + runtimeLocation: 'TEST', + }); + } catch (e) { + thrown = e; + } + // The thrown error chains back to our injected AbortError via .cause. + const isAbortInChain = (err: unknown): boolean => { + for (let cur: unknown = err; cur != null; cur = (cur as { cause?: unknown }).cause) { + if (cur instanceof Error && cur.name === 'AbortError') return true; + } + return false; + }; + if (!isAbortInChain(thrown)) { + throw new Error(`expected rejection to carry an AbortError in its cause chain; got: ${thrown instanceof Error ? `${thrown.name}: ${thrown.message}` : String(thrown)}`); + } + }, + ); +}); + +// Empty visible upstream list: a caller cap pinned to an empty set yields +// `{candidates: [], sawModel: false, failedUpstreams: []}` without any +// upstream fetch. The failure renderer surfaces this as a model-missing 404 +// without re-deriving the empty-cap branch. +test('enumerateModelCandidates returns the empty triple when the visible upstream list is empty', async () => { + clearInFlightForTesting(); + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + // Save one upstream so `listModelProviders([])` (empty filter) can return + // an empty selection without throwing on "unknown id". + await repo.upstreams.save(buildCustomUpstreamRecord({ id: 'up_a', name: 'A', sortOrder: 1 })); + + const resolved = await enumerateModelCandidates({ + upstreamIds: [], + model: 'any-model', + kind: 'chat', + scheduler: testScheduler, + runtimeLocation: 'TEST', + }); + assertEquals(resolved.candidates, []); + assertEquals(resolved.sawModel, false); + assertEquals(resolved.failedUpstreams, []); +}); + +// The alias walk visits every target, tags each real-catalog candidate +// with that target's rule overlay, flattens across targets in `selection` +// order, and dedups by (model, upstream, rules). Two targets pointing at +// the same real model with the same rules collapse; the same pair with +// distinct rules stays as two candidates so both can be attempted. +describe('enumerateModelCandidates alias walk (flat + dedup)', () => { + const aliasCommon = { + displayName: null, + visibleInModelsList: true, + announcedMetadata: null, + sortOrder: 1, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + } as const; + + const buildCatalogFetch = (byModel: Record) => (request: Request): Response => { + const url = new URL(request.url); + if (url.hostname === 'a.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: byModel.up_a.map(id => ({ id })) }); + } + if (url.hostname === 'b.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: byModel.up_b.map(id => ({ id })) }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }; + + const seedUpstreams = async (repo: Awaited>['repo']): Promise => { + await repo.upstreams.deleteAll(); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_a', name: 'A', sortOrder: 1, + config: { baseUrl: 'https://a.example.com', authStyle: 'bearer', apiKey: 'sk-a', endpoints: { chatCompletions: {} } }, + })); + await repo.upstreams.save(buildCustomUpstreamRecord({ + id: 'up_b', name: 'B', sortOrder: 2, + config: { baseUrl: 'https://b.example.com', authStyle: 'bearer', apiKey: 'sk-b', endpoints: { chatCompletions: {} } }, + })); + }; + + test('flattens across targets in declaration order for first-available', async () => { + clearInFlightForTesting(); + const { repo } = await setupAppTest(); + await seedUpstreams(repo); + await repo.modelAliases.insert({ + name: 'smart', kind: 'chat', selection: 'first-available', + targets: [ + { target_model_id: 'gpt-5', rules: {} }, + { target_model_id: 'claude', rules: {} }, + ], + ...aliasCommon, + }); + + await withMockedFetch( + buildCatalogFetch({ up_a: ['gpt-5'], up_b: ['claude'] }), + async () => { + const resolved = await enumerateModelCandidates({ + upstreamIds: null, model: 'smart', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST', + }); + assertEquals( + resolved.candidates.map(c => `${c.model.id}@${c.provider.upstreamId}`), + ['gpt-5@up_a', 'claude@up_b'], + ); + }, + ); + }); + + test('shuffles the outer walk for random selection but keeps intra-target order', async () => { + clearInFlightForTesting(); + const { repo } = await setupAppTest(); + await seedUpstreams(repo); + await repo.modelAliases.insert({ + name: 'random-alias', kind: 'chat', selection: 'random', + targets: [ + { target_model_id: 'gpt-5', rules: {} }, + { target_model_id: 'claude', rules: {} }, + ], + ...aliasCommon, + }); + + await withMockedFetch( + buildCatalogFetch({ up_a: ['gpt-5', 'claude'], up_b: ['gpt-5', 'claude'] }), + async () => { + const resolved = await enumerateModelCandidates({ + upstreamIds: null, model: 'random-alias', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST', + }); + // Each target contributes two candidates (up_a before up_b, the + // configured sort order). The two two-candidate blocks stay together + // regardless of the outer shuffle. + const grouped = [resolved.candidates.slice(0, 2), resolved.candidates.slice(2, 4)]; + for (const block of grouped) { + expect(block.map(c => c.provider.upstreamId)).toEqual(['up_a', 'up_b']); + } + const targetOrder = grouped.map(block => block[0]?.model.id); + expect(new Set(targetOrder)).toEqual(new Set(['gpt-5', 'claude'])); + }, + ); + }); + + test('dedups (model, upstream, rules) when two targets hit the same binding with identical rules', async () => { + clearInFlightForTesting(); + const { repo } = await setupAppTest(); + await seedUpstreams(repo); + await repo.modelAliases.insert({ + name: 'dup-alias', kind: 'chat', selection: 'first-available', + targets: [ + { target_model_id: 'gpt-5', rules: { reasoning: { effort: 'low' } } }, + { target_model_id: 'gpt-5', rules: { reasoning: { effort: 'low' } } }, + ], + ...aliasCommon, + }); + + await withMockedFetch( + buildCatalogFetch({ up_a: ['gpt-5'], up_b: [] }), + async () => { + const resolved = await enumerateModelCandidates({ + upstreamIds: null, model: 'dup-alias', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST', + }); + assertEquals(resolved.candidates.length, 1); + assertEquals(resolved.candidates[0]!.model.id, 'gpt-5'); + assertEquals(resolved.candidates[0]!.provider.upstreamId, 'up_a'); + }, + ); + }); + + test('keeps two entries for the same (model, upstream) with distinct rules', async () => { + clearInFlightForTesting(); + const { repo } = await setupAppTest(); + await seedUpstreams(repo); + await repo.modelAliases.insert({ + name: 'two-rules', kind: 'chat', selection: 'first-available', + targets: [ + { target_model_id: 'gpt-5', rules: { reasoning: { effort: 'low' } } }, + { target_model_id: 'gpt-5', rules: { reasoning: { effort: 'high' } } }, + ], + ...aliasCommon, + }); + + await withMockedFetch( + buildCatalogFetch({ up_a: ['gpt-5'], up_b: [] }), + async () => { + const resolved = await enumerateModelCandidates({ + upstreamIds: null, model: 'two-rules', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST', + }); + assertEquals(resolved.candidates.length, 2); + expect(resolved.candidates.map(c => c.rules?.reasoning?.effort)).toEqual(['low', 'high']); + }, + ); + }); + + test('falls through to a later target when an earlier one has no kind-matching binding', async () => { + clearInFlightForTesting(); + const { repo } = await setupAppTest(); + await seedUpstreams(repo); + await repo.modelAliases.insert({ + name: 'fallback', kind: 'chat', selection: 'first-available', + targets: [ + { target_model_id: 'missing', rules: { verbosity: 'low' } }, + { target_model_id: 'gpt-5', rules: { verbosity: 'high' } }, + ], + ...aliasCommon, + }); + + await withMockedFetch( + buildCatalogFetch({ up_a: ['gpt-5'], up_b: [] }), + async () => { + const resolved = await enumerateModelCandidates({ + upstreamIds: null, model: 'fallback', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST', + }); + // The `missing` target contributes nothing; the `gpt-5` target + // contributes one candidate carrying its own rule overlay. + assertEquals(resolved.candidates.length, 1); + assertEquals(resolved.candidates[0]!.rules?.verbosity, 'high'); + }, + ); + }); +}); diff --git a/packages/gateway/src/data-plane/rerank/attempt.ts b/packages/gateway/src/data-plane/rerank/attempt.ts new file mode 100644 index 0000000000..34560c10ce --- /dev/null +++ b/packages/gateway/src/data-plane/rerank/attempt.ts @@ -0,0 +1,42 @@ +import type { Context } from 'hono'; + +import type { GatewayCtx } from '../shared/gateway-ctx.ts'; +import { inboundHeadersForUpstream } from '../shared/inbound-headers.ts'; +import { telemetryModelIdentity, upstreamPerformanceContext } from '../shared/telemetry/attribution.ts'; +import { buildUpstreamCallOptions } from '../shared/upstream-call-options.ts'; +import type { RerankTarget } from '@floway-dev/protocols/common'; +import type { CanonicalRerankRequest } from '@floway-dev/protocols/rerank'; +import { providerModelOf } from '@floway-dev/provider'; +import type { ModelCandidate, PerformanceTelemetryContext, ProviderRerankCallResult, TelemetryModelIdentity } from '@floway-dev/provider'; + +export interface RerankAttemptResult { + readonly type: 'plain'; + readonly status: number; + readonly response: Response; + readonly target: RerankTarget; + readonly performance: PerformanceTelemetryContext; + readonly identity: TelemetryModelIdentity; +} + +export const rerankAttempt = async ( + c: Context, + ctx: GatewayCtx, + candidate: ModelCandidate, + request: CanonicalRerankRequest, +): Promise => { + const model = providerModelOf(candidate); + const result: ProviderRerankCallResult = await candidate.provider.instance.callRerank( + model, + request, + ctx.abortSignal, + buildUpstreamCallOptions(candidate, ctx, inboundHeadersForUpstream(c)), + ); + return { + type: 'plain', + status: result.response.status, + response: result.response, + target: result.target, + performance: upstreamPerformanceContext(ctx, candidate, 'rerank'), + identity: telemetryModelIdentity(candidate, result.modelKey), + }; +}; diff --git a/packages/gateway/src/data-plane/rerank/serve.ts b/packages/gateway/src/data-plane/rerank/serve.ts index d7ff922221..b0293b12bb 100644 --- a/packages/gateway/src/data-plane/rerank/serve.ts +++ b/packages/gateway/src/data-plane/rerank/serve.ts @@ -1,32 +1,21 @@ import type { Context } from 'hono'; import type { ContentfulStatusCode } from 'hono/utils/http-status'; +import { rerankAttempt, type RerankAttemptResult } from './attempt.ts'; import type { UsageQuantities } from '../../repo/types.ts'; import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { createGatewayCtxFromHono, finalizeGatewayResponse, type GatewayCtx } from '../chat/shared/gateway-ctx.ts'; -import { readRequestBody, takeRequestBody } from '../chat/shared/request-body.ts'; -import { enumerateModelCandidates } from '../providers/registry.ts'; +import { enumerateModelCandidates } from '../providers/resolution.ts'; import { appendFailedUpstreams } from '../shared/failed-upstreams.ts'; -import { inboundHeadersForUpstream } from '../shared/inbound-headers.ts'; +import { createGatewayCtxFromHono, finalizeGatewayResponse, type GatewayCtx } from '../shared/gateway-ctx.ts'; import { iterateCandidates } from '../shared/iterate-candidates.ts'; -import { telemetryModelIdentity, upstreamPerformanceContext } from '../shared/telemetry/attribution.ts'; +import { readRequestBody, takeRequestBody } from '../shared/request-body.ts'; import { recordFailedRequest, recordPerformance, type PerformanceTelemetryContext } from '../shared/telemetry/performance.ts'; import { recordUsage } from '../shared/telemetry/usage.ts'; -import { buildUpstreamCallOptions } from '../shared/upstream-call-options.ts'; import { forwardUpstreamResponse } from '../shared/upstream-response.ts'; -import { canonicalDecimalString, type RerankSourceProtocol, type RerankTarget } from '@floway-dev/protocols/common'; -import { parseRerankRequest, parseRerankResponse, parseRerankUsage, renderRerankResponse, rerankRequestIncompatibility, type CanonicalRerankRequest, type CanonicalRerankResponse, type ParsedRerankRequest } from '@floway-dev/protocols/rerank'; +import { canonicalDecimalString, type RerankSourceProtocol } from '@floway-dev/protocols/common'; +import { parseRerankRequest, parseRerankResponse, parseRerankUsage, renderRerankResponse, rerankRequestIncompatibility, type CanonicalRerankResponse, type ParsedRerankRequest } from '@floway-dev/protocols/rerank'; import { httpResponseToResponse, ProviderModelsUnavailableError, providerModelOf, toInternalDebugError } from '@floway-dev/provider'; -import type { ModelCandidate, ProviderRerankCallResult, TelemetryModelIdentity } from '@floway-dev/provider'; - -interface RerankAttemptResult { - readonly type: 'plain'; - readonly status: number; - readonly response: Response; - readonly target: RerankTarget; - readonly performance: PerformanceTelemetryContext; - readonly identity: TelemetryModelIdentity; -} +import type { TelemetryModelIdentity } from '@floway-dev/provider'; const apiError = (c: Context, message: string, status: ContentfulStatusCode): Response => c.json({ error: { message, type: 'api_error' } }, status); @@ -39,29 +28,6 @@ const parseJson = (bytes: Uint8Array): unknown => { } }; -const attemptRerank = async ( - c: Context, - ctx: GatewayCtx, - candidate: ModelCandidate, - request: CanonicalRerankRequest, -): Promise => { - const model = providerModelOf(candidate); - const result: ProviderRerankCallResult = await candidate.provider.instance.callRerank( - model, - request, - ctx.abortSignal, - buildUpstreamCallOptions(candidate, ctx, inboundHeadersForUpstream(c)), - ); - return { - type: 'plain', - status: result.response.status, - response: result.response, - target: result.target, - performance: upstreamPerformanceContext(ctx, candidate, 'rerank'), - identity: telemetryModelIdentity(candidate, result.modelKey), - }; -}; - const settleRerank = ( ctx: GatewayCtx, performanceContext: PerformanceTelemetryContext, @@ -148,7 +114,7 @@ export const rerank = (sourceProtocol: RerankSourceProtocol) => async (c: Contex 'rerank', ctx, 'rerank', - candidate => attemptRerank(c, ctx, candidate, request), + candidate => rerankAttempt(c, ctx, candidate, request), ); if (!terminal.response.ok) { diff --git a/packages/gateway/src/data-plane/rerank/serve_test.ts b/packages/gateway/src/data-plane/rerank/serve_test.ts index 2e83c141c1..4d5cef8ab1 100644 --- a/packages/gateway/src/data-plane/rerank/serve_test.ts +++ b/packages/gateway/src/data-plane/rerank/serve_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import type { Repo } from '../../repo/types.ts'; -import { buildCustomUpstreamRecord, flushAsyncWork, requestApp, setupAppTest } from '../../test-helpers.ts'; +import { buildCustomUpstreamRecord, flushAsyncWork, requestApp, setupAppTest } from '../../test-utils/app.ts'; import type { ModelPricing, RerankTarget } from '@floway-dev/protocols/common'; import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; import { assertEquals, assertExists, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/routes.ts b/packages/gateway/src/data-plane/routes.ts index 2f8e1dc217..3847c88aae 100644 --- a/packages/gateway/src/data-plane/routes.ts +++ b/packages/gateway/src/data-plane/routes.ts @@ -1,14 +1,14 @@ import type { Hono } from 'hono'; import { mountAlphaSearchRoutes } from './alpha-search/routes.ts'; -import { audioTranscriptions } from './audio/transcriptions.ts'; +import { audioTranscriptions } from './audio/http.ts'; import { mountChatRoutes } from './chat/routes.ts'; import { mountCodexRoutes } from './codex/routes.ts'; import { completions } from './completions/http.ts'; import { embeddings } from './embeddings/http.ts'; import { imagesEdits, imagesGenerations } from './images/http.ts'; import { serveGeminiModelInfo, serveGeminiModels } from './models/gemini.ts'; -import { serveModels } from './models/serve.ts'; +import { serveModels } from './models/http.ts'; import { rerank } from './rerank/serve.ts'; import type { AuthVars } from '../middleware/auth.ts'; diff --git a/packages/gateway/src/data-plane/shared/gateway-ctx.ts b/packages/gateway/src/data-plane/shared/gateway-ctx.ts new file mode 100644 index 0000000000..193f9b8863 --- /dev/null +++ b/packages/gateway/src/data-plane/shared/gateway-ctx.ts @@ -0,0 +1,111 @@ +import type { RequestBody } from './request-body.ts'; +import { type DumpAccumulator, openDumpAccumulator } from '../../dump/accumulator.ts'; +import { apiKeyFromContext, type AuthedContext, effectiveUpstreamIdsFromContext } from '../../middleware/auth.ts'; +import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; +import type { BackgroundScheduler } from '@floway-dev/platform'; +import type { PerformanceTelemetryContext } from '@floway-dev/provider'; + +// Per-attempt performance state. Reset at the start of every +// iterateCandidates attempt so a candidate that short-circuits cannot inherit +// the prior attempt's slots. The numeric slots use `null` because a real +// timestamp of `0` would be ambiguous. +export interface AttemptState { + upstreamCallStartedAt: number | null; + firstOutputTokenAt: number | null; + telemetry: PerformanceTelemetryContext | undefined; +} + +// Stamps at dispatch entry — pre-dial by design. See +// UpstreamCallOptions.wrapUpstreamCall for why the interval includes proxy +// handshake time (the user waits for it too). +export const stampUpstreamCallStart = (attempt: AttemptState) => + (dispatch: () => Promise): Promise => { + attempt.upstreamCallStartedAt = performance.now(); + return dispatch(); + }; + +export interface GatewayCtx { + readonly apiKeyId: string; + readonly requestStartedAt: number; + readonly upstreamIds: readonly string[] | null; + readonly abortSignal?: AbortSignal; + readonly wantsStream: boolean; + readonly downstreamAbortController?: AbortController; + readonly backgroundScheduler: BackgroundScheduler; + readonly attempt: AttemptState; + // The deployment colo / region, used both as the `runtimeLocation` + // performance-telemetry dimension and as the dial-time colo whitelist key. + // Request-scoped, so it is resolved once here rather than at the + // provider-call boundary. + readonly runtimeLocation: string; + // Null when the api key has no dump retention configured, in which case + // `finalizeGatewayResponse` short-circuits the dump tee and returns the + // response untouched. + readonly dump: DumpAccumulator | null; + // Headers staged during request processing and written onto the + // outbound response by `finalizeGatewayResponse`, regardless of how + // the responder built the body. + readonly responseHeaders: Headers; +} + +export interface CreateGatewayCtxOptions { + wantsStream: boolean; + // WebSocket-style call sites own the AbortController (so the upgrade + // handler can cancel mid-stream); HTTP call sites let the factory mint one + // when wantsStream is true. + downstreamAbortController?: AbortController; + // Already-buffered inbound request body bytes. HTTP handlers read them + // once via `readRequestBody` and pass them in so the dump accumulator's + // snapshot reflects the exact bytes the handler parsed. WebSocket + // upgrades carry no HTTP body — the WS Responses path passes the + // per-turn JSON message bytes here so the dump captures the turn's + // input verbatim. + requestBody: RequestBody; + // Override the HTTP method recorded on the dump's request snapshot. The + // WS Responses path uses `'WS'` so a dumped turn reads as + // `WS /v1/responses` in the dashboard rather than the upgrade's `GET`. + method?: string; + // The model id parsed from the request payload (or from the URL on + // Gemini's routes), stamped on the dump immediately so even an + // outright-error turn carries model attribution. Omit only on error + // fallback paths where payload parsing itself failed. + model?: string; + // Sink for every background task the ctx spawns (dump write, upstream + // telemetry, performance recording, usage recording). Provided by the + // call site so the correct lifetime binding is chosen: HTTP handlers + // pass `backgroundSchedulerFromContext(c)` (the runtime's fetch-scoped + // scheduler); the WS Responses transport builds a session-scoped + // scheduler backed by one lifetime `waitUntil` registered while the + // fetch handler is still active, so per-message tasks fired after the + // 101 upgrade has returned still complete. + backgroundScheduler: BackgroundScheduler; +} + +export const createGatewayCtxFromHono = (c: AuthedContext, opts: CreateGatewayCtxOptions): GatewayCtx => { + const controller = opts.downstreamAbortController ?? (opts.wantsStream ? new AbortController() : undefined); + const apiKey = apiKeyFromContext(c); + const upstreamIds = effectiveUpstreamIdsFromContext(c); + const dump = openDumpAccumulator(c, opts.method ?? c.req.method, apiKey, opts.requestBody, opts.backgroundScheduler); + if (opts.model !== undefined) dump?.requestedModel(opts.model); + return { + apiKeyId: apiKey.id, + requestStartedAt: Date.now(), + upstreamIds, + abortSignal: controller?.signal, + wantsStream: opts.wantsStream, + downstreamAbortController: controller, + backgroundScheduler: opts.backgroundScheduler, + attempt: { firstOutputTokenAt: null, upstreamCallStartedAt: null, telemetry: undefined }, + runtimeLocation: getRuntimeLocation(c.req.raw), + dump, + responseHeaders: new Headers(), + }; +}; + +// Run the dump-accumulator's finalize tee on the outgoing Response. Every +// inbound HTTP wrapper returns its response through this seam so the dump +// pipeline applies uniformly across happy-path, error, and passthrough paths. +export const finalizeGatewayResponse = (ctx: GatewayCtx, response: Response): Response => { + for (const [name, value] of ctx.responseHeaders) response.headers.set(name, value); + return ctx.dump?.finalize(response) ?? response; +}; diff --git a/packages/gateway/src/data-plane/chat/shared/gateway-ctx_test.ts b/packages/gateway/src/data-plane/shared/gateway-ctx_test.ts similarity index 98% rename from packages/gateway/src/data-plane/chat/shared/gateway-ctx_test.ts rename to packages/gateway/src/data-plane/shared/gateway-ctx_test.ts index 1d678eb72a..7838fe0289 100644 --- a/packages/gateway/src/data-plane/chat/shared/gateway-ctx_test.ts +++ b/packages/gateway/src/data-plane/shared/gateway-ctx_test.ts @@ -3,8 +3,8 @@ import { describe, test } from 'vitest'; import { createGatewayCtxFromHono, type AttemptState, stampUpstreamCallStart } from './gateway-ctx.ts'; import type { RequestBody } from './request-body.ts'; -import type { AuthVars } from '../../../middleware/auth.ts'; -import type { ApiKey, User } from '../../../repo/types.ts'; +import type { AuthVars } from '../../middleware/auth.ts'; +import type { ApiKey, User } from '../../repo/types.ts'; import type { BackgroundScheduler } from '@floway-dev/platform'; import { assert, assertEquals, assertExists } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/shared/iterate-candidates.ts b/packages/gateway/src/data-plane/shared/iterate-candidates.ts index cb7115d582..25e021ee6f 100644 --- a/packages/gateway/src/data-plane/shared/iterate-candidates.ts +++ b/packages/gateway/src/data-plane/shared/iterate-candidates.ts @@ -1,5 +1,5 @@ +import type { GatewayCtx } from './gateway-ctx.ts'; import { upstreamPerformanceContext } from './telemetry/attribution.ts'; -import type { GatewayCtx } from '../chat/shared/gateway-ctx.ts'; import type { ModelCandidate, PerformanceOperation } from '@floway-dev/provider'; // A serve-layer attempt result counts as success when: @@ -37,8 +37,10 @@ const isAttemptSuccess = (result: IterableAttemptResult): boolean => { }; // Tries each narrowed candidate in order and returns the first success. A -// per-candidate failure falls through so a transient 5xx/429/network on -// one upstream rolls over to the next; when the list is exhausted the +// per-candidate *failure result* falls through so a transient 5xx/429 on +// one upstream rolls over to the next; a thrown error leaves the loop and +// surfaces to the caller, so a dial failure does not advance. When the +// list is exhausted the // most recent failure is returned so callers can forward it verbatim and // clients still see real upstream telemetry rather than a synthetic // gateway envelope. Callers are contractually required to hand in a @@ -50,7 +52,7 @@ const isAttemptSuccess = (result: IterableAttemptResult): boolean => { // `ctx.attempt.telemetry` with the current candidate's // `PerformanceTelemetryContext` synchronously BEFORE handing control to // `run`. That way a mid-attempt throw (interceptor bug, translation -// error, provider-layer JS exception bypassing tryCatchChatServeFailure) +// error, provider-layer JS exception not represented as a ChatServeFailure) // still attributes the perf error row to the throwing candidate: the // outer catch reads `ctx.attempt.telemetry` and feeds it into // `recordFailedRequest`. Callsites don't need to duplicate this stamp. diff --git a/packages/gateway/src/data-plane/shared/iterate-candidates_test.ts b/packages/gateway/src/data-plane/shared/iterate-candidates_test.ts index 1993e06996..33f58a3947 100644 --- a/packages/gateway/src/data-plane/shared/iterate-candidates_test.ts +++ b/packages/gateway/src/data-plane/shared/iterate-candidates_test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; +import type { GatewayCtx } from './gateway-ctx.ts'; import { iterateCandidates } from './iterate-candidates.ts'; -import { mockGatewayCtx } from '../../test-helpers/gateway-ctx.ts'; -import type { GatewayCtx } from '../chat/shared/gateway-ctx.ts'; +import { mockGatewayCtx } from '../../test-utils/gateway-ctx.ts'; import type { ModelCandidate, PerformanceTelemetryContext } from '@floway-dev/provider'; import { mockPerfTelemetryContext, stubModelCandidate, stubProvider } from '@floway-dev/test-utils'; @@ -10,7 +10,7 @@ const stubCandidate = (id: string, upstream = 'up'): ModelCandidate => stubModelCandidate({ model: { id }, provider: { - upstream, + upstreamId: upstream, kind: 'custom', name: upstream, disabledPublicModelIds: [], diff --git a/packages/gateway/src/data-plane/shared/listing/addressable.ts b/packages/gateway/src/data-plane/shared/listing/addressable.ts index 03cd447d9a..0fc2d7fca7 100644 --- a/packages/gateway/src/data-plane/shared/listing/addressable.ts +++ b/packages/gateway/src/data-plane/shared/listing/addressable.ts @@ -11,8 +11,9 @@ // DTO) read `limits` / `chat` / `endpoints` directly off the entry without // a second registry round trip. +import { compareModelIds, getModelsFromProviders } from '../../providers/catalog.ts'; import { fetchUpstreamModelsCached } from '../../providers/models-cache.ts'; -import { compareModelIds, getModelsFromProviders, listModelProviders } from '../../providers/registry.ts'; +import { listModelProviders } from '../../providers/registry.ts'; import type { BackgroundScheduler } from '@floway-dev/platform'; import { isAbortError, type Fetcher, type InternalModel, type Provider, type UpstreamRecord } from '@floway-dev/provider'; @@ -26,7 +27,7 @@ export interface AddressableIdEntry { // wire bytes stay byte-identical. readonly unlisted: true | undefined; // Real catalog row this id routes to. For multi-provider models this is - // the same `InternalModel` instance `getModels` returns (one row per + // the same `InternalModel` instance `getModelsFromProviders` returns (one row per // public-listed id, with the union-merged endpoints already applied). readonly model: InternalModel; // Every upstream instance that surfaces this addressable id in its @@ -34,7 +35,7 @@ export interface AddressableIdEntry { // canonical listed row the addressable id resolves to — addressable-only // alternates inherit the same list (the prefix-stripped id resolves // through the same upstream). Lets the control-plane DTO render per- - // model upstream chips without re-walking the registry. + // model upstream chips without re-walking the catalog. readonly upstreams: readonly Provider[]; } @@ -48,7 +49,7 @@ export const listedRealModels = (entries: readonly AddressableIdEntry[]): readon // tagged with whether the id participates in the default `/v1/models` // listing. Fans out per upstream the same way `collectProviderModels` does, // re-uses the SWR cache so the catalog refresh round-trip is shared with -// `getModels`. +// `getModelsFromProviders`. export const enumerateAddressableModelIds = async ( upstreamFilter: readonly string[] | null, fetcherForUpstream: (upstreamId: string) => Fetcher, @@ -85,7 +86,7 @@ export const enumerateAddressableModelIds = async ( // // A rejected per-upstream catalog refresh collapses to no addressable- // only contribution from THAT upstream — its listed rows already came - // (or were dropped) through `getModels`. Mirrors the `Promise.allSettled` + // (or were dropped) through `getModelsFromProviders`. Mirrors the `Promise.allSettled` // tolerance there so a transiently-down upstream cannot tank /v1/models // on a cold-start gateway. const perUpstream = await Promise.allSettled(providers.map(async provider => { @@ -93,7 +94,7 @@ export const enumerateAddressableModelIds = async ( const addressableOnly = cfg !== null ? cfg.addressable.filter(form => !cfg.listed.includes(form)) : []; if (cfg === null || addressableOnly.length === 0) return [] as AddressableIdEntry[]; - const upstreamModels = await fetchUpstreamModelsCached(provider, { scheduler, fetcher: fetcherForUpstream(provider.upstream) }); + const upstreamModels = await fetchUpstreamModelsCached(provider, { scheduler, fetcher: fetcherForUpstream(provider.upstreamId) }); const disabled = new Set(provider.disabledPublicModelIds); const out: AddressableIdEntry[] = []; diff --git a/packages/gateway/src/data-plane/shared/listing/addressable_test.ts b/packages/gateway/src/data-plane/shared/listing/addressable_test.ts index c65d72da20..6514586ea3 100644 --- a/packages/gateway/src/data-plane/shared/listing/addressable_test.ts +++ b/packages/gateway/src/data-plane/shared/listing/addressable_test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import { enumerateAddressableModelIds } from './addressable.ts'; -import { buildCustomUpstreamRecord, setupAppTest } from '../../../test-helpers.ts'; +import { buildCustomUpstreamRecord, setupAppTest } from '../../../test-utils/app.ts'; import { clearInFlightForTesting } from '../../providers/models-cache.ts'; import { directFetcher } from '@floway-dev/provider'; import { jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/shared/listing/alias.ts b/packages/gateway/src/data-plane/shared/listing/alias.ts index 52a6c8cbc5..7f1f2bff84 100644 --- a/packages/gateway/src/data-plane/shared/listing/alias.ts +++ b/packages/gateway/src/data-plane/shared/listing/alias.ts @@ -245,7 +245,7 @@ const mergeWithOverride = ( // Returns null when no target serves this alias on the gateway, OR when the // caller cannot reach any of the configured targets — the catalog should // never advertise an id the caller would 404 on. The alias itself stays -// addressable through the request-time resolver in `providers/registry.ts`, +// addressable through the request-time resolver in `providers/resolution.ts`, // which walks the alias's targets in configured order and surfaces a // regular model-missing 404 when no target has any kind-matching binding. // Callers (`synthesizeListedAliases`) filter the nulls out. @@ -317,7 +317,7 @@ const sortAliases = (aliases: readonly ModelAliasRecord[]): ModelAliasRecord[] = export const synthesizeListedAliases = (input: ListedAliasInputs): InternalModel[] => sortAliases(input.aliases) // `visibleInModelsList` is a LISTING flag only — the request-time - // resolver in `providers/registry.ts` does not consult it, so a hidden + // resolver in `providers/resolution.ts` does not consult it, so a hidden // alias stays reachable at dispatch. This lets an operator ship a // gateway id (e.g. a legacy client hardcodes it) without cluttering // the public catalog. diff --git a/packages/gateway/src/data-plane/shared/passthrough-attempt.ts b/packages/gateway/src/data-plane/shared/passthrough-attempt.ts index 265467c902..18889c2118 100644 --- a/packages/gateway/src/data-plane/shared/passthrough-attempt.ts +++ b/packages/gateway/src/data-plane/shared/passthrough-attempt.ts @@ -10,12 +10,12 @@ // Response plus the per-call telemetry the serve site needs when it // forwards the winning attempt (2xx) or the last failure (exhausted). +import type { GatewayCtx } from './gateway-ctx.ts'; import { inboundHeadersForUpstream } from './inbound-headers.ts'; import { telemetryModelIdentity, upstreamPerformanceContext } from './telemetry/attribution.ts'; import type { PerformanceTelemetryContext } from './telemetry/performance.ts'; import { buildUpstreamCallOptions } from './upstream-call-options.ts'; import type { AuthedContext } from '../../middleware/auth.ts'; -import type { GatewayCtx } from '../chat/shared/gateway-ctx.ts'; import { providerModelOf } from '@floway-dev/provider'; import type { ModelCandidate, PerformanceOperation, Provider, ProviderCallResult, ProviderModel, TelemetryModelIdentity, UpstreamCallOptions } from '@floway-dev/provider'; diff --git a/packages/gateway/src/data-plane/shared/passthrough-serve.ts b/packages/gateway/src/data-plane/shared/passthrough-serve.ts index 88afaec8e8..9ab44d1e82 100644 --- a/packages/gateway/src/data-plane/shared/passthrough-serve.ts +++ b/packages/gateway/src/data-plane/shared/passthrough-serve.ts @@ -16,16 +16,16 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status'; import type { PassthroughServeApiName } from './api-names.ts'; import { appendFailedUpstreams } from './failed-upstreams.ts'; +import type { GatewayCtx } from './gateway-ctx.ts'; import { iterateCandidates } from './iterate-candidates.ts'; import { passthroughAttempt } from './passthrough-attempt.ts'; +import { type StreamCompletion, writeSSEFrames } from './sse.ts'; import { recordFailedRequest } from './telemetry/performance.ts'; import { settle } from './telemetry/settle.ts'; import { forwardUpstreamHeaders, forwardUpstreamResponse } from './upstream-response.ts'; import type { AuthedContext } from '../../middleware/auth.ts'; import type { TokenUsage } from '../../repo/types.ts'; -import type { GatewayCtx } from '../chat/shared/gateway-ctx.ts'; -import { type StreamCompletion, writeSSEFrames } from '../chat/shared/stream/sse.ts'; -import { enumerateModelCandidates } from '../providers/registry.ts'; +import { enumerateModelCandidates } from '../providers/resolution.ts'; import { doneFrame, eventFrame, type ModelKind, parseSSEStream, parseTargetStreamFrames, type ProtocolFrame, sseCommentFrame, sseFrame } from '@floway-dev/protocols/common'; import { httpResponseToResponse, ProviderModelsUnavailableError, toInternalDebugError } from '@floway-dev/provider'; import type { PerformanceOperation, PerformanceTelemetryContext, InternalModel, Provider, ProviderCallResult, ProviderModel, TelemetryModelIdentity, UpstreamCallOptions } from '@floway-dev/provider'; @@ -104,9 +104,9 @@ export const passthroughServe = async (input: PassthroughServeContext): Promise< // Alias resolution is a top-of-chain step inside the resolver: an alias // id walks every target in `selection` order, tags each returned // candidate with that target's rule overlay, and dedups across the - // flattened list. Passthrough aliases carry empty rules, so the tag - // is a no-op in practice — the alias flow only changes which id the - // gateway addresses upstream. + // flattened list. Passthrough endpoints never consult that overlay, so + // whatever rules a target carries are inert here — the alias flow only + // changes which id the gateway addresses upstream. const { candidates, sawModel, failedUpstreams } = await enumerateModelCandidates({ upstreamIds: ctx.upstreamIds, model, diff --git a/packages/gateway/src/data-plane/shared/passthrough-serve_test.ts b/packages/gateway/src/data-plane/shared/passthrough-serve_test.ts index 3aaf108275..7acdb373a9 100644 --- a/packages/gateway/src/data-plane/shared/passthrough-serve_test.ts +++ b/packages/gateway/src/data-plane/shared/passthrough-serve_test.ts @@ -17,7 +17,7 @@ import { test, vi } from 'vitest'; -import { buildCustomUpstreamRecord, flushAsyncWork, requestApp, setupAppTest } from '../../test-helpers.ts'; +import { buildCustomUpstreamRecord, flushAsyncWork, requestApp, setupAppTest } from '../../test-utils/app.ts'; import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; import { jsonResponse, withMockedFetch, assertEquals, assertExists } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/chat/shared/request-body.ts b/packages/gateway/src/data-plane/shared/request-body.ts similarity index 100% rename from packages/gateway/src/data-plane/chat/shared/request-body.ts rename to packages/gateway/src/data-plane/shared/request-body.ts diff --git a/packages/gateway/src/data-plane/chat/shared/request-body_test.ts b/packages/gateway/src/data-plane/shared/request-body_test.ts similarity index 100% rename from packages/gateway/src/data-plane/chat/shared/request-body_test.ts rename to packages/gateway/src/data-plane/shared/request-body_test.ts diff --git a/packages/gateway/src/data-plane/chat/shared/stream/sse.ts b/packages/gateway/src/data-plane/shared/sse.ts similarity index 100% rename from packages/gateway/src/data-plane/chat/shared/stream/sse.ts rename to packages/gateway/src/data-plane/shared/sse.ts diff --git a/packages/gateway/src/data-plane/chat/shared/stream/sse_test.ts b/packages/gateway/src/data-plane/shared/sse_test.ts similarity index 99% rename from packages/gateway/src/data-plane/chat/shared/stream/sse_test.ts rename to packages/gateway/src/data-plane/shared/sse_test.ts index 4e62eb2ed1..839bc1fcf0 100644 --- a/packages/gateway/src/data-plane/chat/shared/stream/sse_test.ts +++ b/packages/gateway/src/data-plane/shared/sse_test.ts @@ -3,7 +3,7 @@ import { streamSSE } from 'hono/streaming'; import { test } from 'vitest'; import { writeSSEFrames } from './sse.ts'; -import { FakeTime } from '../../../../test-time.ts'; +import { FakeTime } from '../../test-time.ts'; import { parseSSEStream } from '@floway-dev/protocols/common'; import { sseCommentFrame, type SseFrame, sseFrame } from '@floway-dev/protocols/common'; import { assertEquals } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/data-plane/shared/telemetry/attribution.ts b/packages/gateway/src/data-plane/shared/telemetry/attribution.ts index 3c00e4d4ab..073117abd8 100644 --- a/packages/gateway/src/data-plane/shared/telemetry/attribution.ts +++ b/packages/gateway/src/data-plane/shared/telemetry/attribution.ts @@ -1,4 +1,4 @@ -import type { GatewayCtx } from '../../chat/shared/gateway-ctx.ts'; +import type { GatewayCtx } from '../gateway-ctx.ts'; import { providerModelOf, type ModelCandidate, type PerformanceOperation, type PerformanceTelemetryContext, type TelemetryModelIdentity } from '@floway-dev/provider'; export const upstreamPerformanceContext = ( @@ -8,7 +8,7 @@ export const upstreamPerformanceContext = ( ): PerformanceTelemetryContext => ({ keyId: ctx.apiKeyId, model: candidate.model.id, - upstream: candidate.provider.upstream, + upstream: candidate.provider.upstreamId, operation, runtimeLocation: ctx.runtimeLocation, }); @@ -20,7 +20,7 @@ export const upstreamPerformanceContext = ( // surfaces of the same upstream model under one row. export const telemetryModelIdentity = (candidate: ModelCandidate, modelKey: string): TelemetryModelIdentity => ({ model: candidate.model.id, - upstream: candidate.provider.upstream, + upstream: candidate.provider.upstreamId, modelKey, pricing: providerModelOf(candidate).pricing ?? null, }); diff --git a/packages/gateway/src/data-plane/shared/telemetry/performance.ts b/packages/gateway/src/data-plane/shared/telemetry/performance.ts index 3b1d092e8e..e2adb859cb 100644 --- a/packages/gateway/src/data-plane/shared/telemetry/performance.ts +++ b/packages/gateway/src/data-plane/shared/telemetry/performance.ts @@ -1,7 +1,7 @@ import { currentHour } from './hour.ts'; import { getRepo } from '../../../repo/index.ts'; import type { PerformanceDimensions } from '../../../repo/types.ts'; -import type { GatewayCtx } from '../../chat/shared/gateway-ctx.ts'; +import type { GatewayCtx } from '../gateway-ctx.ts'; import type { PerformanceTelemetryContext } from '@floway-dev/provider'; export type { PerformanceTelemetryContext }; diff --git a/packages/gateway/src/data-plane/shared/telemetry/performance_test.ts b/packages/gateway/src/data-plane/shared/telemetry/performance_test.ts index f222c248f7..720798161e 100644 --- a/packages/gateway/src/data-plane/shared/telemetry/performance_test.ts +++ b/packages/gateway/src/data-plane/shared/telemetry/performance_test.ts @@ -1,11 +1,11 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, test } from 'vitest'; import { recordPerformance } from './performance.ts'; import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; -import { mockGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; -import type { AttemptState } from '../../chat/shared/gateway-ctx.ts'; -import { mockPerfTelemetryContext } from '@floway-dev/test-utils'; +import { mockGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; +import type { AttemptState } from '../gateway-ctx.ts'; +import { assertEquals, mockPerfTelemetryContext } from '@floway-dev/test-utils'; const telemetry = mockPerfTelemetryContext({ keyId: 'key_a', @@ -210,3 +210,89 @@ describe('recordPerformance', () => { .toThrow(/negative outputTokens=-1/); }); }); + +const movedTelemetry = mockPerfTelemetryContext({ + keyId: '', + model: 'claude-test', + upstream: 'copilot:1', + runtimeLocation: 'SJC', +}); + +let movedRepo: InMemoryRepo; +let movedBackground: Promise[]; +const movedCtx = ({ firstOutputTokenAt = null, upstreamCallStartedAt = null }: { + firstOutputTokenAt?: number | null; + upstreamCallStartedAt?: number | null; +} = {}) => mockGatewayCtx({ + apiKeyId: 'key_a', + backgroundScheduler: promise => { movedBackground.push(promise); }, + attempt: { firstOutputTokenAt, upstreamCallStartedAt, telemetry: undefined }, +}); + +beforeEach(() => { + movedRepo = new InMemoryRepo(); + initRepo(movedRepo); + movedBackground = []; +}); + +// ── recordPerformance ── + +test('recordPerformance records a full sample when success with upstreamCallStartedAt, firstOutputTokenAt, and outputTokens>=2', async () => { + recordPerformance(movedCtx({ upstreamCallStartedAt: 50, firstOutputTokenAt: 100 }), movedTelemetry, false, 50, 200); + await Promise.all(movedBackground); + + const rows = await movedRepo.performance.listAll(); + assertEquals(rows.length, 1); + assertEquals(rows[0].ttftSamplesOk, 1); + assertEquals(rows[0].tpotSamples, 1); + assertEquals(rows[0].errorsWithOutput, 0); + assertEquals(rows[0].errorsNoOutput, 0); + assertEquals(rows[0].requests, 1); +}); + +test('recordPerformance records TTFT-only sample when outputTokens is zero but first-token stamp fired', async () => { + recordPerformance(movedCtx({ upstreamCallStartedAt: 50, firstOutputTokenAt: 100 }), movedTelemetry, false, 0, 200); + await Promise.all(movedBackground); + + const rows = await movedRepo.performance.listAll(); + assertEquals(rows.length, 1); + assertEquals(rows[0].ttftSamplesOk, 1); + assertEquals(rows[0].tpotSamples, 0); + assertEquals(rows[0].errorsWithOutput, 0); + assertEquals(rows[0].errorsNoOutput, 0); + assertEquals(rows[0].requests, 1); +}); + +test('recordPerformance records neutral when success but firstOutputTokenAt is null', async () => { + recordPerformance(movedCtx({ firstOutputTokenAt: null }), movedTelemetry, false, 50, 200); + await Promise.all(movedBackground); + + const rows = await movedRepo.performance.listAll(); + assertEquals(rows.length, 1); + assertEquals(rows[0].ttftSamplesOk, 0); + assertEquals(rows[0].tpotSamples, 0); + assertEquals(rows[0].neutral, 1); + assertEquals(rows[0].errorsWithOutput, 0); + assertEquals(rows[0].errorsNoOutput, 0); + assertEquals(rows[0].requests, 1); +}); + +test('recordPerformance records a zero-output error when failed without a real TTFT stamp', async () => { + recordPerformance(movedCtx({ firstOutputTokenAt: 100 }), movedTelemetry, true, 50, 200); + await Promise.all(movedBackground); + + const rows = await movedRepo.performance.listAll(); + assertEquals(rows.length, 1); + assertEquals(rows[0].ttftSamplesOk, 0); + assertEquals(rows[0].tpotSamples, 0); + assertEquals(rows[0].errorsNoOutput, 1); + assertEquals(rows[0].errorsWithOutput, 0); + assertEquals(rows[0].requests, 1); +}); + +test('recordPerformance skips when performance context is absent', async () => { + recordPerformance(movedCtx(), undefined, true, 0, 200); + await Promise.all(movedBackground); + + assertEquals(await movedRepo.performance.listAll(), []); +}); diff --git a/packages/gateway/src/data-plane/shared/telemetry/settle.ts b/packages/gateway/src/data-plane/shared/telemetry/settle.ts index 0979a0146a..94319339cb 100644 --- a/packages/gateway/src/data-plane/shared/telemetry/settle.ts +++ b/packages/gateway/src/data-plane/shared/telemetry/settle.ts @@ -1,7 +1,7 @@ import { recordPerformance, type PerformanceTelemetryContext } from './performance.ts'; import { recordTokenUsage, recordUsage, type UsageMeasurement } from './usage.ts'; import type { TokenUsage } from '../../../repo/types.ts'; -import type { GatewayCtx } from '../../chat/shared/gateway-ctx.ts'; +import type { GatewayCtx } from '../gateway-ctx.ts'; import type { TelemetryModelIdentity } from '@floway-dev/provider'; // Terminal settle for a successful upstream call (or a partial-output diff --git a/packages/gateway/src/data-plane/shared/telemetry/settle_test.ts b/packages/gateway/src/data-plane/shared/telemetry/settle_test.ts new file mode 100644 index 0000000000..882adad6d3 --- /dev/null +++ b/packages/gateway/src/data-plane/shared/telemetry/settle_test.ts @@ -0,0 +1,105 @@ +import { beforeEach, expect, test } from 'vitest'; + +import { settle } from './settle.ts'; +import { initRepo } from '../../../repo/index.ts'; +import { InMemoryRepo } from '../../../repo/memory.ts'; +import { tokenCountsFromUsage } from '../../../repo/usage-metrics.ts'; +import { mockGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; +import type { TelemetryModelIdentity } from '@floway-dev/provider'; +import { assertEquals, mockPerfTelemetryContext } from '@floway-dev/test-utils'; + +const testTelemetryModelIdentity: TelemetryModelIdentity = { + model: 'claude-test', + upstream: 'copilot:1', + modelKey: 'claude-test-raw', + pricing: null, +}; + +const testPerformanceContext = mockPerfTelemetryContext({ + keyId: '', + model: 'claude-test', + upstream: 'copilot:1', + runtimeLocation: 'SJC', +}); + +let repo: InMemoryRepo; +let background: Promise[]; +const ctx = ({ firstOutputTokenAt = null, upstreamCallStartedAt = null }: { + firstOutputTokenAt?: number | null; + upstreamCallStartedAt?: number | null; +} = {}) => mockGatewayCtx({ + apiKeyId: 'key_a', + backgroundScheduler: promise => { background.push(promise); }, + attempt: { firstOutputTokenAt, upstreamCallStartedAt, telemetry: undefined }, +}); + +beforeEach(() => { + repo = new InMemoryRepo(); + initRepo(repo); + background = []; +}); + +// ── settle ── + +test('settle records a usage row when the figure carries a billable metric', async () => { + settle(ctx(), testPerformanceContext, testTelemetryModelIdentity, { input: 10, output: 5 }, false); + await Promise.all(background); + + const rows = await repo.usage.listAll(); + assertEquals(rows.length, 1); + assertEquals(rows[0].keyId, 'key_a'); + assertEquals(tokenCountsFromUsage(rows[0]), { input: 10, output: 5 }); + assertEquals(rows[0].requests, 1); +}); + +test('settle records the request without metrics when usage is null', async () => { + settle(ctx(), testPerformanceContext, testTelemetryModelIdentity, null, false); + await Promise.all(background); + + const rows = await repo.usage.listAll(); + assertEquals(rows.length, 1); + assertEquals(rows[0].requests, 1); + assertEquals(rows[0].metrics, []); +}); + +test('settle records the request when usage carries no billable metric', async () => { + settle(ctx(), testPerformanceContext, testTelemetryModelIdentity, {}, false); + await Promise.all(background); + + const rows = await repo.usage.listAll(); + assertEquals(rows.length, 1); + assertEquals(rows[0].requests, 1); + assertEquals(rows[0].metrics, []); +}); + +// TPOT reflects the token stream, not the D1 write that follows it. +// `settle` fires the usage record through backgroundScheduler and records +// the perf sample synchronously — so a slow persistence path cannot leak +// its latency into `tpotUs`. Regressing this (turning the usage record +// back into an in-band await, or moving the perf record past the +// scheduler call) would fold persistence latency into every stream's +// per-token interval. +test('settle records the perf sample without waiting on the usage write', async () => { + const originalRecord = repo.usage.record.bind(repo.usage); + const persistenceDelayMs = 200; + repo.usage.record = async row => { + await new Promise(resolve => setTimeout(resolve, persistenceDelayMs)); + await originalRecord(row); + }; + + const beforeSettle = performance.now(); + const gatewayCtx = ctx({ upstreamCallStartedAt: beforeSettle - 10, firstOutputTokenAt: beforeSettle }); + + settle(gatewayCtx, testPerformanceContext, testTelemetryModelIdentity, { input: 5, output: 3 }, false); + await Promise.all(background); + + const rows = await repo.performance.listAll(); + assertEquals(rows.length, 1); + // TPOT = (requestFinishedAt - firstOutputTokenAt) * 1000 / (outputTokens - 1). + // Recorded synchronously at settle entry: tpotUs reflects only the + // sub-millisecond gap between ctx construction and settle. Fold the + // 200ms usage write into it (in-band await) and tpotUs would be + // ~100_000us (200ms / 2). 50_000us fences the regression while + // tolerating scheduler jitter. + expect(rows[0].tpotUsSum).toBeLessThan(50_000); +}); diff --git a/packages/gateway/src/data-plane/shared/telemetry/usage.ts b/packages/gateway/src/data-plane/shared/telemetry/usage.ts index 6fa7555e54..d407b65100 100644 --- a/packages/gateway/src/data-plane/shared/telemetry/usage.ts +++ b/packages/gateway/src/data-plane/shared/telemetry/usage.ts @@ -2,7 +2,7 @@ import { currentHour } from './hour.ts'; import { getRepo } from '../../../repo/index.ts'; import type { TokenUsage, UsageQuantities } from '../../../repo/types.ts'; import { tokenUsageQuantities, usageMetrics } from '../../../repo/usage-metrics.ts'; -import { canonicalDecimalString, priceRequest, type PricingRuntimeFacts } from '@floway-dev/protocols/common'; +import { priceRequest, type PricingRuntimeFacts } from '@floway-dev/protocols/common'; import type { TelemetryModelIdentity } from '@floway-dev/provider'; const TOKEN_USAGE_KEYS = ['input', 'input_cache_read', 'input_cache_write', 'input_cache_write_1h', 'input_image', 'output', 'output_image'] as const satisfies readonly Exclude[]; @@ -80,14 +80,6 @@ const firstNumber = (candidates: readonly unknown[]): number => { return 0; }; -export const tokenUsageFromEmbeddingsBody = (body: unknown): TokenUsage | null => { - if (!body || typeof body !== 'object') return null; - const { usage } = body as { usage?: unknown }; - if (!usage || typeof usage !== 'object') return null; - const promptTokens = (usage as { prompt_tokens?: unknown }).prompt_tokens; - return typeof promptTokens === 'number' ? tokenUsage({ input: promptTokens }) : null; -}; - export interface UsageMeasurement { readonly quantities: UsageQuantities; readonly pricingFacts: PricingRuntimeFacts; @@ -112,91 +104,6 @@ export const tokenUsageMeasurement = (usage: TokenUsage | null): UsageMeasuremen }; }; -// OpenAI transcription responses discriminate usage by `type`. Token-based -// models split input_token_details into text and audio metrics; without that -// optional split, the aggregate stays on the general input metric. Duration- -// based usage exposes seconds, while Whisper verbose JSON reports the same -// quantity as a top-level `duration`. Unknown breakdowns record the request -// only, while malformed fields under a known discriminator remain observable. -// https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L36378-L36562 -const audioDurationMeasurement = (seconds: unknown, label: string): UsageMeasurement => { - if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) { - throw new Error(`Audio transcription ${label} must be a finite non-negative number`); - } - return { - quantities: { input_audio_seconds: canonicalDecimalString(String(seconds)) }, - pricingFacts: {}, - dumpTokenUsage: null, - }; -}; - -export const audioTranscriptionUsageMeasurement = (body: unknown): UsageMeasurement => { - if (!body || typeof body !== 'object') return requestOnlyUsageMeasurement(); - if (!Object.hasOwn(body, 'usage')) { - if (!Object.hasOwn(body, 'duration')) return requestOnlyUsageMeasurement(); - return audioDurationMeasurement((body as { duration: unknown }).duration, 'duration'); - } - const usage = (body as { usage: unknown }).usage; - if (!usage || typeof usage !== 'object' || Array.isArray(usage)) { - throw new Error('Audio transcription usage must be an object'); - } - const metric = usage as { type?: unknown; seconds?: unknown; input_tokens?: unknown; input_token_details?: unknown; output_tokens?: unknown; total_tokens?: unknown }; - - if (metric.type === 'duration') { - return audioDurationMeasurement(metric.seconds, 'duration usage.seconds'); - } - - if (metric.type !== 'tokens') return requestOnlyUsageMeasurement(); - for (const [field, value] of [ - ['input_tokens', metric.input_tokens], - ['output_tokens', metric.output_tokens], - ['total_tokens', metric.total_tokens], - ] as const) { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { - throw new Error(`Audio transcription token usage.${field} must be a non-negative safe integer`); - } - } - const inputTokens = metric.input_tokens as number; - const outputTokens = metric.output_tokens as number; - const totalTokens = metric.total_tokens as number; - if (totalTokens !== inputTokens + outputTokens) { - throw new Error('Audio transcription token usage.total_tokens must equal input_tokens plus output_tokens'); - } - - let inputQuantities: UsageQuantities = { input_tokens: canonicalDecimalString(String(inputTokens)) }; - if (metric.input_token_details !== undefined) { - if (!metric.input_token_details || typeof metric.input_token_details !== 'object' || Array.isArray(metric.input_token_details)) { - throw new Error('Audio transcription token usage.input_token_details must be an object'); - } - const details = metric.input_token_details as { text_tokens?: unknown; audio_tokens?: unknown }; - for (const [field, value] of [ - ['text_tokens', details.text_tokens], - ['audio_tokens', details.audio_tokens], - ] as const) { - if (value !== undefined && (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)) { - throw new Error(`Audio transcription token usage.input_token_details.${field} must be a non-negative safe integer`); - } - } - const textTokens = details.text_tokens as number | undefined; - const audioTokens = details.audio_tokens as number | undefined; - if ((textTokens ?? 0) + (audioTokens ?? 0) > inputTokens) { - throw new Error('Audio transcription token usage.input_token_details must not exceed input_tokens'); - } - inputQuantities = { - input_tokens: canonicalDecimalString(String(inputTokens - (audioTokens ?? 0))), - ...(audioTokens === undefined ? {} : { input_audio_tokens: canonicalDecimalString(String(audioTokens)) }), - }; - } - return { - quantities: { - ...inputQuantities, - output_tokens: canonicalDecimalString(String(outputTokens)), - }, - pricingFacts: { inputTokens }, - dumpTokenUsage: tokenUsage({ input: inputTokens, output: outputTokens }), - }; -}; - // OpenAI Images responses report usage as // `{input_tokens, output_tokens, total_tokens, input_tokens_details, output_tokens_details}`, // where the details objects split each total into `text_tokens` and diff --git a/packages/gateway/src/data-plane/shared/telemetry/usage_test.ts b/packages/gateway/src/data-plane/shared/telemetry/usage_test.ts index 837b53cf16..4fd8cc2f6f 100644 --- a/packages/gateway/src/data-plane/shared/telemetry/usage_test.ts +++ b/packages/gateway/src/data-plane/shared/telemetry/usage_test.ts @@ -1,10 +1,10 @@ import { test } from 'vitest'; -import { audioTranscriptionUsageMeasurement, openAICacheTokensFromUsage, recordUsage } from './usage.ts'; +import { openAICacheTokensFromUsage, recordUsage } from './usage.ts'; import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; import { basePricing } from '@floway-dev/protocols/common'; -import { assertEquals, assertThrows } from '@floway-dev/test-utils'; +import { assertEquals } from '@floway-dev/test-utils'; test('OpenAI canonical shape — prompt_tokens_details.cached_tokens lands in cacheRead', () => { assertEquals( @@ -125,90 +125,3 @@ test('recordUsage prices audio duration and token metrics together', async () => { metric: 'input_audio_seconds', quantity: '90.5', unitPrice: '0.0001' }, ]); }); - -test('audio transcription usage preserves duration seconds as a base-unit metric', () => { - assertEquals(audioTranscriptionUsageMeasurement({ - usage: { type: 'duration', seconds: 91 }, - duration: 91.8, - }), { - quantities: { input_audio_seconds: '91' }, - pricingFacts: {}, - dumpTokenUsage: null, - }); -}); - -test('audio transcription usage reads Whisper verbose JSON duration', () => { - assertEquals(audioTranscriptionUsageMeasurement({ - task: 'transcribe', - duration: 91.8, - text: 'hello', - }), { - quantities: { input_audio_seconds: '91.8' }, - pricingFacts: {}, - dumpTokenUsage: null, - }); -}); - -test('audio transcription usage maps text and audio input token details to disjoint metrics', () => { - assertEquals(audioTranscriptionUsageMeasurement({ - usage: { - type: 'tokens', - input_tokens: 14, - input_token_details: { text_tokens: 4, audio_tokens: 10 }, - output_tokens: 45, - total_tokens: 59, - }, - }), { - quantities: { input_tokens: '4', input_audio_tokens: '10', output_tokens: '45' }, - pricingFacts: { inputTokens: 14 }, - dumpTokenUsage: { input: 14, output: 45 }, - }); -}); - -test('audio transcription usage keeps aggregate input tokens general when details are absent', () => { - assertEquals(audioTranscriptionUsageMeasurement({ - usage: { type: 'tokens', input_tokens: 14, output_tokens: 45, total_tokens: 59 }, - }).quantities, { input_tokens: '14', output_tokens: '45' }); -}); - -test('audio transcription usage accepts partial details and leaves unclassified input general', () => { - for (const [input_token_details, quantities] of [ - [{}, { input_tokens: '14', output_tokens: '45' }], - [{ text_tokens: 4 }, { input_tokens: '14', output_tokens: '45' }], - [{ audio_tokens: 10 }, { input_tokens: '4', input_audio_tokens: '10', output_tokens: '45' }], - ] as const) { - assertEquals(audioTranscriptionUsageMeasurement({ - usage: { type: 'tokens', input_tokens: 14, input_token_details, output_tokens: 45, total_tokens: 59 }, - }).quantities, quantities); - } -}); - -test('audio transcription usage without a recognized metric is request-only', () => { - for (const body of [ - { usage: { seconds: 10 } }, - { usage: { type: 'future_metric', samples: 10 } }, - ]) { - assertEquals(audioTranscriptionUsageMeasurement(body), { - quantities: {}, pricingFacts: {}, dumpTokenUsage: null, - }); - } -}); - -test('audio transcription usage rejects malformed declared metrics', () => { - for (const [body, message] of [ - [{ duration: '10' }, 'duration must be'], - [{ usage: null }, 'usage must be an object'], - [{ usage: 'tokens' }, 'usage must be an object'], - [{ usage: { type: 'duration' } }, 'duration usage.seconds'], - [{ usage: { type: 'duration', seconds: '10' } }, 'duration usage.seconds'], - [{ usage: { type: 'tokens', input_tokens: -1, output_tokens: 45, total_tokens: 44 } }, 'token usage.input_tokens'], - [{ usage: { type: 'tokens', input_tokens: 14, output_tokens: Number.NaN, total_tokens: 59 } }, 'token usage.output_tokens'], - [{ usage: { type: 'tokens', input_tokens: 14, output_tokens: 45, total_tokens: '59' } }, 'token usage.total_tokens'], - [{ usage: { type: 'tokens', input_tokens: 14, output_tokens: 45, total_tokens: 58 } }, 'total_tokens must equal'], - [{ usage: { type: 'tokens', input_tokens: 14, input_token_details: null, output_tokens: 45, total_tokens: 59 } }, 'input_token_details must be an object'], - [{ usage: { type: 'tokens', input_tokens: 14, input_token_details: { text_tokens: 4, audio_tokens: '10' }, output_tokens: 45, total_tokens: 59 } }, 'audio_tokens must be'], - [{ usage: { type: 'tokens', input_tokens: 14, input_token_details: { text_tokens: 6, audio_tokens: 9 }, output_tokens: 45, total_tokens: 59 } }, 'input_token_details must not exceed'], - ] as const) { - assertThrows(() => audioTranscriptionUsageMeasurement(body), Error, message); - } -}); diff --git a/packages/gateway/src/data-plane/chat/shared/text.ts b/packages/gateway/src/data-plane/shared/text.ts similarity index 100% rename from packages/gateway/src/data-plane/chat/shared/text.ts rename to packages/gateway/src/data-plane/shared/text.ts diff --git a/packages/gateway/src/data-plane/shared/text_test.ts b/packages/gateway/src/data-plane/shared/text_test.ts new file mode 100644 index 0000000000..c325872c4c --- /dev/null +++ b/packages/gateway/src/data-plane/shared/text_test.ts @@ -0,0 +1,33 @@ +import { test } from 'vitest'; + +import { truncatePreservingCodePoints } from './text.ts'; +import { assertEquals, assertFalse } from '@floway-dev/test-utils'; + +test('truncatePreservingCodePoints: empty string is a no-op', () => { + assertEquals(truncatePreservingCodePoints('', 512), ''); +}); + +test('truncatePreservingCodePoints: string of exactly `max` length is unchanged (no ellipsis injected)', () => { + const s = 'a'.repeat(512); + assertEquals(truncatePreservingCodePoints(s, 512), s); +}); + +test('truncatePreservingCodePoints: high surrogate at position max-1 walks back to drop the orphan', () => { + // U+1F600 (grinning face) is a surrogate pair: high D83D + low DE00. + // Place the high surrogate at index max-1 (= 9) so a naive + // slice(0, max) would retain the orphan high surrogate. The helper + // must walk back one code unit and slice at max-1 (= 9), producing + // a 9-char string with no orphan. + const prefix = 'a'.repeat(9); // chars 0..8 + const emoji = '😀'; // chars 9..10 → high at 9, low at 10 + const suffix = 'b'; + const input = prefix + emoji + suffix; // length 12 + const out = truncatePreservingCodePoints(input, 10); + assertEquals(out.length, 9); + assertEquals(out, prefix); + // Sanity: no orphan high surrogate in the output. + for (let i = 0; i < out.length; i++) { + const code = out.charCodeAt(i); + assertFalse(code >= 0xD800 && code <= 0xDBFF); + } +}); diff --git a/packages/gateway/src/data-plane/shared/upstream-call-options.ts b/packages/gateway/src/data-plane/shared/upstream-call-options.ts index b91bcea8bd..d41fbe498d 100644 --- a/packages/gateway/src/data-plane/shared/upstream-call-options.ts +++ b/packages/gateway/src/data-plane/shared/upstream-call-options.ts @@ -1,5 +1,5 @@ -import type { GatewayCtx } from '../chat/shared/gateway-ctx.ts'; -import { stampUpstreamCallStart } from '../chat/shared/gateway-ctx.ts'; +import type { GatewayCtx } from './gateway-ctx.ts'; +import { stampUpstreamCallStart } from './gateway-ctx.ts'; import type { ModelCandidate, UpstreamCallOptions } from '@floway-dev/provider'; // See UpstreamCallOptions in `@floway-dev/provider` for the contract on each diff --git a/packages/gateway/src/data-plane/tools/web-search/alpha-search/upstream.ts b/packages/gateway/src/data-plane/tools/web-search/alpha-search/upstream.ts index a08d4c9345..a525b04505 100644 --- a/packages/gateway/src/data-plane/tools/web-search/alpha-search/upstream.ts +++ b/packages/gateway/src/data-plane/tools/web-search/alpha-search/upstream.ts @@ -1,5 +1,5 @@ -import { enumerateModelCandidates } from '../../../providers/registry.ts'; -import type { SearchConfig } from '../types.ts'; +import { enumerateModelCandidates } from '../../../providers/resolution.ts'; +import type { WebSearchConfig } from '../types.ts'; import type { BackgroundScheduler } from '@floway-dev/platform'; import { identityWrapUpstreamCall, providerModelOf } from '@floway-dev/provider'; @@ -11,7 +11,7 @@ export const resolveAlphaSearchDispatcher = async ({ scheduler, runtimeLocation, }: { - config: Pick; + config: Pick; upstreamIds: readonly string[] | null; scheduler: BackgroundScheduler; runtimeLocation: string; @@ -26,7 +26,7 @@ export const resolveAlphaSearchDispatcher = async ({ scheduler, runtimeLocation, }); - const candidate = candidates.find(value => value.provider.upstream === config.upstreamId); + const candidate = candidates.find(value => value.provider.upstreamId === config.upstreamId); if (candidate === undefined) { throw new Error(`Selected OpenAI search model ${config.model} is unavailable`); } diff --git a/packages/gateway/src/data-plane/tools/web-search/search-config.ts b/packages/gateway/src/data-plane/tools/web-search/config.ts similarity index 77% rename from packages/gateway/src/data-plane/tools/web-search/search-config.ts rename to packages/gateway/src/data-plane/tools/web-search/config.ts index 7b1b216887..b5f5e29d13 100644 --- a/packages/gateway/src/data-plane/tools/web-search/search-config.ts +++ b/packages/gateway/src/data-plane/tools/web-search/config.ts @@ -1,9 +1,9 @@ -import type { SearchConfig } from './types.ts'; +import type { WebSearchConfig } from './types.ts'; import { getRepo } from '../../../repo/index.ts'; import { isJsonObject } from '../../../shared/json-helpers.ts'; import { WEB_SEARCH_PROVIDER_NAMES, isWebSearchProviderName } from '../../../shared/web-search-providers.ts'; -export const DEFAULT_SEARCH_CONFIG: SearchConfig = { +export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchConfig = { provider: 'disabled', tavily: { apiKey: '' }, microsoftGrounding: { apiKey: '' }, @@ -11,15 +11,15 @@ export const DEFAULT_SEARCH_CONFIG: SearchConfig = { passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }; -export const FIXED_SEARCH_CONFIG_TEST_QUERY = 'React documentation'; +export const FIXED_WEB_SEARCH_CONFIG_TEST_QUERY = 'React documentation'; // Returns a fresh deep copy so callers can mutate without corrupting // the module-scoped singleton. -export const parseSearchConfigDefault = (): SearchConfig => structuredClone(DEFAULT_SEARCH_CONFIG); +export const parseWebSearchConfigDefault = (): WebSearchConfig => structuredClone(DEFAULT_WEB_SEARCH_CONFIG); // Strict parse: throws on malformed shape so persistence corruption // surfaces instead of silently downgrading to `disabled`. -export const parseSearchConfigStrict = (input: unknown): SearchConfig => { +export const parseWebSearchConfigStrict = (input: unknown): WebSearchConfig => { if (!isJsonObject(input)) { throw new Error('search config must be a JSON object'); } @@ -66,14 +66,14 @@ export const parseSearchConfigStrict = (input: unknown): SearchConfig => { }; }; -export const loadSearchConfig = async (): Promise => { - const stored = await getRepo().searchConfig.get(); - if (stored === null) return parseSearchConfigDefault(); - return parseSearchConfigStrict(stored); +export const loadWebSearchConfig = async (): Promise => { + const stored = await getRepo().webSearchConfig.get(); + if (stored === null) return parseWebSearchConfigDefault(); + return parseWebSearchConfigStrict(stored); }; -export const saveSearchConfig = async (config: unknown): Promise => { - const parsed = parseSearchConfigStrict(config); - await getRepo().searchConfig.save(parsed); +export const saveWebSearchConfig = async (config: unknown): Promise => { + const parsed = parseWebSearchConfigStrict(config); + await getRepo().webSearchConfig.save(parsed); return parsed; }; diff --git a/packages/gateway/src/data-plane/tools/web-search/search-config_test.ts b/packages/gateway/src/data-plane/tools/web-search/config_test.ts similarity index 71% rename from packages/gateway/src/data-plane/tools/web-search/search-config_test.ts rename to packages/gateway/src/data-plane/tools/web-search/config_test.ts index 7823aef05b..8549dec643 100644 --- a/packages/gateway/src/data-plane/tools/web-search/search-config_test.ts +++ b/packages/gateway/src/data-plane/tools/web-search/config_test.ts @@ -1,14 +1,14 @@ import { test } from 'vitest'; -import { DEFAULT_SEARCH_CONFIG, FIXED_SEARCH_CONFIG_TEST_QUERY, loadSearchConfig, parseSearchConfigDefault, parseSearchConfigStrict, saveSearchConfig } from './search-config.ts'; -import type { SearchConfig } from './types.ts'; +import { DEFAULT_WEB_SEARCH_CONFIG, FIXED_WEB_SEARCH_CONFIG_TEST_QUERY, loadWebSearchConfig, parseWebSearchConfigDefault, parseWebSearchConfigStrict, saveWebSearchConfig } from './config.ts'; +import type { WebSearchConfig } from './types.ts'; import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; import { SqlRepo } from '../../../repo/sql.ts'; import type { SqlDatabase } from '@floway-dev/platform'; import { assertEquals, assertRejects, assertThrows } from '@floway-dev/test-utils'; -interface SearchConfigRow { +interface WebSearchConfigRow { provider: string; tavily_api_key: string; microsoft_grounding_api_key: string; @@ -43,7 +43,7 @@ class FakeSqlPreparedStatement { first>(): Promise { if (this.query === SELECT_SQL) { - return Promise.resolve(this.db.searchConfig === null ? null : ({ ...this.db.searchConfig } as T)); + return Promise.resolve(this.db.webSearchConfig === null ? null : ({ ...this.db.webSearchConfig } as T)); } throw new Error(`Unsupported first() query in test: ${this.query}`); @@ -55,7 +55,7 @@ class FakeSqlPreparedStatement { run(): Promise<{ results: never[]; success: true; meta: Record }> { if (this.query === UPSERT_SQL) { - this.db.searchConfig = { + this.db.webSearchConfig = { provider: String(this.binds[0]), tavily_api_key: String(this.binds[1]), microsoft_grounding_api_key: String(this.binds[2]), @@ -74,7 +74,7 @@ class FakeSqlPreparedStatement { class FakeSqlDatabase implements SqlDatabase { exec(): Promise { return Promise.resolve(undefined); } - searchConfig: SearchConfigRow | null = null; + webSearchConfig: WebSearchConfigRow | null = null; prepare(query: string): FakeSqlPreparedStatement { return new FakeSqlPreparedStatement(this, query); @@ -85,9 +85,9 @@ test('search config repo defaults to disabled and round-trips provider keys', as const repo = new InMemoryRepo(); initRepo(repo); - assertEquals(await loadSearchConfig(), DEFAULT_SEARCH_CONFIG); + assertEquals(await loadWebSearchConfig(), DEFAULT_WEB_SEARCH_CONFIG); - await saveSearchConfig({ + await saveWebSearchConfig({ provider: 'tavily', tavily: { apiKey: 'tvly-test' }, microsoftGrounding: { apiKey: 'ms-test' }, @@ -95,36 +95,36 @@ test('search config repo defaults to disabled and round-trips provider keys', as passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); - assertEquals(await loadSearchConfig(), { + assertEquals(await loadWebSearchConfig(), { provider: 'tavily', tavily: { apiKey: 'tvly-test' }, microsoftGrounding: { apiKey: 'ms-test' }, jina: { apiKey: 'jina-test' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); - assertEquals(FIXED_SEARCH_CONFIG_TEST_QUERY, 'React documentation'); + assertEquals(FIXED_WEB_SEARCH_CONFIG_TEST_QUERY, 'React documentation'); }); -test('loadSearchConfig strict-parses a stored row and rejects unknown provider values', async () => { +test('loadWebSearchConfig strict-parses a stored row and rejects unknown provider values', async () => { const repo = new InMemoryRepo(); initRepo(repo); - await repo.searchConfig.save({ + await repo.webSearchConfig.save({ provider: 'unknown-provider', tavily: { apiKey: ' tvly-test ' }, microsoftGrounding: { apiKey: ' ms-test ' }, jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, - } as unknown as SearchConfig); + } as unknown as WebSearchConfig); - await assertRejects(() => loadSearchConfig(), Error, 'provider'); + await assertRejects(() => loadWebSearchConfig(), Error, 'provider'); }); -test('loadSearchConfig strict-parses a stored row and trims valid api keys', async () => { +test('loadWebSearchConfig strict-parses a stored row and trims valid api keys', async () => { const repo = new InMemoryRepo(); initRepo(repo); - await repo.searchConfig.save({ + await repo.webSearchConfig.save({ provider: 'jina', tavily: { apiKey: ' tvly-trim ' }, microsoftGrounding: { apiKey: ' ms-trim ' }, @@ -132,7 +132,7 @@ test('loadSearchConfig strict-parses a stored row and trims valid api keys', asy passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); - assertEquals(await loadSearchConfig(), { + assertEquals(await loadWebSearchConfig(), { provider: 'jina', tavily: { apiKey: 'tvly-trim' }, microsoftGrounding: { apiKey: 'ms-trim' }, @@ -141,46 +141,46 @@ test('loadSearchConfig strict-parses a stored row and trims valid api keys', asy }); }); -test('parseSearchConfigDefault returns a fresh deep copy so callers cannot corrupt the singleton', () => { - const a = parseSearchConfigDefault(); - const b = parseSearchConfigDefault(); +test('parseWebSearchConfigDefault returns a fresh deep copy so callers cannot corrupt the singleton', () => { + const a = parseWebSearchConfigDefault(); + const b = parseWebSearchConfigDefault(); a.tavily.apiKey = 'mutated'; assertEquals(b.tavily.apiKey, ''); - assertEquals(DEFAULT_SEARCH_CONFIG.tavily.apiKey, ''); + assertEquals(DEFAULT_WEB_SEARCH_CONFIG.tavily.apiKey, ''); }); -test('parseSearchConfigStrict throws on missing required fields', () => { - assertThrows(() => parseSearchConfigStrict({}), Error); - assertThrows(() => parseSearchConfigStrict({ provider: 'disabled' }), Error); +test('parseWebSearchConfigStrict throws on missing required fields', () => { + assertThrows(() => parseWebSearchConfigStrict({}), Error); + assertThrows(() => parseWebSearchConfigStrict({ provider: 'disabled' }), Error); assertThrows( - () => parseSearchConfigStrict({ provider: 'disabled', tavily: { apiKey: '' } }), + () => parseWebSearchConfigStrict({ provider: 'disabled', tavily: { apiKey: '' } }), Error, 'microsoftGrounding', ); assertThrows( - () => parseSearchConfigStrict({ provider: 'disabled', tavily: {}, microsoftGrounding: { apiKey: '' }, jina: { apiKey: '' } }), + () => parseWebSearchConfigStrict({ provider: 'disabled', tavily: {}, microsoftGrounding: { apiKey: '' }, jina: { apiKey: '' } }), Error, 'tavily.apiKey', ); assertThrows( - () => parseSearchConfigStrict({ provider: 'disabled', tavily: { apiKey: '' }, microsoftGrounding: { apiKey: '' } }), + () => parseWebSearchConfigStrict({ provider: 'disabled', tavily: { apiKey: '' }, microsoftGrounding: { apiKey: '' } }), Error, 'jina', ); }); -test('parseSearchConfigStrict requires upstream and model when passthrough is enabled', () => { - assertThrows(() => parseSearchConfigStrict({ - ...DEFAULT_SEARCH_CONFIG, +test('parseWebSearchConfigStrict requires upstream and model when passthrough is enabled', () => { + assertThrows(() => parseWebSearchConfigStrict({ + ...DEFAULT_WEB_SEARCH_CONFIG, passthroughOpenAiSearch: { enabled: true, upstreamId: '', model: '' }, }), Error, 'requires an upstream and model'); }); -test('saveSearchConfig writes the typed columns and round-trips through the same db', async () => { +test('saveWebSearchConfig writes the typed columns and round-trips through the same db', async () => { const db = new FakeSqlDatabase(); initRepo(new SqlRepo(db)); - const saved = await saveSearchConfig({ + const saved = await saveWebSearchConfig({ provider: 'disabled', tavily: { apiKey: ' tvly-test ' }, microsoftGrounding: { apiKey: ' ms-test ' }, @@ -195,7 +195,7 @@ test('saveSearchConfig writes the typed columns and round-trips through the same jina: { apiKey: 'jina-test' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); - assertEquals(db.searchConfig, { + assertEquals(db.webSearchConfig, { provider: 'disabled', tavily_api_key: 'tvly-test', microsoft_grounding_api_key: 'ms-test', @@ -204,7 +204,7 @@ test('saveSearchConfig writes the typed columns and round-trips through the same alpha_search_upstream_id: '', alpha_search_model: '', }); - assertEquals(await loadSearchConfig(), { + assertEquals(await loadWebSearchConfig(), { provider: 'disabled', tavily: { apiKey: 'tvly-test' }, microsoftGrounding: { apiKey: 'ms-test' }, diff --git a/packages/gateway/src/data-plane/tools/web-search/fetch-page.ts b/packages/gateway/src/data-plane/tools/web-search/fetch-page.ts index 1fdc4ba9e3..07692ee20f 100644 --- a/packages/gateway/src/data-plane/tools/web-search/fetch-page.ts +++ b/packages/gateway/src/data-plane/tools/web-search/fetch-page.ts @@ -1,5 +1,5 @@ import type { WebSearchFetchPageRequest, WebSearchFetchPageResult, WebSearchProvider, WebSearchProviderName } from './types.ts'; -import { recordSearchUsage } from './usage.ts'; +import { recordWebSearchUsage } from './usage.ts'; export const fetchPageAndRecordUsage = async (args: { provider: WebSearchProvider; @@ -13,7 +13,7 @@ export const fetchPageAndRecordUsage = async (args: { // Telemetry must never mask the provider result; log and swallow // recording failures. try { - await recordSearchUsage({ + await recordWebSearchUsage({ provider: args.providerName, keyId: args.keyId, action: 'fetch_page', diff --git a/packages/gateway/src/data-plane/tools/web-search/fetch-page_test.ts b/packages/gateway/src/data-plane/tools/web-search/fetch-page_test.ts index e807a6af64..0c66a5a09e 100644 --- a/packages/gateway/src/data-plane/tools/web-search/fetch-page_test.ts +++ b/packages/gateway/src/data-plane/tools/web-search/fetch-page_test.ts @@ -30,7 +30,7 @@ test('fetchPageAndRecordUsage records usage with action=fetch_page on success', }); assertEquals(result, okResult); - const records = await repo.searchUsage.listAll(); + const records = await repo.webSearchUsage.listAll(); assertEquals(records.length, 1); assertEquals(records[0].provider, 'tavily'); assertEquals(records[0].keyId, 'k1'); @@ -46,7 +46,7 @@ test('fetchPageAndRecordUsage records once per URL (one row, requests=N) on a mu request: { urls: ['https://a.com', 'https://b.com', 'https://c.com'] }, }); - const records = await repo.searchUsage.listAll(); + const records = await repo.webSearchUsage.listAll(); assertEquals(records.length, 1); assertEquals(records[0].requests, 3); }); @@ -59,7 +59,7 @@ test('fetchPageAndRecordUsage records usage even when result is type:error', asy request: { urls: ['https://a.com'] }, }); - const records = await repo.searchUsage.listAll(); + const records = await repo.webSearchUsage.listAll(); assertEquals(records.length, 1); }); @@ -80,13 +80,13 @@ test('fetchPageAndRecordUsage rethrows AND records when provider call throws (tr 'network down', ); - const records = await repo.searchUsage.listAll(); + const records = await repo.webSearchUsage.listAll(); assertEquals(records.length, 1); assertEquals(records[0].requests, 1); }); test('fetchPageAndRecordUsage swallows recorder errors but still returns provider result', async () => { - repo.searchUsage.record = () => Promise.reject(new Error('write failed')); + repo.webSearchUsage.record = () => Promise.reject(new Error('write failed')); const originalConsoleError = console.error; console.error = () => {}; @@ -104,7 +104,7 @@ test('fetchPageAndRecordUsage swallows recorder errors but still returns provide }); test('fetchPageAndRecordUsage swallows recorder errors but still rethrows provider errors', async () => { - repo.searchUsage.record = () => Promise.reject(new Error('write failed')); + repo.webSearchUsage.record = () => Promise.reject(new Error('write failed')); const originalConsoleError = console.error; console.error = () => {}; diff --git a/packages/gateway/src/data-plane/tools/web-search/operations.ts b/packages/gateway/src/data-plane/tools/web-search/operations.ts index 4ff4136e12..8eaa91b4de 100644 --- a/packages/gateway/src/data-plane/tools/web-search/operations.ts +++ b/packages/gateway/src/data-plane/tools/web-search/operations.ts @@ -6,9 +6,9 @@ import { normalizeDomainList } from './domain-normalize.ts'; import { fetchPageAndRecordUsage } from './fetch-page.ts'; -import { searchWebAndRecordUsage } from './search.ts'; +import { runWebSearchAndRecordUsage } from './search.ts'; import type { ConfiguredWebSearchProvider, WebSearchProvider, WebSearchProviderName } from './types.ts'; -import { truncatePreservingCodePoints } from '../../chat/shared/text.ts'; +import { truncatePreservingCodePoints } from '../../shared/text.ts'; import type { ResponsesWebSearchAction, ResponsesWebSearchResult } from '@floway-dev/protocols/responses'; import { isAbortError } from '@floway-dev/provider'; @@ -507,7 +507,7 @@ const runOneSearchQuery = async ( userLocation: session.filters.userLocation, ...(session.signal !== undefined ? { signal: session.signal } : {}), }; - const result = await searchWebAndRecordUsage({ + const result = await runWebSearchAndRecordUsage({ provider: active.provider, providerName: active.providerName, keyId: session.apiKeyId, diff --git a/packages/gateway/src/data-plane/tools/web-search/provider.ts b/packages/gateway/src/data-plane/tools/web-search/provider.ts index 5e3b33581f..943da3942f 100644 --- a/packages/gateway/src/data-plane/tools/web-search/provider.ts +++ b/packages/gateway/src/data-plane/tools/web-search/provider.ts @@ -1,8 +1,8 @@ +import { FIXED_WEB_SEARCH_CONFIG_TEST_QUERY } from './config.ts'; import { createJinaWebSearchProvider } from './providers/jina.ts'; import { createMicrosoftGroundingWebSearchProvider } from './providers/microsoft-grounding.ts'; import { createTavilyWebSearchProvider } from './providers/tavily.ts'; -import { FIXED_SEARCH_CONFIG_TEST_QUERY } from './search-config.ts'; -import type { ConfiguredWebSearchProvider, SearchConfig, SearchConfigConnectionTestResult, WebSearchProvider, WebSearchProviderName } from './types.ts'; +import type { ConfiguredWebSearchProvider, WebSearchConfig, WebSearchConfigConnectionTestResult, WebSearchProvider, WebSearchProviderName } from './types.ts'; const toPreviewText = (content: Array<{ type: 'text'; text: string }>): string => content @@ -14,13 +14,13 @@ const toPreviewText = (content: Array<{ type: 'text'; text: string }>): string = // that provider and constructs the impl. Keeps `resolveConfiguredWeb...` // data-driven so adding a fourth provider is one entry, not another // if-branch. -const PROVIDER_FACTORIES: { [N in WebSearchProviderName]: (config: SearchConfig) => { apiKey: string; build: (apiKey: string) => WebSearchProvider } } = { +const PROVIDER_FACTORIES: { [N in WebSearchProviderName]: (config: WebSearchConfig) => { apiKey: string; build: (apiKey: string) => WebSearchProvider } } = { tavily: config => ({ apiKey: config.tavily.apiKey, build: createTavilyWebSearchProvider }), 'microsoft-grounding': config => ({ apiKey: config.microsoftGrounding.apiKey, build: createMicrosoftGroundingWebSearchProvider }), jina: config => ({ apiKey: config.jina.apiKey, build: createJinaWebSearchProvider }), }; -export const resolveConfiguredWebSearchProvider = (config: SearchConfig): ConfiguredWebSearchProvider => { +export const resolveConfiguredWebSearchProvider = (config: WebSearchConfig): ConfiguredWebSearchProvider => { if (config.provider === 'disabled') { return { type: 'disabled' }; } @@ -37,14 +37,14 @@ export const resolveConfiguredWebSearchProvider = (config: SearchConfig): Config }; }; -export const testSearchConfigConnection = async (config: SearchConfig): Promise => { +export const testWebSearchConfigConnection = async (config: WebSearchConfig): Promise => { const resolved = resolveConfiguredWebSearchProvider(config); if (resolved.type === 'disabled') { return { ok: false, provider: 'disabled', - query: FIXED_SEARCH_CONFIG_TEST_QUERY, + query: FIXED_WEB_SEARCH_CONFIG_TEST_QUERY, error: { code: 'disabled', message: 'Search provider is disabled.', @@ -56,7 +56,7 @@ export const testSearchConfigConnection = async (config: SearchConfig): Promise< return { ok: false, provider: resolved.provider, - query: FIXED_SEARCH_CONFIG_TEST_QUERY, + query: FIXED_WEB_SEARCH_CONFIG_TEST_QUERY, error: { code: 'missing_credential', message: `Missing API key for ${resolved.provider}.`, @@ -64,13 +64,13 @@ export const testSearchConfigConnection = async (config: SearchConfig): Promise< }; } - const result = await resolved.impl.search({ query: FIXED_SEARCH_CONFIG_TEST_QUERY }); + const result = await resolved.impl.search({ query: FIXED_WEB_SEARCH_CONFIG_TEST_QUERY }); if (result.type === 'error') { return { ok: false, provider: resolved.provider, - query: FIXED_SEARCH_CONFIG_TEST_QUERY, + query: FIXED_WEB_SEARCH_CONFIG_TEST_QUERY, error: { code: result.errorCode, message: result.message ?? 'Search test failed.', @@ -89,7 +89,7 @@ export const testSearchConfigConnection = async (config: SearchConfig): Promise< return { ok: false, provider: resolved.provider, - query: FIXED_SEARCH_CONFIG_TEST_QUERY, + query: FIXED_WEB_SEARCH_CONFIG_TEST_QUERY, error: { code: 'no_results', message: 'Search returned no preview results.', @@ -100,7 +100,7 @@ export const testSearchConfigConnection = async (config: SearchConfig): Promise< return { ok: true, provider: resolved.provider, - query: FIXED_SEARCH_CONFIG_TEST_QUERY, + query: FIXED_WEB_SEARCH_CONFIG_TEST_QUERY, results: previews, }; }; diff --git a/packages/gateway/src/data-plane/tools/web-search/provider_test.ts b/packages/gateway/src/data-plane/tools/web-search/provider_test.ts index 9aff8ad1c6..0c96c9e6a5 100644 --- a/packages/gateway/src/data-plane/tools/web-search/provider_test.ts +++ b/packages/gateway/src/data-plane/tools/web-search/provider_test.ts @@ -1,13 +1,13 @@ import { test } from 'vitest'; -import { resolveConfiguredWebSearchProvider, testSearchConfigConnection } from './provider.ts'; -import { DEFAULT_SEARCH_CONFIG, FIXED_SEARCH_CONFIG_TEST_QUERY } from './search-config.ts'; +import { DEFAULT_WEB_SEARCH_CONFIG, FIXED_WEB_SEARCH_CONFIG_TEST_QUERY } from './config.ts'; +import { resolveConfiguredWebSearchProvider, testWebSearchConfigConnection } from './provider.ts'; import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; import { assertEquals, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; test('resolveConfiguredWebSearchProvider returns disabled, missing-credential, or enabled', () => { - assertEquals(resolveConfiguredWebSearchProvider(DEFAULT_SEARCH_CONFIG), { + assertEquals(resolveConfiguredWebSearchProvider(DEFAULT_WEB_SEARCH_CONFIG), { type: 'disabled', }); @@ -40,11 +40,11 @@ test('resolveConfiguredWebSearchProvider returns disabled, missing-credential, o assertEquals(resolved.provider, 'microsoft-grounding'); }); -test('testSearchConfigConnection returns structured disabled and missing-credential errors', async () => { - assertEquals(await testSearchConfigConnection(DEFAULT_SEARCH_CONFIG), { +test('testWebSearchConfigConnection returns structured disabled and missing-credential errors', async () => { + assertEquals(await testWebSearchConfigConnection(DEFAULT_WEB_SEARCH_CONFIG), { ok: false, provider: 'disabled', - query: FIXED_SEARCH_CONFIG_TEST_QUERY, + query: FIXED_WEB_SEARCH_CONFIG_TEST_QUERY, error: { code: 'disabled', message: 'Search provider is disabled.', @@ -52,7 +52,7 @@ test('testSearchConfigConnection returns structured disabled and missing-credent }); assertEquals( - await testSearchConfigConnection({ + await testWebSearchConfigConnection({ provider: 'tavily', tavily: { apiKey: '' }, microsoftGrounding: { apiKey: 'ms-test' }, @@ -62,7 +62,7 @@ test('testSearchConfigConnection returns structured disabled and missing-credent { ok: false, provider: 'tavily', - query: FIXED_SEARCH_CONFIG_TEST_QUERY, + query: FIXED_WEB_SEARCH_CONFIG_TEST_QUERY, error: { code: 'missing_credential', message: 'Missing API key for tavily.', @@ -71,7 +71,7 @@ test('testSearchConfigConnection returns structured disabled and missing-credent ); }); -test('testSearchConfigConnection previews at most three normalized results', async () => { +test('testWebSearchConfigConnection previews at most three normalized results', async () => { await withMockedFetch( () => jsonResponse({ @@ -100,7 +100,7 @@ test('testSearchConfigConnection previews at most three normalized results', asy ], }), async () => { - const result = await testSearchConfigConnection({ + const result = await testWebSearchConfigConnection({ provider: 'tavily', tavily: { apiKey: 'tvly-test' }, microsoftGrounding: { apiKey: 'ms-test' }, @@ -114,7 +114,7 @@ test('testSearchConfigConnection previews at most three normalized results', asy } assertEquals(result.provider, 'tavily'); - assertEquals(result.query, FIXED_SEARCH_CONFIG_TEST_QUERY); + assertEquals(result.query, FIXED_WEB_SEARCH_CONFIG_TEST_QUERY); assertEquals(result.results.length, 3); assertEquals(result.results[0].title, 'React A'); assertEquals(result.results[0].url, 'https://react.dev/a'); @@ -125,12 +125,12 @@ test('testSearchConfigConnection previews at most three normalized results', asy ); }); -test('testSearchConfigConnection returns no_results when the provider returns no previews', async () => { +test('testWebSearchConfigConnection returns no_results when the provider returns no previews', async () => { await withMockedFetch( () => jsonResponse({ results: [] }), async () => { assertEquals( - await testSearchConfigConnection({ + await testWebSearchConfigConnection({ provider: 'tavily', tavily: { apiKey: 'tvly-test' }, microsoftGrounding: { apiKey: 'ms-test' }, @@ -140,7 +140,7 @@ test('testSearchConfigConnection returns no_results when the provider returns no { ok: false, provider: 'tavily', - query: FIXED_SEARCH_CONFIG_TEST_QUERY, + query: FIXED_WEB_SEARCH_CONFIG_TEST_QUERY, error: { code: 'no_results', message: 'Search returned no preview results.', @@ -151,7 +151,7 @@ test('testSearchConfigConnection returns no_results when the provider returns no ); }); -test('testSearchConfigConnection returns preview results for Microsoft Grounding too', async () => { +test('testWebSearchConfigConnection returns preview results for Microsoft Grounding too', async () => { await withMockedFetch( () => jsonResponse({ @@ -165,7 +165,7 @@ test('testSearchConfigConnection returns preview results for Microsoft Grounding ], }), async () => { - const result = await testSearchConfigConnection({ + const result = await testWebSearchConfigConnection({ provider: 'microsoft-grounding', tavily: { apiKey: 'tvly-test' }, microsoftGrounding: { apiKey: 'ms-test' }, @@ -179,7 +179,7 @@ test('testSearchConfigConnection returns preview results for Microsoft Grounding } assertEquals(result.provider, 'microsoft-grounding'); - assertEquals(result.query, FIXED_SEARCH_CONFIG_TEST_QUERY); + assertEquals(result.query, FIXED_WEB_SEARCH_CONFIG_TEST_QUERY); assertEquals(result.results, [ { title: 'React on Microsoft Learn', @@ -192,7 +192,7 @@ test('testSearchConfigConnection returns preview results for Microsoft Grounding ); }); -test('testSearchConfigConnection does not record search usage', async () => { +test('testWebSearchConfigConnection does not record search usage', async () => { const repo = new InMemoryRepo(); initRepo(repo); @@ -208,7 +208,7 @@ test('testSearchConfigConnection does not record search usage', async () => { ], }), async () => { - const result = await testSearchConfigConnection({ + const result = await testWebSearchConfigConnection({ provider: 'tavily', tavily: { apiKey: 'tvly-test' }, microsoftGrounding: { apiKey: '' }, @@ -220,5 +220,5 @@ test('testSearchConfigConnection does not record search usage', async () => { }, ); - assertEquals(await repo.searchUsage.listAll(), []); + assertEquals(await repo.webSearchUsage.listAll(), []); }); diff --git a/packages/gateway/src/data-plane/tools/web-search/providers/jina.ts b/packages/gateway/src/data-plane/tools/web-search/providers/jina.ts index 1bbae9cd41..f1f1b9d844 100644 --- a/packages/gateway/src/data-plane/tools/web-search/providers/jina.ts +++ b/packages/gateway/src/data-plane/tools/web-search/providers/jina.ts @@ -1,7 +1,6 @@ -import { extractWebSearchProviderErrorMessage, toWebSearchTextBlocks, validateWebSearchQuery } from './shared.ts'; +import { extractWebSearchProviderErrorMessage, fetchWithRetry, httpStatusToErrorCode, toWebSearchTextBlocks, validateWebSearchQuery } from './shared.ts'; import { truncateUtf8 } from './truncate.ts'; import { isJsonObject } from '../../../../shared/json-helpers.ts'; -import { sleep } from '../../../../shared/sleep.ts'; import { normalizeDomainList } from '../domain-normalize.ts'; import { DEFAULT_WEB_SEARCH_RESULT_COUNT, @@ -9,7 +8,6 @@ import { type WebSearchFetchPageRequest, type WebSearchFetchPageResult, type WebSearchProvider, - type WebSearchProviderErrorCode, type WebSearchProviderRequest, type WebSearchProviderResult, } from '../types.ts'; @@ -32,14 +30,6 @@ const JINA_SEARCH_MAX_TOKENS_PER_RESULT = 500; // Hard cap on Jina's `count` query param (validated server-side as 0..20). const JINA_SEARCH_MAX_COUNT = 20; -// Per-URL retry policy for the Reader endpoint. Matches the Microsoft -// Grounding browse retry shape because the failure modes are the same — -// 429 / 5xx are transient, everything else is structural. Each URL retries -// independently, so the worst-case wall clock for a 5-URL batch is still -// `sum(delays)` ≈ 15s, not 5× that. -const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000] as const; -const RETRYABLE_HTTP_STATUS: ReadonlySet = new Set([429, 500, 502, 503, 504]); - interface JinaEnvelope { code: number; status?: number; @@ -73,24 +63,6 @@ const isAssertionEmptyResults = (envelope: JinaEnvelope): boolean => && typeof envelope.message === 'string' && /no search results/i.test(envelope.message); -const httpStatusToErrorCode = (status: number): WebSearchProviderErrorCode => { - if (status === 429) return 'too_many_requests'; - if (status === 413) return 'request_too_large'; - if (status === 400) return 'invalid_tool_input'; - return 'unavailable'; -}; - -const fetchWithRetry = async (doFetch: () => Promise, signal?: AbortSignal): Promise => { - let attempt = 0; - while (true) { - const response = await doFetch(); - if (!RETRYABLE_HTTP_STATUS.has(response.status)) return response; - if (attempt >= RETRY_DELAYS_MS.length) return response; - await sleep(RETRY_DELAYS_MS[attempt], signal); - attempt += 1; - } -}; - // Jina accepts a single hostname per `X-Site` header, or multiple via the // literal `", "` separator (comma + space). Anything else collapses to a // single bogus hostname server-side; see `serper-search.ts:209` in diff --git a/packages/gateway/src/data-plane/tools/web-search/providers/microsoft-grounding.ts b/packages/gateway/src/data-plane/tools/web-search/providers/microsoft-grounding.ts index bdbc05667a..3a17b5a29e 100644 --- a/packages/gateway/src/data-plane/tools/web-search/providers/microsoft-grounding.ts +++ b/packages/gateway/src/data-plane/tools/web-search/providers/microsoft-grounding.ts @@ -1,7 +1,6 @@ -import { extractWebSearchProviderErrorMessage, toWebSearchTextBlocks, validateWebSearchQuery } from './shared.ts'; +import { extractWebSearchProviderErrorMessage, fetchWithRetry, httpStatusToErrorCode, toWebSearchTextBlocks, validateWebSearchQuery } from './shared.ts'; import { truncateUtf8 } from './truncate.ts'; import { isJsonObject } from '../../../../shared/json-helpers.ts'; -import { sleep } from '../../../../shared/sleep.ts'; import { normalizeDomainList } from '../domain-normalize.ts'; import { DEFAULT_WEB_SEARCH_RESULT_COUNT, @@ -20,24 +19,6 @@ const MICROSOFT_GROUNDING_SEARCH_URL = 'https://api.microsoft.ai/v3/search/web'; // (~30) and the model's parallel call count (≤4 in practice). const MICROSOFT_GROUNDING_BROWSE_URL = 'https://api.microsoft.ai/v3/browse'; -// Retry policy for both `/v3/search/web` and `/v3/browse`. 429 and 5xx -// are documented by Microsoft as transient. Transport-level errors are -// not retried — on Cloudflare Workers a thrown fetch is reliably a -// systemic issue, not transient. -const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000] as const; -const RETRYABLE_HTTP_STATUS: ReadonlySet = new Set([429, 500, 502, 503, 504]); - -const fetchWithRetry = async (doFetch: () => Promise, signal?: AbortSignal): Promise => { - let attempt = 0; - while (true) { - const response = await doFetch(); - if (!RETRYABLE_HTTP_STATUS.has(response.status)) return response; - if (attempt >= RETRY_DELAYS_MS.length) return response; - await sleep(RETRY_DELAYS_MS[attempt], signal); - attempt += 1; - } -}; - const toMicrosoftQuery = (request: WebSearchProviderRequest, query: string) => { // Microsoft Grounding has no allow/block-domain fields, so domain // policy is biased through `site:` / `-site:` operators. Best-effort, @@ -186,7 +167,7 @@ export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: if (response.status === 429) { return { type: 'error', - errorCode: 'too_many_requests', + errorCode: httpStatusToErrorCode(response.status), message: message ?? 'Microsoft Grounding rate limited the request.', }; } @@ -194,7 +175,7 @@ export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: if (response.status === 400) { return { type: 'error', - errorCode: 'invalid_tool_input', + errorCode: httpStatusToErrorCode(response.status), message: message ?? 'Microsoft Grounding rejected the search query.', }; } @@ -202,14 +183,14 @@ export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: if (response.status === 413) { return { type: 'error', - errorCode: 'request_too_large', + errorCode: httpStatusToErrorCode(response.status), message: message ?? 'Microsoft Grounding rejected the request as too large.', }; } return { type: 'error', - errorCode: 'unavailable', + errorCode: httpStatusToErrorCode(response.status), message: message ?? 'Microsoft Grounding search failed.', }; } catch (error) { @@ -259,13 +240,9 @@ export const createMicrosoftGroundingWebSearchProvider = (apiKey: string, deps?: } // 430 is the Browse-only "Too Many On-Demand Crawls" signal. - const errorCode: WebSearchProviderErrorCode = outcome.httpStatus === 429 || outcome.httpStatus === 430 + const errorCode: WebSearchProviderErrorCode = outcome.httpStatus === 430 ? 'too_many_requests' - : outcome.httpStatus === 413 - ? 'request_too_large' - : outcome.httpStatus === 400 - ? 'invalid_tool_input' - : 'unavailable'; + : httpStatusToErrorCode(outcome.httpStatus); failures.push({ url: outcome.url, errorCode, message: outcome.message }); } diff --git a/packages/gateway/src/data-plane/tools/web-search/providers/shared.ts b/packages/gateway/src/data-plane/tools/web-search/providers/shared.ts index e80a2de042..6e2db384cb 100644 --- a/packages/gateway/src/data-plane/tools/web-search/providers/shared.ts +++ b/packages/gateway/src/data-plane/tools/web-search/providers/shared.ts @@ -1,7 +1,32 @@ import { isJsonObject } from '../../../../shared/json-helpers.ts'; -import type { WebSearchProviderResult } from '../types.ts'; +import { sleep } from '../../../../shared/sleep.ts'; +import type { WebSearchProviderErrorCode, WebSearchProviderResult } from '../types.ts'; const MAX_WEB_SEARCH_QUERY_LENGTH = 1000; +const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000] as const; +const RETRYABLE_HTTP_STATUS: ReadonlySet = new Set([429, 500, 502, 503, 504]); + +export const fetchWithRetry = async ( + doFetch: () => Promise, + signal?: AbortSignal, + retryDelaysMs: readonly number[] = RETRY_DELAYS_MS, +): Promise => { + let attempt = 0; + while (true) { + const response = await doFetch(); + if (!RETRYABLE_HTTP_STATUS.has(response.status)) return response; + if (attempt >= retryDelaysMs.length) return response; + await sleep(retryDelaysMs[attempt], signal); + attempt += 1; + } +}; + +export const httpStatusToErrorCode = (status: number): WebSearchProviderErrorCode => { + if (status === 429) return 'too_many_requests'; + if (status === 413) return 'request_too_large'; + if (status === 400) return 'invalid_tool_input'; + return 'unavailable'; +}; export type ValidatedWebSearchQuery = { type: 'ok'; query: string } | { type: 'error'; result: WebSearchProviderResult }; diff --git a/packages/gateway/src/data-plane/tools/web-search/providers/tavily.ts b/packages/gateway/src/data-plane/tools/web-search/providers/tavily.ts index 4ec5e38886..6a568ef502 100644 --- a/packages/gateway/src/data-plane/tools/web-search/providers/tavily.ts +++ b/packages/gateway/src/data-plane/tools/web-search/providers/tavily.ts @@ -1,4 +1,4 @@ -import { extractWebSearchProviderErrorMessage, toWebSearchTextBlocks, validateWebSearchQuery } from './shared.ts'; +import { extractWebSearchProviderErrorMessage, httpStatusToErrorCode, toWebSearchTextBlocks, validateWebSearchQuery } from './shared.ts'; import { truncateUtf8 } from './truncate.ts'; import { isJsonObject } from '../../../../shared/json-helpers.ts'; import { normalizeDomainList } from '../domain-normalize.ts'; @@ -93,7 +93,7 @@ export const createTavilyWebSearchProvider = (apiKey: string, deps?: { fetch?: t if (response.status === 429) { return { type: 'error', - errorCode: 'too_many_requests', + errorCode: httpStatusToErrorCode(response.status), message: message ?? 'Tavily rate limited the request.', }; } @@ -101,7 +101,7 @@ export const createTavilyWebSearchProvider = (apiKey: string, deps?: { fetch?: t if (response.status === 400) { return { type: 'error', - errorCode: 'invalid_tool_input', + errorCode: httpStatusToErrorCode(response.status), message: message ?? 'Tavily rejected the search query.', }; } @@ -109,14 +109,14 @@ export const createTavilyWebSearchProvider = (apiKey: string, deps?: { fetch?: t if (response.status === 413) { return { type: 'error', - errorCode: 'request_too_large', + errorCode: httpStatusToErrorCode(response.status), message: message ?? 'Tavily rejected the request as too large.', }; } return { type: 'error', - errorCode: 'unavailable', + errorCode: httpStatusToErrorCode(response.status), message: message ?? 'Tavily search failed.', }; } @@ -165,13 +165,7 @@ export const createTavilyWebSearchProvider = (apiKey: string, deps?: { fetch?: t if (!response.ok) { const message = await extractWebSearchProviderErrorMessage(response); - const errorCode: WebSearchProviderErrorCode = response.status === 429 - ? 'too_many_requests' - : response.status === 413 - ? 'request_too_large' - : response.status === 400 - ? 'invalid_tool_input' - : 'unavailable'; + const errorCode: WebSearchProviderErrorCode = httpStatusToErrorCode(response.status); // Tavily extract is one batch call, so non-2xx applies to the // whole batch. Per-URL granularity only comes through // `failed_results` inside a 200. diff --git a/packages/gateway/src/data-plane/tools/web-search/search.ts b/packages/gateway/src/data-plane/tools/web-search/search.ts index 09649d4eb7..0d7724b2d6 100644 --- a/packages/gateway/src/data-plane/tools/web-search/search.ts +++ b/packages/gateway/src/data-plane/tools/web-search/search.ts @@ -1,7 +1,7 @@ import type { WebSearchProvider, WebSearchProviderName, WebSearchProviderRequest, WebSearchProviderResult } from './types.ts'; -import { recordSearchUsage } from './usage.ts'; +import { recordWebSearchUsage } from './usage.ts'; -export const searchWebAndRecordUsage = async (opts: { +export const runWebSearchAndRecordUsage = async (opts: { provider: WebSearchProvider; providerName: WebSearchProviderName; keyId: string; @@ -13,7 +13,7 @@ export const searchWebAndRecordUsage = async (opts: { // Telemetry must never mask the provider result; log and swallow // recording failures. try { - await recordSearchUsage({ + await recordWebSearchUsage({ provider: opts.providerName, keyId: opts.keyId, action: 'search', diff --git a/packages/gateway/src/data-plane/tools/web-search/search_test.ts b/packages/gateway/src/data-plane/tools/web-search/search_test.ts index b75980a4ea..42ffbdbffb 100644 --- a/packages/gateway/src/data-plane/tools/web-search/search_test.ts +++ b/packages/gateway/src/data-plane/tools/web-search/search_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { searchWebAndRecordUsage } from './search.ts'; +import { runWebSearchAndRecordUsage } from './search.ts'; import type { WebSearchProvider, WebSearchProviderResult } from './types.ts'; import { initRepo } from '../../../repo/index.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; @@ -11,11 +11,11 @@ const stubProvider = (search: WebSearchProvider['search']): WebSearchProvider => fetchPage: () => Promise.reject(new Error('fetchPage should not be called from search test')), }); -test('searchWebAndRecordUsage records successful provider calls', async () => { +test('runWebSearchAndRecordUsage records successful provider calls', async () => { const repo = new InMemoryRepo(); initRepo(repo); - const result = await searchWebAndRecordUsage({ + const result = await runWebSearchAndRecordUsage({ providerName: 'tavily', keyId: 'key_a', request: { query: 'React' }, @@ -23,18 +23,18 @@ test('searchWebAndRecordUsage records successful provider calls', async () => { }); assertEquals(result, { type: 'ok', results: [] }); - const records = await repo.searchUsage.listAll(); + const records = await repo.webSearchUsage.listAll(); assertEquals(records.length, 1); assertEquals(records[0].provider, 'tavily'); assertEquals(records[0].keyId, 'key_a'); assertEquals(records[0].requests, 1); }); -test('searchWebAndRecordUsage records provider error results', async () => { +test('runWebSearchAndRecordUsage records provider error results', async () => { const repo = new InMemoryRepo(); initRepo(repo); - const result = await searchWebAndRecordUsage({ + const result = await runWebSearchAndRecordUsage({ providerName: 'microsoft-grounding', keyId: 'key_b', request: { query: 'React' }, @@ -47,20 +47,20 @@ test('searchWebAndRecordUsage records provider error results', async () => { }); assertEquals(result.type, 'error'); - const records = await repo.searchUsage.listAll(); + const records = await repo.webSearchUsage.listAll(); assertEquals(records.length, 1); assertEquals(records[0].provider, 'microsoft-grounding'); assertEquals(records[0].keyId, 'key_b'); assertEquals(records[0].requests, 1); }); -test('searchWebAndRecordUsage records when a provider throws', async () => { +test('runWebSearchAndRecordUsage records when a provider throws', async () => { const repo = new InMemoryRepo(); initRepo(repo); await assertRejects( () => - searchWebAndRecordUsage({ + runWebSearchAndRecordUsage({ providerName: 'tavily', keyId: 'key_c', request: { query: 'React' }, @@ -70,16 +70,16 @@ test('searchWebAndRecordUsage records when a provider throws', async () => { 'network failed', ); - const records = await repo.searchUsage.listAll(); + const records = await repo.webSearchUsage.listAll(); assertEquals(records.length, 1); assertEquals(records[0].provider, 'tavily'); assertEquals(records[0].keyId, 'key_c'); assertEquals(records[0].requests, 1); }); -test('searchWebAndRecordUsage returns provider result when recording fails', async () => { +test('runWebSearchAndRecordUsage returns provider result when recording fails', async () => { const repo = new InMemoryRepo(); - repo.searchUsage.record = () => Promise.reject(new Error('write failed')); + repo.webSearchUsage.record = () => Promise.reject(new Error('write failed')); initRepo(repo); const originalConsoleError = console.error; @@ -88,9 +88,9 @@ test('searchWebAndRecordUsage returns provider result when recording fails', asy loggedErrors.push(args); }; - let result: Awaited> | undefined; + let result: Awaited> | undefined; try { - result = await searchWebAndRecordUsage({ + result = await runWebSearchAndRecordUsage({ providerName: 'tavily', keyId: 'key_d', request: { query: 'React' }, diff --git a/packages/gateway/src/data-plane/tools/web-search/types.ts b/packages/gateway/src/data-plane/tools/web-search/types.ts index cd88dd78f2..ab1b05a653 100644 --- a/packages/gateway/src/data-plane/tools/web-search/types.ts +++ b/packages/gateway/src/data-plane/tools/web-search/types.ts @@ -1,7 +1,7 @@ -import type { SearchConfig, WebSearchProviderName } from '../../../shared/web-search-providers.ts'; +import type { WebSearchConfig, WebSearchProviderName } from '../../../shared/web-search-providers.ts'; import type { MessagesWebSearchErrorCode } from '@floway-dev/protocols/messages'; -export type { SearchConfig, WebSearchProviderName } from '../../../shared/web-search-providers.ts'; +export type { WebSearchConfig, WebSearchProviderName } from '../../../shared/web-search-providers.ts'; export const DEFAULT_WEB_SEARCH_RESULT_COUNT = 10; @@ -100,16 +100,16 @@ export type ConfiguredWebSearchProvider = impl: WebSearchProvider; }; -export type SearchConfigConnectionTestResult = +export type WebSearchConfigConnectionTestResult = | { ok: true; - provider: SearchConfig['provider']; + provider: WebSearchConfig['provider']; query: string; results: WebSearchPreviewResult[]; } | { ok: false; - provider: SearchConfig['provider']; + provider: WebSearchConfig['provider']; query: string; error: { code: string; message: string }; }; diff --git a/packages/gateway/src/data-plane/tools/web-search/usage.ts b/packages/gateway/src/data-plane/tools/web-search/usage.ts index 14629cc21e..de231b8fa1 100644 --- a/packages/gateway/src/data-plane/tools/web-search/usage.ts +++ b/packages/gateway/src/data-plane/tools/web-search/usage.ts @@ -1,23 +1,21 @@ import type { WebSearchProviderName } from './types.ts'; import { getRepo } from '../../../repo/index.ts'; -import type { SearchUsageAction } from '../../../repo/types.ts'; +import type { WebSearchUsageAction } from '../../../repo/types.ts'; import { currentHour } from '../../shared/telemetry/hour.ts'; // Records a single usage row. Hour is computed at write time; `requests` // defaults to 1. Throws if the repo write fails — callers wrap this in // try/catch to swallow telemetry failures without masking the underlying // provider result. -export const recordSearchUsage = (args: { +export const recordWebSearchUsage = (args: { provider: WebSearchProviderName; keyId: string; - action: SearchUsageAction; + action: WebSearchUsageAction; requests?: number; -}): Promise => getRepo().searchUsage.record({ +}): Promise => getRepo().webSearchUsage.record({ provider: args.provider, keyId: args.keyId, action: args.action, hour: currentHour(), requests: args.requests ?? 1, }); - -export const queryWebSearchUsage = (opts: { provider?: WebSearchProviderName; keyId?: string; action?: SearchUsageAction; start: string; end: string }) => getRepo().searchUsage.query(opts); diff --git a/packages/gateway/src/dial/fetcher.ts b/packages/gateway/src/dial/fetcher.ts index b2e0d7dc09..94b04c8a8e 100644 --- a/packages/gateway/src/dial/fetcher.ts +++ b/packages/gateway/src/dial/fetcher.ts @@ -1,20 +1,12 @@ +import type { ProxyEntry } from './proxy-catalog.ts'; +import { createReplayableRequest, type ReplayableRequest } from './replayable-request.ts'; import { DIRECT_CONNECT_ID, DIRECT_FETCH_ID, entryMatchesColo } from '../repo/proxy-fallback-list.ts'; import type { Repo } from '../repo/types.ts'; import type { HttpRequest } from '@floway-dev/http'; -import { normalizeDialHost } from '@floway-dev/platform'; import type { Fetcher, ProxyFallbackEntry } from '@floway-dev/provider'; import { isAbortError } from '@floway-dev/provider'; import { ProxyDialError, type ProxyConfig, type ProxyRequestTarget, type RunDirectConnectRequestOptions, type RunProxiedRequestOptions, type SocketDial } from '@floway-dev/proxy'; -// Pairs the parsed wire config with an optional per-proxy dial deadline so -// a slow but real proxy can be granted more time without raising the bar -// for the whole gateway. -export interface ProxyEntry { - config: ProxyConfig; - /** ms; null means "use the dialer's default". */ - dialTimeoutMs: number | null; -} - interface CreateFetcherInput { repo: Pick; upstreamId: string; @@ -48,23 +40,6 @@ interface CreateFetcherInput { socketDial: () => SocketDial; } -/** - * Buffered request shape extracted from a Fetcher call. Splits the - * transport target (host/port/tls/sni) from the HTTP-shaped request - * (method/path/headers/body) so the dial layer and request-shaping layer - * each receive only what they need. - */ -interface MaterializedRequest { - target: ProxyRequestTarget; - request: HttpRequest; -} - -interface ReplayableRequest { - readonly signal: AbortSignal | undefined; - fetchInit(): RequestInit; - materialized(): Promise; -} - // Two-pass dial strategy. First pass walks the fallback list skipping any // entry whose (proxy, upstream) backoff row is still active, so a flaky // proxy gets shed in steady state. The second pass walks the entries that @@ -151,67 +126,6 @@ const runFallbacks = async ( throw new AggregateError(errors, 'all proxies failed at the dial layer'); }; -class ReplayableRequestOwner implements ReplayableRequest { - readonly signal: AbortSignal | undefined; - private fetch: RequestInit; - private materializedRequest: MaterializedRequest | undefined; - private rebuildFetchBody = false; - - constructor( - private readonly url: string, - init: RequestInit, - ) { - this.signal = init.signal ?? undefined; - this.fetch = init; - } - - fetchInit(): RequestInit { - if (this.rebuildFetchBody) { - this.fetch = rebuildInitFromMaterialized(this.fetch, this.materializedRequest!); - this.rebuildFetchBody = false; - } - return this.fetch; - } - - async materialized(): Promise { - if (this.materializedRequest !== undefined) return this.materializedRequest; - this.materializedRequest = await buildMaterializedRequest(this.url, this.fetch); - // Once bytes exist, the original BodyInit must not remain captured for the - // duration of the upstream request. A later direct-fetch fallback rebuilds its - // owned byte body lazily, so a successful proxy does not retain a second - // full buffer merely because `direct_fetch` appears later in the list. - this.fetch = { ...this.fetch, body: null }; - this.rebuildFetchBody = true; - return this.materializedRequest; - } -} - -const createReplayableRequest = (url: string, init: RequestInit): ReplayableRequest => - new ReplayableRequestOwner(url, init); - -const rebuildInitFromMaterialized = (original: RequestInit, materialized: MaterializedRequest): RequestInit => { - const headers = new Headers(original.headers); - const targetCt = materialized.request.headers['content-type']; - if (targetCt !== undefined && !headers.has('content-type')) { - headers.set('content-type', targetCt); - } - // Copy into a freshly-allocated ArrayBuffer-backed Uint8Array so the - // BodyInit slot accepts it under TypeScript's stricter typing — and so - // the buffer we hand to runtime fetch never aliases a backing buffer - // that's also referenced elsewhere. - let body: Uint8Array | null = null; - if (materialized.request.body) { - const owned = new Uint8Array(materialized.request.body.byteLength); - owned.set(materialized.request.body); - body = owned; - } - return { - ...original, - headers, - body, - }; -}; - const tryOne = async ( id: string, input: CreateFetcherInput, @@ -314,79 +228,3 @@ const tryOne = async ( throw err; } }; - -const buildMaterializedRequest = async (url: string, init: RequestInit): Promise => { - const u = new URL(url); - const collected = await collectBody(init.body); - const headers = extractHeaders(init.headers); - // FormData/URLSearchParams synthesize a Content-Type with the multipart - // boundary or the urlencoded marker. Adopt it only when the caller did not - // pre-set Content-Type itself, so explicit overrides keep winning. - if (collected?.contentType !== undefined && headers['content-type'] === undefined) { - headers['content-type'] = collected.contentType; - } - // `URL#hostname` keeps the `[…]` envelope on IPv6 literals; the - // `DialTarget.host` contract requires the bare address. Strip the - // brackets here at the URL→DialTarget seam so every dialer sees a - // canonical host. - const target: ProxyRequestTarget = { - host: normalizeDialHost(u.hostname), - port: u.port ? Number(u.port) : (u.protocol === 'https:' ? 443 : 80), - tls: u.protocol === 'https:', - }; - const request: HttpRequest = { - method: init.method ?? 'GET', - path: `${u.pathname}${u.search}`, - headers, - body: collected?.body, - }; - return { target, request }; -}; - -// Lower-case keys here so the request is canonical at the seam; the http -// package also lowercases internally, but normalizing at the boundary -// keeps the contract simple. -const extractHeaders = (input: HeadersInit | undefined): Record => { - if (!input) return {}; - if (input instanceof Headers) { - const out: Record = {}; - input.forEach((v, k) => { out[k.toLowerCase()] = v; }); - return out; - } - if (Array.isArray(input)) { - const out: Record = {}; - for (const [k, v] of input) out[k.toLowerCase()] = v; - return out; - } - const out: Record = {}; - for (const [k, v] of Object.entries(input)) out[k.toLowerCase()] = v; - return out; -}; - -interface CollectedBody { - body: Uint8Array; - /** Content-Type the runtime synthesizes for FormData/URLSearchParams (with - * multipart boundary or urlencoded marker). undefined for shapes that - * carry no implicit Content-Type. */ - contentType?: string; -} - -const collectBody = async ( - body: BodyInit | null | undefined, -): Promise => { - if (body == null) return undefined; - if (typeof body === 'string') return { body: new TextEncoder().encode(body) }; - if (body instanceof Uint8Array) return { body }; - if (body instanceof ArrayBuffer) return { body: new Uint8Array(body) }; - if (body instanceof Blob) return { body: new Uint8Array(await body.arrayBuffer()) }; - // FormData / URLSearchParams: round-trip through Request so the runtime - // produces a canonical multipart/url-encoded byte stream we can buffer - // alongside the synthesized Content-Type (with boundary or charset). - if (body instanceof FormData || body instanceof URLSearchParams) { - const req = new Request('https://internal/', { method: 'POST', body }); - const buffer = new Uint8Array(await req.arrayBuffer()); - const contentType = req.headers.get('content-type') ?? undefined; - return { body: buffer, contentType }; - } - throw new Error('unsupported BodyInit shape for materialized request'); -}; diff --git a/packages/gateway/src/dial/fetcher_test.ts b/packages/gateway/src/dial/fetcher_test.ts index b39cb233d3..d512d6bb35 100644 --- a/packages/gateway/src/dial/fetcher_test.ts +++ b/packages/gateway/src/dial/fetcher_test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createFetcher, type ProxyEntry } from './fetcher.ts'; +import { createFetcher } from './fetcher.ts'; +import type { ProxyEntry } from './proxy-catalog.ts'; import { InMemoryRepo } from '../repo/memory.ts'; import type { HttpRequest } from '@floway-dev/http'; import { ProxyDialError, type ProxyConfig, type ProxyRequestTarget, type SocketDial } from '@floway-dev/proxy'; diff --git a/packages/gateway/src/dial/per-request.ts b/packages/gateway/src/dial/per-request.ts index 34a8452ec4..a5b218d4d9 100644 --- a/packages/gateway/src/dial/per-request.ts +++ b/packages/gateway/src/dial/per-request.ts @@ -1,9 +1,10 @@ -import { createFetcher, type ProxyEntry } from './fetcher.ts'; +import { createFetcher } from './fetcher.ts'; +import { loadProxyCatalog } from './proxy-catalog.ts'; import { getRepo } from '../repo/index.ts'; import { isDirectFallbackId } from '../repo/proxy-fallback-list.ts'; import { getSocketDial } from '@floway-dev/platform'; import { directFetcher, type Fetcher, type UpstreamRecord } from '@floway-dev/provider'; -import { parseProxyUri, type ProxyUriError, runDirectConnectRequest, runProxiedRequest } from '@floway-dev/proxy'; +import { runDirectConnectRequest, runProxiedRequest } from '@floway-dev/proxy'; // Parse failures on individual proxy rows are isolated to the upstreams that // actually reference them: a single malformed URL must not take down every @@ -28,25 +29,7 @@ export const createPerRequestFetcher = async ( } } - const proxyById = new Map(); - const proxyParseErrors = new Map(); - if (referencedProxyIds.size > 0) { - const proxies = await repo.proxies.list(); - for (const p of proxies) { - if (!referencedProxyIds.has(p.id)) continue; - try { - proxyById.set(p.id, { - config: parseProxyUri(p.url), - // Carry the per-proxy timeout (seconds → ms) so the dial layer can - // honour an operator's override; null preserves the gateway default - // baked into the proxy library. - dialTimeoutMs: p.dialTimeoutSeconds === null ? null : p.dialTimeoutSeconds * 1000, - }); - } catch (err) { - proxyParseErrors.set(p.id, err as ProxyUriError); - } - } - } + const { proxyById, parseErrors: proxyParseErrors } = await loadProxyCatalog(repo, referencedProxyIds); return upstreamId => { // Fail loud on an unknown upstream id. Silently substituting `[]` diff --git a/packages/gateway/src/dial/proxy-catalog.ts b/packages/gateway/src/dial/proxy-catalog.ts new file mode 100644 index 0000000000..ed52685c1e --- /dev/null +++ b/packages/gateway/src/dial/proxy-catalog.ts @@ -0,0 +1,39 @@ +import type { Repo } from '../repo/types.ts'; +import { parseProxyUri, type ProxyConfig, type ProxyUriError } from '@floway-dev/proxy'; + +// Pairs the parsed wire config with an optional per-proxy dial deadline so a +// slow but real proxy can be granted more time without raising the bar for the +// whole gateway. +export interface ProxyEntry { + config: ProxyConfig; + /** ms; null means "use the dialer's default". */ + dialTimeoutMs: number | null; +} + +export interface ProxyCatalog { + readonly proxyById: Map; + readonly parseErrors: Map; +} + +export const loadProxyCatalog = async ( + repo: Pick, + referencedIds: ReadonlySet, +): Promise => { + const proxyById = new Map(); + const parseErrors = new Map(); + if (referencedIds.size === 0) return { proxyById, parseErrors }; + + const proxies = await repo.proxies.list(); + for (const proxy of proxies) { + if (!referencedIds.has(proxy.id)) continue; + try { + proxyById.set(proxy.id, { + config: parseProxyUri(proxy.url), + dialTimeoutMs: proxy.dialTimeoutSeconds === null ? null : proxy.dialTimeoutSeconds * 1000, + }); + } catch (error) { + parseErrors.set(proxy.id, error as ProxyUriError); + } + } + return { proxyById, parseErrors }; +}; diff --git a/packages/gateway/src/dial/replayable-request.ts b/packages/gateway/src/dial/replayable-request.ts new file mode 100644 index 0000000000..b8bb38b07d --- /dev/null +++ b/packages/gateway/src/dial/replayable-request.ts @@ -0,0 +1,151 @@ +import type { HttpRequest } from '@floway-dev/http'; +import { normalizeDialHost } from '@floway-dev/platform'; +import type { ProxyRequestTarget } from '@floway-dev/proxy'; + +interface MaterializedRequest { + target: ProxyRequestTarget; + request: HttpRequest; +} + +export interface ReplayableRequest { + readonly signal: AbortSignal | undefined; + fetchInit(): RequestInit; + materialized(): Promise; +} + +class ReplayableRequestOwner implements ReplayableRequest { + readonly signal: AbortSignal | undefined; + private fetch: RequestInit; + private materializedRequest: MaterializedRequest | undefined; + private rebuildFetchBody = false; + + constructor( + private readonly url: string, + init: RequestInit, + ) { + this.signal = init.signal ?? undefined; + this.fetch = init; + } + + fetchInit(): RequestInit { + if (this.rebuildFetchBody) { + this.fetch = rebuildInitFromMaterialized(this.fetch, this.materializedRequest!); + this.rebuildFetchBody = false; + } + return this.fetch; + } + + async materialized(): Promise { + if (this.materializedRequest !== undefined) return this.materializedRequest; + this.materializedRequest = await buildMaterializedRequest(this.url, this.fetch); + // Once bytes exist, the original BodyInit must not remain captured for the + // duration of the upstream request. A later direct-fetch fallback rebuilds its + // owned byte body lazily, so a successful proxy does not retain a second + // full buffer merely because `direct_fetch` appears later in the list. + this.fetch = { ...this.fetch, body: null }; + this.rebuildFetchBody = true; + return this.materializedRequest; + } +} + +export const createReplayableRequest = (url: string, init: RequestInit): ReplayableRequest => + new ReplayableRequestOwner(url, init); + +const rebuildInitFromMaterialized = (original: RequestInit, materialized: MaterializedRequest): RequestInit => { + const headers = new Headers(original.headers); + const targetCt = materialized.request.headers['content-type']; + if (targetCt !== undefined && !headers.has('content-type')) { + headers.set('content-type', targetCt); + } + // Copy into a freshly-allocated ArrayBuffer-backed Uint8Array so the + // BodyInit slot accepts it under TypeScript's stricter typing — and so + // the buffer we hand to runtime fetch never aliases a backing buffer + // that's also referenced elsewhere. + let body: Uint8Array | null = null; + if (materialized.request.body) { + const owned = new Uint8Array(materialized.request.body.byteLength); + owned.set(materialized.request.body); + body = owned; + } + return { + ...original, + headers, + body, + }; +}; + +const buildMaterializedRequest = async (url: string, init: RequestInit): Promise => { + const u = new URL(url); + const collected = await collectBody(init.body); + const headers = extractHeaders(init.headers); + // FormData/URLSearchParams synthesize a Content-Type with the multipart + // boundary or the urlencoded marker. Adopt it only when the caller did not + // pre-set Content-Type itself, so explicit overrides keep winning. + if (collected?.contentType !== undefined && headers['content-type'] === undefined) { + headers['content-type'] = collected.contentType; + } + // `URL#hostname` keeps the `[…]` envelope on IPv6 literals; the + // `DialTarget.host` contract requires the bare address. Strip the + // brackets here at the URL→DialTarget seam so every dialer sees a + // canonical host. + const target: ProxyRequestTarget = { + host: normalizeDialHost(u.hostname), + port: u.port ? Number(u.port) : (u.protocol === 'https:' ? 443 : 80), + tls: u.protocol === 'https:', + }; + const request: HttpRequest = { + method: init.method ?? 'GET', + path: `${u.pathname}${u.search}`, + headers, + body: collected?.body, + }; + return { target, request }; +}; + +// Lower-case keys here so the request is canonical at the seam; the http +// package also lowercases internally, but normalizing at the boundary +// keeps the contract simple. +const extractHeaders = (input: HeadersInit | undefined): Record => { + if (!input) return {}; + if (input instanceof Headers) { + const out: Record = {}; + input.forEach((value, key) => { out[key.toLowerCase()] = value; }); + return out; + } + if (Array.isArray(input)) { + const out: Record = {}; + for (const [key, value] of input) out[key.toLowerCase()] = value; + return out; + } + const out: Record = {}; + for (const [key, value] of Object.entries(input)) out[key.toLowerCase()] = value; + return out; +}; + +interface CollectedBody { + body: Uint8Array; + /** Content-Type the runtime synthesizes for FormData/URLSearchParams (with + * multipart boundary or urlencoded marker). undefined for shapes that + * carry no implicit Content-Type. */ + contentType?: string; +} + +const collectBody = async ( + body: BodyInit | null | undefined, +): Promise => { + if (body == null) return undefined; + if (typeof body === 'string') return { body: new TextEncoder().encode(body) }; + if (body instanceof Uint8Array) return { body }; + if (body instanceof ArrayBuffer) return { body: new Uint8Array(body) }; + if (body instanceof Blob) return { body: new Uint8Array(await body.arrayBuffer()) }; + // FormData / URLSearchParams: round-trip through Request so the runtime + // produces a canonical multipart/url-encoded byte stream we can buffer + // alongside the synthesized Content-Type (with boundary or charset). + if (body instanceof FormData || body instanceof URLSearchParams) { + const req = new Request('https://internal/', { method: 'POST', body }); + const buffer = new Uint8Array(await req.arrayBuffer()); + const contentType = req.headers.get('content-type') ?? undefined; + return { body: buffer, contentType }; + } + throw new Error('unsupported BodyInit shape for materialized request'); +}; diff --git a/packages/gateway/src/dump/accumulator.ts b/packages/gateway/src/dump/accumulator.ts index 9c1e2ddf63..e1e9ea17df 100644 --- a/packages/gateway/src/dump/accumulator.ts +++ b/packages/gateway/src/dump/accumulator.ts @@ -16,7 +16,7 @@ import type { PreparedDumpRequestBody, StoredDumpResponseBody, } from './types.ts'; -import type { RequestBody } from '../data-plane/chat/shared/request-body.ts'; +import type { RequestBody } from '../data-plane/shared/request-body.ts'; import { getRepo } from '../repo/index.ts'; import type { ApiKey, TokenUsage } from '../repo/types.ts'; import { ulid } from '../shared/ulid.ts'; diff --git a/packages/gateway/src/index.ts b/packages/gateway/src/index.ts index 0e5749a869..c16a63a256 100644 --- a/packages/gateway/src/index.ts +++ b/packages/gateway/src/index.ts @@ -4,6 +4,5 @@ export { FileDumpStore } from './repo/dump-store.ts'; export { SqlRepo } from './repo/sql.ts'; export { initBackgroundSchedulerResolver } from './runtime/background.ts'; export { initDumpBroker, initDumpStore } from './dump/registry.ts'; -export type { DumpStore } from './dump/store-contract.ts'; -export { initResponsesWebSocketUpgradeResolver, type ResponsesWebSocketEvents } from './data-plane/chat/responses/websocket.ts'; +export { initResponsesWebSocketUpgradeResolver } from './data-plane/chat/responses/websocket.ts'; export { runScheduledMaintenance } from './scheduled.ts'; diff --git a/packages/gateway/src/middleware/auth_test.ts b/packages/gateway/src/middleware/auth_test.ts index 670662156d..2df4379414 100644 --- a/packages/gateway/src/middleware/auth_test.ts +++ b/packages/gateway/src/middleware/auth_test.ts @@ -3,7 +3,7 @@ import { expect, test } from 'vitest'; import { authMiddleware } from './auth.ts'; import { initRepo } from '../repo/index.ts'; -import { setupAppTest } from '../test-helpers.ts'; +import { setupAppTest } from '../test-utils/app.ts'; import { assertEquals } from '@floway-dev/test-utils'; const authTestApp = () => { diff --git a/packages/gateway/src/migrations-dir.ts b/packages/gateway/src/migrations-dir.ts new file mode 100644 index 0000000000..bfe2b56c12 --- /dev/null +++ b/packages/gateway/src/migrations-dir.ts @@ -0,0 +1 @@ +export const migrationsDir = new URL('../migrations/', import.meta.url); diff --git a/packages/gateway/src/repo/dump-store_test.ts b/packages/gateway/src/repo/dump-store_test.ts index 926e5fdeb5..6a9ed84669 100644 --- a/packages/gateway/src/repo/dump-store_test.ts +++ b/packages/gateway/src/repo/dump-store_test.ts @@ -6,10 +6,10 @@ import { expect, test } from 'vitest'; import { FileDumpStore } from './dump-store.ts'; import { initRepo } from './index.ts'; -import { collectSpilledFiles } from './spilled-files.ts'; import { SqlRepo } from './sql.ts'; import { createSqliteTestDb } from './test-sqlite.ts'; import type { DumpWriteRecord } from '../dump/types.ts'; +import { collectSpilledFiles } from '../scheduled/spilled-files.ts'; import { initFileStore, MemoryFileStore } from '@floway-dev/platform'; import type { FileStore, SqlDatabase } from '@floway-dev/platform'; import { assertEquals, assertExists } from '@floway-dev/test-utils'; diff --git a/packages/gateway/src/repo/memory.ts b/packages/gateway/src/repo/memory.ts index db66ff0025..ddc8fcc376 100644 --- a/packages/gateway/src/repo/memory.ts +++ b/packages/gateway/src/repo/memory.ts @@ -9,6 +9,7 @@ import { scopedResponsesKey, } from './responses-clone.ts'; import { quantizeResponsesRefreshedAt, RESPONSES_REFRESH_GRANULARITY_MS, responsesStateCutoff } from './responses-retention.ts'; +import { SEED_ADMIN_USER_ID } from './seed-admin.ts'; import { generateSessionToken } from './session-tokens.ts'; import type { ApiKey, @@ -23,7 +24,7 @@ import type { AgentSetupRenewal, AgentSetupRepository, BackoffRow, - CachedModelsRow, + ModelsCacheRow, ModelAliasesRepo, ModelAliasRecord, ModelsCacheRepo, @@ -40,9 +41,9 @@ import type { ResponsesItemsRepo, ResponsesSnapshotsRepo, SpilledFilesRepo, - SearchConfigRepo, - SearchUsageRecord, - SearchUsageRepo, + WebSearchConfigRepo, + WebSearchUsageRecord, + WebSearchUsageRepo, Session, SessionsRepo, StoredResponsesItem, @@ -56,13 +57,13 @@ import type { import { serializeStoredState } from './upstream-json.ts'; import { usageMetricRows } from './usage-metrics.ts'; import { bucketForTtftMs, bucketForTpotUs } from '../shared/performance-histogram.ts'; -import { assertWebSearchProviderName, type SearchConfig } from '../shared/web-search-providers.ts'; +import { assertWebSearchProviderName, type WebSearchConfig } from '../shared/web-search-providers.ts'; import { AgentSetupTokenCollisionError } from '@floway-dev/agent-setup'; import { addDecimalStrings, canonicalPricingSelectorKey, canonicalizePricingSelector, type BillingMetric, type DecimalString, type PricingSelector } from '@floway-dev/protocols/common'; import type { ProviderModel, UpstreamRecord } from '@floway-dev/provider'; const SEED_ADMIN_USER: User = { - id: 1, + id: SEED_ADMIN_USER_ID, username: 'admin', passwordHash: null, isAdmin: true, @@ -384,14 +385,14 @@ class MemoryUsageRepo implements UsageRepo { } } -class MemorySearchUsageRepo implements SearchUsageRepo { - private store = new Map(); +class MemoryWebSearchUsageRepo implements WebSearchUsageRepo { + private store = new Map(); - private key(r: { provider: SearchUsageRecord['provider']; keyId: string; action: SearchUsageRecord['action']; hour: string }): string { + private key(r: { provider: WebSearchUsageRecord['provider']; keyId: string; action: WebSearchUsageRecord['action']; hour: string }): string { return `${r.provider}\0${r.keyId}\0${r.action}\0${r.hour}`; } - record(args: { provider: SearchUsageRecord['provider']; keyId: string; action: SearchUsageRecord['action']; hour: string; requests: number }): Promise { + record(args: { provider: WebSearchUsageRecord['provider']; keyId: string; action: WebSearchUsageRecord['action']; hour: string; requests: number }): Promise { return Promise.resolve().then(() => { const validProvider = assertWebSearchProviderName(args.provider); const k = this.key({ provider: validProvider, keyId: args.keyId, action: args.action, hour: args.hour }); @@ -404,7 +405,7 @@ class MemorySearchUsageRepo implements SearchUsageRepo { }); } - query(opts: { provider?: SearchUsageRecord['provider']; keyId?: string; action?: SearchUsageRecord['action']; start: string; end: string }): Promise { + query(opts: { provider?: WebSearchUsageRecord['provider']; keyId?: string; action?: WebSearchUsageRecord['action']; start: string; end: string }): Promise { return Promise.resolve().then(() => { const provider = opts.provider ? assertWebSearchProviderName(opts.provider) : undefined; return [...this.store.values()] @@ -417,11 +418,11 @@ class MemorySearchUsageRepo implements SearchUsageRepo { }); } - listAll(): Promise { + listAll(): Promise { return Promise.resolve([...this.store.values()].map(r => ({ ...r })).sort((a, b) => a.hour.localeCompare(b.hour))); } - set(record: SearchUsageRecord): Promise { + set(record: WebSearchUsageRecord): Promise { return Promise.resolve().then(() => { const provider = assertWebSearchProviderName(record.provider); const validRecord = { ...record, provider }; @@ -543,9 +544,9 @@ class MemoryPerformanceRepo implements PerformanceRepo { } class MemoryModelsCacheRepo implements ModelsCacheRepo { - private rows = new Map(); + private rows = new Map(); - get(upstreamId: string): Promise { + get(upstreamId: string): Promise { const row = this.rows.get(upstreamId); return Promise.resolve(row ? { ...row, models: [...row.models] } : null); } @@ -569,14 +570,14 @@ class MemoryModelsCacheRepo implements ModelsCacheRepo { } } -class MemorySearchConfigRepo implements SearchConfigRepo { +class MemoryWebSearchConfigRepo implements WebSearchConfigRepo { private config: unknown | null = null; get(): Promise { return Promise.resolve(this.config === null ? null : structuredClone(this.config)); } - save(config: SearchConfig): Promise { + save(config: WebSearchConfig): Promise { this.config = structuredClone(config); return Promise.resolve(); } @@ -1246,10 +1247,10 @@ export class InMemoryRepo implements Repo { users: UsersRepo; sessions: SessionsRepo; usage: UsageRepo; - searchUsage: SearchUsageRepo; + webSearchUsage: WebSearchUsageRepo; performance: PerformanceRepo; modelsCache: ModelsCacheRepo; - searchConfig: SearchConfigRepo; + webSearchConfig: WebSearchConfigRepo; upstreams: UpstreamRepo; proxies: ProxyRepo; proxyBackoffs: ProxyBackoffRepo; @@ -1266,10 +1267,10 @@ export class InMemoryRepo implements Repo { this.expirationSweeps = new MemoryExpirationSweepsRepo(); this.apiKeys = new MemoryApiKeyRepo(this.expirationSweeps); this.usage = new MemoryUsageRepo(); - this.searchUsage = new MemorySearchUsageRepo(); + this.webSearchUsage = new MemoryWebSearchUsageRepo(); this.performance = new MemoryPerformanceRepo(); this.modelsCache = new MemoryModelsCacheRepo(); - this.searchConfig = new MemorySearchConfigRepo(); + this.webSearchConfig = new MemoryWebSearchConfigRepo(); this.upstreams = new MemoryUpstreamRepo(); this.proxies = new MemoryProxyRepo(this.upstreams); this.proxyBackoffs = new MemoryProxyBackoffRepo(); diff --git a/packages/gateway/src/repo/migrations_test.ts b/packages/gateway/src/repo/migrations_test.ts new file mode 100644 index 0000000000..ab53302f91 --- /dev/null +++ b/packages/gateway/src/repo/migrations_test.ts @@ -0,0 +1,26 @@ +import { test } from 'vitest'; + +import { migrationSqlByFilename } from './test-sqlite.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +// Migration filenames must start with a unique NNNN_ prefix so lexical order +// agrees with `wrangler d1 migrations apply`. These collisions predate the +// guard and are already applied in production, so renaming them would create +// new migration identities rather than repairing the old ones. +const KNOWN_DUPLICATE_PREFIXES: ReadonlySet = new Set(['0011', '0025']); + +test('every migration file has a unique numeric prefix', () => { + const byPrefix = new Map(); + for (const [filename] of migrationSqlByFilename) { + const match = /^(\d{4})_/.exec(filename); + assertEquals(match !== null, true, `migration filename must start with NNNN_: ${filename}`); + const prefix = match![1]; + const bucket = byPrefix.get(prefix) ?? []; + bucket.push(filename); + byPrefix.set(prefix, bucket); + } + const collisions = [...byPrefix.entries()] + .filter(([prefix, bucket]) => bucket.length > 1 && !KNOWN_DUPLICATE_PREFIXES.has(prefix)) + .map(([, bucket]) => bucket); + assertEquals(collisions, [], `duplicate migration numbers: ${JSON.stringify(collisions)}`); +}); diff --git a/packages/gateway/src/repo/model-aliases_test.ts b/packages/gateway/src/repo/model-aliases_test.ts index f5ca6dc141..c579e3f7d8 100644 --- a/packages/gateway/src/repo/model-aliases_test.ts +++ b/packages/gateway/src/repo/model-aliases_test.ts @@ -2,13 +2,14 @@ // scenarios by default; the SQL backend (sql.js applying every migration) // catches schema drift, JSON-column round-trips, and rename atomicity. +import initSqlJs from 'sql.js'; import { test } from 'vitest'; import { InMemoryRepo } from './memory.ts'; import { SqlRepo } from './sql.ts'; -import { createSqliteTestDb } from './test-sqlite.ts'; +import { createSqliteTestDb, migrationSqlByFilename } from './test-sqlite.ts'; import type { ModelAliasRecord, Repo } from './types.ts'; -import { assertEquals, assertExists, assertRejects } from '@floway-dev/test-utils'; +import { assertEquals, assertExists, assertRejects, assertThrows } from '@floway-dev/test-utils'; const REPO_BACKENDS: Array Promise]> = [ ['memory', async () => new InMemoryRepo()], @@ -198,3 +199,57 @@ test('[sql] rejects an unknown kind stored under the open database constraint', const repo = new SqlRepo(db); await assertRejects(() => repo.modelAliases.getByName('future-alias'), Error, 'model_aliases.kind for future-alias is invalid'); }); + +type SqlJsDatabase = { + run(sql: string): void; + exec(sql: string): Array<{ columns: string[]; values: unknown[][] }>; + close(): void; +}; + +const migrationFilenames = migrationSqlByFilename.map(([filename]) => filename); + +const createPreUpstreamsMigrationDatabase = async (): Promise => { + const SQL = await initSqlJs(); + const db = new SQL.Database() as SqlJsDatabase; + for (const filename of migrationFilenames.filter(filename => filename < '0010_unified_upstreams.sql')) { + applyMigration(db, filename); + } + return db; +}; + +const applyMigration = (db: SqlJsDatabase, filename: string): void => { + const sql = migrationSqlByFilename.find(([candidate]) => candidate === filename)?.[1]; + if (sql === undefined) throw new Error(`Missing migration SQL fixture: ${filename}`); + db.run(sql); +}; + +const sqlRows = (db: SqlJsDatabase, sql: string): T[] => { + const [result] = db.exec(sql); + if (!result) return []; + return result.values.map(values => Object.fromEntries(result.columns.map((column, index) => [column, values[index] ?? null])) as T); +}; + +test('migration 0062 opens model alias kind while preserving existing aliases', async () => { + const db = await createPreUpstreamsMigrationDatabase(); + try { + for (const filename of migrationFilenames.filter(filename => filename >= '0010_unified_upstreams.sql' && filename < '0063_model_alias_kind.sql').toSorted()) { + applyMigration(db, filename); + } + + applyMigration(db, '0063_model_alias_kind.sql'); + db.run(`INSERT INTO model_aliases (name, kind, selection, visible_in_models_list, targets, sort_order, created_at, updated_at) + VALUES ('rerank-alias', 'rerank', 'first-available', 1, '[]', 1, '2026-07-21T00:00:00.000Z', '2026-07-21T00:00:00.000Z')`); + + assertEquals(sqlRows<{ name: string; kind: string }>(db, 'SELECT name, kind FROM model_aliases ORDER BY sort_order, created_at'), [ + { name: 'codex-auto-review', kind: 'chat' }, + { name: 'rerank-alias', kind: 'rerank' }, + ]); + assertThrows( + () => db.run(`INSERT INTO model_aliases (name, kind, selection, visible_in_models_list, targets, sort_order, created_at, updated_at) + VALUES ('empty-kind', '', 'first-available', 1, '[]', 2, '2026-07-21T00:00:00.000Z', '2026-07-21T00:00:00.000Z')`), + Error, + ); + } finally { + db.close(); + } +}); diff --git a/packages/gateway/src/repo/model-pricing-migration_test.ts b/packages/gateway/src/repo/model-pricing-migration_test.ts deleted file mode 100644 index 3b0f43acb2..0000000000 --- a/packages/gateway/src/repo/model-pricing-migration_test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import initSqlJs from 'sql.js'; -import { test } from 'vitest'; - -import { migrationSqlByFilename } from './test-sqlite.ts'; -import { divideDecimalString, priceRequest, type ModelPricing, validateModelPricing } from '@floway-dev/protocols/common'; -import { assertEquals, assertThrows } from '@floway-dev/test-utils'; - -const sqlString = (value: string): string => `'${value.replaceAll("'", "''")}'`; - -const inputRates = (input: number, output?: number) => ({ - input_tokens: divideDecimalString(String(input), '1000000'), - input_cache_read_tokens: divideDecimalString(String(input), '1000000'), - input_cache_write_tokens: divideDecimalString(String(input), '1000000'), - input_cache_write_1h_tokens: divideDecimalString(String(input), '1000000'), - input_image_tokens: divideDecimalString(String(input), '1000000'), - ...(output === undefined ? {} : { - output_tokens: divideDecimalString(String(output), '1000000'), - output_image_tokens: divideDecimalString(String(output), '1000000'), - }), -}); - -const tokenPricing = (entries: ModelPricing['entries']): ModelPricing => ({ entries }); - -test('model pricing migrations materialize legacy semantics as base-unit metric rates', async () => { - const SQL = await initSqlJs(); - const db = new SQL.Database(); - for (const [filename, sql] of migrationSqlByFilename) { - if (filename === '0054_model_pricing.sql') { - const legacyKey = ['co', 'st'].join(''); - const configJson = JSON.stringify({ - models: [ - { - upstreamModelId: 'base-and-overlay', - [legacyKey]: { input: 1, output: 4, tiers: { priority: { input: 2 } } }, - }, - { - upstreamModelId: 'cache-overrides', - [legacyKey]: { - input: 1, - input_cache_read: 0.1, - input_cache_write: 1.25, - output: 4, - tiers: { fast: { input: 2, input_cache_write: 3 } }, - }, - }, - { - upstreamModelId: 'tiny-rate', - [legacyKey]: { input: 1e-20 }, - }, - { - upstreamModelId: 'tier-adds-output', - [legacyKey]: { input: 1, tiers: { priority: { output: 8 } } }, - }, - { - upstreamModelId: 'tier-only', - [legacyKey]: { tiers: { priority: { input: 2 } } }, - }, - { - upstreamModelId: 'empty-tier', - [legacyKey]: { input: 1, tiers: { priority: {} } }, - }, - { - upstreamModelId: 'empty-rates', - [legacyKey]: { tiers: { priority: {} } }, - }, - { - upstreamModelId: 'write-and-output-only', - [legacyKey]: { input_cache_write: 1.25, output: 4 }, - }, - { - upstreamModelId: 'zero-input', - [legacyKey]: { input: 0, tiers: { priority: { input: 2 } } }, - }, - { - upstreamModelId: 'tier-order', - [legacyKey]: { input: 1, tiers: { priority: { input: 2 }, flex: { input: 0.5 } } }, - }, - { - upstreamModelId: 'base-equivalent-tiers', - [legacyKey]: { - input: 1, - tiers: { - default: { input: 2 }, - '\tDefault\n': { input: 3 }, - '\u00a0standard\u00a0': { output: 8 }, - '\u3000default\u3000': { input: 4 }, - '\t\n': { input: 5 }, - }, - }, - }, - { - upstreamModelId: 'base-equivalent-tier-only', - [legacyKey]: { tiers: { Standard: { input: 2 } } }, - }, - { upstreamModelId: 'unpriced', display_name: 'Unpriced' }, - ], - }); - db.run( - `INSERT INTO upstreams (id, provider, name, created_at, updated_at, config_json) - VALUES ('up_pricing', 'custom', 'Pricing migration', '2026-07-13T00:00:00.000Z', '2026-07-13T00:00:00.000Z', ${sqlString(configJson)})`, - ); - } - db.run(sql); - } - - const [configResult] = db.exec("SELECT config_json FROM upstreams WHERE id = 'up_pricing'"); - const config = JSON.parse(configResult!.values[0]![0] as string) as { - models: { upstreamModelId: string; display_name?: string; pricing?: ModelPricing }[]; - }; - assertEquals(config, { - models: [ - { - upstreamModelId: 'base-and-overlay', - pricing: tokenPricing([ - { rates: inputRates(1, 4) }, - { selector: { serviceTier: 'priority' }, rates: inputRates(2, 4) }, - ]), - }, - { - upstreamModelId: 'cache-overrides', - pricing: tokenPricing([ - { - rates: { - input_tokens: '0.000001', - input_cache_read_tokens: '0.0000001', - input_cache_write_tokens: '0.00000125', - input_cache_write_1h_tokens: '0.00000125', - input_image_tokens: '0.000001', - output_tokens: '0.000004', - output_image_tokens: '0.000004', - }, - }, - { - selector: { serviceTier: 'fast' }, - rates: { - input_tokens: '0.000002', - input_cache_read_tokens: '0.0000001', - input_cache_write_tokens: '0.000003', - input_cache_write_1h_tokens: '0.000003', - input_image_tokens: '0.000002', - output_tokens: '0.000004', - output_image_tokens: '0.000004', - }, - }, - ]), - }, - { - upstreamModelId: 'tiny-rate', - pricing: tokenPricing([{ - rates: { - input_tokens: '0.00000000000000000000000001', - input_cache_read_tokens: '0.00000000000000000000000001', - input_cache_write_tokens: '0.00000000000000000000000001', - input_cache_write_1h_tokens: '0.00000000000000000000000001', - input_image_tokens: '0.00000000000000000000000001', - }, - }]), - }, - { - upstreamModelId: 'tier-adds-output', - pricing: tokenPricing([ - { rates: inputRates(1, 0) }, - { selector: { serviceTier: 'priority' }, rates: inputRates(1, 8) }, - ]), - }, - { - upstreamModelId: 'tier-only', - pricing: tokenPricing([ - { rates: inputRates(0) }, - { selector: { serviceTier: 'priority' }, rates: inputRates(2) }, - ]), - }, - { - upstreamModelId: 'empty-tier', - pricing: tokenPricing([ - { rates: inputRates(1) }, - { selector: { serviceTier: 'priority' }, rates: inputRates(1) }, - ]), - }, - { upstreamModelId: 'empty-rates' }, - { - upstreamModelId: 'write-and-output-only', - pricing: tokenPricing([{ rates: { input_cache_write_tokens: '0.00000125', input_cache_write_1h_tokens: '0.00000125', output_tokens: '0.000004', output_image_tokens: '0.000004' } }]), - }, - { - upstreamModelId: 'zero-input', - pricing: tokenPricing([ - { rates: inputRates(0) }, - { selector: { serviceTier: 'priority' }, rates: inputRates(2) }, - ]), - }, - { - upstreamModelId: 'tier-order', - pricing: tokenPricing([ - { rates: inputRates(1) }, - { selector: { serviceTier: 'priority' }, rates: inputRates(2) }, - { selector: { serviceTier: 'flex' }, rates: inputRates(0.5) }, - ]), - }, - { - upstreamModelId: 'base-equivalent-tiers', - pricing: tokenPricing([{ rates: inputRates(1) }]), - }, - { upstreamModelId: 'base-equivalent-tier-only' }, - { upstreamModelId: 'unpriced', display_name: 'Unpriced' }, - ], - }); - - for (const model of config.models) { - if (!model.pricing) continue; - validateModelPricing(model.pricing); - const base = model.pricing.entries.find(entry => entry.selector === undefined)!.rates; - assertEquals(priceRequest(model.pricing, { inputTokens: 1, serviceTier: 'unknown' }), { selector: {}, rates: base }); - } -}); - -test('model pricing migration preserves every digit in current numeric rate lexemes', async () => { - const SQL = await initSqlJs(); - const db = new SQL.Database(); - for (const [filename, sql] of migrationSqlByFilename) { - if (filename === '0062_usage_billing_metrics.sql') { - const configJson = '{"models":[{"upstreamModelId":"precise-rate","pricing":{"entries":[{"rates":{"input":0.12345678901234566,"output":1e-20,"input_cache_read":9223372036854775807,"input_cache_write":1e-324}}]}}]}'; - db.run( - `INSERT INTO upstreams (id, provider, name, created_at, updated_at, config_json) - VALUES ('up_precise_pricing', 'custom', 'Precise pricing', '2026-07-13T00:00:00.000Z', '2026-07-13T00:00:00.000Z', ${sqlString(configJson)})`, - ); - db.run(sql); - break; - } - db.run(sql); - } - - const row = db.exec("SELECT config_json FROM upstreams WHERE id = 'up_precise_pricing'")[0]!.values[0]![0] as string; - assertEquals(JSON.parse(row).models[0].pricing.entries[0].rates, { - input_tokens: '0.00000012345678901234566', - output_tokens: '0.00000000000000000000000001', - input_cache_read_tokens: '9223372036854.775807', - input_cache_write_tokens: `0.${'0'.repeat(329)}1`, - }); -}); - -test('model pricing migration rejects malformed, negative, and non-finite legacy rates', async () => { - for (const invalidRateJson of ['"not-a-price"', 'null', 'true', '-1', '1e999', '1e-400']) { - const SQL = await initSqlJs(); - const db = new SQL.Database(); - for (const [filename, sql] of migrationSqlByFilename) { - if (filename === '0062_usage_billing_metrics.sql') { - const configJson = `{"models":[{"upstreamModelId":"invalid-rate","pricing":{"entries":[{"rates":{"input":${invalidRateJson}}]}}]}`; - db.run( - `INSERT INTO upstreams (id, provider, name, created_at, updated_at, config_json) - VALUES ('up_invalid_pricing', 'custom', 'Invalid pricing', '2026-07-13T00:00:00.000Z', '2026-07-13T00:00:00.000Z', ${sqlString(configJson)})`, - ); - assertThrows(() => db.run(sql), Error, 'malformed JSON'); - break; - } - db.run(sql); - } - } -}); diff --git a/packages/gateway/src/repo/responses-items_test.ts b/packages/gateway/src/repo/responses-items_test.ts index 4a87df8372..b885001f59 100644 --- a/packages/gateway/src/repo/responses-items_test.ts +++ b/packages/gateway/src/repo/responses-items_test.ts @@ -6,10 +6,10 @@ import { InMemoryRepo } from './memory.ts'; import { hashResponsesJson } from './responses-hash.ts'; import { prepareStoredResponsesPayload } from './responses-payload.ts'; import { quantizeResponsesRefreshedAt, responsesStateCutoff } from './responses-retention.ts'; -import { collectSpilledFiles } from './spilled-files.ts'; import { SqlRepo } from './sql.ts'; import { createSqliteTestDb, migrationSqlByFilename } from './test-sqlite.ts'; import type { ApiKey, Repo, StoredResponsesItem } from './types.ts'; +import { collectSpilledFiles } from '../scheduled/spilled-files.ts'; import { initFileStore, MemoryFileStore } from '@floway-dev/platform'; const RETENTION_SECONDS = 24 * 60 * 60; diff --git a/packages/gateway/src/repo/responses-state-sql.ts b/packages/gateway/src/repo/responses-state-sql.ts index 818acd6a47..d9260e6420 100644 --- a/packages/gateway/src/repo/responses-state-sql.ts +++ b/packages/gateway/src/repo/responses-state-sql.ts @@ -8,6 +8,7 @@ import { } from './responses-payload.ts'; import { quantizeResponsesRefreshedAt, RESPONSES_REFRESH_GRANULARITY_MS } from './responses-retention.ts'; import { SPILLED_FILE_STAGE_GRACE_MS } from './spilled-files-policy.ts'; +import { runStatements } from './sql-batch.ts'; import type { ResponsesItemsRepo, ResponsesSnapshotsRepo, @@ -21,14 +22,6 @@ const RESPONSES_IN_QUERY_CHUNK_SIZE = 80; const RESPONSES_INSERT_CHUNK_SIZE = 14; const RESPONSES_REFRESH_CHUNK_SIZE = 45; -const runStatements = async (db: SqlDatabase, statements: SqlPreparedStatement[]): Promise => { - if (statements.length === 0) return []; - if (db.batch) return await db.batch(statements); - const results: SqlResult[] = []; - for (const statement of statements) results.push(await statement.run()); - return results; -}; - const mapSequentially = async (values: readonly T[], mapper: (value: T) => Promise): Promise => { const mapped: U[] = []; for (const value of values) mapped.push(await mapper(value)); diff --git a/packages/gateway/src/repo/search-usage_test.ts b/packages/gateway/src/repo/search-usage_test.ts index ea81465546..fe6ce4e0da 100644 --- a/packages/gateway/src/repo/search-usage_test.ts +++ b/packages/gateway/src/repo/search-usage_test.ts @@ -2,17 +2,17 @@ import { test } from 'vitest'; import { InMemoryRepo } from './memory.ts'; import { SqlRepo } from './sql.ts'; -import type { SearchUsageRecord, SearchUsageRepo } from './types.ts'; +import type { WebSearchUsageRecord, WebSearchUsageRepo } from './types.ts'; import type { SqlDatabase } from '@floway-dev/platform'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; -const sortSearchUsageRecords = (records: SearchUsageRecord[]) => +const sortWebSearchUsageRecords = (records: WebSearchUsageRecord[]) => records.toSorted( (a, b) => a.hour.localeCompare(b.hour) || a.provider.localeCompare(b.provider) || a.keyId.localeCompare(b.keyId) || a.action.localeCompare(b.action), ); -const exerciseSearchUsageRepo = async (repo: SearchUsageRepo) => { +const exerciseWebSearchUsageRepo = async (repo: WebSearchUsageRepo) => { await repo.deleteAll(); await repo.record({ provider: 'tavily', keyId: 'key_a', action: 'search', hour: '2026-04-25T10', requests: 1 }); await repo.record({ provider: 'tavily', keyId: 'key_a', action: 'search', hour: '2026-04-25T10', requests: 2 }); @@ -104,7 +104,7 @@ const exerciseSearchUsageRepo = async (repo: SearchUsageRepo) => { assertEquals(await repo.listAll(), []); }; -const exerciseActionDimension = async (repo: SearchUsageRepo) => { +const exerciseActionDimension = async (repo: WebSearchUsageRepo) => { await repo.deleteAll(); // Distinct rows per action under the same (provider, keyId, hour). @@ -114,7 +114,7 @@ const exerciseActionDimension = async (repo: SearchUsageRepo) => { const all = await repo.listAll(); assertEquals(all.length, 2); - assertEquals(sortSearchUsageRecords(all), [ + assertEquals(sortWebSearchUsageRecords(all), [ { provider: 'tavily', keyId: 'key_a', @@ -166,7 +166,7 @@ const exerciseActionDimension = async (repo: SearchUsageRepo) => { hour: '2026-05-01T10', requests: 99, }); - const afterSet = sortSearchUsageRecords(await repo.listAll()); + const afterSet = sortWebSearchUsageRecords(await repo.listAll()); assertEquals(afterSet, [ { provider: 'tavily', @@ -187,15 +187,15 @@ const exerciseActionDimension = async (repo: SearchUsageRepo) => { await repo.deleteAll(); }; -const assertRejectsInvalidProvider = async (repo: SearchUsageRepo) => { +const assertRejectsInvalidProvider = async (repo: WebSearchUsageRepo) => { await repo.deleteAll(); - await assertRejects(() => repo.record({ provider: 'disabled' as SearchUsageRecord['provider'], keyId: 'key_a', action: 'search', hour: '2026-04-25T10', requests: 1 }), TypeError, 'Invalid web search provider'); + await assertRejects(() => repo.record({ provider: 'disabled' as WebSearchUsageRecord['provider'], keyId: 'key_a', action: 'search', hour: '2026-04-25T10', requests: 1 }), TypeError, 'Invalid web search provider'); await assertRejects( () => repo.set({ - provider: 'disabled' as SearchUsageRecord['provider'], + provider: 'disabled' as WebSearchUsageRecord['provider'], keyId: 'key_a', action: 'search', hour: '2026-04-25T10', @@ -207,15 +207,15 @@ const assertRejectsInvalidProvider = async (repo: SearchUsageRepo) => { }; test('memory search usage repo records, queries, overwrites, and clears', async () => { - await exerciseSearchUsageRepo(new InMemoryRepo().searchUsage); + await exerciseWebSearchUsageRepo(new InMemoryRepo().webSearchUsage); }); test('memory search usage repo distinguishes search vs fetch_page rows', async () => { - await exerciseActionDimension(new InMemoryRepo().searchUsage); + await exerciseActionDimension(new InMemoryRepo().webSearchUsage); }); test('memory search usage repo rejects invalid provider names', async () => { - await assertRejectsInvalidProvider(new InMemoryRepo().searchUsage); + await assertRejectsInvalidProvider(new InMemoryRepo().webSearchUsage); }); class FakeSqlPreparedStatement { @@ -285,11 +285,11 @@ class FakeSqlDatabase implements SqlDatabase { select(query: string, binds: unknown[]) { if (!query.includes('WHERE')) { - return sortSearchUsageRecords( + return sortWebSearchUsageRecords( this.rows.map(r => ({ - provider: r.provider as SearchUsageRecord['provider'], + provider: r.provider as WebSearchUsageRecord['provider'], keyId: r.key_id, - action: r.action as SearchUsageRecord['action'], + action: r.action as WebSearchUsageRecord['action'], hour: r.hour, requests: r.requests, })), @@ -302,7 +302,7 @@ class FakeSqlDatabase implements SqlDatabase { })); } - // Predicate combinations matched by SqlSearchUsageRepo.query(): + // Predicate combinations matched by SqlWebSearchUsageRepo.query(): // - hour bounds always present (start, end) // - provider may be prepended (unshifted) // - keyId may be appended @@ -331,15 +331,15 @@ class FakeSqlDatabase implements SqlDatabase { } test('SQL search usage repo records, queries, overwrites, and clears', async () => { - await exerciseSearchUsageRepo(new SqlRepo(new FakeSqlDatabase()).searchUsage); + await exerciseWebSearchUsageRepo(new SqlRepo(new FakeSqlDatabase()).webSearchUsage); }); test('SQL search usage repo distinguishes search vs fetch_page rows', async () => { - await exerciseActionDimension(new SqlRepo(new FakeSqlDatabase()).searchUsage); + await exerciseActionDimension(new SqlRepo(new FakeSqlDatabase()).webSearchUsage); }); test('SQL search usage repo rejects invalid provider names', async () => { - await assertRejectsInvalidProvider(new SqlRepo(new FakeSqlDatabase()).searchUsage); + await assertRejectsInvalidProvider(new SqlRepo(new FakeSqlDatabase()).webSearchUsage); }); test('SQL search usage repo rejects invalid stored provider names', async () => { @@ -352,7 +352,7 @@ test('SQL search usage repo rejects invalid stored provider names', async () => requests: 1, }); - await assertRejects(() => new SqlRepo(db).searchUsage.listAll(), TypeError, 'Invalid web search provider'); + await assertRejects(() => new SqlRepo(db).webSearchUsage.listAll(), TypeError, 'Invalid web search provider'); }); test('SQL search usage repo rejects invalid stored action values', async () => { @@ -365,5 +365,5 @@ test('SQL search usage repo rejects invalid stored action values', async () => { requests: 1, }); - await assertRejects(() => new SqlRepo(db).searchUsage.listAll(), TypeError, 'Invalid search usage action'); + await assertRejects(() => new SqlRepo(db).webSearchUsage.listAll(), TypeError, 'Invalid search usage action'); }); diff --git a/packages/gateway/src/repo/seed-admin.ts b/packages/gateway/src/repo/seed-admin.ts new file mode 100644 index 0000000000..52b7722f24 --- /dev/null +++ b/packages/gateway/src/repo/seed-admin.ts @@ -0,0 +1 @@ +export const SEED_ADMIN_USER_ID = 1; diff --git a/packages/gateway/src/repo/sql-batch.ts b/packages/gateway/src/repo/sql-batch.ts new file mode 100644 index 0000000000..f28dd32285 --- /dev/null +++ b/packages/gateway/src/repo/sql-batch.ts @@ -0,0 +1,10 @@ +import type { SqlDatabase, SqlPreparedStatement } from '@floway-dev/platform'; + +export const runStatements = async (db: SqlDatabase, statements: SqlPreparedStatement[]): Promise => { + if (statements.length === 0) return; + if (db.batch) { + await db.batch(statements); + return; + } + for (const statement of statements) await statement.run(); +}; diff --git a/packages/gateway/src/repo/sql.ts b/packages/gateway/src/repo/sql.ts index f455b4835f..4a5be85f82 100644 --- a/packages/gateway/src/repo/sql.ts +++ b/packages/gateway/src/repo/sql.ts @@ -5,6 +5,7 @@ import { normalizeProxyFallbackList } from './proxy-fallback-list.ts'; import { SqlResponsesItemsRepo, SqlResponsesSnapshotsRepo } from './responses-state-sql.ts'; import { generateSessionToken } from './session-tokens.ts'; import { SqlSpilledFilesRepo } from './spilled-files-sql.ts'; +import { runStatements } from './sql-batch.ts'; import type { ApiKey, ApiKeyRepo, @@ -15,7 +16,7 @@ import type { AgentSetupRenewal, AgentSetupRepository, BackoffRow, - CachedModelsRow, + ModelsCacheRow, ModelAliasesRepo, ModelAliasRecord, ModelsCacheRepo, @@ -32,9 +33,9 @@ import type { ResponsesItemsRepo, ResponsesSnapshotsRepo, SpilledFilesRepo, - SearchConfigRepo, - SearchUsageRecord, - SearchUsageRepo, + WebSearchConfigRepo, + WebSearchUsageRecord, + WebSearchUsageRepo, Session, SessionsRepo, UpstreamRepo, @@ -48,21 +49,13 @@ import { parseUpstreamColor, parseUpstreamKind } from './upstream-parse.ts'; import { usageMetricRows } from './usage-metrics.ts'; import { bucketForTtftMs, bucketForTpotUs } from '../shared/performance-histogram.ts'; import { parseServerSecret } from '../shared/server-secret.ts'; -import { assertWebSearchProviderName, type SearchConfig } from '../shared/web-search-providers.ts'; +import { assertWebSearchProviderName, type WebSearchConfig } from '../shared/web-search-providers.ts'; import { AgentSetupTokenCollisionError } from '@floway-dev/agent-setup'; -import type { SqlDatabase, SqlPreparedStatement, SqlResult } from '@floway-dev/platform'; +import type { SqlDatabase, SqlPreparedStatement } from '@floway-dev/platform'; import { addDecimalStrings, canonicalPricingSelectorKey, parseBillingMetric, parseModelKind, parseNonNegativeDecimalString, parsePricingSelectorKey, type AliasSelection, type AliasTarget, type AnnouncedMetadata } from '@floway-dev/protocols/common'; import type { ProviderModel, ProxyFallbackEntry, ModelPrefixConfig, UpstreamRecord } from '@floway-dev/provider'; import { normalizeModelPrefix, parsePerformanceOperation } from '@floway-dev/provider'; -const runStatements = async (db: SqlDatabase, statements: SqlPreparedStatement[]): Promise => { - if (statements.length === 0) return []; - if (db.batch) return await db.batch(statements); - const results: SqlResult[] = []; - for (const statement of statements) results.push(await statement.run()); - return results; -}; - interface ApiKeyRow { id: string; user_id: number; @@ -535,10 +528,10 @@ const assembleUsageRecords = (metrics: readonly UsageMetricRow[], requests: read return [...byBucket.values()].sort((a, b) => a.hour.localeCompare(b.hour)); }; -class SqlSearchUsageRepo implements SearchUsageRepo { +class SqlWebSearchUsageRepo implements WebSearchUsageRepo { constructor(private db: SqlDatabase) {} - async record(args: { provider: SearchUsageRecord['provider']; keyId: string; action: SearchUsageRecord['action']; hour: string; requests: number }): Promise { + async record(args: { provider: WebSearchUsageRecord['provider']; keyId: string; action: WebSearchUsageRecord['action']; hour: string; requests: number }): Promise { const validProvider = assertWebSearchProviderName(args.provider); await this.db .prepare( @@ -550,7 +543,7 @@ class SqlSearchUsageRepo implements SearchUsageRepo { .run(); } - async query(opts: { provider?: SearchUsageRecord['provider']; keyId?: string; action?: SearchUsageRecord['action']; start: string; end: string }): Promise { + async query(opts: { provider?: WebSearchUsageRecord['provider']; keyId?: string; action?: WebSearchUsageRecord['action']; start: string; end: string }): Promise { const filters = ['hour >= ?', 'hour < ?']; const binds: unknown[] = [opts.start, opts.end]; if (opts.provider) { @@ -577,10 +570,10 @@ class SqlSearchUsageRepo implements SearchUsageRepo { hour: string; requests: number; }>(); - return results.map(toSearchUsageRecord); + return results.map(toWebSearchUsageRecord); } - async listAll(): Promise { + async listAll(): Promise { const { results } = await this.db.prepare('SELECT provider, key_id, action, hour, requests FROM search_usage ORDER BY hour').all<{ provider: string; key_id: string; @@ -588,10 +581,10 @@ class SqlSearchUsageRepo implements SearchUsageRepo { hour: string; requests: number; }>(); - return results.map(toSearchUsageRecord); + return results.map(toWebSearchUsageRecord); } - async set(record: SearchUsageRecord): Promise { + async set(record: WebSearchUsageRecord): Promise { const provider = assertWebSearchProviderName(record.provider); await this.db .prepare( @@ -802,7 +795,7 @@ class SqlPerformanceRepo implements PerformanceRepo { } } -const toSearchUsageRecord = (row: { provider: string; key_id: string; action: string; hour: string; requests: number }): SearchUsageRecord => { +const toWebSearchUsageRecord = (row: { provider: string; key_id: string; action: string; hour: string; requests: number }): WebSearchUsageRecord => { if (row.action !== 'search' && row.action !== 'fetch_page') { throw new TypeError(`Invalid search usage action: ${row.action}`); } @@ -827,7 +820,7 @@ const modelsReviver = (key: string, value: unknown): unknown => class SqlModelsCacheRepo implements ModelsCacheRepo { constructor(private db: SqlDatabase) {} - async get(upstreamId: string): Promise { + async get(upstreamId: string): Promise { const row = await this.db .prepare('SELECT revision, fetched_at, models_json, last_error_json FROM models_cache WHERE upstream_id = ?') .bind(upstreamId) @@ -868,7 +861,7 @@ class SqlModelsCacheRepo implements ModelsCacheRepo { } } -class SqlSearchConfigRepo implements SearchConfigRepo { +class SqlWebSearchConfigRepo implements WebSearchConfigRepo { constructor(private db: SqlDatabase) {} async get(): Promise { @@ -889,7 +882,7 @@ class SqlSearchConfigRepo implements SearchConfigRepo { }; } - async save(config: SearchConfig): Promise { + async save(config: WebSearchConfig): Promise { const { provider, tavily, microsoftGrounding, jina, passthroughOpenAiSearch } = config; await this.db .prepare( @@ -1653,10 +1646,10 @@ export class SqlRepo implements Repo { sessions: SessionsRepo; apiKeys: ApiKeyRepo; usage: UsageRepo; - searchUsage: SearchUsageRepo; + webSearchUsage: WebSearchUsageRepo; performance: PerformanceRepo; modelsCache: ModelsCacheRepo; - searchConfig: SearchConfigRepo; + webSearchConfig: WebSearchConfigRepo; upstreams: UpstreamRepo; proxies: ProxyRepo; proxyBackoffs: ProxyBackoffRepo; @@ -1672,10 +1665,10 @@ export class SqlRepo implements Repo { this.sessions = new SqlSessionsRepo(db); this.apiKeys = new SqlApiKeyRepo(db); this.usage = new SqlUsageRepo(db); - this.searchUsage = new SqlSearchUsageRepo(db); + this.webSearchUsage = new SqlWebSearchUsageRepo(db); this.performance = new SqlPerformanceRepo(db); this.modelsCache = new SqlModelsCacheRepo(db); - this.searchConfig = new SqlSearchConfigRepo(db); + this.webSearchConfig = new SqlWebSearchConfigRepo(db); this.upstreams = new SqlUpstreamRepo(db); this.proxies = new SqlProxyRepo(db); this.proxyBackoffs = new SqlProxyBackoffRepo(db); diff --git a/packages/gateway/src/repo/types.ts b/packages/gateway/src/repo/types.ts index 08ccc049bb..0d87dfb9a4 100644 --- a/packages/gateway/src/repo/types.ts +++ b/packages/gateway/src/repo/types.ts @@ -1,4 +1,4 @@ -import type { SearchConfig, WebSearchProviderName } from '../shared/web-search-providers.ts'; +import type { WebSearchConfig, WebSearchProviderName } from '../shared/web-search-providers.ts'; import type { AgentSetupRepository } from '@floway-dev/agent-setup'; import type { AliasSelection, AliasTarget, AnnouncedMetadata, BillingMetric, DecimalString, ModelKind, PricingSelector } from '@floway-dev/protocols/common'; import type { PerformanceTelemetryContext, ProviderModel, UpstreamRecord } from '@floway-dev/provider'; @@ -82,12 +82,12 @@ export interface TokenUsage { tier?: string | null; } -export type SearchUsageAction = 'search' | 'fetch_page'; +export type WebSearchUsageAction = 'search' | 'fetch_page'; -export interface SearchUsageRecord { +export interface WebSearchUsageRecord { provider: WebSearchProviderName; keyId: string; - action: SearchUsageAction; + action: WebSearchUsageAction; hour: string; requests: number; } @@ -205,11 +205,11 @@ export interface UsageRepo { deleteAll(): Promise; } -export interface SearchUsageRepo { - record(args: { provider: WebSearchProviderName; keyId: string; action: SearchUsageAction; hour: string; requests: number }): Promise; - query(opts: { provider?: WebSearchProviderName; keyId?: string; action?: SearchUsageAction; start: string; end: string }): Promise; - listAll(): Promise; - set(record: SearchUsageRecord): Promise; +export interface WebSearchUsageRepo { + record(args: { provider: WebSearchProviderName; keyId: string; action: WebSearchUsageAction; hour: string; requests: number }): Promise; + query(opts: { provider?: WebSearchProviderName; keyId?: string; action?: WebSearchUsageAction; start: string; end: string }): Promise; + listAll(): Promise; + set(record: WebSearchUsageRecord): Promise; deleteAll(): Promise; } @@ -237,7 +237,7 @@ export interface PerformanceRepo { deleteAll(): Promise; } -export interface CachedModelsRow { +export interface ModelsCacheRow { revision: number; fetchedAt: number; models: ProviderModel[]; @@ -245,15 +245,15 @@ export interface CachedModelsRow { } export interface ModelsCacheRepo { - get(upstreamId: string): Promise; + get(upstreamId: string): Promise; put(upstreamId: string, row: { revision: number; fetchedAt: number; models: ProviderModel[] }): Promise; setLastError(upstreamId: string, error: { message: string; at: number } | null): Promise; delete(upstreamId: string): Promise; } -export interface SearchConfigRepo { +export interface WebSearchConfigRepo { get(): Promise; - save(config: SearchConfig): Promise; + save(config: WebSearchConfig): Promise; } export interface UpstreamRepo { @@ -441,10 +441,10 @@ export interface Repo { users: UsersRepo; sessions: SessionsRepo; usage: UsageRepo; - searchUsage: SearchUsageRepo; + webSearchUsage: WebSearchUsageRepo; performance: PerformanceRepo; modelsCache: ModelsCacheRepo; - searchConfig: SearchConfigRepo; + webSearchConfig: WebSearchConfigRepo; upstreams: UpstreamRepo; proxies: ProxyRepo; proxyBackoffs: ProxyBackoffRepo; diff --git a/packages/gateway/src/repo/upstreams_test.ts b/packages/gateway/src/repo/upstreams_test.ts index b14e21ec00..f59cd8df86 100644 --- a/packages/gateway/src/repo/upstreams_test.ts +++ b/packages/gateway/src/repo/upstreams_test.ts @@ -3,8 +3,10 @@ import { test } from 'vitest'; import { InMemoryRepo } from './memory.ts'; import { SqlRepo } from './sql.ts'; +import { migrationSqlByFilename } from './test-sqlite.ts'; import type { UpstreamRepo } from './types.ts'; import type { SqlDatabase } from '@floway-dev/platform'; +import { divideDecimalString, priceRequest, type ModelPricing, validateModelPricing } from '@floway-dev/protocols/common'; import type { UpstreamRecord } from '@floway-dev/provider'; import { assert, assertEquals, assertRejects, assertThrows } from '@floway-dev/test-utils'; @@ -546,7 +548,7 @@ test('migration 0010 creates unified upstreams and rewrites legacy upstream iden test('migration 0042 renames bearerToken to apiKey and backfills authStyle on legacy rows', async () => { const db = await createMigratedSqlJsDatabase(); try { - for (const filename of [...migrationSqlByFilename.keys()].filter(f => f >= '0010_unified_upstreams.sql' && f < '0042_custom_apikey_rename.sql').toSorted()) { + for (const filename of migrationFilenames.filter(f => f >= '0010_unified_upstreams.sql' && f < '0042_custom_apikey_rename.sql').toSorted()) { applySqlJsFile(db, filename); } @@ -588,7 +590,7 @@ test('migration 0042 renames bearerToken to apiKey and backfills authStyle on le test('migration 0044 rewrites pathOverrides keys to the OpenAI-canonical /path/fragment form', async () => { const db = await createMigratedSqlJsDatabase(); try { - for (const filename of [...migrationSqlByFilename.keys()].filter(f => f >= '0010_unified_upstreams.sql' && f < '0044_custom_pathoverrides_slash_keys.sql').toSorted()) { + for (const filename of migrationFilenames.filter(f => f >= '0010_unified_upstreams.sql' && f < '0044_custom_pathoverrides_slash_keys.sql').toSorted()) { applySqlJsFile(db, filename); } @@ -653,35 +655,10 @@ test('migration 0044 rewrites pathOverrides keys to the OpenAI-canonical /path/f } }); -test('migration 0062 opens model alias kind while preserving existing aliases', async () => { - const db = await createMigratedSqlJsDatabase(); - try { - for (const filename of [...migrationSqlByFilename.keys()].filter(filename => filename >= '0010_unified_upstreams.sql' && filename < '0063_model_alias_kind.sql').toSorted()) { - applySqlJsFile(db, filename); - } - - applySqlJsFile(db, '0063_model_alias_kind.sql'); - db.run(`INSERT INTO model_aliases (name, kind, selection, visible_in_models_list, targets, sort_order, created_at, updated_at) - VALUES ('rerank-alias', 'rerank', 'first-available', 1, '[]', 1, '2026-07-21T00:00:00.000Z', '2026-07-21T00:00:00.000Z')`); - - assertEquals(sqlJsRows<{ name: string; kind: string }>(db, 'SELECT name, kind FROM model_aliases ORDER BY sort_order, created_at'), [ - { name: 'codex-auto-review', kind: 'chat' }, - { name: 'rerank-alias', kind: 'rerank' }, - ]); - assertThrows( - () => db.run(`INSERT INTO model_aliases (name, kind, selection, visible_in_models_list, targets, sort_order, created_at, updated_at) - VALUES ('empty-kind', '', 'first-available', 1, '[]', 2, '2026-07-21T00:00:00.000Z', '2026-07-21T00:00:00.000Z')`), - Error, - ); - } finally { - db.close(); - } -}); - test('migration 0047 backfills openaiDeviceId on legacy Codex rows and leaves populated rows alone', async () => { const db = await createMigratedSqlJsDatabase(); try { - for (const filename of [...migrationSqlByFilename.keys()].filter(f => f >= '0010_unified_upstreams.sql' && f < '0047_codex_account_openai_device_id.sql').toSorted()) { + for (const filename of migrationFilenames.filter(f => f >= '0010_unified_upstreams.sql' && f < '0047_codex_account_openai_device_id.sql').toSorted()) { applySqlJsFile(db, filename); } @@ -725,7 +702,7 @@ test('migration 0047 backfills openaiDeviceId on legacy Codex rows and leaves po test('migration 0048 rebuckets Codex quota snapshots by active limit', async () => { const db = await createMigratedSqlJsDatabase(); try { - for (const filename of [...migrationSqlByFilename.keys()].filter(f => f >= '0010_unified_upstreams.sql' && f < '0048_codex_quota_snapshot_active_limit_map.sql').toSorted()) { + for (const filename of migrationFilenames.filter(f => f >= '0010_unified_upstreams.sql' && f < '0048_codex_quota_snapshot_active_limit_map.sql').toSorted()) { applySqlJsFile(db, filename); } @@ -856,7 +833,7 @@ test('migration 0048 rebuckets Codex quota snapshots by active limit', async () test('migration 0055 names existing direct fallback entries direct_fetch', async () => { const db = await createMigratedSqlJsDatabase(); try { - for (const filename of [...migrationSqlByFilename.keys()].filter(f => f >= '0010_unified_upstreams.sql' && f < '0055_direct_transport_fallbacks.sql').toSorted()) { + for (const filename of migrationFilenames.filter(f => f >= '0010_unified_upstreams.sql' && f < '0055_direct_transport_fallbacks.sql').toSorted()) { applySqlJsFile(db, filename); } @@ -1017,24 +994,20 @@ type SqlJsDatabase = { close(): void; }; -const migrationSqlByPath = import.meta.glob('../../migrations/*.sql', { query: '?raw', import: 'default', eager: true }) as Record; - -const migrationSqlByFilename = new Map( - Object.entries(migrationSqlByPath).map(([path, sql]) => [path.slice(path.lastIndexOf('/') + 1), sql]), -); +const migrationFilenames = migrationSqlByFilename.map(([filename]) => filename); const createMigratedSqlJsDatabase = async (): Promise => { const SQL = await initSqlJs(); const db = new SQL.Database() as SqlJsDatabase; - for (const filename of [...migrationSqlByFilename.keys()].filter(filename => filename < '0010_unified_upstreams.sql').toSorted()) { + for (const filename of migrationFilenames.filter(filename => filename < '0010_unified_upstreams.sql').toSorted()) { applySqlJsFile(db, filename); } return db; }; const applySqlJsFile = (db: SqlJsDatabase, filename: string): void => { - const sql = migrationSqlByFilename.get(filename); - if (!sql) throw new Error(`Missing migration SQL fixture: ${filename}`); + const sql = migrationSqlByFilename.find(([candidate]) => candidate === filename)?.[1]; + if (sql === undefined) throw new Error(`Missing migration SQL fixture: ${filename}`); db.run(sql); }; @@ -1093,3 +1066,256 @@ const seedLegacyUpstreamData = (db: SqlJsDatabase): void => { ('2026-05-21T01', 'request_total', 'key', 'gpt-5.4', 'copilot:999', 'gpt-5.4', 'messages', 'responses', 1, 'unknown', 0, 142, 1);`, ); }; + +const sqlString = (value: string): string => `'${value.replaceAll("'", "''")}'`; + +const inputRates = (input: number, output?: number) => ({ + input_tokens: divideDecimalString(String(input), '1000000'), + input_cache_read_tokens: divideDecimalString(String(input), '1000000'), + input_cache_write_tokens: divideDecimalString(String(input), '1000000'), + input_cache_write_1h_tokens: divideDecimalString(String(input), '1000000'), + input_image_tokens: divideDecimalString(String(input), '1000000'), + ...(output === undefined ? {} : { + output_tokens: divideDecimalString(String(output), '1000000'), + output_image_tokens: divideDecimalString(String(output), '1000000'), + }), +}); + +const tokenPricing = (entries: ModelPricing['entries']): ModelPricing => ({ entries }); + +test('model pricing migrations materialize legacy semantics as base-unit metric rates', async () => { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + for (const [filename, sql] of migrationSqlByFilename) { + if (filename === '0054_model_pricing.sql') { + const legacyKey = ['co', 'st'].join(''); + const configJson = JSON.stringify({ + models: [ + { + upstreamModelId: 'base-and-overlay', + [legacyKey]: { input: 1, output: 4, tiers: { priority: { input: 2 } } }, + }, + { + upstreamModelId: 'cache-overrides', + [legacyKey]: { + input: 1, + input_cache_read: 0.1, + input_cache_write: 1.25, + output: 4, + tiers: { fast: { input: 2, input_cache_write: 3 } }, + }, + }, + { + upstreamModelId: 'tiny-rate', + [legacyKey]: { input: 1e-20 }, + }, + { + upstreamModelId: 'tier-adds-output', + [legacyKey]: { input: 1, tiers: { priority: { output: 8 } } }, + }, + { + upstreamModelId: 'tier-only', + [legacyKey]: { tiers: { priority: { input: 2 } } }, + }, + { + upstreamModelId: 'empty-tier', + [legacyKey]: { input: 1, tiers: { priority: {} } }, + }, + { + upstreamModelId: 'empty-rates', + [legacyKey]: { tiers: { priority: {} } }, + }, + { + upstreamModelId: 'write-and-output-only', + [legacyKey]: { input_cache_write: 1.25, output: 4 }, + }, + { + upstreamModelId: 'zero-input', + [legacyKey]: { input: 0, tiers: { priority: { input: 2 } } }, + }, + { + upstreamModelId: 'tier-order', + [legacyKey]: { input: 1, tiers: { priority: { input: 2 }, flex: { input: 0.5 } } }, + }, + { + upstreamModelId: 'base-equivalent-tiers', + [legacyKey]: { + input: 1, + tiers: { + default: { input: 2 }, + '\tDefault\n': { input: 3 }, + '\u00a0standard\u00a0': { output: 8 }, + '\u3000default\u3000': { input: 4 }, + '\t\n': { input: 5 }, + }, + }, + }, + { + upstreamModelId: 'base-equivalent-tier-only', + [legacyKey]: { tiers: { Standard: { input: 2 } } }, + }, + { upstreamModelId: 'unpriced', display_name: 'Unpriced' }, + ], + }); + db.run( + `INSERT INTO upstreams (id, provider, name, created_at, updated_at, config_json) + VALUES ('up_pricing', 'custom', 'Pricing migration', '2026-07-13T00:00:00.000Z', '2026-07-13T00:00:00.000Z', ${sqlString(configJson)})`, + ); + } + db.run(sql); + } + + const [configResult] = db.exec("SELECT config_json FROM upstreams WHERE id = 'up_pricing'"); + const config = JSON.parse(configResult!.values[0]![0] as string) as { + models: { upstreamModelId: string; display_name?: string; pricing?: ModelPricing }[]; + }; + assertEquals(config, { + models: [ + { + upstreamModelId: 'base-and-overlay', + pricing: tokenPricing([ + { rates: inputRates(1, 4) }, + { selector: { serviceTier: 'priority' }, rates: inputRates(2, 4) }, + ]), + }, + { + upstreamModelId: 'cache-overrides', + pricing: tokenPricing([ + { + rates: { + input_tokens: '0.000001', + input_cache_read_tokens: '0.0000001', + input_cache_write_tokens: '0.00000125', + input_cache_write_1h_tokens: '0.00000125', + input_image_tokens: '0.000001', + output_tokens: '0.000004', + output_image_tokens: '0.000004', + }, + }, + { + selector: { serviceTier: 'fast' }, + rates: { + input_tokens: '0.000002', + input_cache_read_tokens: '0.0000001', + input_cache_write_tokens: '0.000003', + input_cache_write_1h_tokens: '0.000003', + input_image_tokens: '0.000002', + output_tokens: '0.000004', + output_image_tokens: '0.000004', + }, + }, + ]), + }, + { + upstreamModelId: 'tiny-rate', + pricing: tokenPricing([{ + rates: { + input_tokens: '0.00000000000000000000000001', + input_cache_read_tokens: '0.00000000000000000000000001', + input_cache_write_tokens: '0.00000000000000000000000001', + input_cache_write_1h_tokens: '0.00000000000000000000000001', + input_image_tokens: '0.00000000000000000000000001', + }, + }]), + }, + { + upstreamModelId: 'tier-adds-output', + pricing: tokenPricing([ + { rates: inputRates(1, 0) }, + { selector: { serviceTier: 'priority' }, rates: inputRates(1, 8) }, + ]), + }, + { + upstreamModelId: 'tier-only', + pricing: tokenPricing([ + { rates: inputRates(0) }, + { selector: { serviceTier: 'priority' }, rates: inputRates(2) }, + ]), + }, + { + upstreamModelId: 'empty-tier', + pricing: tokenPricing([ + { rates: inputRates(1) }, + { selector: { serviceTier: 'priority' }, rates: inputRates(1) }, + ]), + }, + { upstreamModelId: 'empty-rates' }, + { + upstreamModelId: 'write-and-output-only', + pricing: tokenPricing([{ rates: { input_cache_write_tokens: '0.00000125', input_cache_write_1h_tokens: '0.00000125', output_tokens: '0.000004', output_image_tokens: '0.000004' } }]), + }, + { + upstreamModelId: 'zero-input', + pricing: tokenPricing([ + { rates: inputRates(0) }, + { selector: { serviceTier: 'priority' }, rates: inputRates(2) }, + ]), + }, + { + upstreamModelId: 'tier-order', + pricing: tokenPricing([ + { rates: inputRates(1) }, + { selector: { serviceTier: 'priority' }, rates: inputRates(2) }, + { selector: { serviceTier: 'flex' }, rates: inputRates(0.5) }, + ]), + }, + { + upstreamModelId: 'base-equivalent-tiers', + pricing: tokenPricing([{ rates: inputRates(1) }]), + }, + { upstreamModelId: 'base-equivalent-tier-only' }, + { upstreamModelId: 'unpriced', display_name: 'Unpriced' }, + ], + }); + + for (const model of config.models) { + if (!model.pricing) continue; + validateModelPricing(model.pricing); + const base = model.pricing.entries.find(entry => entry.selector === undefined)!.rates; + assertEquals(priceRequest(model.pricing, { inputTokens: 1, serviceTier: 'unknown' }), { selector: {}, rates: base }); + } +}); + +test('model pricing migration preserves every digit in current numeric rate lexemes', async () => { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + for (const [filename, sql] of migrationSqlByFilename) { + if (filename === '0062_usage_billing_metrics.sql') { + const configJson = '{"models":[{"upstreamModelId":"precise-rate","pricing":{"entries":[{"rates":{"input":0.12345678901234566,"output":1e-20,"input_cache_read":9223372036854775807,"input_cache_write":1e-324}}]}}]}'; + db.run( + `INSERT INTO upstreams (id, provider, name, created_at, updated_at, config_json) + VALUES ('up_precise_pricing', 'custom', 'Precise pricing', '2026-07-13T00:00:00.000Z', '2026-07-13T00:00:00.000Z', ${sqlString(configJson)})`, + ); + db.run(sql); + break; + } + db.run(sql); + } + + const row = db.exec("SELECT config_json FROM upstreams WHERE id = 'up_precise_pricing'")[0]!.values[0]![0] as string; + assertEquals(JSON.parse(row).models[0].pricing.entries[0].rates, { + input_tokens: '0.00000012345678901234566', + output_tokens: '0.00000000000000000000000001', + input_cache_read_tokens: '9223372036854.775807', + input_cache_write_tokens: `0.${'0'.repeat(329)}1`, + }); +}); + +test('model pricing migration rejects malformed, negative, and non-finite legacy rates', async () => { + for (const invalidRateJson of ['"not-a-price"', 'null', 'true', '-1', '1e999', '1e-400']) { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + for (const [filename, sql] of migrationSqlByFilename) { + if (filename === '0062_usage_billing_metrics.sql') { + const configJson = `{"models":[{"upstreamModelId":"invalid-rate","pricing":{"entries":[{"rates":{"input":${invalidRateJson}}]}}]}`; + db.run( + `INSERT INTO upstreams (id, provider, name, created_at, updated_at, config_json) + VALUES ('up_invalid_pricing', 'custom', 'Invalid pricing', '2026-07-13T00:00:00.000Z', '2026-07-13T00:00:00.000Z', ${sqlString(configJson)})`, + ); + assertThrows(() => db.run(sql), Error, 'malformed JSON'); + break; + } + db.run(sql); + } + } +}); diff --git a/packages/gateway/src/shared/is-production-request.ts b/packages/gateway/src/runtime/is-production-request.ts similarity index 100% rename from packages/gateway/src/shared/is-production-request.ts rename to packages/gateway/src/runtime/is-production-request.ts diff --git a/packages/gateway/src/scheduled.ts b/packages/gateway/src/scheduled.ts index 7c060586cc..54660842a0 100644 --- a/packages/gateway/src/scheduled.ts +++ b/packages/gateway/src/scheduled.ts @@ -1,5 +1,5 @@ -import { sweepExpirations } from './repo/expiration-sweeps.ts'; -import { collectSpilledFiles } from './repo/spilled-files.ts'; +import { sweepExpirations } from './scheduled/expiration-sweeps.ts'; +import { collectSpilledFiles } from './scheduled/spilled-files.ts'; import { getImageCacheStore } from '@floway-dev/platform'; const runSweep = async (name: string, fn: () => Promise): Promise => { diff --git a/packages/gateway/src/repo/expiration-sweeps.ts b/packages/gateway/src/scheduled/expiration-sweeps.ts similarity index 95% rename from packages/gateway/src/repo/expiration-sweeps.ts rename to packages/gateway/src/scheduled/expiration-sweeps.ts index 1d9f82fac2..0c96137d77 100644 --- a/packages/gateway/src/repo/expiration-sweeps.ts +++ b/packages/gateway/src/scheduled/expiration-sweeps.ts @@ -1,7 +1,7 @@ -import { getRepo } from './index.ts'; -import { RESPONSES_REFRESH_GRANULARITY_MS } from './responses-retention.ts'; -import type { ExpirationDomain, ExpirationSweepCompletion } from './types.ts'; import { getDumpStore } from '../dump/registry.ts'; +import { getRepo } from '../repo/index.ts'; +import { RESPONSES_REFRESH_GRANULARITY_MS } from '../repo/responses-retention.ts'; +import type { ExpirationDomain, ExpirationSweepCompletion } from '../repo/types.ts'; const CLAIM_TIMEOUT_MS = 60 * 60 * 1000; const ERROR_RETRY_MS = 60 * 1000; diff --git a/packages/gateway/src/repo/expiration-sweeps_test.ts b/packages/gateway/src/scheduled/expiration-sweeps_test.ts similarity index 97% rename from packages/gateway/src/repo/expiration-sweeps_test.ts rename to packages/gateway/src/scheduled/expiration-sweeps_test.ts index 805a85d3c4..1fa82379d6 100644 --- a/packages/gateway/src/repo/expiration-sweeps_test.ts +++ b/packages/gateway/src/scheduled/expiration-sweeps_test.ts @@ -1,16 +1,16 @@ import initSqlJs from 'sql.js'; import { afterEach, expect, test, vi } from 'vitest'; -import { FileDumpStore } from './dump-store.ts'; import { sweepExpirations } from './expiration-sweeps.ts'; -import { initRepo } from './index.ts'; -import { InMemoryRepo } from './memory.ts'; -import { quantizeResponsesRefreshedAt, RESPONSES_REFRESH_GRANULARITY_MS } from './responses-retention.ts'; -import { SqlRepo } from './sql.ts'; -import { createSqliteTestDb, migrationSqlByFilename } from './test-sqlite.ts'; -import type { ApiKey, StoredResponsesItem } from './types.ts'; import { initDumpStore } from '../dump/registry.ts'; import type { DumpWriteRecord } from '../dump/types.ts'; +import { FileDumpStore } from '../repo/dump-store.ts'; +import { initRepo } from '../repo/index.ts'; +import { InMemoryRepo } from '../repo/memory.ts'; +import { quantizeResponsesRefreshedAt, RESPONSES_REFRESH_GRANULARITY_MS } from '../repo/responses-retention.ts'; +import { SqlRepo } from '../repo/sql.ts'; +import { createSqliteTestDb, migrationSqlByFilename } from '../repo/test-sqlite.ts'; +import type { ApiKey, StoredResponsesItem } from '../repo/types.ts'; import { initFileStore, MemoryFileStore } from '@floway-dev/platform'; afterEach(() => vi.useRealTimers()); diff --git a/packages/gateway/src/repo/spilled-files.ts b/packages/gateway/src/scheduled/spilled-files.ts similarity index 94% rename from packages/gateway/src/repo/spilled-files.ts rename to packages/gateway/src/scheduled/spilled-files.ts index b7dd6bc05f..5f2d2fe69d 100644 --- a/packages/gateway/src/repo/spilled-files.ts +++ b/packages/gateway/src/scheduled/spilled-files.ts @@ -1,4 +1,4 @@ -import { getRepo } from './index.ts'; +import { getRepo } from '../repo/index.ts'; import { getFileStore } from '@floway-dev/platform'; const CLAIM_TIMEOUT_MS = 60 * 60 * 1000; diff --git a/packages/gateway/src/scheduled_test.ts b/packages/gateway/src/scheduled_test.ts index d34ac24c64..47a5857fe7 100644 --- a/packages/gateway/src/scheduled_test.ts +++ b/packages/gateway/src/scheduled_test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { runScheduledMaintenance } from './scheduled.ts'; -import { setupAppTest } from './test-helpers.ts'; +import { setupAppTest } from './test-utils/app.ts'; import { initFileStore, initImageCacheStore, MemoryFileStore } from '@floway-dev/platform'; test('scheduled maintenance isolates the shared expiration driver from later collectors', async () => { diff --git a/packages/gateway/src/shared/web-search-providers.ts b/packages/gateway/src/shared/web-search-providers.ts index 9b11a91d74..cecbfddfae 100644 --- a/packages/gateway/src/shared/web-search-providers.ts +++ b/packages/gateway/src/shared/web-search-providers.ts @@ -2,7 +2,7 @@ export const WEB_SEARCH_PROVIDER_NAMES = ['tavily', 'microsoft-grounding', 'jina export type WebSearchProviderName = (typeof WEB_SEARCH_PROVIDER_NAMES)[number]; -export interface SearchConfig { +export interface WebSearchConfig { provider: 'disabled' | WebSearchProviderName; tavily: { apiKey: string }; microsoftGrounding: { apiKey: string }; diff --git a/packages/gateway/src/test-helpers.ts b/packages/gateway/src/test-utils/app.ts similarity index 94% rename from packages/gateway/src/test-helpers.ts rename to packages/gateway/src/test-utils/app.ts index 1bf7469bed..2f83e580f7 100644 --- a/packages/gateway/src/test-helpers.ts +++ b/packages/gateway/src/test-utils/app.ts @@ -1,11 +1,11 @@ -import { app } from './app.ts'; -import { clearInFlightForTesting } from './data-plane/providers/models-cache.ts'; -import type { SearchConfig } from './data-plane/tools/web-search/types.ts'; -import { initRepo } from './repo/index.ts'; -import { InMemoryRepo } from './repo/memory.ts'; -import type { ApiKey } from './repo/types.ts'; -import { initBackgroundSchedulerResolver } from './runtime/background.ts'; -import { trackBackground } from './test-helpers/background-tracker.ts'; +import { app } from '../app.ts'; +import { trackBackground } from './background-tracker.ts'; +import { clearInFlightForTesting } from '../data-plane/providers/models-cache.ts'; +import type { WebSearchConfig } from '../data-plane/tools/web-search/types.ts'; +import { initRepo } from '../repo/index.ts'; +import { InMemoryRepo } from '../repo/memory.ts'; +import type { ApiKey } from '../repo/types.ts'; +import { initBackgroundSchedulerResolver } from '../runtime/background.ts'; import { createInMemoryImageProcessor, initEnv, initExternalResourceFetcher, initFileStore, initImageProcessor, MemoryFileStore } from '@floway-dev/platform'; import type { UpstreamRecord } from '@floway-dev/provider'; import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; @@ -20,7 +20,7 @@ interface SetupOptions { apiKey?: ApiKey; githubAccount?: CopilotAccountFixture; copilotUpstream?: UpstreamRecord; - searchConfig?: SearchConfig; + webSearchConfig?: WebSearchConfig; } interface AppTestContext { @@ -110,7 +110,7 @@ export async function setupAppTest(options: SetupOptions = {}): Promise trackBackground); const adminKey = 'adminKey' in options ? options.adminKey : 'admin-test-key'; @@ -161,8 +161,8 @@ export async function setupAppTest(options: SetupOptions = {}): Promise, preBuffered: Uint8Array, ): Promise => { - let buffer = preBuffered; - // Cap accumulation so a misbehaving upstream that streams headers // forever can't exhaust the runtime's heap. 64 KiB is two orders of // magnitude over any sane response-header block. const HEADER_BUFFER_CAP = 64 * 1024; - let headerEnd = findDoubleCrlfFrom(buffer, 0); - while (headerEnd < 0) { - // Resume from the last position where a partial terminator could have - // started straddling the seam — three bytes back covers `CR LF CR ?` - // landing across the read boundary. Without this resume index the - // per-read scan is O(n) on the whole buffer, turning a 1-byte drip - // up to HEADER_BUFFER_CAP into O(n²). - const scanFrom = Math.max(0, buffer.byteLength - 3); - const { value, done } = await reader.read(); - if (done) { - throw new HttpProtocolError( - `unexpected EOF before headers; got ${buffer.byteLength} bytes`, - 'EOF', - ); - } - buffer = concat(buffer, value); - headerEnd = findDoubleCrlfFrom(buffer, scanFrom); - if (headerEnd < 0 && buffer.byteLength > HEADER_BUFFER_CAP) { - throw new HttpProtocolError( - `HTTP/1.1 response headers exceeded ${HEADER_BUFFER_CAP} bytes without a terminator`, - 'HEADER_BUFFER_OVERFLOW', - ); - } - } - - const headerBytes = buffer.subarray(0, headerEnd); - const remainder = copy(buffer.subarray(headerEnd + 4)); - - const headerText = decodeAsciiHeaderSection(headerBytes, 'response headers'); - const lines = headerText.split('\r\n'); - const statusLine = lines.shift()!; + const { statusLine, lines, remainder } = await readHeadSection(reader, preBuffered, { + maxBytes: HEADER_BUFFER_CAP, + decodeContext: 'response headers', + eofError: receivedBytes => new HttpProtocolError( + `unexpected EOF before headers; got ${receivedBytes} bytes`, + 'EOF', + ), + overflowError: maxBytes => new HttpProtocolError( + `HTTP/1.1 response headers exceeded ${maxBytes} bytes without a terminator`, + 'HEADER_BUFFER_OVERFLOW', + ), + }); // RFC 9112 §4: status-line = HTTP-version SP status-code SP reason-phrase. // Two distinct issues to call out separately for a useful error message: // (1) the line MUST start with HTTP/1.0 or HTTP/1.1 — llhttp dispatches diff --git a/packages/http/src/parser_test.ts b/packages/http/src/parser_test.ts index 60c03240b4..dabfee3a1b 100644 --- a/packages/http/src/parser_test.ts +++ b/packages/http/src/parser_test.ts @@ -623,22 +623,6 @@ describe('parseHttpResponse — DoS caps', () => { }); }); - it('rejects a single header that grows past the 64 KiB header buffer', async () => { - const fake = makeFakeDuplex(); - fake.respond('HTTP/1.1 200 OK\r\nX-Big: '); - fake.respond('a'.repeat(70 * 1024)); - fake.endResponse(); - await expect(parseHttpResponse(fake.readable)).rejects.toMatchObject({ - code: 'HEADER_BUFFER_OVERFLOW', - }); - }); - - it('rejects EOF before the header terminator with code EOF', async () => { - await expect(parseHttpResponse(respondAndEnd('HTTP/1.1 200 OK\r\nContent-Type:'))).rejects.toMatchObject({ - code: 'EOF', - }); - }); - it('accepts a response with zero headers', async () => { const r = await parseHttpResponse(respondAndEnd('HTTP/1.1 200 OK\r\n\r\n')); expect(r.status).toBe(200); diff --git a/packages/http/src/read-head-section.ts b/packages/http/src/read-head-section.ts new file mode 100644 index 0000000000..784a034bae --- /dev/null +++ b/packages/http/src/read-head-section.ts @@ -0,0 +1,40 @@ +import { concat, copy, findDoubleCrlfFrom } from './bytes.ts'; +import { decodeAsciiHeaderSection } from './grammar.ts'; + +interface ReadHeadSectionOptions { + maxBytes: number; + decodeContext: string; + eofError: (receivedBytes: number) => Error; + overflowError: (maxBytes: number) => Error; +} + +interface HeadSection { + statusLine: string; + lines: string[]; + remainder: Uint8Array; +} + +export const readHeadSection = async ( + reader: ReadableStreamDefaultReader, + preBuffered: Uint8Array, + options: ReadHeadSectionOptions, +): Promise => { + let buffer = preBuffered; + let headerEnd = findDoubleCrlfFrom(buffer, 0); + while (headerEnd < 0) { + const scanFrom = Math.max(0, buffer.byteLength - 3); + const { value, done } = await reader.read(); + if (done) throw options.eofError(buffer.byteLength); + buffer = concat(buffer, value); + headerEnd = findDoubleCrlfFrom(buffer, scanFrom); + if (headerEnd < 0 && buffer.byteLength > options.maxBytes) { + throw options.overflowError(options.maxBytes); + } + } + + const headerBytes = buffer.subarray(0, headerEnd); + const remainder = copy(buffer.subarray(headerEnd + 4)); + const lines = decodeAsciiHeaderSection(headerBytes, options.decodeContext).split('\r\n'); + const statusLine = lines.shift()!; + return { statusLine, lines, remainder }; +}; diff --git a/packages/http/src/read-head-section_test.ts b/packages/http/src/read-head-section_test.ts new file mode 100644 index 0000000000..ffcb183458 --- /dev/null +++ b/packages/http/src/read-head-section_test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { HttpProtocolError } from './errors.ts'; +import { readHeadSection } from './read-head-section.ts'; +import { makeFakeDuplex, respondAndEnd } from './test-utils.ts'; + +const HEADER_BUFFER_CAP = 64 * 1024; + +const readResponseHeadSection = async ( + readable: ReadableStream, +): ReturnType => { + const reader = readable.getReader(); + try { + return await readHeadSection(reader, new Uint8Array(0), { + maxBytes: HEADER_BUFFER_CAP, + decodeContext: 'response headers', + eofError: receivedBytes => new HttpProtocolError( + `unexpected EOF before headers; got ${receivedBytes} bytes`, + 'EOF', + ), + overflowError: maxBytes => new HttpProtocolError( + `HTTP/1.1 response headers exceeded ${maxBytes} bytes without a terminator`, + 'HEADER_BUFFER_OVERFLOW', + ), + }); + } finally { + reader.releaseLock(); + } +}; + +describe('readHeadSection', () => { + it('rejects a head that grows past the configured buffer limit', async () => { + const fake = makeFakeDuplex(); + fake.respond('HTTP/1.1 200 OK\r\nX-Big: '); + fake.respond('a'.repeat(70 * 1024)); + fake.endResponse(); + await expect(readResponseHeadSection(fake.readable)).rejects.toMatchObject({ + code: 'HEADER_BUFFER_OVERFLOW', + }); + }); + + it('rejects EOF before the head terminator', async () => { + await expect( + readResponseHeadSection(respondAndEnd('HTTP/1.1 200 OK\r\nContent-Type:')), + ).rejects.toMatchObject({ code: 'EOF' }); + }); +}); diff --git a/packages/http/src/ws-upgrade.ts b/packages/http/src/ws-upgrade.ts index 67d26728fc..8bb4354278 100644 --- a/packages/http/src/ws-upgrade.ts +++ b/packages/http/src/ws-upgrade.ts @@ -11,9 +11,10 @@ import { sha1 } from '@noble/hashes/legacy.js'; import { signalAbortReason } from './abort.ts'; -import { base64EncodeBytes, concat, copy, findDoubleCrlfFrom, utf8Bytes } from './bytes.ts'; +import { base64EncodeBytes, concat, copy, utf8Bytes } from './bytes.ts'; import { HttpProtocolError } from './errors.ts'; -import { decodeAsciiHeaderSection, STATUS_LINE, TCHAR, trimFieldValueOws, validateFieldValueBytes, validateRequestTargetBytes } from './grammar.ts'; +import { STATUS_LINE, TCHAR, trimFieldValueOws, validateFieldValueBytes, validateRequestTargetBytes } from './grammar.ts'; +import { readHeadSection } from './read-head-section.ts'; import type { DuplexStream } from './types.ts'; export interface WsUpgradeOptions { @@ -220,34 +221,18 @@ interface UpgradeResponseHead { const readUpgradeResponse = async ( reader: ReadableStreamDefaultReader, ): Promise => { - let buffer = new Uint8Array(0); - let headerEnd = -1; - while (headerEnd < 0) { - // Resume from the last position where a partial terminator could have - // started straddling the seam — mirrors the parser.ts readResponseHead - // scan-from index so a drip-fed upgrade response stays O(n). - const scanFrom = Math.max(0, buffer.byteLength - 3); - const { value, done } = await reader.read(); - if (done) { - throw new HttpProtocolError( - `WS upgrade: unexpected EOF before response head; got ${buffer.byteLength} bytes`, - 'EOF', - ); - } - buffer = concat(buffer, value); - headerEnd = findDoubleCrlfFrom(buffer, scanFrom); - if (headerEnd < 0 && buffer.byteLength > WS_HEAD_BUFFER_CAP) { - throw new HttpProtocolError( - `WS upgrade response head exceeded ${WS_HEAD_BUFFER_CAP} bytes without a terminator`, - 'HEADER_BUFFER_OVERFLOW', - ); - } - } - const headBytes = buffer.subarray(0, headerEnd); - const remainder = copy(buffer.subarray(headerEnd + 4)); - const text = decodeAsciiHeaderSection(headBytes, 'WS upgrade response head'); - const lines = text.split('\r\n'); - const statusLine = lines.shift()!; + const { statusLine, lines, remainder } = await readHeadSection(reader, new Uint8Array(0), { + maxBytes: WS_HEAD_BUFFER_CAP, + decodeContext: 'WS upgrade response head', + eofError: receivedBytes => new HttpProtocolError( + `WS upgrade: unexpected EOF before response head; got ${receivedBytes} bytes`, + 'EOF', + ), + overflowError: maxBytes => new HttpProtocolError( + `WS upgrade response head exceeded ${maxBytes} bytes without a terminator`, + 'HEADER_BUFFER_OVERFLOW', + ), + }); // RFC 6455 §4.1: the upgrade response is HTTP/1.1; its status code MUST // be 101. We surface non-101 verbatim so the caller can include the // server's reason phrase in a debug log. diff --git a/packages/http/tsconfig.json b/packages/http/tsconfig.json index 824d57c3c4..3b64db1d69 100644 --- a/packages/http/tsconfig.json +++ b/packages/http/tsconfig.json @@ -1,7 +1,4 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { - "types": ["node"] - }, - "include": ["src/**/*.ts"] + "include": ["vitest.config.ts", "src/**/*.ts"] } diff --git a/packages/interceptor/src/index.ts b/packages/interceptor/src/index.ts index bcea09dbfd..47fe35a43b 100644 --- a/packages/interceptor/src/index.ts +++ b/packages/interceptor/src/index.ts @@ -1,21 +1,21 @@ -// Around-middleware for wrapping a single typed call. Each interceptor receives -// the call's context, the in-flight request, and a `run` to delegate to the -// next interceptor (the innermost run executes the call itself). Interceptors -// may inspect/mutate the request before `run`, await `run` and transform the +// Interceptors wrap a single typed call. Each interceptor receives the call's +// context, its invocation envelope, and a `run` to delegate to the next +// interceptor (the innermost run executes the call itself). Interceptors may +// inspect or mutate the envelope before `run`, await `run` and transform the // result, short-circuit by returning without calling `run`, or retry by -// invoking `run` again. The shape is intentionally generic in Ctx/Req/Result so +// invoking `run` again. The shape is intentionally generic in Ctx/Env/Result so // it works for any kind of call — provider-side wire shaping, source-side // translation, retry policy — wired by the caller into concrete chains. // // ## Mutation convention // -// Mutations applied to `ctx` or `request` before `run()` propagate forward -// through every downstream interceptor and into the terminal call. They are -// **one-way**: the interceptor that wrote a field does not restore it on the -// way out, and the framework does not snapshot/rewind state for it. Whatever -// consumes the chain's output post-run (the caller that invoked -// `runInterceptors`, an outer interceptor's after-`run()` code) must keep -// its own captured copy of any input it still needs. +// Mutations applied to `ctx` or `env` before `run()` propagate forward through +// every downstream interceptor and into the terminal call. They are **one-way**: +// the interceptor that wrote a field does not restore it on the way out, and +// the framework does not snapshot/rewind state for it. Whatever consumes the +// chain's output post-run (the caller that invoked `runInterceptors`, an outer +// interceptor's after-`run()` code) must keep its own captured copy of any input +// it still needs. // // The convention exists because partial adoption is the worst case: if some // interceptors restore and others don't, there is no honest invariant the @@ -28,14 +28,14 @@ // writes `ctx.foo = bar` in `try` and `ctx.foo = original` in `finally` is a // convention violation, not a feature. export type InterceptorRun = () => Promise; -export type Interceptor = (ctx: Ctx, request: Req, run: InterceptorRun) => Promise; +export type Interceptor = (ctx: Ctx, env: Env, run: InterceptorRun) => Promise; -export const runInterceptors = async ( +export const runInterceptors = async ( ctx: Ctx, - request: Req, - interceptors: readonly Interceptor[], + env: Env, + interceptors: readonly Interceptor[], terminal: InterceptorRun, ): Promise => { - const run = (index: number): Promise => (index < interceptors.length ? interceptors[index](ctx, request, () => run(index + 1)) : terminal()); + const run = (index: number): Promise => (index < interceptors.length ? interceptors[index](ctx, env, () => run(index + 1)) : terminal()); return await run(0); }; diff --git a/packages/interceptor/src/index_test.ts b/packages/interceptor/src/index_test.ts index 1ead0ce5fa..f6e079409d 100644 --- a/packages/interceptor/src/index_test.ts +++ b/packages/interceptor/src/index_test.ts @@ -4,18 +4,18 @@ import { type Interceptor, runInterceptors } from './index.ts'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; type TestCtx = { payload: { value: string } }; -type TestRequest = { traceId: string }; +type TestEnv = { traceId: string }; test('composes interceptors outermost-first and unwinds epilogues inside-out', async () => { const calls: string[] = []; - const outer: Interceptor = async (_ctx, _request, run) => { + const outer: Interceptor = async (_ctx, _env, run) => { calls.push('outer-before'); const result = await run(); calls.push('outer-after'); return result; }; - const inner: Interceptor = async (_ctx, _request, run) => { + const inner: Interceptor = async (_ctx, _env, run) => { calls.push('inner-before'); const result = await run(); calls.push('inner-after'); @@ -34,7 +34,7 @@ test('lets an interceptor retry by calling run() again — each call reruns the const ctx: TestCtx = { payload: { value: 'broken' } }; let attempts = 0; - const interceptor: Interceptor = async (current, _request, run) => { + const interceptor: Interceptor = async (current, _env, run) => { const first = await run(); if (first !== 'fail') return first; current.payload.value = 'fixed'; @@ -53,7 +53,7 @@ test('lets an interceptor retry by calling run() again — each call reruns the test('propagates an inner throw past each enclosing run() call site without swallowing', async () => { const seen: string[] = []; - const wrap = (label: string): Interceptor => async (_ctx, _request, run) => { + const wrap = (label: string): Interceptor => async (_ctx, _env, run) => { seen.push(`${label}-before`); try { return await run(); @@ -75,7 +75,7 @@ test('propagates an inner throw past each enclosing run() call site without swal test('lets an interceptor patch context before run and transform the result after run', async () => { const ctx: TestCtx = { payload: { value: 'original' } }; - const interceptor: Interceptor = async (current, _request, run) => { + const interceptor: Interceptor = async (current, _env, run) => { current.payload.value = 'patched'; const result = await run(); return `${result}:${current.payload.value}`; diff --git a/packages/interceptor/tsconfig.json b/packages/interceptor/tsconfig.json index 9e25e6ece9..3b64db1d69 100644 --- a/packages/interceptor/tsconfig.json +++ b/packages/interceptor/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src/**/*.ts"] + "include": ["vitest.config.ts", "src/**/*.ts"] } diff --git a/packages/platform/src/background.ts b/packages/platform/src/background.ts index bc3e8af568..d65d2ee8ea 100644 --- a/packages/platform/src/background.ts +++ b/packages/platform/src/background.ts @@ -1,4 +1,4 @@ -// Per-request scheduler for fire-and-forget work. The resolver lives in -// `@floway-dev/gateway` (it depends on Hono's `Context`); this type is just the -// shape the resolver returns. +// Accepts already-started work that may outlive its originating operation. +// Gateway HTTP requests resolve the host implementation from their context; +// transports with a longer lifetime can supply their own implementation. export type BackgroundScheduler = (promise: Promise) => void; diff --git a/packages/platform/src/image-processor.ts b/packages/platform/src/image-processor.ts index 4ecadd2bcb..0fdbd3c73d 100644 --- a/packages/platform/src/image-processor.ts +++ b/packages/platform/src/image-processor.ts @@ -20,10 +20,10 @@ export type ImageSizeCalculator = (source: ImageDimensions) => ImageDimensions; export interface ImageProcessor { // Re-encodes arbitrary raster image bytes to WebP at a fixed internal - // quality, scaled to fit `target` (or untransformed when target is null — - // i.e. when the source dimensions could not be read locally). The target - // is pre-resolved by the caller so each impl stays a pure encoder; the - // "read source dimensions" step lives in compressBytesToWebp below. + // quality, scaled to fit `target` (or encoded at source dimensions when target + // is null, i.e. when the source dimensions could not be read locally). The target + // is pre-resolved by the caller so implementations own only runtime encoding + // and caching, not source inspection or model-specific sizing. compressToWebp(input: Uint8Array, target: ImageDimensions | null): Promise; } @@ -67,8 +67,7 @@ export const getImageProcessor = (): ImageProcessor => { }; // Caller-side convenience that owns the "read source dims → run calculator → -// hand resolved target to the processor" responsibility chain. Each -// ImageProcessor impl stays a pure encoder. +// hand resolved target to the processor" responsibility chain. export const compressBytesToWebp = async ( bytes: Uint8Array, calculator: ImageSizeCalculator, diff --git a/packages/platform/tsconfig.json b/packages/platform/tsconfig.json index b6bc955715..3b64db1d69 100644 --- a/packages/platform/tsconfig.json +++ b/packages/platform/tsconfig.json @@ -1,7 +1,4 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { - "resolveJsonModule": true - }, "include": ["vitest.config.ts", "src/**/*.ts"] } diff --git a/packages/protocols/package.json b/packages/protocols/package.json index a42c204284..b7b05d40c1 100644 --- a/packages/protocols/package.json +++ b/packages/protocols/package.json @@ -4,7 +4,6 @@ "version": "0.0.0", "type": "module", "exports": { - ".": { "import": "./src/index.ts", "types": "./src/index.ts" }, "./common": { "import": "./src/common/index.ts", "types": "./src/common/index.ts" }, "./completions": { "import": "./src/completions/index.ts", "types": "./src/completions/index.ts" }, "./chat-completions": { "import": "./src/chat-completions/index.ts", "types": "./src/chat-completions/index.ts" }, diff --git a/packages/protocols/src/audio/index.ts b/packages/protocols/src/audio/index.ts index d0bebde0d2..ec731330f4 100644 --- a/packages/protocols/src/audio/index.ts +++ b/packages/protocols/src/audio/index.ts @@ -1,41 +1,10 @@ -// OpenAI-compatible audio transcription response shapes. The wire remains -// open so provider additions pass through unchanged; named fields cover usage -// extraction and the stream terminal the gateway itself must observe. -// https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L36378-L36562 +// OpenAI-compatible audio transcription stream terminal. The wire remains open +// so provider additions pass through unchanged while the gateway observes only +// the terminal event it needs. // https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L61780-L61924 -export interface AudioTranscriptionInputTokenDetails { - text_tokens?: number; - audio_tokens?: number; - [key: string]: unknown; -} - -export interface AudioTranscriptionTokenUsage { - type: 'tokens'; - input_tokens: number; - input_token_details?: AudioTranscriptionInputTokenDetails; - output_tokens: number; - total_tokens: number; - [key: string]: unknown; -} - -export interface AudioTranscriptionDurationUsage { - type: 'duration'; - seconds: number; - [key: string]: unknown; -} - -export type AudioTranscriptionUsage = AudioTranscriptionTokenUsage | AudioTranscriptionDurationUsage; - -export interface AudioTranscriptionResponse { - duration?: number; - usage?: AudioTranscriptionUsage; - [key: string]: unknown; -} - export interface AudioTranscriptionStreamEvent { type: string; - usage?: AudioTranscriptionUsage; [key: string]: unknown; } diff --git a/packages/protocols/src/chat-completions/stream.ts b/packages/protocols/src/chat-completions/stream.ts index 0175935ab8..d5424007d9 100644 --- a/packages/protocols/src/chat-completions/stream.ts +++ b/packages/protocols/src/chat-completions/stream.ts @@ -1,8 +1,8 @@ import { chatCompletionsErrorPayloadMessage } from './errors.ts'; import type { ChatCompletionsStreamEvent } from './index.ts'; +import { parseSSEStream } from '../common/parse-sse.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '../common/sse.ts'; import { parseTargetStreamFrames } from '../common/stream/parse-events.ts'; -import { parseSSEStream } from '../common/stream/parse-sse.ts'; export interface ParseChatCompletionsStreamOptions { signal?: AbortSignal; diff --git a/packages/protocols/src/common/aliases.ts b/packages/protocols/src/common/aliases.ts index 272f7c1a4d..03475a491b 100644 --- a/packages/protocols/src/common/aliases.ts +++ b/packages/protocols/src/common/aliases.ts @@ -9,7 +9,8 @@ // Resolution runs above prefix routing and never re-enters itself, which // makes recursive aliasing impossible by construction. -import type { ChatModelInfo, ModelKind, PublicModelLimits } from './models.ts'; +import type { ModelKind } from './endpoints.ts'; +import type { ChatModelInfo, PublicModelLimits } from './models.ts'; // Target-picking strategy applied to the pool of currently-routable targets: // diff --git a/packages/protocols/src/common/endpoints.ts b/packages/protocols/src/common/endpoints.ts index 704222b4ad..2c3e25b68c 100644 --- a/packages/protocols/src/common/endpoints.ts +++ b/packages/protocols/src/common/endpoints.ts @@ -1,7 +1,25 @@ // Protocol-level model endpoint types and their intrinsic kind projection. // Provider projection and endpoint dispatch live in packages/gateway/src/data-plane/. -import type { ModelKind } from './models.ts'; +// High-level endpoint-family discriminator. A model belongs to exactly one +// kind; cross-cutting features (vision, function calling, structured +// outputs) are orthogonal and modeled separately when needed. +// +// The initial vocabulary is seeded from Together AI's model type catalog, +// projected onto the endpoint families Floway routes. Together's list remains +// open-ended and uses different names for some families (`transcribe` rather +// than `transcription`, and `language` / `code` where Floway uses `chat`): +// https://github.com/togethercomputer/together-python/blob/b294927e2a3efdd79f95123c630d0520d13ba528/src/together/types/models.py#L10-L20 +// +// Add a value here only when we actually route that endpoint family — do not +// pre-declare future capabilities. +export const MODEL_KINDS = ['chat', 'embedding', 'image', 'rerank', 'transcription'] as const; +export type ModelKind = typeof MODEL_KINDS[number]; + +export const parseModelKind = (value: unknown, label = 'model kind'): ModelKind => { + if (typeof value === 'string' && (MODEL_KINDS as readonly string[]).includes(value)) return value as ModelKind; + throw new Error(`${label} is invalid: ${JSON.stringify(value)}`); +}; // Structured endpoint map. A key being present means the model is served by // that endpoint; its value object carries endpoint-specific metadata, if any. @@ -29,14 +47,11 @@ export interface ModelEndpoints { // addressed by identity rather than as a presence map. export type ModelEndpointKey = keyof ModelEndpoints; -// Derive the high-level model kind from the supported endpoints. Each model -// belongs to exactly one kind. `embeddings` implies embedding, -// `imagesGenerations`/`imagesEdits` implies image, `rerank` implies rerank, -// `audioTranscriptions` implies transcription, and the generation protocols -// imply chat. -// Mixed endpoint sets (e.g. a model tagged with both `embeddings` and -// `chatCompletions`) are configuration errors; the first matching branch wins. -// `kind` is a pure projection of `endpoints`; the dispatch layer never reads it. +// Derive the high-level model kind from the supported endpoints. `embeddings` +// implies embedding, `imagesGenerations`/`imagesEdits` implies image, `rerank` +// implies rerank, `audioTranscriptions` implies transcription, and generation +// protocols imply chat. Mixed endpoint sets use this first-match order for the +// single kind while dispatch continues to narrow on each endpoint's presence. export const kindForEndpoints = (endpoints: ModelEndpoints): ModelKind => { if (endpoints.embeddings) return 'embedding'; if (endpoints.imagesGenerations || endpoints.imagesEdits) return 'image'; diff --git a/packages/protocols/src/common/endpoints_test.ts b/packages/protocols/src/common/endpoints_test.ts index 1d0efd1c7a..e5087fd9f4 100644 --- a/packages/protocols/src/common/endpoints_test.ts +++ b/packages/protocols/src/common/endpoints_test.ts @@ -1,7 +1,13 @@ import { test } from 'vitest'; -import { kindForEndpoints } from './endpoints.ts'; -import { assertEquals } from '@floway-dev/test-utils'; +import { kindForEndpoints, parseModelKind } from './endpoints.ts'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; + +test('parseModelKind accepts endpoint families and rejects unknown storage values', () => { + for (const kind of ['chat', 'embedding', 'image', 'rerank', 'transcription'] as const) assertEquals(parseModelKind(kind), kind); + assertThrows(() => parseModelKind('video'), Error, 'model kind is invalid: "video"'); + assertThrows(() => parseModelKind(null), Error, 'model kind is invalid: null'); +}); test('kindForEndpoints returns image when either images endpoint is present', () => { assertEquals(kindForEndpoints({ imagesGenerations: {} }), 'image'); diff --git a/packages/protocols/src/common/index.ts b/packages/protocols/src/common/index.ts index 9f1fa44b3b..f4a7eef489 100644 --- a/packages/protocols/src/common/index.ts +++ b/packages/protocols/src/common/index.ts @@ -2,11 +2,12 @@ export * from './aliases.ts'; export * from './endpoints.ts'; export * from './decimal.ts'; export * from './models.ts'; +export * from './pricing.ts'; export * from './usage.ts'; export * from './openai-stream.ts'; export * from './opaque-value.ts'; export * from './sse.ts'; -export * from './stream/parse-sse.ts'; +export * from './parse-sse.ts'; export * from './stream/parse-events.ts'; export { isJsonObject, type JsonObject } from './json.ts'; diff --git a/packages/protocols/src/common/models.ts b/packages/protocols/src/common/models.ts index afccb95924..6b150b2e81 100644 --- a/packages/protocols/src/common/models.ts +++ b/packages/protocols/src/common/models.ts @@ -1,428 +1,6 @@ import type { AliasSelection, AliasTarget } from './aliases.ts'; -import { divideDecimalString, parseNonNegativeDecimalString, type DecimalString } from './decimal.ts'; -import type { ModelEndpoints } from './endpoints.ts'; -import { billableServiceTier } from './usage.ts'; - -// Disjoint billing metrics a single request can be charged on. Every count -// keyed by these is non-overlapping: a prompt token is counted under exactly -// one input metric, never several at once. -// -// Bare `input`/`output` preserve an upstream's general counters; they are not -// assumed to be text-only when the upstream does not report modalities -// separately. The `_image` variants are used only for separately metered image -// counters, and adapters keep them disjoint from the corresponding general -// counter. Every metric is priced explicitly; an absent rate leaves that -// metric unpriced. Image cache metrics are absent until an upstream -// exposes disjoint counters that can be recorded without inference. -// -// `input_cache_write` is the generic cache-write bucket — protocols without -// a TTL distinction land all their writes here, and on Anthropic it covers -// the default (5-minute) TTL bucket. `input_cache_write_1h` is the explicit -// 1-hour bucket Anthropic surfaces under -// `cache_creation.ephemeral_1h_input_tokens` (extended-cache-ttl-2025-04-11). -// They are disjoint subsets of `cache_creation_input_tokens`. -export type BillingMetric = 'input_tokens' | 'input_cache_read_tokens' | 'input_cache_write_tokens' | 'input_cache_write_1h_tokens' | 'input_image_tokens' | 'input_audio_tokens' | 'input_audio_seconds' | 'output_tokens' | 'output_image_tokens' | 'rerank_searches'; - -// Iteration form of BillingMetric; the type union is the source of truth. -export const BILLING_METRICS: readonly BillingMetric[] = ['input_tokens', 'input_cache_read_tokens', 'input_cache_write_tokens', 'input_cache_write_1h_tokens', 'input_image_tokens', 'input_audio_tokens', 'input_audio_seconds', 'output_tokens', 'output_image_tokens', 'rerank_searches']; - -export const parseBillingMetric = (value: unknown, label = 'billing metric'): BillingMetric => { - if (typeof value === 'string' && (BILLING_METRICS as readonly string[]).includes(value)) return value as BillingMetric; - throw new TypeError(`${label} is invalid: ${JSON.stringify(value)}`); -}; - -// USD per one base metric unit for one pricing entry. -export type PriceVector = Partial>; - -export type PricingThresholdOperator = 'gt' | 'gte'; - -export interface PricingThresholdCoordinate { - operator: PricingThresholdOperator; - value: number; -} - -export type PricingCoordinateValue = string | PricingThresholdCoordinate; -export type PricingSelector = Readonly>; - -export type PricingRuntimeFacts = Readonly<{ - serviceTier?: string | null; - inputTokens?: number; -}>; - -type PricingRuntimeFactKey = { - [Key in keyof PricingRuntimeFacts]-?: Exclude extends Value ? Key : never; -}[keyof PricingRuntimeFacts] & string; - -export type PricingAxis = - | { id: string; kind: 'equality'; label: string; fact: PricingRuntimeFactKey } - | { id: string; kind: 'threshold'; label: string; fact: PricingRuntimeFactKey }; - -// Each axis binds its authoring metadata to the runtime fact used for request -// projection, so a new registry entry cannot silently remain runtime-inert. -export const PRICING_AXES = [ - { id: 'serviceTier', kind: 'equality', label: 'Service Tier', fact: 'serviceTier' }, - { id: 'inputTokens', kind: 'threshold', label: 'Input Tokens', fact: 'inputTokens' }, -] as const satisfies readonly PricingAxis[]; - -export interface PricingEntry { - selector?: PricingSelector; - rates: PriceVector; -} - -// Per-model pricing as symmetric flat entries. `{ rates }` is the unique Base entry; non-default -// coordinates use the same shape. Threshold bands are implied by selectors -// rather than maintained as a second catalog. An exact selector miss resolves -// to the whole Base vector; rates are never merged or inherited field-by-field -// across entries. -export interface ModelPricing { - entries: readonly PricingEntry[]; -} - -export interface PricedRequest { - selector: PricingSelector; - rates: PriceVector | null; -} - -export type ModelPricingIssue = - | { code: 'empty-catalog'; error: Error } - | { code: 'empty-rates'; entryIndex: number; error: Error } - | { code: 'invalid-rate'; entryIndex: number; metric: BillingMetric; error: Error } - | { code: 'invalid-selector'; entryIndex: number; error: Error } - | { code: 'base-count'; entryIndexes: readonly number[]; error: Error } - | { - code: 'rate-metrics'; - entryIndex: number; - baseIndex: number; - missingMetrics: readonly BillingMetric[]; - addedMetrics: readonly BillingMetric[]; - error: Error; - } - | { code: 'duplicate-selector'; selector: PricingSelector; selectorKey: string; entryIndexes: readonly number[]; error: Error } - | { - code: 'threshold-operator-conflict'; - axisId: string; - value: number; - entryIndexes: readonly [number, number]; - error: Error; - }; - -export const validatePriceVector = (pricing: PriceVector, path = 'price vector'): void => { - const metrics = BILLING_METRICS.filter(metric => pricing[metric] !== undefined); - if (metrics.length === 0) throw new Error(`${path} must contain at least one rate`); - for (const metric of metrics) { - const rate = pricing[metric]!; - const canonical = parseNonNegativeDecimalString(rate, `${path}.${metric}`); - if (canonical !== rate) throw new Error(`${path}.${metric} must be canonical: ${JSON.stringify(canonical)}`); - } -}; - -const axisById = new Map(PRICING_AXES.map(axis => [axis.id, axis])); - -const canonicalThreshold = (value: PricingCoordinateValue, path: string): PricingThresholdCoordinate => { - if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${path} must be a threshold object`); - const unknownKeys = Object.keys(value).filter(key => key !== 'operator' && key !== 'value'); - if (unknownKeys.length > 0) throw new RangeError(`${path} has unknown fields: ${unknownKeys.join(', ')}`); - const { operator, value: threshold } = value; - if (operator !== 'gt' && operator !== 'gte') throw new RangeError(`${path}.operator must be "gt" or "gte"`); - if (!Number.isSafeInteger(threshold) || threshold <= 0) throw new RangeError(`${path}.value must be a positive safe integer`); - return { operator, value: threshold }; -}; - -export const canonicalizePricingSelector = (selector: PricingSelector | undefined): PricingSelector => { - const canonical: Record = {}; - for (const axisId of Object.keys(selector ?? {}).toSorted()) { - const axis = axisById.get(axisId); - if (!axis) throw new RangeError(`unknown pricing selector axis: ${axisId}`); - const value = selector![axisId]; - if (axis.kind === 'equality') { - if (typeof value !== 'string' || value.length === 0) throw new RangeError(`pricing selector ${axisId} must be a non-empty string`); - canonical[axisId] = value; - } else { - canonical[axisId] = canonicalThreshold(value, `pricing selector ${axisId}`); - } - } - return canonical; -}; - -export const canonicalPricingSelectorKey = (selector: PricingSelector | undefined): string => - JSON.stringify(canonicalizePricingSelector(selector)); - -export const parsePricingSelectorKey = (key: string): PricingSelector => { - const parsed: unknown = JSON.parse(key); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new TypeError('pricing selector key must encode an object'); - const selector = canonicalizePricingSelector(parsed as PricingSelector); - if (JSON.stringify(selector) !== key) throw new Error('pricing selector key is not canonical'); - return selector; -}; - -const selectorCoordinatesByKind = (selector: PricingSelector, kind: PricingAxis['kind']): PricingSelector => - Object.fromEntries(Object.entries(selector).filter(([axisId]) => axisById.get(axisId)!.kind === kind)); - -const equalityScopeKey = (selector: PricingSelector): string => - JSON.stringify(selectorCoordinatesByKind(selector, 'equality')); - -const pricingMetrics = (rates: PriceVector): readonly BillingMetric[] => - BILLING_METRICS.filter(metric => rates[metric] !== undefined); - -export const collectModelPricingIssues = (pricing: ModelPricing): readonly ModelPricingIssue[] => { - const issues: ModelPricingIssue[] = []; - if (pricing.entries.length === 0) { - issues.push({ code: 'empty-catalog', error: new Error('model pricing must declare at least one entry') }); - return issues; - } - - const selectors: (PricingSelector | undefined)[] = []; - for (let entryIndex = 0; entryIndex < pricing.entries.length; entryIndex++) { - const entry = pricing.entries[entryIndex]!; - const metrics = pricingMetrics(entry.rates); - if (metrics.length === 0) { - issues.push({ - code: 'empty-rates', - entryIndex, - error: new Error(`model pricing entry ${entryIndex}.rates must contain at least one rate`), - }); - } - for (const metric of metrics) { - const rate = entry.rates[metric]!; - try { - const canonical = parseNonNegativeDecimalString(rate, `model pricing entry ${entryIndex}.rates.${metric}`); - if (canonical !== rate) throw new Error(`model pricing entry ${entryIndex}.rates.${metric} must be canonical: ${JSON.stringify(canonical)}`); - } catch (cause) { - issues.push({ - code: 'invalid-rate', - entryIndex, - metric, - error: cause instanceof Error ? cause : new Error(String(cause)), - }); - } - } - try { - const selector = canonicalizePricingSelector(entry.selector); - if (typeof selector.serviceTier === 'string' && billableServiceTier(selector.serviceTier) === null) { - throw new RangeError('pricing selector serviceTier must not denote Base; omit the coordinate instead'); - } - selectors[entryIndex] = selector; - } catch (cause) { - issues.push({ - code: 'invalid-selector', - entryIndex, - error: cause instanceof Error ? cause : new Error(String(cause)), - }); - } - } - - const baseIndexes = selectors.flatMap((selector, index) => - selector !== undefined && Object.keys(selector).length === 0 ? [index] : []); - if (baseIndexes.length !== 1) { - issues.push({ - code: 'base-count', - entryIndexes: baseIndexes, - error: new Error('model pricing must declare exactly one base entry'), - }); - } - - if (baseIndexes.length === 1) { - const baseIndex = baseIndexes[0]!; - const expectedMetrics = pricingMetrics(pricing.entries[baseIndex]!.rates); - const expectedSet = new Set(expectedMetrics); - for (let entryIndex = 0; entryIndex < pricing.entries.length; entryIndex++) { - if (entryIndex === baseIndex) continue; - const metrics = pricingMetrics(pricing.entries[entryIndex]!.rates); - const metricSet = new Set(metrics); - const missingMetrics = expectedMetrics.filter(metric => !metricSet.has(metric)); - const addedMetrics = metrics.filter(metric => !expectedSet.has(metric)); - if (missingMetrics.length > 0 || addedMetrics.length > 0) { - issues.push({ - code: 'rate-metrics', - entryIndex, - baseIndex, - missingMetrics, - addedMetrics, - error: new Error(`model pricing entry ${entryIndex}.rates must define the same metrics as the base entry (${expectedMetrics.join(', ')})`), - }); - } - } - } - - const selectorIndexesByKey = new Map(); - for (let entryIndex = 0; entryIndex < selectors.length; entryIndex++) { - const selector = selectors[entryIndex]; - if (selector === undefined) continue; - const key = JSON.stringify(selector); - const indexes = selectorIndexesByKey.get(key) ?? []; - indexes.push(entryIndex); - selectorIndexesByKey.set(key, indexes); - } - for (const [selectorKey, entryIndexes] of selectorIndexesByKey) { - if (entryIndexes.length < 2) continue; - issues.push({ - code: 'duplicate-selector', - selector: selectors[entryIndexes[0]!]!, - selectorKey, - entryIndexes, - error: new Error(`duplicate pricing entry selector: ${selectorKey}`), - }); - } - - const selectorKeys = new Set(); - const thresholdOperatorsByScope = new Map>>(); - const operatorsFor = (scopeKey: string, axisId: string): Map => { - const byAxis = thresholdOperatorsByScope.get(scopeKey) ?? new Map>(); - thresholdOperatorsByScope.set(scopeKey, byAxis); - const operators = byAxis.get(axisId) ?? new Map(); - byAxis.set(axisId, operators); - return operators; - }; - for (let entryIndex = 0; entryIndex < selectors.length; entryIndex++) { - const selector = selectors[entryIndex]; - if (selector === undefined) continue; - const key = JSON.stringify(selector); - if (selectorKeys.has(key)) continue; - selectorKeys.add(key); - const scopeKey = equalityScopeKey(selector); - for (const [axisId, coordinate] of Object.entries(selector)) { - if (typeof coordinate === 'string') continue; - const overlappingScopes = scopeKey === '{}' - ? [...thresholdOperatorsByScope.keys()] - : ['{}', scopeKey]; - for (const overlappingScope of overlappingScopes) { - const existing = thresholdOperatorsByScope.get(overlappingScope)?.get(axisId)?.get(coordinate.value); - if (existing !== undefined && existing.operator !== coordinate.operator) { - issues.push({ - code: 'threshold-operator-conflict', - axisId, - value: coordinate.value, - entryIndexes: [existing.entryIndex, entryIndex], - error: new Error(`conflicting pricing threshold operators for ${axisId} at ${coordinate.value} in overlapping equality scopes`), - }); - } - } - operatorsFor(scopeKey, axisId).set(coordinate.value, { operator: coordinate.operator, entryIndex }); - } - } - return issues; -}; - -export const validateModelPricing = (pricing: ModelPricing): void => { - const issue = collectModelPricingIssues(pricing)[0]; - if (issue) throw issue.error; -}; - -interface CompiledModelPricing { - ratesBySelectorKey: ReadonlyMap; - thresholdBandsByAxisAndEqualityScope: ReadonlyMap>; -} - -const compiledPricing = new WeakMap(); - -// Pricing objects are immutable after provider/config construction. Compilation -// validates and canonicalizes once per stable object identity. -const compileModelPricing = (pricing: ModelPricing): CompiledModelPricing => { - const existing = compiledPricing.get(pricing); - if (existing) return existing; - validateModelPricing(pricing); - const ratesBySelectorKey = new Map(); - const bandsByAxisAndEqualityScope = new Map>>(); - for (const entry of pricing.entries) { - const selector = canonicalizePricingSelector(entry.selector); - ratesBySelectorKey.set(JSON.stringify(selector), entry.rates); - for (const axis of PRICING_AXES) { - if (axis.kind !== 'threshold') continue; - const coordinate = selector[axis.id]; - if (typeof coordinate !== 'object') continue; - const scopeKey = equalityScopeKey(selector); - const bandsByScope = bandsByAxisAndEqualityScope.get(axis.id) ?? new Map>(); - bandsByAxisAndEqualityScope.set(axis.id, bandsByScope); - const bands = bandsByScope.get(scopeKey) ?? new Map(); - bands.set(coordinate.value, coordinate); - bandsByScope.set(scopeKey, bands); - } - } - const thresholdBandsByAxisAndEqualityScope = new Map( - [...bandsByAxisAndEqualityScope].map(([axisId, bandsByScope]) => [ - axisId, - new Map([...bandsByScope].map(([scopeKey, bands]) => - [scopeKey, [...bands.values()].toSorted((a, b) => b.value - a.value)] as const)), - ] as const), - ); - const compiled = { ratesBySelectorKey, thresholdBandsByAxisAndEqualityScope }; - compiledPricing.set(pricing, compiled); - return compiled; -}; - -export const pricingEntry = (rates: PriceVector, selector?: PricingSelector): PricingEntry => { - validatePriceVector(rates); - const canonicalSelector = canonicalizePricingSelector(selector); - return { ...(Object.keys(canonicalSelector).length > 0 ? { selector: canonicalSelector } : {}), rates }; -}; -export const modelPricing = (...entries: PricingEntry[]): ModelPricing => { - const pricing: ModelPricing = { entries }; - compileModelPricing(pricing); - return pricing; -}; -export const basePricing = (rates: PriceVector): ModelPricing => modelPricing(pricingEntry(rates)); - -export const perMillionTokenRates = (publishedRates: PriceVector): PriceVector => Object.fromEntries( - Object.entries(publishedRates).map(([metric, price]) => [metric, divideDecimalString(price, '1000000')]), -) as PriceVector; - -export const tokenPricingEntry = (publishedRates: PriceVector, selector?: PricingSelector): PricingEntry => - pricingEntry(perMillionTokenRates(publishedRates), selector); - -export const tokenModelPricing = modelPricing; -export const tokenBasePricing = (publishedRates: PriceVector): ModelPricing => basePricing(perMillionTokenRates(publishedRates)); - -const thresholdMatches = (coordinate: PricingThresholdCoordinate, fact: number): boolean => - coordinate.operator === 'gt' ? fact > coordinate.value : fact >= coordinate.value; - -export const priceRequest = (pricing: ModelPricing | null, facts: PricingRuntimeFacts): PricedRequest => { - const compiled = pricing ? compileModelPricing(pricing) : undefined; - const selector: Record = {}; - for (const axis of PRICING_AXES) { - if (axis.kind !== 'equality') continue; - const fact = facts[axis.fact]; - if (fact != null) selector[axis.id] = fact; - } - const scopeKey = equalityScopeKey(canonicalizePricingSelector(selector)); - for (const axis of PRICING_AXES) { - if (axis.kind !== 'threshold') continue; - const fact = facts[axis.fact]; - if (fact === undefined) continue; - const bandsByScope = compiled?.thresholdBandsByAxisAndEqualityScope.get(axis.id); - const bands = [ - ...(bandsByScope?.get('{}') ?? []), - ...(scopeKey === '{}' ? [] : (bandsByScope?.get(scopeKey) ?? [])), - ].toSorted((a, b) => b.value - a.value); - const band = bands.find(coordinate => thresholdMatches(coordinate, fact)); - if (band) selector[axis.id] = band; - } - const canonicalSelector = canonicalizePricingSelector(selector); - const exactRates = compiled?.ratesBySelectorKey.get(JSON.stringify(canonicalSelector)); - if (exactRates !== undefined) return { selector: canonicalSelector, rates: exactRates }; - const baseRates = compiled?.ratesBySelectorKey.get('{}'); - return baseRates !== undefined - ? { selector: {}, rates: baseRates } - : { selector: canonicalSelector, rates: null }; -}; - -// High-level endpoint-family discriminator. A model belongs to exactly one -// kind; cross-cutting features (vision, function calling, structured -// outputs) are orthogonal and modeled separately when needed. -// -// Convention borrowed from Together AI's `type` field on /v1/models, which -// chooses a single string enum because each model id in practice maps to -// one endpoint family. Field is named `kind` rather than `type` because -// PublicModel already carries Anthropic's `type: 'model'` discriminator. -// -// Add a value here only when we actually route that endpoint family — do -// not pre-declare for future capabilities. -export const MODEL_KINDS = ['chat', 'embedding', 'image', 'rerank', 'transcription'] as const; -export type ModelKind = typeof MODEL_KINDS[number]; - -export const parseModelKind = (value: unknown, label = 'model kind'): ModelKind => { - if (typeof value === 'string' && (MODEL_KINDS as readonly string[]).includes(value)) return value as ModelKind; - throw new Error(`${label} is invalid: ${JSON.stringify(value)}`); -}; +import type { ModelEndpoints, ModelKind } from './endpoints.ts'; +import type { ModelPricing } from './pricing.ts'; export const RERANK_PROTOCOLS = [ 'cohere-v1', @@ -437,7 +15,7 @@ export type RerankProtocol = typeof RERANK_PROTOCOLS[number]; export type RerankSourceProtocol = Exclude; // Rerank has no vendor-neutral upstream URL. The operator chooses the wire -// dialect on each model and may replace that dialect's canonical path for a +// protocol on each model and may replace that protocol's canonical path for a // compatible server. Keeping this off the upstream prevents one model's // protocol choice from leaking onto every other model at the same base URL. export interface RerankTarget { diff --git a/packages/protocols/src/common/opaque-value_test.ts b/packages/protocols/src/common/opaque-value_test.ts new file mode 100644 index 0000000000..ab2e92e81c --- /dev/null +++ b/packages/protocols/src/common/opaque-value_test.ts @@ -0,0 +1,43 @@ +import { test } from 'vitest'; + +import { appendOpaqueTrailer, decodeOpaqueValue, encodeOpaqueValue, splitOpaqueTrailer } from './opaque-value.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +test('opaque values preserve canonical Base64 alphabets byte-for-byte', () => { + assertEquals(decodeOpaqueValue('AP+A'), { bytes: new Uint8Array([0x00, 0xff, 0x80]), origin: 'base64' }); + assertEquals(decodeOpaqueValue('AP-A'), { bytes: new Uint8Array([0x00, 0xff, 0x80]), origin: 'base64url' }); + assertEquals(encodeOpaqueValue(new Uint8Array([0x00, 0xff, 0x80]), 'base64'), 'AP+A'); + assertEquals(encodeOpaqueValue(new Uint8Array([0x00, 0xff, 0x80]), 'base64url'), 'AP-A'); +}); + +test('raw opaque values freeze UTF-16 code units including lone surrogates', () => { + const raw = 'raw:\u0000\ud800'; + const decoded = decodeOpaqueValue(raw); + assertEquals(decoded, { + bytes: new Uint8Array([0x00, 0x72, 0x00, 0x61, 0x00, 0x77, 0x00, 0x3a, 0x00, 0x00, 0xd8, 0x00]), + origin: 'raw', + }); + assertEquals(encodeOpaqueValue(decoded.bytes, decoded.origin), raw); +}); + +test('opaque trailers freeze original-trailer-length framing and alphabet selection', () => { + const base64 = appendOpaqueTrailer( + { bytes: new Uint8Array([0x01, 0x02]), origin: 'base64' }, + new Uint8Array([0xaa, 0xbb, 0xcc]), + ); + assertEquals(base64, 'AQKqu8wAAw=='); + assertEquals(splitOpaqueTrailer(base64), { + original: new Uint8Array([0x01, 0x02]), + trailer: new Uint8Array([0xaa, 0xbb, 0xcc]), + }); + + const base64url = appendOpaqueTrailer( + { bytes: new Uint8Array([0xfb, 0xff]), origin: 'base64url' }, + new Uint8Array([0x01, 0x02, 0x03]), + ); + assertEquals(base64url, '-_8BAgMAAw'); + assertEquals(splitOpaqueTrailer(base64url), { + original: new Uint8Array([0xfb, 0xff]), + trailer: new Uint8Array([0x01, 0x02, 0x03]), + }); +}); diff --git a/packages/protocols/src/common/stream/parse-sse.ts b/packages/protocols/src/common/parse-sse.ts similarity index 97% rename from packages/protocols/src/common/stream/parse-sse.ts rename to packages/protocols/src/common/parse-sse.ts index 489a8df30a..a7e4330bc7 100644 --- a/packages/protocols/src/common/stream/parse-sse.ts +++ b/packages/protocols/src/common/parse-sse.ts @@ -1,4 +1,4 @@ -import { type SseFrame, sseFrame } from '../sse.ts'; +import { type SseFrame, sseFrame } from './sse.ts'; interface ParseSSEStreamOptions { signal?: AbortSignal; diff --git a/packages/protocols/src/common/stream/parse-sse_test.ts b/packages/protocols/src/common/parse-sse_test.ts similarity index 100% rename from packages/protocols/src/common/stream/parse-sse_test.ts rename to packages/protocols/src/common/parse-sse_test.ts diff --git a/packages/protocols/src/common/pricing.ts b/packages/protocols/src/common/pricing.ts new file mode 100644 index 0000000000..de6e22bd64 --- /dev/null +++ b/packages/protocols/src/common/pricing.ts @@ -0,0 +1,401 @@ +import { divideDecimalString, parseNonNegativeDecimalString, type DecimalString } from './decimal.ts'; +import { billableServiceTier } from './usage.ts'; + +// Disjoint billing metrics a single request can be charged on. Every count +// keyed by these is non-overlapping: a prompt token is counted under exactly +// one input metric, never several at once. +// +// Bare `input`/`output` preserve an upstream's general counters; they are not +// assumed to be text-only when the upstream does not report modalities +// separately. The `_image` variants are used only for separately metered image +// counters, and adapters keep them disjoint from the corresponding general +// counter. Every metric is priced explicitly; an absent rate leaves that +// metric unpriced. Image cache metrics are absent until an upstream +// exposes disjoint counters that can be recorded without inference. +// +// `input_cache_write` is the generic cache-write bucket — protocols without +// a TTL distinction land all their writes here, and on Anthropic it covers +// the default (5-minute) TTL bucket. `input_cache_write_1h` is the explicit +// 1-hour bucket Anthropic surfaces under +// `cache_creation.ephemeral_1h_input_tokens` (extended-cache-ttl-2025-04-11). +// They are disjoint subsets of `cache_creation_input_tokens`. +export const BILLING_METRICS = ['input_tokens', 'input_cache_read_tokens', 'input_cache_write_tokens', 'input_cache_write_1h_tokens', 'input_image_tokens', 'input_audio_tokens', 'input_audio_seconds', 'output_tokens', 'output_image_tokens', 'rerank_searches'] as const; +export type BillingMetric = (typeof BILLING_METRICS)[number]; + +export const parseBillingMetric = (value: unknown, label = 'billing metric'): BillingMetric => { + if (typeof value === 'string' && (BILLING_METRICS as readonly string[]).includes(value)) return value as BillingMetric; + throw new TypeError(`${label} is invalid: ${JSON.stringify(value)}`); +}; + +// USD per one base metric unit for one pricing entry. +export type PriceVector = Partial>; + +export type PricingThresholdOperator = 'gt' | 'gte'; + +export interface PricingThresholdCoordinate { + operator: PricingThresholdOperator; + value: number; +} + +export type PricingCoordinateValue = string | PricingThresholdCoordinate; +export type PricingSelector = Readonly>; + +export type PricingRuntimeFacts = Readonly<{ + serviceTier?: string | null; + inputTokens?: number; +}>; + +type PricingRuntimeFactKey = { + [Key in keyof PricingRuntimeFacts]-?: Exclude extends Value ? Key : never; +}[keyof PricingRuntimeFacts] & string; + +export type PricingAxis = + | { id: string; kind: 'equality'; label: string; fact: PricingRuntimeFactKey } + | { id: string; kind: 'threshold'; label: string; fact: PricingRuntimeFactKey }; + +// Each axis binds its authoring metadata to the runtime fact used for request +// projection, so a new registry entry cannot silently remain runtime-inert. +export const PRICING_AXES = [ + { id: 'serviceTier', kind: 'equality', label: 'Service Tier', fact: 'serviceTier' }, + { id: 'inputTokens', kind: 'threshold', label: 'Input Tokens', fact: 'inputTokens' }, +] as const satisfies readonly PricingAxis[]; + +export interface PricingEntry { + selector?: PricingSelector; + rates: PriceVector; +} + +// Per-model pricing as symmetric flat entries. `{ rates }` is the unique Base entry; non-default +// coordinates use the same shape. Threshold bands are implied by selectors +// rather than maintained as a second catalog. An exact selector miss resolves +// to the whole Base vector; rates are never merged or inherited field-by-field +// across entries. +export interface ModelPricing { + entries: readonly PricingEntry[]; +} + +export interface PricedRequest { + selector: PricingSelector; + rates: PriceVector | null; +} + +export type ModelPricingIssue = + | { code: 'empty-catalog'; error: Error } + | { code: 'empty-rates'; entryIndex: number; error: Error } + | { code: 'invalid-rate'; entryIndex: number; metric: BillingMetric; error: Error } + | { code: 'invalid-selector'; entryIndex: number; error: Error } + | { code: 'base-count'; entryIndexes: readonly number[]; error: Error } + | { + code: 'rate-metrics'; + entryIndex: number; + baseIndex: number; + missingMetrics: readonly BillingMetric[]; + addedMetrics: readonly BillingMetric[]; + error: Error; + } + | { code: 'duplicate-selector'; selector: PricingSelector; selectorKey: string; entryIndexes: readonly number[]; error: Error } + | { + code: 'threshold-operator-conflict'; + axisId: string; + value: number; + entryIndexes: readonly [number, number]; + error: Error; + }; + +export const validatePriceVector = (pricing: PriceVector, path = 'price vector'): void => { + const metrics = BILLING_METRICS.filter(metric => pricing[metric] !== undefined); + if (metrics.length === 0) throw new Error(`${path} must contain at least one rate`); + for (const metric of metrics) { + const rate = pricing[metric]!; + const canonical = parseNonNegativeDecimalString(rate, `${path}.${metric}`); + if (canonical !== rate) throw new Error(`${path}.${metric} must be canonical: ${JSON.stringify(canonical)}`); + } +}; + +const axisById = new Map(PRICING_AXES.map(axis => [axis.id, axis])); + +const canonicalThreshold = (value: PricingCoordinateValue, path: string): PricingThresholdCoordinate => { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${path} must be a threshold object`); + const unknownKeys = Object.keys(value).filter(key => key !== 'operator' && key !== 'value'); + if (unknownKeys.length > 0) throw new RangeError(`${path} has unknown fields: ${unknownKeys.join(', ')}`); + const { operator, value: threshold } = value; + if (operator !== 'gt' && operator !== 'gte') throw new RangeError(`${path}.operator must be "gt" or "gte"`); + if (!Number.isSafeInteger(threshold) || threshold <= 0) throw new RangeError(`${path}.value must be a positive safe integer`); + return { operator, value: threshold }; +}; + +export const canonicalizePricingSelector = (selector: PricingSelector | undefined): PricingSelector => { + const canonical: Record = {}; + for (const axisId of Object.keys(selector ?? {}).toSorted()) { + const axis = axisById.get(axisId); + if (!axis) throw new RangeError(`unknown pricing selector axis: ${axisId}`); + const value = selector![axisId]; + if (axis.kind === 'equality') { + if (typeof value !== 'string' || value.length === 0) throw new RangeError(`pricing selector ${axisId} must be a non-empty string`); + canonical[axisId] = value; + } else { + canonical[axisId] = canonicalThreshold(value, `pricing selector ${axisId}`); + } + } + return canonical; +}; + +export const canonicalPricingSelectorKey = (selector: PricingSelector | undefined): string => + JSON.stringify(canonicalizePricingSelector(selector)); + +export const parsePricingSelectorKey = (key: string): PricingSelector => { + const parsed: unknown = JSON.parse(key); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new TypeError('pricing selector key must encode an object'); + const selector = canonicalizePricingSelector(parsed as PricingSelector); + if (JSON.stringify(selector) !== key) throw new Error('pricing selector key is not canonical'); + return selector; +}; + +const selectorCoordinatesByKind = (selector: PricingSelector, kind: PricingAxis['kind']): PricingSelector => + Object.fromEntries(Object.entries(selector).filter(([axisId]) => axisById.get(axisId)!.kind === kind)); + +const equalityScopeKey = (selector: PricingSelector): string => + JSON.stringify(selectorCoordinatesByKind(selector, 'equality')); + +const pricingMetrics = (rates: PriceVector): readonly BillingMetric[] => + BILLING_METRICS.filter(metric => rates[metric] !== undefined); + +export const collectModelPricingIssues = (pricing: ModelPricing): readonly ModelPricingIssue[] => { + const issues: ModelPricingIssue[] = []; + if (pricing.entries.length === 0) { + issues.push({ code: 'empty-catalog', error: new Error('model pricing must declare at least one entry') }); + return issues; + } + + const selectors: (PricingSelector | undefined)[] = []; + for (let entryIndex = 0; entryIndex < pricing.entries.length; entryIndex++) { + const entry = pricing.entries[entryIndex]!; + const metrics = pricingMetrics(entry.rates); + if (metrics.length === 0) { + issues.push({ + code: 'empty-rates', + entryIndex, + error: new Error(`model pricing entry ${entryIndex}.rates must contain at least one rate`), + }); + } + for (const metric of metrics) { + const rate = entry.rates[metric]!; + try { + const canonical = parseNonNegativeDecimalString(rate, `model pricing entry ${entryIndex}.rates.${metric}`); + if (canonical !== rate) throw new Error(`model pricing entry ${entryIndex}.rates.${metric} must be canonical: ${JSON.stringify(canonical)}`); + } catch (cause) { + issues.push({ + code: 'invalid-rate', + entryIndex, + metric, + error: cause instanceof Error ? cause : new Error(String(cause)), + }); + } + } + try { + const selector = canonicalizePricingSelector(entry.selector); + if (typeof selector.serviceTier === 'string' && billableServiceTier(selector.serviceTier) === null) { + throw new RangeError('pricing selector serviceTier must not denote Base; omit the coordinate instead'); + } + selectors[entryIndex] = selector; + } catch (cause) { + issues.push({ + code: 'invalid-selector', + entryIndex, + error: cause instanceof Error ? cause : new Error(String(cause)), + }); + } + } + + const baseIndexes = selectors.flatMap((selector, index) => + selector !== undefined && Object.keys(selector).length === 0 ? [index] : []); + if (baseIndexes.length !== 1) { + issues.push({ + code: 'base-count', + entryIndexes: baseIndexes, + error: new Error('model pricing must declare exactly one base entry'), + }); + } + + if (baseIndexes.length === 1) { + const baseIndex = baseIndexes[0]!; + const expectedMetrics = pricingMetrics(pricing.entries[baseIndex]!.rates); + const expectedSet = new Set(expectedMetrics); + for (let entryIndex = 0; entryIndex < pricing.entries.length; entryIndex++) { + if (entryIndex === baseIndex) continue; + const metrics = pricingMetrics(pricing.entries[entryIndex]!.rates); + const metricSet = new Set(metrics); + const missingMetrics = expectedMetrics.filter(metric => !metricSet.has(metric)); + const addedMetrics = metrics.filter(metric => !expectedSet.has(metric)); + if (missingMetrics.length > 0 || addedMetrics.length > 0) { + issues.push({ + code: 'rate-metrics', + entryIndex, + baseIndex, + missingMetrics, + addedMetrics, + error: new Error(`model pricing entry ${entryIndex}.rates must define the same metrics as the base entry (${expectedMetrics.join(', ')})`), + }); + } + } + } + + const selectorIndexesByKey = new Map(); + for (let entryIndex = 0; entryIndex < selectors.length; entryIndex++) { + const selector = selectors[entryIndex]; + if (selector === undefined) continue; + const key = JSON.stringify(selector); + const indexes = selectorIndexesByKey.get(key) ?? []; + indexes.push(entryIndex); + selectorIndexesByKey.set(key, indexes); + } + for (const [selectorKey, entryIndexes] of selectorIndexesByKey) { + if (entryIndexes.length < 2) continue; + issues.push({ + code: 'duplicate-selector', + selector: selectors[entryIndexes[0]!]!, + selectorKey, + entryIndexes, + error: new Error(`duplicate pricing entry selector: ${selectorKey}`), + }); + } + + const selectorKeys = new Set(); + const thresholdOperatorsByScope = new Map>>(); + const operatorsFor = (scopeKey: string, axisId: string): Map => { + const byAxis = thresholdOperatorsByScope.get(scopeKey) ?? new Map>(); + thresholdOperatorsByScope.set(scopeKey, byAxis); + const operators = byAxis.get(axisId) ?? new Map(); + byAxis.set(axisId, operators); + return operators; + }; + for (let entryIndex = 0; entryIndex < selectors.length; entryIndex++) { + const selector = selectors[entryIndex]; + if (selector === undefined) continue; + const key = JSON.stringify(selector); + if (selectorKeys.has(key)) continue; + selectorKeys.add(key); + const scopeKey = equalityScopeKey(selector); + for (const [axisId, coordinate] of Object.entries(selector)) { + if (typeof coordinate === 'string') continue; + const overlappingScopes = scopeKey === '{}' + ? [...thresholdOperatorsByScope.keys()] + : ['{}', scopeKey]; + for (const overlappingScope of overlappingScopes) { + const existing = thresholdOperatorsByScope.get(overlappingScope)?.get(axisId)?.get(coordinate.value); + if (existing !== undefined && existing.operator !== coordinate.operator) { + issues.push({ + code: 'threshold-operator-conflict', + axisId, + value: coordinate.value, + entryIndexes: [existing.entryIndex, entryIndex], + error: new Error(`conflicting pricing threshold operators for ${axisId} at ${coordinate.value} in overlapping equality scopes`), + }); + } + } + operatorsFor(scopeKey, axisId).set(coordinate.value, { operator: coordinate.operator, entryIndex }); + } + } + return issues; +}; + +export const validateModelPricing = (pricing: ModelPricing): void => { + const issue = collectModelPricingIssues(pricing)[0]; + if (issue) throw issue.error; +}; + +interface CompiledModelPricing { + ratesBySelectorKey: ReadonlyMap; + thresholdBandsByAxisAndEqualityScope: ReadonlyMap>; +} + +const compiledPricing = new WeakMap(); + +// Pricing objects are immutable after provider/config construction. Compilation +// validates and canonicalizes once per stable object identity. +const compileModelPricing = (pricing: ModelPricing): CompiledModelPricing => { + const existing = compiledPricing.get(pricing); + if (existing) return existing; + validateModelPricing(pricing); + const ratesBySelectorKey = new Map(); + const bandsByAxisAndEqualityScope = new Map>>(); + for (const entry of pricing.entries) { + const selector = canonicalizePricingSelector(entry.selector); + ratesBySelectorKey.set(JSON.stringify(selector), entry.rates); + for (const axis of PRICING_AXES) { + if (axis.kind !== 'threshold') continue; + const coordinate = selector[axis.id]; + if (typeof coordinate !== 'object') continue; + const scopeKey = equalityScopeKey(selector); + const bandsByScope = bandsByAxisAndEqualityScope.get(axis.id) ?? new Map>(); + bandsByAxisAndEqualityScope.set(axis.id, bandsByScope); + const bands = bandsByScope.get(scopeKey) ?? new Map(); + bands.set(coordinate.value, coordinate); + bandsByScope.set(scopeKey, bands); + } + } + const thresholdBandsByAxisAndEqualityScope = new Map( + [...bandsByAxisAndEqualityScope].map(([axisId, bandsByScope]) => [ + axisId, + new Map([...bandsByScope].map(([scopeKey, bands]) => + [scopeKey, [...bands.values()].toSorted((a, b) => b.value - a.value)] as const)), + ] as const), + ); + const compiled = { ratesBySelectorKey, thresholdBandsByAxisAndEqualityScope }; + compiledPricing.set(pricing, compiled); + return compiled; +}; + +export const pricingEntry = (rates: PriceVector, selector?: PricingSelector): PricingEntry => { + validatePriceVector(rates); + const canonicalSelector = canonicalizePricingSelector(selector); + return { ...(Object.keys(canonicalSelector).length > 0 ? { selector: canonicalSelector } : {}), rates }; +}; +export const modelPricing = (...entries: PricingEntry[]): ModelPricing => { + const pricing: ModelPricing = { entries }; + compileModelPricing(pricing); + return pricing; +}; +export const basePricing = (rates: PriceVector): ModelPricing => modelPricing(pricingEntry(rates)); + +export const perMillionTokenRates = (publishedRates: PriceVector): PriceVector => Object.fromEntries( + Object.entries(publishedRates).map(([metric, price]) => [metric, divideDecimalString(price, '1000000')]), +) as PriceVector; + +export const tokenPricingEntry = (publishedRates: PriceVector, selector?: PricingSelector): PricingEntry => + pricingEntry(perMillionTokenRates(publishedRates), selector); + +export const tokenBasePricing = (publishedRates: PriceVector): ModelPricing => basePricing(perMillionTokenRates(publishedRates)); + +const thresholdMatches = (coordinate: PricingThresholdCoordinate, fact: number): boolean => + coordinate.operator === 'gt' ? fact > coordinate.value : fact >= coordinate.value; + +export const priceRequest = (pricing: ModelPricing | null, facts: PricingRuntimeFacts): PricedRequest => { + const compiled = pricing ? compileModelPricing(pricing) : undefined; + const selector: Record = {}; + for (const axis of PRICING_AXES) { + if (axis.kind !== 'equality') continue; + const fact = facts[axis.fact]; + if (fact != null) selector[axis.id] = fact; + } + const scopeKey = equalityScopeKey(canonicalizePricingSelector(selector)); + for (const axis of PRICING_AXES) { + if (axis.kind !== 'threshold') continue; + const fact = facts[axis.fact]; + if (fact === undefined) continue; + const bandsByScope = compiled?.thresholdBandsByAxisAndEqualityScope.get(axis.id); + const bands = [ + ...(bandsByScope?.get('{}') ?? []), + ...(scopeKey === '{}' ? [] : (bandsByScope?.get(scopeKey) ?? [])), + ].toSorted((a, b) => b.value - a.value); + const band = bands.find(coordinate => thresholdMatches(coordinate, fact)); + if (band) selector[axis.id] = band; + } + const canonicalSelector = canonicalizePricingSelector(selector); + const exactRates = compiled?.ratesBySelectorKey.get(JSON.stringify(canonicalSelector)); + if (exactRates !== undefined) return { selector: canonicalSelector, rates: exactRates }; + const baseRates = compiled?.ratesBySelectorKey.get('{}'); + return baseRates !== undefined + ? { selector: {}, rates: baseRates } + : { selector: canonicalSelector, rates: null }; +}; diff --git a/packages/protocols/src/common/models_test.ts b/packages/protocols/src/common/pricing_test.ts similarity index 95% rename from packages/protocols/src/common/models_test.ts rename to packages/protocols/src/common/pricing_test.ts index fc17fec127..78003d3ff8 100644 --- a/packages/protocols/src/common/models_test.ts +++ b/packages/protocols/src/common/pricing_test.ts @@ -5,24 +5,17 @@ import { canonicalPricingSelectorKey, canonicalizePricingSelector, collectModelPricingIssues, - tokenModelPricing, + modelPricing, tokenPricingEntry, parseBillingMetric, parsePricingSelectorKey, - parseModelKind, priceRequest, validateModelPricing, type ModelPricing, type PricingSelector, -} from './models.ts'; +} from './pricing.ts'; import { assertEquals, assertThrows } from '@floway-dev/test-utils'; -test('parseModelKind accepts the current model families and rejects unknown storage values', () => { - for (const kind of ['chat', 'embedding', 'image', 'rerank', 'transcription'] as const) assertEquals(parseModelKind(kind), kind); - assertThrows(() => parseModelKind('video'), Error, 'model kind is invalid: "video"'); - assertThrows(() => parseModelKind(null), Error, 'model kind is invalid: null'); -}); - test('billing storage parsers accept current vocabulary and reject unknown values', () => { assertEquals(parseBillingMetric('input_tokens'), 'input_tokens'); assertEquals(parseBillingMetric('input_audio_tokens'), 'input_audio_tokens'); @@ -195,7 +188,7 @@ test('service-specific thresholds remain scoped while global thresholds apply to test('shared pricing helpers canonicalize and eagerly validate catalogs', () => { assertEquals(tokenBasePricing({ input_tokens: '1' }), { entries: [{ rates: { input_tokens: '0.000001' } }] }); - assertEquals(tokenModelPricing( + assertEquals(modelPricing( tokenPricingEntry({ input_tokens: '1' }), tokenPricingEntry({ input_tokens: '2' }, { serviceTier: 'priority' }), ), { @@ -205,7 +198,7 @@ test('shared pricing helpers canonicalize and eagerly validate catalogs', () => ], }); assertThrows( - () => tokenModelPricing( + () => modelPricing( tokenPricingEntry({ input_tokens: '1' }), tokenPricingEntry({ input_tokens: '2' }, { serviceTier: 'priority' }), tokenPricingEntry({ input_tokens: '3' }, { serviceTier: 'priority' }), @@ -214,7 +207,7 @@ test('shared pricing helpers canonicalize and eagerly validate catalogs', () => 'duplicate pricing entry selector', ); assertThrows( - () => tokenModelPricing(tokenPricingEntry({ input_tokens: '1' }, { serviceTier: 'priority' })), + () => modelPricing(tokenPricingEntry({ input_tokens: '1' }, { serviceTier: 'priority' })), Error, 'exactly one base entry', ); diff --git a/packages/protocols/src/gemini/field-keys.ts b/packages/protocols/src/gemini/field-keys.ts new file mode 100644 index 0000000000..ab900f2b3e --- /dev/null +++ b/packages/protocols/src/gemini/field-keys.ts @@ -0,0 +1,7 @@ +import type { GeminiCandidate, GeminiResult } from './index.ts'; + +// These sets mark fields handled on typed paths during reassembly and affinity +// transport. Keeping one definition for both stages ensures unknown upstream +// fields remain extras until every stage gains typed handling for them. +export const GEMINI_RESULT_KEYS: ReadonlySet = new Set(['candidates', 'modelVersion', 'responseId', 'usageMetadata']); +export const GEMINI_CANDIDATE_KEYS: ReadonlySet = new Set(['index', 'content', 'finishReason']); diff --git a/packages/protocols/src/gemini/index.ts b/packages/protocols/src/gemini/index.ts index 7ae7b6fe89..45fcc2f0d7 100644 --- a/packages/protocols/src/gemini/index.ts +++ b/packages/protocols/src/gemini/index.ts @@ -110,6 +110,7 @@ export interface GeminiErrorResponse { export type GeminiStreamEvent = GeminiResult | GeminiErrorResponse; +export { GEMINI_CANDIDATE_KEYS, GEMINI_RESULT_KEYS } from './field-keys.ts'; export { GEMINI_MISSING_TERMINAL_MESSAGE, isGeminiErrorEvent, isGeminiTerminalEvent, collectGeminiProtocolEventsToResult } from './to-result.ts'; export { reassembleGeminiEvents } from './reassemble.ts'; export { geminiProtocolFrameToSSEFrame } from './to-sse.ts'; diff --git a/packages/protocols/src/gemini/reassemble.ts b/packages/protocols/src/gemini/reassemble.ts index 39172375cb..99056c7b11 100644 --- a/packages/protocols/src/gemini/reassemble.ts +++ b/packages/protocols/src/gemini/reassemble.ts @@ -1,15 +1,7 @@ -import type { GeminiCandidate, GeminiResult, GeminiPart, GeminiStreamEvent } from './index.ts'; +import { GEMINI_CANDIDATE_KEYS, GEMINI_RESULT_KEYS } from './field-keys.ts'; +import type { GeminiCandidate, GeminiPart, GeminiResult, GeminiStreamEvent } from './index.ts'; import { captureExtras } from '../common/reassemble-extras.ts'; -// Field-fidelity contract — see {@link captureExtras}. We accumulate the -// fields we understand on typed paths (parts, role, finishReason on -// candidates; modelVersion / responseId / usageMetadata on the top-level -// result); the key sets here name those known fields so anything else an -// upstream emits — `safetyRatings`, `groundingMetadata`, `citationMetadata`, -// future Gemini extensions, etc. — survives onto the assembled result. -const KNOWN_RESULT_KEYS = new Set(['candidates', 'modelVersion', 'responseId', 'usageMetadata']); -const KNOWN_CANDIDATE_KEYS = new Set(['index', 'content', 'finishReason']); - const isMergeableTextPart = (part: GeminiPart): boolean => part.text !== undefined && part.thought !== true @@ -50,7 +42,7 @@ const mergeCandidate = (candidates: Map, inco appendPart(candidate.content.parts, part); } const extras: Record = {}; - captureExtras(incoming as unknown as Record, KNOWN_CANDIDATE_KEYS, extras); + captureExtras(incoming as unknown as Record, GEMINI_CANDIDATE_KEYS, extras); if (Object.keys(extras).length > 0) candidate.__extras = extras; candidates.set(incoming.index, candidate); return; @@ -66,7 +58,7 @@ const mergeCandidate = (candidates: Map, inco existing.finishReason = incoming.finishReason; } const extras = existing.__extras ?? {}; - captureExtras(incoming as unknown as Record, KNOWN_CANDIDATE_KEYS, extras); + captureExtras(incoming as unknown as Record, GEMINI_CANDIDATE_KEYS, extras); if (Object.keys(extras).length > 0) existing.__extras = extras; }; @@ -92,7 +84,7 @@ export async function reassembleGeminiEvents(events: AsyncIterable, KNOWN_RESULT_KEYS, resultExtras); + captureExtras(event as unknown as Record, GEMINI_RESULT_KEYS, resultExtras); } const mergedCandidates = [...candidates.values()].sort((a, b) => a.index - b.index).map(finalizeCandidate); diff --git a/packages/protocols/src/gemini/reassemble_test.ts b/packages/protocols/src/gemini/reassemble_test.ts new file mode 100644 index 0000000000..8bf5fc2f07 --- /dev/null +++ b/packages/protocols/src/gemini/reassemble_test.ts @@ -0,0 +1,139 @@ +import { test } from 'vitest'; + +import type { GeminiResult, GeminiStreamEvent } from './index.ts'; +import { reassembleGeminiEvents } from './reassemble.ts'; +import { USAGE_BILLING } from '../common/index.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +const eventsFrom = async function* (events: readonly GeminiStreamEvent[]) { + yield* events; +}; + +test('reassembleGeminiEvents assembles candidate parts and final metadata', async () => { + const events: GeminiStreamEvent[] = [ + { + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: 'He' }, { text: 'l' }], + }, + }, + ], + modelVersion: 'gemini-test-preview', + responseId: 'response-early', + }, + { + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: 'lo' }, { text: 'thinking', thought: true }], + }, + }, + { + index: 1, + content: { + role: 'model', + parts: [{ functionCall: { id: 'call-1', name: 'lookup', args: {} } }], + }, + }, + ], + usageMetadata: { promptTokenCount: 2, totalTokenCount: 4 }, + }, + { + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: ' signed', thoughtSignature: 'sig-1' }, { text: ' tail' }], + }, + finishReason: 'STOP', + }, + ], + modelVersion: 'gemini-test', + responseId: 'response-final', + usageMetadata: { + promptTokenCount: 2, + candidatesTokenCount: 6, + totalTokenCount: 8, + thoughtsTokenCount: 1, + }, + }, + ]; + + const expected: GeminiResult = { + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: 'Hello' }, { text: 'thinking', thought: true }, { text: ' signed', thoughtSignature: 'sig-1' }, { text: ' tail' }], + }, + finishReason: 'STOP', + }, + { + index: 1, + content: { + role: 'model', + parts: [{ functionCall: { id: 'call-1', name: 'lookup', args: {} } }], + }, + }, + ], + modelVersion: 'gemini-test', + responseId: 'response-final', + usageMetadata: { + promptTokenCount: 2, + candidatesTokenCount: 6, + totalTokenCount: 8, + thoughtsTokenCount: 1, + }, + }; + + assertEquals(await reassembleGeminiEvents(eventsFrom(events)), expected); +}); + +test('Gemini billing metadata survives reassembly without entering JSON', async () => { + const usageMetadata = { + promptTokenCount: 10, + [USAGE_BILLING]: { cacheWriteTokenCount: 4, serviceTier: 'priority' }, + }; + const result = await reassembleGeminiEvents(eventsFrom([{ + candidates: [{ index: 0, content: { role: 'model', parts: [] }, finishReason: 'STOP' }], + usageMetadata, + }])); + assertEquals(result.usageMetadata?.[USAGE_BILLING], { cacheWriteTokenCount: 4, serviceTier: 'priority' }); + assertEquals(JSON.parse(JSON.stringify(result.usageMetadata)), { promptTokenCount: 10 }); +}); + +test('reassembleGeminiEvents preserves unknown candidate-level and result-level fields', async () => { + const event = { + modelVersion: 'gemini-test', + responseId: 'resp_1', + candidates: [{ + index: 0, + content: { role: 'model', parts: [{ text: 'hi' }] }, + finishReason: 'STOP', + safetyRatings: [{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }], + citationMetadata: { citations: [] }, + tokenCount: 7, + }], + usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 1 }, + promptFeedback: { safetyRatings: [] }, + this_is_a_non_standard_field_of_reasoning: 'unknown_top_value', + } as unknown as GeminiStreamEvent; + + const result = await reassembleGeminiEvents(eventsFrom([event])) as GeminiResult & { + promptFeedback?: unknown; + this_is_a_non_standard_field_of_reasoning?: string; + }; + const candidate = result.candidates?.[0] as { safetyRatings?: unknown; citationMetadata?: unknown; tokenCount?: number }; + assertEquals(candidate.safetyRatings, [{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }]); + assertEquals(candidate.citationMetadata, { citations: [] }); + assertEquals(candidate.tokenCount, 7); + assertEquals(result.promptFeedback, { safetyRatings: [] }); + assertEquals(result.this_is_a_non_standard_field_of_reasoning, 'unknown_top_value'); +}); diff --git a/packages/protocols/src/gemini/to-result_test.ts b/packages/protocols/src/gemini/to-result_test.ts index 291fab4e5e..82501b47dc 100644 --- a/packages/protocols/src/gemini/to-result_test.ts +++ b/packages/protocols/src/gemini/to-result_test.ts @@ -1,101 +1,10 @@ import { test } from 'vitest'; -import type { GeminiResult, GeminiStreamEvent } from './index.ts'; +import type { GeminiStreamEvent } from './index.ts'; import { collectGeminiProtocolEventsToResult } from './to-result.ts'; -import { eventFrame, USAGE_BILLING } from '../common/index.ts'; +import { eventFrame } from '../common/index.ts'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; -test('collectGeminiProtocolEventsToResult assembles candidate parts and final metadata', async () => { - async function* events() { - const payloads: GeminiStreamEvent[] = [ - { - candidates: [ - { - index: 0, - content: { - role: 'model', - parts: [{ text: 'He' }, { text: 'l' }], - }, - }, - ], - modelVersion: 'gemini-test-preview', - responseId: 'response-early', - }, - { - candidates: [ - { - index: 0, - content: { - role: 'model', - parts: [{ text: 'lo' }, { text: 'thinking', thought: true }], - }, - }, - { - index: 1, - content: { - role: 'model', - parts: [{ functionCall: { id: 'call-1', name: 'lookup', args: {} } }], - }, - }, - ], - usageMetadata: { promptTokenCount: 2, totalTokenCount: 4 }, - }, - { - candidates: [ - { - index: 0, - content: { - role: 'model', - parts: [{ text: ' signed', thoughtSignature: 'sig-1' }, { text: ' tail' }], - }, - finishReason: 'STOP', - }, - ], - modelVersion: 'gemini-test', - responseId: 'response-final', - usageMetadata: { - promptTokenCount: 2, - candidatesTokenCount: 6, - totalTokenCount: 8, - thoughtsTokenCount: 1, - }, - }, - ]; - - for (const payload of payloads) yield eventFrame(payload); - } - - const expected: GeminiResult = { - candidates: [ - { - index: 0, - content: { - role: 'model', - parts: [{ text: 'Hello' }, { text: 'thinking', thought: true }, { text: ' signed', thoughtSignature: 'sig-1' }, { text: ' tail' }], - }, - finishReason: 'STOP', - }, - { - index: 1, - content: { - role: 'model', - parts: [{ functionCall: { id: 'call-1', name: 'lookup', args: {} } }], - }, - }, - ], - modelVersion: 'gemini-test', - responseId: 'response-final', - usageMetadata: { - promptTokenCount: 2, - candidatesTokenCount: 6, - totalTokenCount: 8, - thoughtsTokenCount: 1, - }, - }; - - assertEquals(await collectGeminiProtocolEventsToResult(events()), expected); -}); - test('collectGeminiProtocolEventsToResult throws Gemini error events', async () => { const errorEvent = { error: { @@ -119,55 +28,3 @@ test('collectGeminiProtocolEventsToResult throws Gemini error events', async () assertEquals(error.cause, errorEvent); }); - -test('Gemini billing metadata survives reassembly without entering JSON', async () => { - const usageMetadata = { - promptTokenCount: 10, - [USAGE_BILLING]: { cacheWriteTokenCount: 4, serviceTier: 'priority' }, - }; - const result = await collectGeminiProtocolEventsToResult((async function* () { - yield eventFrame({ - candidates: [{ index: 0, content: { role: 'model', parts: [] }, finishReason: 'STOP' }], - usageMetadata, - }); - })()); - assertEquals(result.usageMetadata?.[USAGE_BILLING], { cacheWriteTokenCount: 4, serviceTier: 'priority' }); - assertEquals(JSON.parse(JSON.stringify(result.usageMetadata)), { promptTokenCount: 10 }); -}); - -test('collectGeminiProtocolEventsToResult preserves unknown candidate-level and result-level fields', async () => { - async function* events() { - const payloads = [ - { - modelVersion: 'gemini-test', - responseId: 'resp_1', - candidates: [{ - index: 0, - content: { role: 'model', parts: [{ text: 'hi' }] }, - finishReason: 'STOP', - safetyRatings: [{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }], - citationMetadata: { citations: [] }, - tokenCount: 7, - }], - usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 1 }, - promptFeedback: { safetyRatings: [] }, - this_is_a_non_standard_field_of_reasoning: 'unknown_top_value', - }, - ]; - for (const payload of payloads) { - yield eventFrame(payload as GeminiStreamEvent); - } - yield { type: 'done' as const }; - } - - const result = await collectGeminiProtocolEventsToResult(events()) as GeminiResult & { - promptFeedback?: unknown; - this_is_a_non_standard_field_of_reasoning?: string; - }; - const candidate = result.candidates?.[0] as { safetyRatings?: unknown; citationMetadata?: unknown; tokenCount?: number }; - assertEquals(candidate.safetyRatings, [{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }]); - assertEquals(candidate.citationMetadata, { citations: [] }); - assertEquals(candidate.tokenCount, 7); - assertEquals(result.promptFeedback, { safetyRatings: [] }); - assertEquals(result.this_is_a_non_standard_field_of_reasoning, 'unknown_top_value'); -}); diff --git a/packages/protocols/src/images/index.ts b/packages/protocols/src/images/index.ts index 512f802c7a..6867edec4a 100644 --- a/packages/protocols/src/images/index.ts +++ b/packages/protocols/src/images/index.ts @@ -22,30 +22,10 @@ export interface ImagesGenerationsPayload { [key: string]: unknown; } +// POST /v1/images/edits accepts JSON references that point at a URL/data URL +// or an uploaded file. +// https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L12558-L12620 +// https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L47542-L47673 export type ImageEditReference = | { image_url: string; file_id?: never; [key: string]: unknown } | { file_id: string; image_url?: never; [key: string]: unknown }; - -// POST /v1/images/edits accepts either multipart uploads or this JSON shape. -// JSON uses `images` rather than the multipart `image` field, and references -// may point at a URL/data URL or an uploaded file. -// https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L12558-L12620 -// https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L47542-L47673 -export interface ImagesEditsJsonPayload { - model: string; - prompt: string; - images: ImageEditReference[]; - mask?: ImageEditReference; - n?: number | null; - quality?: string | null; - input_fidelity?: string | null; - size?: string | null; - user?: string; - output_format?: string | null; - output_compression?: number | null; - moderation?: string | null; - background?: string | null; - stream?: boolean | null; - partial_images?: number | null; - [key: string]: unknown; -} diff --git a/packages/protocols/src/messages/index.ts b/packages/protocols/src/messages/index.ts index d228ce15c0..bbc225ce65 100644 --- a/packages/protocols/src/messages/index.ts +++ b/packages/protocols/src/messages/index.ts @@ -1,3 +1,5 @@ +import type { MessagesUsage, MessagesUsageServerToolUse } from './usage.ts'; + /** * Messages requires `max_tokens`, but the Chat Completions, Responses, and * Gemini sources may omit their output-token cap. When we translate one of @@ -232,35 +234,13 @@ export interface MessagesNativeWebSearchTool { export type MessagesTool = MessagesClientTool | MessagesNativeWebSearchTool; -export interface MessagesUsageServerToolUse { - web_search_requests?: number; -} - -export interface MessagesUsage { - input_tokens: number; - output_tokens: number; - cache_creation_input_tokens?: number; - cache_read_input_tokens?: number; - // Per-TTL split for cache writes introduced by extended-cache-ttl-2025-04-11. - // Each `ephemeral_*` field is a disjoint subset of `cache_creation_input_tokens` - // (the legacy flat field is the sum of both); upstreams that have not opted - // into the beta omit `cache_creation` entirely and emit only the flat field. - cache_creation?: { - ephemeral_5m_input_tokens?: number; - ephemeral_1h_input_tokens?: number; - }; - // https://docs.claude.com/en/api/service-tiers - service_tier?: 'standard' | 'priority' | 'batch' | (string & {}); - // https://docs.claude.com/en/build-with-claude/fast-mode - speed?: 'standard' | 'fast' | (string & {}); - server_tool_use?: MessagesUsageServerToolUse; -} - export { mergeMessagesUsageSnapshot, messagesUsageSnapshot, splitMessagesCacheCreationTokens, type MessagesCacheCreationUsage, + type MessagesUsage, + type MessagesUsageServerToolUse, type MessagesUsageSnapshot, } from './usage.ts'; diff --git a/packages/protocols/src/messages/reassemble.ts b/packages/protocols/src/messages/reassemble.ts index d4c6740c94..3d648ab2f5 100644 --- a/packages/protocols/src/messages/reassemble.ts +++ b/packages/protocols/src/messages/reassemble.ts @@ -1,5 +1,3 @@ -import { isJsonObject } from '../common/json.ts'; -import { captureExtras } from '../common/reassemble-extras.ts'; import type { MessagesAssistantContentBlock, MessagesRedactedThinkingBlock, @@ -11,7 +9,9 @@ import type { MessagesToolUseBlock, MessagesUsage, MessagesWebSearchToolResultBlock, -} from '@floway-dev/protocols/messages'; +} from './index.ts'; +import { isJsonObject } from '../common/json.ts'; +import { captureExtras } from '../common/reassemble-extras.ts'; const normalizeMessagesTextCitation = (value: unknown): MessagesTextCitation | null => { if (!isJsonObject(value) || typeof value.type !== 'string') { diff --git a/packages/protocols/src/messages/stream.ts b/packages/protocols/src/messages/stream.ts index c335068fbd..8bb0cbd76d 100644 --- a/packages/protocols/src/messages/stream.ts +++ b/packages/protocols/src/messages/stream.ts @@ -1,7 +1,7 @@ import type { MessagesStreamEvent } from './index.ts'; +import { parseSSEStream } from '../common/parse-sse.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '../common/sse.ts'; import { parseTargetStreamFrames } from '../common/stream/parse-events.ts'; -import { parseSSEStream } from '../common/stream/parse-sse.ts'; export interface ParseMessagesStreamOptions { signal?: AbortSignal; diff --git a/packages/protocols/src/messages/usage.ts b/packages/protocols/src/messages/usage.ts index eb2a93955b..f4fbeed0dd 100644 --- a/packages/protocols/src/messages/usage.ts +++ b/packages/protocols/src/messages/usage.ts @@ -1,3 +1,27 @@ +export interface MessagesUsageServerToolUse { + web_search_requests?: number; +} + +export interface MessagesUsage { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + // Per-TTL split for cache writes introduced by extended-cache-ttl-2025-04-11. + // Each `ephemeral_*` field is a disjoint subset of `cache_creation_input_tokens` + // (the legacy flat field is the sum of both); upstreams that have not opted + // into the beta omit `cache_creation` entirely and emit only the flat field. + cache_creation?: { + ephemeral_5m_input_tokens?: number; + ephemeral_1h_input_tokens?: number; + }; + // https://docs.claude.com/en/api/service-tiers + service_tier?: 'standard' | 'priority' | 'batch' | (string & {}); + // https://docs.claude.com/en/build-with-claude/fast-mode + speed?: 'standard' | 'fast' | (string & {}); + server_tool_use?: MessagesUsageServerToolUse; +} + export interface MessagesCacheCreationUsage { cache_creation_input_tokens?: number; cache_creation?: { diff --git a/packages/protocols/src/rerank/default-paths.ts b/packages/protocols/src/rerank/default-paths.ts new file mode 100644 index 0000000000..832a153eaf --- /dev/null +++ b/packages/protocols/src/rerank/default-paths.ts @@ -0,0 +1,18 @@ +import type { RerankProtocol } from '../common/models.ts'; + +export const DEFAULT_RERANK_PATHS: Readonly> = { + // Cohere SDK source: https://github.com/cohere-ai/cohere-python/blob/41f344bde2b195e0a7e51d259f4b3701e62605b5/src/cohere/raw_base_client.py#L1837-L1908 + 'cohere-v1': '/v1/rerank', + // Cohere SDK source: https://github.com/cohere-ai/cohere-python/blob/41f344bde2b195e0a7e51d259f4b3701e62605b5/src/cohere/v2/raw_client.py#L985-L1048 + 'cohere-v2': '/v2/rerank', + // Jina live OpenAPI: https://api.jina.ai/openapi.json + 'jina-v1': '/v1/rerank', + // Voyage REST reference: https://docs.voyageai.com/reference/reranker-api.md + 'voyage-v1': '/v1/rerank', + // DashScope compatible and native structures are deliberately separate: + // https://help.aliyun.com/zh/model-studio/text-rerank-api + 'dashscope-compatible': '/compatible-api/v1/reranks', + // DashScope SDK test pins both this path and the nested request body: + // https://github.com/dashscope/dashscope-sdk-python/blob/f974f108526e87326b2b755b1586054d77a26679/tests/unit/test_rerank.py#L48-L65 + 'dashscope-native': '/api/v1/services/rerank/text-rerank/text-rerank', +}; diff --git a/packages/protocols/src/rerank/default-paths_test.ts b/packages/protocols/src/rerank/default-paths_test.ts new file mode 100644 index 0000000000..93d650b8ad --- /dev/null +++ b/packages/protocols/src/rerank/default-paths_test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest'; + +import { DEFAULT_RERANK_PATHS } from './default-paths.ts'; + +test('canonical paths keep compatible and native DashScope protocols distinct', () => { + expect(DEFAULT_RERANK_PATHS).toEqual({ + 'cohere-v1': '/v1/rerank', + 'cohere-v2': '/v2/rerank', + 'jina-v1': '/v1/rerank', + 'voyage-v1': '/v1/rerank', + 'dashscope-compatible': '/compatible-api/v1/reranks', + 'dashscope-native': '/api/v1/services/rerank/text-rerank/text-rerank', + }); +}); diff --git a/packages/protocols/src/rerank/index.ts b/packages/protocols/src/rerank/index.ts index 05973152dc..e0ccbf4718 100644 --- a/packages/protocols/src/rerank/index.ts +++ b/packages/protocols/src/rerank/index.ts @@ -1,12 +1,46 @@ -export type { - CanonicalRerankRequest, - CanonicalRerankResponse, - CanonicalRerankResult, - ParsedRerankRequest, - RerankInput, -} from './types.ts'; +import type { RerankSourceProtocol } from '../common/models.ts'; + +export type RerankInput = string | Record; + +export interface CanonicalRerankRequest { + sourceProtocol: RerankSourceProtocol; + raw: Record; + query: RerankInput; + documents: RerankInput[]; + topN?: number; + returnDocuments?: boolean; + rankFields?: string[]; + maxChunksPerDocument?: number; + maxTokensPerDocument?: number; + priority?: number; + truncation?: boolean; + maxDocumentLength?: number; + returnEmbeddings?: boolean; +} + +export interface CanonicalRerankResult { + index: number; + relevanceScore: number; + document?: RerankInput; + embedding?: number[]; +} + +export interface CanonicalRerankResponse { + raw: Record; + id?: string; + model?: string; + results: CanonicalRerankResult[]; + totalTokens?: number; + searchUnits?: number; +} + +export interface ParsedRerankRequest { + model: string; + request: CanonicalRerankRequest; +} + +export { DEFAULT_RERANK_PATHS } from './default-paths.ts'; export { - DEFAULT_RERANK_PATHS, parseRerankRequest, parseRerankResponse, parseRerankUsage, diff --git a/packages/protocols/src/rerank/translate.ts b/packages/protocols/src/rerank/translate.ts index a72f6f4120..ae8402478f 100644 --- a/packages/protocols/src/rerank/translate.ts +++ b/packages/protocols/src/rerank/translate.ts @@ -1,4 +1,4 @@ -import type { ParsedRerankRequest, CanonicalRerankRequest, CanonicalRerankResponse, CanonicalRerankResult, RerankInput } from './types.ts'; +import type { ParsedRerankRequest, CanonicalRerankRequest, CanonicalRerankResponse, CanonicalRerankResult, RerankInput } from './index.ts'; import type { RerankProtocol, RerankSourceProtocol } from '../common/models.ts'; const isRecord = (value: unknown): value is Record => @@ -175,23 +175,6 @@ const stringInput = (input: RerankInput): string => { return typeof input.text === 'string' ? input.text : JSON.stringify(input); }; -export const DEFAULT_RERANK_PATHS: Readonly> = { - // Cohere SDK source: https://github.com/cohere-ai/cohere-python/blob/41f344bde2b195e0a7e51d259f4b3701e62605b5/src/cohere/raw_base_client.py#L1837-L1908 - 'cohere-v1': '/v1/rerank', - // Cohere SDK source: https://github.com/cohere-ai/cohere-python/blob/41f344bde2b195e0a7e51d259f4b3701e62605b5/src/cohere/v2/raw_client.py#L985-L1048 - 'cohere-v2': '/v2/rerank', - // Jina live OpenAPI: https://api.jina.ai/openapi.json - 'jina-v1': '/v1/rerank', - // Voyage REST reference: https://docs.voyageai.com/reference/reranker-api.md - 'voyage-v1': '/v1/rerank', - // DashScope compatible and native structures are deliberately separate: - // https://help.aliyun.com/zh/model-studio/text-rerank-api - 'dashscope-compatible': '/compatible-api/v1/reranks', - // DashScope SDK test pins both this path and the nested request body: - // https://github.com/dashscope/dashscope-sdk-python/blob/f974f108526e87326b2b755b1586054d77a26679/tests/unit/test_rerank.py#L48-L65 - 'dashscope-native': '/api/v1/services/rerank/text-rerank/text-rerank', -}; - export const rerankRequestIncompatibility = ( protocol: RerankProtocol, request: CanonicalRerankRequest, diff --git a/packages/protocols/src/rerank/translate_test.ts b/packages/protocols/src/rerank/translate_test.ts index 0e40167a84..e041801ed5 100644 --- a/packages/protocols/src/rerank/translate_test.ts +++ b/packages/protocols/src/rerank/translate_test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import { DEFAULT_RERANK_PATHS, parseRerankRequest, parseRerankResponse, parseRerankUsage, renderRerankResponse, serializeRerankRequest } from './translate.ts'; +import { parseRerankRequest, parseRerankResponse, parseRerankUsage, renderRerankResponse, serializeRerankRequest } from './translate.ts'; describe('rerank request ingress', () => { test('Cohere v1 retains structured documents and v1-only options', () => { @@ -147,17 +147,6 @@ describe('rerank request egress', () => { }); }); - test('canonical paths keep compatible and native DashScope wires distinct', () => { - expect(DEFAULT_RERANK_PATHS).toEqual({ - 'cohere-v1': '/v1/rerank', - 'cohere-v2': '/v2/rerank', - 'jina-v1': '/v1/rerank', - 'voyage-v1': '/v1/rerank', - 'dashscope-compatible': '/compatible-api/v1/reranks', - 'dashscope-native': '/api/v1/services/rerank/text-rerank/text-rerank', - }); - }); - test('rejects source-only controls that the target cannot represent', () => { const jina = parseRerankRequest('jina-v1', { model: 'jina', query: 'query', documents: ['one'], return_embeddings: true, diff --git a/packages/protocols/src/rerank/types.ts b/packages/protocols/src/rerank/types.ts deleted file mode 100644 index 9c45fc74c2..0000000000 --- a/packages/protocols/src/rerank/types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { RerankSourceProtocol } from '../common/models.ts'; - -export type RerankInput = string | Record; - -export interface CanonicalRerankRequest { - sourceProtocol: RerankSourceProtocol; - raw: Record; - query: RerankInput; - documents: RerankInput[]; - topN?: number; - returnDocuments?: boolean; - rankFields?: string[]; - maxChunksPerDocument?: number; - maxTokensPerDocument?: number; - priority?: number; - truncation?: boolean; - maxDocumentLength?: number; - returnEmbeddings?: boolean; -} - -export interface CanonicalRerankResult { - index: number; - relevanceScore: number; - document?: RerankInput; - embedding?: number[]; -} - -export interface CanonicalRerankResponse { - raw: Record; - id?: string; - model?: string; - results: CanonicalRerankResult[]; - totalTokens?: number; - searchUnits?: number; -} - -export interface ParsedRerankRequest { - model: string; - request: CanonicalRerankRequest; -} diff --git a/packages/protocols/src/responses/compact.ts b/packages/protocols/src/responses/compact.ts new file mode 100644 index 0000000000..8d808a2695 --- /dev/null +++ b/packages/protocols/src/responses/compact.ts @@ -0,0 +1,50 @@ +import type { + CanonicalResponsesPayload, + ResponsesInputItem, + ResponsesPromptCacheOptions, + ResponsesPromptCacheRetention, +} from './index.ts'; + +// Narrower payload for `/responses/compact`. The official endpoint accepts a +// strict subset of `/responses` fields — model/input/instructions/ +// previous_response_id/prompt_cache_*/service_tier — plus we honour `store` +// as a gateway-policy hint for snapshot persistence. Anything from +// `ResponsesPayload` not listed here (tools, temperature, max_output_tokens, +// reasoning, stream, etc.) is create-only and would be rejected or silently +// ignored by the upstream compact endpoint. +// Reference: https://developers.openai.com/api/reference/resources/responses/methods/compact +export interface ResponsesCompactPayload { + model: string; + input: string | ResponsesInputItem[]; + instructions?: string | null; + previous_response_id?: string | null; + prompt_cache_key?: string | null; + prompt_cache_options?: ResponsesPromptCacheOptions | null; + prompt_cache_retention?: ResponsesPromptCacheRetention | null; + service_tier?: 'default' | 'auto' | 'flex' | 'priority' | 'scale' | (string & {}) | null; + // Gateway-only: controls whether the compact response's output items + the + // committed snapshot persist. Forwarded NEITHER to upstream nor to the + // provider call body. + store?: boolean | null; +} + +export type CanonicalResponsesCompactPayload = Omit & { + input: ResponsesInputItem[]; +}; + +// Project a (possibly-wider) ResponsesPayload-shaped object into the strict +// compact wire shape. Every native-compact provider terminal calls this +// before dispatching to its upstream's `/responses/compact` endpoint, so a +// post-chain action pivot that arrived carrying generate-only fields +// (tools/temperature/reasoning/...) cannot leak them onto the compact wire. +// `model` and `store` are caller-supplied at the dispatch site (model is +// the resolved upstream id; store is gateway-only). +export const toCompactPayloadShape = (payload: Omit): Omit => ({ + input: payload.input, + ...(payload.instructions !== undefined && { instructions: payload.instructions }), + ...(payload.previous_response_id !== undefined && { previous_response_id: payload.previous_response_id }), + ...(payload.prompt_cache_key !== undefined && { prompt_cache_key: payload.prompt_cache_key }), + ...(payload.prompt_cache_options !== undefined && { prompt_cache_options: payload.prompt_cache_options }), + ...(payload.prompt_cache_retention !== undefined && { prompt_cache_retention: payload.prompt_cache_retention }), + ...(payload.service_tier !== undefined && { service_tier: payload.service_tier }), +}); diff --git a/packages/protocols/src/responses/index_test.ts b/packages/protocols/src/responses/compact_test.ts similarity index 94% rename from packages/protocols/src/responses/index_test.ts rename to packages/protocols/src/responses/compact_test.ts index fae4070a38..34ed27a0c8 100644 --- a/packages/protocols/src/responses/index_test.ts +++ b/packages/protocols/src/responses/compact_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { toCompactPayloadShape } from './index.ts'; +import { toCompactPayloadShape } from './compact.ts'; import { assertEquals } from '@floway-dev/test-utils'; test('toCompactPayloadShape preserves compact cache controls', () => { diff --git a/packages/protocols/src/responses/index.ts b/packages/protocols/src/responses/index.ts index bf38a6d7ab..4d6680abfd 100644 --- a/packages/protocols/src/responses/index.ts +++ b/packages/protocols/src/responses/index.ts @@ -62,54 +62,6 @@ export interface ResponsesPayload { service_tier?: 'default' | 'auto' | 'flex' | 'priority' | 'scale' | (string & {}) | null; } -// Narrower payload for `/responses/compact`. The official endpoint accepts a -// strict subset of `/responses` fields — model/input/instructions/ -// previous_response_id/prompt_cache_*/service_tier — plus we honour `store` -// as a gateway-policy hint for snapshot persistence. Anything from -// `ResponsesPayload` not listed here (tools, temperature, max_output_tokens, -// reasoning, stream, etc.) is create-only and would be rejected or silently -// ignored by the upstream compact endpoint. -// Reference: https://developers.openai.com/api/reference/resources/responses/methods/compact -export interface ResponsesCompactPayload { - model: string; - input: string | ResponsesInputItem[]; - instructions?: string | null; - previous_response_id?: string | null; - prompt_cache_key?: string | null; - prompt_cache_options?: ResponsesPromptCacheOptions | null; - prompt_cache_retention?: ResponsesPromptCacheRetention | null; - service_tier?: 'default' | 'auto' | 'flex' | 'priority' | 'scale' | (string & {}) | null; - // Gateway-only: controls whether the compact response's output items + the - // committed snapshot persist. Forwarded NEITHER to upstream nor to the - // provider call body. - store?: boolean | null; -} - -export type ResponsesCompactRequestPayload = Omit & { - input: string | ResponsesRequestInputItem[]; -}; - -export type CanonicalResponsesCompactPayload = Omit & { - input: ResponsesInputItem[]; -}; - -// Project a (possibly-wider) ResponsesPayload-shaped object into the strict -// compact wire shape. Every native-compact provider terminal calls this -// before dispatching to its upstream's `/responses/compact` endpoint, so a -// post-chain action pivot that arrived carrying generate-only fields -// (tools/temperature/reasoning/...) cannot leak them onto the compact wire. -// `model` and `store` are caller-supplied at the dispatch site (model is -// the resolved upstream id; store is gateway-only). -export const toCompactPayloadShape = (payload: Omit): Omit => ({ - input: payload.input, - ...(payload.instructions !== undefined && { instructions: payload.instructions }), - ...(payload.previous_response_id !== undefined && { previous_response_id: payload.previous_response_id }), - ...(payload.prompt_cache_key !== undefined && { prompt_cache_key: payload.prompt_cache_key }), - ...(payload.prompt_cache_options !== undefined && { prompt_cache_options: payload.prompt_cache_options }), - ...(payload.prompt_cache_retention !== undefined && { prompt_cache_retention: payload.prompt_cache_retention }), - ...(payload.service_tier !== undefined && { service_tier: payload.service_tier }), -}); - export type ResponsesInputItem = | ResponsesInputMessage | ResponsesFunctionToolCallItem @@ -1277,6 +1229,11 @@ export const isResponsesTerminalEvent = (event: Pick 'response' in event ? event.response : null; +export { + type CanonicalResponsesCompactPayload, + type ResponsesCompactPayload, + toCompactPayloadShape, +} from './compact.ts'; export { responsesResultToEvents } from './from-result.ts'; export { imageGenerationCallLifecycleEvents } from './image-generation-lifecycle.ts'; export { webSearchCallLifecycleEvents } from './web-search-lifecycle.ts'; diff --git a/packages/protocols/src/responses/stream.ts b/packages/protocols/src/responses/stream.ts index dc55e7467e..381e639640 100644 --- a/packages/protocols/src/responses/stream.ts +++ b/packages/protocols/src/responses/stream.ts @@ -1,7 +1,7 @@ import { isResponsesTerminalEvent, type ResponsesResult, responsesResultToEvents, type ResponsesStreamEvent } from './index.ts'; +import { parseSSEStream } from '../common/parse-sse.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '../common/sse.ts'; import { parseTargetStreamFrames } from '../common/stream/parse-events.ts'; -import { parseSSEStream } from '../common/stream/parse-sse.ts'; export interface ParseResponsesStreamOptions { signal?: AbortSignal; diff --git a/packages/provider-azure/src/config.ts b/packages/provider-azure/src/config.ts index f0aa5aae86..7914196770 100644 --- a/packages/provider-azure/src/config.ts +++ b/packages/provider-azure/src/config.ts @@ -1,3 +1,4 @@ +import { azureEndpointField } from './endpoint.ts'; import { type UpstreamModelConfig, type UpstreamRecord, isRecord, modelsField, nonEmptyStringField } from '@floway-dev/provider'; export interface AzureUpstreamConfig { @@ -11,55 +12,6 @@ export type AzureUpstreamRecord = UpstreamRecord & { config: AzureUpstreamConfig; }; -const AZURE_ENDPOINT_HOST_SUFFIXES = ['.openai.azure.com', '.services.ai.azure.com']; - -// Path-shape predicates shared by validation (here) and URL resolution -// (./fetch.ts). Exported so the fetch layer recognises the same endpoint -// shapes the validator admits. -export const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, ''); -export const isFoundryProjectRootPath = (path: string): boolean => /^\/api\/projects\/[^/]+$/.test(path); -const isAnthropicBasePath = (path: string): boolean => path === '/anthropic' || path === '/anthropic/v1' || path === '/anthropic/v1/messages'; -const isAzureEndpointHost = (hostname: string): boolean => - AZURE_ENDPOINT_HOST_SUFFIXES.some(suffix => hostname.endsWith(suffix) && hostname.length > suffix.length); - -// All azure-local field validators take the same fully-qualified label -// (`azure upstream config: `) the shared model-config helpers expect, -// so every message reads `Malformed azure upstream config: : `. -const optionalHttpUrlField = (value: unknown, label: string): string | undefined => { - if (value === undefined) return undefined; - const url = trimTrailingSlash(nonEmptyStringField(value, label).trim()); - if (url.includes('?') || url.includes('#')) { - throw new Error(`Malformed ${label}: must be an http(s) URL without query or fragment`); - } - try { - const parsed = new URL(url); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - throw new Error('invalid protocol'); - } - if (parsed.search || parsed.hash) { - throw new Error('query or fragment'); - } - } catch { - throw new Error(`Malformed ${label}: must be an http(s) URL without query or fragment`); - } - return url; -}; - -const azureEndpointField = (value: unknown, label: string): string => { - const url = optionalHttpUrlField(value, label); - if (!url) throw new Error(`Malformed ${label}: is required`); - const parsed = new URL(url); - if (parsed.protocol !== 'https:' || !isAzureEndpointHost(parsed.hostname)) { - throw new Error(`Malformed ${label}: must be an https Azure URL on *.openai.azure.com or *.services.ai.azure.com`); - } - - const path = trimTrailingSlash(parsed.pathname); - if (path !== '' && !isFoundryProjectRootPath(path) && !path.endsWith('/openai/v1') && !isAnthropicBasePath(path)) { - throw new Error(`Malformed ${label}: must be an Azure resource root, a Foundry project endpoint, an OpenAI v1 URL ending in /openai/v1, an /anthropic URL, an /anthropic/v1 URL, or an /anthropic/v1/messages URL`); - } - return url; -}; - export const assertAzureUpstreamRecord = (record: UpstreamRecord): AzureUpstreamRecord => { if (record.kind !== 'azure') throw new Error(`Expected azure upstream record, got ${record.kind}`); if (!isRecord(record.config)) throw new Error('Malformed azure upstream config: config must be an object'); diff --git a/packages/provider-azure/src/config_test.ts b/packages/provider-azure/src/config_test.ts index c49847e324..0974e0fe06 100644 --- a/packages/provider-azure/src/config_test.ts +++ b/packages/provider-azure/src/config_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { assertAzureUpstreamRecord } from './index.ts'; +import { assertAzureUpstreamRecord } from './config.ts'; import type { UpstreamRecord } from '@floway-dev/provider'; import { assertEquals, assertThrows } from '@floway-dev/test-utils'; diff --git a/packages/provider-azure/src/endpoint.ts b/packages/provider-azure/src/endpoint.ts new file mode 100644 index 0000000000..5097a0eb4f --- /dev/null +++ b/packages/provider-azure/src/endpoint.ts @@ -0,0 +1,80 @@ +import { nonEmptyStringField } from '@floway-dev/provider'; + +const AZURE_ENDPOINT_HOST_SUFFIXES = ['.openai.azure.com', '.services.ai.azure.com']; + +export const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, ''); +export const isFoundryProjectRootPath = (path: string): boolean => /^\/api\/projects\/[^/]+$/.test(path); +const isAnthropicBasePath = (path: string): boolean => path === '/anthropic' || path === '/anthropic/v1' || path === '/anthropic/v1/messages'; +const isAzureEndpointHost = (hostname: string): boolean => + AZURE_ENDPOINT_HOST_SUFFIXES.some(suffix => hostname.endsWith(suffix) && hostname.length > suffix.length); + +// All azure-local field validators take the same fully-qualified label +// (`azure upstream config: `) the shared model-config helpers expect, +// so every message reads `Malformed azure upstream config: : `. +const optionalHttpUrlField = (value: unknown, label: string): string | undefined => { + if (value === undefined) return undefined; + const url = trimTrailingSlash(nonEmptyStringField(value, label).trim()); + if (url.includes('?') || url.includes('#')) { + throw new Error(`Malformed ${label}: must be an http(s) URL without query or fragment`); + } + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('invalid protocol'); + } + if (parsed.search || parsed.hash) { + throw new Error('query or fragment'); + } + } catch { + throw new Error(`Malformed ${label}: must be an http(s) URL without query or fragment`); + } + return url; +}; + +export const azureEndpointField = (value: unknown, label: string): string => { + const url = optionalHttpUrlField(value, label); + if (!url) throw new Error(`Malformed ${label}: is required`); + const parsed = new URL(url); + if (parsed.protocol !== 'https:' || !isAzureEndpointHost(parsed.hostname)) { + throw new Error(`Malformed ${label}: must be an https Azure URL on *.openai.azure.com or *.services.ai.azure.com`); + } + + const path = trimTrailingSlash(parsed.pathname); + if (path !== '' && !isFoundryProjectRootPath(path) && !path.endsWith('/openai/v1') && !isAnthropicBasePath(path)) { + throw new Error(`Malformed ${label}: must be an Azure resource root, a Foundry project endpoint, an OpenAI v1 URL ending in /openai/v1, an /anthropic URL, an /anthropic/v1 URL, or an /anthropic/v1/messages URL`); + } + return url; +}; + +export const azureOpenAiV1BaseUrl = (endpoint: string): string => { + const url = new URL(trimTrailingSlash(endpoint)); + const path = trimTrailingSlash(url.pathname); + if (path.endsWith('/openai/v1')) { + url.pathname = path; + } else if (path === '/anthropic/v1/messages' || path === '/anthropic/v1' || path === '/anthropic') { + url.pathname = '/openai/v1'; + } else if (isFoundryProjectRootPath(path)) { + url.pathname = `${path}/openai/v1`; + } else { + url.pathname = '/openai/v1'; + } + return trimTrailingSlash(url.href); +}; + +export const azureAnthropicBaseUrl = (endpoint: string): string => { + const url = new URL(trimTrailingSlash(endpoint)); + if (url.hostname.endsWith('.openai.azure.com')) { + url.hostname = `${url.hostname.slice(0, -'.openai.azure.com'.length)}.services.ai.azure.com`; + } + const path = trimTrailingSlash(url.pathname); + if (path === '/anthropic/v1/messages') { + url.pathname = path.slice(0, -'/v1/messages'.length); + } else if (path === '/anthropic/v1') { + url.pathname = path.slice(0, -3); + } else if (path === '/anthropic') { + url.pathname = path; + } else { + url.pathname = '/anthropic'; + } + return trimTrailingSlash(url.href); +}; diff --git a/packages/provider-azure/src/fetch.ts b/packages/provider-azure/src/fetch.ts index a8e4315c7f..4dad0ae8f8 100644 --- a/packages/provider-azure/src/fetch.ts +++ b/packages/provider-azure/src/fetch.ts @@ -1,39 +1,7 @@ -import { type AzureUpstreamConfig, isFoundryProjectRootPath, trimTrailingSlash } from './config.ts'; +import type { AzureUpstreamConfig } from './config.ts'; +import { azureAnthropicBaseUrl, azureOpenAiV1BaseUrl } from './endpoint.ts'; import { type UpstreamFetchOptions, joinBaseAndPath } from '@floway-dev/provider'; -const azureOpenAiV1BaseUrl = (endpoint: string): string => { - const url = new URL(trimTrailingSlash(endpoint)); - const path = trimTrailingSlash(url.pathname); - if (path.endsWith('/openai/v1')) { - url.pathname = path; - } else if (path === '/anthropic/v1/messages' || path === '/anthropic/v1' || path === '/anthropic') { - url.pathname = '/openai/v1'; - } else if (isFoundryProjectRootPath(path)) { - url.pathname = `${path}/openai/v1`; - } else { - url.pathname = '/openai/v1'; - } - return trimTrailingSlash(url.href); -}; - -const azureAnthropicBaseUrl = (endpoint: string): string => { - const url = new URL(trimTrailingSlash(endpoint)); - if (url.hostname.endsWith('.openai.azure.com')) { - url.hostname = `${url.hostname.slice(0, -'.openai.azure.com'.length)}.services.ai.azure.com`; - } - const path = trimTrailingSlash(url.pathname); - if (path === '/anthropic/v1/messages') { - url.pathname = path.slice(0, -'/v1/messages'.length); - } else if (path === '/anthropic/v1') { - url.pathname = path.slice(0, -3); - } else if (path === '/anthropic') { - url.pathname = path; - } else { - url.pathname = '/anthropic'; - } - return trimTrailingSlash(url.href); -}; - const azureFetchUrl = async ( config: AzureUpstreamConfig, surface: 'openai' | 'anthropic', diff --git a/packages/provider-azure/src/index.ts b/packages/provider-azure/src/index.ts index 4a2f1a066a..233f4596b0 100644 --- a/packages/provider-azure/src/index.ts +++ b/packages/provider-azure/src/index.ts @@ -2,10 +2,8 @@ import { AZURE_DEFAULT_FLAGS } from './defaults.ts'; import { createAzureProvider } from './provider.ts'; import type { ProviderModule } from '@floway-dev/provider'; -export const azureProvider: ProviderModule = { +export const azureProviderModule: ProviderModule = { create: createAzureProvider, defaultFlags: AZURE_DEFAULT_FLAGS, }; - -export { createAzureProvider } from './provider.ts'; export { assertAzureUpstreamRecord } from './config.ts'; diff --git a/packages/provider-azure/src/provider.ts b/packages/provider-azure/src/provider.ts index 173fbce008..2cfcdb1283 100644 --- a/packages/provider-azure/src/provider.ts +++ b/packages/provider-azure/src/provider.ts @@ -107,7 +107,7 @@ export const createAzureProvider = (record: UpstreamRecord): Provider => { }; return { - upstream: azure.id, + upstreamId: azure.id, kind: 'azure', name: azure.name, disabledPublicModelIds: azure.disabledPublicModelIds, diff --git a/packages/provider-azure/src/provider_test.ts b/packages/provider-azure/src/provider_test.ts index 58d587641e..3516667189 100644 --- a/packages/provider-azure/src/provider_test.ts +++ b/packages/provider-azure/src/provider_test.ts @@ -49,7 +49,7 @@ test('createAzureProvider projects configured models into upstream models', asyn const instance = createAzureProvider(azureRecord({ flagOverrides: { 'vendor-kimi': true } })); const models = await instance.instance.getProvidedModels(directFetcher); - assertEquals(instance.upstream, 'up_azure'); + assertEquals(instance.upstreamId, 'up_azure'); assertEquals(instance.name, 'Azure Resource'); assertEquals(models[0]?.enabledFlags.has('vendor-kimi'), true); assertEquals( diff --git a/packages/provider-azure/tsconfig.json b/packages/provider-azure/tsconfig.json index 9e25e6ece9..3b64db1d69 100644 --- a/packages/provider-azure/tsconfig.json +++ b/packages/provider-azure/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src/**/*.ts"] + "include": ["vitest.config.ts", "src/**/*.ts"] } diff --git a/packages/provider-claude-code/src/access-token-cache.ts b/packages/provider-claude-code/src/access-token.ts similarity index 99% rename from packages/provider-claude-code/src/access-token-cache.ts rename to packages/provider-claude-code/src/access-token.ts index 17fcddcf8b..ca1dfd5f03 100644 --- a/packages/provider-claude-code/src/access-token-cache.ts +++ b/packages/provider-claude-code/src/access-token.ts @@ -171,7 +171,7 @@ const ensureClaudeCodeAccessTokenInner = async ( }; // Refresh-token rotation: CAS-write the new refresh token alongside the - // fresh access-token cache in a single state transition. `state` / + // fresh access-token entry in a single state transition. `state` / // `stateUpdatedAt` stay untouched on a successful refresh — 'active' is // already what we want, and bumping the timestamp on every refresh would // muddy the dashboard's "credential health changed" signal. diff --git a/packages/provider-claude-code/src/access-token-cache_test.ts b/packages/provider-claude-code/src/access-token_test.ts similarity index 99% rename from packages/provider-claude-code/src/access-token-cache_test.ts rename to packages/provider-claude-code/src/access-token_test.ts index a8a0d1a12d..f5f1e949d3 100644 --- a/packages/provider-claude-code/src/access-token-cache_test.ts +++ b/packages/provider-claude-code/src/access-token_test.ts @@ -4,7 +4,7 @@ import { ensureClaudeCodeAccessToken, invalidateClaudeCodeAccessToken, type ClaudeCodeAccessTokenEntry, -} from './access-token-cache.ts'; +} from './access-token.ts'; import { ClaudeCodeOAuthSessionTerminatedError } from './auth/oauth.ts'; import type { ClaudeCodeUpstreamConfig } from './config.ts'; import type { ClaudeCodeUpstreamState } from './state.ts'; @@ -250,7 +250,7 @@ describe('ensureClaudeCodeAccessToken (within-isolate herd coalescing)', () => { expect(r.entry.token).toBe('at_new'); } // Every coalesced waiter reports `freshlyMinted: true` — the contract - // documented on `EnsuredAccessToken` in access-token-cache.ts is "this + // documented on `EnsuredAccessToken` in access-token.ts is "this // call site shared in a real mint," not "drove the mint itself." All // ten callers fanned out onto the single in-flight promise here, so // all ten observe `freshlyMinted: true`. diff --git a/packages/provider-claude-code/src/auth/identity_test.ts b/packages/provider-claude-code/src/auth/identity_test.ts index 6a5421847b..2fb7991b9f 100644 --- a/packages/provider-claude-code/src/auth/identity_test.ts +++ b/packages/provider-claude-code/src/auth/identity_test.ts @@ -1,9 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { fetchClaudeCodeIdentity } from './identity.ts'; - -const jsonResponse = (body: unknown, status = 200): Response => - new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +import { jsonResponse } from '@floway-dev/test-utils'; afterEach(() => vi.restoreAllMocks()); diff --git a/packages/provider-claude-code/src/auth/import_test.ts b/packages/provider-claude-code/src/auth/import_test.ts index 29fe721847..34cb3006f3 100644 --- a/packages/provider-claude-code/src/auth/import_test.ts +++ b/packages/provider-claude-code/src/auth/import_test.ts @@ -6,6 +6,7 @@ import { importClaudeCodeFromSetupTokenCallback, } from './import.ts'; import { directFetcher, type Fetcher } from '@floway-dev/provider'; +import { jsonResponse } from '@floway-dev/test-utils'; const profileResponse = { account: { @@ -31,9 +32,6 @@ const tokenResponse = { scope: 'user:inference', }; -const jsonResponse = (body: unknown, status = 200): Response => - new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); - afterEach(() => vi.restoreAllMocks()); describe('importClaudeCodeFromCallback', () => { diff --git a/packages/provider-claude-code/src/auth/oauth.ts b/packages/provider-claude-code/src/auth/oauth.ts index 383bd2ddcb..95f48d7c85 100644 --- a/packages/provider-claude-code/src/auth/oauth.ts +++ b/packages/provider-claude-code/src/auth/oauth.ts @@ -11,7 +11,7 @@ import { import { type Fetcher } from '@floway-dev/provider'; // Discriminates the two PKCE flows. `oauth` is the full Claude Code CLI -// sign-in: 6-scope grant that mints a short-lived access token + rotating +// sign-in: three-scope grant that mints a short-lived access token + rotating // refresh token. `setup-token` is the inference-only long-lived bearer that // Anthropic's "Create a Long-Lived Token" UI issues; no refresh_token, ~1 // year validity, cannot mint API keys. Both share authorize host, client_id, @@ -19,7 +19,7 @@ import { type Fetcher } from '@floway-dev/provider'; // and the optional `expires_in` on the exchange body differ. export type ClaudeCodeOAuthFlowKind = 'oauth' | 'setup-token'; -export interface ClaudeOAuthTokenResponse { +export interface ClaudeCodeOAuthTokenResponse { access_token: string; expires_in: number; // Absent on setup-token exchanges (the long-lived bearer has no rotation @@ -33,7 +33,7 @@ export interface ClaudeOAuthTokenResponse { // from generic OAuth 4xx so callers can react to session-termination // separately from a transient upstream message. `code` carries the raw OAuth // `error` value (`invalid_grant`, `app_session_terminated`, etc.) so the -// refresh-race recovery in the access-token cache can single out +// refresh-race recovery in the access-token module can single out // `invalid_grant` — which is the only terminal code that might actually mean // "a sibling worker just rotated the refresh token, and our copy is stale" — // from codes that signal genuine credential death under any race scenario. @@ -72,7 +72,7 @@ const claudeCodeTokenRequest = async ( body: Record, terminalCodes: ReadonlySet, fetcher: Fetcher, -): Promise => { +): Promise => { const response = await fetcher(CLAUDE_CODE_OAUTH_TOKEN_URL, { method: 'POST', headers: { @@ -160,7 +160,7 @@ export const exchangeClaudeCodeAuthorizationCode = async (opts: { state: string; kind: ClaudeCodeOAuthFlowKind; fetcher: Fetcher; -}): Promise => { +}): Promise => { const body: Record = { grant_type: 'authorization_code', code: opts.code, @@ -185,7 +185,7 @@ export const exchangeClaudeCodeAuthorizationCode = async (opts: { export const refreshClaudeCodeAccessToken = async ( refreshToken: string, fetcher: Fetcher, -): Promise => { +): Promise => { const body = { grant_type: 'refresh_token', refresh_token: refreshToken, diff --git a/packages/provider-claude-code/src/detection.ts b/packages/provider-claude-code/src/detection.ts index 68a142eac5..562b56b108 100644 --- a/packages/provider-claude-code/src/detection.ts +++ b/packages/provider-claude-code/src/detection.ts @@ -1,4 +1,4 @@ -import type { MessagesPayload } from '@floway-dev/protocols'; +import type { MessagesPayload } from '@floway-dev/protocols/messages'; // Decide whether an inbound /v1/messages request is already shaped like a // real Claude Code session and can pass through unmodified, or whether it @@ -125,6 +125,11 @@ const matchesAnyIdentityTemplate = (text: string): boolean => { const looksLikeBillingBlock = (text: string): boolean => text.startsWith('x-anthropic-billing-header') && text.includes('cc_entrypoint=cli'); +// Real Claude Code's periodic connectivity probe carries max_tokens=1 against +// a Haiku id and no system block. +const detectHaikuProbe = (body: MessagesPayload): boolean => + body.model.includes('haiku') && body.max_tokens === 1; + const extractSystemTexts = (body: MessagesPayload): string[] => { const { system } = body; if (!system) return []; @@ -140,7 +145,6 @@ const extractSystemTexts = (body: MessagesPayload): string[] => { export interface ClaudeCodeShapedRequestInput { headers: Headers; body: MessagesPayload; - isMaxTokensOneHaikuProbe: boolean; } export const isClaudeCodeShapedRequest = (input: ClaudeCodeShapedRequestInput): boolean => { @@ -149,7 +153,7 @@ export const isClaudeCodeShapedRequest = (input: ClaudeCodeShapedRequestInput): // Real CC's periodic Haiku connectivity probe sends max_tokens=1 with no // system; surface it as CC-shaped without further checks. - if (input.isMaxTokensOneHaikuProbe) return true; + if (detectHaikuProbe(input.body)) return true; if (!input.headers.get('x-app')) return false; if (!input.headers.get('anthropic-beta')) return false; diff --git a/packages/provider-claude-code/src/detection_test.ts b/packages/provider-claude-code/src/detection_test.ts index d6f1dc03ee..61923900e4 100644 --- a/packages/provider-claude-code/src/detection_test.ts +++ b/packages/provider-claude-code/src/detection_test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import { isClaudeCodeShapedRequest, parseMetadataUserID } from './detection.ts'; -import type { MessagesPayload, MessagesTextBlock } from '@floway-dev/protocols'; +import type { MessagesPayload, MessagesTextBlock } from '@floway-dev/protocols/messages'; const validUserIdLegacy = 'user_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' @@ -73,7 +73,6 @@ describe('isClaudeCodeShapedRequest — UA gate', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders({ 'user-agent': 'claude-cli/2.1.181' }), body: bodyWithSystem("You are Claude Code, Anthropic's official CLI for Claude."), - isMaxTokensOneHaikuProbe: false, })).toBe(true); }); @@ -81,7 +80,6 @@ describe('isClaudeCodeShapedRequest — UA gate', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders({ 'user-agent': 'claude-cli/2.1.10' }), body: bodyWithSystem("You are Claude Code, Anthropic's official CLI for Claude."), - isMaxTokensOneHaikuProbe: false, })).toBe(true); }); @@ -89,7 +87,6 @@ describe('isClaudeCodeShapedRequest — UA gate', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders({ 'user-agent': 'claude-cli/2.1' }), body: bodyWithSystem("You are Claude Code, Anthropic's official CLI for Claude."), - isMaxTokensOneHaikuProbe: false, })).toBe(false); }); @@ -97,7 +94,6 @@ describe('isClaudeCodeShapedRequest — UA gate', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders({ 'user-agent': 'not-claude-cli/2.1.10' }), body: bodyWithSystem("You are Claude Code, Anthropic's official CLI for Claude."), - isMaxTokensOneHaikuProbe: false, })).toBe(false); }); @@ -105,7 +101,6 @@ describe('isClaudeCodeShapedRequest — UA gate', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders({ 'user-agent': 'claude-cli/' }), body: bodyWithSystem("You are Claude Code, Anthropic's official CLI for Claude."), - isMaxTokensOneHaikuProbe: false, })).toBe(false); }); @@ -115,22 +110,21 @@ describe('isClaudeCodeShapedRequest — UA gate', () => { expect(isClaudeCodeShapedRequest({ headers: h, body: bodyWithSystem("You are Claude Code, Anthropic's official CLI for Claude."), - isMaxTokensOneHaikuProbe: false, })).toBe(false); }); }); describe('isClaudeCodeShapedRequest — short-circuit paths', () => { - test('max_tokens=1 Haiku probe passes without system/metadata', () => { - expect(isClaudeCodeShapedRequest({ + test('only a max_tokens=1 Haiku probe passes without system/metadata', () => { + const shaped = (model: string, maxTokens: number): boolean => isClaudeCodeShapedRequest({ headers: baseHeaders(), - body: { - model: 'claude-haiku-4-5-20251001', - max_tokens: 1, - messages: [{ role: 'user', content: 'quota' }], - }, - isMaxTokensOneHaikuProbe: true, - })).toBe(true); + body: { model, max_tokens: maxTokens, messages: [{ role: 'user', content: 'quota' }] }, + }); + + expect(shaped('claude-haiku-4-5-20251001', 1)).toBe(true); + expect(shaped('claude-haiku-4-5-20251001', 2)).toBe(false); + expect(shaped('claude-sonnet-4-5-20250929', 1)).toBe(false); + expect(shaped('', 1)).toBe(false); }); }); @@ -139,7 +133,6 @@ describe('isClaudeCodeShapedRequest — billing-block fast path', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders(), body: bodyWithSystem('x-anthropic-billing-header: cc_version=2.1.181.abc; cc_entrypoint=cli; cch=00000;'), - isMaxTokensOneHaikuProbe: false, })).toBe(true); }); @@ -147,7 +140,6 @@ describe('isClaudeCodeShapedRequest — billing-block fast path', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders(), body: bodyWithSystem('x-anthropic-billing-header: cc_version=2.1.181.abc; some_other_marker=1;'), - isMaxTokensOneHaikuProbe: false, })).toBe(false); }); }); @@ -165,7 +157,6 @@ describe('isClaudeCodeShapedRequest — Dice template fallback', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders(), body: bodyWithSystem(text), - isMaxTokensOneHaikuProbe: false, })).toBe(true); }); @@ -173,7 +164,6 @@ describe('isClaudeCodeShapedRequest — Dice template fallback', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders(), body: bodyWithSystem('Translate the following passage into French and preserve the original meter.'), - isMaxTokensOneHaikuProbe: false, })).toBe(false); }); }); @@ -185,7 +175,6 @@ describe('isClaudeCodeShapedRequest — strict header gate', () => { expect(isClaudeCodeShapedRequest({ headers: h, body: bodyWithSystem("You are Claude Code, Anthropic's official CLI for Claude."), - isMaxTokensOneHaikuProbe: false, })).toBe(false); }); }); @@ -195,7 +184,6 @@ describe('isClaudeCodeShapedRequest — metadata.user_id', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders(), body: bodyWithSystem("You are Claude Code, Anthropic's official CLI for Claude.", validUserIdLegacy), - isMaxTokensOneHaikuProbe: false, })).toBe(true); }); @@ -203,7 +191,6 @@ describe('isClaudeCodeShapedRequest — metadata.user_id', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders(), body: bodyWithSystem("You are Claude Code, Anthropic's official CLI for Claude.", validUserIdJson), - isMaxTokensOneHaikuProbe: false, })).toBe(true); }); @@ -217,7 +204,6 @@ describe('isClaudeCodeShapedRequest — metadata.user_id', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders(), body, - isMaxTokensOneHaikuProbe: false, })).toBe(false); }); @@ -227,7 +213,6 @@ describe('isClaudeCodeShapedRequest — metadata.user_id', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders(), body, - isMaxTokensOneHaikuProbe: false, })).toBe(false); }); }); @@ -244,7 +229,6 @@ describe('isClaudeCodeShapedRequest — system shape variants', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders(), body, - isMaxTokensOneHaikuProbe: false, })).toBe(true); }); @@ -254,7 +238,6 @@ describe('isClaudeCodeShapedRequest — system shape variants', () => { expect(isClaudeCodeShapedRequest({ headers: baseHeaders(), body, - isMaxTokensOneHaikuProbe: false, })).toBe(false); }); }); diff --git a/packages/provider-claude-code/src/fetch.ts b/packages/provider-claude-code/src/fetch.ts index a87bdc7080..ece5d9c0cd 100644 --- a/packages/provider-claude-code/src/fetch.ts +++ b/packages/provider-claude-code/src/fetch.ts @@ -1,13 +1,13 @@ -import { ensureClaudeCodeAccessToken, invalidateClaudeCodeAccessToken, type EnsuredAccessToken } from './access-token-cache.ts'; +import { ensureClaudeCodeAccessToken, invalidateClaudeCodeAccessToken, type EnsuredAccessToken } from './access-token.ts'; import { ClaudeCodeOAuthSessionTerminatedError } from './auth/oauth.ts'; import { pickClaudeCodeHeaders } from './headers.ts'; import { logWarn, logInfo } from './log.ts'; +import type { ClaudeCodeProviderData } from './models.ts'; import { parseClaudeCodeQuotaHeaders, type ClaudeCodeQuotaSnapshot } from './quota.ts'; import { readClaudeCodeUpstreamState, replaceSoleAccount, } from './state.ts'; -import type { ClaudeCodeProviderData } from './types.ts'; import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; import { parseMessagesStream } from '@floway-dev/protocols/messages'; import { @@ -20,16 +20,6 @@ import { const ANTHROPIC_MESSAGES_ENDPOINT = 'https://api.anthropic.com/v1/messages?beta=true'; -// Detection helper: the periodic CC connectivity probe sends `max_tokens: 1` -// against a haiku id (model name substring 'haiku') and never carries a -// system block. Surfacing those as CC-shaped lets them pass through without -// re-mimicry overhead, matching real CC's wire shape exactly. -export const detectHaikuProbe = (body: { model?: unknown; max_tokens?: unknown }): boolean => { - return typeof body.model === 'string' - && body.model.includes('haiku') - && body.max_tokens === 1; -}; - export interface CallClaudeCodeMessagesOptions { upstreamId: string; model: ProviderModel; @@ -237,7 +227,7 @@ const detectTerminalSentinel = (status: number, bodyText: string): string | null }; // Terminal flip from the data-plane sentinel detector. Distinct from -// access-token-cache.ts's `persistTerminalState`: this path runs in a +// access-token.ts's `persistTerminalState`: this path runs in a // fire-and-forget context with no caller-side state, so we re-read; the // flip is body-sentinel-triggered (org disabled/banned), not oauth-error- // triggered, so the log carries `upstream_status` instead of `oauth_code`; diff --git a/packages/provider-claude-code/src/fetch_test.ts b/packages/provider-claude-code/src/fetch_test.ts index a0751e345f..9f0790f622 100644 --- a/packages/provider-claude-code/src/fetch_test.ts +++ b/packages/provider-claude-code/src/fetch_test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { callClaudeCodeMessages, detectHaikuProbe } from './fetch.ts'; +import { callClaudeCodeMessages } from './fetch.ts'; import { CLAUDE_CODE_HEADERS_HAIKU, CLAUDE_CODE_HEADERS_SONNET_OPUS } from './headers.ts'; import type { ClaudeCodeAccessTokenEntry, @@ -127,15 +127,6 @@ const errorJson = (status: number, body: unknown, extraHeaders: Record { - test('true only when model contains "haiku" and max_tokens is exactly 1', () => { - expect(detectHaikuProbe({ model: 'claude-haiku-4-5-20251001', max_tokens: 1 })).toBe(true); - expect(detectHaikuProbe({ model: 'claude-haiku-4-5-20251001', max_tokens: 2 })).toBe(false); - expect(detectHaikuProbe({ model: 'claude-sonnet-4-5-20250929', max_tokens: 1 })).toBe(false); - expect(detectHaikuProbe({ max_tokens: 1 })).toBe(false); - }); -}); - describe('callClaudeCodeMessages — pre-fetch gates', () => { test('non-active account → synthetic 503', async () => { seedAccount({ state: 'session_terminated', stateMessage: 'revoked' }); diff --git a/packages/provider-claude-code/src/index.ts b/packages/provider-claude-code/src/index.ts index 5f3271d0b3..13fe12d88f 100644 --- a/packages/provider-claude-code/src/index.ts +++ b/packages/provider-claude-code/src/index.ts @@ -2,7 +2,7 @@ import { CLAUDE_CODE_DEFAULT_FLAGS } from './defaults.ts'; import { createClaudeCodeProvider } from './provider.ts'; import type { ProviderModule } from '@floway-dev/provider'; -export const claudeCodeProvider: ProviderModule = { +export const claudeCodeProviderModule: ProviderModule = { create: createClaudeCodeProvider, defaultFlags: CLAUDE_CODE_DEFAULT_FLAGS, }; @@ -10,20 +10,16 @@ export const claudeCodeProvider: ProviderModule = { export * from './config.ts'; export * from './state.ts'; export * from './constants.ts'; -export * from './access-token-cache.ts'; +export * from './access-token.ts'; export * from './auth/identity.ts'; export * from './auth/import.ts'; export * from './auth/oauth.ts'; -export * from './auth/usage-probe.ts'; +export * from './usage-probe.ts'; export * from './detection.ts'; export * from './headers.ts'; export * from './log.ts'; export * from './quota.ts'; -export * from './system-blocks.ts'; -export * from './models.ts'; +export * from './interceptors/messages/system-blocks.ts'; export * from './pricing.ts'; export * from './fetch.ts'; export * from './provider.ts'; -export * from './types.ts'; -export type { ClaudeCodeMessagesBoundaryCtx } from './interceptors/messages/index.ts'; -export { claudeCodeMessagesChain } from './interceptors/messages/index.ts'; diff --git a/packages/provider-claude-code/src/interceptors/messages/backfill-required-fields.ts b/packages/provider-claude-code/src/interceptors/messages/backfill-required-fields.ts index 7744ea68dd..1c573a6e08 100644 --- a/packages/provider-claude-code/src/interceptors/messages/backfill-required-fields.ts +++ b/packages/provider-claude-code/src/interceptors/messages/backfill-required-fields.ts @@ -1,4 +1,4 @@ -import type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import { MESSAGES_FALLBACK_MAX_TOKENS } from '@floway-dev/protocols/messages'; // Real Claude Code always sends `max_tokens` and `temperature` on every @@ -19,8 +19,8 @@ import { MESSAGES_FALLBACK_MAX_TOKENS } from '@floway-dev/protocols/messages'; // Positioned at the head of the chain so the rest of the re-mimicry steps // see a fully-formed payload. Caller-supplied values are never overwritten. export const backfillRequiredFields = async ( - ctx: ClaudeCodeMessagesBoundaryCtx, - _request: object, + ctx: MessagesBoundaryCtx, + _env: object, run: () => Promise, ): Promise => { const next = { ...ctx.payload }; diff --git a/packages/provider-claude-code/src/interceptors/messages/backfill-required-fields_test.ts b/packages/provider-claude-code/src/interceptors/messages/backfill-required-fields_test.ts index 2d7853efaf..cf2bbf1870 100644 --- a/packages/provider-claude-code/src/interceptors/messages/backfill-required-fields_test.ts +++ b/packages/provider-claude-code/src/interceptors/messages/backfill-required-fields_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { backfillRequiredFields } from './backfill-required-fields.ts'; -import type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import { MESSAGES_FALLBACK_MAX_TOKENS, type MessagesPayload, type MessagesStreamEvent } from '@floway-dev/protocols/messages'; import type { ProviderModel, ProviderStreamResult } from '@floway-dev/provider'; import { assertEquals, stubProviderModel } from '@floway-dev/test-utils'; @@ -9,7 +9,7 @@ import { assertEquals, stubProviderModel } from '@floway-dev/test-utils'; const okEvents = (): Promise> => Promise.resolve({ ok: true, events: (async function* () {})(), modelKey: 'test' }); -const invocation = (payload: MessagesPayload, model: ProviderModel = stubProviderModel({ endpoints: { messages: {} } })): ClaudeCodeMessagesBoundaryCtx => ({ +const invocation = (payload: MessagesPayload, model: ProviderModel = stubProviderModel({ endpoints: { messages: {} } })): MessagesBoundaryCtx => ({ payload, model, upstreamId: 'up_test', diff --git a/packages/provider-claude-code/src/interceptors/messages/hoist-user-system-to-messages.ts b/packages/provider-claude-code/src/interceptors/messages/hoist-user-system-to-messages.ts index d6cf59e7bd..accd2d94e4 100644 --- a/packages/provider-claude-code/src/interceptors/messages/hoist-user-system-to-messages.ts +++ b/packages/provider-claude-code/src/interceptors/messages/hoist-user-system-to-messages.ts @@ -1,4 +1,4 @@ -import type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import type { MessagesMessage, MessagesTextBlock } from '@floway-dev/protocols/messages'; // Synthetic assistant turn that closes the hoisted user/assistant pair so the @@ -28,8 +28,8 @@ const SYNTHETIC_ACK = 'Understood. I will follow these instructions.'; // References: // - https://github.com/Wei-Shaw/sub2api/blob/4a5665da5b2c6b83c4597844ea6e573746c821b1/backend/internal/service/gateway_service.go#L4480-L4486 export const hoistUserSystemToMessages = async ( - ctx: ClaudeCodeMessagesBoundaryCtx, - _request: object, + ctx: MessagesBoundaryCtx, + _env: object, run: () => Promise, ): Promise => { const system: string | MessagesTextBlock[] | undefined = ctx.payload.system; diff --git a/packages/provider-claude-code/src/interceptors/messages/hoist-user-system-to-messages_test.ts b/packages/provider-claude-code/src/interceptors/messages/hoist-user-system-to-messages_test.ts index d2e7ae532c..4db89533aa 100644 --- a/packages/provider-claude-code/src/interceptors/messages/hoist-user-system-to-messages_test.ts +++ b/packages/provider-claude-code/src/interceptors/messages/hoist-user-system-to-messages_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { hoistUserSystemToMessages } from './hoist-user-system-to-messages.ts'; -import type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; import type { ProviderStreamResult } from '@floway-dev/provider'; import { assertEquals, stubProviderModel } from '@floway-dev/test-utils'; @@ -9,7 +9,7 @@ import { assertEquals, stubProviderModel } from '@floway-dev/test-utils'; const okEvents = (): Promise> => Promise.resolve({ ok: true, events: (async function* () {})(), modelKey: 'test' }); -const invocation = (payload: MessagesPayload): ClaudeCodeMessagesBoundaryCtx => ({ +const invocation = (payload: MessagesPayload): MessagesBoundaryCtx => ({ payload, model: stubProviderModel({ endpoints: { messages: {} } }), upstreamId: 'up_test', diff --git a/packages/provider-claude-code/src/interceptors/messages/index.ts b/packages/provider-claude-code/src/interceptors/messages/index.ts index 6cbd7dbde8..51fc533741 100644 --- a/packages/provider-claude-code/src/interceptors/messages/index.ts +++ b/packages/provider-claude-code/src/interceptors/messages/index.ts @@ -38,12 +38,14 @@ import { injectBillingBlock } from './inject-billing-block.ts'; import { injectDefaultTemplate } from './inject-default-template.ts'; import { injectIdentityBlock } from './inject-identity-block.ts'; import { synthesizeMetadataUserId } from './synthesize-metadata-user-id.ts'; -import type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import type { Interceptor } from '@floway-dev/interceptor'; +import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; +import type { ProviderStreamResult } from '@floway-dev/provider'; -export type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; +export type { MessagesBoundaryCtx } from './types.ts'; -export const claudeCodeMessagesChain = (): readonly Interceptor[] => [ +export const CLAUDE_CODE_MESSAGES_BOUNDARY: readonly Interceptor>[] = [ backfillRequiredFields, synthesizeMetadataUserId, hoistUserSystemToMessages, diff --git a/packages/provider-claude-code/src/interceptors/messages/inject-billing-block.ts b/packages/provider-claude-code/src/interceptors/messages/inject-billing-block.ts index 8f49fc75a1..cf1be7591b 100644 --- a/packages/provider-claude-code/src/interceptors/messages/inject-billing-block.ts +++ b/packages/provider-claude-code/src/interceptors/messages/inject-billing-block.ts @@ -1,6 +1,6 @@ -import type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; +import { buildBillingBlock, computeCcVersionFingerprint } from './system-blocks.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import { CLAUDE_CLI_VERSION } from '../../headers.ts'; -import { buildBillingBlock, computeCcVersionFingerprint } from '../../system-blocks.ts'; // Drops the per-request `cc_version=${VERSION}.${FP}` billing block at the // head of `system`. This must run BEFORE inject-identity-block / @@ -17,8 +17,8 @@ import { buildBillingBlock, computeCcVersionFingerprint } from '../../system-blo // reflect it — fingerprinting the pre-hoist shape would compute a different // value than what the request body settles to and break CC mimicry. export const injectBillingBlock = async ( - ctx: ClaudeCodeMessagesBoundaryCtx, - _request: object, + ctx: MessagesBoundaryCtx, + _env: object, run: () => Promise, ): Promise => { const fingerprint = computeCcVersionFingerprint(CLAUDE_CLI_VERSION, ctx.payload); diff --git a/packages/provider-claude-code/src/interceptors/messages/inject-billing-block_test.ts b/packages/provider-claude-code/src/interceptors/messages/inject-billing-block_test.ts index 0ca3f54537..30426da6b1 100644 --- a/packages/provider-claude-code/src/interceptors/messages/inject-billing-block_test.ts +++ b/packages/provider-claude-code/src/interceptors/messages/inject-billing-block_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { injectBillingBlock } from './inject-billing-block.ts'; -import type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import { CLAUDE_CLI_VERSION } from '../../headers.ts'; import type { MessagesPayload, MessagesStreamEvent, MessagesTextBlock } from '@floway-dev/protocols/messages'; import type { ProviderStreamResult } from '@floway-dev/provider'; @@ -10,7 +10,7 @@ import { assertEquals, stubProviderModel } from '@floway-dev/test-utils'; const okEvents = (): Promise> => Promise.resolve({ ok: true, events: (async function* () {})(), modelKey: 'test' }); -const invocation = (payload: MessagesPayload): ClaudeCodeMessagesBoundaryCtx => ({ +const invocation = (payload: MessagesPayload): MessagesBoundaryCtx => ({ payload, model: stubProviderModel({ endpoints: { messages: {} } }), upstreamId: 'up_test', diff --git a/packages/provider-claude-code/src/interceptors/messages/inject-default-template.ts b/packages/provider-claude-code/src/interceptors/messages/inject-default-template.ts index 92e35986a0..5175878d93 100644 --- a/packages/provider-claude-code/src/interceptors/messages/inject-default-template.ts +++ b/packages/provider-claude-code/src/interceptors/messages/inject-default-template.ts @@ -1,5 +1,5 @@ -import type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; -import { DEFAULT_TEMPLATE_BLOCK } from '../../system-blocks.ts'; +import { DEFAULT_TEMPLATE_BLOCK } from './system-blocks.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import type { MessagesPayload } from '@floway-dev/protocols/messages'; // Anthropic's prompt-caching API rejects requests that carry more than four @@ -58,8 +58,8 @@ const countCacheBreakpoints = (payload: MessagesPayload): number => { // Chain order in ./index.ts guarantees `system` is already a fresh array // when this runs. export const injectDefaultTemplate = async ( - ctx: ClaudeCodeMessagesBoundaryCtx, - _request: object, + ctx: MessagesBoundaryCtx, + _env: object, run: () => Promise, ): Promise => { if (!Array.isArray(ctx.payload.system)) { diff --git a/packages/provider-claude-code/src/interceptors/messages/inject-default-template_test.ts b/packages/provider-claude-code/src/interceptors/messages/inject-default-template_test.ts index a961f4442b..3c32e4a868 100644 --- a/packages/provider-claude-code/src/interceptors/messages/inject-default-template_test.ts +++ b/packages/provider-claude-code/src/interceptors/messages/inject-default-template_test.ts @@ -1,8 +1,8 @@ import { test } from 'vitest'; import { injectDefaultTemplate } from './inject-default-template.ts'; -import type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; -import { DEFAULT_TEMPLATE_BLOCK, IDENTITY_BLOCK } from '../../system-blocks.ts'; +import { DEFAULT_TEMPLATE_BLOCK, IDENTITY_BLOCK } from './system-blocks.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import type { MessagesClientTool, MessagesPayload, MessagesStreamEvent, MessagesTextBlock } from '@floway-dev/protocols/messages'; import type { ProviderStreamResult } from '@floway-dev/provider'; import { assertEquals, stubProviderModel } from '@floway-dev/test-utils'; @@ -10,7 +10,7 @@ import { assertEquals, stubProviderModel } from '@floway-dev/test-utils'; const okEvents = (): Promise> => Promise.resolve({ ok: true, events: (async function* () {})(), modelKey: 'test' }); -const invocation = (payload: MessagesPayload): ClaudeCodeMessagesBoundaryCtx => ({ +const invocation = (payload: MessagesPayload): MessagesBoundaryCtx => ({ payload, model: stubProviderModel({ endpoints: { messages: {} } }), upstreamId: 'up_test', diff --git a/packages/provider-claude-code/src/interceptors/messages/inject-identity-block.ts b/packages/provider-claude-code/src/interceptors/messages/inject-identity-block.ts index f18194f36d..73a2fe2f2b 100644 --- a/packages/provider-claude-code/src/interceptors/messages/inject-identity-block.ts +++ b/packages/provider-claude-code/src/interceptors/messages/inject-identity-block.ts @@ -1,11 +1,11 @@ -import type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; -import { IDENTITY_BLOCK } from '../../system-blocks.ts'; +import { IDENTITY_BLOCK } from './system-blocks.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import type { MessagesTextBlock } from '@floway-dev/protocols/messages'; // system[1]; relies on injectBillingBlock having materialized payload.system as an array (see ./index.ts chain order). export const injectIdentityBlock = async ( - ctx: ClaudeCodeMessagesBoundaryCtx, - _request: object, + ctx: MessagesBoundaryCtx, + _env: object, run: () => Promise, ): Promise => { const system = ctx.payload.system as MessagesTextBlock[]; diff --git a/packages/provider-claude-code/src/interceptors/messages/inject-identity-block_test.ts b/packages/provider-claude-code/src/interceptors/messages/inject-identity-block_test.ts index 9b0ff3343f..8ee2d2687b 100644 --- a/packages/provider-claude-code/src/interceptors/messages/inject-identity-block_test.ts +++ b/packages/provider-claude-code/src/interceptors/messages/inject-identity-block_test.ts @@ -1,8 +1,8 @@ import { test } from 'vitest'; import { injectIdentityBlock } from './inject-identity-block.ts'; -import type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; -import { IDENTITY_BLOCK } from '../../system-blocks.ts'; +import { IDENTITY_BLOCK } from './system-blocks.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; import type { ProviderStreamResult } from '@floway-dev/provider'; import { assertEquals, stubProviderModel } from '@floway-dev/test-utils'; @@ -10,7 +10,7 @@ import { assertEquals, stubProviderModel } from '@floway-dev/test-utils'; const okEvents = (): Promise> => Promise.resolve({ ok: true, events: (async function* () {})(), modelKey: 'test' }); -const invocation = (payload: MessagesPayload): ClaudeCodeMessagesBoundaryCtx => ({ +const invocation = (payload: MessagesPayload): MessagesBoundaryCtx => ({ payload, model: stubProviderModel({ endpoints: { messages: {} } }), upstreamId: 'up_test', diff --git a/packages/provider-claude-code/src/interceptors/messages/synthesize-metadata-user-id.ts b/packages/provider-claude-code/src/interceptors/messages/synthesize-metadata-user-id.ts index 38198011ad..54c8018e8c 100644 --- a/packages/provider-claude-code/src/interceptors/messages/synthesize-metadata-user-id.ts +++ b/packages/provider-claude-code/src/interceptors/messages/synthesize-metadata-user-id.ts @@ -1,7 +1,7 @@ import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex } from '@noble/hashes/utils.js'; -import type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import type { MessagesMessage, MessagesPayload } from '@floway-dev/protocols/messages'; // Real CC includes `metadata.user_id` on every /v1/messages request: a JSON @@ -28,8 +28,8 @@ import type { MessagesMessage, MessagesPayload } from '@floway-dev/protocols/mes // - https://github.com/Wei-Shaw/sub2api/blob/4a5665da5b2c6b83c4597844ea6e573746c821b1/backend/internal/service/metadata_userid.go#L15 export const synthesizeMetadataUserId = async ( - ctx: ClaudeCodeMessagesBoundaryCtx, - _request: object, + ctx: MessagesBoundaryCtx, + _env: object, run: () => Promise, ): Promise => { const existing = ctx.payload.metadata?.user_id; diff --git a/packages/provider-claude-code/src/interceptors/messages/synthesize-metadata-user-id_test.ts b/packages/provider-claude-code/src/interceptors/messages/synthesize-metadata-user-id_test.ts index ec3619cb9c..c0af9903e9 100644 --- a/packages/provider-claude-code/src/interceptors/messages/synthesize-metadata-user-id_test.ts +++ b/packages/provider-claude-code/src/interceptors/messages/synthesize-metadata-user-id_test.ts @@ -1,9 +1,9 @@ import { test } from 'vitest'; import { hoistUserSystemToMessages } from './hoist-user-system-to-messages.ts'; -import { claudeCodeMessagesChain } from './index.ts'; +import { CLAUDE_CODE_MESSAGES_BOUNDARY } from './index.ts'; import { synthesizeMetadataUserId } from './synthesize-metadata-user-id.ts'; -import type { ClaudeCodeMessagesBoundaryCtx } from './types.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import { parseMetadataUserID } from '../../detection.ts'; import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; import type { ProviderStreamResult } from '@floway-dev/provider'; @@ -12,7 +12,7 @@ import { assertEquals, stubProviderModel } from '@floway-dev/test-utils'; const okEvents = (): Promise> => Promise.resolve({ ok: true, events: (async function* () {})(), modelKey: 'test' }); -const invocation = (payload: MessagesPayload, upstreamId = 'up_test'): ClaudeCodeMessagesBoundaryCtx => ({ +const invocation = (payload: MessagesPayload, upstreamId = 'up_test'): MessagesBoundaryCtx => ({ payload, model: stubProviderModel({ endpoints: { messages: {} } }), upstreamId, @@ -113,7 +113,7 @@ test('session_id differs when system prompt is shared but user message differs ( }); test('chain registers synthesize before hoist', () => { - const chain = claudeCodeMessagesChain(); + const chain = CLAUDE_CODE_MESSAGES_BOUNDARY; const synthIdx = chain.indexOf(synthesizeMetadataUserId); const hoistIdx = chain.indexOf(hoistUserSystemToMessages); if (synthIdx === -1 || hoistIdx === -1) throw new Error('chain missing required step'); diff --git a/packages/provider-claude-code/src/system-blocks.ts b/packages/provider-claude-code/src/interceptors/messages/system-blocks.ts similarity index 99% rename from packages/provider-claude-code/src/system-blocks.ts rename to packages/provider-claude-code/src/interceptors/messages/system-blocks.ts index ee013368fe..1bfc3e1990 100644 --- a/packages/provider-claude-code/src/system-blocks.ts +++ b/packages/provider-claude-code/src/interceptors/messages/system-blocks.ts @@ -1,7 +1,7 @@ import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex } from '@noble/hashes/utils.js'; -import type { MessagesPayload, MessagesTextBlock } from '@floway-dev/protocols'; +import type { MessagesPayload, MessagesTextBlock } from '@floway-dev/protocols/messages'; // Three-block `system` array we send to Anthropic on the re-mimicry path, // plus the per-request fingerprint helper that feeds the billing block. diff --git a/packages/provider-claude-code/src/system-blocks_test.ts b/packages/provider-claude-code/src/interceptors/messages/system-blocks_test.ts similarity index 98% rename from packages/provider-claude-code/src/system-blocks_test.ts rename to packages/provider-claude-code/src/interceptors/messages/system-blocks_test.ts index 5b556b3db2..f5fd03cb1d 100644 --- a/packages/provider-claude-code/src/system-blocks_test.ts +++ b/packages/provider-claude-code/src/interceptors/messages/system-blocks_test.ts @@ -6,7 +6,7 @@ import { DEFAULT_TEMPLATE_BLOCK, IDENTITY_BLOCK, } from './system-blocks.ts'; -import type { MessagesPayload } from '@floway-dev/protocols'; +import type { MessagesPayload } from '@floway-dev/protocols/messages'; const minimalBody = (firstUserText: string): MessagesPayload => ({ model: 'claude-sonnet-4-5-20250929', diff --git a/packages/provider-claude-code/src/interceptors/messages/types.ts b/packages/provider-claude-code/src/interceptors/messages/types.ts index 56a144032b..81f0dad0bd 100644 --- a/packages/provider-claude-code/src/interceptors/messages/types.ts +++ b/packages/provider-claude-code/src/interceptors/messages/types.ts @@ -7,7 +7,7 @@ import type { ProviderModel } from '@floway-dev/provider'; // to derive deterministic device/session ids that stay stable per upstream // across requests (so prompt-cache hits depend on conversation content only, // not on per-call randomness). -export interface ClaudeCodeMessagesBoundaryCtx { +export interface MessagesBoundaryCtx { payload: MessagesPayload; readonly model: ProviderModel; readonly upstreamId: string; diff --git a/packages/provider-claude-code/src/models.ts b/packages/provider-claude-code/src/models.ts index 2226439cd6..33d3df76d7 100644 --- a/packages/provider-claude-code/src/models.ts +++ b/packages/provider-claude-code/src/models.ts @@ -15,9 +15,12 @@ import { CLAUDE_CODE_HEADERS_SONNET_OPUS } from './headers.ts'; import { pricingForClaudeCodeModelKey } from './pricing.ts'; -import type { ClaudeCodeProviderData } from './types.ts'; import type { Fetcher, FlagId, ProviderModel, UpstreamChatModelConfig } from '@floway-dev/provider'; +export interface ClaudeCodeProviderData { + readonly upstreamModelId: string; +} + const ANTHROPIC_MODELS_ENDPOINT = 'https://api.anthropic.com/v1/models?limit=100'; // Anthropic extended-thinking minimum `budget_tokens`. Uniform across every diff --git a/packages/provider-claude-code/src/models_test.ts b/packages/provider-claude-code/src/models_test.ts index c45dd69805..f7f71db693 100644 --- a/packages/provider-claude-code/src/models_test.ts +++ b/packages/provider-claude-code/src/models_test.ts @@ -5,9 +5,9 @@ import { buildClaudeCodeCatalog, chatFromCapabilities, type ClaudeCodeApiModel, + type ClaudeCodeProviderData, } from './models.ts'; import { pricingForClaudeCodeModelKey } from './pricing.ts'; -import type { ClaudeCodeProviderData } from './types.ts'; import type { FlagId } from '@floway-dev/provider'; const SAMPLE_API_MODELS: ClaudeCodeApiModel[] = [ diff --git a/packages/provider-claude-code/src/pricing.ts b/packages/provider-claude-code/src/pricing.ts index 6651ff63df..7b73682f46 100644 --- a/packages/provider-claude-code/src/pricing.ts +++ b/packages/provider-claude-code/src/pricing.ts @@ -9,10 +9,10 @@ // priced as a flat multiple of base: 6× on Opus 4.6/4.7, lowered to 2× from // Opus 4.8 onward (Opus 4.8, Opus 5); each entry records its own cache rates. -import { tokenBasePricing, tokenModelPricing, tokenPricingEntry as pricingEntry, type ModelPricing, type PriceVector } from '@floway-dev/protocols/common'; +import { modelPricing, tokenBasePricing, tokenPricingEntry, type ModelPricing, type PriceVector } from '@floway-dev/protocols/common'; const fastPricing = (rates: PriceVector, fastRates: PriceVector): ModelPricing => - tokenModelPricing(pricingEntry(rates), pricingEntry(fastRates, { serviceTier: 'fast' })); + modelPricing(tokenPricingEntry(rates), tokenPricingEntry(fastRates, { serviceTier: 'fast' })); const OPUS_RATES = { input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', input_cache_write_1h_tokens: '10', output_tokens: '25' }; const SONNET_PRICING = tokenBasePricing({ input_tokens: '3', input_cache_read_tokens: '0.3', input_cache_write_tokens: '3.75', input_cache_write_1h_tokens: '6', output_tokens: '15' }); diff --git a/packages/provider-claude-code/src/provider.ts b/packages/provider-claude-code/src/provider.ts index c3cfdfe4d9..6904760e66 100644 --- a/packages/provider-claude-code/src/provider.ts +++ b/packages/provider-claude-code/src/provider.ts @@ -1,9 +1,9 @@ -import { ensureClaudeCodeAccessToken } from './access-token-cache.ts'; +import { ensureClaudeCodeAccessToken } from './access-token.ts'; import { assertClaudeCodeUpstreamRecord } from './config.ts'; import { CLAUDE_CODE_DEFAULT_FLAGS } from './defaults.ts'; import { isClaudeCodeShapedRequest } from './detection.ts'; -import { detectHaikuProbe, callClaudeCodeMessages } from './fetch.ts'; -import { claudeCodeMessagesChain, type ClaudeCodeMessagesBoundaryCtx } from './interceptors/messages/index.ts'; +import { callClaudeCodeMessages } from './fetch.ts'; +import { CLAUDE_CODE_MESSAGES_BOUNDARY, type MessagesBoundaryCtx } from './interceptors/messages/index.ts'; import { buildClaudeCodeCatalog, fetchClaudeCodeModelsList } from './models.ts'; import { assertClaudeCodeUpstreamState } from './state.ts'; import { runInterceptors } from '@floway-dev/interceptor'; @@ -41,7 +41,7 @@ export const createClaudeCodeProvider = (record: UpstreamRecord): Provider => { }, callMessages: async (model, body, signal: AbortSignal | undefined, opts) => { - const ctx: ClaudeCodeMessagesBoundaryCtx = { + const ctx: MessagesBoundaryCtx = { payload: { ...body, model: model.id }, model, upstreamId: record.id, @@ -51,12 +51,14 @@ export const createClaudeCodeProvider = (record: UpstreamRecord): Provider => { // The re-mimicry chain would clobber operator-supplied `system` content // and overwrite the wire shape — exactly what a CC-shaped passthrough // needs to preserve. So the chain only runs on the unshaped path; the - // shaped path skips straight to the terminal call, which forwards the - // caller's headers and body byte-for-byte (Authorization swap only). + // shaped path skips straight to the terminal call, which preserves the + // caller's own system blocks, metadata and tool shape rather than + // re-deriving them. The call still rebuilds the header surface through + // the allowlist in `fetch.ts`, swaps Authorization for our cached OAuth + // token, and restamps the resolved model id. const looksShaped = isClaudeCodeShapedRequest({ headers: opts.headers, body: ctx.payload, - isMaxTokensOneHaikuProbe: detectHaikuProbe(ctx.payload), }); const terminal = async (): Promise> => { @@ -77,10 +79,10 @@ export const createClaudeCodeProvider = (record: UpstreamRecord): Provider => { if (looksShaped) return await terminal(); - return await runInterceptors>( + return await runInterceptors>( ctx, {}, - claudeCodeMessagesChain>(), + CLAUDE_CODE_MESSAGES_BOUNDARY, terminal, ); }, @@ -99,7 +101,7 @@ export const createClaudeCodeProvider = (record: UpstreamRecord): Provider => { }; return { - upstream: record.id, + upstreamId: record.id, kind: 'claude-code', name: record.name, disabledPublicModelIds: record.disabledPublicModelIds, diff --git a/packages/provider-claude-code/src/provider_test.ts b/packages/provider-claude-code/src/provider_test.ts index acf26bcc06..fa47fd86cb 100644 --- a/packages/provider-claude-code/src/provider_test.ts +++ b/packages/provider-claude-code/src/provider_test.ts @@ -146,7 +146,7 @@ describe('createClaudeCodeProvider — factory surface', () => { test('kind is "claude-code"', async () => { const instance = createClaudeCodeProvider(currentRecord); expect(instance.kind).toBe('claude-code'); - expect(instance.upstream).toBe(upstreamId); + expect(instance.upstreamId).toBe(upstreamId); }); }); diff --git a/packages/provider-claude-code/src/state.ts b/packages/provider-claude-code/src/state.ts index 5c75631a9e..9f0073596c 100644 --- a/packages/provider-claude-code/src/state.ts +++ b/packages/provider-claude-code/src/state.ts @@ -12,7 +12,7 @@ // // - `oauth`: a short-lived access token plus a rotating refresh token. // Every refresh call mints a new access token AND rotates the refresh -// token; the access-token cache CASes both together. +// token; the access-token module CASes both together. // - `setup-token`: a long-lived (~1 year) inference-only bearer with NO // refresh token. The `accessToken` entry IS the credential — when it // expires the operator must re-import. `refreshToken` is null. The @@ -48,7 +48,7 @@ export interface ClaudeCodeUsageProbeSnapshotEntry { data: unknown; } -// One account's autonomous credential state, joined back to its identity in +// One account's gateway-written credential state, joined back to its identity in // ClaudeCodeUpstreamConfig.accounts via `accountUuid`. The `tokenKind` axis // crosses with the `state` (health) axis: each combination is independently // valid on the wire, so the type is a cartesian product of both unions. @@ -66,12 +66,12 @@ interface ClaudeCodeAccountCredentialBase { accessToken: ClaudeCodeAccessTokenEntry | null; quotaSnapshot: ClaudeCodeQuotaSnapshotEntry | null; // Most recent /api/oauth/usage probe. Populated by the operator-driven - // probe-quota route; the data-plane hot path never writes it. + // /upstreams/claude-code/probe route; the data-plane hot path never writes it. usageProbeSnapshot: ClaudeCodeUsageProbeSnapshotEntry | null; } // The credential class. `oauth` carries a non-empty rotating refresh token -// that the cache rotates on every refresh round-trip; `setup-token` is the +// that the access-token module rotates on every refresh round-trip; `setup-token` is the // long-lived inference-only bearer with no refresh counterpart. type ClaudeCodeAccountCredentialTokenKind = | { tokenKind: 'oauth'; refreshToken: string } @@ -182,7 +182,7 @@ const assertClaudeCodeAccountCredential = (value: unknown, where: string): void throw new TypeError(`${where}.state must be one of 'active' | 'session_terminated' | 'refresh_failed', got ${String(obj.state)}`); } // Terminal states carry the upstream's terminal message; 'active' must not. - // This split keeps the access-token cache from inventing a fallback string + // This split keeps the access-token module from inventing a fallback string // when it surfaces ClaudeCodeOAuthSessionTerminatedError. if (obj.state === 'active') { if (obj.stateMessage !== undefined) { diff --git a/packages/provider-claude-code/src/types.ts b/packages/provider-claude-code/src/types.ts deleted file mode 100644 index 7fca5e1b2c..0000000000 --- a/packages/provider-claude-code/src/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Per-model side data the gateway carries on ProviderModel.providerData for -// claude-code entries. The catalog advertises Anthropic's public aliases -// (`claude-sonnet-4-5`, etc.) as the public model id so clients can address -// models with the same name regardless of the dated revision Anthropic ships; -// the dated id stays as the on-wire `model` we forward to Anthropic so the -// per-revision rate-limit / pricing routing stays accurate. -export interface ClaudeCodeProviderData { - readonly upstreamModelId: string; -} diff --git a/packages/provider-claude-code/src/auth/usage-probe.ts b/packages/provider-claude-code/src/usage-probe.ts similarity index 99% rename from packages/provider-claude-code/src/auth/usage-probe.ts rename to packages/provider-claude-code/src/usage-probe.ts index bcd7b42d8c..7cd8d86b61 100644 --- a/packages/provider-claude-code/src/auth/usage-probe.ts +++ b/packages/provider-claude-code/src/usage-probe.ts @@ -19,7 +19,7 @@ // (priorIsUsingOverage, hadPriorUtilizationData, ...) without warning; // a strict parser would reject a perfectly usable new field as malformed. -import { CLAUDE_CODE_OAUTH_USER_AGENT, CLAUDE_CODE_USAGE_PROBE_URL } from '../constants.ts'; +import { CLAUDE_CODE_OAUTH_USER_AGENT, CLAUDE_CODE_USAGE_PROBE_URL } from './constants.ts'; import type { Fetcher } from '@floway-dev/provider'; export interface ClaudeCodeUsageProbeResult { diff --git a/packages/provider-claude-code/src/auth/usage-probe_test.ts b/packages/provider-claude-code/src/usage-probe_test.ts similarity index 100% rename from packages/provider-claude-code/src/auth/usage-probe_test.ts rename to packages/provider-claude-code/src/usage-probe_test.ts diff --git a/packages/provider-claude-code/tsconfig.json b/packages/provider-claude-code/tsconfig.json index 9e25e6ece9..3b64db1d69 100644 --- a/packages/provider-claude-code/tsconfig.json +++ b/packages/provider-claude-code/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src/**/*.ts"] + "include": ["vitest.config.ts", "src/**/*.ts"] } diff --git a/packages/provider-codex/src/access-token-cache.ts b/packages/provider-codex/src/access-token.ts similarity index 93% rename from packages/provider-codex/src/access-token-cache.ts rename to packages/provider-codex/src/access-token.ts index b68ea69e50..5c6a94426c 100644 --- a/packages/provider-codex/src/access-token-cache.ts +++ b/packages/provider-codex/src/access-token.ts @@ -1,5 +1,5 @@ import { CodexOAuthSessionTerminatedError, refreshCodexAccessToken } from './auth/oauth.ts'; -import { readCodexUpstreamState, type CodexAccessTokenEntry, type CodexUpstreamState } from './state.ts'; +import { findCodexAccountIndex, readCodexUpstreamState, replaceCodexAccount, type CodexAccessTokenEntry } from './state.ts'; import { getProviderRepo, type Fetcher } from '@floway-dev/provider'; export type { CodexAccessTokenEntry }; @@ -12,18 +12,6 @@ const REFRESH_SKEW_MS = 5 * 60 * 1000; const isAccessTokenFresh = (entry: CodexAccessTokenEntry): boolean => entry.expiresAt > Date.now() + REFRESH_SKEW_MS; -const findAccountIndex = (state: CodexUpstreamState, accountId: string): number => - state.accounts.findIndex(a => a.chatgptAccountId === accountId); - -const replaceAccountAccessToken = ( - state: CodexUpstreamState, - index: number, - entry: CodexAccessTokenEntry | null, -): CodexUpstreamState => ({ - ...state, - accounts: state.accounts.map((account, i) => (i === index ? { ...account, accessToken: entry } : account)), -}); - // A losing CAS is not an error — saveState reports it via `updated: false`, // and the next call re-reads state and refreshes if needed. Genuine storage // failures propagate so the request path surfaces them rather than silently @@ -40,7 +28,7 @@ const persistAccessToken = async ( return; } const state = readCodexUpstreamState(fresh.state); - const idx = findAccountIndex(state, accountId); + const idx = findCodexAccountIndex(state, accountId); if (idx < 0) { console.warn(`${where}: Codex account ${accountId} not found in upstream ${upstreamId}`); return; @@ -48,7 +36,7 @@ const persistAccessToken = async ( // No-op when invalidating an already-null slot — avoids a spurious CAS write // when a 401 retry races a concurrent refresh that already cleared the token. if (entry === null && state.accounts[idx].accessToken === null) return; - const next = replaceAccountAccessToken(state, idx, entry); + const next = replaceCodexAccount(state, idx, account => ({ ...account, accessToken: entry })); await getProviderRepo().upstreams.saveState(upstreamId, next, { expectedState: fresh.state }); }; diff --git a/packages/provider-codex/src/access-token-cache_test.ts b/packages/provider-codex/src/access-token_test.ts similarity index 99% rename from packages/provider-codex/src/access-token-cache_test.ts rename to packages/provider-codex/src/access-token_test.ts index 97b0e88a69..6a6461c420 100644 --- a/packages/provider-codex/src/access-token-cache_test.ts +++ b/packages/provider-codex/src/access-token_test.ts @@ -5,7 +5,7 @@ import { invalidateCodexAccessToken, putCodexAccessToken, type CodexAccessTokenEntry, -} from './access-token-cache.ts'; +} from './access-token.ts'; import { CodexOAuthSessionTerminatedError } from './auth/oauth.ts'; import type { CodexUpstreamState } from './state.ts'; import { initProviderRepo, type UpstreamRecord } from '@floway-dev/provider'; diff --git a/packages/provider-codex/src/auth/import.ts b/packages/provider-codex/src/auth/import.ts index 669cf4b396..0a966bf9d4 100644 --- a/packages/provider-codex/src/auth/import.ts +++ b/packages/provider-codex/src/auth/import.ts @@ -76,7 +76,7 @@ export const importCodexFromAuthJson = async (rawJson: string): Promise { + const url = new URL(CODEX_AUTHORIZE_URL); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', CODEX_CLIENT_ID); + url.searchParams.set('redirect_uri', CODEX_REDIRECT_URI); + url.searchParams.set('scope', CODEX_OAUTH_SCOPE); + url.searchParams.set('state', input.state); + url.searchParams.set('code_challenge', input.codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + url.searchParams.set('id_token_add_organizations', 'true'); + url.searchParams.set('codex_cli_simplified_flow', 'true'); + url.searchParams.set('originator', CODEX_ORIGINATOR); + return url.toString(); +}; + // Terminal error: refresh_token is dead, operator must re-import. Distinct // from generic OAuth 4xx so callers can react to session-termination // separately from a transient upstream message. `code` carries the raw OAuth // `error` value (`invalid_grant`, `app_session_terminated`, etc.) so the -// refresh-race recovery in the access-token cache can single out +// refresh-race recovery in the access-token module can single out // `invalid_grant` — the only terminal code that might mean "a sibling // worker just rotated the refresh token, and our copy is stale" — from // codes that signal genuine credential death under any race scenario. @@ -52,7 +69,7 @@ const EXCHANGE_TERMINAL_OAUTH_CODES: ReadonlySet = new Set([ // (backend/internal/service/token_refresh_service.go:429-451), which shares // the same list across OpenAI/Claude/Gemini OAuth — Codex is OpenAI OAuth, // so the set carries over verbatim. `invalid_grant` is included even though -// the refresh-race recovery in access-token-cache.ts may re-classify it +// the refresh-race recovery in access-token.ts may re-classify it // when a sibling rotation is detected; from the OAuth wire's perspective // it is still a terminal signal. const REFRESH_TERMINAL_OAUTH_CODES: ReadonlySet = new Set([ @@ -162,7 +179,7 @@ export const refreshCodexAccessToken = async (refreshToken: string, fetcher: Fet // OAuth `invalid_grant` on the refresh path is ambiguous on its own — it // can mean a genuinely revoked/expired refresh_token, *or* that a sibling // worker raced us, won the rotation, and our copy is now stale. The - // access-token cache's `recoverFromRefreshRace` distinguishes by re-reading + // access-token module's `recoverFromRefreshRace` distinguishes by re-reading // upstream state; the other codes here always mean credential death. return await codexTokenRequest(body, REFRESH_TERMINAL_OAUTH_CODES, fetcher); }; diff --git a/packages/provider-codex/src/auth/oauth_test.ts b/packages/provider-codex/src/auth/oauth_test.ts index 86400d393e..3fdeacf7ef 100644 --- a/packages/provider-codex/src/auth/oauth_test.ts +++ b/packages/provider-codex/src/auth/oauth_test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; -import { CodexOAuthSessionTerminatedError, exchangeCodexAuthorizationCode, refreshCodexAccessToken } from './oauth.ts'; +import { buildCodexAuthorizeUrl, CodexOAuthSessionTerminatedError, exchangeCodexAuthorizationCode, refreshCodexAccessToken } from './oauth.ts'; import { directFetcher } from '@floway-dev/provider'; const okResponse = (body: unknown): Response => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); @@ -8,6 +8,12 @@ const errorResponse = (status: number, body: unknown): Response => new Response( afterEach(() => vi.restoreAllMocks()); +test('buildCodexAuthorizeUrl preserves the Codex CLI query surface and order', () => { + expect(buildCodexAuthorizeUrl({ state: 'STATE', codeChallenge: 'CHALLENGE' })).toBe( + 'https://auth.openai.com/oauth/authorize?response_type=code&client_id=app_EMoamEEZ73f0CkXaXp7hrann&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email+offline_access&state=STATE&code_challenge=CHALLENGE&code_challenge_method=S256&id_token_add_organizations=true&codex_cli_simplified_flow=true&originator=codex_cli_rs', + ); +}); + describe('exchangeCodexAuthorizationCode', () => { test('POSTs form data and returns parsed tokens', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse({ diff --git a/packages/provider-codex/src/fetch.ts b/packages/provider-codex/src/fetch.ts index 91e3d37780..d3772996d8 100644 --- a/packages/provider-codex/src/fetch.ts +++ b/packages/provider-codex/src/fetch.ts @@ -1,4 +1,4 @@ -import { ensureCodexAccessToken, invalidateCodexAccessToken, mintCodexAccessToken, putCodexAccessToken } from './access-token-cache.ts'; +import { ensureCodexAccessToken, invalidateCodexAccessToken, mintCodexAccessToken, putCodexAccessToken } from './access-token.ts'; import { CodexOAuthSessionTerminatedError } from './auth/oauth.ts'; import { CODEX_BACKEND_BASE, @@ -39,7 +39,6 @@ interface CodexBackendCallBase { account: CodexAccountCredential; model: ProviderModel; headers: Headers; - turnMetadata?: CodexTurnMetadataOptions; signal?: AbortSignal; effects: CodexCallEffects; call: UpstreamCallOptions; @@ -403,7 +402,7 @@ const performStreamingResponsesCall = async ( const clientTurnMetadata = parseClientTurnMetadataJson(trimHeader(opts.headers, 'x-codex-turn-metadata')); const clientMetadata = clientCodexClientMetadata(opts.body); const identity = await buildCodexRequestIdentity(opts, opts.body, clientMetadata, clientTurnMetadata); - const metadata = opts.turnMetadata ?? (opts.body.input.some(item => item.type === 'compaction_trigger') ? CODEX_RESPONSES_COMPACTION_V2_TURN_METADATA : { requestKind: 'turn' }); + const metadata: CodexTurnMetadataOptions = opts.body.input.some(item => item.type === 'compaction_trigger') ? CODEX_RESPONSES_COMPACTION_V2_TURN_METADATA : { requestKind: 'turn' }; const turnMetadataJson = buildCodexTurnMetadataJson(identity, metadata, clientTurnMetadata); const upstreamFetch = dispatchCodexHttpCall( opts, @@ -434,7 +433,7 @@ const performUnaryCompactCall = async ( const clientTurnMetadata = parseClientTurnMetadataJson(trimHeader(opts.headers, 'x-codex-turn-metadata')); const clientMetadata = clientCodexClientMetadata(opts.body); const identity = await buildCodexRequestIdentity(opts, opts.body, clientMetadata, clientTurnMetadata); - const metadata = opts.turnMetadata ?? { requestKind: 'compaction' }; + const metadata: CodexTurnMetadataOptions = { requestKind: 'compaction' }; const turnMetadataJson = buildCodexTurnMetadataJson(identity, metadata, clientTurnMetadata); const response = await dispatchCodexHttpCall( opts, diff --git a/packages/provider-codex/src/index.ts b/packages/provider-codex/src/index.ts index 0bb24a550f..1b5d828fad 100644 --- a/packages/provider-codex/src/index.ts +++ b/packages/provider-codex/src/index.ts @@ -2,12 +2,12 @@ import { CODEX_DEFAULT_FLAGS } from './defaults.ts'; import { createCodexProvider } from './provider.ts'; import type { ProviderModule } from '@floway-dev/provider'; -export const codexProvider: ProviderModule = { +export const codexProviderModule: ProviderModule = { create: createCodexProvider, defaultFlags: CODEX_DEFAULT_FLAGS, }; -export * from './access-token-cache.ts'; +export * from './access-token.ts'; export * from './auth/import.ts'; export * from './auth/oauth.ts'; export * from './constants.ts'; diff --git a/packages/provider-codex/src/interceptors/responses/action-pivot_test.ts b/packages/provider-codex/src/interceptors/responses/action-pivot_test.ts index 19c08e881a..385ece81e3 100644 --- a/packages/provider-codex/src/interceptors/responses/action-pivot_test.ts +++ b/packages/provider-codex/src/interceptors/responses/action-pivot_test.ts @@ -14,7 +14,7 @@ import { assertEquals } from '@floway-dev/test-utils'; // carry generate-only fields (tools/reasoning/temperature/...) — the // per-action narrowing through `toCompactPayloadShape` is what closes that // gap. -const pivotGenerateToCompact: Interceptor = async (ctx, _request, run) => { +const pivotGenerateToCompact: Interceptor = async (ctx, _env, run) => { ctx.action = 'compact'; return await run(); }; diff --git a/packages/provider-codex/src/interceptors/responses/inject-default-instructions.ts b/packages/provider-codex/src/interceptors/responses/inject-default-instructions.ts index da3f22f97f..e2d8dab4a6 100644 --- a/packages/provider-codex/src/interceptors/responses/inject-default-instructions.ts +++ b/packages/provider-codex/src/interceptors/responses/inject-default-instructions.ts @@ -6,7 +6,7 @@ import type { ResponsesBoundaryCtx } from './types.ts'; // https://github.com/im4codes/imcodes/blob/5f769d933dfd679e3a4d670183b0384a1baf62cd/src/agent/providers/codex-sdk.ts#L560-L579 export const injectDefaultInstructions = async ( ctx: ResponsesBoundaryCtx, - _request: object, + _env: object, run: () => Promise, ): Promise => { const instructions = ctx.payload.instructions; diff --git a/packages/provider-codex/src/interceptors/responses/strip-unsupported-fields.ts b/packages/provider-codex/src/interceptors/responses/strip-unsupported-fields.ts index be9721f535..4fc14d1d12 100644 --- a/packages/provider-codex/src/interceptors/responses/strip-unsupported-fields.ts +++ b/packages/provider-codex/src/interceptors/responses/strip-unsupported-fields.ts @@ -22,7 +22,7 @@ const CODEX_UNSUPPORTED_BODY_FIELDS = [ export const stripUnsupportedFields = async ( ctx: ResponsesBoundaryCtx, - _request: object, + _env: object, run: () => Promise, ): Promise => { const next: Record = { ...(ctx.payload as unknown as Record) }; diff --git a/packages/provider-codex/src/pricing.ts b/packages/provider-codex/src/pricing.ts index 63afa4efa0..76ecf81324 100644 --- a/packages/provider-codex/src/pricing.ts +++ b/packages/provider-codex/src/pricing.ts @@ -7,16 +7,16 @@ // https://developers.openai.com/api/docs/pricing // .agents/skills/fetching-models-pricing/ -import { tokenModelPricing, tokenPricingEntry as pricingEntry, type ModelPricing } from '@floway-dev/protocols/common'; +import { modelPricing, tokenPricingEntry, type ModelPricing } from '@floway-dev/protocols/common'; -const GPT_5_4_PRICING = tokenModelPricing( - pricingEntry({ input_tokens: '2.5', input_cache_read_tokens: '0.25', output_tokens: '15' }), - pricingEntry({ input_tokens: '1.25', input_cache_read_tokens: '0.13', output_tokens: '7.5' }, { serviceTier: 'flex' }), - pricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', output_tokens: '30' }, { serviceTier: 'priority' }), +const GPT_5_4_PRICING = modelPricing( + tokenPricingEntry({ input_tokens: '2.5', input_cache_read_tokens: '0.25', output_tokens: '15' }), + tokenPricingEntry({ input_tokens: '1.25', input_cache_read_tokens: '0.13', output_tokens: '7.5' }, { serviceTier: 'flex' }), + tokenPricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', output_tokens: '30' }, { serviceTier: 'priority' }), // OpenAI's whole-request long-context rate. No flex/priority combination is // published, so those selector misses resolve to the whole Base vector. // https://web.archive.org/web/20260709205359/https://platform.openai.com/docs/pricing - pricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', output_tokens: '22.5' }, { inputTokens: { operator: 'gt', value: 272000 } }), + tokenPricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', output_tokens: '22.5' }, { inputTokens: { operator: 'gt', value: 272000 } }), ); const CODEX_MODEL_PRICING: readonly (readonly [key: string | RegExp, pricing: ModelPricing])[] = [ @@ -29,31 +29,31 @@ const CODEX_MODEL_PRICING: readonly (readonly [key: string | RegExp, pricing: Mo // https://github.com/openai/codex/blob/d2d00b6632dc991aa4471db0529773029cae5d68/codex-rs/models-manager/models.json // Cross-check only: // https://github.com/caozhiyuan/copilot-api/blob/5a28eee7ced4fda51b6b224fb8723df5e6534708/src/lib/token-usage/pricing.ts#L98-L148 - ['gpt-5.6-sol', tokenModelPricing( - pricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '30' }), - pricingEntry({ input_tokens: '10', input_cache_read_tokens: '1', input_cache_write_tokens: '12.5', output_tokens: '60' }, { serviceTier: 'priority' }), - pricingEntry({ input_tokens: '10', input_cache_read_tokens: '1', input_cache_write_tokens: '12.5', output_tokens: '45' }, { inputTokens: { operator: 'gt', value: 272000 } }), + ['gpt-5.6-sol', modelPricing( + tokenPricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '30' }), + tokenPricingEntry({ input_tokens: '10', input_cache_read_tokens: '1', input_cache_write_tokens: '12.5', output_tokens: '60' }, { serviceTier: 'priority' }), + tokenPricingEntry({ input_tokens: '10', input_cache_read_tokens: '1', input_cache_write_tokens: '12.5', output_tokens: '45' }, { inputTokens: { operator: 'gt', value: 272000 } }), )], - ['gpt-5.6-terra', tokenModelPricing( - pricingEntry({ input_tokens: '2.5', input_cache_read_tokens: '0.25', input_cache_write_tokens: '3.125', output_tokens: '15' }), - pricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '30' }, { serviceTier: 'priority' }), - pricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '22.5' }, { inputTokens: { operator: 'gt', value: 272000 } }), + ['gpt-5.6-terra', modelPricing( + tokenPricingEntry({ input_tokens: '2.5', input_cache_read_tokens: '0.25', input_cache_write_tokens: '3.125', output_tokens: '15' }), + tokenPricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '30' }, { serviceTier: 'priority' }), + tokenPricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '22.5' }, { inputTokens: { operator: 'gt', value: 272000 } }), )], - ['gpt-5.6-luna', tokenModelPricing( - pricingEntry({ input_tokens: '1', input_cache_read_tokens: '0.1', input_cache_write_tokens: '1.25', output_tokens: '6' }), - pricingEntry({ input_tokens: '2', input_cache_read_tokens: '0.2', input_cache_write_tokens: '2.5', output_tokens: '12' }, { serviceTier: 'priority' }), - pricingEntry({ input_tokens: '2', input_cache_read_tokens: '0.2', input_cache_write_tokens: '2.5', output_tokens: '9' }, { inputTokens: { operator: 'gt', value: 272000 } }), + ['gpt-5.6-luna', modelPricing( + tokenPricingEntry({ input_tokens: '1', input_cache_read_tokens: '0.1', input_cache_write_tokens: '1.25', output_tokens: '6' }), + tokenPricingEntry({ input_tokens: '2', input_cache_read_tokens: '0.2', input_cache_write_tokens: '2.5', output_tokens: '12' }, { serviceTier: 'priority' }), + tokenPricingEntry({ input_tokens: '2', input_cache_read_tokens: '0.2', input_cache_write_tokens: '2.5', output_tokens: '9' }, { inputTokens: { operator: 'gt', value: 272000 } }), )], - ['gpt-5.5', tokenModelPricing( - pricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', output_tokens: '30' }), - pricingEntry({ input_tokens: '2.5', input_cache_read_tokens: '0.25', output_tokens: '15' }, { serviceTier: 'flex' }), - pricingEntry({ input_tokens: '12.5', input_cache_read_tokens: '1.25', output_tokens: '75' }, { serviceTier: 'priority' }), + ['gpt-5.5', modelPricing( + tokenPricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', output_tokens: '30' }), + tokenPricingEntry({ input_tokens: '2.5', input_cache_read_tokens: '0.25', output_tokens: '15' }, { serviceTier: 'flex' }), + tokenPricingEntry({ input_tokens: '12.5', input_cache_read_tokens: '1.25', output_tokens: '75' }, { serviceTier: 'priority' }), )], ['gpt-5.4', GPT_5_4_PRICING], - ['gpt-5.4-mini', tokenModelPricing( - pricingEntry({ input_tokens: '0.75', input_cache_read_tokens: '0.075', output_tokens: '4.5' }), - pricingEntry({ input_tokens: '0.375', input_cache_read_tokens: '0.0375', output_tokens: '2.25' }, { serviceTier: 'flex' }), - pricingEntry({ input_tokens: '1.5', input_cache_read_tokens: '0.15', output_tokens: '9' }, { serviceTier: 'priority' }), + ['gpt-5.4-mini', modelPricing( + tokenPricingEntry({ input_tokens: '0.75', input_cache_read_tokens: '0.075', output_tokens: '4.5' }), + tokenPricingEntry({ input_tokens: '0.375', input_cache_read_tokens: '0.0375', output_tokens: '2.25' }, { serviceTier: 'flex' }), + tokenPricingEntry({ input_tokens: '1.5', input_cache_read_tokens: '0.15', output_tokens: '9' }, { serviceTier: 'priority' }), )], // No public price surface; notional clone of gpt-5.4. ['codex-auto-review', GPT_5_4_PRICING], diff --git a/packages/provider-codex/src/provider.ts b/packages/provider-codex/src/provider.ts index 95305bc5e0..34dc54b09c 100644 --- a/packages/provider-codex/src/provider.ts +++ b/packages/provider-codex/src/provider.ts @@ -1,4 +1,4 @@ -import { ensureCodexAccessToken, mintCodexAccessToken } from './access-token-cache.ts'; +import { ensureCodexAccessToken, mintCodexAccessToken } from './access-token.ts'; import { CodexOAuthSessionTerminatedError } from './auth/oauth.ts'; import { assertCodexUpstreamRecord, type CodexUpstreamConfig } from './config.ts'; import { CODEX_DEFAULT_FLAGS } from './defaults.ts'; @@ -6,7 +6,7 @@ import { callCodexAlphaSearch, callCodexResponses, callCodexResponsesCompact, ty import { CODEX_RESPONSES_BOUNDARY } from './interceptors/responses/index.ts'; import type { ResponsesBoundaryCtx } from './interceptors/responses/types.ts'; import { codexRawToProviderModel, fetchCodexCatalog } from './models.ts'; -import { assertCodexUpstreamState, type CodexUpstreamState } from './state.ts'; +import { assertCodexUpstreamState, findCodexAccountIndex, replaceCodexAccount } from './state.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import { toCompactPayloadShape } from '@floway-dev/protocols/responses'; import { getProviderRepo, resolveEffectiveFlags, type ProviderInstance, type Provider, type ProviderCallResult, type ProviderResponsesResult, type ProviderStreamResult, type UpstreamRecord } from '@floway-dev/provider'; @@ -37,20 +37,16 @@ export const createCodexProvider = (record: UpstreamRecord): Provider => { if (!fresh) throw new Error(`Codex upstream ${record.id} disappeared mid-request`); assertCodexUpstreamState(fresh.state); const state = fresh.state; - const account = state.accounts.find(a => a.chatgptAccountId === accountIdentity.chatgptAccountId); - if (!account) { + const accountIndex = findCodexAccountIndex(state, accountIdentity.chatgptAccountId); + if (accountIndex < 0) { throw new Error(`Codex upstream ${record.id} state has no credential for account ${accountIdentity.chatgptAccountId}`); } - return { state, account }; + return { state, accountIndex, account: state.accounts[accountIndex]! }; }; - const replaceActiveAccount = (state: CodexUpstreamState, next: CodexUpstreamState['accounts'][number]): CodexUpstreamState => ({ - accounts: state.accounts.map(a => (a.chatgptAccountId === next.chatgptAccountId ? next : a)), - }); - const persistRefreshTokenRotation = async (newRefreshToken: string): Promise => { - const { state, account } = await readActiveAccount(); - const next = replaceActiveAccount(state, { ...account, refresh_token: newRefreshToken, state_updated_at: new Date().toISOString() }); + const { state, accountIndex } = await readActiveAccount(); + const next = replaceCodexAccount(state, accountIndex, account => ({ ...account, refresh_token: newRefreshToken, state_updated_at: new Date().toISOString() })); // CAS write keyed on the just-read state. A losing CAS means a concurrent // operator re-import (or another isolate's rotation) already advanced the // row; their write supersedes ours and no retry is needed. @@ -58,11 +54,11 @@ export const createCodexProvider = (record: UpstreamRecord): Provider => { }; const persistTerminalState = async (newState: 'session_terminated' | 'refresh_failed', message: string): Promise => { - const { state, account } = await readActiveAccount(); + const { state, accountIndex } = await readActiveAccount(); // Clear any cached access token on the terminal flip — once the credential // is dead the cached token is dead too, and leaving it would confuse the // dashboard's status panel. - const next = replaceActiveAccount(state, { ...account, state: newState, state_message: message, state_updated_at: new Date().toISOString(), accessToken: null }); + const next = replaceCodexAccount(state, accountIndex, account => ({ ...account, state: newState, state_message: message, state_updated_at: new Date().toISOString(), accessToken: null })); await getProviderRepo().upstreams.saveState(record.id, next, { expectedState: state }); }; @@ -156,7 +152,7 @@ export const createCodexProvider = (record: UpstreamRecord): Provider => { }; return { - upstream: record.id, + upstreamId: record.id, kind: 'codex', name: record.name, disabledPublicModelIds: record.disabledPublicModelIds, diff --git a/packages/provider-codex/src/provider_test.ts b/packages/provider-codex/src/provider_test.ts index fc819edbd1..8aeb92d52c 100644 --- a/packages/provider-codex/src/provider_test.ts +++ b/packages/provider-codex/src/provider_test.ts @@ -73,7 +73,7 @@ describe('createCodexProvider', () => { test('returns an instance carrying provider kind and identity', async () => { const instance = createCodexProvider(baseRecord); expect(instance.kind).toBe('codex'); - expect(instance.upstream).toBe('up_codex'); + expect(instance.upstreamId).toBe('up_codex'); expect(instance.name).toBe('Codex Plus'); }); diff --git a/packages/provider-codex/src/quota.ts b/packages/provider-codex/src/quota.ts index 07fb79eb4e..420f7353db 100644 --- a/packages/provider-codex/src/quota.ts +++ b/packages/provider-codex/src/quota.ts @@ -1,4 +1,4 @@ -import { readCodexUpstreamState, type CodexQuotaSnapshotMapEntry, type CodexUpstreamState } from './state.ts'; +import { findCodexAccountIndex, readCodexUpstreamState, replaceCodexAccount } from './state.ts'; import { getProviderRepo } from '@floway-dev/provider'; export interface CodexQuotaSnapshot { @@ -103,18 +103,6 @@ export const computeCodexQuotaTtlMs = (snapshot: CodexQuotaSnapshot, now: Date): return Math.max(TTL_FLOOR_MS, ...horizons); }; -const findAccountIndex = (state: CodexUpstreamState, accountId: string): number => - state.accounts.findIndex(a => a.chatgptAccountId === accountId); - -const replaceAccountQuota = ( - state: CodexUpstreamState, - index: number, - quotaSnapshot: CodexQuotaSnapshotMapEntry, -): CodexUpstreamState => ({ - ...state, - accounts: state.accounts.map((account, i) => (i === index ? { ...account, quotaSnapshot } : account)), -}); - // Returns all fresh quota snapshots keyed by active limit. Stale buckets read as // absent — the next upstream response for that active limit will overwrite it. // state_json is unbounded, so freshness is gated inline by @@ -145,10 +133,10 @@ export const putCodexQuota = async ( const fresh = await getProviderRepo().upstreams.getById(upstreamId); if (!fresh) throw new Error(`putCodexQuota: Codex upstream ${upstreamId} disappeared mid-request`); const state = readCodexUpstreamState(fresh.state); - const idx = findAccountIndex(state, accountId); + const idx = findCodexAccountIndex(state, accountId); if (idx < 0) throw new Error(`putCodexQuota: Codex account ${accountId} not found in upstream ${upstreamId}`); const currentQuota = state.accounts[idx].quotaSnapshot ?? {}; const nextQuota = { ...currentQuota, [codexQuotaActiveLimitKey(snapshot)]: { fetchedAt: Date.now(), data: snapshot } }; - const next = replaceAccountQuota(state, idx, nextQuota); + const next = replaceCodexAccount(state, idx, account => ({ ...account, quotaSnapshot: nextQuota })); await getProviderRepo().upstreams.saveState(upstreamId, next, { expectedState: fresh.state }); }; diff --git a/packages/provider-codex/src/state.ts b/packages/provider-codex/src/state.ts index 79968c13c2..9bbcd193f6 100644 --- a/packages/provider-codex/src/state.ts +++ b/packages/provider-codex/src/state.ts @@ -4,7 +4,7 @@ import type { CodexQuotaSnapshot } from './quota.ts'; -export type CodexCredentialHealth = 'active' | 'session_terminated' | 'refresh_failed'; +export type CodexAccountCredentialHealth = 'active' | 'session_terminated' | 'refresh_failed'; // Short-lived OAuth access token minted by exchanging the stored refresh_token // against /oauth/token. The refresh_token itself stays on CodexAccountCredential @@ -33,7 +33,7 @@ export interface CodexAccountCredential { // OpenAI rotates refresh_token on every /oauth/token call. Stored in D1 // (not KV) so KV eviction never forces operator re-import. refresh_token: string; - state: CodexCredentialHealth; + state: CodexAccountCredentialHealth; state_message?: string; // ISO 8601, written on every state transition (initial import, rotation, // terminal-state flip). The mutation paths in routes.ts and provider.ts @@ -61,6 +61,18 @@ export interface CodexUpstreamState { accounts: CodexAccountCredential[]; } +export const findCodexAccountIndex = (state: CodexUpstreamState, accountId: string): number => + state.accounts.findIndex(account => account.chatgptAccountId === accountId); + +export const replaceCodexAccount = ( + state: CodexUpstreamState, + index: number, + patch: (account: CodexAccountCredential) => CodexAccountCredential, +): CodexUpstreamState => ({ + ...state, + accounts: state.accounts.map((account, currentIndex) => currentIndex === index ? patch(account) : account), +}); + const ALLOWED_CREDENTIAL_KEYS_MAP: Record = { chatgptAccountId: true, refresh_token: true, @@ -216,7 +228,7 @@ export function assertCodexUpstreamState(value: unknown): asserts value is Codex // promises `null` rather than `undefined`. Build a shallow copy of the // state with absent → `null` so consumers can rely on `=== null` checks // without seeing legacy rows escape unfilled. The original `raw` is left -// untouched so callers (e.g. access-token-cache, quota) can still pass it +// untouched so callers (e.g. the access-token and quota modules) can still pass it // straight through as the CAS `expectedState`. export const readCodexUpstreamState = (raw: unknown): CodexUpstreamState => { assertCodexUpstreamState(raw); diff --git a/packages/provider-codex/tsconfig.json b/packages/provider-codex/tsconfig.json index 9e25e6ece9..3b64db1d69 100644 --- a/packages/provider-codex/tsconfig.json +++ b/packages/provider-codex/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src/**/*.ts"] + "include": ["vitest.config.ts", "src/**/*.ts"] } diff --git a/packages/provider-copilot/src/auth.ts b/packages/provider-copilot/src/auth.ts index 62bc4b47d5..9bffa9d1b4 100644 --- a/packages/provider-copilot/src/auth.ts +++ b/packages/provider-copilot/src/auth.ts @@ -63,31 +63,6 @@ export class CopilotTokenFetchError extends Error { export const isCopilotTokenFetchError = (error: unknown): error is CopilotTokenFetchError => error instanceof CopilotTokenFetchError; -export async function clearCopilotTokenCache(upstreamId: string): Promise { - // Drop both the in-process memo and the persisted `state.copilotToken`. The - // persisted entry outlives the in-process clear by ~25 minutes, so a caller - // that just rotated the upstream's GitHub PAT (or otherwise needs the next - // request to mint a fresh Copilot token) MUST also wipe the persisted entry — - // otherwise `getCopilotToken` would happily return the still-valid hydrated - // token that was minted from the previous PAT, authenticating subsequent - // requests as the prior identity until the natural expiry. - inProcessTokenCache.clear(); - const repo = getRepo().upstreams; - const fresh = await repo.getById(upstreamId); - if (!fresh) return; - const state = readCopilotUpstreamState(fresh.state); - if (state.copilotToken === null) return; - try { - await repo.saveState( - upstreamId, - { ...state, copilotToken: null } satisfies CopilotUpstreamState, - { expectedState: fresh.state }, - ); - } catch (err) { - console.warn(`Failed to clear persisted Copilot token for ${upstreamId}:`, err); - } -} - // Tests use this to drop only the process-local memo between cases — they // run against a fresh DB per test so the persisted state needs no separate // reset, and some tests deliberately want the next call to hydrate from diff --git a/packages/provider/src/compaction.ts b/packages/provider-copilot/src/compaction.ts similarity index 94% rename from packages/provider/src/compaction.ts rename to packages/provider-copilot/src/compaction.ts index 6c908cdf3b..61d446ded5 100644 --- a/packages/provider/src/compaction.ts +++ b/packages/provider-copilot/src/compaction.ts @@ -1,6 +1,6 @@ -// Shared helper for synthesizing the `response.compaction` envelope from a -// trigger turn that returns one `compaction` output item. Used by Copilot, -// which has no native /responses/compact endpoint and replays the official +// Synthesizes the `response.compaction` envelope from Copilot's trigger turn, +// which returns one `compaction` output item. Copilot has no native +// /responses/compact endpoint and replays the official // `RemoteCompactionV2` protocol client-side over /responses with stream:false. // Providers whose upstream exposes native /responses/compact (Azure, Codex, // custom) call that endpoint directly and bypass this helper entirely. diff --git a/packages/provider-copilot/src/compaction_test.ts b/packages/provider-copilot/src/compaction_test.ts index ea965e3180..8f30149299 100644 --- a/packages/provider-copilot/src/compaction_test.ts +++ b/packages/provider-copilot/src/compaction_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; +import { compactionResponse } from './compaction.ts'; import type { ResponsesInputItem, ResponsesInputText, ResponsesResult } from '@floway-dev/protocols/responses'; -import { compactionResponse } from '@floway-dev/provider'; const generatedResult = (output: unknown[]): ResponsesResult => ({ diff --git a/packages/provider-copilot/src/index.ts b/packages/provider-copilot/src/index.ts index e1f2327cb3..d527349276 100644 --- a/packages/provider-copilot/src/index.ts +++ b/packages/provider-copilot/src/index.ts @@ -2,26 +2,22 @@ import { COPILOT_DEFAULT_FLAGS } from './defaults.ts'; import { createCopilotProvider } from './provider.ts'; import type { ProviderModule } from '@floway-dev/provider'; -export const copilotProvider: ProviderModule = { +export const copilotProviderModule: ProviderModule = { create: createCopilotProvider, defaultFlags: COPILOT_DEFAULT_FLAGS, }; export { - clearCopilotTokenCache, clearInProcessCopilotTokenCache, exchangeCopilotToken, - githubHeaders, } from './auth.ts'; export { fetchGitHubUser, pollGitHubDeviceFlow, startGitHubDeviceFlow } from './github-device-flow.ts'; -export { fetchCopilotUsage, type CopilotQuotaDetail, type CopilotUsageResponse } from './quota.ts'; +export { fetchCopilotUsage, type CopilotUsageResponse } from './quota.ts'; export { - assertCopilotUpstreamRecord, type CopilotUpstreamConfig, type CopilotUpstreamUser, } from './config.ts'; export { - assertCopilotUpstreamState, emptyCopilotUpstreamState, readCopilotUpstreamState, type CopilotTokenEntry, diff --git a/packages/provider-copilot/src/interceptors/chat-completions/abort-on-tool-argument-whitespace.ts b/packages/provider-copilot/src/interceptors/chat-completions/abort-on-tool-argument-whitespace.ts index 8f60ea21d5..325f6577f5 100644 --- a/packages/provider-copilot/src/interceptors/chat-completions/abort-on-tool-argument-whitespace.ts +++ b/packages/provider-copilot/src/interceptors/chat-completions/abort-on-tool-argument-whitespace.ts @@ -48,7 +48,7 @@ const isWhitespaceExceeded = ( return false; }; -export const withToolArgumentWhitespaceAborted: CopilotChatCompletionsBoundaryInterceptor = async (_invocation, _request, run) => { +export const withToolArgumentWhitespaceAborted: CopilotChatCompletionsBoundaryInterceptor = async (_invocation, _env, run) => { const result = await run(); if (result.type !== 'events') return result; diff --git a/packages/provider-copilot/src/interceptors/chat-completions/attach-cache-control-markers.ts b/packages/provider-copilot/src/interceptors/chat-completions/attach-cache-control-markers.ts index d6aa9c1b5a..c7006d011a 100644 --- a/packages/provider-copilot/src/interceptors/chat-completions/attach-cache-control-markers.ts +++ b/packages/provider-copilot/src/interceptors/chat-completions/attach-cache-control-markers.ts @@ -63,7 +63,7 @@ const selectCacheMarkerIndexes = (messages: readonly ChatCompletionsMessage[]): return [...new Set([...systemIndexes, ...nonSystemIndexes])].sort((a, b) => a - b); }; -export const withCacheControlMarkersAttached: CopilotChatCompletionsBoundaryInterceptor = async (ctx, _request, run) => { +export const withCacheControlMarkersAttached: CopilotChatCompletionsBoundaryInterceptor = async (ctx, _env, run) => { const indexes = selectCacheMarkerIndexes(ctx.payload.messages); for (const index of indexes) { // Fresh object per message so downstream mutations (none today, but diff --git a/packages/provider-copilot/src/interceptors/chat-completions/compress-images.ts b/packages/provider-copilot/src/interceptors/chat-completions/compress-images.ts index e241338c82..99ded20b17 100644 --- a/packages/provider-copilot/src/interceptors/chat-completions/compress-images.ts +++ b/packages/provider-copilot/src/interceptors/chat-completions/compress-images.ts @@ -1,7 +1,8 @@ +import { memoizedDataUrlCompressor } from '../image-compression.ts'; import { targetSizeForResponsesChat } from '../image-size.ts'; import type { ChatCompletionsBoundaryCtx, CopilotChatCompletionsBoundaryInterceptor } from './types.ts'; import type { ChatCompletionsContentPart, ChatCompletionsMessage } from '@floway-dev/protocols/chat-completions'; -import { isBase64ImageDataUrl, memoizedDataUrlCompressor } from '@floway-dev/provider'; +import { isBase64ImageDataUrl } from '@floway-dev/provider'; type ChatCompletionsImagePart = Extract; @@ -45,7 +46,7 @@ const compressInlineImages = async (ctx: ChatCompletionsBoundaryCtx): Promise { +export const withInlineImagesCompressed: CopilotChatCompletionsBoundaryInterceptor = async (ctx, _env, run) => { // Finish this nested activation before starting the upstream call. Its // request-local memoizer keys are the full source data URLs, which can be // several megabytes each and must not stay live for the response stream. diff --git a/packages/provider-copilot/src/interceptors/chat-completions/index.ts b/packages/provider-copilot/src/interceptors/chat-completions/index.ts index fb380ae1b3..f85df41b27 100644 --- a/packages/provider-copilot/src/interceptors/chat-completions/index.ts +++ b/packages/provider-copilot/src/interceptors/chat-completions/index.ts @@ -14,7 +14,7 @@ import type { CopilotChatCompletionsBoundaryInterceptor } from './types.ts'; // populate `ctx.headers` for the upstream call. Cache-control marker // attachment is a payload mutator, so it sits with the other payload mutators // and before any header derivation. -export const COPILOT_CHATCOMPLETIONS_BOUNDARY = [ +export const COPILOT_CHAT_COMPLETIONS_BOUNDARY = [ withInlineImagesCompressed, withToolArgumentWhitespaceAborted, withCacheControlMarkersAttached, diff --git a/packages/provider-copilot/src/interceptors/chat-completions/set-initiator-header.ts b/packages/provider-copilot/src/interceptors/chat-completions/set-initiator-header.ts index b5bfdc86d4..cd2ffa59bd 100644 --- a/packages/provider-copilot/src/interceptors/chat-completions/set-initiator-header.ts +++ b/packages/provider-copilot/src/interceptors/chat-completions/set-initiator-header.ts @@ -32,7 +32,7 @@ import type { CopilotChatCompletionsBoundaryInterceptor } from './types.ts'; * - https://github.com/microsoft/vscode/blob/fb5e582d1c8edb9ad0a69e50fe6f508a8c095466/extensions/copilot/src/platform/endpoint/node/responsesApi.ts#L419-L468 * - https://github.com/microsoft/vscode/blob/fb5e582d1c8edb9ad0a69e50fe6f508a8c095466/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts#L1453-L1474 */ -export const withInitiatorHeaderSet: CopilotChatCompletionsBoundaryInterceptor = async (ctx, _request, run) => { +export const withInitiatorHeaderSet: CopilotChatCompletionsBoundaryInterceptor = async (ctx, _env, run) => { const lastMessage = ctx.payload.messages.at(-1); const agentInitiated = lastMessage?.role === 'assistant' || lastMessage?.role === 'tool'; diff --git a/packages/provider-copilot/src/interceptors/chat-completions/set-vision-header.ts b/packages/provider-copilot/src/interceptors/chat-completions/set-vision-header.ts index b8f0a7a5b6..c1b875eab1 100644 --- a/packages/provider-copilot/src/interceptors/chat-completions/set-vision-header.ts +++ b/packages/provider-copilot/src/interceptors/chat-completions/set-vision-header.ts @@ -9,7 +9,7 @@ import type { CopilotChatCompletionsBoundaryInterceptor } from './types.ts'; * References: * - https://github.com/caozhiyuan/copilot-api/blob/cd0d0182eb4b9bf68a3376dc79728afa7f42ce07/src/services/copilot/create-chat-completions.ts#L28-L49 */ -export const withVisionHeaderSet: CopilotChatCompletionsBoundaryInterceptor = async (ctx, _request, run) => { +export const withVisionHeaderSet: CopilotChatCompletionsBoundaryInterceptor = async (ctx, _env, run) => { const hasImage = ctx.payload.messages.some( message => Array.isArray(message.content) && message.content.some(part => part.type === 'image_url'), ); diff --git a/packages/provider-copilot/src/interceptors/image-compression.ts b/packages/provider-copilot/src/interceptors/image-compression.ts new file mode 100644 index 0000000000..927c4553bb --- /dev/null +++ b/packages/provider-copilot/src/interceptors/image-compression.ts @@ -0,0 +1,53 @@ +import { compressBytesToWebp, type ImageSizeCalculator } from '@floway-dev/platform'; +import { base64ToBytes, bytesToBase64, parseBase64ImageDataUrl } from '@floway-dev/provider'; + +const compressBase64ImageToWebp = async ( + base64: string, + calculator: ImageSizeCalculator, +): Promise => { + const webp = await compressBytesToWebp(base64ToBytes(base64), calculator); + return bytesToBase64(webp); +}; + +// Recompresses a `data:image/*;base64,...` URL to a WebP data URL. Returns the +// original URL unchanged when it is not a base64 image data URL (e.g. a remote +// https image reference, which the egress forwards as-is). +const compressImageDataUrlToWebp = async ( + url: string, + calculator: ImageSizeCalculator, +): Promise => { + const parsed = parseBase64ImageDataUrl(url); + if (parsed === null) return url; + const webp = await compressBase64ImageToWebp(parsed.base64, calculator); + return `data:image/webp;base64,${webp}`; +}; + +// A single agentic request often replays the same screenshot across many turns, +// so the boundary interceptors run `Promise.all` over dozens of inline images +// that hash to the same cache key. Without dedup, every duplicate races a +// concurrent cache write on that one key, tripping Cloudflare KV's per-key +// 1-write/sec limit and wasting work on the Node target. The returned function +// shares one in-flight compression per input for its lifetime. +const memoize = ( + compute: (input: TInput) => Promise, +): ((input: TInput) => Promise) => { + const cache = new Map>(); + return input => { + let pending = cache.get(input); + if (!pending) { + pending = compute(input); + cache.set(input, pending); + } + return pending; + }; +}; + +export const memoizedDataUrlCompressor = ( + calculator: ImageSizeCalculator, +): ((url: string) => Promise) => + memoize(url => compressImageDataUrlToWebp(url, calculator)); + +export const memoizedBase64Compressor = ( + calculator: ImageSizeCalculator, +): ((base64: string) => Promise) => + memoize(base64 => compressBase64ImageToWebp(base64, calculator)); diff --git a/packages/provider-copilot/src/interceptors/messages/align-context-management-beta.ts b/packages/provider-copilot/src/interceptors/messages/align-context-management-beta.ts index 404f502b55..e7ab444259 100644 --- a/packages/provider-copilot/src/interceptors/messages/align-context-management-beta.ts +++ b/packages/provider-copilot/src/interceptors/messages/align-context-management-beta.ts @@ -1,4 +1,5 @@ -import type { MessagesBoundaryCtx, MessagesCountTokensBoundaryCtx } from './types.ts'; +import { CONTEXT_MANAGEMENT_BETA } from './filter-anthropic-beta-header.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; /** * Copilot's Anthropic-shaped upstream gates the body field `context_management` @@ -24,11 +25,9 @@ import type { MessagesBoundaryCtx, MessagesCountTokensBoundaryCtx } from './type * writer of `anthropic-beta` on `ctx.headers`. Reading the post-filter * value is the whole point. */ -const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27'; - export const withContextManagementBetaAligned = async ( - ctx: MessagesBoundaryCtx | MessagesCountTokensBoundaryCtx, - _request: object, + ctx: MessagesBoundaryCtx, + _env: object, run: () => Promise, ): Promise => { const payload = ctx.payload as typeof ctx.payload & { context_management?: unknown }; diff --git a/packages/provider-copilot/src/interceptors/messages/apply-top-level-cache-control.ts b/packages/provider-copilot/src/interceptors/messages/apply-top-level-cache-control.ts index d65c0ef554..cdec4bbc0c 100644 --- a/packages/provider-copilot/src/interceptors/messages/apply-top-level-cache-control.ts +++ b/packages/provider-copilot/src/interceptors/messages/apply-top-level-cache-control.ts @@ -35,7 +35,7 @@ type CacheableBlock = Extract< const isCacheableBlock = (block: MessagesUserContentBlock | MessagesAssistantContentBlock): block is CacheableBlock => block.type === 'text' || block.type === 'image' || block.type === 'tool_use' || block.type === 'tool_result'; -export const withTopLevelCacheControlApplied: CopilotMessagesBoundaryInterceptor = async (ctx, _request, run) => { +export const withTopLevelCacheControlApplied: CopilotMessagesBoundaryInterceptor = async (ctx, _env, run) => { const payload = ctx.payload as typeof ctx.payload & { cache_control?: { type: 'ephemeral' } }; const topLevel = payload.cache_control; if (topLevel === undefined) return await run(); diff --git a/packages/provider-copilot/src/interceptors/messages/compress-images.ts b/packages/provider-copilot/src/interceptors/messages/compress-images.ts index 9f2f33c78b..121bc84cc8 100644 --- a/packages/provider-copilot/src/interceptors/messages/compress-images.ts +++ b/packages/provider-copilot/src/interceptors/messages/compress-images.ts @@ -1,7 +1,7 @@ -import type { MessagesBoundaryCtx, MessagesCountTokensBoundaryCtx } from './types.ts'; +import { memoizedBase64Compressor } from '../image-compression.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import { type ImageSizeCalculator, type SizeCaps, fitWithin } from '@floway-dev/platform'; import type { MessagesImageBlock, MessagesMessage, MessagesToolResultBlock, MessagesToolResultContentBlock, MessagesUserContentBlock } from '@floway-dev/protocols/messages'; -import { memoizedBase64Compressor } from '@floway-dev/provider'; // Per-model image caps for the Claude (Messages) egress, measured from the real // /v1/messages generation path (count_tokens misreports the downscale here). @@ -48,7 +48,7 @@ const collectImageBlocks = (messages: MessagesMessage[]): MessagesImageBlock[] = return blocks; }; -const compressInlineImages = async (ctx: MessagesBoundaryCtx | MessagesCountTokensBoundaryCtx): Promise => { +const compressInlineImages = async (ctx: MessagesBoundaryCtx): Promise => { const blocks = collectImageBlocks(ctx.payload.messages); if (blocks.length === 0) return; @@ -104,8 +104,8 @@ const compressInlineImages = async (ctx: MessagesBoundaryCtx | MessagesCountToke // count_tokens boundary chain, so count_tokens sizes the same recompressed // payload the chat path sends. export const withInlineImagesCompressed = async ( - ctx: MessagesBoundaryCtx | MessagesCountTokensBoundaryCtx, - _request: object, + ctx: MessagesBoundaryCtx, + _env: object, run: () => Promise, ): Promise => { // Finish this nested activation before starting the upstream call. Its diff --git a/packages/provider-copilot/src/interceptors/messages/filter-anthropic-beta-header.ts b/packages/provider-copilot/src/interceptors/messages/filter-anthropic-beta-header.ts index f1b6f0bba6..ce40522114 100644 --- a/packages/provider-copilot/src/interceptors/messages/filter-anthropic-beta-header.ts +++ b/packages/provider-copilot/src/interceptors/messages/filter-anthropic-beta-header.ts @@ -1,4 +1,4 @@ -import type { MessagesBoundaryCtx, MessagesCountTokensBoundaryCtx } from './types.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import { parseAnthropicBetaHeader } from '@floway-dev/protocols/messages'; /** @@ -21,8 +21,8 @@ import { parseAnthropicBetaHeader } from '@floway-dev/protocols/messages'; * not have interleaved auto-added even with non-adaptive budget thinking — * matching VSCode Copilot Chat. * - * Generic in the run-result type because pre-Path A the equivalent filter - * ran on every Copilot Messages HTTP exchange (chat AND count_tokens). + * Generic in the run-result type because the Copilot provider historically + * applied this filter to every Messages HTTP exchange (chat AND count_tokens). * Keeping a single generic interceptor lets both the streaming Messages * boundary chain (`ExecuteResult<...>`) and the count_tokens chain * (`Response`) share one definition. @@ -33,16 +33,18 @@ import { parseAnthropicBetaHeader } from '@floway-dev/protocols/messages'; * - https://github.com/caozhiyuan/copilot-api/commit/b2dbf9d57612bdf75e87f71993567bd5315b22b5 * - https://github.com/caozhiyuan/copilot-api/blob/main/src/services/copilot/create-messages.ts (buildAnthropicBetaHeader) */ +export const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27'; + const ALLOWED_ANTHROPIC_BETAS = new Set([ 'interleaved-thinking-2025-05-14', - 'context-management-2025-06-27', + CONTEXT_MANAGEMENT_BETA, 'advanced-tool-use-2025-11-20', ]); const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; export const withAnthropicBetaHeaderFiltered = async ( - ctx: MessagesBoundaryCtx | MessagesCountTokensBoundaryCtx, - _request: object, + ctx: MessagesBoundaryCtx, + _env: object, run: () => Promise, ): Promise => { // Read the caller's untouched intent before deleting the raw header — diff --git a/packages/provider-copilot/src/interceptors/messages/handle-speed-fast.ts b/packages/provider-copilot/src/interceptors/messages/handle-speed-fast.ts index f37ca9708a..da75945374 100644 --- a/packages/provider-copilot/src/interceptors/messages/handle-speed-fast.ts +++ b/packages/provider-copilot/src/interceptors/messages/handle-speed-fast.ts @@ -27,7 +27,7 @@ import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; * - https://docs.claude.com/en/build-with-claude/fast-mode * - https://docs.claude.com/en/api/service-tiers */ -export const withSpeedFast: CopilotMessagesBoundaryInterceptor = async (ctx, _request, run) => { +export const withSpeedFast: CopilotMessagesBoundaryInterceptor = async (ctx, _env, run) => { const speed = ctx.payload.speed; const stampFast = speed === 'fast'; diff --git a/packages/provider-copilot/src/interceptors/messages/index.ts b/packages/provider-copilot/src/interceptors/messages/index.ts index fdacedd2a9..af4b29395c 100644 --- a/packages/provider-copilot/src/interceptors/messages/index.ts +++ b/packages/provider-copilot/src/interceptors/messages/index.ts @@ -75,19 +75,17 @@ export const COPILOT_MESSAGES_BOUNDARY = [ ] as const satisfies readonly CopilotMessagesBoundaryInterceptor[]; // /v1/messages/count_tokens is a one-shot HTTP exchange that returns the raw -// upstream Response. Pre-Path A the Copilot provider's call helper applied -// vision detection, x-initiator classification, and anthropic-beta allow-list -// filtering to BOTH chat and count_tokens; only count_tokens stopped seeing -// them when the headers moved onto the chat-planning target interceptor -// chain. This list re-instates exactly those three header-shaping workarounds -// at the Copilot count_tokens boundary so behavior matches pre-Path A. +// upstream Response. The Copilot provider applies vision detection, +// x-initiator classification, anthropic-beta allow-list filtering, and +// context-management beta alignment to both chat and count_tokens. // // withInlineImagesCompressed runs first so count_tokens sizes the same -// WebP-recompressed payload the chat path sends — and reuses its cached -// transform — keeping the estimate consistent with the real request. -// withThinkingDisplayPromoted / withTopLevelCacheControlApplied / -// withCacheControlExtensionsStripped / withEagerInputStreamingStripped are -// intentionally absent: pre-Path A they also never ran on count_tokens. +// WebP-recompressed payload the chat path sends, keeping the estimate +// consistent with the real request. withContextManagementBetaAligned follows +// withAnthropicBetaHeaderFiltered so any surviving `context_management` field +// remains paired with its required header token. Event-stream and chat-only +// payload mutators are intentionally absent because count_tokens returns a raw +// Response and never used those transformations. export const COPILOT_MESSAGES_COUNT_TOKENS_BOUNDARY = [ withInlineImagesCompressed, withVisionHeaderSet, diff --git a/packages/provider-copilot/src/interceptors/messages/promote-thinking-display.ts b/packages/provider-copilot/src/interceptors/messages/promote-thinking-display.ts index 6493722394..a46b64c40a 100644 --- a/packages/provider-copilot/src/interceptors/messages/promote-thinking-display.ts +++ b/packages/provider-copilot/src/interceptors/messages/promote-thinking-display.ts @@ -91,7 +91,7 @@ const omitThinkingTextFromProtocolFrames = async function* (frames: AsyncIterabl * - https://github.com/anthropics/claude-code/issues/46987 * - https://github.com/anthropics/claude-code/issues/50477 */ -export const withThinkingDisplayPromoted: CopilotMessagesBoundaryInterceptor = async (ctx, _request, run) => { +export const withThinkingDisplayPromoted: CopilotMessagesBoundaryInterceptor = async (ctx, _env, run) => { const downstreamDisplay = resolveMessagesDownstreamThinkingDisplay(ctx); const thinking = ctx.payload.thinking; const hasActiveThinking = !!thinking && thinking.type !== 'disabled'; diff --git a/packages/provider-copilot/src/interceptors/messages/rewrite-context-window-error.ts b/packages/provider-copilot/src/interceptors/messages/rewrite-context-window-error.ts index 8451530de7..7fec1d6b9d 100644 --- a/packages/provider-copilot/src/interceptors/messages/rewrite-context-window-error.ts +++ b/packages/provider-copilot/src/interceptors/messages/rewrite-context-window-error.ts @@ -17,7 +17,7 @@ const isContextWindowError = (text: string): boolean => text.includes('Request b * substring set here (`Request body is too large...`) is disjoint from * the Responses/Chat shapes those pairs match on. */ -export const rewriteContextWindowError: CopilotMessagesBoundaryInterceptor = async (_ctx, _request, run) => { +export const rewriteContextWindowError: CopilotMessagesBoundaryInterceptor = async (_ctx, _env, run) => { const result = await run(); if (result.type !== 'api-error' || result.source !== 'upstream') return result; diff --git a/packages/provider-copilot/src/interceptors/messages/set-claude-agent-headers.ts b/packages/provider-copilot/src/interceptors/messages/set-claude-agent-headers.ts index ef0066c7e4..234c0903fc 100644 --- a/packages/provider-copilot/src/interceptors/messages/set-claude-agent-headers.ts +++ b/packages/provider-copilot/src/interceptors/messages/set-claude-agent-headers.ts @@ -42,7 +42,7 @@ import { CLAUDE_AGENT_USER_AGENT } from '../../auth.ts'; */ const UPSTREAM_REJECTS_CLAUDE_AGENT_IDENTITY = new Set(['claude-opus-4-8']); -export const withClaudeAgentHeadersSet: CopilotMessagesBoundaryInterceptor = async (ctx, _request, run) => { +export const withClaudeAgentHeadersSet: CopilotMessagesBoundaryInterceptor = async (ctx, _env, run) => { if (UPSTREAM_REJECTS_CLAUDE_AGENT_IDENTITY.has(ctx.payload.model)) { return await run(); } diff --git a/packages/provider-copilot/src/interceptors/messages/set-compact-headers.ts b/packages/provider-copilot/src/interceptors/messages/set-compact-headers.ts index f36a8b496b..ceab728610 100644 --- a/packages/provider-copilot/src/interceptors/messages/set-compact-headers.ts +++ b/packages/provider-copilot/src/interceptors/messages/set-compact-headers.ts @@ -105,7 +105,7 @@ const classifyCompact = (payload: { messages: MessagesMessage[]; system?: string return null; }; -export const withCompactHeadersSet: CopilotMessagesBoundaryInterceptor = async (ctx, _request, run) => { +export const withCompactHeadersSet: CopilotMessagesBoundaryInterceptor = async (ctx, _env, run) => { const kind = classifyCompact(ctx.payload); if (kind === 'compact-request') { ctx.headers.set('x-initiator', 'agent'); diff --git a/packages/provider-copilot/src/interceptors/messages/set-initiator-header.ts b/packages/provider-copilot/src/interceptors/messages/set-initiator-header.ts index 3f20ada512..1e4691852b 100644 --- a/packages/provider-copilot/src/interceptors/messages/set-initiator-header.ts +++ b/packages/provider-copilot/src/interceptors/messages/set-initiator-header.ts @@ -1,4 +1,4 @@ -import type { MessagesBoundaryCtx, MessagesCountTokensBoundaryCtx } from './types.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; /** * Copilot's `x-initiator` header distinguishes turns that the human user just @@ -15,15 +15,15 @@ import type { MessagesBoundaryCtx, MessagesCountTokensBoundaryCtx } from './type * * Generic in the run-result type so the count_tokens boundary chain * (`Response`) and the streaming Messages boundary chain (`ExecuteResult<...>`) - * can share one definition, matching the pre-Path A behavior where - * x-initiator was set on every Copilot Messages HTTP call. + * can share one definition, matching the established behavior where + * x-initiator is set on every Copilot Messages HTTP call. * * References: * - https://github.com/caozhiyuan/copilot-api/blob/master/src/services/copilot/create-chat-completions.ts */ export const withInitiatorHeaderSet = async ( - ctx: MessagesBoundaryCtx | MessagesCountTokensBoundaryCtx, - _request: object, + ctx: MessagesBoundaryCtx, + _env: object, run: () => Promise, ): Promise => { const lastMessage = ctx.payload.messages[ctx.payload.messages.length - 1]; diff --git a/packages/provider-copilot/src/interceptors/messages/set-interaction-id-header.ts b/packages/provider-copilot/src/interceptors/messages/set-interaction-id-header.ts index e3abb8b4ce..f4a2a63b56 100644 --- a/packages/provider-copilot/src/interceptors/messages/set-interaction-id-header.ts +++ b/packages/provider-copilot/src/interceptors/messages/set-interaction-id-header.ts @@ -28,7 +28,7 @@ const sessionUuid = async (input: string): Promise => { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; }; -export const withInteractionIdHeaderSet: CopilotMessagesBoundaryInterceptor = async (ctx, _request, run) => { +export const withInteractionIdHeaderSet: CopilotMessagesBoundaryInterceptor = async (ctx, _env, run) => { const { sessionId } = parseUserIdMetadata(ctx.payload.metadata?.user_id); if (sessionId) { ctx.headers.set('x-interaction-id', await sessionUuid(sessionId)); diff --git a/packages/provider-copilot/src/interceptors/messages/set-vision-header.ts b/packages/provider-copilot/src/interceptors/messages/set-vision-header.ts index 83cd00ee6b..472d230a30 100644 --- a/packages/provider-copilot/src/interceptors/messages/set-vision-header.ts +++ b/packages/provider-copilot/src/interceptors/messages/set-vision-header.ts @@ -1,4 +1,4 @@ -import type { MessagesBoundaryCtx, MessagesCountTokensBoundaryCtx } from './types.ts'; +import type { MessagesBoundaryCtx } from './types.ts'; import type { MessagesAssistantMessage, MessagesUserMessage } from '@floway-dev/protocols/messages'; /** @@ -8,9 +8,9 @@ import type { MessagesAssistantMessage, MessagesUserMessage } from '@floway-dev/ * and cover both the top-level `message.content` and the nested * `tool_result.content[]` shape; Anthropic allows images in both positions. * - * Generic in the run-result type because pre-Path A the equivalent vision - * detection ran on every Copilot Messages HTTP exchange (chat AND - * count_tokens). Keeping a single generic interceptor lets both the streaming + * Generic in the run-result type because the Copilot provider historically + * applied equivalent vision detection to every Messages HTTP exchange (chat + * AND count_tokens). Keeping a single generic interceptor lets both the streaming * Messages boundary chain (`ExecuteResult<...>`) and the count_tokens chain * (`Response`) share one definition. * @@ -29,8 +29,8 @@ const contentHasImage = (content: MessagesUserMessage['content'] | MessagesAssis }; export const withVisionHeaderSet = async ( - ctx: MessagesBoundaryCtx | MessagesCountTokensBoundaryCtx, - _request: object, + ctx: MessagesBoundaryCtx, + _env: object, run: () => Promise, ): Promise => { if (ctx.payload.messages.some(message => contentHasImage(message.content))) { diff --git a/packages/provider-copilot/src/interceptors/messages/strip-cache-control-extensions.ts b/packages/provider-copilot/src/interceptors/messages/strip-cache-control-extensions.ts index 7c34d855e9..7bd9548afe 100644 --- a/packages/provider-copilot/src/interceptors/messages/strip-cache-control-extensions.ts +++ b/packages/provider-copilot/src/interceptors/messages/strip-cache-control-extensions.ts @@ -42,7 +42,7 @@ const stripExtensions = (block: Record): void => { else delete block.cache_control; }; -export const withCacheControlExtensionsStripped: CopilotMessagesBoundaryInterceptor = async (ctx, _request, run) => { +export const withCacheControlExtensionsStripped: CopilotMessagesBoundaryInterceptor = async (ctx, _env, run) => { if (Array.isArray(ctx.payload.system)) { for (const block of ctx.payload.system as unknown as Record[]) { stripExtensions(block); diff --git a/packages/provider-copilot/src/interceptors/messages/strip-eager-input-streaming.ts b/packages/provider-copilot/src/interceptors/messages/strip-eager-input-streaming.ts index c0f9f7c74f..13e3838a71 100644 --- a/packages/provider-copilot/src/interceptors/messages/strip-eager-input-streaming.ts +++ b/packages/provider-copilot/src/interceptors/messages/strip-eager-input-streaming.ts @@ -11,7 +11,7 @@ import type { CopilotMessagesBoundaryInterceptor } from './types.ts'; * References: * - https://github.com/anthropics/anthropic-sdk-typescript/blob/a53f60d59ca904f3e79296586642aac3ce68ae02/src/resources/messages/messages.ts#L1761 */ -export const withEagerInputStreamingStripped: CopilotMessagesBoundaryInterceptor = async (ctx, _request, run) => { +export const withEagerInputStreamingStripped: CopilotMessagesBoundaryInterceptor = async (ctx, _env, run) => { if (ctx.payload.tools) { ctx.payload.tools = ctx.payload.tools.map(tool => { const { eager_input_streaming: _, ...rest } = tool as typeof tool & { diff --git a/packages/provider-copilot/src/interceptors/messages/strip-structured-output-format.ts b/packages/provider-copilot/src/interceptors/messages/strip-structured-output-format.ts index 1dac67f44b..74be28aa7b 100644 --- a/packages/provider-copilot/src/interceptors/messages/strip-structured-output-format.ts +++ b/packages/provider-copilot/src/interceptors/messages/strip-structured-output-format.ts @@ -29,7 +29,7 @@ import type { CopilotMessagesBoundaryInterceptor } from './types.ts'; * - https://github.com/anthropics/anthropic-sdk-typescript/blob/main/src/resources/messages/messages.ts (OutputConfig, JSONOutputFormat) * - https://github.com/imbuxiangnan-cyber/copilot-api-plus/blob/0350e8805456b2c14e12358db66ae0584a5cc4ac/src/routes/messages/handler.ts#L260-L285 (prior art: transparent retry) */ -export const withStructuredOutputFormatStripped: CopilotMessagesBoundaryInterceptor = async (ctx, _request, run) => { +export const withStructuredOutputFormatStripped: CopilotMessagesBoundaryInterceptor = async (ctx, _env, run) => { const config = ctx.payload.output_config as Record | undefined; if (config && 'format' in config) { delete config.format; diff --git a/packages/provider-copilot/src/interceptors/messages/strip-tool-strict.ts b/packages/provider-copilot/src/interceptors/messages/strip-tool-strict.ts index f729e17314..4a42978987 100644 --- a/packages/provider-copilot/src/interceptors/messages/strip-tool-strict.ts +++ b/packages/provider-copilot/src/interceptors/messages/strip-tool-strict.ts @@ -10,7 +10,7 @@ import type { CopilotMessagesBoundaryInterceptor } from './types.ts'; * outbound; the model still respects `input_schema`, only the * grammar-constrained guarantee is gone. */ -export const withToolStrictStripped: CopilotMessagesBoundaryInterceptor = async (ctx, _request, run) => { +export const withToolStrictStripped: CopilotMessagesBoundaryInterceptor = async (ctx, _env, run) => { if (Array.isArray(ctx.payload.tools)) { for (const tool of ctx.payload.tools as unknown as Record[]) { if ('strict' in tool) delete tool.strict; diff --git a/packages/provider-copilot/src/interceptors/messages/types.ts b/packages/provider-copilot/src/interceptors/messages/types.ts index c5d7ee827f..46ee1c92a7 100644 --- a/packages/provider-copilot/src/interceptors/messages/types.ts +++ b/packages/provider-copilot/src/interceptors/messages/types.ts @@ -28,16 +28,9 @@ export type CopilotMessagesBoundaryInterceptor = Interceptor< // count_tokens is a one-shot, non-streaming HTTP exchange: the terminal // returns the raw upstream `Response` directly. Pure header/payload mutators -// only — post-`run()` event-stream inspection is not portable to this -// result type. -export interface MessagesCountTokensBoundaryCtx { - payload: MessagesPayload; - headers: Headers; - readonly model: ProviderModel; -} - +// only — post-`run()` event-stream inspection is not portable to this result. export type CopilotMessagesCountTokensBoundaryInterceptor = Interceptor< - MessagesCountTokensBoundaryCtx, + MessagesBoundaryCtx, object, Response >; diff --git a/packages/provider-copilot/src/interceptors/responses/abort-on-tool-argument-whitespace.ts b/packages/provider-copilot/src/interceptors/responses/abort-on-tool-argument-whitespace.ts index d8910f2c4b..c5ad7f3962 100644 --- a/packages/provider-copilot/src/interceptors/responses/abort-on-tool-argument-whitespace.ts +++ b/packages/provider-copilot/src/interceptors/responses/abort-on-tool-argument-whitespace.ts @@ -33,7 +33,7 @@ const errorEvent = (): ResponsesStreamEvent => code: 'api_error', }) as ResponsesStreamEvent; -export const withToolArgumentWhitespaceAborted: CopilotResponsesBoundaryInterceptor = async (_invocation, _request, run) => { +export const withToolArgumentWhitespaceAborted: CopilotResponsesBoundaryInterceptor = async (_invocation, _env, run) => { const result = await run(); // Only the streaming generate branch produces events worth inspecting. // The compact branch is a single value envelope; pass it through unchanged. diff --git a/packages/provider-copilot/src/interceptors/responses/action-pivot_test.ts b/packages/provider-copilot/src/interceptors/responses/action-pivot_test.ts index ffb0b7d69d..303ed33b8f 100644 --- a/packages/provider-copilot/src/interceptors/responses/action-pivot_test.ts +++ b/packages/provider-copilot/src/interceptors/responses/action-pivot_test.ts @@ -14,7 +14,7 @@ import { assertEquals } from '@floway-dev/test-utils'; // observed wire request must be the streaming /responses shape // (stream:true, no `compaction_trigger`), and the typed result must // surface as the `action: 'generate'` variant. -const pivotCompactToGenerate: Interceptor = async (ctx, _request, run) => { +const pivotCompactToGenerate: Interceptor = async (ctx, _env, run) => { ctx.action = 'generate'; return await run(); }; diff --git a/packages/provider-copilot/src/interceptors/responses/compress-images.ts b/packages/provider-copilot/src/interceptors/responses/compress-images.ts index 08f0c46b41..f9339efadf 100644 --- a/packages/provider-copilot/src/interceptors/responses/compress-images.ts +++ b/packages/provider-copilot/src/interceptors/responses/compress-images.ts @@ -1,7 +1,8 @@ +import { memoizedDataUrlCompressor } from '../image-compression.ts'; import { targetSizeForResponsesChat } from '../image-size.ts'; import type { ResponsesBoundaryCtx } from './types.ts'; import type { ResponsesInputContent, ResponsesInputImage } from '@floway-dev/protocols/responses'; -import { isBase64ImageDataUrl, memoizedDataUrlCompressor } from '@floway-dev/provider'; +import { isBase64ImageDataUrl } from '@floway-dev/provider'; // A cyber-policy retry re-enters this boundary with the same nested image // part. Remember the exact generated URL on that request-owned object so the @@ -78,7 +79,7 @@ const compressInlineImages = async (ctx: ResponsesBoundaryCtx): Promise => // compaction chain. export const withInlineImagesCompressed = async ( ctx: ResponsesBoundaryCtx, - _request: object, + _env: object, run: () => Promise, ): Promise => { // Finish this nested activation before starting the upstream call. Its diff --git a/packages/provider-copilot/src/interceptors/responses/force-store-false.ts b/packages/provider-copilot/src/interceptors/responses/force-store-false.ts index 0b1d9b6006..b3b7f8b03a 100644 --- a/packages/provider-copilot/src/interceptors/responses/force-store-false.ts +++ b/packages/provider-copilot/src/interceptors/responses/force-store-false.ts @@ -14,7 +14,7 @@ import type { ResponsesBoundaryCtx } from './types.ts'; */ export const withStoreForcedFalse = async ( ctx: ResponsesBoundaryCtx, - _request: object, + _env: object, run: () => Promise, ): Promise => { ctx.payload = { ...ctx.payload, store: false }; diff --git a/packages/provider-copilot/src/interceptors/responses/item-id-membrane.ts b/packages/provider-copilot/src/interceptors/responses/item-id-membrane.ts index 6213c14312..7ed0d1d9ba 100644 --- a/packages/provider-copilot/src/interceptors/responses/item-id-membrane.ts +++ b/packages/provider-copilot/src/interceptors/responses/item-id-membrane.ts @@ -74,19 +74,19 @@ const mapCarrierValues = ( }; const restoreInputItem = (item: ResponsesInputItem): ResponsesInputItem => { - const upstreamIds = new Set(); + const upstreamItemIds = new Set(); const restored = mapCarrierValues(item, value => { const decoded = unwrapCopilotItemId(value); if (decoded.kind === 'foreign') return value; - upstreamIds.add(decoded.id); + upstreamItemIds.add(decoded.id); return decoded.value; }); - if (upstreamIds.size === 0) return restored; - if (upstreamIds.size > 1) { + if (upstreamItemIds.size === 0) return restored; + if (upstreamItemIds.size > 1) { throw new TypeError('Copilot Responses item carries conflicting upstream ids'); } - return { ...restored, id: [...upstreamIds][0] } as ResponsesInputItem; + return { ...restored, id: [...upstreamItemIds][0] } as ResponsesInputItem; }; const restoreInputItemIds = (payload: CanonicalResponsesPayload): CanonicalResponsesPayload => ({ @@ -268,7 +268,7 @@ const normalizeCompactionResult = (response: ResponsesResult): ResponsesResult = }), }); -export const withCopilotResponsesItemIdMembrane: CopilotResponsesBoundaryInterceptor = async (ctx, _request, run) => { +export const withCopilotResponsesItemIdMembrane: CopilotResponsesBoundaryInterceptor = async (ctx, _env, run) => { ctx.payload = restoreInputItemIds(ctx.payload); const result = await run(); if (!result.ok) return result; diff --git a/packages/provider-copilot/src/interceptors/responses/set-initiator-header.ts b/packages/provider-copilot/src/interceptors/responses/set-initiator-header.ts index e1094c98fe..f1d1fdf927 100644 --- a/packages/provider-copilot/src/interceptors/responses/set-initiator-header.ts +++ b/packages/provider-copilot/src/interceptors/responses/set-initiator-header.ts @@ -24,7 +24,7 @@ import type { ResponsesInputItem } from '@floway-dev/protocols/responses'; */ export const withInitiatorHeaderSet = async ( ctx: ResponsesBoundaryCtx, - _request: object, + _env: object, run: () => Promise, ): Promise => { const lastItem: ResponsesInputItem | undefined = ctx.payload.input.at(-1); diff --git a/packages/provider-copilot/src/interceptors/responses/set-vision-header.ts b/packages/provider-copilot/src/interceptors/responses/set-vision-header.ts index 84a5de1a46..3e600e19ff 100644 --- a/packages/provider-copilot/src/interceptors/responses/set-vision-header.ts +++ b/packages/provider-copilot/src/interceptors/responses/set-vision-header.ts @@ -21,7 +21,7 @@ const itemHasImage = (item: ResponsesInputItem): boolean => { export const withVisionHeaderSet = async ( ctx: ResponsesBoundaryCtx, - _request: object, + _env: object, run: () => Promise, ): Promise => { if (ctx.payload.input.some(itemHasImage)) ctx.headers.set('copilot-vision-request', 'true'); diff --git a/packages/provider-copilot/src/interceptors/responses/strip-image-generation.ts b/packages/provider-copilot/src/interceptors/responses/strip-image-generation.ts index e50dc93cfe..fca89bb3b0 100644 --- a/packages/provider-copilot/src/interceptors/responses/strip-image-generation.ts +++ b/packages/provider-copilot/src/interceptors/responses/strip-image-generation.ts @@ -51,7 +51,7 @@ export const stripImageGenerationFromPayload = (payload: CanonicalResponsesPaylo export const withImageGenerationStripped = async ( ctx: ResponsesBoundaryCtx, - _request: object, + _env: object, run: () => Promise, ): Promise => { stripImageGenerationFromPayload(ctx.payload); diff --git a/packages/provider-copilot/src/interceptors/responses/strip-service-tier.ts b/packages/provider-copilot/src/interceptors/responses/strip-service-tier.ts index 23887ec7da..5178cb4d42 100644 --- a/packages/provider-copilot/src/interceptors/responses/strip-service-tier.ts +++ b/packages/provider-copilot/src/interceptors/responses/strip-service-tier.ts @@ -14,7 +14,7 @@ import type { ResponsesBoundaryCtx } from './types.ts'; */ export const withServiceTierStripped = async ( ctx: ResponsesBoundaryCtx, - _request: object, + _env: object, run: () => Promise, ): Promise => { const { service_tier: _, ...payload } = ctx.payload; diff --git a/packages/provider-copilot/src/model-name.ts b/packages/provider-copilot/src/model-name.ts index 06353869dc..050189fee5 100644 --- a/packages/provider-copilot/src/model-name.ts +++ b/packages/provider-copilot/src/model-name.ts @@ -1,5 +1,8 @@ -const CLAUDE_VARIANT_SUFFIX = /-(?:high|xhigh|1m(?:-internal)?|fast)$/; -const CLAUDE_DATE_SUFFIX = /-\d{8}$/; +export const CLAUDE_VARIANT_SUFFIX = /-(?:high|xhigh|1m(?:-internal)?|fast)$/; +export const CLAUDE_DATE_SUFFIX = /-\d{8}$/; + +export const stripClaudeDateSuffix = (id: string): string => + id.startsWith('claude-') ? id.replace(CLAUDE_DATE_SUFFIX, '') : id; export const copilotRawModelId = (id: string): string => { if (!id.startsWith('claude-')) return id; diff --git a/packages/provider-copilot/src/model-selection.ts b/packages/provider-copilot/src/model-selection.ts index 38e591714a..57b0e96048 100644 --- a/packages/provider-copilot/src/model-selection.ts +++ b/packages/provider-copilot/src/model-selection.ts @@ -1,9 +1,8 @@ -import { copilotRawModelId } from './model-name.ts'; +import { copilotRawModelId, stripClaudeDateSuffix } from './model-name.ts'; import type { CopilotModelsResponse, CopilotRawModel } from './types.ts'; export const CONTEXT_1M_BETA = 'context-1m-2025-08-07'; -const CLAUDE_DATE_SUFFIX = /-\d{8}$/; const STANDARD_CLAUDE_BASE_ID = /^claude-[a-z0-9-]+-\d+(?:\.\d+)?$/; const KNOWN_CLAUDE_VARIANT_SUFFIXES = new Set(['high', 'xhigh', '1m', '1m-internal', 'fast']); @@ -13,8 +12,6 @@ export interface ModelSelectionHints { fast?: boolean; } -const stripClaudeDateSuffix = (id: string): string => (id.startsWith('claude-') ? id.replace(CLAUDE_DATE_SUFFIX, '') : id); - const normalizedClaudeLookupId = (id: string): string => copilotRawModelId(stripClaudeDateSuffix(id)); const standardClaudeBaseId = (id: string): string | undefined => { diff --git a/packages/provider-copilot/src/pricing.ts b/packages/provider-copilot/src/pricing.ts index efa9c365b2..01391ad418 100644 --- a/packages/provider-copilot/src/pricing.ts +++ b/packages/provider-copilot/src/pricing.ts @@ -7,7 +7,7 @@ // https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing // After changing this table, run the unit-price backfill for existing rows. // Refresh procedure: .agents/skills/fetching-models-pricing/. -import { tokenBasePricing, tokenModelPricing, tokenPricingEntry as pricingEntry, type ModelPricing } from '@floway-dev/protocols/common'; +import { modelPricing, tokenBasePricing, tokenPricingEntry, type ModelPricing } from '@floway-dev/protocols/common'; type PricingRule = readonly [key: string | RegExp, pricing: ModelPricing]; @@ -15,19 +15,19 @@ const COPILOT_MODEL_PRICING: readonly PricingRule[] = [ ['claude-opus-4-5', tokenBasePricing({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '25' })], // Anthropic public Fast Mode pricing is 6× base for Opus 4.6 / 4.7. // https://docs.claude.com/en/build-with-claude/fast-mode - [/^claude-opus-4-[67]$/, tokenModelPricing( - pricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '25' }), - pricingEntry({ input_tokens: '30', input_cache_read_tokens: '3', input_cache_write_tokens: '37.5', output_tokens: '150' }, { serviceTier: 'fast' }), + [/^claude-opus-4-[67]$/, modelPricing( + tokenPricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '25' }), + tokenPricingEntry({ input_tokens: '30', input_cache_read_tokens: '3', input_cache_write_tokens: '37.5', output_tokens: '150' }, { serviceTier: 'fast' }), )], - ['claude-opus-4-8', tokenModelPricing( - pricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '25' }), - pricingEntry({ input_tokens: '10', input_cache_read_tokens: '1', input_cache_write_tokens: '12.5', output_tokens: '50' }, { serviceTier: 'fast' }), + ['claude-opus-4-8', modelPricing( + tokenPricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '25' }), + tokenPricingEntry({ input_tokens: '10', input_cache_read_tokens: '1', input_cache_write_tokens: '12.5', output_tokens: '50' }, { serviceTier: 'fast' }), )], // Opus 5 lists at Opus 4.8 rates; Copilot bills it at provider API list price. // https://github.blog/changelog/2026-07-24-claude-opus-5-is-now-available-in-github-copilot/ - ['claude-opus-5', tokenModelPricing( - pricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '25' }), - pricingEntry({ input_tokens: '10', input_cache_read_tokens: '1', input_cache_write_tokens: '12.5', output_tokens: '50' }, { serviceTier: 'fast' }), + ['claude-opus-5', modelPricing( + tokenPricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '25' }), + tokenPricingEntry({ input_tokens: '10', input_cache_read_tokens: '1', input_cache_write_tokens: '12.5', output_tokens: '50' }, { serviceTier: 'fast' }), )], ['claude-sonnet-5', tokenBasePricing({ input_tokens: '2', input_cache_read_tokens: '0.2', input_cache_write_tokens: '2.5', output_tokens: '10' })], [/^claude-sonnet-4(-[56])?$/, tokenBasePricing({ input_tokens: '3', input_cache_read_tokens: '0.3', input_cache_write_tokens: '3.75', output_tokens: '15' })], @@ -38,28 +38,28 @@ const COPILOT_MODEL_PRICING: readonly PricingRule[] = [ // https://github.com/BerriAI/litellm/blob/6fa088224bc2022c7541ee44cf02c0bd6dd2942e/model_prices_and_context_window.json // Cross-check only: // https://github.com/caozhiyuan/copilot-api/blob/5a28eee7ced4fda51b6b224fb8723df5e6534708/src/lib/token-usage/pricing.ts#L98-L148 - ['gpt-5.6-sol', tokenModelPricing( - pricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '30' }), - pricingEntry({ input_tokens: '10', input_cache_read_tokens: '1', input_cache_write_tokens: '12.5', output_tokens: '45' }, { inputTokens: { operator: 'gt', value: 272000 } }), + ['gpt-5.6-sol', modelPricing( + tokenPricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '30' }), + tokenPricingEntry({ input_tokens: '10', input_cache_read_tokens: '1', input_cache_write_tokens: '12.5', output_tokens: '45' }, { inputTokens: { operator: 'gt', value: 272000 } }), )], - ['gpt-5.6-terra', tokenModelPricing( - pricingEntry({ input_tokens: '2.5', input_cache_read_tokens: '0.25', input_cache_write_tokens: '3.125', output_tokens: '15' }), - pricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '22.5' }, { inputTokens: { operator: 'gt', value: 272000 } }), + ['gpt-5.6-terra', modelPricing( + tokenPricingEntry({ input_tokens: '2.5', input_cache_read_tokens: '0.25', input_cache_write_tokens: '3.125', output_tokens: '15' }), + tokenPricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', input_cache_write_tokens: '6.25', output_tokens: '22.5' }, { inputTokens: { operator: 'gt', value: 272000 } }), )], - ['gpt-5.6-luna', tokenModelPricing( - pricingEntry({ input_tokens: '1', input_cache_read_tokens: '0.1', input_cache_write_tokens: '1.25', output_tokens: '6' }), - pricingEntry({ input_tokens: '2', input_cache_read_tokens: '0.2', input_cache_write_tokens: '2.5', output_tokens: '9' }, { inputTokens: { operator: 'gt', value: 272000 } }), + ['gpt-5.6-luna', modelPricing( + tokenPricingEntry({ input_tokens: '1', input_cache_read_tokens: '0.1', input_cache_write_tokens: '1.25', output_tokens: '6' }), + tokenPricingEntry({ input_tokens: '2', input_cache_read_tokens: '0.2', input_cache_write_tokens: '2.5', output_tokens: '9' }, { inputTokens: { operator: 'gt', value: 272000 } }), )], // Copilot's live catalog exposes a 1.05M context window for GPT-5.5/5.4; // OpenAI reprices the whole request above 272k input tokens. // https://web.archive.org/web/20260709205359/https://platform.openai.com/docs/pricing - ['gpt-5.5', tokenModelPricing( - pricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', output_tokens: '30' }), - pricingEntry({ input_tokens: '10', input_cache_read_tokens: '1', output_tokens: '45' }, { inputTokens: { operator: 'gt', value: 272000 } }), + ['gpt-5.5', modelPricing( + tokenPricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', output_tokens: '30' }), + tokenPricingEntry({ input_tokens: '10', input_cache_read_tokens: '1', output_tokens: '45' }, { inputTokens: { operator: 'gt', value: 272000 } }), )], - ['gpt-5.4', tokenModelPricing( - pricingEntry({ input_tokens: '2.5', input_cache_read_tokens: '0.25', output_tokens: '15' }), - pricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', output_tokens: '22.5' }, { inputTokens: { operator: 'gt', value: 272000 } }), + ['gpt-5.4', modelPricing( + tokenPricingEntry({ input_tokens: '2.5', input_cache_read_tokens: '0.25', output_tokens: '15' }), + tokenPricingEntry({ input_tokens: '5', input_cache_read_tokens: '0.5', output_tokens: '22.5' }, { inputTokens: { operator: 'gt', value: 272000 } }), )], ['gpt-5.4-mini', tokenBasePricing({ input_tokens: '0.75', input_cache_read_tokens: '0.075', output_tokens: '4.5' })], ['gpt-5.4-nano', tokenBasePricing({ input_tokens: '0.2', input_cache_read_tokens: '0.02', output_tokens: '1.25' })], @@ -81,9 +81,9 @@ const COPILOT_MODEL_PRICING: readonly PricingRule[] = [ // https://github.com/sst/models.dev/blob/6dfc39c81b6cd57a91c155aa7b4f68ed1b360da0/providers/google/models/gemini-3.1-pro-preview.toml ['gemini-2.5-pro', tokenBasePricing({ input_tokens: '1.25', input_cache_read_tokens: '0.125', output_tokens: '10' })], ['gemini-3-flash-preview', tokenBasePricing({ input_tokens: '0.5', input_cache_read_tokens: '0.05', output_tokens: '3' })], - ['gemini-3.1-pro-preview', tokenModelPricing( - pricingEntry({ input_tokens: '2', input_cache_read_tokens: '0.2', output_tokens: '12' }), - pricingEntry({ input_tokens: '4', input_cache_read_tokens: '0.4', output_tokens: '18' }, { inputTokens: { operator: 'gt', value: 200000 } }), + ['gemini-3.1-pro-preview', modelPricing( + tokenPricingEntry({ input_tokens: '2', input_cache_read_tokens: '0.2', output_tokens: '12' }), + tokenPricingEntry({ input_tokens: '4', input_cache_read_tokens: '0.4', output_tokens: '18' }, { inputTokens: { operator: 'gt', value: 200000 } }), )], ['gemini-3.5-flash', tokenBasePricing({ input_tokens: '1.5', input_cache_read_tokens: '0.15', output_tokens: '9' })], [/^grok-code-fast/, tokenBasePricing({ input_tokens: '0.2', output_tokens: '1.5' })], diff --git a/packages/provider-copilot/src/provider.ts b/packages/provider-copilot/src/provider.ts index 6394ba559b..983f332188 100644 --- a/packages/provider-copilot/src/provider.ts +++ b/packages/provider-copilot/src/provider.ts @@ -1,12 +1,13 @@ import { chatFromCopilotRaw } from './chat-from-raw.ts'; +import { COMPACTION_TRIGGER, compactionResponse } from './compaction.ts'; import { assertCopilotUpstreamRecord } from './config.ts'; import { COPILOT_DEFAULT_FLAGS, defaultFlagsForCopilotModel } from './defaults.ts'; import { fetchCopilotModels } from './fetch-models.ts'; import { copilotFetchChatCompletions, copilotFetchEmbeddings, copilotFetchMessages, copilotFetchMessagesCountTokens, copilotFetchResponses } from './fetch.ts'; -import { COPILOT_CHATCOMPLETIONS_BOUNDARY } from './interceptors/chat-completions/index.ts'; +import { COPILOT_CHAT_COMPLETIONS_BOUNDARY } from './interceptors/chat-completions/index.ts'; import type { ChatCompletionsBoundaryCtx } from './interceptors/chat-completions/types.ts'; import { COPILOT_MESSAGES_BOUNDARY, COPILOT_MESSAGES_COUNT_TOKENS_BOUNDARY } from './interceptors/messages/index.ts'; -import type { MessagesBoundaryCtx, MessagesCountTokensBoundaryCtx } from './interceptors/messages/types.ts'; +import type { MessagesBoundaryCtx } from './interceptors/messages/types.ts'; import { COPILOT_RESPONSES_BOUNDARY } from './interceptors/responses/index.ts'; import type { ResponsesBoundaryCtx } from './interceptors/responses/types.ts'; import { emptyKnownModels, mergeKnownModels, projectKnownModels } from './known-models.ts'; @@ -21,7 +22,7 @@ import { parseChatCompletionsStream, type ChatCompletionsPayload, type ChatCompl import { type ModelEndpointKey, type ModelEndpoints, type ProtocolFrame, kindForEndpoints } from '@floway-dev/protocols/common'; import { parseAnthropicBetaHeader, parseMessagesStream, type MessagesPayload, type MessagesStreamEvent } from '@floway-dev/protocols/messages'; import { parseResponsesStream, type CanonicalResponsesPayload, type ResponsesResult } from '@floway-dev/protocols/responses'; -import { COMPACTION_TRIGGER, compactionResponse, eventResult, getProviderRepo, readUpstreamApiError, streamingProviderCall, apiErrorToResponse, resolveEffectiveFlags, type ExecuteResult, type FlagOverrides, type ProviderInstance, type Provider, type ProviderCallResult, type ProviderModel, type ProviderResponsesResult, type ProviderStreamResult, type TelemetryModelIdentity, type UpstreamCallOptions, type UpstreamFetchOptions, type UpstreamRecord } from '@floway-dev/provider'; +import { eventResult, getProviderRepo, readUpstreamApiError, streamingProviderCall, apiErrorToResponse, resolveEffectiveFlags, type ExecuteResult, type FlagOverrides, type ProviderInstance, type Provider, type ProviderCallResult, type ProviderModel, type ProviderResponsesResult, type ProviderStreamResult, type TelemetryModelIdentity, type UpstreamCallOptions, type UpstreamFetchOptions, type UpstreamRecord } from '@floway-dev/provider'; interface CopilotProviderData { rawModels: CopilotRawModel[]; @@ -328,7 +329,7 @@ export const createCopilotProvider = (record: UpstreamRecord): Provider => { model, }; const result = await runInterceptors>>( - ctx, {}, COPILOT_CHATCOMPLETIONS_BOUNDARY, async () => { + ctx, {}, COPILOT_CHAT_COMPLETIONS_BOUNDARY, async () => { const { model: _ignored, ...wireBody } = ctx.payload; return await liftStream(callStreaming(copilotFetchChatCompletions, wireBody, signal, rawModel, ctx.headers, parseChatCompletionsStream, opts)); }, @@ -445,12 +446,12 @@ export const createCopilotProvider = (record: UpstreamRecord): Provider => { context1m: betas.includes(CONTEXT_1M_BETA), reasoningEffort: messagesReasoningEffort(body), }); - const ctx: MessagesCountTokensBoundaryCtx = { + const ctx: MessagesBoundaryCtx = { payload: { ...body, model: model.id }, headers: new Headers(opts.headers), model, }; - const response = await runInterceptors( + const response = await runInterceptors( ctx, {}, COPILOT_MESSAGES_COUNT_TOKENS_BOUNDARY, async () => { const { model: _ignored, ...wireBody } = ctx.payload; const { response } = await call(copilotFetchMessagesCountTokens, wireBody, signal, rawModel, ctx.headers, opts); @@ -469,7 +470,7 @@ export const createCopilotProvider = (record: UpstreamRecord): Provider => { }; return { - upstream: copilot.id, + upstreamId: copilot.id, kind: 'copilot', name: copilot.name, disabledPublicModelIds: copilot.disabledPublicModelIds, diff --git a/packages/provider-copilot/src/provider_test.ts b/packages/provider-copilot/src/provider_test.ts index c00f38299b..f4ef745c40 100644 --- a/packages/provider-copilot/src/provider_test.ts +++ b/packages/provider-copilot/src/provider_test.ts @@ -424,7 +424,7 @@ test('Copilot provider exposes its default flag set via ProviderModel.enabledFla }); const instance = createCopilotProvider(copilotUpstream); - assertEquals(instance.upstream, 'up_copilot'); + assertEquals(instance.upstreamId, 'up_copilot'); assertEquals(instance.name, copilotUpstream.name); await withMockedFetch( @@ -665,7 +665,7 @@ test('Copilot Messages boundary chain does NOT fire on the Chat Completions wire // boundary chain. The Messages-only `withClaudeAgentHeadersSet` interceptor // would set x-interaction-type to 'messages-proxy' for Claude Code SDK // metadata, but it MUST NOT run when the translated path calls Copilot's - // chat-completions wire — that path runs `COPILOT_CHATCOMPLETIONS_BOUNDARY`, + // chat-completions wire — that path runs `COPILOT_CHAT_COMPLETIONS_BOUNDARY`, // which has no Messages-source headers in it. const { copilotUpstream } = await setupCopilotTest(); const instance = createCopilotProvider(copilotUpstream); diff --git a/packages/provider-copilot/tsconfig.json b/packages/provider-copilot/tsconfig.json index 9e25e6ece9..3b64db1d69 100644 --- a/packages/provider-copilot/tsconfig.json +++ b/packages/provider-copilot/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src/**/*.ts"] + "include": ["vitest.config.ts", "src/**/*.ts"] } diff --git a/packages/provider-custom/src/config.ts b/packages/provider-custom/src/config.ts index e8a5f9574a..879235fbc0 100644 --- a/packages/provider-custom/src/config.ts +++ b/packages/provider-custom/src/config.ts @@ -13,7 +13,7 @@ // vendor-neutral rerank path exists. // // Custom upstreams surface models from two sources, merged at the data -// plane: a statically configured list of per-model overrides +// plane: a manual list of per-model entries // (`config.models`) that pin metadata/pricing locally, and an optional // live fetch of the upstream `/models` (`config.modelsFetch`). The `/models` // path is part of the fetch toggle (`modelsFetch.endpoint`), not a generic @@ -29,11 +29,11 @@ export type CustomAuthStyle = 'bearer' | 'anthropic' | 'none'; // count-tokens endpoint, the responses compact endpoint) and the catalog // (`/models` — owned by modelsFetch.endpoint) are intentionally absent: // they derive their URL from a parent override or a separate field. Each -// key is the OpenAI-canonical path fragment so the default upstream path -// is just `/v1` + the key — the lookup table is the key itself. Kept +// key is the default path fragment, so the upstream path is `/v1` + the key +// unless overridden — the lookup table is the key itself. Kept // package-internal because outside callers reach the upstream through // the typed `customFetchXxx` transports, not by naming an endpoint key. -type CustomPathOverrideKey = +export type CustomPathOverrideKey = | '/completions' | '/chat/completions' | '/responses' diff --git a/packages/provider-custom/src/defaults.ts b/packages/provider-custom/src/defaults.ts index af0b3dbe6d..cc1b4ae83b 100644 --- a/packages/provider-custom/src/defaults.ts +++ b/packages/provider-custom/src/defaults.ts @@ -20,7 +20,7 @@ export const CUSTOM_DEFAULT_FLAGS: FlagDefaults = { 'promote-system-to-developer': false, // `x-anthropic-billing-header:` from Claude Code clients is meaningful // only to the Anthropic subscription endpoint; strip it here so it - // does not pollute the OpenAI-compatible upstream's prompt-cache key. + // does not pollute the upstream's prompt-cache key. 'strip-billing-attribution': true, 'strip-prompt-cache-key': false, }; diff --git a/packages/provider-custom/src/fetch.ts b/packages/provider-custom/src/fetch.ts index ab6457f6f4..74a83bace1 100644 --- a/packages/provider-custom/src/fetch.ts +++ b/packages/provider-custom/src/fetch.ts @@ -1,6 +1,7 @@ -import type { CustomUpstreamConfig } from './config.ts'; +import type { CustomPathOverrideKey, CustomUpstreamConfig } from './config.ts'; import { type UpstreamFetchOptions, joinBaseAndPath } from '@floway-dev/provider'; +// https://docs.anthropic.com/en/api/versioning const ANTHROPIC_VERSION = '2023-06-01'; // Endpoint key is the OpenAI-canonical path fragment (`/chat/completions`, @@ -9,9 +10,7 @@ const ANTHROPIC_VERSION = '2023-06-01'; // messages count-tokens and responses compact endpoints append a suffix // to their parent's resolved path so an override of the parent ripples // down to both. -type EndpointKey = keyof NonNullable; - -const resolveOverridable = (config: CustomUpstreamConfig, key: EndpointKey): string => +const pathOverrideFor = (config: CustomUpstreamConfig, key: CustomPathOverrideKey): string => config.pathOverrides?.[key] ?? `/v1${key}`; const customFetchInternal = async ( @@ -43,27 +42,27 @@ export const customFetchRerank = (config: CustomUpstreamConfig, path: string, in customFetchInternal(config, path, init, options); export const customFetchChatCompletions = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => - customFetchInternal(config, resolveOverridable(config, '/chat/completions'), init, options); + customFetchInternal(config, pathOverrideFor(config, '/chat/completions'), init, options); export const customFetchResponses = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => - customFetchInternal(config, resolveOverridable(config, '/responses'), init, options); + customFetchInternal(config, pathOverrideFor(config, '/responses'), init, options); export const customFetchResponsesCompact = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => - customFetchInternal(config, `${resolveOverridable(config, '/responses')}/compact`, init, options); + customFetchInternal(config, `${pathOverrideFor(config, '/responses')}/compact`, init, options); export const customFetchMessages = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => - customFetchInternal(config, resolveOverridable(config, '/messages'), init, options); + customFetchInternal(config, pathOverrideFor(config, '/messages'), init, options); export const customFetchMessagesCountTokens = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => - customFetchInternal(config, `${resolveOverridable(config, '/messages')}/count_tokens`, init, options); + customFetchInternal(config, `${pathOverrideFor(config, '/messages')}/count_tokens`, init, options); export const customFetchEmbeddings = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => - customFetchInternal(config, resolveOverridable(config, '/embeddings'), init, options); + customFetchInternal(config, pathOverrideFor(config, '/embeddings'), init, options); export const customFetchCompletions = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => - customFetchInternal(config, resolveOverridable(config, '/completions'), init, options); + customFetchInternal(config, pathOverrideFor(config, '/completions'), init, options); export const customFetchImagesGenerations = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => - customFetchInternal(config, resolveOverridable(config, '/images/generations'), init, options); + customFetchInternal(config, pathOverrideFor(config, '/images/generations'), init, options); export const customFetchImagesEdits = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => - customFetchInternal(config, resolveOverridable(config, '/images/edits'), init, options); + customFetchInternal(config, pathOverrideFor(config, '/images/edits'), init, options); export const customFetchAudioTranscriptions = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => - customFetchInternal(config, resolveOverridable(config, '/audio/transcriptions'), init, options); + customFetchInternal(config, pathOverrideFor(config, '/audio/transcriptions'), init, options); export const customFetchAlphaSearch = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => - customFetchInternal(config, resolveOverridable(config, '/alpha/search'), init, options); + customFetchInternal(config, pathOverrideFor(config, '/alpha/search'), init, options); // /models lives on its own fetch toggle (see config.modelsFetch.endpoint), // not in pathOverrides. export const customFetchModels = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => diff --git a/packages/provider-custom/src/fetch_test.ts b/packages/provider-custom/src/fetch_test.ts index bb541996eb..845a745a65 100644 --- a/packages/provider-custom/src/fetch_test.ts +++ b/packages/provider-custom/src/fetch_test.ts @@ -12,9 +12,10 @@ import { customFetchResponses, customFetchResponsesCompact, } from './fetch.ts'; +import { createCustomProvider } from './provider.ts'; import type { UpstreamRecord } from '@floway-dev/provider'; import { directFetcher, identityWrapUpstreamCall } from '@floway-dev/provider'; -import { assertEquals, withMockedFetch } from '@floway-dev/test-utils'; +import { assertEquals, assertExists, jsonResponse, noopUpstreamCallOptions, withMockedFetch } from '@floway-dev/test-utils'; const baseRecord: UpstreamRecord = { id: 'up_test', @@ -256,3 +257,86 @@ test('authStyle "none" sends neither Authorization nor x-api-key', async () => { assertEquals(xApiKey, null); assertEquals(anthropicVersion, null); }); + +test('Custom provider callImagesEdits forwards multipart body with model field appended', async () => { + const record: UpstreamRecord = { + ...baseRecord, + config: { + baseUrl: 'https://custom.example.com', + authStyle: 'bearer', + apiKey: 'sk-custom', + endpoints: { chatCompletions: {} }, + }, + }; + let forwarded: { url: string; form: FormData } | undefined; + await withMockedFetch( + async request => { + const path = new URL(request.url).pathname; + if (path === '/v1/models') return jsonResponse({ data: [{ id: 'gpt-image-2' }] }); + if (path === '/v1/images/edits') { + forwarded = { url: request.url, form: await request.formData() }; + return jsonResponse({ data: [{ b64_json: 'abc' }], usage: { input_tokens: 5, output_tokens: 20 } }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const provider = createCustomProvider(record); + const [model] = await provider.instance.getProvidedModels(directFetcher); + const result = await provider.instance.callImagesEdits(model, { + parameters: { prompt: 'add a kite' }, + images: [{ + type: 'upload', + file: new File([new Uint8Array([1, 2, 3])], 'photo.png', { type: 'image/png' }), + }], + }, undefined, noopUpstreamCallOptions()); + assertEquals(result.modelKey, 'gpt-image-2'); + assertEquals(result.response.status, 200); + }, + ); + assertExists(forwarded); + assertEquals(forwarded.form.get('model'), 'gpt-image-2'); + assertEquals(forwarded.form.get('prompt'), 'add a kite'); + assertEquals(forwarded.form.get('image') instanceof File, true); +}); + +test('Custom provider callAudioTranscriptions preserves multipart entries and honors the path override', async () => { + const record: UpstreamRecord = { + ...baseRecord, + config: { + baseUrl: 'https://custom.example.com', + authStyle: 'bearer', + apiKey: 'sk-custom', + endpoints: {}, + pathOverrides: { '/audio/transcriptions': '/speech/to-text' }, + modelsFetch: { enabled: false }, + models: [{ upstreamModelId: 'whisper-upstream', kind: 'transcription', endpoints: { audioTranscriptions: {} } }], + }, + }; + let forwarded: { url: string; form: FormData } | undefined; + await withMockedFetch( + async request => { + forwarded = { url: request.url, form: await request.formData() }; + return jsonResponse({ text: 'hello' }); + }, + async () => { + const provider = createCustomProvider(record); + const [model] = await provider.instance.getProvidedModels(directFetcher); + const result = await provider.instance.callAudioTranscriptions(model, { + entries: [ + { name: 'file', value: new File([new Uint8Array([7, 8])], 'voice.ogg', { type: 'audio/ogg' }) }, + { name: 'model', value: 'public-model' }, + { name: 'language', value: 'en' }, + ], + }, undefined, noopUpstreamCallOptions()); + assertEquals(result.modelKey, 'whisper-upstream'); + }, + ); + assertExists(forwarded); + assertEquals(forwarded.url, 'https://custom.example.com/speech/to-text'); + assertEquals(forwarded.form.get('model'), 'whisper-upstream'); + assertEquals(forwarded.form.get('language'), 'en'); + const file = forwarded.form.get('file'); + assertEquals(file instanceof File, true); + assertEquals((file as File).name, 'voice.ogg'); + assertEquals((file as File).type, 'audio/ogg'); +}); diff --git a/packages/provider-custom/src/index.ts b/packages/provider-custom/src/index.ts index 36fcf5ff41..672747b3e8 100644 --- a/packages/provider-custom/src/index.ts +++ b/packages/provider-custom/src/index.ts @@ -2,7 +2,7 @@ import { CUSTOM_DEFAULT_FLAGS } from './defaults.ts'; import { createCustomProvider } from './provider.ts'; import type { ProviderModule } from '@floway-dev/provider'; -export const customProvider: ProviderModule = { +export const customProviderModule: ProviderModule = { create: createCustomProvider, defaultFlags: CUSTOM_DEFAULT_FLAGS, }; diff --git a/packages/provider-custom/src/infer-endpoints_test.ts b/packages/provider-custom/src/infer-endpoints_test.ts index 1dcc0d49d6..1de8e18318 100644 --- a/packages/provider-custom/src/infer-endpoints_test.ts +++ b/packages/provider-custom/src/infer-endpoints_test.ts @@ -1,7 +1,9 @@ import { test } from 'vitest'; import { inferEndpointsFromModelId } from './infer-endpoints.ts'; -import { assertEquals } from '@floway-dev/test-utils'; +import { createCustomProvider } from './provider.ts'; +import { directFetcher, type UpstreamRecord } from '@floway-dev/provider'; +import { assertEquals, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; const EMBEDDINGS = { embeddings: {} }; const IMAGES = { imagesGenerations: {}, imagesEdits: {} }; @@ -93,3 +95,42 @@ test('inferEndpointsFromModelId returns audio transcription for standard transcr assertEquals(inferEndpointsFromModelId(id), AUDIO); } }); + +test('Custom provider projects gpt-image-* models with kind=image and both image endpoints', async () => { + const record: UpstreamRecord = { + id: 'up_custom_image', + kind: 'custom', + name: 'Custom Image', + enabled: true, + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + config: { + baseUrl: 'https://custom.example.com', + authStyle: 'bearer', + apiKey: 'sk-custom', + endpoints: { chatCompletions: {} }, + }, + state: null, + flagOverrides: {}, + disabledPublicModelIds: [], + proxyFallbackList: [], + modelPrefix: null, + color: null, + }; + await withMockedFetch( + request => { + if (new URL(request.url).pathname === '/v1/models') { + return jsonResponse({ data: [{ id: 'gpt-image-2-2026-04-21' }] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const models = await createCustomProvider(record).instance.getProvidedModels(directFetcher); + assertEquals(models.length, 1); + assertEquals(models[0].id, 'gpt-image-2-2026-04-21'); + assertEquals(models[0].kind, 'image'); + assertEquals(models[0].endpoints, IMAGES); + }, + ); +}); diff --git a/packages/provider-custom/src/provider.ts b/packages/provider-custom/src/provider.ts index 5c42f0b898..32b8aaed0e 100644 --- a/packages/provider-custom/src/provider.ts +++ b/packages/provider-custom/src/provider.ts @@ -212,7 +212,7 @@ export const createCustomProvider = (record: UpstreamRecord): Provider => { }; return { - upstream: record.id, + upstreamId: record.id, kind: 'custom', name: record.name, disabledPublicModelIds: record.disabledPublicModelIds, diff --git a/packages/provider-custom/src/provider_test.ts b/packages/provider-custom/src/provider_test.ts index 49064b0562..31e90563b6 100644 --- a/packages/provider-custom/src/provider_test.ts +++ b/packages/provider-custom/src/provider_test.ts @@ -5,7 +5,7 @@ import type { ModelPricing } from '@floway-dev/protocols/common'; import { parseRerankRequest } from '@floway-dev/protocols/rerank'; import type { UpstreamModelConfig, UpstreamRecord } from '@floway-dev/provider'; import { directFetcher, identityWrapUpstreamCall } from '@floway-dev/provider'; -import { assertEquals, assertExists, assertRejects, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; +import { assertEquals, assertExists, assertRejects, jsonResponse, noopUpstreamCallOptions, sseResponse, withMockedFetch } from '@floway-dev/test-utils'; interface BuildOptions { modelsFetchEnabled?: boolean; @@ -271,3 +271,175 @@ test('callRerank honors the per-model path without adding an upstream path overr ); assertEquals(requestUrl, 'https://custom.example.com/workspace/rerank'); }); + +test('Custom provider forces stream=true for streaming endpoints and leaves count-tokens/embeddings alone', async () => { + const provider = createCustomProvider(buildCustomUpstream()).instance; + const bodies: Record> = {}; + + await withMockedFetch( + async request => { + const path = new URL(request.url).pathname; + if (path === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'echo', object: 'model' }] }); + } + + bodies[path] = (await request.json()) as Record; + if (path === '/v1/chat/completions' || path === '/v1/responses' || path === '/v1/messages') { + return sseResponse(); + } + if (path === '/v1/messages/count_tokens') return jsonResponse({ input_tokens: 1 }); + if (path === '/v1/embeddings') return jsonResponse({ object: 'list', data: [], model: 'echo' }); + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const [model] = await provider.getProvidedModels(directFetcher); + assertExists(model); + const opts = noopUpstreamCallOptions(); + await provider.callChatCompletions(model, { messages: [{ role: 'user', content: 'hi' }] }, undefined, opts); + await provider.callResponses(model, { input: [] }, 'generate', undefined, opts); + await provider.callMessages(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, opts); + await provider.callMessagesCountTokens(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, opts); + await provider.callEmbeddings(model, { input: 'hi' }, undefined, opts); + }, + ); + + assertEquals(bodies['/v1/chat/completions'].stream, true); + assertEquals(bodies['/v1/responses'].stream, true); + assertEquals(bodies['/v1/messages'].stream, true); + assertEquals('stream' in bodies['/v1/messages/count_tokens'], false); + assertEquals('stream' in bodies['/v1/embeddings'], false); +}); + +test('Custom provider uses configured endpoints regardless of per-model hints in the /models response', async () => { + await withMockedFetch( + () => jsonResponse({ object: 'list', data: [{ id: 'm-1', supported_endpoints: ['/some/random/path'] }] }), + async () => { + const provider = createCustomProvider(buildCustomUpstream()).instance; + const [model] = await provider.getProvidedModels(directFetcher); + assertEquals(model.endpoints, { chatCompletions: {} }); + assertEquals(model.kind, 'chat'); + }, + ); +}); + +test('Custom provider projects display_name / created / limits / pricing from a Floway-style /models response', async () => { + await withMockedFetch( + () => jsonResponse({ + object: 'list', + data: [{ + id: 'm-rich', + type: 'model', + display_name: 'Rich Model', + created_at: '2026-04-01T00:00:00Z', + limits: { max_output_tokens: 8192, max_context_window_tokens: 200000 }, + pricing: { entries: [{ rates: { input_tokens: '3', output_tokens: '15', input_cache_read_tokens: '0.3' } }] }, + }], + }), + async () => { + const [model] = await createCustomProvider(buildCustomUpstream()).instance.getProvidedModels(directFetcher); + assertEquals(model.display_name, 'Rich Model'); + assertEquals(model.created, Math.floor(Date.parse('2026-04-01T00:00:00Z') / 1000)); + assertEquals(model.limits.max_output_tokens, 8192); + assertEquals(model.limits.max_context_window_tokens, 200000); + assertEquals(model.pricing?.entries[0]?.rates.input_tokens, '3'); + assertEquals(model.pricing?.entries[0]?.rates.output_tokens, '15'); + assertEquals(model.pricing?.entries[0]?.rates.input_cache_read_tokens, '0.3'); + }, + ); +}); + +test('Custom provider falls back to `name` when display_name is missing (loose OpenAI-compat upstreams)', async () => { + await withMockedFetch( + () => jsonResponse({ object: 'list', data: [{ id: 'm-named', name: 'Named Model' }] }), + async () => { + const [model] = await createCustomProvider(buildCustomUpstream()).instance.getProvidedModels(directFetcher); + assertEquals(model.display_name, 'Named Model'); + }, + ); +}); + +test('Custom provider callImagesGenerations posts JSON with model re-injected', async () => { + let forwarded: { url: string; body: { model?: unknown; prompt?: unknown } } | undefined; + await withMockedFetch( + async request => { + const path = new URL(request.url).pathname; + if (path === '/v1/models') return jsonResponse({ data: [{ id: 'gpt-image-2' }] }); + if (path === '/v1/images/generations') { + forwarded = { url: request.url, body: await request.json() as Record }; + return jsonResponse({ data: [{ b64_json: 'abc' }], usage: { input_tokens: 10, output_tokens: 50 } }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const provider = createCustomProvider(buildCustomUpstream()); + const [model] = await provider.instance.getProvidedModels(directFetcher); + const result = await provider.instance.callImagesGenerations(model, { prompt: 'hi' }, undefined, noopUpstreamCallOptions()); + assertEquals(result.modelKey, 'gpt-image-2'); + assertEquals(result.response.status, 200); + }, + ); + assertExists(forwarded); + assertEquals(forwarded.body.model, 'gpt-image-2'); + assertEquals(forwarded.body.prompt, 'hi'); +}); + +test('Custom provider callAlphaSearch posts JSON to /v1/alpha/search with the upstream model', async () => { + let forwarded: { url: string; body: Record } | undefined; + await withMockedFetch( + async request => { + const path = new URL(request.url).pathname; + if (path === '/v1/models') return jsonResponse({ data: [{ id: 'gpt-search' }] }); + if (path === '/v1/alpha/search') { + forwarded = { url: request.url, body: await request.json() as Record }; + return jsonResponse({ encrypted_output: null, output: 'result', results: [] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const provider = createCustomProvider(buildCustomUpstream()); + const [model] = await provider.instance.getProvidedModels(directFetcher); + const result = await provider.instance.callAlphaSearch( + model, + { id: 'search-session', commands: { search_query: [{ q: 'Floway' }] } }, + undefined, + noopUpstreamCallOptions(), + ); + assertEquals(result.response.status, 200); + assertEquals(result.modelKey, 'gpt-search'); + }, + ); + assertEquals(forwarded, { + url: 'https://custom.example.com/v1/alpha/search', + body: { + id: 'search-session', + commands: { search_query: [{ q: 'Floway' }] }, + model: 'gpt-search', + }, + }); +}); + +test('Custom provider forwards inbound anthropic-beta header through opts.headers', async () => { + const provider = createCustomProvider(buildCustomUpstream()).instance; + const seen: Array = []; + + await withMockedFetch( + request => { + const path = new URL(request.url).pathname; + if (path === '/v1/models') return jsonResponse({ object: 'list', data: [{ id: 'echo', object: 'model' }] }); + seen.push(request.headers.get('anthropic-beta')); + if (path === '/v1/messages') return sseResponse(); + if (path === '/v1/messages/count_tokens') return jsonResponse({ input_tokens: 1 }); + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const [model] = await provider.getProvidedModels(directFetcher); + const opts = noopUpstreamCallOptions(); + await provider.callMessages(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, { ...opts, headers: new Headers({ 'anthropic-beta': 'oauth-2025-04-20,interleaved-thinking-2025-05-14' }) }); + await provider.callMessagesCountTokens(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, { ...opts, headers: new Headers({ 'anthropic-beta': 'oauth-2025-04-20' }) }); + await provider.callMessages(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, opts); + await provider.callMessages(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, opts); + }, + ); + + assertEquals(seen, ['oauth-2025-04-20,interleaved-thinking-2025-05-14', 'oauth-2025-04-20', null, null]); +}); diff --git a/packages/provider-custom/tsconfig.json b/packages/provider-custom/tsconfig.json index 9e25e6ece9..3b64db1d69 100644 --- a/packages/provider-custom/tsconfig.json +++ b/packages/provider-custom/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src/**/*.ts"] + "include": ["vitest.config.ts", "src/**/*.ts"] } diff --git a/packages/provider-ollama/src/fetch-models.ts b/packages/provider-ollama/src/fetch-models.ts index 2da8796eb6..659c4f2bb4 100644 --- a/packages/provider-ollama/src/fetch-models.ts +++ b/packages/provider-ollama/src/fetch-models.ts @@ -8,8 +8,7 @@ // (`completion`/`tools`/`thinking`/`vision`/ // `embedding`) and the `model_info` map (keyed by // a varying-per-architecture prefix that carries -// `.context_length` and -// `.embedding_length`). +// `.context_length`). // // We fan out one /api/show per tag in parallel and synthesize the per-model // shape the gateway consumes. /api/show calls are independent and read-only; @@ -31,11 +30,6 @@ export interface OllamaRawModel { modifiedAt?: number; capabilities: ReadonlySet; contextLength?: number; - embeddingLength?: number; - family?: string; - architecture?: string; - parameterCount?: number; - quantizationLevel?: string; } export interface OllamaCatalog { @@ -104,26 +98,13 @@ const parseShowResponse = (id: string, modifiedAt: number | undefined, value: un } } - const details = isRecord(value.details) ? value.details : null; const modelInfo = isRecord(value.model_info) ? value.model_info : null; const raw: OllamaRawModel = { id, capabilities }; if (modifiedAt !== undefined) raw.modifiedAt = modifiedAt; - if (details) { - const family = optionalStringField(details.family); - if (family) raw.family = family; - const quant = optionalStringField(details.quantization_level); - if (quant) raw.quantizationLevel = quant; - } if (modelInfo) { - const architecture = optionalStringField(modelInfo['general.architecture']); - if (architecture) raw.architecture = architecture; - const paramCount = optionalNumberField(modelInfo['general.parameter_count']); - if (paramCount !== undefined && paramCount > 0) raw.parameterCount = paramCount; const contextLength = findArchSuffixedNumber(modelInfo, '.context_length'); if (contextLength !== undefined) raw.contextLength = contextLength; - const embeddingLength = findArchSuffixedNumber(modelInfo, '.embedding_length'); - if (embeddingLength !== undefined) raw.embeddingLength = embeddingLength; } return raw; diff --git a/packages/provider-ollama/src/fetch-models_test.ts b/packages/provider-ollama/src/fetch-models_test.ts index 5776acb3f7..338b14cc38 100644 --- a/packages/provider-ollama/src/fetch-models_test.ts +++ b/packages/provider-ollama/src/fetch-models_test.ts @@ -81,18 +81,12 @@ test('fetchOllamaCatalog projects /api/show capabilities + model_info into the r assertEquals(gptoss.capabilities.has('tools'), true); assertEquals(gptoss.capabilities.has('embedding'), false); assertEquals(gptoss.contextLength, 131072); - assertEquals(gptoss.embeddingLength, 2880); - assertEquals(gptoss.family, 'gptoss'); - assertEquals(gptoss.architecture, 'gptoss'); - assertEquals(gptoss.quantizationLevel, 'MXFP4'); - assertEquals(gptoss.parameterCount, 116829156672); const embed = catalog.data.find(m => m.id === 'nomic-embed-text:latest')!; assertEquals(embed.capabilities.has('embedding'), true); // model_info keys are arch-prefixed; the fetcher must enumerate keys to // find `.context_length` without hardcoding `gptoss.`. assertEquals(embed.contextLength, 8192); - assertEquals(embed.embeddingLength, 768); }); }); diff --git a/packages/provider-ollama/src/index.ts b/packages/provider-ollama/src/index.ts index 345ff8e287..49dfb890b4 100644 --- a/packages/provider-ollama/src/index.ts +++ b/packages/provider-ollama/src/index.ts @@ -2,7 +2,7 @@ import { OLLAMA_DEFAULT_FLAGS } from './defaults.ts'; import { createOllamaProvider } from './provider.ts'; import type { ProviderModule } from '@floway-dev/provider'; -export const ollamaProvider: ProviderModule = { +export const ollamaProviderModule: ProviderModule = { create: createOllamaProvider, defaultFlags: OLLAMA_DEFAULT_FLAGS, }; diff --git a/packages/provider-ollama/src/pricing.ts b/packages/provider-ollama/src/pricing.ts index e532e50a98..2ca6a33d4c 100644 --- a/packages/provider-ollama/src/pricing.ts +++ b/packages/provider-ollama/src/pricing.ts @@ -29,7 +29,7 @@ // // Refresh procedure: .agents/skills/fetching-models-pricing/. -import { tokenBasePricing, tokenModelPricing, tokenPricingEntry as pricingEntry, type ModelPricing } from '@floway-dev/protocols/common'; +import { modelPricing, tokenBasePricing, tokenPricingEntry, type ModelPricing } from '@floway-dev/protocols/common'; type PricingRule = readonly [key: string | RegExp, pricing: ModelPricing]; @@ -99,9 +99,9 @@ const OLLAMA_MODEL_PRICING: readonly PricingRule[] = [ // https://platform.minimax.io/docs/guides/pricing-paygo [/^minimax-m2(\.[15])?$/, tokenBasePricing({ input_tokens: '0.3', input_cache_read_tokens: '0.03', output_tokens: '1.2' })], ['minimax-m2.7', tokenBasePricing({ input_tokens: '0.3', input_cache_read_tokens: '0.06', output_tokens: '1.2' })], - ['minimax-m3', tokenModelPricing( - pricingEntry({ input_tokens: '0.3', input_cache_read_tokens: '0.06', output_tokens: '1.2' }), - pricingEntry({ input_tokens: '0.6', input_cache_read_tokens: '0.12', output_tokens: '2.4' }, { inputTokens: { operator: 'gt', value: 512000 } }), + ['minimax-m3', modelPricing( + tokenPricingEntry({ input_tokens: '0.3', input_cache_read_tokens: '0.06', output_tokens: '1.2' }), + tokenPricingEntry({ input_tokens: '0.6', input_cache_read_tokens: '0.12', output_tokens: '2.4' }, { inputTokens: { operator: 'gt', value: 512000 } }), )], // Mistral La Plateforme — Mistral Large 3 is the MoE flagship (41B diff --git a/packages/provider-ollama/src/provider.ts b/packages/provider-ollama/src/provider.ts index c2c8499697..401b60e345 100644 --- a/packages/provider-ollama/src/provider.ts +++ b/packages/provider-ollama/src/provider.ts @@ -189,7 +189,7 @@ export const createOllamaProvider = (record: UpstreamRecord): Provider => { }; return { - upstream: record.id, + upstreamId: record.id, kind: 'ollama', name: record.name, disabledPublicModelIds: record.disabledPublicModelIds, diff --git a/packages/provider-ollama/tsconfig.json b/packages/provider-ollama/tsconfig.json index 9e25e6ece9..3b64db1d69 100644 --- a/packages/provider-ollama/tsconfig.json +++ b/packages/provider-ollama/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src/**/*.ts"] + "include": ["vitest.config.ts", "src/**/*.ts"] } diff --git a/packages/provider/package.json b/packages/provider/package.json index afa716debc..01bb979f03 100644 --- a/packages/provider/package.json +++ b/packages/provider/package.json @@ -13,7 +13,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@floway-dev/platform": "workspace:*", "@floway-dev/protocols": "workspace:*" }, "devDependencies": { diff --git a/packages/provider/src/image-helpers.ts b/packages/provider/src/image-helpers.ts index 1540006fc3..74974c11d8 100644 --- a/packages/provider/src/image-helpers.ts +++ b/packages/provider/src/image-helpers.ts @@ -1,5 +1,3 @@ -import { compressBytesToWebp, type ImageSizeCalculator } from '@floway-dev/platform'; - const BASE64_CHUNK = 0x8000; export const base64ToBytes = (base64: string): Uint8Array => { @@ -26,60 +24,5 @@ export const parseBase64ImageDataUrl = (url: string): { mimeType: string; base64 return mimeType?.toLowerCase().startsWith('image/') && base64 !== undefined ? { mimeType, base64 } : null; }; -const compressBase64ImageToWebp = async ( - base64: string, - calculator: ImageSizeCalculator, -): Promise => { - const webp = await compressBytesToWebp(base64ToBytes(base64), calculator); - return bytesToBase64(webp); -}; - -// Recompresses a `data:image/*;base64,...` URL to a WebP data URL. Returns the -// original URL unchanged when it is not a base64 image data URL (e.g. a remote -// https image reference, which the egress forwards as-is). -const compressImageDataUrlToWebp = async ( - url: string, - calculator: ImageSizeCalculator, -): Promise => { - const parsed = parseBase64ImageDataUrl(url); - if (parsed === null) return url; - const webp = await compressBase64ImageToWebp(parsed.base64, calculator); - return `data:image/webp;base64,${webp}`; -}; - export const isBase64ImageDataUrl = (url: string): boolean => parseBase64ImageDataUrl(url) !== null; - -// Per-request memoizing wrappers around the compress helpers above. A single -// agentic request often replays the same screenshot across many turns, so the -// boundary interceptors run `Promise.all` over dozens of inline images that -// hash to the same cache key. Without dedup, every duplicate races a -// concurrent `kv.put`/sqlite UPDATE on that one key, which trips Cloudflare -// KV's per-key 1-write/sec limit and wastes work on the Node target. Only the -// memoized wrappers are exposed so a future caller cannot reintroduce the -// dedup gap by reaching for the unmemoized form. The returned function shares -// one in-flight compression per identical input for the lifetime of the -// wrapper — discard it after the request finishes. -const memoize = ( - compute: (input: TInput) => Promise, -): ((input: TInput) => Promise) => { - const cache = new Map>(); - return input => { - let pending = cache.get(input); - if (!pending) { - pending = compute(input); - cache.set(input, pending); - } - return pending; - }; -}; - -export const memoizedDataUrlCompressor = ( - calculator: ImageSizeCalculator, -): ((url: string) => Promise) => - memoize(url => compressImageDataUrlToWebp(url, calculator)); - -export const memoizedBase64Compressor = ( - calculator: ImageSizeCalculator, -): ((base64: string) => Promise) => - memoize(base64 => compressBase64ImageToWebp(base64, calculator)); diff --git a/packages/provider/src/index.ts b/packages/provider/src/index.ts index e63fafd098..2b366f807c 100644 --- a/packages/provider/src/index.ts +++ b/packages/provider/src/index.ts @@ -31,17 +31,16 @@ export { export type { InternalAliasedFrom, InternalModel, - PerformanceOperation, - PerformanceTelemetryContext, ProviderModel, ProxyFallbackEntry, - TelemetryModelIdentity, UpstreamColor, UpstreamColorPreset, UpstreamProviderKind, UpstreamRecord, } from './model.ts'; -export { ALL_PROVIDER_KINDS, assertUpstreamProviderKind, normalizeUpstreamColor, parsePerformanceOperation, PERFORMANCE_OPERATIONS, UPSTREAM_COLOR_HEX_REGEX, UPSTREAM_COLOR_PRESETS } from './model.ts'; +export { ALL_PROVIDER_KINDS, assertUpstreamProviderKind, normalizeUpstreamColor, UPSTREAM_COLOR_HEX_REGEX, UPSTREAM_COLOR_PRESETS } from './model.ts'; +export type { PerformanceOperation, PerformanceTelemetryContext, TelemetryModelIdentity } from './telemetry.ts'; +export { parsePerformanceOperation, PERFORMANCE_OPERATIONS } from './telemetry.ts'; export type { AddressableForm, ModelPrefixConfig } from './model-prefix.ts'; export { MODEL_PREFIX_MAX_LENGTH, MODEL_PREFIX_REGEX, normalizeModelPrefix } from './model-prefix.ts'; @@ -90,13 +89,10 @@ export type { export { chatField, endpointsField, - flagOverridesField, isRecord, - limitsField, modelsField, nonEmptyStringField, optionalStringField, - pricingField, publicModelId, } from './model-config.ts'; @@ -109,10 +105,9 @@ export { directFetcher, dispatchUpstreamFetch, identityWrapUpstreamCall } from ' export { isAbortError } from './abort.ts'; export { + base64ToBytes, + bytesToBase64, isBase64ImageDataUrl, - memoizedBase64Compressor, - memoizedDataUrlCompressor, + parseBase64ImageDataUrl, } from './image-helpers.ts'; - -export { COMPACTION_TRIGGER, compactionResponse } from './compaction.ts'; export { uuidV7 } from './ids.ts'; diff --git a/packages/provider/src/invocation.ts b/packages/provider/src/invocation.ts index dea4c1fcd1..9e475ef952 100644 --- a/packages/provider/src/invocation.ts +++ b/packages/provider/src/invocation.ts @@ -25,9 +25,7 @@ export type ChatTargetApi = 'messages' | 'responses' | 'chat-completions'; // `rules` is set only for candidates minted by the alias walk — it carries // the picked target's rule overlay so the attempt's terminal wire call can // apply it against the target IR. Absent (undefined) for direct-resolution -// candidates, present (possibly `{}`) for alias-origin candidates; the two -// values together also mark the candidate as needing a `payload.model` -// rewrite before dispatch. +// candidates and present (possibly `{}`) for alias-origin candidates. export interface ModelCandidate { readonly provider: Provider; readonly model: InternalModel; @@ -38,7 +36,7 @@ export interface ModelCandidate { // Pull the emitting upstream's `ProviderModel` off the candidate. Dispatch // hands this to the provider's `callXxx`; interceptor gates read // `.enabledFlags`, boundary shims read `.providerData`, etc. The candidate -// always names exactly one upstream via `provider.upstream`; for real-row +// always names exactly one upstream via `provider.upstreamId`; for real-row // candidates the resolver populates `model.providerModels` with an entry // under that key at candidate-creation time. // @@ -53,9 +51,9 @@ export const providerModelOf = (candidate: ModelCandidate): ProviderModel => { if (model.providerModels === undefined) { throw new Error(`providerModelOf: model '${model.id}' is an alias row; the resolver should have expanded it to a target row before dispatch`); } - const providerModel = model.providerModels[provider.upstream]; + const providerModel = model.providerModels[provider.upstreamId]; if (providerModel === undefined) { - throw new Error(`providerModelOf: model '${model.id}' has no providerModel for '${provider.upstream}'`); + throw new Error(`providerModelOf: model '${model.id}' has no providerModel for '${provider.upstreamId}'`); } return providerModel; }; diff --git a/packages/provider/src/invocation_test.ts b/packages/provider/src/invocation_test.ts index 049ca330e0..bd981c822f 100644 --- a/packages/provider/src/invocation_test.ts +++ b/packages/provider/src/invocation_test.ts @@ -58,7 +58,7 @@ test('providerModelOf throws the alias-row diagnostic when the candidate names a // candidate literally. const candidate: ModelCandidate = { provider: { - upstream: 'test-upstream', + upstreamId: 'test-upstream', kind: 'custom', name: 'Test Upstream', disabledPublicModelIds: [], diff --git a/packages/provider/src/model-config.ts b/packages/provider/src/model-config.ts index 0aade664f4..032f33e22e 100644 --- a/packages/provider/src/model-config.ts +++ b/packages/provider/src/model-config.ts @@ -92,7 +92,7 @@ const optionalMetadataRecord = (value: unknown, label: string): Record { +const limitsField = (value: unknown, label: string): UpstreamModelLimits | undefined => { const record = optionalMetadataRecord(value, label); if (!record) return undefined; return { @@ -102,7 +102,7 @@ export const limitsField = (value: unknown, label: string): UpstreamModelLimits }; }; -export const flagOverridesField = (value: unknown, label: string): FlagOverrides | undefined => { +const flagOverridesField = (value: unknown, label: string): FlagOverrides | undefined => { if (value === undefined) return undefined; return validateFlagOverridesRecord(value, { notObject: `Malformed ${label}: must be an object`, diff --git a/packages/provider/src/model.ts b/packages/provider/src/model.ts index c9489c6d93..7299809f4a 100644 --- a/packages/provider/src/model.ts +++ b/packages/provider/src/model.ts @@ -81,8 +81,8 @@ export interface UpstreamRecord { createdAt: string; updatedAt: string; config: unknown; - // Runtime state managed by the gateway autonomous flows; null when a - // provider has no autonomous state. + // Gateway-written state that can change without an operator editing config; + // null when a provider has no runtime state. state: unknown; flagOverrides: FlagOverrides; // Public model ids the operator switched off for this upstream. Orthogonal to @@ -102,45 +102,6 @@ export interface UpstreamRecord { color: UpstreamColor | null; } -// Model identity attached to every provider result at the provider boundary -// so the identity is decided once. -export interface TelemetryModelIdentity { - model: string; - upstream: string; - modelKey: string; - pricing: ModelPricing | null; -} - -// `chat`, `text_completion`, and `embeddings` are the OTel `gen_ai.operation.name` -// well-known values we route; `image_generation`, `image_edit`, `rerank`, and -// `audio_transcription` are gateway-defined extensions for concrete endpoints -// not covered by OTel. Extend only when a new route lands — no wildcard string. -// OTel canonical set: -// https://github.com/open-telemetry/semantic-conventions/blob/v1.37.0/docs/gen-ai/gen-ai-spans.md#gen_aioperationname -export const PERFORMANCE_OPERATIONS = [ - 'chat', - 'text_completion', - 'embeddings', - 'image_generation', - 'image_edit', - 'rerank', - 'audio_transcription', -] as const; -export type PerformanceOperation = typeof PERFORMANCE_OPERATIONS[number]; - -export const parsePerformanceOperation = (value: unknown): PerformanceOperation => { - if (typeof value === 'string' && (PERFORMANCE_OPERATIONS as readonly string[]).includes(value)) return value as PerformanceOperation; - throw new TypeError(`Invalid performance operation: ${JSON.stringify(value)}`); -}; - -export interface PerformanceTelemetryContext { - keyId: string; - model: string; - upstream: string; - operation: PerformanceOperation; - runtimeLocation: string; -} - // Public identity + capability surface shared by `InternalModel` (the merged, // gateway-facing view) and `ProviderModel` (a single upstream's emission). // The two shapes carry the same metadata verbatim; the merge step OR-unions @@ -181,8 +142,8 @@ interface ModelMetadata { // dispatch reads the chosen upstream's `ProviderModel` off this map via // `providerModelOf(candidate)`. A per-candidate row (from // `enumerateRealModelCandidates`) narrows the map to the single dispatched -// upstream; the merged catalog row from `getModels` aggregates every -// contributing upstream. +// upstream; the merged catalog row from `getModelsFromProviders` +// aggregates every contributing upstream. // • Alias row — carries `aliasedFrom`, the operator-defined alias record. // Alias rows appear in listings but never dispatch directly; the resolver // walks the alias's targets and yields real-row candidates instead. diff --git a/packages/provider/src/provider.ts b/packages/provider/src/provider.ts index 073fcaff1e..e906c7ee3c 100644 --- a/packages/provider/src/provider.ts +++ b/packages/provider/src/provider.ts @@ -24,7 +24,7 @@ import type { CanonicalResponsesPayload, ResponsesResult, ResponsesStreamEvent } export type ResponsesAction = 'generate' | 'compact'; export interface Provider { - upstream: string; + upstreamId: string; kind: UpstreamProviderKind; name: string; disabledPublicModelIds: readonly string[]; diff --git a/packages/provider/src/result.ts b/packages/provider/src/result.ts index 51ce68045d..215cdce850 100644 --- a/packages/provider/src/result.ts +++ b/packages/provider/src/result.ts @@ -1,5 +1,5 @@ import type { InternalDebugError } from './error.ts'; -import type { PerformanceTelemetryContext, TelemetryModelIdentity } from './model.ts'; +import type { PerformanceTelemetryContext, TelemetryModelIdentity } from './telemetry.ts'; export interface EventResult { type: 'events'; @@ -23,10 +23,9 @@ export interface EventResultMetadata { // non-2xx from a gateway-synthesized envelope (model not routable, missing // stored item, server-tool input rejected, etc.) so observers like the // request dump can record the failure category truthfully rather than -// labelling every 4xx as `upstream error N`. `upstream` is the id of the -// upstream that produced the error — set on real upstream 4xx/5xx -// (`source === 'upstream'`) so the dump row can attribute the failure to -// the upstream it came from; absent on gateway-synthesized envelopes that +// labelling every 4xx as `upstream error N`. `upstreamId` is set on real +// upstream 4xx/5xx (`source === 'upstream'`) so the dump row can attribute the +// failure to its source; it is absent on gateway-synthesized envelopes that // never reached an upstream. export interface ApiErrorResult { type: 'api-error'; @@ -35,7 +34,7 @@ export interface ApiErrorResult { headers: Headers; body: Uint8Array; performance?: PerformanceTelemetryContext; - upstream?: string; + upstreamId?: string; } // Gateway-side bug surface (parser crash, interceptor throw, etc.). The @@ -53,15 +52,15 @@ export interface InternalErrorResult { // that measures rather than generates (count_tokens). It is NOT an // `ExecuteResult`: the target emit/interceptor layer never produces one. The // orchestrator passes it straight to `respond` without persistence, and -// `respond` emits it verbatim. `upstream` is the responsible upstream id when -// the body came from a real upstream call; absent for gateway-synthesized -// envelopes (rewrite failures, internal-debug bodies). +// `respond` emits it verbatim. `upstreamId` is present when the body came from +// a real upstream call and absent for gateway-synthesized envelopes (rewrite +// failures, internal-debug bodies). export interface PlainResult { type: 'plain'; status: number; headers: Headers; body: Uint8Array; - upstream?: string; + upstreamId?: string; } export type ExecuteResult = EventResult | ApiErrorResult | InternalErrorResult; @@ -91,21 +90,21 @@ export const internalErrorResult = (status: number, error: InternalDebugError, p ...(performance ? { performance } : {}), }); -export const plainResult = (status: number, headers: Headers, body: Uint8Array, upstream?: string): PlainResult => ({ +export const plainResult = (status: number, headers: Headers, body: Uint8Array, upstreamId?: string): PlainResult => ({ type: 'plain', status, headers, body, - ...(upstream !== undefined ? { upstream } : {}), + ...(upstreamId !== undefined ? { upstreamId } : {}), }); -export const readUpstreamApiError = async (response: Response, upstream?: string): Promise => ({ +export const readUpstreamApiError = async (response: Response, upstreamId?: string): Promise => ({ type: 'api-error', source: 'upstream', status: response.status, headers: new Headers(response.headers), body: new Uint8Array(await response.arrayBuffer()), - ...(upstream !== undefined ? { upstream } : {}), + ...(upstreamId !== undefined ? { upstreamId } : {}), }); export const apiErrorToResponse = (error: ApiErrorResult): Response => diff --git a/packages/provider/src/telemetry.ts b/packages/provider/src/telemetry.ts new file mode 100644 index 0000000000..4d0e2bf236 --- /dev/null +++ b/packages/provider/src/telemetry.ts @@ -0,0 +1,40 @@ +import type { ModelPricing } from '@floway-dev/protocols/common'; + +// Model identity attached to every provider result at the provider boundary +// so the identity is decided once. +export interface TelemetryModelIdentity { + model: string; + upstream: string; + modelKey: string; + pricing: ModelPricing | null; +} + +// `chat`, `text_completion`, and `embeddings` are the OTel `gen_ai.operation.name` +// well-known values we route; `image_generation`, `image_edit`, `rerank`, and +// `audio_transcription` are gateway-defined extensions for concrete endpoints +// not covered by OTel. Extend only when a new route lands — no wildcard string. +// OTel canonical set: +// https://github.com/open-telemetry/semantic-conventions/blob/v1.37.0/docs/gen-ai/gen-ai-spans.md#gen_aioperationname +export const PERFORMANCE_OPERATIONS = [ + 'chat', + 'text_completion', + 'embeddings', + 'image_generation', + 'image_edit', + 'rerank', + 'audio_transcription', +] as const; +export type PerformanceOperation = typeof PERFORMANCE_OPERATIONS[number]; + +export const parsePerformanceOperation = (value: unknown): PerformanceOperation => { + if (typeof value === 'string' && (PERFORMANCE_OPERATIONS as readonly string[]).includes(value)) return value as PerformanceOperation; + throw new TypeError(`Invalid performance operation: ${JSON.stringify(value)}`); +}; + +export interface PerformanceTelemetryContext { + keyId: string; + model: string; + upstream: string; + operation: PerformanceOperation; + runtimeLocation: string; +} diff --git a/packages/provider/tsconfig.json b/packages/provider/tsconfig.json index 9e25e6ece9..3b64db1d69 100644 --- a/packages/provider/tsconfig.json +++ b/packages/provider/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src/**/*.ts"] + "include": ["vitest.config.ts", "src/**/*.ts"] } diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 04ac4f73f5..3de09b6fde 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -33,9 +33,5 @@ "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "@reclaimprotocol/tls": "0.1.2" - }, - "devDependencies": { - "@types/node": "^22", - "typescript": "^5.9.3" } } diff --git a/packages/proxy/src/bytes.ts b/packages/proxy/src/bytes.ts index 6407a1b89e..6dfde00eea 100644 --- a/packages/proxy/src/bytes.ts +++ b/packages/proxy/src/bytes.ts @@ -1,7 +1,7 @@ -// Tiny byte-buffer primitives shared across the proxy-protocol dialers. -// Buffers come in from a transport-owned ReadableStream — those buffers may -// be pooled or reused by the runtime (most visibly on Node), so anything we -// enqueue downstream or retain past the next read needs to own its memory. +// Byte ownership, encoding, HTTP head scanning, URI-host formatting, and +// SOCKS-style address framing for proxy dialing and request execution. +// Buffers read from a transport-owned ReadableStream may be pooled or reused +// by the runtime, so retained or downstream-enqueued bytes must own their memory. /** * Allocate a fresh ArrayBuffer-backed Uint8Array detached from any diff --git a/packages/proxy/src/dial-target.ts b/packages/proxy/src/dial-target.ts new file mode 100644 index 0000000000..fa41275665 --- /dev/null +++ b/packages/proxy/src/dial-target.ts @@ -0,0 +1,82 @@ +import { ProxyDialError } from './errors.ts'; +import type { DialedSocket, SocketDial, SocketDialOptions } from './types.ts'; + +/** + * Reject a port outside the 1..65535 range used by TCP. Port 0 is + * reserved (RFC 6335 §6) — its presence on the wire is almost always + * a config bug. We surface a typed dial error at stage 'config' before + * any I/O so the fallback chain can advance to the next proxy entry + * without burning a TCP slot. */ +export const assertValidTargetPort = (port: number, protocol: string): void => { + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new ProxyDialError(`${protocol}: target port must be 1..65535, got ${port}`, 'config'); + } +}; + +/** + * Enforce the `DialTarget.host` ASCII + non-empty contract before any I/O. + * Also reject the C0 control set (NUL, CR, LF, the rest of 0x00-0x1F), + * SP, and DEL: a host containing one of those bytes that flows into the + * HTTP CONNECT request line as `${target.host}:${target.port}` would + * split the request line and inject a forged head onto the wire. Length- + * prefixed dialers are not exposed to that smuggling shape, but + * centralizing the byte filter here lets every dialer inherit the same + * defense. + * + * SOCKS-style ATYP-domain framing carries the host as a 1-byte length- + * prefix + bytes, so callers wiring those protocols pass `maxBytes: 255`. + * Rejecting here surfaces as 'config' before any TCP slot is burned, + * instead of masquerading mid-dial as a proxy-handshake error on an empty + * length-prefixed domain, an over-long domain, or a `CONNECT :PORT` + * request line. */ +export const assertValidTargetHost = ( + host: string, + protocol: string, + opts?: { maxBytes?: number }, +): void => { + if (host.length === 0) { + throw new ProxyDialError(`${protocol}: target host is empty`, 'config'); + } + for (let i = 0; i < host.length; i++) { + const c = host.charCodeAt(i); + if (c > 0x7f) { + throw new ProxyDialError( + `${protocol}: target host must be ASCII (punycode IDN before dial): ${host}`, + 'config', + ); + } + if (c < 0x21 || c === 0x7f) { + throw new ProxyDialError( + `${protocol}: target host contains a forbidden byte 0x${c.toString(16).padStart(2, '0')}`, + 'config', + ); + } + } + // ASCII-only above guarantees 1-byte-per-char UTF-8, so host.length is + // both the char count and the encoded byte count. + if (opts?.maxBytes !== undefined && host.length > opts.maxBytes) { + throw new ProxyDialError( + `${protocol}: target host too long (${host.length} bytes; ATYP domain is 1-byte length-prefixed, max ${opts.maxBytes})`, + 'config', + ); + } +}; + +/** + * Open a TCP socket and rewrap any failure as a typed `tcp-connect` + * ProxyDialError. Every dialer's outer `socket = await socketDial.connect(…)` + * needs the same wrap so the fallback chain sees a uniform discriminant — + * this is that wrap, centralised. + */ +export const connectOrDialError = async ( + socketDial: SocketDial, + host: string, + port: number, + opts?: SocketDialOptions, +): Promise => { + try { + return await socketDial.connect(host, port, opts); + } catch (cause) { + throw new ProxyDialError(`tcp connect to ${host}:${port} failed`, 'tcp-connect', { cause }); + } +}; diff --git a/packages/proxy/src/dialer.ts b/packages/proxy/src/dialer.ts index dbe79676b0..ddb1924f36 100644 --- a/packages/proxy/src/dialer.ts +++ b/packages/proxy/src/dialer.ts @@ -1,5 +1,6 @@ import { formatHostForUri } from './bytes.ts'; import { DEFAULT_DIAL_DEADLINE_MS } from './constants.ts'; +import { connectOrDialError } from './dial-target.ts'; import { ProxyDialError } from './errors.ts'; import { dialHttpConnect } from './protocols/http-connect.ts'; import { dialReality } from './protocols/reality.ts'; @@ -9,7 +10,7 @@ import { dialSocks5 } from './protocols/socks5.ts'; import { dialTrojan } from './protocols/trojan.ts'; import { dialVlessTcpTls, dialVlessWsTls } from './protocols/vless.ts'; import type { ProxyConfig } from './proxy-config.ts'; -import { connectOrDialError, type DialedSocket, type DialOptions, type DialResult, type DialTarget, type ProxyRequestTarget } from './types.ts'; +import type { DialedSocket, DialOptions, DialResult, DialTarget, ProxyRequestTarget } from './types.ts'; import { fetchOnStream, signalAbortReason, userspaceTls, type DuplexStream, type HttpRequest, type TlsStream } from '@floway-dev/http'; /** diff --git a/packages/proxy/src/protocols/http-connect.ts b/packages/proxy/src/protocols/http-connect.ts index 6ba10132ca..d915e7e29e 100644 --- a/packages/proxy/src/protocols/http-connect.ts +++ b/packages/proxy/src/protocols/http-connect.ts @@ -9,9 +9,9 @@ // avoids `startTls()` entirely. import { base64EncodeBytes, concat, copy, findDoubleCrlfFrom, formatHostForUri, utf8Bytes } from '../bytes.ts'; +import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../dial-target.ts'; import { ProxyDialError } from '../errors.ts'; import type { HttpProxyConfig } from '../proxy-config.ts'; -import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../types.ts'; import type { DialOptions, DialResult, DialTarget, DialedSocket } from '../types.ts'; import { STATUS_LINE } from '@floway-dev/http'; diff --git a/packages/proxy/src/protocols/reality.ts b/packages/proxy/src/protocols/reality.ts index 0e0e9847dc..c52105e408 100644 --- a/packages/proxy/src/protocols/reality.ts +++ b/packages/proxy/src/protocols/reality.ts @@ -46,9 +46,9 @@ import { setCryptoImplementation, makeTLSClient } from '@reclaimprotocol/tls'; import { webcryptoCrypto } from '@reclaimprotocol/tls/webcrypto'; import { base64DecodeBytes, copy, utf8Bytes, randomBytes, hexDecode } from '../bytes.ts'; +import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../dial-target.ts'; import { ProxyDialError } from '../errors.ts'; import type { RealityProxyConfig } from '../proxy-config.ts'; -import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../types.ts'; import type { DialOptions, DialResult, DialTarget, DialedSocket } from '../types.ts'; import { vlessFrameOverStream } from './vless-core.ts'; import { signalAbortReason } from '@floway-dev/http'; diff --git a/packages/proxy/src/protocols/shadowsocks-2022.ts b/packages/proxy/src/protocols/shadowsocks-2022.ts index 68c700c2dc..f770f69e9d 100644 --- a/packages/proxy/src/protocols/shadowsocks-2022.ts +++ b/packages/proxy/src/protocols/shadowsocks-2022.ts @@ -14,10 +14,10 @@ import { blake3 } from '@noble/hashes/blake3.js'; import { base64DecodeBytes, concat, encodeAtypAddress, randomBytes, utf8Bytes } from '../bytes.ts'; +import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../dial-target.ts'; import { ProxyDialError } from '../errors.ts'; import { makeExactReader } from '../exact-reader.ts'; import type { Shadowsocks2022ProxyConfig, Ss2022Method } from '../proxy-config.ts'; -import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../types.ts'; import type { DialOptions, DialResult, DialTarget, DialedSocket } from '../types.ts'; import { type Aead, leNonce, makeAead } from './shadowsocks-aead.ts'; diff --git a/packages/proxy/src/protocols/shadowsocks.ts b/packages/proxy/src/protocols/shadowsocks.ts index d2415891ff..3dfe8365e1 100644 --- a/packages/proxy/src/protocols/shadowsocks.ts +++ b/packages/proxy/src/protocols/shadowsocks.ts @@ -20,10 +20,10 @@ import { hkdf } from '@noble/hashes/hkdf.js'; import { md5, sha1 } from '@noble/hashes/legacy.js'; import { utf8Bytes, concat, encodeAtypAddress, randomBytes } from '../bytes.ts'; +import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../dial-target.ts'; import { ProxyDialError } from '../errors.ts'; import { makeExactReader } from '../exact-reader.ts'; import type { ShadowsocksProxyConfig, SsMethod } from '../proxy-config.ts'; -import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../types.ts'; import type { DialOptions, DialResult, DialTarget, DialedSocket } from '../types.ts'; import { type Aead, leNonce, makeAead } from './shadowsocks-aead.ts'; diff --git a/packages/proxy/src/protocols/socks5.ts b/packages/proxy/src/protocols/socks5.ts index 049cbd0413..5eb46d2c33 100644 --- a/packages/proxy/src/protocols/socks5.ts +++ b/packages/proxy/src/protocols/socks5.ts @@ -1,9 +1,9 @@ // SOCKS5 client (TCP CONNECT only). import { concat, copy, encodeAtypAddress, utf8Bytes } from '../bytes.ts'; +import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../dial-target.ts'; import { ProxyDialError } from '../errors.ts'; import type { Socks5ProxyConfig } from '../proxy-config.ts'; -import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../types.ts'; import type { DialOptions, DialResult, DialTarget, DialedSocket } from '../types.ts'; export const dialSocks5 = async ( diff --git a/packages/proxy/src/protocols/trojan.ts b/packages/proxy/src/protocols/trojan.ts index 1125ae903f..9088343a59 100644 --- a/packages/proxy/src/protocols/trojan.ts +++ b/packages/proxy/src/protocols/trojan.ts @@ -17,9 +17,9 @@ import { sha224 } from '@noble/hashes/sha2.js'; import { encodeAtypAddress, utf8Bytes } from '../bytes.ts'; +import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../dial-target.ts'; import { ProxyDialError } from '../errors.ts'; import type { TrojanProxyConfig } from '../proxy-config.ts'; -import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../types.ts'; import type { DialOptions, DialResult, DialTarget, DialedSocket } from '../types.ts'; import { userspaceTls, type TlsStream } from '@floway-dev/http'; diff --git a/packages/proxy/src/protocols/vless.ts b/packages/proxy/src/protocols/vless.ts index 327a9865c8..aaba94cd50 100644 --- a/packages/proxy/src/protocols/vless.ts +++ b/packages/proxy/src/protocols/vless.ts @@ -4,8 +4,8 @@ // variant inserts `wsUpgradeAndFrame` between the outer TLS and the VLESS // framing. +import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../dial-target.ts'; import type { VlessTcpTlsProxyConfig, VlessWsTlsProxyConfig } from '../proxy-config.ts'; -import { assertValidTargetHost, assertValidTargetPort, connectOrDialError } from '../types.ts'; import type { DialOptions, DialResult, DialTarget } from '../types.ts'; import { vlessFrameOverStream } from './vless-core.ts'; import { wsUpgradeAndFrame } from '@floway-dev/http'; diff --git a/packages/proxy/src/types.ts b/packages/proxy/src/types.ts index b5564648c4..6a24f4407a 100644 --- a/packages/proxy/src/types.ts +++ b/packages/proxy/src/types.ts @@ -1,12 +1,10 @@ -// Public type surface for proxy-dial. +// Transport and request types for proxy dialing. // // The dial layer is transport-only. `DialTarget` describes WHERE to land // after the proxy hop completes — host + port — and nothing else. TLS, // SNI, ALPN, and HTTP-shaped concerns live one layer up in the // orchestrator (runProxiedRequest). -import { ProxyDialError } from './errors.ts'; - /** Pure transport target: where the proxy should land us. */ export interface DialTarget { /** @@ -32,67 +30,6 @@ export interface DialTarget { port: number; } -/** - * Reject a port outside the 1..65535 range used by TCP. Port 0 is - * reserved (RFC 6335 §6) — its presence on the wire is almost always - * a config bug. We surface a typed dial error at stage 'config' before - * any I/O so the fallback chain can advance to the next proxy entry - * without burning a TCP slot. */ -export const assertValidTargetPort = (port: number, protocol: string): void => { - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new ProxyDialError(`${protocol}: target port must be 1..65535, got ${port}`, 'config'); - } -}; - -/** - * Enforce the `DialTarget.host` ASCII + non-empty contract before any I/O. - * Also reject the C0 control set (NUL, CR, LF, the rest of 0x00-0x1F), - * SP, and DEL: a host containing one of those bytes that flows into the - * HTTP CONNECT request line as `${target.host}:${target.port}` would - * split the request line and inject a forged head onto the wire. Length- - * prefixed dialers are not exposed to that smuggling shape, but - * centralizing the byte filter here lets every dialer inherit the same - * defense. - * - * SOCKS-style ATYP-domain framing carries the host as a 1-byte length- - * prefix + bytes, so callers wiring those protocols pass `maxBytes: 255`. - * Rejecting here surfaces as 'config' before any TCP slot is burned, - * instead of masquerading mid-dial as a proxy-handshake error on an empty - * length-prefixed domain, an over-long domain, or a `CONNECT :PORT` - * request line. */ -export const assertValidTargetHost = ( - host: string, - protocol: string, - opts?: { maxBytes?: number }, -): void => { - if (host.length === 0) { - throw new ProxyDialError(`${protocol}: target host is empty`, 'config'); - } - for (let i = 0; i < host.length; i++) { - const c = host.charCodeAt(i); - if (c > 0x7f) { - throw new ProxyDialError( - `${protocol}: target host must be ASCII (punycode IDN before dial): ${host}`, - 'config', - ); - } - if (c < 0x21 || c === 0x7f) { - throw new ProxyDialError( - `${protocol}: target host contains a forbidden byte 0x${c.toString(16).padStart(2, '0')}`, - 'config', - ); - } - } - // ASCII-only above guarantees 1-byte-per-char UTF-8, so host.length is - // both the char count and the encoded byte count. - if (opts?.maxBytes !== undefined && host.length > opts.maxBytes) { - throw new ProxyDialError( - `${protocol}: target host too long (${host.length} bytes; ATYP domain is 1-byte length-prefixed, max ${opts.maxBytes})`, - 'config', - ); - } -}; - /** * Request-time target for the orchestrator: a DialTarget plus the * inner-TLS parameters needed to wrap the post-dial stream. @@ -141,30 +78,11 @@ export interface DialedSocket { // Structurally identical to @floway-dev/platform's SocketDialOptions; // duplicated rather than imported so @floway-dev/proxy stays runtime- // agnostic and the platform's impl is assignable by structural typing. -interface SocketDialOptions { +export interface SocketDialOptions { tls?: boolean; signal?: AbortSignal; } -/** - * Open a TCP socket and rewrap any failure as a typed `tcp-connect` - * ProxyDialError. Every dialer's outer `socket = await socketDial.connect(…)` - * needs the same wrap so the fallback chain sees a uniform discriminant — - * this is that wrap, centralised. - */ -export const connectOrDialError = async ( - socketDial: SocketDial, - host: string, - port: number, - opts?: SocketDialOptions, -): Promise => { - try { - return await socketDial.connect(host, port, opts); - } catch (cause) { - throw new ProxyDialError(`tcp connect to ${host}:${port} failed`, 'tcp-connect', { cause }); - } -}; - /** * Output of a per-protocol `dial`. The duplex stream points at * `target.host:target.port` (after the proxy's framing has been peeled diff --git a/packages/proxy/src/url-kind_test.ts b/packages/proxy/src/url-kind_test.ts index 234d2a4e47..2556f09494 100644 --- a/packages/proxy/src/url-kind_test.ts +++ b/packages/proxy/src/url-kind_test.ts @@ -27,10 +27,7 @@ describe('kindFromUri', () => { expect(kindFromUri('http://')).toBe('PROXY'); }); - it('does not throw on a malformed percent-escape — the URL parser leaves them raw in userinfo', () => { - // kindFromUri must be total — a single malformed URI in a list must - // surface as a generic label rather than throw out of the discriminator. - expect(() => kindFromUri('ss://%zz@h:8388')).not.toThrow(); + it('handles a malformed percent-escape left raw in URL userinfo', () => { expect(kindFromUri('ss://%zz@h:8388')).toBe('SS'); }); diff --git a/packages/proxy/tsconfig.json b/packages/proxy/tsconfig.json index 824d57c3c4..3b64db1d69 100644 --- a/packages/proxy/tsconfig.json +++ b/packages/proxy/tsconfig.json @@ -1,7 +1,4 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { - "types": ["node"] - }, - "include": ["src/**/*.ts"] + "include": ["vitest.config.ts", "src/**/*.ts"] } diff --git a/packages/test-utils/src/assert.ts b/packages/test-utils/src/assert.ts index 62421bf289..e75c5ced4f 100644 --- a/packages/test-utils/src/assert.ts +++ b/packages/test-utils/src/assert.ts @@ -23,10 +23,6 @@ export function assertStringIncludes(actual: string, expected: string, message?: expect(actual, message).toContain(expected); } -export function assertAlmostEquals(actual: number, expected: number, tolerance = 1e-7, message?: string): void { - expect(Math.abs(actual - expected), message).toBeLessThanOrEqual(tolerance); -} - export function assertThrows(fn: () => unknown, errorClass?: ErrorConstructor, messageIncludes?: string, message?: string): Error { try { fn(); diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index ab90fd1626..4c1c8a16a3 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -1,6 +1,5 @@ export { assert, - assertAlmostEquals, assertEquals, assertExists, assertFalse, diff --git a/packages/test-utils/src/stubs.ts b/packages/test-utils/src/stubs.ts index 121c362a6f..17f4d2f8f9 100644 --- a/packages/test-utils/src/stubs.ts +++ b/packages/test-utils/src/stubs.ts @@ -97,7 +97,7 @@ export const stubModelCandidate = (overrides: { providerData?: unknown; } = {}): ModelCandidate => { const provider = overrides.provider ?? { - upstream: 'test-upstream', + upstreamId: 'test-upstream', kind: 'custom', name: 'Test Upstream', disabledPublicModelIds: [], @@ -124,7 +124,7 @@ export const stubModelCandidate = (overrides: { provider, model: stubInternalModel({ ...modelOverrides, - providerModels: modelOverrides.providerModels ?? { [provider.upstream]: providerModel }, + providerModels: modelOverrides.providerModels ?? { [provider.upstreamId]: providerModel }, }), fetcher: directFetcher, }; diff --git a/packages/translate/src/chat-completions-via-messages/events.ts b/packages/translate/src/chat-completions-via-messages/events.ts index 92615787c4..d5bfc0f8dc 100644 --- a/packages/translate/src/chat-completions-via-messages/events.ts +++ b/packages/translate/src/chat-completions-via-messages/events.ts @@ -1,6 +1,8 @@ +import { openAIServiceTierFromMessagesUsage } from '../shared/via-messages/service-tier.ts'; +import { inclusiveMessagesInputUsage } from '../shared/via-messages/usage.ts'; import type { ChatCompletionsStreamEvent, ChatCompletionsResult, ChatCompletionsDelta } from '@floway-dev/protocols/chat-completions'; import { doneFrame, eventFrame, USAGE_BILLING, type ProtocolFrame } from '@floway-dev/protocols/common'; -import { mergeMessagesUsageSnapshot, messagesUsageSnapshot, splitMessagesCacheCreationTokens, type MessagesResult, type MessagesStreamEvent, type MessagesUsageSnapshot } from '@floway-dev/protocols/messages'; +import { mergeMessagesUsageSnapshot, messagesUsageSnapshot, type MessagesResult, type MessagesStreamEvent, type MessagesUsageSnapshot } from '@floway-dev/protocols/messages'; const mapMessagesStopReasonToChatCompletionsFinishReason = (stopReason: MessagesResult['stop_reason']): ChatCompletionsResult['choices'][0]['finish_reason'] => { switch (stopReason) { @@ -69,13 +71,9 @@ const makeChunk = (state: MessagesToChatCompletionsStreamState, delta: ChatCompl }); const makeUsageChunk = (state: MessagesToChatCompletionsStreamState): ChatCompletionsStreamEvent => { - const { cacheWrite, cacheWrite1h } = splitMessagesCacheCreationTokens(state.usage); - const cachedPromptTokens = state.usage.cache_read_input_tokens ?? 0; + const { cacheRead: cachedPromptTokens, cacheWrite, cacheWrite1h, inclusiveInput: promptTokens } = inclusiveMessagesInputUsage(state.usage); const cacheCreationPromptTokens = cacheWrite + cacheWrite1h; - const promptTokens = (state.usage.input_tokens ?? 0) + cachedPromptTokens + cacheCreationPromptTokens; - // Anthropic's `speed: 'fast'` surfaces as OpenAI `service_tier: 'fast'`; - // all other Anthropic service_tier values pass through directly. - const serviceTier = state.usage.speed === 'fast' ? 'fast' : state.usage.service_tier; + const serviceTier = openAIServiceTierFromMessagesUsage(state.usage); return { id: state.messageId, diff --git a/packages/translate/src/chat-completions-via-messages/request.ts b/packages/translate/src/chat-completions-via-messages/request.ts index e88f96857d..4780185671 100644 --- a/packages/translate/src/chat-completions-via-messages/request.ts +++ b/packages/translate/src/chat-completions-via-messages/request.ts @@ -1,12 +1,14 @@ import { messagesThinkingBlockFromChatCompletionsScalarReasoning } from '../shared/chat-completions-and-messages/reasoning.ts'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../shared/via-messages/cache-breakpoints.ts'; -import { type RemoteImageLoader, resolveImageUrlToMessagesImage, unavailableRemoteImageLoader } from '../shared/via-messages/remote-images.ts'; +import { resolveImageUrlToMessagesImage, unavailableRemoteImageLoader } from '../shared/via-messages/remote-images.ts'; +import { messagesServiceTierFieldsFromOpenAI } from '../shared/via-messages/service-tier.ts'; import { parseToolArgumentsObject } from '../shared/via-messages/tool-arguments.ts'; import { TranslatorInputError } from '../translator-input-error.ts'; +import type { RemoteImageLoader } from '../types.ts'; import type { ChatCompletionsPayload, ChatCompletionsMessage, ChatCompletionsTool } from '@floway-dev/protocols/chat-completions'; import { MESSAGES_FALLBACK_MAX_TOKENS, type MessagesAssistantContentBlock, type MessagesMessage, type MessagesPayload, type MessagesTextBlock, type MessagesUserContentBlock } from '@floway-dev/protocols/messages'; -interface TranslateChatCompletionsToMessagesOptions { +interface BuildTargetRequestOptions { loadRemoteImage?: RemoteImageLoader; /** * Preferred cap used when the source payload omits `max_tokens`. Callers in @@ -179,7 +181,7 @@ const CHAT_TOOL_CHOICES = { required: { type: 'any' }, } satisfies Record, MessagesPayload['tool_choice']>; -export const translateChatCompletionsToMessages = async (payload: ChatCompletionsPayload, options: TranslateChatCompletionsToMessagesOptions = {}): Promise => { +export const buildTargetRequest = async (payload: ChatCompletionsPayload, options: BuildTargetRequestOptions = {}): Promise => { // Hoist the leading contiguous run of system/developer messages to // MessagesPayload.system, preserving each ContentPart text as its own // MessagesTextBlock so part boundaries survive the hoist. Non-leading @@ -218,15 +220,7 @@ export const translateChatCompletionsToMessages = async (payload: ChatCompletion if (formatSchema) outputConfig.format = { type: 'json_schema', schema: formatSchema }; const hasOutputConfig = Object.keys(outputConfig).length > 0; - // `service_tier: 'fast'` from the Chat Completions caller maps to - // Anthropic's `speed: 'fast'`; all other defined service_tier values - // pass through as `service_tier` on the Messages wire. - const serviceTierFields: Partial = - payload.service_tier === 'fast' - ? { speed: 'fast' } - : payload.service_tier != null - ? { service_tier: payload.service_tier } - : {}; + const serviceTierFields = messagesServiceTierFieldsFromOpenAI(payload.service_tier); // Leave OpenAI `user` and generic metadata out of the Messages fallback instead // of treating them as a backchannel for Anthropic `metadata.user_id`. @@ -249,6 +243,3 @@ export const translateChatCompletionsToMessages = async (payload: ChatCompletion ...serviceTierFields, }; }; - -export const buildTargetRequest = (payload: ChatCompletionsPayload, options: TranslateChatCompletionsToMessagesOptions): Promise => - translateChatCompletionsToMessages(payload, options); diff --git a/packages/translate/src/chat-completions-via-messages/request_test.ts b/packages/translate/src/chat-completions-via-messages/request_test.ts index d7ec259015..35a1051919 100644 --- a/packages/translate/src/chat-completions-via-messages/request_test.ts +++ b/packages/translate/src/chat-completions-via-messages/request_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; -import { translateChatCompletionsToMessages } from './request.ts'; -import type { RemoteImageLoader } from '../shared/via-messages/remote-images.ts'; +import { buildTargetRequest } from './request.ts'; +import type { RemoteImageLoader } from '../types.ts'; import type { ChatCompletionsMessage, ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; import { MESSAGES_FALLBACK_MAX_TOKENS, @@ -45,8 +45,8 @@ function stubRemoteImageLoader(result: Awaited>): // ── service_tier → speed mapping ── -test('translateChatCompletionsToMessages maps service_tier:fast to speed:fast (no service_tier on target)', async () => { - const result = await translateChatCompletionsToMessages( +test('buildTargetRequest maps service_tier:fast to speed:fast (no service_tier on target)', async () => { + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'hi' }], service_tier: 'fast', @@ -57,8 +57,8 @@ test('translateChatCompletionsToMessages maps service_tier:fast to speed:fast (n assertFalse('service_tier' in result); }); -test('translateChatCompletionsToMessages passes service_tier:priority through as service_tier (no speed override)', async () => { - const result = await translateChatCompletionsToMessages( +test('buildTargetRequest passes service_tier:priority through as service_tier (no speed override)', async () => { + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'hi' }], service_tier: 'priority', @@ -69,8 +69,8 @@ test('translateChatCompletionsToMessages passes service_tier:priority through as assertFalse('speed' in result); }); -test('translateChatCompletionsToMessages passes service_tier:auto through as service_tier', async () => { - const result = await translateChatCompletionsToMessages( +test('buildTargetRequest passes service_tier:auto through as service_tier', async () => { + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'hi' }], service_tier: 'auto', @@ -81,8 +81,8 @@ test('translateChatCompletionsToMessages passes service_tier:auto through as ser assertFalse('speed' in result); }); -test('translateChatCompletionsToMessages omits both speed and service_tier when service_tier is absent', async () => { - const result = await translateChatCompletionsToMessages( +test('buildTargetRequest omits both speed and service_tier when service_tier is absent', async () => { + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'hi' }], }), @@ -93,7 +93,7 @@ test('translateChatCompletionsToMessages omits both speed and service_tier when }); test('leading system message hoisted to top-level system field', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'system', content: 'You are helpful.' }, @@ -107,7 +107,7 @@ test('leading system message hoisted to top-level system field', async () => { }); test('leading developer message hoisted as system', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'developer', content: 'Dev instructions' }, @@ -120,7 +120,7 @@ test('leading developer message hoisted as system', async () => { }); test('non-leading system stays inline, leading is hoisted', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'system', content: 'First' }, @@ -138,7 +138,7 @@ test('non-leading system stays inline, leading is hoisted', async () => { }); test('empty leading system content is not hoisted', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'system', content: '' }, @@ -151,7 +151,7 @@ test('empty leading system content is not hoisted', async () => { }); test('leading empty system is skipped, leading non-empty is still hoisted', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'system', content: '' }, @@ -166,7 +166,7 @@ test('leading empty system is skipped, leading non-empty is still hoisted', asyn }); test('leading system with ContentPart array preserves text parts as separate blocks', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { @@ -188,7 +188,7 @@ test('leading system with ContentPart array preserves text parts as separate blo }); test('multiple consecutive leading system messages accumulate as separate blocks', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'system', content: 'First' }, @@ -207,7 +207,7 @@ test('multiple consecutive leading system messages accumulate as separate blocks test('image content part in leading system message throws', async () => { await assertRejects( () => - translateChatCompletionsToMessages( + buildTargetRequest( mkPayload({ messages: [ { @@ -229,7 +229,7 @@ test('image content part in leading system message throws', async () => { test('image content part in non-leading system message throws', async () => { await assertRejects( () => - translateChatCompletionsToMessages( + buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -251,7 +251,7 @@ test('image content part in non-leading system message throws', async () => { // ── Basic message mapping ── test('simple user message → string content', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hello' }], }), @@ -264,7 +264,7 @@ test('simple user message → string content', async () => { }); test('simple assistant message → text block', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -279,7 +279,7 @@ test('simple assistant message → text block', async () => { }); test('assistant with null content → empty text block', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -294,7 +294,7 @@ test('assistant with null content → empty text block', async () => { }); test('user with null content → empty text block', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: null }], }), @@ -307,7 +307,7 @@ test('user with null content → empty text block', async () => { // ── User/user merge ── test('consecutive user messages merged', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'First' }, @@ -324,7 +324,7 @@ test('consecutive user messages merged', async () => { }); test('three consecutive users all merged into one', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'A' }, @@ -341,7 +341,7 @@ test('three consecutive users all merged into one', async () => { // ── Tool messages ── test('tool message creates user with tool_result block', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -370,7 +370,7 @@ test('tool message creates user with tool_result block', async () => { }); test('multiple tool messages after assistant merged into one user', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -404,7 +404,7 @@ test('multiple tool messages after assistant merged into one user', async () => }); test('tool + user merged: tool_results + text in same user msg', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -435,7 +435,7 @@ test('tool + user merged: tool_results + text in same user msg', async () => { test('tool message without tool_call_id is rejected', async () => { await assertRejects( () => - translateChatCompletionsToMessages( + buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -462,7 +462,7 @@ test('tool message without tool_call_id is rejected', async () => { // ── Assistant content block ordering ── test('assistant blocks ordered: thinking → text → tool_use', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -490,7 +490,7 @@ test('assistant blocks ordered: thinking → text → tool_use', async () => { }); test('assistant with only tool_calls, no content', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -516,7 +516,7 @@ test('assistant with only tool_calls, no content', async () => { }); test('assistant with multiple tool_calls', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -548,7 +548,7 @@ test('assistant with multiple tool_calls', async () => { }); test('assistant tool_calls with invalid JSON arguments → raw_arguments fallback', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -575,7 +575,7 @@ test('assistant tool_calls with invalid JSON arguments → raw_arguments fallbac // ── Thinking / Redacted thinking ── test('reasoning_text + reasoning_opaque → thinking block with signature', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -596,7 +596,7 @@ test('reasoning_text + reasoning_opaque → thinking block with signature', asyn }); test('reasoning_text only → thinking block without signature', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -612,7 +612,7 @@ test('reasoning_text only → thinking block without signature', async () => { }); test('reasoning_opaque only → redacted_thinking block', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -627,7 +627,7 @@ test('reasoning_opaque only → redacted_thinking block', async () => { }); test('no reasoning fields → no thinking block', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -641,7 +641,7 @@ test('no reasoning fields → no thinking block', async () => { }); test('null reasoning fields → no thinking block', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -662,7 +662,7 @@ test('null reasoning fields → no thinking block', async () => { // ── Image handling ── test('image_url with data URL → base64 image block', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { @@ -692,7 +692,7 @@ test('image_url with data URL → base64 image block', async () => { }); test('image_url with remote image loader → base64 image block', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { @@ -728,7 +728,7 @@ test('image_url with remote image loader → base64 image block', async () => { }); test('image_url with remote image loader failure → gracefully skipped', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { @@ -753,7 +753,7 @@ test('image_url with remote image loader failure → gracefully skipped', async }); test('image with jpeg media type', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { @@ -775,7 +775,7 @@ test('image with jpeg media type', async () => { }); test('data URL with unsupported media type → skipped', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { @@ -792,7 +792,7 @@ test('data URL with unsupported media type → skipped', async () => { }); test('content with only non-parseable image → empty text fallback', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { @@ -811,7 +811,7 @@ test('content with only non-parseable image → empty text fallback', async () = // ── Field mapping ── test('max_tokens defaults to MESSAGES_FALLBACK_MAX_TOKENS when neither payload nor fallbackMaxOutputTokens supply one', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], }), @@ -820,12 +820,12 @@ test('max_tokens defaults to MESSAGES_FALLBACK_MAX_TOKENS when neither payload n }); test('max_tokens uses fallbackMaxOutputTokens over the gateway const when the payload omits it', async () => { - const result = await translateChatCompletionsToMessages(mkPayload({ messages: [{ role: 'user', content: 'Hi' }] }), { fallbackMaxOutputTokens: 6144 }); + const result = await buildTargetRequest(mkPayload({ messages: [{ role: 'user', content: 'Hi' }] }), { fallbackMaxOutputTokens: 6144 }); assertEquals(result.max_tokens, 6144); }); test('max_tokens passed through when provided, overriding fallbackMaxOutputTokens', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], max_tokens: 1024, @@ -836,7 +836,7 @@ test('max_tokens passed through when provided, overriding fallbackMaxOutputToken }); test('temperature mapped', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], temperature: 0.7, @@ -846,7 +846,7 @@ test('temperature mapped', async () => { }); test('temperature 0 is mapped (not treated as falsy)', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], temperature: 0, @@ -856,7 +856,7 @@ test('temperature 0 is mapped (not treated as falsy)', async () => { }); test('top_p mapped', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], top_p: 0.9, @@ -866,7 +866,7 @@ test('top_p mapped', async () => { }); test('null temperature/top_p not included', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], temperature: null, @@ -878,7 +878,7 @@ test('null temperature/top_p not included', async () => { }); test('stop string → stop_sequences array', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], stop: 'END', @@ -888,7 +888,7 @@ test('stop string → stop_sequences array', async () => { }); test('stop array → stop_sequences array', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], stop: ['END', 'STOP'], @@ -900,7 +900,7 @@ test('stop array → stop_sequences array', async () => { test('always emits stream: true regardless of source stream flag', async () => { // Translation assumes streaming upstream (provider forces stream=true); // source `respond.ts` collects SSE when client wants non-stream. - const streamed = await translateChatCompletionsToMessages( + const streamed = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], stream: true, @@ -908,7 +908,7 @@ test('always emits stream: true regardless of source stream flag', async () => { ); assertEquals(streamed.stream, true); - const nonStreamed = await translateChatCompletionsToMessages( + const nonStreamed = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], stream: false, @@ -920,7 +920,7 @@ test('always emits stream: true regardless of source stream flag', async () => { // ── Tool choice mapping ── test('tool_choice auto → { type: auto }', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], tools: [{ type: 'function', function: { name: 'f', parameters: {} } }], @@ -931,7 +931,7 @@ test('tool_choice auto → { type: auto }', async () => { }); test('tool_choice none → { type: none }', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], tool_choice: 'none', @@ -941,7 +941,7 @@ test('tool_choice none → { type: none }', async () => { }); test('tool_choice required → { type: any }', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], tools: [{ type: 'function', function: { name: 'f', parameters: {} } }], @@ -952,7 +952,7 @@ test('tool_choice required → { type: any }', async () => { }); test('tool_choice specific function → { type: tool, name }', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], tools: [ @@ -968,7 +968,7 @@ test('tool_choice specific function → { type: tool, name }', async () => { }); test('null tool_choice → not set', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], tool_choice: null, @@ -980,7 +980,7 @@ test('null tool_choice → not set', async () => { // ── Tools mapping ── test('tools translated correctly', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], tools: [ @@ -1013,7 +1013,7 @@ test('tools translated correctly', async () => { }); test('tools preserve explicit strict values', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], tools: [ @@ -1044,7 +1044,7 @@ test('tools preserve explicit strict values', async () => { }); test('tools omit strict when Chat omitted strict', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], tools: [ @@ -1065,7 +1065,7 @@ test('tools omit strict when Chat omitted strict', async () => { }); test('empty tools array → not set', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], tools: [], @@ -1077,7 +1077,7 @@ test('empty tools array → not set', async () => { // ── Model passthrough ── test('model name passed through', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ model: 'claude-opus-4', messages: [{ role: 'user', content: 'Hi' }], @@ -1089,7 +1089,7 @@ test('model name passed through', async () => { // ── Complex multi-turn conversations ── test('full tool use round-trip conversation', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'system', content: 'You are helpful.' }, @@ -1123,7 +1123,7 @@ test('full tool use round-trip conversation', async () => { // ── Cache breakpoints ── test('attaches ephemeral cache breakpoints to last function tool and last message block', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'system', content: 'You are helpful.' }, @@ -1158,7 +1158,7 @@ test('attaches ephemeral cache breakpoint to the promoted text block when last m // Promotion path: assistant.content === string → wrapped into a single // text block carrying the breakpoint. Mirrors the user-string case but // exercises the assistant branch of the message-content union. - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Hi' }, @@ -1173,7 +1173,7 @@ test('attaches ephemeral cache breakpoint to the promoted text block when last m }); test('interleaved thinking round-trip', async () => { - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [ { role: 'user', content: 'Solve this.' }, @@ -1211,9 +1211,9 @@ test('interleaved thinking round-trip', async () => { assertEquals(a2[1].type, 'text'); }); -test('translateChatCompletionsToMessages extracts nested response_format json_schema into output_config.format', async () => { +test('buildTargetRequest extracts nested response_format json_schema into output_config.format', async () => { const schema = { type: 'object', properties: { x: { type: 'string' } }, required: ['x'], additionalProperties: false }; - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], response_format: { type: 'json_schema', json_schema: { name: 'whatever', strict: true, schema } }, @@ -1223,9 +1223,9 @@ test('translateChatCompletionsToMessages extracts nested response_format json_sc assertEquals(result.output_config, { format: { type: 'json_schema', schema } }); }); -test('translateChatCompletionsToMessages merges reasoning_effort with structured-output format on a single output_config', async () => { +test('buildTargetRequest merges reasoning_effort with structured-output format on a single output_config', async () => { const schema = { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false }; - const result = await translateChatCompletionsToMessages( + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], reasoning_effort: 'high', @@ -1236,8 +1236,8 @@ test('translateChatCompletionsToMessages merges reasoning_effort with structured assertEquals(result.output_config, { effort: 'high', format: { type: 'json_schema', schema } }); }); -test('translateChatCompletionsToMessages drops response_format json_object (no Anthropic equivalent)', async () => { - const result = await translateChatCompletionsToMessages( +test('buildTargetRequest drops response_format json_object (no Anthropic equivalent)', async () => { + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], response_format: { type: 'json_object' }, @@ -1247,10 +1247,10 @@ test('translateChatCompletionsToMessages drops response_format json_object (no A assertEquals(result.output_config, undefined); }); -test('translateChatCompletionsToMessages rejects an unknown message role', async () => { +test('buildTargetRequest rejects an unknown message role', async () => { await assertRejects( () => - translateChatCompletionsToMessages({ + buildTargetRequest({ model: 'claude-test', messages: [{ role: 'function', content: 'hi' } as unknown as ChatCompletionsMessage], }), @@ -1259,10 +1259,10 @@ test('translateChatCompletionsToMessages rejects an unknown message role', async ); }); -test('translateChatCompletionsToMessages rejects an unknown user content part type', async () => { +test('buildTargetRequest rejects an unknown user content part type', async () => { await assertRejects( () => - translateChatCompletionsToMessages({ + buildTargetRequest({ model: 'claude-test', messages: [{ role: 'user', content: [{ type: 'video_url' }] } as unknown as ChatCompletionsMessage], }), @@ -1273,8 +1273,8 @@ test('translateChatCompletionsToMessages rejects an unknown user content part ty // ── service_tier forwarding ── -test('translateChatCompletionsToMessages forwards service_tier verbatim', async () => { - const result = await translateChatCompletionsToMessages( +test('buildTargetRequest forwards service_tier verbatim', async () => { + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'hi' }], service_tier: 'priority', @@ -1284,8 +1284,8 @@ test('translateChatCompletionsToMessages forwards service_tier verbatim', async assertEquals(result.service_tier, 'priority'); }); -test('translateChatCompletionsToMessages does not emit thinking or fast-mode fields for a bare payload', async () => { - const result = await translateChatCompletionsToMessages( +test('buildTargetRequest does not emit thinking or fast-mode fields for a bare payload', async () => { + const result = await buildTargetRequest( mkPayload({ messages: [{ role: 'user', content: 'hi' }], }), diff --git a/packages/translate/src/chat-completions-via-messages/translate.ts b/packages/translate/src/chat-completions-via-messages/translate.ts index fe3b62f49e..60c17300ba 100644 --- a/packages/translate/src/chat-completions-via-messages/translate.ts +++ b/packages/translate/src/chat-completions-via-messages/translate.ts @@ -1,7 +1,6 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { RemoteImageLoader } from '../shared/via-messages/remote-images.ts'; -import type { TranslateTrip } from '../types.ts'; +import type { RemoteImageLoader, TranslateTrip } from '../types.ts'; import type { ChatCompletionsStreamEvent, ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; diff --git a/packages/translate/src/chat-completions-via-responses/events.ts b/packages/translate/src/chat-completions-via-responses/events.ts index 30e40a3541..cc08681f91 100644 --- a/packages/translate/src/chat-completions-via-responses/events.ts +++ b/packages/translate/src/chat-completions-via-responses/events.ts @@ -1,9 +1,9 @@ import { hasReadableSummary, toChatCompletionsReasoningItem } from '../shared/chat-completions-and-responses/reasoning.ts'; import { createResponsesOutputOrderState, recordResponsesOutputOrderEvent, type ResponsesOutputOrderState, shouldDeferForEarlierResponsesOutput } from '../shared/via-responses/responses-stream-order.ts'; -import { type ResponsesEvent, responsesPartKey } from '../shared/via-responses/responses-stream.ts'; +import { responsesPartKey } from '../shared/via-responses/responses-stream.ts'; import type { ChatCompletionsStreamEvent, ChatCompletionsResult, ChatCompletionsReasoningItem, ChatCompletionsDelta } from '@floway-dev/protocols/chat-completions'; import { doneFrame, eventFrame, splitInclusiveInputTokens, USAGE_BILLING, type ProtocolFrame } from '@floway-dev/protocols/common'; -import type { ResponsesOutputItem, ResponsesResult, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; +import { isResponsesTerminalEvent, type ResponsesOutputItem, type ResponsesResult, type ResponsesStreamEvent } from '@floway-dev/protocols/responses'; const mapResponsesFinishReasonToChatCompletionsFinishReason = (response: ResponsesResult): ChatCompletionsResult['choices'][0]['finish_reason'] => response.status === 'incomplete' && response.incomplete_details?.reason === 'max_output_tokens' @@ -19,7 +19,7 @@ const upstreamResponsesEventsUntilTerminal = async function* (frames: AsyncItera if (frame.type === 'done') continue; yield frame.event; - if (frame.event.type === 'response.completed' || frame.event.type === 'response.incomplete' || frame.event.type === 'response.failed' || frame.event.type === 'error') { + if (isResponsesTerminalEvent(frame.event)) { return; } } @@ -78,7 +78,7 @@ const flushPendingReasoningChunks = (state: ResponsesToChatCompletionsStreamStat const isReasoningOutputDone = (event: ResponsesStreamEvent): boolean => { if (event.type !== 'response.output_item.done') return false; - return (event as ResponsesEvent<'response.output_item.done'>).item.type === 'reasoning'; + return (event as Extract).item.type === 'reasoning'; }; const takeNextReadyDeferredResponseEvent = (state: ResponsesToChatCompletionsStreamState, onlyReasoningOutputDone: boolean): ResponsesStreamEvent | undefined => { @@ -155,7 +155,7 @@ export const translateResponsesEventToChatCompletionsChunks = (event: ResponsesS switch (event.type) { case 'response.created': { - const { response } = event as ResponsesEvent<'response.created'>; + const { response } = event as Extract; state.messageId = response.id; state.model = response.model; if (response.service_tier !== undefined) state.serviceTier = response.service_tier; @@ -163,7 +163,7 @@ export const translateResponsesEventToChatCompletionsChunks = (event: ResponsesS } case 'response.output_item.added': { - const { item, output_index } = event as ResponsesEvent<'response.output_item.added'>; + const { item, output_index } = event as Extract; if (item.type !== 'function_call') return []; state.toolCallIndex++; @@ -187,7 +187,7 @@ export const translateResponsesEventToChatCompletionsChunks = (event: ResponsesS } case 'response.output_item.done': { - const { item, output_index } = event as ResponsesEvent<'response.output_item.done'>; + const { item, output_index } = event as Extract; if (item.type !== 'reasoning') return []; const chunks: ChatCompletionsStreamEvent[] = []; @@ -203,18 +203,18 @@ export const translateResponsesEventToChatCompletionsChunks = (event: ResponsesS } case 'response.reasoning_summary_text.delta': { - const { delta, output_index, summary_index } = event as ResponsesEvent<'response.reasoning_summary_text.delta'>; + const { delta, output_index, summary_index } = event as Extract; return emitReasoningSummaryText(output_index, summary_index, delta, state, 'delta'); } case 'response.reasoning_summary_text.done': { - const { text, output_index, summary_index } = event as ResponsesEvent<'response.reasoning_summary_text.done'>; + const { text, output_index, summary_index } = event as Extract; queueReasoningSummaryDoneFallback(output_index, summary_index, text, state); return []; } case 'response.output_text.delta': { - const { delta, output_index, content_index } = event as ResponsesEvent<'response.output_text.delta'>; + const { delta, output_index, content_index } = event as Extract; if (delta) { state.emittedTextContentKeys.add(responsesPartKey(output_index, content_index)); } @@ -222,7 +222,7 @@ export const translateResponsesEventToChatCompletionsChunks = (event: ResponsesS } case 'response.output_text.done': { - const { text, output_index, content_index } = event as ResponsesEvent<'response.output_text.done'>; + const { text, output_index, content_index } = event as Extract; const key = responsesPartKey(output_index, content_index); if (!text || state.emittedTextContentKeys.has(key)) return []; @@ -231,7 +231,7 @@ export const translateResponsesEventToChatCompletionsChunks = (event: ResponsesS } case 'response.content_part.done': { - const { part, output_index, content_index } = event as ResponsesEvent<'response.content_part.done'>; + const { part, output_index, content_index } = event as Extract; if (part.type !== 'refusal') return []; const key = responsesPartKey(output_index, content_index); @@ -242,7 +242,7 @@ export const translateResponsesEventToChatCompletionsChunks = (event: ResponsesS } case 'response.function_call_arguments.delta': { - const { delta, output_index } = event as ResponsesEvent<'response.function_call_arguments.delta'>; + const { delta, output_index } = event as Extract; if (!delta) return []; const toolCallIndex = state.functionCallIndices.get(output_index); @@ -262,7 +262,7 @@ export const translateResponsesEventToChatCompletionsChunks = (event: ResponsesS } case 'response.function_call_arguments.done': { - const { arguments: args, output_index } = event as ResponsesEvent<'response.function_call_arguments.done'>; + const { arguments: args, output_index } = event as Extract; if (!args || state.emittedFunctionArgumentOutputIndexes.has(output_index)) { return []; } @@ -285,7 +285,7 @@ export const translateResponsesEventToChatCompletionsChunks = (event: ResponsesS case 'response.completed': case 'response.incomplete': { - const { response } = event as ResponsesEvent<'response.completed' | 'response.incomplete'>; + const { response } = event as Extract; const chunks: ChatCompletionsStreamEvent[] = []; if (response.service_tier !== undefined) state.serviceTier = response.service_tier; @@ -381,7 +381,7 @@ const debugFieldsFrom = (value: Record) => ({ ...(typeof value.target_api === 'string' ? { target_api: value.target_api } : {}), }); -const chatErrorPayloadFromResponsesError = (event: ResponsesEvent<'error'>): ChatCompletionsErrorPayload => ({ +const chatErrorPayloadFromResponsesError = (event: Extract): ChatCompletionsErrorPayload => ({ error: { message: event.message, type: event.code ?? 'api_error', @@ -395,7 +395,7 @@ const chatErrorPayloadFromResponsesError = (event: ResponsesEvent<'error'>): Cha const isObjectLike = (value: unknown): value is Record => typeof value === 'object' && value !== null; -const chatErrorPayloadFromResponsesFailure = (event: ResponsesEvent<'response.failed'>): ChatCompletionsErrorPayload => { +const chatErrorPayloadFromResponsesFailure = (event: Extract): ChatCompletionsErrorPayload => { const response = event.response as ResponsesResult; const error = isObjectLike(response.error) ? response.error : undefined; @@ -413,11 +413,11 @@ const chatErrorFrameFromResponsesFatalEvent = (event: ResponsesStreamEvent): Pro if (event.type === 'error') { // OpenAI-compatible Chat streams can carry top-level error payloads; // ChatCompletionsStreamEvent only models successful chunk payloads. - return eventFrame(chatErrorPayloadFromResponsesError(event as ResponsesEvent<'error'>) as unknown as ChatCompletionsStreamEvent); + return eventFrame(chatErrorPayloadFromResponsesError(event as Extract) as unknown as ChatCompletionsStreamEvent); } if (event.type === 'response.failed') { - return eventFrame(chatErrorPayloadFromResponsesFailure(event as ResponsesEvent<'response.failed'>) as unknown as ChatCompletionsStreamEvent); + return eventFrame(chatErrorPayloadFromResponsesFailure(event as Extract) as unknown as ChatCompletionsStreamEvent); } return undefined; diff --git a/packages/translate/src/chat-completions-via-responses/events_test.ts b/packages/translate/src/chat-completions-via-responses/events_test.ts index 4177c1441d..65f6d101bb 100644 --- a/packages/translate/src/chat-completions-via-responses/events_test.ts +++ b/packages/translate/src/chat-completions-via-responses/events_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { translateToSourceEvents } from './events.ts'; +import { createResponsesToChatCompletionsStreamState, translateResponsesEventToChatCompletionsChunks, translateToSourceEvents } from './events.ts'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import { eventFrame, type ProtocolFrame, type SseFrame, sseFrame } from '@floway-dev/protocols/common'; import { responsesResultToEvents, type ResponsesResult, type ResponsesStreamEvent } from '@floway-dev/protocols/responses'; @@ -403,3 +403,908 @@ test('translateToSourceEvents rejects truncated Responses streams without termin await assertRejects(async () => await drain(translateToSourceEvents(stream())), Error, 'Upstream Responses stream ended without a terminal event.'); }); + +test('translateResponsesEventToChatCompletionsChunks drops reasoning items without readable summary', () => { + const state = createResponsesToChatCompletionsStreamState(); + + const created = translateResponsesEventToChatCompletionsChunks( + { + type: 'response.created', + response: { + id: 'resp_single_opaque', + object: 'response', + model: 'gpt-test', + status: 'in_progress', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ); + assertEquals(created.length, 1); + assertEquals(created[0].choices[0].delta.role, 'assistant'); + + const during = translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_1', + summary: [], + }, + }, + state, + ); + assertEquals(during, []); + + const completed = translateResponsesEventToChatCompletionsChunks( + { + type: 'response.completed', + response: { + id: 'resp_single_opaque', + object: 'response', + model: 'gpt-test', + status: 'completed', + output: [], + output_text: '', + error: null, + incomplete_details: null, + usage: { + input_tokens: 1, + output_tokens: 2, + total_tokens: 3, + }, + }, + }, + state, + ); + + assertEquals(completed.length, 2); + assertEquals(completed[0].choices[0].delta, {}); + assertEquals(completed[0].choices[0].finish_reason, 'stop'); + assertEquals(completed[0].usage, undefined); + assertEquals(completed[1].choices, []); + assertEquals(completed[1].usage, { + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3, + }); +}); + +test('translateResponsesEventToChatCompletionsChunks does not fill scalar opaque from later empty reasoning', () => { + const state = createResponsesToChatCompletionsStreamState(); + + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.created', + response: { + id: 'resp_stream_no_cross_pair', + object: 'response', + model: 'gpt-test', + status: 'in_progress', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ); + + const chunks = [ + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + delta: 'first', + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_1', + summary: [{ type: 'summary_text', text: 'first' }], + }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.done', + output_index: 1, + item: { + type: 'reasoning', + id: 'rs_2', + summary: [], + }, + }, + state, + ), + ].flatMap(result => result); + + const completed = translateResponsesEventToChatCompletionsChunks( + { + type: 'response.completed', + response: { + id: 'resp_stream_no_cross_pair', + object: 'response', + model: 'gpt-test', + status: 'completed', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ); + + assertEquals( + [...chunks, ...completed].some(chunk => chunk.choices[0]?.delta.reasoning_opaque !== undefined), + false, + ); + assertEquals(completed[0].usage, undefined); +}); + +test('translateResponsesEventToChatCompletionsChunks drops multiple reasoning items without readable summaries', () => { + const state = createResponsesToChatCompletionsStreamState(); + + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.created', + response: { + id: 'resp_multi_opaque', + object: 'response', + model: 'gpt-test', + status: 'in_progress', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ); + + const firstReasoning = translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_1', + summary: [], + }, + }, + state, + ); + const secondReasoning = translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.done', + output_index: 1, + item: { + type: 'reasoning', + id: 'rs_2', + summary: [], + }, + }, + state, + ); + + const completed = translateResponsesEventToChatCompletionsChunks( + { + type: 'response.completed', + response: { + id: 'resp_multi_opaque', + object: 'response', + model: 'gpt-test', + status: 'completed', + output: [], + output_text: '', + error: null, + incomplete_details: null, + usage: { + input_tokens: 1, + output_tokens: 2, + total_tokens: 3, + }, + }, + }, + state, + ); + + assertEquals(firstReasoning, []); + assertEquals(secondReasoning, []); + assertEquals(completed.length, 2); + assertEquals(completed[0].choices[0].finish_reason, 'stop'); + assertEquals(completed[0].usage, undefined); + assertEquals(completed[1].choices, []); + assertEquals(completed[1].usage, { + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3, + }); +}); + +test('translateResponsesEventToChatCompletionsChunks projects done-only summary text into scalar reasoning_text', () => { + const state = createResponsesToChatCompletionsStreamState(); + + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.created', + response: { + id: 'resp_done_only_summary', + object: 'response', + model: 'gpt-test', + status: 'in_progress', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ); + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.reasoning_summary_text.done', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + text: 'done trace', + }, + state, + ); + const reasoning = translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_1', + summary: [{ type: 'summary_text', text: 'done trace' }], + }, + }, + state, + ); + + const completed = translateResponsesEventToChatCompletionsChunks( + { + type: 'response.completed', + response: { + id: 'resp_done_only_summary', + object: 'response', + model: 'gpt-test', + status: 'completed', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ); + + assertEquals(reasoning[0].choices[0].delta.reasoning_text, 'done trace'); + assertEquals(reasoning[1].choices[0].delta.reasoning_items, [ + { + type: 'reasoning', + id: 'rs_1', + summary: [{ type: 'summary_text', text: 'done trace' }], + }, + ]); + assertEquals(completed[0].choices[0].finish_reason, 'stop'); +}); + +test('translateResponsesEventToChatCompletionsChunks projects output_item.done summary into scalar reasoning_text', () => { + const state = createResponsesToChatCompletionsStreamState(); + + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.created', + response: { + id: 'resp_output_done_summary', + object: 'response', + model: 'gpt-test', + status: 'in_progress', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ); + const reasoning = translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_1', + summary: [{ type: 'summary_text', text: 'output trace' }], + }, + }, + state, + ); + + const completed = translateResponsesEventToChatCompletionsChunks( + { + type: 'response.completed', + response: { + id: 'resp_output_done_summary', + object: 'response', + model: 'gpt-test', + status: 'completed', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ); + + assertEquals(reasoning[0].choices[0].delta.reasoning_text, 'output trace'); + assertEquals(reasoning[1].choices[0].delta.reasoning_items, [ + { + type: 'reasoning', + id: 'rs_1', + summary: [{ type: 'summary_text', text: 'output trace' }], + }, + ]); + assertEquals(completed[0].choices[0].finish_reason, 'stop'); +}); + +test('translateResponsesEventToChatCompletionsChunks emits stream usage as a usage-only chunk', () => { + const state = createResponsesToChatCompletionsStreamState(); + + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.created', + response: { + id: 'resp_usage_only', + object: 'response', + model: 'gpt-test', + status: 'in_progress', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ); + + const completed = translateResponsesEventToChatCompletionsChunks( + { + type: 'response.completed', + response: { + id: 'resp_usage_only', + object: 'response', + model: 'gpt-test', + status: 'completed', + output: [], + output_text: '', + error: null, + incomplete_details: null, + usage: { + input_tokens: 12, + output_tokens: 4, + total_tokens: 16, + input_tokens_details: { cached_tokens: 3 }, + }, + }, + }, + state, + ); + + assertEquals(completed.length, 2); + assertEquals(completed[0].choices[0].finish_reason, 'stop'); + assertEquals(completed[0].usage, undefined); + assertEquals(completed[1].choices, []); + assertEquals(completed[1].usage, { + prompt_tokens: 12, + completion_tokens: 4, + total_tokens: 16, + prompt_tokens_details: { cached_tokens: 3 }, + }); +}); + +test('translateResponsesEventToChatCompletionsChunks preserves text order around empty reasoning', () => { + const state = createResponsesToChatCompletionsStreamState(); + const chunks = [ + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.created', + response: { + id: 'resp_late_opaque_order', + object: 'response', + model: 'gpt-test', + status: 'in_progress', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'reasoning', id: 'rs_0', summary: [] }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_text.delta', + item_id: 'msg_1', + output_index: 1, + content_index: 0, + delta: 'answer', + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_0', + summary: [], + }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.completed', + response: { + id: 'resp_late_opaque_order', + object: 'response', + model: 'gpt-test', + status: 'completed', + output: [ + { + type: 'reasoning', + id: 'rs_0', + summary: [], + }, + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'answer' }], + }, + ], + output_text: 'answer', + error: null, + incomplete_details: null, + }, + }, + state, + ), + ].flatMap(result => result); + + assertEquals( + chunks.map(chunk => chunk.choices[0]?.delta), + [ + { role: 'assistant' }, + { content: 'answer' }, + {}, + ], + ); +}); + +test('translateResponsesEventToChatCompletionsChunks preserves later text after empty reasoning is done', () => { + const state = createResponsesToChatCompletionsStreamState(); + const chunks = [ + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.created', + response: { + id: 'resp_done_before_text', + object: 'response', + model: 'gpt-test', + status: 'in_progress', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'reasoning', id: 'rs_0', summary: [] }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_0', + summary: [], + }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_text.delta', + item_id: 'msg_1', + output_index: 1, + content_index: 0, + delta: 'answer', + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.completed', + response: { + id: 'resp_done_before_text', + object: 'response', + model: 'gpt-test', + status: 'completed', + output: [ + { + type: 'reasoning', + id: 'rs_0', + summary: [], + }, + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'answer' }], + }, + ], + output_text: 'answer', + error: null, + incomplete_details: null, + }, + }, + state, + ), + ].flatMap(result => result); + + assertEquals( + chunks.map(chunk => chunk.choices[0]?.delta), + [ + { role: 'assistant' }, + { content: 'answer' }, + {}, + ], + ); +}); + +test('translateResponsesEventToChatCompletionsChunks emits output_text.done when no delta arrived', () => { + const state = createResponsesToChatCompletionsStreamState(); + const chunks = [ + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.created', + response: { + id: 'resp_done_text', + object: 'response', + model: 'gpt-test', + status: 'in_progress', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_text.done', + item_id: 'msg_0', + output_index: 0, + content_index: 0, + text: 'answer', + }, + state, + ), + ].flatMap(result => result); + + assertEquals( + chunks.map(chunk => chunk.choices[0]?.delta), + [{ role: 'assistant' }, { content: 'answer' }], + ); +}); + +test('translateResponsesEventToChatCompletionsChunks emits function_call_arguments.done when no delta arrived', () => { + const state = createResponsesToChatCompletionsStreamState(); + const chunks = [ + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.created', + response: { + id: 'resp_done_args', + object: 'response', + model: 'gpt-test', + status: 'in_progress', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'function_call', + call_id: 'call_0', + name: 'lookup', + arguments: '', + status: 'in_progress', + }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.function_call_arguments.done', + item_id: 'fc_0', + output_index: 0, + arguments: '{"q":1}', + }, + state, + ), + ].flatMap(result => result); + + assertEquals( + chunks.map(chunk => chunk.choices[0]?.delta), + [ + { role: 'assistant' }, + { + tool_calls: [ + { + index: 0, + id: 'call_0', + type: 'function', + function: { name: 'lookup', arguments: '' }, + }, + ], + }, + { + tool_calls: [ + { + index: 0, + function: { arguments: '{"q":1}' }, + }, + ], + }, + ], + ); +}); + +test('translateResponsesEventToChatCompletionsChunks emits all done-only reasoning summary parts', () => { + const state = createResponsesToChatCompletionsStreamState(); + const chunks = [ + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.created', + response: { + id: 'resp_done_reasoning_parts', + object: 'response', + model: 'gpt-test', + status: 'in_progress', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'reasoning', id: 'rs_0', summary: [] }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.reasoning_summary_text.done', + item_id: 'rs_0', + output_index: 0, + summary_index: 0, + text: 'first', + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.reasoning_summary_text.done', + item_id: 'rs_0', + output_index: 0, + summary_index: 1, + text: 'second', + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_0', + summary: [ + { type: 'summary_text', text: 'first' }, + { type: 'summary_text', text: 'second' }, + ], + }, + }, + state, + ), + ].flatMap(result => result); + + assertEquals( + chunks.map(chunk => chunk.choices[0]?.delta.reasoning_text).filter(text => text !== undefined), + ['first', 'second'], + ); +}); + +test('translateResponsesEventToChatCompletionsChunks flushes pending done-only reasoning summary at completion', () => { + const state = createResponsesToChatCompletionsStreamState(); + + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.created', + response: { + id: 'resp_terminal_reasoning_done', + object: 'response', + model: 'gpt-test', + status: 'in_progress', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ); + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.reasoning_summary_text.done', + item_id: 'rs_0', + output_index: 0, + summary_index: 0, + text: 'terminal trace', + }, + state, + ); + const completed = translateResponsesEventToChatCompletionsChunks( + { + type: 'response.completed', + response: { + id: 'resp_terminal_reasoning_done', + object: 'response', + model: 'gpt-test', + status: 'completed', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ); + + assertEquals( + completed.map(chunk => chunk.choices[0]?.delta), + [{ reasoning_text: 'terminal trace' }, {}], + ); +}); + +test('translateResponsesEventToChatCompletionsChunks keeps first scalar reasoning by output order', () => { + const state = createResponsesToChatCompletionsStreamState(); + const chunks = [ + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.created', + response: { + id: 'resp_reasoning_order', + object: 'response', + model: 'gpt-test', + status: 'in_progress', + output: [], + output_text: '', + error: null, + incomplete_details: null, + }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'reasoning', id: 'rs_0', summary: [] }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.added', + output_index: 1, + item: { type: 'reasoning', id: 'rs_1', summary: [] }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.done', + output_index: 1, + item: { + type: 'reasoning', + id: 'rs_1', + summary: [{ type: 'summary_text', text: 'second' }], + }, + }, + state, + ), + translateResponsesEventToChatCompletionsChunks( + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: 'rs_0', + summary: [{ type: 'summary_text', text: 'first' }], + }, + }, + state, + ), + ].flatMap(result => result); + + assertEquals( + chunks.map(chunk => chunk.choices[0]?.delta), + [ + { role: 'assistant' }, + { reasoning_text: 'first' }, + { + reasoning_items: [ + { + type: 'reasoning', + id: 'rs_0', + summary: [{ type: 'summary_text', text: 'first' }], + }, + { + type: 'reasoning', + id: 'rs_1', + summary: [{ type: 'summary_text', text: 'second' }], + }, + ], + }, + ], + ); +}); diff --git a/packages/translate/src/chat-completions-via-responses/request.ts b/packages/translate/src/chat-completions-via-responses/request.ts index c99c30c1f7..20aaf2c3cc 100644 --- a/packages/translate/src/chat-completions-via-responses/request.ts +++ b/packages/translate/src/chat-completions-via-responses/request.ts @@ -20,7 +20,7 @@ const translateChatTools = (tools?: ChatCompletionsTool[] | null): ResponsesTool const translateChatToolChoice = (choice?: ChatCompletionsPayload['tool_choice']): ResponsesToolChoice => choice == null ? 'auto' : typeof choice === 'string' ? choice : { type: 'function', name: choice.function.name }; -export const translateChatCompletionsToResponses = (payload: ChatCompletionsPayload): CanonicalResponsesPayload => { +export const buildTargetRequest = (payload: ChatCompletionsPayload): CanonicalResponsesPayload => { const instructions: string[] = []; const input: ResponsesInputItem[] = []; let hoistSystemPrefix = true; @@ -148,5 +148,3 @@ export const translateChatCompletionsToResponses = (payload: ChatCompletionsPayl ...(payload.service_tier !== undefined ? { service_tier: payload.service_tier } : {}), }; }; - -export const buildTargetRequest = translateChatCompletionsToResponses; diff --git a/packages/translate/src/chat-completions-via-responses/request_test.ts b/packages/translate/src/chat-completions-via-responses/request_test.ts index 889c011e80..b3be4b9218 100644 --- a/packages/translate/src/chat-completions-via-responses/request_test.ts +++ b/packages/translate/src/chat-completions-via-responses/request_test.ts @@ -1,40 +1,12 @@ import { expect, test } from 'vitest'; -import { translateChatCompletionsToResponses } from './request.ts'; -import { createChatCompletionsToResponsesStreamState, flushChatCompletionsToResponsesEvents, translateChatCompletionsChunkToResponsesEvents } from '../responses-via-chat-completions/events.ts'; -import type { ChatCompletionsMessage, ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; -import type { ResponsesInputReasoning, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; +import { buildTargetRequest } from './request.ts'; +import type { ChatCompletionsMessage } from '@floway-dev/protocols/chat-completions'; +import type { ResponsesInputReasoning } from '@floway-dev/protocols/responses'; import { assertEquals, assertFalse, assertThrows } from '@floway-dev/test-utils'; -type ResponsesOutputItemDoneEvent = Extract; - -type ResponsesOutputItemAddedEvent = Extract; - -type ResponsesCompletedEvent = Extract; - -const chunk = (delta: ChatCompletionsStreamEvent['choices'][0]['delta'], finishReason: ChatCompletionsStreamEvent['choices'][0]['finish_reason'] = null): ChatCompletionsStreamEvent => ({ - id: 'chatcmpl_stream_test', - object: 'chat.completion.chunk', - created: 1, - model: 'gpt-test', - choices: [{ index: 0, delta, finish_reason: finishReason }], -}); - -const assertEveryAddedOutputItemIsDone = (events: ResponsesStreamEvent[]): void => { - const added = events - .filter((event): event is ResponsesOutputItemAddedEvent => event.type === 'response.output_item.added') - .map(event => event.output_index) - .sort((a, b) => a - b); - const done = events - .filter((event): event is ResponsesOutputItemDoneEvent => event.type === 'response.output_item.done') - .map(event => event.output_index) - .sort((a, b) => a - b); - - assertEquals(done, added); -}; - -test('translateChatCompletionsToResponses uses rs-prefixed ids for reasoning input items', () => { - const result = translateChatCompletionsToResponses({ +test('buildTargetRequest uses rs-prefixed ids for reasoning input items', () => { + const result = buildTargetRequest({ model: 'gpt-test', messages: [ { @@ -52,8 +24,8 @@ test('translateChatCompletionsToResponses uses rs-prefixed ids for reasoning inp expect(reasoning.id).toMatch(/^rs_[0-9a-f]{32}$/); }); -test('translateChatCompletionsToResponses preserves text-only scalar reasoning', () => { - const result = translateChatCompletionsToResponses({ +test('buildTargetRequest preserves text-only scalar reasoning', () => { + const result = buildTargetRequest({ model: 'gpt-test', messages: [ { @@ -72,8 +44,8 @@ test('translateChatCompletionsToResponses preserves text-only scalar reasoning', }); }); -test('translateChatCompletionsToResponses prefers reasoning_items over scalar reasoning', () => { - const result = translateChatCompletionsToResponses({ +test('buildTargetRequest prefers reasoning_items over scalar reasoning', () => { + const result = buildTargetRequest({ model: 'gpt-test', messages: [ { @@ -106,10 +78,10 @@ test('translateChatCompletionsToResponses prefers reasoning_items over scalar re ]); }); -test('translateChatCompletionsToResponses rejects tool messages without tool_call_id', () => { +test('buildTargetRequest rejects tool messages without tool_call_id', () => { assertThrows( () => - translateChatCompletionsToResponses({ + buildTargetRequest({ model: 'gpt-test', messages: [{ role: 'tool', content: 'result' }], }), @@ -118,8 +90,8 @@ test('translateChatCompletionsToResponses rejects tool messages without tool_cal ); }); -test('translateChatCompletionsToResponses preserves translated OpenAI request fields', () => { - const result = translateChatCompletionsToResponses({ +test('buildTargetRequest preserves translated OpenAI request fields', () => { + const result = buildTargetRequest({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], response_format: { type: 'json_schema', json_schema: { name: 'shape' } }, @@ -143,8 +115,8 @@ test('translateChatCompletionsToResponses preserves translated OpenAI request fi assertFalse('include' in result); }); -test('translateChatCompletionsToResponses never invents reasoning.context from reasoning_effort', () => { - const result = translateChatCompletionsToResponses({ +test('buildTargetRequest never invents reasoning.context from reasoning_effort', () => { + const result = buildTargetRequest({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], reasoning_effort: 'high', @@ -155,8 +127,8 @@ test('translateChatCompletionsToResponses never invents reasoning.context from r assertFalse('context' in (result.reasoning ?? {})); }); -test('translateChatCompletionsToResponses omits store when Chat omits store', () => { - const result = translateChatCompletionsToResponses({ +test('buildTargetRequest omits store when Chat omits store', () => { + const result = buildTargetRequest({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], }); @@ -164,8 +136,8 @@ test('translateChatCompletionsToResponses omits store when Chat omits store', () assertFalse('store' in result); }); -test('translateChatCompletionsToResponses preserves explicit null prompt cache and safety fields', () => { - const result = translateChatCompletionsToResponses({ +test('buildTargetRequest preserves explicit null prompt cache and safety fields', () => { + const result = buildTargetRequest({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], prompt_cache_key: null, @@ -178,8 +150,8 @@ test('translateChatCompletionsToResponses preserves explicit null prompt cache a assertEquals(result.safety_identifier, null); }); -test('translateChatCompletionsToResponses hoists only the initial contiguous system prefix', () => { - const result = translateChatCompletionsToResponses({ +test('buildTargetRequest hoists only the initial contiguous system prefix', () => { + const result = buildTargetRequest({ model: 'gpt-test', messages: [ { role: 'system', content: 'sys-1' }, @@ -204,8 +176,8 @@ test('translateChatCompletionsToResponses hoists only the initial contiguous sys ]); }); -test('translateChatCompletionsToResponses preserves explicit tool strict and defaults omission to false', () => { - const result = translateChatCompletionsToResponses({ +test('buildTargetRequest preserves explicit tool strict and defaults omission to false', () => { + const result = buildTargetRequest({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], tools: [ @@ -243,199 +215,10 @@ test('translateChatCompletionsToResponses preserves explicit tool strict and def ]); }); -test('translateChatCompletionsChunkToResponsesEvents keeps late opaque with prior scalar reasoning text', () => { - const state = createChatCompletionsToResponsesStreamState(); - const events = [ - ...translateChatCompletionsChunkToResponsesEvents(chunk({ role: 'assistant', reasoning_text: 'trace' }), state), - ...translateChatCompletionsChunkToResponsesEvents(chunk({ content: 'answer' }), state), - ...translateChatCompletionsChunkToResponsesEvents(chunk({ reasoning_opaque: 'sig' }), state), - ...translateChatCompletionsChunkToResponsesEvents(chunk({}, 'stop'), state), - ...flushChatCompletionsToResponsesEvents(state), - ]; - - const reasoningDoneEvents = events.filter(event => event.type === 'response.output_item.done' && (event as ResponsesOutputItemDoneEvent).item.type === 'reasoning') as ResponsesOutputItemDoneEvent[]; - - assertEquals(reasoningDoneEvents.length, 1); - assertEquals(reasoningDoneEvents[0].output_index, 0); - assertEquals(reasoningDoneEvents[0].item, { - type: 'reasoning', - id: expect.stringMatching(/^rs_[0-9a-f]{32}$/), - summary: [{ type: 'summary_text', text: 'trace' }], - }); -}); - -test('translateChatCompletionsChunkToResponsesEvents prefers reasoning_items over scalar reasoning in streaming composition', () => { - const state = createChatCompletionsToResponsesStreamState(); - const events = [ - ...translateChatCompletionsChunkToResponsesEvents(chunk({ role: 'assistant' }), state), - ...translateChatCompletionsChunkToResponsesEvents(chunk({ reasoning_text: 'trace' }), state), - ...translateChatCompletionsChunkToResponsesEvents(chunk({ content: 'answer' }), state), - ...translateChatCompletionsChunkToResponsesEvents( - chunk({ - reasoning_items: [ - { - type: 'reasoning', - id: 'rs_carrier', - summary: [{ type: 'summary_text', text: 'trace' }], - }, - ], - }), - state, - ), - ...translateChatCompletionsChunkToResponsesEvents(chunk({}, 'stop'), state), - ...flushChatCompletionsToResponsesEvents(state), - ]; - - const reasoningDoneEvents = events.filter(event => event.type === 'response.output_item.done' && (event as ResponsesOutputItemDoneEvent).item.type === 'reasoning') as ResponsesOutputItemDoneEvent[]; - const completed = events.find(event => event.type === 'response.completed') as ResponsesCompletedEvent | undefined; - - assertEveryAddedOutputItemIsDone(events); - assertEquals(reasoningDoneEvents.length, 1); - assertEquals(reasoningDoneEvents[0].item, { - type: 'reasoning', - id: 'rs_carrier', - summary: [{ type: 'summary_text', text: 'trace' }], - }); - assertEquals(completed?.response.output, [ - { - type: 'reasoning', - id: 'rs_carrier', - summary: [{ type: 'summary_text', text: 'trace' }], - }, - { - type: 'message', - id: expect.stringMatching(/^msg_[0-9a-f]{32}$/), - role: 'assistant', - content: [{ type: 'output_text', text: 'answer' }], - }, - ]); -}); - -test('translateChatCompletionsChunkToResponsesEvents keeps terminal output ordered by output_index', () => { - const state = createChatCompletionsToResponsesStreamState(); - const events = [ - ...translateChatCompletionsChunkToResponsesEvents(chunk({ role: 'assistant' }), state), - ...translateChatCompletionsChunkToResponsesEvents( - chunk({ - tool_calls: [ - { - index: 0, - id: 'call_1', - type: 'function', - function: { name: 'lookup', arguments: '{"q":"x"}' }, - }, - ], - }), - state, - ), - ...translateChatCompletionsChunkToResponsesEvents( - chunk({ - reasoning_items: [ - { - type: 'reasoning', - id: 'rs_after_tool', - summary: [{ type: 'summary_text', text: 'trace' }], - }, - ], - }), - state, - ), - ...translateChatCompletionsChunkToResponsesEvents(chunk({}, 'tool_calls'), state), - ...flushChatCompletionsToResponsesEvents(state), - ]; - - const added = events.filter(event => event.type === 'response.output_item.added') as ResponsesOutputItemAddedEvent[]; - const completed = events.find(event => event.type === 'response.completed') as ResponsesCompletedEvent | undefined; - - assertEquals( - added.map(event => [event.output_index, event.item.type]), - [ - [0, 'function_call'], - [1, 'reasoning'], - ], - ); - assertEquals( - completed?.response.output.map(item => item.type), - ['function_call', 'reasoning'], - ); -}); - -test('translateChatCompletionsChunkToResponsesEvents discards scalar reasoning when carrier arrives after opaque', () => { - const state = createChatCompletionsToResponsesStreamState(); - const events = [ - ...translateChatCompletionsChunkToResponsesEvents(chunk({ role: 'assistant' }), state), - ...translateChatCompletionsChunkToResponsesEvents(chunk({ reasoning_text: 'trace' }), state), - ...translateChatCompletionsChunkToResponsesEvents(chunk({ content: 'answer' }), state), - ...translateChatCompletionsChunkToResponsesEvents(chunk({ reasoning_opaque: 'sig' }), state), - ...translateChatCompletionsChunkToResponsesEvents( - chunk({ - reasoning_items: [ - { - type: 'reasoning', - id: 'rs_carrier', - summary: [{ type: 'summary_text', text: 'trace' }], - }, - ], - }), - state, - ), - ...translateChatCompletionsChunkToResponsesEvents(chunk({}, 'stop'), state), - ...flushChatCompletionsToResponsesEvents(state), - ]; - - const reasoningDoneEvents = events.filter(event => event.type === 'response.output_item.done' && (event as ResponsesOutputItemDoneEvent).item.type === 'reasoning') as ResponsesOutputItemDoneEvent[]; - const completed = events.find(event => event.type === 'response.completed') as ResponsesCompletedEvent | undefined; - - assertEveryAddedOutputItemIsDone(events); - assertEquals(reasoningDoneEvents.length, 1); - assertEquals(reasoningDoneEvents[0].item, { - type: 'reasoning', - id: 'rs_carrier', - summary: [{ type: 'summary_text', text: 'trace' }], - }); - assertEquals(completed?.response.output, [ - { - type: 'reasoning', - id: 'rs_carrier', - summary: [{ type: 'summary_text', text: 'trace' }], - }, - { - type: 'message', - id: expect.stringMatching(/^msg_[0-9a-f]{32}$/), - role: 'assistant', - content: [{ type: 'output_text', text: 'answer' }], - }, - ]); -}); - -test('translateChatCompletionsChunkToResponsesEvents ignores empty tool_calls arrays', () => { - const state = createChatCompletionsToResponsesStreamState(); - // Before the fix, empty tool_calls [] was truthy and entered the - // tool-calls branch, prematurely closing the text item. After the fix - // (choice.delta.tool_calls?.length), empty arrays are treated as absent. - const events1 = translateChatCompletionsChunkToResponsesEvents(chunk({ role: 'assistant', tool_calls: [] }), state); - // role + empty tool_calls should only emit response.created + response.in_progress. - // No tool-call events should be emitted. - assertEquals(events1.length, 2); - assertEquals(events1[0].type, 'response.created'); - assertEquals(events1[1].type, 'response.in_progress'); - - // Content delta should create a message item and emit text delta — not a new - // output item for empty tool_calls. - const events2 = translateChatCompletionsChunkToResponsesEvents(chunk({ content: 'hello' }), state); - const addedEvents = events2.filter(e => e.type === 'response.output_item.added') as ResponsesOutputItemAddedEvent[]; - assertEquals(addedEvents.length, 1, 'content delta should create one message output item'); - assertEquals(addedEvents[0].item.type, 'message'); - - const deltaEvents = events2.filter(e => e.type === 'response.output_text.delta'); - assertEquals(deltaEvents.length, 1); - assertEquals((deltaEvents[0] as { delta: string }).delta, 'hello'); -}); - -test('translateChatCompletionsToResponses rejects an unknown message role', () => { +test('buildTargetRequest rejects an unknown message role', () => { assertThrows( () => - translateChatCompletionsToResponses({ + buildTargetRequest({ model: 'gpt-test', messages: [{ role: 'function', content: 'hi' } as unknown as ChatCompletionsMessage], }), @@ -444,8 +227,8 @@ test('translateChatCompletionsToResponses rejects an unknown message role', () = ); }); -test('translateChatCompletionsToResponses forwards reasoning_effort and service_tier onto the native slots', () => { - const result = translateChatCompletionsToResponses({ +test('buildTargetRequest forwards reasoning_effort and service_tier onto the native slots', () => { + const result = buildTargetRequest({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }], reasoning_effort: 'medium', @@ -456,8 +239,8 @@ test('translateChatCompletionsToResponses forwards reasoning_effort and service_ assertEquals(result.service_tier, 'priority'); }); -test("translateChatCompletionsToResponses drops reasoning_effort='none' since Responses has no equivalent", () => { - const result = translateChatCompletionsToResponses({ +test("buildTargetRequest drops reasoning_effort='none' since Responses has no equivalent", () => { + const result = buildTargetRequest({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }], reasoning_effort: 'none', diff --git a/packages/translate/src/gemini-via-messages/events.ts b/packages/translate/src/gemini-via-messages/events.ts index 9ff8792f4d..ba1f24a34c 100644 --- a/packages/translate/src/gemini-via-messages/events.ts +++ b/packages/translate/src/gemini-via-messages/events.ts @@ -1,7 +1,8 @@ import { flushGeminiThoughtSignature, type GeminiThoughtSignatureState, geminiCandidateEvent, parseStrictJsonObject, setGeminiThoughtSignature, signGeminiPart } from '../shared/gemini-via/gemini.ts'; +import { inclusiveMessagesInputUsage } from '../shared/via-messages/usage.ts'; import { billableServiceTier, eventFrame, splitInclusiveInputTokens, USAGE_BILLING, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiFinishReason, GeminiStreamEvent, GeminiUsageMetadata } from '@floway-dev/protocols/gemini'; -import { mergeMessagesUsageSnapshot, messagesUsageSnapshot, splitMessagesCacheCreationTokens, type MessagesStreamEvent, type MessagesUsageSnapshot } from '@floway-dev/protocols/messages'; +import { mergeMessagesUsageSnapshot, messagesUsageSnapshot, type MessagesStreamEvent, type MessagesUsageSnapshot } from '@floway-dev/protocols/messages'; const messagesStopReasonToGemini = (stopReason: Extract['delta']['stop_reason']): GeminiFinishReason => { switch (stopReason) { @@ -45,15 +46,9 @@ interface MessagesToGeminiStreamState extends GeminiThoughtSignatureState { toolUses: Record; } -// Anthropic's input_tokens excludes cache reads and cache creation; Gemini's -// promptTokenCount is an inclusive total like OpenAI's prompt_tokens. Fold all -// three Anthropic buckets into the Gemini total, then surface cache reads -// separately as cachedContentTokenCount. const mapUsage = (state: MessagesToGeminiStreamState, hasTerminalUsage: boolean): GeminiUsageMetadata | undefined => { - const { cacheWrite, cacheWrite1h } = splitMessagesCacheCreationTokens(state.usage); + const { cacheRead, cacheWrite, cacheWrite1h, inclusiveInput: promptTokenCount } = inclusiveMessagesInputUsage(state.usage); const cacheWriteTotal = cacheWrite + cacheWrite1h; - const cacheRead = state.usage.cache_read_input_tokens ?? 0; - const promptTokenCount = (state.usage.input_tokens ?? 0) + cacheRead + cacheWriteTotal; const candidatesTokenCount = state.usage.output_tokens; splitInclusiveInputTokens(promptTokenCount, cacheRead, cacheWriteTotal); const serviceTier = billableServiceTier(state.usage.speed) ?? billableServiceTier(state.usage.service_tier); diff --git a/packages/translate/src/gemini-via-messages/request.ts b/packages/translate/src/gemini-via-messages/request.ts index 978a21fa9d..21a54847f8 100644 --- a/packages/translate/src/gemini-via-messages/request.ts +++ b/packages/translate/src/gemini-via-messages/request.ts @@ -146,33 +146,37 @@ const buildAssistantMessage = (content: GeminiContent, turnIndex: number, unmatc return blocks.length ? { role: 'assistant', content: blocks } : null; }; -const applyThinkingConfig = (request: MessagesPayload, thinkingConfig?: GeminiThinkingConfig): void => { - if (!thinkingConfig) return; - - if (thinkingConfig.thinkingBudget !== undefined) { - if (thinkingConfig.thinkingBudget === -1) { - request.thinking = { type: 'adaptive' }; - } else if (thinkingConfig.thinkingBudget > 0) { - request.thinking = { - type: 'enabled', - budget_tokens: thinkingConfig.thinkingBudget, - }; - } else if (thinkingConfig.thinkingBudget === 0) { - request.thinking = { type: 'disabled' }; - } +interface ThinkingConfigFields { + thinking?: NonNullable; + outputConfig: NonNullable; +} + +const applyThinkingConfig = (thinkingConfig?: GeminiThinkingConfig): ThinkingConfigFields => { + if (!thinkingConfig) return { outputConfig: {} }; + + let thinking: ThinkingConfigFields['thinking']; + if (thinkingConfig.thinkingBudget === -1) { + thinking = { type: 'adaptive' }; + } else if (thinkingConfig.thinkingBudget !== undefined && thinkingConfig.thinkingBudget > 0) { + thinking = { + type: 'enabled', + budget_tokens: thinkingConfig.thinkingBudget, + }; + } else if (thinkingConfig.thinkingBudget === 0) { + thinking = { type: 'disabled' }; } const effort = geminiThinkingLevelEffort(thinkingConfig); - // Spread to merge with any output_config fields a sibling helper has - // already written (e.g. structured-output `format` from - // applyGenerationConfig). - if (effort !== undefined) request.output_config = { ...request.output_config, effort }; + return { + ...(thinking !== undefined ? { thinking } : {}), + outputConfig: effort !== undefined ? { effort } : {}, + }; }; -const applyGenerationConfig = (request: MessagesPayload, generationConfig: GeminiGenerationConfig | undefined, fallbackMaxOutputTokens: number): void => { +const applyGenerationConfig = (request: MessagesPayload, generationConfig: GeminiGenerationConfig | undefined, fallbackMaxOutputTokens: number): NonNullable => { request.max_tokens = generationConfig?.maxOutputTokens ?? fallbackMaxOutputTokens; - if (!generationConfig) return; + if (!generationConfig) return {}; if (generationConfig.temperature !== undefined) { request.temperature = generationConfig.temperature; @@ -190,14 +194,9 @@ const applyGenerationConfig = (request: MessagesPayload, generationConfig: Gemin // as `output_config.format = { type: 'json_schema', schema }`. `responseMimeType: // application/json` without a schema has no Anthropic equivalent and is // dropped — the routing fallback degrades gracefully rather than fails. - if (generationConfig.responseSchema !== undefined) { - request.output_config = { - ...request.output_config, - format: { type: 'json_schema', schema: generationConfig.responseSchema as Record }, - }; - } - - applyThinkingConfig(request, generationConfig.thinkingConfig); + return generationConfig.responseSchema !== undefined + ? { format: { type: 'json_schema', schema: generationConfig.responseSchema as Record } } + : {}; }; const inputSchemaForDeclaration = (parameters: Record | undefined): Record => { @@ -226,8 +225,8 @@ export const buildTargetRequest = ( ): MessagesPayload => { // Gemini can omit maxOutputTokens, but MessagesPayload requires max_tokens. // Prefer the model's advertised `/models` cap when one is known; otherwise - // fall back to the gateway policy default shared with the other *-to-Messages - // translators. + // fall back to the gateway policy default shared with the other + // `*-via-messages` translators. const fallbackMaxOutputTokens = options.fallbackMaxOutputTokens ?? MESSAGES_FALLBACK_MAX_TOKENS; const request: MessagesPayload = { model, @@ -260,7 +259,19 @@ export const buildTargetRequest = ( if (message) request.messages.push(message); }); - applyGenerationConfig(request, payload.generationConfig, fallbackMaxOutputTokens); + const generationOutputConfig = applyGenerationConfig(request, payload.generationConfig, fallbackMaxOutputTokens); + const { thinking, outputConfig: thinkingOutputConfig } = applyThinkingConfig(payload.generationConfig?.thinkingConfig); + const outputConfig = { ...generationOutputConfig, ...thinkingOutputConfig }; + const hasGenerationOutputConfig = Object.keys(generationOutputConfig).length > 0; + const attachOutputConfig = (): void => { + request.output_config = outputConfig; + }; + + // Preserve request-key insertion order: a structured-output format precedes + // `thinking`, while an effort-only `output_config` follows it. + if (hasGenerationOutputConfig) attachOutputConfig(); + if (thinking !== undefined) request.thinking = thinking; + if (!hasGenerationOutputConfig && Object.keys(outputConfig).length > 0) attachOutputConfig(); const tools = buildTools(payload); if (tools) request.tools = tools; diff --git a/packages/translate/src/gemini-via-responses/events.ts b/packages/translate/src/gemini-via-responses/events.ts index c6fa7508d3..970fd490a5 100644 --- a/packages/translate/src/gemini-via-responses/events.ts +++ b/packages/translate/src/gemini-via-responses/events.ts @@ -1,9 +1,7 @@ import { geminiCandidateEvent, parseStrictJsonObject } from '../shared/gemini-via/gemini.ts'; import { billableServiceTier, eventFrame, splitCacheWriteTokens, splitInclusiveInputTokens, splitInclusiveOutputTokens, USAGE_BILLING, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiFinishReason, GeminiPart, GeminiStreamEvent, GeminiUsageMetadata } from '@floway-dev/protocols/gemini'; -import type { ResponsesOutputFunctionCall, ResponsesOutputReasoning, ResponsesResult, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; - -type ResponsesTerminalEvent = Extract; +import { isResponsesTerminalEvent, type ResponsesOutputFunctionCall, type ResponsesOutputReasoning, type ResponsesResult, type ResponsesStreamEvent } from '@floway-dev/protocols/responses'; // Responses input_tokens already includes input_tokens_details.cached_tokens, // matching Gemini's inclusive promptTokenCount semantics. Pass both through @@ -57,7 +55,7 @@ const isSafetyFailure = (response: ResponsesResult): boolean => { return text.includes('safety') || text.includes('content_filter') || text.includes('policy'); }; -const mapTerminalFinishReason = (event: ResponsesTerminalEvent): GeminiFinishReason => { +const mapTerminalFinishReason = (event: Extract): GeminiFinishReason => { if (event.type === 'response.completed') return 'STOP'; if (event.type === 'response.failed') { return isSafetyFailure(event.response) ? 'SAFETY' : 'OTHER'; @@ -73,7 +71,7 @@ const upstreamResponsesEventsUntilTerminal = async function* (frames: AsyncItera if (frame.type === 'done') continue; yield frame.event; - if (frame.event.type === 'response.completed' || frame.event.type === 'response.incomplete' || frame.event.type === 'response.failed' || frame.event.type === 'error') { + if (isResponsesTerminalEvent(frame.event)) { return; } } @@ -81,8 +79,6 @@ const upstreamResponsesEventsUntilTerminal = async function* (frames: AsyncItera throw new Error(UPSTREAM_RESPONSES_MISSING_TERMINAL_MESSAGE); }; -type ResponsesEvent = Extract; - interface ResponsesFunctionCallDraft { id?: string; name?: string; @@ -137,7 +133,7 @@ const functionCallDoneFrame = (item: ResponsesOutputFunctionCall, outputIndex: n ); }; -const handleTerminal = (event: ResponsesTerminalEvent, state: ResponsesToGeminiStreamState): ProtocolFrame => { +const handleTerminal = (event: Extract, state: ResponsesToGeminiStreamState): ProtocolFrame => { if (event.response.service_tier !== undefined) state.serviceTier = event.response.service_tier; return eventFrame(geminiCandidateEvent([], mapTerminalFinishReason(event), mapUsage(event.response, state.serviceTier))); }; @@ -152,14 +148,14 @@ export const translateToSourceEvents = async function* (frames: AsyncIterable).response; + const response = (event as Extract).response; if (response.service_tier !== undefined) state.serviceTier = response.service_tier; break; } case 'response.reasoning_summary_text.delta': case 'response.reasoning_summary_text.done': { - const textEvent = event as ResponsesEvent<'response.reasoning_summary_text.delta'> | ResponsesEvent<'response.reasoning_summary_text.done'>; + const textEvent = event as Extract | Extract; const text = textEvent.type === 'response.reasoning_summary_text.delta' ? textEvent.delta : textEvent.text; if (!text) break; @@ -173,7 +169,7 @@ export const translateToSourceEvents = async function* (frames: AsyncIterable | ResponsesEvent<'response.output_text.done'>; + const textEvent = event as Extract | Extract; const text = textEvent.type === 'response.output_text.delta' ? textEvent.delta : textEvent.text; if (!text) break; @@ -186,7 +182,7 @@ export const translateToSourceEvents = async function* (frames: AsyncIterable; + const addedEvent = event as Extract; if (addedEvent.item.type === 'function_call') { state.functionCalls.set(addedEvent.output_index, { id: addedEvent.item.call_id, @@ -198,21 +194,21 @@ export const translateToSourceEvents = async function* (frames: AsyncIterable; + const deltaEvent = event as Extract; const current = state.functionCalls.get(deltaEvent.output_index); if (current) current.argsJson += deltaEvent.delta; break; } case 'response.function_call_arguments.done': { - const doneEvent = event as ResponsesEvent<'response.function_call_arguments.done'>; + const doneEvent = event as Extract; const current = state.functionCalls.get(doneEvent.output_index); if (current) current.argsJson = doneEvent.arguments; break; } case 'response.output_item.done': { - const doneEvent = event as ResponsesEvent<'response.output_item.done'>; + const doneEvent = event as Extract; if (doneEvent.item.type === 'reasoning') { yield* reasoningItemDoneFrames(doneEvent.item, doneEvent.output_index, state); } else if (doneEvent.item.type === 'function_call') { @@ -224,11 +220,11 @@ export const translateToSourceEvents = async function* (frames: AsyncIterable, state); break; case 'error': { - const errorEvent = event as ResponsesEvent<'error'>; + const errorEvent = event as Extract; throw new Error(`Upstream Responses stream error: ${errorEvent.message}`, { cause: errorEvent }); } diff --git a/packages/translate/src/gemini-via-responses/request.ts b/packages/translate/src/gemini-via-responses/request.ts index d52cccd01b..d7d24c4558 100644 --- a/packages/translate/src/gemini-via-responses/request.ts +++ b/packages/translate/src/gemini-via-responses/request.ts @@ -7,7 +7,6 @@ import { geminiPartKind, geminiPartText, geminiReasoningEffort, - geminiReasoningId, geminiText, geminiThoughtText, type GeminiToolCallIds, @@ -17,6 +16,8 @@ import { TranslatorInputError } from '../translator-input-error.ts'; import type { GeminiContent, GeminiPayload, GeminiGenerationConfig, GeminiPart } from '@floway-dev/protocols/gemini'; import type { CanonicalResponsesPayload, ResponsesInputContent, ResponsesInputItem, ResponsesTool } from '@floway-dev/protocols/responses'; +const geminiReasoningId = (turnIndex: number, partIndex: number): string => `gemini_reasoning_${turnIndex}_${partIndex}`; + const flushPendingContent = (input: ResponsesInputItem[], pending: ResponsesInputContent[], role: 'user' | 'assistant'): void => { if (pending.length === 0) return; input.push({ type: 'message', role, content: [...pending] }); diff --git a/packages/translate/src/index.ts b/packages/translate/src/index.ts index 78dce4fa6c..bc0b2aee6c 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -9,6 +9,5 @@ export { translateGeminiViaResponses } from './gemini-via-responses/translate.ts export { translateGeminiViaChatCompletions } from './gemini-via-chat-completions/translate.ts'; export { canonicalizeResponsesPayload } from './canonicalize-responses-payload.ts'; -export type { TranslatedApiError, TranslationContext } from './types.ts'; -export type { RemoteImageData, RemoteImageLoader } from './shared/via-messages/remote-images.ts'; +export type { RemoteImageData, RemoteImageLoader, TranslatedApiError, TranslationContext } from './types.ts'; export { TranslatorInputError } from './translator-input-error.ts'; diff --git a/packages/translate/src/messages-via-chat-completions/request.ts b/packages/translate/src/messages-via-chat-completions/request.ts index 966301532d..ce14f3378d 100644 --- a/packages/translate/src/messages-via-chat-completions/request.ts +++ b/packages/translate/src/messages-via-chat-completions/request.ts @@ -1,7 +1,9 @@ import { type ChatCompletionsScalarReasoning, chatCompletionsScalarReasoningFromMessagesBlock } from '../shared/chat-completions-and-messages/reasoning.ts'; import { filterMessagesClientTools } from '../shared/messages-via/client-tools.ts'; import { resolveMessagesReasoningEffort } from '../shared/messages-via/reasoning-effort.ts'; +import { openAIServiceTierFromMessages } from '../shared/messages-via/service-tier.ts'; import { openAiJsonSchemaCoreFromMessagesFormat } from '../shared/messages-via/structured-output.ts'; +import { flattenMessagesToolResult } from '../shared/messages-via/tool-result.ts'; import { normalizeMessagesToolInputSchema } from '../shared/messages-via/tool-schema.ts'; import { TranslatorInputError } from '../translator-input-error.ts'; import type { ChatCompletionsPayload, ChatCompletionsContentPart, ChatCompletionsMessage, ChatCompletionsTool, ChatCompletionsToolCall } from '@floway-dev/protocols/chat-completions'; @@ -51,19 +53,6 @@ const toChatCompletionsContent = (content: string | MessagesUserContentBlock[] | return parts; }; -const toChatCompletionsToolResultContent = (content: MessagesToolResultBlock['content']): string => { - if (typeof content === 'string') { - return content; - } - - const textBlocks = content.filter((block): block is MessagesTextBlock => block.type === 'text'); - if (textBlocks.length === content.length) { - return textBlocks.map(block => block.text).join('\n\n'); - } - - return JSON.stringify(content); -}; - const toChatCompletionsFunctionCall = (block: MessagesToolUseBlock | MessagesServerToolUseBlock): ChatCompletionsToolCall => ({ id: block.id, type: 'function', @@ -141,7 +130,7 @@ const translateMessagesUser = (message: MessagesUserMessage, messageIdx: number) messages.push({ role: 'tool', tool_call_id: block.tool_use_id, - content: toChatCompletionsToolResultContent(block.content), + content: flattenMessagesToolResult(block.content), }); continue; } @@ -273,7 +262,7 @@ const translateMessagesToolChoice = (toolChoice?: MessagesPayload['tool_choice'] } }; -export const translateMessagesToChatCompletions = (payload: MessagesPayload): ChatCompletionsPayload => { +export const buildTargetRequest = (payload: MessagesPayload): ChatCompletionsPayload => { const clientTools = filterMessagesClientTools(payload.tools); // Pass effort through verbatim; per-upstream enum acceptance (e.g. some // backends rejecting `xhigh`/`max`) is the target interceptor's concern. @@ -281,11 +270,7 @@ export const translateMessagesToChatCompletions = (payload: MessagesPayload): Ch const jsonSchema = openAiJsonSchemaCoreFromMessagesFormat(payload.output_config?.format); const responseFormat = jsonSchema ? { type: 'json_schema' as const, json_schema: jsonSchema } : undefined; - // `speed: 'fast'` maps to Chat Completions `service_tier: 'fast'`; other - // non-fast `speed` values have no OpenAI equivalent and are dropped. When - // `speed` is absent, Anthropic's own `service_tier` ('auto'/'standard_only') - // is passed through verbatim for symmetry with the forward direction. - const serviceTier = payload.speed === 'fast' ? 'fast' : payload.speed === undefined ? payload.service_tier : undefined; + const serviceTier = openAIServiceTierFromMessages(payload); return { model: payload.model, @@ -302,5 +287,3 @@ export const translateMessagesToChatCompletions = (payload: MessagesPayload): Ch ...(serviceTier !== undefined ? { service_tier: serviceTier } : {}), }; }; - -export const buildTargetRequest = translateMessagesToChatCompletions; diff --git a/packages/translate/src/messages-via-chat-completions/request_test.ts b/packages/translate/src/messages-via-chat-completions/request_test.ts index 96445d064f..8e2fd38674 100644 --- a/packages/translate/src/messages-via-chat-completions/request_test.ts +++ b/packages/translate/src/messages-via-chat-completions/request_test.ts @@ -1,11 +1,11 @@ import { test } from 'vitest'; -import { translateMessagesToChatCompletions } from './request.ts'; +import { buildTargetRequest } from './request.ts'; import type { MessagesAssistantContentBlock, MessagesUserContentBlock } from '@floway-dev/protocols/messages'; import { assertEquals, assertFalse, assertThrows } from '@floway-dev/test-utils'; -test('translateMessagesToChatCompletions maps thinking.disabled to reasoning_effort none', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest maps thinking.disabled to reasoning_effort none', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, thinking: { type: 'disabled' }, @@ -15,8 +15,8 @@ test('translateMessagesToChatCompletions maps thinking.disabled to reasoning_eff assertEquals(result.reasoning_effort, 'none'); }); -test('translateMessagesToChatCompletions prefers output_config.effort over thinking.disabled', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest prefers output_config.effort over thinking.disabled', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, output_config: { effort: 'high' }, @@ -27,8 +27,8 @@ test('translateMessagesToChatCompletions prefers output_config.effort over think assertEquals(result.reasoning_effort, 'high'); }); -test('translateMessagesToChatCompletions treats empty output_config.effort as absent', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest treats empty output_config.effort as absent', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, output_config: { effort: '' }, @@ -39,9 +39,9 @@ test('translateMessagesToChatCompletions treats empty output_config.effort as ab assertEquals(result.reasoning_effort, 'none'); }); -test('translateMessagesToChatCompletions maps thinking.enabled to reasoning_effort medium regardless of budget_tokens', () => { +test('buildTargetRequest maps thinking.enabled to reasoning_effort medium regardless of budget_tokens', () => { for (const budget of [undefined, 1024, 16384]) { - const result = translateMessagesToChatCompletions({ + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 4096, thinking: budget === undefined ? { type: 'enabled' } : { type: 'enabled', budget_tokens: budget }, @@ -52,8 +52,8 @@ test('translateMessagesToChatCompletions maps thinking.enabled to reasoning_effo } }); -test('translateMessagesToChatCompletions maps thinking.adaptive to reasoning_effort medium', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest maps thinking.adaptive to reasoning_effort medium', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 4096, thinking: { type: 'adaptive' }, @@ -63,8 +63,8 @@ test('translateMessagesToChatCompletions maps thinking.adaptive to reasoning_eff assertEquals(result.reasoning_effort, 'medium'); }); -test('translateMessagesToChatCompletions prefers output_config.effort over thinking.enabled', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest prefers output_config.effort over thinking.enabled', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 4096, output_config: { effort: 'high' }, @@ -75,8 +75,8 @@ test('translateMessagesToChatCompletions prefers output_config.effort over think assertEquals(result.reasoning_effort, 'high'); }); -test('translateMessagesToChatCompletions keeps tool_result and user text as separate chat messages', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest keeps tool_result and user text as separate chat messages', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -96,8 +96,8 @@ test('translateMessagesToChatCompletions keeps tool_result and user text as sepa ]); }); -test('translateMessagesToChatCompletions drops filtered-native tool_choice and rewrites assistant native web-search history as tool-call history', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest drops filtered-native tool_choice and rewrites assistant native web-search history as tool-call history', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, tool_choice: { type: 'any' }, @@ -154,8 +154,8 @@ test('translateMessagesToChatCompletions drops filtered-native tool_choice and r ]); }); -test('translateMessagesToChatCompletions flattens text-block tool_result content but serializes search-result arrays', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest flattens text-block tool_result content but serializes search-result arrays', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -194,8 +194,8 @@ test('translateMessagesToChatCompletions flattens text-block tool_result content ]); }); -test('translateMessagesToChatCompletions preserves mixed user/tool_result chronology', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest preserves mixed user/tool_result chronology', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -219,8 +219,8 @@ test('translateMessagesToChatCompletions preserves mixed user/tool_result chrono ]); }); -test('translateMessagesToChatCompletions preserves redacted_thinking as reasoning_opaque', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest preserves redacted_thinking as reasoning_opaque', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -241,8 +241,8 @@ test('translateMessagesToChatCompletions preserves redacted_thinking as reasonin ]); }); -test('translateMessagesToChatCompletions projects only the first scalar reasoning group', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest projects only the first scalar reasoning group', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -265,8 +265,8 @@ test('translateMessagesToChatCompletions projects only the first scalar reasonin }); }); -test('translateMessagesToChatCompletions does not pair readable thinking with later redacted opaque data', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest does not pair readable thinking with later redacted opaque data', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -294,8 +294,8 @@ test('translateMessagesToChatCompletions does not pair readable thinking with la // packages/translate/src/chat-completions-via-messages/request.ts already // defaults `parameters` to {type: 'object', properties: {}}. Ref: // https://github.com/caozhiyuan/copilot-api/commit/ad57069826843c5d17d7b0e5ef2f75050128893c -test('translateMessagesToChatCompletions defaults missing input_schema.properties to {} for object tools', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest defaults missing input_schema.properties to {} for object tools', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, tools: [{ name: 'no_args', input_schema: { type: 'object' } }], @@ -314,8 +314,8 @@ test('translateMessagesToChatCompletions defaults missing input_schema.propertie ]); }); -test('translateMessagesToChatCompletions preserves declared input_schema.properties verbatim', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest preserves declared input_schema.properties verbatim', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, tools: [ @@ -338,8 +338,8 @@ test('translateMessagesToChatCompletions preserves declared input_schema.propert }); }); -test('translateMessagesToChatCompletions does not inject properties for non-object input_schema', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest does not inject properties for non-object input_schema', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, // Non-object root schemas are unusual but legal upstream; we should not @@ -351,14 +351,14 @@ test('translateMessagesToChatCompletions does not inject properties for non-obje assertEquals(result.tools?.[0].function.parameters, { type: 'string' }); }); -test('translateMessagesToChatCompletions wraps output_config.format json_schema as response_format with nested json_schema and strict', () => { +test('buildTargetRequest wraps output_config.format json_schema as response_format with nested json_schema and strict', () => { const schema = { type: 'object', properties: { test: { type: 'string' } }, required: ['test'], additionalProperties: false, }; - const result = translateMessagesToChatCompletions({ + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: 'Hi' }], @@ -371,8 +371,8 @@ test('translateMessagesToChatCompletions wraps output_config.format json_schema }); }); -test('translateMessagesToChatCompletions omits response_format when output_config has no format', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest omits response_format when output_config has no format', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: 'Hi' }], @@ -382,10 +382,10 @@ test('translateMessagesToChatCompletions omits response_format when output_confi assertFalse('response_format' in result); }); -test('translateMessagesToChatCompletions rejects an unknown assistant content block type', () => { +test('buildTargetRequest rejects an unknown assistant content block type', () => { assertThrows( () => - translateMessagesToChatCompletions({ + buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'assistant', content: [{ type: 'audio' } as unknown as MessagesAssistantContentBlock] }], @@ -395,10 +395,10 @@ test('translateMessagesToChatCompletions rejects an unknown assistant content bl ); }); -test('translateMessagesToChatCompletions rejects an unknown user content block type', () => { +test('buildTargetRequest rejects an unknown user content block type', () => { assertThrows( () => - translateMessagesToChatCompletions({ + buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: [{ type: 'audio' } as unknown as MessagesUserContentBlock] }], @@ -408,8 +408,8 @@ test('translateMessagesToChatCompletions rejects an unknown user content block t ); }); -test('translateMessagesToChatCompletions emits in-array role:"system" inline as a CC system message', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest emits in-array role:"system" inline as a CC system message', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -425,8 +425,8 @@ test('translateMessagesToChatCompletions emits in-array role:"system" inline as assertEquals(result.messages[2].role, 'user'); }); -test('translateMessagesToChatCompletions preserves in-array system text blocks as separate content parts', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest preserves in-array system text blocks as separate content parts', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -450,8 +450,8 @@ test('translateMessagesToChatCompletions preserves in-array system text blocks a }); }); -test('translateMessagesToChatCompletions preserves top-level system text blocks as separate content parts', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest preserves top-level system text blocks as separate content parts', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, system: [ @@ -472,8 +472,8 @@ test('translateMessagesToChatCompletions preserves top-level system text blocks }); }); -test('translateMessagesToChatCompletions skips system message when top-level system is empty array', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest skips system message when top-level system is empty array', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, system: [], @@ -484,8 +484,8 @@ test('translateMessagesToChatCompletions skips system message when top-level sys assertEquals(result.messages[0].role, 'user'); }); -test('translateMessagesToChatCompletions preserves chronology of multiple in-array system messages', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest preserves chronology of multiple in-array system messages', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, system: 'top-level prompt', @@ -508,10 +508,10 @@ test('translateMessagesToChatCompletions preserves chronology of multiple in-arr assertEquals(result.messages[5].role, 'user'); }); -test('translateMessagesToChatCompletions rejects an unknown message role', () => { +test('buildTargetRequest rejects an unknown message role', () => { assertThrows( () => - translateMessagesToChatCompletions({ + buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'tool', content: 'oops' } as unknown as { role: 'user'; content: string }], @@ -521,8 +521,8 @@ test('translateMessagesToChatCompletions rejects an unknown message role', () => ); }); -test('translateMessagesToChatCompletions drops Anthropic-only knobs that have no Chat-completions slot', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest drops Anthropic-only knobs that have no Chat-completions slot', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: 'hi' }], @@ -538,8 +538,8 @@ test('translateMessagesToChatCompletions drops Anthropic-only knobs that have no // ── speed ↔ service_tier bridge ── -test('translateMessagesToChatCompletions maps speed:fast to service_tier:fast on the outbound Chat Completions payload', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest maps speed:fast to service_tier:fast on the outbound Chat Completions payload', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, speed: 'fast', @@ -549,8 +549,8 @@ test('translateMessagesToChatCompletions maps speed:fast to service_tier:fast on assertEquals(result.service_tier, 'fast'); }); -test('translateMessagesToChatCompletions omits service_tier when speed is absent', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest omits service_tier when speed is absent', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: 'hi' }], @@ -559,8 +559,8 @@ test('translateMessagesToChatCompletions omits service_tier when speed is absent assertFalse('service_tier' in result); }); -test('translateMessagesToChatCompletions drops speed values other than fast without emitting service_tier', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest drops speed values other than fast without emitting service_tier', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, speed: 'standard', @@ -570,8 +570,8 @@ test('translateMessagesToChatCompletions drops speed values other than fast with assertFalse('service_tier' in result); }); -test('translateMessagesToChatCompletions forwards Anthropic service_tier to Chat Completions when speed is absent', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest forwards Anthropic service_tier to Chat Completions when speed is absent', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, service_tier: 'auto', @@ -581,8 +581,8 @@ test('translateMessagesToChatCompletions forwards Anthropic service_tier to Chat assertEquals(result.service_tier, 'auto'); }); -test('translateMessagesToChatCompletions forwards service_tier:standard_only to Chat Completions when speed is absent', () => { - const result = translateMessagesToChatCompletions({ +test('buildTargetRequest forwards service_tier:standard_only to Chat Completions when speed is absent', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, service_tier: 'standard_only', diff --git a/packages/translate/src/messages-via-responses/events.ts b/packages/translate/src/messages-via-responses/events.ts index 7af69248e7..6961aa414b 100644 --- a/packages/translate/src/messages-via-responses/events.ts +++ b/packages/translate/src/messages-via-responses/events.ts @@ -1,10 +1,10 @@ import { packReasoningSignature } from '../shared/messages-and-responses/reasoning.ts'; import { isContextExceededError } from '../shared/messages-via/context-window-error.ts'; import { createResponsesOutputOrderState, recordResponsesOutputOrderEvent, type ResponsesOutputOrderState, shouldDeferForEarlierResponsesOutput } from '../shared/via-responses/responses-stream-order.ts'; -import { type ResponsesEvent, responsesPartKey } from '../shared/via-responses/responses-stream.ts'; +import { responsesPartKey } from '../shared/via-responses/responses-stream.ts'; import { eventFrame, splitCacheWriteTokens, splitInclusiveInputTokens, USAGE_BILLING, type ProtocolFrame } from '@floway-dev/protocols/common'; import { PROMPT_TOO_LONG_MESSAGE, type MessagesResult, type MessagesStreamEvent, type MessagesUsage } from '@floway-dev/protocols/messages'; -import type { ResponsesResult, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; +import { isResponsesTerminalEvent, type ResponsesResult, type ResponsesStreamEvent } from '@floway-dev/protocols/responses'; const mapResponsesStopReason = (response: ResponsesResult): MessagesResult['stop_reason'] => { if (response.status === 'completed') { @@ -50,7 +50,7 @@ const upstreamResponsesEventsUntilTerminal = async function* (frames: AsyncItera if (frame.type === 'done') continue; yield frame.event; - if (frame.event.type === 'response.completed' || frame.event.type === 'response.incomplete' || frame.event.type === 'response.failed' || frame.event.type === 'error') { + if (isResponsesTerminalEvent(frame.event)) { return; } } @@ -147,7 +147,7 @@ const handleResponseCreated = (response: ResponsesResult): MessagesStreamEvent[] }, ]; -const handleOutputItemAdded = (event: ResponsesEvent<'response.output_item.added'>, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { +const handleOutputItemAdded = (event: Extract, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { if (event.item.type !== 'function_call') return []; const blockIndex = state.nextBlockIndex++; @@ -181,7 +181,7 @@ const handleOutputItemAdded = (event: ResponsesEvent<'response.output_item.added return events; }; -const handleOutputItemDone = (event: ResponsesEvent<'response.output_item.done'>, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { +const handleOutputItemDone = (event: Extract, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { if (event.item.type !== 'reasoning') return []; const hasEmittedSummary = hasResponsePartForOutput(state.emittedReasoningSummaryKeys, event.output_index); @@ -234,7 +234,7 @@ const handleOutputItemDone = (event: ResponsesEvent<'response.output_item.done'> return events; }; -const handleThinkingDelta = (event: ResponsesEvent<'response.reasoning_summary_text.delta'>, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { +const handleThinkingDelta = (event: Extract, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { const events: MessagesStreamEvent[] = []; const blockIndex = openThinkingBlock(state, event.output_index, events); events.push({ @@ -246,7 +246,7 @@ const handleThinkingDelta = (event: ResponsesEvent<'response.reasoning_summary_t return events; }; -const handleThinkingDone = (event: ResponsesEvent<'response.reasoning_summary_text.done'>, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { +const handleThinkingDone = (event: Extract, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { const events: MessagesStreamEvent[] = []; const blockIndex = openThinkingBlock(state, event.output_index, events); const key = responsesPartKey(event.output_index, event.summary_index); @@ -263,7 +263,7 @@ const handleThinkingDone = (event: ResponsesEvent<'response.reasoning_summary_te return events; }; -const handleTextDelta = (event: ResponsesEvent<'response.output_text.delta'>, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { +const handleTextDelta = (event: Extract, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { if (!event.delta) return []; const events: MessagesStreamEvent[] = []; @@ -277,7 +277,7 @@ const handleTextDelta = (event: ResponsesEvent<'response.output_text.delta'>, st return events; }; -const handleTextDone = (event: ResponsesEvent<'response.output_text.done'>, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { +const handleTextDone = (event: Extract, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { const events: MessagesStreamEvent[] = []; const blockIndex = openTextBlock(state, event.output_index, event.content_index, events); @@ -294,7 +294,7 @@ const handleTextDone = (event: ResponsesEvent<'response.output_text.done'>, stat return events; }; -const handleContentPartDone = (event: ResponsesEvent<'response.content_part.done'>, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { +const handleContentPartDone = (event: Extract, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { if (event.part.type !== 'refusal') return []; const key = responsesPartKey(event.output_index, event.content_index); @@ -311,7 +311,7 @@ const handleContentPartDone = (event: ResponsesEvent<'response.content_part.done return events; }; -const handleFunctionArgumentsDelta = (event: ResponsesEvent<'response.function_call_arguments.delta'>, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { +const handleFunctionArgumentsDelta = (event: Extract, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { if (!event.delta) return []; const functionCallState = state.functionCallState.get(event.output_index); @@ -328,7 +328,7 @@ const handleFunctionArgumentsDelta = (event: ResponsesEvent<'response.function_c ]; }; -const handleFunctionArgumentsDone = (event: ResponsesEvent<'response.function_call_arguments.done'>, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { +const handleFunctionArgumentsDone = (event: Extract, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => { const functionCallState = state.functionCallState.get(event.output_index); if (!functionCallState) return []; @@ -396,7 +396,7 @@ const handleStreamError = ( const handleFailed = (response: ResponsesResult, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => handleStreamError(state, response.error ?? undefined, 'Response failed due to unknown error.'); -const handleError = (event: ResponsesEvent<'error'>, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => +const handleError = (event: Extract, state: ResponsesToMessagesStreamState): MessagesStreamEvent[] => handleStreamError(state, { code: event.code, message: event.message }, 'An unexpected error occurred during streaming.'); export const createResponsesToMessagesStreamState = (): ResponsesToMessagesStreamState => ({ @@ -417,32 +417,32 @@ const translateReadyResponsesEvent = (event: ResponsesStreamEvent, state: Respon switch (event.type) { case 'response.created': - return handleResponseCreated((event as ResponsesEvent<'response.created'>).response); + return handleResponseCreated((event as Extract).response); case 'response.output_item.added': - return handleOutputItemAdded(event as ResponsesEvent<'response.output_item.added'>, state); + return handleOutputItemAdded(event as Extract, state); case 'response.output_item.done': - return handleOutputItemDone(event as ResponsesEvent<'response.output_item.done'>, state); + return handleOutputItemDone(event as Extract, state); case 'response.reasoning_summary_text.delta': - return handleThinkingDelta(event as ResponsesEvent<'response.reasoning_summary_text.delta'>, state); + return handleThinkingDelta(event as Extract, state); case 'response.reasoning_summary_text.done': - return handleThinkingDone(event as ResponsesEvent<'response.reasoning_summary_text.done'>, state); + return handleThinkingDone(event as Extract, state); case 'response.output_text.delta': - return handleTextDelta(event as ResponsesEvent<'response.output_text.delta'>, state); + return handleTextDelta(event as Extract, state); case 'response.output_text.done': - return handleTextDone(event as ResponsesEvent<'response.output_text.done'>, state); + return handleTextDone(event as Extract, state); case 'response.content_part.done': - return handleContentPartDone(event as ResponsesEvent<'response.content_part.done'>, state); + return handleContentPartDone(event as Extract, state); case 'response.function_call_arguments.delta': - return handleFunctionArgumentsDelta(event as ResponsesEvent<'response.function_call_arguments.delta'>, state); + return handleFunctionArgumentsDelta(event as Extract, state); case 'response.function_call_arguments.done': - return handleFunctionArgumentsDone(event as ResponsesEvent<'response.function_call_arguments.done'>, state); + return handleFunctionArgumentsDone(event as Extract, state); case 'response.completed': case 'response.incomplete': - return handleCompleted((event as ResponsesEvent<'response.completed' | 'response.incomplete'>).response, state); + return handleCompleted((event as Extract).response, state); case 'response.failed': - return handleFailed((event as ResponsesEvent<'response.failed'>).response, state); + return handleFailed((event as Extract).response, state); case 'error': - return handleError(event as ResponsesEvent<'error'>, state); + return handleError(event as Extract, state); case 'ping': return [{ type: 'ping' }]; default: diff --git a/packages/translate/src/messages-via-responses/request.ts b/packages/translate/src/messages-via-responses/request.ts index 3b747dfc1a..f23906d7a9 100644 --- a/packages/translate/src/messages-via-responses/request.ts +++ b/packages/translate/src/messages-via-responses/request.ts @@ -1,7 +1,9 @@ import { messagesReasoningBlockToResponsesReasoning } from '../shared/messages-and-responses/reasoning.ts'; import { filterMessagesClientTools } from '../shared/messages-via/client-tools.ts'; import { resolveMessagesReasoningEffort } from '../shared/messages-via/reasoning-effort.ts'; +import { openAIServiceTierFromMessages } from '../shared/messages-via/service-tier.ts'; import { openAiJsonSchemaCoreFromMessagesFormat } from '../shared/messages-via/structured-output.ts'; +import { flattenMessagesToolResult } from '../shared/messages-via/tool-result.ts'; import { normalizeMessagesToolInputSchema } from '../shared/messages-via/tool-schema.ts'; import { TranslatorInputError } from '../translator-input-error.ts'; import { @@ -43,19 +45,6 @@ const translateUserContentBlock = ( throw new TranslatorInputError(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' user content blocks are not supported on this model`); }; -const toResponsesToolResultOutput = (content: MessagesToolResultBlock['content']): string => { - if (typeof content === 'string') { - return content; - } - - const textBlocks = content.filter((block): block is MessagesTextBlock => block.type === 'text'); - if (textBlocks.length === content.length) { - return textBlocks.map(block => block.text).join('\n\n'); - } - - return JSON.stringify(content); -}; - const toResponsesFunctionCall = (block: MessagesToolUseBlock | MessagesServerToolUseBlock): ResponsesInputItem => ({ type: 'function_call', call_id: block.id, @@ -88,7 +77,7 @@ const translateUserMessage = (message: MessagesUserMessage, messageIdx: number): input.push({ type: 'function_call_output', call_id: block.tool_use_id, - output: toResponsesToolResultOutput(block.content), + output: flattenMessagesToolResult(block.content), status: block.is_error ? 'incomplete' : 'completed', }); continue; @@ -223,7 +212,7 @@ const translateToolChoice = (toolChoice: MessagesPayload['tool_choice'], tools?: } }; -export const translateMessagesToResponses = (payload: MessagesPayload): CanonicalResponsesPayload => { +export const buildTargetRequest = (payload: MessagesPayload): CanonicalResponsesPayload => { // Preserve the source `output_config.effort` value as-is, even if the chosen // Responses upstream may reject it. Translation stays pairwise and leaves // target-side validation to the selected upstream endpoint. @@ -234,11 +223,7 @@ export const translateMessagesToResponses = (payload: MessagesPayload): Canonica const jsonSchema = openAiJsonSchemaCoreFromMessagesFormat(payload.output_config?.format); const text = jsonSchema ? { format: { type: 'json_schema' as const, ...jsonSchema } } : undefined; - // `speed: 'fast'` maps to Responses `service_tier: 'fast'`; other non-fast - // `speed` values have no OpenAI equivalent and are dropped. When `speed` is - // absent, Anthropic's own `service_tier` ('auto'/'standard_only') is passed - // through verbatim for symmetry with the forward direction. - const serviceTier = payload.speed === 'fast' ? 'fast' : payload.speed === undefined ? payload.service_tier : undefined; + const serviceTier = openAIServiceTierFromMessages(payload); // Keep fallback semantics strict: do not synthesize `temperature: 1`, // `store: false`, `parallel_tool_calls: true`, `reasoning.summary`, or @@ -261,5 +246,3 @@ export const translateMessagesToResponses = (payload: MessagesPayload): Canonica ...(serviceTier !== undefined ? { service_tier: serviceTier } : {}), }; }; - -export { translateMessagesToResponses as buildTargetRequest }; diff --git a/packages/translate/src/messages-via-responses/request_test.ts b/packages/translate/src/messages-via-responses/request_test.ts index 1a8f759b7c..3d14b30552 100644 --- a/packages/translate/src/messages-via-responses/request_test.ts +++ b/packages/translate/src/messages-via-responses/request_test.ts @@ -1,13 +1,13 @@ import { expect, test } from 'vitest'; -import { translateMessagesToResponses } from './request.ts'; +import { buildTargetRequest } from './request.ts'; import { packReasoningSignature } from '../shared/messages-and-responses/reasoning.ts'; import type { MessagesAssistantContentBlock, MessagesUserContentBlock } from '@floway-dev/protocols/messages'; import type { ResponsesFunctionTool, ResponsesInputReasoning } from '@floway-dev/protocols/responses'; import { assertEquals, assertFalse, assertThrows } from '@floway-dev/test-utils'; -test('translateMessagesToResponses preserves a native thinking signature as encrypted_content with a synthesized id', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest preserves a native thinking signature as encrypted_content with a synthesized id', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -28,8 +28,8 @@ test('translateMessagesToResponses preserves a native thinking signature as encr }); }); -test('translateMessagesToResponses recovers Responses ids and encrypted_content from packed thinking signatures', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest recovers Responses ids and encrypted_content from packed thinking signatures', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -56,8 +56,8 @@ test('translateMessagesToResponses recovers Responses ids and encrypted_content }); }); -test('translateMessagesToResponses recovers an empty-front packed signature as id-only reasoning', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest recovers an empty-front packed signature as id-only reasoning', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -78,8 +78,8 @@ test('translateMessagesToResponses recovers an empty-front packed signature as i }); }); -test('translateMessagesToResponses drops filtered-native tool_choice and rewrites assistant native web-search history as function-call history', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest drops filtered-native tool_choice and rewrites assistant native web-search history as function-call history', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, tool_choice: { type: 'any' }, @@ -130,8 +130,8 @@ test('translateMessagesToResponses drops filtered-native tool_choice and rewrite ]); }); -test('translateMessagesToResponses maps output_config.effort directly to reasoning.effort', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest maps output_config.effort directly to reasoning.effort', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, output_config: { effort: 'xhigh' }, @@ -142,8 +142,8 @@ test('translateMessagesToResponses maps output_config.effort directly to reasoni assertFalse('include' in result); }); -test('translateMessagesToResponses prefers output_config.effort over thinking.disabled', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest prefers output_config.effort over thinking.disabled', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, output_config: { effort: 'high' }, @@ -154,8 +154,8 @@ test('translateMessagesToResponses prefers output_config.effort over thinking.di assertEquals(result.reasoning, { effort: 'high' }); }); -test('translateMessagesToResponses preserves output_config.effort max at the translation boundary', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest preserves output_config.effort max at the translation boundary', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, output_config: { effort: 'max' }, @@ -165,9 +165,9 @@ test('translateMessagesToResponses preserves output_config.effort max at the tra assertEquals(result.reasoning, { effort: 'max' }); }); -test('translateMessagesToResponses maps thinking.enabled to reasoning.effort medium regardless of budget_tokens', () => { +test('buildTargetRequest maps thinking.enabled to reasoning.effort medium regardless of budget_tokens', () => { for (const budget of [undefined, 1024, 16384]) { - const result = translateMessagesToResponses({ + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 4096, thinking: budget === undefined ? { type: 'enabled' } : { type: 'enabled', budget_tokens: budget }, @@ -178,8 +178,8 @@ test('translateMessagesToResponses maps thinking.enabled to reasoning.effort med } }); -test('translateMessagesToResponses maps thinking.adaptive to reasoning.effort medium', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest maps thinking.adaptive to reasoning.effort medium', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 4096, thinking: { type: 'adaptive' }, @@ -189,11 +189,11 @@ test('translateMessagesToResponses maps thinking.adaptive to reasoning.effort me assertEquals(result.reasoning, { effort: 'medium' }); }); -test('translateMessagesToResponses never invents reasoning.context from a source thinking block', () => { +test('buildTargetRequest never invents reasoning.context from a source thinking block', () => { // The Messages thinking shape carries no reasoning-context mode, so the // target reasoning object must expose effort only — never a synthesized // `all_turns` (or any other) context value. - const result = translateMessagesToResponses({ + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 4096, thinking: { type: 'enabled', budget_tokens: 8192 }, @@ -205,8 +205,8 @@ test('translateMessagesToResponses never invents reasoning.context from a source assertFalse('context' in (result.reasoning ?? {})); }); -test('translateMessagesToResponses preserves max_tokens at the translation boundary', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest preserves max_tokens at the translation boundary', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: 'hi' }], @@ -215,8 +215,8 @@ test('translateMessagesToResponses preserves max_tokens at the translation bound assertEquals(result.max_output_tokens, 256); }); -test('translateMessagesToResponses maps thinking.disabled to reasoning.effort none', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest maps thinking.disabled to reasoning.effort none', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, thinking: { type: 'disabled' }, @@ -227,8 +227,8 @@ test('translateMessagesToResponses maps thinking.disabled to reasoning.effort no assertFalse('include' in result); }); -test('translateMessagesToResponses preserves explicit temperature and omits translated-path defaults', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest preserves explicit temperature and omits translated-path defaults', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, temperature: 0.2, @@ -240,8 +240,8 @@ test('translateMessagesToResponses preserves explicit temperature and omits tran assertFalse('parallel_tool_calls' in result); }); -test('translateMessagesToResponses omits temperature when the source omitted it', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest omits temperature when the source omitted it', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: 'hi' }], @@ -250,8 +250,8 @@ test('translateMessagesToResponses omits temperature when the source omitted it' assertFalse('temperature' in result); }); -test('translateMessagesToResponses prepends multi-block top-level system as a leading input system message preserving block boundaries', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest prepends multi-block top-level system as a leading input system message preserving block boundaries', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, system: [ @@ -273,8 +273,8 @@ test('translateMessagesToResponses prepends multi-block top-level system as a le }); }); -test('translateMessagesToResponses keeps a single-block top-level system in canonical `instructions` slot', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest keeps a single-block top-level system in canonical `instructions` slot', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, system: [{ type: 'text', text: 'You are helpful.' }], @@ -286,8 +286,8 @@ test('translateMessagesToResponses keeps a single-block top-level system in cano assertEquals(input[0].role, 'user'); }); -test('translateMessagesToResponses preserves redacted_thinking as a native-signature reasoning item', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest preserves redacted_thinking as a native-signature reasoning item', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -309,8 +309,8 @@ test('translateMessagesToResponses preserves redacted_thinking as a native-signa ]); }); -test('translateMessagesToResponses recovers id and encrypted_content from packed redacted_thinking data', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest recovers id and encrypted_content from packed redacted_thinking data', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -332,8 +332,8 @@ test('translateMessagesToResponses recovers id and encrypted_content from packed ]); }); -test('translateMessagesToResponses preserves text-only thinking input', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest preserves text-only thinking input', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -357,8 +357,8 @@ test('translateMessagesToResponses preserves text-only thinking input', () => { // `properties` field. Anthropic accepts that shape, so the input_schema must // be normalized before forwarding to Responses. Ref: // https://github.com/caozhiyuan/copilot-api/commit/ad57069826843c5d17d7b0e5ef2f75050128893c -test('translateMessagesToResponses defaults missing input_schema.properties to {} for object tools', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest defaults missing input_schema.properties to {} for object tools', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, tools: [{ name: 'no_args', input_schema: { type: 'object' } }], @@ -375,8 +375,8 @@ test('translateMessagesToResponses defaults missing input_schema.properties to { ]); }); -test('translateMessagesToResponses preserves declared input_schema.properties verbatim', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest preserves declared input_schema.properties verbatim', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, tools: [ @@ -400,8 +400,8 @@ test('translateMessagesToResponses preserves declared input_schema.properties ve }); }); -test('translateMessagesToResponses does not inject properties for non-object input_schema', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest does not inject properties for non-object input_schema', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, tools: [{ name: 'scalar', input_schema: { type: 'string' } }], @@ -412,14 +412,14 @@ test('translateMessagesToResponses does not inject properties for non-object inp assertEquals(tool.parameters, { type: 'string' }); }); -test('translateMessagesToResponses wraps output_config.format json_schema as text.format with synthesised name and strict', () => { +test('buildTargetRequest wraps output_config.format json_schema as text.format with synthesised name and strict', () => { const schema = { type: 'object', properties: { test: { type: 'string' } }, required: ['test'], additionalProperties: false, }; - const result = translateMessagesToResponses({ + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: 'Hi' }], @@ -431,8 +431,8 @@ test('translateMessagesToResponses wraps output_config.format json_schema as tex }); }); -test('translateMessagesToResponses omits text when output_config has no format', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest omits text when output_config has no format', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: 'Hi' }], @@ -442,10 +442,10 @@ test('translateMessagesToResponses omits text when output_config has no format', assertFalse('text' in result); }); -test('translateMessagesToResponses rejects an unknown assistant content block type', () => { +test('buildTargetRequest rejects an unknown assistant content block type', () => { assertThrows( () => - translateMessagesToResponses({ + buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'assistant', content: [{ type: 'audio' } as unknown as MessagesAssistantContentBlock] }], @@ -455,10 +455,10 @@ test('translateMessagesToResponses rejects an unknown assistant content block ty ); }); -test('translateMessagesToResponses rejects an unknown user content block type', () => { +test('buildTargetRequest rejects an unknown user content block type', () => { assertThrows( () => - translateMessagesToResponses({ + buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: [{ type: 'audio' } as unknown as MessagesUserContentBlock] }], @@ -468,8 +468,8 @@ test('translateMessagesToResponses rejects an unknown user content block type', ); }); -test('translateMessagesToResponses emits in-array role:"system" inline as a Responses message input item', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest emits in-array role:"system" inline as a Responses message input item', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -486,8 +486,8 @@ test('translateMessagesToResponses emits in-array role:"system" inline as a Resp assertEquals(input[2].role, 'user'); }); -test('translateMessagesToResponses preserves in-array system text blocks as separate input_text parts', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest preserves in-array system text blocks as separate input_text parts', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -513,8 +513,8 @@ test('translateMessagesToResponses preserves in-array system text blocks as sepa }); }); -test('translateMessagesToResponses preserves chronology of multiple in-array system messages', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest preserves chronology of multiple in-array system messages', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [ @@ -535,10 +535,10 @@ test('translateMessagesToResponses preserves chronology of multiple in-array sys assertEquals(input[4].role, 'user'); }); -test('translateMessagesToResponses rejects an unknown message role', () => { +test('buildTargetRequest rejects an unknown message role', () => { assertThrows( () => - translateMessagesToResponses({ + buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'tool', content: 'oops' } as unknown as { role: 'user'; content: string }], @@ -548,8 +548,8 @@ test('translateMessagesToResponses rejects an unknown message role', () => { ); }); -test('translateMessagesToResponses collapses Anthropic thinking mode onto reasoning.effort only', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest collapses Anthropic thinking mode onto reasoning.effort only', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: 'hi' }], @@ -563,8 +563,8 @@ test('translateMessagesToResponses collapses Anthropic thinking mode onto reason // ── speed ↔ service_tier bridge ── -test('translateMessagesToResponses maps speed:fast to service_tier:fast on the outbound Responses payload', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest maps speed:fast to service_tier:fast on the outbound Responses payload', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, speed: 'fast', @@ -574,8 +574,8 @@ test('translateMessagesToResponses maps speed:fast to service_tier:fast on the o assertEquals(result.service_tier, 'fast'); }); -test('translateMessagesToResponses omits service_tier when speed is absent', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest omits service_tier when speed is absent', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: 'hi' }], @@ -584,8 +584,8 @@ test('translateMessagesToResponses omits service_tier when speed is absent', () assertFalse('service_tier' in result); }); -test('translateMessagesToResponses drops speed values other than fast without emitting service_tier', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest drops speed values other than fast without emitting service_tier', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, speed: 'standard', @@ -595,8 +595,8 @@ test('translateMessagesToResponses drops speed values other than fast without em assertFalse('service_tier' in result); }); -test('translateMessagesToResponses forwards Anthropic service_tier to Responses when speed is absent', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest forwards Anthropic service_tier to Responses when speed is absent', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, service_tier: 'auto', @@ -606,8 +606,8 @@ test('translateMessagesToResponses forwards Anthropic service_tier to Responses assertEquals(result.service_tier, 'auto'); }); -test('translateMessagesToResponses forwards service_tier:standard_only to Responses when speed is absent', () => { - const result = translateMessagesToResponses({ +test('buildTargetRequest forwards service_tier:standard_only to Responses when speed is absent', () => { + const result = buildTargetRequest({ model: 'gpt-test', max_tokens: 256, service_tier: 'standard_only', diff --git a/packages/translate/src/responses-via-chat-completions/events_test.ts b/packages/translate/src/responses-via-chat-completions/events_test.ts index be7c84d89a..ba0a0ee04f 100644 --- a/packages/translate/src/responses-via-chat-completions/events_test.ts +++ b/packages/translate/src/responses-via-chat-completions/events_test.ts @@ -10,6 +10,10 @@ type ResponsesCompletedEvent = Extract; +type ResponsesOutputItemAddedEvent = Extract; + +type ResponsesOutputItemDoneEvent = Extract; + const chunk = ( delta: ChatCompletionsStreamEvent['choices'][0]['delta'], finishReason: ChatCompletionsStreamEvent['choices'][0]['finish_reason'] = null, @@ -30,6 +34,19 @@ const translate = (chunks: ChatCompletionsStreamEvent[]): ResponsesStreamEvent[] const sequenceNumbers = (events: ResponsesStreamEvent[]): number[] => events.map(event => (event as ResponsesStreamEvent & { sequence_number: number }).sequence_number); +const assertEveryAddedOutputItemIsDone = (events: ResponsesStreamEvent[]): void => { + const added = events + .filter((event): event is ResponsesOutputItemAddedEvent => event.type === 'response.output_item.added') + .map(event => event.output_index) + .sort((a, b) => a - b); + const done = events + .filter((event): event is ResponsesOutputItemDoneEvent => event.type === 'response.output_item.done') + .map(event => event.output_index) + .sort((a, b) => a - b); + + assertEquals(done, added); +}; + const drain = async (frames: AsyncIterable): Promise => { for await (const _frame of frames) { // Exhaust the stream so async translator errors surface to the caller. @@ -286,3 +303,192 @@ test('translateChatCompletionsChunkToResponsesEvents unwraps wrapped custom tool assertEquals(itemDone.item.input, '*** Begin Patch\n*** End Patch'); assertEquals(itemDone.item.call_id, 'call_ctc'); }); + +test('translateChatCompletionsChunkToResponsesEvents keeps late opaque with prior scalar reasoning text', () => { + const state = createChatCompletionsToResponsesStreamState(); + const events = [ + ...translateChatCompletionsChunkToResponsesEvents(chunk({ role: 'assistant', reasoning_text: 'trace' }), state), + ...translateChatCompletionsChunkToResponsesEvents(chunk({ content: 'answer' }), state), + ...translateChatCompletionsChunkToResponsesEvents(chunk({ reasoning_opaque: 'sig' }), state), + ...translateChatCompletionsChunkToResponsesEvents(chunk({}, 'stop'), state), + ...flushChatCompletionsToResponsesEvents(state), + ]; + + const reasoningDoneEvents = events.filter(event => event.type === 'response.output_item.done' && (event as ResponsesOutputItemDoneEvent).item.type === 'reasoning') as ResponsesOutputItemDoneEvent[]; + + assertEquals(reasoningDoneEvents.length, 1); + assertEquals(reasoningDoneEvents[0].output_index, 0); + assertEquals(reasoningDoneEvents[0].item, { + type: 'reasoning', + id: expect.stringMatching(/^rs_[0-9a-f]{32}$/), + summary: [{ type: 'summary_text', text: 'trace' }], + }); +}); + +test('translateChatCompletionsChunkToResponsesEvents prefers reasoning_items over scalar reasoning in streaming composition', () => { + const state = createChatCompletionsToResponsesStreamState(); + const events = [ + ...translateChatCompletionsChunkToResponsesEvents(chunk({ role: 'assistant' }), state), + ...translateChatCompletionsChunkToResponsesEvents(chunk({ reasoning_text: 'trace' }), state), + ...translateChatCompletionsChunkToResponsesEvents(chunk({ content: 'answer' }), state), + ...translateChatCompletionsChunkToResponsesEvents( + chunk({ + reasoning_items: [ + { + type: 'reasoning', + id: 'rs_carrier', + summary: [{ type: 'summary_text', text: 'trace' }], + }, + ], + }), + state, + ), + ...translateChatCompletionsChunkToResponsesEvents(chunk({}, 'stop'), state), + ...flushChatCompletionsToResponsesEvents(state), + ]; + + const reasoningDoneEvents = events.filter(event => event.type === 'response.output_item.done' && (event as ResponsesOutputItemDoneEvent).item.type === 'reasoning') as ResponsesOutputItemDoneEvent[]; + const completed = events.find(event => event.type === 'response.completed') as ResponsesCompletedEvent | undefined; + + assertEveryAddedOutputItemIsDone(events); + assertEquals(reasoningDoneEvents.length, 1); + assertEquals(reasoningDoneEvents[0].item, { + type: 'reasoning', + id: 'rs_carrier', + summary: [{ type: 'summary_text', text: 'trace' }], + }); + assertEquals(completed?.response.output, [ + { + type: 'reasoning', + id: 'rs_carrier', + summary: [{ type: 'summary_text', text: 'trace' }], + }, + { + type: 'message', + id: expect.stringMatching(/^msg_[0-9a-f]{32}$/), + role: 'assistant', + content: [{ type: 'output_text', text: 'answer' }], + }, + ]); +}); + +test('translateChatCompletionsChunkToResponsesEvents keeps terminal output ordered by output_index', () => { + const state = createChatCompletionsToResponsesStreamState(); + const events = [ + ...translateChatCompletionsChunkToResponsesEvents(chunk({ role: 'assistant' }), state), + ...translateChatCompletionsChunkToResponsesEvents( + chunk({ + tool_calls: [ + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '{"q":"x"}' }, + }, + ], + }), + state, + ), + ...translateChatCompletionsChunkToResponsesEvents( + chunk({ + reasoning_items: [ + { + type: 'reasoning', + id: 'rs_after_tool', + summary: [{ type: 'summary_text', text: 'trace' }], + }, + ], + }), + state, + ), + ...translateChatCompletionsChunkToResponsesEvents(chunk({}, 'tool_calls'), state), + ...flushChatCompletionsToResponsesEvents(state), + ]; + + const added = events.filter(event => event.type === 'response.output_item.added') as ResponsesOutputItemAddedEvent[]; + const completed = events.find(event => event.type === 'response.completed') as ResponsesCompletedEvent | undefined; + + assertEquals( + added.map(event => [event.output_index, event.item.type]), + [ + [0, 'function_call'], + [1, 'reasoning'], + ], + ); + assertEquals( + completed?.response.output.map(item => item.type), + ['function_call', 'reasoning'], + ); +}); + +test('translateChatCompletionsChunkToResponsesEvents discards scalar reasoning when carrier arrives after opaque', () => { + const state = createChatCompletionsToResponsesStreamState(); + const events = [ + ...translateChatCompletionsChunkToResponsesEvents(chunk({ role: 'assistant' }), state), + ...translateChatCompletionsChunkToResponsesEvents(chunk({ reasoning_text: 'trace' }), state), + ...translateChatCompletionsChunkToResponsesEvents(chunk({ content: 'answer' }), state), + ...translateChatCompletionsChunkToResponsesEvents(chunk({ reasoning_opaque: 'sig' }), state), + ...translateChatCompletionsChunkToResponsesEvents( + chunk({ + reasoning_items: [ + { + type: 'reasoning', + id: 'rs_carrier', + summary: [{ type: 'summary_text', text: 'trace' }], + }, + ], + }), + state, + ), + ...translateChatCompletionsChunkToResponsesEvents(chunk({}, 'stop'), state), + ...flushChatCompletionsToResponsesEvents(state), + ]; + + const reasoningDoneEvents = events.filter(event => event.type === 'response.output_item.done' && (event as ResponsesOutputItemDoneEvent).item.type === 'reasoning') as ResponsesOutputItemDoneEvent[]; + const completed = events.find(event => event.type === 'response.completed') as ResponsesCompletedEvent | undefined; + + assertEveryAddedOutputItemIsDone(events); + assertEquals(reasoningDoneEvents.length, 1); + assertEquals(reasoningDoneEvents[0].item, { + type: 'reasoning', + id: 'rs_carrier', + summary: [{ type: 'summary_text', text: 'trace' }], + }); + assertEquals(completed?.response.output, [ + { + type: 'reasoning', + id: 'rs_carrier', + summary: [{ type: 'summary_text', text: 'trace' }], + }, + { + type: 'message', + id: expect.stringMatching(/^msg_[0-9a-f]{32}$/), + role: 'assistant', + content: [{ type: 'output_text', text: 'answer' }], + }, + ]); +}); + +test('translateChatCompletionsChunkToResponsesEvents ignores empty tool_calls arrays', () => { + const state = createChatCompletionsToResponsesStreamState(); + // Before the fix, empty tool_calls [] was truthy and entered the + // tool-calls branch, prematurely closing the text item. After the fix + // (choice.delta.tool_calls?.length), empty arrays are treated as absent. + const events1 = translateChatCompletionsChunkToResponsesEvents(chunk({ role: 'assistant', tool_calls: [] }), state); + // role + empty tool_calls should only emit response.created + response.in_progress. + // No tool-call events should be emitted. + assertEquals(events1.length, 2); + assertEquals(events1[0].type, 'response.created'); + assertEquals(events1[1].type, 'response.in_progress'); + + // Content delta should create a message item and emit text delta — not a new + // output item for empty tool_calls. + const events2 = translateChatCompletionsChunkToResponsesEvents(chunk({ content: 'hello' }), state); + const addedEvents = events2.filter(e => e.type === 'response.output_item.added') as ResponsesOutputItemAddedEvent[]; + assertEquals(addedEvents.length, 1, 'content delta should create one message output item'); + assertEquals(addedEvents[0].item.type, 'message'); + + const deltaEvents = events2.filter(e => e.type === 'response.output_text.delta'); + assertEquals(deltaEvents.length, 1); + assertEquals((deltaEvents[0] as { delta: string }).delta, 'hello'); +}); diff --git a/packages/translate/src/responses-via-chat-completions/request.ts b/packages/translate/src/responses-via-chat-completions/request.ts index 3231543ffc..ed1388f713 100644 --- a/packages/translate/src/responses-via-chat-completions/request.ts +++ b/packages/translate/src/responses-via-chat-completions/request.ts @@ -163,12 +163,12 @@ const buildChatCompletionsResponseFormat = (text: ResponsesPayload['text']): Cha * the trip's events translator can project wrapped function calls back into * `custom_tool_call` outputs. */ -export interface ResponsesToChatCompletionsResult { +export interface TargetRequestResult { target: ChatCompletionsPayload; customToolNames: Set; } -export const translateResponsesToChatCompletions = (source: ResponsesRequestPayload): ResponsesToChatCompletionsResult => { +export const buildTargetRequest = (source: ResponsesRequestPayload): TargetRequestResult => { const payload = canonicalizeResponsesPayload(source); rejectProgrammaticResponsesPayload(payload, 'Chat Completions'); const customToolNames = new Set(); @@ -306,5 +306,3 @@ export const translateResponsesToChatCompletions = (source: ResponsesRequestPayl return { target, customToolNames }; }; - -export const buildTargetRequest = translateResponsesToChatCompletions; diff --git a/packages/translate/src/responses-via-chat-completions/request_test.ts b/packages/translate/src/responses-via-chat-completions/request_test.ts index 6e68db598a..53b51cdb6e 100644 --- a/packages/translate/src/responses-via-chat-completions/request_test.ts +++ b/packages/translate/src/responses-via-chat-completions/request_test.ts @@ -1,12 +1,11 @@ import { test } from 'vitest'; -import { translateResponsesToChatCompletions } from './request.ts'; -import { createResponsesToChatCompletionsStreamState, translateResponsesEventToChatCompletionsChunks } from '../chat-completions-via-responses/events.ts'; +import { buildTargetRequest } from './request.ts'; import type { ResponsesAgentMessageContent, ResponsesInputMultiAgentCallOutputItem, ResponsesTool, ResponsesToolChoice } from '@floway-dev/protocols/responses'; import { assertEquals, assertThrows } from '@floway-dev/test-utils'; -test('translateResponsesToChatCompletions accepts an implicit message discriminator', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest accepts an implicit message discriminator', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: [{ role: 'system', content: 'rules' }], }); @@ -16,8 +15,8 @@ test('translateResponsesToChatCompletions accepts an implicit message discrimina ]); }); -test('translateResponsesToChatCompletions merges adjacent assistant reasoning text and tool calls', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest merges adjacent assistant reasoning text and tool calls', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: [ { type: 'message', role: 'user', content: 'Hi' }, @@ -116,8 +115,8 @@ test('translateResponsesToChatCompletions merges adjacent assistant reasoning te ]); }); -test('translateResponsesToChatCompletions preserves all reasoning items and projects only the first scalar group', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest preserves all reasoning items and projects only the first scalar group', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: [ { @@ -164,8 +163,8 @@ test('translateResponsesToChatCompletions preserves all reasoning items and proj ]); }); -test('translateResponsesToChatCompletions preserves explicit null prompt cache and safety fields', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest preserves explicit null prompt cache and safety fields', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: 'hello', prompt_cache_key: null, @@ -178,8 +177,8 @@ test('translateResponsesToChatCompletions preserves explicit null prompt cache a assertEquals(result.target.safety_identifier, null); }); -test('translateResponsesToChatCompletions omits response_format when Responses text.format is absent', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest omits response_format when Responses text.format is absent', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: 'Hi', text: {}, @@ -188,8 +187,8 @@ test('translateResponsesToChatCompletions omits response_format when Responses t assertEquals('response_format' in result.target, false); }); -test('translateResponsesToChatCompletions preserves explicit null text format', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest preserves explicit null text format', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: 'Hi', text: null, @@ -198,13 +197,13 @@ test('translateResponsesToChatCompletions preserves explicit null text format', assertEquals(result.target.response_format, null); }); -test('translateResponsesToChatCompletions reshapes flat json_schema text format into Chat Completions shape', () => { +test('buildTargetRequest reshapes flat json_schema text format into Chat Completions shape', () => { const schema = { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], }; - const result = translateResponsesToChatCompletions({ + const result = buildTargetRequest({ model: 'gpt-test', input: 'Hi', text: { @@ -227,8 +226,8 @@ test('translateResponsesToChatCompletions reshapes flat json_schema text format }); }); -test('translateResponsesToChatCompletions passes through plain text format without wrapping', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest passes through plain text format without wrapping', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: 'Hi', text: { format: { type: 'text' } }, @@ -237,8 +236,8 @@ test('translateResponsesToChatCompletions passes through plain text format witho assertEquals(result.target.response_format, { type: 'text' }); }); -test('translateResponsesToChatCompletions does not double-wrap an already-wrapped json_schema', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest does not double-wrap an already-wrapped json_schema', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: 'Hi', text: { @@ -255,917 +254,12 @@ test('translateResponsesToChatCompletions does not double-wrap an already-wrappe }); }); -test('translateResponsesEventToChatCompletionsChunks drops reasoning items without readable summary', () => { - const state = createResponsesToChatCompletionsStreamState(); - - const created = translateResponsesEventToChatCompletionsChunks( - { - type: 'response.created', - response: { - id: 'resp_single_opaque', - object: 'response', - model: 'gpt-test', - status: 'in_progress', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ); - assertEquals(created.length, 1); - assertEquals(created[0].choices[0].delta.role, 'assistant'); - - const during = translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.done', - output_index: 0, - item: { - type: 'reasoning', - id: 'rs_1', - summary: [], - }, - }, - state, - ); - assertEquals(during, []); - - const completed = translateResponsesEventToChatCompletionsChunks( - { - type: 'response.completed', - response: { - id: 'resp_single_opaque', - object: 'response', - model: 'gpt-test', - status: 'completed', - output: [], - output_text: '', - error: null, - incomplete_details: null, - usage: { - input_tokens: 1, - output_tokens: 2, - total_tokens: 3, - }, - }, - }, - state, - ); - - assertEquals(completed.length, 2); - assertEquals(completed[0].choices[0].delta, {}); - assertEquals(completed[0].choices[0].finish_reason, 'stop'); - assertEquals(completed[0].usage, undefined); - assertEquals(completed[1].choices, []); - assertEquals(completed[1].usage, { - prompt_tokens: 1, - completion_tokens: 2, - total_tokens: 3, - }); -}); - -test('translateResponsesEventToChatCompletionsChunks does not fill scalar opaque from later empty reasoning', () => { - const state = createResponsesToChatCompletionsStreamState(); - - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.created', - response: { - id: 'resp_stream_no_cross_pair', - object: 'response', - model: 'gpt-test', - status: 'in_progress', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ); - - const chunks = [ - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.reasoning_summary_text.delta', - item_id: 'rs_1', - output_index: 0, - summary_index: 0, - delta: 'first', - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.done', - output_index: 0, - item: { - type: 'reasoning', - id: 'rs_1', - summary: [{ type: 'summary_text', text: 'first' }], - }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.done', - output_index: 1, - item: { - type: 'reasoning', - id: 'rs_2', - summary: [], - }, - }, - state, - ), - ].flatMap(result => result); - - const completed = translateResponsesEventToChatCompletionsChunks( - { - type: 'response.completed', - response: { - id: 'resp_stream_no_cross_pair', - object: 'response', - model: 'gpt-test', - status: 'completed', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ); - - assertEquals( - [...chunks, ...completed].some(chunk => chunk.choices[0]?.delta.reasoning_opaque !== undefined), - false, - ); - assertEquals(completed[0].usage, undefined); -}); - -test('translateResponsesEventToChatCompletionsChunks drops multiple reasoning items without readable summaries', () => { - const state = createResponsesToChatCompletionsStreamState(); - - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.created', - response: { - id: 'resp_multi_opaque', - object: 'response', - model: 'gpt-test', - status: 'in_progress', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ); - - const firstReasoning = translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.done', - output_index: 0, - item: { - type: 'reasoning', - id: 'rs_1', - summary: [], - }, - }, - state, - ); - const secondReasoning = translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.done', - output_index: 1, - item: { - type: 'reasoning', - id: 'rs_2', - summary: [], - }, - }, - state, - ); - - const completed = translateResponsesEventToChatCompletionsChunks( - { - type: 'response.completed', - response: { - id: 'resp_multi_opaque', - object: 'response', - model: 'gpt-test', - status: 'completed', - output: [], - output_text: '', - error: null, - incomplete_details: null, - usage: { - input_tokens: 1, - output_tokens: 2, - total_tokens: 3, - }, - }, - }, - state, - ); - - assertEquals(firstReasoning, []); - assertEquals(secondReasoning, []); - assertEquals(completed.length, 2); - assertEquals(completed[0].choices[0].finish_reason, 'stop'); - assertEquals(completed[0].usage, undefined); - assertEquals(completed[1].choices, []); - assertEquals(completed[1].usage, { - prompt_tokens: 1, - completion_tokens: 2, - total_tokens: 3, - }); -}); - -test('translateResponsesEventToChatCompletionsChunks projects done-only summary text into scalar reasoning_text', () => { - const state = createResponsesToChatCompletionsStreamState(); - - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.created', - response: { - id: 'resp_done_only_summary', - object: 'response', - model: 'gpt-test', - status: 'in_progress', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ); - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.reasoning_summary_text.done', - item_id: 'rs_1', - output_index: 0, - summary_index: 0, - text: 'done trace', - }, - state, - ); - const reasoning = translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.done', - output_index: 0, - item: { - type: 'reasoning', - id: 'rs_1', - summary: [{ type: 'summary_text', text: 'done trace' }], - }, - }, - state, - ); - - const completed = translateResponsesEventToChatCompletionsChunks( - { - type: 'response.completed', - response: { - id: 'resp_done_only_summary', - object: 'response', - model: 'gpt-test', - status: 'completed', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ); - - assertEquals(reasoning[0].choices[0].delta.reasoning_text, 'done trace'); - assertEquals(reasoning[1].choices[0].delta.reasoning_items, [ - { - type: 'reasoning', - id: 'rs_1', - summary: [{ type: 'summary_text', text: 'done trace' }], - }, - ]); - assertEquals(completed[0].choices[0].finish_reason, 'stop'); -}); - -test('translateResponsesEventToChatCompletionsChunks projects output_item.done summary into scalar reasoning_text', () => { - const state = createResponsesToChatCompletionsStreamState(); - - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.created', - response: { - id: 'resp_output_done_summary', - object: 'response', - model: 'gpt-test', - status: 'in_progress', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ); - const reasoning = translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.done', - output_index: 0, - item: { - type: 'reasoning', - id: 'rs_1', - summary: [{ type: 'summary_text', text: 'output trace' }], - }, - }, - state, - ); - - const completed = translateResponsesEventToChatCompletionsChunks( - { - type: 'response.completed', - response: { - id: 'resp_output_done_summary', - object: 'response', - model: 'gpt-test', - status: 'completed', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ); - - assertEquals(reasoning[0].choices[0].delta.reasoning_text, 'output trace'); - assertEquals(reasoning[1].choices[0].delta.reasoning_items, [ - { - type: 'reasoning', - id: 'rs_1', - summary: [{ type: 'summary_text', text: 'output trace' }], - }, - ]); - assertEquals(completed[0].choices[0].finish_reason, 'stop'); -}); - -test('translateResponsesEventToChatCompletionsChunks emits stream usage as a usage-only chunk', () => { - const state = createResponsesToChatCompletionsStreamState(); - - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.created', - response: { - id: 'resp_usage_only', - object: 'response', - model: 'gpt-test', - status: 'in_progress', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ); - - const completed = translateResponsesEventToChatCompletionsChunks( - { - type: 'response.completed', - response: { - id: 'resp_usage_only', - object: 'response', - model: 'gpt-test', - status: 'completed', - output: [], - output_text: '', - error: null, - incomplete_details: null, - usage: { - input_tokens: 12, - output_tokens: 4, - total_tokens: 16, - input_tokens_details: { cached_tokens: 3 }, - }, - }, - }, - state, - ); - - assertEquals(completed.length, 2); - assertEquals(completed[0].choices[0].finish_reason, 'stop'); - assertEquals(completed[0].usage, undefined); - assertEquals(completed[1].choices, []); - assertEquals(completed[1].usage, { - prompt_tokens: 12, - completion_tokens: 4, - total_tokens: 16, - prompt_tokens_details: { cached_tokens: 3 }, - }); -}); - -test('translateResponsesEventToChatCompletionsChunks preserves text order around empty reasoning', () => { - const state = createResponsesToChatCompletionsStreamState(); - const chunks = [ - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.created', - response: { - id: 'resp_late_opaque_order', - object: 'response', - model: 'gpt-test', - status: 'in_progress', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'reasoning', id: 'rs_0', summary: [] }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_text.delta', - item_id: 'msg_1', - output_index: 1, - content_index: 0, - delta: 'answer', - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.done', - output_index: 0, - item: { - type: 'reasoning', - id: 'rs_0', - summary: [], - }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.completed', - response: { - id: 'resp_late_opaque_order', - object: 'response', - model: 'gpt-test', - status: 'completed', - output: [ - { - type: 'reasoning', - id: 'rs_0', - summary: [], - }, - { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'answer' }], - }, - ], - output_text: 'answer', - error: null, - incomplete_details: null, - }, - }, - state, - ), - ].flatMap(result => result); - - assertEquals( - chunks.map(chunk => chunk.choices[0]?.delta), - [ - { role: 'assistant' }, - { content: 'answer' }, - {}, - ], - ); -}); - -test('translateResponsesEventToChatCompletionsChunks preserves later text after empty reasoning is done', () => { - const state = createResponsesToChatCompletionsStreamState(); - const chunks = [ - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.created', - response: { - id: 'resp_done_before_text', - object: 'response', - model: 'gpt-test', - status: 'in_progress', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'reasoning', id: 'rs_0', summary: [] }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.done', - output_index: 0, - item: { - type: 'reasoning', - id: 'rs_0', - summary: [], - }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_text.delta', - item_id: 'msg_1', - output_index: 1, - content_index: 0, - delta: 'answer', - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.completed', - response: { - id: 'resp_done_before_text', - object: 'response', - model: 'gpt-test', - status: 'completed', - output: [ - { - type: 'reasoning', - id: 'rs_0', - summary: [], - }, - { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'answer' }], - }, - ], - output_text: 'answer', - error: null, - incomplete_details: null, - }, - }, - state, - ), - ].flatMap(result => result); - - assertEquals( - chunks.map(chunk => chunk.choices[0]?.delta), - [ - { role: 'assistant' }, - { content: 'answer' }, - {}, - ], - ); -}); - -test('translateResponsesEventToChatCompletionsChunks emits output_text.done when no delta arrived', () => { - const state = createResponsesToChatCompletionsStreamState(); - const chunks = [ - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.created', - response: { - id: 'resp_done_text', - object: 'response', - model: 'gpt-test', - status: 'in_progress', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_text.done', - item_id: 'msg_0', - output_index: 0, - content_index: 0, - text: 'answer', - }, - state, - ), - ].flatMap(result => result); - - assertEquals( - chunks.map(chunk => chunk.choices[0]?.delta), - [{ role: 'assistant' }, { content: 'answer' }], - ); -}); - -test('translateResponsesEventToChatCompletionsChunks emits function_call_arguments.done when no delta arrived', () => { - const state = createResponsesToChatCompletionsStreamState(); - const chunks = [ - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.created', - response: { - id: 'resp_done_args', - object: 'response', - model: 'gpt-test', - status: 'in_progress', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.added', - output_index: 0, - item: { - type: 'function_call', - call_id: 'call_0', - name: 'lookup', - arguments: '', - status: 'in_progress', - }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.function_call_arguments.done', - item_id: 'fc_0', - output_index: 0, - arguments: '{"q":1}', - }, - state, - ), - ].flatMap(result => result); - - assertEquals( - chunks.map(chunk => chunk.choices[0]?.delta), - [ - { role: 'assistant' }, - { - tool_calls: [ - { - index: 0, - id: 'call_0', - type: 'function', - function: { name: 'lookup', arguments: '' }, - }, - ], - }, - { - tool_calls: [ - { - index: 0, - function: { arguments: '{"q":1}' }, - }, - ], - }, - ], - ); -}); - -test('translateResponsesEventToChatCompletionsChunks emits all done-only reasoning summary parts', () => { - const state = createResponsesToChatCompletionsStreamState(); - const chunks = [ - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.created', - response: { - id: 'resp_done_reasoning_parts', - object: 'response', - model: 'gpt-test', - status: 'in_progress', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'reasoning', id: 'rs_0', summary: [] }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.reasoning_summary_text.done', - item_id: 'rs_0', - output_index: 0, - summary_index: 0, - text: 'first', - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.reasoning_summary_text.done', - item_id: 'rs_0', - output_index: 0, - summary_index: 1, - text: 'second', - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.done', - output_index: 0, - item: { - type: 'reasoning', - id: 'rs_0', - summary: [ - { type: 'summary_text', text: 'first' }, - { type: 'summary_text', text: 'second' }, - ], - }, - }, - state, - ), - ].flatMap(result => result); - - assertEquals( - chunks.map(chunk => chunk.choices[0]?.delta.reasoning_text).filter(text => text !== undefined), - ['first', 'second'], - ); -}); - -test('translateResponsesEventToChatCompletionsChunks flushes pending done-only reasoning summary at completion', () => { - const state = createResponsesToChatCompletionsStreamState(); - - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.created', - response: { - id: 'resp_terminal_reasoning_done', - object: 'response', - model: 'gpt-test', - status: 'in_progress', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ); - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.reasoning_summary_text.done', - item_id: 'rs_0', - output_index: 0, - summary_index: 0, - text: 'terminal trace', - }, - state, - ); - const completed = translateResponsesEventToChatCompletionsChunks( - { - type: 'response.completed', - response: { - id: 'resp_terminal_reasoning_done', - object: 'response', - model: 'gpt-test', - status: 'completed', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ); - - assertEquals( - completed.map(chunk => chunk.choices[0]?.delta), - [{ reasoning_text: 'terminal trace' }, {}], - ); -}); - -test('translateResponsesEventToChatCompletionsChunks keeps first scalar reasoning by output order', () => { - const state = createResponsesToChatCompletionsStreamState(); - const chunks = [ - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.created', - response: { - id: 'resp_reasoning_order', - object: 'response', - model: 'gpt-test', - status: 'in_progress', - output: [], - output_text: '', - error: null, - incomplete_details: null, - }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'reasoning', id: 'rs_0', summary: [] }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.added', - output_index: 1, - item: { type: 'reasoning', id: 'rs_1', summary: [] }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.done', - output_index: 1, - item: { - type: 'reasoning', - id: 'rs_1', - summary: [{ type: 'summary_text', text: 'second' }], - }, - }, - state, - ), - translateResponsesEventToChatCompletionsChunks( - { - type: 'response.output_item.done', - output_index: 0, - item: { - type: 'reasoning', - id: 'rs_0', - summary: [{ type: 'summary_text', text: 'first' }], - }, - }, - state, - ), - ].flatMap(result => result); - - assertEquals( - chunks.map(chunk => chunk.choices[0]?.delta), - [ - { role: 'assistant' }, - { reasoning_text: 'first' }, - { - reasoning_items: [ - { - type: 'reasoning', - id: 'rs_0', - summary: [{ type: 'summary_text', text: 'first' }], - }, - { - type: 'reasoning', - id: 'rs_1', - summary: [{ type: 'summary_text', text: 'second' }], - }, - ], - }, - ], - ); -}); - -test('translateResponsesToChatCompletions filters out builtin tools that have no Chat Completions equivalent', () => { +test('buildTargetRequest filters out builtin tools that have no Chat Completions equivalent', () => { // Responses exposes server-side builtin tools (web_search_preview, // file_search, image_generation, ...) that have no Chat Completions // analogue and no `name` field. These should be filtered out rather than // emitting `function: {}` which strict upstreams (vLLM) reject. - const result = translateResponsesToChatCompletions({ + const result = buildTargetRequest({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'Hi' }], instructions: null, @@ -1214,8 +308,8 @@ test('translateResponsesToChatCompletions filters out builtin tools that have no assertEquals(result.target.tools![1].function.description, undefined); }); -test('translateResponsesToChatCompletions returns undefined tools when only builtin tools are present', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest returns undefined tools when only builtin tools are present', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'Hi' }], instructions: null, @@ -1233,10 +327,10 @@ test('translateResponsesToChatCompletions returns undefined tools when only buil assertEquals(result.target.tools, undefined); }); -test('translateResponsesToChatCompletions drops forced builtin tool_choice but keeps function tool_choice', () => { +test('buildTargetRequest drops forced builtin tool_choice but keeps function tool_choice', () => { // Forced builtin tool choices have no Chat Completions analogue; // they should be dropped (falling back to auto/default). - const resultWithBuiltinChoice = translateResponsesToChatCompletions({ + const resultWithBuiltinChoice = buildTargetRequest({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'Hi' }], instructions: null, @@ -1257,7 +351,7 @@ test('translateResponsesToChatCompletions drops forced builtin tool_choice but k assertEquals(resultWithBuiltinChoice.target.tool_choice, undefined); // Forced function tool_choice should be preserved. - const resultWithFunctionChoice = translateResponsesToChatCompletions({ + const resultWithFunctionChoice = buildTargetRequest({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'Hi' }], instructions: null, @@ -1282,8 +376,8 @@ test('translateResponsesToChatCompletions drops forced builtin tool_choice but k }); }); -test('translateResponsesToChatCompletions returns undefined tool_choice for string auto/required/none choices', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest returns undefined tool_choice for string auto/required/none choices', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'Hi' }], instructions: null, @@ -1302,8 +396,8 @@ test('translateResponsesToChatCompletions returns undefined tool_choice for stri assertEquals(result.target.tool_choice, 'auto'); }); -test('translateResponsesToChatCompletions wraps custom tools as single-string function tools and records their names', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest wraps custom tools as single-string function tools and records their names', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: 'hi', instructions: null, @@ -1351,8 +445,8 @@ test('translateResponsesToChatCompletions wraps custom tools as single-string fu assertEquals(result.target.tool_choice, { type: 'function', function: { name: 'apply_patch' } }); }); -test('translateResponsesToChatCompletions projects custom_tool_call history into wrapped tool_calls shape', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest projects custom_tool_call history into wrapped tool_calls shape', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: [ { type: 'message', role: 'user', content: 'apply this patch' }, @@ -1409,70 +503,38 @@ test.each([ { name: 'multi_agent_call_output', input: [{ type: 'multi_agent_call_output', action: 'spawn_agent', call_id: 'call_1', output: [] as ResponsesInputMultiAgentCallOutputItem['output'] }] }, { name: 'context_compaction', input: [{ type: 'context_compaction', encrypted_content: 'opaque' }] }, { name: 'item_reference', input: [{ type: 'item_reference', id: 'msg_1' }] }, -] as const)('translateResponsesToChatCompletions rejects Responses-only $name input', ({ name, input }) => { +] as const)('buildTargetRequest rejects Responses-only $name input', ({ name, input }) => { assertThrows( - () => translateResponsesToChatCompletions({ model: 'gpt-test', input: [...input] }), + () => buildTargetRequest({ model: 'gpt-test', input: [...input] }), Error, `Invalid input item type '${name}'`, ); }); -test.each([ - { type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{}', status: 'completed', caller: { type: 'program', caller_id: 'call_prog_1' } }, - { type: 'function_call_output', call_id: 'call_1', output: 'ok', caller: { type: 'program', caller_id: 'call_prog_1' } }, - { type: 'custom_tool_call', call_id: 'call_1', name: 'exec', input: 'run', caller: { type: 'program', caller_id: 'call_prog_1' } }, - { type: 'custom_tool_call_output', call_id: 'call_1', output: 'ok', caller: { type: 'program', caller_id: 'call_prog_1' } }, -] as const)('translateResponsesToChatCompletions rejects $type program caller metadata', item => { +test('buildTargetRequest wires Responses tooling guards', () => { assertThrows( - () => translateResponsesToChatCompletions({ model: 'gpt-test', input: [item] }), + () => buildTargetRequest({ + model: 'gpt-test', + input: [{ type: 'function_call_output', call_id: 'call_1', output: 'ok', caller: { type: 'program', caller_id: 'call_prog_1' } }], + }), Error, 'program caller', ); -}); - -test('translateResponsesToChatCompletions accepts null tool_choice', () => { - const result = translateResponsesToChatCompletions({ model: 'gpt-test', input: 'hi', tool_choice: null }); - assertEquals(result.target.tool_choice, undefined); -}); - -test.each([ - { name: 'programmatic tool', payload: { tools: [{ type: 'programmatic_tool_calling' as const }] } }, - { name: 'programmatic allowed caller', payload: { tools: [{ type: 'function' as const, name: 'lookup', parameters: {}, strict: true, allowed_callers: ['programmatic' as const] }] } }, - { name: 'programmatic tool choice', payload: { tool_choice: { type: 'programmatic_tool_calling' as const } } }, -])('translateResponsesToChatCompletions rejects $name', ({ payload }) => { assertThrows( - () => translateResponsesToChatCompletions({ model: 'gpt-test', input: 'hi', ...payload }), + () => buildTargetRequest({ model: 'gpt-test', input: 'hi', tools: [{ type: 'programmatic_tool_calling' }] }), Error, 'Programmatic', ); }); -test.each([ - { type: 'function' as const, name: 'lookup', parameters: {}, strict: true, defer_loading: true }, - { type: 'custom' as const, name: 'exec', defer_loading: true }, -])('translateResponsesToChatCompletions rejects deferred $type tools', tool => { - assertThrows( - () => translateResponsesToChatCompletions({ model: 'gpt-test', input: 'hi', tools: [tool] }), - Error, - 'Deferred', - ); -}); - -test('translateResponsesToChatCompletions rejects nested namespace programmatic callers', () => { - assertThrows( - () => translateResponsesToChatCompletions({ - model: 'gpt-test', - input: 'hi', - tools: [{ type: 'namespace', name: 'ops', description: 'ops', tools: [{ type: 'custom', name: 'exec', allowed_callers: ['programmatic'] }] } as unknown as ResponsesTool], - }), - Error, - 'Programmatic', - ); +test('buildTargetRequest accepts null tool_choice', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: 'hi', tool_choice: null }); + assertEquals(result.target.tool_choice, undefined); }); -test('translateResponsesToChatCompletions rejects multimodal custom tool output', () => { +test('buildTargetRequest rejects multimodal custom tool output', () => { assertThrows( - () => translateResponsesToChatCompletions({ + () => buildTargetRequest({ model: 'gpt-test', input: [{ type: 'custom_tool_call_output', call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }], }), @@ -1481,9 +543,9 @@ test('translateResponsesToChatCompletions rejects multimodal custom tool output' ); }); -test('translateResponsesToChatCompletions rejects file tool output', () => { +test('buildTargetRequest rejects file tool output', () => { assertThrows( - () => translateResponsesToChatCompletions({ + () => buildTargetRequest({ model: 'gpt-test', input: [{ type: 'function_call_output', call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }], }), @@ -1492,9 +554,9 @@ test('translateResponsesToChatCompletions rejects file tool output', () => { ); }); -test('translateResponsesToChatCompletions rejects file assistant content', () => { +test('buildTargetRequest rejects file assistant content', () => { assertThrows( - () => translateResponsesToChatCompletions({ + () => buildTargetRequest({ model: 'gpt-test', input: [{ type: 'message', role: 'assistant', content: [{ type: 'input_file', file_id: 'file_1' }] }], }), @@ -1503,9 +565,9 @@ test('translateResponsesToChatCompletions rejects file assistant content', () => ); }); -test('translateResponsesToChatCompletions rejects image assistant content', () => { +test('buildTargetRequest rejects image assistant content', () => { assertThrows( - () => translateResponsesToChatCompletions({ + () => buildTargetRequest({ model: 'gpt-test', input: [{ type: 'message', role: 'assistant', content: [{ type: 'input_image', image_url: 'https://example.com/a.png', detail: 'auto' }] }], }), @@ -1514,9 +576,9 @@ test('translateResponsesToChatCompletions rejects image assistant content', () = ); }); -test('translateResponsesToChatCompletions rejects file_id-only images', () => { +test('buildTargetRequest rejects file_id-only images', () => { assertThrows( - () => translateResponsesToChatCompletions({ + () => buildTargetRequest({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: [{ type: 'input_image', file_id: 'file_1', detail: 'auto' }] }], }), @@ -1525,9 +587,9 @@ test('translateResponsesToChatCompletions rejects file_id-only images', () => { ); }); -test('translateResponsesToChatCompletions rejects Responses-only original image detail', () => { +test('buildTargetRequest rejects Responses-only original image detail', () => { assertThrows( - () => translateResponsesToChatCompletions({ + () => buildTargetRequest({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: [{ type: 'input_image', image_url: 'https://example.com/a.png', detail: 'original' }] }], }), @@ -1536,14 +598,14 @@ test('translateResponsesToChatCompletions rejects Responses-only original image ); }); -test('translateResponsesToChatCompletions throws on a stray web_search_call input item (shim owns the reverse path)', () => { +test('buildTargetRequest throws on a stray web_search_call input item (shim owns the reverse path)', () => { // The Responses web-search shim rewrites web_search_call input items into // upstream function_call + function_call_output pairs before this // translator runs. Reaching the translator with a raw web_search_call // means the shim regressed; the translator surfaces a loud error so the // bug is caught rather than silently dropping search context. assertThrows( - () => translateResponsesToChatCompletions({ + () => buildTargetRequest({ model: 'gpt-test', input: [ { type: 'message', role: 'user', content: 'hi' }, @@ -1570,13 +632,13 @@ test('translateResponsesToChatCompletions throws on a stray web_search_call inpu ); }); -test('translateResponsesToChatCompletions throws on a stray compaction_trigger input item (compact-shim owns the strip)', () => { +test('buildTargetRequest throws on a stray compaction_trigger input item (compact-shim owns the strip)', () => { // The compact-shim is structurally required on non-responses targets and // strips compaction_trigger items before reaching this translator. // Reaching here with one in input means the shim disengaged; the // translator's catch-all guard surfaces the regression. assertThrows( - () => translateResponsesToChatCompletions({ + () => buildTargetRequest({ model: 'gpt-test', input: [ { type: 'message', role: 'user', content: 'hi' }, @@ -1598,13 +660,13 @@ test('translateResponsesToChatCompletions throws on a stray compaction_trigger i ); }); -test('translateResponsesToChatCompletions throws on a stray compaction input item (compact-shim owns the expansion)', () => { +test('buildTargetRequest throws on a stray compaction input item (compact-shim owns the expansion)', () => { // The compact-shim expands its own shim-encoded compaction items inline // before reaching this translator and round-trips foreign compactions // back to the upstream as raw items. Either way the translator should // never see one. assertThrows( - () => translateResponsesToChatCompletions({ + () => buildTargetRequest({ model: 'gpt-test', input: [ { type: 'message', role: 'user', content: 'hi' }, @@ -1626,8 +688,8 @@ test('translateResponsesToChatCompletions throws on a stray compaction input ite ); }); -test('translateResponsesToChatCompletions lifts tool-output images into a following user message', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest lifts tool-output images into a following user message', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: [ { type: 'function_call', call_id: 'call_1', name: 'screenshot', arguments: '{}', status: 'completed' }, @@ -1673,8 +735,8 @@ test('translateResponsesToChatCompletions lifts tool-output images into a follow ]); }); -test('translateResponsesToChatCompletions keeps grouped tool results contiguous before lifted images', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest keeps grouped tool results contiguous before lifted images', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: [ { type: 'function_call', call_id: 'call_a', name: 'capture_a', arguments: '{}', status: 'completed' }, @@ -1713,12 +775,12 @@ test('translateResponsesToChatCompletions keeps grouped tool results contiguous ]); }); -test('translateResponsesToChatCompletions places lifted images before a later source message', () => { +test('buildTargetRequest places lifted images before a later source message', () => { for (const trailing of [ { type: 'message' as const, role: 'user' as const, content: 'new user turn' }, { type: 'message' as const, role: 'system' as const, content: 'new system turn' }, ]) { - const result = translateResponsesToChatCompletions({ + const result = buildTargetRequest({ model: 'gpt-test', input: [ { type: 'function_call', call_id: 'call_1', name: 'capture', arguments: '{}', status: 'completed' }, @@ -1738,8 +800,8 @@ test('translateResponsesToChatCompletions places lifted images before a later so // ── Native field forwarding ── -test('translateResponsesToChatCompletions maps text.verbosity onto verbosity', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest maps text.verbosity onto verbosity', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'hi' }], text: { verbosity: 'low' }, @@ -1748,8 +810,8 @@ test('translateResponsesToChatCompletions maps text.verbosity onto verbosity', ( assertEquals(result.target.verbosity, 'low'); }); -test('translateResponsesToChatCompletions co-emits reasoning.effort onto reasoning_effort and service_tier verbatim', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest co-emits reasoning.effort onto reasoning_effort and service_tier verbatim', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'hi' }], reasoning: { effort: 'xhigh' }, @@ -1760,8 +822,8 @@ test('translateResponsesToChatCompletions co-emits reasoning.effort onto reasoni assertEquals(result.target.service_tier, 'priority'); }); -test('translateResponsesToChatCompletions drops reasoning.summary (Chat has no slot)', () => { - const result = translateResponsesToChatCompletions({ +test('buildTargetRequest drops reasoning.summary (Chat has no slot)', () => { + const result = buildTargetRequest({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'hi' }], reasoning: { effort: 'medium', summary: 'concise' }, diff --git a/packages/translate/src/responses-via-messages/events.ts b/packages/translate/src/responses-via-messages/events.ts index c773447a55..8fe9b00cdc 100644 --- a/packages/translate/src/responses-via-messages/events.ts +++ b/packages/translate/src/responses-via-messages/events.ts @@ -1,10 +1,11 @@ import { unwrapCustomToolInput } from '../shared/responses-via/custom-tool-wrap.ts'; import * as responses from '../shared/responses-via/responses-event-builder.ts'; +import { openAIServiceTierFromMessagesUsage } from '../shared/via-messages/service-tier.ts'; +import { inclusiveMessagesInputUsage } from '../shared/via-messages/usage.ts'; import { eventFrame, USAGE_BILLING, type ProtocolFrame } from '@floway-dev/protocols/common'; import { mergeMessagesUsageSnapshot, messagesUsageSnapshot, - splitMessagesCacheCreationTokens, } from '@floway-dev/protocols/messages'; import type { MessagesContentBlockDeltaEvent, @@ -87,16 +88,12 @@ interface MessagesToResponsesStreamState { } const buildResult = (state: MessagesToResponsesStreamState, status: ResponsesResult['status']): ResponsesResult => { - const { cacheWrite, cacheWrite1h } = splitMessagesCacheCreationTokens(state.usage); - const cacheRead = state.usage.cache_read_input_tokens ?? 0; + const { cacheWrite, cacheWrite1h, inclusiveInput: inputTokens } = inclusiveMessagesInputUsage(state.usage); const cacheCreation = cacheWrite + cacheWrite1h; const hasCacheCreation = state.usage.cache_creation_input_tokens !== undefined || state.usage.cache_creation?.ephemeral_5m_input_tokens !== undefined || state.usage.cache_creation?.ephemeral_1h_input_tokens !== undefined; - const inputTokens = (state.usage.input_tokens ?? 0) + cacheRead + cacheCreation; - // Anthropic's `speed: 'fast'` surfaces as OpenAI `service_tier: 'fast'`; - // all other Anthropic service_tier values pass through directly. - const serviceTier = state.usage.speed === 'fast' ? 'fast' : state.usage.service_tier; + const serviceTier = openAIServiceTierFromMessagesUsage(state.usage); return responses.result({ id: state.responseId, diff --git a/packages/translate/src/responses-via-messages/request.ts b/packages/translate/src/responses-via-messages/request.ts index e5a42adcb8..c9863f249b 100644 --- a/packages/translate/src/responses-via-messages/request.ts +++ b/packages/translate/src/responses-via-messages/request.ts @@ -3,9 +3,11 @@ import { responsesReasoningToMessagesUpstreamBlock } from '../shared/messages-an import { buildCustomToolInputSchema } from '../shared/responses-via/custom-tool-wrap.ts'; import { rejectProgramCaller, rejectProgrammaticResponsesPayload } from '../shared/responses-via/programmatic-tooling.ts'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../shared/via-messages/cache-breakpoints.ts'; -import { type RemoteImageLoader, resolveImageUrlToMessagesImage, unavailableRemoteImageLoader } from '../shared/via-messages/remote-images.ts'; +import { resolveImageUrlToMessagesImage, unavailableRemoteImageLoader } from '../shared/via-messages/remote-images.ts'; +import { messagesServiceTierFieldsFromOpenAI } from '../shared/via-messages/service-tier.ts'; import { parseToolArgumentsObject } from '../shared/via-messages/tool-arguments.ts'; import { TranslatorInputError } from '../translator-input-error.ts'; +import type { RemoteImageLoader } from '../types.ts'; import { MESSAGES_FALLBACK_MAX_TOKENS, type MessagesAssistantContentBlock, @@ -30,7 +32,7 @@ import type { ResponsesToolChoice, } from '@floway-dev/protocols/responses'; -interface TranslateResponsesToMessagesOptions { +interface BuildTargetRequestOptions { loadRemoteImage?: RemoteImageLoader; /** * Preferred cap used when the source payload omits `max_output_tokens`. @@ -47,7 +49,7 @@ interface TranslateResponsesToMessagesOptions { * the trip's events translator can project wrapped function calls back into * `custom_tool_call` outputs. */ -export interface ResponsesToMessagesResult { +export interface TargetRequestResult { target: MessagesPayload; customToolNames: Set; } @@ -333,7 +335,7 @@ const translateToolChoice = (toolChoice: ResponsesToolChoice | null | undefined) return undefined; }; -export const translateResponsesToMessages = async (source: ResponsesRequestPayload, options: TranslateResponsesToMessagesOptions = {}): Promise => { +export const buildTargetRequest = async (source: ResponsesRequestPayload, options: BuildTargetRequestOptions = {}): Promise => { const payload = canonicalizeResponsesPayload(source); rejectProgrammaticResponsesPayload(payload, 'Messages'); const customToolNames = new Set(); @@ -374,16 +376,7 @@ export const translateResponsesToMessages = async (source: ResponsesRequestPaylo const thinking = effort === 'none' ? { type: 'disabled' as const } : undefined; - // `service_tier: 'fast'` from the Responses caller maps to Anthropic's - // `speed: 'fast'`; all other defined service_tier values pass through as - // `service_tier` on the Messages wire (Anthropic accepts 'auto', - // 'standard_only', and future literals). - const serviceTierFields: Partial = - payload.service_tier === 'fast' - ? { speed: 'fast' } - : payload.service_tier != null - ? { service_tier: payload.service_tier } - : {}; + const serviceTierFields = messagesServiceTierFieldsFromOpenAI(payload.service_tier); // Responses `metadata` is intentionally omitted on the Messages path; // not coerced into Anthropic metadata.user_id, prompt-cache, or safety @@ -405,6 +398,3 @@ export const translateResponsesToMessages = async (source: ResponsesRequestPaylo return { target, customToolNames }; }; - -export const buildTargetRequest = (payload: ResponsesRequestPayload, options: TranslateResponsesToMessagesOptions): Promise => - translateResponsesToMessages(payload, options); diff --git a/packages/translate/src/responses-via-messages/request_test.ts b/packages/translate/src/responses-via-messages/request_test.ts index 999837e9b4..2037fee464 100644 --- a/packages/translate/src/responses-via-messages/request_test.ts +++ b/packages/translate/src/responses-via-messages/request_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { translateResponsesToMessages } from './request.ts'; +import { buildTargetRequest } from './request.ts'; import { MESSAGES_FALLBACK_MAX_TOKENS, type MessagesClientTool, type MessagesToolResultBlock, type MessagesUserContentBlock } from '@floway-dev/protocols/messages'; import type { ResponsesAgentMessageContent, ResponsesInputMultiAgentCallOutputItem, ResponsesTool } from '@floway-dev/protocols/responses'; import { assert, assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; @@ -22,8 +22,8 @@ const minimalPayload = { parallel_tool_calls: true, }; -test('translateResponsesToMessages accepts an implicit message discriminator', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest accepts an implicit message discriminator', async () => { + const result = await buildTargetRequest({ ...minimalPayload, input: [{ role: 'user', content: 'hello' }], }); @@ -45,69 +45,38 @@ test.each([ { name: 'multi_agent_call_output', input: [{ type: 'multi_agent_call_output', action: 'spawn_agent', call_id: 'call_1', output: [] as ResponsesInputMultiAgentCallOutputItem['output'] }] }, { name: 'context_compaction', input: [{ type: 'context_compaction', encrypted_content: 'opaque' }] }, { name: 'item_reference', input: [{ type: 'item_reference', id: 'msg_1' }] }, -] as const)('translateResponsesToMessages rejects Responses-only $name input', async ({ name, input }) => { +] as const)('buildTargetRequest rejects Responses-only $name input', async ({ name, input }) => { await assertRejects( - () => translateResponsesToMessages({ ...minimalPayload, input: [...input] }), + () => buildTargetRequest({ ...minimalPayload, input: [...input] }), Error, name, ); }); -test.each([ - { type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{}', status: 'completed', caller: { type: 'program', caller_id: 'call_prog_1' } }, - { type: 'function_call_output', call_id: 'call_1', output: 'ok', caller: { type: 'program', caller_id: 'call_prog_1' } }, - { type: 'custom_tool_call', call_id: 'call_1', name: 'exec', input: 'run', caller: { type: 'program', caller_id: 'call_prog_1' } }, - { type: 'custom_tool_call_output', call_id: 'call_1', output: 'ok', caller: { type: 'program', caller_id: 'call_prog_1' } }, -] as const)('translateResponsesToMessages rejects $type program caller metadata', async item => { +test('buildTargetRequest wires Responses tooling guards', async () => { await assertRejects( - () => translateResponsesToMessages({ ...minimalPayload, input: [item] }), + () => buildTargetRequest({ + ...minimalPayload, + input: [{ type: 'function_call_output', call_id: 'call_1', output: 'ok', caller: { type: 'program', caller_id: 'call_prog_1' } }], + }), Error, 'program caller', ); -}); - -test('translateResponsesToMessages accepts null tool_choice', async () => { - const result = await translateResponsesToMessages({ ...minimalPayload, tool_choice: null }); - assertEquals(result.target.tool_choice, undefined); -}); - -test.each([ - { name: 'programmatic tool', payload: { tools: [{ type: 'programmatic_tool_calling' as const }] } }, - { name: 'programmatic allowed caller', payload: { tools: [{ type: 'function' as const, name: 'lookup', parameters: {}, strict: true, allowed_callers: ['programmatic' as const] }] } }, - { name: 'programmatic tool choice', payload: { tool_choice: { type: 'programmatic_tool_calling' as const } } }, -])('translateResponsesToMessages rejects $name', async ({ payload }) => { await assertRejects( - () => translateResponsesToMessages({ ...minimalPayload, ...payload }), + () => buildTargetRequest({ ...minimalPayload, tools: [{ type: 'programmatic_tool_calling' }] }), Error, 'Programmatic', ); }); -test.each([ - { type: 'function' as const, name: 'lookup', parameters: {}, strict: true, defer_loading: true }, - { type: 'custom' as const, name: 'exec', defer_loading: true }, -])('translateResponsesToMessages rejects deferred $type tools', async tool => { - await assertRejects( - () => translateResponsesToMessages({ ...minimalPayload, tools: [tool] }), - Error, - 'Deferred', - ); -}); - -test('translateResponsesToMessages rejects nested namespace programmatic callers', async () => { - await assertRejects( - () => translateResponsesToMessages({ - ...minimalPayload, - tools: [{ type: 'namespace', name: 'ops', description: 'ops', tools: [{ type: 'custom', name: 'exec', allowed_callers: ['programmatic'] }] } as unknown as ResponsesTool], - }), - Error, - 'Programmatic', - ); +test('buildTargetRequest accepts null tool_choice', async () => { + const result = await buildTargetRequest({ ...minimalPayload, tool_choice: null }); + assertEquals(result.target.tool_choice, undefined); }); -test('translateResponsesToMessages rejects multimodal custom tool output', async () => { +test('buildTargetRequest rejects multimodal custom tool output', async () => { await assertRejects( - () => translateResponsesToMessages({ + () => buildTargetRequest({ ...minimalPayload, input: [{ type: 'custom_tool_call_output', call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }], }), @@ -116,9 +85,9 @@ test('translateResponsesToMessages rejects multimodal custom tool output', async ); }); -test('translateResponsesToMessages rejects file tool output', async () => { +test('buildTargetRequest rejects file tool output', async () => { await assertRejects( - () => translateResponsesToMessages({ + () => buildTargetRequest({ ...minimalPayload, input: [{ type: 'function_call_output', call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }], }), @@ -127,9 +96,9 @@ test('translateResponsesToMessages rejects file tool output', async () => { ); }); -test('translateResponsesToMessages rejects file message content', async () => { +test('buildTargetRequest rejects file message content', async () => { await assertRejects( - () => translateResponsesToMessages({ + () => buildTargetRequest({ ...minimalPayload, input: [{ type: 'message', role: 'user', content: [{ type: 'input_file', file_id: 'file_1' }] }], }), @@ -138,9 +107,9 @@ test('translateResponsesToMessages rejects file message content', async () => { ); }); -test('translateResponsesToMessages rejects file assistant content', async () => { +test('buildTargetRequest rejects file assistant content', async () => { await assertRejects( - () => translateResponsesToMessages({ + () => buildTargetRequest({ ...minimalPayload, input: [{ type: 'message', role: 'assistant', content: [{ type: 'input_file', file_id: 'file_1' }] }], }), @@ -149,8 +118,8 @@ test('translateResponsesToMessages rejects file assistant content', async () => ); }); -test('translateResponsesToMessages preserves assistant input_text', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest preserves assistant input_text', async () => { + const result = await buildTargetRequest({ ...minimalPayload, input: [{ type: 'message', role: 'assistant', content: [{ type: 'input_text', text: 'prior reply' }] }], }); @@ -160,9 +129,9 @@ test('translateResponsesToMessages preserves assistant input_text', async () => ]); }); -test('translateResponsesToMessages rejects assistant images', async () => { +test('buildTargetRequest rejects assistant images', async () => { await assertRejects( - () => translateResponsesToMessages({ + () => buildTargetRequest({ ...minimalPayload, input: [{ type: 'message', role: 'assistant', content: [{ type: 'input_image', image_url: 'https://example.com/a.png', detail: 'auto' }] }], }), @@ -171,9 +140,9 @@ test('translateResponsesToMessages rejects assistant images', async () => { ); }); -test('translateResponsesToMessages rejects file_id-only images', async () => { +test('buildTargetRequest rejects file_id-only images', async () => { await assertRejects( - () => translateResponsesToMessages({ + () => buildTargetRequest({ ...minimalPayload, input: [{ type: 'message', role: 'user', content: [{ type: 'input_image', file_id: 'file_1', detail: 'auto' }] }], }), @@ -182,9 +151,9 @@ test('translateResponsesToMessages rejects file_id-only images', async () => { ); }); -test('translateResponsesToMessages rejects file_id-only image tool output', async () => { +test('buildTargetRequest rejects file_id-only image tool output', async () => { await assertRejects( - () => translateResponsesToMessages({ + () => buildTargetRequest({ ...minimalPayload, input: [{ type: 'function_call_output', call_id: 'call_1', output: [{ type: 'input_image', file_id: 'file_1', detail: 'auto' }] }], }), @@ -195,36 +164,36 @@ test('translateResponsesToMessages rejects file_id-only image tool output', asyn // ── service_tier → speed mapping ── -test('translateResponsesToMessages maps service_tier:fast to speed:fast (no service_tier on target)', async () => { - const result = await translateResponsesToMessages({ ...minimalPayload, service_tier: 'fast' }); +test('buildTargetRequest maps service_tier:fast to speed:fast (no service_tier on target)', async () => { + const result = await buildTargetRequest({ ...minimalPayload, service_tier: 'fast' }); assertEquals(result.target.speed, 'fast'); assertFalse('service_tier' in result.target); }); -test('translateResponsesToMessages passes service_tier:priority through as service_tier (no speed override)', async () => { - const result = await translateResponsesToMessages({ ...minimalPayload, service_tier: 'priority' }); +test('buildTargetRequest passes service_tier:priority through as service_tier (no speed override)', async () => { + const result = await buildTargetRequest({ ...minimalPayload, service_tier: 'priority' }); assertEquals(result.target.service_tier, 'priority'); assertFalse('speed' in result.target); }); -test('translateResponsesToMessages passes service_tier:auto through as service_tier', async () => { - const result = await translateResponsesToMessages({ ...minimalPayload, service_tier: 'auto' }); +test('buildTargetRequest passes service_tier:auto through as service_tier', async () => { + const result = await buildTargetRequest({ ...minimalPayload, service_tier: 'auto' }); assertEquals(result.target.service_tier, 'auto'); assertFalse('speed' in result.target); }); -test('translateResponsesToMessages omits both speed and service_tier when service_tier is absent', async () => { - const result = await translateResponsesToMessages(minimalPayload); +test('buildTargetRequest omits both speed and service_tier when service_tier is absent', async () => { + const result = await buildTargetRequest(minimalPayload); assertFalse('speed' in result.target); assertFalse('service_tier' in result.target); }); -test('translateResponsesToMessages maps reasoning.effort none to thinking.disabled (summary ignored when reasoning is disabled)', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest maps reasoning.effort none to thinking.disabled (summary ignored when reasoning is disabled)', async () => { + const result = await buildTargetRequest({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], instructions: null, @@ -244,8 +213,8 @@ test('translateResponsesToMessages maps reasoning.effort none to thinking.disabl assertFalse('output_config' in result.target); }); -test('translateResponsesToMessages maps reasoning.effort directly to output_config.effort', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest maps reasoning.effort directly to output_config.effort', async () => { + const result = await buildTargetRequest({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], instructions: null, @@ -265,8 +234,8 @@ test('translateResponsesToMessages maps reasoning.effort directly to output_conf assertFalse('thinking' in result.target); }); -test('translateResponsesToMessages defaults max_tokens to MESSAGES_FALLBACK_MAX_TOKENS when neither source nor fallbackMaxOutputTokens supplies one', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest defaults max_tokens to MESSAGES_FALLBACK_MAX_TOKENS when neither source nor fallbackMaxOutputTokens supplies one', async () => { + const result = await buildTargetRequest({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], instructions: null, @@ -284,8 +253,8 @@ test('translateResponsesToMessages defaults max_tokens to MESSAGES_FALLBACK_MAX_ assertEquals(result.target.max_tokens, MESSAGES_FALLBACK_MAX_TOKENS); }); -test('translateResponsesToMessages uses fallbackMaxOutputTokens over the gateway const when the source omitted max_output_tokens', async () => { - const result = await translateResponsesToMessages( +test('buildTargetRequest uses fallbackMaxOutputTokens over the gateway const when the source omitted max_output_tokens', async () => { + const result = await buildTargetRequest( { model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -306,8 +275,8 @@ test('translateResponsesToMessages uses fallbackMaxOutputTokens over the gateway assertEquals(result.target.max_tokens, 4096); }); -test('translateResponsesToMessages sends the genuine encrypted_content as the upstream signature, with no gateway envelope', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest sends the genuine encrypted_content as the upstream signature, with no gateway envelope', async () => { + const result = await buildTargetRequest({ model: 'claude-test', input: [ { @@ -341,8 +310,8 @@ test('translateResponsesToMessages sends the genuine encrypted_content as the up }); }); -test('translateResponsesToMessages omits the signature for a reasoning with no encrypted_content', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest omits the signature for a reasoning with no encrypted_content', async () => { + const result = await buildTargetRequest({ model: 'claude-test', input: [ { @@ -371,8 +340,8 @@ test('translateResponsesToMessages omits the signature for a reasoning with no e assertEquals(assistant.content[0], { type: 'thinking', thinking: 'trace' }); }); -test('translateResponsesToMessages omits generic metadata instead of coercing it to metadata.user_id', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest omits generic metadata instead of coercing it to metadata.user_id', async () => { + const result = await buildTargetRequest({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], instructions: null, @@ -390,8 +359,8 @@ test('translateResponsesToMessages omits generic metadata instead of coercing it assertFalse('metadata' in result.target); }); -test('translateResponsesToMessages resolves remote input images through the shared loader', async () => { - const result = await translateResponsesToMessages( +test('buildTargetRequest resolves remote input images through the shared loader', async () => { + const result = await buildTargetRequest( { model: 'claude-test', input: [ @@ -444,8 +413,8 @@ test('translateResponsesToMessages resolves remote input images through the shar ]); }); -test('translateResponsesToMessages drops reasoning input without readable summary', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest drops reasoning input without readable summary', async () => { + const result = await buildTargetRequest({ model: 'gpt-test', input: [ { type: 'message', role: 'user', content: 'hi' }, @@ -477,8 +446,8 @@ test('translateResponsesToMessages drops reasoning input without readable summar ); }); -test('translateResponsesToMessages wraps custom tools as single-string function tools and records their names', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest wraps custom tools as single-string function tools and records their names', async () => { + const result = await buildTargetRequest({ model: 'claude-test', input: 'hi', instructions: null, @@ -524,8 +493,8 @@ test('translateResponsesToMessages wraps custom tools as single-string function assertEquals(result.target.tool_choice, { type: 'tool', name: 'apply_patch' }); }); -test('translateResponsesToMessages projects custom_tool_call history into wrapped tool_use shape', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest projects custom_tool_call history into wrapped tool_use shape', async () => { + const result = await buildTargetRequest({ model: 'claude-test', input: [ { type: 'message', role: 'user', content: 'apply this patch' }, @@ -579,8 +548,8 @@ test('translateResponsesToMessages projects custom_tool_call history into wrappe }); }); -test('translateResponsesToMessages keeps plain-text function_call_output as string content', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest keeps plain-text function_call_output as string content', async () => { + const result = await buildTargetRequest({ model: 'claude-test', input: [ { type: 'function_call', call_id: 'call_1', name: 'tool', arguments: '{}', status: 'completed' }, @@ -606,8 +575,8 @@ test('translateResponsesToMessages keeps plain-text function_call_output as stri assertEquals(toolResult.content, 'plain text body'); }); -test('translateResponsesToMessages maps multimodal function_call_output into tool_result image and text blocks', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest maps multimodal function_call_output into tool_result image and text blocks', async () => { + const result = await buildTargetRequest({ model: 'claude-test', input: [ { type: 'function_call', call_id: 'call_1', name: 'screenshot', arguments: '{}', status: 'completed' }, @@ -643,14 +612,14 @@ test('translateResponsesToMessages maps multimodal function_call_output into too ]); }); -test('translateResponsesToMessages throws on a stray web_search_call input item (shim owns the reverse path)', async () => { +test('buildTargetRequest throws on a stray web_search_call input item (shim owns the reverse path)', async () => { // The Responses web-search shim rewrites web_search_call input items into // upstream function_call + function_call_output pairs before this // translator runs. Reaching the translator with a raw web_search_call // means the shim regressed; the translator surfaces a loud error so the // bug is caught rather than silently dropping search context. await assertRejects( - () => translateResponsesToMessages({ + () => buildTargetRequest({ model: 'claude-test', input: [ { type: 'message', role: 'user', content: 'hi' }, @@ -677,13 +646,13 @@ test('translateResponsesToMessages throws on a stray web_search_call input item ); }); -test('translateResponsesToMessages throws on a stray compaction_trigger input item (compact-shim owns the strip)', async () => { +test('buildTargetRequest throws on a stray compaction_trigger input item (compact-shim owns the strip)', async () => { // The compact-shim is structurally required on non-responses targets and // strips compaction_trigger items before reaching this translator. // Reaching here with one in input means the shim disengaged; the // translator's exhaustive default surfaces the regression. await assertRejects( - () => translateResponsesToMessages({ + () => buildTargetRequest({ model: 'claude-test', input: [ { type: 'message', role: 'user', content: 'hi' }, @@ -705,13 +674,13 @@ test('translateResponsesToMessages throws on a stray compaction_trigger input it ); }); -test('translateResponsesToMessages throws on a stray compaction input item (compact-shim owns the expansion)', async () => { +test('buildTargetRequest throws on a stray compaction input item (compact-shim owns the expansion)', async () => { // The compact-shim expands its own shim-encoded compaction items inline // before reaching this translator and round-trips foreign compactions // back to the upstream as raw items. Either way the translator should // never see one. await assertRejects( - () => translateResponsesToMessages({ + () => buildTargetRequest({ model: 'claude-test', input: [ { type: 'message', role: 'user', content: 'hi' }, @@ -733,8 +702,8 @@ test('translateResponsesToMessages throws on a stray compaction input item (comp ); }); -test('translateResponsesToMessages attaches ephemeral cache breakpoints to system, last function tool, and last message block', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest attaches ephemeral cache breakpoints to system, last function tool, and last message block', async () => { + const result = await buildTargetRequest({ model: 'claude-test', input: [ { type: 'message', role: 'user', content: 'Look up the weather.' }, @@ -768,9 +737,9 @@ test('translateResponsesToMessages attaches ephemeral cache breakpoints to syste assertEquals(lastBlock.cache_control, { type: 'ephemeral' }); }); -test('translateResponsesToMessages extracts flat text.format json_schema into output_config.format and drops OpenAI-only fields', async () => { +test('buildTargetRequest extracts flat text.format json_schema into output_config.format and drops OpenAI-only fields', async () => { const schema = { type: 'object', properties: { x: { type: 'string' } }, required: ['x'], additionalProperties: false }; - const result = await translateResponsesToMessages({ + const result = await buildTargetRequest({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], instructions: null, @@ -789,9 +758,9 @@ test('translateResponsesToMessages extracts flat text.format json_schema into ou assertEquals(result.target.output_config, { format: { type: 'json_schema', schema } }); }); -test('translateResponsesToMessages merges reasoning.effort with structured-output format on a single output_config', async () => { +test('buildTargetRequest merges reasoning.effort with structured-output format on a single output_config', async () => { const schema = { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false }; - const result = await translateResponsesToMessages({ + const result = await buildTargetRequest({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], instructions: null, @@ -811,8 +780,8 @@ test('translateResponsesToMessages merges reasoning.effort with structured-outpu assertEquals(result.target.output_config, { effort: 'high', format: { type: 'json_schema', schema } }); }); -test('translateResponsesToMessages drops text.format json_object (no Anthropic equivalent)', async () => { - const result = await translateResponsesToMessages({ +test('buildTargetRequest drops text.format json_object (no Anthropic equivalent)', async () => { + const result = await buildTargetRequest({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], instructions: null, @@ -831,8 +800,8 @@ test('translateResponsesToMessages drops text.format json_object (no Anthropic e assertFalse('output_config' in result.target); }); -test('translateResponsesToMessages hoists leading role:"system" to top-level system field', async () => { - const result = await translateResponsesToMessages( +test('buildTargetRequest hoists leading role:"system" to top-level system field', async () => { + const result = await buildTargetRequest( { model: 'claude-test', input: [ @@ -858,8 +827,8 @@ test('translateResponsesToMessages hoists leading role:"system" to top-level sys assertEquals(result.target.messages[0].role, 'user'); }); -test('translateResponsesToMessages keeps non-leading role:"system" inline', async () => { - const result = await translateResponsesToMessages( +test('buildTargetRequest keeps non-leading role:"system" inline', async () => { + const result = await buildTargetRequest( { model: 'claude-test', input: [ @@ -888,8 +857,8 @@ test('translateResponsesToMessages keeps non-leading role:"system" inline', asyn assertFalse('system' in result.target); }); -test('translateResponsesToMessages hoists leading role:"developer" to top-level system field', async () => { - const result = await translateResponsesToMessages( +test('buildTargetRequest hoists leading role:"developer" to top-level system field', async () => { + const result = await buildTargetRequest( { model: 'claude-test', input: [ @@ -915,8 +884,8 @@ test('translateResponsesToMessages hoists leading role:"developer" to top-level assertEquals(result.target.system, [{ type: 'text', text: 'dev rule', cache_control: { type: 'ephemeral' } }]); }); -test('translateResponsesToMessages preserves payload.instructions and leading system as separate blocks; non-leading stays inline', async () => { - const result = await translateResponsesToMessages( +test('buildTargetRequest preserves payload.instructions and leading system as separate blocks; non-leading stays inline', async () => { + const result = await buildTargetRequest( { model: 'claude-test', input: [ @@ -949,10 +918,10 @@ test('translateResponsesToMessages preserves payload.instructions and leading sy assertEquals(result.target.messages[2].role, 'user'); }); -test('translateResponsesToMessages throws when a system input message contains an image part', async () => { +test('buildTargetRequest throws when a system input message contains an image part', async () => { await assertRejects( () => - translateResponsesToMessages( + buildTargetRequest( { model: 'claude-test', input: [ @@ -984,10 +953,10 @@ test('translateResponsesToMessages throws when a system input message contains a ); }); -test('translateResponsesToMessages throws when a non-leading developer input message contains an image part', async () => { +test('buildTargetRequest throws when a non-leading developer input message contains an image part', async () => { await assertRejects( () => - translateResponsesToMessages( + buildTargetRequest( { model: 'claude-test', input: [ @@ -1020,5 +989,5 @@ test('translateResponsesToMessages throws when a non-leading developer input mes }); // (Native native↔native only — Responses no longer carries thinking-mode -// extension fields; alias overlays land on the Messages IR at the wire +// extension fields; alias overlays land on the Messages target payload at the wire // call via `applyRulesToUpstreamMessages`.) diff --git a/packages/translate/src/responses-via-messages/translate.ts b/packages/translate/src/responses-via-messages/translate.ts index 3fe636b9fd..4925ec6568 100644 --- a/packages/translate/src/responses-via-messages/translate.ts +++ b/packages/translate/src/responses-via-messages/translate.ts @@ -1,7 +1,6 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { RemoteImageLoader } from '../shared/via-messages/remote-images.ts'; -import type { TranslateTrip } from '../types.ts'; +import type { RemoteImageLoader, TranslateTrip } from '../types.ts'; import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; import type { ResponsesRequestPayload, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; diff --git a/packages/translate/src/shared/AGENTS.md b/packages/translate/src/shared/AGENTS.md index 4939ea9cc3..f18877344c 100644 --- a/packages/translate/src/shared/AGENTS.md +++ b/packages/translate/src/shared/AGENTS.md @@ -1,55 +1,49 @@ # `shared/` Convention -Helpers in this folder are translate-internal. Their location encodes who is -allowed to import them. Pick the folder before writing the file; do not put -flat `.ts` files at the top level of `shared/`. +Helpers in this folder are translate-internal. Their location is an import +ceiling over the sibling translation-pair directories. Pick the narrowest +matching category before writing a helper; do not put flat `.ts` files at the +top level of `shared/`. ## Categories -1. **Single-pair helper** — inline into the pair's directory - (`messages-via-responses/`, etc.). Do not extract. -2. **Source-locked, `-via/`** — helpers shared by every pair that has `X` - as the source. Example: `responses-via/` is consumed only by - `responses-via-*` pairs. -3. **Target-locked, `via-/`** — helpers shared by every pair that has `Y` - as the target. Example: `via-messages/` is consumed only by - `*-via-messages` pairs. -4. **One-protocol-bidirectional, `

/`** — helpers used wherever protocol `P` - appears as either source or target. -5. **Two-protocol-bidirectional, `-and-/`** — helpers used by both - `A-via-B` and `B-via-A`. Example: +1. **Single-pair helper** — keep it in the pair's directory + (`messages-via-responses/`, etc.). Do not extract it into `shared/`. +2. **Source-locked, `-via/`** — only `X-via-*` pairs may import the helper. + A helper does not need to serve every pair within that ceiling. +3. **Target-locked, `via-/`** — only `*-via-Y` pairs may import the helper. + A helper does not need to serve every pair within that ceiling. +4. **One-protocol-bidirectional, `

/`** — only pairs with `P` as either source + or target may import the helper. +5. **Two-protocol-bidirectional, `-and-/`** — only the `A-via-B` and + `B-via-A` pairs may import the helper. For example, `chat-completions-and-responses/reasoning.ts` runs both directions of the Chat Completions ↔ Responses reasoning round trip. ## Current subdirectories -- `chat-completions-and-responses/` — helpers used by both +- `chat-completions-and-responses/` — available only to `chat-completions-via-responses` and `responses-via-chat-completions`. -- `chat-completions-and-messages/` — helpers used by both +- `chat-completions-and-messages/` — available only to `chat-completions-via-messages` and `messages-via-chat-completions`. -- `messages-and-responses/` — helpers used by both `messages-via-responses` and +- `messages-and-responses/` — available only to `messages-via-responses` and `responses-via-messages`. -- `messages-via/` — helpers used by all `messages-via-*` pairs - (source-locked). -- `responses-via/` — helpers used by all `responses-via-*` pairs - (source-locked). -- `gemini-via/` — helpers used by all `gemini-via-*` pairs (source-locked). -- `via-messages/` — helpers used by all `*-via-messages` pairs - (target-locked). -- `via-responses/` — helpers used by all `*-via-responses` pairs - (target-locked). +- `messages-via/` — available only to `messages-via-*` pairs. +- `responses-via/` — available only to `responses-via-*` pairs. +- `gemini-via/` — available only to `gemini-via-*` pairs. +- `via-messages/` — available only to `*-via-messages` pairs. +- `via-responses/` — available only to `*-via-responses` pairs. ## Rules -- Shallow wrappers (one-liners that only rename or stringify) must be inlined - at every call site, not extracted. The shim file should be deleted. -- Flat `.ts` files at the top level of `shared/` are forbidden. Every helper - lives in one of the five categories above. -- Helpers that do not fit any of the five categories must be inlined into - every consumer. Do not invent new folder patterns without explicit - confirmation. If a helper feels like it does not belong to translation at - all (defending against degenerate upstream streams, etc.), it belongs to a - `packages/gateway` interceptor instead. +- Shallow wrappers that only rename or stringify must be inlined at every call + site, not extracted. Delete the wrapper rather than retaining a shim. +- Flat `.ts` files at the top level of `shared/` are forbidden. Every shared + helper lives in one of the categories above. +- Helpers that fit no category stay in their pair directories. Do not invent a + folder pattern without explicit confirmation. +- A helper that is not translation logic, such as defense against a malformed + upstream stream, belongs to the gateway boundary that owns that policy. See the project root `AGENTS.md` for package boundary rules (`packages/protocols` vs `packages/translate` vs `packages/gateway`). diff --git a/packages/translate/src/shared/gemini-via/gemini.ts b/packages/translate/src/shared/gemini-via/gemini.ts index 3cb8819e5d..3a0aaebfbf 100644 --- a/packages/translate/src/shared/gemini-via/gemini.ts +++ b/packages/translate/src/shared/gemini-via/gemini.ts @@ -25,8 +25,6 @@ export type GeminiSupportedImageMimeType = (typeof GEMINI_SUPPORTED_IMAGE_MIME_T export const geminiToolCallId = (turnIndex: number, partIndex: number): string => `gemini_call_${turnIndex}_${partIndex}`; -export const geminiReasoningId = (turnIndex: number, partIndex: number): string => `gemini_reasoning_${turnIndex}_${partIndex}`; - export type GeminiPartKind = 'text' | 'inline_data' | 'function_call' | 'function_response' | 'file_data' | 'executable_code' | 'code_execution_result'; type GeminiPartDataField = keyof Omit; diff --git a/packages/translate/src/shared/messages-via/service-tier.ts b/packages/translate/src/shared/messages-via/service-tier.ts new file mode 100644 index 0000000000..3e6180e171 --- /dev/null +++ b/packages/translate/src/shared/messages-via/service-tier.ts @@ -0,0 +1,8 @@ +import type { MessagesPayload } from '@floway-dev/protocols/messages'; + +// `speed: 'fast'` maps to OpenAI `service_tier: 'fast'`; other non-fast +// `speed` values have no OpenAI equivalent and are dropped. When `speed` is +// absent, Anthropic's own `service_tier` passes through verbatim. +// https://docs.claude.com/en/build-with-claude/fast-mode +export const openAIServiceTierFromMessages = (payload: Pick): string | undefined => + payload.speed === 'fast' ? 'fast' : payload.speed === undefined ? payload.service_tier : undefined; diff --git a/packages/translate/src/shared/messages-via/tool-result.ts b/packages/translate/src/shared/messages-via/tool-result.ts new file mode 100644 index 0000000000..f1926626a7 --- /dev/null +++ b/packages/translate/src/shared/messages-via/tool-result.ts @@ -0,0 +1,14 @@ +import type { MessagesTextBlock, MessagesToolResultBlock } from '@floway-dev/protocols/messages'; + +export const flattenMessagesToolResult = (content: MessagesToolResultBlock['content']): string => { + if (typeof content === 'string') { + return content; + } + + const textBlocks = content.filter((block): block is MessagesTextBlock => block.type === 'text'); + if (textBlocks.length === content.length) { + return textBlocks.map(block => block.text).join('\n\n'); + } + + return JSON.stringify(content); +}; diff --git a/packages/translate/src/shared/responses-via/programmatic-tooling_test.ts b/packages/translate/src/shared/responses-via/programmatic-tooling_test.ts new file mode 100644 index 0000000000..c549d90160 --- /dev/null +++ b/packages/translate/src/shared/responses-via/programmatic-tooling_test.ts @@ -0,0 +1,78 @@ +import { expect, test } from 'vitest'; + +import { rejectProgramCaller, rejectProgrammaticResponsesPayload } from './programmatic-tooling.ts'; +import type { ResponsesInputItem, ResponsesPayload, ResponsesTool } from '@floway-dev/protocols/responses'; + +const programCallerItems = [ + { type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{}', status: 'completed', caller: { type: 'program', caller_id: 'call_prog_1' } }, + { type: 'function_call_output', call_id: 'call_1', output: 'ok', caller: { type: 'program', caller_id: 'call_prog_1' } }, + { type: 'custom_tool_call', call_id: 'call_1', name: 'exec', input: 'run', caller: { type: 'program', caller_id: 'call_prog_1' } }, + { type: 'custom_tool_call_output', call_id: 'call_1', output: 'ok', caller: { type: 'program', caller_id: 'call_prog_1' } }, +] as const satisfies readonly ResponsesInputItem[]; + +test.each(programCallerItems)('rejectProgramCaller rejects $type program caller metadata', item => { + expect(() => rejectProgramCaller(item)).toThrow('program caller'); +}); + +const payloadCases: Array<{ + name: string; + payload: Partial; + message: string; +}> = [ + { + name: 'programmatic tool', + payload: { tools: [{ type: 'programmatic_tool_calling' }] }, + message: 'Programmatic', + }, + { + name: 'programmatic allowed caller', + payload: { tools: [{ type: 'function', name: 'lookup', parameters: {}, strict: true, allowed_callers: ['programmatic'] }] }, + message: 'Programmatic', + }, + { + name: 'programmatic tool choice', + payload: { tool_choice: { type: 'programmatic_tool_calling' } }, + message: 'Programmatic', + }, + { + name: 'deferred function tool', + payload: { tools: [{ type: 'function', name: 'lookup', parameters: {}, strict: true, defer_loading: true }] }, + message: 'Deferred', + }, + { + name: 'deferred custom tool', + payload: { tools: [{ type: 'custom', name: 'exec', defer_loading: true }] }, + message: 'Deferred', + }, + { + name: 'nested namespace programmatic caller', + payload: { + tools: [{ + type: 'namespace', + name: 'ops', + description: 'ops', + tools: [{ type: 'custom', name: 'exec', allowed_callers: ['programmatic'] }], + } as unknown as ResponsesTool], + }, + message: 'Programmatic', + }, +]; + +const targetPayloadCases = ['Chat Completions', 'Messages'].flatMap(target => + payloadCases.map(testCase => ({ target, ...testCase }))); + +test.each(targetPayloadCases)('rejectProgrammaticResponsesPayload rejects $name for $target', ({ target, payload, message }) => { + expect(() => rejectProgrammaticResponsesPayload({ model: 'gpt-test', input: [], ...payload }, target)).toThrow(message); +}); + +test('rejectProgramCaller accepts ordinary callers', () => { + expect(() => rejectProgramCaller({ type: 'function_call_output', call_id: 'call_1', output: 'ok' })).not.toThrow(); +}); + +test('rejectProgrammaticResponsesPayload accepts ordinary function tooling', () => { + expect(() => rejectProgrammaticResponsesPayload({ + model: 'gpt-test', + input: [], + tools: [{ type: 'function', name: 'lookup', parameters: {}, strict: true }], + }, 'Messages')).not.toThrow(); +}); diff --git a/packages/translate/src/shared/via-messages/cache-breakpoints.ts b/packages/translate/src/shared/via-messages/cache-breakpoints.ts index 1486bb298c..644bf5786e 100644 --- a/packages/translate/src/shared/via-messages/cache-breakpoints.ts +++ b/packages/translate/src/shared/via-messages/cache-breakpoints.ts @@ -22,16 +22,14 @@ import type { MessagesAssistantContentBlock, - MessagesImageBlock, + MessagesCacheControl, MessagesMessage, MessagesTextBlock, MessagesTool, - MessagesToolResultBlock, - MessagesToolUseBlock, MessagesUserContentBlock, } from '@floway-dev/protocols/messages'; -export const EPHEMERAL_CACHE_CONTROL = { type: 'ephemeral' } as const; +export const EPHEMERAL_CACHE_CONTROL: MessagesCacheControl = { type: 'ephemeral' }; export const applyLastToolCacheBreakpoint = (tools: MessagesTool[] | undefined): void => { if (!tools || tools.length === 0) return; @@ -47,11 +45,6 @@ export const applyLastToolCacheBreakpoint = (tools: MessagesTool[] | undefined): } }; -type CacheableContentBlock = MessagesTextBlock | MessagesImageBlock | MessagesToolUseBlock | MessagesToolResultBlock; - -const isCacheableBlock = (block: MessagesUserContentBlock | MessagesAssistantContentBlock): block is CacheableContentBlock => - block.type === 'text' || block.type === 'image' || block.type === 'tool_use' || block.type === 'tool_result'; - export const applyLastSystemCacheBreakpoint = (system: MessagesTextBlock[] | undefined): void => { if (!system || system.length === 0) return; system[system.length - 1].cache_control = EPHEMERAL_CACHE_CONTROL; @@ -71,7 +64,7 @@ export const applyLastMessageCacheBreakpoint = (messages: MessagesMessage[]): vo for (let b = message.content.length - 1; b >= 0; b--) { const block = message.content[b]; - if (isCacheableBlock(block)) { + if (block.type === 'text' || block.type === 'image' || block.type === 'tool_use' || block.type === 'tool_result') { block.cache_control = EPHEMERAL_CACHE_CONTROL; return; } diff --git a/packages/translate/src/shared/via-messages/remote-images.ts b/packages/translate/src/shared/via-messages/remote-images.ts index 51276ff0fb..c3d6f7b8fa 100644 --- a/packages/translate/src/shared/via-messages/remote-images.ts +++ b/packages/translate/src/shared/via-messages/remote-images.ts @@ -1,14 +1,8 @@ +import type { RemoteImageLoader } from '../../types.ts'; import type { MessagesImageBlock } from '@floway-dev/protocols/messages'; const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); -export interface RemoteImageData { - mediaType: string | null; - data: Uint8Array; -} - -export type RemoteImageLoader = (url: string) => Promise; - // Pure translation has no runtime egress of its own. Direct callers that omit // a loader retain the existing "unavailable remote image is dropped" semantic; // gateway call sites inject the platform-backed loader explicitly. diff --git a/packages/translate/src/shared/via-messages/service-tier.ts b/packages/translate/src/shared/via-messages/service-tier.ts new file mode 100644 index 0000000000..43607d7a53 --- /dev/null +++ b/packages/translate/src/shared/via-messages/service-tier.ts @@ -0,0 +1,15 @@ +import type { MessagesPayload, MessagesUsageSnapshot } from '@floway-dev/protocols/messages'; + +// OpenAI `service_tier: 'fast'` maps to Anthropic `speed: 'fast'`; all other +// defined values pass through as `service_tier` so upstream-owned literals +// remain opaque in both directions. +// https://docs.claude.com/en/build-with-claude/fast-mode +export const messagesServiceTierFieldsFromOpenAI = (serviceTier: string | null | undefined): Partial => + serviceTier === 'fast' + ? { speed: 'fast' } + : serviceTier != null + ? { service_tier: serviceTier } + : {}; + +export const openAIServiceTierFromMessagesUsage = (usage: Pick): string | undefined => + usage.speed === 'fast' ? 'fast' : usage.service_tier; diff --git a/packages/translate/src/shared/via-messages/usage.ts b/packages/translate/src/shared/via-messages/usage.ts new file mode 100644 index 0000000000..1ac0294daa --- /dev/null +++ b/packages/translate/src/shared/via-messages/usage.ts @@ -0,0 +1,25 @@ +import { splitMessagesCacheCreationTokens, type MessagesUsageSnapshot } from '@floway-dev/protocols/messages'; + +export interface InclusiveMessagesInputUsage { + input: number; + cacheRead: number; + cacheWrite: number; + cacheWrite1h: number; + inclusiveInput: number; +} + +// Anthropic's `input_tokens` excludes cache reads and cache creation, while +// OpenAI and Gemini input totals include those buckets. +export const inclusiveMessagesInputUsage = (usage: MessagesUsageSnapshot): InclusiveMessagesInputUsage => { + const { cacheWrite, cacheWrite1h } = splitMessagesCacheCreationTokens(usage); + const input = usage.input_tokens ?? 0; + const cacheRead = usage.cache_read_input_tokens ?? 0; + + return { + input, + cacheRead, + cacheWrite, + cacheWrite1h, + inclusiveInput: input + cacheRead + cacheWrite + cacheWrite1h, + }; +}; diff --git a/packages/translate/src/shared/via-responses/responses-stream.ts b/packages/translate/src/shared/via-responses/responses-stream.ts index 1bf0e6250e..c70368d3b3 100644 --- a/packages/translate/src/shared/via-responses/responses-stream.ts +++ b/packages/translate/src/shared/via-responses/responses-stream.ts @@ -1,5 +1 @@ -import type { ResponsesStreamEvent } from '@floway-dev/protocols/responses'; - -export type ResponsesEvent = Extract; - export const responsesPartKey = (outputIndex: number, partIndex: number): string => `${outputIndex}:${partIndex}`; diff --git a/packages/translate/src/types.ts b/packages/translate/src/types.ts index 21816a1f5f..396277fc88 100644 --- a/packages/translate/src/types.ts +++ b/packages/translate/src/types.ts @@ -1,5 +1,12 @@ import type { ProtocolFrame } from '@floway-dev/protocols/common'; +export interface RemoteImageData { + mediaType: string | null; + data: Uint8Array; +} + +export type RemoteImageLoader = (url: string) => Promise; + /** * Per-trip context. Carries the model name plus a per-pair-declared `TExtras` * shape that lists exactly the capability fields and runtime adapters the trip diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2da335931..ce357923f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@stylistic/eslint-plugin': specifier: 5.6.1 version: 5.6.1(eslint@9.39.1(jiti@2.6.1)) + '@types/node': + specifier: ^22 + version: 22.19.19 '@typescript-eslint/eslint-plugin': specifier: 8.48.1 version: 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) @@ -251,6 +254,9 @@ importers: '@floway-dev/test-utils': specifier: workspace:* version: link:../test-utils + '@types/node': + specifier: ^22 + version: 22.19.19 packages/gateway: dependencies: @@ -327,13 +333,6 @@ importers: '@reclaimprotocol/tls': specifier: 0.1.2 version: 0.1.2(patch_hash=8ed07af54e914cbcc2cea19ce7109635092cbbe35b9d2ad1bf55e3dfef1b8fd6) - devDependencies: - '@types/node': - specifier: ^22 - version: 22.19.19 - typescript: - specifier: ^5.9.3 - version: 5.9.3 packages/interceptor: devDependencies: @@ -359,9 +358,6 @@ importers: packages/provider: dependencies: - '@floway-dev/platform': - specifier: workspace:* - version: link:../platform '@floway-dev/protocols': specifier: workspace:* version: link:../protocols @@ -477,13 +473,6 @@ importers: '@reclaimprotocol/tls': specifier: 0.1.2 version: 0.1.2(patch_hash=8ed07af54e914cbcc2cea19ce7109635092cbbe35b9d2ad1bf55e3dfef1b8fd6) - devDependencies: - '@types/node': - specifier: ^22 - version: 22.19.19 - typescript: - specifier: ^5.9.3 - version: 5.9.3 packages/test-utils: dependencies: diff --git a/scripts/check-wrangler.ts b/scripts/check-wrangler.ts index 78d626d634..480d767c42 100644 --- a/scripts/check-wrangler.ts +++ b/scripts/check-wrangler.ts @@ -35,6 +35,7 @@ interface Mismatch { } const fmt = (v: unknown): string => JSON.stringify(v); +const isArray = (value: unknown): value is readonly unknown[] => Array.isArray(value); const isBindingArray = (arr: readonly unknown[]): arr is ReadonlyArray> => arr.length > 0 && arr.every( @@ -49,7 +50,7 @@ const compare = (expected: unknown, actual: unknown, path: string, out: Mismatch return; } - if (Array.isArray(expected)) { + if (isArray(expected)) { if (!Array.isArray(actual)) { out.push({ path, reason: `expected array, got ${fmt(actual)}` }); return; diff --git a/tsconfig.base.json b/tsconfig.base.json index 378a648db0..a52f6d1931 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -5,6 +5,7 @@ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "strict": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": [] } } diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000000..62dc913d85 --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["scripts/**/*.ts"] +} diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc index 02c0de72fc..abe57ac41c 100644 --- a/wrangler.example.jsonc +++ b/wrangler.example.jsonc @@ -20,8 +20,8 @@ // 3. Run `pnpm wrangler d1 create ` and paste the returned // `database_id` below; `database_name` is the same value you passed. // 4. Run `pnpm wrangler r2 bucket create ` for the FILES -// bucket (general-purpose spillover store), then put the name in the -// matching `bucket_name` below. +// bucket. It stores file-backed request/response dump bodies and oversized +// Stateful Responses item payloads. Put its name in `bucket_name` below. // 5. Run `pnpm wrangler kv namespace create ` and paste the returned // id into `kv_namespaces[0].id`. { @@ -29,9 +29,9 @@ "name": "", "main": "apps/platform-cloudflare/entry.ts", "compatibility_date": "2025-01-01", - // `@reclaimprotocol/tls/webcrypto` (used by packages/http for userspace - // TLS) still `import { webcrypto } from 'crypto'`, so the Worker won't - // resolve modules at cold start without the Node compat flag. + // `@reclaimprotocol/tls/webcrypto` (used by packages/http and the proxy + // REALITY dialer) still imports `webcrypto` from `crypto`, so the Worker + // cannot resolve those modules at cold start without the Node compat flag. "compatibility_flags": ["nodejs_compat"], "triggers": { "crons": ["17 * * * *"] From a5faad27a537fda4aa6dec61a577fadbc28cb788 Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 27 Jul 2026 05:04:07 +0800 Subject: [PATCH 3/7] fix: correct two statements falsified by their own code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while auditing the two landed refactor commits against the tree. The README's Custom-provider row claimed live catalog discovery accepts an OpenAI-compatible `/models` only. The parser accepts three shapes — OpenAI, Anthropic, and the superset — so the row understated it. The PowerShell installer test searched for `function Set-ScriptAgent {` while the fragments define `Set-SetupAgent`. `indexOf` returned -1, so the ordering assertion degraded to `29331 > -1` and could no longer fail. The agent function was renamed without its assertion following. --- README.md | 2 +- packages/agent-setup/scripts/test-installers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d394e7cc84..198723c555 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ responses retain their upstream wire shape. | GitHub Copilot | GitHub device OAuth | Fetched live from Copilot | | Codex | ChatGPT subscription through the Codex CLI OAuth client | Fetched live from the Codex backend | | Claude Code | Claude.ai Pro, Max, Team, or Enterprise subscription through the Claude Code CLI OAuth client | Fetched live from Anthropic | -| Custom | Configurable multi-protocol HTTP endpoint and credential | Live OpenAI-compatible `/models`, manual models, or both | +| Custom | Configurable multi-protocol HTTP endpoint and credential | Live `/models` (OpenAI, Anthropic, or superset shapes), manual models, or both | | Azure | Azure AI resource or Foundry project endpoint and API key | Configured models | | Ollama | ollama.com or a self-hosted Ollama-compatible server | Fetched live from Ollama, with optional manual overrides | diff --git a/packages/agent-setup/scripts/test-installers.ts b/packages/agent-setup/scripts/test-installers.ts index 1f7ab793ec..0e35adeaae 100644 --- a/packages/agent-setup/scripts/test-installers.ts +++ b/packages/agent-setup/scripts/test-installers.ts @@ -1100,7 +1100,7 @@ test('claude', 'PowerShell installer body parses without syntax errors', async t const body = powerShellBody('claude'); const entry = powerShellEntry('claude'); t.ok(body.trimEnd().endsWith(entry), 'the downloaded script starts execution only from its final line'); - t.ok(body.lastIndexOf(entry) > body.indexOf('function Set-ScriptAgent {'), 'the entry call follows every agent function'); + t.ok(body.lastIndexOf(entry) > body.indexOf('function Set-SetupAgent {'), 'the entry call follows every agent function'); const script = renderPowerShellPrefix({ agent: 'claude', apiKey: SENTINEL_KEY, From 21415dbedc52e9c6b406b4990461c6b1aaf6452a Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 27 Jul 2026 05:37:28 +0800 Subject: [PATCH 4/7] refactor: make the tree's own statements true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited the two preceding commits against the tree, one job per landed work item and one per rejection, then executed what the audit recovered. The audit found the branch behavior-preserving: every accepted item preserves observable behavior, migrations are untouched, and every route literal, error code, and status literal is unchanged. What it found instead was residue. The rejection categories covered renames, moves, sweeps, decompositions, and behavior changes; none of them covered "make this statement true", so factual comment corrections had neither an owner nor a rejecter and fell through unadjudicated. Nine in ten of the recovered items are correctness work of that kind. Corrected the statements the code falsifies, across the operator skills, AGENTS.md, README.md, the routing and translation specifications, and the gateway, provider, protocols, translate, and vendor-package comments. The TTFT anchor is now described the same way in all three of its homes: the interval includes the gateway's own egress work and excludes pre-dispatch work, and clears per candidate, so a recorded interval after failover is shorter than what the client observed. Deleted declarations a fresh census proves unreachable, and extracted what two callers had been copying: the constant-time comparison, the JSON passthrough preflight, the egress-probe IP grammar, the WebP quality constant, the workerd WebSocket test double, and the Copilot upstream-config grammar, which moves into the Copilot package with both parsers intact — they diverge on blank githubToken and must. Closed three verification gaps. The affinity carrier now has a golden vector generated from the codec itself, and each chat protocol has an egress-to-ingress round trip through its real reassembler, so the AAD domain is linked across the membrane rather than discarded by a stub. The HTTP header-overflow limit is asserted at parser level again, and the assembled installer bodies are checked against an independently restated section tiling. Every new assertion was mutation-checked. Pinned the OverlayScrollbars patch key to its version: a bare key makes pnpm treat a failed patch as a warning rather than an error. --- .../skills/fetching-models-pricing/SKILL.md | 35 +++--- .agents/skills/probing-copilot/SKILL.md | 101 ++++++++++-------- .husky/main-commit-warning | 11 +- .husky/post-checkout | 6 +- AGENTS.md | 73 ++++++++----- README.md | 6 +- .../src/image-processor.ts | 14 +-- .../src/external-resource-fetcher.ts | 7 +- .../src/sharp-image-processor.ts | 7 +- docker/systemd/README.md | 4 +- docs/RESOLUTION.md | 40 ++++--- docs/TRANSLATION.md | 6 +- eslint.config.ts | 9 +- .../agent-setup/scripts/test-installers.ts | 8 +- packages/agent-setup/src/render_test.ts | 2 +- .../agent-setup/src/script-assets_test.ts | 97 ++++++++++++++++- packages/agent-setup/tsconfig.scripts.json | 2 +- .../src/control-plane/api-keys/routes_test.ts | 2 +- .../gateway/src/control-plane/auth/routes.ts | 3 +- .../src/control-plane/data-transfer/routes.ts | 19 ++-- .../src/control-plane/models/routes.ts | 2 +- .../control-plane/performance/aggregate.ts | 7 +- .../src/control-plane/proxies/egress-probe.ts | 65 +++++++++++ .../proxies/egress-probe_test.ts | 79 ++++++++++++++ .../src/control-plane/proxies/routes.ts | 65 +---------- packages/gateway/src/control-plane/schemas.ts | 9 +- .../control-plane/shared/field-validators.ts | 32 ------ .../src/control-plane/upstreams/models.ts | 8 +- .../src/control-plane/upstreams/routes.ts | 6 +- .../src/control-plane/upstreams/serialize.ts | 13 ++- .../gateway/src/data-plane/audio/respond.ts | 6 +- .../gateway/src/data-plane/audio/usage.ts | 14 +-- .../affinity/roundtrip_test.ts | 83 ++++++++++++++ .../chat/gemini/affinity/roundtrip_test.ts | 80 ++++++++++++++ .../chat/gemini/interceptors/index.ts | 4 +- .../chat/messages/affinity/roundtrip_test.ts | 94 ++++++++++++++++ .../chat/responses/client-output.ts | 6 +- .../responses/interceptors/compact-shim.ts | 12 ++- .../interceptors/retry-cyber-policy_test.ts | 1 - .../data-plane/chat/responses/serve-prep.ts | 1 - .../data-plane/chat/responses/websocket.ts | 7 -- .../chat/responses/websocket_test.ts | 67 +----------- .../chat/shared/affinity/codec_test.ts | 22 ++++ .../data-plane/chat/shared/affinity/index.ts | 8 +- .../chat/shared/translate-traverse.ts | 8 +- .../data-plane/codex/routes_websocket_test.ts | 67 +----------- .../src/data-plane/codex/synthesize.ts | 3 +- .../src/data-plane/completions/http.ts | 59 +++------- .../gateway/src/data-plane/embeddings/http.ts | 38 +------ .../gateway/src/data-plane/images/http.ts | 27 +---- .../src/data-plane/models/http_test.ts | 2 +- .../gateway/src/data-plane/models/load.ts | 13 ++- .../src/data-plane/models/load_test.ts | 2 +- .../src/data-plane/providers/catalog.ts | 7 +- .../src/data-plane/providers/registry.ts | 9 +- .../src/data-plane/providers/resolution.ts | 5 +- .../data-plane/providers/resolution_test.ts | 2 +- .../gateway/src/data-plane/rerank/serve.ts | 6 +- .../src/data-plane/shared/gateway-ctx.ts | 14 +-- .../data-plane/shared/iterate-candidates.ts | 11 +- .../data-plane/shared/listing/addressable.ts | 6 +- .../src/data-plane/shared/listing/alias.ts | 4 +- .../data-plane/shared/passthrough-request.ts | 30 ++++++ .../shared/telemetry/attribution.ts | 11 +- .../shared/telemetry/performance.ts | 19 ++-- .../src/data-plane/shared/telemetry/usage.ts | 10 +- .../tools/web-search/providers/shared.ts | 5 +- packages/gateway/src/dial/fetcher.ts | 4 +- packages/gateway/src/dump/accumulator.ts | 4 +- packages/gateway/src/middleware/auth.ts | 2 +- .../gateway/src/repo/responses-payload.ts | 2 +- packages/gateway/src/repo/types.ts | 20 ++-- packages/gateway/src/repo/usage-metrics.ts | 4 +- packages/gateway/src/shared/passwords.ts | 9 +- .../src/shared/performance-histogram.ts | 6 +- .../gateway/src/shared/timing-safe-equal.ts | 6 ++ .../gateway/src/test-utils/gateway-ctx.ts | 1 - .../src/test-utils/worker-websocket.ts | 72 +++++++++++++ packages/http/src/errors.ts | 5 +- packages/http/src/index.ts | 8 +- packages/http/src/parser_test.ts | 14 +++ packages/interceptor/src/index.ts | 21 ++-- packages/platform/src/image-processor.ts | 12 +++ .../protocols/src/chat-completions/stream.ts | 2 +- packages/protocols/src/common/aliases.ts | 24 +++-- packages/protocols/src/common/decimal.ts | 2 +- packages/protocols/src/common/decimal_test.ts | 14 +-- packages/protocols/src/common/index.ts | 2 +- packages/protocols/src/common/models.ts | 38 ++++--- .../src/common/{stream => }/parse-events.ts | 2 +- packages/protocols/src/gemini/index.ts | 2 +- packages/protocols/src/index.ts | 7 -- packages/protocols/src/messages/index.ts | 2 +- packages/protocols/src/messages/stream.ts | 2 +- packages/protocols/src/responses/stream.ts | 2 +- packages/provider-azure/src/endpoint.ts | 16 +-- .../provider-claude-code/src/auth/import.ts | 16 +-- packages/provider-claude-code/src/pricing.ts | 2 + packages/provider-codex/src/fetch.ts | 4 +- packages/provider-codex/src/fetch_test.ts | 4 +- packages/provider-codex/src/ids.ts | 22 ++++ packages/provider-codex/src/index.ts | 1 - packages/provider-codex/src/quota_test.ts | 4 +- packages/provider-codex/src/state.ts | 8 +- packages/provider-copilot/src/auth.ts | 2 +- packages/provider-copilot/src/config.ts | 51 ++++++--- packages/provider-copilot/src/defaults.ts | 5 +- packages/provider-copilot/src/index.ts | 1 + .../src/interceptors/messages/index.ts | 8 +- .../messages/promote-thinking-display.ts | 4 +- .../messages/set-compact-headers.ts | 8 +- .../messages/set-vision-header.ts | 2 +- packages/provider-copilot/src/model-name.ts | 4 +- packages/provider-custom/src/config.ts | 37 +++---- packages/provider-custom/src/fetch-models.ts | 2 +- .../provider-custom/src/fetch-models_test.ts | 2 +- packages/provider-custom/src/index.ts | 1 - packages/provider-custom/src/provider_test.ts | 68 +++++++++++- packages/provider-ollama/src/config.ts | 7 +- packages/provider-ollama/src/provider.ts | 8 +- packages/provider/src/flags.ts | 7 +- packages/provider/src/flags_test.ts | 16 +-- packages/provider/src/ids.ts | 21 ---- packages/provider/src/index.ts | 4 - packages/provider/src/model-config.ts | 27 +++-- packages/provider/src/model.ts | 59 +++++----- packages/provider/src/provider.ts | 9 +- packages/provider/src/repo.ts | 6 +- packages/proxy/src/types.ts | 5 +- .../events_test.ts | 7 +- .../src/gemini-via-messages/events.ts | 5 + .../src/gemini-via-messages/request_test.ts | 2 +- packages/translate/src/index.ts | 2 +- .../responses-via-chat-completions/request.ts | 12 +-- .../src/responses-via-messages/request.ts | 12 +-- packages/translate/src/shared/AGENTS.md | 8 +- .../messages-and-responses/reasoning.ts | 13 +-- .../src/shared/via-messages/service-tier.ts | 6 ++ packages/translate/src/types.ts | 29 +++-- ...s.patch => overlayscrollbars@2.13.0.patch} | 0 pnpm-lock.yaml | 4 +- pnpm-workspace.yaml | 2 +- 142 files changed, 1413 insertions(+), 920 deletions(-) create mode 100644 packages/gateway/src/control-plane/proxies/egress-probe.ts create mode 100644 packages/gateway/src/control-plane/proxies/egress-probe_test.ts create mode 100644 packages/gateway/src/data-plane/chat/chat-completions/affinity/roundtrip_test.ts create mode 100644 packages/gateway/src/data-plane/chat/gemini/affinity/roundtrip_test.ts create mode 100644 packages/gateway/src/data-plane/chat/messages/affinity/roundtrip_test.ts create mode 100644 packages/gateway/src/data-plane/shared/passthrough-request.ts create mode 100644 packages/gateway/src/shared/timing-safe-equal.ts create mode 100644 packages/gateway/src/test-utils/worker-websocket.ts rename packages/protocols/src/common/{stream => }/parse-events.ts (96%) delete mode 100644 packages/protocols/src/index.ts delete mode 100644 packages/provider/src/ids.ts rename patches/{overlayscrollbars.patch => overlayscrollbars@2.13.0.patch} (100%) diff --git a/.agents/skills/fetching-models-pricing/SKILL.md b/.agents/skills/fetching-models-pricing/SKILL.md index e0a7e77f36..02e74679de 100644 --- a/.agents/skills/fetching-models-pricing/SKILL.md +++ b/.agents/skills/fetching-models-pricing/SKILL.md @@ -53,30 +53,30 @@ subset for a model. type PriceVector, } from '@floway-dev/protocols/common'; - const PUBLISHED_BASE_RATES = { - input_tokens: '2.5', - input_cache_read_tokens: '0.25', - output_tokens: '15', + const EXAMPLE_BASE_RATES = { + input_tokens: '1', + input_cache_read_tokens: '0.1', + output_tokens: '10', } satisfies PriceVector; - const PUBLISHED_PRIORITY_RATES = { - input_tokens: '5', - input_cache_read_tokens: '0.5', - output_tokens: '30', + const EXAMPLE_PRIORITY_RATES = { + input_tokens: '2', + input_cache_read_tokens: '0.2', + output_tokens: '20', } satisfies PriceVector; - export const BASE_ONLY_PRICING = tokenBasePricing(PUBLISHED_BASE_RATES); + export const BASE_ONLY_PRICING = tokenBasePricing(EXAMPLE_BASE_RATES); export const TIERED_PRICING = modelPricing( - tokenPricingEntry(PUBLISHED_BASE_RATES), - tokenPricingEntry(PUBLISHED_PRIORITY_RATES, { serviceTier: 'priority' }), + tokenPricingEntry(EXAMPLE_BASE_RATES), + tokenPricingEntry(EXAMPLE_PRIORITY_RATES, { serviceTier: 'priority' }), ); ``` - Published token rate cards are normally USD per million tokens. - `tokenPricingEntry` and `tokenBasePricing` apply the existing - `perMillionTokenRates` conversion, so their resulting `PriceVector` values - are USD per base token. Do not divide manually or pass number literals. + The numbers above are placeholders. Published token rate cards are normally + USD per million tokens. `tokenPricingEntry` and `tokenBasePricing` apply the + existing `perMillionTokenRates` conversion, so their resulting `PriceVector` + values are USD per base token. Do not divide manually or pass number literals. Follow `packages/provider-codex/src/pricing.ts` for a complete production example instead of copying a rate vector into this skill. @@ -128,7 +128,10 @@ ineligible. ## Provider Identity - Copilot usage stores raw variant suffixes such as `-high`, `-xhigh`, and - `-1m` in `model_key`; its pricing lookup normalizes them to the public id. + `-1m` in `model_key`. Its pricing table is keyed by the public id that survives + variant merging: catalog projection merges the raw variants first, and + `pricingForCopilotPublicModelId` is a plain table match over anchored keys that + normalizes nothing itself. - Claude Code resolves pricing from the dated raw upstream id before catalog aliases are merged into public ids. - Codex and Ollama use the raw upstream slug directly. diff --git a/.agents/skills/probing-copilot/SKILL.md b/.agents/skills/probing-copilot/SKILL.md index 7ffb2d6ac4..4d4e76312d 100644 --- a/.agents/skills/probing-copilot/SKILL.md +++ b/.agents/skills/probing-copilot/SKILL.md @@ -16,53 +16,64 @@ already own. 1. Read `` from `wrangler.jsonc` (`d1_databases[0].database_name`). -2. Query enabled Copilot upstreams against production - (`pnpm wrangler d1 execute --remote --command "..."`). Production - is the default because the probe must mirror the real account and its ordered - proxy fallback list. Only fall back to local D1 when production is - unreachable or the probe is specifically validating a local-only seed. - - Return one row per fallback entry instead of selecting the first proxy row - that happens to join. An empty persisted list is expanded to the runtime's - implicit `direct_fetch` candidate so direct egress is always visible: - - ```sql - SELECT u.id, - u.name, - json_extract(u.config_json, '$.githubToken') AS github_token, - CAST(j.key AS INTEGER) AS fallback_index, - json_extract(j.value, '$.id') AS fallback_id, - json_extract(j.value, '$.colos') AS fallback_colos, - p.url AS proxy_url, - b.expires_at AS active_backoff_expires_at - FROM upstreams u - JOIN json_each( - CASE - WHEN json_array_length(u.proxy_fallback_list_json) = 0 - THEN '[{"id":"direct_fetch"}]' - ELSE u.proxy_fallback_list_json - END - ) AS j - LEFT JOIN proxies p - ON p.id = json_extract(j.value, '$.id') - LEFT JOIN proxy_upstream_backoffs b - ON b.proxy_id = json_extract(j.value, '$.id') - AND b.upstream_id = u.id - AND b.expires_at > CAST(strftime('%s', 'now') AS INTEGER) - WHERE u.provider = 'copilot' AND u.enabled = 1 - ORDER BY u.sort_order, u.id, CAST(j.key AS INTEGER); - ``` - +2. Run the fallback inventory query below against production. Production is the + default because the probe must mirror the real account and its ordered proxy + fallback list. Only fall back to local D1 when production is unreachable or + the probe is specifically validating a local-only seed. 3. Pick any returned upstream unless the probe needs a specific one, in which case select it by `id` or `name`. For the runtime location being mirrored, - discard entries whose `fallback_colos` excludes that location. Production - attempts the remaining rows with NULL `active_backoff_expires_at` in fallback - order, then retries the active-backoff rows in fallback order. If every - persisted entry is excluded, record that production collapses the list to an - implicit `direct_fetch` before probing. + discard only the entries whose non-NULL `fallback_colos` omits that location. + A NULL `fallback_colos` is an unrestricted entry that runs in every colo, and + those are the entries production normally carries. Stored colo codes are + uppercased on write and the dial-time location is uppercased on read, so + uppercase both sides before comparing by hand. Production attempts the + remaining rows with NULL `active_backoff_expires_at` in fallback order, then + retries the active-backoff rows in fallback order. If every persisted entry is + excluded, record that production collapses the list to an implicit + `direct_fetch` before probing. 4. Treat the PAT as a secret: do not echo it into commit messages, code comments, or the chat transcript. +### Fallback inventory query + +Return one row per fallback entry instead of selecting the first proxy row that +happens to join. An empty persisted list is expanded to the runtime's implicit +`direct_fetch` candidate so direct egress is always visible. + +The query carries both single-quoted SQL literals and a double-quoted JSON +literal, so no inline shell quoting survives it. Feed it to `--command` through a +quoted heredoc, which passes the bytes through unexpanded: + +```bash +pnpm wrangler d1 execute --remote --command "$(cat <<'SQL' +SELECT u.id, + u.name, + json_extract(u.config_json, '$.githubToken') AS github_token, + CAST(j.key AS INTEGER) AS fallback_index, + json_extract(j.value, '$.id') AS fallback_id, + json_extract(j.value, '$.colos') AS fallback_colos, + p.url AS proxy_url, + b.expires_at AS active_backoff_expires_at +FROM upstreams u +JOIN json_each( + CASE + WHEN json_array_length(u.proxy_fallback_list_json) = 0 + THEN '[{"id":"direct_fetch"}]' + ELSE u.proxy_fallback_list_json + END +) AS j +LEFT JOIN proxies p + ON p.id = json_extract(j.value, '$.id') +LEFT JOIN proxy_upstream_backoffs b + ON b.proxy_id = json_extract(j.value, '$.id') + AND b.upstream_id = u.id + AND b.expires_at > CAST(strftime('%s', 'now') AS INTEGER) +WHERE u.provider = 'copilot' AND u.enabled = 1 +ORDER BY u.sort_order, u.id, CAST(j.key AS INTEGER); +SQL +)" +``` + ## Route through the selected fallback Use the same selected fallback for both the GitHub token exchange and the @@ -77,8 +88,10 @@ unless the selected entry is explicitly `direct_fetch` or `direct_connect`. - `ss://`, `trojan://`, `vless://` — curl cannot speak these. Use a throwaway script outside the repository with the current `@floway-dev/proxy` dialer, or report that a faithful probe is blocked. Do not go direct. -- A non-built-in `fallback_id` with NULL `proxy_url` is a missing proxy record; - stop and report it rather than going direct. +- A non-built-in `fallback_id` with NULL `proxy_url` is a dangling reference to a + deleted proxy row. Production records it as a dial failure for that entry alone + and advances to the next entry in the list, so do the same, and report the + dangling reference. ## Exchange the PAT diff --git a/.husky/main-commit-warning b/.husky/main-commit-warning index 7982b6a992..3212881720 100755 --- a/.husky/main-commit-warning +++ b/.husky/main-commit-warning @@ -1,12 +1,15 @@ #!/bin/sh -# Warn the agent before a commit / merge-commit / cherry-pick / FF lands +# Warn the agent after a commit / merge-commit / cherry-pick / FF lands # from the main worktree (NOT from any linked worktree). Always exits 0; # the warning goes to stderr and never blocks the calling git command. -# Sourced by `.husky/post-commit` and `.husky/post-merge`. +# `.husky/post-commit` and `.husky/post-merge` `exec` this script, so that +# exit 0 becomes the hook's own status. # A linked worktree's per-worktree git dir sits under the main repo's -# .git/worktrees//, so --git-dir and --git-common-dir differ. In the -# main worktree they point at the same .git directory. +# .git/worktrees//, so --absolute-git-dir and --git-common-dir +# differ. In the main worktree they point at the same .git directory — +# but --git-common-dir answers relative to the cwd there, so it is +# normalized before the comparison. git_dir=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0 case "$common_dir" in diff --git a/.husky/post-checkout b/.husky/post-checkout index 23c33154ae..f702593960 100755 --- a/.husky/post-checkout +++ b/.husky/post-checkout @@ -17,8 +17,10 @@ branch_flag="$3" [ "$prev_head" = "$new_head" ] && exit 0 # A linked worktree's per-worktree git dir sits under the main repo's -# .git/worktrees//, so --git-dir and --git-common-dir differ. In the -# main worktree they point at the same .git directory. +# .git/worktrees//, so --absolute-git-dir and --git-common-dir +# differ. In the main worktree they point at the same .git directory — +# but --git-common-dir answers relative to the cwd there, so it is +# normalized before the comparison. git_dir=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0 case "$common_dir" in diff --git a/AGENTS.md b/AGENTS.md index 1e67e0118c..d498604b18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,8 +85,12 @@ Allowed: for a CLI which itself expects a name family (Claude Code CLI expects `claude-*`, Codex CLI expects `gpt-5-*`) MAY filter that picker by the same pattern. Mirroring the CLI's own expectation is not Floway asserting an - endpoint mapping. Scope must be the CLI setup helper; general model pickers - still read `endpoints` from the DTO. + endpoint mapping. Scope must be the CLI setup helper. The Agent Setup picker + does not use the allowance: it keeps the whole addressable chat catalog and + only re-orders it by family. Model selection everywhere reads `kind`, which + `kindForEndpoints` (`packages/protocols/src/common/endpoints.ts`) derives + from the endpoint map and an operator can override per model + (`packages/provider/src/model-config.ts`). - **Per-provider pricing tables** (`pricing.ts`) — return null for unknown keys. - **Provider config discriminators naming the OWN kind** — @@ -145,6 +149,7 @@ The `@floway-dev/platform` package owns abstract runtime contracts ```text Floway/ +├── .agents/skills/ # operator procedures for upstream probing and pricing upkeep ├── packages/ │ ├── agent-setup/ # @floway-dev/agent-setup — setup config, installers, route factories, lease repository contract │ ├── gateway/ # @floway-dev/gateway — Hono app, control/data planes, repositories, migrations @@ -171,12 +176,15 @@ Floway/ Dependency direction is strict. `protocols` and `interceptor` have no runtime workspace dependencies. `http` is also independent of other workspace -packages; it owns HTTP/1.1 framing and userspace TLS. `translate` depends on -`protocols`. `agent-setup` depends only on Hono and Zod at runtime; it knows -nothing of gateway databases, auth/CORS/logging, mount paths, or deployment -runtimes. `platform` owns runtime-neutral contracts and helpers. `proxy` -depends on `http`; its dialers take raw socket primitives through -`DialOptions`, so the package never imports `@floway-dev/platform`. +packages; it owns HTTP/1.1 framing, userspace TLS, and WebSocket upgrade and +frame handling. `translate` depends on `protocols`. `agent-setup` depends only +on Hono and Zod at runtime; it knows nothing of gateway databases, +auth/CORS/logging, mount paths, or deployment runtimes. `platform` owns +runtime-neutral contracts and helpers. `proxy` depends on `http`; its dialers +receive the byte-stream dial primitive through `DialOptions` — a `connect` +that opens a duplex and can also wrap it in the runtime's native TLS — +declared structurally in `proxy` itself, so the package never imports +`@floway-dev/platform`. The base `provider` package depends only on `protocols`. Azure, Custom, and Ollama depend on `provider` + `protocols`; Claude Code and Codex add @@ -232,7 +240,9 @@ Tests are co-located as `*_test.ts`. Every tested package owns a projects include their Vitest configs. Root `scripts/**/*.ts` and `packages/agent-setup/scripts/**/*.ts` have Node-typed script projects; the base config sets `types: []` so ambient types enter only projects that request -them. ESLint checks both script trees and all Vitest configs. +them. ESLint checks both script trees and every package Vitest config; the +workspace-root `eslint.config.ts` and `vitest.config.ts` sit outside every +checked TypeScript project and are ignored. Client-carried affinity is a source-protocol membrane. Shared codec, candidate narrowing, and affinity request context live under @@ -246,9 +256,14 @@ Candidate resolution, target selection, and iteration live in `docs/TRANSLATION.md`. Everything else — provider interfaces, route details, flag resolution, and -wire workarounds — lives in the owning code and its comments. The -`audit-copilot-workarounds` skill builds the Copilot inventory from provider -registrations, defaults, model/auth/item-id modules, and their reference URLs. +wire workarounds — lives in the owning code and its comments. `.agents/skills/` +carries the recurring operator procedures: `audit-copilot-workarounds` builds +the Copilot inventory from provider registrations, defaults, +model/auth/item-id modules, and their reference URLs, then re-tests each entry +against live upstream; `probing-copilot` calls Copilot directly with a stored +credential; `fetching-models-pricing` refreshes the rate cards of providers +whose upstream publishes no token prices; and `backfill-model-pricing` rewrites +recorded `usage.unit_price` after a rate change. ## Verification @@ -263,6 +278,10 @@ To work on a single package, use pnpm filters (e.g. `pnpm --filter @floway-dev/translate run typecheck`). Wrangler commands go through the local dependency with `pnpm wrangler` or package scripts. +Run lint and test through the scripts rather than a bare `eslint` or `vitest`: +a workspace-wide pass exhausts Node's default heap, so the scripts raise the +ceiling — 12 GiB for `lint` and `lint:fix`, 8 GiB for `test`. + ## Development ```bash @@ -306,20 +325,22 @@ prejoined served bodies in `src/script-assets.generated.ts`. Regenerate with fail on drift) after editing a source fragment. `ADMIN_KEY` is optional on dev instances so a fresh checkout is usable without -any secret setup: with the env var unset (which is the default once `.dev.vars` -is deleted), the login page grants seed-admin access to a blank username + any -password. Real deployments must set it — the Node entry refuses to boot under -`NODE_ENV=production` with an empty `ADMIN_KEY`, and the Cloudflare-side -request handler refuses passwordless logins whenever the request carries a -`CF-Ray` header (workerd's local inbound used by `wrangler dev` never writes -CF-Ray; only Cloudflare's edge does). It is not a data-plane credential; its -only purpose is to let an operator who lost the admin password log in via -`POST /auth/login`. - -For manual data-plane validation, log into the dashboard with the `ADMIN_KEY` -backdoor (or, on a dev instance, the passwordless shortcut) or with your own -user, then create or pick an API key under your account and use it as -`x-api-key`. +any secret setup: with the env var unset — the default for a fresh clone, +which carries no `.dev.vars` — the login page grants seed-admin access to a +blank username + any password. Real deployments must set it — the Node entry +refuses to boot under `NODE_ENV=production` with an empty `ADMIN_KEY`, and the +Cloudflare-side request handler refuses passwordless logins whenever the +request carries a `CF-Ray` header (workerd's local inbound used by `wrangler +dev` never writes CF-Ray; only Cloudflare's edge does). It is not a data-plane +credential; it is the control plane's bootstrap and recovery credential. The +seed admin (user 1) ships with no password hash and username login rejects any +user that has none, so on a deployment that sets it, a blank username plus +`ADMIN_KEY` at `POST /auth/login` is both the first way in and the way back in +after an admin password is lost. + +For manual data-plane validation, log into the dashboard with `ADMIN_KEY` (or, +on a dev instance, the passwordless shortcut) or with your own user, then +create or pick an API key under your account and use it as `x-api-key`. ## Deployment diff --git a/README.md b/README.md index 198723c555..d2f6c6331a 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,9 @@ the password. Then: 3. Give that key to a client as a bearer token or `x-api-key`, or use **Agent Setup** to configure Claude Code or Codex. -The data-plane API is also exposed directly at . SQLite, -file-backed dump bodies, and oversized Stateful Responses item payloads persist -in the `floway-data` volume. +The data-plane and control-plane APIs are also exposed directly at +. SQLite, file-backed dump bodies, and oversized +Stateful Responses item payloads persist in the `floway-data` volume. The dashboard uses Floway's control plane to manage users, keys, upstreams, routing, and telemetry. Coding agents and API clients call the data plane, diff --git a/apps/platform-cloudflare/src/image-processor.ts b/apps/platform-cloudflare/src/image-processor.ts index b0bdee2f1a..b46fc9e8d7 100644 --- a/apps/platform-cloudflare/src/image-processor.ts +++ b/apps/platform-cloudflare/src/image-processor.ts @@ -1,17 +1,5 @@ import type { ImageDimensions, ImageProcessor } from '@floway-dev/platform'; -import { getImageCacheStore, sha256Hex } from '@floway-dev/platform'; - -// Fixed WebP quality for every recompressed inline image. 82 sits above the -// cwebp / photographic default of 75 so screenshots and text-heavy UI images — -// the bulk of Copilot traffic — survive our lossy pass before the upstream -// provider applies its own downscale and re-encode, while keeping the bandwidth -// win. Confirmed on real traffic: the production Cloudflare Images encoder at -// q82 matches local cwebp within <0.1 dB PSNR. References: -// - https://developers.google.com/speed/webp/docs/cwebp (default quality 75) -// - https://platform.claude.com/docs/en/build-with-claude/vision (multi-pass -// compression warning) -// - https://getwebp.com/blog/screenshots-webp-settings-text-ui -const WEBP_QUALITY = 82; +import { WEBP_QUALITY, getImageCacheStore, sha256Hex } from '@floway-dev/platform'; // Minimal shapes of the Cloudflare bindings we depend on, hand-typed so the // runtime contract does not pull in the full @cloudflare/workers-types diff --git a/apps/platform-node/src/external-resource-fetcher.ts b/apps/platform-node/src/external-resource-fetcher.ts index 0c6a573225..09110c1740 100644 --- a/apps/platform-node/src/external-resource-fetcher.ts +++ b/apps/platform-node/src/external-resource-fetcher.ts @@ -3,6 +3,7 @@ import { BlockList, isIP, type LookupFunction } from 'node:net'; import { Agent, fetch as undiciFetch } from 'undici'; +import { normalizeDialHost } from '@floway-dev/platform'; import type { ExternalResourceFetcher } from '@floway-dev/platform'; const blockedAddresses = new BlockList(); @@ -98,10 +99,8 @@ export const createNodeExternalResourceFetcher = (): ExternalResourceFetcher => const dispatcher = new Agent({ connect: { lookup: createPublicAddressLookup() } }); return async (url, signal) => { // Undici bypasses `lookup` for IP literals, so validate them before the - // dispatcher sees the request. URL.hostname retains brackets on IPv6. - const hostname = url.hostname.startsWith('[') && url.hostname.endsWith(']') - ? url.hostname.slice(1, -1) - : url.hostname; + // dispatcher sees the request. + const hostname = normalizeDialHost(url.hostname); if (isIP(hostname) !== 0 && !isPublicIpAddress(hostname)) throw nonPublicTargetError(); const response = await undiciFetch(url, { dispatcher, redirect: 'manual', signal }); const body = response.body === null diff --git a/apps/platform-node/src/sharp-image-processor.ts b/apps/platform-node/src/sharp-image-processor.ts index 31d348acda..d13a6fb738 100644 --- a/apps/platform-node/src/sharp-image-processor.ts +++ b/apps/platform-node/src/sharp-image-processor.ts @@ -1,13 +1,8 @@ import sharp from 'sharp'; -import { getImageCacheStore, sha256Hex } from '@floway-dev/platform'; +import { WEBP_QUALITY, getImageCacheStore, sha256Hex } from '@floway-dev/platform'; import type { ImageDimensions, ImageProcessor } from '@floway-dev/platform'; -// Fixed WebP quality matching the Cloudflare encoder so both deployment -// targets pass the same lossy budget through to the upstream model. See -// platform-cloudflare/src/image-processor.ts for the calibration notes. -const WEBP_QUALITY = 82; - export const createSharpImageProcessor = (): ImageProcessor => ({ async compressToWebp(input: Uint8Array, target: ImageDimensions | null): Promise { const cacheKey = `imgwebp:${await sha256Hex(input)}:${target ? `${target.width}x${target.height}` : 'orig'}:webp:q${WEBP_QUALITY}`; diff --git a/docker/systemd/README.md b/docker/systemd/README.md index 07a27712cd..0501351360 100644 --- a/docker/systemd/README.md +++ b/docker/systemd/README.md @@ -13,7 +13,7 @@ Podman Quadlets allow you to manage containers as native systemd services. These cp floway-data.volume floway-server.container.example floway-web.container floway.pod ~/.config/containers/systemd/ ``` -3. Edit `~/.config/containers/systemd/floway-server.container.example` by replacing `` with your desired admin key. +3. Edit `~/.config/containers/systemd/floway-server.container.example` by replacing `` with your desired admin secret. 4. ```bash mv ~/.config/containers/systemd/floway-server.container.example ~/.config/containers/systemd/floway-server.container @@ -35,7 +35,7 @@ Podman Quadlets allow you to manage containers as native systemd services. These sudo cp floway-data.volume floway-server.container.example floway-web.container floway.pod /etc/containers/systemd/ ``` -3. Edit `/etc/containers/systemd/floway-server.container.example` by replacing `` with your desired admin key. +3. Edit `/etc/containers/systemd/floway-server.container.example` by replacing `` with your desired admin secret. 4. ```bash sudo mv /etc/containers/systemd/floway-server.container.example /etc/containers/systemd/floway-server.container diff --git a/docs/RESOLUTION.md b/docs/RESOLUTION.md index 08af148ef2..1249b75253 100644 --- a/docs/RESOLUTION.md +++ b/docs/RESOLUTION.md @@ -31,8 +31,10 @@ surface: - With a prefix policy, each listed `unprefixed` or `prefixed` form becomes a row. A prefixed row gets the public id `` and display name `: `. -- Operator-disabled public ids are removed before either form is emitted. The - disable is per upstream and does not hide the same id from other upstreams. +- The operator's disable list is matched against the provider-emitted id before + any prefix is applied, so a disabled id removes both its unprefixed and + prefixed forms. The disable is per upstream and does not hide the same id from + other upstreams. The prefixed row is a shallow `ProviderModel` clone. `providerData` is preserved as opaque provider-private invocation data; it is not a universal upstream-id @@ -101,8 +103,9 @@ pointing them back to their canonical listed row. `enumerateModelCandidates` receives: - the inbound `model` string unchanged; -- the effective upstream cap (`null` means unrestricted, an empty list means no - provider is visible); the cap is the intersection of user and API-key scopes; +- `upstreamIds` — the intersection of the user's and the API key's own + `upstreamIds`, naming the upstreams this request may reach (`null` means + unrestricted, an empty list means no provider is visible); - `kind`, derived from the source route: `chat`, `embedding`, `image`, `rerank`, or `transcription`; - the background scheduler and runtime-location tag needed by catalog fetch and @@ -144,7 +147,7 @@ providers again. This supports dated client ids such as match suppresses the retry because changing the spelling cannot change its kind. Failure names from the two walks are deduplicated. -The inbound request body is never mutated by this fallback. Candidates carry +The inbound request body is never mutated by this retry. Candidates carry the real matched model id. ### Alias walk @@ -194,7 +197,9 @@ interface ModelCandidate { `ProviderModel`, including opaque `providerData`, resolved `enabledFlags`, optional per-model flag overrides, rerank target, and pricing schedule. `providerModelOf(candidate)` is the only dispatch accessor. -- `fetcher` is the request's proxy-chain-bound fetcher for this upstream. +- `fetcher` runs this upstream's proxy fallback chain, collapsing to direct + fetch when the configured list is empty or fully excluded by the request's + runtime location. - `rules` is absent on direct candidates and present on alias candidates, including `{}` for an alias target with no overlay. @@ -239,7 +244,10 @@ Completions is the passthrough exception in alias-rule handling. It resolves as wire has no rule-application step and ignores them. Embeddings, Images, Audio Transcriptions, and Rerank use non-chat alias kinds whose schemas require empty rules. Chat source routes apply rules after target selection, on the selected -target protocol's native fields. +target protocol's native fields: `data-plane/chat/shared/alias-rules.ts` owns +those overlays, and each attempt's terminal wire call runs the matching +`applyRulesToUpstream{ChatCompletions,Responses,Messages}` over the target +payload, dropping any rule the chosen protocol has no native slot for. Audio Transcriptions buffers and normalizes multipart input before iteration, then rebuilds it with each attempted provider model id. Its successful media @@ -251,7 +259,11 @@ it never translates through a chat protocol. Before affinity, candidates are ordered: 1. alias target order, when the source id is an alias; -2. configured provider `sort_order` within each target/real-id walk; +2. upstream order within each target/real-id walk: a request with a non-null + `upstreamIds` walks that array in its own order — the intersection filters + the API key's array by the user's set, so the key's order wins whenever the + key restricts anything — while an unrestricted request walks every enabled + upstream in configured `sort_order`; 3. addressable-form order within one provider (normally unprefixed before prefixed when configured that way). @@ -293,12 +305,12 @@ models. Each iteration clears `upstreamCallStartedAt` and `firstOutputTokenAt`. Providers receive `wrapUpstreamCall`; invoking it stamps -`upstreamCallStartedAt` synchronously immediately before the outbound fetcher -runs. This is a pre-dial anchor: proxy selection already happened when the -candidate fetcher was constructed, but proxy connect/TLS/CONNECT handshakes -inside that fetcher are included because the client waits for them. Gateway -parsing, model resolution, affinity, translation, and interceptor work before -the provider dispatch are outside TTFT. +`upstreamCallStartedAt` synchronously immediately before dispatch, so the +interval includes the gateway's own egress work — the proxy-backoff lookup, +dial, TLS, and CONNECT — and excludes gateway pre-dispatch work: parsing, +model resolution, affinity, translation, and interceptor entry. Because the +anchors are cleared per candidate, after a failover the recorded interval is +shorter than the one the client observed. The first emitted output token stamps `firstOutputTokenAt`. Chat TTFT is their monotonic difference. A represented failure with no output records a zero-output diff --git a/docs/TRANSLATION.md b/docs/TRANSLATION.md index d7ada2cb79..4b50c720ab 100644 --- a/docs/TRANSLATION.md +++ b/docs/TRANSLATION.md @@ -255,8 +255,8 @@ Request mapping: preserve chronology. - string input becomes one user message. - user `input_text` becomes Messages text; `input_image` URLs are resolved via - the gateway-injected platform external-resource loader and converted to - base64 image blocks when supported. + the gateway-injected external-image loader and converted to base64 image + blocks when supported. - assistant `output_text` becomes assistant text blocks. - `function_call` becomes assistant `tool_use`. - `function_call_output` becomes user `tool_result`; incomplete status marks the @@ -381,7 +381,7 @@ Request mapping: Messages `system`, preserving each source content part as a separate text block. Later instruction messages remain inline in chronological order. - Chat user text and supported images become Messages user blocks. Remote images - are resolved through the same gateway-injected external-resource loader. + are resolved through the same gateway-injected external-image loader. - Chat assistant `content` becomes assistant text. - Chat assistant scalar `reasoning_text` / `reasoning_opaque` becomes one `thinking` block or one `redacted_thinking` block. diff --git a/eslint.config.ts b/eslint.config.ts index 88c32a1d7d..77bf0f5763 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -155,7 +155,6 @@ const commonConfig: Linter.Config = { }, }], 'stylistic/type-annotation-spacing': ['error'], - 'stylistic/jsx-quotes': ['error', 'prefer-double'], }, settings: { 'import/internal-regex': '^@floway-dev/', @@ -253,10 +252,10 @@ const config: Linter.Config[] = [ // Block runtime `import { ... } from '@floway-dev/gateway[/...]'` // — apps/web may only type-import from the gateway package (`import // type`). Runtime imports would land gateway's data plane into the - // SPA bundle. Implemented via `no-restricted-syntax` rather than - // `@typescript-eslint/no-restricted-imports`'s `allowTypeImports` - // because the latter requires type-aware linting (it OOMs eslint's - // default heap on this workspace). + // SPA bundle. `@typescript-eslint/no-restricted-imports`'s + // `allowTypeImports` is the closest built-in, but it also clears the + // inline `import { type X }` form; the selector holds the whole + // declaration to `import type`. 'no-restricted-syntax': ['error', { selector: 'ImportDeclaration[importKind!="type"][source.value=/^@floway-dev\\u002Fgateway($|\\u002F)/]', message: 'apps/web may only type-import from @floway-dev/gateway. The SPA bundle must not pull gateway runtime code.', diff --git a/packages/agent-setup/scripts/test-installers.ts b/packages/agent-setup/scripts/test-installers.ts index 0e35adeaae..3cec156aeb 100644 --- a/packages/agent-setup/scripts/test-installers.ts +++ b/packages/agent-setup/scripts/test-installers.ts @@ -467,7 +467,7 @@ esac `, { mode: 0o755 }); }; -type InstallerTestConfiguration = AgentSetupConfiguration & { readonly testAgent: 'claude' | 'codex' }; +type InstallerTestConfiguration = AgentSetupConfiguration & { readonly testAgent: ScriptAgent }; const claudeConfig = (overrides: Partial = {}): InstallerTestConfiguration => ({ testAgent: 'claude', @@ -505,7 +505,7 @@ const bothConfig = ( interface RunOptions { workspace: Workspace; configuration: InstallerTestConfiguration; - agent?: 'claude' | 'codex'; + agent?: ScriptAgent; baseUrl: string; // The wrapping one-line command injects the gateway origin into the executing // shell (Bash exports SETUP_ENDPOINT; PowerShell assigns $SetupEndpoint in the @@ -556,7 +556,7 @@ interface RunOptions { failRestore?: boolean; } -const targetAgent = (configuration: InstallerTestConfiguration, agent?: 'claude' | 'codex'): 'claude' | 'codex' => +const targetAgent = (configuration: InstallerTestConfiguration, agent?: ScriptAgent): ScriptAgent => agent ?? configuration.testAgent; interface RunResult { code: number; stdout: string; stderr: string; combined: string } @@ -2510,7 +2510,7 @@ test('codex', 'PowerShell rollback restore failure preserves the Codex provider- // --- run -------------------------------------------------------------------- -const parseAgentFilter = (): 'claude' | 'codex' | 'all' => { +const parseAgentFilter = (): ScriptAgent | 'all' => { const index = process.argv.indexOf('--agent'); if (index === -1) return 'all'; const value = process.argv[index + 1]; diff --git a/packages/agent-setup/src/render_test.ts b/packages/agent-setup/src/render_test.ts index 6ddfa3fdfb..5c9dfc82ae 100644 --- a/packages/agent-setup/src/render_test.ts +++ b/packages/agent-setup/src/render_test.ts @@ -62,7 +62,7 @@ describe('renderShellPrefix', () => { }); test('flattens control characters in the API key label before it reaches terminal metadata', () => { - const prefix = renderShellPrefix({ agent: 'claude', apiKey: 'key', apiKeyName: 'CI\n', configuration: fullConfiguration }); + const prefix = renderShellPrefix({ agent: 'claude', apiKey: 'key', apiKeyName: 'CI\n\x1b[2J', configuration: fullConfiguration }); expect(prefix).toContain("SETUP_API_KEY_NAME='CI [2J'"); }); diff --git a/packages/agent-setup/src/script-assets_test.ts b/packages/agent-setup/src/script-assets_test.ts index 55ebbe2132..9f2bba7294 100644 --- a/packages/agent-setup/src/script-assets_test.ts +++ b/packages/agent-setup/src/script-assets_test.ts @@ -1,7 +1,88 @@ import { test } from 'vitest'; -import { SETUP_SCRIPT_SOURCE_FRAGMENTS } from './script-assets.generated.ts'; -import { assertEquals } from '@floway-dev/test-utils'; +import { + SETUP_BASH_COMMON, + SETUP_BASH_COMMON_CLI, + SETUP_BASH_COMMON_JQ, + SETUP_BASH_COMMON_MAIN, + SETUP_BASH_COMMON_MANAGED_FILE, + SETUP_BASH_COMMON_OUTPUT, + SETUP_BASH_COMMON_PROCESS, + SETUP_POWERSHELL_COMMON, + SETUP_POWERSHELL_COMMON_CLI, + SETUP_POWERSHELL_COMMON_JSON_DOCUMENT, + SETUP_POWERSHELL_COMMON_MAIN, + SETUP_POWERSHELL_COMMON_MANAGED_FILE, + SETUP_POWERSHELL_COMMON_OUTPUT, + SETUP_POWERSHELL_COMMON_PLATFORM, + SETUP_POWERSHELL_COMMON_PROCESS, + SETUP_SCRIPT_SOURCE_FRAGMENTS, +} from './script-assets.generated.ts'; +import { assert, assertEquals } from '@floway-dev/test-utils'; + +interface Section { + file: string; + source: string; + start?: string; + end?: string; + append?: string; +} + +// The prejoined common bodies are what the routes actually serve, and the +// generator's manifest is the only other place their section order and cut +// points are written down. These tables restate that tiling independently, so +// reordering, dropping, or re-cutting a section in the manifest and +// regenerating cannot ship a different installer on a green suite. +const BASH_COMMON_SECTIONS: readonly Section[] = [ + { file: 'installers/bash/common/output.sh', source: SETUP_BASH_COMMON_OUTPUT, append: '\n' }, + { file: 'installers/bash/common/main.sh', source: SETUP_BASH_COMMON_MAIN, end: '# --- run' }, + { file: 'installers/bash/common/process.sh', source: SETUP_BASH_COMMON_PROCESS, append: '\n' }, + { file: 'installers/bash/common/jq.sh', source: SETUP_BASH_COMMON_JQ, append: '\n' }, + { file: 'installers/bash/common/cli.sh', source: SETUP_BASH_COMMON_CLI, end: '_install_brew_cask() {' }, + { file: 'installers/bash/common/managed-file.sh', source: SETUP_BASH_COMMON_MANAGED_FILE, append: '\n' }, + { file: 'installers/bash/common/cli.sh', source: SETUP_BASH_COMMON_CLI, start: '_install_brew_cask() {' }, + { file: 'installers/bash/common/main.sh', source: SETUP_BASH_COMMON_MAIN, start: '# --- run' }, +]; + +const POWERSHELL_COMMON_SECTIONS: readonly Section[] = [ + { file: 'installers/powershell/common/output.ps1', source: SETUP_POWERSHELL_COMMON_OUTPUT }, + { file: 'installers/powershell/common/platform.ps1', source: SETUP_POWERSHELL_COMMON_PLATFORM, end: 'function Get-SetupPlatform' }, + { file: 'installers/powershell/common/json-document.ps1', source: SETUP_POWERSHELL_COMMON_JSON_DOCUMENT, append: '\n' }, + { file: 'installers/powershell/common/main.ps1', source: SETUP_POWERSHELL_COMMON_MAIN, end: '# --- run' }, + { file: 'installers/powershell/common/managed-file.ps1', source: SETUP_POWERSHELL_COMMON_MANAGED_FILE, end: '# Rollback retains' }, + { file: 'installers/powershell/common/process.ps1', source: SETUP_POWERSHELL_COMMON_PROCESS, end: '# Run a fixed package-manager' }, + { file: 'installers/powershell/common/platform.ps1', source: SETUP_POWERSHELL_COMMON_PLATFORM, start: 'function Get-SetupPlatform', append: '\n' }, + { + file: 'installers/powershell/common/process.ps1', + source: SETUP_POWERSHELL_COMMON_PROCESS, + start: '# Run a fixed package-manager', + end: '# Run a child process with captured output', + }, + { file: 'installers/powershell/common/cli.ps1', source: SETUP_POWERSHELL_COMMON_CLI, append: '\n' }, + { file: 'installers/powershell/common/managed-file.ps1', source: SETUP_POWERSHELL_COMMON_MANAGED_FILE, start: '# Rollback retains', append: '\n' }, + { file: 'installers/powershell/common/process.ps1', source: SETUP_POWERSHELL_COMMON_PROCESS, start: '# Run a child process with captured output' }, + { file: 'installers/powershell/common/main.ps1', source: SETUP_POWERSHELL_COMMON_MAIN, start: '# --- run' }, +]; + +const PLATFORM_COMMONS = [ + { platform: 'bash', prejoined: SETUP_BASH_COMMON, sections: BASH_COMMON_SECTIONS }, + { platform: 'powershell', prejoined: SETUP_POWERSHELL_COMMON, sections: POWERSHELL_COMMON_SECTIONS }, +]; + +const startOffset = ({ file, source, start }: Section): number => { + if (start === undefined) return 0; + const index = source.indexOf(start); + assert(index !== -1, `${file} does not contain the start boundary ${JSON.stringify(start)}`); + return index; +}; + +const sliceOf = (section: Section): string => { + const start = startOffset(section); + if (section.end === undefined) return section.source.slice(start); + const end = section.source.indexOf(section.end, start); + assert(end !== -1, `${section.file} does not contain the end boundary ${JSON.stringify(section.end)}`); + return section.source.slice(start, end); +}; test('generated installer sources match the checked-in canonical fragments byte for byte', async () => { const { readFile } = await import('node:fs/promises'); @@ -9,3 +90,15 @@ test('generated installer sources match the checked-in canonical fragments byte assertEquals(generated, await readFile(new URL(`../${file}`, import.meta.url), 'utf8')); } }); + +test.each(PLATFORM_COMMONS)('the prejoined $platform common body is exactly its declared section tiling', ({ prejoined, sections }) => { + assertEquals(sections.map(section => sliceOf(section) + (section.append ?? '')).join(''), prejoined); +}); + +test.each(PLATFORM_COMMONS)('every byte of each $platform source file reaches the prejoined body', ({ sections }) => { + const sourceByFile = new Map(sections.map(({ file, source }) => [file, source])); + for (const [file, source] of sourceByFile) { + const tiles = sections.filter(section => section.file === file).sort((a, b) => startOffset(a) - startOffset(b)); + assertEquals(tiles.map(sliceOf).join(''), source, `${file} is not fully covered by its sections`); + } +}); diff --git a/packages/agent-setup/tsconfig.scripts.json b/packages/agent-setup/tsconfig.scripts.json index 61529f7615..2537b7fe1d 100644 --- a/packages/agent-setup/tsconfig.scripts.json +++ b/packages/agent-setup/tsconfig.scripts.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["node"] }, - "include": ["scripts/**/*.ts", "src/**/*.ts"] + "include": ["scripts/**/*.ts"] } diff --git a/packages/gateway/src/control-plane/api-keys/routes_test.ts b/packages/gateway/src/control-plane/api-keys/routes_test.ts index 03df00c787..329950e53c 100644 --- a/packages/gateway/src/control-plane/api-keys/routes_test.ts +++ b/packages/gateway/src/control-plane/api-keys/routes_test.ts @@ -12,7 +12,7 @@ const ownerPatch = (id: string, body: unknown, rawKey: string) => body: JSON.stringify(body), }); -test('GET /api/keys never exposes the server-side server secret', async () => { +test('GET /api/keys never exposes the server secret', async () => { const { apiKey } = await setupAppTest(); const response = await requestApp('/api/keys', { headers: { 'x-api-key': apiKey.key } }); assertEquals(response.status, 200); diff --git a/packages/gateway/src/control-plane/auth/routes.ts b/packages/gateway/src/control-plane/auth/routes.ts index de12ed1131..7e2dd1679a 100644 --- a/packages/gateway/src/control-plane/auth/routes.ts +++ b/packages/gateway/src/control-plane/auth/routes.ts @@ -4,7 +4,8 @@ import { getRepo } from '../../repo/index.ts'; import { SEED_ADMIN_USER_ID } from '../../repo/seed-admin.ts'; import type { User } from '../../repo/types.ts'; import { isProductionRequest } from '../../runtime/is-production-request.ts'; -import { dummyPasswordHash, timingSafeEqual, verifyPassword } from '../../shared/passwords.ts'; +import { dummyPasswordHash, verifyPassword } from '../../shared/passwords.ts'; +import { timingSafeEqual } from '../../shared/timing-safe-equal.ts'; import type { authLoginBody } from '../schemas.ts'; import { userToSessionWire } from '../users/wire.ts'; import { getEnvOptional } from '@floway-dev/platform'; diff --git a/packages/gateway/src/control-plane/data-transfer/routes.ts b/packages/gateway/src/control-plane/data-transfer/routes.ts index 3ed68ce178..c4e09d3418 100644 --- a/packages/gateway/src/control-plane/data-transfer/routes.ts +++ b/packages/gateway/src/control-plane/data-transfer/routes.ts @@ -23,7 +23,7 @@ import { RETENTION_MAX_SECONDS } from '../../shared/retention.ts'; import { parseServerSecret } from '../../shared/server-secret.ts'; import { isWebSearchProviderName } from '../../shared/web-search-providers.ts'; import { USERNAME_PATTERN, type exportQuery, type importBody } from '../schemas.ts'; -import { copilotConfigField, isRecord, nonEmptyStringField } from '../shared/field-validators.ts'; +import { isRecord, nonEmptyStringField } from '../shared/field-validators.ts'; import { parseUpstreamIdsValue } from '../shared/upstream-ids.ts'; import { warmModelsCache } from '../shared/warm-models-cache.ts'; import { type SerializedUpstreamRecord, upstreamRecordToFullJson } from '../upstreams/serialize.ts'; @@ -32,6 +32,7 @@ import { ALL_PROVIDER_KINDS, normalizeModelPrefix, normalizeUpstreamColor, parse import { assertAzureUpstreamRecord } from '@floway-dev/provider-azure'; import { assertClaudeCodeUpstreamRecord, assertClaudeCodeUpstreamState } from '@floway-dev/provider-claude-code'; import { assertCodexUpstreamRecord, assertCodexUpstreamState } from '@floway-dev/provider-codex'; +import { parseCopilotUpstreamConfig } from '@floway-dev/provider-copilot'; import { assertCustomUpstreamRecord } from '@floway-dev/provider-custom'; import { parseProxyUri } from '@floway-dev/proxy'; @@ -90,16 +91,16 @@ const normalizeUpstreamConfig = (record: UpstreamRecord): unknown => { assertClaudeCodeUpstreamRecord(record); return record.config; } - return copilotConfigField(record.config, importErrorBuilder); + return parseCopilotUpstreamConfig(record.config, importErrorBuilder); }; -// State is persisted only for providers that own autonomous runtime state. -// Codex rotates a refresh_token and tracks credential health; Claude Code -// holds per-account refresh tokens, OAuth-minted access tokens, and quota -// snapshots; Custom/Azure/Copilot have no such state and serialize to null. -// Round-trip the stateful providers through the same shape assertion the -// runtime uses so a corrupt or hand-edited import can't smuggle unknown -// fields onto the column. +// Only Codex and Claude Code carry state across an import: their per-account +// refresh tokens and credential health cannot be re-derived, so they +// round-trip through the same shape assertion the runtime uses and a corrupt +// or hand-edited payload can't smuggle unknown fields onto the column. +// Copilot's state is a cached model catalog plus a short-lived exchanged +// token, both re-minted on demand, so it lands as null; Custom, Azure, and +// Ollama own no state at all. const normalizeUpstreamState = (provider: UpstreamProviderKind, value: unknown): unknown => { if (provider !== 'codex' && provider !== 'claude-code') return null; if (value === null || value === undefined) { diff --git a/packages/gateway/src/control-plane/models/routes.ts b/packages/gateway/src/control-plane/models/routes.ts index 984ebdb3e4..78460380fb 100644 --- a/packages/gateway/src/control-plane/models/routes.ts +++ b/packages/gateway/src/control-plane/models/routes.ts @@ -70,7 +70,7 @@ export const controlPlaneModels = async (c: CtxWithQuery) => // have self-restricted out of their own data-plane access, and the // dashboard filters the result client-side for surfaces that should // respect the restriction (Models page, playground). Non-admin - // sessions stay scoped to their effective upstream cap so the + // sessions stay scoped to their effective `upstreamIds` so the // dashboard cannot leak models from upstreams their account has no // data-plane access to. const isAdmin = userFromContext(c).isAdmin; diff --git a/packages/gateway/src/control-plane/performance/aggregate.ts b/packages/gateway/src/control-plane/performance/aggregate.ts index 920828ca79..dd59444a7a 100644 --- a/packages/gateway/src/control-plane/performance/aggregate.ts +++ b/packages/gateway/src/control-plane/performance/aggregate.ts @@ -4,6 +4,10 @@ import { type HistogramBucket, percentileFromBuckets } from '../../shared/perfor export type PerformanceBucketGranularity = 'hour' | '4h' | '8h' | 'day' | 'all'; export type PerformanceGroupBy = 'none' | 'keyId' | 'userId' | 'model' | 'upstream' | 'operation' | 'runtimeLocation'; +// One aggregated row in the shape the dashboard consumes. `ttftMs*` render as +// milliseconds directly; `tpotUs*` render as tok/s via `1_000_000 / tpotUs`, +// and that reciprocal inverts percentile direction — the p95 microsecond +// figure is the 5th percentile of the tok/s figure. export interface PerformanceDisplayRecord { bucket: string; group: string; @@ -55,7 +59,8 @@ const displayGroup = (record: PerformanceTelemetryRecord, options: AggregateOpti if (options.groupBy === 'none') return 'all'; if (options.groupBy === 'userId') { const userId = keyToUser.get(record.keyId); - // Drop, don't collapse — userId 0 is a valid real user. + // A keyToUser miss means the key row was hard-deleted, so the row has no + // owner at all; the by-user axis drops it instead of inventing a bucket. if (userId === undefined) return null; return String(userId); } diff --git a/packages/gateway/src/control-plane/proxies/egress-probe.ts b/packages/gateway/src/control-plane/proxies/egress-probe.ts new file mode 100644 index 0000000000..cb24ef9976 --- /dev/null +++ b/packages/gateway/src/control-plane/proxies/egress-probe.ts @@ -0,0 +1,65 @@ +// IP-echo anchors over HTTPS. ipify and AWS checkip return v4 by default +// (when the proxy egress carries a v4 route); 6.ident.me forces v6, useful +// when an operator wants to confirm a proxy actually has a v6 path. +export const ANCHORS = { + 'ipify': { host: 'api.ipify.org', port: 443, path: '/' }, + 'aws': { host: 'checkip.amazonaws.com', port: 443, path: '/' }, + 'ident.me-v6': { host: '6.ident.me', port: 443, path: '/' }, +} as const; + +export type AnchorName = keyof typeof ANCHORS; + +// IP-echo anchors return either an IPv4 in dot-notation or an IPv6 in mixed +// hex/colon (with an optional embedded IPv4 tail). Cap the response at 256 +// chars before sniffing — a misbehaving anchor could otherwise feed an +// arbitrary HTML page into the test-response payload. We validate octet +// ranges and canonical v6 shape (one optional `::` shorthand, 1-4 hex +// digits per group, RFC 4291 group counts), so anchor strings like +// `999.999.999.999` or `aaaa::bbbb::cccc` cannot pass. +export const isIpV4 = (s: string): boolean => { + const octets = s.split('.'); + if (octets.length !== 4) return false; + for (const o of octets) { + if (!/^\d{1,3}$/.test(o)) return false; + // Reject leading zeros (e.g. `01`) — RFC 3986 forbids them and some + // resolvers interpret the value as octal, so accepting them invites + // ambiguity. + if (o.length > 1 && o.startsWith('0')) return false; + const n = Number(o); + if (n > 255) return false; + } + return true; +}; + +export const isIpV6 = (s: string): boolean => { + if (!s.includes(':')) return false; + // At most one `::` shorthand (per RFC 4291 §2.2). + if ((s.match(/::/g) ?? []).length > 1) return false; + if (s.includes(':::')) return false; + + // Normalize an embedded v4 tail to two synthetic hex groups so the rest + // of the validation runs on a pure-hex shape. + let normalized = s; + const lastColon = s.lastIndexOf(':'); + const afterLastColon = s.slice(lastColon + 1); + if (afterLastColon.includes('.')) { + if (!isIpV4(afterLastColon)) return false; + normalized = `${s.slice(0, lastColon + 1)}0:0`; + } + + const validGroup = (g: string): boolean => /^[0-9a-fA-F]{1,4}$/.test(g); + + if (normalized.includes('::')) { + const [leftRaw, rightRaw] = normalized.split('::'); + const left = leftRaw === '' ? [] : leftRaw.split(':'); + const right = rightRaw === '' ? [] : rightRaw.split(':'); + if (!left.every(validGroup) || !right.every(validGroup)) return false; + // `::` must elide at least one group, so the explicit group total + // is strictly less than 8. + return left.length + right.length < 8; + } + + const groups = normalized.split(':'); + if (groups.length !== 8) return false; + return groups.every(validGroup); +}; diff --git a/packages/gateway/src/control-plane/proxies/egress-probe_test.ts b/packages/gateway/src/control-plane/proxies/egress-probe_test.ts new file mode 100644 index 0000000000..ffb1d1deb7 --- /dev/null +++ b/packages/gateway/src/control-plane/proxies/egress-probe_test.ts @@ -0,0 +1,79 @@ +import { test } from 'vitest'; + +import { ANCHORS, isIpV4, isIpV6, type AnchorName } from './egress-probe.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +test('every anchor is reachable over the HTTPS port the probe dials with TLS', () => { + // testProxy dials each anchor with `tls: true` unconditionally, so an + // anchor on any other port would be handed a TLS handshake it cannot answer. + for (const name of Object.keys(ANCHORS) as AnchorName[]) { + assertEquals(ANCHORS[name].port, 443, `${name} must stay on the HTTPS port`); + } +}); + +test('isIpV4 accepts a dotted quad and rejects out-of-range octets', () => { + assertEquals(isIpV4('203.0.113.7'), true); + assertEquals(isIpV4('0.0.0.0'), true); + assertEquals(isIpV4('255.255.255.255'), true); + assertEquals(isIpV4('256.0.0.1'), false); + assertEquals(isIpV4('999.999.999.999'), false); +}); + +test('isIpV4 rejects leading zeros so no octet can be read as octal', () => { + assertEquals(isIpV4('192.168.001.1'), false); + assertEquals(isIpV4('010.0.0.1'), false); +}); + +test('isIpV4 rejects anything that is not exactly four numeric groups', () => { + assertEquals(isIpV4('1.2.3'), false); + assertEquals(isIpV4('1.2.3.4.5'), false); + assertEquals(isIpV4('1.2.3.'), false); + assertEquals(isIpV4('1.2.3.x'), false); + assertEquals(isIpV4(''), false); +}); + +test('isIpV6 accepts full form, `::` shorthand, and an embedded v4 tail', () => { + assertEquals(isIpV6('2001:0db8:0000:0000:0000:ff00:0042:8329'), true); + assertEquals(isIpV6('2001:db8::1'), true); + assertEquals(isIpV6('::1'), true); + assertEquals(isIpV6('fe80::'), true); + assertEquals(isIpV6('::'), true); + assertEquals(isIpV6('::ffff:192.0.2.128'), true); +}); + +test('isIpV6 rejects more than one `::` shorthand', () => { + assertEquals(isIpV6('aaaa::bbbb::cccc'), false); + // A single `:::` run matches the `::` scan only once, so it needs its own + // guard to stay out of the accepted grammar. + assertEquals(isIpV6('2001:::1'), false); +}); + +test('isIpV6 rejects wrong group counts and oversized groups', () => { + assertEquals(isIpV6('2001:db8:0:0:0:ff00:42'), false); + assertEquals(isIpV6('1:2:3:4:5:6:7:8:9'), false); + assertEquals(isIpV6('20011:db8::1'), false); + assertEquals(isIpV6('2001:db8::xyz'), false); + // Eight explicit groups leave nothing for `::` to elide. + assertEquals(isIpV6('1:2:3:4::5:6:7:8'), false); +}); + +test('isIpV6 rejects a malformed embedded v4 tail', () => { + assertEquals(isIpV6('::ffff:999.0.2.128'), false); + assertEquals(isIpV6('::ffff:192.0.2'), false); +}); + +test('isIpV6 rejects a bare v4 address', () => { + assertEquals(isIpV6('203.0.113.7'), false); +}); + +test('neither validator accepts an HTML page an anchor could return', () => { + // This pair is what stops a captive portal or error page from being echoed + // back to the operator as the proxy's egress IP. + const page = '198.51.100.20'; + assertEquals(isIpV4(page), false); + assertEquals(isIpV6(page), false); + + const error = 'Error: 502'; + assertEquals(isIpV4(error), false); + assertEquals(isIpV6(error), false); +}); diff --git a/packages/gateway/src/control-plane/proxies/routes.ts b/packages/gateway/src/control-plane/proxies/routes.ts index 19818ad4c6..4b036f1ff5 100644 --- a/packages/gateway/src/control-plane/proxies/routes.ts +++ b/packages/gateway/src/control-plane/proxies/routes.ts @@ -1,5 +1,6 @@ import type { Context } from 'hono'; +import { ANCHORS, isIpV4, isIpV6 } from './egress-probe.ts'; import { backoffRowToJson, proxyRecordToJson } from './serialize.ts'; import { type CtxWithJson } from '../../middleware/zod-validator.ts'; import { getRepo } from '../../repo/index.ts'; @@ -106,70 +107,6 @@ export const deleteProxy = async (c: Context) => { return c.body(null, 204); }; -// IP-echo anchors over HTTPS. ipify and AWS checkip return v4 by default -// (when the proxy egress carries a v4 route); 6.ident.me forces v6, useful -// when an operator wants to confirm a proxy actually has a v6 path. -const ANCHORS = { - 'ipify': { host: 'api.ipify.org', port: 443, path: '/' }, - 'aws': { host: 'checkip.amazonaws.com', port: 443, path: '/' }, - 'ident.me-v6': { host: '6.ident.me', port: 443, path: '/' }, -} as const; - -// IP-echo anchors return either an IPv4 in dot-notation or an IPv6 in mixed -// hex/colon (with an optional embedded IPv4 tail). Cap the response at 256 -// chars before sniffing — a misbehaving anchor could otherwise feed an -// arbitrary HTML page into the test-response payload. We validate octet -// ranges and canonical v6 shape (one optional `::` shorthand, 1-4 hex -// digits per group, RFC 4291 group counts), so anchor strings like -// `999.999.999.999` or `aaaa::bbbb::cccc` cannot pass. -const isIpV4 = (s: string): boolean => { - const octets = s.split('.'); - if (octets.length !== 4) return false; - for (const o of octets) { - if (!/^\d{1,3}$/.test(o)) return false; - // Reject leading zeros (e.g. `01`) — RFC 3986 forbids them and some - // resolvers interpret the value as octal, so accepting them invites - // ambiguity. - if (o.length > 1 && o.startsWith('0')) return false; - const n = Number(o); - if (n > 255) return false; - } - return true; -}; - -const isIpV6 = (s: string): boolean => { - if (!s.includes(':')) return false; - // At most one `::` shorthand (per RFC 4291 §2.2). - if ((s.match(/::/g) ?? []).length > 1) return false; - if (s.includes(':::')) return false; - - // Normalize an embedded v4 tail to two synthetic hex groups so the rest - // of the validation runs on a pure-hex shape. - let normalized = s; - const lastColon = s.lastIndexOf(':'); - const afterLastColon = s.slice(lastColon + 1); - if (afterLastColon.includes('.')) { - if (!isIpV4(afterLastColon)) return false; - normalized = `${s.slice(0, lastColon + 1)}0:0`; - } - - const validGroup = (g: string): boolean => /^[0-9a-fA-F]{1,4}$/.test(g); - - if (normalized.includes('::')) { - const [leftRaw, rightRaw] = normalized.split('::'); - const left = leftRaw === '' ? [] : leftRaw.split(':'); - const right = rightRaw === '' ? [] : rightRaw.split(':'); - if (!left.every(validGroup) || !right.every(validGroup)) return false; - // `::` must elide at least one group, so the explicit group total - // is strictly less than 8. - return left.length + right.length < 8; - } - - const groups = normalized.split(':'); - if (groups.length !== 8) return false; - return groups.every(validGroup); -}; - export const testProxy = async (c: CtxWithJson) => { const body = c.req.valid('json'); const anchorName = body.anchor ?? 'ipify'; diff --git a/packages/gateway/src/control-plane/schemas.ts b/packages/gateway/src/control-plane/schemas.ts index dc8bd2696c..d7ca3e0170 100644 --- a/packages/gateway/src/control-plane/schemas.ts +++ b/packages/gateway/src/control-plane/schemas.ts @@ -138,8 +138,8 @@ const limitsSchema = z.object({ }); // Mirrors the runtime UpstreamModelConfig in @floway-dev/provider. -// Azure and custom upstreams share this per-model entry; the canonical -// per-model endpoint validation lives in the runtime validator. +// Azure, custom, and ollama upstreams share this per-model entry; the +// canonical per-model endpoint validation lives in the runtime validator. const upstreamModelSchema = z.object({ upstreamModelId: z.string().min(1), publicModelId: z.string().optional(), @@ -736,9 +736,8 @@ export const tokenUsageQuery = z.object(usageBaseQuery); // Dashboard `/api/models` accepts two query knobs. `aliases=false` skips the // alias-merge pass — the alias edit dialog and shadow detection need the // raw real-model set. `include_unlisted=true` extends the payload with the -// addressable-but-not-listed surface (prefix-form alternates, Copilot -// variant ids, provider-side redirects), so the alias dialog combobox sees -// every id the data-plane resolver would accept. +// addressable-but-not-listed surface (prefix-form alternates), so the alias +// dialog combobox sees every id the data-plane resolver would accept. export const modelsQuery = z.object({ aliases: z.enum(['true', 'false']).optional(), include_unlisted: z.enum(['true', 'false']).optional(), diff --git a/packages/gateway/src/control-plane/shared/field-validators.ts b/packages/gateway/src/control-plane/shared/field-validators.ts index a724b17d56..9dd26b3218 100644 --- a/packages/gateway/src/control-plane/shared/field-validators.ts +++ b/packages/gateway/src/control-plane/shared/field-validators.ts @@ -1,7 +1,3 @@ -import type { CopilotUpstreamConfig, CopilotUpstreamUser } from '@floway-dev/provider-copilot'; - -export type { CopilotUpstreamConfig, CopilotUpstreamUser }; - export const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); @@ -17,31 +13,3 @@ export const nonEmptyStringField = (value: unknown, field: string, err: FieldErr if (str === '') throw err(field, 'a non-empty string'); return str; }; - -const nullableStringField = (value: unknown, field: string, err: FieldErrorBuilder): string | null => { - if (value !== null && typeof value !== 'string') throw err(field, 'a string or null'); - return value; -}; - -const integerField = (value: unknown, field: string, err: FieldErrorBuilder): number => { - if (typeof value !== 'number' || !Number.isSafeInteger(value)) throw err(field, 'an integer'); - return value; -}; - -const copilotUserField = (value: unknown, err: FieldErrorBuilder): CopilotUpstreamUser => { - if (!isRecord(value)) throw err('user', 'an object'); - return { - login: stringField(value.login, 'user.login', err), - avatar_url: stringField(value.avatar_url, 'user.avatar_url', err), - name: nullableStringField(value.name, 'user.name', err), - id: integerField(value.id, 'user.id', err), - }; -}; - -export const copilotConfigField = (value: unknown, err: FieldErrorBuilder): CopilotUpstreamConfig => { - if (!isRecord(value)) throw err('config', 'an object'); - return { - githubToken: nonEmptyStringField(value.githubToken, 'githubToken', err), - user: copilotUserField(value.user, err), - }; -}; diff --git a/packages/gateway/src/control-plane/upstreams/models.ts b/packages/gateway/src/control-plane/upstreams/models.ts index b0667ce8f5..1a593b0ba8 100644 --- a/packages/gateway/src/control-plane/upstreams/models.ts +++ b/packages/gateway/src/control-plane/upstreams/models.ts @@ -12,10 +12,12 @@ import { assertCustomUpstreamRecord, fetchCustomModels } from '@floway-dev/provi import { assertOllamaUpstreamRecord, createOllamaProvider } from '@floway-dev/provider-ollama'; // `upstreamModelId` is the wire-side identifier the provider will send when -// a caller invokes the public `model.id` — claude-code exposes +// a caller invokes the public `model.id` — Claude Code exposes // `claude-sonnet-4-5` publicly while sending `claude-sonnet-4-5-20250929` -// on the wire, and other providers may distinguish similarly through their -// opaque `providerData` blob. +// on the wire. `providerData` is opaque provider-private invocation data, +// not a universal upstream-id field: only the providers that shape it as +// `{ upstreamModelId }` surface a distinct wire id here, and the rest +// (Copilot carries its raw variant list there) report the public id. const reshapeModelForDashboard = (model: ProviderModel): Record => { const providerData = typeof model.providerData === 'object' && model.providerData !== null ? model.providerData as { upstreamModelId?: unknown } : null; const wireId = typeof providerData?.upstreamModelId === 'string' && providerData.upstreamModelId.length > 0 ? providerData.upstreamModelId : model.id; diff --git a/packages/gateway/src/control-plane/upstreams/routes.ts b/packages/gateway/src/control-plane/upstreams/routes.ts index 608a82081b..7bb007061d 100644 --- a/packages/gateway/src/control-plane/upstreams/routes.ts +++ b/packages/gateway/src/control-plane/upstreams/routes.ts @@ -8,7 +8,7 @@ import { getRepo } from '../../repo/index.ts'; import { isDirectFallbackId, normalizeProxyFallbackList } from '../../repo/proxy-fallback-list.ts'; import { shortId } from '../../shared/short-id.ts'; import type { createUpstreamBody, updateUpstreamBody } from '../schemas.ts'; -import { copilotConfigField, isRecord } from '../shared/field-validators.ts'; +import { isRecord } from '../shared/field-validators.ts'; import { nextSortOrder } from '../shared/sort-order.ts'; import { warmModelsCache } from '../shared/warm-models-cache.ts'; import { @@ -23,7 +23,7 @@ import { import { assertAzureUpstreamRecord } from '@floway-dev/provider-azure'; import { assertClaudeCodeUpstreamRecord, readClaudeCodeUpstreamState } from '@floway-dev/provider-claude-code'; import { type CodexQuotaSnapshotMap, assertCodexUpstreamRecord, assertCodexUpstreamState, getCodexQuota } from '@floway-dev/provider-codex'; -import { readCopilotUpstreamState } from '@floway-dev/provider-copilot'; +import { parseCopilotUpstreamConfig, readCopilotUpstreamState } from '@floway-dev/provider-copilot'; import { assertCustomUpstreamRecord } from '@floway-dev/provider-custom'; import { assertOllamaUpstreamRecord } from '@floway-dev/provider-ollama'; @@ -89,7 +89,7 @@ const normalizeConfig = (record: UpstreamRecord): ValidationResult => { } return { ok: true, - value: copilotConfigField( + value: parseCopilotUpstreamConfig( record.config, (field, expected) => new Error(`Malformed copilot upstream config: ${field} must be ${expected}`), ), diff --git a/packages/gateway/src/control-plane/upstreams/serialize.ts b/packages/gateway/src/control-plane/upstreams/serialize.ts index 106a3d8b74..be189ac1b3 100644 --- a/packages/gateway/src/control-plane/upstreams/serialize.ts +++ b/packages/gateway/src/control-plane/upstreams/serialize.ts @@ -162,7 +162,8 @@ const redactedState = (upstream: UpstreamRecord): unknown => { case 'custom': case 'azure': case 'ollama': - // These providers have no autonomous state. + // These kinds carry no state at all — no credential rotation, quota + // snapshot, or OAuth account slot to keep server-side. return null; default: { const exhaustive: never = upstream.kind; @@ -199,13 +200,15 @@ export const upstreamRecordToJson = (upstream: UpstreamRecord): SerializedUpstre export const upstreamRecordToFullJson = (upstream: UpstreamRecord): SerializedUpstreamRecord => serializeBase(upstream, clone(upstream.config), clone(upstream.state)); -// Shape-complete UpstreamRecord blank for `kind`, never persisted. Serves the +// Shape-complete per-kind UpstreamRecord default, never persisted. Serves the // GET /api/upstreams/blueprint endpoint so the create page consumes the same // SerializedUpstreamRecord shape edit does — the front-end draft variable is // uniform across create and edit, and no write-path invariants have to be -// satisfied. Field values are true blanks (empty strings, empty arrays), -// matching what an editor sees before typing anything or completing any -// OAuth flow. +// satisfied. Operator-typed text starts empty; every other slot takes the +// kind's own default rather than a blank (`authStyle: 'bearer'`, +// `modelsFetch: { enabled: false }`, a zero-value Copilot `user` object), so +// the draft is a well-formed record before anything is typed or any OAuth +// flow completes. export const blueprintUpstreamRecord = (kind: UpstreamProviderKind): UpstreamRecord => { const base = { id: '', diff --git a/packages/gateway/src/data-plane/audio/respond.ts b/packages/gateway/src/data-plane/audio/respond.ts index 5d0d5481c7..260f3448fd 100644 --- a/packages/gateway/src/data-plane/audio/respond.ts +++ b/packages/gateway/src/data-plane/audio/respond.ts @@ -1,6 +1,6 @@ import { streamSSE } from 'hono/streaming'; -import { measureAudioUsage } from './usage.ts'; +import { measureAudioTranscriptionUsage } from './usage.ts'; import { passthroughApiError } from '../shared/passthrough-serve.ts'; import type { PassthroughResponseStrategyContext } from '../shared/passthrough-serve.ts'; import { type StreamCompletion, writeSSEFrames } from '../shared/sse.ts'; @@ -25,7 +25,7 @@ const respondNonStreaming = async ({ ctx, sourceApi, response, performance, iden ); } if (parsed !== undefined) { - measurement = measureAudioUsage(parsed, sourceApi); + measurement = measureAudioTranscriptionUsage(parsed, sourceApi); } } ctx.dump?.success(identity, measurement.dumpTokenUsage); @@ -59,7 +59,7 @@ const respondStreaming = ({ c, ctx, sourceApi, response, performance, identity } ctx.dump?.frame(eventFrame(event)); if (isAudioTranscriptionDoneEvent(event)) { terminalEventSeen = true; - measurement = measureAudioUsage(event, sourceApi); + measurement = measureAudioTranscriptionUsage(event, sourceApi); yield frame; return; } diff --git a/packages/gateway/src/data-plane/audio/usage.ts b/packages/gateway/src/data-plane/audio/usage.ts index b4e95ca97e..57baa7835f 100644 --- a/packages/gateway/src/data-plane/audio/usage.ts +++ b/packages/gateway/src/data-plane/audio/usage.ts @@ -1,6 +1,6 @@ import type { UsageQuantities } from '../../repo/types.ts'; import { requestOnlyUsageMeasurement, tokenUsage, type UsageMeasurement } from '../shared/telemetry/usage.ts'; -import { canonicalDecimalString } from '@floway-dev/protocols/common'; +import { parseDecimalString } from '@floway-dev/protocols/common'; // OpenAI transcription responses discriminate usage by `type`. Token-based // models split input_token_details into text and audio metrics; without that @@ -14,7 +14,7 @@ const audioDurationMeasurement = (seconds: unknown, label: string): UsageMeasure throw new Error(`Audio transcription ${label} must be a finite non-negative number`); } return { - quantities: { input_audio_seconds: canonicalDecimalString(String(seconds)) }, + quantities: { input_audio_seconds: parseDecimalString(String(seconds)) }, pricingFacts: {}, dumpTokenUsage: null, }; @@ -53,7 +53,7 @@ export const audioTranscriptionUsageMeasurement = (body: unknown): UsageMeasurem throw new Error('Audio transcription token usage.total_tokens must equal input_tokens plus output_tokens'); } - let inputQuantities: UsageQuantities = { input_tokens: canonicalDecimalString(String(inputTokens)) }; + let inputQuantities: UsageQuantities = { input_tokens: parseDecimalString(String(inputTokens)) }; if (metric.input_token_details !== undefined) { if (!metric.input_token_details || typeof metric.input_token_details !== 'object' || Array.isArray(metric.input_token_details)) { throw new Error('Audio transcription token usage.input_token_details must be an object'); @@ -73,21 +73,21 @@ export const audioTranscriptionUsageMeasurement = (body: unknown): UsageMeasurem throw new Error('Audio transcription token usage.input_token_details must not exceed input_tokens'); } inputQuantities = { - input_tokens: canonicalDecimalString(String(inputTokens - (audioTokens ?? 0))), - ...(audioTokens === undefined ? {} : { input_audio_tokens: canonicalDecimalString(String(audioTokens)) }), + input_tokens: parseDecimalString(String(inputTokens - (audioTokens ?? 0))), + ...(audioTokens === undefined ? {} : { input_audio_tokens: parseDecimalString(String(audioTokens)) }), }; } return { quantities: { ...inputQuantities, - output_tokens: canonicalDecimalString(String(outputTokens)), + output_tokens: parseDecimalString(String(outputTokens)), }, pricingFacts: { inputTokens }, dumpTokenUsage: tokenUsage({ input: inputTokens, output: outputTokens }), }; }; -export const measureAudioUsage = (value: unknown, sourceApi: string): UsageMeasurement => { +export const measureAudioTranscriptionUsage = (value: unknown, sourceApi: string): UsageMeasurement => { try { return audioTranscriptionUsageMeasurement(value); } catch (error) { diff --git a/packages/gateway/src/data-plane/chat/chat-completions/affinity/roundtrip_test.ts b/packages/gateway/src/data-plane/chat/chat-completions/affinity/roundtrip_test.ts new file mode 100644 index 0000000000..4eaae50d7a --- /dev/null +++ b/packages/gateway/src/data-plane/chat/chat-completions/affinity/roundtrip_test.ts @@ -0,0 +1,83 @@ +import { expect, test } from 'vitest'; + +import { wrapChatCompletionsAffinityEgress } from './egress.ts'; +import { prepareChatCompletionsAffinity } from './ingress.ts'; +import { AffinityCodec, type AffinityTarget } from '../../shared/affinity/index.ts'; +import { reassembleChatCompletionsEvents, type ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; +import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import type { ModelCandidate } from '@floway-dev/provider'; +import { stubModelCandidate } from '@floway-dev/test-utils'; + +const codec = new AffinityCodec('22'.repeat(32)); + +const candidate = (upstream: string): ModelCandidate => { + const base = stubModelCandidate(); + return stubModelCandidate({ + provider: { ...base.provider, upstreamId: upstream }, + model: { id: 'model' }, + }); +}; + +const targetFor = (value: ModelCandidate): AffinityTarget => ({ + upstreamId: value.provider.upstreamId, + modelId: value.model.id, + ...(value.rules !== undefined ? { rules: value.rules } : {}), +}); + +const chunk = (choices: ChatCompletionsStreamEvent['choices']): ChatCompletionsStreamEvent => ({ + id: 'chatcmpl_1', + object: 'chat.completion.chunk', + created: 1, + model: 'model', + choices, +}); + +const frames = async function* (values: ProtocolFrame[]) { + yield* values; +}; + +// The client's next turn replays the assistant message it reassembled from the +// stream, so reassembly is what carries egress output back to ingress. +const assistantMessage = async (source: AsyncIterable>) => { + const events = async function* () { + for await (const frame of source) if (frame.type === 'event') yield frame.event; + }; + return (await reassembleChatCompletionsEvents(events())).choices[0].message; +}; + +test('a carrier a real codec emits on reasoning_opaque decodes on the next turn', async () => { + const candidateA = candidate('upstream-a'); + const candidateB = candidate('upstream-b'); + const message = await assistantMessage(wrapChatCompletionsAffinityEgress(frames([ + eventFrame(chunk([{ + index: 0, + delta: { content: 'answer', reasoning_opaque: 'upstream-opaque' }, + finish_reason: 'stop', + }])), + doneFrame(), + ]), { codec, affinity: targetFor(candidateA) })); + + const prepared = await prepareChatCompletionsAffinity({ model: 'model', messages: [message] }, codec); + + expect(prepared.narrowingEvidence).toEqual([{ target: targetFor(candidateA), mode: 'prefer' }]); + expect(prepared.payloadForCandidate(candidateA).messages[0]).toMatchObject({ + content: 'answer', + reasoning_opaque: 'upstream-opaque', + }); + expect(prepared.payloadForCandidate(candidateB).messages[0]).not.toHaveProperty('reasoning_opaque'); +}); + +test('a synthetic carrier issued for a choice without reasoning decodes on the next turn', async () => { + const candidateA = candidate('upstream-a'); + const candidateB = candidate('upstream-b'); + const message = await assistantMessage(wrapChatCompletionsAffinityEgress(frames([ + eventFrame(chunk([{ index: 0, delta: { content: 'answer' }, finish_reason: 'stop' }])), + doneFrame(), + ]), { codec, affinity: targetFor(candidateA) })); + + const prepared = await prepareChatCompletionsAffinity({ model: 'model', messages: [message] }, codec); + + expect(prepared.narrowingEvidence).toEqual([{ target: targetFor(candidateA), mode: 'prefer' }]); + expect(prepared.payloadForCandidate(candidateA).messages[0]).not.toHaveProperty('reasoning_opaque'); + expect(prepared.payloadForCandidate(candidateB).messages[0]).not.toHaveProperty('reasoning_opaque'); +}); diff --git a/packages/gateway/src/data-plane/chat/gemini/affinity/roundtrip_test.ts b/packages/gateway/src/data-plane/chat/gemini/affinity/roundtrip_test.ts new file mode 100644 index 0000000000..1fd6ab91d4 --- /dev/null +++ b/packages/gateway/src/data-plane/chat/gemini/affinity/roundtrip_test.ts @@ -0,0 +1,80 @@ +import { expect, test } from 'vitest'; + +import { wrapGeminiAffinityEgress } from './egress.ts'; +import { prepareGeminiAffinity } from './ingress.ts'; +import { AffinityCodec, type AffinityTarget } from '../../shared/affinity/index.ts'; +import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { reassembleGeminiEvents, type GeminiContent, type GeminiStreamEvent } from '@floway-dev/protocols/gemini'; +import type { ModelCandidate } from '@floway-dev/provider'; +import { stubModelCandidate } from '@floway-dev/test-utils'; + +const codec = new AffinityCodec('22'.repeat(32)); + +const candidate = (upstream: string): ModelCandidate => { + const base = stubModelCandidate(); + return stubModelCandidate({ + provider: { ...base.provider, upstreamId: upstream }, + model: { id: 'model' }, + }); +}; + +const targetFor = (value: ModelCandidate): AffinityTarget => ({ + upstreamId: value.provider.upstreamId, + modelId: value.model.id, + ...(value.rules !== undefined ? { rules: value.rules } : {}), +}); + +const frames = async function* (values: ProtocolFrame[]) { + yield* values; +}; + +// The client's next turn replays the model content it reassembled from the +// stream, so reassembly is what carries egress output back to ingress. +const modelContents = async ( + source: AsyncIterable>, +): Promise => { + const events = async function* () { + for await (const frame of source) if (frame.type === 'event') yield frame.event; + }; + const result = await reassembleGeminiEvents(events()); + if (result.candidates === undefined) throw new Error('Expected reassembled Gemini candidates'); + return result.candidates.map(reassembled => reassembled.content); +}; + +test('a carrier a real codec emits on thoughtSignature decodes on the next turn', async () => { + const candidateA = candidate('upstream-a'); + const candidateB = candidate('upstream-b'); + const contents = await modelContents(wrapGeminiAffinityEgress(frames([ + eventFrame({ + candidates: [{ + index: 0, + content: { role: 'model', parts: [{ text: 'visible', thoughtSignature: 'upstream-signature' }] }, + finishReason: 'STOP', + }], + }), + ]), { codec, affinity: targetFor(candidateA) })); + + const prepared = await prepareGeminiAffinity({ contents }, codec); + + expect(prepared.narrowingEvidence).toEqual([{ target: targetFor(candidateA), mode: 'prefer' }]); + expect(prepared.payloadForCandidate(candidateA).contents?.[0].parts).toEqual([ + { text: 'visible', thoughtSignature: 'upstream-signature' }, + ]); + expect(prepared.payloadForCandidate(candidateB).contents?.[0].parts).toEqual([{ text: 'visible' }]); +}); + +test('a synthetic carrier issued for a candidate without a signature decodes on the next turn', async () => { + const candidateA = candidate('upstream-a'); + const candidateB = candidate('upstream-b'); + const contents = await modelContents(wrapGeminiAffinityEgress(frames([ + eventFrame({ + candidates: [{ index: 0, content: { role: 'model', parts: [{ text: 'visible' }] }, finishReason: 'STOP' }], + }), + ]), { codec, affinity: targetFor(candidateA) })); + + const prepared = await prepareGeminiAffinity({ contents }, codec); + + expect(prepared.narrowingEvidence).toEqual([{ target: targetFor(candidateA), mode: 'prefer' }]); + expect(prepared.payloadForCandidate(candidateA).contents?.[0].parts).toEqual([{ text: 'visible' }]); + expect(prepared.payloadForCandidate(candidateB).contents?.[0].parts).toEqual([{ text: 'visible' }]); +}); diff --git a/packages/gateway/src/data-plane/chat/gemini/interceptors/index.ts b/packages/gateway/src/data-plane/chat/gemini/interceptors/index.ts index 17c1d71127..4478357895 100644 --- a/packages/gateway/src/data-plane/chat/gemini/interceptors/index.ts +++ b/packages/gateway/src/data-plane/chat/gemini/interceptors/index.ts @@ -24,6 +24,6 @@ export const geminiInterceptors: readonly GeminiInterceptor[] = [ // dispatch (acceptable) or wrap the post-`run()` event stream (incompatible // with the count-tokens result shape). `geminiAttempt.countTokens` applies // the payload-mutators inline before handing the translated payload to the -// Messages count_tokens path, so this list stays empty and serves only as a -// clear extension point for provider-supplied geminiCountTokens entries. +// Messages count_tokens path, so this list stays empty; the chain still runs +// so the count-tokens path keeps the same invocation envelope as generate. export const geminiCountTokensInterceptors: readonly GeminiCountTokensInterceptor[] = []; diff --git a/packages/gateway/src/data-plane/chat/messages/affinity/roundtrip_test.ts b/packages/gateway/src/data-plane/chat/messages/affinity/roundtrip_test.ts new file mode 100644 index 0000000000..ba06074a6e --- /dev/null +++ b/packages/gateway/src/data-plane/chat/messages/affinity/roundtrip_test.ts @@ -0,0 +1,94 @@ +import { expect, test } from 'vitest'; + +import { wrapMessagesAffinityEgress } from './egress.ts'; +import { prepareMessagesAffinity } from './ingress.ts'; +import { AffinityCodec, type AffinityTarget } from '../../shared/affinity/index.ts'; +import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { reassembleMessagesEvents, type MessagesAssistantContentBlock, type MessagesStreamEvent } from '@floway-dev/protocols/messages'; +import type { ModelCandidate } from '@floway-dev/provider'; +import { stubModelCandidate } from '@floway-dev/test-utils'; + +const codec = new AffinityCodec('22'.repeat(32)); + +const candidate = (upstream: string): ModelCandidate => { + const base = stubModelCandidate(); + return stubModelCandidate({ + provider: { ...base.provider, upstreamId: upstream }, + model: { id: 'model' }, + }); +}; + +const targetFor = (value: ModelCandidate): AffinityTarget => ({ + upstreamId: value.provider.upstreamId, + modelId: value.model.id, + ...(value.rules !== undefined ? { rules: value.rules } : {}), +}); + +const frames = async function* (values: ProtocolFrame[]) { + yield* values; +}; + +// The client's next turn replays the assistant blocks it reassembled from the +// stream, so reassembly is what carries egress output back to ingress. +const assistantContent = async ( + source: AsyncIterable>, +): Promise => { + const events = async function* () { + for await (const frame of source) if (frame.type === 'event') yield frame.event; + }; + return (await reassembleMessagesEvents(events())).content; +}; + +test('carriers a real codec emits on both Messages slots decode on the next turn', async () => { + const candidateA = candidate('upstream-a'); + const candidateB = candidate('upstream-b'); + const content = await assistantContent(wrapMessagesAffinityEgress(frames([ + eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } }), + eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'visible' } }), + eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'upstream-signature' } }), + eventFrame({ type: 'content_block_stop', index: 0 }), + eventFrame({ type: 'content_block_start', index: 1, content_block: { type: 'redacted_thinking', data: 'upstream-redacted' } }), + eventFrame({ type: 'content_block_stop', index: 1 }), + eventFrame({ type: 'message_delta', delta: { stop_reason: 'end_turn' } }), + eventFrame({ type: 'message_stop' }), + ]), { codec, affinity: targetFor(candidateA) })); + + const prepared = await prepareMessagesAffinity({ + model: 'model', + max_tokens: 100, + messages: [{ role: 'assistant', content }], + }, codec); + + expect(prepared.narrowingEvidence).toEqual([ + { target: targetFor(candidateA), mode: 'prefer' }, + { target: targetFor(candidateA), mode: 'prefer' }, + ]); + expect(prepared.payloadForCandidate(candidateA).messages[0].content).toEqual([ + { type: 'thinking', thinking: 'visible', signature: 'upstream-signature' }, + { type: 'redacted_thinking', data: 'upstream-redacted' }, + ]); + expect(prepared.payloadForCandidate(candidateB).messages[0].content).toEqual([ + { type: 'thinking', thinking: 'visible' }, + ]); +}); + +test('a synthetic carrier issued for a turn without thinking decodes on the next turn', async () => { + const candidateA = candidate('upstream-a'); + const candidateB = candidate('upstream-b'); + const content = await assistantContent(wrapMessagesAffinityEgress(frames([ + eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }), + eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'answer' } }), + eventFrame({ type: 'content_block_stop', index: 0 }), + eventFrame({ type: 'message_stop' }), + ]), { codec, affinity: targetFor(candidateA) })); + + const prepared = await prepareMessagesAffinity({ + model: 'model', + max_tokens: 100, + messages: [{ role: 'assistant', content }], + }, codec); + + expect(prepared.narrowingEvidence).toEqual([{ target: targetFor(candidateA), mode: 'prefer' }]); + expect(prepared.payloadForCandidate(candidateA).messages[0].content).toEqual([{ type: 'text', text: 'answer' }]); + expect(prepared.payloadForCandidate(candidateB).messages[0].content).toEqual([{ type: 'text', text: 'answer' }]); +}); diff --git a/packages/gateway/src/data-plane/chat/responses/client-output.ts b/packages/gateway/src/data-plane/chat/responses/client-output.ts index 13db883fff..062b137742 100644 --- a/packages/gateway/src/data-plane/chat/responses/client-output.ts +++ b/packages/gateway/src/data-plane/chat/responses/client-output.ts @@ -2,6 +2,7 @@ import { wrapResponsesAffinityEgress } from './affinity/egress.ts'; import { wrapResponsesClientOutput } from './items/output.ts'; import { createResponsesResponseId } from './response-id.ts'; import type { GatewayCtx } from '../../shared/gateway-ctx.ts'; +import { affinityEgressOptions } from '../shared/affinity/index.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ResponsesStreamEvent } from '@floway-dev/protocols/responses'; @@ -15,10 +16,7 @@ export const wrapNativeResponsesClientOutput = ( ): AsyncIterable> => { if (!('affinity' in ctx) || !('store' in ctx)) throw new Error('Responses output reached the client-facing boundary without chat context'); const chatCtx = ctx as ChatGatewayCtx; - const withAffinity = wrapResponsesAffinityEgress(frames, { - codec: chatCtx.affinity.codec, - affinity: chatCtx.affinity.selectedTarget(), - }); + const withAffinity = wrapResponsesAffinityEgress(frames, affinityEgressOptions(ctx)); return wrapResponsesClientOutput(withAffinity, { store: chatCtx.store, responseId: createResponsesResponseId(), diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/compact-shim.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/compact-shim.ts index d960fed121..ab38afd382 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/compact-shim.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/compact-shim.ts @@ -3,9 +3,11 @@ // // Engagement is the OR of two conditions: // 1. The per-upstream `responses-compact-shim` flag is on. This is the -// operator-controlled opt-in for Responses-target upstreams (codex / -// copilot / azure / custom) that natively support compaction but where -// we still want shim-synthesized envelopes. +// operator-controlled opt-in for Responses-target upstreams that +// already answer a compact request themselves — natively through +// `/responses/compact` (codex / azure / custom), or by replaying +// `RemoteCompactionV2` over `/responses` (copilot) — but where we still +// want shim-synthesized envelopes. // 2. The candidate's `targetApi` is not `responses`. When the upstream is // Messages or Chat Completions, the translation layer has no concept // of a `compaction` output item or a `compaction_trigger` input item. @@ -47,8 +49,8 @@ // // Foreign-upstream blobs (opaque strings that fail base64url+JSON decoding // or fail the array-of-objects-with-string-types schema below) round-trip -// untouched, so the operator can selectively turn the flag off for codex / -// copilot / azure / custom upstreams that natively support compaction. +// untouched, so the operator can selectively turn the flag off for the +// codex / copilot / azure / custom upstreams that answer compact themselves. import type { ResponsesInterceptor, ResponsesInvocation } from './types.ts'; import { decodeBase64UrlJson, encodeBase64UrlJson } from '../../../../shared/base64url-json.ts'; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/retry-cyber-policy_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/retry-cyber-policy_test.ts index 13a63bde68..678e31146d 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/retry-cyber-policy_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/retry-cyber-policy_test.ts @@ -143,7 +143,6 @@ const performanceFor = (model: string) => ({ stream: true, runtimeLocation: 'TEST', dump: null, - responseHeaders: new Headers(), }); const upstreamCyberPolicyError = (message: string): ExecuteResult> => ({ diff --git a/packages/gateway/src/data-plane/chat/responses/serve-prep.ts b/packages/gateway/src/data-plane/chat/responses/serve-prep.ts index 375fef0e63..96782ce2f8 100644 --- a/packages/gateway/src/data-plane/chat/responses/serve-prep.ts +++ b/packages/gateway/src/data-plane/chat/responses/serve-prep.ts @@ -81,7 +81,6 @@ export const prepareResponsesServePlan = async (args: { }): Promise => { const { payload, ctx } = args; const store = ctx.store; - if (store === undefined) throw new Error('Native Responses serve requires a state store'); const prepared = await expandPreviousResponseId(payload, store); const { candidates, sawModel, failedUpstreams } = await enumerateModelCandidates({ upstreamIds: ctx.upstreamIds, diff --git a/packages/gateway/src/data-plane/chat/responses/websocket.ts b/packages/gateway/src/data-plane/chat/responses/websocket.ts index 757c2a66f6..0aff7529ea 100644 --- a/packages/gateway/src/data-plane/chat/responses/websocket.ts +++ b/packages/gateway/src/data-plane/chat/responses/websocket.ts @@ -34,13 +34,6 @@ interface ResponsesWebSocketSocket { const UTF8_ENCODER = new TextEncoder(); -export interface ResponsesWebSocketEvents { - onOpen?(event: Event, socket: ResponsesWebSocketSocket): void; - onMessage?(event: { readonly data: unknown }, socket: ResponsesWebSocketSocket): void; - onClose?(event: unknown, socket: ResponsesWebSocketSocket): void; - onError?(event: unknown, socket: ResponsesWebSocketSocket): void; -} - interface ResponsesWebSocketHandlers { onMessage(event: { readonly data: unknown }, socket: ResponsesWebSocketSocket): void; onClose(event: unknown, socket: ResponsesWebSocketSocket): void; diff --git a/packages/gateway/src/data-plane/chat/responses/websocket_test.ts b/packages/gateway/src/data-plane/chat/responses/websocket_test.ts index 639e34fda2..beb904ecc2 100644 --- a/packages/gateway/src/data-plane/chat/responses/websocket_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/websocket_test.ts @@ -8,75 +8,10 @@ import { initDumpBroker, initDumpStore } from '../../../dump/registry.ts'; import { installDumpStubs } from '../../../dump/test-fixtures.ts'; import { FakeTime } from '../../../test-time.ts'; import { copilotModels, flushAsyncWork, setupAppTest, sseResponsesResponse } from '../../../test-utils/app.ts'; +import { installWorkerWebSocketRuntime, type TestWorkerWebSocket } from '../../../test-utils/worker-websocket.ts'; import { DOWNSTREAM_KEEP_ALIVE_INTERVAL_MS } from '../../shared/sse.ts'; import { assert, assertEquals, assertExists, assertStringIncludes, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; -type WorkerResponseInit = ResponseInit & { readonly webSocket?: WebSocket }; - -class TestWorkerWebSocket extends EventTarget { - peer?: TestWorkerWebSocket; - readyState: number = WebSocket.OPEN; - - accept(): void {} - - send(data: string): void { - this.peer?.dispatchEvent(new MessageEvent('message', { data })); - } - - close(): void { - this.readyState = WebSocket.CLOSED; - if (this.peer) { - this.peer.readyState = WebSocket.CLOSED; - this.peer.dispatchEvent(new Event('close')); - } - } -} - -const installWorkerWebSocketRuntime = (): { - readonly pairs: Array<{ readonly client: TestWorkerWebSocket; readonly server: TestWorkerWebSocket }>; - restore(): void; -} => { - const globals = globalThis as typeof globalThis & { - WebSocketPair?: unknown; - Response: typeof Response; - }; - const originalWebSocketPair = globals.WebSocketPair; - const OriginalResponse = globals.Response; - const pairs: Array<{ readonly client: TestWorkerWebSocket; readonly server: TestWorkerWebSocket }> = []; - - globals.WebSocketPair = class { - constructor() { - const client = new TestWorkerWebSocket(); - const server = new TestWorkerWebSocket(); - client.peer = server; - server.peer = client; - pairs.push({ client, server }); - return { 0: client, 1: server }; - } - }; - - globals.Response = class extends OriginalResponse { - constructor(body?: BodyInit | null, init?: WorkerResponseInit) { - if (init?.status === 101) { - const { webSocket, status: _status, ...rest } = init; - super(null, { ...rest, status: 200 }); - Object.defineProperty(this, 'status', { value: 101 }); - Object.defineProperty(this, 'webSocket', { value: webSocket }); - return; - } - super(body, init); - } - }; - - return { - pairs, - restore: () => { - globals.WebSocketPair = originalWebSocketPair; - globals.Response = OriginalResponse; - }, - }; -}; - const waitForMessages = async ( socket: TestWorkerWebSocket, done: (messages: readonly Record[]) => boolean, diff --git a/packages/gateway/src/data-plane/chat/shared/affinity/codec_test.ts b/packages/gateway/src/data-plane/chat/shared/affinity/codec_test.ts index 073b7b26f2..7adf8e0271 100644 --- a/packages/gateway/src/data-plane/chat/shared/affinity/codec_test.ts +++ b/packages/gateway/src/data-plane/chat/shared/affinity/codec_test.ts @@ -11,7 +11,29 @@ const affinity: AffinityTarget = { modelId: 'model-a', }; +// A carrier this codec issued for SECRET and DOMAIN. It is a frozen wire +// contract, not a fixture: carriers already held by clients are decrypted by +// whatever ships next, so the HKDF salt and info, the AAD layout, the +// plaintext property names, and the trailer framing all have to survive. +// Changing any of them fails here, and re-recording the literal is the same +// act as invalidating every conversation in flight. +const FROZEN_CARRIER = 'AQIDBAWDP9gwaNMPLCk0oQ+usEVivj9ZICVyL3fu4x8gkOodb/vEU6189ANDLBtP1EXGNZgndPVyP96bDlZSoRj0YjhY8AoD+3/H71+8hcKBW/GSaV0w7FiF2KM8wk70DuHUIi3AW6CvFXHjzpj0+kpy5J1oYWqpTuzqLLydXk0QCTDAvV7rEGaeayaWFfS1mp3j6ScS51X0wCa9niXtm/iMSQCe'; + describe('AffinityCodec', () => { + test('unwraps a frozen carrier', async () => { + expect(await new AffinityCodec(SECRET).unwrap(FROZEN_CARRIER, DOMAIN)).toEqual({ + kind: 'owned', + value: 'AQIDBAU=', + version: 1, + origin: 'base64', + affinity: { + upstreamId: 'upstream-a', + modelId: 'model-a', + rules: { reasoning: { effort: 'high' } }, + }, + }); + }); + test.each([ ['raw', 'not base64!'], ['base64', btoa('upstream opaque bytes')], diff --git a/packages/gateway/src/data-plane/chat/shared/affinity/index.ts b/packages/gateway/src/data-plane/chat/shared/affinity/index.ts index 6a62dd60cd..160c25a687 100644 --- a/packages/gateway/src/data-plane/chat/shared/affinity/index.ts +++ b/packages/gateway/src/data-plane/chat/shared/affinity/index.ts @@ -7,8 +7,6 @@ import type { ChatGatewayCtx } from '../gateway-ctx.ts'; import { appendOpaqueTrailer, concatBytes, decodeOpaqueValue, encodeOpaqueValue, MAX_OPAQUE_TRAILER_BYTES, splitOpaqueTrailer, uint16be, type AliasRules, type OpaqueValueOrigin } from '@floway-dev/protocols/common'; import type { ModelCandidate } from '@floway-dev/provider'; -type AffinityOrigin = OpaqueValueOrigin; - export interface AffinityTarget { upstreamId: string; modelId: string; @@ -22,7 +20,7 @@ export interface AffinityEvidence { interface AffinityData { version: 1; - origin?: AffinityOrigin; + origin?: OpaqueValueOrigin; affinity: AffinityTarget; } @@ -264,8 +262,8 @@ export class AffinityRequestContext { readonly codec: AffinityCodec; #selectedCandidate: ModelCandidate | undefined; - constructor(secret: string) { - this.codec = new AffinityCodec(secret); + constructor(serverSecret: string) { + this.codec = new AffinityCodec(serverSecret); } select(candidate: ModelCandidate): void { diff --git a/packages/gateway/src/data-plane/chat/shared/translate-traverse.ts b/packages/gateway/src/data-plane/chat/shared/translate-traverse.ts index 2bb4e6dd01..a647ebb624 100644 --- a/packages/gateway/src/data-plane/chat/shared/translate-traverse.ts +++ b/packages/gateway/src/data-plane/chat/shared/translate-traverse.ts @@ -1,6 +1,6 @@ import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ExecuteResult } from '@floway-dev/provider'; -import type { TranslatedApiError } from '@floway-dev/translate'; +import type { TranslateTripResult } from '@floway-dev/translate'; // Threads a translate trip around an inner attempt. The trip itself is async // (the real `@floway-dev/translate` pair functions resolve a `Promise`), so @@ -16,11 +16,7 @@ import type { TranslatedApiError } from '@floway-dev/translate'; // pass the upstream body through verbatim. export const traverseTranslation = async ( payload: SP, - translate: (p: SP) => Promise<{ - target: TP; - events: (e: AsyncIterable>) => AsyncIterable>; - apiError?: (upstream: TranslatedApiError) => TranslatedApiError | undefined; - }>, + translate: (p: SP) => Promise>, innerAttempt: (translated: TP) => Promise>>, ): Promise>> => { const trip = await translate(payload); diff --git a/packages/gateway/src/data-plane/codex/routes_websocket_test.ts b/packages/gateway/src/data-plane/codex/routes_websocket_test.ts index f20d79cdc2..82ceae4807 100644 --- a/packages/gateway/src/data-plane/codex/routes_websocket_test.ts +++ b/packages/gateway/src/data-plane/codex/routes_websocket_test.ts @@ -3,74 +3,9 @@ import { expect, it } from 'vitest'; import { app as gatewayApp } from '../../app.ts'; import { copilotModels, setupAppTest, sseResponsesResponse } from '../../test-utils/app.ts'; +import { installWorkerWebSocketRuntime, type TestWorkerWebSocket } from '../../test-utils/worker-websocket.ts'; import { jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; -type WorkerResponseInit = ResponseInit & { readonly webSocket?: WebSocket }; - -class TestWorkerWebSocket extends EventTarget { - peer?: TestWorkerWebSocket; - readyState: number = WebSocket.OPEN; - - accept(): void {} - - send(data: string): void { - this.peer?.dispatchEvent(new MessageEvent('message', { data })); - } - - close(): void { - this.readyState = WebSocket.CLOSED; - if (this.peer) { - this.peer.readyState = WebSocket.CLOSED; - this.peer.dispatchEvent(new Event('close')); - } - } -} - -const installWorkerWebSocketRuntime = (): { - readonly pairs: Array<{ readonly client: TestWorkerWebSocket; readonly server: TestWorkerWebSocket }>; - restore(): void; -} => { - const globals = globalThis as typeof globalThis & { - WebSocketPair?: unknown; - Response: typeof Response; - }; - const originalWebSocketPair = globals.WebSocketPair; - const OriginalResponse = globals.Response; - const pairs: Array<{ readonly client: TestWorkerWebSocket; readonly server: TestWorkerWebSocket }> = []; - - globals.WebSocketPair = class { - constructor() { - const client = new TestWorkerWebSocket(); - const server = new TestWorkerWebSocket(); - client.peer = server; - server.peer = client; - pairs.push({ client, server }); - return { 0: client, 1: server }; - } - }; - - globals.Response = class extends OriginalResponse { - constructor(body?: BodyInit | null, init?: WorkerResponseInit) { - if (init?.status === 101) { - const { webSocket, status: _status, ...rest } = init; - super(null, { ...rest, status: 200 }); - Object.defineProperty(this, 'status', { value: 101 }); - Object.defineProperty(this, 'webSocket', { value: webSocket }); - return; - } - super(body, init); - } - }; - - return { - pairs, - restore: () => { - globals.WebSocketPair = originalWebSocketPair; - globals.Response = OriginalResponse; - }, - }; -}; - const waitForMessages = async ( socket: TestWorkerWebSocket, done: (messages: readonly Record[]) => boolean, diff --git a/packages/gateway/src/data-plane/codex/synthesize.ts b/packages/gateway/src/data-plane/codex/synthesize.ts index b0e6a7d7ee..d69507ee41 100644 --- a/packages/gateway/src/data-plane/codex/synthesize.ts +++ b/packages/gateway/src/data-plane/codex/synthesize.ts @@ -40,7 +40,8 @@ import type { CatalogModel, CodexCatalogCapabilities, CodexReasoningLevel } from './catalog.ts'; import { synthesizedBaseInstructions } from './synthesized-base-instructions.ts'; -import type { InternalModel, Modality } from '@floway-dev/provider'; +import type { Modality } from '@floway-dev/protocols/common'; +import type { InternalModel } from '@floway-dev/provider'; // A synthesized (miss-path) entry with no registry-supplied // `max_context_window_tokens` still needs SOME window — codex's auto-compact diff --git a/packages/gateway/src/data-plane/completions/http.ts b/packages/gateway/src/data-plane/completions/http.ts index 280a275fd4..5ed1e8084f 100644 --- a/packages/gateway/src/data-plane/completions/http.ts +++ b/packages/gateway/src/data-plane/completions/http.ts @@ -12,56 +12,19 @@ import { tokenUsageFromCompletionsUsage } from './usage.ts'; import type { TokenUsage } from '../../repo/types.ts'; import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; +import { prepareJsonModelRequest } from '../shared/passthrough-request.ts'; import { passthroughApiError, passthroughServe } from '../shared/passthrough-serve.ts'; import { readRequestBody, takeRequestBody } from '../shared/request-body.ts'; import { isOpenAIUsageOnlyEventShape, type ProtocolFrame } from '@floway-dev/protocols/common'; -interface CompletionsRequestBody { - model?: unknown; - stream?: unknown; - stream_options?: { include_usage?: unknown } | null; - [key: string]: unknown; -} - -type PreparedRequest = - | { - type: 'ok'; - body: Record; - model: string; - wantsStream: boolean; - clientWantsUsageChunk: boolean; - } - | { type: 'invalid'; message: string }; - -// `model` must be a non-empty string because gateway routing depends on -// it; every other field on the body flows through to the upstream -// unchanged. -const prepareCompletionsRequest = (bytes: Uint8Array): PreparedRequest => { - let request: CompletionsRequestBody; - try { - const parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { type: 'invalid', message: 'Completions request body must be an object.' }; - } - request = parsed as CompletionsRequestBody; - } catch { - return { type: 'invalid', message: 'Completions request body must be valid JSON.' }; - } - - if (typeof request.model !== 'string' || request.model.length === 0) { - return { type: 'invalid', message: 'Completions request body must include a model string.' }; - } - - const wantsStream = request.stream === true; - const clientWantsUsageChunk = request.stream_options?.include_usage === true; - return { type: 'ok', body: request, model: request.model, wantsStream, clientWantsUsageChunk }; -}; - export const completions = async (c: Context): Promise => { const requestBody = await readRequestBody(c); - const request = prepareCompletionsRequest(requestBody.bytes); + const request = prepareJsonModelRequest(requestBody.bytes, 'Completions'); + // `stream` decides the response shape, so the gateway context has to learn + // it before the invalid branch — which has no body to read it from. + const wantsStream = request.type === 'ok' && request.body.stream === true; const ctx = createGatewayCtxFromHono(c, { - wantsStream: request.type === 'ok' ? request.wantsStream : false, + wantsStream, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c), }); @@ -71,13 +34,15 @@ export const completions = async (c: Context): Promise => { } ctx.dump?.requestedModel(request.model); + const streamOptions = request.body.stream_options as { include_usage?: unknown } | null | undefined; + const clientWantsUsageChunk = streamOptions?.include_usage === true; // Strip the inbound model; the provider re-stamps the upstream-resolved // model id. For streaming requests we force `stream_options.include_usage` // on so billing always sees the usage chunk — sibling keys on // stream_options (if any) ride through unchanged. const { model: _model, ...upstreamBodyBase } = request.body; - const upstreamBody = request.wantsStream - ? { ...upstreamBodyBase, stream_options: { ...(request.body.stream_options ?? {}), include_usage: true } } + const upstreamBody = wantsStream + ? { ...upstreamBodyBase, stream_options: { ...(streamOptions ?? {}), include_usage: true } } : upstreamBodyBase; // Streaming closure: track the usage block (only on the usage-only @@ -92,7 +57,7 @@ export const completions = async (c: Context): Promise => { if (eventRoot.service_tier !== undefined) streamingServiceTier = eventRoot.service_tier; if (!isOpenAIUsageOnlyEventShape(frame.event)) return frame; streamingUsageBlock = eventRoot.usage; - return request.clientWantsUsageChunk ? frame : null; + return clientWantsUsageChunk ? frame : null; }; const settleUsage = (): TokenUsage | null => streamingUsageBlock === null ? null : tokenUsageFromCompletionsUsage(streamingUsageBlock, streamingServiceTier); @@ -107,7 +72,7 @@ export const completions = async (c: Context): Promise => { modelServesEndpoint: model => model.endpoints.completions !== undefined, call: (provider, model, opts) => provider.instance.callCompletions(model, upstreamBody, ctx.abortSignal, opts), - response: request.wantsStream + response: wantsStream ? { format: 'sse', transformFrame, settleUsage } : { format: 'json', diff --git a/packages/gateway/src/data-plane/embeddings/http.ts b/packages/gateway/src/data-plane/embeddings/http.ts index 4e1a9c282b..de5c06801a 100644 --- a/packages/gateway/src/data-plane/embeddings/http.ts +++ b/packages/gateway/src/data-plane/embeddings/http.ts @@ -6,47 +6,13 @@ import type { Context } from 'hono'; import { tokenUsageFromEmbeddingsBody } from './usage.ts'; import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; +import { prepareJsonModelRequest } from '../shared/passthrough-request.ts'; import { passthroughApiError, passthroughServe } from '../shared/passthrough-serve.ts'; import { readRequestBody, takeRequestBody } from '../shared/request-body.ts'; -interface EmbeddingsRequestBody { - model?: unknown; - input?: unknown; - [key: string]: unknown; -} - -const prepareEmbeddingsRequest = (bytes: Uint8Array): { type: 'ok'; body: Record; model: string } | { type: 'invalid'; message: string } => { - let request: EmbeddingsRequestBody; - - try { - const parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { - type: 'invalid', - message: 'Embeddings request body must be an object.', - }; - } - request = parsed as EmbeddingsRequestBody; - } catch { - return { - type: 'invalid', - message: 'Embeddings request body must be valid JSON.', - }; - } - - if (typeof request.model !== 'string' || request.model.length === 0) { - return { - type: 'invalid', - message: 'Embeddings request body must include a model string.', - }; - } - - return { type: 'ok', body: request, model: request.model }; -}; - export const embeddings = async (c: Context): Promise => { const requestBody = await readRequestBody(c); - const request = prepareEmbeddingsRequest(requestBody.bytes); + const request = prepareJsonModelRequest(requestBody.bytes, 'Embeddings'); const ctx = createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); if (request.type === 'invalid') { ctx.dump?.error('gateway'); diff --git a/packages/gateway/src/data-plane/images/http.ts b/packages/gateway/src/data-plane/images/http.ts index 3da8dc4ecf..5f22728730 100644 --- a/packages/gateway/src/data-plane/images/http.ts +++ b/packages/gateway/src/data-plane/images/http.ts @@ -11,38 +11,13 @@ import type { Context } from 'hono'; import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; +import { prepareJsonModelRequest } from '../shared/passthrough-request.ts'; import { passthroughApiError, passthroughServe } from '../shared/passthrough-serve.ts'; import { readRequestBody, takeRequestBody, type RequestBody } from '../shared/request-body.ts'; import { tokenUsageFromImagesBody } from '../shared/telemetry/usage.ts'; import type { ImageEditReference } from '@floway-dev/protocols/images'; import { isBase64ImageDataUrl, type ImagesEditsRequest, type ImagesEditsSource } from '@floway-dev/provider'; -interface JsonModelRequestBody { - model?: unknown; - [key: string]: unknown; -} - -type PreparedJsonRequest = - | { type: 'ok'; body: Record; model: string } - | { type: 'invalid'; message: string }; - -const prepareJsonModelRequest = (bytes: Uint8Array, requestName: string): PreparedJsonRequest => { - let request: JsonModelRequestBody; - try { - const parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { type: 'invalid', message: `${requestName} request body must be an object.` }; - } - request = parsed as JsonModelRequestBody; - } catch { - return { type: 'invalid', message: `${requestName} request body must be valid JSON.` }; - } - if (typeof request.model !== 'string' || request.model.length === 0) { - return { type: 'invalid', message: `${requestName} request body must include a model string.` }; - } - return { type: 'ok', body: request as Record, model: request.model }; -}; - type PreparedImagesEdit = | { type: 'ok'; request: ImagesEditsRequest } | { type: 'invalid'; message: string }; diff --git a/packages/gateway/src/data-plane/models/http_test.ts b/packages/gateway/src/data-plane/models/http_test.ts index 623c86b642..3702518533 100644 --- a/packages/gateway/src/data-plane/models/http_test.ts +++ b/packages/gateway/src/data-plane/models/http_test.ts @@ -105,7 +105,7 @@ test('/v1/models returns merged model list from Copilot and custom upstreams', a const claude = body.data.find(m => m.id === 'claude-sonnet-4')!; // Superset DTO: OpenAI's object + Anthropic's type + Anthropic's display_name - // + our extras. Slim ModelMetadata fields only. + // + our extras. Only `PublicModel` fields reach the wire. assertEquals(claude.object, 'model'); assertEquals(claude.type, 'model'); assertEquals(claude.display_name, 'Claude Sonnet 4'); diff --git a/packages/gateway/src/data-plane/models/load.ts b/packages/gateway/src/data-plane/models/load.ts index de93fd64fa..8ccbe09f73 100644 --- a/packages/gateway/src/data-plane/models/load.ts +++ b/packages/gateway/src/data-plane/models/load.ts @@ -6,11 +6,14 @@ import type { PublicModel, PublicModelsResponse } from '@floway-dev/protocols/co import type { Fetcher, InternalModel } from '@floway-dev/provider'; // Project an `InternalModel` onto the public-facing `/v1/models` wire DTO. -// `endpoints` rides through so listing clients can introspect each model's -// reach without a per-endpoint probe. When the row is an alias-synthesized -// one, `aliasedFrom` is emitted verbatim from the internal shape (they -// share the same fields); the real branch never carries it, so the sidecar -// is present exactly on alias rows. +// `endpoints` rides through as the merged upstream wire surface — the +// endpoints the upstreams serve this model on, not the inbound routes a +// client may call. Translation widens the chat keys: any one of +// `chatCompletions` / `messages` / `responses` makes the model reachable from +// all four inbound chat routes, and the Gemini route has no key of its own. +// When the row is an alias-synthesized one, `aliasedFrom` is emitted verbatim +// from the internal shape (they share the same fields); the real branch never +// carries it, so the sidecar is present exactly on alias rows. export const toPublicModel = (model: InternalModel): PublicModel => { const info: PublicModel = { id: model.id, diff --git a/packages/gateway/src/data-plane/models/load_test.ts b/packages/gateway/src/data-plane/models/load_test.ts index 7877943966..63f9d1c92e 100644 --- a/packages/gateway/src/data-plane/models/load_test.ts +++ b/packages/gateway/src/data-plane/models/load_test.ts @@ -31,5 +31,5 @@ describe('toPublicModel', () => { // The alias merge step inside `loadModels` (alias entries follow real // entries, alias names winning id collisions) is exercised through the -// integration suite in `serve_test.ts` so the assertion observes the same +// integration suite in `http_test.ts` so the assertion observes the same // `/v1/models` payload a real client would see. diff --git a/packages/gateway/src/data-plane/providers/catalog.ts b/packages/gateway/src/data-plane/providers/catalog.ts index e798f312bc..7764029996 100644 --- a/packages/gateway/src/data-plane/providers/catalog.ts +++ b/packages/gateway/src/data-plane/providers/catalog.ts @@ -125,9 +125,10 @@ const collectProviderModels = async ( // dropped before they reach the catalog map, so they appear in no /models // listing and resolve to nothing for routing. The disable is per-upstream, // so the same id can still surface from another upstream that allows it. - // The disable matches against the bare upstream id, so a disabled `gpt-4o` - // hides both `gpt-4o` and `gpt-4o` from this upstream's - // contribution. + // The disable matches the pre-prefix public id — the id the provider's + // own catalog projection publishes, before this loop surfaces it in each + // listed form — so a disabled `gpt-4o` hides both `gpt-4o` and + // `gpt-4o` from this upstream's contribution. const disabled = new Set(instance.disabledPublicModelIds); for (const providerModel of providedModels) { if (!providerModel.id) continue; diff --git a/packages/gateway/src/data-plane/providers/registry.ts b/packages/gateway/src/data-plane/providers/registry.ts index b901596777..8f0115ba55 100644 --- a/packages/gateway/src/data-plane/providers/registry.ts +++ b/packages/gateway/src/data-plane/providers/registry.ts @@ -22,10 +22,11 @@ export const createProvider = (record: UpstreamRecord): Provider => export const flagDefaultsForKind = (kind: UpstreamProviderKind): FlagDefaults => providersByKind[kind].defaultFlags; -// The upstream scope is a required argument across the provider-listing boundary -// this so a caller can never omit it and silently receive the -// full, unscoped catalog — a missing scope is a compile error, not a runtime -// leak. Pass `null` to deliberately request every enabled upstream. +// The upstream scope is a required argument across the catalog-assembly chain +// (this, `enumerateAddressableModelIds`, `enumerateModelCandidates`) so a +// caller can never omit it and silently receive the full, unscoped catalog — +// a missing scope is a compile error, not a runtime leak. Pass `null` to +// deliberately request every enabled upstream. // // `preFetchedUpstreams` lets a caller reuse a list it already loaded on // this request instead of paying a second `upstreams.list()` round-trip. diff --git a/packages/gateway/src/data-plane/providers/resolution.ts b/packages/gateway/src/data-plane/providers/resolution.ts index 722fe448be..b5069e640f 100644 --- a/packages/gateway/src/data-plane/providers/resolution.ts +++ b/packages/gateway/src/data-plane/providers/resolution.ts @@ -175,9 +175,10 @@ const orderAliasTargets = (alias: ModelAliasRecord): readonly ModelAliasRecord[' // // Endpoint-level narrowing — picking the chat target protocol from // `model.endpoints`, or checking the specific `imagesEdits` / -// `imagesGenerations` / `audioTranscriptions` / `completions` endpoint key — is the caller's job. +// `imagesGenerations` / `audioTranscriptions` / `completions` endpoint key — +// is the caller's job. // This function stays endpoint-blind so the same path serves chat, -// embeddings, image generation/edits, rerank, audio transcription, and legacy +// embeddings, image generation/edits, rerank, audio transcription, and // completions. // // The alias walk is a natural top-of-chain check: by construction an diff --git a/packages/gateway/src/data-plane/providers/resolution_test.ts b/packages/gateway/src/data-plane/providers/resolution_test.ts index 5c497cdfbf..6e6c119df0 100644 --- a/packages/gateway/src/data-plane/providers/resolution_test.ts +++ b/packages/gateway/src/data-plane/providers/resolution_test.ts @@ -101,7 +101,7 @@ test('enumerateModelCandidates does not retry when the inbound id has no dated s }); test('enumerateModelCandidates prefers the literal dated id over the stripped base when the catalog lists both', async () => { - // The dated suffix fallback is a SECOND attempt, gated on the first + // The dated-suffix retry is a SECOND attempt, gated on the first // attempt finding nothing. When the upstream catalog already lists the // dated id verbatim, the first attempt wins and the stripped form // never enters the candidate list. diff --git a/packages/gateway/src/data-plane/rerank/serve.ts b/packages/gateway/src/data-plane/rerank/serve.ts index b0293b12bb..7d64ba63d2 100644 --- a/packages/gateway/src/data-plane/rerank/serve.ts +++ b/packages/gateway/src/data-plane/rerank/serve.ts @@ -12,7 +12,7 @@ import { readRequestBody, takeRequestBody } from '../shared/request-body.ts'; import { recordFailedRequest, recordPerformance, type PerformanceTelemetryContext } from '../shared/telemetry/performance.ts'; import { recordUsage } from '../shared/telemetry/usage.ts'; import { forwardUpstreamResponse } from '../shared/upstream-response.ts'; -import { canonicalDecimalString, type RerankSourceProtocol } from '@floway-dev/protocols/common'; +import { parseDecimalString, type RerankSourceProtocol } from '@floway-dev/protocols/common'; import { parseRerankRequest, parseRerankResponse, parseRerankUsage, renderRerankResponse, rerankRequestIncompatibility, type CanonicalRerankResponse, type ParsedRerankRequest } from '@floway-dev/protocols/rerank'; import { httpResponseToResponse, ProviderModelsUnavailableError, providerModelOf, toInternalDebugError } from '@floway-dev/provider'; import type { TelemetryModelIdentity } from '@floway-dev/provider'; @@ -36,8 +36,8 @@ const settleRerank = ( failed: boolean, ): void => { const quantities: UsageQuantities = {}; - if (usage?.searchUnits !== undefined) quantities.rerank_searches = canonicalDecimalString(String(usage.searchUnits)); - if (usage?.totalTokens !== undefined) quantities.input_tokens = canonicalDecimalString(String(usage.totalTokens)); + if (usage?.searchUnits !== undefined) quantities.rerank_searches = parseDecimalString(String(usage.searchUnits)); + if (usage?.totalTokens !== undefined) quantities.input_tokens = parseDecimalString(String(usage.totalTokens)); const pricingFacts = usage?.totalTokens === undefined ? {} : { inputTokens: usage.totalTokens }; ctx.backgroundScheduler(recordUsage(ctx.apiKeyId, identity, quantities, pricingFacts).catch(error => { console.error('Failed to record rerank usage:', error); diff --git a/packages/gateway/src/data-plane/shared/gateway-ctx.ts b/packages/gateway/src/data-plane/shared/gateway-ctx.ts index 193f9b8863..5889261c59 100644 --- a/packages/gateway/src/data-plane/shared/gateway-ctx.ts +++ b/packages/gateway/src/data-plane/shared/gateway-ctx.ts @@ -16,8 +16,7 @@ export interface AttemptState { } // Stamps at dispatch entry — pre-dial by design. See -// UpstreamCallOptions.wrapUpstreamCall for why the interval includes proxy -// handshake time (the user waits for it too). +// UpstreamCallOptions.wrapUpstreamCall for what the interval covers. export const stampUpstreamCallStart = (attempt: AttemptState) => (dispatch: () => Promise): Promise => { attempt.upstreamCallStartedAt = performance.now(); @@ -42,10 +41,6 @@ export interface GatewayCtx { // `finalizeGatewayResponse` short-circuits the dump tee and returns the // response untouched. readonly dump: DumpAccumulator | null; - // Headers staged during request processing and written onto the - // outbound response by `finalizeGatewayResponse`, regardless of how - // the responder built the body. - readonly responseHeaders: Headers; } export interface CreateGatewayCtxOptions { @@ -98,14 +93,11 @@ export const createGatewayCtxFromHono = (c: AuthedContext, opts: CreateGatewayCt attempt: { firstOutputTokenAt: null, upstreamCallStartedAt: null, telemetry: undefined }, runtimeLocation: getRuntimeLocation(c.req.raw), dump, - responseHeaders: new Headers(), }; }; // Run the dump-accumulator's finalize tee on the outgoing Response. Every // inbound HTTP wrapper returns its response through this seam so the dump // pipeline applies uniformly across happy-path, error, and passthrough paths. -export const finalizeGatewayResponse = (ctx: GatewayCtx, response: Response): Response => { - for (const [name, value] of ctx.responseHeaders) response.headers.set(name, value); - return ctx.dump?.finalize(response) ?? response; -}; +export const finalizeGatewayResponse = (ctx: GatewayCtx, response: Response): Response => + ctx.dump?.finalize(response) ?? response; diff --git a/packages/gateway/src/data-plane/shared/iterate-candidates.ts b/packages/gateway/src/data-plane/shared/iterate-candidates.ts index 25e021ee6f..bab0f7fccc 100644 --- a/packages/gateway/src/data-plane/shared/iterate-candidates.ts +++ b/packages/gateway/src/data-plane/shared/iterate-candidates.ts @@ -40,12 +40,11 @@ const isAttemptSuccess = (result: IterableAttemptResult): boolean => { // per-candidate *failure result* falls through so a transient 5xx/429 on // one upstream rolls over to the next; a thrown error leaves the loop and // surfaces to the caller, so a dial failure does not advance. When the -// list is exhausted the -// most recent failure is returned so callers can forward it verbatim and -// clients still see real upstream telemetry rather than a synthetic -// gateway envelope. Callers are contractually required to hand in a -// non-empty candidate list — the empty-candidate branch renders each -// caller's own protocol-shaped "no viable candidate" envelope at the +// list is exhausted the most recent failure is returned so callers can +// forward it verbatim and clients still see real upstream telemetry rather +// than a synthetic gateway envelope. Callers are contractually required to +// hand in a non-empty candidate list — the empty-candidate branch renders +// each caller's own protocol-shaped "no viable candidate" envelope at the // serve site. // // Owns per-attempt AttemptState: clears the two timing slots and stamps diff --git a/packages/gateway/src/data-plane/shared/listing/addressable.ts b/packages/gateway/src/data-plane/shared/listing/addressable.ts index 0fc2d7fca7..6c9e622162 100644 --- a/packages/gateway/src/data-plane/shared/listing/addressable.ts +++ b/packages/gateway/src/data-plane/shared/listing/addressable.ts @@ -1,4 +1,4 @@ -// One enumeration per (effective upstream cap) of every inbound model id the +// One enumeration per `upstreamIds` set of every inbound model id the // gateway accepts — the union of the listed catalog surface and the // addressable-but-not-listed surface contributed by `modelPrefix.addressable` // alternates. Listing-side availability checks (this module's alias helper, @@ -99,8 +99,8 @@ export const enumerateAddressableModelIds = async ( const out: AddressableIdEntry[] = []; // The canonical listed form for this upstream — the row the listing - // surface emitted, and the row a redirect-only addressable id should - // resolve back into so consumers find one consistent `InternalModel`. + // surface emitted, and the row an addressable-only prefix alternate + // resolves back into so consumers find one consistent `InternalModel`. const canonicalForm = cfg.listed.includes('prefixed') ? 'prefixed' : 'unprefixed'; for (const upstreamModel of upstreamModels) { diff --git a/packages/gateway/src/data-plane/shared/listing/alias.ts b/packages/gateway/src/data-plane/shared/listing/alias.ts index 7f1f2bff84..68b3230115 100644 --- a/packages/gateway/src/data-plane/shared/listing/alias.ts +++ b/packages/gateway/src/data-plane/shared/listing/alias.ts @@ -256,8 +256,8 @@ const synthesizeOne = ( narrowTargets: boolean, ): InternalModel | null => { // Gateway-wide kind-matched targets — the basis for stable metadata. - // A target reachable only via a prefix-addressable alternate or a - // provider-side redirect (Copilot variant id) still counts. + // A target reachable only through a prefix-addressable alternate still + // counts. const gatewayById = new Map(gatewayAddressableModelIds.map(entry => [entry.id, entry.model] as const)); const gatewayAvailable = alias.targets .map(target => ({ target, real: gatewayById.get(target.target_model_id) })) diff --git a/packages/gateway/src/data-plane/shared/passthrough-request.ts b/packages/gateway/src/data-plane/shared/passthrough-request.ts new file mode 100644 index 0000000000..06c4858108 --- /dev/null +++ b/packages/gateway/src/data-plane/shared/passthrough-request.ts @@ -0,0 +1,30 @@ +// Preflight shared by the JSON passthrough endpoints. Each of them accepts an +// arbitrary JSON object and forwards it upstream verbatim, so the only field +// the gateway insists on is a non-empty `model` string — routing depends on +// it. `requestName` prefixes the 400 messages so each endpoint names itself. + +interface JsonModelRequestBody { + model?: unknown; + [key: string]: unknown; +} + +type PreparedJsonRequest = + | { type: 'ok'; body: Record; model: string } + | { type: 'invalid'; message: string }; + +export const prepareJsonModelRequest = (bytes: Uint8Array, requestName: string): PreparedJsonRequest => { + let request: JsonModelRequestBody; + try { + const parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { type: 'invalid', message: `${requestName} request body must be an object.` }; + } + request = parsed as JsonModelRequestBody; + } catch { + return { type: 'invalid', message: `${requestName} request body must be valid JSON.` }; + } + if (typeof request.model !== 'string' || request.model.length === 0) { + return { type: 'invalid', message: `${requestName} request body must include a model string.` }; + } + return { type: 'ok', body: request as Record, model: request.model }; +}; diff --git a/packages/gateway/src/data-plane/shared/telemetry/attribution.ts b/packages/gateway/src/data-plane/shared/telemetry/attribution.ts index 073117abd8..ac00c9ab2f 100644 --- a/packages/gateway/src/data-plane/shared/telemetry/attribution.ts +++ b/packages/gateway/src/data-plane/shared/telemetry/attribution.ts @@ -13,11 +13,12 @@ export const upstreamPerformanceContext = ( runtimeLocation: ctx.runtimeLocation, }); -// `model` is the upstream-facing bare id (`candidate.model.id`, -// e.g. `gpt-4o`) regardless of which surface form the client called -// (`or/gpt-4o` or `gpt-4o`). Usage and performance aggregates therefore key on -// the canonical upstream id, and a dashboard slice over `model` rolls up both -// surfaces of the same upstream model under one row. +// `model` is the public catalog id the candidate resolved under, before any +// per-upstream name prefix — the operator's `publicModelId` override when set, +// otherwise the id the upstream published. The genuinely upstream-facing value +// is `modelKey`, the id that went out on the wire. Usage and performance +// aggregates key on `model`, so a dashboard slice over it rolls up both the +// prefixed and the bare surface of the same model under one row. export const telemetryModelIdentity = (candidate: ModelCandidate, modelKey: string): TelemetryModelIdentity => ({ model: candidate.model.id, upstream: candidate.provider.upstreamId, diff --git a/packages/gateway/src/data-plane/shared/telemetry/performance.ts b/packages/gateway/src/data-plane/shared/telemetry/performance.ts index e2adb859cb..be0151bf86 100644 --- a/packages/gateway/src/data-plane/shared/telemetry/performance.ts +++ b/packages/gateway/src/data-plane/shared/telemetry/performance.ts @@ -21,12 +21,16 @@ const record = async (op: Promise, label: string): Promise => { } }; -// TTFT is measured from the provider's outbound-fetch stamp so it isolates -// upstream round-trip latency from gateway-internal overhead. Any success -// without a real upstream call or first-output-token stamp records as -// neutral; only genuine upstream failures with no output land in a pure -// zero-output-error bucket. TPOT layers on top only when at least two -// output tokens streamed — see the per-branch comments below. +// TTFT is anchored on the provider's outbound-fetch stamp, so the interval +// includes the gateway's own egress work — proxy-backoff lookup, dial, TLS, +// CONNECT — and excludes everything the gateway does before dispatch; after a +// failover the per-candidate anchor reset makes the recorded interval shorter +// than the latency the client observed. See +// `UpstreamCallOptions.wrapUpstreamCall`. Any success without a real upstream +// call or first-output-token stamp records as neutral; only genuine upstream +// failures with no output land in a pure zero-output-error bucket. TPOT layers +// on top only when at least two output tokens streamed — see the per-branch +// comments below. // // A failure that produced output tokens (mid-stream failure that streamed // tokens before dying) records a partial-output sample: the row bumps @@ -60,6 +64,9 @@ export const recordPerformance = ( scheduler(record(settle, failed ? 'zero-output-error' : 'neutral')); return; } + // Time to first token. Matches the OpenTelemetry GenAI spec + // gen_ai.server.time_to_first_token + // (https://github.com/open-telemetry/semantic-conventions-genai/blob/953dd22e3cecd3a397d742c349d2435d59c8b771/docs/gen-ai/gen-ai-metrics.md#metric-gen_aiservertime_to_first_token). const ttftMs = Math.round(attempt.firstOutputTokenAt - attempt.upstreamCallStartedAt); const success = !failed; if (outputTokens < 2) { diff --git a/packages/gateway/src/data-plane/shared/telemetry/usage.ts b/packages/gateway/src/data-plane/shared/telemetry/usage.ts index d407b65100..b29f5d3dfd 100644 --- a/packages/gateway/src/data-plane/shared/telemetry/usage.ts +++ b/packages/gateway/src/data-plane/shared/telemetry/usage.ts @@ -141,20 +141,20 @@ interface ImagesUsageShape { } const splitModalityCounts = ( - textDimension: Exclude, - imageDimension: Exclude, + textUsageKey: Exclude, + imageUsageKey: Exclude, total: number | undefined, details: unknown, ): TokenUsage | null => { if (total === undefined) return {}; - if (details === undefined) return { [textDimension]: total }; + if (details === undefined) return { [textUsageKey]: total }; if (!details || typeof details !== 'object') return null; const { text_tokens: text, image_tokens: image } = details as { text_tokens?: unknown; image_tokens?: unknown }; if (text !== undefined && typeof text !== 'number') return null; if (image !== undefined && typeof image !== 'number') return null; // A details object that carries neither split is as good as absent. - if (text === undefined && image === undefined) return { [textDimension]: total }; - return { [textDimension]: text ?? 0, [imageDimension]: image ?? 0 }; + if (text === undefined && image === undefined) return { [textUsageKey]: total }; + return { [textUsageKey]: text ?? 0, [imageUsageKey]: image ?? 0 }; }; export const recordUsage = async ( diff --git a/packages/gateway/src/data-plane/tools/web-search/providers/shared.ts b/packages/gateway/src/data-plane/tools/web-search/providers/shared.ts index 6e2db384cb..9a4b18b90d 100644 --- a/packages/gateway/src/data-plane/tools/web-search/providers/shared.ts +++ b/packages/gateway/src/data-plane/tools/web-search/providers/shared.ts @@ -9,14 +9,13 @@ const RETRYABLE_HTTP_STATUS: ReadonlySet = new Set([429, 500, 502, 503, export const fetchWithRetry = async ( doFetch: () => Promise, signal?: AbortSignal, - retryDelaysMs: readonly number[] = RETRY_DELAYS_MS, ): Promise => { let attempt = 0; while (true) { const response = await doFetch(); if (!RETRYABLE_HTTP_STATUS.has(response.status)) return response; - if (attempt >= retryDelaysMs.length) return response; - await sleep(retryDelaysMs[attempt], signal); + if (attempt >= RETRY_DELAYS_MS.length) return response; + await sleep(RETRY_DELAYS_MS[attempt], signal); attempt += 1; } }; diff --git a/packages/gateway/src/dial/fetcher.ts b/packages/gateway/src/dial/fetcher.ts index 94b04c8a8e..de040d5912 100644 --- a/packages/gateway/src/dial/fetcher.ts +++ b/packages/gateway/src/dial/fetcher.ts @@ -32,7 +32,9 @@ interface CreateFetcherInput { options: RunDirectConnectRequestOptions, ) => Promise; /** - * Platform-injected raw TCP dial primitive, threaded into runProxied. + * Platform-injected byte-stream dial, threaded into runProxied. Each dialer + * asks through `SocketDialOptions` for either a raw connection or one + * wrapped in the runtime's native TLS. * Lazily evaluated — only invoked when a socket-backed fallback entry is * actually attempted, so direct-fetch-only call sites can run without an * installed SocketDial impl. diff --git a/packages/gateway/src/dump/accumulator.ts b/packages/gateway/src/dump/accumulator.ts index e1e9ea17df..1525c2aea1 100644 --- a/packages/gateway/src/dump/accumulator.ts +++ b/packages/gateway/src/dump/accumulator.ts @@ -68,9 +68,9 @@ interface ResponseSnapshot { // `requestedModel`-set model survives across both error variants so even an // outright-failed turn carries model attribution. -// Anthropic-style disjoint per-dimension counts: input excludes cache reads +// Anthropic-style disjoint per-category counts: input excludes cache reads // and cache writes; sum the present ones onto the dump's single inputTokens -// column. Missing dimensions stay null (not measured) instead of zero so a +// column. Missing categories stay null (not measured) instead of zero so a // recorded zero genuinely means "upstream said zero". const tokenUsageInput = (usage: TokenUsage | null): number | null => { if (!usage) return null; diff --git a/packages/gateway/src/middleware/auth.ts b/packages/gateway/src/middleware/auth.ts index 459d529a00..206cea863d 100644 --- a/packages/gateway/src/middleware/auth.ts +++ b/packages/gateway/src/middleware/auth.ts @@ -2,7 +2,7 @@ import type { Context, Next } from 'hono'; import { getRepo } from '../repo/index.ts'; import type { ApiKey, User } from '../repo/types.ts'; -import { timingSafeEqual } from '../shared/passwords.ts'; +import { timingSafeEqual } from '../shared/timing-safe-equal.ts'; import { getEnvOptional } from '@floway-dev/platform'; const PUBLIC_PATHS = new Set(['/api/health', '/favicon.ico']); diff --git a/packages/gateway/src/repo/responses-payload.ts b/packages/gateway/src/repo/responses-payload.ts index 2cdd7c2a44..a637df3a79 100644 --- a/packages/gateway/src/repo/responses-payload.ts +++ b/packages/gateway/src/repo/responses-payload.ts @@ -19,7 +19,7 @@ type StoredResponsesPayloadJson = // Caps the JSON descriptor written into D1's `payload_json` column. Compressing // the body before this check trades a little CPU for a meaningful cut in D1 // storage on the JSON-heavy gpt-5 transcripts the gateway stores, and the -// cap pushes large tool outputs out to the file provider where per-byte +// cap pushes large tool outputs out to the file store where per-byte // storage is dramatically cheaper than D1. const INLINE_PAYLOAD_LIMIT_BYTES = 64 * 1024; const RESPONSES_ITEMS_FILE_ROOT = 'responses-items/v2/objects/'; diff --git a/packages/gateway/src/repo/types.ts b/packages/gateway/src/repo/types.ts index 0d87dfb9a4..4119965dc4 100644 --- a/packages/gateway/src/repo/types.ts +++ b/packages/gateway/src/repo/types.ts @@ -13,7 +13,9 @@ export interface ApiKey { serverSecret: string; createdAt: string; lastUsedAt?: string; - // null = inherit global upstream order; array = whitelist + priority order. + // null = inherit the user-level cap; array = whitelist in priority order. + // When both levels carry a list the effective list is their intersection + // taken in this order, so a key that sets one also decides the priority. upstreamIds: string[] | null; deletedAt: string | null; // null = dump capture disabled; positive integer = seconds of retention. @@ -32,7 +34,9 @@ export interface User { passwordHash: string | null; isAdmin: boolean; // null = unrestricted at the user level; an array intersects with the - // per-key whitelist when both are present. + // per-key whitelist when both are present. Membership only — the key's + // order carries the intersection, so this order applies only to requests + // whose key sets no list of its own. upstreamIds: string[] | null; createdAt: string; deletedAt: string | null; @@ -92,6 +96,8 @@ export interface WebSearchUsageRecord { requests: number; } +// `ttft_ms` is time to first token in milliseconds; `tpot_us` is time per +// output token in microseconds. export type PerformanceMetric = 'ttft_ms' | 'tpot_us'; // A performance-summary row is a `PerformanceTelemetryContext` (the provider- @@ -262,10 +268,12 @@ export interface UpstreamRepo { save(upstream: UpstreamRecord): Promise; delete(id: string): Promise; deleteAll(): Promise; - // Gateway autonomous state write with optimistic concurrency. Returns - // updated:true only if the row's state_json equals the serialized form of - // options.expectedState at write time. On updated:false the caller re-reads - // and decides whether to retry or drop the update. + // Upstream state write with optimistic concurrency, used both by the + // gateway's own token-rotation work and by the operator-triggered OAuth + // refresh / probe routes. Returns updated:true only if the row's + // state_json equals the serialized form of options.expectedState at write + // time. On updated:false the caller re-reads and decides whether to retry + // or drop the update. saveState(id: string, newState: unknown, options: { expectedState: unknown }): Promise<{ updated: boolean }>; } diff --git a/packages/gateway/src/repo/usage-metrics.ts b/packages/gateway/src/repo/usage-metrics.ts index 97367068a2..a7850b4504 100644 --- a/packages/gateway/src/repo/usage-metrics.ts +++ b/packages/gateway/src/repo/usage-metrics.ts @@ -1,5 +1,5 @@ import type { TokenUsage, UsageMetricRecord, UsageQuantities, UsageRecord } from './types.ts'; -import { BILLING_METRICS, canonicalDecimalString, decimalStringToNumber, parseNonNegativeDecimalString, type BillingMetric, type PriceVector } from '@floway-dev/protocols/common'; +import { BILLING_METRICS, decimalStringToNumber, parseDecimalString, parseNonNegativeDecimalString, type BillingMetric, type PriceVector } from '@floway-dev/protocols/common'; const TOKEN_METRIC_BY_USAGE_KEY = { input: 'input_tokens', @@ -37,7 +37,7 @@ export const tokenUsageQuantities = (tokens: TokenUsage): UsageQuantities => { const quantities: UsageQuantities = {}; for (const [key, metric] of Object.entries(TOKEN_METRIC_BY_USAGE_KEY) as [keyof typeof TOKEN_METRIC_BY_USAGE_KEY, BillingMetric][]) { const quantity = tokens[key]; - if (quantity !== undefined) quantities[metric] = canonicalDecimalString(String(quantity), `token usage ${key}`); + if (quantity !== undefined) quantities[metric] = parseDecimalString(String(quantity), `token usage ${key}`); } return quantities; }; diff --git a/packages/gateway/src/shared/passwords.ts b/packages/gateway/src/shared/passwords.ts index 4896774fae..b5d037a00a 100644 --- a/packages/gateway/src/shared/passwords.ts +++ b/packages/gateway/src/shared/passwords.ts @@ -1,3 +1,5 @@ +import { timingSafeEqual } from './timing-safe-equal.ts'; + // Cloudflare Workers' Web Crypto refuses PBKDF2 with iterations above 100k as a // CPU-time DoS guard ("Pbkdf2 failed: iteration counts above 100000 are not // supported"). 100k is below OWASP's current 600k recommendation for @@ -30,13 +32,6 @@ const deriveBits = async (plaintext: string, salt: Uint8Array, iterations: numbe return new Uint8Array(bits); }; -export const timingSafeEqual = (a: Uint8Array, b: Uint8Array): boolean => { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; - return diff === 0; -}; - export const hashPassword = async (plaintext: string): Promise => { const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)); const bits = await deriveBits(plaintext, salt, ITERATIONS); diff --git a/packages/gateway/src/shared/performance-histogram.ts b/packages/gateway/src/shared/performance-histogram.ts index 6e7ba9195b..0a4ef24a03 100644 --- a/packages/gateway/src/shared/performance-histogram.ts +++ b/packages/gateway/src/shared/performance-histogram.ts @@ -8,8 +8,10 @@ // single-bucket concentration below ~15% and give meaningful percentiles. // // TPOT edges anchor exactly on the human-facing tok/s SLO points (100, 50, -// 20, 10 tok/s) so a dashboard alert reads as "p95 speed >= 20 tok/s" and -// hits a real edge, not a factor-1.5 estimate. +// 20, 10 tok/s) so a percentile reading lands on a real edge rather than a +// factor-1.5 estimate. The direction is inverted against speed: a p95 TPOT of +// 50 000 µs is the slow tail — the slowest 5% of samples — and reads as +// "95% of samples generated at 20 tok/s or faster". export const TTFT_UPPER_EDGES_MS = [ 100, 200, 300, 500, 700, 1_000, 1_400, 2_000, 2_800, 4_000, diff --git a/packages/gateway/src/shared/timing-safe-equal.ts b/packages/gateway/src/shared/timing-safe-equal.ts new file mode 100644 index 0000000000..ef4beb08ba --- /dev/null +++ b/packages/gateway/src/shared/timing-safe-equal.ts @@ -0,0 +1,6 @@ +export const timingSafeEqual = (a: Uint8Array, b: Uint8Array): boolean => { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +}; diff --git a/packages/gateway/src/test-utils/gateway-ctx.ts b/packages/gateway/src/test-utils/gateway-ctx.ts index 65e21ceefb..32c073c6e0 100644 --- a/packages/gateway/src/test-utils/gateway-ctx.ts +++ b/packages/gateway/src/test-utils/gateway-ctx.ts @@ -19,7 +19,6 @@ export const mockGatewayCtx = (overrides: Partial = {}): GatewayCtx dump: null, backgroundScheduler: promise => { void promise; }, attempt: { firstOutputTokenAt: null, upstreamCallStartedAt: null, telemetry: undefined }, - responseHeaders: new Headers(), ...overrides, }); diff --git a/packages/gateway/src/test-utils/worker-websocket.ts b/packages/gateway/src/test-utils/worker-websocket.ts new file mode 100644 index 0000000000..1610e8d0dd --- /dev/null +++ b/packages/gateway/src/test-utils/worker-websocket.ts @@ -0,0 +1,72 @@ +// workerd's WebSocket upgrade has no standard equivalent: `WebSocketPair` is a +// runtime global, and the 101 response carries the client half in a +// non-standard `webSocket` init field. Tests that drive an upgrade handler +// install both here and read back the pairs the handler created. +type WorkerResponseInit = ResponseInit & { readonly webSocket?: WebSocket }; + +export class TestWorkerWebSocket extends EventTarget { + peer?: TestWorkerWebSocket; + readyState: number = WebSocket.OPEN; + + accept(): void {} + + send(data: string): void { + this.peer?.dispatchEvent(new MessageEvent('message', { data })); + } + + close(): void { + this.readyState = WebSocket.CLOSED; + if (this.peer) { + this.peer.readyState = WebSocket.CLOSED; + this.peer.dispatchEvent(new Event('close')); + } + } +} + +export const installWorkerWebSocketRuntime = (): { + readonly pairs: Array<{ readonly client: TestWorkerWebSocket; readonly server: TestWorkerWebSocket }>; + restore(): void; +} => { + const globals = globalThis as typeof globalThis & { + WebSocketPair?: unknown; + Response: typeof Response; + }; + const originalWebSocketPair = globals.WebSocketPair; + const OriginalResponse = globals.Response; + const pairs: Array<{ readonly client: TestWorkerWebSocket; readonly server: TestWorkerWebSocket }> = []; + + globals.WebSocketPair = class { + constructor() { + const client = new TestWorkerWebSocket(); + const server = new TestWorkerWebSocket(); + client.peer = server; + server.peer = client; + pairs.push({ client, server }); + return { 0: client, 1: server }; + } + }; + + globals.Response = class extends OriginalResponse { + constructor(body?: BodyInit | null, init?: WorkerResponseInit) { + // The standard constructor rejects any status outside 200-599 with a + // RangeError (https://fetch.spec.whatwg.org/#dom-response), so a 101 + // upgrade is built at 200 and then redefined. + if (init?.status === 101) { + const { webSocket, status: _status, ...rest } = init; + super(null, { ...rest, status: 200 }); + Object.defineProperty(this, 'status', { value: 101 }); + Object.defineProperty(this, 'webSocket', { value: webSocket }); + return; + } + super(body, init); + } + }; + + return { + pairs, + restore: () => { + globals.WebSocketPair = originalWebSocketPair; + globals.Response = OriginalResponse; + }, + }; +}; diff --git a/packages/http/src/errors.ts b/packages/http/src/errors.ts index 0e5f431f30..a95e4a00b8 100644 --- a/packages/http/src/errors.ts +++ b/packages/http/src/errors.ts @@ -1,5 +1,6 @@ -// Errors raised when an HTTP/1.1 message is malformed or smuggling-shaped. -// Transport errors propagate as the underlying error. +// Errors raised when an HTTP/1.1 message, a WebSocket handshake, or a WebSocket +// frame is malformed, smuggling-shaped, or over a DoS cap. Transport errors +// propagate as the underlying error. /** * Stable discriminator for an HttpProtocolError. Lets callers branch on diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index 193a315c6b..b5699ebc09 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -1,4 +1,5 @@ -// @floway-dev/http — HTTP/1.1 over a duplex byte stream + userspace TLS. +// @floway-dev/http — HTTP/1.1, userspace TLS, and WebSocket framing over a +// duplex byte stream. // // This package speaks HTTP/1.1 against any duplex transport — a raw TCP // socket, a userspace-TLS-wrapped stream, a CONNECT-tunnelled stream, etc. @@ -13,6 +14,11 @@ // negotiate the WebSocket Upgrade, return a frame-level duplex of // unmasked binary payloads. Lets WebSocket-tunnelled protocols stay // runtime-agnostic in the same way TCP+TLS protocols already are. +// +// We intend to extract and publish this package as an independent library, +// so its export surface is the whole protocol layer rather than the subset +// the rest of the workspace happens to call — a root export with no in-repo +// consumer is deliberate public API, not dead surface. export type { DuplexStream, HttpRequest, RawHttpResponse } from './types.ts'; diff --git a/packages/http/src/parser_test.ts b/packages/http/src/parser_test.ts index dabfee3a1b..a442ef1adb 100644 --- a/packages/http/src/parser_test.ts +++ b/packages/http/src/parser_test.ts @@ -623,6 +623,20 @@ describe('parseHttpResponse — DoS caps', () => { }); }); + // The head reader is generic over its cap and error factories; this vector + // pins the pair `parseHttpResponse` actually wires in — the 64 KiB cap and + // HEADER_BUFFER_OVERFLOW — so neither an unbounded read nor a different + // code can pass unnoticed. + it('rejects a single header that grows past the 64 KiB header buffer', async () => { + const fake = makeFakeDuplex(); + fake.respond('HTTP/1.1 200 OK\r\nX-Big: '); + fake.respond('a'.repeat(70 * 1024)); + fake.endResponse(); + await expect(parseHttpResponse(fake.readable)).rejects.toMatchObject({ + code: 'HEADER_BUFFER_OVERFLOW', + }); + }); + it('accepts a response with zero headers', async () => { const r = await parseHttpResponse(respondAndEnd('HTTP/1.1 200 OK\r\n\r\n')); expect(r.status).toBe(200); diff --git a/packages/interceptor/src/index.ts b/packages/interceptor/src/index.ts index 47fe35a43b..7c56eea7e2 100644 --- a/packages/interceptor/src/index.ts +++ b/packages/interceptor/src/index.ts @@ -1,11 +1,18 @@ // Interceptors wrap a single typed call. Each interceptor receives the call's -// context, its invocation envelope, and a `run` to delegate to the next -// interceptor (the innermost run executes the call itself). Interceptors may -// inspect or mutate the envelope before `run`, await `run` and transform the -// result, short-circuit by returning without calling `run`, or retry by -// invoking `run` again. The shape is intentionally generic in Ctx/Env/Result so -// it works for any kind of call — provider-side wire shaping, source-side -// translation, retry policy — wired by the caller into concrete chains. +// own invocation state, the ambient environment around it, and a `run` to +// delegate to the next interceptor (the innermost run executes the call +// itself). Interceptors may inspect or mutate that invocation state before +// `run`, await `run` and transform the result, short-circuit by returning +// without calling `run`, or retry by invoking `run` again. The shape is +// intentionally generic in Ctx/Env/Result so it works for any kind of call — +// provider-side wire shaping, source-side translation, retry policy — wired by +// the caller into concrete chains. +// +// `Ctx` carries the call itself — payload, headers, chosen target — and is the +// slot interceptors write to. `Env` carries what surrounds the call and does +// not belong to it: the gateway's chat chains pass the request-scoped gateway +// context there, while the provider-boundary chains have nothing ambient to +// hand down and pass `{}`. // // ## Mutation convention // diff --git a/packages/platform/src/image-processor.ts b/packages/platform/src/image-processor.ts index 0fdbd3c73d..03569b5f62 100644 --- a/packages/platform/src/image-processor.ts +++ b/packages/platform/src/image-processor.ts @@ -18,6 +18,18 @@ export interface SizeCaps { // in here (see `fitWithin`) without the processor learning any model specifics. export type ImageSizeCalculator = (source: ImageDimensions) => ImageDimensions; +// Fixed WebP quality for every recompressed inline image. 82 sits above the +// cwebp / photographic default of 75 so screenshots and text-heavy UI images — +// the bulk of Copilot traffic — survive our lossy pass before the upstream +// provider applies its own downscale and re-encode, while keeping the bandwidth +// win. Confirmed on real traffic: the production Cloudflare Images encoder at +// q82 matches local cwebp within <0.1 dB PSNR. References: +// - https://developers.google.com/speed/webp/docs/cwebp (default quality 75) +// - https://platform.claude.com/docs/en/build-with-claude/vision (multi-pass +// compression warning) +// - https://getwebp.com/blog/screenshots-webp-settings-text-ui +export const WEBP_QUALITY = 82; + export interface ImageProcessor { // Re-encodes arbitrary raster image bytes to WebP at a fixed internal // quality, scaled to fit `target` (or encoded at source dimensions when target diff --git a/packages/protocols/src/chat-completions/stream.ts b/packages/protocols/src/chat-completions/stream.ts index d5424007d9..582004cc8c 100644 --- a/packages/protocols/src/chat-completions/stream.ts +++ b/packages/protocols/src/chat-completions/stream.ts @@ -1,8 +1,8 @@ import { chatCompletionsErrorPayloadMessage } from './errors.ts'; import type { ChatCompletionsStreamEvent } from './index.ts'; +import { parseTargetStreamFrames } from '../common/parse-events.ts'; import { parseSSEStream } from '../common/parse-sse.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '../common/sse.ts'; -import { parseTargetStreamFrames } from '../common/stream/parse-events.ts'; export interface ParseChatCompletionsStreamOptions { signal?: AbortSignal; diff --git a/packages/protocols/src/common/aliases.ts b/packages/protocols/src/common/aliases.ts index 03475a491b..d6e38394ee 100644 --- a/packages/protocols/src/common/aliases.ts +++ b/packages/protocols/src/common/aliases.ts @@ -12,13 +12,19 @@ import type { ModelKind } from './endpoints.ts'; import type { ChatModelInfo, PublicModelLimits } from './models.ts'; -// Target-picking strategy applied to the pool of currently-routable targets: +// Walk order over the alias's configured targets. Nothing is filtered out +// up front: the resolver takes the raw target list in this order, resolves +// each one against the live catalog, and flattens the results in target +// order, so a later target's candidates are the failover pool for an +// earlier one. // -// - `first-available` — pick the first target in declaration order whose -// target_model_id resolves to an enabled upstream binding. -// - `random` — pick uniformly at random from the same pool. +// - `first-available` — declaration order. +// - `random` — a uniform shuffle of the whole configured list, so the walk +// distributes evenly across targets. // -// When the pool is empty both strategies surface the same 404 to the caller. +// When the walk produces no candidate at all the caller sees a 404 only if +// no target id was known to any upstream; an id that is known but of the +// wrong kind is model-unsupported, a 400. export type AliasSelection = 'random' | 'first-available'; // Discrete reasoning-effort presets understood across upstreams. The literal @@ -78,9 +84,11 @@ export interface AliasTarget { // entirely, so other limit keys disappear from the announced metadata // unless the override re-states them. (The dashboard hides this by // seeding the buffer from the full computed snapshot at the moment the -// "Enable override" switch flips on.) `kind` and the supported endpoint -// set are not part of this payload; they follow from the alias row -// (`kind`) and the target union (endpoints). +// "Enable override" switch flips on.) `kind`, the supported endpoint set, +// and `pricing` are not part of this payload: `kind` follows from the alias +// row, endpoints from the target union, and `pricing` is announced only when +// the alias has exactly one available target, because rates across several +// targets have no meaningful merge. export interface AnnouncedMetadata { limits?: PublicModelLimits; chat?: ChatModelInfo; diff --git a/packages/protocols/src/common/decimal.ts b/packages/protocols/src/common/decimal.ts index bd58595969..fdfcc60353 100644 --- a/packages/protocols/src/common/decimal.ts +++ b/packages/protocols/src/common/decimal.ts @@ -81,7 +81,7 @@ const parseFixedDecimal = (value: string, label: string, limits: DecimalLimits): return { coefficient, scale }; }; -export const canonicalDecimalString = (value: string, label = 'decimal'): DecimalString => +export const parseDecimalString = (value: string, label = 'decimal'): DecimalString => formatFixedDecimal(parseFixedDecimal(value, label, PUBLIC_LIMITS)); export const parseNonNegativeDecimalString = (value: unknown, label = 'decimal'): DecimalString => { diff --git a/packages/protocols/src/common/decimal_test.ts b/packages/protocols/src/common/decimal_test.ts index ee2fa1e57e..1d4b08ccca 100644 --- a/packages/protocols/src/common/decimal_test.ts +++ b/packages/protocols/src/common/decimal_test.ts @@ -1,18 +1,18 @@ import { test } from 'vitest'; -import { addDecimalStrings, canonicalDecimalString, divideDecimalString, multiplyDecimalStrings, parseNonNegativeDecimalString } from './decimal.ts'; +import { addDecimalStrings, divideDecimalString, multiplyDecimalStrings, parseDecimalString, parseNonNegativeDecimalString } from './decimal.ts'; import { assertEquals, assertThrows } from '@floway-dev/test-utils'; test('decimal strings canonicalize without floating-point conversion', () => { - assertEquals(canonicalDecimalString('001.2300'), '1.23'); - assertEquals(canonicalDecimalString('1e-7'), '0.0000001'); - assertEquals(canonicalDecimalString('-0'), '0'); + assertEquals(parseDecimalString('001.2300'), '1.23'); + assertEquals(parseDecimalString('1e-7'), '0.0000001'); + assertEquals(parseDecimalString('-0'), '0'); assertEquals(parseNonNegativeDecimalString('0.00000000000000000001'), '0.00000000000000000001'); assertThrows(() => parseNonNegativeDecimalString(-1), TypeError, 'must be a decimal string'); assertThrows(() => parseNonNegativeDecimalString('-0.1'), RangeError, 'must be non-negative'); - assertEquals(canonicalDecimalString('1e-324'), `0.${'0'.repeat(323)}1`); - assertThrows(() => canonicalDecimalString('1e401'), RangeError, 'exponent must be between'); - assertThrows(() => canonicalDecimalString('1'.repeat(101)), RangeError, 'significant digits'); + assertEquals(parseDecimalString('1e-324'), `0.${'0'.repeat(323)}1`); + assertThrows(() => parseDecimalString('1e401'), RangeError, 'exponent must be between'); + assertThrows(() => parseDecimalString('1'.repeat(101)), RangeError, 'significant digits'); }); test('decimal arithmetic preserves exact finite decimal results', () => { diff --git a/packages/protocols/src/common/index.ts b/packages/protocols/src/common/index.ts index f4a7eef489..a012caed2d 100644 --- a/packages/protocols/src/common/index.ts +++ b/packages/protocols/src/common/index.ts @@ -8,7 +8,7 @@ export * from './openai-stream.ts'; export * from './opaque-value.ts'; export * from './sse.ts'; export * from './parse-sse.ts'; -export * from './stream/parse-events.ts'; +export * from './parse-events.ts'; export { isJsonObject, type JsonObject } from './json.ts'; export { captureExtras } from './reassemble-extras.ts'; diff --git a/packages/protocols/src/common/models.ts b/packages/protocols/src/common/models.ts index 6b150b2e81..ead6a4896f 100644 --- a/packages/protocols/src/common/models.ts +++ b/packages/protocols/src/common/models.ts @@ -25,9 +25,11 @@ export interface RerankTarget { export type Modality = 'text' | 'image'; -// Operator-configured chat capability metadata. Lives in protocols because it -// flows verbatim onto PublicModel.chat (the wire DTO) and is also re-exported -// by @floway-dev/provider as UpstreamChatModelConfig for the catalog side; one +// Chat capability metadata for one model. Providers that can read it off the +// raw upstream catalog fill it themselves; elsewhere it comes from the +// operator's model config. Lives in protocols because it flows verbatim onto +// PublicModel.chat (the wire DTO) and is also re-exported by +// @floway-dev/provider as UpstreamChatModelConfig for the catalog side; one // definition serves both surfaces. export interface ChatModelInfo { modalities?: { @@ -49,10 +51,9 @@ export interface ChatModelInfo { // Alias provenance attached to a `/v1/models` entry that the gateway // synthesized from an operator-defined alias rather than fetched from an -// upstream catalog. `targets` carries every configured target — including -// targets the live catalog currently can not serve — so the dashboard can -// show the full configuration and warn about unavailable ones without a -// second control-plane round trip. The alias's `kind` and `name` live on +// upstream catalog. `targets` is the configured target list — projected +// as-is on admin surfaces and filtered to the caller-reachable subset on +// data-plane / non-admin surfaces. The alias's `kind` and `name` live on // the enclosing `PublicModel` (`kind`, `id`); every alias-synthesized row // puts the alias name on its outer `id` and the alias kind on its outer // `kind`, so the sidecar avoids duplicating them. @@ -86,19 +87,16 @@ export interface PublicModel { // Non-standard extra fields below. limits: PublicModelLimits; kind: ModelKind; - // Public-facing endpoint surface. Mirrors the upstream-side ModelEndpoints - // verbatim — by the time a model reaches this DTO, the provider layer - // (e.g. provider-ollama, provider-copilot) has already projected the raw - // upstream catalog into the public-facing shape: the three chat endpoints - // (chatCompletions / messages / responses) appear together because the - // gateway translates between them, while `completions`, `embeddings`, - // `imagesGenerations`, `imagesEdits`, `rerank`, and `audioTranscriptions` - // only appear when the upstream - // natively serves them. Alias entries surface the UNION of every - // currently-available target's endpoint map — at request time the - // resolver narrows the pool to targets that serve the inbound endpoint, - // so any endpoint advertised here is reachable through at least one - // target. + // The merged upstream wire surface: the union of the endpoint keys the + // contributing upstreams expose natively, and on an alias-synthesized row + // the union across the alias's currently-available targets, so every key + // advertised here is served natively by at least one of them. It is not a + // list of client-callable Floway routes. Translation widens the callable + // chat surface past the listed keys — a chat source protocol reaches any + // candidate carrying one of its preferred chat targets, and Gemini has no + // key of its own at all. The non-chat keys (`completions`, `embeddings`, + // `imagesGenerations`, `imagesEdits`, `rerank`, `audioTranscriptions`) are + // callable exactly where they appear. endpoints: ModelEndpoints; pricing?: ModelPricing; chat?: ChatModelInfo; diff --git a/packages/protocols/src/common/stream/parse-events.ts b/packages/protocols/src/common/parse-events.ts similarity index 96% rename from packages/protocols/src/common/stream/parse-events.ts rename to packages/protocols/src/common/parse-events.ts index add6c32d19..08caedea83 100644 --- a/packages/protocols/src/common/stream/parse-events.ts +++ b/packages/protocols/src/common/parse-events.ts @@ -1,4 +1,4 @@ -import type { SseFrame } from '../sse.ts'; +import type { SseFrame } from './sse.ts'; export interface ParseTargetStreamFramesOptions { protocol: string; diff --git a/packages/protocols/src/gemini/index.ts b/packages/protocols/src/gemini/index.ts index 45fcc2f0d7..d359c3d9af 100644 --- a/packages/protocols/src/gemini/index.ts +++ b/packages/protocols/src/gemini/index.ts @@ -44,7 +44,7 @@ export interface GeminiGenerationConfig { export interface GeminiThinkingConfig { thinkingBudget?: number; - thinkingLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | string; + thinkingLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | (string & {}); includeThoughts?: boolean; } diff --git a/packages/protocols/src/index.ts b/packages/protocols/src/index.ts deleted file mode 100644 index 981d4fda1b..0000000000 --- a/packages/protocols/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './common/index.ts'; -export * from './completions/index.ts'; -export * from './chat-completions/index.ts'; -export * from './embeddings/index.ts'; -export * from './gemini/index.ts'; -export * from './messages/index.ts'; -export * from './responses/index.ts'; diff --git a/packages/protocols/src/messages/index.ts b/packages/protocols/src/messages/index.ts index bbc225ce65..50e2d9e9ec 100644 --- a/packages/protocols/src/messages/index.ts +++ b/packages/protocols/src/messages/index.ts @@ -4,7 +4,7 @@ import type { MessagesUsage, MessagesUsageServerToolUse } from './usage.ts'; * Messages requires `max_tokens`, but the Chat Completions, Responses, and * Gemini sources may omit their output-token cap. When we translate one of * those sources to a Messages target, the data-plane prefers the model's - * advertised `/models` output cap (`capabilities.maxOutputTokens`); this + * advertised `/models` output cap (`limits.max_output_tokens`); this * constant is the last-resort gateway policy default when both the source * payload and the model capability are silent. * diff --git a/packages/protocols/src/messages/stream.ts b/packages/protocols/src/messages/stream.ts index 8bb0cbd76d..3c0efd5cd9 100644 --- a/packages/protocols/src/messages/stream.ts +++ b/packages/protocols/src/messages/stream.ts @@ -1,7 +1,7 @@ import type { MessagesStreamEvent } from './index.ts'; +import { parseTargetStreamFrames } from '../common/parse-events.ts'; import { parseSSEStream } from '../common/parse-sse.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '../common/sse.ts'; -import { parseTargetStreamFrames } from '../common/stream/parse-events.ts'; export interface ParseMessagesStreamOptions { signal?: AbortSignal; diff --git a/packages/protocols/src/responses/stream.ts b/packages/protocols/src/responses/stream.ts index 381e639640..240054d3b0 100644 --- a/packages/protocols/src/responses/stream.ts +++ b/packages/protocols/src/responses/stream.ts @@ -1,7 +1,7 @@ import { isResponsesTerminalEvent, type ResponsesResult, responsesResultToEvents, type ResponsesStreamEvent } from './index.ts'; +import { parseTargetStreamFrames } from '../common/parse-events.ts'; import { parseSSEStream } from '../common/parse-sse.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '../common/sse.ts'; -import { parseTargetStreamFrames } from '../common/stream/parse-events.ts'; export interface ParseResponsesStreamOptions { signal?: AbortSignal; diff --git a/packages/provider-azure/src/endpoint.ts b/packages/provider-azure/src/endpoint.ts index 5097a0eb4f..f9fe178da5 100644 --- a/packages/provider-azure/src/endpoint.ts +++ b/packages/provider-azure/src/endpoint.ts @@ -51,8 +51,6 @@ export const azureOpenAiV1BaseUrl = (endpoint: string): string => { const path = trimTrailingSlash(url.pathname); if (path.endsWith('/openai/v1')) { url.pathname = path; - } else if (path === '/anthropic/v1/messages' || path === '/anthropic/v1' || path === '/anthropic') { - url.pathname = '/openai/v1'; } else if (isFoundryProjectRootPath(path)) { url.pathname = `${path}/openai/v1`; } else { @@ -66,15 +64,9 @@ export const azureAnthropicBaseUrl = (endpoint: string): string => { if (url.hostname.endsWith('.openai.azure.com')) { url.hostname = `${url.hostname.slice(0, -'.openai.azure.com'.length)}.services.ai.azure.com`; } - const path = trimTrailingSlash(url.pathname); - if (path === '/anthropic/v1/messages') { - url.pathname = path.slice(0, -'/v1/messages'.length); - } else if (path === '/anthropic/v1') { - url.pathname = path.slice(0, -3); - } else if (path === '/anthropic') { - url.pathname = path; - } else { - url.pathname = '/anthropic'; - } + // The Anthropic surface is resource-scoped, so every admitted endpoint shape — + // resource root, Foundry project root, an /openai/v1 URL, or an /anthropic* + // URL — resolves to the same `/anthropic` base. + url.pathname = '/anthropic'; return trimTrailingSlash(url.href); }; diff --git a/packages/provider-claude-code/src/auth/import.ts b/packages/provider-claude-code/src/auth/import.ts index 1804a6e907..94ef143032 100644 --- a/packages/provider-claude-code/src/auth/import.ts +++ b/packages/provider-claude-code/src/auth/import.ts @@ -110,6 +110,14 @@ export const importClaudeCodeFromSetupTokenCallback = async (opts: { }); }; +const pickNonEmptyString = (record: Record, key: string, prefix: string): string => { + const value = record[key]; + if (typeof value !== 'string' || value === '') { + throw new TypeError(`${prefix}.${key} must be a non-empty string`); + } + return value; +}; + // Verbatim ~/.claude/.credentials.json paste. The CLI's on-disk format wraps // tokens under `.claudeAiOauth` and stores `subscriptionType` ('pro' / 'max' // / 'team' / 'enterprise') and `rateLimitTier` @@ -125,14 +133,6 @@ export const importClaudeCodeFromSetupTokenCallback = async (opts: { // // `fetcher` is forwarded to the identity call so the control-plane import // route can route through an operator-supplied proxy chain. Default direct. -const pickNonEmptyString = (record: Record, key: string, prefix: string): string => { - const value = record[key]; - if (typeof value !== 'string' || value === '') { - throw new TypeError(`${prefix}.${key} must be a non-empty string`); - } - return value; -}; - export const importClaudeCodeFromCredentialsJson = async ( rawJson: string, fetcher: Fetcher = directFetcher, diff --git a/packages/provider-claude-code/src/pricing.ts b/packages/provider-claude-code/src/pricing.ts index 7b73682f46..c402b85cee 100644 --- a/packages/provider-claude-code/src/pricing.ts +++ b/packages/provider-claude-code/src/pricing.ts @@ -8,6 +8,8 @@ // × 2 (1-hour write). Fast mode is an explicit `serviceTier: 'fast'` entry // priced as a flat multiple of base: 6× on Opus 4.6/4.7, lowered to 2× from // Opus 4.8 onward (Opus 4.8, Opus 5); each entry records its own cache rates. +// +// Refresh procedure: .agents/skills/fetching-models-pricing/. import { modelPricing, tokenBasePricing, tokenPricingEntry, type ModelPricing, type PriceVector } from '@floway-dev/protocols/common'; diff --git a/packages/provider-codex/src/fetch.ts b/packages/provider-codex/src/fetch.ts index d3772996d8..7adddf4ffe 100644 --- a/packages/provider-codex/src/fetch.ts +++ b/packages/provider-codex/src/fetch.ts @@ -8,7 +8,7 @@ import { CODEX_RESPONSES_PATH, CODEX_USER_AGENT, } from './constants.ts'; -import { sha256Uuid } from './ids.ts'; +import { sha256Uuid, uuidV7 } from './ids.ts'; import { parseCodexQuotaHeaders, putCodexQuota, @@ -16,7 +16,7 @@ import { import type { CodexAccountCredential } from './state.ts'; import type { CanonicalResponsesCompactPayload, CanonicalResponsesPayload, ResponsesInputItem, ResponsesResult, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; import { parseResponsesStream } from '@floway-dev/protocols/responses'; -import { type ProviderCallResult, type ProviderModel, type ProviderStreamResult, streamingProviderCall, uuidV7, type UpstreamCallOptions } from '@floway-dev/provider'; +import { type ProviderCallResult, type ProviderModel, type ProviderStreamResult, streamingProviderCall, type UpstreamCallOptions } from '@floway-dev/provider'; export type ProviderCompactionResult = | { ok: true; result: ResponsesResult; modelKey: string } diff --git a/packages/provider-codex/src/fetch_test.ts b/packages/provider-codex/src/fetch_test.ts index eac7cbbb0f..43d3fce8d9 100644 --- a/packages/provider-codex/src/fetch_test.ts +++ b/packages/provider-codex/src/fetch_test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { CODEX_ORIGINATOR, CODEX_USER_AGENT } from './constants.ts'; import { callCodexAlphaSearch, callCodexResponses, callCodexResponsesCompact, type CodexCallEffects } from './fetch.ts'; -import type { CodexAccessTokenEntry, CodexAccountCredential, CodexQuotaSnapshotMapEntry, CodexUpstreamState } from './state.ts'; +import type { CodexAccessTokenEntry, CodexAccountCredential, CodexQuotaSnapshotEntryMap, CodexUpstreamState } from './state.ts'; import type { ResponsesResult } from '@floway-dev/protocols/responses'; import { initProviderRepo, type UpstreamRecord } from '@floway-dev/provider'; import { noopUpstreamCallOptions, stubProviderModel } from '@floway-dev/test-utils'; @@ -52,7 +52,7 @@ const seedAccountState = (overrides: Partial): void => { currentRecord = makeRecord({ accounts: [{ ...activeAccount, ...overrides }] }); }; -const readQuotaEntry = (): CodexQuotaSnapshotMapEntry | null => +const readQuotaEntry = (): CodexQuotaSnapshotEntryMap | null => (currentRecord.state as CodexUpstreamState).accounts[0].quotaSnapshot; // putCodexQuota fires-and-forgets via .catch(() => {}); yield to the task diff --git a/packages/provider-codex/src/ids.ts b/packages/provider-codex/src/ids.ts index 8172c22e86..3a851ecee4 100644 --- a/packages/provider-codex/src/ids.ts +++ b/packages/provider-codex/src/ids.ts @@ -7,3 +7,25 @@ export const sha256Uuid = async (input: string): Promise => { const variantNibble = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variantNibble}${hex.slice(17, 20)}-${hex.slice(20, 32)}`; }; + +export const uuidV7 = (): string => { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + + const timestampMs = BigInt(Date.now()); + bytes[0] = Number((timestampMs >> 40n) & 0xffn); + bytes[1] = Number((timestampMs >> 32n) & 0xffn); + bytes[2] = Number((timestampMs >> 24n) & 0xffn); + bytes[3] = Number((timestampMs >> 16n) & 0xffn); + bytes[4] = Number((timestampMs >> 8n) & 0xffn); + bytes[5] = Number(timestampMs & 0xffn); + bytes[6] = (bytes[6] & 0x0f) | 0x70; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + return uuidFromBytes(bytes); +}; + +const uuidFromBytes = (bytes: Uint8Array): string => { + const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')); + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`; +}; diff --git a/packages/provider-codex/src/index.ts b/packages/provider-codex/src/index.ts index 1b5d828fad..d9e52b55dd 100644 --- a/packages/provider-codex/src/index.ts +++ b/packages/provider-codex/src/index.ts @@ -14,4 +14,3 @@ export * from './constants.ts'; export * from './config.ts'; export * from './state.ts'; export * from './quota.ts'; -export { createCodexProvider } from './provider.ts'; diff --git a/packages/provider-codex/src/quota_test.ts b/packages/provider-codex/src/quota_test.ts index 1ea7dd6aaa..4a0a46b8bf 100644 --- a/packages/provider-codex/src/quota_test.ts +++ b/packages/provider-codex/src/quota_test.ts @@ -9,7 +9,7 @@ import { putCodexQuota, type CodexQuotaSnapshot, } from './quota.ts'; -import type { CodexQuotaSnapshotMapEntry, CodexUpstreamState } from './state.ts'; +import type { CodexQuotaSnapshotEntryMap, CodexUpstreamState } from './state.ts'; import { initProviderRepo, type UpstreamRecord } from '@floway-dev/provider'; const accountId = 'acc_1'; @@ -39,7 +39,7 @@ const baseAccount = { state_updated_at: '2026-06-01T00:00:00.000Z', openaiDeviceId: '11111111-2222-4333-8444-555555555555', accessToken: null, - quotaSnapshot: null as CodexQuotaSnapshotMapEntry | null, + quotaSnapshot: null as CodexQuotaSnapshotEntryMap | null, }; let current: UpstreamRecord | null; diff --git a/packages/provider-codex/src/state.ts b/packages/provider-codex/src/state.ts index 9bbcd193f6..9e06a0bae3 100644 --- a/packages/provider-codex/src/state.ts +++ b/packages/provider-codex/src/state.ts @@ -24,7 +24,7 @@ export interface CodexQuotaSnapshotEntry { data: CodexQuotaSnapshot; } -export type CodexQuotaSnapshotMapEntry = Record; +export type CodexQuotaSnapshotEntryMap = Record; // One account's autonomous credential state, joined back to its identity in // CodexUpstreamConfig.accounts via `chatgptAccountId`. @@ -52,7 +52,7 @@ export interface CodexAccountCredential { // normalizes absent → `null` on a shallow copy, so consumers can rely on // the typed `null` slot here. accessToken: CodexAccessTokenEntry | null; - quotaSnapshot: CodexQuotaSnapshotMapEntry | null; + quotaSnapshot: CodexQuotaSnapshotEntryMap | null; } // Account-pool state. v1 always carries exactly one entry; the asserter @@ -144,7 +144,7 @@ const assertCodexQuotaSnapshotEntry = (value: unknown, where: string): void => { const isUnsafeMapKey = (key: string): boolean => key === '' || key === '__proto__' || key === 'constructor' || key === 'prototype'; -const assertCodexQuotaSnapshotMapEntry = (value: unknown, where: string): void => { +const assertCodexQuotaSnapshotEntryMap = (value: unknown, where: string): void => { if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw new TypeError(`${where} must be a plain object`); } @@ -196,7 +196,7 @@ const assertCodexAccountCredential = (value: unknown, where: string): void => { assertCodexAccessTokenEntry(obj.accessToken, `${where}.accessToken`); } if (obj.quotaSnapshot !== undefined && obj.quotaSnapshot !== null) { - assertCodexQuotaSnapshotMapEntry(obj.quotaSnapshot, `${where}.quotaSnapshot`); + assertCodexQuotaSnapshotEntryMap(obj.quotaSnapshot, `${where}.quotaSnapshot`); } }; diff --git a/packages/provider-copilot/src/auth.ts b/packages/provider-copilot/src/auth.ts index 9bffa9d1b4..15fec48001 100644 --- a/packages/provider-copilot/src/auth.ts +++ b/packages/provider-copilot/src/auth.ts @@ -243,7 +243,7 @@ export async function copilotAuthedFetch(path: string, init: RequestInit, auth: headers.set('x-interaction-type', 'conversation-agent'); // Provider-attached invocation headers (vision, initiator, anthropic-beta, - // ...) flow through unchanged. The provider's target interceptors decide + // ...) flow through unchanged. The provider's boundary interceptors decide // which headers each upstream call needs; this layer only knows how to ship // them. Setting them last lets workaround interceptors override the static // VSCode identification block when a future workaround needs to. diff --git a/packages/provider-copilot/src/config.ts b/packages/provider-copilot/src/config.ts index 1a2489dbd1..09ff258be6 100644 --- a/packages/provider-copilot/src/config.ts +++ b/packages/provider-copilot/src/config.ts @@ -17,42 +17,63 @@ export type CopilotUpstreamRecord = UpstreamRecord & { config: CopilotUpstreamConfig; }; +type FieldErrorBuilder = (field: string, expected: string) => Error; + +const malformedConfig: FieldErrorBuilder = (field, expected) => new Error(`Malformed copilot upstream config: ${field} must be ${expected}`); + const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -const stringField = (value: unknown, field: string): string => { - if (typeof value !== 'string') throw new Error(`Malformed copilot upstream config: ${field} must be a string`); +const stringField = (value: unknown, field: string, err: FieldErrorBuilder): string => { + if (typeof value !== 'string') throw err(field, 'a string'); return value; }; -const nullableStringField = (value: unknown, field: string): string | null => { - if (value !== null && typeof value !== 'string') throw new Error(`Malformed copilot upstream config: ${field} must be a string or null`); +const nonEmptyStringField = (value: unknown, field: string, err: FieldErrorBuilder): string => { + const str = stringField(value, field, err).trim(); + if (str === '') throw err(field, 'a non-empty string'); + return str; +}; + +const nullableStringField = (value: unknown, field: string, err: FieldErrorBuilder): string | null => { + if (value !== null && typeof value !== 'string') throw err(field, 'a string or null'); return value; }; -const numberField = (value: unknown, field: string): number => { - if (typeof value !== 'number' || !Number.isSafeInteger(value)) throw new Error(`Malformed copilot upstream config: ${field} must be an integer`); +const integerField = (value: unknown, field: string, err: FieldErrorBuilder): number => { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) throw err(field, 'an integer'); return value; }; -const copilotUserField = (value: unknown): CopilotUpstreamUser => { - if (!isRecord(value)) throw new Error('Malformed copilot upstream config: user must be an object'); +const copilotUserField = (value: unknown, err: FieldErrorBuilder): CopilotUpstreamUser => { + if (!isRecord(value)) throw err('user', 'an object'); + return { + login: stringField(value.login, 'user.login', err), + avatar_url: stringField(value.avatar_url, 'user.avatar_url', err), + name: nullableStringField(value.name, 'user.name', err), + id: integerField(value.id, 'user.id', err), + }; +}; + +// Grammar for an incoming config payload. The caller supplies the error +// builder because the surfaces that accept such a payload word their +// rejections differently. +export const parseCopilotUpstreamConfig = (value: unknown, err: FieldErrorBuilder): CopilotUpstreamConfig => { + if (!isRecord(value)) throw err('config', 'an object'); return { - login: stringField(value.login, 'user.login'), - avatar_url: stringField(value.avatar_url, 'user.avatar_url'), - name: nullableStringField(value.name, 'user.name'), - id: numberField(value.id, 'user.id'), + githubToken: nonEmptyStringField(value.githubToken, 'githubToken', err), + user: copilotUserField(value.user, err), }; }; export const assertCopilotUpstreamRecord = (record: UpstreamRecord): CopilotUpstreamRecord => { if (record.kind !== 'copilot') throw new Error(`Expected copilot upstream record, got ${record.kind}`); - if (!isRecord(record.config)) throw new Error('Malformed copilot upstream config: config must be an object'); + if (!isRecord(record.config)) throw malformedConfig('config', 'an object'); return { ...record, kind: 'copilot', config: { - githubToken: stringField(record.config.githubToken, 'githubToken'), - user: copilotUserField(record.config.user), + githubToken: stringField(record.config.githubToken, 'githubToken', malformedConfig), + user: copilotUserField(record.config.user, malformedConfig), }, }; }; diff --git a/packages/provider-copilot/src/defaults.ts b/packages/provider-copilot/src/defaults.ts index f31ec6d7ab..6f163d4928 100644 --- a/packages/provider-copilot/src/defaults.ts +++ b/packages/provider-copilot/src/defaults.ts @@ -16,7 +16,10 @@ export const COPILOT_DEFAULT_FLAGS: FlagDefaults = { 'responses-image-generation-shim': true, // Copilot has no native compact endpoint. The provider replays // `RemoteCompactionV2` through `/responses` with `stream: false` and a - // trailing `compaction_trigger`, so the gateway compact shim stays disabled. + // trailing `compaction_trigger`, so this default leaves the gateway compact + // shim off. The shim still engages on its own whenever a Responses request + // lands on a Copilot Messages or Chat Completions target, neither of which + // has a compaction wire. 'responses-compact-shim': false, 'disable-reasoning-on-forced-tool-choice': false, // Upstream default is off; Claude models below 4.8 flip it on via the diff --git a/packages/provider-copilot/src/index.ts b/packages/provider-copilot/src/index.ts index d527349276..512e0236f4 100644 --- a/packages/provider-copilot/src/index.ts +++ b/packages/provider-copilot/src/index.ts @@ -14,6 +14,7 @@ export { export { fetchGitHubUser, pollGitHubDeviceFlow, startGitHubDeviceFlow } from './github-device-flow.ts'; export { fetchCopilotUsage, type CopilotUsageResponse } from './quota.ts'; export { + parseCopilotUpstreamConfig, type CopilotUpstreamConfig, type CopilotUpstreamUser, } from './config.ts'; diff --git a/packages/provider-copilot/src/interceptors/messages/index.ts b/packages/provider-copilot/src/interceptors/messages/index.ts index af4b29395c..1a6dc687cf 100644 --- a/packages/provider-copilot/src/interceptors/messages/index.ts +++ b/packages/provider-copilot/src/interceptors/messages/index.ts @@ -83,9 +83,11 @@ export const COPILOT_MESSAGES_BOUNDARY = [ // WebP-recompressed payload the chat path sends, keeping the estimate // consistent with the real request. withContextManagementBetaAligned follows // withAnthropicBetaHeaderFiltered so any surviving `context_management` field -// remains paired with its required header token. Event-stream and chat-only -// payload mutators are intentionally absent because count_tokens returns a raw -// Response and never used those transformations. +// remains paired with its required header token. The chat boundary's other +// entries stay out: its post-`run()` inspectors cannot be expressed against a +// raw Response at all, and the remaining payload mutators and header setters +// each answer something we observed on the generation endpoint, with no +// equivalent need seen on count_tokens. export const COPILOT_MESSAGES_COUNT_TOKENS_BOUNDARY = [ withInlineImagesCompressed, withVisionHeaderSet, diff --git a/packages/provider-copilot/src/interceptors/messages/promote-thinking-display.ts b/packages/provider-copilot/src/interceptors/messages/promote-thinking-display.ts index a46b64c40a..39f7297644 100644 --- a/packages/provider-copilot/src/interceptors/messages/promote-thinking-display.ts +++ b/packages/provider-copilot/src/interceptors/messages/promote-thinking-display.ts @@ -23,7 +23,7 @@ const isClaudeVersionAtLeast = (model: string, major: number, minor: number): bo export const resolveMessagesDownstreamThinkingDisplay = (ctx: Pick): MessagesThinkingDisplay | undefined => { const display = ctx.payload.thinking?.display; if (display !== undefined) { - // Request JSON is not runtime-validated before target interceptors; leave + // Request JSON is not runtime-validated before boundary interceptors; leave // unknown display values untouched so upstream, not this workaround, owns // rejecting or accepting future values. return isMessagesThinkingDisplay(display) ? display : undefined; @@ -73,7 +73,7 @@ const omitThinkingTextFromProtocolFrames = async function* (frames: AsyncIterabl * the model is reasoning. Our Copilot probes found Claude 4.7 defaults to * omitted display, while 4.6/4.5 default to summarized; forcing summarized * upstream keeps data flowing during thinking and avoids the idle gap. To keep - * downstream omitted semantics, this target interceptor removes only thinking + * downstream omitted semantics, this boundary interceptor removes only thinking * text/deltas after the upstream attempt and preserves every `signature` byte; * the same probes showed blank thinking text is accepted, while any signature * tampering makes the next Messages request fail with 400. Those probes justify diff --git a/packages/provider-copilot/src/interceptors/messages/set-compact-headers.ts b/packages/provider-copilot/src/interceptors/messages/set-compact-headers.ts index ceab728610..ef15d64012 100644 --- a/packages/provider-copilot/src/interceptors/messages/set-compact-headers.ts +++ b/packages/provider-copilot/src/interceptors/messages/set-compact-headers.ts @@ -111,11 +111,13 @@ export const withCompactHeadersSet: CopilotMessagesBoundaryInterceptor = async ( ctx.headers.set('x-initiator', 'agent'); ctx.headers.set('x-interaction-type', 'conversation-compaction'); // openai-intent stays at `copilotAuthedFetch`'s `conversation-agent` - // default from `../../auth.ts` — the same value caozhiyuan/copilot-api - // re-pins inside prepareForCompact, so setting it here would be a no-op. + // default from `packages/provider-copilot/src/auth.ts` — the same value + // caozhiyuan/copilot-api re-pins inside prepareForCompact, so setting it + // here would be a no-op. } else if (kind === 'auto-continue') { // Auto-continue gets only the agent-initiator tag; interaction-type stays - // at `copilotAuthedFetch`'s `conversation-agent` default from `../../auth.ts`. + // at `copilotAuthedFetch`'s `conversation-agent` default from + // `packages/provider-copilot/src/auth.ts`. // This mirrors prepareForCompact when compactType === COMPACT_AUTO_CONTINUE: // it sets x-initiator: agent and leaves x-interaction-type untouched. ctx.headers.set('x-initiator', 'agent'); diff --git a/packages/provider-copilot/src/interceptors/messages/set-vision-header.ts b/packages/provider-copilot/src/interceptors/messages/set-vision-header.ts index 472d230a30..cd6ab590e0 100644 --- a/packages/provider-copilot/src/interceptors/messages/set-vision-header.ts +++ b/packages/provider-copilot/src/interceptors/messages/set-vision-header.ts @@ -4,7 +4,7 @@ import type { MessagesAssistantMessage, MessagesUserMessage } from '@floway-dev/ /** * Copilot rejects Anthropic `image` blocks as plain text unless the private * `copilot-vision-request: true` header is set. Detection must scan the final - * post-mutation payload (after other Messages target interceptors have run) + * post-mutation payload (after other Messages boundary interceptors have run) * and cover both the top-level `message.content` and the nested * `tool_result.content[]` shape; Anthropic allows images in both positions. * diff --git a/packages/provider-copilot/src/model-name.ts b/packages/provider-copilot/src/model-name.ts index 050189fee5..12f53e91e7 100644 --- a/packages/provider-copilot/src/model-name.ts +++ b/packages/provider-copilot/src/model-name.ts @@ -1,5 +1,5 @@ -export const CLAUDE_VARIANT_SUFFIX = /-(?:high|xhigh|1m(?:-internal)?|fast)$/; -export const CLAUDE_DATE_SUFFIX = /-\d{8}$/; +const CLAUDE_VARIANT_SUFFIX = /-(?:high|xhigh|1m(?:-internal)?|fast)$/; +const CLAUDE_DATE_SUFFIX = /-\d{8}$/; export const stripClaudeDateSuffix = (id: string): string => id.startsWith('claude-') ? id.replace(CLAUDE_DATE_SUFFIX, '') : id; diff --git a/packages/provider-custom/src/config.ts b/packages/provider-custom/src/config.ts index 879235fbc0..8e8edcd581 100644 --- a/packages/provider-custom/src/config.ts +++ b/packages/provider-custom/src/config.ts @@ -33,16 +33,19 @@ export type CustomAuthStyle = 'bearer' | 'anthropic' | 'none'; // unless overridden — the lookup table is the key itself. Kept // package-internal because outside callers reach the upstream through // the typed `customFetchXxx` transports, not by naming an endpoint key. -export type CustomPathOverrideKey = - | '/completions' - | '/chat/completions' - | '/responses' - | '/messages' - | '/embeddings' - | '/alpha/search' - | '/images/generations' - | '/images/edits' - | '/audio/transcriptions'; +const CUSTOM_PATH_OVERRIDE_KEYS = [ + '/completions', + '/chat/completions', + '/responses', + '/messages', + '/embeddings', + '/alpha/search', + '/images/generations', + '/images/edits', + '/audio/transcriptions', +] as const; + +export type CustomPathOverrideKey = typeof CUSTOM_PATH_OVERRIDE_KEYS[number]; export interface CustomModelsFetch { enabled: boolean; @@ -98,17 +101,7 @@ const baseUrlField = (value: unknown): string => { return baseUrl; }; -const PATH_OVERRIDE_KEYS = new Set([ - '/completions', - '/chat/completions', - '/responses', - '/messages', - '/embeddings', - '/alpha/search', - '/images/generations', - '/images/edits', - '/audio/transcriptions', -]); +const PATH_OVERRIDE_KEYS: ReadonlySet = new Set(CUSTOM_PATH_OVERRIDE_KEYS); const pathOverridesField = (value: unknown): CustomUpstreamConfigBase['pathOverrides'] => { if (value === undefined) return undefined; @@ -116,7 +109,7 @@ const pathOverridesField = (value: unknown): CustomUpstreamConfigBase['pathOverr const pathOverrides: NonNullable = {}; for (const [key, path] of Object.entries(value)) { - if (!PATH_OVERRIDE_KEYS.has(key as CustomPathOverrideKey)) { + if (!PATH_OVERRIDE_KEYS.has(key)) { throw new Error(`Malformed custom upstream config: unsupported pathOverrides key ${key}`); } const validPath = validateUpstreamPath(path, `pathOverrides.${key}`); diff --git a/packages/provider-custom/src/fetch-models.ts b/packages/provider-custom/src/fetch-models.ts index 6b5ead58ca..b43d66d9d6 100644 --- a/packages/provider-custom/src/fetch-models.ts +++ b/packages/provider-custom/src/fetch-models.ts @@ -31,7 +31,7 @@ export interface CustomRawModel { max_prompt_tokens?: number; }; pricing?: ModelPricing; - // Optional ModelKind published by Floway upstreams; absent on plain + // Optional ModelKind published by Floway-shaped upstreams; absent on plain // OpenAI-compat upstreams. kind?: ModelKind; // Optional chat metadata from Floway-shaped upstreams; absent on plain diff --git a/packages/provider-custom/src/fetch-models_test.ts b/packages/provider-custom/src/fetch-models_test.ts index 782bcd598f..1928546757 100644 --- a/packages/provider-custom/src/fetch-models_test.ts +++ b/packages/provider-custom/src/fetch-models_test.ts @@ -56,7 +56,7 @@ test('fetchCustomModels accepts an Anthropic-shape response with no top-level `o ); }); -test('fetchCustomModels reads superset fields (display_name, limits, pricing) from our own /models', async () => { +test('fetchCustomModels reads superset fields (display_name, limits, pricing) from Floway-shaped upstreams', async () => { const { config } = assertCustomUpstreamRecord(upstreamRecord()); await withMockedFetch( () => jsonResponse({ diff --git a/packages/provider-custom/src/index.ts b/packages/provider-custom/src/index.ts index 672747b3e8..f91eff885f 100644 --- a/packages/provider-custom/src/index.ts +++ b/packages/provider-custom/src/index.ts @@ -7,6 +7,5 @@ export const customProviderModule: ProviderModule = { defaultFlags: CUSTOM_DEFAULT_FLAGS, }; -export { createCustomProvider } from './provider.ts'; export { assertCustomUpstreamRecord } from './config.ts'; export { fetchCustomModels } from './fetch-models.ts'; diff --git a/packages/provider-custom/src/provider_test.ts b/packages/provider-custom/src/provider_test.ts index 31e90563b6..9041670eb4 100644 --- a/packages/provider-custom/src/provider_test.ts +++ b/packages/provider-custom/src/provider_test.ts @@ -322,7 +322,7 @@ test('Custom provider uses configured endpoints regardless of per-model hints in ); }); -test('Custom provider projects display_name / created / limits / pricing from a Floway-style /models response', async () => { +test('Custom provider projects display_name / created / limits / pricing from a Floway-shaped /models response', async () => { await withMockedFetch( () => jsonResponse({ object: 'list', @@ -418,6 +418,72 @@ test('Custom provider callAlphaSearch posts JSON to /v1/alpha/search with the up }); }); +test('Custom provider with modelsFetch disabled serves only manual models and never fetches', async () => { + const provider = createCustomProvider(buildCustomUpstream({ + modelsFetchEnabled: false, + models: [{ + upstreamModelId: 'pinned-chat', + publicModelId: 'pinned', + kind: 'chat', + endpoints: { chatCompletions: {} }, + display_name: 'Pinned Chat', + limits: { max_output_tokens: 4096 }, + pricing: { entries: [{ rates: { input_tokens: '1', output_tokens: '2' } }] }, + }], + })).instance; + + await withMockedFetch( + () => { throw new Error('upstream /models must not be fetched when modelsFetch is disabled'); }, + async () => { + const models = await provider.getProvidedModels(directFetcher); + assertEquals(models.length, 1); + assertEquals(models[0].id, 'pinned'); + assertEquals(models[0].kind, 'chat'); + assertEquals(models[0].endpoints, { chatCompletions: {} }); + assertEquals(models[0].display_name, 'Pinned Chat'); + assertEquals(models[0].limits.max_output_tokens, 4096); + assertEquals(models[0].pricing?.entries[0]?.rates.input_tokens, '1'); + }, + ); +}); + +test('Custom provider with a manual override sharing an upstream id wins over the auto copy', async () => { + const provider = createCustomProvider(buildCustomUpstream({ + models: [{ + upstreamModelId: 'shared', + kind: 'chat', + endpoints: { chatCompletions: {} }, + display_name: 'Manual Shared', + pricing: { entries: [{ rates: { input_tokens: '1', output_tokens: '2' } }] }, + }], + })).instance; + + await withMockedFetch( + request => { + if (new URL(request.url).pathname === '/v1/models') { + return jsonResponse({ + object: 'list', + data: [ + { id: 'shared', pricing: { entries: [{ rates: { input_tokens: '9', output_tokens: '9' } }] } }, + { id: 'auto-only' }, + ], + }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const models = await provider.getProvidedModels(directFetcher); + assertEquals(models.map(model => model.id), ['shared', 'auto-only']); + const shared = models.find(model => model.id === 'shared'); + assertExists(shared); + assertEquals(shared.display_name, 'Manual Shared'); + assertEquals(shared.pricing?.entries[0]?.rates.input_tokens, '1'); + assertEquals(shared.pricing?.entries[0]?.rates.output_tokens, '2'); + assertEquals(models.find(model => model.id === 'auto-only')?.pricing, undefined); + }, + ); +}); + test('Custom provider forwards inbound anthropic-beta header through opts.headers', async () => { const provider = createCustomProvider(buildCustomUpstream()).instance; const seen: Array = []; diff --git a/packages/provider-ollama/src/config.ts b/packages/provider-ollama/src/config.ts index 7e4e87023d..988d8b612e 100644 --- a/packages/provider-ollama/src/config.ts +++ b/packages/provider-ollama/src/config.ts @@ -13,12 +13,11 @@ // protocol it speaks without going through a translation pair. // // `/api/show` does not expose a dedicated transcription capability, so audio -// models are manual overrides rather than inferred catalog entries. +// models reach the catalog only through manual `models[]` entries. // https://github.com/ollama/ollama/blob/573386c35eac76124ffce571f4b0fefa0a7fe13c/server/routes.go#L1909-L1922 // -// Operators can pin per-model overrides via `models[]`; auto-fetched and -// manual entries merge the same way as the custom provider (manual wins on -// id collision). +// A manual `models[]` entry wins over an auto-fetched row carrying the same +// upstream id. import type { UpstreamModelConfig, UpstreamRecord } from '@floway-dev/provider'; import { modelsField } from '@floway-dev/provider'; diff --git a/packages/provider-ollama/src/provider.ts b/packages/provider-ollama/src/provider.ts index 401b60e345..2517b88f8d 100644 --- a/packages/provider-ollama/src/provider.ts +++ b/packages/provider-ollama/src/provider.ts @@ -20,8 +20,9 @@ // semantic endpoint; ordinary catalog rows stay chat/embedding. // https://github.com/ollama/ollama/blob/573386c35eac76124ffce571f4b0fefa0a7fe13c/middleware/openai.go#L682-L789 // -// Manual config.models[] entries override auto-fetched models with the same -// upstreamModelId, mirroring the custom provider's pinning behavior. +// Manual config.models[] entries are emitted ahead of the auto-fetched +// catalog, and an auto row carrying the same upstreamModelId is dropped so the +// manual copy is the only one for that id. import { chatFromOllamaRaw } from './chat-from-raw.ts'; import { assertOllamaUpstreamRecord, type OllamaUpstreamConfig } from './config.ts'; @@ -77,8 +78,7 @@ export const createOllamaProvider = (record: UpstreamRecord): Provider => { const { config } = assertOllamaUpstreamRecord(record); const upstreamFlags = resolveEffectiveFlags([OLLAMA_DEFAULT_FLAGS, record.flagOverrides]); - // Manual overrides always emit, regardless of whether the upstream catalog - // fetch succeeds. Same shape and merge precedence as the custom provider. + // Manual models always emit. const overriddenIds = new Set(config.models.map(m => m.upstreamModelId)); const manualModels: ProviderModel[] = config.models.map(model => { const enabledFlags = resolveEffectiveFlags([OLLAMA_DEFAULT_FLAGS, record.flagOverrides, model.flagOverrides]); diff --git a/packages/provider/src/flags.ts b/packages/provider/src/flags.ts index d52024ab1f..5521bf2897 100644 --- a/packages/provider/src/flags.ts +++ b/packages/provider/src/flags.ts @@ -176,9 +176,10 @@ export const parseFlagOverridesWire = (value: unknown): FlagOverrides => // Canonical layer order across every provider: // 1. Provider upstream default (per-kind constant) // 2. Operator upstream override (`UpstreamRecord.flagOverrides`) -// 3. Per-model layer — provider's per-model default for auto rows -// (`defaultFlagsForCopilotModel(model)`), operator's per-model -// override for manual rows (`UpstreamModelConfig.flagOverrides`). +// 3. Per-model layer — the provider's per-model default +// (`defaultFlagsForCopilotModel(model)`) for an auto row, the operator's +// `UpstreamModelConfig.flagOverrides` for a manual row; the two row +// kinds are defined on `UpstreamModelConfig` in `model-config.ts`. // Never both, since an auto/manual row cannot be the other. // // Placing per-model last lets provider-declared technical necessities diff --git a/packages/provider/src/flags_test.ts b/packages/provider/src/flags_test.ts index 22f7e6eb06..515c45240f 100644 --- a/packages/provider/src/flags_test.ts +++ b/packages/provider/src/flags_test.ts @@ -43,17 +43,17 @@ test('provider flags: every catalog entry has id, label, description string fiel } }); -test('flags-resolve: no layers → empty set', () => { +test('provider flags: resolveEffectiveFlags — no layers → empty set', () => { const set = resolveEffectiveFlags([]); assertEquals([...set].sort(), []); }); -test('flags-resolve: a layer with a true flag adds it', () => { +test('provider flags: resolveEffectiveFlags — a layer with a true flag adds it', () => { const set = resolveEffectiveFlags([{ 'retry-cyber-policy': true }]); assertEquals([...set].sort(), ['retry-cyber-policy']); }); -test('flags-resolve: a later layer can force-off an earlier true', () => { +test('provider flags: resolveEffectiveFlags — a later layer can force-off an earlier true', () => { const set = resolveEffectiveFlags([ { 'retry-cyber-policy': true }, { 'retry-cyber-policy': false }, @@ -61,7 +61,7 @@ test('flags-resolve: a later layer can force-off an earlier true', () => { assertEquals([...set].sort(), []); }); -test('flags-resolve: a still-later layer can force-on again', () => { +test('provider flags: resolveEffectiveFlags — a still-later layer can force-on again', () => { const set = resolveEffectiveFlags([ { 'retry-cyber-policy': true }, { 'retry-cyber-policy': false }, @@ -70,12 +70,12 @@ test('flags-resolve: a still-later layer can force-on again', () => { assertEquals([...set].sort(), ['retry-cyber-policy']); }); -test('flags-resolve: upstream layer force-on adds a flag', () => { +test('provider flags: resolveEffectiveFlags — upstream layer force-on adds a flag', () => { const set = resolveEffectiveFlags([{ 'vendor-deepseek': true }]); assertEquals([...set].sort(), ['vendor-deepseek']); }); -test('flags-resolve: model layer force-off wins over upstream force-on', () => { +test('provider flags: resolveEffectiveFlags — model layer force-off wins over upstream force-on', () => { const set = resolveEffectiveFlags([ { 'vendor-deepseek': true }, { 'vendor-deepseek': false }, @@ -83,7 +83,7 @@ test('flags-resolve: model layer force-off wins over upstream force-on', () => { assertEquals([...set].sort(), []); }); -test('flags-resolve: later layer wins when both set the same flag', () => { +test('provider flags: resolveEffectiveFlags — later layer wins when both set the same flag', () => { const set = resolveEffectiveFlags([ { 'vendor-qwen': false }, { 'vendor-qwen': true }, @@ -91,7 +91,7 @@ test('flags-resolve: later layer wins when both set the same flag', () => { assertEquals([...set].sort(), ['vendor-qwen']); }); -test('flags-resolve: undefined layers are skipped', () => { +test('provider flags: resolveEffectiveFlags — undefined layers are skipped', () => { const set = resolveEffectiveFlags([undefined, { 'retry-cyber-policy': true }, undefined]); assertEquals([...set].sort(), ['retry-cyber-policy']); }); diff --git a/packages/provider/src/ids.ts b/packages/provider/src/ids.ts deleted file mode 100644 index 71737d841a..0000000000 --- a/packages/provider/src/ids.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const uuidV7 = (): string => { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - - const timestampMs = BigInt(Date.now()); - bytes[0] = Number((timestampMs >> 40n) & 0xffn); - bytes[1] = Number((timestampMs >> 32n) & 0xffn); - bytes[2] = Number((timestampMs >> 24n) & 0xffn); - bytes[3] = Number((timestampMs >> 16n) & 0xffn); - bytes[4] = Number((timestampMs >> 8n) & 0xffn); - bytes[5] = Number(timestampMs & 0xffn); - bytes[6] = (bytes[6] & 0x0f) | 0x70; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - - return uuidFromBytes(bytes); -}; - -const uuidFromBytes = (bytes: Uint8Array): string => { - const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')); - return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`; -}; diff --git a/packages/provider/src/index.ts b/packages/provider/src/index.ts index 2b366f807c..c728e4e552 100644 --- a/packages/provider/src/index.ts +++ b/packages/provider/src/index.ts @@ -75,15 +75,12 @@ export { export type { Flag, FlagDefaults, FlagId, FlagOverrides } from './flags.ts'; export { OPTIONAL_FLAGS, - isKnownFlagId, parseFlagOverridesWire, resolveEffectiveFlags, } from './flags.ts'; export type { UpstreamModelConfig, - UpstreamModelLimits, - Modality, UpstreamChatModelConfig, } from './model-config.ts'; export { @@ -110,4 +107,3 @@ export { isBase64ImageDataUrl, parseBase64ImageDataUrl, } from './image-helpers.ts'; -export { uuidV7 } from './ids.ts'; diff --git a/packages/provider/src/model-config.ts b/packages/provider/src/model-config.ts index 032f33e22e..806fdd3348 100644 --- a/packages/provider/src/model-config.ts +++ b/packages/provider/src/model-config.ts @@ -1,26 +1,26 @@ import { type FlagOverrides, validateFlagOverridesRecord } from './flags.ts'; import { validateUpstreamPath } from './join.ts'; -import { BILLING_METRICS, canonicalizePricingSelector, kindForEndpoints, MODEL_KINDS, parseNonNegativeDecimalString, RERANK_PROTOCOLS, type BillingMetric, type ChatModelInfo, type ModelEndpointKey, type ModelEndpoints, type ModelKind, type Modality, type ModelPricing, type PriceVector, type PricingSelector, type RerankProtocol, type RerankTarget, validateModelPricing } from '@floway-dev/protocols/common'; - -export type { Modality } from '@floway-dev/protocols/common'; - -export interface UpstreamModelLimits { - max_context_window_tokens?: number; - max_prompt_tokens?: number; - max_output_tokens?: number; -} +import { BILLING_METRICS, canonicalizePricingSelector, kindForEndpoints, MODEL_KINDS, parseNonNegativeDecimalString, RERANK_PROTOCOLS, type BillingMetric, type ChatModelInfo, type ModelEndpointKey, type ModelEndpoints, type ModelKind, type Modality, type ModelPricing, type PriceVector, type PricingSelector, type PublicModelLimits, type RerankProtocol, type RerankTarget, validateModelPricing } from '@floway-dev/protocols/common'; // The catalog-side name for the wire chat metadata. Shape lives in // @floway-dev/protocols/common so PublicModel.chat and the upstream catalog // share a single declaration. export type UpstreamChatModelConfig = ChatModelInfo; +// One model row on an upstream. A row's kind names the source of its config, +// not its shape — both kinds are this interface: +// • Manual — an entry of the upstream's persisted `config.models[]`. The +// operator authored it, PATCH persists it, and `modelsField` below is its +// validator. +// • Auto — the live projection of a provider's own emission, rendered by +// `POST /api/upstreams/list-models` from the `ProviderModel` the provider +// returned. Read-only; it never persists. export interface UpstreamModelConfig { // Mirrors of fields that flow through to PublicModel (snake_case for parity). kind: ModelKind; endpoints: ModelEndpoints; display_name?: string; - limits?: UpstreamModelLimits; + limits?: PublicModelLimits; pricing?: ModelPricing; chat?: UpstreamChatModelConfig; rerankTarget?: RerankTarget; @@ -31,9 +31,8 @@ export interface UpstreamModelConfig { // per-model override, applied on top of the upstream default + // operator upstream override. Absent / `{}` = no per-model override // (pure inherit). The auto-row counterpart is - // `ProviderModel.flagOverrides` — same field name, occupies the same - // layer-3 slot, but sourced from the provider's per-model rule - // rather than an operator-authored config row. + // `ProviderModel.flagOverrides`, sourced from the provider's per-model + // rule rather than an operator-authored config row. flagOverrides?: FlagOverrides; } @@ -92,7 +91,7 @@ const optionalMetadataRecord = (value: unknown, label: string): Record { +const limitsField = (value: unknown, label: string): PublicModelLimits | undefined => { const record = optionalMetadataRecord(value, label); if (!record) return undefined; return { diff --git a/packages/provider/src/model.ts b/packages/provider/src/model.ts index 7299809f4a..9eef36d426 100644 --- a/packages/provider/src/model.ts +++ b/packages/provider/src/model.ts @@ -1,7 +1,7 @@ import type { FlagId, FlagOverrides } from './flags.ts'; import type { UpstreamChatModelConfig } from './model-config.ts'; import type { ModelPrefixConfig } from './model-prefix.ts'; -import type { AliasSelection, AliasTarget, ModelKind, ModelEndpoints, ModelPricing, RerankTarget } from '@floway-dev/protocols/common'; +import type { AliasSelection, AliasTarget, ModelKind, ModelEndpoints, ModelPricing, PublicModelLimits, RerankTarget } from '@floway-dev/protocols/common'; export const ALL_PROVIDER_KINDS = ['copilot', 'custom', 'azure', 'codex', 'claude-code', 'ollama'] as const; export type UpstreamProviderKind = typeof ALL_PROVIDER_KINDS[number]; @@ -85,10 +85,12 @@ export interface UpstreamRecord { // null when a provider has no runtime state. state: unknown; flagOverrides: FlagOverrides; - // Public model ids the operator switched off for this upstream. Orthogonal to - // every per-model metadata field and uniform across provider kinds: a disabled - // id is hidden from the catalog and unroutable, but its row metadata stays - // editable. Entries may reference ids no longer present in the live model list. + // Model ids the operator switched off for this upstream, matched against the + // provider-emitted id before any model prefix is applied — so one entry hides + // both the bare and the prefixed surface. Orthogonal to every per-model + // metadata field and uniform across provider kinds: a disabled id is hidden + // from the catalog and unroutable, but its row metadata stays editable. + // Entries may reference ids no longer present in the live model list. disabledPublicModelIds: string[]; proxyFallbackList: ProxyFallbackEntry[]; // Per-upstream model name prefix policy. `null` keeps the bare-id behavior @@ -108,24 +110,23 @@ export interface UpstreamRecord { // `endpoints` and recomputes `kind`. Kept internal so callers can only touch // the wrapper types — this base has no meaning on its own. // -// `kind` is the high-level endpoint-family discriminator; `endpoints` is the -// precise per-protocol availability map. They are linked invariants enforced -// at the producer boundary: -// `kind === 'embedding'` ⇔ `endpoints === { embeddings: {} }` -// `kind === 'image'` ⇔ `endpoints ⊂ {imagesGenerations, imagesEdits}` -// `kind === 'rerank'` ⇔ `endpoints === { rerank: {} }` -// `kind === 'transcription'` ⇔ `endpoints === { audioTranscriptions: {} }` -// `kind === 'chat'` ⇒ `endpoints ⊂ generation endpoints`. +// `endpoints` is the precise per-protocol availability map; `kind` is always +// `kindForEndpoints(endpoints)`, a lossy first-match projection of it onto the +// endpoint-family discriminator. Only `kind === 'chat'` says anything about the +// whole map — it means no non-chat family key is present, since each of those +// short-circuits ahead of it. Every other value says only that its own key is +// present; the map may carry any other endpoint alongside, and no producer +// checks otherwise. `data-plane/providers/catalog.ts`'s union merge +// manufactures exactly such mixed sets on purpose when several upstreams +// contribute one public id, then recomputes `kind` from the union. Dispatch is +// unaffected: every serve path narrows on the endpoint key it needs, never on +// `kind`. interface ModelMetadata { id: string; display_name?: string; owned_by?: string; created?: number; - limits: { - max_output_tokens?: number; - max_context_window_tokens?: number; - max_prompt_tokens?: number; - }; + limits: PublicModelLimits; kind: ModelKind; pricing?: ModelPricing; chat?: UpstreamChatModelConfig; @@ -172,13 +173,15 @@ export interface InternalAliasedFrom { // Per-upstream projection returned by every provider's `getProvidedModels` and // the shape every provider's `callXxx(model, ...)` takes at dispatch time. -// Carries the same metadata as `InternalModel` plus `providerData` (the opaque -// per-provider wire carrier — Copilot's raw variant list, Claude Code's dated -// upstream id, ...), `enabledFlags` (the effective flag set for the model -// on the emitting upstream, already resolved through every layer), and -// `flagOverrides` (optional dashboard-only view of the per-model layer -// that fed into `enabledFlags`). Providers only ever see their own emission — -// the surrounding `InternalModel` map is assembled by the registry. +// Carries the same metadata as `InternalModel` plus `providerData` (opaque +// provider-private invocation data, not a universal upstream-id field — +// Copilot uses it for raw variants, Claude Code for a dated wire id, and other +// providers may omit it or carry a different private shape), `enabledFlags` +// (the effective flag set for the model on the emitting upstream, already +// resolved through every layer), and `flagOverrides` (optional dashboard-only +// view of the per-model layer that fed into `enabledFlags`). Providers only +// ever see their own emission — the surrounding `InternalModel` map is +// assembled by the registry. export interface ProviderModel extends ModelMetadata { providerData?: unknown; rerankTarget?: RerankTarget; @@ -197,8 +200,8 @@ export interface ProviderModel extends ModelMetadata { // the provider itself calls on this specific model — // reshapeModelForDashboard projects it onto the wire as the auto-row // counterpart to the operator-authored - // `UpstreamModelConfig.flagOverrides` on manual rows. The two - // occupy the same layer-3 slot; the source is carried by the - // enclosing row type (auto vs manual), not by the field name. + // `UpstreamModelConfig.flagOverrides` on manual rows. Both occupy the + // same layer-3 slot; which one a row carries follows from where the row + // came from, not from anything on the field itself. flagOverrides?: FlagOverrides; } diff --git a/packages/provider/src/provider.ts b/packages/provider/src/provider.ts index e906c7ee3c..0433617f89 100644 --- a/packages/provider/src/provider.ts +++ b/packages/provider/src/provider.ts @@ -105,9 +105,12 @@ export interface UpstreamCallOptions { // runs synchronously and stamps `attempt.upstreamCallStartedAt` before // invoking the factory, so the stamp fires ahead of dial + TLS + CONNECT // (which live inside the returned promise's async body under a proxied - // fetcher). The pre-dial anchor is deliberate: TTFT from the user's - // viewpoint includes proxy handshake time, so keeping it in the interval - // matches observed client latency. + // fetcher). The interval anchored here therefore includes the gateway's + // own egress work — proxy-backoff lookup, dial, TLS, CONNECT — and + // excludes everything the gateway does before dispatch (routing, + // translation, interceptor entry). Candidate iteration clears the anchors + // per candidate, so after a failover the recorded interval is shorter + // than the latency the client observed. wrapUpstreamCall: (dispatch: () => Promise) => Promise; } diff --git a/packages/provider/src/repo.ts b/packages/provider/src/repo.ts index c6800bfd08..73facd5b12 100644 --- a/packages/provider/src/repo.ts +++ b/packages/provider/src/repo.ts @@ -1,7 +1,9 @@ import type { UpstreamRecord } from './model.ts'; -// Slim upstream-state surface for providers that own autonomous runtime state -// (e.g. Codex's rotated tokens). Structurally compatible with the full +// Slim upstream-state surface for providers that own runtime state (e.g. +// Codex's rotated tokens). Reached from the data plane as a request runs and +// from operator-triggered control-plane actions alike, so every write goes +// through the same read-modify-CAS. Structurally compatible with the full // UpstreamRepo in packages/gateway, so the wiring stays a single accessor. export interface UpstreamsRepoSlim { getById(id: string): Promise; diff --git a/packages/proxy/src/types.ts b/packages/proxy/src/types.ts index 6a24f4407a..2bd73ff48e 100644 --- a/packages/proxy/src/types.ts +++ b/packages/proxy/src/types.ts @@ -102,8 +102,9 @@ export interface DialOptions { * DEFAULT_DIAL_DEADLINE_MS when absent. */ dialTimeoutMs?: number; /** - * Platform-injected raw TCP dial primitive. Required — every dialer - * needs to open at least one TCP connection. + * Platform-injected byte-stream dial primitive: a `connect` that opens a + * duplex to a host:port and can also wrap it in the runtime's native TLS. + * Required — every dialer needs to open at least one connection. */ socketDial: SocketDial; } diff --git a/packages/translate/src/chat-completions-via-responses/events_test.ts b/packages/translate/src/chat-completions-via-responses/events_test.ts index 65f6d101bb..af5fa79fe0 100644 --- a/packages/translate/src/chat-completions-via-responses/events_test.ts +++ b/packages/translate/src/chat-completions-via-responses/events_test.ts @@ -6,10 +6,9 @@ import { eventFrame, type ProtocolFrame, type SseFrame, sseFrame } from '@floway import { responsesResultToEvents, type ResponsesResult, type ResponsesStreamEvent } from '@floway-dev/protocols/responses'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; -// Inlined copy of the gateway's chatCompletionsProtocolFrameToSSEFrame: kept here so this -// translate-package test does not deep-import into packages/gateway. The behavior -// under test is the translate output's terminal-frame discipline, not the -// SSE projection itself. +// Local stand-in for `chatCompletionsProtocolFrameToSSEFrame`: the behavior +// under test is the translate output's terminal-frame discipline, not the SSE +// projection itself. const isUsageOnlyChunk = (frame: ProtocolFrame): boolean => frame.type === 'event' && Array.isArray(frame.event.choices) && frame.event.choices.length === 0 && frame.event.usage !== undefined; diff --git a/packages/translate/src/gemini-via-messages/events.ts b/packages/translate/src/gemini-via-messages/events.ts index ba1f24a34c..1fcbfbc217 100644 --- a/packages/translate/src/gemini-via-messages/events.ts +++ b/packages/translate/src/gemini-via-messages/events.ts @@ -46,6 +46,11 @@ interface MessagesToGeminiStreamState extends GeminiThoughtSignatureState { toolUses: Record; } +// Gemini's `promptTokenCount` is an inclusive total that already contains the +// cached prefix, and `cachedContentTokenCount` is the breakdown of that share +// rather than an extra bucket — so the folded Anthropic total goes out whole +// and cache reads are re-surfaced alongside it, not subtracted from it. +// https://github.com/googleapis/js-genai/blob/86d4bfa5b8d026b6d9fae46f0069e7b7972beb80/src/types.ts#L7594-L7597 const mapUsage = (state: MessagesToGeminiStreamState, hasTerminalUsage: boolean): GeminiUsageMetadata | undefined => { const { cacheRead, cacheWrite, cacheWrite1h, inclusiveInput: promptTokenCount } = inclusiveMessagesInputUsage(state.usage); const cacheWriteTotal = cacheWrite + cacheWrite1h; diff --git a/packages/translate/src/gemini-via-messages/request_test.ts b/packages/translate/src/gemini-via-messages/request_test.ts index f3c3416074..3bd61ce01d 100644 --- a/packages/translate/src/gemini-via-messages/request_test.ts +++ b/packages/translate/src/gemini-via-messages/request_test.ts @@ -63,7 +63,7 @@ test('buildTargetRequest maps system, default max tokens, and multimodal user co }); }); -test('buildTargetRequest prefers capabilities.maxOutputTokens over the gateway default when payload omits maxOutputTokens', () => { +test('buildTargetRequest prefers limits.max_output_tokens over the gateway default when payload omits maxOutputTokens', () => { const request = buildTargetRequest({}, 'claude-test', withMaxOutputTokens(6144)); assertEquals(request.max_tokens, 6144); }); diff --git a/packages/translate/src/index.ts b/packages/translate/src/index.ts index bc0b2aee6c..21c45b1d82 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -9,5 +9,5 @@ export { translateGeminiViaResponses } from './gemini-via-responses/translate.ts export { translateGeminiViaChatCompletions } from './gemini-via-chat-completions/translate.ts'; export { canonicalizeResponsesPayload } from './canonicalize-responses-payload.ts'; -export type { RemoteImageData, RemoteImageLoader, TranslatedApiError, TranslationContext } from './types.ts'; +export type { RemoteImageData, RemoteImageLoader, TranslatedApiError, TranslateTripResult, TranslationContext } from './types.ts'; export { TranslatorInputError } from './translator-input-error.ts'; diff --git a/packages/translate/src/responses-via-chat-completions/request.ts b/packages/translate/src/responses-via-chat-completions/request.ts index ed1388f713..24c6317b74 100644 --- a/packages/translate/src/responses-via-chat-completions/request.ts +++ b/packages/translate/src/responses-via-chat-completions/request.ts @@ -157,14 +157,14 @@ const buildChatCompletionsResponseFormat = (text: ResponsesPayload['text']): Cha return format; }; -/** - * Names of Responses `custom` tools the request translator wrapped as - * single-string function tools. Returned alongside the translated payload so - * the trip's events translator can project wrapped function calls back into - * `custom_tool_call` outputs. - */ export interface TargetRequestResult { target: ChatCompletionsPayload; + /** + * Names of Responses `custom` tools the request translator wrapped as + * single-string function tools. Returned alongside the translated payload so + * the trip's events translator can project wrapped function calls back into + * `custom_tool_call` outputs. + */ customToolNames: Set; } diff --git a/packages/translate/src/responses-via-messages/request.ts b/packages/translate/src/responses-via-messages/request.ts index c9863f249b..949f6fbf29 100644 --- a/packages/translate/src/responses-via-messages/request.ts +++ b/packages/translate/src/responses-via-messages/request.ts @@ -43,14 +43,14 @@ interface BuildTargetRequestOptions { fallbackMaxOutputTokens?: number; } -/** - * Names of Responses `custom` tools the request translator wrapped as - * single-string function tools. Returned alongside the translated payload so - * the trip's events translator can project wrapped function calls back into - * `custom_tool_call` outputs. - */ export interface TargetRequestResult { target: MessagesPayload; + /** + * Names of Responses `custom` tools the request translator wrapped as + * single-string function tools. Returned alongside the translated payload so + * the trip's events translator can project wrapped function calls back into + * `custom_tool_call` outputs. + */ customToolNames: Set; } diff --git a/packages/translate/src/shared/AGENTS.md b/packages/translate/src/shared/AGENTS.md index f18877344c..68f4c850db 100644 --- a/packages/translate/src/shared/AGENTS.md +++ b/packages/translate/src/shared/AGENTS.md @@ -14,7 +14,7 @@ top level of `shared/`. 3. **Target-locked, `via-/`** — only `*-via-Y` pairs may import the helper. A helper does not need to serve every pair within that ceiling. 4. **One-protocol-bidirectional, `

/`** — only pairs with `P` as either source - or target may import the helper. + or target may import the helper. No helper currently occupies this ceiling. 5. **Two-protocol-bidirectional, `-and-/`** — only the `A-via-B` and `B-via-A` pairs may import the helper. For example, `chat-completions-and-responses/reasoning.ts` runs both directions of the @@ -37,7 +37,11 @@ top level of `shared/`. ## Rules - Shallow wrappers that only rename or stringify must be inlined at every call - site, not extracted. Delete the wrapper rather than retaining a shim. + site, not extracted. Delete the wrapper rather than retaining a shim. A + one-liner that *defines* a format two or more pairs must agree on is not such + a wrapper: `via-responses/responses-stream.ts` owns the composite stream-part + key both Responses-target pairs build and compare, and inlining it would let + the copies drift apart. - Flat `.ts` files at the top level of `shared/` are forbidden. Every shared helper lives in one of the categories above. - Helpers that fit no category stay in their pair directories. Do not invent a diff --git a/packages/translate/src/shared/messages-and-responses/reasoning.ts b/packages/translate/src/shared/messages-and-responses/reasoning.ts index 80f501da20..aee218762c 100644 --- a/packages/translate/src/shared/messages-and-responses/reasoning.ts +++ b/packages/translate/src/shared/messages-and-responses/reasoning.ts @@ -54,7 +54,7 @@ export const packReasoningSignature = (id: string, encryptedContent: string): st * base64/base64url (Anthropic, OpenAI), whose alphabet excludes `@`; only our * packing injects one, so the final `@` is always our delimiter. */ -export const unpackReasoningSignature = (signature: string): { id: string | null; encryptedContent: string } => { +const unpackReasoningSignature = (signature: string): { id: string | null; encryptedContent: string } => { const splitIndex = signature.lastIndexOf('@'); if (splitIndex === -1 || splitIndex === signature.length - 1) { return { id: null, encryptedContent: signature }; @@ -84,11 +84,12 @@ export const messagesReasoningBlockToResponsesReasoning = (block: MessagesReason /** * Project a Responses reasoning item into a Messages reasoning carrier bound - * for a real Messages UPSTREAM. This sends the GENUINE signature only — the - * upstream owns and validates that field, so we never wrap it in a gateway - * envelope. The opaque - * `encrypted_content` rides verbatim: as `thinking.signature` when there is - * readable text, else as `redacted_thinking.data`. + * for a real Messages UPSTREAM. Unlike {@link packReasoningSignature}, which + * wraps the blob in a gateway envelope for a downstream Messages CLIENT, this + * sends the GENUINE signature only — the upstream owns and validates that + * field. The opaque `encrypted_content` rides verbatim: as + * `thinking.signature` when there is readable text, else as + * `redacted_thinking.data`. * * No-opaque sub-case: a Responses-origin reasoning with an id but no * `encrypted_content` (we never auto-request it) has nothing the upstream can diff --git a/packages/translate/src/shared/via-messages/service-tier.ts b/packages/translate/src/shared/via-messages/service-tier.ts index 43607d7a53..9517fd9bf3 100644 --- a/packages/translate/src/shared/via-messages/service-tier.ts +++ b/packages/translate/src/shared/via-messages/service-tier.ts @@ -11,5 +11,11 @@ export const messagesServiceTierFieldsFromOpenAI = (serviceTier: string | null | ? { service_tier: serviceTier } : {}; +// Anthropic's `speed: 'fast'` surfaces as OpenAI `service_tier: 'fast'`; every +// other Anthropic `service_tier` passes through directly. The near-homonym +// `openAIServiceTierFromMessages` in `shared/messages-via/service-tier.ts` +// encodes the opposite rule for a non-`fast` `speed`: on the request side a +// present-but-not-fast `speed` drops the tier, while a usage snapshot still +// falls back to the reported `service_tier`. export const openAIServiceTierFromMessagesUsage = (usage: Pick): string | undefined => usage.speed === 'fast' ? 'fast' : usage.service_tier; diff --git a/packages/translate/src/types.ts b/packages/translate/src/types.ts index 396277fc88..32c1aa87aa 100644 --- a/packages/translate/src/types.ts +++ b/packages/translate/src/types.ts @@ -38,6 +38,23 @@ export interface TranslatedApiError { readonly body: Uint8Array; } +/** + * What one translation trip hands back: the target payload, an events + * translator closure mapping target-protocol events into source-protocol + * events, and an optional upstream-error rewriter. + * + * `apiError` is optional: when the target upstream returns a non-2xx HTTP + * body (rather than an SSE stream), the pair may rewrite it into the source + * protocol's envelope. Returning `undefined` — or omitting the field + * entirely — passes the upstream body through verbatim, which is what most + * pairs want. + */ +export interface TranslateTripResult { + target: TgtPayload; + events: (frames: AsyncIterable>) => AsyncIterable>; + apiError?: (upstream: TranslatedApiError) => TranslatedApiError | undefined; +} + /** * One pairwise translation trip. The function body owns the trip: it builds * the target payload and returns an events translator closure that maps @@ -51,18 +68,8 @@ export interface TranslatedApiError { * `TExtras` is the pair-declared context surface: each pair lists exactly the * capabilities and injected runtime adapters it reads. Pairs that need no * extra context leave it as `unknown` (default). - * - * `apiError` is optional: when the target upstream returns a non-2xx HTTP - * body (rather than an SSE stream), the pair may rewrite it into the source - * protocol's envelope. Returning `undefined` — or omitting the field - * entirely — passes the upstream body through verbatim, which is what most - * pairs want. */ export type TranslateTrip = ( src: SrcPayload, ctx: TranslationContext, -) => Promise<{ - target: TgtPayload; - events: (frames: AsyncIterable>) => AsyncIterable>; - apiError?: (upstream: TranslatedApiError) => TranslatedApiError | undefined; -}>; +) => Promise>; diff --git a/patches/overlayscrollbars.patch b/patches/overlayscrollbars@2.13.0.patch similarity index 100% rename from patches/overlayscrollbars.patch rename to patches/overlayscrollbars@2.13.0.patch diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce357923f1..bb45dff639 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,9 @@ patchedDependencies: '@reclaimprotocol/tls@0.1.2': hash: 8ed07af54e914cbcc2cea19ce7109635092cbbe35b9d2ad1bf55e3dfef1b8fd6 path: patches/@reclaimprotocol__tls@0.1.2.patch - overlayscrollbars: + overlayscrollbars@2.13.0: hash: 0989d8ab263bb64de03bfa5ce05a0492b7a0b2211ac83be29beaf137c31b2e22 - path: patches/overlayscrollbars.patch + path: patches/overlayscrollbars@2.13.0.patch importers: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 738158a06a..5af142242a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,4 +4,4 @@ packages: patchedDependencies: '@reclaimprotocol/tls@0.1.2': patches/@reclaimprotocol__tls@0.1.2.patch - overlayscrollbars: patches/overlayscrollbars.patch + 'overlayscrollbars@2.13.0': patches/overlayscrollbars@2.13.0.patch From 2ea1379416215797324c025f37d36902d6156705 Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 27 Jul 2026 14:03:51 +0800 Subject: [PATCH 5/7] refactor: restore the bare OverlayScrollbars patch key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning the key to `overlayscrollbars@2.13.0` changes what pnpm does with a patch that fails to apply — a bare key resolves to `strict: false`, so `allowFailure` is true and PATCH_FAILED degrades to a warning; a pinned key takes the strict branch and fails the install. That is a behavior change, and this branch carries equivalence- preserving refactoring and documentation corrections only. The pin belongs to the follow-up that owns it. --- ...overlayscrollbars@2.13.0.patch => overlayscrollbars.patch} | 0 pnpm-lock.yaml | 4 ++-- pnpm-workspace.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename patches/{overlayscrollbars@2.13.0.patch => overlayscrollbars.patch} (100%) diff --git a/patches/overlayscrollbars@2.13.0.patch b/patches/overlayscrollbars.patch similarity index 100% rename from patches/overlayscrollbars@2.13.0.patch rename to patches/overlayscrollbars.patch diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb45dff639..ce357923f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,9 @@ patchedDependencies: '@reclaimprotocol/tls@0.1.2': hash: 8ed07af54e914cbcc2cea19ce7109635092cbbe35b9d2ad1bf55e3dfef1b8fd6 path: patches/@reclaimprotocol__tls@0.1.2.patch - overlayscrollbars@2.13.0: + overlayscrollbars: hash: 0989d8ab263bb64de03bfa5ce05a0492b7a0b2211ac83be29beaf137c31b2e22 - path: patches/overlayscrollbars@2.13.0.patch + path: patches/overlayscrollbars.patch importers: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5af142242a..738158a06a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,4 +4,4 @@ packages: patchedDependencies: '@reclaimprotocol/tls@0.1.2': patches/@reclaimprotocol__tls@0.1.2.patch - 'overlayscrollbars@2.13.0': patches/overlayscrollbars@2.13.0.patch + overlayscrollbars: patches/overlayscrollbars.patch From 8bfcc39632f2524f984d3466723bf74217a43d38 Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 27 Jul 2026 14:31:47 +0800 Subject: [PATCH 6/7] docs: keep provider-private model naming out of the resolution spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Variant selection is not resolution. `resolveCopilotRawModel` has one caller, `provider-copilot/src/provider.ts`, and nothing under `packages/gateway/` references it or `ModelSelectionHints`. The resolver matches the public id, picks a candidate, and hands off; the provider then chooses its raw wire id from request fields at dispatch. Stating that as a resolution edge put a dispatch-time, vendor-private decision in the global spec. The sentence carrying it also restated the sentence before it. "The `-\d{8}` retry is the only request-time model-id normalization" already excludes every other rewrite, so "arbitrary suffixes are not rewritten" added nothing — and a negative claim stays vacuously true no matter how far the surrounding design moves, so nothing ever catches it going stale. The `providerData` paragraph keeps its argument — the field has no universal shape — without naming which providers happen to use it today. The remaining vendor names in this file are gateway behavior: the Codex and Claude Code catalog shapes are selected by the models route itself from the client User-Agent. --- docs/RESOLUTION.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/RESOLUTION.md b/docs/RESOLUTION.md index 1249b75253..0b19545a88 100644 --- a/docs/RESOLUTION.md +++ b/docs/RESOLUTION.md @@ -38,9 +38,9 @@ surface: The prefixed row is a shallow `ProviderModel` clone. `providerData` is preserved as opaque provider-private invocation data; it is not a universal upstream-id -field. Copilot uses it for raw variants, Claude Code for a dated wire id, and -other providers may omit it or carry a different private shape. Dispatch always -returns the exact provider's own emitted `ProviderModel` to its `call*` method. +field. Each provider defines its own shape for it, and a provider that needs no +private data omits it. Dispatch always returns the exact provider's own emitted +`ProviderModel` to its `call*` method. Rows collide by public id. The first contribution wins ordinary display/limit/ pricing metadata; later contributions union their `endpoints`, recompute `kind` @@ -399,9 +399,7 @@ coordinate boundary. ## Known edges - Disabling an id on one upstream does not hide the same id on another. -- The `-\d{8}` retry is the only request-time model-id normalization. Vendor - effort/context/speed variants must be advertised or sent through request - fields; arbitrary suffixes are not rewritten. +- The `-\d{8}` retry is the only request-time model-id normalization. - Catalogs are SWR-cached per upstream. Soft-fresh reads do not block on refresh. - Dual-addressable forms intentionally remain separate candidates. Their order follows the configured `addressable` array. From 6ee9014ca28b31c6e7376391c974794fa4ff0f1d Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 27 Jul 2026 15:17:29 +0800 Subject: [PATCH 7/7] fix: close final refactor review gaps Make direct Copilot probes preserve SOCKS5 remote DNS semantics and align model metadata prose with kind-gated resolution. Restore multi-frame Gemini collector coverage and remove patch-history commentary from the Responses translation test. --- .agents/skills/probing-copilot/SKILL.md | 7 ++- .../protocols/src/gemini/to-result_test.ts | 44 +++++++++++++++++++ packages/provider/src/model.ts | 7 +-- .../events_test.ts | 23 ++++------ 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/.agents/skills/probing-copilot/SKILL.md b/.agents/skills/probing-copilot/SKILL.md index 4d4e76312d..f42f4fb739 100644 --- a/.agents/skills/probing-copilot/SKILL.md +++ b/.agents/skills/probing-copilot/SKILL.md @@ -83,8 +83,11 @@ unless the selected entry is explicitly `direct_fetch` or `direct_connect`. - `direct_fetch`, `direct_connect` — direct egress is intentional and visible in the query result. -- `http://`, `https://`, `socks5://` — curl-native; use - `curl -x "$proxy_url" …`. +- `http://`, `https://` — curl-native; use `curl -x "$proxy_url" …`. +- `socks5://` — Floway sends the target hostname to the proxy for resolution, + while curl resolves it locally under this scheme. Convert it before use with + `curl_proxy_url="socks5h://${proxy_url#socks5://}"`, then run + `curl -x "$curl_proxy_url" …` so the probe follows the production DNS path. - `ss://`, `trojan://`, `vless://` — curl cannot speak these. Use a throwaway script outside the repository with the current `@floway-dev/proxy` dialer, or report that a faithful probe is blocked. Do not go direct. diff --git a/packages/protocols/src/gemini/to-result_test.ts b/packages/protocols/src/gemini/to-result_test.ts index 82501b47dc..a5aca38992 100644 --- a/packages/protocols/src/gemini/to-result_test.ts +++ b/packages/protocols/src/gemini/to-result_test.ts @@ -5,6 +5,50 @@ import { collectGeminiProtocolEventsToResult } from './to-result.ts'; import { eventFrame } from '../common/index.ts'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; +test('collectGeminiProtocolEventsToResult consumes preterminal events and stops at the terminal event', async () => { + let consumed = 0; + const frames = (async function* () { + const events = [ + { + candidates: [{ + index: 0, + content: { role: 'model', parts: [{ text: 'Hel' }] }, + }], + }, + { + candidates: [{ + index: 0, + content: { role: 'model', parts: [{ text: 'lo' }] }, + finishReason: 'STOP', + }], + responseId: 'response-final', + }, + { + error: { + code: 500, + message: 'must not be consumed', + status: 'INTERNAL', + }, + }, + ] satisfies GeminiStreamEvent[]; + + for (const event of events) { + consumed += 1; + yield eventFrame(event); + } + })(); + + assertEquals(await collectGeminiProtocolEventsToResult(frames), { + candidates: [{ + index: 0, + content: { role: 'model', parts: [{ text: 'Hello' }] }, + finishReason: 'STOP', + }], + responseId: 'response-final', + }); + assertEquals(consumed, 2); +}); + test('collectGeminiProtocolEventsToResult throws Gemini error events', async () => { const errorEvent = { error: { diff --git a/packages/provider/src/model.ts b/packages/provider/src/model.ts index 9eef36d426..642eb3795d 100644 --- a/packages/provider/src/model.ts +++ b/packages/provider/src/model.ts @@ -118,9 +118,10 @@ export interface UpstreamRecord { // present; the map may carry any other endpoint alongside, and no producer // checks otherwise. `data-plane/providers/catalog.ts`'s union merge // manufactures exactly such mixed sets on purpose when several upstreams -// contribute one public id, then recomputes `kind` from the union. Dispatch is -// unaffected: every serve path narrows on the endpoint key it needs, never on -// `kind`. +// contribute one public id, then recomputes `kind` from the union. Resolution +// uses this projection as the source-route discriminator before the selected +// serve path reads its endpoint configuration; `endpoints` remains the +// catalog's precise capability metadata even for mixed sets. interface ModelMetadata { id: string; display_name?: string; diff --git a/packages/translate/src/responses-via-chat-completions/events_test.ts b/packages/translate/src/responses-via-chat-completions/events_test.ts index ba0a0ee04f..6965c3629d 100644 --- a/packages/translate/src/responses-via-chat-completions/events_test.ts +++ b/packages/translate/src/responses-via-chat-completions/events_test.ts @@ -471,24 +471,17 @@ test('translateChatCompletionsChunkToResponsesEvents discards scalar reasoning w test('translateChatCompletionsChunkToResponsesEvents ignores empty tool_calls arrays', () => { const state = createChatCompletionsToResponsesStreamState(); - // Before the fix, empty tool_calls [] was truthy and entered the - // tool-calls branch, prematurely closing the text item. After the fix - // (choice.delta.tool_calls?.length), empty arrays are treated as absent. - const events1 = translateChatCompletionsChunkToResponsesEvents(chunk({ role: 'assistant', tool_calls: [] }), state); - // role + empty tool_calls should only emit response.created + response.in_progress. - // No tool-call events should be emitted. - assertEquals(events1.length, 2); - assertEquals(events1[0].type, 'response.created'); - assertEquals(events1[1].type, 'response.in_progress'); - - // Content delta should create a message item and emit text delta — not a new - // output item for empty tool_calls. - const events2 = translateChatCompletionsChunkToResponsesEvents(chunk({ content: 'hello' }), state); - const addedEvents = events2.filter(e => e.type === 'response.output_item.added') as ResponsesOutputItemAddedEvent[]; + const initialEvents = translateChatCompletionsChunkToResponsesEvents(chunk({ role: 'assistant', tool_calls: [] }), state); + assertEquals(initialEvents.length, 2); + assertEquals(initialEvents[0].type, 'response.created'); + assertEquals(initialEvents[1].type, 'response.in_progress'); + + const contentEvents = translateChatCompletionsChunkToResponsesEvents(chunk({ content: 'hello' }), state); + const addedEvents = contentEvents.filter(event => event.type === 'response.output_item.added') as ResponsesOutputItemAddedEvent[]; assertEquals(addedEvents.length, 1, 'content delta should create one message output item'); assertEquals(addedEvents[0].item.type, 'message'); - const deltaEvents = events2.filter(e => e.type === 'response.output_text.delta'); + const deltaEvents = contentEvents.filter(event => event.type === 'response.output_text.delta'); assertEquals(deltaEvents.length, 1); assertEquals((deltaEvents[0] as { delta: string }).delta, 'hello'); });