Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 128 additions & 1 deletion src/provider.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -1645,13 +1645,140 @@ 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");
expect(providerByName("mock-fail").name).toBe("mock-fail");
});
});

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<string, string | undefined> = {};

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<string, unknown> = {}): Record<string, unknown> {
return {
title: "x",
Expand Down
175 changes: 175 additions & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<unknown> {
const { apiKey, baseUrl, model, reasoningEffort, timeoutMs } = gatewayConfig(options);
const body: Record<string, unknown> = {
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<string> {
// 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<AgentMapOutput> {
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<PartitionedReviewOutput> {
const output = await runGatewayJson(prompt, options, reviewJsonSchema, "review");
return parseReviewOutput(output);
},
async fix(_root: string, prompt: string, options: ProviderOptions): Promise<FixPlanOutput> {
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<RevalidateOutput> {
const output = await runGatewayJson(prompt, options, revalidateJsonSchema, "revalidate");
return parseOrThrow(revalidateOutputSchema, output, "gateway revalidate");
},
};

const mockProvider: Provider = {
name: "mock",
async check(): Promise<string> {
Expand Down Expand Up @@ -2251,4 +2425,5 @@ export const __testing = {
piThinkingLevel,
providerExitCode,
providerJsonSchema,
gatewayConfig,
};
Loading