From a94058edfca46a7e538785bbfa402b8adb9caebe Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Tue, 20 Oct 2026 22:46:49 +0700 Subject: [PATCH 1/2] feat: support OpenAI-compatible endpoints via backends.openai.baseUrl The openai backend can now call any server that implements Chat Completions, such as OpenRouter, Ollama, LM Studio or Gemini's OpenAI endpoint. - backends.openai.baseUrl (http(s) only, no credentials, query or fragment); JUNTO_OPENAI_BASE_URL overrides it. The default stays api.openai.com. - No key is needed for a localhost/127.0.0.1/[::1] baseUrl; no Authorization header is sent without one. Remote endpoints still require the key. - Responses without usage or model no longer throw. - README documents the four servers; CLAUDE.md status now describes 0.6.2. --- CLAUDE.md | 15 +- README.md | 34 +++- packages/core/src/backends/openai.ts | 76 +++++++-- packages/core/src/backends/resolve.ts | 21 ++- packages/core/src/schema.ts | 11 +- packages/core/test/backends/openai.test.ts | 78 +++++++++- packages/core/test/backends/resolve.test.ts | 84 +++++++++- packages/core/test/schema.test.ts | 17 ++ plugin/hooks/guard.js | 32 +++- plugin/hooks/handoff.js | 32 +++- plugin/hooks/session.js | 32 +++- plugin/hooks/state.js | 32 +++- plugin/mcp/server.js | 163 +++++++++++++------- 13 files changed, 528 insertions(+), 99 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ddc8e82..a2434c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,13 +60,8 @@ corepack pnpm --filter @junto/core add ## Current status -M0 through M4 are released: core policy/storage/gates, hooks and commands, advisory multi-model -consult/panel, API and CLI-spawned backends, deep-task panel/review phases, and repository/release -hardening. Optional bounded source context for advisory calls, code-review-graph-aware commands, and -RTK compatibility guidance are implemented without adding runtime dependencies. Version `0.4.0` -adds a read-only status tool and command backed by the same transition policy and verdict evidence. - -The three parked M3a CLI-backend findings are resolved in the current worktree: empty successful -responses are rejected, output is bounded to 1 MB per stream, and spawn/signal failures use stable -CLI-backend errors. The hardening has focused tests and must retain the same full-suite/typecheck and -committed-plugin rebuild checks as other backend changes. +The latest release is `0.6.2` (2026-10-15). Released so far: the task lifecycle with user-only plan +approval, hooks and commands, command and review gates bound to source hashes (OpenCodeReview and +host CLI reviewers), config `rules` and skills, read-only status, review reports, and advisory +consult/panel calls to Anthropic, OpenAI or a spawned CLI. `0.6.x` requires Node.js 22.12+ and +keeps `zod` on 3.x to keep hooks small and fast. Releases follow `docs/release-checklist.md`. diff --git a/README.md b/README.md index 11636fd..d2971e0 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,37 @@ command, writes the prompt to its stdin, and reads the response from stdout. Emp rejected and output is limited to 1 MB per stream. CLI backends have no token accounting (`consultBudget` cannot cap their spend). Override any role's prompt by adding `.junto/roles/.md`. +### OpenAI-compatible endpoints + +The `openai` backend calls the Chat Completions API (`POST /chat/completions`), which most +OpenAI-compatible servers implement. Set `baseUrl` to point it at one; the default is +`https://api.openai.com/v1`. Include the version path (usually `/v1`) and set `model`, because the +default `gpt-5` only exists on OpenAI. Trailing slashes are ignored; the URL must be http(s) and may +not contain credentials, a query string or a fragment. + +```json +{ "backends": { "openai": { + "apiKeyEnv": "OPENROUTER_API_KEY", + "baseUrl": "https://openrouter.ai/api/v1", + "model": "google/gemini-2.5-pro" +} } } +``` + +| Server | `baseUrl` | Key | +| --- | --- | --- | +| OpenRouter | `https://openrouter.ai/api/v1` | required | +| Gemini | `https://generativelanguage.googleapis.com/v1beta/openai/` | required (`GEMINI_API_KEY`) | +| Ollama | `http://localhost:11434/v1` | none | +| LM Studio | `http://localhost:1234/v1` | none | + +For a `baseUrl` on `localhost`, `127.0.0.1` or `[::1]`, the key is optional: if the variable named +by `apiKeyEnv` is unset, junto sends no `Authorization` header. Any other `baseUrl` still requires +the key. Local models can be slow, so raise `timeoutMs` if calls time out. Servers that do not +report token usage count as 0 tokens, so `consultBudget` cannot cap them. + +`JUNTO_OPENAI_BASE_URL` overrides `backends.openai.baseUrl` for one machine or shell, e.g. +`JUNTO_OPENAI_BASE_URL=http://localhost:11434/v1`. It needs an existing `backends.openai` entry. + See [examples/config.basic.json](examples/config.basic.json) for a gate-only setup and [examples/config.multi-model.json](examples/config.multi-model.json) for API and CLI advisory backends. Copy one to `.junto/config.json` and adjust commands and environment-variable names for @@ -201,7 +232,8 @@ Runtime log files are ignored by `.junto/.gitignore`; the rest can be retained a - Durable small, standard, and deep task lifecycles. - User-approved plans and evidence-backed quality gates. - Read-only task status with next actions, blockers, gate state, and advisory budget usage. -- Anthropic, OpenAI, and generic CLI advisory backends with project-defined roles. +- Anthropic, OpenAI (or any OpenAI-compatible endpoint), and generic CLI advisory backends with + project-defined roles. - Optional bounded source context and code-review-graph-aware planning and review. - Self-contained Claude Code plugin with Windows and Linux CI coverage. diff --git a/packages/core/src/backends/openai.ts b/packages/core/src/backends/openai.ts index 0e747ed..256ffc1 100644 --- a/packages/core/src/backends/openai.ts +++ b/packages/core/src/backends/openai.ts @@ -2,29 +2,74 @@ import type { CompleteInput, CompleteResult, ModelBackend } from "./types.js" const DEFAULT_MODEL = "gpt-5" const DEFAULT_TIMEOUT_MS = 60_000 -const API_URL = "https://api.openai.com/v1/chat/completions" +export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]) interface OpenAIResponse { - choices: { message: { content: string } }[] - usage: { total_tokens: number } - model: string + choices: { message: { content: string | null } }[] + usage?: { total_tokens?: number } + model?: string +} + +export interface OpenAIBackendOptions { + /** Any OpenAI-compatible chat completions endpoint, e.g. `http://localhost:11434/v1`. */ + baseUrl?: string } -export function openaiBackend(apiKey: string): ModelBackend { +/** + * Validates an OpenAI-compatible base URL and strips trailing slashes. The request path is + * appended to it, so a query string or fragment would end up in the wrong place, and + * embedded credentials would be persisted in config and echoed in error messages. + */ +export function normalizeBaseUrl(raw: string, label = "baseUrl"): string { + let url: URL + try { + url = new URL(raw) + } catch { + throw new Error(`${label} "${raw}" is not a valid URL. Use an http(s) URL such as https://openrouter.ai/api/v1.`) + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${label} must use http or https, not "${url.protocol}".`) + } + if (url.username !== "" || url.password !== "") { + throw new Error(`${label} must not contain credentials. Put the key in the environment variable named by apiKeyEnv.`) + } + if (url.search !== "" || url.hash !== "") { + throw new Error(`${label} must not contain a query string or fragment.`) + } + return `${url.origin}${url.pathname}`.replace(/\/+$/, "") +} + +/** Local servers such as Ollama and LM Studio accept requests without a key. */ +export function isLoopbackUrl(raw: string): boolean { + try { + return LOOPBACK_HOSTS.has(new URL(raw).hostname) + } catch { + return false + } +} + +export function openaiBackend(apiKey: string | undefined, options: OpenAIBackendOptions = {}): ModelBackend { + const baseUrl = options.baseUrl === undefined ? DEFAULT_OPENAI_BASE_URL : normalizeBaseUrl(options.baseUrl) + // Chat completions, not the Responses API: it is the endpoint OpenAI-compatible servers implement. + const apiUrl = `${baseUrl}/chat/completions` + const label = baseUrl === DEFAULT_OPENAI_BASE_URL ? "OpenAI API" : `OpenAI-compatible API at ${new URL(baseUrl).host}` + const headers: Record = { "content-type": "application/json" } + if (apiKey !== undefined && apiKey !== "") headers.Authorization = `Bearer ${apiKey}` + return { async complete({ systemPrompt, userPrompt, model, timeoutMs }: CompleteInput): Promise { const controller = new AbortController() const effectiveTimeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS const timer = setTimeout(() => controller.abort(), effectiveTimeoutMs) + const requestedModel = model ?? DEFAULT_MODEL try { - const res = await fetch(API_URL, { + const res = await fetch(apiUrl, { method: "POST", - headers: { - "Authorization": `Bearer ${apiKey}`, - "content-type": "application/json", - }, + headers, body: JSON.stringify({ - model: model ?? DEFAULT_MODEL, + model: requestedModel, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, @@ -33,18 +78,19 @@ export function openaiBackend(apiKey: string): ModelBackend { signal: controller.signal, }) if (!res.ok) { - throw new Error(`OpenAI API returned ${res.status}: ${await res.text()}`) + throw new Error(`${label} returned ${res.status}: ${await res.text()}`) } const data = await res.json() as OpenAIResponse const first = data.choices[0] + // Some compatible servers omit usage or model; such calls count as zero tokens. return { text: first?.message.content ?? "", - tokensUsed: data.usage.total_tokens, - model: data.model, + tokensUsed: data.usage?.total_tokens ?? 0, + model: data.model ?? requestedModel, } } catch (err) { if ((err as Error).name === "AbortError") { - throw new Error(`OpenAI API call timed out after ${effectiveTimeoutMs}ms.`) + throw new Error(`${label} call timed out after ${effectiveTimeoutMs}ms.`) } throw err } finally { diff --git a/packages/core/src/backends/resolve.ts b/packages/core/src/backends/resolve.ts index d32c417..3ccb7ff 100644 --- a/packages/core/src/backends/resolve.ts +++ b/packages/core/src/backends/resolve.ts @@ -1,6 +1,6 @@ import { anthropicBackend } from "./anthropic.js" import { cliBackend } from "./cli.js" -import { openaiBackend } from "./openai.js" +import { isLoopbackUrl, normalizeBaseUrl, openaiBackend } from "./openai.js" import { DEFAULT_ROLE_PROVIDER, isBuiltInRole } from "./roles.js" import type { ModelBackend } from "./types.js" import type { Config } from "../schema.js" @@ -27,16 +27,22 @@ export function resolveBackend(name: string, config: Config, root: string): Reso if (name !== "anthropic" && name !== "openai") { throw new Error(`"${name}" under "backends" is not a supported API vendor (only "anthropic" and "openai" are).`) } + if (name === "anthropic" && apiSpec.baseUrl !== undefined) { + throw new Error("backends.anthropic.baseUrl is not supported; baseUrl applies only to backends.openai.") + } + const baseUrl = name === "openai" ? openaiBaseUrl(apiSpec.baseUrl) : undefined const apiKey = process.env[apiSpec.apiKeyEnv] - if (apiKey === undefined || apiKey === "") { + const keyMissing = apiKey === undefined || apiKey === "" + // Local OpenAI-compatible servers (Ollama, LM Studio) take no key; remote endpoints always need one. + if (keyMissing && !(baseUrl !== undefined && isLoopbackUrl(baseUrl))) { throw new Error( `Environment variable "${apiSpec.apiKeyEnv}" is not set (required by backends.${name}.apiKeyEnv ` + "in .junto/config.json).", ) } return name === "anthropic" - ? { backend: anthropicBackend(apiKey), model: apiSpec.model, timeoutMs: apiSpec.timeoutMs } - : { backend: openaiBackend(apiKey), model: apiSpec.model, timeoutMs: apiSpec.timeoutMs } + ? { backend: anthropicBackend(apiKey ?? ""), model: apiSpec.model, timeoutMs: apiSpec.timeoutMs } + : { backend: openaiBackend(apiKey, { baseUrl }), model: apiSpec.model, timeoutMs: apiSpec.timeoutMs } } const cliSpec = config.cliBackends?.[name] @@ -49,3 +55,10 @@ export function resolveBackend(name: string, config: Config, root: string): Reso + `Add one under "backends" (API vendor) or "cliBackends" (spawned CLI tool).`, ) } + +/** `JUNTO_OPENAI_BASE_URL` wins over config so one machine can point at a local server. */ +function openaiBaseUrl(configured: string | undefined): string | undefined { + const fromEnv = process.env.JUNTO_OPENAI_BASE_URL + if (fromEnv !== undefined && fromEnv !== "") return normalizeBaseUrl(fromEnv, "JUNTO_OPENAI_BASE_URL") + return configured === undefined ? undefined : normalizeBaseUrl(configured, "backends.openai.baseUrl") +} diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 0469f98..72944ce 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -1,4 +1,5 @@ import { z } from "zod" +import { normalizeBaseUrl } from "./backends/openai.js" export const SCHEMA_VERSION = 1 @@ -96,7 +97,15 @@ export const backendSpecSchema = z.object({ apiKeyEnv: z.string().min(1), model: z.string().optional(), timeoutMs: z.number().int().positive().optional(), -}).strict() + baseUrl: z.string().min(1).optional(), +}).strict().superRefine((spec, ctx) => { + if (spec.baseUrl === undefined) return + try { + normalizeBaseUrl(spec.baseUrl) + } catch (error) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: (error as Error).message, path: ["baseUrl"] }) + } +}) export const cliBackendSpecSchema = z.object({ argv: z.array(z.string()).min(1), diff --git a/packages/core/test/backends/openai.test.ts b/packages/core/test/backends/openai.test.ts index 1588d05..581c260 100644 --- a/packages/core/test/backends/openai.test.ts +++ b/packages/core/test/backends/openai.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest" -import { openaiBackend } from "../../src/backends/openai.js" +import { isLoopbackUrl, normalizeBaseUrl, openaiBackend } from "../../src/backends/openai.js" afterEach(() => { vi.unstubAllGlobals() }) @@ -65,3 +65,79 @@ describe("openaiBackend", () => { } }) }) + +describe("openaiBackend — OpenAI-compatible endpoints", () => { + const okResponse = (body: unknown) => vi.fn().mockResolvedValue({ ok: true, json: async () => body }) + const chatBody = { choices: [{ message: { content: "ok" } }], usage: { total_tokens: 7 }, model: "llama3.1" } + + it("posts chat completions relative to a custom baseUrl, normalising trailing slashes", async () => { + const fetchMock = okResponse(chatBody) + vi.stubGlobal("fetch", fetchMock) + const backend = openaiBackend("k", { baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai//" }) + await backend.complete({ systemPrompt: "s", userPrompt: "u", model: "gemini-2.5-flash" }) + const [url] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe("https://generativelanguage.googleapis.com/v1beta/openai/chat/completions") + }) + + it("sends no Authorization header when no key is given", async () => { + const fetchMock = okResponse(chatBody) + vi.stubGlobal("fetch", fetchMock) + await openaiBackend(undefined, { baseUrl: "http://localhost:11434/v1" }) + .complete({ systemPrompt: "s", userPrompt: "u", model: "llama3.1" }) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe("http://localhost:11434/v1/chat/completions") + const headers = init.headers as Record + expect(Object.keys(headers).map(h => h.toLowerCase())).not.toContain("authorization") + }) + + it("sends no Authorization header for an empty key", async () => { + const fetchMock = okResponse(chatBody) + vi.stubGlobal("fetch", fetchMock) + await openaiBackend("", { baseUrl: "http://127.0.0.1:1234/v1" }).complete({ systemPrompt: "s", userPrompt: "u" }) + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(Object.keys(init.headers as Record).map(h => h.toLowerCase())).not.toContain("authorization") + }) + + it("tolerates a server that omits usage and model", async () => { + vi.stubGlobal("fetch", okResponse({ choices: [{ message: { content: "hi" } }] })) + const result = await openaiBackend(undefined, { baseUrl: "http://localhost:1234/v1" }) + .complete({ systemPrompt: "s", userPrompt: "u", model: "qwen3" }) + expect(result).toEqual({ text: "hi", tokensUsed: 0, model: "qwen3" }) + }) + + it("names the custom endpoint, never the key, in an error", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 401, text: async () => "unauthorized" })) + const call = openaiBackend("sk-secret-value", { baseUrl: "https://openrouter.ai/api/v1" }) + .complete({ systemPrompt: "s", userPrompt: "u" }) + await expect(call).rejects.toThrow(/OpenAI-compatible API at openrouter\.ai returned 401/) + await expect(call).rejects.not.toThrow(/sk-secret-value/) + }) + + it("rejects a non-http(s) baseUrl", () => { + expect(() => openaiBackend("k", { baseUrl: "ftp://example.com/v1" })).toThrow(/http/i) + expect(() => openaiBackend("k", { baseUrl: "not a url" })).toThrow(/baseUrl/i) + }) +}) + +describe("normalizeBaseUrl", () => { + it("strips trailing slashes and keeps the path", () => { + expect(normalizeBaseUrl("https://openrouter.ai/api/v1/")).toBe("https://openrouter.ai/api/v1") + expect(normalizeBaseUrl("http://localhost:11434/v1")).toBe("http://localhost:11434/v1") + }) + + it("rejects credentials, query strings and fragments", () => { + expect(() => normalizeBaseUrl("https://user:pass@example.com/v1")).toThrow(/credentials/i) + expect(() => normalizeBaseUrl("https://example.com/v1?x=1")).toThrow(/query|fragment/i) + expect(() => normalizeBaseUrl("https://example.com/v1#x")).toThrow(/query|fragment/i) + }) +}) + +describe("isLoopbackUrl", () => { + it("accepts localhost, 127.0.0.1 and ::1 only", () => { + expect(isLoopbackUrl("http://localhost:11434/v1")).toBe(true) + expect(isLoopbackUrl("http://127.0.0.1:1234/v1")).toBe(true) + expect(isLoopbackUrl("http://[::1]:1234/v1")).toBe(true) + expect(isLoopbackUrl("https://openrouter.ai/api/v1")).toBe(false) + expect(isLoopbackUrl("http://localhost.example.com/v1")).toBe(false) + }) +}) diff --git a/packages/core/test/backends/resolve.test.ts b/packages/core/test/backends/resolve.test.ts index 1f4764b..3522f89 100644 --- a/packages/core/test/backends/resolve.test.ts +++ b/packages/core/test/backends/resolve.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" import { resolveBackend, resolveRoleProvider } from "../../src/backends/resolve.js" import type { Config } from "../../src/schema.js" @@ -78,3 +78,85 @@ describe("resolveBackend — cliBackends", () => { expect(() => resolveBackend("cohere", config, "/tmp/does-not-need-to-exist")).toThrow(/not a supported api vendor/i) }) }) + +describe("resolveBackend — OpenAI-compatible baseUrl", () => { + afterEach(() => { + delete process.env.JUNTO_TEST_KEY + delete process.env.JUNTO_OPENAI_BASE_URL + vi.unstubAllGlobals() + }) + + const chatOk = () => vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [{ message: { content: "ok" } }], usage: { total_tokens: 1 }, model: "m" }), + }) + + async function calledUrl(config: Config): Promise<{ url: string, headers: Record }> { + const fetchMock = chatOk() + vi.stubGlobal("fetch", fetchMock) + await resolveBackend("openai", config, "/tmp/x").backend.complete({ systemPrompt: "s", userPrompt: "u" }) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + return { url, headers: init.headers as Record } + } + + it("uses the configured baseUrl", async () => { + process.env.JUNTO_TEST_KEY = "sk-test" + const config: Config = { + ...baseConfig, + backends: { openai: { apiKeyEnv: "JUNTO_TEST_KEY", baseUrl: "https://openrouter.ai/api/v1/" } }, + } + const { url, headers } = await calledUrl(config) + expect(url).toBe("https://openrouter.ai/api/v1/chat/completions") + expect(headers.Authorization).toBe("Bearer sk-test") + }) + + it("keeps api.openai.com when no baseUrl is set", async () => { + process.env.JUNTO_TEST_KEY = "sk-test" + const config: Config = { ...baseConfig, backends: { openai: { apiKeyEnv: "JUNTO_TEST_KEY" } } } + expect((await calledUrl(config)).url).toBe("https://api.openai.com/v1/chat/completions") + }) + + it("lets JUNTO_OPENAI_BASE_URL override the configured baseUrl", async () => { + process.env.JUNTO_TEST_KEY = "sk-test" + process.env.JUNTO_OPENAI_BASE_URL = "http://localhost:1234/v1" + const config: Config = { + ...baseConfig, + backends: { openai: { apiKeyEnv: "JUNTO_TEST_KEY", baseUrl: "https://openrouter.ai/api/v1" } }, + } + expect((await calledUrl(config)).url).toBe("http://localhost:1234/v1/chat/completions") + }) + + it("resolves a local server without a key and sends no Authorization header", async () => { + const config: Config = { + ...baseConfig, + backends: { openai: { apiKeyEnv: "JUNTO_TEST_KEY", baseUrl: "http://localhost:11434/v1" } }, + } + const { url, headers } = await calledUrl(config) + expect(url).toBe("http://localhost:11434/v1/chat/completions") + expect(Object.keys(headers).map(h => h.toLowerCase())).not.toContain("authorization") + }) + + it("still requires the key for a remote baseUrl", () => { + const config: Config = { + ...baseConfig, + backends: { openai: { apiKeyEnv: "JUNTO_TEST_KEY", baseUrl: "https://openrouter.ai/api/v1" } }, + } + expect(() => resolveBackend("openai", config, "/tmp/x")).toThrow(/JUNTO_TEST_KEY/) + }) + + it("rejects a non-http(s) JUNTO_OPENAI_BASE_URL, naming the variable", () => { + process.env.JUNTO_TEST_KEY = "sk-test" + process.env.JUNTO_OPENAI_BASE_URL = "file:///etc/passwd" + const config: Config = { ...baseConfig, backends: { openai: { apiKeyEnv: "JUNTO_TEST_KEY" } } } + expect(() => resolveBackend("openai", config, "/tmp/x")).toThrow(/JUNTO_OPENAI_BASE_URL.*http/is) + }) + + it("rejects baseUrl on the anthropic backend", () => { + process.env.JUNTO_TEST_KEY = "sk-test" + const config: Config = { + ...baseConfig, + backends: { anthropic: { apiKeyEnv: "JUNTO_TEST_KEY", baseUrl: "https://example.com/v1" } }, + } + expect(() => resolveBackend("anthropic", config, "/tmp/x")).toThrow(/baseUrl.*openai/i) + }) +}) diff --git a/packages/core/test/schema.test.ts b/packages/core/test/schema.test.ts index 6de1963..ae3494b 100644 --- a/packages/core/test/schema.test.ts +++ b/packages/core/test/schema.test.ts @@ -110,6 +110,23 @@ describe("parseConfig", () => { })).toThrow() }) + it("accepts an http(s) baseUrl on a backend", () => { + const cfg = parseConfig({ + schemaVersion: 1, + gates: {}, + backends: { openai: { apiKeyEnv: "OPENAI_API_KEY", baseUrl: "http://localhost:11434/v1" } }, + }) + expect(cfg.backends?.openai?.baseUrl).toBe("http://localhost:11434/v1") + }) + + it("rejects a non-http(s) baseUrl", () => { + expect(() => parseConfig({ + schemaVersion: 1, + gates: {}, + backends: { openai: { apiKeyEnv: "OPENAI_API_KEY", baseUrl: "ftp://example.com/v1" } }, + })).toThrow(/http/i) + }) + it("rejects a backend spec carrying a literal key value instead of an env var name", () => { expect(() => parseConfig({ schemaVersion: 1, diff --git a/plugin/hooks/guard.js b/plugin/hooks/guard.js index f428e20..1588811 100644 --- a/plugin/hooks/guard.js +++ b/plugin/hooks/guard.js @@ -4050,6 +4050,26 @@ var coerce = { }; var NEVER = INVALID; +// packages/core/src/backends/openai.ts +function normalizeBaseUrl(raw, label = "baseUrl") { + let url; + try { + url = new URL(raw); + } catch { + throw new Error(`${label} "${raw}" is not a valid URL. Use an http(s) URL such as https://openrouter.ai/api/v1.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${label} must use http or https, not "${url.protocol}".`); + } + if (url.username !== "" || url.password !== "") { + throw new Error(`${label} must not contain credentials. Put the key in the environment variable named by apiKeyEnv.`); + } + if (url.search !== "" || url.hash !== "") { + throw new Error(`${label} must not contain a query string or fragment.`); + } + return `${url.origin}${url.pathname}`.replace(/\/+$/, ""); +} + // packages/core/src/schema.ts var SCHEMA_VERSION = 1; var DEFAULT_STALE_IGNORE = ["**/*.md", "docs/**", ".junto/**"]; @@ -4130,8 +4150,16 @@ var providerSchema = external_exports.enum(["anthropic", "openai"]); var backendSpecSchema = external_exports.object({ apiKeyEnv: external_exports.string().min(1), model: external_exports.string().optional(), - timeoutMs: external_exports.number().int().positive().optional() -}).strict(); + timeoutMs: external_exports.number().int().positive().optional(), + baseUrl: external_exports.string().min(1).optional() +}).strict().superRefine((spec, ctx) => { + if (spec.baseUrl === void 0) return; + try { + normalizeBaseUrl(spec.baseUrl); + } catch (error) { + ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: error.message, path: ["baseUrl"] }); + } +}); var cliBackendSpecSchema = external_exports.object({ argv: external_exports.array(external_exports.string()).min(1), timeoutMs: external_exports.number().int().positive().optional() diff --git a/plugin/hooks/handoff.js b/plugin/hooks/handoff.js index 41438fe..f896928 100644 --- a/plugin/hooks/handoff.js +++ b/plugin/hooks/handoff.js @@ -4050,6 +4050,26 @@ var coerce = { }; var NEVER = INVALID; +// packages/core/src/backends/openai.ts +function normalizeBaseUrl(raw, label = "baseUrl") { + let url; + try { + url = new URL(raw); + } catch { + throw new Error(`${label} "${raw}" is not a valid URL. Use an http(s) URL such as https://openrouter.ai/api/v1.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${label} must use http or https, not "${url.protocol}".`); + } + if (url.username !== "" || url.password !== "") { + throw new Error(`${label} must not contain credentials. Put the key in the environment variable named by apiKeyEnv.`); + } + if (url.search !== "" || url.hash !== "") { + throw new Error(`${label} must not contain a query string or fragment.`); + } + return `${url.origin}${url.pathname}`.replace(/\/+$/, ""); +} + // packages/core/src/schema.ts var DEFAULT_STALE_IGNORE = ["**/*.md", "docs/**", ".junto/**"]; var phaseSchema = external_exports.enum(["brief", "plan", "panel", "build", "review", "verify", "done"]); @@ -4113,8 +4133,16 @@ var providerSchema = external_exports.enum(["anthropic", "openai"]); var backendSpecSchema = external_exports.object({ apiKeyEnv: external_exports.string().min(1), model: external_exports.string().optional(), - timeoutMs: external_exports.number().int().positive().optional() -}).strict(); + timeoutMs: external_exports.number().int().positive().optional(), + baseUrl: external_exports.string().min(1).optional() +}).strict().superRefine((spec, ctx) => { + if (spec.baseUrl === void 0) return; + try { + normalizeBaseUrl(spec.baseUrl); + } catch (error) { + ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: error.message, path: ["baseUrl"] }); + } +}); var cliBackendSpecSchema = external_exports.object({ argv: external_exports.array(external_exports.string()).min(1), timeoutMs: external_exports.number().int().positive().optional() diff --git a/plugin/hooks/session.js b/plugin/hooks/session.js index bc24cde..8b8e372 100644 --- a/plugin/hooks/session.js +++ b/plugin/hooks/session.js @@ -4050,6 +4050,26 @@ var coerce = { }; var NEVER = INVALID; +// packages/core/src/backends/openai.ts +function normalizeBaseUrl(raw, label = "baseUrl") { + let url; + try { + url = new URL(raw); + } catch { + throw new Error(`${label} "${raw}" is not a valid URL. Use an http(s) URL such as https://openrouter.ai/api/v1.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${label} must use http or https, not "${url.protocol}".`); + } + if (url.username !== "" || url.password !== "") { + throw new Error(`${label} must not contain credentials. Put the key in the environment variable named by apiKeyEnv.`); + } + if (url.search !== "" || url.hash !== "") { + throw new Error(`${label} must not contain a query string or fragment.`); + } + return `${url.origin}${url.pathname}`.replace(/\/+$/, ""); +} + // packages/core/src/schema.ts var SCHEMA_VERSION = 1; var DEFAULT_STALE_IGNORE = ["**/*.md", "docs/**", ".junto/**"]; @@ -4130,8 +4150,16 @@ var providerSchema = external_exports.enum(["anthropic", "openai"]); var backendSpecSchema = external_exports.object({ apiKeyEnv: external_exports.string().min(1), model: external_exports.string().optional(), - timeoutMs: external_exports.number().int().positive().optional() -}).strict(); + timeoutMs: external_exports.number().int().positive().optional(), + baseUrl: external_exports.string().min(1).optional() +}).strict().superRefine((spec, ctx) => { + if (spec.baseUrl === void 0) return; + try { + normalizeBaseUrl(spec.baseUrl); + } catch (error) { + ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: error.message, path: ["baseUrl"] }); + } +}); var cliBackendSpecSchema = external_exports.object({ argv: external_exports.array(external_exports.string()).min(1), timeoutMs: external_exports.number().int().positive().optional() diff --git a/plugin/hooks/state.js b/plugin/hooks/state.js index 7fbaa8f..688337d 100644 --- a/plugin/hooks/state.js +++ b/plugin/hooks/state.js @@ -4050,6 +4050,26 @@ var coerce = { }; var NEVER = INVALID; +// packages/core/src/backends/openai.ts +function normalizeBaseUrl(raw, label = "baseUrl") { + let url; + try { + url = new URL(raw); + } catch { + throw new Error(`${label} "${raw}" is not a valid URL. Use an http(s) URL such as https://openrouter.ai/api/v1.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${label} must use http or https, not "${url.protocol}".`); + } + if (url.username !== "" || url.password !== "") { + throw new Error(`${label} must not contain credentials. Put the key in the environment variable named by apiKeyEnv.`); + } + if (url.search !== "" || url.hash !== "") { + throw new Error(`${label} must not contain a query string or fragment.`); + } + return `${url.origin}${url.pathname}`.replace(/\/+$/, ""); +} + // packages/core/src/schema.ts var SCHEMA_VERSION = 1; var DEFAULT_STALE_IGNORE = ["**/*.md", "docs/**", ".junto/**"]; @@ -4130,8 +4150,16 @@ var providerSchema = external_exports.enum(["anthropic", "openai"]); var backendSpecSchema = external_exports.object({ apiKeyEnv: external_exports.string().min(1), model: external_exports.string().optional(), - timeoutMs: external_exports.number().int().positive().optional() -}).strict(); + timeoutMs: external_exports.number().int().positive().optional(), + baseUrl: external_exports.string().min(1).optional() +}).strict().superRefine((spec, ctx) => { + if (spec.baseUrl === void 0) return; + try { + normalizeBaseUrl(spec.baseUrl); + } catch (error) { + ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: error.message, path: ["baseUrl"] }); + } +}); var cliBackendSpecSchema = external_exports.object({ argv: external_exports.array(external_exports.string()).min(1), timeoutMs: external_exports.number().int().positive().optional() diff --git a/plugin/mcp/server.js b/plugin/mcp/server.js index db64678..a7e7348 100644 --- a/plugin/mcp/server.js +++ b/plugin/mcp/server.js @@ -18351,6 +18351,83 @@ var Server = class extends Protocol { } }; +// packages/core/src/backends/openai.ts +var DEFAULT_MODEL = "gpt-5"; +var DEFAULT_TIMEOUT_MS = 6e4; +var DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"; +var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]); +function normalizeBaseUrl(raw, label = "baseUrl") { + let url; + try { + url = new URL(raw); + } catch { + throw new Error(`${label} "${raw}" is not a valid URL. Use an http(s) URL such as https://openrouter.ai/api/v1.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${label} must use http or https, not "${url.protocol}".`); + } + if (url.username !== "" || url.password !== "") { + throw new Error(`${label} must not contain credentials. Put the key in the environment variable named by apiKeyEnv.`); + } + if (url.search !== "" || url.hash !== "") { + throw new Error(`${label} must not contain a query string or fragment.`); + } + return `${url.origin}${url.pathname}`.replace(/\/+$/, ""); +} +function isLoopbackUrl(raw) { + try { + return LOOPBACK_HOSTS.has(new URL(raw).hostname); + } catch { + return false; + } +} +function openaiBackend(apiKey, options = {}) { + const baseUrl = options.baseUrl === void 0 ? DEFAULT_OPENAI_BASE_URL : normalizeBaseUrl(options.baseUrl); + const apiUrl = `${baseUrl}/chat/completions`; + const label = baseUrl === DEFAULT_OPENAI_BASE_URL ? "OpenAI API" : `OpenAI-compatible API at ${new URL(baseUrl).host}`; + const headers = { "content-type": "application/json" }; + if (apiKey !== void 0 && apiKey !== "") headers.Authorization = `Bearer ${apiKey}`; + return { + async complete({ systemPrompt, userPrompt, model, timeoutMs }) { + const controller = new AbortController(); + const effectiveTimeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS; + const timer = setTimeout(() => controller.abort(), effectiveTimeoutMs); + const requestedModel = model ?? DEFAULT_MODEL; + try { + const res = await fetch(apiUrl, { + method: "POST", + headers, + body: JSON.stringify({ + model: requestedModel, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt } + ] + }), + signal: controller.signal + }); + if (!res.ok) { + throw new Error(`${label} returned ${res.status}: ${await res.text()}`); + } + const data = await res.json(); + const first = data.choices[0]; + return { + text: first?.message.content ?? "", + tokensUsed: data.usage?.total_tokens ?? 0, + model: data.model ?? requestedModel + }; + } catch (err) { + if (err.name === "AbortError") { + throw new Error(`${label} call timed out after ${effectiveTimeoutMs}ms.`); + } + throw err; + } finally { + clearTimeout(timer); + } + } + }; +} + // packages/core/src/schema.ts var SCHEMA_VERSION = 1; var DEFAULT_STALE_IGNORE = ["**/*.md", "docs/**", ".junto/**"]; @@ -18431,8 +18508,16 @@ var providerSchema = external_exports.enum(["anthropic", "openai"]); var backendSpecSchema = external_exports.object({ apiKeyEnv: external_exports.string().min(1), model: external_exports.string().optional(), - timeoutMs: external_exports.number().int().positive().optional() -}).strict(); + timeoutMs: external_exports.number().int().positive().optional(), + baseUrl: external_exports.string().min(1).optional() +}).strict().superRefine((spec, ctx) => { + if (spec.baseUrl === void 0) return; + try { + normalizeBaseUrl(spec.baseUrl); + } catch (error2) { + ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: error2.message, path: ["baseUrl"] }); + } +}); var cliBackendSpecSchema = external_exports.object({ argv: external_exports.array(external_exports.string()).min(1), timeoutMs: external_exports.number().int().positive().optional() @@ -26518,7 +26603,7 @@ function resolveExecutable(cmd, cwd) { // packages/core/src/gates.ts var OUTPUT_TAIL_BYTES = 8192; -var DEFAULT_TIMEOUT_MS = 3e5; +var DEFAULT_TIMEOUT_MS2 = 3e5; var VALID_GATE_NAME = /^[A-Za-z0-9._-]+$/; var WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i; function tail(text, maxBytes) { @@ -26578,7 +26663,7 @@ async function runGate(opts) { const t0 = Date.now(); const argv = spec.argv ?? []; const [cmd, ...args] = argv; - const outcome = cmd === void 0 ? skipOutcome(name, "empty argv") : resolveExecutable(cmd, cwd) ? await spawnOutcome(cmd, args, cwd, name, spec.timeoutMs ?? DEFAULT_TIMEOUT_MS) : skipOutcome(name, `command "${cmd}" does not exist or is not executable`); + const outcome = cmd === void 0 ? skipOutcome(name, "empty argv") : resolveExecutable(cmd, cwd) ? await spawnOutcome(cmd, args, cwd, name, spec.timeoutMs ?? DEFAULT_TIMEOUT_MS2) : skipOutcome(name, `command "${cmd}" does not exist or is not executable`); const dir = join4(taskDir(root, taskId), "verdicts"); mkdirSync2(dir, { recursive: true }); writeFileSync3(join4(dir, `${name}.log`), outcome.output, "utf-8"); @@ -26632,15 +26717,15 @@ function resolveRolePrompt(root, role) { } // packages/core/src/backends/anthropic.ts -var DEFAULT_MODEL = "claude-sonnet-5"; +var DEFAULT_MODEL2 = "claude-sonnet-5"; var DEFAULT_MAX_TOKENS = 4096; -var DEFAULT_TIMEOUT_MS2 = 6e4; +var DEFAULT_TIMEOUT_MS3 = 6e4; var API_URL = "https://api.anthropic.com/v1/messages"; function anthropicBackend(apiKey) { return { async complete({ systemPrompt, userPrompt, model, timeoutMs }) { const controller = new AbortController(); - const effectiveTimeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS2; + const effectiveTimeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS3; const timer = setTimeout(() => controller.abort(), effectiveTimeoutMs); try { const res = await fetch(API_URL, { @@ -26651,7 +26736,7 @@ function anthropicBackend(apiKey) { "content-type": "application/json" }, body: JSON.stringify({ - model: model ?? DEFAULT_MODEL, + model: model ?? DEFAULT_MODEL2, max_tokens: DEFAULT_MAX_TOKENS, system: systemPrompt, messages: [{ role: "user", content: userPrompt }] @@ -26676,54 +26761,6 @@ function anthropicBackend(apiKey) { }; } -// packages/core/src/backends/openai.ts -var DEFAULT_MODEL2 = "gpt-5"; -var DEFAULT_TIMEOUT_MS3 = 6e4; -var API_URL2 = "https://api.openai.com/v1/chat/completions"; -function openaiBackend(apiKey) { - return { - async complete({ systemPrompt, userPrompt, model, timeoutMs }) { - const controller = new AbortController(); - const effectiveTimeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS3; - const timer = setTimeout(() => controller.abort(), effectiveTimeoutMs); - try { - const res = await fetch(API_URL2, { - method: "POST", - headers: { - "Authorization": `Bearer ${apiKey}`, - "content-type": "application/json" - }, - body: JSON.stringify({ - model: model ?? DEFAULT_MODEL2, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt } - ] - }), - signal: controller.signal - }); - if (!res.ok) { - throw new Error(`OpenAI API returned ${res.status}: ${await res.text()}`); - } - const data = await res.json(); - const first = data.choices[0]; - return { - text: first?.message.content ?? "", - tokensUsed: data.usage.total_tokens, - model: data.model - }; - } catch (err) { - if (err.name === "AbortError") { - throw new Error(`OpenAI API call timed out after ${effectiveTimeoutMs}ms.`); - } - throw err; - } finally { - clearTimeout(timer); - } - } - }; -} - // packages/core/src/backends/cli.ts var DEFAULT_CLI_TIMEOUT_MS = 12e4; var MAX_CLI_OUTPUT_BYTES = 1e6; @@ -26788,13 +26825,18 @@ function resolveBackend(name, config2, root) { if (name !== "anthropic" && name !== "openai") { throw new Error(`"${name}" under "backends" is not a supported API vendor (only "anthropic" and "openai" are).`); } + if (name === "anthropic" && apiSpec.baseUrl !== void 0) { + throw new Error("backends.anthropic.baseUrl is not supported; baseUrl applies only to backends.openai."); + } + const baseUrl = name === "openai" ? openaiBaseUrl(apiSpec.baseUrl) : void 0; const apiKey = process.env[apiSpec.apiKeyEnv]; - if (apiKey === void 0 || apiKey === "") { + const keyMissing = apiKey === void 0 || apiKey === ""; + if (keyMissing && !(baseUrl !== void 0 && isLoopbackUrl(baseUrl))) { throw new Error( `Environment variable "${apiSpec.apiKeyEnv}" is not set (required by backends.${name}.apiKeyEnv in .junto/config.json).` ); } - return name === "anthropic" ? { backend: anthropicBackend(apiKey), model: apiSpec.model, timeoutMs: apiSpec.timeoutMs } : { backend: openaiBackend(apiKey), model: apiSpec.model, timeoutMs: apiSpec.timeoutMs }; + return name === "anthropic" ? { backend: anthropicBackend(apiKey ?? ""), model: apiSpec.model, timeoutMs: apiSpec.timeoutMs } : { backend: openaiBackend(apiKey, { baseUrl }), model: apiSpec.model, timeoutMs: apiSpec.timeoutMs }; } const cliSpec = config2.cliBackends?.[name]; if (cliSpec !== void 0) { @@ -26804,6 +26846,11 @@ function resolveBackend(name, config2, root) { `No "${name}" entry under "backends" or "cliBackends" in .junto/config.json. Add one under "backends" (API vendor) or "cliBackends" (spawned CLI tool).` ); } +function openaiBaseUrl(configured) { + const fromEnv = process.env.JUNTO_OPENAI_BASE_URL; + if (fromEnv !== void 0 && fromEnv !== "") return normalizeBaseUrl(fromEnv, "JUNTO_OPENAI_BASE_URL"); + return configured === void 0 ? void 0 : normalizeBaseUrl(configured, "backends.openai.baseUrl"); +} // packages/core/src/changes.ts var ChangedFilesError = class extends Error { From d6cc5bfa34643d47b094b0890ca46882e26fe16d Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Tue, 20 Oct 2026 22:48:44 +0700 Subject: [PATCH 2/2] test: isolate baseUrl tests from an exported JUNTO_OPENAI_BASE_URL --- packages/core/test/backends/resolve.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/test/backends/resolve.test.ts b/packages/core/test/backends/resolve.test.ts index 3522f89..cdc6b8e 100644 --- a/packages/core/test/backends/resolve.test.ts +++ b/packages/core/test/backends/resolve.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { resolveBackend, resolveRoleProvider } from "../../src/backends/resolve.js" import type { Config } from "../../src/schema.js" @@ -80,6 +80,8 @@ describe("resolveBackend — cliBackends", () => { }) describe("resolveBackend — OpenAI-compatible baseUrl", () => { + // A developer's shell may export the override; these tests must not inherit it. + beforeEach(() => { delete process.env.JUNTO_OPENAI_BASE_URL }) afterEach(() => { delete process.env.JUNTO_TEST_KEY delete process.env.JUNTO_OPENAI_BASE_URL