diff --git a/package-lock.json b/package-lock.json index 78a41b0..04fcbdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1073,6 +1073,14 @@ "resolved": "packages/context-broker", "link": true }, + "node_modules/@fiale-plus/pi-rogue-fusion": { + "resolved": "packages/fusion", + "link": true + }, + "node_modules/@fiale-plus/pi-rogue-gateway": { + "resolved": "packages/rogue-gateway", + "link": true + }, "node_modules/@fiale-plus/pi-rogue-guardrails": { "resolved": "packages/lab/guardrails", "link": true @@ -3730,18 +3738,6 @@ "@earendil-works/pi-tui": "^0.74.0" } }, - "packages/lab/brain": { - "name": "@fiale-plus/pi-rogue-brain", - "version": "0.1.0", - "license": "MIT", - "dependencies": { - "@fiale-plus/pi-core": "^0.1.0", - "@sinclair/typebox": "^0.34.48" - }, - "peerDependencies": { - "@earendil-works/pi-coding-agent": "^0.74.0" - } - }, "packages/bundle": { "name": "@fiale-plus/pi-rogue", "version": "0.3.8", @@ -3758,9 +3754,9 @@ "@fiale-plus/pi-core": "^0.1.0", "@fiale-plus/pi-rogue-advisor": "^0.1.0", "@fiale-plus/pi-rogue-context-broker": "^0.1.0", + "@fiale-plus/pi-rogue-fusion": "^0.1.0", "@fiale-plus/pi-rogue-orchestration": "^0.1.0", - "@fiale-plus/pi-rogue-router": "^0.1.0", - "@fiale-plus/pi-rogue-fusion": "^0.1.0" + "@fiale-plus/pi-rogue-router": "^0.1.0" }, "peerDependencies": { "@earendil-works/pi-coding-agent": "^0.74.0" @@ -3780,6 +3776,14 @@ "version": "0.1.0", "license": "MIT" }, + "packages/fusion": { + "name": "@fiale-plus/pi-rogue-fusion", + "version": "0.1.0", + "license": "MIT", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "^0.74.0" + } + }, "packages/goal": { "name": "@fiale-plus/pi-goal", "version": "0.1.0", @@ -3792,26 +3796,27 @@ "@earendil-works/pi-coding-agent": "^0.74.0" } }, - "packages/lab/guardrails": { - "name": "@fiale-plus/pi-rogue-guardrails", + "packages/lab/brain": { + "name": "@fiale-plus/pi-rogue-brain", "version": "0.1.0", "license": "MIT", "dependencies": { - "@fiale-plus/pi-core": "^0.1.0" + "@fiale-plus/pi-core": "^0.1.0", + "@sinclair/typebox": "^0.34.48" }, "peerDependencies": { "@earendil-works/pi-coding-agent": "^0.74.0" } }, - "packages/orchestration": { - "name": "@fiale-plus/pi-rogue-orchestration", - "version": "0.1.11", + "packages/lab/guardrails": { + "name": "@fiale-plus/pi-rogue-guardrails", + "version": "0.1.0", "license": "MIT", + "dependencies": { + "@fiale-plus/pi-core": "^0.1.0" + }, "peerDependencies": { "@earendil-works/pi-coding-agent": "^0.74.0" - }, - "dependencies": { - "typebox": "^1.1.24" } }, "packages/lab/repo-arch": { @@ -3822,20 +3827,23 @@ "@earendil-works/pi-coding-agent": "^0.74.0" } }, - "packages/router": { - "name": "@fiale-plus/pi-rogue-router", - "version": "0.1.0", + "packages/orchestration": { + "name": "@fiale-plus/pi-rogue-orchestration", + "version": "0.1.11", "license": "MIT", + "dependencies": { + "typebox": "^1.1.24" + }, "peerDependencies": { "@earendil-works/pi-coding-agent": "^0.74.0" } }, - "node_modules/@fiale-plus/pi-rogue-fusion": { - "resolved": "packages/fusion", - "link": true + "packages/rogue-gateway": { + "version": "0.1.0", + "license": "MIT" }, - "packages/fusion": { - "name": "@fiale-plus/pi-rogue-fusion", + "packages/router": { + "name": "@fiale-plus/pi-rogue-router", "version": "0.1.0", "license": "MIT", "peerDependencies": { diff --git a/packages/rogue-gateway/.env.example b/packages/rogue-gateway/.env.example new file mode 100644 index 0000000..aa659c4 --- /dev/null +++ b/packages/rogue-gateway/.env.example @@ -0,0 +1,19 @@ +# Portkey Gateway OSS / OpenAI-compatible base URL +PORTKEY_BASE_URL=http://127.0.0.1:8787/v1 + +# Optional: only needed if your Portkey deployment requires auth. +PORTKEY_API_KEY= +PORTKEY_AUTH_HEADER=x-portkey-api-key +PORTKEY_AUTH_SCHEME=Bearer +PORTKEY_EXTRA_HEADERS_JSON={} + +# Rogue config path defaults (override if you keep config elsewhere) +PI_ROGUE_CONFIG_PATH=/Users/pavel/.pi/agent/pi-rogue/config.json +PI_ROGUE_ROUTER_CONFIG_PATH=/Users/pavel/.pi/agent/pi-rogue/router/config.json + +# Manual experiment knobs +PI_ROGUE_PROFILE=local-smart +PI_ROGUE_ROLE=smart +RAW_INPUT_TOKENS=82000 +FORWARDED_INPUT_TOKENS=2400 +EXPECTED_OUTPUT_TOKENS=900 diff --git a/packages/rogue-gateway/COMMODITY_GATEWAY_SUBSTRATE_CHECK.md b/packages/rogue-gateway/COMMODITY_GATEWAY_SUBSTRATE_CHECK.md new file mode 100644 index 0000000..a68b41b --- /dev/null +++ b/packages/rogue-gateway/COMMODITY_GATEWAY_SUBSTRATE_CHECK.md @@ -0,0 +1,36 @@ +# Commodity Gateway Substrate Check (Issue #207) + +## 1) Which parts would be duplicated if Rogue built everything from scratch? +- Auth/key tenanting + provider credential flows +- OpenAI-compatible request/response normalization and protocol compatibility matrix +- Retry/circuit-breaker/failover logic +- Rate limiting, budget enforcement, and usage/cost accounting logic +- Caching, retries, load balancing, and failover orchestration +- Operational observability layers (dashboards, hooks, request tracing) + +## 2) Which parts can LiteLLM/Portkey own? +- Commodity gateway concerns above (key handling, retries, policy config, routing policies, budget/rate-limit enforcement, usage telemetry, failover/fallbacks, etc.) +- In practice, these are best delegated to avoid reimplementation in Rogue and keep this package focused. + +## 3) Can Rogue sit in front of a generic OpenAI-compatible substrate? +**Yes.** `OpenAICompatibleSubstrate` intentionally implements a minimal adapter that calls `GET /models` and `POST /chat/completions` over a configurable base URL and optional API key, so Rogue can evaluate routing decisions without assuming a specific gateway vendor. + +## 4) Does Rogue require substrate-specific code for the tokenomics/context logic? +**No.** Routing/tokenomics decisions are computed in `src/planner.ts` using normalized `GatewayAsset` metadata and context-profile + task heuristics (no vendor-specific branch logic). + +## 5) What minimal substrate interface was enough? +The minimal interface was: +- `listModels()` +- `callChat(req)` +- optional `estimateCost(req)` +- optional `getUsage(runId)` + +This has been sufficient for deterministic planning and explainable alternatives in the spike. + +## 6) What would be painful to outsource? +- Rogue’s own policy semantics: local-first preference, context-lens scoring, deterministic alternatives/reasons, and explanation-ready savings calculations. +- If these are moved into external gateways, we lose the same decision traceability and control Rogue needs for policy-level policy evolution. + +## Notes +- This spike does not include LiteLLM/Portkey as hard dependencies (matching the requested scope). +- Live LiteLLM/Portkey runtime checks are out-of-scope for this PR and remain optional follow-up validations. diff --git a/packages/rogue-gateway/PORTKEY_SELF_HOST_EXPERIMENT.md b/packages/rogue-gateway/PORTKEY_SELF_HOST_EXPERIMENT.md new file mode 100644 index 0000000..29b70af --- /dev/null +++ b/packages/rogue-gateway/PORTKEY_SELF_HOST_EXPERIMENT.md @@ -0,0 +1,66 @@ +# Portkey self-host experiment for Rogue Gateway spike + +This repository cannot launch a live Portkey instance by itself. The experiment is therefore prepared as a **repo-local, env-driven manual run** against an already-running Portkey/OpenAI-compatible endpoint. + +## Outcome statement + +This spike is a **validated spike / pilot setup**, not target architecture yet. + +## What this experiment measures + +- `raw_forward` +- `typed_lens` +- `lookup_compress` + +For each mode, Rogue resolves `pi-dedicated` through the Pi Rogue router config to the configured upstream GPT target, then measures the routed request against the live endpoint. + +## Required runtime configuration + +Set these in your shell or runtime environment: + +```sh +export PORTKEY_BASE_URL="http://127.0.0.1:8787/v1" +export PORTKEY_API_KEY="" # optional for self-host; set if your deployment requires auth +export PORTKEY_AUTH_HEADER="x-portkey-api-key" # optional +export PORTKEY_AUTH_SCHEME="Bearer" # optional +export PI_ROGUE_CONFIG_PATH="$HOME/.pi/agent/pi-rogue/config.json" +export PI_ROGUE_ROUTER_CONFIG_PATH="$HOME/.pi/agent/pi-rogue/router/config.json" +``` + +Optional knobs: + +```sh +export PI_ROGUE_PROFILE="local-smart" +export PI_ROGUE_ROLE="smart" +export RAW_INPUT_TOKENS="82000" +export FORWARDED_INPUT_TOKENS="2400" +export EXPECTED_OUTPUT_TOKENS="900" +``` + +## Run command + +From repo root: + +```sh +cp packages/rogue-gateway/.env.example packages/rogue-gateway/.env +# optionally edit packages/rogue-gateway/.env to match your local paths / auth + +docker compose --env-file packages/rogue-gateway/.env -f packages/rogue-gateway/docker-compose.yml up -d +npm run measure:portkey --workspace @fiale-plus/pi-rogue-gateway +``` + +## Expected output + +A JSON report containing: + +- resolved profile/role +- `pi-dedicated` requested model +- upstream model target from Pi config +- per-mode route decision +- upstream usage/tokens returned by the live endpoint + +## What is still missing for live execution + +- A real Portkey/OpenAI-compatible base URL +- A running Portkey self-host instance (or other compatible endpoint) +- Optional auth only if your deployment requires it diff --git a/packages/rogue-gateway/README.md b/packages/rogue-gateway/README.md new file mode 100644 index 0000000..4ba790c --- /dev/null +++ b/packages/rogue-gateway/README.md @@ -0,0 +1,61 @@ +# @fiale-plus/pi-rogue-gateway + +Spike package for the **Rogue Gateway** prototype. + +This package is intentionally scoped to three concerns: + +- gateway substrate boundary (`GatewaySubstrate`, `substrate-mock`, `substrate-openai-compatible`) +- tokenomics planner (`quoteRoute`, `RoutePlanChoice`, `QuoteResult`, route selection) +- event ledger hooks for run-level explainability (`events.ts`) + +The code is intentionally conservative and deterministic, designed to prove that +Rogue can own **routing/policy/tokenomics** without reimplementing commodity +provider/gateway plumbing. + +## Commodity Gateway Substrate Check + +Issue #207 requires an explicit check against LiteLLM/Portkey-style commodities. This spike’s scope is intentionally narrow and the results are: + +1. **Which parts would be duplicated if Rogue built everything from scratch?** + - Provider auth/key/tenant wiring, protocol drift handling (OpenAI-compatible endpoints/headers), retries, circuit-breaking, and fallbacks. + - Budget/rate-limit enforcement, spend/usage accounting, audit trails, and production-grade observability. + - Retry policy DSLs, multi-endpoint failover/load-balancing, and gateway caching/normalization layers. + +2. **Which parts can LiteLLM/Portkey own?** + - Those commodity runtime concerns above (routing rules, retries, budgets, key management, provider fan-out/failover, cost metrics, dashboarding). + - In a full product shape, these can remain delegated so Rogue does not become another gateway implementation. + +3. **Can Rogue sit in front of a generic OpenAI-compatible substrate?** + - **Yes (within this spike).** + - `OpenAICompatibleSubstrate` in `src/substrate-openai-compatible.ts` only assumes a base URL + optional API key and `/models`, `/v1/models`, and `/chat/completions`-style flows. + - Local smoke tests verify model listing and chat-call plumbing against a mock compatible endpoint. + +4. **Does Rogue require substrate-specific code for tokenomics/context logic?** + - **No.** + - Routing/tokenomics policy is driven by the provided `GatewayAsset`/candidate metadata (`quoteRoute` in `src/planner.ts`) and is decoupled from any provider API details. + - The planner operates on normalized assets + route heuristics (`local_first`, raw/sealed/context-lens variants), not on vendor SDK semantics. + +5. **What minimal substrate interface was enough?** + - `GatewaySubstrate` (`src/substrate.ts`) with `listModels`, `callChat`, optional `estimateCost`, and optional `getUsage`. + - This was sufficient to validate deterministic planning plus route observability without hard binding to any vendor. + +6. **What is painful to outsource?** + - Anything tied to Rogue’s own product semantics: task/profile-specific scoring, local-first policy, context-lens savings accounting, and deterministic explainable route reasons. + - Those are explicitly implemented in Rogue to keep behavior explainable, while gateway plumbing remains external. + +### Explicitly out-of-scope for this spike + +- No hard dependency on LiteLLM or Portkey in-package (consistent with the issue request). +- No production gateway execution path (no claim to own full retry/key/dashboard/resilience stack). +- Optional non-blocking manual checks remain pending (e.g., live LiteLLM Proxy and Portkey runs) and were not a CI requirement for this PR. + +### Config / measurement hooks + +- `PI_ROGUE_CONFIG_PATH` and `PI_ROGUE_ROUTER_CONFIG_PATH` can point the spike at local Pi Rogue config files. +- `measurePiDedicatedModes()` in `src/measurement.ts` reports raw-forward, typed-lens, and lookup-compress token economics while resolving the pi-dedicated alias to the configured upstream GPT target. +- `npm run measure:portkey --workspace @fiale-plus/pi-rogue-gateway` runs the env-driven manual measurement wrapper in `src/measure.ts`. +- `docker-compose.yml` and `.env.example` provide the tiny self-host Portkey experiment draft. +- Self-host/manual experiment steps are documented in `PORTKEY_SELF_HOST_EXPERIMENT.md`. + +Full written answers for the ticket’s required Commodity Gateway Substrate Check (including the 1..6 questions) are in: +`packages/rogue-gateway/COMMODITY_GATEWAY_SUBSTRATE_CHECK.md`. diff --git a/packages/rogue-gateway/docker-compose.yml b/packages/rogue-gateway/docker-compose.yml new file mode 100644 index 0000000..a331ab8 --- /dev/null +++ b/packages/rogue-gateway/docker-compose.yml @@ -0,0 +1,9 @@ +services: + portkey: + image: portkeyai/gateway:latest + container_name: rogue-portkey-gateway + ports: + - "8787:8787" + env_file: + - .env + restart: unless-stopped diff --git a/packages/rogue-gateway/package.json b/packages/rogue-gateway/package.json new file mode 100644 index 0000000..38a9dd6 --- /dev/null +++ b/packages/rogue-gateway/package.json @@ -0,0 +1,22 @@ +{ + "name": "@fiale-plus/pi-rogue-gateway", + "version": "0.1.0", + "description": "Standalone Rogue tokenomics and substrate routing prototype module.", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "check": "tsc -p ../../tsconfig.json --noEmit", + "test": "cd ../.. && vitest run packages/rogue-gateway/src/*.test.ts", + "measure:portkey": "tsx src/measure.ts" + }, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src", + "README.md", + "package.json" + ] +} diff --git a/packages/rogue-gateway/src/default-assets.ts b/packages/rogue-gateway/src/default-assets.ts new file mode 100644 index 0000000..5395d67 --- /dev/null +++ b/packages/rogue-gateway/src/default-assets.ts @@ -0,0 +1,54 @@ +import type { AssetRegistry } from "./types.js"; + +export const defaultAssetRegistry: AssetRegistry = { + assets: { + "local.qwen35": { + class: "owned_capacity", + substrate: "openai-compatible.local", + model: "qwen3.6-35b-a3b-q4", + contextWindow: 128000, + observedTps: 31, + inputCostPerMTok: 0, + cachedInputCostPerMTok: 0, + outputCostPerMTok: 0, + privacy: "local", + qualityTier: "mid", + tags: ["coding", "debug", "cheap-broad-scan"], + }, + "remote.cheap": { + class: "metered_api", + substrate: "mock", + model: "cheap-remote", + contextWindow: 128000, + observedTps: 80, + inputCostPerMTok: 0.2, + cachedInputCostPerMTok: 0.02, + outputCostPerMTok: 1.0, + privacy: "remote", + qualityTier: "cheap", + }, + "remote.premium": { + class: "metered_api", + substrate: "mock", + model: "premium-frontier", + contextWindow: 256000, + observedTps: 45, + inputCostPerMTok: 5.0, + cachedInputCostPerMTok: 0.5, + outputCostPerMTok: 30.0, + privacy: "remote", + qualityTier: "frontier", + }, + "subscription.smart": { + class: "subscription_quota", + substrate: "mock", + model: "subscription-smart", + contextWindow: 128000, + observedTps: 60, + quotaRemaining: 20, + quotaResetHours: 8, + privacy: "remote", + qualityTier: "smart", + }, + }, +}; diff --git a/packages/rogue-gateway/src/events.ts b/packages/rogue-gateway/src/events.ts new file mode 100644 index 0000000..a3a8131 --- /dev/null +++ b/packages/rogue-gateway/src/events.ts @@ -0,0 +1,53 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import type { EventRecord } from "./types.js"; + +const DEFAULT_EVENT_SCHEMA = "pi-rogue-gateway.event.v1" as const; + +export const GATEWAY_EVENT_SCHEMA = DEFAULT_EVENT_SCHEMA; + +let counter = 0; + +export function newEventId(prefix = "evt"): string { + counter += 1; + return `${prefix}_${Date.now().toString(36)}_${String(counter).padStart(4, "0")}`; +} + +export function appendEvent(path: string, event: Omit): EventRecord { + const resolved = resolve(path); + const nowIso = new Date().toISOString(); + const stamped: EventRecord = { + eventId: newEventId(), + timestamp: nowIso, + ...event, + data: { ...event.data }, + }; + + const payload = { + schema: DEFAULT_EVENT_SCHEMA, + ...stamped, + }; + + mkdirSync(dirname(resolved), { recursive: true }); + writeFileSync(resolved, `${JSON.stringify(payload)}\n`, { flag: "a" }); + return payload; +} + +export function readEvents(path: string): EventRecord[] { + const resolved = resolve(path); + + try { + const data = readFileSync(resolved, "utf8"); + return data + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => { + const parsed = JSON.parse(line) as EventRecord & { schema?: string }; + const { schema: _schema, ...event } = parsed; + return event; + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} diff --git a/packages/rogue-gateway/src/index.ts b/packages/rogue-gateway/src/index.ts new file mode 100644 index 0000000..07fa8b5 --- /dev/null +++ b/packages/rogue-gateway/src/index.ts @@ -0,0 +1,11 @@ +export * from "./types.js"; +export * from "./substrate.js"; +export * from "./substrate-mock.js"; +export * from "./substrate-openai-compatible.js"; +export * from "./substrate-portkey.js"; +export * from "./default-assets.js"; +export * from "./planner.js"; +export * from "./events.js"; +export * from "./model-routing.js"; +export * from "./measurement.js"; +export * from "./server.js"; diff --git a/packages/rogue-gateway/src/measure.ts b/packages/rogue-gateway/src/measure.ts new file mode 100644 index 0000000..ec3cab7 --- /dev/null +++ b/packages/rogue-gateway/src/measure.ts @@ -0,0 +1,35 @@ +import { measurePiDedicatedModes } from "./measurement.js"; + +function requireEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +async function main(): Promise { + requireEnv("PORTKEY_BASE_URL"); + + const report = await measurePiDedicatedModes({ + env: process.env, + profile: process.env.PI_ROGUE_PROFILE?.trim() || "local-smart", + role: process.env.PI_ROGUE_ROLE?.trim() || "smart", + request: { + profile: process.env.PI_ROGUE_REQUEST_PROFILE?.trim() || "local-first-economy", + taskKind: process.env.PI_ROGUE_TASK_KIND?.trim() || "manual_portkey_measurement", + rawInputTokensApprox: Number(process.env.RAW_INPUT_TOKENS ?? 82_000), + forwardedInputTokensApprox: Number(process.env.FORWARDED_INPUT_TOKENS ?? 2_400), + expectedOutputTokensApprox: Number(process.env.EXPECTED_OUTPUT_TOKENS ?? 900), + contextPolicy: process.env.PI_ROGUE_CONTEXT_POLICY?.trim() || "typed_lens", + candidateAssets: ["local.qwen35", "remote.cheap", "remote.premium", "subscription.smart"], + }, + }); + + console.log(JSON.stringify(report, null, 2)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/packages/rogue-gateway/src/measurement.test.ts b/packages/rogue-gateway/src/measurement.test.ts new file mode 100644 index 0000000..fdfc71d --- /dev/null +++ b/packages/rogue-gateway/src/measurement.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { measurePiDedicatedModes } from "./measurement.js"; +import type { PiRogueRouterConfig } from "./model-routing.js"; +import { SubstrateMock } from "./substrate-mock.js"; + +const fixtureRouterConfig: PiRogueRouterConfig = { + activeProfile: "local-smart", + profiles: { + "local-smart": { + smart: "openai-codex/gpt-5.5", + worker: "llamacpp-qwen-unsloth/qwen3.6-35b-a3b-q4-k-m", + reviewer: "openai-codex/gpt-5.5", + teacher: "openai-codex/gpt-5.5", + explore: "llamacpp-qwen-unsloth/qwen3.6-35b-a3b-q4-k-m", + debug_diagnose: "openai-codex/gpt-5.5", + review: "openai-codex/gpt-5.5", + verify: "llamacpp-qwen-unsloth/qwen3.6-35b-a3b-q4-k-m", + }, + }, +}; + +describe("pi-dedicated mode measurements", () => { + it("maps the pi-dedicated request to a configured GPT upstream target and reports mode token deltas", async () => { + const report = await measurePiDedicatedModes({ + routerConfig: fixtureRouterConfig, + profile: "local-smart", + role: "smart", + substrate: new SubstrateMock("substrate-mock", { + models: [{ id: "openai-codex/gpt-5.5", object: "model" }], + }), + request: { + profile: "local-first-economy", + taskKind: "coding_debug", + rawInputTokensApprox: 82_000, + forwardedInputTokensApprox: 2_400, + expectedOutputTokensApprox: 900, + contextPolicy: "typed_lens", + candidateAssets: ["local.qwen35", "remote.cheap", "remote.premium", "subscription.smart"], + }, + }); + + expect(report.requestedModel).toBe("pi-dedicated"); + expect(report.upstreamModel).toBe("openai-codex/gpt-5.5"); + expect(report.modes.map((mode) => mode.mode)).toEqual([ + "raw_forward", + "typed_lens", + "lookup_compress", + ]); + + const rawForward = report.modes[0]; + const typedLens = report.modes[1]; + const lookupCompress = report.modes[2]; + + expect(rawForward.request.forwardedInputTokensApprox).toBe(82_000); + expect(typedLens.request.forwardedInputTokensApprox).toBe(2_400); + expect(lookupCompress.request.forwardedInputTokensApprox).toBeLessThan(typedLens.request.forwardedInputTokensApprox ?? 0); + expect(rawForward.quote.selected.asset).toBeDefined(); + expect(rawForward.quote.selected.route).toMatch(/^(local_first_typed_lens|cheap_remote_typed_lens|subscription_hard_call|premium_sealed_packet|premium_raw_oracle_eval_only)$/); + expect(rawForward.chat.usage?.total_tokens).toBeGreaterThan(typedLens.chat.usage?.total_tokens ?? 0); + expect(typedLens.chat.usage?.total_tokens).toBeLessThan(lookupCompress.chat.usage?.total_tokens ?? 0); + }); +}); diff --git a/packages/rogue-gateway/src/measurement.ts b/packages/rogue-gateway/src/measurement.ts new file mode 100644 index 0000000..0124dcb --- /dev/null +++ b/packages/rogue-gateway/src/measurement.ts @@ -0,0 +1,135 @@ +import { defaultAssetRegistry } from "./default-assets.js"; +import { createRoutedPortkeyGatewaySubstrate, loadPiRogueRouterConfig, resolveRouterModelTarget, PI_DEDICATED_MODEL_ALIAS, type PiRogueRouterConfig } from "./model-routing.js"; +import { quoteRoute } from "./planner.js"; +import type { AssetRegistry, QuoteRequest, QuoteResult } from "./types.js"; +import type { GatewaySubstrate, SubstrateChatResult } from "./substrate.js"; + +export type MeasurementMode = "raw_forward" | "typed_lens" | "lookup_compress"; + +export interface PiDedicatedModeMeasurement { + mode: MeasurementMode; + request: QuoteRequest; + quote: QuoteResult; + resolvedModel: string; + chat: SubstrateChatResult; +} + +export interface PiDedicatedMeasurementReport { + profile: string; + role: string; + requestedModel: string; + upstreamModel: string; + modes: PiDedicatedModeMeasurement[]; +} + +export interface MeasurePiDedicatedModesOptions { + routerConfig?: PiRogueRouterConfig; + routerConfigPath?: string; + profile?: string; + role?: string; + request: QuoteRequest; + registry?: AssetRegistry; + substrate?: GatewaySubstrate; + env?: NodeJS.ProcessEnv; +} + +function modeRequest(request: QuoteRequest, mode: MeasurementMode): QuoteRequest { + switch (mode) { + case "raw_forward": + return { + ...request, + contextPolicy: "raw_forward", + forwardedInputTokensApprox: request.rawInputTokensApprox, + }; + case "typed_lens": + return { + ...request, + contextPolicy: "typed_lens", + }; + case "lookup_compress": + return { + ...request, + contextPolicy: "typed_lens", + forwardedInputTokensApprox: Math.max(1, Math.floor(request.forwardedInputTokensApprox * 0.5)), + }; + } +} + +function modeMessages(request: QuoteRequest, mode: MeasurementMode): unknown[] { + const raw = Math.max(1, Math.floor(request.rawInputTokensApprox)); + const forwarded = Math.max(1, Math.floor(request.forwardedInputTokensApprox)); + const compressed = Math.max(1, Math.floor(forwarded * 0.5)); + const rawPayload = "x".repeat(Math.min(2048, raw)); + const forwardedPayload = "x".repeat(Math.min(1024, forwarded)); + const compressedPayload = "x".repeat(Math.min(512, compressed)); + + switch (mode) { + case "raw_forward": + return [ + { role: "system", content: "raw-forward measurement" }, + { role: "assistant", content: `carried-context:${rawPayload}` }, + { role: "user", content: "measure pi-dedicated" }, + ]; + case "typed_lens": + return [ + { role: "system", content: "typed-lens measurement" }, + { role: "user", content: `lens:${forwardedPayload}` }, + ]; + case "lookup_compress": + return [ + { role: "system", content: "lookup-compress measurement" }, + { role: "user", content: "lookup context" }, + { role: "assistant", content: `compressed:${compressedPayload}` }, + { role: "user", content: "measure pi-dedicated" }, + ]; + } +} + +export async function measurePiDedicatedModes( + options: MeasurePiDedicatedModesOptions, +): Promise { + const routerConfig = options.routerConfig ?? (await loadPiRogueRouterConfig(options.routerConfigPath)); + const resolvedTarget = resolveRouterModelTarget(routerConfig, { + profile: options.profile, + role: options.role, + requestedModel: PI_DEDICATED_MODEL_ALIAS, + }); + const registry = options.registry ?? defaultAssetRegistry; + const substrate = + options.substrate ?? + (await createRoutedPortkeyGatewaySubstrate({ + routerConfig, + routerConfigPath: options.routerConfigPath, + profile: options.profile, + role: options.role, + env: options.env, + })); + + const modes: MeasurementMode[] = ["raw_forward", "typed_lens", "lookup_compress"]; + return { + profile: resolvedTarget.profile, + role: resolvedTarget.role, + requestedModel: PI_DEDICATED_MODEL_ALIAS, + upstreamModel: resolvedTarget.upstreamModel, + modes: await Promise.all(modes.map(async (mode) => { + const request = modeRequest(options.request, mode); + const quote = quoteRoute({ request, registry }); + const chat = await substrate.callChat({ + model: PI_DEDICATED_MODEL_ALIAS, + messages: modeMessages(options.request, mode), + metadata: { + measurementMode: mode, + requestedModel: PI_DEDICATED_MODEL_ALIAS, + routedUpstreamModel: resolvedTarget.upstreamModel, + }, + }); + return { + mode, + request, + quote, + resolvedModel: resolvedTarget.upstreamModel, + chat, + }; + })), + }; +} diff --git a/packages/rogue-gateway/src/model-routing.test.ts b/packages/rogue-gateway/src/model-routing.test.ts new file mode 100644 index 0000000..b6fc18a --- /dev/null +++ b/packages/rogue-gateway/src/model-routing.test.ts @@ -0,0 +1,218 @@ +import { createServer } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AddressInfo } from "node:net"; +import { describe, expect, it } from "vitest"; + +import { SubstrateMock } from "./substrate-mock.js"; +import { createRoutedGatewaySubstrate, createRoutedPortkeyGatewaySubstrate, loadPiRogueRouterConfig, resolveRouterModelTarget, type PiRogueRouterConfig } from "./model-routing.js"; + +const fixtureRouterConfig: PiRogueRouterConfig = { + activeProfile: "fusion-smart", + profiles: { + "fusion-smart": { + worker: "fusion/opencode-go-qwen-deepseek-gpt55", + smart: "fusion/opencode-go-qwen-deepseek-gpt55", + teacher: "fusion/opencode-go-qwen-deepseek-gpt55", + reviewer: "fusion/opencode-go-qwen-deepseek-gpt55", + explore: "fusion/opencode-go-qwen-deepseek-gpt55", + debug_diagnose: "fusion/opencode-go-qwen-deepseek-gpt55", + review: "fusion/opencode-go-qwen-deepseek-gpt55", + verify: "fusion/opencode-go-qwen-deepseek-gpt55", + }, + "local-smart": { + worker: "llamacpp-qwen-unsloth/qwen3.6-35b-a3b-ud-q4-k-m", + smart: "openai-codex/gpt-5.5", + teacher: "openai-codex/gpt-5.5", + reviewer: "openai-codex/gpt-5.5", + explore: "llamacpp-qwen-unsloth/qwen3.6-35b-a3b-ud-q4-k-m", + debug_diagnose: "openai-codex/gpt-5.5", + review: "openai-codex/gpt-5.5", + verify: "llamacpp-qwen-unsloth/qwen3.6-35b-a3b-ud-q4-k-m", + }, + }, +}; + +describe("model routing", () => { + it("maps the active Pi profile to the upstream GPT target from config", () => { + const selected = resolveRouterModelTarget(fixtureRouterConfig, { + profile: "local-smart", + role: "smart", + requestedModel: "pi-dedicated", + }); + + expect(selected).toMatchObject({ + profile: "local-smart", + role: "smart", + requestedModel: "pi-dedicated", + upstreamModel: "openai-codex/gpt-5.5", + source: "profile-role", + }); + }); + + it("forwards chat calls to the configured upstream GPT model target", async () => { + const upstream = new SubstrateMock("substrate-mock", { + models: [{ id: "openai-codex/gpt-5.5", object: "model" }], + }); + + const routed = await createRoutedGatewaySubstrate(upstream, { + routerConfig: fixtureRouterConfig, + profile: "local-smart", + role: "smart", + }); + + const response = await routed.callChat({ + model: "pi-dedicated", + messages: [{ role: "user", content: "measure token modes" }], + }); + + expect(response.ok).toBe(true); + expect(response.model).toBe("openai-codex/gpt-5.5"); + expect(response.content).toMatchObject({ role: "assistant", content: "mock:openai-codex/gpt-5.5" }); + }); + + it("reads router config from the explicit env-config path", async () => { + const workdir = mkdtempSync(join(tmpdir(), "rogue-router-config-")); + const rootConfigPath = join(workdir, "config.json"); + const routerConfigPath = join(workdir, "router.config.json"); + + writeFileSync(rootConfigPath, JSON.stringify({ router: { config: routerConfigPath } }, null, 2)); + writeFileSync(routerConfigPath, JSON.stringify(fixtureRouterConfig, null, 2)); + + const prevRoot = process.env.PI_ROGUE_CONFIG_PATH; + const prevRouter = process.env.PI_ROGUE_ROUTER_CONFIG_PATH; + process.env.PI_ROGUE_CONFIG_PATH = rootConfigPath; + process.env.PI_ROGUE_ROUTER_CONFIG_PATH = routerConfigPath; + + try { + const loaded = await loadPiRogueRouterConfig(); + expect(loaded.activeProfile).toBe("fusion-smart"); + expect(loaded.profiles["local-smart"]?.smart).toBe("openai-codex/gpt-5.5"); + } finally { + if (typeof prevRoot === "undefined") { + delete process.env.PI_ROGUE_CONFIG_PATH; + } else { + process.env.PI_ROGUE_CONFIG_PATH = prevRoot; + } + + if (typeof prevRouter === "undefined") { + delete process.env.PI_ROGUE_ROUTER_CONFIG_PATH; + } else { + process.env.PI_ROGUE_ROUTER_CONFIG_PATH = prevRouter; + } + + rmSync(workdir, { recursive: true, force: true }); + } + }); + + it("creates an env-driven Portkey-compatible routed gateway and maps pi-dedicated to the configured GPT model", async () => { + const requests: Array<{ method?: string; path: string; body?: string }> = []; + + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const body = await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("error", reject); + req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); + requests.push({ method: req.method, path: url.pathname, body }); + + if (req.method === "GET" && url.pathname === "/models") { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + return; + } + + if (req.method === "GET" && url.pathname === "/v1/models") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ data: [{ id: "openai-codex/gpt-5.5", object: "model" }] })); + return; + } + + if (req.method === "POST" && url.pathname === "/chat/completions") { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + return; + } + + if (req.method === "POST" && url.pathname === "/v1/chat/completions") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ + id: "chatcmpl-compat-1", + model: "openai-codex/gpt-5.5", + choices: [{ message: { role: "assistant", content: "ok" } }], + })); + return; + } + + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: `unexpected ${req.method} ${url.pathname}` })); + }); + + const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)); + }); + + const prev = { + PORTKEY_BASE_URL: process.env.PORTKEY_BASE_URL, + PORTKEY_API_KEY: process.env.PORTKEY_API_KEY, + PORTKEY_AUTH_HEADER: process.env.PORTKEY_AUTH_HEADER, + PORTKEY_EXTRA_HEADERS_JSON: process.env.PORTKEY_EXTRA_HEADERS_JSON, + }; + + process.env.PORTKEY_BASE_URL = `http://127.0.0.1:${port}`; + process.env.PORTKEY_API_KEY = "pk_test_123"; + process.env.PORTKEY_AUTH_HEADER = "x-portkey-api-key"; + process.env.PORTKEY_EXTRA_HEADERS_JSON = JSON.stringify({ "x-portkey-workspace-id": "ws_test" }); + + const rootWorkdir = mkdtempSync(join(tmpdir(), "rogue-router-config-")); + const rootConfigPath = join(rootWorkdir, "config.json"); + const routerConfigPath = join(rootWorkdir, "router.config.json"); + writeFileSync(rootConfigPath, JSON.stringify({ router: { config: routerConfigPath } }, null, 2)); + writeFileSync(routerConfigPath, JSON.stringify(fixtureRouterConfig, null, 2)); + + const prevRoot = process.env.PI_ROGUE_CONFIG_PATH; + const prevRouter = process.env.PI_ROGUE_ROUTER_CONFIG_PATH; + process.env.PI_ROGUE_CONFIG_PATH = rootConfigPath; + process.env.PI_ROGUE_ROUTER_CONFIG_PATH = routerConfigPath; + + try { + const routed = await createRoutedPortkeyGatewaySubstrate({ profile: "local-smart", role: "smart" }); + const response = await routed.callChat({ + model: "pi-dedicated", + messages: [{ role: "user", content: "measure token modes" }], + }); + + expect(response.ok).toBe(true); + expect(response.model).toBe("openai-codex/gpt-5.5"); + expect(requests.some((entry) => entry.path === "/v1/chat/completions" && entry.body?.includes('"model":"openai-codex/gpt-5.5"'))).toBe(true); + } finally { + server.close(); + + if (typeof prevRoot === "undefined") { + delete process.env.PI_ROGUE_CONFIG_PATH; + } else { + process.env.PI_ROGUE_CONFIG_PATH = prevRoot; + } + + if (typeof prevRouter === "undefined") { + delete process.env.PI_ROGUE_ROUTER_CONFIG_PATH; + } else { + process.env.PI_ROGUE_ROUTER_CONFIG_PATH = prevRouter; + } + + for (const [key, value] of Object.entries(prev)) { + if (typeof value === "undefined") { + delete process.env[key as keyof NodeJS.ProcessEnv]; + } else { + process.env[key as keyof NodeJS.ProcessEnv] = value; + } + } + + rmSync(rootWorkdir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/rogue-gateway/src/model-routing.ts b/packages/rogue-gateway/src/model-routing.ts new file mode 100644 index 0000000..7c25d06 --- /dev/null +++ b/packages/rogue-gateway/src/model-routing.ts @@ -0,0 +1,227 @@ +import { readFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { homedir } from "node:os"; + +import type { + GatewaySubstrate, + SubstrateChatRequest, + SubstrateChatResult, + SubstrateCostQuote, + SubstrateModel, + SubstrateUsage, +} from "./substrate.js"; +import { PortkeyCompatibleSubstrate } from "./substrate-portkey.js"; + +export interface PiRogueRouterProfileConfig { + [role: string]: string | undefined; +} + +export interface PiRogueRouterConfig { + enabled?: boolean; + mode?: string; + activeProfile?: string; + profileOrder?: string[]; + profiles: Record; +} + +export interface PiRogueRootConfig { + router?: { + enabled?: boolean; + activeProfile?: string; + config?: string; + }; + storage?: { + root?: string; + }; +} + +export interface ModelRoutingSelection { + profile: string; + role: string; + requestedModel: string; + upstreamModel: string; + source: "profile-role" | "profile-fallback" | "requested-model"; +} + +export interface RoutedGatewayOptions { + profile?: string; + role?: string; +} + +export interface RoutedGatewayFactoryOptions extends RoutedGatewayOptions { + routerConfig?: PiRogueRouterConfig; + routerConfigPath?: string; +} + +export const PI_DEDICATED_MODEL_ALIAS = "pi-dedicated" as const; +export const DEFAULT_PI_ROGUE_ROOT = resolve(homedir(), ".pi", "agent", "pi-rogue"); +export const DEFAULT_PI_ROGUE_CONFIG_PATH = join(DEFAULT_PI_ROGUE_ROOT, "config.json"); + +function resolvePiRogueConfigPathFromEnv(env: NodeJS.ProcessEnv = process.env): string { + return env.PI_ROGUE_CONFIG_PATH?.trim() || DEFAULT_PI_ROGUE_CONFIG_PATH; +} + +function resolvePiRogueRouterConfigPathFromEnv(env: NodeJS.ProcessEnv = process.env): string { + return env.PI_ROGUE_ROUTER_CONFIG_PATH?.trim() + || join(dirname(resolvePiRogueConfigPathFromEnv(env)), "router", "config.json"); +} + +async function readJson(path: string): Promise { + const raw = await readFile(path, "utf8"); + return JSON.parse(raw) as T; +} + +function resolvePath(basePath: string, maybeRelativePath: string): string { + if (isAbsolute(maybeRelativePath)) return maybeRelativePath; + return resolve(dirname(basePath), maybeRelativePath); +} + +export async function loadPiRogueRootConfig(configPath = resolvePiRogueConfigPathFromEnv()): Promise { + return await readJson(configPath); +} + +export async function loadPiRogueRouterConfig(configPath?: string): Promise { + const candidatePath = configPath ?? resolvePiRogueRouterConfigPathFromEnv(); + const directConfig = await readJson & PiRogueRootConfig>(candidatePath); + + if (directConfig && typeof directConfig === "object" && "profiles" in directConfig) { + return directConfig as PiRogueRouterConfig; + } + + const root = directConfig as PiRogueRootConfig; + const routerPath = root.router?.config + ? resolvePath(candidatePath, root.router.config) + : resolvePiRogueRouterConfigPathFromEnv({ ...process.env, PI_ROGUE_CONFIG_PATH: candidatePath }); + + const routerConfig = await readJson(routerPath); + if (root.router?.activeProfile && !routerConfig.activeProfile) { + routerConfig.activeProfile = root.router.activeProfile; + } + return routerConfig; +} + +export function resolveRouterModelTarget( + routerConfig: PiRogueRouterConfig, + options: { + profile?: string; + role?: string; + requestedModel?: string; + } = {}, +): ModelRoutingSelection { + const profile = options.profile ?? routerConfig.activeProfile ?? Object.keys(routerConfig.profiles)[0] ?? ""; + const role = options.role ?? "smart"; + const profileConfig = routerConfig.profiles[profile] ?? {}; + const requestedModel = options.requestedModel ?? ""; + + const roleTarget = profileConfig[role]; + if (typeof roleTarget === "string" && roleTarget.trim().length > 0) { + return { + profile, + role, + requestedModel, + upstreamModel: roleTarget, + source: "profile-role", + }; + } + + const fallbackRoles = ["smart", "worker", "reviewer", "teacher", "explore", "debug_diagnose", "verify", "review"]; + for (const fallbackRole of fallbackRoles) { + const candidate = profileConfig[fallbackRole]; + if (typeof candidate === "string" && candidate.trim().length > 0) { + return { + profile, + role, + requestedModel, + upstreamModel: candidate, + source: "profile-fallback", + }; + } + } + + if (requestedModel.trim().length > 0) { + return { + profile, + role, + requestedModel, + upstreamModel: requestedModel, + source: "requested-model", + }; + } + + throw new Error(`No model target configured for profile ${profile}`); +} + +class RoutedGatewaySubstrate implements GatewaySubstrate { + constructor( + public readonly id: string, + private readonly upstream: GatewaySubstrate, + private readonly routerConfig: PiRogueRouterConfig, + private readonly options: RoutedGatewayOptions = {}, + ) {} + + async listModels(): Promise { + return await this.upstream.listModels(); + } + + async callChat(req: SubstrateChatRequest): Promise { + const target = resolveRouterModelTarget(this.routerConfig, { + profile: this.options.profile, + role: this.options.role, + requestedModel: req.model, + }); + + return await this.upstream.callChat({ + ...req, + model: target.upstreamModel, + }); + } + + async estimateCost(req: { + model: string; + inputTokensApprox: number; + outputTokensApprox: number; + cachedInputTokensApprox?: number; + }): Promise { + const target = resolveRouterModelTarget(this.routerConfig, { + profile: this.options.profile, + role: this.options.role, + requestedModel: req.model, + }); + + const upstreamEstimate = this.upstream.estimateCost; + if (!upstreamEstimate) { + return { + inputCostUsdPerMTok: 0, + outputCostUsdPerMTok: 0, + cachedInputCostUsdPerMTok: 0, + currency: "USD", + }; + } + + return await upstreamEstimate.call(this.upstream, { + ...req, + model: target.upstreamModel, + }); + } + + async getUsage(runId: string): Promise { + const upstreamUsage = this.upstream.getUsage; + if (!upstreamUsage) return null; + return await upstreamUsage.call(this.upstream, runId); + } +} + +export async function createRoutedGatewaySubstrate( + upstream: GatewaySubstrate, + options: RoutedGatewayFactoryOptions = {}, +): Promise { + const routerConfig = options.routerConfig ?? (await loadPiRogueRouterConfig(options.routerConfigPath)); + return new RoutedGatewaySubstrate("substrate-routed", upstream, routerConfig, options); +} + +export async function createRoutedPortkeyGatewaySubstrate( + options: RoutedGatewayFactoryOptions & { env?: NodeJS.ProcessEnv } = {}, +): Promise { + const upstream = PortkeyCompatibleSubstrate.fromEnv(options.env); + return await createRoutedGatewaySubstrate(upstream, options); +} diff --git a/packages/rogue-gateway/src/planner.test.ts b/packages/rogue-gateway/src/planner.test.ts new file mode 100644 index 0000000..34d344c --- /dev/null +++ b/packages/rogue-gateway/src/planner.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { quoteRoute } from "./planner.js"; +import { defaultAssetRegistry } from "./default-assets.js"; +import type { QuoteRequest } from "./types.js"; + +describe("rogue gateway route planning", () => { + const baseRequest = (overrides: Partial = {}): QuoteRequest => ({ + profile: "local-first-economy", + taskKind: "coding_debug", + rawInputTokensApprox: 82_000, + forwardedInputTokensApprox: 2_400, + expectedOutputTokensApprox: 900, + contextPolicy: "typed_lens", + candidateAssets: ["local.qwen35", "remote.cheap", "remote.premium", "subscription.smart"], + repeatedFailureHint: 0, + evalOnly: false, + ...overrides, + }); + + it("prefers local-first route when profile is local-first-economy", () => { + const result = quoteRoute({ + request: baseRequest(), + registry: defaultAssetRegistry, + }); + + expect(result.selected.route).toBe("local_first_typed_lens"); + expect(result.selected.asset).toBe("local.qwen35"); + expect(result.alternatives.length).toBeGreaterThanOrEqual(3); + expect(result.selected.estimatedTotalTokenCostUsd).toBe(0); + }); + + it("blocks premium raw oracle unless evalOnly is enabled", () => { + const request = baseRequest({ profile: "fast-but-not-crazy" }); + const result = quoteRoute({ + request, + registry: defaultAssetRegistry, + }); + + const premiumRaw = result.alternatives.find((alternative) => alternative.route === "premium_raw_oracle_eval_only"); + + expect(premiumRaw).toBeDefined(); + expect(premiumRaw?.reasonNotChosen).toContain("evalOnly"); + expect(result.guards.rawFullContextToPremium).toBe(true); + }); + + it("unblocks premium raw oracle when evalOnly=true", () => { + const request = baseRequest({ profile: "fast-but-not-crazy", evalOnly: true }); + const result = quoteRoute({ + request, + registry: defaultAssetRegistry, + }); + + const premiumRaw = result.alternatives.find((alternative) => alternative.route === "premium_raw_oracle_eval_only"); + expect(premiumRaw).toBeDefined(); + expect(premiumRaw?.reasonNotChosen).toContain("score worse"); + expect(result.selected.route).not.toBe("premium_raw_oracle_eval_only"); + }); +}); diff --git a/packages/rogue-gateway/src/planner.ts b/packages/rogue-gateway/src/planner.ts new file mode 100644 index 0000000..4d24444 --- /dev/null +++ b/packages/rogue-gateway/src/planner.ts @@ -0,0 +1,396 @@ +import type { + AssetRegistry, + QuoteRequest, + RouteEconomicsInput, + RoutePlanChoice, + RouteKind, + RouteSavings, + RouteGuards, + QuoteResult, +} from "./types.js"; + +const DEFAULT_TREATED_TOKENS_FOR_TYPED_LENS = 30_000; +const DEFAULT_ROUTE_TPS = 40; +const DEFAULT_MAX_PREMIUM_INPUT_TOKENS = 100_000; +const PROFILE_UNKNOWN: "local-first-economy" = "local-first-economy"; + +export interface RoutePlanContext { + request: QuoteRequest; + registry: AssetRegistry; + now?: () => string; +} + +type ScoredPlan = { + choice: RoutePlanChoice; + allowed: boolean; + reasonBlocked?: string; + routeScore: number; +}; + +function asNumber(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function isRouteTyped(route: RouteKind): boolean { + return route === "local_first_typed_lens" || route === "cheap_remote_typed_lens" || route === "premium_sealed_packet"; +} + +function normalizeProfile(profile: string): string { + return String(profile || PROFILE_UNKNOWN).trim() || PROFILE_UNKNOWN; +} + +function typedPolicyScore(profile: string, route: RouteKind): number { + switch (profile) { + case "local-only": { + return route === "local_first_typed_lens" ? 0 : 1.2; + } + case "premium-surgical": { + if (route === "premium_raw_oracle_eval_only") return 0; + if (route === "premium_sealed_packet") return 0.2; + if (route === "subscription_hard_call") return 0.3; + if (route === "cheap_remote_typed_lens") return 0.45; + return 0.6; + } + case "fast-but-not-crazy": { + if (route === "cheap_remote_typed_lens") return 0; + if (route === "premium_sealed_packet") return 0.25; + if (route === "subscription_hard_call") return 0.45; + if (route === "premium_raw_oracle_eval_only") return 0.95; + return 0.35; + } + default: + return profile === "local-first-economy" && route === "local_first_typed_lens" + ? 0 + : profile === "local-first-economy" && route === "cheap_remote_typed_lens" + ? 0.25 + : 0.5; + } +} + +function routeOrderPenalty(route: RouteKind): number { + switch (route) { + case "local_first_typed_lens": + return 0; + case "cheap_remote_typed_lens": + return 1; + case "subscription_hard_call": + return 2; + case "premium_sealed_packet": + return 3; + case "premium_raw_oracle_eval_only": + return 4; + default: + return 5; + } +} + +function isProfileAllowsPremiumRaw(profile: string, req: QuoteRequest): boolean { + if (req.evalOnly) return true; + return profile === "premium-surgical" && (req.repeatedFailureHint ?? 0) >= 2; +} + +function estimateTokensForRoute( + route: RouteKind, + request: QuoteRequest, +): { + input: number; + forwardedSavings: number; +} { + const rawInput = Math.max(0, Math.floor(request.rawInputTokensApprox)); + const forwardedInput = + request.contextPolicy === "typed_lens" + ? Math.max(0, Math.floor(request.forwardedInputTokensApprox)) + : rawInput; + const typedInput = + request.contextPolicy === "typed_lens" + ? Math.min(forwardedInput, rawInput) + : rawInput; + + const fallbackTypedInput = Math.min(rawInput, rawInput - DEFAULT_TREATED_TOKENS_FOR_TYPED_LENS); + const typedSavings = rawInput - typedInput; + + if (route === "premium_raw_oracle_eval_only") { + return { + input: rawInput, + forwardedSavings: Math.max(0, 0), + }; + } + + if (route === "subscription_hard_call") { + return { + input: request.contextPolicy === "typed_lens" ? typedInput : rawInput, + forwardedSavings: Math.max(0, typedSavings), + }; + } + + if (route === "premium_sealed_packet" || isRouteTyped(route)) { + const effectiveInput = request.contextPolicy === "typed_lens" ? typedInput : rawInput; + return { + input: effectiveInput, + forwardedSavings: typedSavings > 0 ? typedSavings : fallbackTypedInput, + }; + } + + return { + input: rawInput, + forwardedSavings: 0, + }; +} + +function buildPlanChoice( + route: RouteKind, + assetId: string, + request: QuoteRequest, + asset: AssetRegistry["assets"][string], +): RoutePlanChoice { + const { input, forwardedSavings } = estimateTokensForRoute(route, request); + const output = Math.max(0, Math.floor(request.expectedOutputTokensApprox)); + const tps = Math.max(0.0001, asNumber(asset.observedTps) || DEFAULT_ROUTE_TPS); + const wallTime = Math.max(1, (input + output) / tps); + const wallTimeMs = Math.max(1, Math.round(wallTime * 1000)); + + const cacheSavedUsd = + isRouteTyped(route) && forwardedSavings > 0 + ? (asNumber(asset.cachedInputCostPerMTok) * forwardedSavings) / 1_000_000 + : 0; + + const inputCost = asNumber(asset.inputCostPerMTok); + const outputCost = asNumber(asset.outputCostPerMTok); + const metered = + (input * inputCost) / 1_000_000 + + (output * outputCost) / 1_000_000 - + Math.max(0, cacheSavedUsd); + + const quotaCost = + asset.class === "subscription_quota" && route === "subscription_hard_call" + ? Math.max(0, (asNumber(asset.quotaResetHours) ? asNumber(asset.quotaResetHours) / 24_000 : 0)) + : 0; + + const total = metered + quotaCost; + + return { + asset: assetId, + route, + estimatedWallTimeMs: wallTimeMs, + estimatedMeteredUsd: metered, + estimatedQuotaCost: quotaCost, + estimatedTotalTokenCostUsd: total, + reason: + asset.class === "owned_capacity" + ? "owned local capacity route" + : asset.class === "subscription_quota" + ? "quota-bounded route" + : "metered route with policy-aware context shaping", + rawInputTokensApprox: request.rawInputTokensApprox, + forwardedInputTokensApprox: request.forwardedInputTokensApprox, + }; +} + +function routeAllowed(route: RouteKind, asset: AssetRegistry["assets"][string], request: QuoteRequest): string | null { + switch (route) { + case "local_first_typed_lens": + if (asset.privacy !== "local") return "local_first_typed_lens requires local asset"; + if (request.contextPolicy !== "typed_lens") return "local-first typed-lens route requires typed lens context"; + return null; + + case "cheap_remote_typed_lens": + if (asset.privacy !== "remote") return "cheap_remote_typed_lens requires remote candidate"; + if (asset.class === "subscription_quota") return "subscription assets are not eligible for cheap-tunnel lane"; + if (request.contextPolicy !== "typed_lens") return "typed-lens route requires typed context"; + if (request.taskKind && /oracle|eval/i.test(request.taskKind) && request.evalOnly !== true) { + return "oracle/eval tasks should route to premium only when evalOnly is set"; + } + return null; + + case "subscription_hard_call": + if (asset.class !== "subscription_quota") return "subscription hard-call requires subscription_quota asset class"; + if ((asset.quotaRemaining ?? 0) <= 0) return "subscription_quota depleted"; + return null; + + case "premium_sealed_packet": + if (asset.privacy !== "remote") return "sealed premium route expects remote privacy class"; + if (asset.qualityTier === "cheap") return "cheap assets cannot satisfy sealed premium route"; + if (request.expectedOutputTokensApprox <= 0) return "output estimate required for premium sealed route"; + return null; + + case "premium_raw_oracle_eval_only": + if (!isProfileAllowsPremiumRaw(normalizeProfile(request.profile), request)) { + return "premium raw oracle requires evalOnly or premium-surgical repeated failure mode"; + } + if (asset.privacy !== "remote") return "premium raw oracle requires remote asset"; + if ((asNumber(request.rawInputTokensApprox) || 0) > DEFAULT_MAX_PREMIUM_INPUT_TOKENS) { + return `raw premium payload above guard (${DEFAULT_MAX_PREMIUM_INPUT_TOKENS} tokens)`; + } + return null; + + default: + return `unsupported route ${route}`; + } +} + +function scoreCandidate(route: RouteKind, choice: RoutePlanChoice, profile: string, request: QuoteRequest): number { + const base = typedPolicyScore(profile, route) * 10; + const routePenalty = routeOrderPenalty(route) * 75; + const wallPenalty = choice.estimatedWallTimeMs / 1000; + const costPenalty = Math.max(0, choice.estimatedTotalTokenCostUsd) * 1000; + const repeatedPenalty = + (request.repeatedFailureHint ?? 0) > 1 && !route.startsWith("premium") + ? ((request.repeatedFailureHint ?? 0) - 1) * 125 + : 0; + + return base + routePenalty + wallPenalty + costPenalty + repeatedPenalty; +} + +function toSavingsValue(candidate: RoutePlanChoice | undefined): number { + return candidate ? Math.max(0, candidate.estimatedTotalTokenCostUsd) : 0; +} + +export function quoteRoute(context: RoutePlanContext): QuoteResult { + const request = context.request; + const profile = normalizeProfile(request.profile); + + const requestedAssetIds = + request.candidateAssets.length > 0 + ? request.candidateAssets + : Object.keys(context.registry.assets); + + const routeOptions: RouteKind[] = [ + "local_first_typed_lens", + "cheap_remote_typed_lens", + "subscription_hard_call", + "premium_sealed_packet", + "premium_raw_oracle_eval_only", + ]; + + const ranked: ScoredPlan[] = []; + for (const assetId of requestedAssetIds) { + const asset = context.registry.assets[assetId]; + if (!asset) { + const reason = `candidate asset '${assetId}' not found in registry`; + ranked.push({ + choice: { + asset: assetId, + route: "cheap_remote_typed_lens", + estimatedWallTimeMs: Number.POSITIVE_INFINITY, + estimatedMeteredUsd: 0, + estimatedQuotaCost: 0, + estimatedTotalTokenCostUsd: Number.POSITIVE_INFINITY, + reason, + }, + allowed: false, + reasonBlocked: reason, + routeScore: Number.POSITIVE_INFINITY, + }); + continue; + } + + for (const route of routeOptions) { + const reasonBlocked = routeAllowed(route, asset, request); + const choice = buildPlanChoice(route, assetId, request, asset); + const allowed = reasonBlocked === null; + const routeScore = scoreCandidate(route, choice, profile, request) + (allowed ? 0 : 1_000_000); + ranked.push({ choice, allowed, reasonBlocked: reasonBlocked ?? undefined, routeScore }); + } + } + + const sorted = ranked + .slice() + .sort((a, b) => { + if (a.routeScore !== b.routeScore) return a.routeScore - b.routeScore; + return a.choice.route.localeCompare(b.choice.route) || a.choice.asset.localeCompare(b.choice.asset); + }); + + const selectedPlan = sorted.find((entry) => entry.allowed) ?? sorted[0]; + if (!selectedPlan) { + throw new Error("no route candidates available"); + } + + const selected = { ...selectedPlan.choice, reasonNotChosen: undefined }; + + const alternatives: RoutePlanChoice[] = sorted + .filter((entry) => entry !== selectedPlan) + .map((entry) => { + const reasonNotChosen = entry.reasonBlocked + ? entry.reasonBlocked + : `score worse than selected (${(entry.routeScore - (selectedPlan?.routeScore ?? 0)).toFixed(3)})`; + + return { + ...entry.choice, + reasonNotChosen, + }; + }); + + const premiumRaw = sorted.find((entry) => entry.choice.route === "premium_raw_oracle_eval_only" && Number.isFinite(entry.choice.estimatedTotalTokenCostUsd)); + const premiumSealed = sorted.find((entry) => entry.choice.route === "premium_sealed_packet" && Number.isFinite(entry.choice.estimatedTotalTokenCostUsd)); + const localFirst = sorted.find((entry) => entry.choice.route === "local_first_typed_lens" && Number.isFinite(entry.choice.estimatedTotalTokenCostUsd)); + + const savings: RouteSavings = { + tokensAvoidedByContextLens: Math.max( + 0, + (context.request.contextPolicy === "typed_lens") + ? Math.max(0, context.request.rawInputTokensApprox - context.request.forwardedInputTokensApprox) + : Math.min(DEFAULT_TREATED_TOKENS_FOR_TYPED_LENS, context.request.rawInputTokensApprox), + ), + premiumRawVsPremiumSealedUsdSaved: toSavingsValue(premiumRaw?.choice) - toSavingsValue(premiumSealed?.choice), + premiumRawVsSelectedUsdSaved: toSavingsValue(premiumRaw?.choice) - toSavingsValue(selectedPlan?.choice), + cacheSavingsEstimatedUsd: + selectedPlan.choice.estimatedTotalTokenCostUsd / + (selectedPlan.choice.estimatedWallTimeMs > 0 ? selectedPlan.choice.estimatedWallTimeMs : 1), + localFirstSavingsEstimatedUsd: + toSavingsValue(localFirst?.choice) - toSavingsValue(selectedPlan?.allowed ? selectedPlan.choice : undefined), + }; + + const premiumRequirements: string[] = []; + if (!isProfileAllowsPremiumRaw(profile, context.request)) { + premiumRequirements.push("set evalOnly=true for premium_raw_oracle_eval_only"); + } + + const guards: RouteGuards = { + rawFullContextToPremium: !isProfileAllowsPremiumRaw(profile, context.request), + maxPremiumInputTokens: DEFAULT_MAX_PREMIUM_INPUT_TOKENS, + premiumRequires: premiumRequirements, + }; + + const now = context.now ? context.now() : new Date().toISOString(); + void now; + + return { + selected, + alternatives, + savings, + guards, + }; +} + +export function buildRouteEconomicsInput(selected: RoutePlanChoice, alternatives: RoutePlanChoice[]): RouteEconomicsInput { + const candidateAssets = [selected, ...alternatives].map((item) => item.asset); + const uniqueAssets = Array.from(new Set(candidateAssets)); + + const baseline = alternatives.find((item) => item.route === "premium_raw_oracle_eval_only") ?? selected; + const savings: RouteSavings = { + tokensAvoidedByContextLens: Math.max(0, selected.rawInputTokensApprox ?? 0), + premiumRawVsPremiumSealedUsdSaved: (baseline.estimatedTotalTokenCostUsd || 0) - (selected.estimatedTotalTokenCostUsd || 0), + premiumRawVsSelectedUsdSaved: (baseline.estimatedTotalTokenCostUsd || 0) - (selected.estimatedTotalTokenCostUsd || 0), + cacheSavingsEstimatedUsd: selected.estimatedWallTimeMs > 0 ? (selected.estimatedMeteredUsd / 10_000) : 0, + localFirstSavingsEstimatedUsd: alternatives + .filter((item) => item.route !== selected.route) + .reduce((acc, item) => acc + Math.max(0, (item.estimatedTotalTokenCostUsd || 0) - (selected.estimatedTotalTokenCostUsd || 0)), 0), + }; + + const guards: RouteGuards = { + rawFullContextToPremium: false, + maxPremiumInputTokens: DEFAULT_MAX_PREMIUM_INPUT_TOKENS, + premiumRequires: [], + }; + + return { + asset: selected.asset, + route: selected.route, + runId: "prototype-run", + candidateAssets: uniqueAssets, + selected, + alternatives, + savings, + guards, + }; +} diff --git a/packages/rogue-gateway/src/server.test.ts b/packages/rogue-gateway/src/server.test.ts new file mode 100644 index 0000000..a6b3e20 --- /dev/null +++ b/packages/rogue-gateway/src/server.test.ts @@ -0,0 +1,99 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { readEvents } from "./events.js"; +import { DEFAULT_GATEWAY_EVENT_LOG, startGatewayServer } from "./server.js"; + +describe("rogue gateway server", () => { + it("serves /rogue/economics/quote and emits expected decision events", async () => { + const workdir = mkdtempSync(join(tmpdir(), "rogue-gateway-")); + const eventLogPath = join(workdir, ".pi", "rogue-gateway-spike", "events.jsonl"); + const server = await startGatewayServer({ + port: 0, + eventLogPath, + }); + + const response = await fetch(`http://127.0.0.1:${server.port}/rogue/economics/quote`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + profile: "local-first-economy", + taskKind: "coding_debug", + rawInputTokensApprox: 82_000, + forwardedInputTokensApprox: 2_400, + expectedOutputTokensApprox: 900, + contextPolicy: "typed_lens", + candidateAssets: ["local.qwen35", "remote.cheap", "remote.premium", "subscription.smart"], + }), + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.selected.route).toBe("local_first_typed_lens"); + expect(payload.selected.asset).toBe("local.qwen35"); + + const events = readEvents(eventLogPath); + const eventsSet = new Set(events.map((row) => row.type)); + expect(eventsSet).toContain("request_received"); + expect(eventsSet).toContain("artifact_detected"); + expect(eventsSet).toContain("context_lens_created"); + expect(eventsSet).toContain("economics_quoted"); + expect(eventsSet).toContain("route_planned"); + expect(eventsSet).toContain("profile_resolved"); + expect(eventsSet).toContain("response_returned"); + + const recorded = readFileSync(eventLogPath, "utf8"); + expect(recorded.length).toBeGreaterThan(10); + + await server.close(); + rmSync(workdir, { recursive: true, force: true }); + }); + + it("exports default health endpoint", async () => { + const server = await startGatewayServer({ + port: 0, + eventLogPath: DEFAULT_GATEWAY_EVENT_LOG, + }); + + const response = await fetch(`http://127.0.0.1:${server.port}/health`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true }); + await server.close(); + }); + + it("rejects malformed quote payload", async () => { + const server = await startGatewayServer({ + port: 0, + eventLogPath: "/tmp/nonexistent-events.jsonl", + }); + + const bad = await fetch(`http://127.0.0.1:${server.port}/rogue/economics/quote`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ profile: "local-first-economy" }), + }); + + expect(bad.status).toBe(400); + expect(await bad.json()).toMatchObject({ error: "invalid quote request" }); + await server.close(); + }); + + it("rejects invalid JSON body", async () => { + const server = await startGatewayServer({ + port: 0, + eventLogPath: "/tmp/nonexistent-events.jsonl", + }); + + const bad = await fetch(`http://127.0.0.1:${server.port}/rogue/economics/quote`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ invalid-json", + }); + + expect(bad.status).toBe(400); + expect(await bad.json()).toMatchObject({ error: "invalid json" }); + await server.close(); + }); +}); diff --git a/packages/rogue-gateway/src/server.ts b/packages/rogue-gateway/src/server.ts new file mode 100644 index 0000000..5e64b39 --- /dev/null +++ b/packages/rogue-gateway/src/server.ts @@ -0,0 +1,252 @@ +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; +import { appendEvent } from "./events.js"; +import { defaultAssetRegistry } from "./default-assets.js"; +import type { AssetRegistry, QuoteRequest, QuoteResult } from "./types.js"; +import { quoteRoute } from "./planner.js"; + +export const DEFAULT_GATEWAY_EVENT_LOG = ".pi/rogue-gateway-spike/events.jsonl"; + +export interface GatewayServerOptions { + port?: number; + registry?: AssetRegistry; + eventLogPath?: string; + now?: () => string; +} + +interface GatewayServerHandle { + close: () => Promise; + port: number; + eventsPath: string; +} + +interface QuoteRequestEnvelope { + runId?: string; + request?: QuoteRequest; +} + +function parseRequestBody(text: string): unknown { + if (!text) return {}; + return JSON.parse(text); +} + +function parseJsonBodySafely(text: string): { parsed: unknown; parseError: string | null } { + try { + return { parsed: parseRequestBody(text), parseError: null }; + } catch { + return { + parsed: null, + parseError: "invalid json", + }; + } +} + +function isQuoteRequest(value: unknown): value is QuoteRequest { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + const contextPolicy = candidate.contextPolicy; + const candidateAssets = candidate.candidateAssets; + + if (typeof candidate.profile !== "string") return false; + if (typeof candidate.taskKind !== "string") return false; + if (typeof candidate.rawInputTokensApprox !== "number" || !Number.isFinite(candidate.rawInputTokensApprox)) return false; + if (typeof candidate.forwardedInputTokensApprox !== "number" || !Number.isFinite(candidate.forwardedInputTokensApprox)) return false; + if (typeof candidate.expectedOutputTokensApprox !== "number" || !Number.isFinite(candidate.expectedOutputTokensApprox)) return false; + if (typeof contextPolicy !== "string") return false; + if (!Array.isArray(candidateAssets) || candidateAssets.some((asset) => typeof asset !== "string")) return false; + + return true; +} + +function isQuoteRequestEnvelope(value: unknown): value is QuoteRequestEnvelope { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + if ("request" in candidate && candidate.request === undefined) return false; + if (typeof candidate.runId !== "undefined" && typeof candidate.runId !== "string") return false; + if (!Object.hasOwn(candidate, "request")) return false; + if (!isQuoteRequest(candidate.request)) return false; + return true; +} + +function safeResponsePayload(result: QuoteResult, runId: string) { + return { + runId, + selected: result.selected, + alternatives: result.alternatives, + savings: result.savings, + guards: result.guards, + }; +} + +function sendJson(res: import("node:http").ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body); + res.statusCode = status; + res.setHeader("content-type", "application/json"); + res.end(payload); +} + +async function readBody(req: import("node:http").IncomingMessage): Promise { + const chunks: string[] = []; + for await (const chunk of req) { + chunks.push(String(chunk)); + } + return chunks.join(""); +} + +export async function startGatewayServer(options: GatewayServerOptions = {}): Promise { + const eventLogPath = options.eventLogPath ?? DEFAULT_GATEWAY_EVENT_LOG; + const port = options.port ?? 0; + const registry = options.registry ?? defaultAssetRegistry; + + const server = createServer(async (req, res) => { + if (req.method === "GET" && req.url === "/health") { + sendJson(res, 200, { ok: true }); + return; + } + + if (req.method === "POST" && req.url === "/rogue/economics/quote") { + const body = await readBody(req); + const { parsed, parseError } = parseJsonBodySafely(body); + + if (parseError) { + sendJson(res, 400, { error: parseError }); + return; + } + + try { + const request = isQuoteRequestEnvelope(parsed) + ? parsed.request + : isQuoteRequest(parsed) + ? parsed + : null; + + if (!request) { + sendJson(res, 400, { error: "invalid quote request" }); + return; + } + + const runId = isQuoteRequestEnvelope(parsed) && typeof parsed.runId === "string" + ? parsed.runId + : randomUUID(); + + appendEvent(eventLogPath, { + runId, + type: "request_received", + data: { + method: req.method ?? "POST", + route: req.url ?? "/rogue/economics/quote", + requestPath: "/rogue/economics/quote", + }, + }); + + const result = quoteRoute({ + request, + registry, + now: options.now, + }); + + appendEvent(eventLogPath, { + runId, + type: "artifact_detected", + data: { + profile: String(request.profile), + candidateAssets: request.candidateAssets, + contextPolicy: request.contextPolicy, + }, + }); + + appendEvent(eventLogPath, { + runId, + type: "context_lens_created", + data: { + rawInputTokensApprox: request.rawInputTokensApprox, + forwardedInputTokensApprox: request.forwardedInputTokensApprox, + contextPolicy: request.contextPolicy, + }, + }); + + appendEvent(eventLogPath, { + runId, + type: "profile_resolved", + data: { + profile: String(request.profile), + candidateAssets: request.candidateAssets, + }, + }); + + appendEvent(eventLogPath, { + runId, + type: "route_planned", + data: { + selected: result.selected, + alternatives: result.alternatives.length, + }, + }); + + appendEvent(eventLogPath, { + runId, + type: "economics_quoted", + data: { + estimatedSavings: result.savings, + selectedRoute: result.selected.route, + selectedAsset: result.selected.asset, + }, + }); + + appendEvent(eventLogPath, { + runId, + type: "response_returned", + data: { + status: 200, + alternatives: result.alternatives.length, + }, + }); + + sendJson(res, 200, safeResponsePayload(result, runId)); + return; + } catch (error) { + const failure = error instanceof Error ? error.message : "quote failed"; + const errorRunId = randomUUID(); + appendEvent(eventLogPath, { + runId: errorRunId, + type: "response_returned", + data: { + status: 500, + error: failure, + }, + }); + sendJson(res, 500, { error: failure }); + return; + } + } + + res.statusCode = 404; + res.end("not-found"); + }); + + await new Promise((resolve, reject) => { + server.listen(port, (error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + const address = server.address(); + const actualPort = typeof address === "object" && address !== null ? address.port : port; + + return { + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + }, + port: actualPort, + eventsPath: eventLogPath, + }; +} diff --git a/packages/rogue-gateway/src/substrate-mock.ts b/packages/rogue-gateway/src/substrate-mock.ts new file mode 100644 index 0000000..fb803a6 --- /dev/null +++ b/packages/rogue-gateway/src/substrate-mock.ts @@ -0,0 +1,112 @@ +import { randomUUID } from "node:crypto"; + +import type { GatewaySubstrate, SubstrateChatRequest, SubstrateChatResult, SubstrateCostQuote, SubstrateModel, SubstrateUsage } from "./substrate.js"; + +export type MockModel = Pick & Partial; + +export interface MockSubstrateOptions { + models?: MockModel[]; + usageSeed?: SubstrateUsage[]; + latencyMs?: number; +} + +function cloneUsage(usage: SubstrateUsage): SubstrateUsage { + return { + runId: usage.runId, + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + totalTokens: usage.totalTokens, + billedTokens: usage.billedTokens, + }; +} + +function pickRunId(metadata: SubstrateChatRequest["metadata"]): string | null { + const runId = metadata?.runId; + if (typeof runId === "string" && runId.length > 0) return runId; + return null; +} + +export class SubstrateMock implements GatewaySubstrate { + private usages: Map = new Map(); + + constructor( + public readonly id = "substrate-mock", + private readonly options: MockSubstrateOptions = {}, + ) { + if (options.usageSeed) { + for (const usage of options.usageSeed) { + this.usages.set(usage.runId, cloneUsage(usage)); + } + } + } + + async listModels(): Promise { + const models = this.options.models; + if (models && models.length > 0) return models; + return [ + { id: "mock-generic", object: "model", maxTokens: 32000 }, + { id: "mock-fast", object: "model", maxTokens: 12000 }, + ]; + } + + async callChat(req: SubstrateChatRequest): Promise { + const latencyMs = this.options.latencyMs ?? 0; + if (latencyMs > 0) { + await new Promise((resolve) => setTimeout(resolve, latencyMs)); + } + + const runId = pickRunId(req.metadata) ?? randomUUID(); + const input = Array.isArray(req.messages) ? req.messages.length : 0; + const usage: SubstrateUsage = { + runId, + promptTokens: Math.max(0, input * 50), + completionTokens: 42, + totalTokens: Math.max(0, input * 50) + 42, + billedTokens: Math.max(0, input * 50) + 42, + }; + + this.usages.set(runId, usage); + + return { + ok: true, + status: 200, + model: req.model, + usage: { + prompt_tokens: usage.promptTokens, + completion_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + }, + content: { + role: "assistant", + content: `mock:${req.model}`, + }, + rawResponse: { + id: runId, + }, + }; + } + + async estimateCost(req: { + model: string; + inputTokensApprox: number; + outputTokensApprox: number; + cachedInputTokensApprox?: number; + }): Promise { + const isCached = req.cachedInputTokensApprox && req.cachedInputTokensApprox > 0; + const baseInput = req.inputTokensApprox * 0; + const baseOut = req.outputTokensApprox * 0; + const cached = isCached ? req.cachedInputTokensApprox! * 0 : 0; + return { + inputCostUsdPerMTok: baseInput / 1_000_000, + outputCostUsdPerMTok: baseOut / 1_000_000, + cachedInputCostUsdPerMTok: cached / 1_000_000, + currency: "USD", + }; + } + + async getUsage(runId: string): Promise { + const usage = this.usages.get(runId); + if (!usage) return null; + return cloneUsage(usage); + } +} diff --git a/packages/rogue-gateway/src/substrate-openai-compatible.test.ts b/packages/rogue-gateway/src/substrate-openai-compatible.test.ts new file mode 100644 index 0000000..8081d4c --- /dev/null +++ b/packages/rogue-gateway/src/substrate-openai-compatible.test.ts @@ -0,0 +1,161 @@ +import { createServer } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; +import { describe, expect, it } from "vitest"; + +import { OpenAICompatibleSubstrate } from "./substrate-openai-compatible.js"; + +type BodyData = Record; + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("error", reject); + req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + req.on("end", () => { + if (chunks.length === 0) { + resolve(undefined); + return; + } + + const payload = Buffer.concat(chunks).toString("utf8"); + try { + resolve(JSON.parse(payload) as BodyData); + } catch { + resolve(payload); + } + }); + }); +} + +function writeJson(res: ServerResponse, status: number, payload: unknown): void { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(payload)); +} + +describe("OpenAICompatibleSubstrate", () => { + it("falls back to /v1-compatible paths used by Portkey-like gateways", async () => { + const requests: string[] = []; + const server = createServer(async (req, res) => { + const reqBody = await readBody(req); + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + requests.push(`${req.method} ${url.pathname}`); + + if (req.method === "GET" && url.pathname === "/v1/models") { + writeJson(res, 200, { + data: [{ id: "remote.gpt-4o-mini", object: "model" }], + }); + return; + } + + if (req.method === "GET" && url.pathname === "/models") { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + return; + } + + if (req.method === "POST" && url.pathname === "/v1/chat/completions") { + const body = (reqBody && typeof reqBody === "object" ? reqBody : {}) as { + model?: string; + }; + + writeJson(res, 200, { + id: "chatcmpl-1", + model: body.model, + object: "chat.completion", + choices: [{ message: { content: "ok", role: "assistant" } }], + usage: { prompt_tokens: 7, completion_tokens: 1, total_tokens: 8 }, + }); + return; + } + + if (req.method === "POST" && url.pathname === "/chat/completions") { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + return; + } + + if (req.method === "POST" && url.pathname === "/v1/context/compress") { + writeJson(res, 200, { compressed: true, tokenCount: 17 }); + return; + } + + if (req.method === "POST" && url.pathname === "/v1/context/clip") { + writeJson(res, 200, { clipped: true, clippedLength: 32 }); + return; + } + + if (req.method === "POST" && url.pathname === "/v1/context/lookup") { + writeJson(res, 200, { + lookup: { + hit: true, + source: "mock-portkey", + }, + }); + return; + } + + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: `unexpected ${req.method} ${url.pathname}` })); + }); + + try { + const port = await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => { + resolve((server.address() as AddressInfo).port); + }), + ); + + const substrate = new OpenAICompatibleSubstrate({ + baseUrl: `http://127.0.0.1:${port}`, + }); + + const models = await substrate.listModels(); + expect(models).toEqual([{ id: "remote.gpt-4o-mini", object: "model" }]); + + const chat = await substrate.callChat({ + model: "remote.gpt-4o-mini", + messages: [{ role: "user", content: "hello" }], + }); + + expect(chat.ok).toBe(true); + expect(chat.status).toBe(200); + expect(chat.model).toBe("remote.gpt-4o-mini"); + expect(chat.usage).toEqual({ prompt_tokens: 7, completion_tokens: 1, total_tokens: 8 }); + + const compress = await fetch(`http://127.0.0.1:${port}/v1/context/compress`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: "This is very long user context payload." }), + }); + const compressBody = await compress.json(); + expect(compressBody).toMatchObject({ compressed: true, tokenCount: 17 }); + + const clip = await fetch(`http://127.0.0.1:${port}/v1/context/clip`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: "payload", limitTokens: 10 }), + }); + const clipBody = await clip.json(); + expect(clipBody).toMatchObject({ clipped: true, clippedLength: 32 }); + + const lookup = await fetch(`http://127.0.0.1:${port}/v1/context/lookup`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query: "recent contract clause" }), + }); + const lookupBody = await lookup.json(); + expect(lookupBody).toMatchObject({ lookup: { hit: true, source: "mock-portkey" } }); + + expect(requests).toContain("GET /models"); + expect(requests).toContain("GET /v1/models"); + expect(requests).toContain("POST /chat/completions"); + expect(requests).toContain("POST /v1/chat/completions"); + expect(requests).toContain("POST /v1/context/compress"); + expect(requests).toContain("POST /v1/context/clip"); + expect(requests).toContain("POST /v1/context/lookup"); + } finally { + server.close(); + } + }); +}); diff --git a/packages/rogue-gateway/src/substrate-openai-compatible.ts b/packages/rogue-gateway/src/substrate-openai-compatible.ts new file mode 100644 index 0000000..3563937 --- /dev/null +++ b/packages/rogue-gateway/src/substrate-openai-compatible.ts @@ -0,0 +1,230 @@ +type HeadersMap = Record; + +import type { + GatewaySubstrate, + SubstrateChatRequest, + SubstrateChatResult, + SubstrateCostQuote, + SubstrateModel, + SubstrateUsage, +} from "./substrate.js"; + +export interface OpenAICompatibleSubstrateOptions { + id?: string; + baseUrl: string; + apiKey?: string; + authHeaderName?: string; + authScheme?: string; + defaultHeaders?: HeadersMap; + timeoutMs?: number; +} + +interface OpenAIModelListResponse { + data?: Array<{ id: string; object?: string; [key: string]: unknown }>; +} + +interface OpenAIChatResponse { + id?: string; + model?: string; + object?: string; + choices?: Array<{ message?: unknown; finish_reason?: string }>; + usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }; +} + +async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +function unique(values: T[]): T[] { + return [...new Set(values)]; +} + +function normalizeHeaderName(name: string | undefined): string { + const trimmed = String(name ?? "").trim(); + return trimmed.length > 0 ? trimmed : "Authorization"; +} + +function authHeaderValue(apiKey: string, authHeaderName?: string, authScheme?: string): string { + const header = normalizeHeaderName(authHeaderName); + if (header.toLowerCase() === "authorization") { + return `${authScheme ?? "Bearer"} ${apiKey}`.trim(); + } + + return apiKey; +} + +export class OpenAICompatibleSubstrate implements GatewaySubstrate { + readonly id: string; + + constructor(private readonly options: OpenAICompatibleSubstrateOptions) { + this.id = options.id ?? "substrate-openai-compatible"; + } + + private baseUrl(): string { + return this.options.baseUrl.replace(/\/+$/, ""); + } + + private headers(): HeadersMap { + const headers: HeadersMap = { + "content-type": "application/json", + ...(this.options.defaultHeaders ?? {}), + }; + + if (this.options.apiKey) { + headers[normalizeHeaderName(this.options.authHeaderName)] = authHeaderValue( + this.options.apiKey, + this.options.authHeaderName, + this.options.authScheme, + ); + } + + return headers; + } + + private candidateUrls(endpoint: string): string[] { + const base = this.baseUrl(); + return unique([ + `${base}${endpoint}`, + `${base}/v1${endpoint}`, + ]); + } + + private async requestWithFallback( + endpoint: string, + init: RequestInit, + timeoutMs: number, + ): Promise { + const urls = this.candidateUrls(endpoint); + let fallbackResponse: Response | undefined; + let lastError: unknown; + + for (const url of urls) { + try { + const response = await fetchWithTimeout(url, init, timeoutMs); + if (response.status !== 404) { + return response; + } + + fallbackResponse = response; + } catch (error) { + lastError = error; + } + } + + if (fallbackResponse) { + return fallbackResponse; + } + + if (lastError) { + throw lastError; + } + + throw new Error(`request to openai-compatible substrate failed for ${endpoint}`); + } + + private async parseBody(response: Response): Promise { + try { + return await response.json(); + } catch { + return await response.text(); + } + } + + async listModels(): Promise { + const response = await this.requestWithFallback( + "/models", + { + method: "GET", + headers: this.headers(), + }, + this.options.timeoutMs ?? 2_000, + ); + + const body = (await this.parseBody(response)) as OpenAIModelListResponse; + + if (!response.ok) { + throw new Error(`listModels failed (${response.status} ${response.statusText})`); + } + + const data = Array.isArray((body as OpenAIModelListResponse | undefined)?.data) + ? ((body as OpenAIModelListResponse).data ?? []) + : []; + + return data.map((row) => ({ id: String(row.id), object: row.object })); + } + + async callChat(req: SubstrateChatRequest): Promise { + const response = await this.requestWithFallback( + "/chat/completions", + { + method: "POST", + headers: this.headers(), + body: JSON.stringify({ + model: req.model, + messages: req.messages, + stream: req.stream, + max_tokens: req.max_tokens, + temperature: req.temperature, + metadata: req.metadata, + }), + }, + this.options.timeoutMs ?? 2_000, + ); + + const body = (await this.parseBody(response)) as OpenAIChatResponse; + + if (!response.ok) { + return { + ok: false, + status: response.status, + model: req.model, + content: body, + rawResponse: body, + }; + } + + return { + ok: true, + status: response.status, + model: body && typeof body === "object" ? body.model ?? req.model : req.model, + usage: body && typeof body === "object" ? body.usage : undefined, + content: body, + rawResponse: body, + }; + } + + async estimateCost(req: { + model: string; + inputTokensApprox: number; + outputTokensApprox: number; + cachedInputTokensApprox?: number; + }): Promise { + const _ = req.model; + const _input = req.inputTokensApprox; + const _output = req.outputTokensApprox; + const _cached = req.cachedInputTokensApprox ?? 0; + + return { + inputCostUsdPerMTok: 0, + outputCostUsdPerMTok: 0, + cachedInputCostUsdPerMTok: 0, + currency: "USD", + }; + } + + async getUsage(runId: string): Promise { + return { + runId, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + billedTokens: 0, + }; + } +} diff --git a/packages/rogue-gateway/src/substrate-portkey.test.ts b/packages/rogue-gateway/src/substrate-portkey.test.ts new file mode 100644 index 0000000..29a82e6 --- /dev/null +++ b/packages/rogue-gateway/src/substrate-portkey.test.ts @@ -0,0 +1,110 @@ +import { createServer } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; +import { describe, expect, it } from "vitest"; + +import { PortkeyCompatibleSubstrate } from "./substrate-portkey.js"; + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("error", reject); + req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); +} + +function writeJson(res: ServerResponse, status: number, payload: unknown): void { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(payload)); +} + +describe("PortkeyCompatibleSubstrate", () => { + it("reads env-driven Portkey/OpenAI-compatible settings and forwards the pi-dedicated model through the gateway", async () => { + const requests: Array<{ method?: string; path: string; headers: Record; body?: string }> = []; + + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const body = await readBody(req); + requests.push({ method: req.method, path: url.pathname, headers: req.headers, body }); + + if (req.method === "GET" && url.pathname === "/models") { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + return; + } + + if (req.method === "GET" && url.pathname === "/v1/models") { + writeJson(res, 200, { data: [{ id: "openai-codex/gpt-5.5", object: "model" }] }); + return; + } + + if (req.method === "POST" && url.pathname === "/chat/completions") { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + return; + } + + if (req.method === "POST" && url.pathname === "/v1/chat/completions") { + writeJson(res, 200, { + id: "chatcmpl-compat-1", + model: "openai-codex/gpt-5.5", + choices: [{ message: { role: "assistant", content: "ok" } }], + usage: { prompt_tokens: 12, completion_tokens: 4, total_tokens: 16 }, + }); + return; + } + + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: `unexpected ${req.method} ${url.pathname}` })); + }); + + const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)); + }); + + const prev = { + PORTKEY_BASE_URL: process.env.PORTKEY_BASE_URL, + PORTKEY_API_KEY: process.env.PORTKEY_API_KEY, + PORTKEY_AUTH_HEADER: process.env.PORTKEY_AUTH_HEADER, + PORTKEY_EXTRA_HEADERS_JSON: process.env.PORTKEY_EXTRA_HEADERS_JSON, + }; + + process.env.PORTKEY_BASE_URL = `http://127.0.0.1:${port}`; + process.env.PORTKEY_API_KEY = "pk_test_123"; + process.env.PORTKEY_AUTH_HEADER = "x-portkey-api-key"; + process.env.PORTKEY_EXTRA_HEADERS_JSON = JSON.stringify({ "x-portkey-workspace-id": "ws_test" }); + + try { + const substrate = PortkeyCompatibleSubstrate.fromEnv(); + const models = await substrate.listModels(); + expect(models).toEqual([{ id: "openai-codex/gpt-5.5", object: "model" }]); + + const chat = await substrate.callChat({ + model: "pi-dedicated", + messages: [{ role: "user", content: "measure token modes" }], + metadata: { modelMode: "lookup_compress" }, + }); + + expect(chat.ok).toBe(true); + expect(chat.model).toBe("openai-codex/gpt-5.5"); + expect(chat.usage).toEqual({ prompt_tokens: 12, completion_tokens: 4, total_tokens: 16 }); + + expect(requests.some((entry) => entry.path === "/v1/models")).toBe(true); + expect(requests.some((entry) => entry.path === "/v1/chat/completions")).toBe(true); + const firstChat = requests.find((entry) => entry.path === "/v1/chat/completions"); + expect(firstChat?.headers["x-portkey-api-key"]).toBe("pk_test_123"); + expect(firstChat?.headers["x-portkey-workspace-id"]).toBe("ws_test"); + expect(firstChat?.body).toContain('"model":"pi-dedicated"'); + } finally { + server.close(); + for (const [key, value] of Object.entries(prev)) { + if (typeof value === "undefined") { + delete process.env[key as keyof NodeJS.ProcessEnv]; + } else { + process.env[key as keyof NodeJS.ProcessEnv] = value; + } + } + } + }); +}); diff --git a/packages/rogue-gateway/src/substrate-portkey.ts b/packages/rogue-gateway/src/substrate-portkey.ts new file mode 100644 index 0000000..9276ed8 --- /dev/null +++ b/packages/rogue-gateway/src/substrate-portkey.ts @@ -0,0 +1,66 @@ +import { OpenAICompatibleSubstrate, type OpenAICompatibleSubstrateOptions } from "./substrate-openai-compatible.js"; + +export interface PortkeyCompatibleSubstrateOptions extends Omit { + env?: NodeJS.ProcessEnv; + baseUrl?: string; + apiKey?: string; + authHeaderName?: string; + authScheme?: string; + defaultHeaders?: Record; +} + +function parseHeadersJson(value: string | undefined): Record { + if (!value) return {}; + try { + const parsed = JSON.parse(value) as Record; + return Object.fromEntries( + Object.entries(parsed).flatMap(([key, val]) => + typeof val === "string" ? [[key, val]] : [], + ), + ); + } catch { + return {}; + } +} + +export function resolvePortkeyCompatibleOptions( + options: PortkeyCompatibleSubstrateOptions = {}, +): OpenAICompatibleSubstrateOptions { + const env = options.env ?? process.env; + const baseUrl = + options.baseUrl ?? + env.PORTKEY_BASE_URL?.trim() ?? + env.OPENAI_COMPATIBLE_BASE_URL?.trim() ?? + env.OPENAI_BASE_URL?.trim() ?? + env.OPENAI_API_BASE?.trim() ?? + "http://127.0.0.1:8000/v1"; + + const apiKey = options.apiKey ?? env.PORTKEY_API_KEY?.trim() ?? env.OPENAI_API_KEY?.trim(); + const authHeaderName = options.authHeaderName ?? env.PORTKEY_AUTH_HEADER?.trim() ?? env.OPENAI_AUTH_HEADER?.trim() ?? "Authorization"; + const authScheme = options.authScheme ?? env.PORTKEY_AUTH_SCHEME?.trim() ?? env.OPENAI_AUTH_SCHEME?.trim() ?? "Bearer"; + const defaultHeaders = { + ...parseHeadersJson(env.PORTKEY_EXTRA_HEADERS_JSON), + ...parseHeadersJson(env.OPENAI_EXTRA_HEADERS_JSON), + ...(options.defaultHeaders ?? {}), + }; + + return { + id: options.id ?? "substrate-portkey-compatible", + baseUrl, + apiKey, + authHeaderName, + authScheme, + defaultHeaders, + timeoutMs: options.timeoutMs, + }; +} + +export class PortkeyCompatibleSubstrate extends OpenAICompatibleSubstrate { + constructor(options: PortkeyCompatibleSubstrateOptions = {}) { + super(resolvePortkeyCompatibleOptions(options)); + } + + static fromEnv(env: NodeJS.ProcessEnv = process.env): PortkeyCompatibleSubstrate { + return new PortkeyCompatibleSubstrate({ env }); + } +} diff --git a/packages/rogue-gateway/src/substrate.ts b/packages/rogue-gateway/src/substrate.ts new file mode 100644 index 0000000..2a190ef --- /dev/null +++ b/packages/rogue-gateway/src/substrate.ts @@ -0,0 +1,62 @@ +export const SUBSTRATE_SCHEMA = "pi-rogue-gateway.substrate.v1" as const; + +export interface SubstrateModel { + id: string; + provider?: string; + object?: string; + owned?: boolean; + maxTokens?: number; +} + +export interface SubstrateChatRequest { + model: string; + messages: unknown[]; + stream?: boolean; + max_tokens?: number; + temperature?: number; + metadata?: Record; +} + +export interface SubstrateCostQuote { + inputCostUsdPerMTok: number; + outputCostUsdPerMTok: number; + cachedInputCostUsdPerMTok?: number; + currency?: string; +} + +export interface SubstrateUsage { + runId: string; + promptTokens: number; + completionTokens: number; + totalTokens: number; + billedTokens?: number; +} + +export interface SubstrateChatResult { + ok: boolean; + status?: number; + content?: unknown; + model?: string; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + rawResponse?: unknown; +} + +export interface GatewaySubstrate { + id: string; + listModels(): Promise; + callChat(req: SubstrateChatRequest): Promise; + estimateCost?( + req: { + model: string; + inputTokensApprox: number; + outputTokensApprox: number; + cachedInputTokensApprox?: number; + }, + ): Promise; + + getUsage?(runId: string): Promise; +} diff --git a/packages/rogue-gateway/src/types.ts b/packages/rogue-gateway/src/types.ts new file mode 100644 index 0000000..c93386a --- /dev/null +++ b/packages/rogue-gateway/src/types.ts @@ -0,0 +1,146 @@ +export const ROGUE_GATEWAY_SCHEME = "pi-rogue-gateway.v0" as const; + +export type AssetClass = + | "owned_capacity" + | "metered_api" + | "subscription_quota" + | "composite_fusion" + | "mock"; + +export type QualityTier = "cheap" | "mid" | "smart" | "frontier"; + +export type PrivacyClass = "local" | "remote"; + +export interface GatewayAsset { + class: AssetClass; + substrate: string; + model: string; + contextWindow?: number; + observedTps?: number; + inputCostPerMTok?: number; + cachedInputCostPerMTok?: number; + outputCostPerMTok?: number; + quotaRemaining?: number; + quotaResetHours?: number; + privacy: PrivacyClass; + qualityTier: QualityTier; + tags?: string[]; +} + +export interface AssetRegistry { + assets: Record; +} + +export type RouterProfile = + | "local-only" + | "local-first-economy" + | "fast-but-not-crazy" + | "premium-surgical" + | string; + +export type ContextPolicy = "typed_lens" | "raw_forward" | "none" | string; + +export type RouteKind = + | "local_first_typed_lens" + | "cheap_remote_typed_lens" + | "subscription_hard_call" + | "premium_sealed_packet" + | "premium_raw_oracle_eval_only"; + +export interface QuoteRequest { + profile: RouterProfile; + taskKind: string; + rawInputTokensApprox: number; + forwardedInputTokensApprox: number; + expectedOutputTokensApprox: number; + contextPolicy: ContextPolicy; + candidateAssets: string[]; + repeatedFailureHint?: number; + evalOnly?: boolean; + latencyPreference?: "latency" | "quality" | "cost"; +} + +export interface RouteEconomicsInput { + asset: string; + route: RouteKind; + runId: string; + candidateAssets: string[]; + selected: RoutePlanChoice; + alternatives: RoutePlanChoice[]; + savings: RouteSavings; + guards: RouteGuards; +} + +export interface RoutePlanChoice { + asset: string; + route: RouteKind; + estimatedWallTimeMs: number; + estimatedMeteredUsd: number; + estimatedQuotaCost: number; + estimatedTotalTokenCostUsd: number; + reason: string; + rawInputTokensApprox?: number; + forwardedInputTokensApprox?: number; + reasonNotChosen?: string; +} + +export interface RouteSavings { + tokensAvoidedByContextLens: number; + premiumRawVsPremiumSealedUsdSaved: number; + premiumRawVsSelectedUsdSaved: number; + cacheSavingsEstimatedUsd: number; + localFirstSavingsEstimatedUsd: number; +} + +export interface RouteGuards { + rawFullContextToPremium: boolean; + maxPremiumInputTokens: number; + premiumRequires: string[]; +} + +export interface QuoteResult { + selected: RoutePlanChoice; + alternatives: RoutePlanChoice[]; + savings: RouteSavings; + guards: RouteGuards; +} + +export interface RoutePlanCandidate { + asset: string; + route: RouteKind; + forwardedInputTokensApprox: number; + rawInputTokensApprox: number; + outputTokensApprox: number; + estimatedWallTimeMs: number; + estimatedMeteredUsd: number; + estimatedQuotaCost: number; + estimatedTotalTokenCostUsd: number; + reason: string; + allowed: boolean; + reasonBlocked?: string; +} + +export interface EventRecord { + eventId: string; + runId: string; + type: + | "request_received" + | "profile_resolved" + | "artifact_detected" + | "context_lens_created" + | "economics_quoted" + | "route_planned" + | "upstream_call_started" + | "upstream_call_finished" + | "cache_estimated" + | "context_lookup" + | "response_returned"; + timestamp: string; + data: Record; +} + +export interface RunRecord { + runId: string; + request: QuoteRequest; + createdAt: string; +}