From 81668c28cfada4253128281df59ccfe800f822f3 Mon Sep 17 00:00:00 2001 From: GitHub CI Date: Sun, 24 May 2026 11:44:35 -0700 Subject: [PATCH] feat(provider): add gateway provider for OpenAI-compatible endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All existing providers (codex, claude, acpx, cursor, ...) shell out to a local CLI binary that has to be installed + OAuth'd on the host. That's a poor fit for containerized agent deployments where the only available LLM channel is an HTTP endpoint (LiteLLM gateway, vLLM, vanilla OpenAI). This adds a `gateway` provider that POSTs the already-assembled prompt to an OpenAI-compatible /chat/completions endpoint with structured outputs (response_format: json_schema). No subprocess; no extra CLI to install; same JSON contract as every other provider. Designed for the protoLabs LiteLLM gateway (internally http://gateway:4000/v1, externally https://api.proto-labs.ai/v1) but works against any OpenAI-compatible endpoint — vanilla OpenAI, vLLM, LM Studio, Ollama with the OpenAI shim, etc. Configuration: GATEWAY_API_KEY (preferred) or OPENAI_API_KEY — Bearer token OPENAI_BASE_URL default https://api.proto-labs.ai/v1 CLAWPATCH_GATEWAY_MODEL default protolabs/smart; --model on the CLI overrides CLAWPATCH_GATEWAY_TIMEOUT_MS default 300000 (5 min — reasoning models on big features can be slow) --reasoning-effort forwarded as `reasoning_effort` body field Because the buildReviewPrompt / buildMapPrompt / buildFixPrompt helpers already inline the relevant file contents as Files blocks, the gateway provider needs zero file IO. It's effectively the minimal provider implementation: prompt in, JSON out. The `check()` method validates env + returns a fingerprint string (model + base URL) without making a network call — `clawpatch doctor` won't spend tokens on a probe and won't time out when offline. +11 tests covering env precedence (GATEWAY_API_KEY > OPENAI_API_KEY), URL normalization (trailing slashes), model precedence (--model > CLAWPATCH_GATEWAY_MODEL > default), timeout parsing (garbage values fall back), check() fingerprint format, and the no-API-key auth-error path. 724 pass / 1 pre-existing skipped. --- src/provider.test.ts | 129 ++++++++++++++++++++++++++++++- src/provider.ts | 175 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+), 1 deletion(-) diff --git a/src/provider.test.ts b/src/provider.test.ts index eb0977e..878fb3b 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ClawpatchError } from "./errors.js"; import { __testing, extractJson, providerByName } from "./provider.js"; import { safeProviderPreview } from "./provider-json.js"; @@ -1645,6 +1645,10 @@ describe("providerByName", () => { expect(providerByName("cursor").name).toBe("cursor"); }); + it("returns the gateway provider for HTTP-based reviews", () => { + expect(providerByName("gateway").name).toBe("gateway"); + }); + it("still supports codex, mock, and mock-fail", () => { expect(providerByName("codex").name).toBe("codex"); expect(providerByName("mock").name).toBe("mock"); @@ -1652,6 +1656,129 @@ describe("providerByName", () => { }); }); +describe("gateway provider config", () => { + const ENV_KEYS = [ + "GATEWAY_API_KEY", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "CLAWPATCH_GATEWAY_MODEL", + "CLAWPATCH_GATEWAY_TIMEOUT_MS", + "CLAWPATCH_PROVIDER_TIMEOUT_MS", + ] as const; + const snapshot: Record = {}; + + beforeEach(() => { + for (const k of ENV_KEYS) snapshot[k] = process.env[k]; + for (const k of ENV_KEYS) delete process.env[k]; + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (snapshot[k] === undefined) delete process.env[k]; + else process.env[k] = snapshot[k]; + } + }); + + it("throws ClawpatchError(provider-auth) when no API key is set", () => { + expect(() => + // eslint-disable-next-line no-underscore-dangle + __testing.gatewayConfig({ model: null, reasoningEffort: null, skipGitRepoCheck: false }), + ).toThrowError(ClawpatchError); + }); + + it("prefers GATEWAY_API_KEY over OPENAI_API_KEY", () => { + process.env["GATEWAY_API_KEY"] = "gw-primary"; + process.env["OPENAI_API_KEY"] = "openai-fallback"; + // eslint-disable-next-line no-underscore-dangle + const cfg = __testing.gatewayConfig({ model: null, reasoningEffort: null, skipGitRepoCheck: false }); + expect(cfg.apiKey).toBe("gw-primary"); + }); + + it("falls back to OPENAI_API_KEY when GATEWAY_API_KEY is unset", () => { + process.env["OPENAI_API_KEY"] = "openai-only"; + // eslint-disable-next-line no-underscore-dangle + const cfg = __testing.gatewayConfig({ model: null, reasoningEffort: null, skipGitRepoCheck: false }); + expect(cfg.apiKey).toBe("openai-only"); + }); + + it("strips trailing slashes from OPENAI_BASE_URL", () => { + process.env["GATEWAY_API_KEY"] = "k"; + process.env["OPENAI_BASE_URL"] = "https://gateway.example/v1////"; + // eslint-disable-next-line no-underscore-dangle + const cfg = __testing.gatewayConfig({ model: null, reasoningEffort: null, skipGitRepoCheck: false }); + expect(cfg.baseUrl).toBe("https://gateway.example/v1"); + }); + + it("defaults to api.proto-labs.ai when OPENAI_BASE_URL is unset", () => { + process.env["GATEWAY_API_KEY"] = "k"; + // eslint-disable-next-line no-underscore-dangle + const cfg = __testing.gatewayConfig({ model: null, reasoningEffort: null, skipGitRepoCheck: false }); + expect(cfg.baseUrl).toBe("https://api.proto-labs.ai/v1"); + }); + + it("options.model wins over CLAWPATCH_GATEWAY_MODEL wins over default", () => { + process.env["GATEWAY_API_KEY"] = "k"; + process.env["CLAWPATCH_GATEWAY_MODEL"] = "env-model"; + // eslint-disable-next-line no-underscore-dangle + const fromOpts = __testing.gatewayConfig({ model: "opts-model", reasoningEffort: null, skipGitRepoCheck: false }); + expect(fromOpts.model).toBe("opts-model"); + // eslint-disable-next-line no-underscore-dangle + const fromEnv = __testing.gatewayConfig({ model: null, reasoningEffort: null, skipGitRepoCheck: false }); + expect(fromEnv.model).toBe("env-model"); + delete process.env["CLAWPATCH_GATEWAY_MODEL"]; + // eslint-disable-next-line no-underscore-dangle + const fromDefault = __testing.gatewayConfig({ model: null, reasoningEffort: null, skipGitRepoCheck: false }); + expect(fromDefault.model).toBe("protolabs/smart"); + }); + + it("parses CLAWPATCH_GATEWAY_TIMEOUT_MS and rejects garbage values", () => { + process.env["GATEWAY_API_KEY"] = "k"; + process.env["CLAWPATCH_GATEWAY_TIMEOUT_MS"] = "12345"; + // eslint-disable-next-line no-underscore-dangle + expect(__testing.gatewayConfig({ model: null, reasoningEffort: null, skipGitRepoCheck: false }).timeoutMs).toBe(12345); + process.env["CLAWPATCH_GATEWAY_TIMEOUT_MS"] = "not-a-number"; + // eslint-disable-next-line no-underscore-dangle + expect(__testing.gatewayConfig({ model: null, reasoningEffort: null, skipGitRepoCheck: false }).timeoutMs).toBe(300000); + process.env["CLAWPATCH_GATEWAY_TIMEOUT_MS"] = "-1"; + // eslint-disable-next-line no-underscore-dangle + expect(__testing.gatewayConfig({ model: null, reasoningEffort: null, skipGitRepoCheck: false }).timeoutMs).toBe(300000); + }); +}); + +describe("gateway provider check()", () => { + const saved = { + gw: process.env["GATEWAY_API_KEY"], + oa: process.env["OPENAI_API_KEY"], + base: process.env["OPENAI_BASE_URL"], + }; + + afterEach(() => { + if (saved.gw === undefined) delete process.env["GATEWAY_API_KEY"]; + else process.env["GATEWAY_API_KEY"] = saved.gw; + if (saved.oa === undefined) delete process.env["OPENAI_API_KEY"]; + else process.env["OPENAI_API_KEY"] = saved.oa; + if (saved.base === undefined) delete process.env["OPENAI_BASE_URL"]; + else process.env["OPENAI_BASE_URL"] = saved.base; + }); + + it("returns a fingerprint string when env is configured (no network call)", async () => { + delete process.env["OPENAI_API_KEY"]; + process.env["GATEWAY_API_KEY"] = "test-key"; + process.env["OPENAI_BASE_URL"] = "https://my.gateway/v1"; + const out = await providerByName("gateway").check("/tmp"); + expect(out).toContain("gateway"); + expect(out).toContain("https://my.gateway/v1"); + expect(out).toContain("protolabs/smart"); + expect(out).not.toContain("test-key"); // never leak the secret into stdout + }); + + it("throws ClawpatchError when no API key is available", async () => { + delete process.env["GATEWAY_API_KEY"]; + delete process.env["OPENAI_API_KEY"]; + await expect(providerByName("gateway").check("/tmp")).rejects.toThrow(ClawpatchError); + }); +}); + function buildToleranceFinding(overrides: Record = {}): Record { return { title: "x", diff --git a/src/provider.ts b/src/provider.ts index 1acd3cc..ccf0553 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -281,6 +281,9 @@ export function providerByName(name: string): Provider { if (name === "claude") { return claudeProvider; } + if (name === "gateway") { + return gatewayProvider; + } if (name === "mock") { return mockProvider; } @@ -1327,6 +1330,177 @@ function piTimeoutMs(): number { return Number.isFinite(parsed) && parsed > 0 ? parsed : PI_DEFAULT_TIMEOUT_MS; } +// ── Gateway provider ───────────────────────────────────────────────────────── +// +// Talks to an OpenAI-compatible chat-completions endpoint with structured +// outputs (response_format: json_schema). Designed for the protoLabs LiteLLM +// gateway (api.proto-labs.ai/v1, internally http://gateway:4000/v1) — but +// works against any OpenAI-compatible server (vanilla OpenAI, vLLM, LM Studio, +// Ollama with OpenAI shim, etc.). +// +// Unlike the CLI-shim providers (claude, codex, acpx, ...), this provider does +// NOT shell out. The prompts built by buildReviewPrompt() etc. already inline +// the relevant file contents as Files blocks, so the gateway provider just +// POSTs the full prompt with the JSON schema and returns the parsed response. +// +// Env: +// GATEWAY_API_KEY (preferred) or OPENAI_API_KEY — Bearer token +// OPENAI_BASE_URL default: https://api.proto-labs.ai/v1 +// CLAWPATCH_GATEWAY_MODEL default: protolabs/smart +// CLAWPATCH_GATEWAY_TIMEOUT_MS default: 300000 (5 min — reasoning models +// on big features can be slow) + +const GATEWAY_DEFAULT_BASE_URL = "https://api.proto-labs.ai/v1"; +const GATEWAY_DEFAULT_MODEL = "protolabs/smart"; +const GATEWAY_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; + +function gatewayConfig(options: ProviderOptions): { + apiKey: string; + baseUrl: string; + model: string; + reasoningEffort: ReasoningEffort | null; + timeoutMs: number; +} { + const apiKey = process.env["GATEWAY_API_KEY"] ?? process.env["OPENAI_API_KEY"]; + if (!apiKey) { + throw new ClawpatchError( + "gateway provider needs GATEWAY_API_KEY or OPENAI_API_KEY in the environment", + 4, + "provider-auth", + ); + } + const rawBase = process.env["OPENAI_BASE_URL"] ?? GATEWAY_DEFAULT_BASE_URL; + const baseUrl = rawBase.replace(/\/+$/, ""); + const model = options.model ?? process.env["CLAWPATCH_GATEWAY_MODEL"] ?? GATEWAY_DEFAULT_MODEL; + const timeoutRaw = process.env["CLAWPATCH_GATEWAY_TIMEOUT_MS"] ?? process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; + const parsedTimeout = timeoutRaw === undefined ? GATEWAY_DEFAULT_TIMEOUT_MS : Number(timeoutRaw); + const timeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : GATEWAY_DEFAULT_TIMEOUT_MS; + return { apiKey, baseUrl, model, reasoningEffort: options.reasoningEffort, timeoutMs }; +} + +async function runGatewayJson( + prompt: string, + options: ProviderOptions, + schema: object, + label: string, +): Promise { + const { apiKey, baseUrl, model, reasoningEffort, timeoutMs } = gatewayConfig(options); + const body: Record = { + model, + messages: [{ role: "user", content: prompt }], + response_format: { + type: "json_schema", + json_schema: { + name: label.replace(/\s+/g, "_"), + strict: true, + schema, + }, + }, + }; + if (reasoningEffort && reasoningEffort !== "none") { + body["reasoning_effort"] = reasoningEffort; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let response: Response; + try { + response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timer); + const msg = err instanceof Error ? err.message : String(err); + throw new ClawpatchError( + `gateway ${label}: request failed (${msg})`, + 4, + "provider-failure", + ); + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + throw new ClawpatchError( + `gateway ${label}: HTTP ${response.status} ${response.statusText} — ${errText.slice(0, 500)}`, + 4, + "provider-failure", + ); + } + + const payload = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + error?: { message?: string }; + }; + if (payload.error?.message) { + throw new ClawpatchError( + `gateway ${label}: API error — ${payload.error.message}`, + 4, + "provider-failure", + ); + } + const content = payload.choices?.[0]?.message?.content; + if (!content || typeof content !== "string") { + throw new ClawpatchError( + `gateway ${label}: empty choices[0].message.content in response`, + 4, + "provider-failure", + ); + } + const extracted = extractJson(content); + if (extracted === null) { + throw new ClawpatchError( + `gateway ${label}: response was not parseable JSON (preview=${safeProviderPreview(content)})`, + 4, + "provider-failure", + ); + } + return extracted; +} + +const gatewayProvider: Provider = { + name: "gateway", + async check(): Promise { + // Auth check only — no actual fetch. Avoids spending tokens on a probe. + // gatewayConfig() throws ClawpatchError with code "provider-auth" if env + // is missing, which the caller's `clawpatch doctor` flow surfaces as the + // expected pre-flight failure. + const { baseUrl, model } = gatewayConfig({ model: null, reasoningEffort: null, skipGitRepoCheck: false }); + return `gateway model=${model} base=${baseUrl}`; + }, + async map(_root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runGatewayJson(prompt, options, agentMapJsonSchema, "agent-map"); + return parseOrThrow(agentMapOutputSchema, output, "gateway agent-map"); + }, + async review( + _root: string, + prompt: string, + options: ProviderOptions, + ): Promise { + const output = await runGatewayJson(prompt, options, reviewJsonSchema, "review"); + return parseReviewOutput(output); + }, + async fix(_root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runGatewayJson(prompt, options, fixPlanJsonSchema, "fix-plan"); + return parseOrThrow(fixPlanOutputSchema, output, "gateway fix-plan"); + }, + async revalidate( + _root: string, + prompt: string, + options: ProviderOptions, + ): Promise { + const output = await runGatewayJson(prompt, options, revalidateJsonSchema, "revalidate"); + return parseOrThrow(revalidateOutputSchema, output, "gateway revalidate"); + }, +}; + const mockProvider: Provider = { name: "mock", async check(): Promise { @@ -2251,4 +2425,5 @@ export const __testing = { piThinkingLevel, providerExitCode, providerJsonSchema, + gatewayConfig, };