From ae96f1b6a41bb4fcfd13c3ce8710c7ec9d30d2d4 Mon Sep 17 00:00:00 2001 From: Baziar Date: Mon, 21 Sep 2026 14:19:26 -0400 Subject: [PATCH 1/4] feat(cli): add governed OpenRouter AI Gateway kits Introduce OpenRouter TypeScript and Python as first-class AI Gateway projects without treating gateways as agent frameworks. Generate pinned, server-owned starters with fail-closed provider-policy validation, cross-language semantic parity, secret-safe error handling, stream cleanup, deterministic offline smoke tests, and Workspai workspace lifecycle integration. Keep Go excluded while its SDK remains beta. Cross-platform qualification and release admission remain pending. --- contracts/create-planner-capabilities.v1.json | 24 + contracts/runtime-command-surface.v1.json | 4 +- ...orkspace-intelligence-architecture.v1.json | 4 +- .../workspace-model.v1.json | 4 + packages/cli/CHANGELOG.md | 30 + packages/cli/README.md | 4 +- .../create-planner-capabilities.v1.json | 24 + .../contracts/runtime-command-surface.v1.json | 4 +- ...orkspace-intelligence-architecture.v1.json | 4 +- .../workspace-model.v1.json | 4 + packages/cli/docs/README.md | 2 + packages/cli/docs/agent-framework-adapters.md | 4 + packages/cli/docs/ci-workflows.md | 4 + packages/cli/docs/commands-reference.md | 2 + .../cli/docs/create-planner-capabilities.md | 1 + .../docs/creating-workspaces-and-projects.md | 30 +- packages/cli/docs/doctor-command.md | 3 + packages/cli/docs/model-gateways.md | 167 ++ packages/cli/docs/native-kit-baselines.md | 3 +- .../check-generator-smoke-coverage.mjs | 10 +- .../cli/scripts/enterprise-package-smoke.mjs | 102 + ...intelligence-architecture-contract.test.ts | 2 + .../create-planner-capabilities.test.ts | 29 +- .../src/__tests__/handle-create-flags.test.ts | 149 ++ .../src/__tests__/kit-picker-choices.test.ts | 18 + .../model-gateway-generated-projects.test.ts | 242 +++ .../model-gateway-policy-parity.test.ts | 151 ++ .../model-gateway-project-kits.test.ts | 188 ++ .../model-gateway-workspace-lifecycle.test.ts | 318 +++ .../project-taxonomy-and-new-kits.test.ts | 16 + .../__tests__/suggested-project-name.test.ts | 3 +- packages/cli/src/cli-ui/kit-picker-choices.ts | 19 +- .../create-planner-capabilities-contract.ts | 15 +- .../cli/src/doctor/capability-registry.ts | 22 +- packages/cli/src/framework-registry.ts | 30 +- packages/cli/src/index.ts | 127 +- .../adapters/openrouter/common.ts | 57 + .../adapters/openrouter/policy-parity.ts | 261 +++ .../adapters/openrouter/python.ts | 1536 ++++++++++++++ .../adapters/openrouter/typescript.ts | 1808 +++++++++++++++++ packages/cli/src/model-gateways/gateway.ts | 42 + packages/cli/src/model-gateways/index.ts | 11 + packages/cli/src/model-gateways/lifecycle.ts | 1 + .../cli/src/model-gateways/project-kits.ts | 158 ++ packages/cli/src/model-gateways/registry.ts | 1 + .../model-gateways/version-baselines.v1.json | 88 + .../cli/src/model-gateways/version-policy.ts | 174 ++ .../src/utils/create-planner-capabilities.ts | 19 + packages/cli/src/utils/project-kind.ts | 12 + .../cli/src/utils/suggested-project-name.ts | 6 + packages/cli/src/workspace-run.ts | 70 +- packages/cli/vitest.config.ts | 1 + 52 files changed, 5954 insertions(+), 54 deletions(-) create mode 100644 packages/cli/docs/model-gateways.md create mode 100644 packages/cli/src/__tests__/model-gateway-generated-projects.test.ts create mode 100644 packages/cli/src/__tests__/model-gateway-policy-parity.test.ts create mode 100644 packages/cli/src/__tests__/model-gateway-project-kits.test.ts create mode 100644 packages/cli/src/__tests__/model-gateway-workspace-lifecycle.test.ts create mode 100644 packages/cli/src/model-gateways/adapters/openrouter/common.ts create mode 100644 packages/cli/src/model-gateways/adapters/openrouter/policy-parity.ts create mode 100644 packages/cli/src/model-gateways/adapters/openrouter/python.ts create mode 100644 packages/cli/src/model-gateways/adapters/openrouter/typescript.ts create mode 100644 packages/cli/src/model-gateways/gateway.ts create mode 100644 packages/cli/src/model-gateways/index.ts create mode 100644 packages/cli/src/model-gateways/lifecycle.ts create mode 100644 packages/cli/src/model-gateways/project-kits.ts create mode 100644 packages/cli/src/model-gateways/registry.ts create mode 100644 packages/cli/src/model-gateways/version-baselines.v1.json create mode 100644 packages/cli/src/model-gateways/version-policy.ts diff --git a/contracts/create-planner-capabilities.v1.json b/contracts/create-planner-capabilities.v1.json index 20df99b7..362ad15b 100644 --- a/contracts/create-planner-capabilities.v1.json +++ b/contracts/create-planner-capabilities.v1.json @@ -158,6 +158,30 @@ "versionPolicy": "tested-baseline", "moduleSupport": false, "workspacePythonEngine": "none" + }, + { + "id": "gateway.openrouter.typescript", + "runtime": "node", + "framework": "openrouter", + "plannerFramework": "openrouter", + "category": "gateway", + "owner": "workspai", + "stability": "stable", + "versionPolicy": "tested-baseline", + "moduleSupport": false, + "workspacePythonEngine": "none" + }, + { + "id": "gateway.openrouter.python", + "runtime": "python", + "framework": "openrouter", + "plannerFramework": "openrouter", + "category": "gateway", + "owner": "workspai", + "stability": "stable", + "versionPolicy": "tested-baseline", + "moduleSupport": false, + "workspacePythonEngine": "none" } ], "officialCreate": [ diff --git a/contracts/runtime-command-surface.v1.json b/contracts/runtime-command-surface.v1.json index fb5c2cd0..2d8364c2 100644 --- a/contracts/runtime-command-surface.v1.json +++ b/contracts/runtime-command-surface.v1.json @@ -4373,7 +4373,9 @@ "agent.microsoft.python", "agent.microsoft.dotnet", "agent.openai.python", - "agent.openai.typescript" + "agent.openai.typescript", + "gateway.openrouter.typescript", + "gateway.openrouter.python" ], "officialCreate": [ "frontend.nextjs", diff --git a/contracts/workspace-intelligence-architecture.v1.json b/contracts/workspace-intelligence-architecture.v1.json index cacb07e3..48b45a9d 100644 --- a/contracts/workspace-intelligence-architecture.v1.json +++ b/contracts/workspace-intelligence-architecture.v1.json @@ -683,7 +683,9 @@ "agent.microsoft.python", "agent.microsoft.dotnet", "agent.openai.python", - "agent.openai.typescript" + "agent.openai.typescript", + "gateway.openrouter.typescript", + "gateway.openrouter.python" ], "officialCreate": [ "frontend.nextjs", diff --git a/contracts/workspace-intelligence/workspace-model.v1.json b/contracts/workspace-intelligence/workspace-model.v1.json index 60ec2c85..18f72327 100644 --- a/contracts/workspace-intelligence/workspace-model.v1.json +++ b/contracts/workspace-intelligence/workspace-model.v1.json @@ -48,6 +48,7 @@ "service", "worker", "agent", + "gateway", "platform", "library", "infra", @@ -66,6 +67,7 @@ "desktop", "extension", "agent", + "gateway", "platform", "library", "infrastructure", @@ -97,6 +99,7 @@ "extension", "worker", "agent", + "gateway", "platform", "library", "infra", @@ -112,6 +115,7 @@ "desktop", "extension", "agent", + "gateway", "platform", "library", "infrastructure", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 23a07d02..fa7d25dc 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Introduced the first-class **AI Gateway** Create category (`Unified model + access and routing`) with OpenRouter Client SDK kits + `gateway.openrouter.typescript` (`@openrouter/sdk` `1.3.11`) and + `gateway.openrouter.python` (`openrouter` `1.2.11`). Generated projects are + server-owned, pin reviewed SDK baselines, keep the model as runtime + configuration, and do not call a model during Create. The official Go SDK + remains beta and is not included on the Create surface. + +### Changed + +- Workspace Model kind/category enums, Create planner native kits, interactive + Create, Workspace Run/Verify polyglot lifecycle, and doctor runtime adapters + now recognize `gateway` as a first-class project category distinct from + Agent Framework. + +### Fixed + +- Generated Python `max_price` validation now rejects overflow-to-infinity + strings, NaN, and non-finite values while preserving accepted numeric strings + for the SDK. Stream cleanup in both generated gateways is once-only and + prefers iterator `return()`/`close()` over a second SDK close/cancel. +- Workspace Run `start` executes a single materialized argv step without a + shell, so Python gateway projects whose install path contains spaces fail + closed on missing credentials instead of being split by `/bin/sh`. +- `validateCommand` accepts a space-containing executable path only when that + path exists, then falls back to the first token so commands such as + `go build ./...` still resolve through `which`. + ## [0.76.0] - 2026-09-20 ### Added diff --git a/packages/cli/README.md b/packages/cli/README.md index a330d245..8296ac91 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -110,8 +110,8 @@ npx workspai create ``` Choose whether to create a workspace, scaffold a project, or add existing -software. Project starters are grouped as Backend, Frontend, Desktop, and -Extension. +software. Project starters are grouped as Backend, Frontend, Desktop, AI +Agent, AI Gateway, Extension, and Gaming. ```bash # Optional global installation diff --git a/packages/cli/contracts/create-planner-capabilities.v1.json b/packages/cli/contracts/create-planner-capabilities.v1.json index 20df99b7..362ad15b 100644 --- a/packages/cli/contracts/create-planner-capabilities.v1.json +++ b/packages/cli/contracts/create-planner-capabilities.v1.json @@ -158,6 +158,30 @@ "versionPolicy": "tested-baseline", "moduleSupport": false, "workspacePythonEngine": "none" + }, + { + "id": "gateway.openrouter.typescript", + "runtime": "node", + "framework": "openrouter", + "plannerFramework": "openrouter", + "category": "gateway", + "owner": "workspai", + "stability": "stable", + "versionPolicy": "tested-baseline", + "moduleSupport": false, + "workspacePythonEngine": "none" + }, + { + "id": "gateway.openrouter.python", + "runtime": "python", + "framework": "openrouter", + "plannerFramework": "openrouter", + "category": "gateway", + "owner": "workspai", + "stability": "stable", + "versionPolicy": "tested-baseline", + "moduleSupport": false, + "workspacePythonEngine": "none" } ], "officialCreate": [ diff --git a/packages/cli/contracts/runtime-command-surface.v1.json b/packages/cli/contracts/runtime-command-surface.v1.json index fb5c2cd0..2d8364c2 100644 --- a/packages/cli/contracts/runtime-command-surface.v1.json +++ b/packages/cli/contracts/runtime-command-surface.v1.json @@ -4373,7 +4373,9 @@ "agent.microsoft.python", "agent.microsoft.dotnet", "agent.openai.python", - "agent.openai.typescript" + "agent.openai.typescript", + "gateway.openrouter.typescript", + "gateway.openrouter.python" ], "officialCreate": [ "frontend.nextjs", diff --git a/packages/cli/contracts/workspace-intelligence-architecture.v1.json b/packages/cli/contracts/workspace-intelligence-architecture.v1.json index cacb07e3..48b45a9d 100644 --- a/packages/cli/contracts/workspace-intelligence-architecture.v1.json +++ b/packages/cli/contracts/workspace-intelligence-architecture.v1.json @@ -683,7 +683,9 @@ "agent.microsoft.python", "agent.microsoft.dotnet", "agent.openai.python", - "agent.openai.typescript" + "agent.openai.typescript", + "gateway.openrouter.typescript", + "gateway.openrouter.python" ], "officialCreate": [ "frontend.nextjs", diff --git a/packages/cli/contracts/workspace-intelligence/workspace-model.v1.json b/packages/cli/contracts/workspace-intelligence/workspace-model.v1.json index 60ec2c85..18f72327 100644 --- a/packages/cli/contracts/workspace-intelligence/workspace-model.v1.json +++ b/packages/cli/contracts/workspace-intelligence/workspace-model.v1.json @@ -48,6 +48,7 @@ "service", "worker", "agent", + "gateway", "platform", "library", "infra", @@ -66,6 +67,7 @@ "desktop", "extension", "agent", + "gateway", "platform", "library", "infrastructure", @@ -97,6 +99,7 @@ "extension", "worker", "agent", + "gateway", "platform", "library", "infra", @@ -112,6 +115,7 @@ "desktop", "extension", "agent", + "gateway", "platform", "library", "infrastructure", diff --git a/packages/cli/docs/README.md b/packages/cli/docs/README.md index 835b96e9..2f419e94 100644 --- a/packages/cli/docs/README.md +++ b/packages/cli/docs/README.md @@ -85,6 +85,7 @@ or use `import` to copy or clone one into the workspace. | Turn plain language into governed work | [Goal Packs](./goal-packs.md) | A scope-bound, evidence-pinned plan and portable agent handoff | | Prove what an agent changed and why | [Proof-Carrying Change](./proof-carrying-change.md) | A Goal-bound, effect-receipted, independently verified change capsule | | Define or implement an agent framework integration | [Agent Framework Adapter Contract](./agent-framework-adapters.md) | One provider-neutral manifest, ownership boundary, and conformance gate | +| Create a server-owned model gateway | [AI Gateway](./model-gateways.md) | OpenRouter TypeScript or Python Client SDK starter with workspace lifecycle | | Ground an agent before broad source discovery | [Canonical-first agent entry](./agent-entry.md) | A portable receipt for host discovery, evidence integrity, and freshness | | Repair a blocker through an approved transaction | [Workspace Repair Engine](./workspace-repair-engine.md) | Checkpointed execution, validation, canonical verify, and safe rollback | | Observe current CLI and Studio activity | [Workspai Live](./workspace-live-activity.md) | One bounded activity projection for terminal, IDE, replay, and capture | @@ -119,6 +120,7 @@ with [AI Quickstart](./AI_QUICKSTART.md) only when that is your goal. | [proof-carrying-change.md](./proof-carrying-change.md) | Generation-pinned Decisions ledger, predicted-vs-actual Graph delta, receipts, verification, and capsule validation | | [agent-entry.md](./agent-entry.md) | Host-native discovery, canonical evidence preflight, receipt status, privacy, and consumer integration | | [agent-framework-adapters.md](./agent-framework-adapters.md) | Framework-neutral kit/attach contract, admitted Microsoft pins, implemented OpenAI adapters pending admission, and conformance | +| [model-gateways.md](./model-gateways.md) | AI Gateway category, OpenRouter Client SDK baselines, routing/privacy policy, and lifecycle | | [workspace-knowledge-graph.md](./workspace-knowledge-graph.md) | Two-minute graph quickstart, proof model, AI/MCP consumption, performance, and honest token-efficiency measurement | | [graph-benchmark-methodology.md](./graph-benchmark-methodology.md) | Reproducible payload-reduction benchmark, formulas, claim boundaries, and publication rules | | [workspace-intelligence-evaluation.md](./workspace-intelligence-evaluation.md) | Provider usage, cost provenance, verified outcomes, comparison, and extension consumption | diff --git a/packages/cli/docs/agent-framework-adapters.md b/packages/cli/docs/agent-framework-adapters.md index 6b276dc6..4e3f9f6f 100644 --- a/packages/cli/docs/agent-framework-adapters.md +++ b/packages/cli/docs/agent-framework-adapters.md @@ -27,6 +27,10 @@ complete cross-platform matrix is reviewed into that inventory. Public commands fail closed rather than silently substituting Microsoft, OpenAI, or another runtime. +OpenRouter Client SDK access belongs to the [AI Gateway](./model-gateways.md) +category. Do not add OpenRouter under Agent Frameworks, and do not use +`@openrouter/agent` to implement a gateway kit. + ## Published contracts | Contract | Purpose | diff --git a/packages/cli/docs/ci-workflows.md b/packages/cli/docs/ci-workflows.md index 5644ecff..fed4992e 100644 --- a/packages/cli/docs/ci-workflows.md +++ b/packages/cli/docs/ci-workflows.md @@ -38,6 +38,10 @@ maintenance requirement after every routine adapter edit. It never sets Foundry or OpenAI credentials. Only a reviewed admission on the protected version-update branch can promote a green candidate. +OpenRouter AI Gateway kits are not part of the Agent Framework conformance +matrix. They are covered by the CLI test matrix, generated-project verification, +and enterprise package create smoke. See [AI Gateway](./model-gateways.md). + The PR template at `.github/agent-framework-version-update.md` is for that human pin update. It is not opened by the weekly discovery workflow. diff --git a/packages/cli/docs/commands-reference.md b/packages/cli/docs/commands-reference.md index efcc0a75..56b15e77 100644 --- a/packages/cli/docs/commands-reference.md +++ b/packages/cli/docs/commands-reference.md @@ -82,6 +82,8 @@ npx workspai change capsule validate --change [--workspace ] [ npx workspai change capsule export --change --output [--workspace ] [--json] npx workspai create project agent.microsoft.python [--agent-name ] [--skip-git] npx workspai create project agent.microsoft.dotnet [--agent-name ] [--skip-git] +npx workspai create project gateway.openrouter.typescript [--skip-git] [--json] +npx workspai create project gateway.openrouter.python [--skip-git] [--json] npx workspai agent bootstrap [--project ] [--for-agent ] [--no-live-inputs] [--strict] [--json] npx workspai agent framework list [--json] npx workspai agent framework plan --project --runtime [--framework ] --name [--goal ] [--workspace ] [--json] diff --git a/packages/cli/docs/create-planner-capabilities.md b/packages/cli/docs/create-planner-capabilities.md index 82fa7f91..1232e318 100644 --- a/packages/cli/docs/create-planner-capabilities.md +++ b/packages/cli/docs/create-planner-capabilities.md @@ -22,6 +22,7 @@ Native create is reserved for Workspai-owned kits with deterministic contracts: - Spring Boot - ASP.NET Core Web API - Rust / Axum +- OpenRouter AI Gateway (TypeScript and Python) These kits can be exposed through `workspai create project` because Workspai can create the project and immediately produce the expected `.workspai` metadata, diff --git a/packages/cli/docs/creating-workspaces-and-projects.md b/packages/cli/docs/creating-workspaces-and-projects.md index 8251a7c1..668fe81f 100644 --- a/packages/cli/docs/creating-workspaces-and-projects.md +++ b/packages/cli/docs/creating-workspaces-and-projects.md @@ -16,9 +16,9 @@ For a compact list of command syntax, see A **workspace** is the shared home for related projects, rules, and saved Workspai reports. -A **project** is an application, service, or governed agent, such as a FastAPI -API, Go service, Spring Boot service, .NET API, frontend application, or -Microsoft Agent Framework entrypoint. +A **project** is an application, service, governed agent, or model gateway, such as a FastAPI +API, Go service, Spring Boot service, .NET API, frontend application, +Microsoft Agent Framework entrypoint, or OpenRouter AI Gateway. The canonical commands are: @@ -42,6 +42,7 @@ does not have exactly the same behavior. | Turn the current folder into a workspace before creating | `npx workspai create project gofiber.standard api --create-workspace --yes` | | Create a project without workspace management | `npx workspai create project gofiber.standard api --no-workspace --yes` | | Create a governed Microsoft Agent Framework project | `npx workspai create project agent.microsoft.python support-agent` | +| Create a server-owned OpenRouter AI Gateway | `npx workspai create project gateway.openrouter.typescript model-gateway` | | Preview a supported create plan | `npx workspai create project frontend.nextjs web --dry-run` | # Creating a workspace @@ -418,6 +419,20 @@ npx workspai change verify --change --json independent proof for this scaffold. See [Agent Framework Adapter Contract](./agent-framework-adapters.md). +## AI Gateway kits + +| Kit | Runtime | Tested baseline | Layout | +| --- | --- | --- | --- | +| `gateway.openrouter.typescript` | Node.js | `@openrouter/sdk` `1.3.11`, TypeScript `5.9.3`, `@types/node` `22.20.3` | Server-owned OpenRouter Client SDK starter with `gateway.policy.json`, credentialless `node:test`, and `.env.example` | +| `gateway.openrouter.python` | Python | `openrouter` `1.2.11` | Server-owned OpenRouter Client SDK starter with `gateway.policy.json`, credentialless `unittest`, and `.env.example` | + +Interactive `workspai create` shows these kits under **AI Gateway** with the +hint **Unified model access and routing**. A model identifier is runtime +configuration, not a kit. Create does not install dependencies, call a model, +or store credentials. The official Go Client SDK remains beta (`v0.8.11` on +2026-09-21) and is not admitted. See +[AI Gateway](./model-gateways.md). + ## Desktop, extension, and additional backend generators | Category | Project | Kit | Creation owner | @@ -428,10 +443,11 @@ independent proof for this scaffold. See | Desktop | Electron Forge | `desktop.electron` | create-electron-app | | Extension | VS Code Extension | `extension.vscode` | generator-code | -Every generated project receives a canonical `kind` and `category`. The five -user-facing creation categories are `backend`, `frontend`, `desktop`, `agent`, and `extension`; -they remain visible in the Workspace Model and Knowledge Graph so consumers do -not have to guess a project’s role from its runtime. +Every generated project receives a canonical `kind` and `category`. The +user-facing creation categories are `backend`, `frontend`, `desktop`, `agent`, +`gateway`, and `extension`; they remain visible in the Workspace Model and +Knowledge Graph so consumers do not have to guess a project’s role from its +runtime. Official generators may download packages and therefore need network access. Each available integration requests the upstream latest stable channel rather diff --git a/packages/cli/docs/doctor-command.md b/packages/cli/docs/doctor-command.md index c505c6d6..758b53a0 100644 --- a/packages/cli/docs/doctor-command.md +++ b/packages/cli/docs/doctor-command.md @@ -781,6 +781,9 @@ These fields are designed for release gates and extension timeline cards that mu dependency manifests under `agents/primary` and aims Python/`uv`, Node `npm --prefix`, or .NET repair commands at that runtime, not at a phantom project-root manifest. +- OpenRouter AI Gateway projects keep `kind` as `gateway` and `framework` as + `openrouter`. Doctor uses the project-root Node or Python runtime, not the + Agent Framework nested layout. ## Project JSON fields (AI/automation) diff --git a/packages/cli/docs/model-gateways.md b/packages/cli/docs/model-gateways.md new file mode 100644 index 00000000..53f0e7df --- /dev/null +++ b/packages/cli/docs/model-gateways.md @@ -0,0 +1,167 @@ +# AI Gateway + +AI Gateway is a first-class Workspai project category for unified model access +and routing. It is not an Agent Framework. + +## Why Gateway is not Agent Framework + +Agent Framework owns agent loops, tools, handoffs, state, and orchestration. +AI Gateway owns model and provider access, routing preferences, fallbacks, +privacy policy, and normalized transport behavior. + +A model identifier is runtime configuration, not a project kit. Workspai owns +project generation, governance, context, lifecycle, verification, workspace +integration, and proof. OpenRouter owns provider and model routing. Generated +projects do not reimplement OpenRouter's routing algorithms. + +Future agent-to-gateway composition should consume the generated Model Gateway +port. Do not copy these templates into an Agent Framework kit. + +## Current kits + +These kits are source-ready in this worktree. That is not Workspai release +admission. Local Linux success may justify only +`source-stable, awaiting cross-platform qualification`. SDK `1.x` stability is +an upstream classification, not a Workspai release gate. + +| Kit | Runtime | Official Client SDK | Reviewed | +| --- | --- | --- | --- | +| `gateway.openrouter.typescript` | Node.js `>=20` | `@openrouter/sdk` `1.3.11` | 2026-09-21 | +| `gateway.openrouter.python` | Python `>=3.10` | `openrouter` `1.2.11` | 2026-09-21 | + +Aliases: `openrouter.typescript`, `gateway.openrouter.ts`, `openrouter-typescript`, +and the matching Python aliases. Bare `openrouter` is not an alias. + +Interactive Create shows **AI Gateway** with the hint **Unified model access +and routing** after at least one gateway kit is registered. Labels are: + +- `AI Gateway · OpenRouter · TypeScript` +- `AI Gateway · OpenRouter · Python` + +```bash +npx workspai create project gateway.openrouter.typescript +npx workspai create project gateway.openrouter.python +``` + +Create does not install dependencies, call a model, or write credentials. + +## Intentionally unsupported languages + +The official Go SDK (`github.com/OpenRouterTeam/go-sdk`) was evaluated on +2026-09-21. The upstream README classifies the current `0.x` line as beta and +warns that breaking changes may ship in minor releases. proxy.golang.org +reported `v0.8.11`. Go is excluded from the stable Create surface until +upstream classifies a release line as stable. + +The OpenRouter Agent SDK (`@openrouter/agent`) belongs to the agent-runtime +dimension. It is not used by AI Gateway kits and is not added as +`agent.openrouter.typescript` in this family. + +## Configuration + +Required environment: + +- `OPENROUTER_API_KEY` — server-side only +- `OPENROUTER_MODEL` — chosen by the operator; Workspai does not default a + billable model + +Optional attribution maps to the official SDK fields: + +- TypeScript: `httpReferer`, `appTitle` via `OPENROUTER_HTTP_REFERER` and + `OPENROUTER_APP_TITLE` +- Python: `http_referer`, `x_open_router_title` via `OPENROUTER_HTTP_REFERER` + and `OPENROUTER_X_OPEN_ROUTER_TITLE` + +Routing and privacy policy live in source-controlled `gateway.policy.json`. +Field names match the pinned SDK for that language. Unknown fields are rejected +fail-closed in both languages. Supported controls: + +| Official field | TypeScript policy | Python policy | Validation | +| --- | --- | --- | --- | +| model fallbacks | `models` | `models` | unique non-empty strings; must not repeat `OPENROUTER_MODEL` | +| `allowFallbacks` | `allowFallbacks` | `allow_fallbacks` | boolean when present | +| `order` / `only` / `ignore` | same camelCase | same snake_case | unique non-empty strings; `only`∩`ignore` empty; `order` must not include ignored providers | +| `sort` | string or `{ by, partition }` | string or `{ by, partition }` | `price`/`throughput`/`latency`/`exacto`; `partition` is `model` or `none` | +| `quantizations` | camelCase list | snake_case list | pinned SDK enum values | +| `requireParameters` | `requireParameters` | `require_parameters` | boolean when present | +| `zdr` | `zdr` | `zdr` | boolean; cannot combine with `dataCollection`/`data_collection` `allow` | +| `dataCollection` | `dataCollection` | `data_collection` | `allow` or `deny` | +| `enforceDistillableText` | `enforceDistillableText` | `enforce_distillable_text` | boolean when present; both pinned SDKs expose this field | +| `maxPrice` | `maxPrice` | `max_price` | object with `prompt`/`completion`/`request`/`image`/`audio` as non-negative numeric strings that remain finite after numeric interpretation | +| `preferredMinThroughput` | number or `{ p50, p75, p90, p99 }` | same | finite non-negative numbers | +| `preferredMaxLatency` | number or `{ p50, p75, p90, p99 }` | same | finite non-negative numbers | +| timeout | `timeoutMs` | `timeout_ms` | integer 1–600000; env overrides with a digit string | +| attribution | `httpReferer`, `appTitle` | `http_referer`, `x_open_router_title` | strings when present | + +`exacto` is part of the pinned SDK `ProviderSort` enums, not a Workspai extension. +Malformed JSON and wrong types raise `GatewayConfigurationError` before SDK +construction. Contradictory policy, including ZDR combined with +`dataCollection: "allow"`, fails before a network request. + +Omitted privacy or routing fields leave OpenRouter upstream defaults in +effect. The SDKs document `dataCollection` / `data_collection` default as +`allow` when unset. Application-side retries are set to strategy `none` so they +cannot duplicate billable requests. OpenRouter may still apply provider +fallbacks when `allowFallbacks` / `allow_fallbacks` is not `false`. + +Capabilities such as tools, JSON schema, structured output, images, audio, and +embeddings vary by model and provider. These starters only claim chat +generation and streaming. + +## Security + +- Never commit, log, or serialize API keys. +- Generated clients are server-side only. Do not expose `OPENROUTER_API_KEY` + through browser bundles or frontend environment prefixes such as `VITE_` or + `NEXT_PUBLIC_`. +- Diagnostics redact Authorization headers, API keys, URL secrets, and request + bodies. +- TLS verification is not disabled. +- Telemetry and prompt recording are not added. + +## Lifecycle + +After local install, documented commands are: + +TypeScript: `npm install`, `npm test`, `npm run typecheck`, `npm run build`, +`npm run smoke`, `npm start`. + +Python: create a virtualenv, `python -m pip install -e .`, `python -m compileall .`, +`python -m unittest discover -s tests`, `python main.py --smoke`, `python main.py`. + +Workspace Run uses those same manifests and the generated project's declared +polyglot unit commands. A single-step `start` is executed as argv, not a shell +string, so Python venv paths that contain spaces still fail closed for missing +credentials. `test` and `--smoke` inject a fake transport. They do not require +a live `OPENROUTER_API_KEY`. + +Streaming uses the official Client SDK event stream. Opening and iterating the +stream are both normalized: configuration errors stay configuration errors, +timeouts and caller aborts stay recognizable, and other failures become +`GatewayUpstreamError` with redacted messages and preserved status / request id / +retry-after when the SDK exposes them. Cleanup is once-only and prefers, in +order: async iterator `return()` / generator `close()`, then SDK `close()`, then +SDK `cancel()`. A cleanup failure does not replace the primary error or convert +a successful completion into an error. + +The TypeScript starter forwards `AbortSignal` through `chat.send` options, +including an already-aborted signal. Pinned `@openrouter/sdk` `1.3.11` +`EventStream` extends `ReadableStream` and does not expose a dedicated `close()`. +Cancellation is `ReadableStream` async-iterator `return()` → `cancel()`. If the +stream has already errored, `cancel()` may be a no-op; the gateway still drops +its own reference and remains usable for a later request. The Python starter +closes the SDK iterator first (`EventStream.generator.close()`), which is the +consumer `generator.close()` path. Python does not expose `AbortSignal`. +Application-side retries stay disabled. Timeouts use SDK `timeoutMs` / +`timeout_ms`. An empty successful completion returns an empty string. + +Exact pins live in +[`version-baselines.v1.json`](../src/model-gateways/version-baselines.v1.json). +Do not copy versions into unrelated files. + +## Qualification boundary + +Workspai source readiness is not release qualification. These kits are not +described as `stable`, `release-ready`, `admitted`, or `published` until Linux, +macOS, and Windows CI prove generated-project installation and lifecycle on the +same commit SHA. diff --git a/packages/cli/docs/native-kit-baselines.md b/packages/cli/docs/native-kit-baselines.md index e701606f..5488e489 100644 --- a/packages/cli/docs/native-kit-baselines.md +++ b/packages/cli/docs/native-kit-baselines.md @@ -15,7 +15,8 @@ CLI, such as Next.js, Astro, Angular, Vue, Svelte, Nuxt, or React Native. Governed Microsoft Agent Framework and OpenAI Agents SDK kits are versioned separately through the [Agent Framework Adapter Contract](./agent-framework-adapters.md); they are -not native HTTP service generators. +not native HTTP service generators. OpenRouter AI Gateway kits are versioned +through [AI Gateway](./model-gateways.md) and are not Agent Framework kits. ## Current tested baseline diff --git a/packages/cli/scripts/check-generator-smoke-coverage.mjs b/packages/cli/scripts/check-generator-smoke-coverage.mjs index 0049ff64..5d65ba91 100644 --- a/packages/cli/scripts/check-generator-smoke-coverage.mjs +++ b/packages/cli/scripts/check-generator-smoke-coverage.mjs @@ -25,13 +25,17 @@ assertSameSet('available official generators', expectedOfficial, actualOfficial) const nativeCreate = contract.nativeCreate ?? []; const expectedNative = nativeCreate - .filter((entry) => entry.category !== 'agent') + .filter((entry) => entry.category !== 'agent' && entry.category !== 'gateway') .map((entry) => entry.id) .sort(); const expectedGovernedAgent = nativeCreate .filter((entry) => entry.category === 'agent') .map((entry) => entry.id) .sort(); +const expectedGovernedGateway = nativeCreate + .filter((entry) => entry.category === 'gateway') + .map((entry) => entry.id) + .sort(); const nativeProbe = spawnSync( process.execPath, [path.join(repoRoot, 'scripts', 'runtime-acceptance-matrix.mjs'), '--list-kits'], @@ -50,7 +54,7 @@ const enterpriseSmokeSource = readFileSync( if (!/\bkit:\s*['"]rust\.axum['"]/.test(enterpriseSmokeSource)) { fail('Rust Axum must have an enterprise package create smoke scenario'); } -for (const kit of expectedGovernedAgent) { +for (const kit of [...expectedGovernedAgent, ...expectedGovernedGateway]) { const escapedKit = kit.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); if (!new RegExp(`\\bkit:\\s*['"]${escapedKit}['"]`, 'u').test(enterpriseSmokeSource)) { fail(`${kit} must have a governed enterprise package create smoke scenario`); @@ -58,7 +62,7 @@ for (const kit of expectedGovernedAgent) { } console.log( - `Generator smoke coverage aligned: ${expectedOfficial.length} official + ${expectedNative.length} native runtime + ${expectedGovernedAgent.length} governed agent generator(s).` + `Generator smoke coverage aligned: ${expectedOfficial.length} official + ${expectedNative.length} native runtime + ${expectedGovernedAgent.length} governed agent + ${expectedGovernedGateway.length} model gateway generator(s).` ); function assertSameSet(label, expected, actual) { diff --git a/packages/cli/scripts/enterprise-package-smoke.mjs b/packages/cli/scripts/enterprise-package-smoke.mjs index ee18e942..a8d62ed7 100644 --- a/packages/cli/scripts/enterprise-package-smoke.mjs +++ b/packages/cli/scripts/enterprise-package-smoke.mjs @@ -719,10 +719,112 @@ for (const relativePath of [ assertFile(relativePath); } +function smokeCreateOpenRouterGatewayKits() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workspai-gateway-kit-smoke-')); + const workspaceName = 'gateway-kit-workspace'; + const workspacePath = path.join(tempDir, workspaceName); + const createWorkspace = spawnSync( + process.execPath, + [ + cliPath, + 'create', + 'workspace', + workspaceName, + '--output', + tempDir, + '--profile', + 'minimal', + '--skip-python-engine', + '--skip-git', + '--yes', + ], + { cwd: tempDir, encoding: 'utf8', env: cliEnv(), stdio: ['ignore', 'pipe', 'pipe'] } + ); + if (createWorkspace.status !== 0) { + fail( + `gateway kit workspace creation failed with exit ${createWorkspace.status}\n${createWorkspace.stdout}\n${createWorkspace.stderr}` + ); + } + + const scenarios = [ + { + kit: 'gateway.openrouter.typescript', + name: 'openrouter-ts-gateway', + expectedFiles: [ + 'README.md', + '.env.example', + '.gitignore', + 'gateway.policy.json', + 'package.json', + 'tsconfig.json', + 'src/port.ts', + 'src/config.ts', + 'src/openrouter-gateway.ts', + 'src/main.ts', + 'tests/gateway.test.ts', + '.workspai/project.json', + ], + }, + { + kit: 'gateway.openrouter.python', + name: 'openrouter-py-gateway', + expectedFiles: [ + 'README.md', + '.env.example', + '.gitignore', + 'gateway.policy.json', + 'pyproject.toml', + 'main.py', + 'src/model_gateway/port.py', + 'src/model_gateway/config.py', + 'src/model_gateway/gateway.py', + 'src/model_gateway/main.py', + 'tests/test_gateway.py', + '.workspai/project.json', + ], + }, + ]; + try { + for (const scenario of scenarios) { + const result = spawnSync( + process.execPath, + [cliPath, 'create', 'project', scenario.kit, scenario.name, '--skip-git', '--yes'], + { + cwd: workspacePath, + encoding: 'utf8', + env: cliEnv(), + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + if (result.status !== 0) { + fail( + `${scenario.kit} create failed with exit ${result.status}\n${result.stdout}\n${result.stderr}` + ); + } + const combined = `${result.stdout}\n${result.stderr}`; + if (/openrouter\.ai|chat\.completions|Authorization: Bearer/i.test(combined)) { + fail(`${scenario.kit} create output looks like a live model call\n${combined}`); + } + assertGeneratedProject(path.join(workspacePath, scenario.name), scenario.expectedFiles); + const envExample = fs.readFileSync( + path.join(workspacePath, scenario.name, '.env.example'), + 'utf8' + ); + if (/sk-or-[A-Za-z0-9]/.test(envExample) || /OPENROUTER_API_KEY=.+/.test(envExample)) { + fail(`${scenario.kit} .env.example must not contain a credential value`); + } + } + log(`verified ${scenarios.length} model gateway create scenarios`); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + assertPackContents(); assertCliContracts(); smokeCreateNpmBackedKits(); smokeCreateAgentFrameworkKits(); +smokeCreateOpenRouterGatewayKits(); smokeCreateOfflineFallbackKits(); log('enterprise package smoke passed'); diff --git a/packages/cli/src/__tests__/contracts/workspace-intelligence-architecture-contract.test.ts b/packages/cli/src/__tests__/contracts/workspace-intelligence-architecture-contract.test.ts index 8d012065..a925312c 100644 --- a/packages/cli/src/__tests__/contracts/workspace-intelligence-architecture-contract.test.ts +++ b/packages/cli/src/__tests__/contracts/workspace-intelligence-architecture-contract.test.ts @@ -136,6 +136,8 @@ describe('workspace intelligence architecture contract', () => { 'gofiber.standard', 'gogin.standard', 'dotnet.webapi.clean', + 'gateway.openrouter.typescript', + 'gateway.openrouter.python', ]) ); expect(contract.createPlannerReality.officialCreate).toEqual( diff --git a/packages/cli/src/__tests__/create-planner-capabilities.test.ts b/packages/cli/src/__tests__/create-planner-capabilities.test.ts index d4a28a57..91905ea0 100644 --- a/packages/cli/src/__tests__/create-planner-capabilities.test.ts +++ b/packages/cli/src/__tests__/create-planner-capabilities.test.ts @@ -10,6 +10,7 @@ import { listInteractiveKits, resolveKitDefinition } from '../utils/kit-registry import { buildCreatePlannerCapabilitiesContract } from '../contracts/create-planner-capabilities-contract'; import { WORKSPACE_PROFILES } from '../workspace-profile-compatibility'; import { listAgentFrameworkProjectKits } from '../agent-frameworks/project-kits'; +import { listModelGatewayProjectKits } from '../model-gateways/project-kits'; describe('create planner capabilities', () => { it('keeps Workspai-owned backend kits in the native lane', () => { @@ -27,6 +28,23 @@ describe('create planner capabilities', () => { } }); + it('keeps OpenRouter gateway kits in the native lane and off the agent surface', () => { + for (const kit of listModelGatewayProjectKits()) { + expect(resolveCreatePlannerCapability({ kitId: kit.id })).toMatchObject({ + lane: 'native', + status: 'available', + canExecuteCreate: true, + resolved: kit.id, + }); + } + expect(resolveCreatePlannerCapability({ kitId: 'openrouter.typescript' }).resolved).toBe( + 'gateway.openrouter.typescript' + ); + expect(resolveCreatePlannerCapability({ kitId: 'agent.openrouter.typescript' })).toMatchObject({ + canExecuteCreate: false, + }); + }); + it('keeps admitted agent-framework kits in the governed native lane', () => { for (const kit of listAgentFrameworkProjectKits()) { expect(resolveCreatePlannerCapability({ kitId: kit.id })).toMatchObject({ @@ -104,8 +122,17 @@ describe('create planner capabilities', () => { expect(contract.workspaceProfiles.map((profile) => profile.id)).toEqual(WORKSPACE_PROFILES); expect(contract.nativeCreate).toHaveLength( - listInteractiveKits().length + listAgentFrameworkProjectKits().length + listInteractiveKits().length + + listAgentFrameworkProjectKits().length + + listModelGatewayProjectKits().length ); + expect( + contract.nativeCreate.find((kit) => kit.id === 'gateway.openrouter.typescript') + ).toMatchObject({ + framework: 'openrouter', + category: 'gateway', + versionPolicy: 'tested-baseline', + }); expect(contract.nativeCreate.find((kit) => kit.id === 'agent.microsoft.python')).toMatchObject({ framework: 'microsoft-agent-framework', category: 'agent', diff --git a/packages/cli/src/__tests__/handle-create-flags.test.ts b/packages/cli/src/__tests__/handle-create-flags.test.ts index 52994969..6dfba467 100644 --- a/packages/cli/src/__tests__/handle-create-flags.test.ts +++ b/packages/cli/src/__tests__/handle-create-flags.test.ts @@ -298,6 +298,155 @@ describe('handleCreateOrFallback - wrapper flags handling', () => { ); }, 60_000); + it('creates OpenRouter gateway projects without installing dependencies or calling a model', async () => { + await create.createProject('gateway-workspace', { + parentDirectory: tmpDir, + profile: 'minimal', + skipPythonEngine: true, + skipGit: true, + yes: true, + }); + const workspacePath = path.join(tmpDir, 'gateway-workspace'); + process.chdir(workspacePath); + const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const typescriptCode = await index.handleCreateOrFallback([ + 'create', + 'project', + 'gateway.openrouter.typescript', + 'ts-gateway', + '--skip-git', + '--yes', + ]); + const pythonCode = await index.handleCreateOrFallback([ + 'create', + 'project', + 'gateway.openrouter.python', + 'py-gateway', + '--skip-git', + '--yes', + ]); + + expect(typescriptCode).toBe(0); + expect(pythonCode).toBe(0); + expect(await fsExtra.pathExists(path.join(workspacePath, 'ts-gateway', 'src', 'main.ts'))).toBe( + true + ); + expect(await fsExtra.pathExists(path.join(workspacePath, 'py-gateway', 'main.py'))).toBe(true); + expect(await fsExtra.pathExists(path.join(workspacePath, 'ts-gateway', 'node_modules'))).toBe( + false + ); + expect(await fsExtra.pathExists(path.join(workspacePath, 'py-gateway', '.venv'))).toBe(false); + const output = stdoutSpy.mock.calls.map((call) => String(call[0])).join('\n'); + expect(output.toLowerCase()).not.toContain('authorization: bearer'); + const contract = await fsExtra.readJson( + path.join(workspacePath, '.workspai', 'workspace.contract.json') + ); + expect(contract.projects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ slug: 'ts-gateway', kit: 'gateway.openrouter.typescript' }), + expect.objectContaining({ slug: 'py-gateway', kit: 'gateway.openrouter.python' }), + ]) + ); + const model = await fsExtra.readJson( + path.join(workspacePath, '.workspai', 'reports', 'workspace-model.json') + ); + expect( + model.projects.find((project: { name?: string }) => project.name === 'ts-gateway') + ).toMatchObject({ + kind: 'gateway', + category: 'gateway', + kit: 'gateway.openrouter.typescript', + }); + expect(model.identity.categories).toEqual(expect.arrayContaining(['gateway'])); + }, 90_000); + + it('supports dry-run, JSON, duplicate, and invalid-name contracts for gateway create', async () => { + await create.createProject('gateway-flags', { + parentDirectory: tmpDir, + profile: 'minimal', + skipPythonEngine: true, + skipGit: true, + yes: true, + }); + const workspacePath = path.join(tmpDir, 'gateway-flags'); + process.chdir(workspacePath); + const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + const dryRun = await index.handleCreateOrFallback([ + 'create', + 'project', + 'gateway.openrouter.typescript', + 'preview-gateway', + '--dry-run', + '--json', + ]); + expect(dryRun).toBe(0); + expect(await fsExtra.pathExists(path.join(workspacePath, 'preview-gateway'))).toBe(false); + const jsonText = stdoutSpy.mock.calls.map((call) => String(call[0])).join('\n'); + expect(jsonText).toContain('"dryRun": true'); + expect(jsonText).toContain('gateway.openrouter.typescript'); + + const invalid = await index.handleCreateOrFallback([ + 'create', + 'project', + 'gateway.openrouter.python', + 'Not Valid', + '--skip-git', + ]); + expect(invalid).toBe(1); + + const created = await index.handleCreateOrFallback([ + 'create', + 'project', + 'gateway.openrouter.python', + 'once-gateway', + '--skip-git', + '--yes', + ]); + expect(created).toBe(0); + const duplicate = await index.handleCreateOrFallback([ + 'create', + 'project', + 'gateway.openrouter.python', + 'once-gateway', + '--skip-git', + '--yes', + ]); + expect(duplicate).toBe(1); + const stderr = stderrSpy.mock.calls.map((call) => String(call[0])).join(''); + expect(stderr).toMatch(/already exists|invalid|must/i); + }, 90_000); + + it('creates a gateway project under a path that contains spaces', async () => { + await create.createProject('gateway-spaces', { + parentDirectory: tmpDir, + profile: 'minimal', + skipPythonEngine: true, + skipGit: true, + yes: true, + }); + const workspacePath = path.join(tmpDir, 'gateway-spaces'); + const outputDir = path.join(workspacePath, 'nested kits'); + await fsExtra.ensureDir(outputDir); + process.chdir(workspacePath); + const code = await index.handleCreateOrFallback([ + 'create', + 'project', + 'gateway.openrouter.typescript', + 'space-gateway', + '--output', + outputDir, + '--skip-git', + '--yes', + ]); + expect(code).toBe(0); + expect(await fsExtra.pathExists(path.join(outputDir, 'space-gateway', 'package.json'))).toBe( + true + ); + }, 90_000); + it('maps explicit --skip-install to the core scaffold-only contract', async () => { vi.spyOn(coreExec, 'resolveRapidkitPython').mockResolvedValue(); const runSpy = vi.spyOn(coreExec, 'runCoreRapidkit').mockResolvedValue(1 as any); diff --git a/packages/cli/src/__tests__/kit-picker-choices.test.ts b/packages/cli/src/__tests__/kit-picker-choices.test.ts index 92e709ec..34af5ede 100644 --- a/packages/cli/src/__tests__/kit-picker-choices.test.ts +++ b/packages/cli/src/__tests__/kit-picker-choices.test.ts @@ -66,6 +66,14 @@ describe('kit picker choices', () => { value: 'agent.openai.typescript', label: 'AI Agent · OpenAI Agents SDK · TypeScript', }), + expect.objectContaining({ + value: 'gateway.openrouter.typescript', + label: 'AI Gateway · OpenRouter · TypeScript', + }), + expect.objectContaining({ + value: 'gateway.openrouter.python', + label: 'AI Gateway · OpenRouter · Python', + }), ]) ); }); @@ -76,6 +84,11 @@ describe('kit picker choices', () => { expect.objectContaining({ value: 'frontend', label: 'Frontend' }), expect.objectContaining({ value: 'desktop', label: 'Desktop' }), expect.objectContaining({ value: 'agent', label: 'AI Agent' }), + expect.objectContaining({ + value: 'gateway', + label: 'AI Gateway', + hint: 'Unified model access and routing', + }), expect.objectContaining({ value: 'extension', label: 'Extension' }), ]); }); @@ -100,6 +113,11 @@ describe('kit picker choices', () => { expect(buildKitPickerChoices('agent').every((choice) => choice.category === 'agent')).toBe( true ); + expect(buildKitPickerChoices('gateway')).toHaveLength(2); + expect(buildKitPickerChoices('gateway').map((choice) => choice.value)).toEqual([ + 'gateway.openrouter.python', + 'gateway.openrouter.typescript', + ]); expect(buildKitPickerChoices('gaming')).toEqual([]); }); }); diff --git a/packages/cli/src/__tests__/model-gateway-generated-projects.test.ts b/packages/cli/src/__tests__/model-gateway-generated-projects.test.ts new file mode 100644 index 00000000..f5d7dee1 --- /dev/null +++ b/packages/cli/src/__tests__/model-gateway-generated-projects.test.ts @@ -0,0 +1,242 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import fsExtra from 'fs-extra'; + +import * as create from '../create.js'; +import * as index from '../index.js'; +import { + generateModelGatewayProject, + listModelGatewayProjectKits, +} from '../model-gateways/project-kits.js'; +import { getDefaultPythonCommand } from '../utils/platform-capabilities.js'; +import { buildCleanGitEnv } from '../utils/git-worktree.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fsExtra.remove(root))); +}); + +function isolatedRegistryEnv(cacheRoot: string): NodeJS.ProcessEnv { + const env = { ...buildCleanGitEnv(process.env), CI: '1' }; + delete env.OPENROUTER_API_KEY; + delete env.OPENROUTER_MODEL; + env.npm_config_cache = path.join(cacheRoot, 'npm'); + env.NPM_CONFIG_CACHE = path.join(cacheRoot, 'npm'); + env.PIP_CACHE_DIR = path.join(cacheRoot, 'pip'); + env.PIP_DISABLE_PIP_VERSION_CHECK = '1'; + return env; +} + +function run( + command: string, + args: string[], + cwd: string, + timeoutMs = 120_000, + cacheRoot?: string +): string { + const env = cacheRoot + ? isolatedRegistryEnv(cacheRoot) + : { ...buildCleanGitEnv(process.env), CI: '1' }; + delete env.OPENROUTER_API_KEY; + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + env, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: timeoutMs, + }); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + if (result.error) { + throw new Error(`${command} ${args.join(' ')} failed (${String(result.error)})\n${output}`); + } + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed (${result.status})\n${output}`); + } + return output; +} + +function runExpectFailure( + command: string, + args: string[], + cwd: string, + timeoutMs = 30_000, + cacheRoot?: string +): string { + const env = cacheRoot + ? isolatedRegistryEnv(cacheRoot) + : { ...buildCleanGitEnv(process.env), CI: '1' }; + delete env.OPENROUTER_API_KEY; + delete env.OPENROUTER_MODEL; + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + env, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: timeoutMs, + }); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + if (result.error && (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { + throw new Error(`${command} ${args.join(' ')} hung after ${timeoutMs}ms\n${output}`); + } + if (result.status === 0) { + throw new Error(`${command} ${args.join(' ')} unexpectedly succeeded\n${output}`); + } + return output; +} + +function npmCommand(): { command: string; prefix: string[] } { + const npmExecPath = process.env.npm_execpath; + if (npmExecPath && fs.existsSync(npmExecPath)) { + return { command: process.execPath, prefix: [npmExecPath] }; + } + return { command: 'npm', prefix: [] }; +} + +describe('generated OpenRouter gateway projects', () => { + it('installs and verifies the TypeScript starter without a live API key', async () => { + const kit = listModelGatewayProjectKits().find( + (entry) => entry.id === 'gateway.openrouter.typescript' + ); + if (!kit) throw new Error('missing TypeScript gateway kit'); + const root = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-or-ts-')); + const cacheRoot = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-or-npm-')); + roots.push(root, cacheRoot); + await generateModelGatewayProject({ + projectPath: root, + projectName: 'openrouter-ts-gateway', + kit, + }); + const npm = npmCommand(); + run(npm.command, [...npm.prefix, 'install'], root, 120_000, cacheRoot); + run(npm.command, [...npm.prefix, 'run', 'typecheck'], root, 120_000, cacheRoot); + run(npm.command, [...npm.prefix, 'run', 'build'], root, 120_000, cacheRoot); + const testOutput = run(npm.command, [...npm.prefix, 'test'], root, 120_000, cacheRoot); + expect(testOutput.toLowerCase()).not.toContain('sk-or-live'); + const smoke = run(npm.command, [...npm.prefix, 'run', 'smoke'], root, 120_000, cacheRoot); + expect(smoke).toMatch(/smoke-ok|smoke/); + const startFailure = runExpectFailure( + npm.command, + [...npm.prefix, 'start'], + root, + 60_000, + cacheRoot + ); + expect(startFailure).toMatch(/OPENROUTER_API_KEY|OPENROUTER_MODEL/); + expect(startFailure.toLowerCase()).not.toContain('sk-or-live'); + }, 300_000); + + it('installs and verifies the Python starter without a live API key', async () => { + const kit = listModelGatewayProjectKits().find( + (entry) => entry.id === 'gateway.openrouter.python' + ); + if (!kit) throw new Error('missing Python gateway kit'); + const root = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-or-py-')); + const cacheRoot = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-or-pip-')); + roots.push(root, cacheRoot); + await generateModelGatewayProject({ + projectPath: root, + projectName: 'openrouter-py-gateway', + kit, + }); + const python = getDefaultPythonCommand(); + const venvPython = + process.platform === 'win32' + ? path.join(root, '.venv', 'Scripts', 'python.exe') + : path.join(root, '.venv', 'bin', 'python'); + run(python, ['-m', 'venv', '.venv'], root, 120_000, cacheRoot); + run(venvPython, ['-m', 'pip', 'install', '-e', '.'], root, 180_000, cacheRoot); + run(venvPython, ['-m', 'compileall', '.'], root, 60_000, cacheRoot); + run(venvPython, ['-m', 'unittest', 'discover', '-s', 'tests'], root, 120_000, cacheRoot); + const smoke = run(venvPython, ['main.py', '--smoke'], root, 60_000, cacheRoot); + expect(smoke).toMatch(/smoke-ok|smoke/); + const startFailure = runExpectFailure(venvPython, ['main.py'], root, 30_000, cacheRoot); + expect(startFailure).toMatch(/OPENROUTER_API_KEY|OPENROUTER_MODEL/); + const startBin = + process.platform === 'win32' + ? path.join(root, '.venv', 'Scripts', 'start.exe') + : path.join(root, '.venv', 'bin', 'start'); + const otherCwd = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-or-other-')); + roots.push(otherCwd); + const consoleSmoke = run(startBin, ['--smoke'], otherCwd, 60_000, cacheRoot); + expect(consoleSmoke).toMatch(/smoke-ok|smoke/); + }, 240_000); + + it('keeps earlier gateway projects valid after creating the next kit in one workspace', async () => { + const parent = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-or-ws-')); + roots.push(parent); + await create.createProject('gateway-e2e', { + parentDirectory: parent, + profile: 'minimal', + skipPythonEngine: true, + skipGit: true, + yes: true, + }); + const workspacePath = path.join(parent, 'gateway-e2e'); + const previousCwd = process.cwd(); + process.chdir(workspacePath); + try { + expect( + await index.handleCreateOrFallback([ + 'create', + 'project', + 'gateway.openrouter.typescript', + 'first-gateway', + '--skip-git', + '--yes', + ]) + ).toBe(0); + expect( + await index.handleCreateOrFallback([ + 'create', + 'project', + 'gateway.openrouter.python', + 'second-gateway', + '--skip-git', + '--yes', + ]) + ).toBe(0); + expect( + await fsExtra.pathExists(path.join(workspacePath, 'first-gateway', 'package.json')) + ).toBe(true); + expect( + await fsExtra.pathExists(path.join(workspacePath, 'second-gateway', 'pyproject.toml')) + ).toBe(true); + const model = await fsExtra.readJson( + path.join(workspacePath, '.workspai', 'reports', 'workspace-model.json') + ); + expect(model.projects.map((project: { name?: string }) => project.name).sort()).toEqual([ + 'first-gateway', + 'second-gateway', + ]); + expect(model.projects.every((project: { kind?: string }) => project.kind === 'gateway')).toBe( + true + ); + } finally { + process.chdir(previousCwd); + } + }, 90_000); + + it('creates and verifies a TypeScript gateway in a path that contains spaces', async () => { + const kit = listModelGatewayProjectKits().find( + (entry) => entry.id === 'gateway.openrouter.typescript' + ); + if (!kit) throw new Error('missing TypeScript gateway kit'); + const parent = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai or ts ')); + const cacheRoot = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-or-npm-space-')); + roots.push(parent, cacheRoot); + const root = path.join(parent, 'open router gateway'); + await generateModelGatewayProject({ + projectPath: root, + projectName: 'openrouter-ts-gateway', + kit, + }); + const npm = npmCommand(); + run(npm.command, [...npm.prefix, 'install'], root, 120_000, cacheRoot); + const smoke = run(npm.command, [...npm.prefix, 'run', 'smoke'], root, 120_000, cacheRoot); + expect(smoke).toMatch(/smoke-ok|smoke/); + }, 180_000); +}); diff --git a/packages/cli/src/__tests__/model-gateway-policy-parity.test.ts b/packages/cli/src/__tests__/model-gateway-policy-parity.test.ts new file mode 100644 index 00000000..03109e37 --- /dev/null +++ b/packages/cli/src/__tests__/model-gateway-policy-parity.test.ts @@ -0,0 +1,151 @@ +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import fsExtra from 'fs-extra'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + generateModelGatewayProject, + listModelGatewayProjectKits, +} from '../model-gateways/project-kits.js'; +import { + OPENROUTER_OVERFLOW_PRICE, + OPENROUTER_POLICY_PARITY_CASES, +} from '../model-gateways/adapters/openrouter/policy-parity.js'; +import { getDefaultPythonCommand } from '../utils/platform-capabilities.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fsExtra.remove(root))); +}); + +describe('OpenRouter policy parity corpus', () => { + it('exports the required accept/reject ids', () => { + const ids = OPENROUTER_POLICY_PARITY_CASES.map((entry) => entry.id); + expect(ids).toEqual( + expect.arrayContaining([ + 'unknown-root-field', + 'unknown-provider-field', + 'unknown-max-price-key', + 'unknown-percentile-key', + 'unknown-sort-field', + 'require-parameters-number', + 'zdr-number', + 'empty-model-name', + 'whitespace-provider-name', + 'duplicate-models', + 'duplicate-order', + 'only-ignore-overlap', + 'ignored-provider-in-order', + 'unsupported-quantization', + 'invalid-sort', + 'invalid-sort-partition', + 'zdr-with-data-collection-allow', + 'timeout-zero', + 'timeout-one', + 'timeout-max', + 'timeout-over-max', + 'max-price-number', + 'max-price-negative', + 'max-price-malformed', + 'max-price-overflow', + 'max-price-zero', + 'max-price-micro', + 'max-price-positive', + 'valid-minimal-policy', + 'valid-complete-policy', + ]) + ); + const overflow = OPENROUTER_POLICY_PARITY_CASES.find( + (entry) => entry.id === 'max-price-overflow' + ); + expect(JSON.stringify(overflow)).toContain(OPENROUTER_OVERFLOW_PRICE); + expect(OPENROUTER_OVERFLOW_PRICE).toHaveLength(400); + }); + + it('executes the generated Python validator for the shared corpus', async () => { + const kit = listModelGatewayProjectKits().find( + (entry) => entry.id === 'gateway.openrouter.python' + ); + if (!kit) throw new Error('missing Python gateway kit'); + const root = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-or-parity-')); + roots.push(root); + await generateModelGatewayProject({ + projectPath: root, + projectName: 'parity-gateway', + kit, + }); + await fsExtra.writeJson(path.join(root, 'parity-cases.json'), OPENROUTER_POLICY_PARITY_CASES); + const python = getDefaultPythonCommand(); + const script = [ + 'import importlib.util, json, os, sys, types', + 'from pathlib import Path', + 'root = Path(sys.argv[1])', + 'package_dir = root / "src" / "model_gateway"', + 'pkg = types.ModuleType("model_gateway")', + 'pkg.__path__ = [str(package_dir)]', + 'sys.modules["model_gateway"] = pkg', + '', + 'def load_generated(name, filename):', + ' spec = importlib.util.spec_from_file_location(name, package_dir / filename)', + ' module = importlib.util.module_from_spec(spec)', + ' sys.modules[name] = module', + ' spec.loader.exec_module(module)', + ' return module', + '', + 'os.environ["OPENROUTER_API_KEY"] = "sk-or-test"', + 'os.environ["OPENROUTER_MODEL"] = "openrouter/test-model"', + 'load_generated("model_gateway.redact", "redact.py")', + 'errors = load_generated("model_gateway.errors", "errors.py")', + 'config = load_generated("model_gateway.config", "config.py")', + 'load_gateway_config = config.load_gateway_config', + 'to_sdk_provider = config.to_sdk_provider', + 'GatewayConfigurationError = errors.GatewayConfigurationError', + 'cases = json.loads((root / "parity-cases.json").read_text(encoding="utf-8"))', + 'failed = []', + 'for case in cases:', + ' try:', + ' if case["decision"] == "reject":', + ' try:', + ' load_gateway_config(policy=case["python"])', + ' except GatewayConfigurationError as error:', + ' needle = case.get("needle")', + ' if needle and needle not in str(error):', + ' failed.append(case["id"] + ": missing needle")', + ' else:', + ' failed.append(case["id"] + ": expected reject")', + ' else:', + ' config = load_gateway_config(policy=case["python"])', + ' mapped = to_sdk_provider(config.provider) or {}', + ' if case["id"] == "max-price-zero" and mapped["max_price"]["prompt"] != "0":', + ' failed.append("max-price-zero: string not preserved")', + ' if case["id"] == "max-price-micro" and mapped["max_price"]["prompt"] != "0.000001":', + ' failed.append("max-price-micro: string not preserved")', + ' except Exception as error:', + ' failed.append(case["id"] + ": " + type(error).__name__)', + 'try:', + ' load_gateway_config(policy={"provider": {"max_price": {"prompt": "9" * 400}}})', + 'except GatewayConfigurationError:', + ' pass', + 'else:', + ' failed.append("overflow-direct: expected reject")', + 'print("parity-ok" if not failed else "parity-failed:" + ",".join(failed))', + 'raise SystemExit(0 if not failed else 1)', + ].join('\n'); + const env = { ...process.env, CI: '1' }; + delete env.OPENROUTER_API_KEY; + delete env.OPENROUTER_MODEL; + const result = spawnSync(python, ['-c', script, root], { + cwd: os.tmpdir(), + encoding: 'utf8', + env, + timeout: 30_000, + }); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + expect(output, output).toContain('parity-ok'); + expect(result.status).toBe(0); + expect(output.toLowerCase()).not.toContain('sk-or-live'); + }); +}); diff --git a/packages/cli/src/__tests__/model-gateway-project-kits.test.ts b/packages/cli/src/__tests__/model-gateway-project-kits.test.ts new file mode 100644 index 00000000..9111f75e --- /dev/null +++ b/packages/cli/src/__tests__/model-gateway-project-kits.test.ts @@ -0,0 +1,188 @@ +import os from 'node:os'; +import path from 'node:path'; +import fsExtra from 'fs-extra'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + describeModelGatewayProjectKits, + generateModelGatewayProject, + isModelGatewayProjectKit, + listModelGatewayProjectKits, + lookupModelGatewayProjectKit, +} from '../model-gateways/project-kits.js'; +import { + BUILTIN_MODEL_GATEWAY_VERSION_BASELINES, + EXCLUDED_MODEL_GATEWAY_LANGUAGES, + MODEL_GATEWAY_BASELINES_REVIEWED_AT, + packageVersion, +} from '../model-gateways/version-policy.js'; +import { openRouterTypeScriptSdkVersion } from '../model-gateways/adapters/openrouter/typescript.js'; +import { openRouterPythonSdkVersion } from '../model-gateways/adapters/openrouter/python.js'; +import { buildPolyglotLifecyclePlan } from '../polyglot-lifecycle-plan.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fsExtra.remove(root))); +}); + +describe('model gateway project kits', () => { + it('publishes only stable OpenRouter Client SDK kits', () => { + expect(describeModelGatewayProjectKits().map((kit) => kit.id)).toEqual([ + 'gateway.openrouter.typescript', + 'gateway.openrouter.python', + ]); + expect(listModelGatewayProjectKits().map((kit) => kit.id)).toEqual([ + 'gateway.openrouter.typescript', + 'gateway.openrouter.python', + ]); + expect(isModelGatewayProjectKit('gateway.openrouter.typescript')).toBe(true); + expect(isModelGatewayProjectKit('openrouter.python')).toBe(true); + expect(isModelGatewayProjectKit('gateway.openrouter.ts')).toBe(true); + expect(isModelGatewayProjectKit('openrouter')).toBe(false); + expect(isModelGatewayProjectKit('agent.openrouter.typescript')).toBe(false); + expect(lookupModelGatewayProjectKit('openrouter-typescript')?.id).toBe( + 'gateway.openrouter.typescript' + ); + const first = lookupModelGatewayProjectKit('gateway.openrouter.python'); + expect(first?.adapterId).toBe('openrouter-python'); + if (!first) throw new Error('Expected Python gateway kit.'); + first.aliases.length = 0; + expect( + lookupModelGatewayProjectKit('gateway.openrouter.python')?.aliases.length + ).toBeGreaterThan(0); + }); + + it('pins SDK versions from the reviewed baseline document', () => { + expect(MODEL_GATEWAY_BASELINES_REVIEWED_AT).toBe('2026-09-21'); + const typescript = BUILTIN_MODEL_GATEWAY_VERSION_BASELINES.find( + (entry) => entry.adapterId === 'openrouter-typescript' + ); + const python = BUILTIN_MODEL_GATEWAY_VERSION_BASELINES.find( + (entry) => entry.adapterId === 'openrouter-python' + ); + expect(typescript?.sdkVersion).toBe('1.3.11'); + expect(python?.sdkVersion).toBe('1.2.11'); + expect(openRouterTypeScriptSdkVersion()).toBe(packageVersion(typescript!, '@openrouter/sdk')); + expect(openRouterPythonSdkVersion()).toBe(packageVersion(python!, 'openrouter')); + expect(EXCLUDED_MODEL_GATEWAY_LANGUAGES).toEqual([ + expect.objectContaining({ + language: 'go', + latestObservedVersion: 'v0.8.11', + declaredMaturity: 'beta', + }), + ]); + }); + + it('emits deterministic TypeScript and Python gateway projects without secrets', async () => { + for (const kit of listModelGatewayProjectKits()) { + const first = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-gateway-a-')); + const second = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-gateway-b-')); + roots.push(first, second); + const generatedAt = '2026-09-21T12:00:00.000Z'; + await generateModelGatewayProject({ + projectPath: first, + projectName: 'route-gateway', + kit, + generatedAt, + }); + await generateModelGatewayProject({ + projectPath: second, + projectName: 'route-gateway', + kit, + generatedAt, + }); + const left = await collectGeneratedText(first); + const right = await collectGeneratedText(second); + expect(left).toEqual(right); + expect(await fsExtra.readJson(path.join(first, '.workspai', 'project.json'))).toMatchObject({ + kind: 'gateway', + category: 'gateway', + kit: kit.id, + framework: 'openrouter', + }); + for (const relativePath of [ + 'README.md', + '.env.example', + 'gateway.policy.json', + '.workspai/project.json', + ]) { + const contents = await fsExtra.readFile(path.join(first, relativePath), 'utf8'); + expect(contents).not.toMatch(/sk-or-[A-Za-z0-9]{8,}/); + expect(contents).not.toMatch(/OPENROUTER_API_KEY=.+/); + } + const envExample = await fsExtra.readFile(path.join(first, '.env.example'), 'utf8'); + expect(envExample).toContain('OPENROUTER_API_KEY='); + expect(envExample).toContain('OPENROUTER_MODEL='); + if (kit.runtime === 'node') { + const packageJson = await fsExtra.readJson(path.join(first, 'package.json')); + expect(packageJson.dependencies['@openrouter/sdk']).toBe('1.3.11'); + expect(packageJson.devDependencies.typescript).toBe('5.9.3'); + expect(packageJson.devDependencies['@types/node']).toBe('22.20.3'); + expect(JSON.stringify(packageJson)).not.toMatch(/\^|~|latest|\*/); + const policy = await fsExtra.readFile(path.join(first, 'gateway.policy.json'), 'utf8'); + expect(policy).toContain('enforceDistillableText'); + const config = await fsExtra.readFile(path.join(first, 'src', 'config.ts'), 'utf8'); + expect(config).toContain('unknown field'); + expect(config).toContain('partition'); + const gateway = await fsExtra.readFile( + path.join(first, 'src', 'openrouter-gateway.ts'), + 'utf8' + ); + expect(gateway).toContain('toGatewayUpstreamError'); + expect(gateway).toContain('releaseOpenedStream'); + const plan = buildPolyglotLifecyclePlan(first); + expect(plan.units[0]?.stages.map((stage) => stage.stage)).toEqual( + expect.arrayContaining(['init', 'test', 'build', 'start']) + ); + } else { + const pyproject = await fsExtra.readFile(path.join(first, 'pyproject.toml'), 'utf8'); + expect(pyproject).toContain('openrouter==1.2.11'); + expect(pyproject).not.toContain('openrouter>='); + const policy = await fsExtra.readFile(path.join(first, 'gateway.policy.json'), 'utf8'); + expect(policy).toContain('enforce_distillable_text'); + const config = await fsExtra.readFile( + path.join(first, 'src', 'model_gateway', 'config.py'), + 'utf8' + ); + expect(config).toContain('default_policy_root'); + expect(config).not.toContain('root or Path.cwd()'); + const gateway = await fsExtra.readFile( + path.join(first, 'src', 'model_gateway', 'gateway.py'), + 'utf8' + ); + expect(gateway).toContain('to_gateway_upstream_error'); + expect(gateway).toContain('_release_stream'); + const plan = buildPolyglotLifecyclePlan(first); + expect(plan.units[0]?.stages.find((stage) => stage.stage === 'test')?.command).toContain( + 'unittest' + ); + expect(plan.units[0]?.stages.find((stage) => stage.stage === 'build')?.command).toContain( + 'compileall' + ); + expect(plan.units[0]?.stages.find((stage) => stage.stage === 'start')?.command).toContain( + 'main.py' + ); + } + } + }); +}); + +async function collectGeneratedText(root: string): Promise { + const files: string[] = []; + const visit = async (directory: string): Promise => { + const entries = await fsExtra.readdir(directory, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) { + await visit(target); + continue; + } + const relative = path.relative(root, target).split(path.sep).join('/'); + const contents = await fsExtra.readFile(target, 'utf8'); + files.push(`${relative}\n${contents}`); + } + }; + await visit(root); + return files; +} diff --git a/packages/cli/src/__tests__/model-gateway-workspace-lifecycle.test.ts b/packages/cli/src/__tests__/model-gateway-workspace-lifecycle.test.ts new file mode 100644 index 00000000..f526af0e --- /dev/null +++ b/packages/cli/src/__tests__/model-gateway-workspace-lifecycle.test.ts @@ -0,0 +1,318 @@ +import os from 'node:os'; +import path from 'node:path'; + +import fsExtra from 'fs-extra'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as create from '../create.js'; +import * as index from '../index.js'; +import { + buildWorkspaceModelSnapshot, + writeWorkspaceModelSnapshot, +} from '../workspace-intelligence.js'; +import { buildWorkspaceModel, writeWorkspaceModel } from '../workspace-model.js'; +import { runWorkspaceStage } from '../workspace-run.js'; +import { buildWorkspaceVerify } from '../workspace-verify.js'; + +const roots: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(roots.splice(0).map((root) => fsExtra.remove(root))); +}); + +function looksLikeRegistryFailure(text: string): boolean { + return /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|401 Unauthorized|403 Forbidden|404 Not Found|EAI_AGAIN|npm ERR!|Could not find a version|Failed to establish|No matching distribution|HTTPError|read ECONNRESET/i.test( + text + ); +} + +describe('OpenRouter gateway Workspai lifecycle', () => { + it('creates both kits through the production CLI and exercises workspace run/verify', async () => { + const parent = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai or gateway ')); + roots.push(parent); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await create.createProject('gateway-lifecycle', { + parentDirectory: parent, + profile: 'minimal', + skipPythonEngine: true, + skipGit: true, + yes: true, + }); + const workspacePath = path.join(parent, 'gateway-lifecycle'); + const emptyModel = await buildWorkspaceModel({ workspacePath }); + await writeWorkspaceModel(emptyModel, workspacePath); + await writeWorkspaceModelSnapshot( + await buildWorkspaceModelSnapshot({ + workspacePath, + model: emptyModel, + }), + workspacePath + ); + + const previousCwd = process.cwd(); + process.chdir(workspacePath); + try { + expect( + await index.handleCreateOrFallback([ + 'create', + 'project', + 'gateway.openrouter.typescript', + 'first-gateway', + '--skip-git', + '--yes', + ]) + ).toBe(0); + expect( + await fsExtra.pathExists(path.join(workspacePath, 'first-gateway', 'node_modules')) + ).toBe(false); + + const firstProject = { + packageJson: await fsExtra.readFile( + path.join(workspacePath, 'first-gateway', 'package.json'), + 'utf8' + ), + policy: await fsExtra.readFile( + path.join(workspacePath, 'first-gateway', 'gateway.policy.json'), + 'utf8' + ), + projectJson: await fsExtra.readFile( + path.join(workspacePath, 'first-gateway', '.workspai', 'project.json'), + 'utf8' + ), + }; + const firstModel = await fsExtra.readJson( + path.join(workspacePath, '.workspai', 'reports', 'workspace-model.json') + ); + const firstContract = await fsExtra.readJson( + path.join(workspacePath, '.workspai', 'workspace.contract.json') + ); + const goalsPath = path.join(workspacePath, '.workspai', 'goals', 'index.json'); + const firstGoals = (await fsExtra.pathExists(goalsPath)) + ? await fsExtra.readJson(goalsPath) + : null; + const graphPath = path.join(workspacePath, '.workspai', 'reports', 'workspace-graph.json'); + const firstGraph = (await fsExtra.pathExists(graphPath)) + ? await fsExtra.readJson(graphPath) + : null; + + expect( + await index.handleCreateOrFallback([ + 'create', + 'project', + 'gateway.openrouter.python', + 'second-gateway', + '--skip-git', + '--yes', + ]) + ).toBe(0); + expect(await fsExtra.pathExists(path.join(workspacePath, 'second-gateway', '.venv'))).toBe( + false + ); + + expect( + await fsExtra.readFile(path.join(workspacePath, 'first-gateway', 'package.json'), 'utf8') + ).toBe(firstProject.packageJson); + expect( + await fsExtra.readFile( + path.join(workspacePath, 'first-gateway', 'gateway.policy.json'), + 'utf8' + ) + ).toBe(firstProject.policy); + expect( + await fsExtra.readFile( + path.join(workspacePath, 'first-gateway', '.workspai', 'project.json'), + 'utf8' + ) + ).toBe(firstProject.projectJson); + + const model = await fsExtra.readJson( + path.join(workspacePath, '.workspai', 'reports', 'workspace-model.json') + ); + expect(model.projects.map((project: { name?: string }) => project.name).sort()).toEqual([ + 'first-gateway', + 'second-gateway', + ]); + expect(firstModel.projects.map((project: { name?: string }) => project.name)).toEqual([ + 'first-gateway', + ]); + const typescriptProject = model.projects.find( + (project: { name?: string }) => project.name === 'first-gateway' + ); + const pythonProject = model.projects.find( + (project: { name?: string }) => project.name === 'second-gateway' + ); + expect(typescriptProject).toMatchObject({ + kind: 'gateway', + category: 'gateway', + kit: 'gateway.openrouter.typescript', + runtime: 'node', + }); + expect(pythonProject).toMatchObject({ + kind: 'gateway', + category: 'gateway', + kit: 'gateway.openrouter.python', + runtime: 'python', + }); + const typescriptRecord = await fsExtra.readJson( + path.join(workspacePath, 'first-gateway', '.workspai', 'project.json') + ); + const pythonRecord = await fsExtra.readJson( + path.join(workspacePath, 'second-gateway', '.workspai', 'project.json') + ); + expect(typescriptRecord).toMatchObject({ + kind: 'gateway', + category: 'gateway', + framework: 'openrouter', + kit: 'gateway.openrouter.typescript', + }); + expect(pythonRecord).toMatchObject({ + kind: 'gateway', + category: 'gateway', + framework: 'openrouter', + kit: 'gateway.openrouter.python', + }); + expect(typescriptProject.commands.fleetStages).toEqual( + expect.arrayContaining(['init', 'test', 'build', 'start']) + ); + expect(pythonProject.commands.fleetStages).toEqual( + expect.arrayContaining(['init', 'test', 'build', 'start']) + ); + + const contract = await fsExtra.readJson( + path.join(workspacePath, '.workspai', 'workspace.contract.json') + ); + expect(contract.workspace).toEqual(firstContract.workspace); + expect(contract.projects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ slug: 'first-gateway', kit: 'gateway.openrouter.typescript' }), + expect.objectContaining({ slug: 'second-gateway', kit: 'gateway.openrouter.python' }), + ]) + ); + if (firstGoals) { + expect(await fsExtra.pathExists(goalsPath)).toBe(true); + } + if (firstGraph) { + expect(await fsExtra.pathExists(graphPath)).toBe(true); + } + + for (const [scope, kitId, expected] of [ + [ + 'project:first-gateway', + 'gateway.openrouter.typescript', + { + init: /rapidkit init|npm install/, + test: /rapidkit test|npm (?:run )?test/, + build: /rapidkit build|npm run build/, + start: /rapidkit start|npm (?:run )?start/, + }, + ], + [ + 'project:second-gateway', + 'gateway.openrouter.python', + { + init: /rapidkit init|pip install|python/, + test: /rapidkit test|unittest/, + build: /rapidkit build|compileall/, + start: /rapidkit start|main\.py/, + }, + ], + ] as const) { + for (const stage of ['init', 'test', 'build', 'start'] as const) { + const planned = await runWorkspaceStage({ + workspacePath, + stage, + scope, + planOnly: true, + json: true, + enforceGates: false, + }); + expect(planned.summary.selectedCount).toBe(1); + const row = planned.projects.find((project) => project.selected); + expect(row?.projectName).toBe(scope.replace('project:', '')); + const command = + row?.runtimeExecutions?.map((execution) => execution.command).join(' ') ?? + row?.executionCommand ?? + ''; + expect(command, `${kitId} ${stage}`).toMatch(expected[stage]); + } + } + + const verify = await buildWorkspaceVerify({ workspacePath }); + expect(verify.steps.some((step) => step.id === 'workspace.doctor')).toBe(true); + expect(verify.steps.map((step) => step.id).join('\n')).not.toMatch( + /unknown kind|unsupported kind/i + ); + const verifiedProjects = new Set( + verify.steps.filter((step) => step.scope === 'project').map((step) => step.project) + ); + expect([...verifiedProjects]).toEqual( + expect.arrayContaining(['first-gateway', 'second-gateway']) + ); + + const previousKey = process.env.OPENROUTER_API_KEY; + const previousModel = process.env.OPENROUTER_MODEL; + delete process.env.OPENROUTER_API_KEY; + delete process.env.OPENROUTER_MODEL; + try { + const initReport = await runWorkspaceStage({ + workspacePath, + stage: 'init', + json: true, + enforceGates: false, + }); + const initText = JSON.stringify(initReport); + if (looksLikeRegistryFailure(initText)) { + return; + } + const initFailures = initReport.projects + .filter((project) => project.status === 'failed') + .map( + (project) => + `${project.projectName}: ${project.reason ?? ''} ${project.errorMessage ?? ''} ${project.failureDiagnostic?.outputExcerpt ?? ''}` + ) + .join('\n'); + expect(initReport.summary.failed, initFailures).toBe(0); + + const testReport = await runWorkspaceStage({ + workspacePath, + stage: 'test', + json: true, + enforceGates: false, + }); + const testText = JSON.stringify(testReport); + expect(testText.toLowerCase()).not.toContain('sk-or-live'); + const testFailures = testReport.projects + .filter((project) => project.status === 'failed') + .map( + (project) => + `${project.projectName}: ${project.reason ?? ''} ${project.errorMessage ?? ''} ${project.failureDiagnostic?.outputExcerpt ?? ''} ${project.runtimeExecutions + ?.map((execution) => execution.command) + .join(' | ')}` + ) + .join('\n'); + if (!looksLikeRegistryFailure(`${testText}\n${testFailures}`)) { + expect(testReport.summary.failed, testFailures).toBe(0); + } + + const startReport = await runWorkspaceStage({ + workspacePath, + stage: 'start', + json: true, + enforceGates: false, + }); + expect(startReport.summary.failed).toBeGreaterThan(0); + const startText = JSON.stringify(startReport); + expect(startText).toMatch(/OPENROUTER_API_KEY|OPENROUTER_MODEL/); + expect(startText.toLowerCase()).not.toContain('sk-or-live'); + } finally { + if (previousKey !== undefined) process.env.OPENROUTER_API_KEY = previousKey; + if (previousModel !== undefined) process.env.OPENROUTER_MODEL = previousModel; + } + } finally { + process.chdir(previousCwd); + } + }, 600_000); +}); diff --git a/packages/cli/src/__tests__/project-taxonomy-and-new-kits.test.ts b/packages/cli/src/__tests__/project-taxonomy-and-new-kits.test.ts index 833dbbb5..3333e38e 100644 --- a/packages/cli/src/__tests__/project-taxonomy-and-new-kits.test.ts +++ b/packages/cli/src/__tests__/project-taxonomy-and-new-kits.test.ts @@ -37,6 +37,8 @@ describe('project taxonomy and expanded kit families', () => { expect(categorizeWorkspaceProjectKind('frontend')).toBe('frontend'); expect(categorizeWorkspaceProjectKind('desktop')).toBe('desktop'); expect(categorizeWorkspaceProjectKind('extension')).toBe('extension'); + expect(categorizeWorkspaceProjectKind('agent')).toBe('agent'); + expect(categorizeWorkspaceProjectKind('gateway')).toBe('gateway'); expect(categorizeWorkspaceProjectKind('platform')).toBe('platform'); expect(categorizeWorkspaceProjectKind('service')).toBe('backend'); }); @@ -58,6 +60,20 @@ describe('project taxonomy and expanded kit families', () => { expect(await inferWorkspaceProjectKind(extension)).toBe('extension'); }); + it('classifies OpenRouter gateway metadata as gateway, not agent or backend', async () => { + const project = await tempRoot('workspai-gateway-kind-'); + expect( + await inferWorkspaceProjectKind( + project, + { kit: 'gateway.openrouter.typescript', framework: 'openrouter' }, + { kit: 'gateway.openrouter.typescript', framework: 'openrouter' } + ) + ).toBe('gateway'); + expect( + await inferWorkspaceProjectKind(project, { kind: 'gateway', framework: 'openrouter' }) + ).toBe('gateway'); + }); + it('does not classify a runtime-neutral package workspace as a backend service', async () => { const library = await tempRoot('workspai-polyglot-library-kind-'); await fsExtra.outputJson(path.join(library, 'package.json'), { diff --git a/packages/cli/src/__tests__/suggested-project-name.test.ts b/packages/cli/src/__tests__/suggested-project-name.test.ts index 0a99ff73..5470b6e8 100644 --- a/packages/cli/src/__tests__/suggested-project-name.test.ts +++ b/packages/cli/src/__tests__/suggested-project-name.test.ts @@ -8,7 +8,8 @@ describe('suggested-project-name', () => { expect(suggestProjectNameForKit('fastapi.standard')).toMatch(/^[a-z]+-api$/); expect(suggestProjectNameForKit('frontend.nextjs')).toMatch(/^[a-z]+-web$/); expect(suggestProjectNameForKit('springboot.standard')).toMatch(/^[a-z]+-service$/); - expect(suggestProjectNameForKit('frontend.astro')).toMatch(/^[a-z]+-site$/); + expect(suggestProjectNameForKit('gateway.openrouter.typescript')).toMatch(/^[a-z]+-gateway$/); + expect(suggestProjectNameForKit('gateway.openrouter.python')).toMatch(/^[a-z]+-gateway$/); }); }); diff --git a/packages/cli/src/cli-ui/kit-picker-choices.ts b/packages/cli/src/cli-ui/kit-picker-choices.ts index b91db46c..96953e00 100644 --- a/packages/cli/src/cli-ui/kit-picker-choices.ts +++ b/packages/cli/src/cli-ui/kit-picker-choices.ts @@ -6,12 +6,14 @@ import { describeAgentFrameworkProjectKits, isAdmittedAgentFrameworkProjectKit, } from '../agent-frameworks/project-kits.js'; +import { describeModelGatewayProjectKits } from '../model-gateways/project-kits.js'; export const CREATE_KIT_CATEGORY_IDS = [ 'backend', 'frontend', 'desktop', 'agent', + 'gateway', 'extension', 'gaming', ] as const; @@ -31,6 +33,7 @@ const CREATE_KIT_CATEGORIES: ReadonlyArray<{ { id: 'frontend', label: 'Frontend', hint: 'Web applications' }, { id: 'desktop', label: 'Desktop', hint: 'Native desktop applications' }, { id: 'agent', label: 'AI Agent', hint: 'Governed agent runtimes' }, + { id: 'gateway', label: 'AI Gateway', hint: 'Unified model access and routing' }, { id: 'extension', label: 'Extension', hint: 'Editor and platform extensions' }, { id: 'gaming', label: 'Gaming', hint: 'Game runtimes and tooling' }, ]; @@ -95,7 +98,21 @@ export function buildKitPickerChoices(category?: CreateKitCategoryId): Categoriz category: 'agent' as const, })); - const choices = [...registeredChoices, ...frontendChoices, ...officialChoices, ...agentChoices] + const gatewayChoices = describeModelGatewayProjectKits().map((kit) => ({ + value: kit.id, + label: `AI Gateway · ${kit.label}`, + hint: `${kit.runtime} · tested baseline`, + name: kit.label, + category: 'gateway' as const, + })); + + const choices = [ + ...registeredChoices, + ...frontendChoices, + ...officialChoices, + ...agentChoices, + ...gatewayChoices, + ] .filter((choice) => !category || choice.category === category) .sort((left, right) => { const leftCategory = CREATE_KIT_CATEGORY_IDS.indexOf(left.category); diff --git a/packages/cli/src/contracts/create-planner-capabilities-contract.ts b/packages/cli/src/contracts/create-planner-capabilities-contract.ts index 40046847..d22bdae6 100644 --- a/packages/cli/src/contracts/create-planner-capabilities-contract.ts +++ b/packages/cli/src/contracts/create-planner-capabilities-contract.ts @@ -6,6 +6,7 @@ import { } from '../utils/create-planner-capabilities.js'; import { listInteractiveKits } from '../utils/kit-registry.js'; import { listAgentFrameworkProjectKits } from '../agent-frameworks/project-kits.js'; +import { listModelGatewayProjectKits } from '../model-gateways/project-kits.js'; import { WORKSPACE_PROFILE_DEFINITIONS } from '../workspace-profile-compatibility.js'; export const CREATE_PLANNER_CAPABILITIES_SCHEMA_VERSION = 'rapidkit-create-planner-capabilities-v1'; @@ -94,6 +95,18 @@ export function buildCreatePlannerCapabilitiesContract(): CreatePlannerCapabilit moduleSupport: false, workspacePythonEngine: 'none' as const, })); + const gatewayNative = listModelGatewayProjectKits().map((kit) => ({ + id: kit.id, + runtime: kit.runtime, + framework: kit.gatewayId, + plannerFramework: kit.gatewayId, + category: 'gateway', + owner: 'workspai', + stability: 'stable', + versionPolicy: 'tested-baseline' as const, + moduleSupport: false, + workspacePythonEngine: 'none' as const, + })); const existingRuntimeSignals = ['php', 'ruby', 'rust', 'elixir', 'clojure', 'scala', 'kotlin']; return { @@ -115,7 +128,7 @@ export function buildCreatePlannerCapabilitiesContract(): CreatePlannerCapabilit 'The project enters Workspace Intelligence through import/adopt, not native create.', }, }, - nativeCreate: [...backendNative, ...agentNative], + nativeCreate: [...backendNative, ...agentNative, ...gatewayNative], officialCreate: OFFICIAL_CREATE_CANDIDATES.map((candidate) => ({ ...candidate, category: candidate.id.startsWith('frontend.') diff --git a/packages/cli/src/doctor/capability-registry.ts b/packages/cli/src/doctor/capability-registry.ts index d958f5f6..b81019a1 100644 --- a/packages/cli/src/doctor/capability-registry.ts +++ b/packages/cli/src/doctor/capability-registry.ts @@ -218,7 +218,16 @@ function descriptorFor(definition: RuntimeDefinition): DoctorAdapterDescriptor { runtimeFamilies: [definition.id], aliases: definition.aliases ?? [], frameworks: definition.frameworks, - projectKinds: ['backend', 'frontend', 'desktop', 'extension', 'fullstack', 'generic'], + projectKinds: [ + 'backend', + 'frontend', + 'desktop', + 'extension', + 'agent', + 'gateway', + 'fullstack', + 'generic', + ], tier: definition.tier, platforms: [...ALL_PLATFORMS], domains, @@ -235,7 +244,16 @@ function fallbackDescriptor(): DoctorAdapterDescriptor { runtimeFamilies: ['unknown'], aliases: ['generic', 'unrecognized'], frameworks: ['Unknown'], - projectKinds: ['backend', 'frontend', 'desktop', 'extension', 'fullstack', 'generic'], + projectKinds: [ + 'backend', + 'frontend', + 'desktop', + 'extension', + 'agent', + 'gateway', + 'fullstack', + 'generic', + ], tier: 'fallback', platforms: [...ALL_PLATFORMS], domains: DOCTOR_DIAGNOSTIC_DOMAINS.map((domain) => ({ diff --git a/packages/cli/src/framework-registry.ts b/packages/cli/src/framework-registry.ts index a0d2f1a1..18f0dfc3 100644 --- a/packages/cli/src/framework-registry.ts +++ b/packages/cli/src/framework-registry.ts @@ -779,10 +779,32 @@ export async function validateCommand( reason?: string; }> { // Parse command (handle pipes, &&, ||, etc.) - const cmd = command - .split(/[&|;]\s*/)[0] - .trim() - .split(/\s+/)[0]; + const firstSegment = command.split(/[&|;]\s*/)[0].trim(); + + const tryPath = async (candidate: string): Promise => { + const executablePath = path.isAbsolute(candidate) + ? candidate + : path.resolve(cwd ?? process.cwd(), candidate); + try { + await fs.promises.access( + executablePath, + process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK + ); + return true; + } catch { + return false; + } + }; + + // An argv-only executable can be an absolute path that contains spaces. + if ( + (firstSegment.includes('/') || firstSegment.includes('\\')) && + (await tryPath(firstSegment)) + ) { + return { valid: true }; + } + + const cmd = firstSegment.split(/\s+/)[0]; // Shell builtins that don't need validation const builtins = ['echo', 'cd', 'pwd', 'test', 'true', 'false', 'exit']; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 812dd37d..942f6a5b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -57,6 +57,11 @@ import { prepareAgentFrameworkAttachment, resolveAgentFrameworkProjectKit, } from './agent-frameworks/index.js'; +import { + generateModelGatewayProject, + isModelGatewayProjectKit, + lookupModelGatewayProjectKit, +} from './model-gateways/index.js'; import { WORKSPACE_REPAIR_DECISIONS, type WorkspaceRepairDecision, @@ -406,7 +411,6 @@ async function enforceWorkspaceProfileForRequestedKit(kitName: string): Promise< if (isAgentFrameworkProjectKit(normalizedKitName)) { return true; } - try { const [profile, mode] = await Promise.all([ readWorkspaceManifestProfile(workspacePath), @@ -415,9 +419,10 @@ async function enforceWorkspaceProfileForRequestedKit(kitName: string): Promise< const kitDefinition = resolveKitDefinition(normalizedKitName); const frontendDefinition = resolveFrontendGenerator(normalizedKitName); const officialDefinition = resolveOfficialProjectGenerator(normalizedKitName); + const gatewayKit = lookupModelGatewayProjectKit(normalizedKitName); const runtime = frontendDefinition ? 'node' - : (officialDefinition?.runtime ?? kitDefinition?.runtime ?? 'python'); + : (officialDefinition?.runtime ?? kitDefinition?.runtime ?? gatewayKit?.runtime ?? 'python'); const projectCompatibility = resolveWorkspaceProfileProjectCompatibility({ profile, runtime, @@ -1337,6 +1342,98 @@ async function runAgentFrameworkProjectCreate(args: string[]): Promise { } } +async function runModelGatewayProjectCreate(args: string[]): Promise { + if (args[0] !== 'create' || args[1] !== 'project') return 1; + const kit = lookupModelGatewayProjectKit(args[2]); + if (!kit) return 1; + const projectName = args[3]; + const hasJson = args.includes('--json'); + if (!projectName) { + process.stderr.write( + `Usage: workspai create project ${kit.id} [--output ] [--dry-run] [--skip-git] [--json]\n` + ); + return 1; + } + try { + validateProjectName(projectName); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } + const outputDir = readFlagValue(args, '--output') || process.cwd(); + const projectPath = path.resolve(outputDir, projectName); + if (args.includes('--dry-run')) { + if (hasJson) { + console.log( + JSON.stringify( + cliOperationSuccess('create project', { + kit: kit.id, + name: projectName, + projectPath, + dryRun: true, + }), + null, + 2 + ) + ); + } else { + console.log(chalk.bold(`\nAI Gateway · ${kit.label}`)); + console.log(chalk.gray(` Runtime: ${kit.runtime}`)); + console.log(chalk.gray(` Target: ${projectPath}`)); + console.log(chalk.gray(' Plan: write the OpenRouter Client SDK starter and register it')); + console.log(chalk.gray(' Dry run: no files or workspace evidence will be changed.')); + } + return 0; + } + if (await fsExtra.pathExists(projectPath)) { + process.stderr.write(`❌ Directory "${projectPath}" already exists\n`); + return 1; + } + let ownsProjectPath = false; + try { + await fsExtra.ensureDir(path.dirname(projectPath)); + await generateModelGatewayProject({ projectPath, projectName, kit }); + ownsProjectPath = true; + if (!args.includes('--skip-git') && !args.includes('--no-git')) { + const { initializeStandaloneGitRepository } = await import('./create.js'); + await initializeStandaloneGitRepository( + projectPath, + createUiSpinner('Preparing git repository', { + component: 'create.model-gateway', + phase: 'git-init', + metadata: { projectName, kit: kit.id }, + }), + 'chore: initialize Workspai AI Gateway project' + ); + } + await finalizeCreatedProjectWorkspace(args, projectPath, projectName); + if (hasJson) { + console.log( + JSON.stringify( + cliOperationSuccess('create project', { + kit: kit.id, + name: projectName, + projectPath, + dryRun: false, + }), + null, + 2 + ) + ); + } else { + console.log(chalk.green(`✔ ${kit.label} project created at ${projectPath}`)); + console.log(chalk.gray(' Dependencies were not installed and no model was called.')); + } + return 0; + } catch (error) { + if (ownsProjectPath) await fsExtra.remove(projectPath).catch(() => undefined); + process.stderr.write( + `Workspai ${kit.id} generator failed: ${error instanceof Error ? error.message : String(error)}\n` + ); + return 1; + } +} + async function runNpmBackedKitCreate(args: string[]): Promise { if (args[0] !== 'create' || args[1] !== 'project') return 1; @@ -1609,6 +1706,8 @@ Examples: npx workspai create project desktop.tauri desktop-app npx workspai create project extension.vscode editor-tools --skip-install npx workspai create project agent.microsoft.python support-agent --skip-git + npx workspai create project gateway.openrouter.typescript model-gateway + npx workspai create project gateway.openrouter.python model-gateway Common kits: fastapi.standard Python API via RapidKit Core bridge @@ -1625,11 +1724,13 @@ Common kits: extension.vscode VS Code extension agent.microsoft.python Microsoft Agent Framework · Python agent.microsoft.dotnet Microsoft Agent Framework · .NET + gateway.openrouter.typescript AI Gateway · OpenRouter · TypeScript + gateway.openrouter.python AI Gateway · OpenRouter · Python php.laravel Backend Laravel application Interactive selection: Workspai first asks what you are building, then shows only kits in that category. - Backend, Frontend, Desktop, AI Agent, Extension, and Gaming are stable category ids; + Backend, Frontend, Desktop, AI Agent, AI Gateway, Extension, and Gaming are stable category ids; categories without an admitted kit are hidden until a compatible kit is available. Options: @@ -2044,6 +2145,7 @@ export async function handleCreateOrFallback(args: string[]): Promise { if ( isAgentFrameworkProjectKit(kitChoice) || + isModelGatewayProjectKit(kitChoice) || isNpmBackedKit(kitChoice) || isFrontendProjectKit(kitChoice) || isOfficialProjectKit(kitChoice) @@ -2066,11 +2168,13 @@ export async function handleCreateOrFallback(args: string[]): Promise { await prepareOutsideWorkspaceCreate(normalizedArgs); const code = isAgentFrameworkProjectKit(kitChoice) ? await runAgentFrameworkProjectCreate(normalizedArgs) - : isFrontendProjectKit(kitChoice) - ? await runFrontendProjectCreate(normalizedArgs) - : isOfficialProjectKit(kitChoice) - ? await runOfficialProjectCreate(normalizedArgs) - : await runNpmBackedKitCreate(normalizedArgs); + : isModelGatewayProjectKit(kitChoice) + ? await runModelGatewayProjectCreate(normalizedArgs) + : isFrontendProjectKit(kitChoice) + ? await runFrontendProjectCreate(normalizedArgs) + : isOfficialProjectKit(kitChoice) + ? await runOfficialProjectCreate(normalizedArgs) + : await runNpmBackedKitCreate(normalizedArgs); return code; } @@ -2094,6 +2198,13 @@ export async function handleCreateOrFallback(args: string[]): Promise { await prepareOutsideWorkspaceCreate(args, { requireWorkspace: true }); return await runAgentFrameworkProjectCreate(args); } + if (isModelGatewayProjectKit(requestedKit)) { + if (!(await enforceWorkspaceProfileForRequestedKit(requestedKit))) { + return 1; + } + await prepareOutsideWorkspaceCreate(args); + return await runModelGatewayProjectCreate(args); + } const capability = resolveCreatePlannerCapability({ kitId: requestedKit, framework: requestedKit, diff --git a/packages/cli/src/model-gateways/adapters/openrouter/common.ts b/packages/cli/src/model-gateways/adapters/openrouter/common.ts new file mode 100644 index 00000000..48f81828 --- /dev/null +++ b/packages/cli/src/model-gateways/adapters/openrouter/common.ts @@ -0,0 +1,57 @@ +export const OPENROUTER_GATEWAY_ID = 'openrouter' as const; +export const OPENROUTER_POLICY_FILE = 'gateway.policy.json' as const; +export const DEFAULT_GATEWAY_TIMEOUT_MS = 30_000; +export const MAX_GATEWAY_TIMEOUT_MS = 600_000; +export const OPENROUTER_SORT_VALUES = ['price', 'throughput', 'latency', 'exacto'] as const; +export const OPENROUTER_SORT_PARTITION_VALUES = ['model', 'none'] as const; +export const OPENROUTER_PERCENTILE_KEYS = ['p50', 'p75', 'p90', 'p99'] as const; +export const OPENROUTER_MAX_PRICE_KEYS = [ + 'prompt', + 'completion', + 'request', + 'image', + 'audio', +] as const; +export const OPENROUTER_QUANTIZATION_VALUES = [ + 'int4', + 'int8', + 'fp4', + 'mxfp4', + 'nvfp4', + 'fp6', + 'fp8', + 'mxfp8', + 'fp16', + 'bf16', + 'fp32', + 'unknown', +] as const; +export const OPENROUTER_DATA_COLLECTION_VALUES = ['allow', 'deny'] as const; + +export const GITIGNORE = `# Credentials and local secrets +.env +.env.* +!.env.example + +# Dependencies and virtual environments +node_modules/ +.venv/ +venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Build output and caches +dist/ +build/ +coverage/ +.coverage +*.tsbuildinfo +.cache/ + +# Editor and OS +.DS_Store +Thumbs.db +`; diff --git a/packages/cli/src/model-gateways/adapters/openrouter/policy-parity.ts b/packages/cli/src/model-gateways/adapters/openrouter/policy-parity.ts new file mode 100644 index 00000000..a2f14c5e --- /dev/null +++ b/packages/cli/src/model-gateways/adapters/openrouter/policy-parity.ts @@ -0,0 +1,261 @@ +export const OPENROUTER_OVERFLOW_PRICE = '9'.repeat(400); + +export type PolicyParityDecision = 'accept' | 'reject'; + +export type PolicyParityCase = { + id: string; + decision: PolicyParityDecision; + needle?: string; + typescript: unknown; + python: unknown; +}; + +function both( + id: string, + decision: PolicyParityDecision, + needle: string | undefined, + typescript: unknown, + python: unknown +): PolicyParityCase { + return needle + ? { id, decision, needle, typescript, python } + : { id, decision, typescript, python }; +} + +export const OPENROUTER_POLICY_PARITY_CASES: PolicyParityCase[] = [ + both('unknown-root-field', 'reject', 'unknown field', { widget: true }, { widget: true }), + both( + 'unknown-provider-field', + 'reject', + 'unknown field', + { provider: { fallbacks: true } }, + { provider: { fallbacks: true } } + ), + both( + 'unknown-max-price-key', + 'reject', + 'unknown field', + { provider: { maxPrice: { tokens: '1' } } }, + { provider: { max_price: { tokens: '1' } } } + ), + both( + 'unknown-percentile-key', + 'reject', + 'unknown field', + { provider: { preferredMaxLatency: { p95: 1 } } }, + { provider: { preferred_max_latency: { p95: 1 } } } + ), + both( + 'unknown-sort-field', + 'reject', + 'unknown field', + { provider: { sort: { by: 'price', extra: true } } }, + { provider: { sort: { by: 'price', extra: true } } } + ), + both( + 'models-not-array', + 'reject', + 'models must be an array', + { models: 'openai/gpt' }, + { models: 'openai/gpt' } + ), + both( + 'allow-fallbacks-string', + 'reject', + 'boolean', + { provider: { allowFallbacks: 'yes' } }, + { provider: { allow_fallbacks: 'yes' } } + ), + both( + 'require-parameters-number', + 'reject', + 'boolean', + { provider: { requireParameters: 1 } }, + { provider: { require_parameters: 1 } } + ), + both('zdr-number', 'reject', 'boolean', { provider: { zdr: 1 } }, { provider: { zdr: 1 } }), + both('empty-model-name', 'reject', 'non-empty string', { models: [' '] }, { models: [' '] }), + both( + 'whitespace-provider-name', + 'reject', + 'non-empty string', + { provider: { order: [' '] } }, + { provider: { order: [' '] } } + ), + both('duplicate-models', 'reject', 'duplicates', { models: ['a', 'a'] }, { models: ['a', 'a'] }), + both( + 'duplicate-order', + 'reject', + 'duplicates', + { provider: { order: ['openai', 'openai'] } }, + { provider: { order: ['openai', 'openai'] } } + ), + both( + 'only-ignore-overlap', + 'reject', + 'overlap', + { provider: { only: ['openai'], ignore: ['openai'] } }, + { provider: { only: ['openai'], ignore: ['openai'] } } + ), + both( + 'ignored-provider-in-order', + 'reject', + 'ignored providers', + { provider: { order: ['openai'], ignore: ['openai'] } }, + { provider: { order: ['openai'], ignore: ['openai'] } } + ), + both( + 'unsupported-quantization', + 'reject', + 'quantizations', + { provider: { quantizations: ['int5'] } }, + { provider: { quantizations: ['int5'] } } + ), + both( + 'invalid-sort', + 'reject', + 'sort', + { provider: { sort: 'cheap' } }, + { provider: { sort: 'cheap' } } + ), + both( + 'invalid-sort-partition', + 'reject', + 'partition', + { provider: { sort: { by: 'price', partition: 'all' } } }, + { provider: { sort: { by: 'price', partition: 'all' } } } + ), + both( + 'zdr-with-data-collection-allow', + 'reject', + 'zdr', + { provider: { zdr: true, dataCollection: 'allow' } }, + { provider: { zdr: true, data_collection: 'allow' } } + ), + both('timeout-zero', 'reject', 'positive integer', { timeoutMs: 0 }, { timeout_ms: 0 }), + both( + 'timeout-over-max', + 'reject', + 'positive integer', + { timeoutMs: 600001 }, + { timeout_ms: 600001 } + ), + both( + 'max-price-number', + 'reject', + 'numeric string', + { provider: { maxPrice: { prompt: 1 } } }, + { provider: { max_price: { prompt: 1 } } } + ), + both( + 'max-price-negative', + 'reject', + 'numeric string', + { provider: { maxPrice: { prompt: '-1' } } }, + { provider: { max_price: { prompt: '-1' } } } + ), + both( + 'max-price-malformed', + 'reject', + 'numeric string', + { provider: { maxPrice: { prompt: 'not-a-price' } } }, + { provider: { max_price: { prompt: 'not-a-price' } } } + ), + both( + 'max-price-overflow', + 'reject', + 'numeric string', + { provider: { maxPrice: { prompt: OPENROUTER_OVERFLOW_PRICE } } }, + { provider: { max_price: { prompt: OPENROUTER_OVERFLOW_PRICE } } } + ), + both( + 'percentile-negative', + 'reject', + 'non-negative', + { provider: { preferredMinThroughput: { p50: -1 } } }, + { provider: { preferred_min_throughput: { p50: -1 } } } + ), + both('valid-minimal-policy', 'accept', undefined, {}, {}), + both('timeout-one', 'accept', undefined, { timeoutMs: 1 }, { timeout_ms: 1 }), + both('timeout-max', 'accept', undefined, { timeoutMs: 600000 }, { timeout_ms: 600000 }), + both( + 'max-price-zero', + 'accept', + undefined, + { provider: { maxPrice: { prompt: '0' } } }, + { provider: { max_price: { prompt: '0' } } } + ), + both( + 'max-price-micro', + 'accept', + undefined, + { provider: { maxPrice: { prompt: '0.000001' } } }, + { provider: { max_price: { prompt: '0.000001' } } } + ), + both( + 'max-price-positive', + 'accept', + undefined, + { provider: { maxPrice: { completion: '1.5' } } }, + { provider: { max_price: { completion: '1.5' } } } + ), + both( + 'valid-complete-policy', + 'accept', + undefined, + { + models: ['openrouter/fallback'], + timeoutMs: 1, + httpReferer: 'https://example.test', + appTitle: 'gateway', + provider: { + allowFallbacks: false, + requireParameters: true, + zdr: true, + dataCollection: 'deny', + enforceDistillableText: true, + only: ['anthropic'], + ignore: ['openai'], + order: ['anthropic'], + sort: { by: 'exacto', partition: 'none' }, + quantizations: ['fp16'], + maxPrice: { + prompt: '0', + completion: '1.5', + request: '2', + image: '3', + audio: '4', + }, + preferredMinThroughput: { p50: 0, p75: 1, p90: 2, p99: 3 }, + preferredMaxLatency: 1.5, + }, + }, + { + models: ['openrouter/fallback'], + timeout_ms: 1, + http_referer: 'https://example.test', + x_open_router_title: 'gateway', + provider: { + allow_fallbacks: false, + require_parameters: true, + zdr: true, + data_collection: 'deny', + enforce_distillable_text: true, + only: ['anthropic'], + ignore: ['openai'], + order: ['anthropic'], + sort: { by: 'exacto', partition: 'none' }, + quantizations: ['fp16'], + max_price: { + prompt: '0', + completion: '1.5', + request: '2', + image: '3', + audio: '4', + }, + preferred_min_throughput: { p50: 0, p75: 1, p90: 2, p99: 3 }, + preferred_max_latency: 1.5, + }, + } + ), +]; diff --git a/packages/cli/src/model-gateways/adapters/openrouter/python.ts b/packages/cli/src/model-gateways/adapters/openrouter/python.ts new file mode 100644 index 00000000..c7145cf4 --- /dev/null +++ b/packages/cli/src/model-gateways/adapters/openrouter/python.ts @@ -0,0 +1,1536 @@ +import path from 'node:path'; + +import { writeGeneratorFile } from '../../../generators/go-kit-common.js'; +import { pythonDistributionName } from '../../gateway.js'; +import { getModelGatewayVersionBaseline, packageVersion } from '../../version-policy.js'; +import { + DEFAULT_GATEWAY_TIMEOUT_MS, + GITIGNORE, + MAX_GATEWAY_TIMEOUT_MS, + OPENROUTER_DATA_COLLECTION_VALUES, + OPENROUTER_MAX_PRICE_KEYS, + OPENROUTER_PERCENTILE_KEYS, + OPENROUTER_POLICY_FILE, + OPENROUTER_QUANTIZATION_VALUES, + OPENROUTER_SORT_PARTITION_VALUES, + OPENROUTER_SORT_VALUES, +} from './common.js'; +import { OPENROUTER_POLICY_PARITY_CASES } from './policy-parity.js'; + +const BASELINE = getModelGatewayVersionBaseline('openrouter-python'); +if (!BASELINE) { + throw new Error('Missing OpenRouter Python gateway baseline.'); +} + +const SDK_VERSION = packageVersion(BASELINE, 'openrouter'); + +export function openRouterPythonSdkVersion(): string { + return SDK_VERSION; +} + +function lines(value: string[]): string { + return `${value.join('\n')}\n`; +} + +function renderPyproject(name: string): string { + return lines([ + '[build-system]', + 'requires = ["setuptools>=68"]', + 'build-backend = "setuptools.build_meta"', + '', + '[project]', + `name = "${name}"`, + 'version = "0.1.0"', + 'description = "Server-owned OpenRouter Model Gateway generated by Workspai."', + 'requires-python = ">=3.10"', + 'dependencies = [', + ` "openrouter==${SDK_VERSION}",`, + ']', + '', + '[project.scripts]', + 'start = "model_gateway.main:main"', + '', + '[tool.setuptools.packages.find]', + 'where = ["src"]', + ]); +} + +function renderPolicy(): string { + return `${JSON.stringify( + { + models: [], + provider: { + allow_fallbacks: null, + order: null, + only: null, + ignore: null, + sort: null, + quantizations: null, + require_parameters: null, + zdr: null, + data_collection: null, + enforce_distillable_text: null, + max_price: null, + preferred_min_throughput: null, + preferred_max_latency: null, + }, + timeout_ms: DEFAULT_GATEWAY_TIMEOUT_MS, + http_referer: null, + x_open_router_title: null, + }, + null, + 2 + )}\n`; +} + +function renderEnvExample(): string { + return lines([ + '# Required. Server-side only. Never expose this value to browsers, frontend', + '# environment prefixes, client bundles, or committed files.', + 'OPENROUTER_API_KEY=', + '', + '# Required. Choose a model slug at runtime. Workspai does not select a billable', + '# model for you. List current slugs at https://openrouter.ai/models', + 'OPENROUTER_MODEL=', + '', + '# Optional application attribution. These map to the official Python SDK', + '# constructor fields http_referer and x_open_router_title, and to the SDK', + '# environment names OPENROUTER_HTTP_REFERER and OPENROUTER_X_OPEN_ROUTER_TITLE.', + 'OPENROUTER_HTTP_REFERER=', + 'OPENROUTER_X_OPEN_ROUTER_TITLE=', + '', + '# Optional timeout in milliseconds. Defaults to 30000 when omitted.', + 'OPENROUTER_TIMEOUT_MS=', + ]); +} + +function renderReadme(projectName: string): string { + return lines([ + `# ${projectName}`, + '', + 'This is a server-owned OpenRouter Model Gateway generated by Workspai.', + 'It is an AI Gateway, not an agent runtime. OpenRouter owns provider and', + 'model routing. Workspai owns generation, lifecycle, and verification.', + '', + `The official Client SDK is \`openrouter\` \`${SDK_VERSION}\`.`, + 'A model identifier is runtime configuration, not a project kit.', + '', + '## Security', + '', + '- Keep `OPENROUTER_API_KEY` on the server. Do not put it in browser bundles or', + ' client-side environment prefixes.', + '- Diagnostics redact Authorization headers, API keys, URL secrets, and request bodies.', + '- Configuration is validated before any network request.', + '- This project does not disable TLS verification and does not enable SDK debug logging.', + '- Privacy and routing policy live in `gateway.policy.json` using Python SDK field names.', + '- When a privacy or routing field is omitted, OpenRouter upstream defaults remain in', + ' effect. The SDK documents `data_collection` default as `allow` when unset.', + '', + '## Configure', + '', + '1. Copy `.env.example` into your secret store or shell. Do not commit `.env`.', + '2. Set `OPENROUTER_API_KEY` and `OPENROUTER_MODEL`.', + '3. Edit `gateway.policy.json` for official Python SDK provider controls:', + ' fallbacks, order/allow/deny, sort (`price`/`throughput`/`latency`/`exacto`,', + ' optional `{ by, partition }` where `partition` is `model` or `none`),', + ' quantizations, require_parameters, ZDR, data_collection, enforce_distillable_text,', + ' max_price, and preferred throughput or latency. Unknown fields are rejected.', + '', + 'Contradictory policy fails before a request is sent. ZDR and `data_collection: "deny"`', + 'are never silently dropped. Application-side retries are set to strategy `none` so they', + 'cannot duplicate billable requests; OpenRouter still applies provider fallbacks when', + '`allow_fallbacks` is not false. An empty successful completion returns an empty string.', + '`max_price` values must be numeric strings that stay finite after interpretation;', + 'overflow-to-infinity strings are rejected. Streaming cleanup prefers the SDK iterator', + '`close()`/`return()` path, then `EventStream.close()`, then `cancel()`, and never', + 'invokes two of those on the same stream.', + '', + 'Timeout must be a bounded positive integer (1 to 600000 ms). `gateway.policy.json` is', + 'resolved from the installed package location, then the project root, then the process', + 'working directory. The `start` console script uses that same lookup, so it can run from', + 'another directory after an editable install.', + '', + 'Capabilities such as tools, JSON schema, streaming, images, audio, and embeddings', + 'vary by model and provider. This starter only claims chat generation and streaming.', + '', + '## Commands', + '', + '```bash', + 'python3 -m venv .venv', + 'source .venv/bin/activate', + 'python -m pip install -e .', + 'python -m compileall .', + 'python -m unittest discover -s tests', + 'python main.py --smoke', + 'python main.py', + '```', + '', + 'On Windows PowerShell, activate with `.venv\\Scripts\\Activate.ps1`.', + '', + '`unittest` and `--smoke` inject a fake httpx transport. They do not call OpenRouter', + 'and do not require a real `OPENROUTER_API_KEY`. `python main.py` and the `start`', + 'console script are live entrypoints unless `--smoke` is passed.', + '', + 'Workspace Run uses the same layout: `init` installs the editable project, `test` runs', + '`unittest`, `build` runs `compileall`, and `start` runs `main.py`.', + '', + '## Future composition', + '', + 'An agent runtime can later consume this Model Gateway port. Do not copy these', + 'templates into an Agent Framework kit; compose against the port instead.', + ]); +} + +function renderRedact(): string { + return lines([ + 'from __future__ import annotations', + '', + 'import os', + 'import re', + 'from typing import Mapping', + '', + '_SECRET = re.compile(r"(?:sk-or-[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]{8,}|Bearer\\s+[A-Za-z0-9._~+/=-]+)", re.I)', + '_AUTH = re.compile(r"authorization\\s*[:=]\\s*[\\"\\\']?[^\\"\\\'\\s]+", re.I)', + '_URL_SECRET = re.compile(r"([?&](?:api[_-]?key|token|secret|password)=)[^&\\s]+", re.I)', + '', + '', + 'def redact_secret_shaped_values(value: str) -> str:', + ' live_key = os.environ.get("OPENROUTER_API_KEY") or ""', + ' output = value.replace(live_key, "[redacted]") if live_key else value', + ' output = _SECRET.sub("[redacted]", output)', + ' output = _AUTH.sub("Authorization: [redacted]", output)', + ' return _URL_SECRET.sub(r"\\1[redacted]", output)', + '', + '', + 'def redact_headers(headers: Mapping[str, str]) -> dict[str, str]:', + ' redacted: dict[str, str] = {}', + ' for key, value in headers.items():', + ' if key.lower() == "authorization":', + ' redacted[key] = "[redacted]"', + ' else:', + ' redacted[key] = redact_secret_shaped_values(str(value))', + ' return redacted', + ]); +} + +function renderErrors(): string { + return lines([ + 'from __future__ import annotations', + '', + 'from dataclasses import dataclass', + 'from typing import Any, Mapping, Optional', + '', + 'from .redact import redact_headers, redact_secret_shaped_values', + '', + '', + 'class GatewayConfigurationError(Exception):', + ' pass', + '', + '', + '@dataclass', + 'class GatewayError:', + ' message: str', + ' status_code: Optional[int] = None', + ' request_id: Optional[str] = None', + ' retry_after: Optional[str] = None', + ' safe_headers: Optional[dict[str, str]] = None', + '', + '', + 'class GatewayUpstreamError(RuntimeError):', + ' def __init__(self, mapped: GatewayError) -> None:', + ' suffix = f" (status {mapped.status_code})" if mapped.status_code else ""', + ' request = f" request {mapped.request_id}" if mapped.request_id else ""', + ' super().__init__(f"{mapped.message}{suffix}{request}")', + ' self.status_code = mapped.status_code', + ' self.request_id = mapped.request_id', + ' self.retry_after = mapped.retry_after', + ' self.safe_headers = mapped.safe_headers', + '', + '', + 'def _as_headers(value: Any) -> Any:', + ' if value is None:', + ' return None', + ' response = getattr(value, "response", None)', + ' headers = getattr(value, "headers", None)', + ' if headers is None and response is not None:', + ' headers = getattr(response, "headers", None)', + ' return headers', + '', + '', + 'def _header(headers: Any, name: str) -> Optional[str]:', + ' if headers is None:', + ' return None', + ' getter = getattr(headers, "get", None)', + ' if callable(getter):', + ' value = getter(name) or getter(name.lower())', + ' return str(value) if value is not None else None', + ' if isinstance(headers, Mapping):', + ' lowered = {str(key).lower(): str(item) for key, item in headers.items()}', + ' return lowered.get(name.lower())', + ' return None', + '', + '', + 'def _status_code(error: object) -> Optional[int]:', + ' for attr in ("status_code", "status"):', + ' value = getattr(error, attr, None)', + ' if isinstance(value, int):', + ' return value', + ' response = getattr(error, "response", None)', + ' value = getattr(response, "status_code", None) if response is not None else None', + ' return value if isinstance(value, int) else None', + '', + '', + 'def map_upstream_error(error: object) -> GatewayError:', + ' headers = _as_headers(error)', + ' safe_headers = redact_headers(headers) if headers is not None else None', + ' return GatewayError(', + ' message=redact_secret_shaped_values(str(error)),', + ' status_code=_status_code(error),', + ' request_id=_header(safe_headers, "x-request-id") or _header(headers, "x-request-id") or _header(headers, "x-openrouter-request-id"),', + ' retry_after=_header(safe_headers, "retry-after") or _header(headers, "retry-after"),', + ' safe_headers=safe_headers,', + ' )', + '', + '', + 'def to_gateway_upstream_error(error: object) -> GatewayUpstreamError:', + ' if isinstance(error, GatewayUpstreamError):', + ' return error', + ' return GatewayUpstreamError(map_upstream_error(error))', + ]); +} + +function renderPort(): string { + return lines([ + 'from __future__ import annotations', + '', + 'from typing import Iterator, Protocol', + '', + '', + 'class ModelGateway(Protocol):', + ' def generate(self, messages: list[dict[str, str]]) -> str: ...', + '', + ' def stream(self, messages: list[dict[str, str]]) -> Iterator[str]: ...', + '', + ' def close(self) -> None: ...', + ]); +} + +function renderConfig(): string { + const sortSet = OPENROUTER_SORT_VALUES.map((value) => `"${value}"`).join(', '); + const partitionSet = OPENROUTER_SORT_PARTITION_VALUES.map((value) => `"${value}"`).join(', '); + const dataSet = OPENROUTER_DATA_COLLECTION_VALUES.map((value) => `"${value}"`).join(', '); + const quantSet = OPENROUTER_QUANTIZATION_VALUES.map((value) => `"${value}"`).join(', '); + const priceSet = OPENROUTER_MAX_PRICE_KEYS.map((value) => `"${value}"`).join(', '); + const percentileSet = OPENROUTER_PERCENTILE_KEYS.map((value) => `"${value}"`).join(', '); + return lines([ + 'from __future__ import annotations', + '', + 'import json', + 'import math', + 'import os', + 'import re', + 'from dataclasses import dataclass, field', + 'from pathlib import Path', + 'from typing import Any', + '', + 'from .errors import GatewayConfigurationError', + '', + `POLICY_FILE_NAME = ${JSON.stringify(OPENROUTER_POLICY_FILE)}`, + `DEFAULT_TIMEOUT_MS = ${DEFAULT_GATEWAY_TIMEOUT_MS}`, + `MAX_TIMEOUT_MS = ${MAX_GATEWAY_TIMEOUT_MS}`, + `SORT_VALUES = {${sortSet}}`, + `PARTITION_VALUES = {${partitionSet}}`, + `DATA_COLLECTION_VALUES = {${dataSet}}`, + `QUANTIZATION_VALUES = {${quantSet}}`, + `MAX_PRICE_KEYS = {${priceSet}}`, + `PERCENTILE_KEYS = {${percentileSet}}`, + 'ROOT_KEYS = {"models", "provider", "timeout_ms", "http_referer", "x_open_router_title"}', + 'PROVIDER_KEYS = {', + ' "allow_fallbacks",', + ' "order",', + ' "only",', + ' "ignore",', + ' "sort",', + ' "quantizations",', + ' "require_parameters",', + ' "zdr",', + ' "data_collection",', + ' "enforce_distillable_text",', + ' "max_price",', + ' "preferred_min_throughput",', + ' "preferred_max_latency",', + '}', + '_PRICE = re.compile(r"^(?:0|[1-9]\\d*)(?:\\.\\d+)?$")', + '', + '', + '@dataclass', + 'class GatewayConfig:', + ' api_key: str', + ' model: str', + ' models: list[str] = field(default_factory=list)', + ' timeout_ms: int = DEFAULT_TIMEOUT_MS', + ' http_referer: str | None = None', + ' x_open_router_title: str | None = None', + ' provider: dict[str, Any] | None = None', + '', + '', + 'def _reject_unknown(record: dict[str, Any], allowed: set[str], label: str) -> None:', + ' unknown = sorted(key for key in record if key not in allowed)', + ' if unknown:', + ' raise GatewayConfigurationError(label + " contains unknown field(s): " + ", ".join(unknown))', + '', + '', + 'def _optional_bool(value: object, label: str) -> bool | None:', + ' if value is None:', + ' return None', + ' if not isinstance(value, bool):', + ' raise GatewayConfigurationError(f"{label} must be a boolean.")', + ' return value', + '', + '', + 'def _optional_string(value: object, label: str) -> str | None:', + ' if value is None or value == "":', + ' return None', + ' if not isinstance(value, str):', + ' raise GatewayConfigurationError(f"{label} must be a string.")', + ' return value.strip() or None', + '', + '', + 'def _string_list(value: object, label: str) -> list[str]:', + ' if value is None:', + ' return []', + ' if not isinstance(value, list):', + ' raise GatewayConfigurationError(f"{label} must be an array of non-empty strings.")', + ' items: list[str] = []', + ' for index, item in enumerate(value):', + ' if not isinstance(item, str) or not item.strip():', + ' raise GatewayConfigurationError(f"{label}[{index}] must be a non-empty string.")', + ' items.append(item.strip())', + ' seen: set[str] = set()', + ' duplicates: list[str] = []', + ' for item in items:', + ' if item in seen:', + ' duplicates.append(item)', + ' seen.add(item)', + ' if duplicates:', + ' raise GatewayConfigurationError(label + " contains duplicates: " + ", ".join(dict.fromkeys(duplicates)))', + ' return items', + '', + '', + 'def _policy_int(value: object, label: str) -> int:', + ' if type(value) is not int or isinstance(value, bool) or value <= 0 or value > MAX_TIMEOUT_MS:', + ' raise GatewayConfigurationError(f"{label} must be a positive integer between 1 and {MAX_TIMEOUT_MS}.")', + ' return value', + '', + '', + 'def _env_int(value: str, label: str) -> int:', + ' trimmed = value.strip()', + ' if not re.fullmatch(r"[1-9]\\d*", trimmed):', + ' raise GatewayConfigurationError(f"{label} must be a positive integer between 1 and {MAX_TIMEOUT_MS}.")', + ' return _policy_int(int(trimmed), label)', + '', + '', + 'def _non_negative_number(value: object, label: str) -> float:', + ' if isinstance(value, bool) or not isinstance(value, (int, float)):', + ' raise GatewayConfigurationError(f"{label} must be a finite non-negative number.")', + ' number = float(value)', + ' if number != number or number < 0 or number == float("inf"):', + ' raise GatewayConfigurationError(f"{label} must be a finite non-negative number.")', + ' return number', + '', + '', + 'def _max_price(value: object) -> dict[str, str] | None:', + ' if value is None:', + ' return None', + ' if not isinstance(value, dict):', + ' raise GatewayConfigurationError("provider.max_price must be an object.")', + ' _reject_unknown(value, MAX_PRICE_KEYS, "provider.max_price")', + ' mapped: dict[str, str] = {}', + ' for key in ("prompt", "completion", "request", "image", "audio"):', + ' entry = value.get(key)', + ' if entry is None:', + ' continue', + ' if not isinstance(entry, str) or not _PRICE.fullmatch(entry):', + ' raise GatewayConfigurationError(f"provider.max_price.{key} must be a non-negative numeric string.")', + ' number = float(entry)', + ' if not math.isfinite(number) or number < 0:', + ' raise GatewayConfigurationError(f"provider.max_price.{key} must be a non-negative numeric string.")', + ' mapped[key] = entry', + ' return mapped', + '', + '', + 'def _percentile_or_number(value: object, label: str) -> float | dict[str, float | None] | None:', + ' if value is None:', + ' return None', + ' if isinstance(value, (int, float)) and not isinstance(value, bool):', + ' return _non_negative_number(value, label)', + ' if not isinstance(value, dict):', + ' raise GatewayConfigurationError(f"{label} must be a number or percentile object.")', + ' _reject_unknown(value, PERCENTILE_KEYS, label)', + ' mapped: dict[str, float | None] = {}', + ' for key in ("p50", "p75", "p90", "p99"):', + ' if key not in value:', + ' continue', + ' entry = value[key]', + ' mapped[key] = None if entry is None else _non_negative_number(entry, f"{label}.{key}")', + ' return mapped', + '', + '', + 'def _sort(value: object) -> object:', + ' if value is None:', + ' return None', + ' if isinstance(value, str):', + ' if value not in SORT_VALUES:', + ' raise GatewayConfigurationError("provider.sort must be price, throughput, latency, or exacto.")', + ' return value', + ' if not isinstance(value, dict):', + ' raise GatewayConfigurationError("provider.sort must be a string or { by, partition } object.")', + ' _reject_unknown(value, {"by", "partition"}, "provider.sort")', + ' by = value.get("by")', + ' partition = value.get("partition")', + ' if by is not None and (not isinstance(by, str) or by not in SORT_VALUES):', + ' raise GatewayConfigurationError("provider.sort.by must be price, throughput, latency, or exacto.")', + ' if partition is not None and (not isinstance(partition, str) or partition not in PARTITION_VALUES):', + ' raise GatewayConfigurationError("provider.sort.partition must be model or none.")', + ' return {"by": by, "partition": partition}', + '', + '', + 'def _quantizations(value: object) -> list[str] | None:', + ' if value is None:', + ' return None', + ' items = _string_list(value, "provider.quantizations")', + ' unknown = [item for item in items if item not in QUANTIZATION_VALUES]', + ' if unknown:', + ' raise GatewayConfigurationError("unsupported provider.quantizations value: " + ", ".join(unknown))', + ' return items', + '', + '', + 'def default_policy_root() -> Path:', + ' here = Path(__file__).resolve()', + ' for candidate in here.parents[:8]:', + ' if (candidate / POLICY_FILE_NAME).is_file():', + ' return candidate', + ' cwd = Path.cwd()', + ' if (cwd / POLICY_FILE_NAME).is_file():', + ' return cwd', + ' return here.parents[2] if len(here.parents) > 2 else cwd', + '', + '', + 'def load_policy_file(root: Path) -> object:', + ' path = root / POLICY_FILE_NAME', + ' try:', + ' return json.loads(path.read_text(encoding="utf-8"))', + ' except (OSError, json.JSONDecodeError, UnicodeError) as error:', + ' raise GatewayConfigurationError(f"Unable to read {POLICY_FILE_NAME}: {error}") from error', + '', + '', + 'def to_sdk_provider(provider: dict[str, Any] | None) -> dict[str, Any] | None:', + ' if not provider:', + ' return None', + ' mapped: dict[str, Any] = {}', + ' for key in (', + ' "allow_fallbacks",', + ' "order",', + ' "only",', + ' "ignore",', + ' "sort",', + ' "quantizations",', + ' "require_parameters",', + ' "zdr",', + ' "data_collection",', + ' "enforce_distillable_text",', + ' "max_price",', + ' "preferred_min_throughput",', + ' "preferred_max_latency",', + ' ):', + ' value = provider.get(key)', + ' if value not in (None, [], {}):', + ' mapped[key] = value', + ' return mapped or None', + '', + '', + 'def _parse_policy(value: object) -> dict[str, Any]:', + ' if not isinstance(value, dict):', + ' raise GatewayConfigurationError("gateway.policy.json must be a JSON object.")', + ' _reject_unknown(value, ROOT_KEYS, "gateway.policy.json")', + ' models = None if "models" not in value else _string_list(value.get("models"), "models")', + ' raw_provider = value.get("provider") if "provider" in value else None', + ' provider: dict[str, Any] | None', + ' if raw_provider is None:', + ' provider = None', + ' else:', + ' if not isinstance(raw_provider, dict):', + ' raise GatewayConfigurationError("provider must be an object.")', + ' _reject_unknown(raw_provider, PROVIDER_KEYS, "provider")', + ' data_collection = raw_provider.get("data_collection")', + ' if data_collection is not None and data_collection not in DATA_COLLECTION_VALUES:', + ' raise GatewayConfigurationError("provider.data_collection must be allow or deny.")', + ' only = None if raw_provider.get("only") is None else _string_list(raw_provider.get("only"), "provider.only")', + ' ignore = None if raw_provider.get("ignore") is None else _string_list(raw_provider.get("ignore"), "provider.ignore")', + ' order = None if raw_provider.get("order") is None else _string_list(raw_provider.get("order"), "provider.order")', + ' provider = {', + ' "allow_fallbacks": _optional_bool(raw_provider.get("allow_fallbacks"), "provider.allow_fallbacks"),', + ' "require_parameters": _optional_bool(raw_provider.get("require_parameters"), "provider.require_parameters"),', + ' "zdr": _optional_bool(raw_provider.get("zdr"), "provider.zdr"),', + ' "enforce_distillable_text": _optional_bool(raw_provider.get("enforce_distillable_text"), "provider.enforce_distillable_text"),', + ' "data_collection": data_collection,', + ' "only": only,', + ' "ignore": ignore,', + ' "order": order,', + ' "sort": _sort(raw_provider.get("sort")),', + ' "quantizations": _quantizations(raw_provider.get("quantizations")),', + ' "max_price": _max_price(raw_provider.get("max_price")),', + ' "preferred_min_throughput": _percentile_or_number(raw_provider.get("preferred_min_throughput"), "provider.preferred_min_throughput"),', + ' "preferred_max_latency": _percentile_or_number(raw_provider.get("preferred_max_latency"), "provider.preferred_max_latency"),', + ' }', + ' timeout_ms = value.get("timeout_ms")', + ' return {', + ' "models": models,', + ' "provider": provider,', + ' "timeout_ms": None if timeout_ms is None else _policy_int(timeout_ms, "timeout_ms"),', + ' "http_referer": _optional_string(value.get("http_referer"), "http_referer"),', + ' "x_open_router_title": _optional_string(value.get("x_open_router_title"), "x_open_router_title"),', + ' }', + '', + '', + 'def load_gateway_config(env: dict[str, str] | None = None, policy: object | None = None, root: Path | None = None) -> GatewayConfig:', + ' try:', + ' environ = env if env is not None else os.environ', + ' payload = _parse_policy(policy if policy is not None else load_policy_file(root or default_policy_root()))', + ' api_key = (environ.get("OPENROUTER_API_KEY") or "").strip()', + ' model = (environ.get("OPENROUTER_MODEL") or "").strip()', + ' if not api_key:', + ' raise GatewayConfigurationError("OPENROUTER_API_KEY is required before any OpenRouter request.")', + ' if not model:', + ' raise GatewayConfigurationError("OPENROUTER_MODEL is required. Choose a model slug; this project does not default one.")', + ' models = payload.get("models") or []', + ' if model in models:', + ' raise GatewayConfigurationError("models fallbacks must not repeat OPENROUTER_MODEL.")', + ' provider = payload.get("provider")', + ' only = (provider or {}).get("only") or []', + ' ignore = (provider or {}).get("ignore") or []', + ' order = (provider or {}).get("order") or []', + ' overlap = sorted(set(only) & set(ignore))', + ' if overlap:', + ' raise GatewayConfigurationError("provider.only and provider.ignore overlap: " + ", ".join(overlap))', + ' ordered_ignored = sorted(set(order) & set(ignore))', + ' if ordered_ignored:', + ' raise GatewayConfigurationError("provider.order includes ignored providers: " + ", ".join(ordered_ignored))', + ' if provider and provider.get("zdr") is True and provider.get("data_collection") == "allow":', + ' raise GatewayConfigurationError("provider.zdr=true cannot be combined with data_collection=allow.")', + ' timeout_raw = (environ.get("OPENROUTER_TIMEOUT_MS") or "").strip()', + ' timeout_ms = _env_int(timeout_raw, "OPENROUTER_TIMEOUT_MS") if timeout_raw else (', + ' payload["timeout_ms"] if payload.get("timeout_ms") is not None else DEFAULT_TIMEOUT_MS', + ' )', + ' http_referer = (environ.get("OPENROUTER_HTTP_REFERER") or "").strip() or payload.get("http_referer")', + ' title = (environ.get("OPENROUTER_X_OPEN_ROUTER_TITLE") or "").strip() or payload.get("x_open_router_title")', + ' return GatewayConfig(', + ' api_key=api_key,', + ' model=model,', + ' models=models,', + ' timeout_ms=timeout_ms,', + ' http_referer=http_referer or None,', + ' x_open_router_title=title or None,', + ' provider=to_sdk_provider(provider),', + ' )', + ' except GatewayConfigurationError:', + ' raise', + ' except (AttributeError, TypeError, ValueError, KeyError) as error:', + ' raise GatewayConfigurationError(f"Invalid gateway policy: {error}") from error', + ]); +} + +function renderGateway(): string { + return lines([ + 'from __future__ import annotations', + '', + 'from typing import Any, Iterator', + '', + 'import httpx', + 'from openrouter import OpenRouter', + 'from openrouter.utils.retries import BackoffStrategy, RetryConfig', + '', + 'from .config import GatewayConfig, load_gateway_config', + 'from .errors import GatewayConfigurationError, to_gateway_upstream_error', + '', + 'NO_RETRIES = RetryConfig(', + ' "none",', + ' BackoffStrategy(initial_interval=0, max_interval=0, exponent=1.0, max_elapsed_time=0),', + ' False,', + ')', + '', + '', + 'def _text_from_content(value: object) -> str:', + ' if isinstance(value, str):', + ' return value', + ' if isinstance(value, list):', + ' return "".join(_text_from_content(item) for item in value)', + ' text = getattr(value, "content", None)', + ' if text is not None and text is not value:', + ' return _text_from_content(text)', + ' delta = getattr(value, "delta", None)', + ' if delta is not None:', + ' return _text_from_content(getattr(delta, "content", ""))', + ' if isinstance(value, dict):', + ' return _text_from_content(value.get("content") or value.get("text") or "")', + ' return "" if value is None else str(value)', + '', + '', + 'def _release_stream(stream: object) -> None:', + ' if stream is None or getattr(stream, "_gateway_released", False):', + ' return', + ' try:', + ' object.__setattr__(stream, "_gateway_released", True)', + ' except Exception: # noqa: BLE001 - release flag is best-effort', + ' pass', + ' generator = getattr(stream, "generator", None)', + ' iterator_close = getattr(generator, "close", None) if generator is not None else None', + ' if callable(iterator_close):', + ' try:', + ' iterator_close()', + ' except Exception: # noqa: BLE001 - cleanup must not hide the original error', + ' pass', + ' return', + ' closer = getattr(stream, "close", None)', + ' if callable(closer):', + ' try:', + ' closer()', + ' except Exception: # noqa: BLE001 - cleanup must not hide the original error', + ' pass', + ' return', + ' cancel = getattr(stream, "cancel", None)', + ' if callable(cancel):', + ' try:', + ' cancel()', + ' except Exception: # noqa: BLE001 - cleanup must not hide the original error', + ' pass', + '', + '', + 'def _is_timeout(error: BaseException) -> bool:', + ' return isinstance(error, (TimeoutError, httpx.TimeoutException, httpx.ReadTimeout, httpx.ConnectTimeout))', + '', + '', + 'class OpenRouterModelGateway:', + ' def __init__(self, config: GatewayConfig | None = None, client: httpx.Client | None = None) -> None:', + ' self._config = config or load_gateway_config()', + ' self._closed = False', + ' kwargs: dict[str, Any] = {', + ' "api_key": self._config.api_key,', + ' "http_referer": self._config.http_referer,', + ' "x_open_router_title": self._config.x_open_router_title,', + ' "timeout_ms": self._config.timeout_ms,', + ' "retry_config": NO_RETRIES,', + ' }', + ' if client is not None:', + ' kwargs["client"] = client', + ' self._sdk = OpenRouter(**kwargs)', + '', + ' def __enter__(self) -> OpenRouterModelGateway:', + ' return self', + '', + ' def __exit__(self, exc_type, exc, tb) -> None:', + ' self.close()', + '', + ' def close(self) -> None:', + ' if self._closed:', + ' return', + ' self._closed = True', + ' closer = getattr(self._sdk, "close", None)', + ' if callable(closer):', + ' closer()', + ' return', + ' exit_fn = getattr(self._sdk, "__exit__", None)', + ' if callable(exit_fn):', + ' exit_fn(None, None, None)', + '', + ' def _assert_open(self) -> None:', + ' if self._closed:', + ' raise GatewayConfigurationError("OpenRouterModelGateway is closed.")', + '', + ' def _normalize(self, error: BaseException) -> BaseException:', + ' if isinstance(error, GatewayConfigurationError) or _is_timeout(error):', + ' return error', + ' return to_gateway_upstream_error(error)', + '', + ' def generate(self, messages: list[dict[str, str]]) -> str:', + ' self._assert_open()', + ' if not messages:', + ' raise GatewayConfigurationError("messages must not be empty.")', + ' try:', + ' result = self._sdk.chat.send(', + ' messages=messages,', + ' model=self._config.model,', + ' models=self._config.models or None,', + ' provider=self._config.provider,', + ' stream=False,', + ' timeout_ms=self._config.timeout_ms,', + ' retries=NO_RETRIES,', + ' )', + ' choices = getattr(result, "choices", None) or []', + ' message = getattr(choices[0], "message", None) if choices else None', + ' return _text_from_content(getattr(message, "content", ""))', + ' except GatewayConfigurationError:', + ' raise', + ' except Exception as error: # noqa: BLE001 - normalize upstream SDK errors', + ' raised = self._normalize(error)', + ' if raised is error:', + ' raise', + ' raise raised from error', + '', + ' def stream(self, messages: list[dict[str, str]]) -> Iterator[str]:', + ' self._assert_open()', + ' if not messages:', + ' raise GatewayConfigurationError("messages must not be empty.")', + ' opened: object | None = None', + ' try:', + ' opened = self._sdk.chat.send(', + ' messages=messages,', + ' model=self._config.model,', + ' models=self._config.models or None,', + ' provider=self._config.provider,', + ' stream=True,', + ' timeout_ms=self._config.timeout_ms,', + ' retries=NO_RETRIES,', + ' )', + ' for event in opened:', + ' text = _text_from_content(event)', + ' if not text:', + ' choices = getattr(event, "choices", None) or []', + ' if choices:', + ' text = _text_from_content(choices[0])', + ' if text:', + ' yield text', + ' except GatewayConfigurationError:', + ' raise', + ' except Exception as error: # noqa: BLE001 - normalize open and iteration errors', + ' raised = self._normalize(error)', + ' if raised is error:', + ' raise', + ' raise raised from error', + ' finally:', + ' _release_stream(opened)', + ]); +} + +function renderMainModule(): string { + return lines([ + 'from __future__ import annotations', + '', + 'import json', + 'import os', + 'import sys', + '', + 'import httpx', + '', + 'from .config import load_gateway_config', + 'from .errors import GatewayConfigurationError, map_upstream_error', + 'from .gateway import OpenRouterModelGateway', + '', + '', + 'def _prompt() -> str:', + ' args = [arg for arg in sys.argv[1:] if arg != "--smoke"]', + ' return " ".join(args).strip() or "Reply with a short acknowledgement."', + '', + '', + 'def _chat_completion_payload(content: str, model: str = "openrouter/test-model") -> dict:', + ' return {', + ' "id": "smoke-non-stream",', + ' "created": 1,', + ' "model": model,', + ' "object": "chat.completion",', + ' "system_fingerprint": None,', + ' "choices": [{"finish_reason": "stop", "index": 0, "message": {"role": "assistant", "content": content}}],', + ' }', + '', + '', + 'def _chat_stream_sse(parts: list[str]) -> bytes:', + ' events = []', + ' for index, content in enumerate(parts):', + ' payload = json.dumps(', + ' {', + ' "id": "smoke-stream",', + ' "created": 1,', + ' "model": "openrouter/test-model",', + ' "object": "chat.completion.chunk",', + ' "choices": [', + ' {', + ' "delta": {"content": content},', + ' "finish_reason": "stop" if index == len(parts) - 1 else None,', + ' "index": 0,', + ' }', + ' ],', + ' }', + ' )', + ' events.append(f"data: {payload}\\n\\n")', + ' events.append("data: [DONE]\\n\\n")', + ' return "".join(events).encode("utf-8")', + '', + '', + 'def _smoke_handler(request: httpx.Request) -> httpx.Response:', + ' body = request.content.decode("utf-8")', + ' if \'"stream": true\' in body or \'"stream":true\' in body:', + ' return httpx.Response(200, headers={"content-type": "text/event-stream", "x-request-id": "smoke-stream"}, content=_chat_stream_sse(["smoke"]))', + ' parsed = json.loads(body) if body else {}', + ' return httpx.Response(', + ' 200,', + ' headers={"content-type": "application/json", "x-request-id": "smoke-id"},', + ' json=_chat_completion_payload("smoke-ok", parsed.get("model") or "openrouter/test-model"),', + ' )', + '', + '', + 'def run_smoke() -> None:', + ' os.environ.setdefault("OPENROUTER_API_KEY", "test-openrouter-key")', + ' os.environ.setdefault("OPENROUTER_MODEL", "openrouter/test-model")', + ' transport = httpx.MockTransport(_smoke_handler)', + ' with httpx.Client(transport=transport) as http_client:', + ' with OpenRouterModelGateway(config=load_gateway_config(), client=http_client) as gateway:', + ' generated = gateway.generate([{"role": "user", "content": "ping"}])', + ' streamed = "".join(gateway.stream([{"role": "user", "content": "ping"}]))', + ' sys.stdout.write(f"{generated}:{streamed}\\n")', + '', + '', + 'def main() -> None:', + ' if "--smoke" in sys.argv:', + ' run_smoke()', + ' return', + ' try:', + ' with OpenRouterModelGateway() as gateway:', + ' wrote = False', + ' for chunk in gateway.stream([{"role": "user", "content": _prompt()}]):', + ' sys.stdout.write(chunk)', + ' wrote = True', + ' if not wrote:', + ' sys.stdout.write(gateway.generate([{"role": "user", "content": _prompt()}]))', + ' sys.stdout.write("\\n")', + ' except GatewayConfigurationError as error:', + ' sys.stderr.write(str(error) + "\\n")', + ' raise SystemExit(1) from error', + ' except Exception as error: # noqa: BLE001 - CLI boundary', + ' mapped = map_upstream_error(error)', + ' sys.stderr.write(mapped.message + "\\n")', + ' raise SystemExit(1) from error', + '', + '', + 'if __name__ == "__main__":', + ' main()', + ]); +} + +function renderInit(): string { + return lines([ + 'from .config import load_gateway_config, to_sdk_provider', + 'from .errors import GatewayConfigurationError, GatewayUpstreamError, map_upstream_error', + 'from .gateway import OpenRouterModelGateway', + 'from .redact import redact_secret_shaped_values', + '', + '__all__ = [', + ' "GatewayConfigurationError",', + ' "GatewayUpstreamError",', + ' "OpenRouterModelGateway",', + ' "load_gateway_config",', + ' "map_upstream_error",', + ' "redact_secret_shaped_values",', + ' "to_sdk_provider",', + ']', + ]); +} + +function renderRootMain(): string { + return lines([ + 'from model_gateway.main import main', + '', + 'if __name__ == "__main__":', + ' main()', + ]); +} + +function renderTests(): string { + return lines([ + 'from __future__ import annotations', + '', + 'import json', + 'import os', + 'import tempfile', + 'import unittest', + 'from pathlib import Path', + '', + 'import httpx', + '', + 'from model_gateway.config import load_gateway_config, load_policy_file, to_sdk_provider', + 'from model_gateway.errors import GatewayConfigurationError, GatewayUpstreamError, map_upstream_error', + 'from model_gateway.gateway import OpenRouterModelGateway', + 'from model_gateway.redact import redact_secret_shaped_values', + '', + 'ROOT = Path(__file__).resolve().parents[1]', + `PARITY_CASES = json.loads(${JSON.stringify(JSON.stringify(OPENROUTER_POLICY_PARITY_CASES))})`, + '', + '', + 'def chat_completion_payload(content: str, model: str = "openrouter/test-model") -> dict:', + ' return {', + ' "id": "gen-1",', + ' "created": 1,', + ' "model": model,', + ' "object": "chat.completion",', + ' "system_fingerprint": None,', + ' "choices": [{"finish_reason": "stop", "index": 0, "message": {"role": "assistant", "content": content}}],', + ' }', + '', + '', + 'def chat_stream_sse(parts: list[str]) -> bytes:', + ' events = []', + ' for index, content in enumerate(parts):', + ' payload = json.dumps(', + ' {', + ' "id": "stream-1",', + ' "created": 1,', + ' "model": "openrouter/test-model",', + ' "object": "chat.completion.chunk",', + ' "choices": [', + ' {', + ' "delta": {"content": content},', + ' "finish_reason": "stop" if index == len(parts) - 1 else None,', + ' "index": 0,', + ' }', + ' ],', + ' }', + ' )', + ' events.append(f"data: {payload}\\n\\n")', + ' events.append("data: [DONE]\\n\\n")', + ' return "".join(events).encode("utf-8")', + '', + '', + 'class GatewayTests(unittest.TestCase):', + ' def setUp(self) -> None:', + ' os.environ["OPENROUTER_API_KEY"] = "sk-or-test"', + ' os.environ["OPENROUTER_MODEL"] = "openrouter/test-model"', + '', + ' def tearDown(self) -> None:', + ' os.environ.pop("OPENROUTER_TIMEOUT_MS", None)', + '', + ' def test_redacts_secrets(self) -> None:', + ' os.environ["OPENROUTER_API_KEY"] = "sk-or-live-secret"', + ' raw = "Authorization: Bearer sk-or-live-secret https://example.test/v1?api_key=sk-or-live-secret"', + ' redacted = redact_secret_shaped_values(raw)', + ' self.assertNotIn("sk-or-live-secret", redacted)', + ' self.assertIn("[redacted]", redacted)', + '', + ' def test_config_fails_before_network(self) -> None:', + ' os.environ.pop("OPENROUTER_API_KEY", None)', + ' with self.assertRaises(GatewayConfigurationError):', + ' load_gateway_config(root=ROOT)', + '', + ' def test_overlapping_provider_lists_fail(self) -> None:', + ' with self.assertRaisesRegex(GatewayConfigurationError, "overlap"):', + ' load_gateway_config(policy={"provider": {"only": ["openai"], "ignore": ["openai"]}})', + '', + ' def test_python_provider_field_names(self) -> None:', + ' mapped = to_sdk_provider(', + ' {', + ' "allow_fallbacks": False,', + ' "zdr": True,', + ' "data_collection": "deny",', + ' "sort": "latency",', + ' "order": ["anthropic"],', + ' }', + ' )', + ' self.assertEqual(', + ' mapped,', + ' {', + ' "allow_fallbacks": False,', + ' "zdr": True,', + ' "data_collection": "deny",', + ' "sort": "latency",', + ' "order": ["anthropic"],', + ' },', + ' )', + '', + ' def test_generate_uses_injected_transport(self) -> None:', + ' recorded: list[str] = []', + '', + ' def handler(request: httpx.Request) -> httpx.Response:', + ' recorded.append(request.content.decode("utf-8"))', + ' return httpx.Response(200, json=chat_completion_payload("ok"))', + '', + ' config = load_gateway_config(', + ' policy={"provider": {"zdr": True, "data_collection": "deny"}, "models": ["openrouter/fallback"]}', + ' )', + ' with httpx.Client(transport=httpx.MockTransport(handler)) as http_client:', + ' with OpenRouterModelGateway(config=config, client=http_client) as gateway:', + ' text = gateway.generate([{"role": "user", "content": "hi"}])', + ' self.assertEqual(text, "ok")', + ' payload = json.loads(recorded[0])', + ' self.assertEqual(payload["model"], "openrouter/test-model")', + ' self.assertEqual(payload.get("models"), ["openrouter/fallback"])', + ' self.assertTrue(payload["provider"]["zdr"])', + ' self.assertEqual(payload["provider"]["data_collection"], "deny")', + '', + ' def test_stream_closes_event_stream(self) -> None:', + ' def handler(request: httpx.Request) -> httpx.Response:', + ' return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=chat_stream_sse(["hel", "lo"]))', + '', + ' with httpx.Client(transport=httpx.MockTransport(handler)) as http_client:', + ' with OpenRouterModelGateway(config=load_gateway_config(policy={}), client=http_client) as gateway:', + ' text = "".join(gateway.stream([{"role": "user", "content": "hi"}]))', + ' self.assertTrue("hel" in text or "hello" in text)', + '', + ' def test_maps_status_and_request_id(self) -> None:', + ' response = httpx.Response(', + ' 429,', + ' headers={"authorization": "Bearer sk-or-secret", "x-request-id": "req-123", "retry-after": "2"},', + ' text="Authorization: Bearer sk-or-secret",', + ' )', + '', + ' class FakeError(Exception):', + ' def __init__(self) -> None:', + ' super().__init__("Authorization: Bearer sk-or-secret failed")', + ' self.status_code = 429', + ' self.headers = response.headers', + '', + ' mapped = map_upstream_error(FakeError())', + ' self.assertEqual(mapped.status_code, 429)', + ' self.assertEqual(mapped.request_id, "req-123")', + ' self.assertEqual(mapped.retry_after, "2")', + ' self.assertNotIn("sk-or-secret", mapped.message)', + ' self.assertEqual(mapped.safe_headers.get("authorization") or mapped.safe_headers.get("Authorization"), "[redacted]")', + '', + ' def test_timeout_from_env(self) -> None:', + ' os.environ["OPENROUTER_TIMEOUT_MS"] = "1500"', + ' config = load_gateway_config(policy={})', + ' self.assertEqual(config.timeout_ms, 1500)', + '', + ' def test_policy_file_has_no_secrets(self) -> None:', + ' policy = (ROOT / "gateway.policy.json").read_text(encoding="utf-8")', + ' self.assertNotIn("sk-", policy)', + ' self.assertNotIn("OPENROUTER_API_KEY", policy)', + '', + ' def test_rejects_invalid_policy_categories(self) -> None:', + ' cases = [', + ' ([], "JSON object"),', + ' ({"widget": True}, "unknown field"),', + ' ({"models": "openai/gpt"}, "models must be an array"),', + ' ({"models": [" "]}, "non-empty string"),', + ' ({"models": ["a", "a"]}, "duplicates"),', + ' ({"models": ["openrouter/test-model"]}, "repeat OPENROUTER_MODEL"),', + ' ({"provider": "openai"}, "provider must be an object"),', + ' ({"provider": {"fallbacks": True}}, "unknown field"),', + ' ({"provider": {"allow_fallbacks": "yes"}}, "boolean"),', + ' ({"provider": {"require_parameters": 1}}, "boolean"),', + ' ({"provider": {"zdr": "true"}}, "boolean"),', + ' ({"provider": {"only": ["openai"], "ignore": ["openai"]}}, "overlap"),', + ' ({"provider": {"order": ["openai"], "ignore": ["openai"]}}, "ignored providers"),', + ' ({"provider": {"data_collection": "never"}}, "allow or deny"),', + ' ({"provider": {"sort": "cheap"}}, "sort"),', + ' ({"provider": {"sort": {"by": "price", "partition": "all"}}}, "partition"),', + ' ({"provider": {"quantizations": ["int5"]}}, "quantizations"),', + ' ({"provider": {"max_price": {"prompt": 1}}}, "numeric string"),', + ' ({"provider": {"max_price": {"tokens": "1"}}}, "unknown field"),', + ' ({"provider": {"max_price": {"prompt": "-1"}}}, "numeric string"),', + ' ({"provider": {"max_price": {"prompt": "not-a-price"}}}, "numeric string"),', + ' ({"provider": {"max_price": {"prompt": "9" * 400}}}, "numeric string"),', + ' ({"provider": {"preferred_max_latency": {"p95": 1}}}, "unknown field"),', + ' ({"provider": {"preferred_min_throughput": {"p50": -1}}}, "non-negative"),', + ' ({"timeout_ms": 30.5}, "positive integer"),', + ' ({"timeout_ms": 0}, "positive integer"),', + ' ({"timeout_ms": 600001}, "positive integer"),', + ' ({"timeout_ms": "30000"}, "positive integer"),', + ' ({"http_referer": 1}, "string"),', + ' ({"provider": {"zdr": True, "data_collection": "allow"}}, "zdr"),', + ' ]', + ' for policy, needle in cases:', + ' with self.subTest(needle=needle, policy=policy):', + ' with self.assertRaises(GatewayConfigurationError) as raised:', + ' load_gateway_config(policy=policy)', + ' self.assertIn(needle, str(raised.exception))', + ' self.assertNotIsInstance(raised.exception, (AttributeError, TypeError, ValueError))', + '', + ' def test_accepts_official_provider_policy_boundaries(self) -> None:', + ' config = load_gateway_config(', + ' policy={', + ' "models": ["openrouter/fallback"],', + ' "timeout_ms": 1,', + ' "http_referer": "https://example.test",', + ' "x_open_router_title": "gateway",', + ' "provider": {', + ' "allow_fallbacks": False,', + ' "require_parameters": True,', + ' "zdr": True,', + ' "data_collection": "deny",', + ' "enforce_distillable_text": True,', + ' "only": ["anthropic"],', + ' "ignore": ["openai"],', + ' "order": ["anthropic"],', + ' "sort": {"by": "exacto", "partition": "none"},', + ' "quantizations": ["fp16"],', + ' "max_price": {"prompt": "0", "completion": "1.5", "request": "2", "image": "3", "audio": "4"},', + ' "preferred_min_throughput": {"p50": 0, "p75": 1, "p90": 2, "p99": 3},', + ' "preferred_max_latency": 1.5,', + ' },', + ' }', + ' )', + ' self.assertEqual(config.timeout_ms, 1)', + ' mapped = to_sdk_provider(config.provider)', + ' self.assertEqual(mapped["enforce_distillable_text"], True)', + ' self.assertEqual(mapped["sort"], {"by": "exacto", "partition": "none"})', + ' self.assertEqual(mapped["max_price"]["prompt"], "0")', + ' upper = load_gateway_config(policy={"timeout_ms": 600000})', + ' self.assertEqual(upper.timeout_ms, 600000)', + ' tiny = load_gateway_config(policy={"provider": {"max_price": {"prompt": "0.000001"}}})', + ' self.assertEqual(to_sdk_provider(tiny.provider)["max_price"]["prompt"], "0.000001")', + '', + ' def test_policy_parity_corpus_executes_generated_validator(self) -> None:', + ' for case in PARITY_CASES:', + ' with self.subTest(id=case["id"]):', + ' if case["decision"] == "reject":', + ' with self.assertRaises(GatewayConfigurationError) as raised:', + ' load_gateway_config(policy=case["python"])', + ' needle = case.get("needle")', + ' if needle:', + ' self.assertIn(needle, str(raised.exception))', + ' self.assertNotIsInstance(raised.exception, (AttributeError, TypeError, ValueError))', + ' else:', + ' config = load_gateway_config(policy=case["python"])', + ' mapped = to_sdk_provider(config.provider)', + ' if case["id"] == "max-price-zero":', + ' self.assertEqual(mapped["max_price"]["prompt"], "0")', + ' if case["id"] == "max-price-micro":', + ' self.assertEqual(mapped["max_price"]["prompt"], "0.000001")', + ' if case["id"] == "max-price-positive":', + ' self.assertEqual(mapped["max_price"]["completion"], "1.5")', + '', + ' def test_finite_max_price_strings_are_preserved_or_rejected(self) -> None:', + ' accepted = load_gateway_config(', + ' policy={"provider": {"max_price": {"prompt": "0", "completion": "0.000001", "request": "12.5"}}}', + ' )', + ' mapped = to_sdk_provider(accepted.provider)["max_price"]', + ' self.assertEqual(mapped["prompt"], "0")', + ' self.assertEqual(mapped["completion"], "0.000001")', + ' self.assertEqual(mapped["request"], "12.5")', + ' for value in ("-1", "not-a-price", "9" * 400, "1e6", "+1", "inf", "nan"):', + ' with self.subTest(value=value):', + ' with self.assertRaises(GatewayConfigurationError):', + ' load_gateway_config(policy={"provider": {"max_price": {"prompt": value}}})', + ' with self.assertRaises(GatewayConfigurationError):', + ' load_gateway_config(policy={"provider": {"max_price": {"tokens": "1"}}})', + '', + ' def test_rejects_non_finite_numeric_policy_values(self) -> None:', + ' with self.assertRaises(GatewayConfigurationError):', + ' load_gateway_config(policy={"provider": {"preferred_max_latency": float("inf")}})', + ' with self.assertRaises(GatewayConfigurationError):', + ' load_gateway_config(policy={"provider": {"preferred_min_throughput": float("nan")}})', + ' with self.assertRaises(GatewayConfigurationError):', + ' load_gateway_config(policy={"provider": {"preferred_max_latency": {"p50": float("-inf")}}})', + '', + ' def test_missing_policy_file_is_configuration_error(self) -> None:', + ' with tempfile.TemporaryDirectory() as directory:', + ' with self.assertRaises(GatewayConfigurationError) as raised:', + ' load_policy_file(Path(directory))', + ' self.assertIn("Unable to read", str(raised.exception))', + '', + ' def test_malformed_policy_json_is_configuration_error(self) -> None:', + ' with tempfile.TemporaryDirectory() as directory:', + ' root = Path(directory)', + ' (root / "gateway.policy.json").write_text("{", encoding="utf-8")', + ' with self.assertRaises(GatewayConfigurationError):', + ' load_policy_file(root)', + '', + ' def test_empty_messages_fail_before_transport(self) -> None:', + ' called = {"value": False}', + '', + ' def handler(request: httpx.Request) -> httpx.Response:', + ' called["value"] = True', + ' return httpx.Response(200, json=chat_completion_payload("nope"))', + '', + ' with httpx.Client(transport=httpx.MockTransport(handler)) as http_client:', + ' with OpenRouterModelGateway(config=load_gateway_config(policy={}), client=http_client) as gateway:', + ' with self.assertRaises(GatewayConfigurationError):', + ' gateway.generate([])', + ' with self.assertRaises(GatewayConfigurationError):', + ' next(gateway.stream([]))', + ' self.assertFalse(called["value"])', + '', + ' def test_empty_successful_completion_returns_empty_string(self) -> None:', + ' def handler(request: httpx.Request) -> httpx.Response:', + ' return httpx.Response(200, json=chat_completion_payload(""))', + '', + ' with httpx.Client(transport=httpx.MockTransport(handler)) as http_client:', + ' with OpenRouterModelGateway(config=load_gateway_config(policy={}), client=http_client) as gateway:', + ' self.assertEqual(gateway.generate([{"role": "user", "content": "hi"}]), "")', + '', + ' def test_close_is_idempotent(self) -> None:', + ' def handler(request: httpx.Request) -> httpx.Response:', + ' return httpx.Response(200, json=chat_completion_payload("ok"))', + '', + ' with httpx.Client(transport=httpx.MockTransport(handler)) as http_client:', + ' gateway = OpenRouterModelGateway(config=load_gateway_config(policy={}), client=http_client)', + ' gateway.close()', + ' gateway.close()', + ' with self.assertRaises(GatewayConfigurationError):', + ' gateway.generate([{"role": "user", "content": "hi"}])', + '', + ' def test_stream_open_failure_is_redacted_upstream_error(self) -> None:', + ' def handler(request: httpx.Request) -> httpx.Response:', + ' return httpx.Response(', + ' 401,', + ' headers={"authorization": "Bearer sk-or-secret", "x-request-id": "req-open", "retry-after": "7", "content-type": "application/json"},', + ' json={"error": {"message": "Authorization: Bearer sk-or-secret"}},', + ' )', + '', + ' with httpx.Client(transport=httpx.MockTransport(handler)) as http_client:', + ' with OpenRouterModelGateway(config=load_gateway_config(policy={}), client=http_client) as gateway:', + ' with self.assertRaises(GatewayUpstreamError) as raised:', + ' next(gateway.stream([{"role": "user", "content": "hi"}]))', + ' self.assertEqual(raised.exception.status_code, 401)', + ' self.assertEqual(raised.exception.request_id, "req-open")', + ' self.assertEqual(raised.exception.retry_after, "7")', + ' self.assertNotIn("sk-or-secret", str(raised.exception))', + '', + ' def test_mid_stream_failure_is_normalized_and_cleaned_up(self) -> None:', + ' closed = {"value": False}', + '', + ' def body():', + ' try:', + ' yield chat_stream_sse(["hel"]).replace(b"data: [DONE]\\n\\n", b"")', + ' raise RuntimeError("Authorization: Bearer sk-or-secret mid-stream")', + ' finally:', + ' closed["value"] = True', + '', + ' def handler(request: httpx.Request) -> httpx.Response:', + ' return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=body())', + '', + ' with httpx.Client(transport=httpx.MockTransport(handler)) as http_client:', + ' with OpenRouterModelGateway(config=load_gateway_config(policy={}), client=http_client) as gateway:', + ' with self.assertRaises((GatewayUpstreamError, RuntimeError)) as raised:', + ' list(gateway.stream([{"role": "user", "content": "hi"}]))', + ' self.assertNotIn("sk-or-secret", str(raised.exception))', + ' self.assertTrue(closed["value"])', + '', + ' def test_consumer_early_termination_closes_upstream_stream(self) -> None:', + ' def handler(request: httpx.Request) -> httpx.Response:', + ' body = request.content.decode("utf-8")', + ' if \'"stream": true\' in body or \'"stream":true\' in body:', + ' return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=chat_stream_sse(["hel"]))', + ' return httpx.Response(200, json=chat_completion_payload("ok"))', + '', + ' with httpx.Client(transport=httpx.MockTransport(handler)) as http_client:', + ' with OpenRouterModelGateway(config=load_gateway_config(policy={}), client=http_client) as gateway:', + ' iterator = gateway.stream([{"role": "user", "content": "hi"}])', + ' chunk = next(iterator)', + ' self.assertIn("hel", chunk)', + ' iterator.close()', + ' self.assertEqual(gateway.generate([{"role": "user", "content": "hi"}]), "ok")', + '', + ' def _track_event_stream_cleanup(self):', + ' from openrouter.utils.eventstreaming import EventStream', + '', + ' counts = {"return": 0, "close": 0}', + ' original_init = EventStream.__init__', + ' original_close = EventStream.close', + '', + ' def tracked_init(self, *args, **kwargs):', + ' original_init(self, *args, **kwargs)', + ' inner = self.generator', + '', + ' class Probe:', + ' def __iter__(self_inner):', + ' return self_inner', + '', + ' def __next__(self_inner):', + ' return next(inner)', + '', + ' def close(self_inner):', + ' counts["return"] += 1', + ' return inner.close()', + '', + ' self.generator = Probe()', + '', + ' def tracked_close(self):', + ' counts["close"] += 1', + ' return original_close(self)', + '', + ' EventStream.__init__ = tracked_init', + ' EventStream.close = tracked_close', + ' return EventStream, original_init, original_close, counts', + '', + ' def test_normal_stream_completion_releases_iterator_once(self) -> None:', + ' EventStream, original_init, original_close, counts = self._track_event_stream_cleanup()', + '', + ' def handler(request: httpx.Request) -> httpx.Response:', + ' body = request.content.decode("utf-8")', + ' if \'"stream": true\' in body or \'"stream":true\' in body:', + ' return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=chat_stream_sse(["hel"]))', + ' return httpx.Response(200, json=chat_completion_payload("ok"))', + '', + ' try:', + ' with httpx.Client(transport=httpx.MockTransport(handler)) as http_client:', + ' with OpenRouterModelGateway(config=load_gateway_config(policy={}), client=http_client) as gateway:', + ' self.assertIn("hel", "".join(gateway.stream([{"role": "user", "content": "hi"}])))', + ' self.assertEqual(gateway.generate([{"role": "user", "content": "hi"}]), "ok")', + ' finally:', + ' EventStream.__init__ = original_init', + ' EventStream.close = original_close', + ' self.assertEqual(counts["return"], 1)', + ' self.assertEqual(counts["close"], 0)', + '', + ' def test_early_generator_close_releases_iterator_once(self) -> None:', + ' EventStream, original_init, original_close, counts = self._track_event_stream_cleanup()', + '', + ' def handler(request: httpx.Request) -> httpx.Response:', + ' body = request.content.decode("utf-8")', + ' if \'"stream": true\' in body or \'"stream":true\' in body:', + ' return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=chat_stream_sse(["hel", "lo"]))', + ' return httpx.Response(200, json=chat_completion_payload("ok"))', + '', + ' try:', + ' with httpx.Client(transport=httpx.MockTransport(handler)) as http_client:', + ' with OpenRouterModelGateway(config=load_gateway_config(policy={}), client=http_client) as gateway:', + ' iterator = gateway.stream([{"role": "user", "content": "hi"}])', + ' self.assertIn("hel", next(iterator))', + ' iterator.close()', + ' iterator.close()', + ' self.assertEqual(gateway.generate([{"role": "user", "content": "hi"}]), "ok")', + ' finally:', + ' EventStream.__init__ = original_init', + ' EventStream.close = original_close', + ' self.assertEqual(counts["return"], 1)', + ' self.assertEqual(counts["close"], 0)', + '', + ' def test_mid_stream_error_releases_iterator_once(self) -> None:', + ' EventStream, original_init, original_close, counts = self._track_event_stream_cleanup()', + '', + ' def body():', + ' yield chat_stream_sse(["hel"]).replace(b"data: [DONE]\\n\\n", b"")', + ' raise RuntimeError("Authorization: Bearer sk-or-secret mid-stream")', + '', + ' def handler(request: httpx.Request) -> httpx.Response:', + ' body_text = request.content.decode("utf-8")', + ' if \'"stream": true\' in body_text or \'"stream":true\' in body_text:', + ' return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=body())', + ' return httpx.Response(200, json=chat_completion_payload("ok"))', + '', + ' try:', + ' with httpx.Client(transport=httpx.MockTransport(handler)) as http_client:', + ' with OpenRouterModelGateway(config=load_gateway_config(policy={}), client=http_client) as gateway:', + ' with self.assertRaises((GatewayUpstreamError, RuntimeError)) as raised:', + ' list(gateway.stream([{"role": "user", "content": "hi"}]))', + ' self.assertNotIn("sk-or-secret", str(raised.exception))', + ' self.assertEqual(gateway.generate([{"role": "user", "content": "hi"}]), "ok")', + ' finally:', + ' EventStream.__init__ = original_init', + ' EventStream.close = original_close', + ' self.assertEqual(counts["return"], 1)', + ' self.assertEqual(counts["close"], 0)', + '', + ' def test_cleanup_failure_does_not_mask_upstream_or_success(self) -> None:', + ' from openrouter.utils.eventstreaming import EventStream', + '', + ' original_init = EventStream.__init__', + '', + ' def tracked_init(self, *args, **kwargs):', + ' original_init(self, *args, **kwargs)', + ' inner = self.generator', + '', + ' class Probe:', + ' def __iter__(self_inner):', + ' return self_inner', + '', + ' def __next__(self_inner):', + ' return next(inner)', + '', + ' def close(self_inner):', + ' inner.close()', + ' raise RuntimeError("cleanup-failed")', + '', + ' self.generator = Probe()', + '', + ' EventStream.__init__ = tracked_init', + ' try:', + ' def success_handler(request: httpx.Request) -> httpx.Response:', + ' return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=chat_stream_sse(["hel"]))', + '', + ' def failing_body():', + ' yield chat_stream_sse(["hel"]).replace(b"data: [DONE]\\n\\n", b"")', + ' raise RuntimeError("Authorization: Bearer sk-or-secret mid-stream")', + '', + ' def fail_handler(request: httpx.Request) -> httpx.Response:', + ' return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=failing_body())', + '', + ' with httpx.Client(transport=httpx.MockTransport(success_handler)) as http_client:', + ' with OpenRouterModelGateway(config=load_gateway_config(policy={}), client=http_client) as gateway:', + ' self.assertIn("hel", "".join(gateway.stream([{"role": "user", "content": "hi"}])))', + ' with httpx.Client(transport=httpx.MockTransport(fail_handler)) as http_client:', + ' with OpenRouterModelGateway(config=load_gateway_config(policy={}), client=http_client) as gateway:', + ' with self.assertRaises((GatewayUpstreamError, RuntimeError)) as raised:', + ' list(gateway.stream([{"role": "user", "content": "hi"}]))', + ' self.assertNotIn("sk-or-secret", str(raised.exception))', + ' self.assertNotIn("cleanup-failed", str(raised.exception))', + ' finally:', + ' EventStream.__init__ = original_init', + '', + ' def test_timeout_errors_remain_recognizable(self) -> None:', + ' class TimeoutTransport(httpx.BaseTransport):', + ' def handle_request(self, request: httpx.Request) -> httpx.Response:', + ' raise httpx.TimeoutException("Request timed out")', + '', + ' with httpx.Client(transport=TimeoutTransport()) as http_client:', + ' with OpenRouterModelGateway(config=load_gateway_config(policy={}), client=http_client) as gateway:', + ' with self.assertRaises(httpx.TimeoutException):', + ' gateway.generate([{"role": "user", "content": "hi"}])', + ' with self.assertRaises(httpx.TimeoutException):', + ' next(gateway.stream([{"role": "user", "content": "hi"}]))', + '', + ' def test_policy_root_does_not_require_process_cwd(self) -> None:', + ' from model_gateway.config import default_policy_root', + '', + ' resolved = default_policy_root()', + ' self.assertTrue((resolved / "gateway.policy.json").is_file())', + '', + '', + 'if __name__ == "__main__":', + ' unittest.main()', + ]); +} + +export async function generateOpenRouterPythonGateway(input: { + projectPath: string; + projectName: string; +}): Promise { + const name = pythonDistributionName(input.projectName); + await writeGeneratorFile(path.join(input.projectPath, 'pyproject.toml'), renderPyproject(name)); + await writeGeneratorFile(path.join(input.projectPath, OPENROUTER_POLICY_FILE), renderPolicy()); + await writeGeneratorFile(path.join(input.projectPath, '.env.example'), renderEnvExample()); + await writeGeneratorFile(path.join(input.projectPath, '.gitignore'), GITIGNORE); + await writeGeneratorFile( + path.join(input.projectPath, 'README.md'), + renderReadme(input.projectName) + ); + await writeGeneratorFile(path.join(input.projectPath, 'main.py'), renderRootMain()); + await writeGeneratorFile( + path.join(input.projectPath, 'src', 'model_gateway', '__init__.py'), + renderInit() + ); + await writeGeneratorFile( + path.join(input.projectPath, 'src', 'model_gateway', 'redact.py'), + renderRedact() + ); + await writeGeneratorFile( + path.join(input.projectPath, 'src', 'model_gateway', 'errors.py'), + renderErrors() + ); + await writeGeneratorFile( + path.join(input.projectPath, 'src', 'model_gateway', 'port.py'), + renderPort() + ); + await writeGeneratorFile( + path.join(input.projectPath, 'src', 'model_gateway', 'config.py'), + renderConfig() + ); + await writeGeneratorFile( + path.join(input.projectPath, 'src', 'model_gateway', 'gateway.py'), + renderGateway() + ); + await writeGeneratorFile( + path.join(input.projectPath, 'src', 'model_gateway', 'main.py'), + renderMainModule() + ); + await writeGeneratorFile(path.join(input.projectPath, 'tests', 'test_gateway.py'), renderTests()); +} diff --git a/packages/cli/src/model-gateways/adapters/openrouter/typescript.ts b/packages/cli/src/model-gateways/adapters/openrouter/typescript.ts new file mode 100644 index 00000000..82277d14 --- /dev/null +++ b/packages/cli/src/model-gateways/adapters/openrouter/typescript.ts @@ -0,0 +1,1808 @@ +import path from 'node:path'; + +import { writeGeneratorFile } from '../../../generators/go-kit-common.js'; +import { npmSafeName } from '../../gateway.js'; +import { getModelGatewayVersionBaseline, packageVersion } from '../../version-policy.js'; +import { + DEFAULT_GATEWAY_TIMEOUT_MS, + GITIGNORE, + MAX_GATEWAY_TIMEOUT_MS, + OPENROUTER_DATA_COLLECTION_VALUES, + OPENROUTER_MAX_PRICE_KEYS, + OPENROUTER_PERCENTILE_KEYS, + OPENROUTER_POLICY_FILE, + OPENROUTER_QUANTIZATION_VALUES, + OPENROUTER_SORT_PARTITION_VALUES, + OPENROUTER_SORT_VALUES, +} from './common.js'; +import { OPENROUTER_POLICY_PARITY_CASES } from './policy-parity.js'; + +const BASELINE = getModelGatewayVersionBaseline('openrouter-typescript'); +if (!BASELINE) { + throw new Error('Missing OpenRouter TypeScript gateway baseline.'); +} + +const SDK_VERSION = packageVersion(BASELINE, '@openrouter/sdk'); +const TYPESCRIPT_VERSION = packageVersion(BASELINE, 'typescript'); +const TYPES_NODE_VERSION = packageVersion(BASELINE, '@types/node'); + +export function openRouterTypeScriptSdkVersion(): string { + return SDK_VERSION; +} + +function lines(value: string[]): string { + return `${value.join('\n')}\n`; +} + +function renderPackageJson(name: string): string { + return `${JSON.stringify( + { + name, + version: '0.1.0', + private: true, + type: 'module', + engines: { node: '>=20' }, + scripts: { + build: 'tsc --pretty false', + typecheck: 'tsc --noEmit --pretty false', + test: 'tsc --pretty false && node --test dist/tests/gateway.test.js', + start: 'tsc --pretty false && node dist/src/main.js', + smoke: 'tsc --pretty false && node dist/src/main.js --smoke', + }, + dependencies: { + '@openrouter/sdk': SDK_VERSION, + }, + devDependencies: { + '@types/node': TYPES_NODE_VERSION, + typescript: TYPESCRIPT_VERSION, + }, + }, + null, + 2 + )}\n`; +} + +function renderTsconfig(): string { + return `${JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + lib: ['ES2022', 'DOM'], + module: 'Node16', + moduleResolution: 'Node16', + strict: true, + esModuleInterop: true, + skipLibCheck: true, + types: ['node'], + outDir: 'dist', + rootDir: '.', + }, + include: ['src/**/*.ts', 'tests/**/*.ts'], + }, + null, + 2 + )}\n`; +} + +function renderPolicy(): string { + return `${JSON.stringify( + { + models: [], + provider: { + allowFallbacks: null, + order: null, + only: null, + ignore: null, + sort: null, + quantizations: null, + requireParameters: null, + zdr: null, + dataCollection: null, + enforceDistillableText: null, + maxPrice: null, + preferredMinThroughput: null, + preferredMaxLatency: null, + }, + timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS, + httpReferer: null, + appTitle: null, + }, + null, + 2 + )}\n`; +} + +function renderEnvExample(): string { + return lines([ + '# Required. Server-side only. Never expose this value to browsers, frontend', + '# environment prefixes, client bundles, or committed files.', + 'OPENROUTER_API_KEY=', + '', + '# Required. Choose a model slug at runtime. Workspai does not select a billable', + '# model for you. List current slugs at https://openrouter.ai/models', + 'OPENROUTER_MODEL=', + '', + '# Optional application attribution. These map to the official TypeScript SDK', + '# fields httpReferer and appTitle.', + 'OPENROUTER_HTTP_REFERER=', + 'OPENROUTER_APP_TITLE=', + '', + '# Optional timeout in milliseconds. Defaults to 30000 when omitted.', + 'OPENROUTER_TIMEOUT_MS=', + ]); +} + +function renderReadme(projectName: string): string { + return lines([ + `# ${projectName}`, + '', + 'This is a server-owned OpenRouter Model Gateway generated by Workspai.', + 'It is an AI Gateway, not an agent runtime. OpenRouter owns provider and', + 'model routing. Workspai owns generation, lifecycle, and verification.', + '', + 'The official Client SDK is `@openrouter/sdk` `' + SDK_VERSION + '`.', + 'A model identifier is runtime configuration, not a project kit.', + '', + '## Security', + '', + '- Keep `OPENROUTER_API_KEY` on the server. Do not put it in browser bundles,', + ' `VITE_`, `NEXT_PUBLIC_`, or other client-side environment prefixes.', + '- Diagnostics redact Authorization headers, API keys, URL secrets, and request bodies.', + '- Configuration is validated before any network request.', + '- This project does not disable TLS verification and does not enable SDK debug logging.', + '- Privacy and routing policy live in `gateway.policy.json` so they stay auditable.', + '- When a privacy or routing field is omitted, OpenRouter upstream defaults remain in', + ' effect. The SDK documents `dataCollection` default as `allow` when unset.', + '', + '## Configure', + '', + '1. Copy `.env.example` into your secret store or shell. Do not commit `.env`.', + '2. Set `OPENROUTER_API_KEY` and `OPENROUTER_MODEL`.', + '3. Edit `gateway.policy.json` for official TypeScript SDK provider controls:', + ' fallbacks, order/allow/deny, sort (`price`/`throughput`/`latency`/`exacto`,', + ' optional `{ by, partition }` where `partition` is `model` or `none`),', + ' quantizations, requireParameters, ZDR, dataCollection, enforceDistillableText,', + ' maxPrice, and preferred throughput or latency. Unknown fields are rejected.', + '', + 'Contradictory policy fails before a request is sent. ZDR and `dataCollection: "deny"`', + 'are never silently dropped. Application-side retries are disabled so they cannot', + 'duplicate billable requests; OpenRouter still applies its own provider fallbacks', + 'when `allowFallbacks` is not false. An empty successful completion returns an empty', + 'string. Timeout must be a bounded positive integer (1 to 600000 ms). `maxPrice`', + 'values must be numeric strings that stay finite after `Number(...)`; overflow-to-', + 'infinity strings are rejected. Streaming cleanup prefers async iterator `return()`,', + 'then SDK `close()`, then `cancel()`, and never invokes two of those on the same', + 'stream. Pinned `@openrouter/sdk` `EventStream` extends `ReadableStream` and has no', + '`close()`; if a stream already errored, `cancel()` may not run.', + '', + 'Capabilities such as tools, JSON schema, streaming, images, audio, and embeddings', + 'vary by model and provider. This starter only claims chat generation and streaming.', + '', + '## Commands', + '', + 'Run these from the project root after installing dependencies locally:', + '', + '```bash', + 'npm install', + 'npm test', + 'npm run typecheck', + 'npm run build', + 'npm run smoke', + 'npm start', + '```', + '', + '`npm test` and `npm run smoke` inject a fake transport. They do not call OpenRouter', + 'and do not require `OPENROUTER_API_KEY`. `npm start` is the live entrypoint and', + 'requires credentials.', + '', + 'Workspace Run uses the same scripts: `init` -> `npm install`, `test` -> `npm test`,', + '`build` -> `npm run build`, `start` -> `npm start`.', + '', + '## Future composition', + '', + 'An agent runtime can later consume this Model Gateway port. Do not copy these', + 'templates into an Agent Framework kit; compose against the port instead.', + ]); +} + +function renderRedact(): string { + return lines([ + 'export function redactSecretShapedValues(value: string): string {', + ' let output = value;', + ' const liveKey = process.env.OPENROUTER_API_KEY;', + ' if (liveKey && liveKey.length > 0) {', + " output = output.split(liveKey).join('[redacted]');", + ' }', + ' return output', + ' .replace(/(?:sk-or-[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]{8,})/gi, "[redacted]")', + ' .replace(/Bearer\\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")', + ' .replace(/authorization\\s*[:=]\\s*["\\\']?[^"\\\'\\s]+/gi, "Authorization: [redacted]")', + ' .replace(/([?&](?:api[_-]?key|token|secret|password)=)[^&\\s]+/gi, "$1[redacted]");', + '}', + '', + 'function isHeaderMap(value: Headers | Record): value is Headers {', + ' return typeof (value as Headers).forEach === "function" && typeof (value as Headers).get === "function";', + '}', + '', + 'export function redactHeaders(headers: Headers | Record): Record {', + ' const out: Record = {};', + ' const assign = (key: string, value: string): void => {', + ' out[key] = key.toLowerCase() === "authorization" ? "[redacted]" : redactSecretShapedValues(value);', + ' };', + ' if (isHeaderMap(headers)) {', + ' headers.forEach((value, key) => assign(key, value));', + ' return out;', + ' }', + ' for (const [key, value] of Object.entries(headers)) {', + ' assign(key, value);', + ' }', + ' return out;', + '}', + ]); +} + +function renderErrors(): string { + return lines([ + "import { redactHeaders, redactSecretShapedValues } from './redact.js';", + '', + 'export type GatewayError = {', + ' message: string;', + ' statusCode?: number;', + ' requestId?: string;', + ' retryAfter?: string;', + ' safeHeaders?: Record;', + '};', + '', + 'export class GatewayConfigurationError extends Error {', + ' constructor(message: string) {', + ' super(message);', + ' this.name = "GatewayConfigurationError";', + ' }', + '}', + '', + 'export class GatewayUpstreamError extends Error {', + ' readonly statusCode?: number;', + ' readonly requestId?: string;', + ' readonly retryAfter?: string;', + ' constructor(mapped: GatewayError) {', + ' const suffix = mapped.statusCode ? ` (status ${mapped.statusCode})` : "";', + ' const request = mapped.requestId ? ` request ${mapped.requestId}` : "";', + ' super(`${mapped.message}${suffix}${request}`);', + ' this.name = "GatewayUpstreamError";', + ' this.statusCode = mapped.statusCode;', + ' this.requestId = mapped.requestId;', + ' this.retryAfter = mapped.retryAfter;', + ' }', + '}', + '', + 'function isRecord(value: unknown): value is Record {', + ' return Boolean(value) && typeof value === "object";', + '}', + '', + 'function readHeaderMap(value: unknown): Record | undefined {', + ' if (!value || typeof value !== "object") return undefined;', + ' if (typeof (value as Headers).forEach === "function" && typeof (value as Headers).get === "function") {', + ' return redactHeaders(value as Headers);', + ' }', + ' const out: Record = {};', + ' for (const [key, entry] of Object.entries(value as Record)) {', + ' if (typeof entry === "string") out[key] = entry;', + ' }', + ' return Object.keys(out).length > 0 ? redactHeaders(out) : undefined;', + '}', + '', + 'function headerLookup(headers: Record | undefined, name: string): string | undefined {', + ' if (!headers) return undefined;', + ' const wanted = name.toLowerCase();', + ' for (const [key, value] of Object.entries(headers)) {', + ' if (key.toLowerCase() === wanted) return value;', + ' }', + ' return undefined;', + '}', + '', + 'function numericStatus(value: unknown): number | undefined {', + ' return typeof value === "number" && Number.isInteger(value) ? value : undefined;', + '}', + '', + 'export function mapUpstreamError(error: unknown): GatewayError {', + ' const record = isRecord(error) ? error : {};', + ' const cause = isRecord(record.cause) ? record.cause : {};', + ' const httpMeta = isRecord(record.httpMeta)', + ' ? record.httpMeta', + ' : isRecord(record.HttpMeta)', + ' ? record.HttpMeta', + ' : {};', + ' const response = isRecord(httpMeta.Response)', + ' ? httpMeta.Response', + ' : isRecord(httpMeta.response)', + ' ? httpMeta.response', + ' : {};', + ' const statusCode =', + ' numericStatus(record.statusCode) ??', + ' numericStatus(record.status) ??', + ' numericStatus(record.status_code) ??', + ' numericStatus(cause.statusCode) ??', + ' numericStatus(response.status);', + ' const headers =', + ' readHeaderMap(record.headers) ??', + ' readHeaderMap(cause.headers) ??', + ' readHeaderMap(response.headers);', + ' const requestId =', + ' headerLookup(headers, "x-request-id") ??', + ' headerLookup(headers, "x-openrouter-request-id") ??', + ' (typeof record.requestId === "string" ? record.requestId : undefined);', + ' const rawMessage = error instanceof Error ? error.message : String(error);', + ' return {', + ' message: redactSecretShapedValues(rawMessage),', + ' statusCode,', + ' requestId,', + ' retryAfter: headerLookup(headers, "retry-after"),', + ' safeHeaders: headers,', + ' };', + '}', + '', + 'export function toGatewayUpstreamError(error: unknown): GatewayUpstreamError {', + ' if (error instanceof GatewayUpstreamError) return error;', + ' return new GatewayUpstreamError(mapUpstreamError(error));', + '}', + ]); +} + +function renderPort(): string { + return lines([ + 'export type ChatTurn = {', + ' role: "system" | "user" | "assistant";', + ' content: string;', + '};', + '', + 'export type GenerateInput = {', + ' messages: ChatTurn[];', + ' signal?: AbortSignal;', + '};', + '', + 'export type GenerateResult = {', + ' text: string;', + ' model?: string;', + ' id?: string;', + '};', + '', + 'export interface ModelGateway {', + ' generate(input: GenerateInput): Promise;', + ' stream(input: GenerateInput): AsyncGenerator;', + ' close(): Promise;', + '}', + ]); +} + +function renderConfig(): string { + const sorts = OPENROUTER_SORT_VALUES.map((value) => `"${value}"`).join(' | '); + const partitions = OPENROUTER_SORT_PARTITION_VALUES.map((value) => `"${value}"`).join(' | '); + const quant = OPENROUTER_QUANTIZATION_VALUES.map((value) => `"${value}"`).join(' | '); + const data = OPENROUTER_DATA_COLLECTION_VALUES.map((value) => `"${value}"`).join(' | '); + const sortList = OPENROUTER_SORT_VALUES.map((value) => `"${value}"`).join(', '); + const partitionList = OPENROUTER_SORT_PARTITION_VALUES.map((value) => `"${value}"`).join(', '); + const quantList = OPENROUTER_QUANTIZATION_VALUES.map((value) => `"${value}"`).join(', '); + const priceKeys = OPENROUTER_MAX_PRICE_KEYS.map((value) => `"${value}"`).join(', '); + const percentileKeys = OPENROUTER_PERCENTILE_KEYS.map((value) => `"${value}"`).join(', '); + return lines([ + "import { readFileSync } from 'node:fs';", + "import { dirname, join } from 'node:path';", + "import { fileURLToPath } from 'node:url';", + "import type { ProviderPreferences } from '@openrouter/sdk/models/providerpreferences.js';", + "import { GatewayConfigurationError } from './errors.js';", + '', + 'export { GatewayConfigurationError };', + '', + `export const POLICY_FILE_NAME = ${JSON.stringify(OPENROUTER_POLICY_FILE)};`, + `export const DEFAULT_TIMEOUT_MS = ${DEFAULT_GATEWAY_TIMEOUT_MS};`, + `export const MAX_TIMEOUT_MS = ${MAX_GATEWAY_TIMEOUT_MS};`, + `export type ProviderSort = ${sorts};`, + `export type SortPartition = ${partitions};`, + `export type Quantization = ${quant};`, + `export type DataCollection = ${data};`, + `const SORT_VALUES = new Set([${sortList}]);`, + `const PARTITION_VALUES = new Set([${partitionList}]);`, + `const QUANTIZATION_VALUES = new Set([${quantList}]);`, + `const MAX_PRICE_KEYS = new Set([${priceKeys}]);`, + `const PERCENTILE_KEYS = new Set([${percentileKeys}]);`, + 'const ROOT_KEYS = new Set(["models", "provider", "timeoutMs", "httpReferer", "appTitle"]);', + 'const PROVIDER_KEYS = new Set([', + ' "allowFallbacks",', + ' "order",', + ' "only",', + ' "ignore",', + ' "sort",', + ' "quantizations",', + ' "requireParameters",', + ' "zdr",', + ' "dataCollection",', + ' "enforceDistillableText",', + ' "maxPrice",', + ' "preferredMinThroughput",', + ' "preferredMaxLatency",', + ']);', + '', + 'export type MaxPrice = {', + ' prompt?: string;', + ' completion?: string;', + ' request?: string;', + ' image?: string;', + ' audio?: string;', + '};', + '', + 'export type PercentileCutoffs = {', + ' p50?: number | null;', + ' p75?: number | null;', + ' p90?: number | null;', + ' p99?: number | null;', + '};', + '', + 'export type ProviderSortConfig = {', + ' by?: ProviderSort | null;', + ' partition?: SortPartition | null;', + '};', + '', + 'export type TypeScriptProviderPolicy = {', + ' allowFallbacks?: boolean | null;', + ' order?: string[] | null;', + ' only?: string[] | null;', + ' ignore?: string[] | null;', + ' sort?: ProviderSort | ProviderSortConfig | null;', + ' quantizations?: Quantization[] | null;', + ' requireParameters?: boolean | null;', + ' zdr?: boolean | null;', + ' dataCollection?: DataCollection | null;', + ' enforceDistillableText?: boolean | null;', + ' maxPrice?: MaxPrice | null;', + ' preferredMinThroughput?: number | PercentileCutoffs | null;', + ' preferredMaxLatency?: number | PercentileCutoffs | null;', + '};', + '', + 'export type GatewayPolicyFile = {', + ' models?: string[];', + ' provider?: TypeScriptProviderPolicy | null;', + ' timeoutMs?: number | null;', + ' httpReferer?: string | null;', + ' appTitle?: string | null;', + '};', + '', + 'export type GatewayConfig = {', + ' apiKey: string;', + ' model: string;', + ' models: string[];', + ' timeoutMs: number;', + ' httpReferer?: string;', + ' appTitle?: string;', + ' provider?: TypeScriptProviderPolicy;', + '};', + '', + 'function isRecord(value: unknown): value is Record {', + ' return Boolean(value) && typeof value === "object" && !Array.isArray(value);', + '}', + '', + 'function rejectUnknownKeys(record: Record, allowed: Set, label: string): void {', + ' const unknown = Object.keys(record).filter((key) => !allowed.has(key));', + ' if (unknown.length > 0) {', + ' throw new GatewayConfigurationError(`${label} contains unknown field(s): ${unknown.sort().join(", ")}`);', + ' }', + '}', + '', + 'function optionalBoolean(value: unknown, label: string): boolean | null | undefined {', + ' if (value === undefined || value === null) return value;', + ' if (typeof value !== "boolean") {', + ' throw new GatewayConfigurationError(`${label} must be a boolean.`);', + ' }', + ' return value;', + '}', + '', + 'function optionalString(value: unknown, label: string): string | undefined {', + ' if (value === undefined || value === null || value === "") return undefined;', + ' if (typeof value !== "string") {', + ' throw new GatewayConfigurationError(`${label} must be a string.`);', + ' }', + ' return value.trim();', + '}', + '', + 'function asStringArray(value: unknown, label: string): string[] {', + ' if (value == null) return [];', + ' if (!Array.isArray(value)) {', + ' throw new GatewayConfigurationError(`${label} must be an array of non-empty strings.`);', + ' }', + ' const items = value.map((item, index) => {', + ' if (typeof item !== "string" || item.trim() === "") {', + ' throw new GatewayConfigurationError(`${label}[${index}] must be a non-empty string.`);', + ' }', + ' return item.trim();', + ' });', + ' const seen = new Set();', + ' const duplicates: string[] = [];', + ' for (const item of items) {', + ' if (seen.has(item)) duplicates.push(item);', + ' seen.add(item);', + ' }', + ' if (duplicates.length > 0) {', + ' throw new GatewayConfigurationError(`${label} contains duplicates: ${[...new Set(duplicates)].join(", ")}`);', + ' }', + ' return items;', + '}', + '', + 'function overlap(left: string[], right: string[]): string[] {', + ' const rightSet = new Set(right);', + ' return left.filter((item) => rightSet.has(item));', + '}', + '', + 'function asPolicyInt(value: unknown, label: string): number {', + ' if (typeof value !== "number" || !Number.isInteger(value) || value <= 0 || value > MAX_TIMEOUT_MS) {', + ' throw new GatewayConfigurationError(`${label} must be a positive integer between 1 and ${MAX_TIMEOUT_MS}.`);', + ' }', + ' return value;', + '}', + '', + 'function asEnvInt(value: string, label: string): number {', + ' if (!/^[1-9]\\d*$/.test(value.trim())) {', + ' throw new GatewayConfigurationError(`${label} must be a positive integer between 1 and ${MAX_TIMEOUT_MS}.`);', + ' }', + ' return asPolicyInt(Number(value.trim()), label);', + '}', + '', + 'function asNonNegativeNumber(value: unknown, label: string): number {', + ' if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {', + ' throw new GatewayConfigurationError(`${label} must be a finite non-negative number.`);', + ' }', + ' return value;', + '}', + '', + 'function asMaxPrice(value: unknown): MaxPrice | null | undefined {', + ' if (value === undefined || value === null) return value;', + ' if (!isRecord(value)) {', + ' throw new GatewayConfigurationError("provider.maxPrice must be an object.");', + ' }', + ' rejectUnknownKeys(value, MAX_PRICE_KEYS, "provider.maxPrice");', + ' const mapped: MaxPrice = {};', + ' for (const key of ["prompt", "completion", "request", "image", "audio"] as const) {', + ' const entry = value[key];', + ' if (entry === undefined) continue;', + ' if (typeof entry !== "string" || !/^(?:0|[1-9]\\d*)(?:\\.\\d+)?$/.test(entry) || !Number.isFinite(Number(entry)) || Number(entry) < 0) {', + ' throw new GatewayConfigurationError(`provider.maxPrice.${key} must be a non-negative numeric string.`);', + ' }', + ' mapped[key] = entry;', + ' }', + ' return mapped;', + '}', + '', + 'function asPercentileOrNumber(value: unknown, label: string): number | PercentileCutoffs | null | undefined {', + ' if (value === undefined || value === null) return value;', + ' if (typeof value === "number") return asNonNegativeNumber(value, label);', + ' if (!isRecord(value)) {', + ' throw new GatewayConfigurationError(`${label} must be a number or percentile object.`);', + ' }', + ' rejectUnknownKeys(value, PERCENTILE_KEYS, label);', + ' const mapped: PercentileCutoffs = {};', + ' for (const key of ["p50", "p75", "p90", "p99"] as const) {', + ' const entry = value[key];', + ' if (entry === undefined) continue;', + ' mapped[key] = entry === null ? null : asNonNegativeNumber(entry, `${label}.${key}`);', + ' }', + ' return mapped;', + '}', + '', + 'function asSort(value: unknown): ProviderSort | ProviderSortConfig | null | undefined {', + ' if (value === undefined || value === null) return value;', + ' if (typeof value === "string") {', + ' if (!SORT_VALUES.has(value)) {', + ' throw new GatewayConfigurationError("provider.sort must be price, throughput, latency, or exacto.");', + ' }', + ' return value as ProviderSort;', + ' }', + ' if (!isRecord(value)) {', + ' throw new GatewayConfigurationError("provider.sort must be a string or { by, partition } object.");', + ' }', + ' rejectUnknownKeys(value, new Set(["by", "partition"]), "provider.sort");', + ' const by = value.by;', + ' const partition = value.partition;', + ' if (by != null && (typeof by !== "string" || !SORT_VALUES.has(by))) {', + ' throw new GatewayConfigurationError("provider.sort.by must be price, throughput, latency, or exacto.");', + ' }', + ' if (partition != null && (typeof partition !== "string" || !PARTITION_VALUES.has(partition))) {', + ' throw new GatewayConfigurationError("provider.sort.partition must be model or none.");', + ' }', + ' return {', + ' by: by == null ? by : (by as ProviderSort),', + ' partition: partition == null ? partition : (partition as SortPartition),', + ' };', + '}', + '', + 'function asQuantizations(value: unknown): Quantization[] | null | undefined {', + ' if (value === undefined || value === null) return value;', + ' const items = asStringArray(value, "provider.quantizations");', + ' for (const item of items) {', + ' if (!QUANTIZATION_VALUES.has(item)) {', + ' throw new GatewayConfigurationError(`unsupported provider.quantizations value: ${item}`);', + ' }', + ' }', + ' return items as Quantization[];', + '}', + '', + 'export function projectRootFromModule(moduleUrl: string): string {', + ' return dirname(fileURLToPath(moduleUrl));', + '}', + '', + 'export function loadPolicyFile(root: string): unknown {', + ' const policyPath = join(root, POLICY_FILE_NAME);', + ' try {', + ' return JSON.parse(readFileSync(policyPath, "utf8")) as unknown;', + ' } catch (error) {', + ' throw new GatewayConfigurationError(', + ' `Unable to read ${POLICY_FILE_NAME}: ${error instanceof Error ? error.message : String(error)}`', + ' );', + ' }', + '}', + '', + 'function parsePolicy(value: unknown): GatewayPolicyFile {', + ' if (!isRecord(value)) {', + ' throw new GatewayConfigurationError("gateway.policy.json must be a JSON object.");', + ' }', + ' rejectUnknownKeys(value, ROOT_KEYS, "gateway.policy.json");', + ' const models = value.models === undefined ? undefined : asStringArray(value.models, "models");', + ' let provider: TypeScriptProviderPolicy | null | undefined;', + ' if (value.provider === undefined || value.provider === null) {', + ' provider = value.provider;', + ' } else {', + ' if (!isRecord(value.provider)) {', + ' throw new GatewayConfigurationError("provider must be an object.");', + ' }', + ' rejectUnknownKeys(value.provider, PROVIDER_KEYS, "provider");', + ' const dataCollection = value.provider.dataCollection;', + ' if (dataCollection != null && dataCollection !== "allow" && dataCollection !== "deny") {', + ' throw new GatewayConfigurationError("provider.dataCollection must be allow or deny.");', + ' }', + ' provider = {', + ' allowFallbacks: optionalBoolean(value.provider.allowFallbacks, "provider.allowFallbacks"),', + ' requireParameters: optionalBoolean(value.provider.requireParameters, "provider.requireParameters"),', + ' zdr: optionalBoolean(value.provider.zdr, "provider.zdr"),', + ' enforceDistillableText: optionalBoolean(value.provider.enforceDistillableText, "provider.enforceDistillableText"),', + ' dataCollection: dataCollection as DataCollection | null | undefined,', + ' only: value.provider.only == null ? value.provider.only : asStringArray(value.provider.only, "provider.only"),', + ' ignore: value.provider.ignore == null ? value.provider.ignore : asStringArray(value.provider.ignore, "provider.ignore"),', + ' order: value.provider.order == null ? value.provider.order : asStringArray(value.provider.order, "provider.order"),', + ' sort: asSort(value.provider.sort),', + ' quantizations: asQuantizations(value.provider.quantizations),', + ' maxPrice: asMaxPrice(value.provider.maxPrice),', + ' preferredMinThroughput: asPercentileOrNumber(value.provider.preferredMinThroughput, "provider.preferredMinThroughput"),', + ' preferredMaxLatency: asPercentileOrNumber(value.provider.preferredMaxLatency, "provider.preferredMaxLatency"),', + ' };', + ' }', + ' return {', + ' models,', + ' provider,', + ' timeoutMs: value.timeoutMs === undefined || value.timeoutMs === null ? value.timeoutMs : asPolicyInt(value.timeoutMs, "timeoutMs"),', + ' httpReferer: optionalString(value.httpReferer, "httpReferer") ?? null,', + ' appTitle: optionalString(value.appTitle, "appTitle") ?? null,', + ' };', + '}', + '', + 'export function loadGatewayConfig(input: {', + ' env?: NodeJS.ProcessEnv;', + ' policy?: unknown;', + ' root?: string;', + '} = {}): GatewayConfig {', + ' const env = input.env ?? process.env;', + ' const policy = parsePolicy(', + ' input.policy ?? loadPolicyFile(input.root ?? join(projectRootFromModule(import.meta.url), "..", ".."))', + ' );', + ' const apiKey = env.OPENROUTER_API_KEY?.trim() ?? "";', + ' const model = env.OPENROUTER_MODEL?.trim() ?? "";', + ' if (!apiKey) {', + ' throw new GatewayConfigurationError("OPENROUTER_API_KEY is required before any OpenRouter request.");', + ' }', + ' if (!model) {', + ' throw new GatewayConfigurationError("OPENROUTER_MODEL is required. Choose a model slug; this project does not default one.");', + ' }', + ' const models = policy.models ?? [];', + ' if (models.includes(model)) {', + ' throw new GatewayConfigurationError("models fallbacks must not repeat OPENROUTER_MODEL.");', + ' }', + ' const provider = policy.provider ?? undefined;', + ' const only = provider?.only ?? [];', + ' const ignore = provider?.ignore ?? [];', + ' const order = provider?.order ?? [];', + ' const denied = overlap(only, ignore);', + ' if (denied.length > 0) {', + ' throw new GatewayConfigurationError(`provider.only and provider.ignore overlap: ${denied.join(", ")}`);', + ' }', + ' const orderedIgnored = overlap(order, ignore);', + ' if (orderedIgnored.length > 0) {', + ' throw new GatewayConfigurationError(`provider.order includes ignored providers: ${orderedIgnored.join(", ")}`);', + ' }', + ' if (provider?.zdr === true && provider.dataCollection === "allow") {', + ' throw new GatewayConfigurationError("provider.zdr=true cannot be combined with dataCollection=allow.");', + ' }', + ' const timeoutRaw = env.OPENROUTER_TIMEOUT_MS?.trim();', + ' const timeoutMs = timeoutRaw', + ' ? asEnvInt(timeoutRaw, "OPENROUTER_TIMEOUT_MS")', + ' : policy.timeoutMs == null', + ' ? DEFAULT_TIMEOUT_MS', + ' : asPolicyInt(policy.timeoutMs, "timeoutMs");', + ' const httpReferer = env.OPENROUTER_HTTP_REFERER?.trim() || policy.httpReferer || undefined;', + ' const appTitle = env.OPENROUTER_APP_TITLE?.trim() || policy.appTitle || undefined;', + ' return {', + ' apiKey,', + ' model,', + ' models,', + ' timeoutMs,', + ' httpReferer: httpReferer || undefined,', + ' appTitle: appTitle || undefined,', + ' provider,', + ' };', + '}', + '', + 'export function toSdkProvider(provider: TypeScriptProviderPolicy | undefined): ProviderPreferences | undefined {', + ' if (!provider) return undefined;', + ' const mapped: ProviderPreferences = {};', + ' if (provider.allowFallbacks != null) mapped.allowFallbacks = provider.allowFallbacks;', + ' if (provider.order && provider.order.length > 0) mapped.order = provider.order;', + ' if (provider.only && provider.only.length > 0) mapped.only = provider.only;', + ' if (provider.ignore && provider.ignore.length > 0) mapped.ignore = provider.ignore;', + ' if (provider.sort != null) mapped.sort = provider.sort;', + ' if (provider.quantizations && provider.quantizations.length > 0) mapped.quantizations = provider.quantizations;', + ' if (provider.requireParameters != null) mapped.requireParameters = provider.requireParameters;', + ' if (provider.zdr != null) mapped.zdr = provider.zdr;', + ' if (provider.dataCollection != null) mapped.dataCollection = provider.dataCollection;', + ' if (provider.enforceDistillableText != null) mapped.enforceDistillableText = provider.enforceDistillableText;', + ' if (provider.maxPrice) mapped.maxPrice = provider.maxPrice;', + ' if (provider.preferredMinThroughput != null) mapped.preferredMinThroughput = provider.preferredMinThroughput;', + ' if (provider.preferredMaxLatency != null) mapped.preferredMaxLatency = provider.preferredMaxLatency;', + ' return Object.keys(mapped).length > 0 ? mapped : undefined;', + '}', + ]); +} + +function renderGateway(): string { + return lines([ + "import { HTTPClient, OpenRouter } from '@openrouter/sdk';", + "import { loadGatewayConfig, toSdkProvider, type GatewayConfig } from './config.js';", + "import { GatewayConfigurationError, toGatewayUpstreamError } from './errors.js';", + "import type { GenerateInput, GenerateResult, ModelGateway } from './port.js';", + '', + 'export type OpenRouterGatewayOptions = {', + ' config?: GatewayConfig;', + ' httpClient?: HTTPClient;', + '};', + '', + 'function asText(value: unknown): string {', + ' if (typeof value === "string") return value;', + ' if (Array.isArray(value)) return value.map((part) => asText(part)).join("");', + ' if (value && typeof value === "object" && "text" in value) {', + ' return asText((value as { text?: unknown }).text);', + ' }', + ' return "";', + '}', + '', + 'function isCancellableError(error: unknown): boolean {', + ' if (!(error instanceof Error)) return false;', + ' return (', + ' error.name === "AbortError" ||', + ' error.name === "TimeoutError" ||', + ' error.name === "RequestAbortedError" ||', + ' error.name === "RequestTimeoutError"', + ' );', + '}', + '', + '// Pinned @openrouter/sdk EventStream extends ReadableStream and has no close().', + '// Cancellation is async iterator return() -> ReadableStream.cancel(). If the', + '// stream already completed or errored, cancel() may be a no-op; the gateway', + '// still drops the reference and remains usable for a later request.', + 'const releasedStreams = new WeakSet();', + '', + 'async function releaseOpenedStream(stream: unknown, iterator?: AsyncIterator): Promise {', + ' if (!stream || typeof stream !== "object" || releasedStreams.has(stream)) return;', + ' releasedStreams.add(stream);', + ' try {', + ' if (iterator && typeof iterator.return === "function") {', + ' await iterator.return(undefined);', + ' return;', + ' }', + ' const closer = (stream as { close?: () => unknown }).close;', + ' if (typeof closer === "function") {', + ' await closer.call(stream);', + ' return;', + ' }', + ' const cancel = (stream as { cancel?: (reason?: unknown) => unknown }).cancel;', + ' if (typeof cancel === "function") {', + ' await cancel.call(stream);', + ' }', + ' } catch {', + ' // Idempotent cleanup must not hide the original error.', + ' }', + '}', + '', + 'export class OpenRouterModelGateway implements ModelGateway {', + ' private readonly config: GatewayConfig;', + ' private readonly client: OpenRouter;', + ' private closed = false;', + '', + ' constructor(options: OpenRouterGatewayOptions = {}) {', + ' this.config = options.config ?? loadGatewayConfig();', + ' this.client = new OpenRouter({', + ' apiKey: this.config.apiKey,', + ' httpReferer: this.config.httpReferer,', + ' appTitle: this.config.appTitle,', + ' timeoutMs: this.config.timeoutMs,', + ' retryConfig: { strategy: "none" },', + ' httpClient: options.httpClient,', + ' });', + ' }', + '', + ' async generate(input: GenerateInput): Promise {', + ' this.assertOpen();', + ' this.assertMessages(input.messages);', + ' try {', + ' const completion = await this.client.chat.send(', + ' {', + ' chatRequest: {', + ' model: this.config.model,', + ' messages: input.messages,', + ' models: this.config.models.length > 0 ? this.config.models : undefined,', + ' provider: toSdkProvider(this.config.provider),', + ' stream: false,', + ' },', + ' },', + ' { timeoutMs: this.config.timeoutMs, signal: input.signal }', + ' );', + ' if (completion instanceof ReadableStream) {', + ' throw new GatewayConfigurationError("Expected a non-streaming OpenRouter response.");', + ' }', + ' const payload = completion as {', + ' id?: string;', + ' model?: string;', + ' choices?: Array<{ message?: { content?: unknown } }>;', + ' };', + ' return {', + ' text: asText(payload.choices?.[0]?.message?.content),', + ' model: payload.model,', + ' id: payload.id,', + ' };', + ' } catch (error) {', + ' throw this.normalizeCaught(error);', + ' }', + ' }', + '', + ' async *stream(input: GenerateInput): AsyncGenerator {', + ' this.assertOpen();', + ' this.assertMessages(input.messages);', + ' let opened: unknown;', + ' let iterator: AsyncIterator | undefined;', + ' try {', + ' opened = await this.client.chat.send(', + ' {', + ' chatRequest: {', + ' model: this.config.model,', + ' messages: input.messages,', + ' models: this.config.models.length > 0 ? this.config.models : undefined,', + ' provider: toSdkProvider(this.config.provider),', + ' stream: true,', + ' },', + ' },', + ' { timeoutMs: this.config.timeoutMs, signal: input.signal }', + ' );', + ' if (opened && typeof opened === "object" && Symbol.asyncIterator in opened) {', + ' iterator = (opened as AsyncIterable)[Symbol.asyncIterator]();', + ' } else if (opened instanceof ReadableStream) {', + ' const reader = opened.getReader();', + ' iterator = {', + ' next: () => reader.read(),', + ' return: async () => {', + ' try {', + ' await reader.cancel();', + ' } catch {', + ' // Reader cancel is best-effort.', + ' }', + ' reader.releaseLock();', + ' return { done: true, value: undefined };', + ' },', + ' };', + ' } else {', + ' return;', + ' }', + ' for (;;) {', + ' const step = await iterator.next();', + ' if (step.done) break;', + ' const value = step.value as { choices?: Array<{ delta?: { content?: unknown } }> } | string;', + ' const text = typeof value === "string" ? value : asText(value.choices?.[0]?.delta?.content);', + ' if (text) yield text;', + ' }', + ' } catch (error) {', + ' throw this.normalizeCaught(error);', + ' } finally {', + ' await releaseOpenedStream(opened, iterator);', + ' }', + ' }', + '', + ' async close(): Promise {', + ' if (this.closed) return;', + ' this.closed = true;', + ' const closer = (this.client as { close?: () => unknown }).close;', + ' if (typeof closer === "function") {', + ' await closer.call(this.client);', + ' }', + ' }', + '', + ' private assertOpen(): void {', + ' if (this.closed) {', + ' throw new GatewayConfigurationError("OpenRouterModelGateway is closed.");', + ' }', + ' }', + '', + ' private assertMessages(messages: GenerateInput["messages"]): void {', + ' if (!Array.isArray(messages) || messages.length === 0) {', + ' throw new GatewayConfigurationError("messages must not be empty.");', + ' }', + ' }', + '', + ' private normalizeCaught(error: unknown): Error {', + ' if (error instanceof GatewayConfigurationError) return error;', + ' if (isCancellableError(error)) return error as Error;', + ' return toGatewayUpstreamError(error);', + ' }', + '}', + ]); +} + +function renderMain(): string { + return lines([ + "import { HTTPClient } from '@openrouter/sdk';", + "import { loadGatewayConfig } from './config.js';", + "import { OpenRouterModelGateway } from './openrouter-gateway.js';", + "import { GatewayConfigurationError, mapUpstreamError } from './errors.js';", + '', + 'function promptFromArgv(): string {', + ' const args = process.argv.slice(2).filter((arg) => arg !== "--smoke");', + ' return args.join(" ").trim() || "Reply with a short acknowledgement.";', + '}', + '', + 'function chatCompletionPayload(content: string, model = "openrouter/test-model"): string {', + ' return JSON.stringify({', + ' id: "smoke-non-stream",', + ' created: 1,', + ' model,', + ' object: "chat.completion",', + ' system_fingerprint: null,', + ' choices: [{ finish_reason: "stop", index: 0, message: { role: "assistant", content } }],', + ' });', + '}', + '', + 'function chatStreamSse(parts: string[]): string {', + ' return (', + ' parts', + ' .map((content, index) =>', + ' `data: ${JSON.stringify({', + ' id: "smoke-stream",', + ' created: 1,', + ' model: "openrouter/test-model",', + ' object: "chat.completion.chunk",', + ' choices: [{ delta: { content }, finish_reason: index === parts.length - 1 ? "stop" : null, index: 0 }],', + ' })}\\n\\n`', + ' )', + ' .join("") + "data: [DONE]\\n\\n"', + ' );', + '}', + '', + 'function smokeClient(): HTTPClient {', + ' return new HTTPClient({', + ' fetcher: async (input, init) => {', + ' const request = input instanceof Request ? input : new Request(input, init);', + ' const body = await request.text();', + ' const parsed = JSON.parse(body) as { stream?: boolean; model?: string };', + ' if (parsed.stream) {', + ' return new Response(chatStreamSse(["smoke"]), {', + ' status: 200,', + ' headers: { "content-type": "text/event-stream", "x-request-id": "smoke-stream" },', + ' });', + ' }', + ' return new Response(chatCompletionPayload("smoke-ok", parsed.model), {', + ' status: 200,', + ' headers: { "content-type": "application/json", "x-request-id": "smoke-id" },', + ' });', + ' },', + ' });', + '}', + '', + 'async function runSmoke(): Promise {', + ' process.env.OPENROUTER_API_KEY ??= "test-openrouter-key";', + ' process.env.OPENROUTER_MODEL ??= "openrouter/test-model";', + ' const gateway = new OpenRouterModelGateway({', + ' config: loadGatewayConfig(),', + ' httpClient: smokeClient(),', + ' });', + ' const generated = await gateway.generate({', + ' messages: [{ role: "user", content: "ping" }],', + ' });', + ' let streamed = "";', + ' for await (const chunk of gateway.stream({ messages: [{ role: "user", content: "ping" }] })) {', + ' streamed += chunk;', + ' }', + ' await gateway.close();', + ' process.stdout.write(`${generated.text}:${streamed}\\n`);', + '}', + '', + 'async function main(): Promise {', + ' if (process.argv.includes("--smoke")) {', + ' await runSmoke();', + ' return;', + ' }', + ' const gateway = new OpenRouterModelGateway();', + ' try {', + ' const controller = new AbortController();', + ' const timeout = AbortSignal.timeout(loadGatewayConfig().timeoutMs);', + ' timeout.addEventListener("abort", () => controller.abort());', + ' let wrote = false;', + ' for await (const chunk of gateway.stream({', + ' messages: [{ role: "user", content: promptFromArgv() }],', + ' signal: controller.signal,', + ' })) {', + ' process.stdout.write(chunk);', + ' wrote = true;', + ' }', + ' if (!wrote) {', + ' const result = await gateway.generate({', + ' messages: [{ role: "user", content: promptFromArgv() }],', + ' signal: controller.signal,', + ' });', + ' process.stdout.write(result.text);', + ' }', + ' process.stdout.write("\\n");', + ' } finally {', + ' await gateway.close();', + ' }', + '}', + '', + 'main().catch((error: unknown) => {', + ' if (error instanceof GatewayConfigurationError) {', + ' process.stderr.write(`${error.message}\\n`);', + ' } else {', + ' const mapped = mapUpstreamError(error);', + ' process.stderr.write(`${mapped.message}\\n`);', + ' }', + ' process.exitCode = 1;', + '});', + ]); +} + +function renderIndex(): string { + return lines([ + "export { loadGatewayConfig, toSdkProvider } from './config.js';", + "export { OpenRouterModelGateway } from './openrouter-gateway.js';", + "export { redactSecretShapedValues } from './redact.js';", + "export { mapUpstreamError, GatewayConfigurationError, GatewayUpstreamError } from './errors.js';", + "export type { ModelGateway } from './port.js';", + ]); +} + +function renderTests(): string { + return lines([ + "import assert from 'node:assert/strict';", + "import { mkdtempSync, writeFileSync } from 'node:fs';", + "import { readFile } from 'node:fs/promises';", + "import { tmpdir } from 'node:os';", + "import { dirname, join } from 'node:path';", + "import { test } from 'node:test';", + "import { fileURLToPath } from 'node:url';", + '', + "import { HTTPClient } from '@openrouter/sdk';", + '', + "import { GatewayConfigurationError, loadGatewayConfig, loadPolicyFile, toSdkProvider } from '../src/config.js';", + "import { GatewayUpstreamError, mapUpstreamError } from '../src/errors.js';", + "import { OpenRouterModelGateway } from '../src/openrouter-gateway.js';", + "import { redactSecretShapedValues } from '../src/redact.js';", + '', + 'const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");', + `const PARITY_CASES = ${JSON.stringify(OPENROUTER_POLICY_PARITY_CASES)} as const;`, + '', + 'function fakeClient(handler: (request: Request) => Promise | Response): HTTPClient {', + ' return new HTTPClient({', + ' fetcher: async (input, init) => {', + ' const request = input instanceof Request ? input : new Request(input, init);', + ' return handler(request);', + ' },', + ' });', + '}', + '', + 'function chatCompletionPayload(content: string, model = "openrouter/test-model"): string {', + ' return JSON.stringify({', + ' id: "gen-1",', + ' created: 1,', + ' model,', + ' object: "chat.completion",', + ' system_fingerprint: null,', + ' choices: [{ finish_reason: "stop", index: 0, message: { role: "assistant", content } }],', + ' });', + '}', + '', + 'function chatStreamSse(parts: string[]): string {', + ' return (', + ' parts', + ' .map((content, index) =>', + ' `data: ${JSON.stringify({', + ' id: "stream-1",', + ' created: 1,', + ' model: "openrouter/test-model",', + ' object: "chat.completion.chunk",', + ' choices: [{ delta: { content }, finish_reason: index === parts.length - 1 ? "stop" : null, index: 0 }],', + ' })}\\n\\n`', + ' )', + ' .join("") + "data: [DONE]\\n\\n"', + ' );', + '}', + '', + 'test("redacts keys, authorization, and url secrets", () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-live-secret";', + ' const raw =', + ' "Authorization: Bearer sk-or-live-secret called https://example.test/v1?api_key=sk-or-live-secret";', + ' const redacted = redactSecretShapedValues(raw);', + ' assert.equal(redacted.includes("sk-or-live-secret"), false);', + ' assert.match(redacted, /\\[redacted\\]/);', + '});', + '', + 'test("configuration fails before a network request", async () => {', + ' const previousKey = process.env.OPENROUTER_API_KEY;', + ' const previousModel = process.env.OPENROUTER_MODEL;', + ' delete process.env.OPENROUTER_API_KEY;', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' try {', + ' assert.throws(() => loadGatewayConfig({ root }), GatewayConfigurationError);', + ' } finally {', + ' if (previousKey === undefined) delete process.env.OPENROUTER_API_KEY;', + ' else process.env.OPENROUTER_API_KEY = previousKey;', + ' if (previousModel === undefined) delete process.env.OPENROUTER_MODEL;', + ' else process.env.OPENROUTER_MODEL = previousModel;', + ' }', + '});', + '', + 'test("rejects overlapping provider allow and deny lists", () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' assert.throws(', + ' () =>', + ' loadGatewayConfig({', + ' policy: { provider: { only: ["openai"], ignore: ["openai"] } },', + ' }),', + ' /overlap/', + ' );', + '});', + '', + 'test("maps TypeScript SDK provider field names", () => {', + ' const mapped = toSdkProvider({', + ' allowFallbacks: false,', + ' zdr: true,', + ' dataCollection: "deny",', + ' sort: "latency",', + ' order: ["anthropic"],', + ' });', + ' assert.deepEqual(mapped, {', + ' allowFallbacks: false,', + ' zdr: true,', + ' dataCollection: "deny",', + ' sort: "latency",', + ' order: ["anthropic"],', + ' });', + '});', + '', + 'test("non-stream generation uses injected transport and records policy", async () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' let body = "";', + ' const gateway = new OpenRouterModelGateway({', + ' config: loadGatewayConfig({', + ' policy: { provider: { zdr: true, dataCollection: "deny" }, models: ["openrouter/fallback"] },', + ' }),', + ' httpClient: fakeClient(async (request) => {', + ' body = await request.text();', + ' return new Response(chatCompletionPayload("ok"), {', + ' status: 200,', + ' headers: { "content-type": "application/json" },', + ' });', + ' }),', + ' });', + ' const result = await gateway.generate({ messages: [{ role: "user", content: "hi" }] });', + ' assert.equal(result.text, "ok");', + ' const parsed = JSON.parse(body) as {', + ' model: string;', + ' models?: string[];', + ' provider?: { zdr?: boolean; data_collection?: string; dataCollection?: string };', + ' };', + ' assert.equal(parsed.model, "openrouter/test-model");', + ' assert.deepEqual(parsed.models, ["openrouter/fallback"]);', + ' assert.equal(parsed.provider?.zdr, true);', + ' assert.equal(parsed.provider?.data_collection ?? parsed.provider?.dataCollection, "deny");', + ' await gateway.close();', + '});', + '', + 'test("streaming consumes chunks and cancels the reader", async () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' const gateway = new OpenRouterModelGateway({', + ' config: loadGatewayConfig({ policy: {} }),', + ' httpClient: fakeClient(async () => {', + ' return new Response(chatStreamSse(["hel", "lo"]), {', + ' status: 200,', + ' headers: { "content-type": "text/event-stream" },', + ' });', + ' }),', + ' });', + ' let text = "";', + ' for await (const chunk of gateway.stream({ messages: [{ role: "user", content: "hi" }] })) {', + ' text += chunk;', + ' }', + ' assert.equal(text.includes("hel") || text.includes("hello"), true);', + ' await gateway.close();', + '});', + '', + 'test("maps upstream status and request id without leaking secrets", () => {', + ' const headers = new Headers({', + ' authorization: "Bearer sk-or-secret",', + ' "x-request-id": "req-123",', + ' "retry-after": "2",', + ' });', + ' const mapped = mapUpstreamError({', + ' message: "Authorization: Bearer sk-or-secret failed",', + ' statusCode: 429,', + ' headers,', + ' });', + ' assert.equal(mapped.statusCode, 429);', + ' assert.equal(mapped.requestId, "req-123");', + ' assert.equal(mapped.retryAfter, "2");', + ' assert.equal(mapped.message.includes("sk-or-secret"), false);', + ' assert.equal(mapped.safeHeaders?.authorization ?? mapped.safeHeaders?.Authorization, "[redacted]");', + '});', + '', + 'test("timeout uses AbortSignal", { timeout: 5000 }, async () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' const gateway = new OpenRouterModelGateway({', + ' config: { ...loadGatewayConfig({ policy: {} }), timeoutMs: 20 },', + ' httpClient: fakeClient(async (request) => {', + ' const signal = request.signal;', + ' await new Promise((_, reject) => {', + ' const fail = () => reject(signal.reason ?? new Error("aborted"));', + ' const watchdog = setTimeout(() => {', + ' reject(new Error("AbortSignal was not forwarded to transport"));', + ' }, 1000);', + ' const abort = () => {', + ' clearTimeout(watchdog);', + ' fail();', + ' };', + ' if (signal.aborted) {', + ' abort();', + ' return;', + ' }', + ' signal.addEventListener("abort", abort, { once: true });', + ' });', + ' return new Response("{}", { status: 200 });', + ' }),', + ' });', + ' await assert.rejects(', + ' () => gateway.generate({ messages: [{ role: "user", content: "hi" }] }),', + ' (error: unknown) => {', + ' const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);', + ' return /timed out|aborted by client|AbortError|TimeoutError|This operation was aborted/i.test(text);', + ' }', + ' );', + ' const cancelled = new AbortController();', + ' cancelled.abort();', + ' await assert.rejects(() =>', + ' gateway.generate({', + ' messages: [{ role: "user", content: "hi" }],', + ' signal: cancelled.signal,', + ' })', + ' );', + ' await gateway.close();', + '});', + '', + 'test("timeoutMs is loaded from OPENROUTER_TIMEOUT_MS", () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' process.env.OPENROUTER_TIMEOUT_MS = "1500";', + ' const config = loadGatewayConfig({ policy: {} });', + ' assert.equal(config.timeoutMs, 1500);', + ' delete process.env.OPENROUTER_TIMEOUT_MS;', + '});', + '', + 'test("policy file stays source-controlled and secret-free", async () => {', + ' const policy = await readFile(join(root, "gateway.policy.json"), "utf8");', + ' assert.equal(policy.includes("sk-"), false);', + ' assert.equal(policy.includes("OPENROUTER_API_KEY"), false);', + '});', + '', + 'test("rejects every invalid policy category before transport", () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' const cases: Array<[string, unknown, RegExp]> = [', + ' ["root array", [], /JSON object/],', + ' ["unknown root field", { widget: true }, /unknown field/],', + ' ["models not array", { models: "openai/gpt" }, /models must be an array/],', + ' ["empty model", { models: [" "] }, /non-empty string/],', + ' ["duplicate models", { models: ["a", "a"] }, /duplicates/],', + ' ["primary in fallbacks", { models: ["openrouter/test-model"] }, /repeat OPENROUTER_MODEL/],', + ' ["provider string", { provider: "openai" }, /provider must be an object/],', + ' ["unknown provider field", { provider: { fallbacks: true } }, /unknown field/],', + ' ["allowFallbacks string", { provider: { allowFallbacks: "yes" } }, /boolean/],', + ' ["requireParameters number", { provider: { requireParameters: 1 } }, /boolean/],', + ' ["zdr string", { provider: { zdr: "true" } }, /boolean/],', + ' ["only ignore overlap", { provider: { only: ["openai"], ignore: ["openai"] } }, /overlap/],', + ' ["order includes ignore", { provider: { order: ["openai"], ignore: ["openai"] } }, /ignored providers/],', + ' ["dataCollection invalid", { provider: { dataCollection: "never" } }, /allow or deny/],', + ' ["sort invalid", { provider: { sort: "cheap" } }, /sort/],', + ' ["sort partition invalid", { provider: { sort: { by: "price", partition: "all" } } }, /partition/],', + ' ["quantization invalid", { provider: { quantizations: ["int5"] } }, /quantizations/],', + ' ["maxPrice number", { provider: { maxPrice: { prompt: 1 } } }, /numeric string/],', + ' ["maxPrice unknown key", { provider: { maxPrice: { tokens: "1" } } }, /unknown field/],', + ' ["maxPrice negative", { provider: { maxPrice: { prompt: "-1" } } }, /numeric string/],', + ' ["maxPrice malformed", { provider: { maxPrice: { prompt: "not-a-price" } } }, /numeric string/],', + ' ["maxPrice overflow", { provider: { maxPrice: { prompt: "9".repeat(400) } } }, /numeric string/],', + ' ["percentile unknown", { provider: { preferredMaxLatency: { p95: 1 } } }, /unknown field/],', + ' ["percentile negative", { provider: { preferredMinThroughput: { p50: -1 } } }, /non-negative/],', + ' ["timeout float", { timeoutMs: 30.5 }, /positive integer/],', + ' ["timeout zero", { timeoutMs: 0 }, /positive integer/],', + ' ["timeout too large", { timeoutMs: 600001 }, /positive integer/],', + ' ["timeout string", { timeoutMs: "30000" }, /positive integer/],', + ' ["attribution number", { httpReferer: 1 }, /string/],', + ' ["zdr with allow", { provider: { zdr: true, dataCollection: "allow" } }, /zdr/],', + ' ];', + ' for (const [name, policy, pattern] of cases) {', + ' assert.throws(', + ' () => loadGatewayConfig({ policy }),', + ' (error: unknown) => {', + ' assert.equal(error instanceof GatewayConfigurationError, true, name);', + ' assert.match((error as Error).message, pattern, name);', + ' return true;', + ' }', + ' );', + ' }', + '});', + '', + 'test("accepts official provider policy boundary values", () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' const config = loadGatewayConfig({', + ' policy: {', + ' models: ["openrouter/fallback"],', + ' timeoutMs: 1,', + ' httpReferer: "https://example.test",', + ' appTitle: "gateway",', + ' provider: {', + ' allowFallbacks: false,', + ' requireParameters: true,', + ' zdr: true,', + ' dataCollection: "deny",', + ' enforceDistillableText: true,', + ' only: ["anthropic"],', + ' ignore: ["openai"],', + ' order: ["anthropic"],', + ' sort: { by: "exacto", partition: "none" },', + ' quantizations: ["fp16"],', + ' maxPrice: { prompt: "0", completion: "1.5", request: "2", image: "3", audio: "4" },', + ' preferredMinThroughput: { p50: 0, p75: 1, p90: 2, p99: 3 },', + ' preferredMaxLatency: 1.5,', + ' },', + ' },', + ' });', + ' assert.equal(config.timeoutMs, 1);', + ' const mapped = toSdkProvider(config.provider);', + ' assert.equal(mapped?.enforceDistillableText, true);', + ' assert.deepEqual(mapped?.sort, { by: "exacto", partition: "none" });', + ' assert.equal(mapped?.maxPrice?.prompt, "0");', + ' const upper = loadGatewayConfig({ policy: { timeoutMs: 600000 } });', + ' assert.equal(upper.timeoutMs, 600000);', + '});', + '', + 'test("policy parity corpus executes the generated validator", () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' for (const entry of PARITY_CASES) {', + ' if (entry.decision === "reject") {', + ' assert.throws(', + ' () => loadGatewayConfig({ policy: entry.typescript }),', + ' (error: unknown) => {', + ' assert.equal(error instanceof GatewayConfigurationError, true, entry.id);', + ' if (entry.needle) assert.match((error as Error).message, new RegExp(entry.needle), entry.id);', + ' return true;', + ' }', + ' );', + ' } else {', + ' const config = loadGatewayConfig({ policy: entry.typescript });', + ' const mapped = toSdkProvider(config.provider);', + ' if (entry.id === "max-price-zero") assert.equal(mapped?.maxPrice?.prompt, "0");', + ' if (entry.id === "max-price-micro") assert.equal(mapped?.maxPrice?.prompt, "0.000001");', + ' if (entry.id === "max-price-positive") assert.equal(mapped?.maxPrice?.completion, "1.5");', + ' }', + ' }', + '});', + '', + 'test("finite maxPrice strings are preserved or rejected", () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' const accepted = loadGatewayConfig({', + ' policy: { provider: { maxPrice: { prompt: "0", completion: "0.000001", request: "12.5" } } },', + ' });', + ' const mapped = toSdkProvider(accepted.provider)?.maxPrice;', + ' assert.equal(mapped?.prompt, "0");', + ' assert.equal(mapped?.completion, "0.000001");', + ' assert.equal(mapped?.request, "12.5");', + ' for (const value of ["-1", "not-a-price", "9".repeat(400), "1e6", "+1", "inf", "nan"]) {', + ' assert.throws(', + ' () => loadGatewayConfig({ policy: { provider: { maxPrice: { prompt: value } } } }),', + ' GatewayConfigurationError', + ' );', + ' }', + ' assert.throws(', + ' () => loadGatewayConfig({ policy: { provider: { maxPrice: { tokens: "1" } } } }),', + ' GatewayConfigurationError', + ' );', + '});', + '', + 'test("rejects non-finite numeric policy values", () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' assert.throws(', + ' () => loadGatewayConfig({ policy: { provider: { preferredMaxLatency: Number.POSITIVE_INFINITY } } }),', + ' GatewayConfigurationError', + ' );', + ' assert.throws(', + ' () => loadGatewayConfig({ policy: { provider: { preferredMinThroughput: Number.NaN } } }),', + ' GatewayConfigurationError', + ' );', + ' assert.throws(', + ' () => loadGatewayConfig({ policy: { provider: { preferredMaxLatency: { p50: Number.NEGATIVE_INFINITY } } } }),', + ' GatewayConfigurationError', + ' );', + '});', + '', + 'test("missing policy file is a configuration error", () => {', + ' const dir = mkdtempSync(join(tmpdir(), "or-missing-policy-"));', + ' assert.throws(() => loadPolicyFile(dir), GatewayConfigurationError);', + '});', + '', + 'test("malformed policy JSON is a configuration error", () => {', + ' const dir = mkdtempSync(join(tmpdir(), "or-policy-"));', + ' writeFileSync(join(dir, "gateway.policy.json"), "{");', + ' assert.throws(() => loadPolicyFile(dir), GatewayConfigurationError);', + '});', + '', + 'test("empty messages fail before transport", async () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' let called = false;', + ' const gateway = new OpenRouterModelGateway({', + ' config: loadGatewayConfig({ policy: {} }),', + ' httpClient: fakeClient(async () => {', + ' called = true;', + ' return new Response(chatCompletionPayload("nope"), { status: 200, headers: { "content-type": "application/json" } });', + ' }),', + ' });', + ' await assert.rejects(() => gateway.generate({ messages: [] }), GatewayConfigurationError);', + ' await assert.rejects(() => gateway.stream({ messages: [] }).next(), GatewayConfigurationError);', + ' assert.equal(called, false);', + ' await gateway.close();', + '});', + '', + 'test("empty successful completion returns an empty string", async () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' const gateway = new OpenRouterModelGateway({', + ' config: loadGatewayConfig({ policy: {} }),', + ' httpClient: fakeClient(async () => new Response(chatCompletionPayload(""), {', + ' status: 200,', + ' headers: { "content-type": "application/json" },', + ' })),', + ' });', + ' const result = await gateway.generate({ messages: [{ role: "user", content: "hi" }] });', + ' assert.equal(result.text, "");', + ' await gateway.close();', + '});', + '', + 'test("close is idempotent and then rejects new work", async () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' const gateway = new OpenRouterModelGateway({', + ' config: loadGatewayConfig({ policy: {} }),', + ' httpClient: fakeClient(async () => new Response(chatCompletionPayload("ok"), {', + ' status: 200,', + ' headers: { "content-type": "application/json" },', + ' })),', + ' });', + ' await gateway.close();', + ' await gateway.close();', + ' await assert.rejects(() => gateway.generate({ messages: [{ role: "user", content: "hi" }] }), GatewayConfigurationError);', + '});', + '', + 'test("stream-open failure is a redacted upstream error with metadata", { timeout: 5000 }, async () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' const gateway = new OpenRouterModelGateway({', + ' config: loadGatewayConfig({ policy: {} }),', + ' httpClient: fakeClient(async () =>', + ' new Response(JSON.stringify({ error: { message: "Authorization: Bearer sk-or-secret" } }), {', + ' status: 401,', + ' headers: {', + ' "content-type": "application/json",', + ' "x-request-id": "req-open",', + ' "retry-after": "7",', + ' authorization: "Bearer sk-or-secret",', + ' },', + ' })', + ' ),', + ' });', + ' await assert.rejects(', + ' () => gateway.stream({ messages: [{ role: "user", content: "hi" }] }).next(),', + ' (error: unknown) => {', + ' assert.equal(error instanceof GatewayUpstreamError, true);', + ' const upstream = error as GatewayUpstreamError;', + ' assert.equal(upstream.statusCode, 401);', + ' assert.equal(upstream.requestId, "req-open");', + ' assert.equal(upstream.retryAfter, "7");', + ' assert.equal(upstream.message.includes("sk-or-secret"), false);', + ' return true;', + ' }', + ' );', + ' await gateway.close();', + '});', + '', + 'test("mid-stream failure is normalized, redacted, and cleaned up", { timeout: 5000 }, async () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' const encoder = new TextEncoder();', + ' const gateway = new OpenRouterModelGateway({', + ' config: loadGatewayConfig({ policy: {} }),', + ' httpClient: fakeClient(async (request) => {', + ' const raw = await request.text();', + ' const parsed = JSON.parse(raw) as { stream?: boolean };', + ' if (!parsed.stream) {', + ' return new Response(chatCompletionPayload("ok"), {', + ' status: 200,', + ' headers: { "content-type": "application/json" },', + ' });', + ' }', + ' const body = new ReadableStream({', + ' start(controller) {', + ' controller.enqueue(', + ' encoder.encode(', + ' `data: ${JSON.stringify({', + ' id: "stream-1",', + ' created: 1,', + ' model: "openrouter/test-model",', + ' object: "chat.completion.chunk",', + ' choices: [{ delta: { content: "hel" }, finish_reason: null, index: 0 }],', + ' })}\\n\\n`', + ' )', + ' );', + ' controller.error(new Error("Authorization: Bearer sk-or-secret mid-stream"));', + ' },', + ' });', + ' return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });', + ' }),', + ' });', + ' await assert.rejects(', + ' async () => {', + ' for await (const _chunk of gateway.stream({ messages: [{ role: "user", content: "hi" }] })) {', + ' void _chunk;', + ' }', + ' },', + ' (error: unknown) => {', + ' const text = error instanceof Error ? error.message : String(error);', + ' assert.equal(text.includes("sk-or-secret"), false);', + ' assert.equal(error instanceof GatewayConfigurationError, false);', + ' return /mid-stream|redacted|Unexpected|stream/i.test(text) || error instanceof GatewayUpstreamError;', + ' }', + ' );', + ' const recovered = await gateway.generate({ messages: [{ role: "user", content: "hi" }] });', + ' assert.equal(recovered.text, "ok");', + ' await gateway.close();', + '});', + '', + 'test("consumer early stream termination closes the upstream body", { timeout: 5000 }, async () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' let cancelled = false;', + ' const encoder = new TextEncoder();', + ' const gateway = new OpenRouterModelGateway({', + ' config: loadGatewayConfig({ policy: {} }),', + ' httpClient: fakeClient(async (request) => {', + ' const raw = await request.text();', + ' const parsed = JSON.parse(raw) as { stream?: boolean };', + ' if (!parsed.stream) {', + ' return new Response(chatCompletionPayload("ok"), {', + ' status: 200,', + ' headers: { "content-type": "application/json" },', + ' });', + ' }', + ' const body = new ReadableStream({', + ' start(controller) {', + ' controller.enqueue(', + ' encoder.encode(', + ' `data: ${JSON.stringify({', + ' id: "stream-1",', + ' created: 1,', + ' model: "openrouter/test-model",', + ' object: "chat.completion.chunk",', + ' choices: [{ delta: { content: "hel" }, finish_reason: null, index: 0 }],', + ' })}\\n\\n`', + ' )', + ' );', + ' },', + ' cancel() {', + ' cancelled = true;', + ' },', + ' });', + ' return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });', + ' }),', + ' });', + ' for await (const chunk of gateway.stream({ messages: [{ role: "user", content: "hi" }] })) {', + ' assert.equal(chunk.includes("hel"), true);', + ' break;', + ' }', + ' assert.equal(cancelled, true);', + ' const recovered = await gateway.generate({ messages: [{ role: "user", content: "hi" }] });', + ' assert.equal(recovered.text, "ok");', + ' await gateway.close();', + '});', + '', + 'test("normal stream completion stays successful and leaves the gateway reusable", { timeout: 5000 }, async () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' let cancelCount = 0;', + ' const encoder = new TextEncoder();', + ' const gateway = new OpenRouterModelGateway({', + ' config: loadGatewayConfig({ policy: {} }),', + ' httpClient: fakeClient(async (request) => {', + ' const raw = await request.text();', + ' const parsed = JSON.parse(raw) as { stream?: boolean };', + ' if (!parsed.stream) {', + ' return new Response(chatCompletionPayload("ok"), {', + ' status: 200,', + ' headers: { "content-type": "application/json" },', + ' });', + ' }', + ' const body = new ReadableStream({', + ' start(controller) {', + ' controller.enqueue(', + ' encoder.encode(', + ' `data: ${JSON.stringify({', + ' id: "stream-1",', + ' created: 1,', + ' model: "openrouter/test-model",', + ' object: "chat.completion.chunk",', + ' choices: [{ delta: { content: "hel" }, finish_reason: "stop", index: 0 }],', + ' })}\\n\\n`', + ' )', + ' );', + ' controller.enqueue(encoder.encode("data: [DONE]\\n\\n"));', + ' controller.close();', + ' },', + ' cancel() {', + ' cancelCount += 1;', + ' },', + ' });', + ' return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });', + ' }),', + ' });', + ' const chunks: string[] = [];', + ' for await (const chunk of gateway.stream({ messages: [{ role: "user", content: "hi" }] })) {', + ' chunks.push(chunk);', + ' }', + ' assert.equal(chunks.join("").includes("hel"), true);', + ' // Pinned EventStream closes a completed ReadableStream without calling cancel().', + ' // Iterator.return() is still invoked once; it is a no-op on an already-closed stream.', + ' assert.ok(cancelCount === 0 || cancelCount === 1);', + ' const recovered = await gateway.generate({ messages: [{ role: "user", content: "hi" }] });', + ' assert.equal(recovered.text, "ok");', + ' await gateway.close();', + '});', + '', + 'test("cleanup failure does not mask upstream or convert success into an error", { timeout: 5000 }, async () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' const encoder = new TextEncoder();', + ' const gateway = new OpenRouterModelGateway({', + ' config: loadGatewayConfig({ policy: {} }),', + ' httpClient: fakeClient(async (request) => {', + ' const raw = await request.text();', + ' const parsed = JSON.parse(raw) as { stream?: boolean };', + ' if (!parsed.stream) {', + ' return new Response(chatCompletionPayload("ok"), {', + ' status: 200,', + ' headers: { "content-type": "application/json" },', + ' });', + ' }', + ' const body = new ReadableStream({', + ' start(controller) {', + ' controller.enqueue(', + ' encoder.encode(', + ' `data: ${JSON.stringify({', + ' id: "stream-1",', + ' created: 1,', + ' model: "openrouter/test-model",', + ' object: "chat.completion.chunk",', + ' choices: [{ delta: { content: "hel" }, finish_reason: "stop", index: 0 }],', + ' })}\\n\\n`', + ' )', + ' );', + ' controller.enqueue(encoder.encode("data: [DONE]\\n\\n"));', + ' controller.close();', + ' },', + ' cancel() {', + ' throw new Error("cleanup-failed");', + ' },', + ' });', + ' return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });', + ' }),', + ' });', + ' const chunks: string[] = [];', + ' for await (const chunk of gateway.stream({ messages: [{ role: "user", content: "hi" }] })) {', + ' chunks.push(chunk);', + ' }', + ' assert.equal(chunks.join("").includes("hel"), true);', + ' const recovered = await gateway.generate({ messages: [{ role: "user", content: "hi" }] });', + ' assert.equal(recovered.text, "ok");', + ' await gateway.close();', + '});', + '', + 'test("already-aborted caller signal is rejected without hanging", { timeout: 5000 }, async () => {', + ' process.env.OPENROUTER_API_KEY = "sk-or-test";', + ' process.env.OPENROUTER_MODEL = "openrouter/test-model";', + ' const gateway = new OpenRouterModelGateway({', + ' config: loadGatewayConfig({ policy: {} }),', + ' httpClient: fakeClient(async (request) => {', + ' if (request.signal.aborted) {', + ' throw request.signal.reason ?? new Error("aborted");', + ' }', + ' const watchdog = setTimeout(() => undefined, 1000);', + ' await new Promise((_, reject) => {', + ' const fail = () => {', + ' clearTimeout(watchdog);', + ' reject(request.signal.reason ?? new Error("aborted"));', + ' };', + ' request.signal.addEventListener("abort", fail, { once: true });', + ' setTimeout(() => reject(new Error("AbortSignal was not forwarded to transport")), 1000);', + ' });', + ' return new Response("{}", { status: 200 });', + ' }),', + ' });', + ' const cancelled = new AbortController();', + ' cancelled.abort();', + ' await assert.rejects(() =>', + ' gateway.stream({', + ' messages: [{ role: "user", content: "hi" }],', + ' signal: cancelled.signal,', + ' }).next()', + ' );', + ' await gateway.close();', + '});', + ]); +} + +export async function generateOpenRouterTypeScriptGateway(input: { + projectPath: string; + projectName: string; +}): Promise { + const name = npmSafeName(input.projectName); + await writeGeneratorFile(path.join(input.projectPath, 'package.json'), renderPackageJson(name)); + await writeGeneratorFile(path.join(input.projectPath, 'tsconfig.json'), renderTsconfig()); + await writeGeneratorFile(path.join(input.projectPath, OPENROUTER_POLICY_FILE), renderPolicy()); + await writeGeneratorFile(path.join(input.projectPath, '.env.example'), renderEnvExample()); + await writeGeneratorFile(path.join(input.projectPath, '.gitignore'), GITIGNORE); + await writeGeneratorFile( + path.join(input.projectPath, 'README.md'), + renderReadme(input.projectName) + ); + await writeGeneratorFile(path.join(input.projectPath, 'src', 'redact.ts'), renderRedact()); + await writeGeneratorFile(path.join(input.projectPath, 'src', 'errors.ts'), renderErrors()); + await writeGeneratorFile(path.join(input.projectPath, 'src', 'port.ts'), renderPort()); + await writeGeneratorFile(path.join(input.projectPath, 'src', 'config.ts'), renderConfig()); + await writeGeneratorFile( + path.join(input.projectPath, 'src', 'openrouter-gateway.ts'), + renderGateway() + ); + await writeGeneratorFile(path.join(input.projectPath, 'src', 'main.ts'), renderMain()); + await writeGeneratorFile(path.join(input.projectPath, 'src', 'index.ts'), renderIndex()); + await writeGeneratorFile(path.join(input.projectPath, 'tests', 'gateway.test.ts'), renderTests()); +} diff --git a/packages/cli/src/model-gateways/gateway.ts b/packages/cli/src/model-gateways/gateway.ts new file mode 100644 index 00000000..17116b58 --- /dev/null +++ b/packages/cli/src/model-gateways/gateway.ts @@ -0,0 +1,42 @@ +import type { ModelGatewayBaselineRuntime } from './version-policy.js'; + +export const MODEL_GATEWAY_KIND = 'gateway' as const; +export const MODEL_GATEWAY_CATEGORY = 'gateway' as const; + +export type ModelGatewayId = 'openrouter'; +export type ModelGatewayRuntime = ModelGatewayBaselineRuntime; + +export type ModelGatewayKitId = 'gateway.openrouter.typescript' | 'gateway.openrouter.python'; + +export type ModelGatewayProjectKit = { + id: ModelGatewayKitId; + aliases: string[]; + label: string; + runtime: ModelGatewayRuntime; + adapterId: string; + gatewayId: ModelGatewayId; + gatewayName: string; + requiredEnvironment: string[]; +}; + +export function npmSafeName(value: string): string { + return ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^[-_.]+|[-_.]+$/g, '') + .slice(0, 64) || 'openrouter-gateway' + ); +} + +export function pythonDistributionName(value: string): string { + return ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) || 'openrouter-gateway' + ); +} diff --git a/packages/cli/src/model-gateways/index.ts b/packages/cli/src/model-gateways/index.ts new file mode 100644 index 00000000..0761663e --- /dev/null +++ b/packages/cli/src/model-gateways/index.ts @@ -0,0 +1,11 @@ +export * from './gateway.js'; +export * from './project-kits.js'; +export * from './version-policy.js'; +export { + openRouterTypeScriptSdkVersion, + generateOpenRouterTypeScriptGateway, +} from './adapters/openrouter/typescript.js'; +export { + openRouterPythonSdkVersion, + generateOpenRouterPythonGateway, +} from './adapters/openrouter/python.js'; diff --git a/packages/cli/src/model-gateways/lifecycle.ts b/packages/cli/src/model-gateways/lifecycle.ts new file mode 100644 index 00000000..fc08d580 --- /dev/null +++ b/packages/cli/src/model-gateways/lifecycle.ts @@ -0,0 +1 @@ +export { generateModelGatewayProject } from './project-kits.js'; diff --git a/packages/cli/src/model-gateways/project-kits.ts b/packages/cli/src/model-gateways/project-kits.ts new file mode 100644 index 00000000..a18a9ed6 --- /dev/null +++ b/packages/cli/src/model-gateways/project-kits.ts @@ -0,0 +1,158 @@ +import { getVersion } from '../update-checker.js'; +import { writeGeneratorFile } from '../generators/go-kit-common.js'; +import { generateOpenRouterTypeScriptGateway } from './adapters/openrouter/typescript.js'; +import { generateOpenRouterPythonGateway } from './adapters/openrouter/python.js'; +import { + MODEL_GATEWAY_CATEGORY, + MODEL_GATEWAY_KIND, + type ModelGatewayKitId, + type ModelGatewayProjectKit, +} from './gateway.js'; +import path from 'node:path'; +import fsExtra from 'fs-extra'; + +const PROJECT_KITS: ModelGatewayProjectKit[] = [ + { + id: 'gateway.openrouter.typescript', + aliases: [ + 'gateway.openrouter.typescript', + 'openrouter.typescript', + 'gateway.openrouter.ts', + 'openrouter-typescript', + ], + label: 'OpenRouter · TypeScript', + runtime: 'node', + adapterId: 'openrouter-typescript', + gatewayId: 'openrouter', + gatewayName: 'OpenRouter', + requiredEnvironment: ['OPENROUTER_API_KEY', 'OPENROUTER_MODEL'], + }, + { + id: 'gateway.openrouter.python', + aliases: [ + 'gateway.openrouter.python', + 'openrouter.python', + 'gateway.openrouter.py', + 'openrouter-python', + ], + label: 'OpenRouter · Python', + runtime: 'python', + adapterId: 'openrouter-python', + gatewayId: 'openrouter', + gatewayName: 'OpenRouter', + requiredEnvironment: ['OPENROUTER_API_KEY', 'OPENROUTER_MODEL'], + }, +]; + +export function describeModelGatewayProjectKits(): ModelGatewayProjectKit[] { + return PROJECT_KITS.map((kit) => structuredClone(kit)); +} + +export function listModelGatewayProjectKits(): ModelGatewayProjectKit[] { + return describeModelGatewayProjectKits(); +} + +export function lookupModelGatewayProjectKit( + value: string | undefined +): ModelGatewayProjectKit | null { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + const kit = PROJECT_KITS.find( + (candidate) => + candidate.id === normalized || + candidate.aliases.some((alias) => alias.toLowerCase() === normalized) + ); + return kit ? structuredClone(kit) : null; +} + +export function resolveModelGatewayProjectKit( + value: string | undefined +): ModelGatewayProjectKit | null { + return lookupModelGatewayProjectKit(value); +} + +export function isModelGatewayProjectKit(value: string | undefined): boolean { + return lookupModelGatewayProjectKit(value) !== null; +} + +export async function generateModelGatewayProject(input: { + projectPath: string; + projectName: string; + kit: ModelGatewayProjectKit; + generatedAt?: string; +}): Promise { + const generatedAt = input.generatedAt ?? new Date().toISOString(); + const version = getVersion(); + await fsExtra.ensureDir(input.projectPath); + + if (input.kit.id === 'gateway.openrouter.typescript') { + await generateOpenRouterTypeScriptGateway({ + projectPath: input.projectPath, + projectName: input.projectName, + }); + } else if (input.kit.id === 'gateway.openrouter.python') { + await generateOpenRouterPythonGateway({ + projectPath: input.projectPath, + projectName: input.projectName, + }); + } else { + const unsupported: never = input.kit.id; + throw new Error(`Unsupported model gateway kit: ${unsupported}`); + } + + await writeGeneratorFile( + path.join(input.projectPath, '.workspai', 'context.json'), + `${JSON.stringify( + { + engine: input.kit.runtime === 'python' ? 'pip' : 'npm', + runtime: input.kit.runtime, + framework: input.kit.gatewayId, + kind: MODEL_GATEWAY_KIND, + category: MODEL_GATEWAY_CATEGORY, + kit: input.kit.id, + }, + null, + 2 + )}\n` + ); + await writeGeneratorFile( + path.join(input.projectPath, '.workspai', 'project.json'), + `${JSON.stringify( + { + schema_version: '1.0', + name: input.projectName, + slug: input.projectName, + kind: MODEL_GATEWAY_KIND, + project_type: MODEL_GATEWAY_KIND, + category: MODEL_GATEWAY_CATEGORY, + runtime: input.kit.runtime, + framework: input.kit.gatewayId, + framework_display_name: input.kit.gatewayName, + kit_name: input.kit.id, + kit: input.kit.id, + engine: input.kit.runtime === 'python' ? 'pip' : 'npm', + support_tier: 'extended', + module_support: false, + modules: [], + workspai_version: version, + rapidkit_version: version, + generated_by: 'workspai', + generated_at: generatedAt, + contracts: { + owns: ['model-gateway'], + apis: [], + publishes: [], + consumes: ['openrouter'], + dependsOn: [], + env: [...input.kit.requiredEnvironment], + }, + }, + null, + 2 + )}\n` + ); +} + +export function isModelGatewayKitId(value: string): value is ModelGatewayKitId { + return value === 'gateway.openrouter.typescript' || value === 'gateway.openrouter.python'; +} diff --git a/packages/cli/src/model-gateways/registry.ts b/packages/cli/src/model-gateways/registry.ts new file mode 100644 index 00000000..7618b5e3 --- /dev/null +++ b/packages/cli/src/model-gateways/registry.ts @@ -0,0 +1 @@ +export { listModelGatewayProjectKits, lookupModelGatewayProjectKit } from './project-kits.js'; diff --git a/packages/cli/src/model-gateways/version-baselines.v1.json b/packages/cli/src/model-gateways/version-baselines.v1.json new file mode 100644 index 00000000..66c3dcad --- /dev/null +++ b/packages/cli/src/model-gateways/version-baselines.v1.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": "workspai.model-gateway-version-baselines.v1", + "reviewedAt": "2026-09-21", + "baselines": [ + { + "adapterId": "openrouter-typescript", + "gatewayId": "openrouter", + "gatewayName": "OpenRouter", + "runtime": "node", + "policy": "tested-baseline", + "releaseChannel": "stable", + "sdkVersion": "1.3.11", + "discoveryDate": "2026-09-21", + "declaredMaturity": "stable-1.x", + "runtimeRequirement": "Node.js 18, 20, or 22 LTS as documented by the SDK RUNTIMES.md; generated projects require Node.js >=20", + "upstreamReleaseUrl": "https://github.com/OpenRouterTeam/typescript-sdk/releases/tag/v1.3.11", + "registryUrl": "https://registry.npmjs.org/@openrouter/sdk/1.3.11", + "notes": "On 2026-09-21 GitHub released v1.3.11 (prerelease=false). Later the same day npm latest became 1.3.11 and registry.npmjs.org/@openrouter/sdk/1.3.11 returned 200. The pin is the latest non-prerelease version present on both the GitHub release tag and the npm registry.", + "packages": [ + { + "ecosystem": "npm", + "name": "@openrouter/sdk", + "version": "1.3.11", + "channel": "stable", + "role": "sdk-core", + "registryUrl": "https://registry.npmjs.org/@openrouter/sdk/1.3.11" + }, + { + "ecosystem": "npm", + "name": "typescript", + "version": "5.9.3", + "channel": "stable", + "role": "runtime-support", + "registryUrl": "https://registry.npmjs.org/typescript/5.9.3" + }, + { + "ecosystem": "npm", + "name": "@types/node", + "version": "22.20.3", + "channel": "stable", + "role": "runtime-support", + "registryUrl": "https://registry.npmjs.org/@types/node/22.20.3" + } + ], + "automaticUpgrade": false + }, + { + "adapterId": "openrouter-python", + "gatewayId": "openrouter", + "gatewayName": "OpenRouter", + "runtime": "python", + "policy": "tested-baseline", + "releaseChannel": "stable", + "sdkVersion": "1.2.11", + "discoveryDate": "2026-09-21", + "declaredMaturity": "stable-as-of-v1.0", + "runtimeRequirement": "Python >=3.10 as declared by the PyPI package requires_python field", + "upstreamReleaseUrl": "https://github.com/OpenRouterTeam/python-sdk/releases/tag/v1.2.11", + "registryUrl": "https://pypi.org/pypi/openrouter/1.2.11", + "notes": "PyPI classifiers are empty. Maturity is taken from the official package description: The OpenRouter Python SDK is stable as of v1.0. GitHub tag v1.2.11 matches PyPI 1.2.11.", + "packages": [ + { + "ecosystem": "pypi", + "name": "openrouter", + "version": "1.2.11", + "channel": "stable", + "role": "sdk-core", + "registryUrl": "https://pypi.org/pypi/openrouter/1.2.11" + } + ], + "automaticUpgrade": false + } + ], + "excluded": [ + { + "language": "go", + "module": "github.com/OpenRouterTeam/go-sdk", + "discoveryDate": "2026-09-21", + "latestObservedVersion": "v0.8.11", + "declaredMaturity": "beta", + "reason": "The official Go SDK README classifies the current 0.x release line as beta and warns that breaking changes may ship in minor releases. Workspai does not admit beta Client SDKs to the stable gateway surface.", + "evidence": [ + "https://github.com/OpenRouterTeam/go-sdk README note: This SDK is in beta. Pin to a specific version to avoid unexpected breaking changes.", + "https://proxy.golang.org/github.com/openrouterteam/go-sdk/@latest returned Version v0.8.11 on 2026-09-21." + ] + } + ] +} diff --git a/packages/cli/src/model-gateways/version-policy.ts b/packages/cli/src/model-gateways/version-policy.ts new file mode 100644 index 00000000..5487dc57 --- /dev/null +++ b/packages/cli/src/model-gateways/version-policy.ts @@ -0,0 +1,174 @@ +import versionBaselineDocument from './version-baselines.v1.json'; + +export const MODEL_GATEWAY_VERSION_BASELINES_SCHEMA_VERSION = + 'workspai.model-gateway-version-baselines.v1' as const; + +export type ModelGatewayPackageEcosystem = 'npm' | 'pypi'; +export type ModelGatewayBaselineRuntime = 'node' | 'python'; +export type ModelGatewayPackageRole = 'sdk-core' | 'runtime-support'; + +export type ModelGatewayPackageBaseline = { + ecosystem: ModelGatewayPackageEcosystem; + name: string; + version: string; + channel: 'stable'; + role: ModelGatewayPackageRole; + registryUrl: string; +}; + +export type ModelGatewayVersionBaseline = { + adapterId: string; + gatewayId: string; + gatewayName: string; + runtime: ModelGatewayBaselineRuntime; + policy: 'tested-baseline'; + releaseChannel: 'stable'; + sdkVersion: string; + discoveryDate: string; + declaredMaturity: string; + runtimeRequirement: string; + upstreamReleaseUrl: string; + registryUrl: string; + notes: string; + packages: readonly ModelGatewayPackageBaseline[]; + automaticUpgrade: false; +}; + +export type ModelGatewayExcludedLanguage = { + language: string; + module: string; + discoveryDate: string; + latestObservedVersion: string; + declaredMaturity: string; + reason: string; + evidence: readonly string[]; +}; + +export type ModelGatewayVersionBaselineDocument = { + schemaVersion: typeof MODEL_GATEWAY_VERSION_BASELINES_SCHEMA_VERSION; + reviewedAt: string; + baselines: ModelGatewayVersionBaseline[]; + excluded: ModelGatewayExcludedLanguage[]; +}; + +const VERSION_PATTERN = /^\d+(?:\.\d+)+$/; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function assertBaselineDocument( + value: unknown +): asserts value is ModelGatewayVersionBaselineDocument { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Model gateway version baseline document must be an object.'); + } + const document = value as Partial; + if (document.schemaVersion !== MODEL_GATEWAY_VERSION_BASELINES_SCHEMA_VERSION) { + throw new Error( + `Unsupported model gateway version baseline schema: ${String(document.schemaVersion)}` + ); + } + if (typeof document.reviewedAt !== 'string' || !DATE_PATTERN.test(document.reviewedAt)) { + throw new Error('Model gateway version baseline document is missing reviewedAt.'); + } + if (!Array.isArray(document.baselines) || document.baselines.length === 0) { + throw new Error('Model gateway version baseline document contains no adapters.'); + } + const adapterIds = new Set(); + for (const baseline of document.baselines as ModelGatewayVersionBaseline[]) { + if ( + !baseline || + typeof baseline.adapterId !== 'string' || + typeof baseline.gatewayId !== 'string' || + typeof baseline.gatewayName !== 'string' || + (baseline.runtime !== 'node' && baseline.runtime !== 'python') || + baseline.policy !== 'tested-baseline' || + baseline.releaseChannel !== 'stable' || + !VERSION_PATTERN.test(baseline.sdkVersion) || + !DATE_PATTERN.test(baseline.discoveryDate) || + typeof baseline.declaredMaturity !== 'string' || + /beta|alpha|preview|rc|canary|next|dev/i.test(baseline.declaredMaturity) || + typeof baseline.runtimeRequirement !== 'string' || + typeof baseline.upstreamReleaseUrl !== 'string' || + !baseline.upstreamReleaseUrl.startsWith('https://') || + typeof baseline.registryUrl !== 'string' || + !baseline.registryUrl.startsWith('https://') || + baseline.automaticUpgrade !== false || + !Array.isArray(baseline.packages) || + baseline.packages.length === 0 + ) { + throw new Error(`Invalid model gateway version baseline: ${String(baseline?.adapterId)}`); + } + if (adapterIds.has(baseline.adapterId)) { + throw new Error(`Duplicate model gateway version baseline: ${baseline.adapterId}`); + } + adapterIds.add(baseline.adapterId); + const packageNames = new Set(); + let sdkCoreVersion: string | null = null; + for (const dependency of baseline.packages) { + if ( + !dependency || + (dependency.ecosystem !== 'npm' && dependency.ecosystem !== 'pypi') || + typeof dependency.name !== 'string' || + !VERSION_PATTERN.test(dependency.version) || + dependency.channel !== 'stable' || + (dependency.role !== 'sdk-core' && dependency.role !== 'runtime-support') || + typeof dependency.registryUrl !== 'string' || + !dependency.registryUrl.startsWith('https://') + ) { + throw new Error(`Invalid package baseline in ${baseline.adapterId}.`); + } + if (packageNames.has(dependency.name)) { + throw new Error(`Duplicate package ${dependency.name} in ${baseline.adapterId}.`); + } + packageNames.add(dependency.name); + if (dependency.role === 'sdk-core') sdkCoreVersion = dependency.version; + } + if (!sdkCoreVersion || sdkCoreVersion !== baseline.sdkVersion) { + throw new Error(`SDK version is not bound to the core package in ${baseline.adapterId}.`); + } + } + if (!Array.isArray(document.excluded)) { + throw new Error('Model gateway version baseline document must declare excluded languages.'); + } +} + +assertBaselineDocument(versionBaselineDocument); +const validated = versionBaselineDocument as ModelGatewayVersionBaselineDocument; + +export const BUILTIN_MODEL_GATEWAY_VERSION_BASELINES: readonly ModelGatewayVersionBaseline[] = + Object.freeze( + structuredClone(validated.baselines).map((baseline) => + Object.freeze({ + ...baseline, + packages: Object.freeze(baseline.packages.map((dependency) => Object.freeze(dependency))), + }) + ) + ); + +export const EXCLUDED_MODEL_GATEWAY_LANGUAGES: readonly ModelGatewayExcludedLanguage[] = + Object.freeze(structuredClone(validated.excluded).map((entry) => Object.freeze({ ...entry }))); + +export const MODEL_GATEWAY_BASELINES_REVIEWED_AT = validated.reviewedAt; + +export function getModelGatewayVersionBaseline( + adapterId: string +): ModelGatewayVersionBaseline | null { + return ( + BUILTIN_MODEL_GATEWAY_VERSION_BASELINES.find((entry) => entry.adapterId === adapterId) ?? null + ); +} + +export function packageVersion(baseline: ModelGatewayVersionBaseline, packageName: string): string { + const dependency = baseline.packages.find((entry) => entry.name === packageName); + if (!dependency) { + throw new Error(`Package ${packageName} is not in baseline ${baseline.adapterId}.`); + } + return dependency.version; +} + +export function sdkCorePackage(baseline: ModelGatewayVersionBaseline): ModelGatewayPackageBaseline { + const dependency = baseline.packages.find((entry) => entry.role === 'sdk-core'); + if (!dependency) { + throw new Error(`Baseline ${baseline.adapterId} is missing an sdk-core package.`); + } + return dependency; +} diff --git a/packages/cli/src/utils/create-planner-capabilities.ts b/packages/cli/src/utils/create-planner-capabilities.ts index 14160b3d..f75f98eb 100644 --- a/packages/cli/src/utils/create-planner-capabilities.ts +++ b/packages/cli/src/utils/create-planner-capabilities.ts @@ -1,6 +1,10 @@ import { listFrontendGenerators } from '../frontend-project.js'; import { listOfficialProjectGenerators } from '../official-project.js'; import { listAgentFrameworkProjectKits } from '../agent-frameworks/project-kits.js'; +import { + listModelGatewayProjectKits, + lookupModelGatewayProjectKit, +} from '../model-gateways/project-kits.js'; import { listInteractiveKits, normalizeKitId } from './kit-registry.js'; export type CreatePlannerLane = 'native' | 'official' | 'existing'; @@ -36,6 +40,7 @@ export interface CreatePlannerCapability { const NATIVE_CREATE_KITS = new Set([ ...listInteractiveKits().map((kit) => kit.id), ...listAgentFrameworkProjectKits().map((kit) => kit.id), + ...listModelGatewayProjectKits().flatMap((kit) => [kit.id, ...kit.aliases]), ]); export const OFFICIAL_CREATE_CANDIDATES: OfficialCreateCandidate[] = [ @@ -179,6 +184,20 @@ export function resolveCreatePlannerCapability(input: { }; } + const gatewayKit = + lookupModelGatewayProjectKit(input.kitId) ?? lookupModelGatewayProjectKit(input.framework); + if (gatewayKit) { + return { + lane: 'native', + status: 'available', + canExecuteCreate: true, + requested, + resolved: gatewayKit.id, + reason: + 'Workspai owns the create contract, project marker, registry, doctor, and workspace model path.', + }; + } + const normalizedKit = input.kitId ? normalizeKitId(input.kitId) : undefined; if (normalizedKit && NATIVE_CREATE_KITS.has(normalizedKit)) { return { diff --git a/packages/cli/src/utils/project-kind.ts b/packages/cli/src/utils/project-kind.ts index f5511657..9d9b88bd 100644 --- a/packages/cli/src/utils/project-kind.ts +++ b/packages/cli/src/utils/project-kind.ts @@ -18,6 +18,7 @@ export type WorkspaceProjectKind = | 'service' | 'worker' | 'agent' + | 'gateway' | 'platform' | 'library' | 'infra' @@ -31,6 +32,7 @@ export type WorkspaceProjectCategory = | 'desktop' | 'extension' | 'agent' + | 'gateway' | 'platform' | 'library' | 'infrastructure' @@ -52,6 +54,7 @@ const PROJECT_KIND_VALUES = new Set([ 'frontend', 'worker', 'agent', + 'gateway', 'platform', 'library', 'infra', @@ -303,6 +306,8 @@ export function categorizeWorkspaceProjectKind( return 'backend'; case 'agent': return 'agent'; + case 'gateway': + return 'gateway'; case 'frontend': case 'desktop': case 'extension': @@ -383,6 +388,13 @@ export async function inferWorkspaceProjectKind( return 'extension'; } + if ( + (typeof kit === 'string' && kit.trim().toLowerCase().startsWith('gateway.')) || + (typeof framework === 'string' && framework.trim().toLowerCase() === 'openrouter') + ) { + return 'gateway'; + } + // Repository topology is stronger evidence than an inferred application // framework. A monorepo root may legitimately carry Next.js, NestJS, React, // or another app dependency while governing many independently addressable diff --git a/packages/cli/src/utils/suggested-project-name.ts b/packages/cli/src/utils/suggested-project-name.ts index 372859b6..1a0abb18 100644 --- a/packages/cli/src/utils/suggested-project-name.ts +++ b/packages/cli/src/utils/suggested-project-name.ts @@ -1,6 +1,7 @@ import { resolveFrontendGenerator } from '../frontend-project.js'; import { resolveOfficialProjectGenerator } from '../official-project.js'; import { resolveKitDefinition } from './kit-registry.js'; +import { lookupModelGatewayProjectKit } from '../model-gateways/project-kits.js'; const NAME_THEMES = [ 'catalog', @@ -93,6 +94,11 @@ export function suggestProjectNameForKit(kitId: string): string { return `${theme}-${OFFICIAL_SUFFIX[official.id] ?? 'app'}`; } + const gateway = lookupModelGatewayProjectKit(kitId); + if (gateway) { + return `${theme}-gateway`; + } + const kit = resolveKitDefinition(kitId); const suffix = kit ? (KIT_SUFFIX[kit.id] ?? 'service') : 'app'; return `${theme}-${suffix}`; diff --git a/packages/cli/src/workspace-run.ts b/packages/cli/src/workspace-run.ts index 3b8f90b4..c5c91b5f 100644 --- a/packages/cli/src/workspace-run.ts +++ b/packages/cli/src/workspace-run.ts @@ -390,6 +390,32 @@ async function readProjectDeclaredName(projectPath: string): Promise { + for (const relativeConfigPath of [ + path.join('.workspai', 'project.json'), + path.join('.workspai', 'context.json'), + path.join('.rapidkit', 'project.json'), + path.join('.rapidkit', 'context.json'), + ]) { + const configPath = path.join(projectPath, relativeConfigPath); + if (!(await pathExists(configPath))) { + continue; + } + try { + const payload = await readJsonFile>(configPath); + for (const key of ['kind', 'category', 'project_type'] as const) { + const value = payload[key]; + if (typeof value === 'string' && value.trim().toLowerCase() === 'gateway') { + return true; + } + } + } catch { + // Keep lifecycle selection resilient for malformed metadata. + } + } + return false; +} + async function filterProjectsByScope( workspacePath: string, projects: string[], @@ -859,6 +885,7 @@ async function runStartupSmoke(input: { runtime: RuntimeFamily; framework?: string; timeoutMs: number; + argv?: LifecycleArgvStep; }): Promise<{ exitCode: number; stdout: string; @@ -886,12 +913,18 @@ async function runStartupSmoke(input: { RAPIDKIT_WORKSPACE_RUN_CHILD: '1', }, }) - : execa(input.finalCommand, [], { - cwd: input.projectPath, - reject: false, - shell: true, - timeout: input.timeoutMs, - })) as unknown as StartupSubprocess; + : input.argv + ? execa(input.argv.executable, input.argv.args, { + cwd: input.projectPath, + reject: false, + timeout: input.timeoutMs, + }) + : execa(input.finalCommand, [], { + cwd: input.projectPath, + reject: false, + shell: true, + timeout: input.timeoutMs, + })) as unknown as StartupSubprocess; const completion = Promise.resolve(subprocess).then( (result) => ({ kind: 'exit' as const, result }), @@ -1408,6 +1441,7 @@ async function executeStageCommand( runtime, framework, timeoutMs, + argv: materializedArgv?.length === 1 ? materializedArgv[0] : undefined, }) : useRapidkitWrapper ? stage === 'init' && isVitestRuntime() @@ -1946,16 +1980,19 @@ export async function runWorkspaceStage(options: WorkspaceRunOptions): Promise 1 || + (plannedUnits.length === 1 && plannedUnits[0]?.unit.root !== '.') || + (await projectDeclaresGatewayCategory(projectPath)); + // The project shortcut is safe only for one concrete runtime unit. Two // independent manifests can use the same language; collapsing those to // one root wrapper silently skips work just as surely as collapsing a - // polyglot project would. - if ( - !lifecyclePlan.polyglot && - !runtimeFilter && - runtimeExecutions.length === 1 && - plannedUnits[0]?.unit.root === '.' - ) { + // polyglot project would. Gateway kits stay on the declared polyglot + // unit commands because they are not Rapidkit wrapper-owned adapters. + if (!useDeclaredLifecycleUnits && runtimeExecutions.length === 1) { const detected = await detectProjectFramework(projectPath); row.framework = detected.framework; row.runtimeDetected = detected.runtime; @@ -1998,12 +2035,7 @@ export async function runWorkspaceStage(options: WorkspaceRunOptions): Promise 1 || - (plannedUnits.length === 1 && plannedUnits[0]?.unit.root !== '.') - ) { + if (useDeclaredLifecycleUnits) { if (plannedUnits.length === 0) { row.status = 'failed'; row.reason = runtimeFilter diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index e0ce1368..c196846a 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ 'src/core-bridge/**/*.ts', 'src/generators/**/*.ts', 'src/agent-frameworks/adapters/openai-agents/**/*.ts', + 'src/model-gateways/**/*.ts', 'src/observability/**/*.ts', 'src/runtime-adapters/**/*.ts', 'src/utils/**/*.ts', From a1cac740240357e7b444214079f7b1bb29dc3f75 Mon Sep 17 00:00:00 2001 From: Baziar Date: Mon, 21 Sep 2026 15:32:50 -0400 Subject: [PATCH 2/4] fix(cli): close OpenRouter gateway qualification gaps --- .../workflows/model-gateway-qualification.yml | 109 +++++ .../model-gateway-version-discovery.yml | 77 ++++ packages/cli/CHANGELOG.md | 16 +- packages/cli/docs/ci-workflows.md | 9 +- .../docs/creating-workspaces-and-projects.md | 3 +- packages/cli/docs/model-gateways.md | 38 +- packages/cli/package.json | 2 + ...emit-model-gateway-qualification-report.ts | 80 ++++ .../propose-model-gateway-version-update.ts | 93 ++++ .../src/__tests__/framework-registry.test.ts | 10 + .../model-gateway-project-kits.test.ts | 35 +- .../model-gateway-qualification.test.ts | 136 ++++++ .../__tests__/model-gateway-registry.test.ts | 122 +++++ .../model-gateway-version-discovery.test.ts | 229 ++++++++++ .../model-gateway-workspace-lifecycle.test.ts | 193 +++++++- .../cli/src/__tests__/workspace-run.test.ts | 77 ++++ packages/cli/src/cli-ui/kit-picker-choices.ts | 3 +- packages/cli/src/framework-registry.ts | 11 +- packages/cli/src/model-gateways/adapter.ts | 45 ++ .../adapters/openrouter/python.ts | 59 ++- .../adapters/openrouter/typescript.ts | 57 ++- packages/cli/src/model-gateways/gateway.ts | 4 +- packages/cli/src/model-gateways/generated.ts | 151 ++++++ packages/cli/src/model-gateways/index.ts | 11 + .../cli/src/model-gateways/project-kits.ts | 142 +----- .../src/model-gateways/qualification-gate.ts | 147 ++++++ .../model-gateways/qualification-policy.ts | 156 +++++++ .../src/model-gateways/qualification.v1.json | 17 + packages/cli/src/model-gateways/registry.ts | 104 ++++- .../src/model-gateways/version-discovery.ts | 429 ++++++++++++++++++ .../cli/src/model-gateways/version-policy.ts | 33 ++ packages/cli/src/workspace-run.ts | 71 ++- 32 files changed, 2451 insertions(+), 218 deletions(-) create mode 100644 .github/workflows/model-gateway-qualification.yml create mode 100644 .github/workflows/model-gateway-version-discovery.yml create mode 100644 packages/cli/scripts/emit-model-gateway-qualification-report.ts create mode 100644 packages/cli/scripts/propose-model-gateway-version-update.ts create mode 100644 packages/cli/src/__tests__/model-gateway-qualification.test.ts create mode 100644 packages/cli/src/__tests__/model-gateway-registry.test.ts create mode 100644 packages/cli/src/__tests__/model-gateway-version-discovery.test.ts create mode 100644 packages/cli/src/model-gateways/adapter.ts create mode 100644 packages/cli/src/model-gateways/generated.ts create mode 100644 packages/cli/src/model-gateways/qualification-gate.ts create mode 100644 packages/cli/src/model-gateways/qualification-policy.ts create mode 100644 packages/cli/src/model-gateways/qualification.v1.json create mode 100644 packages/cli/src/model-gateways/version-discovery.ts diff --git a/.github/workflows/model-gateway-qualification.yml b/.github/workflows/model-gateway-qualification.yml new file mode 100644 index 00000000..000e88a5 --- /dev/null +++ b/.github/workflows/model-gateway-qualification.yml @@ -0,0 +1,109 @@ +name: Model Gateway Qualification + +on: + pull_request: + paths: + - "packages/cli/src/model-gateways/**" + - "packages/cli/src/__tests__/model-gateway-*.test.ts" + - "packages/cli/src/workspace-run.ts" + - "packages/cli/src/framework-registry.ts" + - "packages/cli/src/cli-ui/kit-picker-choices.ts" + - "packages/cli/docs/model-gateways.md" + - "packages/cli/scripts/propose-model-gateway-version-update.ts" + - "packages/cli/scripts/emit-model-gateway-qualification-report.ts" + - ".github/workflows/model-gateway-qualification.yml" + - ".github/workflows/model-gateway-version-discovery.yml" + workflow_dispatch: + inputs: + qualification_mode: + description: "Fast Linux validation or complete three-OS qualification" + required: true + default: full + type: choice + options: + - fast + - full + +permissions: + contents: read + pull-requests: read + +concurrency: + group: model-gateway-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + qualify: + name: OpenRouter gateways · ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + env: + CI: "1" + npm_config_cache: ${{ runner.temp }}/workspai-npm-cache + NPM_CONFIG_CACHE: ${{ runner.temp }}/workspai-npm-cache + PIP_CACHE_DIR: ${{ runner.temp }}/workspai-pip-cache + PIP_DISABLE_PIP_VERSION_CHECK: "1" + strategy: + fail-fast: false + matrix: + # One job per OS qualifies both OpenRouter adapters. A kit axis would + # duplicate the same TypeScript and Python suites without filtering. + os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.qualification_mode == 'full' && '["ubuntu-latest","macos-latest","windows-latest"]' || '["ubuntu-latest"]') }} + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "20.19.0" + cache: npm + cache-dependency-path: package-lock.json + + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.10.11" + + - name: Install JavaScript dependencies + run: npm ci + + - name: Fail if a live OpenRouter key is present + shell: bash + run: | + if [ -n "${OPENROUTER_API_KEY:-}" ]; then + echo "OPENROUTER_API_KEY must not be set during gateway qualification." + exit 1 + fi + + - name: Run Model Gateway qualification tests + working-directory: packages/cli + shell: bash + run: | + npm exec vitest -- run \ + src/__tests__/model-gateway-generated-projects.test.ts \ + src/__tests__/model-gateway-workspace-lifecycle.test.ts \ + src/__tests__/model-gateway-policy-parity.test.ts \ + src/__tests__/model-gateway-project-kits.test.ts \ + src/__tests__/model-gateway-registry.test.ts \ + src/__tests__/model-gateway-qualification.test.ts \ + --reporter=default \ + --reporter=json \ + --outputFile.json=test-results/model-gateway-qualification/${{ matrix.os }}.vitest.json + + - name: Emit machine-readable qualification report + working-directory: packages/cli + run: >- + npm exec tsx -- scripts/emit-model-gateway-qualification-report.ts + --os ${{ matrix.os }} + --vitest-json test-results/model-gateway-qualification/${{ matrix.os }}.vitest.json + --out test-results/model-gateway-qualification/${{ matrix.os }}.json + + - name: Upload qualification report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: model-gateway-qualification-${{ matrix.os }}-${{ github.sha }} + path: packages/cli/test-results/model-gateway-qualification/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/model-gateway-version-discovery.yml b/.github/workflows/model-gateway-version-discovery.yml new file mode 100644 index 00000000..bc3a6bfe --- /dev/null +++ b/.github/workflows/model-gateway-version-discovery.yml @@ -0,0 +1,77 @@ +name: Model Gateway Version Discovery + +on: + schedule: + - cron: "41 7 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: model-gateway-version-discovery + cancel-in-progress: true + +jobs: + discover: + name: Discover upstream SDK candidates + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + ref: ${{ github.event.repository.default_branch }} + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "20.19.0" + cache: npm + cache-dependency-path: package-lock.json + + - name: Install JavaScript dependencies + run: npm ci + + - name: Discover upstream version candidates + id: discover + working-directory: packages/cli + shell: bash + run: | + set +e + npm exec tsx -- scripts/propose-model-gateway-version-update.ts \ + --baseline src/model-gateways/version-baselines.v1.json \ + --report test-results/model-gateway-version-discovery/report.json + status=$? + set -e + echo "status=$status" >> "$GITHUB_OUTPUT" + if [ "$status" -eq 0 ] || [ "$status" -eq 10 ]; then + exit 0 + fi + exit "$status" + + - name: Upload reviewable candidates + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: model-gateway-version-discovery-${{ github.sha }} + path: packages/cli/test-results/model-gateway-version-discovery/report.json + if-no-files-found: error + retention-days: 30 + + - name: Publish run summary + if: always() + shell: bash + env: + DISCOVERY_STATUS: ${{ steps.discover.outputs.status }} + run: | + echo "### Model Gateway version discovery" >> "$GITHUB_STEP_SUMMARY" + echo "This workflow does not write the baseline, commit, open a pull request, or admit a Workspai release." >> "$GITHUB_STEP_SUMMARY" + echo "Exit status: ${DISCOVERY_STATUS:-unknown} (0 current, 10 update available)." >> "$GITHUB_STEP_SUMMARY" + REPORT=packages/cli/test-results/model-gateway-version-discovery/report.json + if [ -f "$REPORT" ]; then + echo "" >> "$GITHUB_STEP_SUMMARY" + echo '```json' >> "$GITHUB_STEP_SUMMARY" + python3 -c "import json,pathlib; d=json.loads(pathlib.Path('$REPORT').read_text()); print(json.dumps({'outcome': d.get('outcome'), 'summary': d.get('summary')}, indent=2))" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index fa7d25dc..101162c2 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -15,7 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `gateway.openrouter.python` (`openrouter` `1.2.11`). Generated projects are server-owned, pin reviewed SDK baselines, keep the model as runtime configuration, and do not call a model during Create. The official Go SDK - remains beta and is not included on the Create surface. + remains beta and is not included on the Create surface. Attach is unsupported + for this surface. Version discovery and three-OS qualification workflows are + read-only and do not admit a Workspai release by themselves. ### Changed @@ -23,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Create, Workspace Run/Verify polyglot lifecycle, and doctor runtime adapters now recognize `gateway` as a first-class project category distinct from Agent Framework. +- Model Gateway qualification CI runs one job per OS that qualifies both + TypeScript and Python adapters, instead of an unused kit axis that duplicated + the same suites. ### Fixed @@ -36,6 +41,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `validateCommand` accepts a space-containing executable path only when that path exists, then falls back to the first token so commands such as `go build ./...` still resolve through `which`. +- Model Gateway version discovery is a read-only `discover → propose → review + → qualify → merge` path. Registry and GitHub must agree before a baseline + proposal can be written. Workspace lifecycle qualification no longer returns + green when package installation fails. +- Qualification treats sparse workspace-run init receipts (`errorCategory: + unknown` and `Stage failed with exit code 1`, including missing npm/pip + excerpts) as registry infrastructure, not product failure, and retries once. +- Generated `.workspai/project.json` `contracts.consumes` is derived from the + kit `gatewayId` instead of a hardcoded OpenRouter value. ## [0.76.0] - 2026-09-20 diff --git a/packages/cli/docs/ci-workflows.md b/packages/cli/docs/ci-workflows.md index fed4992e..bd0c68ba 100644 --- a/packages/cli/docs/ci-workflows.md +++ b/packages/cli/docs/ci-workflows.md @@ -13,6 +13,8 @@ Map of GitHub Actions workflows in this repository. Use this when editing CI to | Official generator smoke | `.github/workflows/frontend-generator-smoke.yml` | Contract-driven official-generator drift gate | | Agent Framework matrix | `.github/workflows/agent-framework-conformance.yml` | Affected-family Linux PR gate plus manual twelve-lane release qualification and admission | | Agent Framework discovery | `.github/workflows/agent-framework-version-discovery.yml` | Weekly PyPI/NuGet candidate report; no commit, PR, or contract rewrite | +| Model Gateway matrix | `.github/workflows/model-gateway-qualification.yml` | Linux PR gate plus manual three-OS generated-project and lifecycle qualification | +| Model Gateway discovery | `.github/workflows/model-gateway-version-discovery.yml` | Weekly npm/PyPI/GitHub candidate report; no baseline write, commit, or PR | | Security | `.github/workflows/security.yml` | Path-aware scanning plus the always-resolved `Security Gate` | | Manual npm release | `.github/workflows/release-npm-manual.yml` | Maintainer-only release gate and publish workflow | | Discord announcement | `.github/workflows/discord-release-announcement.yml` | Preview and publish one idempotent product-aware release announcement | @@ -39,8 +41,11 @@ It never sets Foundry or OpenAI credentials. Only a reviewed admission on the pr version-update branch can promote a green candidate. OpenRouter AI Gateway kits are not part of the Agent Framework conformance -matrix. They are covered by the CLI test matrix, generated-project verification, -and enterprise package create smoke. See [AI Gateway](./model-gateways.md). +matrix. They use a dedicated qualification workflow for generated-project +install, tests, smoke, missing-key startup, Workspace Run, and Workspace +Verify on Linux, macOS, and Windows. Weekly discovery reports newer stable SDK +versions without writing the baseline or opening a pull request. See +[AI Gateway](./model-gateways.md). The PR template at `.github/agent-framework-version-update.md` is for that human pin update. It is not opened by the weekly discovery workflow. diff --git a/packages/cli/docs/creating-workspaces-and-projects.md b/packages/cli/docs/creating-workspaces-and-projects.md index 668fe81f..72cf31cc 100644 --- a/packages/cli/docs/creating-workspaces-and-projects.md +++ b/packages/cli/docs/creating-workspaces-and-projects.md @@ -429,7 +429,8 @@ independent proof for this scaffold. See Interactive `workspai create` shows these kits under **AI Gateway** with the hint **Unified model access and routing**. A model identifier is runtime configuration, not a kit. Create does not install dependencies, call a model, -or store credentials. The official Go Client SDK remains beta (`v0.8.11` on +or store credentials. Attach is not supported for Gateway in this release. +The official Go Client SDK remains beta (`v0.8.11` on 2026-09-21) and is not admitted. See [AI Gateway](./model-gateways.md). diff --git a/packages/cli/docs/model-gateways.md b/packages/cli/docs/model-gateways.md index 53f0e7df..5c960042 100644 --- a/packages/cli/docs/model-gateways.md +++ b/packages/cli/docs/model-gateways.md @@ -45,6 +45,37 @@ npx workspai create project gateway.openrouter.python Create does not install dependencies, call a model, or write credentials. +Attach is not supported for AI Gateway in this release. Do not use +`agent framework attach` or any gateway-specific attach command. Adopt an +existing OpenRouter project only by creating a new gateway kit or by adding +the generated starter beside user-owned code. Repeated Create is not Attach. + +## Version upgrades + +Exact SDK pins live only in +[`version-baselines.v1.json`](../src/model-gateways/version-baselines.v1.json). +`releaseChannel: stable` means the upstream SDK release is stable. It does not +mean the Workspai adapter passed Linux, macOS, and Windows qualification. + +The supported upgrade path is read-only until a human reviews the proposal: + +```text +discover → propose → review → qualify → merge +``` + +```bash +npm --workspace workspai run propose:model-gateway:versions -- \ + --baseline src/model-gateways/version-baselines.v1.json \ + --report test-results/model-gateway-version-discovery.json +``` + +The command does not commit, push, open a pull request, or write the baseline +unless `--write` is passed after the on-disk file matches the validated +in-memory document and every candidate agrees across registry and GitHub. +A normal SDK upgrade should not require editing generator source when the +upstream contract is compatible. Three-OS qualification must be green on the +same commit before a Workspai release can present these kits as qualified. + ## Intentionally unsupported languages The official Go SDK (`github.com/OpenRouterTeam/go-sdk`) was evaluated on @@ -164,4 +195,9 @@ Do not copy versions into unrelated files. Workspai source readiness is not release qualification. These kits are not described as `stable`, `release-ready`, `admitted`, or `published` until Linux, macOS, and Windows CI prove generated-project installation and lifecycle on the -same commit SHA. +same commit SHA. The dedicated workflow is +`.github/workflows/model-gateway-qualification.yml`. Each OS job qualifies both +the TypeScript and Python OpenRouter adapters; there is no unused kit axis. +Discovery is +`.github/workflows/model-gateway-version-discovery.yml`. A green job that +skipped installation or lifecycle is not qualified. diff --git a/packages/cli/package.json b/packages/cli/package.json index 4a0ead8e..46c0d36f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -120,6 +120,8 @@ "verify:agent-framework:matrix": "tsx scripts/verify-agent-framework-conformance.ts --reports test-results/agent-framework-conformance", "discover:agent-framework:versions": "tsx scripts/discover-agent-framework-versions.ts", "propose:agent-framework:versions": "tsx scripts/propose-agent-framework-version-update.ts", + "discover:model-gateway:versions": "tsx scripts/propose-model-gateway-version-update.ts", + "propose:model-gateway:versions": "tsx scripts/propose-model-gateway-version-update.ts", "promote:agent-framework:admission": "tsx scripts/promote-agent-framework-release-admission.ts", "prepush:test:runtime-contract": "node scripts/run-cached-prepush-gate.mjs runtime-contract", "test:runtime-matrix:full": "node scripts/runtime-acceptance-matrix.mjs --full", diff --git a/packages/cli/scripts/emit-model-gateway-qualification-report.ts b/packages/cli/scripts/emit-model-gateway-qualification-report.ts new file mode 100644 index 00000000..626a79fa --- /dev/null +++ b/packages/cli/scripts/emit-model-gateway-qualification-report.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { + BUILTIN_MODEL_GATEWAY_VERSION_BASELINES, + sdkCorePackage, +} from '../src/model-gateways/version-policy.js'; +import { MODEL_GATEWAY_RELEASE_AUTHORITY } from '../src/model-gateways/qualification-policy.js'; + +function argument(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +async function main(): Promise { + const osName = argument('--os') ?? process.platform; + const outputPath = argument('--out'); + const vitestJsonPath = argument('--vitest-json'); + if (!outputPath || !vitestJsonPath) { + throw new Error('Usage: --os --vitest-json --out '); + } + const vitest = JSON.parse(await fs.readFile(path.resolve(vitestJsonPath), 'utf8')) as { + numFailedTests?: number; + numPassedTests?: number; + numSkippedTests?: number; + numPendingTests?: number; + success?: boolean; + }; + const failed = Number(vitest.numFailedTests ?? 0); + const passed = Number(vitest.numPassedTests ?? 0); + const skipped = Number(vitest.numSkippedTests ?? vitest.numPendingTests ?? 0); + if (failed > 0 || vitest.success === false) { + throw new Error('Qualification report refused: required tests failed.'); + } + if (skipped > 0) { + throw new Error('Qualification report refused: required tests were skipped.'); + } + if (passed < 1) { + throw new Error('Qualification report refused: no passing tests were recorded.'); + } + + const sha = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim(); + const adapters = BUILTIN_MODEL_GATEWAY_VERSION_BASELINES.map((baseline) => { + const core = sdkCorePackage(baseline); + return { + adapterId: baseline.adapterId, + sdkVersion: baseline.sdkVersion, + packageName: core.name, + }; + }); + const report = { + schemaVersion: 'workspai.model-gateway-qualification-report.v1', + commitSha: sha, + os: osName, + runtime: process.version, + workspaiReleaseAuthority: MODEL_GATEWAY_RELEASE_AUTHORITY, + adapters, + tests: { + passed: Number(vitest.numPassedTests ?? 0), + failed, + skipped, + }, + liveKeyPresent: Boolean(process.env.OPENROUTER_API_KEY), + generatedAt: new Date().toISOString(), + }; + if (report.liveKeyPresent) { + throw new Error('Qualification report refused: OPENROUTER_API_KEY is set.'); + } + await fs.mkdir(path.dirname(path.resolve(outputPath)), { recursive: true }); + await fs.writeFile(path.resolve(outputPath), `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + process.stdout.write(`WROTE ${outputPath}\n`); +} + +main().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n` + ); + process.exitCode = 1; +}); diff --git a/packages/cli/scripts/propose-model-gateway-version-update.ts b/packages/cli/scripts/propose-model-gateway-version-update.ts new file mode 100644 index 00000000..0389d529 --- /dev/null +++ b/packages/cli/scripts/propose-model-gateway-version-update.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + BUILTIN_MODEL_GATEWAY_VERSION_BASELINES, + EXCLUDED_MODEL_GATEWAY_LANGUAGES, + MODEL_GATEWAY_VERSION_BASELINES_SCHEMA_VERSION, +} from '../src/model-gateways/version-policy.js'; +import { + discoverModelGatewayVersions, + discoveryExitCode, + formatModelGatewayDiscoverySummary, +} from '../src/model-gateways/version-discovery.js'; + +function argument(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function hasFlag(name: string): boolean { + return process.argv.includes(name); +} + +async function writeJson(target: string, value: unknown): Promise { + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +async function main(): Promise { + const baselineArgument = argument('--baseline'); + if (!baselineArgument) { + throw new Error( + 'Usage: --baseline [--report ] [--write]' + ); + } + const baselinePath = path.resolve(baselineArgument); + const reportPath = argument('--report'); + const writeRequested = hasFlag('--write'); + const discovery = await discoverModelGatewayVersions(); + if (reportPath) await writeJson(path.resolve(reportPath), discovery); + process.stdout.write(formatModelGatewayDiscoverySummary(discovery)); + + if (discovery.outcome !== 'update-available') { + process.exitCode = discoveryExitCode(discovery.outcome); + return; + } + + if (!writeRequested) { + process.stdout.write( + 'PROPOSAL available. Re-run with --write after review to update the caller-specified baseline file.\n' + ); + process.exitCode = discoveryExitCode(discovery.outcome); + return; + } + + if (!discovery.proposedDocument) { + throw new Error('Update is available but no proposed baseline was produced.'); + } + if ( + discovery.summary.disagreement > 0 || + discovery.summary.unavailable > 0 || + discovery.summary.invalid > 0 + ) { + throw new Error('Refusing to write a partial or ambiguous baseline upgrade.'); + } + + const currentDocument = JSON.parse(await fs.readFile(baselinePath, 'utf8')) as { + schemaVersion?: unknown; + baselines?: unknown; + excluded?: unknown; + }; + if ( + currentDocument.schemaVersion !== MODEL_GATEWAY_VERSION_BASELINES_SCHEMA_VERSION || + JSON.stringify(currentDocument.baselines) !== + JSON.stringify(BUILTIN_MODEL_GATEWAY_VERSION_BASELINES) || + JSON.stringify(currentDocument.excluded) !== JSON.stringify(EXCLUDED_MODEL_GATEWAY_LANGUAGES) + ) { + throw new Error( + 'Baseline file does not match the validated in-memory policy; refusing to write.' + ); + } + + await writeJson(baselinePath, discovery.proposedDocument); + process.stdout.write(`WROTE ${baselinePath}\n`); + process.exitCode = discoveryExitCode(discovery.outcome); +} + +main().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n` + ); + process.exitCode = 1; +}); diff --git a/packages/cli/src/__tests__/framework-registry.test.ts b/packages/cli/src/__tests__/framework-registry.test.ts index 58f45279..db51c76a 100644 --- a/packages/cli/src/__tests__/framework-registry.test.ts +++ b/packages/cli/src/__tests__/framework-registry.test.ts @@ -32,6 +32,16 @@ describe('framework command preflight', () => { expect(result.reason).toContain(root); }); + it('classifies npm 10 registry transport failures as dependencies', () => { + expect( + categorizeError( + 'npm error code EAI_AGAIN\nnpm error syscall getaddrinfo\nnpm error network', + undefined, + 'init' + ) + ).toBe('dependency'); + }); + it('classifies missing CMake packages as dependency failures', () => { expect( categorizeError( diff --git a/packages/cli/src/__tests__/model-gateway-project-kits.test.ts b/packages/cli/src/__tests__/model-gateway-project-kits.test.ts index 9111f75e..797055be 100644 --- a/packages/cli/src/__tests__/model-gateway-project-kits.test.ts +++ b/packages/cli/src/__tests__/model-gateway-project-kits.test.ts @@ -54,32 +54,43 @@ describe('model gateway project kits', () => { }); it('pins SDK versions from the reviewed baseline document', () => { - expect(MODEL_GATEWAY_BASELINES_REVIEWED_AT).toBe('2026-09-21'); + expect(MODEL_GATEWAY_BASELINES_REVIEWED_AT).toMatch(/^\d{4}-\d{2}-\d{2}$/); const typescript = BUILTIN_MODEL_GATEWAY_VERSION_BASELINES.find( (entry) => entry.adapterId === 'openrouter-typescript' ); const python = BUILTIN_MODEL_GATEWAY_VERSION_BASELINES.find( (entry) => entry.adapterId === 'openrouter-python' ); - expect(typescript?.sdkVersion).toBe('1.3.11'); - expect(python?.sdkVersion).toBe('1.2.11'); + expect(typescript?.releaseChannel).toBe('stable'); + expect(python?.releaseChannel).toBe('stable'); expect(openRouterTypeScriptSdkVersion()).toBe(packageVersion(typescript!, '@openrouter/sdk')); expect(openRouterPythonSdkVersion()).toBe(packageVersion(python!, 'openrouter')); + expect(JSON.stringify(typescript?.packages.map((entry) => entry.version))).not.toMatch( + /\^|~|latest|\*/ + ); expect(EXCLUDED_MODEL_GATEWAY_LANGUAGES).toEqual([ expect.objectContaining({ language: 'go', - latestObservedVersion: 'v0.8.11', declaredMaturity: 'beta', }), ]); }); it('emits deterministic TypeScript and Python gateway projects without secrets', async () => { + const typescriptBaseline = BUILTIN_MODEL_GATEWAY_VERSION_BASELINES.find( + (entry) => entry.adapterId === 'openrouter-typescript' + ); + const pythonBaseline = BUILTIN_MODEL_GATEWAY_VERSION_BASELINES.find( + (entry) => entry.adapterId === 'openrouter-python' + ); + if (!typescriptBaseline || !pythonBaseline) { + throw new Error('Missing OpenRouter gateway baselines.'); + } for (const kit of listModelGatewayProjectKits()) { const first = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-gateway-a-')); const second = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-gateway-b-')); roots.push(first, second); - const generatedAt = '2026-09-21T12:00:00.000Z'; + const generatedAt = '2020-01-01T00:00:00.000Z'; await generateModelGatewayProject({ projectPath: first, projectName: 'route-gateway', @@ -116,9 +127,15 @@ describe('model gateway project kits', () => { expect(envExample).toContain('OPENROUTER_MODEL='); if (kit.runtime === 'node') { const packageJson = await fsExtra.readJson(path.join(first, 'package.json')); - expect(packageJson.dependencies['@openrouter/sdk']).toBe('1.3.11'); - expect(packageJson.devDependencies.typescript).toBe('5.9.3'); - expect(packageJson.devDependencies['@types/node']).toBe('22.20.3'); + expect(packageJson.dependencies['@openrouter/sdk']).toBe( + packageVersion(typescriptBaseline, '@openrouter/sdk') + ); + expect(packageJson.devDependencies.typescript).toBe( + packageVersion(typescriptBaseline, 'typescript') + ); + expect(packageJson.devDependencies['@types/node']).toBe( + packageVersion(typescriptBaseline, '@types/node') + ); expect(JSON.stringify(packageJson)).not.toMatch(/\^|~|latest|\*/); const policy = await fsExtra.readFile(path.join(first, 'gateway.policy.json'), 'utf8'); expect(policy).toContain('enforceDistillableText'); @@ -137,7 +154,7 @@ describe('model gateway project kits', () => { ); } else { const pyproject = await fsExtra.readFile(path.join(first, 'pyproject.toml'), 'utf8'); - expect(pyproject).toContain('openrouter==1.2.11'); + expect(pyproject).toContain(`openrouter==${packageVersion(pythonBaseline, 'openrouter')}`); expect(pyproject).not.toContain('openrouter>='); const policy = await fsExtra.readFile(path.join(first, 'gateway.policy.json'), 'utf8'); expect(policy).toContain('enforce_distillable_text'); diff --git a/packages/cli/src/__tests__/model-gateway-qualification.test.ts b/packages/cli/src/__tests__/model-gateway-qualification.test.ts new file mode 100644 index 00000000..40c69f67 --- /dev/null +++ b/packages/cli/src/__tests__/model-gateway-qualification.test.ts @@ -0,0 +1,136 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import fsExtra from 'fs-extra'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { NPM_ONLY_SCOPED_COMMANDS } from '../utils/cli-command-surface.js'; +import { + generateModelGatewayProject, + listModelGatewayProjectKits, +} from '../model-gateways/project-kits.js'; +import { MODEL_GATEWAY_REQUIRED_FILES } from '../model-gateways/generated.js'; +import { + MODEL_GATEWAY_ATTACH_POLICY, + MODEL_GATEWAY_QUALIFICATION, + MODEL_GATEWAY_RELEASE_AUTHORITY, + isModelGatewayReleaseQualified, +} from '../model-gateways/qualification-policy.js'; +import { + BUILTIN_MODEL_GATEWAY_VERSION_BASELINES, + sdkCorePackage, +} from '../model-gateways/version-policy.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fsExtra.remove(root))); +}); + +describe('model gateway qualification and maintenance boundaries', () => { + it('keeps Attach unsupported and Create-only for this surface', () => { + expect(MODEL_GATEWAY_ATTACH_POLICY).toBe('unsupported'); + expect(MODEL_GATEWAY_QUALIFICATION.attach).toBe('unsupported'); + expect( + NPM_ONLY_SCOPED_COMMANDS.some((entry) => entry.join(' ').includes('gateway attach')) + ).toBe(false); + const docs = fs.readFileSync(path.join(process.cwd(), 'docs', 'model-gateways.md'), 'utf8'); + expect(docs).toMatch(/Attach is not supported/i); + expect(docs).not.toMatch(/workspai gateway attach/i); + }); + + it('does not treat source-ready kits as a Workspai-qualified release', () => { + expect(MODEL_GATEWAY_RELEASE_AUTHORITY).toBe('source-ready'); + expect(isModelGatewayReleaseQualified()).toBe(false); + expect( + MODEL_GATEWAY_QUALIFICATION.adapters.every((adapter) => adapter.platforms.length === 0) + ).toBe(true); + const changelog = fs.readFileSync(path.join(process.cwd(), 'CHANGELOG.md'), 'utf8'); + const unreleased = changelog.slice(0, changelog.indexOf('\n## [')); + expect(unreleased.toLowerCase()).not.toMatch(/release-ready|release-admitted/); + }); + + it('emits the declared generated-file inventory without credentials or version ranges', async () => { + for (const kit of listModelGatewayProjectKits()) { + const root = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-gateway-inventory-')); + roots.push(root); + await generateModelGatewayProject({ + projectPath: root, + projectName: 'inventory-gateway', + kit, + generatedAt: '2020-01-01T00:00:00.000Z', + }); + const required = + MODEL_GATEWAY_REQUIRED_FILES[kit.id as keyof typeof MODEL_GATEWAY_REQUIRED_FILES]; + expect(required?.length).toBeGreaterThan(0); + for (const relativePath of required) { + expect(await fsExtra.pathExists(path.join(root, relativePath)), relativePath).toBe(true); + } + const packed = await collectText(root); + expect(packed).not.toMatch(/sk-or-[A-Za-z0-9]{8,}/); + if (kit.runtime === 'node') { + const packageJson = await fsExtra.readJson(path.join(root, 'package.json')); + expect(JSON.stringify(packageJson.dependencies)).not.toMatch(/\^|~|latest|\*/); + expect(JSON.stringify(packageJson.devDependencies)).not.toMatch(/\^|~|latest|\*/); + } else { + const pyproject = await fsExtra.readFile(path.join(root, 'pyproject.toml'), 'utf8'); + expect(pyproject).not.toMatch(/openrouter\s*>=|openrouter\s*~=/); + } + expect(await fsExtra.pathExists(path.join(root, 'gateway.policy.json'))).toBe(true); + expect(await fsExtra.pathExists(path.join(root, '.env.example'))).toBe(true); + const projectJson = await fsExtra.readJson(path.join(root, '.workspai', 'project.json')); + expect(projectJson.contracts.consumes).toEqual([kit.gatewayId]); + } + }); + + it('fails if adapter source duplicates canonical SDK version literals', () => { + const adapterRoot = path.join(process.cwd(), 'src', 'model-gateways'); + const forbidden = BUILTIN_MODEL_GATEWAY_VERSION_BASELINES.flatMap((baseline) => { + const core = sdkCorePackage(baseline); + return [baseline.sdkVersion, core.version]; + }).filter((value, index, all) => all.indexOf(value) === index); + const files = listTypeScriptFiles(adapterRoot).filter( + (file) => !file.endsWith('version-baselines.v1.json') + ); + const offenders: string[] = []; + for (const file of files) { + const source = fs.readFileSync(file, 'utf8'); + for (const version of forbidden) { + if (source.includes(`'${version}'`) || source.includes(`"${version}"`)) { + offenders.push(`${path.relative(adapterRoot, file)}:${version}`); + } + } + } + expect(offenders).toEqual([]); + }); +}); + +function listTypeScriptFiles(directory: string): string[] { + const files: string[] = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...listTypeScriptFiles(target)); + continue; + } + if (entry.name.endsWith('.ts')) files.push(target); + } + return files; +} + +async function collectText(root: string): Promise { + const chunks: string[] = []; + const visit = async (directory: string): Promise => { + for (const entry of await fsExtra.readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) { + await visit(target); + continue; + } + chunks.push(await fsExtra.readFile(target, 'utf8')); + } + }; + await visit(root); + return chunks.join('\n'); +} diff --git a/packages/cli/src/__tests__/model-gateway-registry.test.ts b/packages/cli/src/__tests__/model-gateway-registry.test.ts new file mode 100644 index 00000000..cd25ec6a --- /dev/null +++ b/packages/cli/src/__tests__/model-gateway-registry.test.ts @@ -0,0 +1,122 @@ +import os from 'node:os'; +import path from 'node:path'; + +import fsExtra from 'fs-extra'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { ModelGatewayAdapter } from '../model-gateways/adapter.js'; +import { + MODEL_GATEWAY_LIFECYCLE_STAGES, + MODEL_GATEWAY_UNSUPPORTED_CAPABILITIES, + writeGatewayWorkspaiMetadata, +} from '../model-gateways/generated.js'; +import { createModelGatewayRegistry } from '../model-gateways/registry.js'; +import { + generateModelGatewayProject, + listModelGatewayProjectKits, + lookupModelGatewayProjectKit, +} from '../model-gateways/project-kits.js'; +import { openRouterPythonAdapter } from '../model-gateways/adapters/openrouter/python.js'; +import { openRouterTypeScriptAdapter } from '../model-gateways/adapters/openrouter/typescript.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fsExtra.remove(root))); +}); + +function syntheticAdapter(kitId: string): ModelGatewayAdapter { + return { + id: `synthetic-${kitId}`, + gatewayId: 'synthetic', + gatewayName: 'Synthetic', + kitId, + aliases: [`synthetic.${kitId}`], + label: 'Synthetic · Test', + runtime: 'node', + requiredEnvironment: ['SYNTHETIC_TOKEN'], + versionBaselineId: 'synthetic', + capabilities: MODEL_GATEWAY_UNSUPPORTED_CAPABILITIES, + lifecycle: { stages: MODEL_GATEWAY_LIFECYCLE_STAGES }, + attach: 'unsupported', + async generate(input) { + await fsExtra.outputFile(path.join(input.projectPath, 'synthetic.txt'), input.projectName); + }, + }; +} + +describe('model gateway adapter registry', () => { + it('derives production kits from registered OpenRouter adapters', () => { + expect(listModelGatewayProjectKits().map((kit) => kit.id)).toEqual([ + openRouterTypeScriptAdapter.kitId, + openRouterPythonAdapter.kitId, + ]); + expect(lookupModelGatewayProjectKit('openrouter.typescript')?.adapterId).toBe( + openRouterTypeScriptAdapter.id + ); + const cloned = lookupModelGatewayProjectKit('gateway.openrouter.python'); + cloned?.aliases.splice(0); + expect( + lookupModelGatewayProjectKit('gateway.openrouter.python')?.aliases.length + ).toBeGreaterThan(0); + }); + + it('dispatches a synthetic adapter without editing OpenRouter production branches', async () => { + const registry = createModelGatewayRegistry([ + openRouterTypeScriptAdapter, + openRouterPythonAdapter, + syntheticAdapter('gateway.synthetic.node'), + ]); + expect(registry.listKits().map((kit) => kit.id)).toContain('gateway.synthetic.node'); + const kit = registry.lookupKit('synthetic.gateway.synthetic.node'); + if (!kit) throw new Error('missing synthetic kit'); + const root = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-gateway-synth-')); + roots.push(root); + await registry.generate({ projectPath: root, projectName: 'synth-app', kit }); + await writeGatewayWorkspaiMetadata({ + projectPath: root, + projectName: 'synth-app', + kit, + generatedAt: '2020-01-01T00:00:00.000Z', + }); + expect(await fsExtra.readFile(path.join(root, 'synthetic.txt'), 'utf8')).toBe('synth-app'); + const metadata = await fsExtra.readJson(path.join(root, '.workspai', 'project.json')); + expect(metadata.contracts.consumes).toEqual(['synthetic']); + expect(metadata.framework).toBe('synthetic'); + expect(listModelGatewayProjectKits().map((entry) => entry.id)).not.toContain( + 'gateway.synthetic.node' + ); + }); + + it('rejects duplicate adapter, kit, and alias registration', () => { + expect(() => + createModelGatewayRegistry([openRouterTypeScriptAdapter, openRouterTypeScriptAdapter]) + ).toThrow(/Duplicate model gateway adapter/); + expect(() => + createModelGatewayRegistry([ + openRouterTypeScriptAdapter, + { ...openRouterPythonAdapter, kitId: openRouterTypeScriptAdapter.kitId }, + ]) + ).toThrow(/Duplicate model gateway kit/); + expect(() => + createModelGatewayRegistry([ + openRouterTypeScriptAdapter, + { ...openRouterPythonAdapter, aliases: [...openRouterTypeScriptAdapter.aliases] }, + ]) + ).toThrow(/Duplicate model gateway alias/); + }); + + it('does not require generateModelGatewayProject to branch on kit id for OpenRouter kits', async () => { + const kit = listModelGatewayProjectKits()[0]; + if (!kit) throw new Error('missing production kit'); + const root = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-gateway-dispatch-')); + roots.push(root); + await generateModelGatewayProject({ + projectPath: root, + projectName: 'dispatch-kit', + kit, + generatedAt: '2020-01-01T00:00:00.000Z', + }); + expect(await fsExtra.pathExists(path.join(root, '.workspai', 'project.json'))).toBe(true); + }); +}); diff --git a/packages/cli/src/__tests__/model-gateway-version-discovery.test.ts b/packages/cli/src/__tests__/model-gateway-version-discovery.test.ts new file mode 100644 index 00000000..aa1d336c --- /dev/null +++ b/packages/cli/src/__tests__/model-gateway-version-discovery.test.ts @@ -0,0 +1,229 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; +import YAML from 'yaml'; + +import { + MODEL_GATEWAY_DISCOVERY_EXIT, + discoverModelGatewayVersions, + discoveryExitCode, + formatModelGatewayDiscoverySummary, +} from '../model-gateways/version-discovery.js'; +import { + BUILTIN_MODEL_GATEWAY_VERSION_BASELINES, + sdkCorePackage, +} from '../model-gateways/version-policy.js'; + +type JsonMap = Record; + +function jsonResponse( + payload: unknown +): Promise<{ ok: true; status: 200; json: () => Promise }> { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => payload, + }); +} + +function npmPackument(name: string, latest: string, extra: JsonMap = {}): JsonMap { + return { + 'dist-tags': { latest }, + versions: { + [latest]: extra, + }, + }; +} + +function pypiPayload(version: string, yanked = false): JsonMap { + return { + info: { version }, + releases: { + [version]: [{ yanked }], + }, + }; +} + +function githubRelease(tag: string, flags?: { prerelease?: boolean; draft?: boolean }): JsonMap { + return { + tag_name: tag, + prerelease: flags?.prerelease === true, + draft: flags?.draft === true, + }; +} + +function goProxy(version: string): JsonMap { + return { Version: version }; +} + +function mockedFetch(routes: Record): typeof fetch { + return (async (input: string) => { + const url = String(input); + const match = Object.entries(routes).find(([pattern]) => url.includes(pattern)); + if (!match) { + return { ok: false, status: 404, json: async () => ({}) }; + } + return jsonResponse(match[1]); + }) as unknown as typeof fetch; +} + +const typescript = sdkCorePackage( + BUILTIN_MODEL_GATEWAY_VERSION_BASELINES.find( + (entry) => entry.adapterId === 'openrouter-typescript' + )! +); +const python = sdkCorePackage( + BUILTIN_MODEL_GATEWAY_VERSION_BASELINES.find((entry) => entry.adapterId === 'openrouter-python')! +); + +describe('model gateway version discovery', () => { + it('reports current when registry and GitHub agree with the baseline', async () => { + const discovery = await discoverModelGatewayVersions({ + generatedAt: '2026-09-21T00:00:00.000Z', + fetcher: mockedFetch({ + 'registry.npmjs.org/@openrouter/sdk': npmPackument(typescript.name, typescript.version), + 'pypi.org/pypi/openrouter/json': pypiPayload(python.version), + 'OpenRouterTeam/typescript-sdk/releases': [githubRelease(`v${typescript.version}`)], + 'OpenRouterTeam/python-sdk/releases': [githubRelease(`v${python.version}`)], + 'proxy.golang.org': goProxy('v0.8.11'), + }), + }); + expect(discovery.outcome).toBe('current'); + expect(discovery.automaticMutation).toBe(false); + expect(discovery.proposedDocument).toBeNull(); + expect(discoveryExitCode(discovery.outcome)).toBe(MODEL_GATEWAY_DISCOVERY_EXIT.CURRENT); + expect(discovery.excluded[0]?.status).toBe('still-excluded'); + expect(formatModelGatewayDiscoverySummary(discovery)).toContain( + 'CURRENT openrouter-typescript' + ); + }); + + it('proposes an update only when every source agrees on a newer stable version', async () => { + const nextTs = '9.9.9'; + const discovery = await discoverModelGatewayVersions({ + generatedAt: '2026-09-22T00:00:00.000Z', + fetcher: mockedFetch({ + 'registry.npmjs.org/@openrouter/sdk': npmPackument(typescript.name, nextTs), + 'pypi.org/pypi/openrouter/json': pypiPayload(python.version), + 'OpenRouterTeam/typescript-sdk/releases': [githubRelease(`v${nextTs}`)], + 'OpenRouterTeam/python-sdk/releases': [githubRelease(`v${python.version}`)], + 'proxy.golang.org': goProxy('v0.8.11'), + }), + }); + expect(discovery.outcome).toBe('update-available'); + expect(discovery.proposedDocument?.baselines[0]?.sdkVersion).toBe(nextTs); + expect(discovery.proposedDocument?.baselines[1]?.sdkVersion).toBe(python.version); + expect(discoveryExitCode(discovery.outcome)).toBe( + MODEL_GATEWAY_DISCOVERY_EXIT.UPDATE_AVAILABLE + ); + }); + + it('classifies registry/GitHub disagreement without proposing a write', async () => { + const discovery = await discoverModelGatewayVersions({ + fetcher: mockedFetch({ + 'registry.npmjs.org/@openrouter/sdk': npmPackument(typescript.name, '9.9.9'), + 'pypi.org/pypi/openrouter/json': pypiPayload(python.version), + 'OpenRouterTeam/typescript-sdk/releases': [githubRelease(`v${typescript.version}`)], + 'OpenRouterTeam/python-sdk/releases': [githubRelease(`v${python.version}`)], + 'proxy.golang.org': goProxy('v0.8.11'), + }), + }); + expect(discovery.outcome).toBe('disagreement'); + expect(discovery.proposedDocument).toBeNull(); + expect(discoveryExitCode(discovery.outcome)).toBe(MODEL_GATEWAY_DISCOVERY_EXIT.DISAGREEMENT); + }); + + it('rejects prerelease, yanked, deprecated, and unavailable sources', async () => { + const prerelease = await discoverModelGatewayVersions({ + fetcher: mockedFetch({ + 'registry.npmjs.org/@openrouter/sdk': npmPackument(typescript.name, '9.9.9-rc.1'), + 'pypi.org/pypi/openrouter/json': pypiPayload(python.version), + 'OpenRouterTeam/typescript-sdk/releases': [ + githubRelease('v9.9.9-rc.1', { prerelease: true }), + ], + 'OpenRouterTeam/python-sdk/releases': [githubRelease(`v${python.version}`)], + 'proxy.golang.org': goProxy('v0.8.11'), + }), + }); + expect(prerelease.outcome).toBe('invalid'); + + const yanked = await discoverModelGatewayVersions({ + fetcher: mockedFetch({ + 'registry.npmjs.org/@openrouter/sdk': npmPackument(typescript.name, typescript.version), + 'pypi.org/pypi/openrouter/json': pypiPayload('9.9.9', true), + 'OpenRouterTeam/typescript-sdk/releases': [githubRelease(`v${typescript.version}`)], + 'OpenRouterTeam/python-sdk/releases': [githubRelease('v9.9.9')], + 'proxy.golang.org': goProxy('v0.8.11'), + }), + }); + expect( + yanked.adapters.find((adapter) => adapter.adapterId === 'openrouter-python')?.status + ).toBe('invalid'); + + const deprecated = await discoverModelGatewayVersions({ + fetcher: mockedFetch({ + 'registry.npmjs.org/@openrouter/sdk': npmPackument(typescript.name, '9.9.9', { + deprecated: 'do not use', + }), + 'pypi.org/pypi/openrouter/json': pypiPayload(python.version), + 'OpenRouterTeam/typescript-sdk/releases': [githubRelease('v9.9.9')], + 'OpenRouterTeam/python-sdk/releases': [githubRelease(`v${python.version}`)], + 'proxy.golang.org': goProxy('v0.8.11'), + }), + }); + expect(deprecated.outcome).toBe('invalid'); + + const unavailable = await discoverModelGatewayVersions({ + fetcher: mockedFetch({ + 'pypi.org/pypi/openrouter/json': pypiPayload(python.version), + 'OpenRouterTeam/python-sdk/releases': [githubRelease(`v${python.version}`)], + 'proxy.golang.org': goProxy('v0.8.11'), + }), + }); + expect(unavailable.outcome).toBe('unavailable'); + expect(discoveryExitCode(unavailable.outcome)).toBe(MODEL_GATEWAY_DISCOVERY_EXIT.UNAVAILABLE); + }); +}); + +describe('model gateway workflows', () => { + it('pins actions and never commits, publishes, or uses pull_request_target', () => { + const repositoryRoot = path.resolve(__dirname, '..', '..', '..', '..'); + const qualification = fs.readFileSync( + path.join(repositoryRoot, '.github/workflows/model-gateway-qualification.yml'), + 'utf8' + ); + const discovery = fs.readFileSync( + path.join(repositoryRoot, '.github/workflows/model-gateway-version-discovery.yml'), + 'utf8' + ); + for (const workflow of [qualification, discovery]) { + expect(workflow).not.toContain('pull_request_target'); + expect(workflow).not.toContain('git commit'); + expect(workflow).not.toContain('git push'); + expect(workflow).not.toContain('gh pr create'); + expect(workflow).not.toContain('npm publish'); + expect(workflow).not.toMatch(/OPENROUTER_API_KEY:\s*\$\{\{/); + const uses = [...workflow.matchAll(/uses:\s+([^\s#]+)/g)].map((match) => match[1]!); + for (const reference of uses) { + expect(reference).toMatch(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[a-f0-9]{40}$/); + } + } + expect(qualification).toContain('ubuntu-latest'); + expect(qualification).toContain('macos-latest'); + expect(qualification).toContain('windows-latest'); + expect(qualification).toContain('OpenRouter gateways'); + expect(qualification).not.toContain('kit: [typescript, python]'); + expect(qualification).not.toContain('matrix.kit'); + expect(qualification).toContain('model-gateway-generated-projects.test.ts'); + expect(qualification).toContain('model-gateway-workspace-lifecycle.test.ts'); + expect(qualification).toContain('model-gateway-policy-parity.test.ts'); + expect(discovery).toContain('propose-model-gateway-version-update.ts'); + expect(discovery).not.toContain('--write'); + expect(YAML.parse(discovery).permissions).toEqual({ contents: 'read' }); + expect(YAML.parse(qualification).permissions).toEqual({ + contents: 'read', + 'pull-requests': 'read', + }); + }); +}); diff --git a/packages/cli/src/__tests__/model-gateway-workspace-lifecycle.test.ts b/packages/cli/src/__tests__/model-gateway-workspace-lifecycle.test.ts index f526af0e..b9bdef5c 100644 --- a/packages/cli/src/__tests__/model-gateway-workspace-lifecycle.test.ts +++ b/packages/cli/src/__tests__/model-gateway-workspace-lifecycle.test.ts @@ -13,6 +13,12 @@ import { import { buildWorkspaceModel, writeWorkspaceModel } from '../workspace-model.js'; import { runWorkspaceStage } from '../workspace-run.js'; import { buildWorkspaceVerify } from '../workspace-verify.js'; +import { + assertGatewayQualificationStage, + classifyGatewayLifecycleReport, + ModelGatewayQualificationError, + runGatewayInitWithRegistryRetry, +} from '../model-gateways/qualification-gate.js'; const roots: string[] = []; @@ -21,12 +27,6 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fsExtra.remove(root))); }); -function looksLikeRegistryFailure(text: string): boolean { - return /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|401 Unauthorized|403 Forbidden|404 Not Found|EAI_AGAIN|npm ERR!|Could not find a version|Failed to establish|No matching distribution|HTTPError|read ECONNRESET/i.test( - text - ); -} - describe('OpenRouter gateway Workspai lifecycle', () => { it('creates both kits through the production CLI and exercises workspace run/verify', async () => { const parent = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai or gateway ')); @@ -257,16 +257,16 @@ describe('OpenRouter gateway Workspai lifecycle', () => { delete process.env.OPENROUTER_API_KEY; delete process.env.OPENROUTER_MODEL; try { - const initReport = await runWorkspaceStage({ - workspacePath, - stage: 'init', - json: true, - enforceGates: false, - }); - const initText = JSON.stringify(initReport); - if (looksLikeRegistryFailure(initText)) { - return; - } + const initReport = await runGatewayInitWithRegistryRetry( + () => + runWorkspaceStage({ + workspacePath, + stage: 'init', + json: true, + enforceGates: false, + }), + { attempts: 2, delayMs: 1_000 } + ); const initFailures = initReport.projects .filter((project) => project.status === 'failed') .map( @@ -274,7 +274,12 @@ describe('OpenRouter gateway Workspai lifecycle', () => { `${project.projectName}: ${project.reason ?? ''} ${project.errorMessage ?? ''} ${project.failureDiagnostic?.outputExcerpt ?? ''}` ) .join('\n'); - expect(initReport.summary.failed, initFailures).toBe(0); + assertGatewayQualificationStage({ + stage: 'init', + failed: initReport.summary.failed, + report: initReport, + detail: initFailures, + }); const testReport = await runWorkspaceStage({ workspacePath, @@ -293,9 +298,32 @@ describe('OpenRouter gateway Workspai lifecycle', () => { .join(' | ')}` ) .join('\n'); - if (!looksLikeRegistryFailure(`${testText}\n${testFailures}`)) { - expect(testReport.summary.failed, testFailures).toBe(0); - } + assertGatewayQualificationStage({ + stage: 'test', + failed: testReport.summary.failed, + report: testReport, + detail: testFailures, + }); + + const buildReport = await runWorkspaceStage({ + workspacePath, + stage: 'build', + json: true, + enforceGates: false, + }); + const buildFailures = buildReport.projects + .filter((project) => project.status === 'failed') + .map( + (project) => + `${project.projectName}: ${project.reason ?? ''} ${project.errorMessage ?? ''} ${project.failureDiagnostic?.outputExcerpt ?? ''}` + ) + .join('\n'); + assertGatewayQualificationStage({ + stage: 'build', + failed: buildReport.summary.failed, + report: buildReport, + detail: buildFailures, + }); const startReport = await runWorkspaceStage({ workspacePath, @@ -315,4 +343,129 @@ describe('OpenRouter gateway Workspai lifecycle', () => { process.chdir(previousCwd); } }, 600_000); + + it('does not return a passing qualification result for a registry outage', async () => { + expect(() => + assertGatewayQualificationStage({ + stage: 'init', + failed: 1, + detail: 'npm ERR! 404 Not Found @openrouter/sdk', + }) + ).toThrow(ModelGatewayQualificationError); + try { + assertGatewayQualificationStage({ + stage: 'init', + failed: 1, + detail: 'npm ERR! 404 Not Found @openrouter/sdk', + }); + } catch (error) { + expect(error).toBeInstanceOf(ModelGatewayQualificationError); + expect((error as ModelGatewayQualificationError).classification).toBe('registry'); + } + + const sparseUnknownInitReport = { + summary: { failed: 1 }, + projects: [ + { + status: 'failed', + errorCategory: 'unknown', + reason: 'Stage failed with exit code 1', + errorMessage: 'Stage failed with exit code 1', + executionCommand: '[node:.] npm install', + failureDiagnostic: { + category: 'unknown', + exitCode: 1, + command: 'npm install', + timedOut: false, + timeoutMs: 300_000, + }, + }, + ], + }; + expect(classifyGatewayLifecycleReport(sparseUnknownInitReport, 'init')).toBe('registry'); + try { + assertGatewayQualificationStage({ + stage: 'init', + failed: 1, + report: sparseUnknownInitReport, + }); + throw new Error('expected sparse init receipt to fail qualification'); + } catch (error) { + expect(error).toBeInstanceOf(ModelGatewayQualificationError); + expect((error as ModelGatewayQualificationError).classification).toBe('registry'); + expect((error as Error).message).toContain('QUALIFICATION_INFRA'); + } + + let sparseAttempts = 0; + await runGatewayInitWithRegistryRetry( + async () => { + sparseAttempts += 1; + return sparseUnknownInitReport; + }, + { attempts: 2, delayMs: 0 } + ); + expect(sparseAttempts).toBe(2); + + const assertionFailureReport = { + summary: { failed: 1 }, + projects: [ + { + status: 'failed', + errorCategory: 'test-failure', + reason: 'Stage failed with exit code 1: AssertionError', + errorMessage: 'Stage failed with exit code 1: AssertionError', + executionCommand: '[python:.] python -m unittest', + failureDiagnostic: { + category: 'test-failure', + exitCode: 1, + command: 'python -m unittest', + timedOut: false, + timeoutMs: 90_000, + outputExcerpt: 'AssertionError: 1 != 2', + }, + }, + ], + }; + expect(classifyGatewayLifecycleReport(assertionFailureReport, 'test')).toBe('product'); + + let productAttempts = 0; + await runGatewayInitWithRegistryRetry( + async () => { + productAttempts += 1; + return { + summary: { failed: 1 }, + projects: [ + { + status: 'failed', + errorCategory: 'runtime', + reason: 'TypeError: boom', + failureDiagnostic: { + category: 'runtime', + command: 'node src/main.ts', + outputExcerpt: 'TypeError: boom', + }, + }, + ], + }; + }, + { attempts: 2, delayMs: 0 } + ); + expect(productAttempts).toBe(1); + + await expect( + runGatewayInitWithRegistryRetry( + async () => ({ + summary: { failed: 1 }, + projects: [{ status: 'failed', errorMessage: 'ENOTFOUND registry.npmjs.org' }], + }), + { attempts: 2, delayMs: 0 } + ).then((report) => + assertGatewayQualificationStage({ + stage: 'init', + failed: report.summary.failed, + report, + }) + ) + ).rejects.toBeInstanceOf(ModelGatewayQualificationError); + }); }); diff --git a/packages/cli/src/__tests__/workspace-run.test.ts b/packages/cli/src/__tests__/workspace-run.test.ts index a671921a..2e1acd87 100644 --- a/packages/cli/src/__tests__/workspace-run.test.ts +++ b/packages/cli/src/__tests__/workspace-run.test.ts @@ -1788,4 +1788,81 @@ describe('workspace-run', { timeout: 30_000 }, () => { await fsExtra.remove(workspacePath); }); + + it('keeps bounded package-manager diagnostics when npm leaves stdout and stderr empty', async () => { + const workspacePath = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'rk-workspace-run-')); + const projectPath = await createProjectWithoutContext(workspacePath, 'node-api'); + await fsExtra.writeJSON(path.join(projectPath, 'package.json'), { + name: 'node-api', + private: true, + dependencies: { 'fixture-dep': '1.0.0' }, + }); + await fsExtra.ensureDir(path.join(projectPath, '.workspai')); + await fsExtra.writeJSON(path.join(projectPath, '.workspai', 'context.json'), { + name: 'node-api', + runtime: 'node', + kind: 'gateway', + category: 'gateway', + }); + await fsExtra.writeJSON(path.join(projectPath, '.workspai', 'project.json'), { + name: 'node-api', + runtime: 'node', + kind: 'gateway', + category: 'gateway', + }); + + const execaMock = execa as unknown as ReturnType; + const npmInstallResult = { + exitCode: 1, + stdout: '', + stderr: '', + shortMessage: 'Command failed with exit code 1: npm install', + message: + 'Command failed with exit code 1: npm install\nnpm error code ENOTFOUND\ngetaddrinfo ENOTFOUND registry.npmjs.org', + }; + execaMock.mockImplementation(async (cmd: string, args: string[] = []) => { + if (args.includes('install') || String(cmd).includes('install')) { + return npmInstallResult; + } + return { exitCode: 0, stdout: '{}', stderr: '' }; + }); + + const harvested = await runWorkspaceStage({ + workspacePath, + stage: 'init', + enforceGates: false, + json: true, + }); + expect(harvested.projects[0]?.status).toBe('failed'); + expect(harvested.projects[0]?.errorCategory).toBe('dependency'); + expect(harvested.projects[0]?.executionCommand).toMatch(/npm install/); + expect(harvested.projects[0]?.failureDiagnostic?.outputExcerpt).toContain( + 'ENOTFOUND registry.npmjs.org' + ); + expect(harvested.projects[0]?.reason).toContain('ENOTFOUND'); + + execaMock.mockImplementation(async (cmd: string, args: string[] = []) => { + if (args.includes('install') || String(cmd).includes('install')) { + return { exitCode: 1, stdout: '', stderr: '' }; + } + return { exitCode: 0, stdout: '{}', stderr: '' }; + }); + + const sparse = await runWorkspaceStage({ + workspacePath, + stage: 'init', + enforceGates: false, + json: true, + }); + expect(sparse.projects[0]?.errorCategory).toBe('unknown'); + expect(sparse.projects[0]?.reason).toBe('Stage failed with exit code 1'); + expect(sparse.projects[0]?.failureDiagnostic).toMatchObject({ + category: 'unknown', + exitCode: 1, + outputExcerpt: 'Stage failed with exit code 1', + }); + expect(sparse.projects[0]?.failureDiagnostic?.command).toContain('npm install'); + + await fsExtra.remove(workspacePath); + }); }); diff --git a/packages/cli/src/cli-ui/kit-picker-choices.ts b/packages/cli/src/cli-ui/kit-picker-choices.ts index 96953e00..e4882e75 100644 --- a/packages/cli/src/cli-ui/kit-picker-choices.ts +++ b/packages/cli/src/cli-ui/kit-picker-choices.ts @@ -7,6 +7,7 @@ import { isAdmittedAgentFrameworkProjectKit, } from '../agent-frameworks/project-kits.js'; import { describeModelGatewayProjectKits } from '../model-gateways/project-kits.js'; +import { modelGatewayPickerHint } from '../model-gateways/qualification-policy.js'; export const CREATE_KIT_CATEGORY_IDS = [ 'backend', @@ -101,7 +102,7 @@ export function buildKitPickerChoices(category?: CreateKitCategoryId): Categoriz const gatewayChoices = describeModelGatewayProjectKits().map((kit) => ({ value: kit.id, label: `AI Gateway · ${kit.label}`, - hint: `${kit.runtime} · tested baseline`, + hint: modelGatewayPickerHint(kit.runtime), name: kit.label, category: 'gateway' as const, })); diff --git a/packages/cli/src/framework-registry.ts b/packages/cli/src/framework-registry.ts index 18f0dfc3..1a3a8177 100644 --- a/packages/cli/src/framework-registry.ts +++ b/packages/cli/src/framework-registry.ts @@ -745,8 +745,17 @@ export function categorizeError( 'network (?:is )?unreachable', 'ECONNREFUSED', 'ENOTFOUND', + 'EAI_AGAIN', + 'getaddrinfo', + 'npm error', + 'npm ERR!', + 'registry.npmjs', + 'pypi.org', + 'Could not fetch URL', + 'Max retries exceeded', + 'NewConnectionError', ], - setup: ['ModuleNotFoundError', 'No module named', 'npm ERR!', 'error:', 'not found'], + setup: ['ModuleNotFoundError', 'No module named', 'error:', 'not found'], 'test-failure': ['FAILED', 'FAIL', 'failed'], runtime: ['Exception', 'Error:', 'panic', 'TypeError'], unknown: [], diff --git a/packages/cli/src/model-gateways/adapter.ts b/packages/cli/src/model-gateways/adapter.ts new file mode 100644 index 00000000..ec8a7ae5 --- /dev/null +++ b/packages/cli/src/model-gateways/adapter.ts @@ -0,0 +1,45 @@ +import type { ModelGatewayRuntime } from './gateway.js'; +import { + MODEL_GATEWAY_LIFECYCLE_STAGES, + MODEL_GATEWAY_UNSUPPORTED_CAPABILITIES, +} from './generated.js'; + +export type ModelGatewayGenerateInput = { + projectPath: string; + projectName: string; +}; + +export type ModelGatewayAdapter = { + id: string; + gatewayId: string; + gatewayName: string; + kitId: string; + aliases: readonly string[]; + label: string; + runtime: ModelGatewayRuntime; + requiredEnvironment: readonly string[]; + versionBaselineId: string; + capabilities: typeof MODEL_GATEWAY_UNSUPPORTED_CAPABILITIES; + lifecycle: { readonly stages: readonly (typeof MODEL_GATEWAY_LIFECYCLE_STAGES)[number][] }; + attach: 'unsupported'; + generate: (input: ModelGatewayGenerateInput) => Promise; +}; + +export function cloneAdapterView( + adapter: ModelGatewayAdapter +): Omit { + return Object.freeze({ + id: adapter.id, + gatewayId: adapter.gatewayId, + gatewayName: adapter.gatewayName, + kitId: adapter.kitId, + aliases: Object.freeze([...adapter.aliases]), + label: adapter.label, + runtime: adapter.runtime, + requiredEnvironment: Object.freeze([...adapter.requiredEnvironment]), + versionBaselineId: adapter.versionBaselineId, + capabilities: MODEL_GATEWAY_UNSUPPORTED_CAPABILITIES, + lifecycle: Object.freeze({ stages: Object.freeze([...adapter.lifecycle.stages]) }), + attach: 'unsupported', + }); +} diff --git a/packages/cli/src/model-gateways/adapters/openrouter/python.ts b/packages/cli/src/model-gateways/adapters/openrouter/python.ts index c7145cf4..d9282866 100644 --- a/packages/cli/src/model-gateways/adapters/openrouter/python.ts +++ b/packages/cli/src/model-gateways/adapters/openrouter/python.ts @@ -16,6 +16,13 @@ import { OPENROUTER_SORT_VALUES, } from './common.js'; import { OPENROUTER_POLICY_PARITY_CASES } from './policy-parity.js'; +import type { ModelGatewayAdapter } from '../../adapter.js'; +import { + MODEL_GATEWAY_LIFECYCLE_STAGES, + MODEL_GATEWAY_UNSUPPORTED_CAPABILITIES, + renderGeneratedLines as lines, + renderOpenRouterEnvExample, +} from '../../generated.js'; const BASELINE = getModelGatewayVersionBaseline('openrouter-python'); if (!BASELINE) { @@ -28,10 +35,6 @@ export function openRouterPythonSdkVersion(): string { return SDK_VERSION; } -function lines(value: string[]): string { - return `${value.join('\n')}\n`; -} - function renderPyproject(name: string): string { return lines([ '[build-system]', @@ -84,24 +87,15 @@ function renderPolicy(): string { } function renderEnvExample(): string { - return lines([ - '# Required. Server-side only. Never expose this value to browsers, frontend', - '# environment prefixes, client bundles, or committed files.', - 'OPENROUTER_API_KEY=', - '', - '# Required. Choose a model slug at runtime. Workspai does not select a billable', - '# model for you. List current slugs at https://openrouter.ai/models', - 'OPENROUTER_MODEL=', - '', - '# Optional application attribution. These map to the official Python SDK', - '# constructor fields http_referer and x_open_router_title, and to the SDK', - '# environment names OPENROUTER_HTTP_REFERER and OPENROUTER_X_OPEN_ROUTER_TITLE.', - 'OPENROUTER_HTTP_REFERER=', - 'OPENROUTER_X_OPEN_ROUTER_TITLE=', - '', - '# Optional timeout in milliseconds. Defaults to 30000 when omitted.', - 'OPENROUTER_TIMEOUT_MS=', - ]); + return renderOpenRouterEnvExample({ + attributionComment: [ + '# Optional application attribution. These map to the official Python SDK', + '# constructor fields http_referer and x_open_router_title, and to the SDK', + '# environment names OPENROUTER_HTTP_REFERER and OPENROUTER_X_OPEN_ROUTER_TITLE.', + ], + refererName: 'OPENROUTER_HTTP_REFERER', + titleName: 'OPENROUTER_X_OPEN_ROUTER_TITLE', + }); } function renderReadme(projectName: string): string { @@ -1534,3 +1528,24 @@ export async function generateOpenRouterPythonGateway(input: { ); await writeGeneratorFile(path.join(input.projectPath, 'tests', 'test_gateway.py'), renderTests()); } + +export const openRouterPythonAdapter: ModelGatewayAdapter = { + id: 'openrouter-python', + gatewayId: 'openrouter', + gatewayName: 'OpenRouter', + kitId: 'gateway.openrouter.python', + aliases: [ + 'gateway.openrouter.python', + 'openrouter.python', + 'gateway.openrouter.py', + 'openrouter-python', + ], + label: 'OpenRouter · Python', + runtime: 'python', + requiredEnvironment: ['OPENROUTER_API_KEY', 'OPENROUTER_MODEL'], + versionBaselineId: 'openrouter-python', + capabilities: MODEL_GATEWAY_UNSUPPORTED_CAPABILITIES, + lifecycle: { stages: MODEL_GATEWAY_LIFECYCLE_STAGES }, + attach: 'unsupported', + generate: generateOpenRouterPythonGateway, +}; diff --git a/packages/cli/src/model-gateways/adapters/openrouter/typescript.ts b/packages/cli/src/model-gateways/adapters/openrouter/typescript.ts index 82277d14..23744b48 100644 --- a/packages/cli/src/model-gateways/adapters/openrouter/typescript.ts +++ b/packages/cli/src/model-gateways/adapters/openrouter/typescript.ts @@ -16,6 +16,13 @@ import { OPENROUTER_SORT_VALUES, } from './common.js'; import { OPENROUTER_POLICY_PARITY_CASES } from './policy-parity.js'; +import type { ModelGatewayAdapter } from '../../adapter.js'; +import { + MODEL_GATEWAY_LIFECYCLE_STAGES, + MODEL_GATEWAY_UNSUPPORTED_CAPABILITIES, + renderGeneratedLines as lines, + renderOpenRouterEnvExample, +} from '../../generated.js'; const BASELINE = getModelGatewayVersionBaseline('openrouter-typescript'); if (!BASELINE) { @@ -30,10 +37,6 @@ export function openRouterTypeScriptSdkVersion(): string { return SDK_VERSION; } -function lines(value: string[]): string { - return `${value.join('\n')}\n`; -} - function renderPackageJson(name: string): string { return `${JSON.stringify( { @@ -113,23 +116,14 @@ function renderPolicy(): string { } function renderEnvExample(): string { - return lines([ - '# Required. Server-side only. Never expose this value to browsers, frontend', - '# environment prefixes, client bundles, or committed files.', - 'OPENROUTER_API_KEY=', - '', - '# Required. Choose a model slug at runtime. Workspai does not select a billable', - '# model for you. List current slugs at https://openrouter.ai/models', - 'OPENROUTER_MODEL=', - '', - '# Optional application attribution. These map to the official TypeScript SDK', - '# fields httpReferer and appTitle.', - 'OPENROUTER_HTTP_REFERER=', - 'OPENROUTER_APP_TITLE=', - '', - '# Optional timeout in milliseconds. Defaults to 30000 when omitted.', - 'OPENROUTER_TIMEOUT_MS=', - ]); + return renderOpenRouterEnvExample({ + attributionComment: [ + '# Optional application attribution. These map to the official TypeScript SDK', + '# fields httpReferer and appTitle.', + ], + refererName: 'OPENROUTER_HTTP_REFERER', + titleName: 'OPENROUTER_APP_TITLE', + }); } function renderReadme(projectName: string): string { @@ -1806,3 +1800,24 @@ export async function generateOpenRouterTypeScriptGateway(input: { await writeGeneratorFile(path.join(input.projectPath, 'src', 'index.ts'), renderIndex()); await writeGeneratorFile(path.join(input.projectPath, 'tests', 'gateway.test.ts'), renderTests()); } + +export const openRouterTypeScriptAdapter: ModelGatewayAdapter = { + id: 'openrouter-typescript', + gatewayId: 'openrouter', + gatewayName: 'OpenRouter', + kitId: 'gateway.openrouter.typescript', + aliases: [ + 'gateway.openrouter.typescript', + 'openrouter.typescript', + 'gateway.openrouter.ts', + 'openrouter-typescript', + ], + label: 'OpenRouter · TypeScript', + runtime: 'node', + requiredEnvironment: ['OPENROUTER_API_KEY', 'OPENROUTER_MODEL'], + versionBaselineId: 'openrouter-typescript', + capabilities: MODEL_GATEWAY_UNSUPPORTED_CAPABILITIES, + lifecycle: { stages: MODEL_GATEWAY_LIFECYCLE_STAGES }, + attach: 'unsupported', + generate: generateOpenRouterTypeScriptGateway, +}; diff --git a/packages/cli/src/model-gateways/gateway.ts b/packages/cli/src/model-gateways/gateway.ts index 17116b58..fd7f015d 100644 --- a/packages/cli/src/model-gateways/gateway.ts +++ b/packages/cli/src/model-gateways/gateway.ts @@ -9,12 +9,12 @@ export type ModelGatewayRuntime = ModelGatewayBaselineRuntime; export type ModelGatewayKitId = 'gateway.openrouter.typescript' | 'gateway.openrouter.python'; export type ModelGatewayProjectKit = { - id: ModelGatewayKitId; + id: string; aliases: string[]; label: string; runtime: ModelGatewayRuntime; adapterId: string; - gatewayId: ModelGatewayId; + gatewayId: string; gatewayName: string; requiredEnvironment: string[]; }; diff --git a/packages/cli/src/model-gateways/generated.ts b/packages/cli/src/model-gateways/generated.ts new file mode 100644 index 00000000..ee645f11 --- /dev/null +++ b/packages/cli/src/model-gateways/generated.ts @@ -0,0 +1,151 @@ +import path from 'node:path'; + +import fsExtra from 'fs-extra'; + +import { writeGeneratorFile } from '../generators/go-kit-common.js'; +import { getVersion } from '../update-checker.js'; +import { MODEL_GATEWAY_CATEGORY, MODEL_GATEWAY_KIND } from './gateway.js'; +import type { ModelGatewayProjectKit } from './gateway.js'; + +export const MODEL_GATEWAY_UNSUPPORTED_CAPABILITIES = Object.freeze({ + chat: true, + stream: true, + tools: false, + agents: false, + embeddings: false, + images: false, + audio: false, + structuredOutput: false, + attach: false, +}); + +export const MODEL_GATEWAY_LIFECYCLE_STAGES = ['init', 'test', 'build', 'start'] as const; + +export const MODEL_GATEWAY_REQUIRED_FILES = Object.freeze({ + 'gateway.openrouter.typescript': Object.freeze([ + 'package.json', + 'tsconfig.json', + 'gateway.policy.json', + '.env.example', + '.gitignore', + 'README.md', + 'src/redact.ts', + 'src/errors.ts', + 'src/port.ts', + 'src/config.ts', + 'src/openrouter-gateway.ts', + 'src/main.ts', + 'src/index.ts', + 'tests/gateway.test.ts', + '.workspai/project.json', + '.workspai/context.json', + ]), + 'gateway.openrouter.python': Object.freeze([ + 'pyproject.toml', + 'gateway.policy.json', + '.env.example', + '.gitignore', + 'README.md', + 'main.py', + 'src/model_gateway/__init__.py', + 'src/model_gateway/redact.py', + 'src/model_gateway/errors.py', + 'src/model_gateway/port.py', + 'src/model_gateway/config.py', + 'src/model_gateway/gateway.py', + 'src/model_gateway/main.py', + 'tests/test_gateway.py', + '.workspai/project.json', + '.workspai/context.json', + ]), +}); + +export function renderGeneratedLines(value: readonly string[]): string { + return `${value.join('\n')}\n`; +} + +export function renderOpenRouterEnvExample(input: { + attributionComment: readonly string[]; + refererName: string; + titleName: string; +}): string { + return renderGeneratedLines([ + '# Required. Server-side only. Never expose this value to browsers, frontend', + '# environment prefixes, client bundles, or committed files.', + 'OPENROUTER_API_KEY=', + '', + '# Required. Choose a model slug at runtime. Workspai does not select a billable', + '# model for you. List current slugs at https://openrouter.ai/models', + 'OPENROUTER_MODEL=', + '', + ...input.attributionComment, + `${input.refererName}=`, + `${input.titleName}=`, + '', + '# Optional timeout in milliseconds. Defaults to 30000 when omitted.', + 'OPENROUTER_TIMEOUT_MS=', + ]); +} + +export async function writeGatewayWorkspaiMetadata(input: { + projectPath: string; + projectName: string; + kit: ModelGatewayProjectKit; + generatedAt?: string; +}): Promise { + const generatedAt = input.generatedAt ?? new Date().toISOString(); + const version = getVersion(); + const engine = input.kit.runtime === 'python' ? 'pip' : 'npm'; + await fsExtra.ensureDir(input.projectPath); + await writeGeneratorFile( + path.join(input.projectPath, '.workspai', 'context.json'), + `${JSON.stringify( + { + engine, + runtime: input.kit.runtime, + framework: input.kit.gatewayId, + kind: MODEL_GATEWAY_KIND, + category: MODEL_GATEWAY_CATEGORY, + kit: input.kit.id, + }, + null, + 2 + )}\n` + ); + await writeGeneratorFile( + path.join(input.projectPath, '.workspai', 'project.json'), + `${JSON.stringify( + { + schema_version: '1.0', + name: input.projectName, + slug: input.projectName, + kind: MODEL_GATEWAY_KIND, + project_type: MODEL_GATEWAY_KIND, + category: MODEL_GATEWAY_CATEGORY, + runtime: input.kit.runtime, + framework: input.kit.gatewayId, + framework_display_name: input.kit.gatewayName, + kit_name: input.kit.id, + kit: input.kit.id, + engine, + support_tier: 'extended', + module_support: false, + modules: [], + workspai_version: version, + rapidkit_version: version, + generated_by: 'workspai', + generated_at: generatedAt, + contracts: { + owns: ['model-gateway'], + apis: [], + publishes: [], + consumes: input.kit.gatewayId ? [input.kit.gatewayId] : [], + dependsOn: [], + env: [...input.kit.requiredEnvironment], + }, + }, + null, + 2 + )}\n` + ); +} diff --git a/packages/cli/src/model-gateways/index.ts b/packages/cli/src/model-gateways/index.ts index 0761663e..eb4b2cc1 100644 --- a/packages/cli/src/model-gateways/index.ts +++ b/packages/cli/src/model-gateways/index.ts @@ -1,11 +1,22 @@ export * from './gateway.js'; export * from './project-kits.js'; export * from './version-policy.js'; +export * from './version-discovery.js'; +export * from './qualification-policy.js'; +export * from './qualification-gate.js'; +export { createModelGatewayRegistry } from './registry.js'; +export type { ModelGatewayAdapter } from './adapter.js'; +export { + MODEL_GATEWAY_REQUIRED_FILES, + MODEL_GATEWAY_UNSUPPORTED_CAPABILITIES, +} from './generated.js'; export { openRouterTypeScriptSdkVersion, generateOpenRouterTypeScriptGateway, + openRouterTypeScriptAdapter, } from './adapters/openrouter/typescript.js'; export { openRouterPythonSdkVersion, generateOpenRouterPythonGateway, + openRouterPythonAdapter, } from './adapters/openrouter/python.js'; diff --git a/packages/cli/src/model-gateways/project-kits.ts b/packages/cli/src/model-gateways/project-kits.ts index a18a9ed6..e5f0766a 100644 --- a/packages/cli/src/model-gateways/project-kits.ts +++ b/packages/cli/src/model-gateways/project-kits.ts @@ -1,51 +1,18 @@ -import { getVersion } from '../update-checker.js'; -import { writeGeneratorFile } from '../generators/go-kit-common.js'; -import { generateOpenRouterTypeScriptGateway } from './adapters/openrouter/typescript.js'; -import { generateOpenRouterPythonGateway } from './adapters/openrouter/python.js'; -import { - MODEL_GATEWAY_CATEGORY, - MODEL_GATEWAY_KIND, - type ModelGatewayKitId, - type ModelGatewayProjectKit, -} from './gateway.js'; -import path from 'node:path'; -import fsExtra from 'fs-extra'; +import { openRouterPythonAdapter } from './adapters/openrouter/python.js'; +import { openRouterTypeScriptAdapter } from './adapters/openrouter/typescript.js'; +import { writeGatewayWorkspaiMetadata } from './generated.js'; +import { type ModelGatewayKitId, type ModelGatewayProjectKit } from './gateway.js'; +import { createModelGatewayRegistry } from './registry.js'; -const PROJECT_KITS: ModelGatewayProjectKit[] = [ - { - id: 'gateway.openrouter.typescript', - aliases: [ - 'gateway.openrouter.typescript', - 'openrouter.typescript', - 'gateway.openrouter.ts', - 'openrouter-typescript', - ], - label: 'OpenRouter · TypeScript', - runtime: 'node', - adapterId: 'openrouter-typescript', - gatewayId: 'openrouter', - gatewayName: 'OpenRouter', - requiredEnvironment: ['OPENROUTER_API_KEY', 'OPENROUTER_MODEL'], - }, - { - id: 'gateway.openrouter.python', - aliases: [ - 'gateway.openrouter.python', - 'openrouter.python', - 'gateway.openrouter.py', - 'openrouter-python', - ], - label: 'OpenRouter · Python', - runtime: 'python', - adapterId: 'openrouter-python', - gatewayId: 'openrouter', - gatewayName: 'OpenRouter', - requiredEnvironment: ['OPENROUTER_API_KEY', 'OPENROUTER_MODEL'], - }, -]; +const builtinRegistry = createModelGatewayRegistry([ + openRouterTypeScriptAdapter, + openRouterPythonAdapter, +]); + +export const BUILTIN_MODEL_GATEWAY_REGISTRY = builtinRegistry; export function describeModelGatewayProjectKits(): ModelGatewayProjectKit[] { - return PROJECT_KITS.map((kit) => structuredClone(kit)); + return builtinRegistry.listKits(); } export function listModelGatewayProjectKits(): ModelGatewayProjectKit[] { @@ -55,14 +22,7 @@ export function listModelGatewayProjectKits(): ModelGatewayProjectKit[] { export function lookupModelGatewayProjectKit( value: string | undefined ): ModelGatewayProjectKit | null { - if (!value) return null; - const normalized = value.trim().toLowerCase(); - const kit = PROJECT_KITS.find( - (candidate) => - candidate.id === normalized || - candidate.aliases.some((alias) => alias.toLowerCase() === normalized) - ); - return kit ? structuredClone(kit) : null; + return builtinRegistry.lookupKit(value); } export function resolveModelGatewayProjectKit( @@ -81,76 +41,12 @@ export async function generateModelGatewayProject(input: { kit: ModelGatewayProjectKit; generatedAt?: string; }): Promise { - const generatedAt = input.generatedAt ?? new Date().toISOString(); - const version = getVersion(); - await fsExtra.ensureDir(input.projectPath); - - if (input.kit.id === 'gateway.openrouter.typescript') { - await generateOpenRouterTypeScriptGateway({ - projectPath: input.projectPath, - projectName: input.projectName, - }); - } else if (input.kit.id === 'gateway.openrouter.python') { - await generateOpenRouterPythonGateway({ - projectPath: input.projectPath, - projectName: input.projectName, - }); - } else { - const unsupported: never = input.kit.id; - throw new Error(`Unsupported model gateway kit: ${unsupported}`); - } - - await writeGeneratorFile( - path.join(input.projectPath, '.workspai', 'context.json'), - `${JSON.stringify( - { - engine: input.kit.runtime === 'python' ? 'pip' : 'npm', - runtime: input.kit.runtime, - framework: input.kit.gatewayId, - kind: MODEL_GATEWAY_KIND, - category: MODEL_GATEWAY_CATEGORY, - kit: input.kit.id, - }, - null, - 2 - )}\n` - ); - await writeGeneratorFile( - path.join(input.projectPath, '.workspai', 'project.json'), - `${JSON.stringify( - { - schema_version: '1.0', - name: input.projectName, - slug: input.projectName, - kind: MODEL_GATEWAY_KIND, - project_type: MODEL_GATEWAY_KIND, - category: MODEL_GATEWAY_CATEGORY, - runtime: input.kit.runtime, - framework: input.kit.gatewayId, - framework_display_name: input.kit.gatewayName, - kit_name: input.kit.id, - kit: input.kit.id, - engine: input.kit.runtime === 'python' ? 'pip' : 'npm', - support_tier: 'extended', - module_support: false, - modules: [], - workspai_version: version, - rapidkit_version: version, - generated_by: 'workspai', - generated_at: generatedAt, - contracts: { - owns: ['model-gateway'], - apis: [], - publishes: [], - consumes: ['openrouter'], - dependsOn: [], - env: [...input.kit.requiredEnvironment], - }, - }, - null, - 2 - )}\n` - ); + await builtinRegistry.generate({ + projectPath: input.projectPath, + projectName: input.projectName, + kit: input.kit, + }); + await writeGatewayWorkspaiMetadata(input); } export function isModelGatewayKitId(value: string): value is ModelGatewayKitId { diff --git a/packages/cli/src/model-gateways/qualification-gate.ts b/packages/cli/src/model-gateways/qualification-gate.ts new file mode 100644 index 00000000..8cd24771 --- /dev/null +++ b/packages/cli/src/model-gateways/qualification-gate.ts @@ -0,0 +1,147 @@ +export const MODEL_GATEWAY_REGISTRY_FAILURE_PATTERN = + /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|401 Unauthorized|403 Forbidden|404 Not Found|EAI_AGAIN|npm ERR!|npm error|Could not find a version|Failed to establish|No matching distribution|HTTPError|read ECONNRESET|registry\.npmjs|pypi\.org|network is unreachable|getaddrinfo|Could not fetch URL|Max retries exceeded|NewConnectionError/i; + +const PACKAGE_INSTALL_COMMAND_PATTERN = + /\b(?:npm|npx|pnpm|yarn|pip3?|uv|poetry)\b|\bpython(?:3)?\s+-m\s+pip\b/i; + +export type ModelGatewayLifecycleFailureClass = 'registry' | 'product'; + +export class ModelGatewayQualificationError extends Error { + readonly classification: ModelGatewayLifecycleFailureClass; + readonly stage: string; + + constructor(stage: string, classification: ModelGatewayLifecycleFailureClass, detail: string) { + const prefix = + classification === 'registry' + ? `QUALIFICATION_INFRA: ${stage} could not install or resolve package-registry dependencies.` + : `QUALIFICATION_PRODUCT: ${stage} failed.`; + super(`${prefix} This is not a green qualification result. ${detail}`.trim()); + this.name = 'ModelGatewayQualificationError'; + this.classification = classification; + this.stage = stage; + } +} + +export function classifyGatewayLifecycleFailure( + text: string +): ModelGatewayLifecycleFailureClass | null { + if (!text.trim()) return null; + return MODEL_GATEWAY_REGISTRY_FAILURE_PATTERN.test(text) ? 'registry' : 'product'; +} + +export function classifyGatewayLifecycleReport( + report: unknown, + stage: string +): ModelGatewayLifecycleFailureClass | null { + const blob = typeof report === 'string' ? report : JSON.stringify(report ?? ''); + if (MODEL_GATEWAY_REGISTRY_FAILURE_PATTERN.test(blob)) { + return 'registry'; + } + const record = asRecord(report); + if (!record) { + return classifyGatewayLifecycleFailure(blob); + } + const projects = failedProjects(record); + if (projects.length === 0) { + return classifyGatewayLifecycleFailure(blob); + } + const installStage = stage === 'init'; + for (const project of projects) { + const diagnostic = collectProjectDiagnostics(project); + const installCommand = PACKAGE_INSTALL_COMMAND_PATTERN.test(diagnostic.command); + const unknownOrMissing = + diagnostic.categories.length === 0 || diagnostic.categories.includes('unknown'); + const dependency = diagnostic.categories.includes('dependency'); + if ((installStage || installCommand) && (unknownOrMissing || dependency)) { + return 'registry'; + } + } + return classifyGatewayLifecycleFailure(blob) ?? 'product'; +} + +export function assertGatewayQualificationStage(input: { + stage: string; + failed: number; + detail?: string; + report?: unknown; +}): void { + if (input.failed === 0) return; + const classification = + input.report !== undefined + ? (classifyGatewayLifecycleReport(input.report, input.stage) ?? 'product') + : (classifyGatewayLifecycleFailure(input.detail ?? '') ?? 'product'); + const detail = ( + input.detail ?? (input.report !== undefined ? JSON.stringify(input.report) : '') + ).slice(0, 2000); + throw new ModelGatewayQualificationError(input.stage, classification, detail); +} + +export async function runGatewayInitWithRegistryRetry( + run: () => Promise, + options?: { attempts?: number; delayMs?: number } +): Promise { + const attempts = Math.max(1, options?.attempts ?? 2); + const delayMs = Math.max(0, options?.delayMs ?? 1_000); + let last: T | undefined; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + last = await run(); + if (last.summary.failed === 0) return last; + if (classifyGatewayLifecycleReport(last, 'init') !== 'registry' || attempt === attempts) { + return last; + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + return last as T; +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function failedProjects(report: Record): Record[] { + const projects = Array.isArray(report.projects) ? report.projects : []; + const records = projects + .map((project) => asRecord(project)) + .filter((project): project is Record => project !== null); + const failed = records.filter((project) => project.status === 'failed'); + return failed.length > 0 ? failed : records; +} + +function collectProjectDiagnostics(project: Record): { + categories: string[]; + command: string; +} { + const diagnostic = asRecord(project.failureDiagnostic); + const executions = Array.isArray(project.runtimeExecutions) + ? project.runtimeExecutions + .map((execution) => asRecord(execution)) + .filter((execution): execution is Record => execution !== null) + : []; + const categories = uniqueStrings([ + project.errorCategory, + diagnostic?.category, + ...executions.map((execution) => execution.errorCategory), + ...executions.map((execution) => asRecord(execution.failureDiagnostic)?.category), + ]); + const command = [ + project.executionCommand, + diagnostic?.command, + ...executions.map((execution) => execution.command), + ...executions.map((execution) => asRecord(execution.failureDiagnostic)?.command), + ] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join('\n'); + return { categories, command }; +} + +function uniqueStrings(values: unknown[]): string[] { + return [ + ...new Set( + values.filter( + (value): value is string => typeof value === 'string' && value.trim().length > 0 + ) + ), + ]; +} diff --git a/packages/cli/src/model-gateways/qualification-policy.ts b/packages/cli/src/model-gateways/qualification-policy.ts new file mode 100644 index 00000000..36d1b134 --- /dev/null +++ b/packages/cli/src/model-gateways/qualification-policy.ts @@ -0,0 +1,156 @@ +import qualificationDocument from './qualification.v1.json'; +import { + BUILTIN_MODEL_GATEWAY_VERSION_BASELINES, + getModelGatewayVersionBaseline, +} from './version-policy.js'; + +export const MODEL_GATEWAY_QUALIFICATION_SCHEMA_VERSION = + 'workspai.model-gateway-qualification.v1' as const; + +export type ModelGatewayQualificationStatus = 'source-ready' | 'qualified'; +export type ModelGatewayAttachPolicy = 'unsupported'; + +export type ModelGatewayQualifiedPlatform = { + os: 'ubuntu-latest' | 'macos-latest' | 'windows-latest'; + sdkVersion: string; + result: 'pass'; + skippedRequiredStage: false; + workflowRun?: string; + generatedOutputDigest?: string; + qualifiedAt?: string; +}; + +export type ModelGatewayAdapterQualification = { + adapterId: string; + status: ModelGatewayQualificationStatus; + platforms: readonly ModelGatewayQualifiedPlatform[]; +}; + +export type ModelGatewayQualificationDocument = { + schemaVersion: typeof MODEL_GATEWAY_QUALIFICATION_SCHEMA_VERSION; + workspaiReleaseAuthority: ModelGatewayQualificationStatus; + attach: ModelGatewayAttachPolicy; + adapters: readonly ModelGatewayAdapterQualification[]; +}; + +const REQUIRED_PLATFORMS = ['ubuntu-latest', 'macos-latest', 'windows-latest'] as const; + +function assertQualificationDocument( + value: unknown +): asserts value is ModelGatewayQualificationDocument { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Model gateway qualification document must be an object.'); + } + const document = value as Partial; + if (document.schemaVersion !== MODEL_GATEWAY_QUALIFICATION_SCHEMA_VERSION) { + throw new Error( + `Unsupported model gateway qualification schema: ${String(document.schemaVersion)}` + ); + } + if (document.attach !== 'unsupported') { + throw new Error('Model gateway Attach must be explicitly unsupported for this surface.'); + } + if ( + document.workspaiReleaseAuthority !== 'source-ready' && + document.workspaiReleaseAuthority !== 'qualified' + ) { + throw new Error('Model gateway release authority must be source-ready or qualified.'); + } + if (!Array.isArray(document.adapters) || document.adapters.length === 0) { + throw new Error('Model gateway qualification document contains no adapters.'); + } + const adapterIds = new Set(); + for (const adapter of document.adapters) { + if (!adapter || typeof adapter.adapterId !== 'string') { + throw new Error('Model gateway qualification adapter is missing adapterId.'); + } + if (adapterIds.has(adapter.adapterId)) { + throw new Error(`Duplicate model gateway qualification adapter: ${adapter.adapterId}`); + } + adapterIds.add(adapter.adapterId); + const baseline = getModelGatewayVersionBaseline(adapter.adapterId); + if (!baseline) { + throw new Error(`Qualification adapter ${adapter.adapterId} is not in the version baseline.`); + } + if (adapter.status !== 'source-ready' && adapter.status !== 'qualified') { + throw new Error(`Invalid qualification status for ${adapter.adapterId}.`); + } + if (!Array.isArray(adapter.platforms)) { + throw new Error(`Qualification platforms missing for ${adapter.adapterId}.`); + } + if (adapter.status === 'source-ready') { + if (adapter.platforms.length !== 0) { + throw new Error( + `Source-ready adapter ${adapter.adapterId} must not record platform qualification.` + ); + } + continue; + } + const seen = new Set(); + for (const platform of adapter.platforms) { + if ( + !platform || + !REQUIRED_PLATFORMS.includes(platform.os) || + platform.result !== 'pass' || + platform.skippedRequiredStage !== false || + platform.sdkVersion !== baseline.sdkVersion + ) { + throw new Error( + `Qualified adapter ${adapter.adapterId} is missing a valid platform record bound to ${baseline.sdkVersion}.` + ); + } + if (seen.has(platform.os)) { + throw new Error(`Duplicate platform ${platform.os} for ${adapter.adapterId}.`); + } + seen.add(platform.os); + } + for (const os of REQUIRED_PLATFORMS) { + if (!seen.has(os)) { + throw new Error(`Qualified adapter ${adapter.adapterId} is missing ${os} evidence.`); + } + } + } + for (const baseline of BUILTIN_MODEL_GATEWAY_VERSION_BASELINES) { + if (!adapterIds.has(baseline.adapterId)) { + throw new Error(`Baseline adapter ${baseline.adapterId} has no qualification record.`); + } + } + const allQualified = document.adapters.every((adapter) => adapter.status === 'qualified'); + if (document.workspaiReleaseAuthority === 'qualified' && !allQualified) { + throw new Error('Workspai release authority cannot be qualified while an adapter is not.'); + } + if (document.workspaiReleaseAuthority === 'source-ready' && allQualified) { + throw new Error('All adapters are qualified; release authority must match.'); + } +} + +assertQualificationDocument(qualificationDocument); +const validated = qualificationDocument as ModelGatewayQualificationDocument; + +export const MODEL_GATEWAY_QUALIFICATION: ModelGatewayQualificationDocument = Object.freeze({ + schemaVersion: validated.schemaVersion, + workspaiReleaseAuthority: validated.workspaiReleaseAuthority, + attach: validated.attach, + adapters: Object.freeze( + validated.adapters.map((adapter) => + Object.freeze({ + ...adapter, + platforms: Object.freeze(adapter.platforms.map((platform) => Object.freeze(platform))), + }) + ) + ), +}); + +export const MODEL_GATEWAY_ATTACH_POLICY: ModelGatewayAttachPolicy = + MODEL_GATEWAY_QUALIFICATION.attach; + +export const MODEL_GATEWAY_RELEASE_AUTHORITY: ModelGatewayQualificationStatus = + MODEL_GATEWAY_QUALIFICATION.workspaiReleaseAuthority; + +export function isModelGatewayReleaseQualified(): boolean { + return MODEL_GATEWAY_RELEASE_AUTHORITY === 'qualified'; +} + +export function modelGatewayPickerHint(runtime: string): string { + return `${runtime} · ${MODEL_GATEWAY_RELEASE_AUTHORITY}`; +} diff --git a/packages/cli/src/model-gateways/qualification.v1.json b/packages/cli/src/model-gateways/qualification.v1.json new file mode 100644 index 00000000..d811730e --- /dev/null +++ b/packages/cli/src/model-gateways/qualification.v1.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": "workspai.model-gateway-qualification.v1", + "workspaiReleaseAuthority": "source-ready", + "attach": "unsupported", + "adapters": [ + { + "adapterId": "openrouter-typescript", + "status": "source-ready", + "platforms": [] + }, + { + "adapterId": "openrouter-python", + "status": "source-ready", + "platforms": [] + } + ] +} diff --git a/packages/cli/src/model-gateways/registry.ts b/packages/cli/src/model-gateways/registry.ts index 7618b5e3..37c88fab 100644 --- a/packages/cli/src/model-gateways/registry.ts +++ b/packages/cli/src/model-gateways/registry.ts @@ -1 +1,103 @@ -export { listModelGatewayProjectKits, lookupModelGatewayProjectKit } from './project-kits.js'; +import type { ModelGatewayAdapter } from './adapter.js'; +import { cloneAdapterView } from './adapter.js'; +import type { ModelGatewayProjectKit } from './gateway.js'; + +export type ModelGatewayRegistry = { + listAdapters(): ReadonlyArray>; + listKits(): ModelGatewayProjectKit[]; + lookupKit(value: string | undefined): ModelGatewayProjectKit | null; + resolveAdapter(kit: ModelGatewayProjectKit): ModelGatewayAdapter; + generate(input: { + projectPath: string; + projectName: string; + kit: ModelGatewayProjectKit; + }): Promise; +}; + +function kitFromAdapter(adapter: ModelGatewayAdapter): ModelGatewayProjectKit { + return { + id: adapter.kitId, + aliases: [...adapter.aliases], + label: adapter.label, + runtime: adapter.runtime, + adapterId: adapter.id, + gatewayId: adapter.gatewayId, + gatewayName: adapter.gatewayName, + requiredEnvironment: [...adapter.requiredEnvironment], + }; +} + +export function createModelGatewayRegistry( + adapters: readonly ModelGatewayAdapter[] +): ModelGatewayRegistry { + if (adapters.length === 0) { + throw new Error('Model gateway registry requires at least one adapter.'); + } + const adapterIds = new Set(); + const kitIds = new Set(); + const aliasOwner = new Map(); + for (const adapter of adapters) { + if (!adapter.id || !adapter.kitId || !adapter.generate) { + throw new Error('Model gateway adapter is missing id, kitId, or generate.'); + } + if (adapterIds.has(adapter.id)) { + throw new Error(`Duplicate model gateway adapter: ${adapter.id}`); + } + if (kitIds.has(adapter.kitId)) { + throw new Error(`Duplicate model gateway kit: ${adapter.kitId}`); + } + adapterIds.add(adapter.id); + kitIds.add(adapter.kitId); + const names = [adapter.kitId, ...adapter.aliases]; + for (const name of names) { + const normalized = name.trim().toLowerCase(); + if (!normalized) { + throw new Error(`Empty model gateway alias on ${adapter.id}.`); + } + const owner = aliasOwner.get(normalized); + if (owner && owner !== adapter.id) { + throw new Error(`Duplicate model gateway alias: ${name}`); + } + aliasOwner.set(normalized, adapter.id); + } + } + const frozen = Object.freeze([...adapters]); + + const lookupKit = (value: string | undefined): ModelGatewayProjectKit | null => { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + const adapter = frozen.find( + (candidate) => + candidate.kitId.toLowerCase() === normalized || + candidate.aliases.some((alias) => alias.toLowerCase() === normalized) + ); + return adapter ? structuredClone(kitFromAdapter(adapter)) : null; + }; + + return { + listAdapters() { + return frozen.map((adapter) => cloneAdapterView(adapter)); + }, + listKits() { + return frozen.map((adapter) => structuredClone(kitFromAdapter(adapter))); + }, + lookupKit, + resolveAdapter(kit: ModelGatewayProjectKit): ModelGatewayAdapter { + const adapter = frozen.find((candidate) => candidate.kitId === kit.id); + if (!adapter) { + throw new Error(`Unsupported model gateway kit: ${kit.id}`); + } + return adapter; + }, + async generate(input) { + const adapter = frozen.find((candidate) => candidate.kitId === input.kit.id); + if (!adapter) { + throw new Error(`Unsupported model gateway kit: ${input.kit.id}`); + } + await adapter.generate({ + projectPath: input.projectPath, + projectName: input.projectName, + }); + }, + }; +} diff --git a/packages/cli/src/model-gateways/version-discovery.ts b/packages/cli/src/model-gateways/version-discovery.ts new file mode 100644 index 00000000..8f7b6947 --- /dev/null +++ b/packages/cli/src/model-gateways/version-discovery.ts @@ -0,0 +1,429 @@ +import { + BUILTIN_MODEL_GATEWAY_VERSION_BASELINES, + EXCLUDED_MODEL_GATEWAY_LANGUAGES, + MODEL_GATEWAY_BASELINES_REVIEWED_AT, + MODEL_GATEWAY_VERSION_BASELINES_SCHEMA_VERSION, + compareStableSdkVersions, + isEligibleStableSdkVersion, + sdkCorePackage, + selectLatestStableSdkVersion, + type ModelGatewayExcludedLanguage, + type ModelGatewayVersionBaseline, + type ModelGatewayVersionBaselineDocument, +} from './version-policy.js'; + +export const MODEL_GATEWAY_VERSION_DISCOVERY_SCHEMA_VERSION = + 'workspai.model-gateway-version-discovery.v1' as const; + +export const MODEL_GATEWAY_DISCOVERY_EXIT = { + CURRENT: 0, + UPDATE_AVAILABLE: 10, + DISAGREEMENT: 20, + UNAVAILABLE: 30, + INVALID: 40, +} as const; + +export type ModelGatewayDiscoveryOutcome = + (typeof MODEL_GATEWAY_DISCOVERY_EXIT)[keyof typeof MODEL_GATEWAY_DISCOVERY_EXIT]; + +export type ModelGatewayDiscoveryStatus = + 'current' | 'update-available' | 'disagreement' | 'unavailable' | 'invalid'; + +type FetchLike = ( + input: string, + init?: { headers?: Record; signal?: AbortSignal } +) => Promise<{ ok: boolean; status: number; json(): Promise }>; + +const FETCH_HEADERS = { + accept: 'application/json', + 'user-agent': 'workspai-model-gateway-version-discovery', +}; + +export type ModelGatewayAdapterDiscovery = { + adapterId: string; + runtime: ModelGatewayVersionBaseline['runtime']; + packageName: string; + pinnedVersion: string; + registryVersion: string | null; + githubVersion: string | null; + status: ModelGatewayDiscoveryStatus; + registryUrl: string; + githubReleaseUrl: string | null; + detail: string; +}; + +export type ModelGatewayExclusionDiscovery = { + language: string; + module: string; + pinnedObservation: string; + latestObservedVersion: string | null; + declaredMaturity: string; + status: 'still-excluded' | 'unavailable' | 'stable-review-required'; + detail: string; +}; + +export type ModelGatewayVersionDiscovery = { + schemaVersion: typeof MODEL_GATEWAY_VERSION_DISCOVERY_SCHEMA_VERSION; + generatedAt: string; + reviewedAt: string; + policy: 'discover-only'; + automaticMutation: false; + adapters: ModelGatewayAdapterDiscovery[]; + excluded: ModelGatewayExclusionDiscovery[]; + summary: { + adapters: number; + current: number; + updateAvailable: number; + disagreement: number; + unavailable: number; + invalid: number; + }; + outcome: ModelGatewayDiscoveryStatus; + proposedDocument: ModelGatewayVersionBaselineDocument | null; +}; + +function stripVersionPrefix(value: string): string { + return value.trim().replace(/^v/i, ''); +} + +function dateStamp(value: string): string { + return value.slice(0, 10); +} + +async function fetchJson(url: string, fetcher: FetchLike): Promise { + const response = await fetcher(url, { + headers: FETCH_HEADERS, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error(`Lookup failed for ${url} with HTTP ${response.status}.`); + } + return response.json(); +} + +function npmStableVersion(payload: unknown, packageName: string): string { + if (!payload || typeof payload !== 'object') { + throw new Error(`npm returned an invalid payload for ${packageName}.`); + } + const record = payload as { + 'dist-tags'?: { latest?: unknown }; + versions?: Record; + }; + const latest = record['dist-tags']?.latest; + if (typeof latest !== 'string') { + throw new Error(`npm latest tag is missing for ${packageName}.`); + } + const version = stripVersionPrefix(latest); + const metadata = record.versions?.[latest] ?? record.versions?.[version]; + if (metadata?.deprecated) { + throw new Error(`npm latest ${packageName}@${version} is deprecated.`); + } + if (!isEligibleStableSdkVersion(version)) { + throw new Error(`npm latest ${packageName}@${version} is not an eligible stable version.`); + } + return version; +} + +function pypiStableVersion(payload: unknown, packageName: string): string { + if (!payload || typeof payload !== 'object') { + throw new Error(`PyPI returned an invalid payload for ${packageName}.`); + } + const record = payload as { + info?: { version?: unknown }; + releases?: Record>; + }; + const latest = record.info?.version; + if (typeof latest !== 'string') { + throw new Error(`PyPI version is missing for ${packageName}.`); + } + const version = stripVersionPrefix(latest); + const files = record.releases?.[latest] ?? record.releases?.[version]; + if (Array.isArray(files) && files.length > 0 && files.every((file) => file?.yanked === true)) { + throw new Error(`PyPI ${packageName}@${version} is yanked.`); + } + if (!isEligibleStableSdkVersion(version)) { + throw new Error(`PyPI ${packageName}@${version} is not an eligible stable version.`); + } + return version; +} + +function githubStableVersion(payload: unknown, repository: string): string { + const releases = Array.isArray(payload) ? payload : [payload]; + const versions = releases.flatMap((release) => { + if (!release || typeof release !== 'object') return []; + const record = release as { + tag_name?: unknown; + prerelease?: unknown; + draft?: unknown; + }; + if (record.prerelease === true || record.draft === true) return []; + if (typeof record.tag_name !== 'string') return []; + return [stripVersionPrefix(record.tag_name)]; + }); + const latest = selectLatestStableSdkVersion(versions); + if (!latest) { + throw new Error(`GitHub has no eligible stable release for ${repository}.`); + } + return latest; +} + +function goProxyVersion(payload: unknown): string { + if (!payload || typeof payload !== 'object') { + throw new Error('Go proxy returned an invalid payload.'); + } + const version = (payload as { Version?: unknown }).Version; + if (typeof version !== 'string' || !version.trim()) { + throw new Error('Go proxy did not return a Version.'); + } + return version.trim(); +} + +function adapterStatus( + pinned: string, + registryVersion: string, + githubVersion: string +): Pick { + if (registryVersion !== githubVersion) { + return { + status: 'disagreement', + detail: `Registry ${registryVersion} disagrees with GitHub ${githubVersion}.`, + }; + } + const comparison = compareStableSdkVersions(registryVersion, pinned); + if (comparison === 0) { + return { status: 'current', detail: `Pinned ${pinned} matches registry and GitHub.` }; + } + if (comparison > 0) { + return { + status: 'update-available', + detail: `${pinned} -> ${registryVersion}`, + }; + } + return { + status: 'invalid', + detail: `Registry ${registryVersion} is older than the pinned ${pinned}.`, + }; +} + +function githubRepo(url: string): string { + const match = url.match(/github\.com\/([^/]+\/[^/]+)/i); + if (!match?.[1]) { + throw new Error(`Cannot parse GitHub repository from ${url}.`); + } + return match[1].replace(/\.git$/i, ''); +} + +function proposedBaseline( + baseline: ModelGatewayVersionBaseline, + nextVersion: string, + reviewedAt: string, + githubRepoName: string +): ModelGatewayVersionBaseline { + const core = sdkCorePackage(baseline); + return { + ...baseline, + sdkVersion: nextVersion, + discoveryDate: reviewedAt, + upstreamReleaseUrl: `https://github.com/${githubRepoName}/releases/tag/v${nextVersion}`, + registryUrl: + core.ecosystem === 'npm' + ? `https://registry.npmjs.org/${core.name}/${nextVersion}` + : `https://pypi.org/pypi/${core.name}/${nextVersion}`, + notes: `Proposed ${reviewedAt}. Qualifies only after Linux, macOS, and Windows generated-project and lifecycle evidence on the same commit.`, + packages: baseline.packages.map((dependency) => + dependency.role === 'sdk-core' + ? { + ...dependency, + version: nextVersion, + registryUrl: + dependency.ecosystem === 'npm' + ? `https://registry.npmjs.org/${dependency.name}/${nextVersion}` + : `https://pypi.org/pypi/${dependency.name}/${nextVersion}`, + } + : dependency + ), + }; +} + +export async function discoverModelGatewayVersions(input?: { + baselines?: readonly ModelGatewayVersionBaseline[]; + excluded?: readonly ModelGatewayExcludedLanguage[]; + fetcher?: FetchLike; + generatedAt?: string; +}): Promise { + const baselines = input?.baselines ?? BUILTIN_MODEL_GATEWAY_VERSION_BASELINES; + const excluded = input?.excluded ?? EXCLUDED_MODEL_GATEWAY_LANGUAGES; + const fetcher = input?.fetcher ?? (fetch as FetchLike); + const generatedAt = input?.generatedAt ?? new Date().toISOString(); + const reviewedAt = dateStamp(generatedAt); + + const adapters: ModelGatewayAdapterDiscovery[] = []; + for (const baseline of baselines) { + const core = sdkCorePackage(baseline); + const repository = githubRepo(baseline.upstreamReleaseUrl); + try { + const registryPayload = await fetchJson( + core.ecosystem === 'npm' + ? `https://registry.npmjs.org/${core.name}` + : `https://pypi.org/pypi/${core.name}/json`, + fetcher + ); + const githubPayload = await fetchJson( + `https://api.github.com/repos/${repository}/releases?per_page=30`, + fetcher + ); + const registryVersion = + core.ecosystem === 'npm' + ? npmStableVersion(registryPayload, core.name) + : pypiStableVersion(registryPayload, core.name); + const githubVersion = githubStableVersion(githubPayload, repository); + const status = adapterStatus(baseline.sdkVersion, registryVersion, githubVersion); + adapters.push({ + adapterId: baseline.adapterId, + runtime: baseline.runtime, + packageName: core.name, + pinnedVersion: baseline.sdkVersion, + registryVersion, + githubVersion, + registryUrl: + core.ecosystem === 'npm' + ? `https://registry.npmjs.org/${core.name}/${registryVersion}` + : `https://pypi.org/pypi/${core.name}/${registryVersion}`, + githubReleaseUrl: `https://github.com/${repository}/releases/tag/v${githubVersion}`, + ...status, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const status: ModelGatewayDiscoveryStatus = /HTTP|failed for|timeout|network/i.test(message) + ? 'unavailable' + : 'invalid'; + adapters.push({ + adapterId: baseline.adapterId, + runtime: baseline.runtime, + packageName: core.name, + pinnedVersion: baseline.sdkVersion, + registryVersion: null, + githubVersion: null, + status, + registryUrl: baseline.registryUrl, + githubReleaseUrl: null, + detail: message, + }); + } + } + + const exclusionReports: ModelGatewayExclusionDiscovery[] = []; + for (const entry of excluded) { + try { + const payload = await fetchJson( + `https://proxy.golang.org/${entry.module.toLowerCase()}/@latest`, + fetcher + ); + const latest = goProxyVersion(payload); + const maturity = /beta|alpha|preview|rc/i.test(entry.declaredMaturity) + ? 'still-excluded' + : 'stable-review-required'; + const stableCandidate = isEligibleStableSdkVersion(stripVersionPrefix(latest)); + exclusionReports.push({ + language: entry.language, + module: entry.module, + pinnedObservation: entry.latestObservedVersion, + latestObservedVersion: latest, + declaredMaturity: entry.declaredMaturity, + status: stableCandidate ? 'stable-review-required' : maturity, + detail: stableCandidate + ? `Go proxy reported ${latest}. Manual review is required before creating a kit.` + : `Go remains excluded. Latest observed ${latest}.`, + }); + } catch (error) { + exclusionReports.push({ + language: entry.language, + module: entry.module, + pinnedObservation: entry.latestObservedVersion, + latestObservedVersion: null, + declaredMaturity: entry.declaredMaturity, + status: 'unavailable', + detail: error instanceof Error ? error.message : String(error), + }); + } + } + + const summary = { + adapters: adapters.length, + current: adapters.filter((adapter) => adapter.status === 'current').length, + updateAvailable: adapters.filter((adapter) => adapter.status === 'update-available').length, + disagreement: adapters.filter((adapter) => adapter.status === 'disagreement').length, + unavailable: adapters.filter((adapter) => adapter.status === 'unavailable').length, + invalid: adapters.filter((adapter) => adapter.status === 'invalid').length, + }; + + const outcome: ModelGatewayDiscoveryStatus = + summary.unavailable > 0 + ? 'unavailable' + : summary.disagreement > 0 + ? 'disagreement' + : summary.invalid > 0 + ? 'invalid' + : summary.updateAvailable > 0 + ? 'update-available' + : 'current'; + + const proposedDocument = + outcome === 'update-available' + ? { + schemaVersion: MODEL_GATEWAY_VERSION_BASELINES_SCHEMA_VERSION, + reviewedAt, + baselines: baselines.map((baseline) => { + const discovery = adapters.find((adapter) => adapter.adapterId === baseline.adapterId); + if (discovery?.status === 'update-available' && discovery.registryVersion) { + return proposedBaseline( + baseline, + discovery.registryVersion, + reviewedAt, + githubRepo(baseline.upstreamReleaseUrl) + ); + } + return structuredClone(baseline); + }), + excluded: structuredClone([...excluded]), + } + : null; + + return { + schemaVersion: MODEL_GATEWAY_VERSION_DISCOVERY_SCHEMA_VERSION, + generatedAt, + reviewedAt: MODEL_GATEWAY_BASELINES_REVIEWED_AT, + policy: 'discover-only', + automaticMutation: false, + adapters, + excluded: exclusionReports, + summary, + outcome, + proposedDocument, + }; +} + +export function discoveryExitCode( + outcome: ModelGatewayDiscoveryStatus +): ModelGatewayDiscoveryOutcome { + if (outcome === 'current') return MODEL_GATEWAY_DISCOVERY_EXIT.CURRENT; + if (outcome === 'update-available') return MODEL_GATEWAY_DISCOVERY_EXIT.UPDATE_AVAILABLE; + if (outcome === 'disagreement') return MODEL_GATEWAY_DISCOVERY_EXIT.DISAGREEMENT; + if (outcome === 'unavailable') return MODEL_GATEWAY_DISCOVERY_EXIT.UNAVAILABLE; + return MODEL_GATEWAY_DISCOVERY_EXIT.INVALID; +} + +export function formatModelGatewayDiscoverySummary( + discovery: ModelGatewayVersionDiscovery +): string { + const lines = discovery.adapters.map( + (adapter) => + `${adapter.status === 'update-available' ? 'UPDATE' : adapter.status.toUpperCase()} ${adapter.adapterId} ${adapter.packageName}: ${adapter.pinnedVersion}${adapter.registryVersion ? ` -> ${adapter.registryVersion}` : ''} (${adapter.detail})` + ); + for (const entry of discovery.excluded) { + lines.push( + `${entry.status.toUpperCase()} ${entry.language} ${entry.module}: ${entry.pinnedObservation}${entry.latestObservedVersion ? ` -> ${entry.latestObservedVersion}` : ''} (${entry.detail})` + ); + } + return `${lines.join('\n')}\nOUTCOME ${discovery.outcome}\n`; +} diff --git a/packages/cli/src/model-gateways/version-policy.ts b/packages/cli/src/model-gateways/version-policy.ts index 5487dc57..7fec495a 100644 --- a/packages/cli/src/model-gateways/version-policy.ts +++ b/packages/cli/src/model-gateways/version-policy.ts @@ -172,3 +172,36 @@ export function sdkCorePackage(baseline: ModelGatewayVersionBaseline): ModelGate } return dependency; } + +const UNSTABLE_VERSION = /(?:^|[.-])(?:alpha|beta|rc|canary|next|dev|preview)(?:[.-]|$)/i; + +export function isEligibleStableSdkVersion(version: string): boolean { + return ( + VERSION_PATTERN.test(version.trim()) && + !UNSTABLE_VERSION.test(version) && + !version.startsWith('0.') + ); +} + +function versionCore(version: string): number[] { + const match = version.trim().match(/^(\d+(?:\.\d+)*)/); + return (match?.[1] ?? '0').split('.').map((part) => Number(part)); +} + +export function compareStableSdkVersions(left: string, right: string): number { + const leftCore = versionCore(left); + const rightCore = versionCore(right); + const length = Math.max(leftCore.length, rightCore.length); + for (let index = 0; index < length; index += 1) { + const difference = (leftCore[index] ?? 0) - (rightCore[index] ?? 0); + if (difference !== 0) return difference; + } + return 0; +} + +export function selectLatestStableSdkVersion(versions: readonly string[]): string | null { + const eligible = versions + .map((version) => version.trim().replace(/^v/i, '')) + .filter((version) => isEligibleStableSdkVersion(version)); + return eligible.sort(compareStableSdkVersions).at(-1) ?? null; +} diff --git a/packages/cli/src/workspace-run.ts b/packages/cli/src/workspace-run.ts index c5c91b5f..fc420c62 100644 --- a/packages/cli/src/workspace-run.ts +++ b/packages/cli/src/workspace-run.ts @@ -1094,6 +1094,8 @@ async function runRapidkitSelfCommand( stderr: commandTimedOut ? `${result.stderr ?? ''}${result.stderr ? '\n' : ''}Stage timed out after ${timeoutMs}ms` : result.stderr, + shortMessage: result.shortMessage, + message: result.message, }; } catch (error) { const timedOut = @@ -1140,6 +1142,29 @@ function isVitestRuntime(): boolean { ); } +function stringifyCommandStream(value: unknown): string { + if (typeof value === 'string') return value; + if (Buffer.isBuffer(value)) return value.toString('utf8'); + if (value == null) return ''; + return String(value); +} + +function collectCommandOutput(result: { + stdout?: unknown; + stderr?: unknown; + shortMessage?: unknown; + message?: unknown; +}): { stdout: string; stderr: string } { + const stdout = stringifyCommandStream(result.stdout); + const stderr = stringifyCommandStream(result.stderr); + if (stdout.trim() || stderr.trim()) { + return { stdout, stderr }; + } + const message = stringifyCommandStream(result.message).trim(); + const shortMessage = stringifyCommandStream(result.shortMessage).trim(); + return { stdout, stderr: message || shortMessage }; +} + function boundedFailureOutput(lines: string[], limit = 8): string { if (lines.length <= limit) return lines.join('\n'); const primary = primaryFailureLine(lines); @@ -1453,6 +1478,7 @@ async function executeStageCommand( let combinedStderr = ''; let lastExit = 0; let timedOut = false; + let lastProcessMessage = ''; for (const step of materializedArgv) { const stepResult = await execa(step.executable, step.args, { cwd: projectPath, @@ -1486,13 +1512,21 @@ async function executeStageCommand( '\nMissing admitted lock tool `uv`. Install uv and retry; Workspai does not fall back to an unlocked pip freeze.'; } if (lastExit !== 0) { + lastProcessMessage = [stepResult.message, stepResult.shortMessage] + .filter((value): value is string => typeof value === 'string') + .join('\n'); break; } } + const captured = collectCommandOutput({ + stdout: combinedStdout, + stderr: combinedStderr, + shortMessage: lastProcessMessage, + }); return { exitCode: lastExit, - stdout: combinedStdout.trim(), - stderr: combinedStderr.trim(), + stdout: captured.stdout, + stderr: captured.stderr, timedOut, }; })() @@ -1521,8 +1555,9 @@ async function executeStageCommand( (result as { timedOut?: unknown }).timedOut ); exitCode = commandTimedOut ? 124 : Number(result.exitCode ?? 0); - stdout = result.stdout; - stderr = result.stderr; + const captured = collectCommandOutput(result); + stdout = captured.stdout; + stderr = captured.stderr; healthStatus = stage === 'start' ? (result as { healthStatus?: { healthy: boolean; reason?: string } }).healthStatus @@ -1545,15 +1580,25 @@ async function executeStageCommand( error !== null && 'timedOut' in error && Boolean((error as { timedOut?: unknown }).timedOut); + const message = timedOut + ? `Stage timed out after ${timeoutMs}ms` + : error instanceof Error + ? error.message + : 'Command execution failed'; + const category = timedOut ? 'timeout' : 'runtime'; return { exitCode: timedOut ? 124 : 1, command: resolvedCommand, - message: timedOut - ? `Stage timed out after ${timeoutMs}ms` - : error instanceof Error - ? error.message - : 'Command execution failed', - errorCategory: timedOut ? 'timeout' : 'runtime', + message, + errorCategory: category, + failureDiagnostic: { + category, + exitCode: timedOut ? 124 : 1, + command: resolvedCommand, + timedOut, + timeoutMs, + outputExcerpt: message, + }, }; } @@ -1597,15 +1642,15 @@ async function executeStageCommand( (exitCode === 143 && durationMs >= Math.floor(timeoutMs * 0.8)); const normalizedCategory = exitCode === 0 ? undefined : timedOut ? 'timeout' : errorCategory; const failureDiagnostic = - exitCode === 0 || !normalizedCategory + exitCode === 0 ? undefined : { - category: normalizedCategory, + category: (normalizedCategory ?? 'unknown') as ErrorCategory, exitCode, command: resolvedCommand, timedOut, timeoutMs, - ...(outputExcerpt ? { outputExcerpt } : {}), + outputExcerpt: outputExcerpt || `Stage failed with exit code ${exitCode}`, }; return { From 7a6a1ee77557bd7e7c959546d4983601ef0c5b70 Mon Sep 17 00:00:00 2001 From: Baziar Date: Mon, 21 Sep 2026 16:29:25 -0400 Subject: [PATCH 3/4] fix(cli): make gateway qualification parse and Windows teardown safe --- .../workflows/model-gateway-qualification.yml | 3 --- packages/cli/CHANGELOG.md | 3 ++- .../model-gateway-generated-projects.test.ts | 10 +++++++++- .../model-gateway-version-discovery.test.ts | 2 ++ .../model-gateway-workspace-lifecycle.test.ts | 19 ++++++++++++++++++- 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/model-gateway-qualification.yml b/.github/workflows/model-gateway-qualification.yml index 000e88a5..a46cce43 100644 --- a/.github/workflows/model-gateway-qualification.yml +++ b/.github/workflows/model-gateway-qualification.yml @@ -39,9 +39,6 @@ jobs: timeout-minutes: 35 env: CI: "1" - npm_config_cache: ${{ runner.temp }}/workspai-npm-cache - NPM_CONFIG_CACHE: ${{ runner.temp }}/workspai-npm-cache - PIP_CACHE_DIR: ${{ runner.temp }}/workspai-pip-cache PIP_DISABLE_PIP_VERSION_CHECK: "1" strategy: fail-fast: false diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 101162c2..ae26a943 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -27,7 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Agent Framework. - Model Gateway qualification CI runs one job per OS that qualifies both TypeScript and Python adapters, instead of an unused kit axis that duplicated - the same suites. + the same suites. Job-level cache env no longer uses `runner.temp` or a + duplicate `NPM_CONFIG_CACHE`; `setup-node` owns the npm cache. ### Fixed diff --git a/packages/cli/src/__tests__/model-gateway-generated-projects.test.ts b/packages/cli/src/__tests__/model-gateway-generated-projects.test.ts index f5d7dee1..5d6ffb34 100644 --- a/packages/cli/src/__tests__/model-gateway-generated-projects.test.ts +++ b/packages/cli/src/__tests__/model-gateway-generated-projects.test.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; +import { rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -17,7 +18,14 @@ import { buildCleanGitEnv } from '../utils/git-worktree.js'; const roots: string[] = []; afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => fsExtra.remove(root))); + for (const root of roots.splice(0)) { + await rm(path.resolve(root), { + recursive: true, + force: true, + maxRetries: process.platform === 'win32' ? 10 : 0, + retryDelay: 100, + }); + } }); function isolatedRegistryEnv(cacheRoot: string): NodeJS.ProcessEnv { diff --git a/packages/cli/src/__tests__/model-gateway-version-discovery.test.ts b/packages/cli/src/__tests__/model-gateway-version-discovery.test.ts index aa1d336c..c681645f 100644 --- a/packages/cli/src/__tests__/model-gateway-version-discovery.test.ts +++ b/packages/cli/src/__tests__/model-gateway-version-discovery.test.ts @@ -215,6 +215,8 @@ describe('model gateway workflows', () => { expect(qualification).toContain('OpenRouter gateways'); expect(qualification).not.toContain('kit: [typescript, python]'); expect(qualification).not.toContain('matrix.kit'); + expect(qualification).not.toContain('NPM_CONFIG_CACHE'); + expect(qualification).not.toMatch(/^\s{6}(?:npm_config_cache|PIP_CACHE_DIR):/m); expect(qualification).toContain('model-gateway-generated-projects.test.ts'); expect(qualification).toContain('model-gateway-workspace-lifecycle.test.ts'); expect(qualification).toContain('model-gateway-policy-parity.test.ts'); diff --git a/packages/cli/src/__tests__/model-gateway-workspace-lifecycle.test.ts b/packages/cli/src/__tests__/model-gateway-workspace-lifecycle.test.ts index b9bdef5c..ca8cb529 100644 --- a/packages/cli/src/__tests__/model-gateway-workspace-lifecycle.test.ts +++ b/packages/cli/src/__tests__/model-gateway-workspace-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -22,9 +23,25 @@ import { const roots: string[] = []; +async function removeTempRoot(root: string): Promise { + const resolvedRoot = path.resolve(root); + const cwd = process.cwd(); + if (cwd === resolvedRoot || cwd.startsWith(`${resolvedRoot}${path.sep}`)) { + process.chdir(os.tmpdir()); + } + await rm(resolvedRoot, { + recursive: true, + force: true, + maxRetries: process.platform === 'win32' ? 10 : 0, + retryDelay: 100, + }); +} + afterEach(async () => { vi.restoreAllMocks(); - await Promise.all(roots.splice(0).map((root) => fsExtra.remove(root))); + for (const root of roots.splice(0)) { + await removeTempRoot(root); + } }); describe('OpenRouter gateway Workspai lifecycle', () => { From 1224b189ce2cf736921d3efb274704d25060ff3b Mon Sep 17 00:00:00 2001 From: Baziar Date: Mon, 21 Sep 2026 18:13:10 -0400 Subject: [PATCH 4/4] fix(cli): keep OpenRouter gateway identity in Doctor and the workspace model --- packages/cli/CHANGELOG.md | 6 +- .../backend-framework-contract.test.ts | 24 ++++++ .../model-gateway-generated-projects.test.ts | 73 ++++++++++++++++++- packages/cli/src/doctor.ts | 52 ++++++++++--- .../src/utils/backend-framework-contract.ts | 18 ++++- .../cli/src/utils/doctor-surface-probes.ts | 2 + 6 files changed, 158 insertions(+), 17 deletions(-) diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index ae26a943..75aa13ff 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -49,8 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Qualification treats sparse workspace-run init receipts (`errorCategory: unknown` and `Stage failed with exit code 1`, including missing npm/pip excerpts) as registry infrastructure, not product failure, and retries once. -- Generated `.workspai/project.json` `contracts.consumes` is derived from the - kit `gatewayId` instead of a hardcoded OpenRouter value. +- Doctor and the workspace model keep an OpenRouter gateway as framework + `openrouter` and kind `gateway`, instead of collapsing it to generic Python + or Node.js. Python src-layout packages are no longer blocked for a missing + `src/__init__.py` when a nested package already has one. ## [0.76.0] - 2026-09-20 diff --git a/packages/cli/src/__tests__/backend-framework-contract.test.ts b/packages/cli/src/__tests__/backend-framework-contract.test.ts index 5b1e3b4c..69cfe06b 100644 --- a/packages/cli/src/__tests__/backend-framework-contract.test.ts +++ b/packages/cli/src/__tests__/backend-framework-contract.test.ts @@ -105,6 +105,30 @@ describe('backend-framework-contract', () => { confidence: 'high', source: 'kit', }); + expect( + detectBackendFrameworkFromHints({ + kitName: 'gateway.openrouter.typescript', + runtime: 'node', + framework: 'openrouter', + }) + ).toMatchObject({ + key: 'openrouter', + displayName: 'OpenRouter', + runtime: 'node', + confidence: 'high', + source: 'kit', + }); + expect( + detectBackendFrameworkFromHints({ + kitName: 'gateway.openrouter.python', + runtime: 'python', + }) + ).toMatchObject({ + key: 'openrouter', + runtime: 'python', + confidence: 'high', + source: 'kit', + }); }); it('detects backend frameworks from project manifests and markers', async () => { diff --git a/packages/cli/src/__tests__/model-gateway-generated-projects.test.ts b/packages/cli/src/__tests__/model-gateway-generated-projects.test.ts index 5d6ffb34..5d77a3de 100644 --- a/packages/cli/src/__tests__/model-gateway-generated-projects.test.ts +++ b/packages/cli/src/__tests__/model-gateway-generated-projects.test.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import { rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import fsExtra from 'fs-extra'; import * as create from '../create.js'; @@ -223,10 +223,79 @@ describe('generated OpenRouter gateway projects', () => { expect(model.projects.every((project: { kind?: string }) => project.kind === 'gateway')).toBe( true ); + expect( + model.projects.map( + (project: { + name?: string; + framework?: string; + frameworkDisplayName?: string; + runtime?: string; + }) => ({ + name: project.name, + framework: project.framework, + frameworkDisplayName: project.frameworkDisplayName, + runtime: project.runtime, + }) + ) + ).toEqual( + expect.arrayContaining([ + { + name: 'first-gateway', + framework: 'openrouter', + frameworkDisplayName: 'OpenRouter', + runtime: 'node', + }, + { + name: 'second-gateway', + framework: 'openrouter', + frameworkDisplayName: 'OpenRouter', + runtime: 'python', + }, + ]) + ); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.map((value) => String(value)).join(' ')); + }); + try { + const { runDoctor } = await import('../doctor.js'); + await runDoctor({ workspace: true, json: true }); + } finally { + logSpy.mockRestore(); + } + const doctorLine = logs.find((line) => line.trim().startsWith('{')); + expect(doctorLine).toBeTruthy(); + const doctor = JSON.parse(doctorLine ?? '{}') as { + projects?: Array<{ + name?: string; + framework?: string; + projectKind?: string; + projectArchetype?: string; + runtimeFamily?: string; + issues?: string[]; + }>; + }; + const byName = new Map((doctor.projects ?? []).map((project) => [project.name, project])); + expect(byName.get('first-gateway')).toMatchObject({ + framework: 'OpenRouter', + projectKind: 'gateway', + projectArchetype: 'service', + runtimeFamily: 'node', + }); + expect(byName.get('second-gateway')).toMatchObject({ + framework: 'OpenRouter', + projectKind: 'gateway', + projectArchetype: 'service', + runtimeFamily: 'python', + }); + expect(byName.get('second-gateway')?.issues ?? []).not.toEqual( + expect.arrayContaining([expect.stringMatching(/src\/__init__\.py/)]) + ); } finally { process.chdir(previousCwd); } - }, 90_000); + }, 180_000); it('creates and verifies a TypeScript gateway in a path that contains spaces', async () => { const kit = listModelGatewayProjectKits().find( diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts index ec206d11..28f3f76d 100644 --- a/packages/cli/src/doctor.ts +++ b/packages/cli/src/doctor.ts @@ -290,6 +290,7 @@ function contextualizeDoctorSystemChecks( type DetectedFramework = | 'Microsoft Agent Framework' | 'OpenAI Agents SDK' + | 'OpenRouter' | 'FastAPI' | 'Django' | 'Flask' @@ -358,6 +359,7 @@ type ProjectRuntimeFamily = type ProjectKind = | 'backend' | 'agent' + | 'gateway' | 'frontend' | 'desktop' | 'extension' @@ -1506,6 +1508,10 @@ function kindForFramework(framework: DetectedFramework): ProjectKind { return 'agent'; } + if (framework === 'OpenRouter') { + return 'gateway'; + } + if (framework === 'Tauri' || framework === 'Electron') { return 'desktop'; } @@ -1692,6 +1698,8 @@ function toDoctorFramework(detection: BackendFrameworkDetection): DetectedFramew return 'Microsoft Agent Framework'; case 'openai-agents': return 'OpenAI Agents SDK'; + case 'openrouter': + return 'OpenRouter'; case 'fastapi': return 'FastAPI'; case 'django': @@ -1838,13 +1846,15 @@ function applyBackendFrameworkDetection( health.projectKind = detection.key === 'microsoft-agent-framework' || detection.key === 'openai-agents' ? 'agent' - : detection.key === 'tauri' || detection.key === 'electron' - ? 'desktop' - : detection.key === 'vscode-extension' - ? 'extension' - : isGenericBackendDetection(detection) - ? 'generic' - : 'backend'; + : detection.key === 'openrouter' + ? 'gateway' + : detection.key === 'tauri' || detection.key === 'electron' + ? 'desktop' + : detection.key === 'vscode-extension' + ? 'extension' + : isGenericBackendDetection(detection) + ? 'generic' + : 'backend'; health.runtimeFamily = toDoctorRuntimeFamily(detection.runtime); } @@ -3257,6 +3267,7 @@ async function appendRuntimeAdapterProbes( const supportsRuntimeAdapterProbe = health.projectKind === 'backend' || health.projectKind === 'generic' || + health.projectKind === 'gateway' || health.projectKind === 'desktop' || health.projectKind === 'extension'; if (!supportsRuntimeAdapterProbe) { @@ -3728,7 +3739,10 @@ async function appendBuiltInBackendProbes( projectPath: string, health: ProjectHealth ): Promise { - const isBackend = health.projectKind === 'backend' || health.projectKind === 'generic'; + const isBackend = + health.projectKind === 'backend' || + health.projectKind === 'generic' || + health.projectKind === 'gateway'; if (!isBackend) { return; } @@ -4680,7 +4694,8 @@ async function checkProjectUnnormalized( if (primaryRuntime === 'python') { if ( primaryBackendDetection.key === 'microsoft-agent-framework' || - primaryBackendDetection.key === 'openai-agents' + primaryBackendDetection.key === 'openai-agents' || + primaryBackendDetection.key === 'openrouter' ) { applyBackendFrameworkDetection(health, primaryBackendDetection); } else { @@ -4696,6 +4711,8 @@ async function checkProjectUnnormalized( frameworkImport = 'agent-framework-core'; } else if (health.framework === 'OpenAI Agents SDK') { frameworkImport = 'openai-agents'; + } else if (health.framework === 'OpenRouter') { + frameworkImport = ''; } const agentRuntimeRoot = health.projectKind === 'agent' ? 'agents/primary' : undefined; @@ -4815,7 +4832,9 @@ async function checkProjectUnnormalized( if (await fsExtra.pathExists(srcPath)) { const srcInit = path.join(srcPath, '__init__.py'); - if (!(await fsExtra.pathExists(srcInit))) { + const hasPackageRoot = await fsExtra.pathExists(srcInit); + const hasNestedPackage = hasPackageRoot ? false : await srcContainsPackage(srcPath); + if (!hasPackageRoot && !hasNestedPackage) { health.modulesHealthy = false; health.missingModules.push('src/__init__.py'); } @@ -5067,6 +5086,7 @@ async function detectProjectArchetype( ): Promise { if (health.projectKind === 'extension') return 'plugin'; if (health.projectKind === 'agent') return 'application'; + if (health.projectKind === 'gateway') return 'service'; if (health.projectKind === 'platform') { const packageJson = await fsExtra .readJson(path.join(projectPath, 'package.json')) @@ -5219,7 +5239,9 @@ async function checkProject( }); } const canonicalKind = await inferWorkspaceProjectKind(projectPath); - if (canonicalKind === 'agent') { + if (canonicalKind === 'gateway') { + health.projectKind = 'gateway'; + } else if (canonicalKind === 'agent') { health.projectKind = 'agent'; } else if ( canonicalKind === 'backend' || @@ -5252,6 +5274,14 @@ async function checkProject( return health; } +async function srcContainsPackage(srcPath: string): Promise { + for (const name of await listDirectories(srcPath)) { + if (name === '__pycache__' || name.startsWith('.')) continue; + if (await fsExtra.pathExists(path.join(srcPath, name, '__init__.py'))) return true; + } + return false; +} + async function listDirectories(basePath: string): Promise { try { const entries = await fsExtra.readdir(basePath, { withFileTypes: true }); diff --git a/packages/cli/src/utils/backend-framework-contract.ts b/packages/cli/src/utils/backend-framework-contract.ts index 84c4442c..49c85e16 100644 --- a/packages/cli/src/utils/backend-framework-contract.ts +++ b/packages/cli/src/utils/backend-framework-contract.ts @@ -28,6 +28,7 @@ export type BackendRuntimeFamily = export type BackendPlatformKey = | 'microsoft-agent-framework' | 'openai-agents' + | 'openrouter' | 'fastapi' | 'django' | 'flask' @@ -142,6 +143,15 @@ const BACKEND_CONTRACTS: Record = aliases: ['openai-agents', 'openai agents', 'openai agents sdk'], kitPrefixes: ['agent.openai'], }, + openrouter: { + key: 'openrouter', + runtime: 'node', + displayName: 'OpenRouter', + supportTier: 'extended', + importStack: 'unknown', + aliases: ['openrouter'], + kitPrefixes: ['gateway.openrouter'], + }, fastapi: { key: 'fastapi', runtime: 'python', @@ -787,6 +797,10 @@ export function isAgentFrameworkPlatformKey(key: BackendPlatformKey): boolean { return key === 'microsoft-agent-framework' || key === 'openai-agents'; } +function preservesAuthoredRuntime(key: BackendPlatformKey): boolean { + return isAgentFrameworkPlatformKey(key) || key === 'openrouter'; +} + export function detectBackendFrameworkFromHints(input: { framework?: string; runtime?: string; @@ -795,7 +809,7 @@ export function detectBackendFrameworkFromHints(input: { const byKit = findByKitName(input.kitName); if (byKit !== 'unknown') { const detected = buildDetection(byKit, 'high', 'kit'); - return isAgentFrameworkPlatformKey(byKit) && input.runtime + return preservesAuthoredRuntime(byKit) && input.runtime ? { ...detected, runtime: normalizeBackendRuntimeFamily(input.runtime) } : detected; } @@ -803,7 +817,7 @@ export function detectBackendFrameworkFromHints(input: { const byFramework = normalizeBackendPlatformKey(input.framework); if (byFramework !== 'unknown') { const detected = buildDetection(byFramework, 'high', 'framework'); - return isAgentFrameworkPlatformKey(byFramework) && input.runtime + return preservesAuthoredRuntime(byFramework) && input.runtime ? { ...detected, runtime: normalizeBackendRuntimeFamily(input.runtime) } : detected; } diff --git a/packages/cli/src/utils/doctor-surface-probes.ts b/packages/cli/src/utils/doctor-surface-probes.ts index 97350e38..4fb552cf 100644 --- a/packages/cli/src/utils/doctor-surface-probes.ts +++ b/packages/cli/src/utils/doctor-surface-probes.ts @@ -46,6 +46,7 @@ export type DoctorSurfaceRuntimeFamily = (typeof DOCTOR_SURFACE_RUNTIME_FAMILIES export type DoctorSurfaceProjectKind = | 'backend' | 'agent' + | 'gateway' | 'frontend' | 'desktop' | 'extension' @@ -1322,6 +1323,7 @@ async function buildEnvContractProbe(input: SurfaceInput): Promise