diff --git a/README.md b/README.md index 3b51d6d3..1aeec8bb 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ In record or `--proxy-only` mode, aimock forwards the caller's auth header to th | `AIMOCK_PROVIDER_COHERE_KEY` | Cohere | `Authorization: Bearer ` | | `AIMOCK_PROVIDER_GROK_KEY` | Grok (xAI) | `Authorization: Bearer ` | | `AIMOCK_PROVIDER_OLLAMA_KEY` | Ollama (Cloud / bearer-gated) | `Authorization: Bearer ` | +| `AIMOCK_PROVIDER_GITHUB_KEY` | GitHub Models | `Authorization: Bearer ` | | `AIMOCK_PROVIDER_ANTHROPIC_KEY` | Anthropic | `x-api-key: ` | | `AIMOCK_PROVIDER_GEMINI_KEY` | Gemini (and Gemini Interactions) | `x-goog-api-key: ` | | `AIMOCK_PROVIDER_VEO_KEY` | Veo | `x-goog-api-key: ` | @@ -149,6 +150,12 @@ The Gemini interactions provider mode reuses `AIMOCK_PROVIDER_GEMINI_KEY` (same This is opt-in and backward-compatible: with no key configured the feature is inert and the caller's header passes through as-is. Injection fires only when the caller sent no credential **or** a dummy credential prefixed with `sk-aimock-` (overridable via `AIMOCK_DUMMY_KEY_MARKER`); a real caller key never starting with that marker is always forwarded verbatim, so the caller overrides aimock. Signed and exchanged credentials — AWS Bedrock (SigV4) and Vertex AI (OAuth) — are never rewritten and always forwarded unchanged. (Azure's static `api-key` is injected; a real Microsoft Entra ID `Authorization: Bearer` token from the caller is never dummy-prefixed, so it too passes through verbatim.) +### GitHub Models — credential-aware upstream routing (`--provider-github`) + +[GitHub Models](https://github.com/marketplace/models) is OpenAI-wire-compatible: clients point a stock OpenAI SDK at `https://models.inference.ai.azure.com` and authenticate with a GitHub PAT (`github_pat_…` / `ghp_…`) as the bearer token. Because the surface is `/v1/chat/completions`, aimock classifies such a request as the `openai` provider — so on a fixture-miss it would proxy to whatever `--provider-openai` points at (e.g. `api.openai.com`), which rejects the GitHub token. + +Wire `--provider-github https://models.inference.ai.azure.com` and, on a fixture-miss, aimock routes requests whose bearer token is detected as a GitHub credential to the GitHub Models upstream instead of the openai one, forwarding the PAT verbatim. This is **opt-in**: with no `--provider-github` set, a GitHub PAT that misses is refused by the cross-provider credential guard (a clear `proxy_refused` 502) rather than leaked to OpenAI. Detection is by credential shape only — a dummy or non-GitHub key is untouched and follows normal `openai` routing. (`AIMOCK_PROVIDER_GITHUB_KEY` is listed above for parity with the other providers, but on this path the caller's real PAT is always present and forwarded, so it is never injected.) + ## Framework Guides Test your AI agents with aimock — no API keys, no network calls: [LangChain](https://aimock.copilotkit.dev/integrate-langchain) · [CrewAI](https://aimock.copilotkit.dev/integrate-crewai) · [PydanticAI](https://aimock.copilotkit.dev/integrate-pydanticai) · [LlamaIndex](https://aimock.copilotkit.dev/integrate-llamaindex) · [Mastra](https://aimock.copilotkit.dev/integrate-mastra) · [Google ADK](https://aimock.copilotkit.dev/integrate-adk) · [Microsoft Agent Framework](https://aimock.copilotkit.dev/integrate-maf) diff --git a/src/__tests__/credential-guard.test.ts b/src/__tests__/credential-guard.test.ts new file mode 100644 index 00000000..7e97a1a1 --- /dev/null +++ b/src/__tests__/credential-guard.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect, afterEach } from "vitest"; +import http from "node:http"; +import { createServer, type ServerInstance } from "../server.js"; +import { + detectCredentialProvider, + detectBearerCredentialProvider, + classifyUpstreamHost, + checkCredentialUpstreamCompat, +} from "../provider-auth.js"; + +// --------------------------------------------------------------------------- +// Fail-safe proxy guard: aimock must never relay a caller credential to an +// upstream that belongs to a DIFFERENT provider (the showcase failure mode: +// a GitHub token forwarded to api.openai.com on a fixture miss). Any +// uncertainty fails OPEN so legitimate/self-hosted setups are never blocked. +// --------------------------------------------------------------------------- + +describe("detectCredentialProvider", () => { + it.each([ + ["github_pat_11ABCDEF0000abcdef", "github"], + ["ghp_abc123", "github"], + ["gho_abc123", "github"], + ["ghs_abc123", "github"], + ["sk-ant-api03-xyz", "anthropic"], + ["sk-or-v1-abc", "openrouter"], + ["xai-abc", "xai"], + ["gsk_abc", "groq"], + ["AIzaSyABC", "google"], + ["sk-proj-abc", "openai"], + ["sk-abc", "openai"], + ])("identifies %s as %s", (cred, expected) => { + expect(detectCredentialProvider(cred)).toBe(expected); + }); + + it("returns undefined for placeholder / custom / empty credentials", () => { + expect(detectCredentialProvider("sk-aimock-dev-ci")).toBeUndefined(); // dummy marker + expect(detectCredentialProvider("my-custom-gateway-key")).toBeUndefined(); + expect(detectCredentialProvider("")).toBeUndefined(); + expect(detectCredentialProvider(undefined)).toBeUndefined(); + }); +}); + +describe("detectBearerCredentialProvider", () => { + it("strips a Bearer scheme prefix (case-insensitive) and classifies the token", () => { + expect(detectBearerCredentialProvider("Bearer github_pat_11ABCDEF")).toBe("github"); + expect(detectBearerCredentialProvider("Bearer sk-proj-abc")).toBe("openai"); + expect(detectBearerCredentialProvider("bearer sk-ant-api03-xyz")).toBe("anthropic"); + }); + + it("treats a bare value with no scheme prefix as the credential", () => { + expect(detectBearerCredentialProvider("github_pat_11ABCDEF")).toBe("github"); + }); + + it("returns undefined for absent / dummy / unknown credentials", () => { + expect(detectBearerCredentialProvider(undefined)).toBeUndefined(); + expect(detectBearerCredentialProvider("Bearer sk-aimock-dev-ci")).toBeUndefined(); + expect(detectBearerCredentialProvider("Bearer my-custom-gateway-key")).toBeUndefined(); + }); +}); + +describe("classifyUpstreamHost", () => { + it.each([ + ["api.openai.com", "openai"], + ["api.anthropic.com", "anthropic"], + ["generativelanguage.googleapis.com", "google"], + ["aiplatform.googleapis.com", "google"], + ["api.x.ai", "xai"], + ["api.groq.com", "groq"], + ["openrouter.ai", "openrouter"], + ["models.inference.ai.azure.com", "github"], + ["models.github.ai", "github"], + ])("classifies %s as %s", (host, expected) => { + expect(classifyUpstreamHost(host)).toBe(expected); + }); + + it("returns undefined for unrecognized / self-hosted hosts", () => { + expect(classifyUpstreamHost("127.0.0.1")).toBeUndefined(); + expect(classifyUpstreamHost("localhost")).toBeUndefined(); + expect(classifyUpstreamHost("my-gateway.internal")).toBeUndefined(); + }); +}); + +describe("checkCredentialUpstreamCompat", () => { + const bearer = (key: string) => ({ Authorization: `Bearer ${key}` }); + const u = (s: string) => new URL(s); + + afterEach(() => { + delete process.env.AIMOCK_ALLOW_CROSS_PROVIDER_PROXY; + }); + + it("REFUSES a GitHub token bound for api.openai.com (the showcase bug)", () => { + const r = checkCredentialUpstreamCompat( + bearer("github_pat_11ABCDEF"), + u("https://api.openai.com/v1/chat/completions"), + "openai", + ); + expect(r.ok).toBe(false); + expect(r.detectedProvider).toBe("github"); + expect(r.upstreamProvider).toBe("openai"); + expect(r.upstreamHost).toBe("api.openai.com"); + }); + + it("ALLOWS a GitHub token bound for GitHub Models (legitimate)", () => { + expect( + checkCredentialUpstreamCompat( + bearer("github_pat_11ABCDEF"), + u("https://models.inference.ai.azure.com/chat/completions"), + "openai", + ).ok, + ).toBe(true); + }); + + it("ALLOWS a matching OpenAI key to api.openai.com", () => { + expect( + checkCredentialUpstreamCompat( + bearer("sk-proj-abc"), + u("https://api.openai.com/v1/chat/completions"), + "openai", + ).ok, + ).toBe(true); + }); + + it("fails OPEN for an unknown credential", () => { + expect( + checkCredentialUpstreamCompat(bearer("custom-key"), u("https://api.openai.com/x"), "openai") + .ok, + ).toBe(true); + }); + + it("fails OPEN for an unrecognized upstream host", () => { + expect( + checkCredentialUpstreamCompat(bearer("github_pat_x"), u("http://127.0.0.1:1234/x"), "openai") + .ok, + ).toBe(true); + }); + + it("fails OPEN for a dummy placeholder key", () => { + expect( + checkCredentialUpstreamCompat(bearer("sk-aimock-x"), u("https://api.openai.com/x"), "openai") + .ok, + ).toBe(true); + }); + + it("fails OPEN for a signed/OAuth provider with no static-key scheme (bedrock)", () => { + expect( + checkCredentialUpstreamCompat( + bearer("github_pat_x"), + u("https://api.openai.com/x"), + "bedrock", + ).ok, + ).toBe(true); + }); + + it("reads non-bearer schemes: a GitHub token in x-api-key bound for api.anthropic.com is REFUSED", () => { + const r = checkCredentialUpstreamCompat( + { "x-api-key": "github_pat_11ABCDEF" }, + u("https://api.anthropic.com/v1/messages"), + "anthropic", + ); + expect(r.ok).toBe(false); + expect(r.upstreamProvider).toBe("anthropic"); + }); + + it("honors the AIMOCK_ALLOW_CROSS_PROVIDER_PROXY escape hatch", () => { + process.env.AIMOCK_ALLOW_CROSS_PROVIDER_PROXY = "1"; + expect( + checkCredentialUpstreamCompat(bearer("github_pat_x"), u("https://api.openai.com/x"), "openai") + .ok, + ).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: the guard is wired into the proxy-on-miss path. +// --------------------------------------------------------------------------- + +async function httpPost( + url: string, + body: object, + headers: Record = {}, +): Promise<{ status: number; body: string }> { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.text() }; +} + +interface FakeUpstream { + server: http.Server; + url: string; + count: () => number; +} + +function createFakeUpstream(): Promise { + return new Promise((resolve) => { + let count = 0; + const server = http.createServer((req, res) => { + count++; + req.on("data", () => {}); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + id: "chatcmpl-fake", + object: "chat.completion", + created: 0, + model: "gpt-4o-mini", + choices: [ + { index: 0, message: { role: "assistant", content: "hi" }, finish_reason: "stop" }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + ); + }); + }); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + resolve({ server, url: `http://127.0.0.1:${port}`, count: () => count }); + }); + }); +} + +const CHAT_PATH = "/v1/chat/completions"; +const CHAT_BODY = { model: "gpt-4o-mini", messages: [{ role: "user", content: "unmatched-miss" }] }; + +describe("proxy-on-miss credential guard (integration)", () => { + let recorder: ServerInstance | undefined; + let upstream: FakeUpstream | undefined; + + afterEach(async () => { + if (recorder) { + await new Promise((r) => recorder!.server.close(() => r())); + recorder = undefined; + } + if (upstream) { + await new Promise((r) => upstream!.server.close(() => r())); + upstream = undefined; + } + }); + + it("REFUSES a GitHub token → api.openai.com with 502 proxy_refused (no upstream call)", async () => { + // The guard short-circuits before any socket is opened, so this makes no + // real network request to api.openai.com. + recorder = await createServer([], { + port: 0, + metrics: true, + record: { + providers: { openai: "https://api.openai.com" }, + proxyOnly: true, + upstreamTimeoutMs: 3000, + }, + }); + const res = await httpPost(recorder.url + CHAT_PATH, CHAT_BODY, { + Authorization: "Bearer github_pat_11ABCDEF0000", + }); + expect(res.status).toBe(502); + expect(res.body).toContain("proxy_refused"); + // The remedy names the first-class github upstream, not a --provider-openai + // repoint (which is impossible on a shared instance). + expect(res.body).toContain("--provider-github"); + + const metrics = await fetch(recorder.url + "/metrics").then((r) => r.text()); + expect(metrics).toContain("aimock_proxy_refused_total"); + expect(metrics).toContain('reason="credential_provider_mismatch"'); + }); + + it("ALLOWS + forwards to an unrecognized (self-hosted) upstream and records the forwarded metric", async () => { + upstream = await createFakeUpstream(); + recorder = await createServer([], { + port: 0, + metrics: true, + record: { providers: { openai: upstream.url }, proxyOnly: true }, + }); + // Same GitHub token, but the upstream host is unrecognized → fail open → + // forwarded (aimock does not second-guess a self-hosted / custom gateway). + const res = await httpPost(recorder.url + CHAT_PATH, CHAT_BODY, { + Authorization: "Bearer github_pat_11ABCDEF0000", + "X-AIMock-Context": "demo", + }); + expect(res.status).toBe(200); + expect(upstream.count()).toBe(1); + + const metrics = await fetch(recorder.url + "/metrics").then((r) => r.text()); + expect(metrics).toContain("aimock_proxy_forwarded_total"); + expect(metrics).toContain('context_present="true"'); + }); +}); + +// --------------------------------------------------------------------------- +// Credential-aware upstream selection (PNI-110): GitHub Models is OpenAI-wire- +// compatible, so a github_pat_… token arrives on the openai surface. When a +// github upstream is wired, route it there instead of the openai upstream; the +// PNI-108 guard then sees github→github and allows the forward. Opt-in: with no +// --provider-github wired, behavior is unchanged (the guard refuses). +// --------------------------------------------------------------------------- + +describe("proxy-on-miss credential-aware github routing (integration)", () => { + let recorder: ServerInstance | undefined; + let openaiUpstream: FakeUpstream | undefined; + let githubUpstream: FakeUpstream | undefined; + + afterEach(async () => { + for (const u of [openaiUpstream, githubUpstream]) { + if (u) await new Promise((r) => u.server.close(() => r())); + } + openaiUpstream = undefined; + githubUpstream = undefined; + if (recorder) { + await new Promise((r) => recorder!.server.close(() => r())); + recorder = undefined; + } + }); + + it("routes a GitHub PAT on the openai surface to the github upstream, not openai", async () => { + openaiUpstream = await createFakeUpstream(); + githubUpstream = await createFakeUpstream(); + recorder = await createServer([], { + port: 0, + metrics: true, + record: { + providers: { openai: openaiUpstream.url, github: githubUpstream.url }, + proxyOnly: true, + }, + }); + const res = await httpPost(recorder.url + CHAT_PATH, CHAT_BODY, { + Authorization: "Bearer github_pat_11ABCDEF0000", + }); + expect(res.status).toBe(200); + expect(githubUpstream.count()).toBe(1); + expect(openaiUpstream.count()).toBe(0); + + // Egress observability is labeled with the effective (github) provider. + const metrics = await fetch(recorder.url + "/metrics").then((r) => r.text()); + expect(metrics).toContain("aimock_proxy_forwarded_total"); + expect(metrics).toContain('provider="github"'); + }); + + it("leaves a real OpenAI key on the openai upstream even when a github upstream is configured", async () => { + openaiUpstream = await createFakeUpstream(); + githubUpstream = await createFakeUpstream(); + recorder = await createServer([], { + port: 0, + record: { + providers: { openai: openaiUpstream.url, github: githubUpstream.url }, + proxyOnly: true, + }, + }); + const res = await httpPost(recorder.url + CHAT_PATH, CHAT_BODY, { + Authorization: "Bearer sk-proj-realopenaikey", + }); + expect(res.status).toBe(200); + expect(openaiUpstream.count()).toBe(1); + expect(githubUpstream.count()).toBe(0); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 7056472d..a4768e94 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -41,6 +41,7 @@ Options: --provider-ollama Upstream URL for Ollama --provider-cohere Upstream URL for Cohere --provider-openrouter Upstream URL for OpenRouter (video record proxy) + --provider-github Upstream URL for GitHub Models (github_pat_… on the OpenAI-compatible surface) --upstream-timeout-ms Idle timeout (ms) on upstream socket before response (default: 30000) --body-timeout-ms Idle timeout (ms) on upstream response body between chunks (default: 30000) --max-proxy-buffer-bytes Cap (bytes) on in-memory proxy-path buffer; full body still relayed (default: 67108864) @@ -79,6 +80,7 @@ const { values } = parseArgs({ "provider-ollama": { type: "string" }, "provider-cohere": { type: "string" }, "provider-openrouter": { type: "string" }, + "provider-github": { type: "string" }, "upstream-timeout-ms": { type: "string" }, "body-timeout-ms": { type: "string" }, "max-proxy-buffer-bytes": { type: "string" }, @@ -255,6 +257,7 @@ if (values.record || values["proxy-only"]) { if (values["provider-ollama"]) providers.ollama = values["provider-ollama"]; if (values["provider-cohere"]) providers.cohere = values["provider-cohere"]; if (values["provider-openrouter"]) providers.openrouter = values["provider-openrouter"]; + if (values["provider-github"]) providers.github = values["provider-github"]; if (Object.keys(providers).length === 0) { console.error( @@ -307,6 +310,7 @@ if (values.record || values["proxy-only"]) { "provider-ollama", "provider-cohere", "provider-openrouter", + "provider-github", ] as const ).filter((flag) => values[flag] !== undefined); if (droppedProviderFlags.length > 0) { diff --git a/src/provider-auth.ts b/src/provider-auth.ts index b00f436e..aec7fa15 100644 --- a/src/provider-auth.ts +++ b/src/provider-auth.ts @@ -28,7 +28,7 @@ export function getDummyKeyMarker(): string { * credential unchanged. * * Scheme kinds (all static long-lived keys): - * - bearer `Authorization: Bearer ` (OpenAI, OpenRouter, Cohere, Grok/xAI, Ollama) + * - bearer `Authorization: Bearer ` (OpenAI, OpenRouter, Cohere, Grok/xAI, Ollama, GitHub Models) * - fal-key `Authorization: Key ` (fal.ai — note the `Key ` prefix, NOT `Bearer`) * - x-api-key `x-api-key: ` (Anthropic) * - x-goog-api-key `x-goog-api-key: ` (Gemini, Gemini Interactions, Veo) @@ -55,6 +55,7 @@ const PROVIDER_AUTH_SCHEMES: Partial> = { cohere: { kind: "bearer" }, grok: { kind: "bearer" }, // xAI ollama: { kind: "bearer" }, // Ollama Cloud / bearer-gated Ollama servers + github: { kind: "bearer" }, // GitHub Models (github_pat_…) on the OpenAI-compatible surface // Anthropic. anthropic: { kind: "x-api-key" }, // Google (Gemini + Veo) use the x-goog-api-key header scheme. @@ -208,6 +209,131 @@ export function applyProviderAuth( } } +/** + * Well-known credential provider families, identified from a credential's + * distinctive prefix. Used by the fail-safe proxy guard below to refuse + * relaying a caller's REAL credential to an upstream that belongs to a + * DIFFERENT provider — the failure mode where a fixture is missing, `--proxy-only` + * falls through to the configured upstream, and (say) a GitHub token gets + * forwarded to `api.openai.com`, which rejects it. + */ +export type CredentialProvider = + | "openai" + | "anthropic" + | "google" + | "github" + | "xai" + | "groq" + | "openrouter"; + +/** + * Identify the provider a credential belongs to from its distinctive prefix. + * Returns `undefined` for a credential with no well-known marker (custom + * gateways, ambiguous keys) or an absent/dummy placeholder key, so the guard + * that consumes this FAILS OPEN and never blocks a legitimate/self-hosted setup. + * + * Order matters: more-specific markers are tested before the broad `sk-` OpenAI + * prefix (e.g. `sk-ant-` Anthropic and `sk-or-` OpenRouter must win over `sk-`). + */ +export function detectCredentialProvider( + credential: string | undefined, +): CredentialProvider | undefined { + if (!credential) return undefined; + const c = credential.trim(); + if (c.length === 0) return undefined; + // A placeholder/dummy key is not a real provider credential. + if (c.startsWith(getDummyKeyMarker())) return undefined; + // GitHub (incl. GitHub Models): fine-grained PATs and classic `gh?_` tokens. + if (/^github_pat_/.test(c) || /^gh[posru]_/.test(c)) return "github"; + if (c.startsWith("sk-ant-")) return "anthropic"; + if (c.startsWith("sk-or-")) return "openrouter"; + if (c.startsWith("xai-")) return "xai"; + if (c.startsWith("gsk_")) return "groq"; + if (c.startsWith("AIza")) return "google"; + // Broad OpenAI prefix LAST (sk-…, sk-proj-…) after the sk-* specials above. + if (c.startsWith("sk-")) return "openai"; + return undefined; +} + +/** + * Detect the credential-provider family of the caller's bearer token straight + * from a raw `Authorization` header value, without building the forward-header + * map. Strips a leading `Bearer ` scheme prefix if present. Used by the + * proxy-on-miss path to pick a credential-aware upstream BEFORE the upstream URL + * is resolved. Only the bearer scheme is inspected — that is the only scheme the + * credential-aware upstream remap currently applies to (the OpenAI-compatible + * surface, which is where GitHub Models' `github_pat_…` tokens arrive). + */ +export function detectBearerCredentialProvider( + authorization: string | undefined, +): CredentialProvider | undefined { + if (!authorization) return undefined; + const match = /^\s*Bearer\s+(.+)$/i.exec(authorization); + const credential = match ? match[1].trim() : authorization.trim(); + return detectCredentialProvider(credential); +} + +/** + * Classify an upstream host into a credential-provider family. Returns + * `undefined` for hosts with no well-known mapping (localhost, custom gateways, + * test doubles) so the guard FAILS OPEN. Note GitHub Models is OpenAI-wire- + * compatible but authenticates with a GitHub token, so its hosts map to + * `github`, not `openai`. + */ +export function classifyUpstreamHost(host: string): CredentialProvider | undefined { + const h = host.toLowerCase(); + if (h === "api.openai.com") return "openai"; + if (h === "api.anthropic.com") return "anthropic"; + if (h.endsWith(".googleapis.com")) return "google"; + if (h === "api.x.ai") return "xai"; + if (h === "api.groq.com") return "groq"; + if (h === "openrouter.ai") return "openrouter"; + if (h === "models.inference.ai.azure.com" || h === "models.github.ai") return "github"; + return undefined; +} + +/** Result of the fail-safe credential/upstream compatibility check. */ +export interface CredentialCompatResult { + ok: boolean; + detectedProvider?: CredentialProvider; + upstreamProvider?: CredentialProvider; + upstreamHost?: string; +} + +/** The `AIMOCK_ALLOW_CROSS_PROVIDER_PROXY=1` escape hatch disables the guard. */ +function isCrossProviderProxyAllowed(env: NodeJS.ProcessEnv = process.env): boolean { + const v = env.AIMOCK_ALLOW_CROSS_PROVIDER_PROXY; + return v === "1" || v === "true"; +} + +/** + * Fail-safe guard for the proxy-on-miss path: is the credential currently in + * `forwardHeaders` safe to send to `target`? Returns `ok:false` ONLY when BOTH + * the credential AND the upstream host are confidently classified to DIFFERENT + * provider families (e.g. a `github_pat_…` token bound for `api.openai.com`). + * + * Any uncertainty fails OPEN (`ok:true`): an unknown/dummy credential, a + * signed/OAuth provider with no static-key scheme, or an unrecognized upstream + * host (localhost, a custom gateway, a self-hosted mirror) is never blocked, so + * this cannot break a legitimate proxy setup. Honors + * `AIMOCK_ALLOW_CROSS_PROVIDER_PROXY=1`. + */ +export function checkCredentialUpstreamCompat( + forwardHeaders: Record, + target: URL, + providerKey: RecordProviderKey, +): CredentialCompatResult { + if (isCrossProviderProxyAllowed()) return { ok: true }; + const scheme = PROVIDER_AUTH_SCHEMES[providerKey]; + if (!scheme) return { ok: true }; // signed/OAuth provider — no static key to classify + const detectedProvider = detectCredentialProvider(readCallerCredential(forwardHeaders, scheme)); + if (!detectedProvider) return { ok: true }; // no known-provider credential → fail open + const upstreamProvider = classifyUpstreamHost(target.hostname); + if (!upstreamProvider) return { ok: true }; // unrecognized upstream host → fail open + if (detectedProvider === upstreamProvider) return { ok: true }; + return { ok: false, detectedProvider, upstreamProvider, upstreamHost: target.hostname }; +} + /** * Read per-provider built-in keys from the environment. Returns undefined when * no provider key is configured so the feature stays fully inert by default. @@ -229,6 +355,7 @@ export function readProviderKeysFromEnv( if (env.AIMOCK_PROVIDER_OPENROUTER_KEY) keys.openrouter = env.AIMOCK_PROVIDER_OPENROUTER_KEY; if (env.AIMOCK_PROVIDER_COHERE_KEY) keys.cohere = env.AIMOCK_PROVIDER_COHERE_KEY; if (env.AIMOCK_PROVIDER_GROK_KEY) keys.grok = env.AIMOCK_PROVIDER_GROK_KEY; + if (env.AIMOCK_PROVIDER_GITHUB_KEY) keys.github = env.AIMOCK_PROVIDER_GITHUB_KEY; if (env.AIMOCK_PROVIDER_OLLAMA_KEY) keys.ollama = env.AIMOCK_PROVIDER_OLLAMA_KEY; if (env.AIMOCK_PROVIDER_VEO_KEY) keys.veo = env.AIMOCK_PROVIDER_VEO_KEY; if (env.AIMOCK_PROVIDER_AZURE_KEY) keys.azure = env.AIMOCK_PROVIDER_AZURE_KEY; diff --git a/src/recorder.ts b/src/recorder.ts index f98b0881..b63bf5fb 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -20,7 +20,12 @@ import type { Logger } from "./logger.js"; import { collapseStreamingResponse, capturedRedactedData } from "./stream-collapse.js"; import { writeErrorResponse } from "./sse-writer.js"; import { resolveUpstreamUrl } from "./url.js"; -import { applyProviderAuth } from "./provider-auth.js"; +import { + applyProviderAuth, + checkCredentialUpstreamCompat, + detectBearerCredentialProvider, +} from "./provider-auth.js"; +import type { MetricsRegistry } from "./metrics.js"; import { getTestId, slugifyTestId, slugifyContext } from "./helpers.js"; import { DEFAULT_TEST_ID } from "./constants.js"; @@ -399,6 +404,7 @@ export async function proxyAndRecord( record?: RecordConfig; logger: Logger; requestTransform?: (req: ChatCompletionRequest) => ChatCompletionRequest; + registry?: MetricsRegistry; }, rawBody?: string, options?: ProxyOptions, @@ -407,13 +413,36 @@ export async function proxyAndRecord( if (!record) return "not_configured"; const providers = record.providers; + + // Credential-aware upstream selection (opt-in). GitHub Models is OpenAI-wire- + // compatible, so a `github_pat_…` token arrives on the OpenAI surface and is + // routed as providerKey "openai". When a github upstream is explicitly wired + // (`--provider-github`), route such a request to GitHub Models instead of the + // openai upstream so the PAT reaches an endpoint that accepts it — otherwise it + // would be forwarded to api.openai.com and rejected. Fires ONLY for a + // confidently-detected github credential; any other/dummy/absent credential is + // untouched (falls through to normal surface routing), and if no github + // upstream is configured the request stays "openai" and the fail-safe guard + // below refuses the true github→openai mismatch. See PNI-108/PNI-110. + let effectiveProviderKey = providerKey; + if ( + providerKey === "openai" && + providers.github && + detectBearerCredentialProvider(req.headers.authorization) === "github" + ) { + effectiveProviderKey = "github"; + } + // Gemini Interactions uses the same upstream API as Gemini (identical base URL // and auth), so we remap the provider key to reuse the configured Gemini URL. - const lookupKey = providerKey === "gemini-interactions" ? "gemini" : providerKey; + const lookupKey = + effectiveProviderKey === "gemini-interactions" ? "gemini" : effectiveProviderKey; const upstreamUrl = providers[lookupKey]; if (!upstreamUrl) { - defaults.logger.warn(`No upstream URL configured for provider "${providerKey}" — cannot proxy`); + defaults.logger.warn( + `No upstream URL configured for provider "${effectiveProviderKey}" — cannot proxy`, + ); return "not_configured"; } @@ -423,7 +452,7 @@ export async function proxyAndRecord( } catch (err) { const msg = err instanceof Error ? err.message : String(err); defaults.logger.error( - `Invalid upstream URL for provider "${providerKey}": ${upstreamUrl} (${msg})`, + `Invalid upstream URL for provider "${effectiveProviderKey}": ${upstreamUrl} (${msg})`, ); writeErrorResponse( res, @@ -443,8 +472,67 @@ export async function proxyAndRecord( // If aimock owns a built-in upstream key for this provider, inject it now // (opt-in, backward-compatible). A real caller credential overrides it; an // absent or dummy-prefixed caller credential is replaced. gemini-interactions - // reuses the Gemini key, mirroring the upstream-URL remap above. - applyProviderAuth(forwardHeaders, target, providerKey, record.providerKeys?.[lookupKey]); + // reuses the Gemini key, mirroring the upstream-URL remap above. NOTE: on the + // credential-aware github remap, the caller by definition sent a REAL + // github_pat_… (that is what triggers the remap), so it is always forwarded + // verbatim and AIMOCK_PROVIDER_GITHUB_KEY is not injected on this path. + applyProviderAuth(forwardHeaders, target, effectiveProviderKey, record.providerKeys?.[lookupKey]); + + // Fail-safe: refuse to relay a caller credential to an upstream that belongs + // to a DIFFERENT provider (e.g. a GitHub token bound for api.openai.com when a + // fixture is missing and --proxy-only fell through to the wrong upstream). + // This prevents leaking a real credential to the wrong vendor and surfaces a + // clear error instead of the vendor's confusing "invalid_api_key". Fails open + // for unknown credentials/hosts; override with AIMOCK_ALLOW_CROSS_PROVIDER_PROXY=1. + const compat = checkCredentialUpstreamCompat(forwardHeaders, target, effectiveProviderKey); + if (!compat.ok) { + // Actionable remedy. GitHub Models is first-class (`--provider-github`), so + // for a github credential we point at that flag directly; for other detected + // providers we keep it generic rather than synthesize a `--provider-` flag + // that may not exist (the credential-provider families are a superset of the + // wired --provider-* flags). + const remedy = + compat.detectedProvider === "github" + ? "wire --provider-github so GitHub Models (github_pat_…) credentials route to GitHub Models" + : `route ${compat.detectedProvider} credentials to a matching ${compat.detectedProvider} upstream`; + defaults.logger.error( + `REFUSING PROXY — caller sent a ${compat.detectedProvider} credential but the ` + + `${effectiveProviderKey} upstream is ${compat.upstreamHost} (a ${compat.upstreamProvider} endpoint). ` + + `Not forwarding the credential. This usually means no fixture matched and --proxy-only ` + + `fell through to the wrong upstream. Add a matching fixture, ${remedy}, ` + + `or set AIMOCK_ALLOW_CROSS_PROVIDER_PROXY=1 to override.`, + ); + defaults.registry?.incrementCounter("aimock_proxy_refused_total", { + provider: effectiveProviderKey, + reason: "credential_provider_mismatch", + }); + if (!res.headersSent) { + writeErrorResponse( + res, + 502, + JSON.stringify({ + error: { + message: + `aimock refused to proxy this request: the caller credential looks like a ` + + `${compat.detectedProvider} key but the configured ${effectiveProviderKey} upstream ` + + `(${compat.upstreamHost}) is a ${compat.upstreamProvider} endpoint. No fixture ` + + `matched and aimock will not forward the credential to a mismatched provider. ` + + `Add a fixture for this request, ${remedy}, or set AIMOCK_ALLOW_CROSS_PROVIDER_PROXY=1.`, + type: "proxy_refused", + }, + }), + ); + } + return "relayed"; + } + + // Observability: a successful fall-through to a live upstream. `context_present` + // surfaces the showcase failure signature (requests proxying with no + // X-AIMock-Context, so every context-scoped fixture missed). + defaults.registry?.incrementCounter("aimock_proxy_forwarded_total", { + provider: effectiveProviderKey, + context_present: request._context ? "true" : "false", + }); const requestBody = rawBody ?? JSON.stringify(request); @@ -842,6 +930,12 @@ export async function proxyAndRecord( if (collapsed?.truncated) { persistWarnings.push("Stream response was truncated — fixture may be incomplete"); } + // Record under the ORIGINAL surface `providerKey` (not `effectiveProviderKey`): + // GitHub Models is OpenAI-wire-compatible, so on replay an inbound github_pat_… + // request is re-classified by its surface as "openai". Persisting under the + // credential-aware "github" key would make the fixture unmatchable on replay. + // `effectiveProviderKey` governs only egress (upstream/auth/guard); wire-format + // classification and fixture identity stay with the surface key. const persistResult = persistFixture({ record, providerKey, diff --git a/src/types.ts b/src/types.ts index 020a136b..4df0e968 100644 --- a/src/types.ts +++ b/src/types.ts @@ -896,7 +896,8 @@ export type RecordProviderKey = | "fal" | "openrouter" | "veo" - | "grok"; + | "grok" + | "github"; export interface RecordConfig { providers: Partial>;