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
15 changes: 5 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,8 @@ corepack pnpm --filter @junto/core add <package>

## 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`.
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<role>.md`.

### OpenAI-compatible endpoints

The `openai` backend calls the Chat Completions API (`POST <baseUrl>/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
Expand All @@ -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.

Expand Down
76 changes: 61 additions & 15 deletions packages/core/src/backends/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = { "content-type": "application/json" }
if (apiKey !== undefined && apiKey !== "") headers.Authorization = `Bearer ${apiKey}`

return {
async complete({ systemPrompt, userPrompt, model, timeoutMs }: CompleteInput): Promise<CompleteResult> {
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 },
Expand All @@ -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 {
Expand Down
21 changes: 17 additions & 4 deletions packages/core/src/backends/resolve.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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]
Expand All @@ -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")
}
11 changes: 10 additions & 1 deletion packages/core/src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod"
import { normalizeBaseUrl } from "./backends/openai.js"

export const SCHEMA_VERSION = 1

Expand Down Expand Up @@ -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),
Expand Down
78 changes: 77 additions & 1 deletion packages/core/test/backends/openai.test.ts
Original file line number Diff line number Diff line change
@@ -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() })

Expand Down Expand Up @@ -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<string, string>
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<string, string>).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)
})
})
Loading
Loading