Merge pull request #847 from fabro-sh/lithos-llm-migration

Replace fabro-llm internals and fabro-model with lithos-llm
This commit is contained in:
Bryan Helmkamp 2026-09-10 08:09:19 -06:00 committed by GitHub
commit 1bb387a0e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
506 changed files with 11509 additions and 69999 deletions

View file

@ -15,9 +15,6 @@ leak-timeout = "500ms"
filter = "package(fabro-workflow)"
slow-timeout = { period = "2s", terminate-after = 3 }
[[profile.default.overrides]]
filter = "package(twin-openai) & test(debug_page_renders_in_headless_chrome)"
slow-timeout = { period = "30s", terminate-after = 1 }
[profile.e2e]
# E2E (ignored) tests: flag SLOW after 10s, hard-kill after 30s
@ -48,6 +45,3 @@ leak-timeout = "2s"
filter = "package(fabro-workflow)"
slow-timeout = { period = "30s", terminate-after = 4 }
[[profile.ci.overrides]]
filter = "package(twin-openai) & test(debug_page_renders_in_headless_chrome)"
slow-timeout = { period = "60s", terminate-after = 2 }

View file

@ -131,7 +131,7 @@ jobs:
# in twin mode; widen as the remaining suites are fixed up for CI.
# Must not use the e2e nextest profile here: NEXTEST_PROFILE=e2e implies
# strict mode, which fails (rather than skips) live tests without keys.
- run: cargo nextest run --locked --workspace --status-level slow --profile ci --run-ignored only -E 'package(fabro-agent) + package(fabro-llm) + package(twin-openai)'
- run: cargo nextest run --locked --workspace --status-level slow --profile ci --run-ignored only -E 'package(fabro-agent) + package(fabro-llm)'
test-macos:
name: Test (macOS)

135
Cargo.lock generated
View file

@ -660,17 +660,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "aws-smithy-eventstream"
version = "0.60.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78d8391e65fcea47c586a22e1a41f173b38615b112b2c6b7a44e80cec3e6b706"
dependencies = [
"aws-smithy-types",
"bytes",
"crc32fast",
]
[[package]]
name = "aws-smithy-http"
version = "0.63.6"
@ -1872,7 +1861,7 @@ name = "daytona-api-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
dependencies = [
"reqwest 0.13.2",
"reqwest 0.13.4",
"reqwest-middleware",
"serde",
"serde_json",
@ -1889,7 +1878,7 @@ dependencies = [
"daytona-api-client",
"daytona-toolbox-client",
"futures-util",
"reqwest 0.13.2",
"reqwest 0.13.4",
"reqwest-middleware",
"rustls",
"serde",
@ -1906,7 +1895,7 @@ name = "daytona-toolbox-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
dependencies = [
"reqwest 0.13.2",
"reqwest 0.13.4",
"reqwest-middleware",
"serde",
"serde_json",
@ -2289,7 +2278,6 @@ dependencies = [
"fabro-llm",
"fabro-macros",
"fabro-mcp",
"fabro-model",
"fabro-sandbox",
"fabro-static",
"fabro-template",
@ -2329,14 +2317,13 @@ dependencies = [
"fabro-automation",
"fabro-config",
"fabro-environment",
"fabro-model",
"fabro-types",
"openapiv3",
"prettyplease",
"progenitor",
"progenitor-client",
"regress",
"reqwest 0.13.2",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_yaml",
@ -2353,13 +2340,13 @@ dependencies = [
"base64",
"chrono",
"fabro-http",
"fabro-model",
"fabro-oauth",
"fabro-redact",
"fabro-static",
"fabro-types",
"fabro-vault",
"httpmock",
"lithos-llm",
"serde",
"serde_json",
"tempfile",
@ -2451,7 +2438,6 @@ dependencies = [
"fabro-manifest",
"fabro-mcp",
"fabro-mcp-server",
"fabro-model",
"fabro-oauth",
"fabro-proc",
"fabro-redact",
@ -2486,7 +2472,7 @@ dependencies = [
"progenitor-client",
"rand 0.9.4",
"regex",
"reqwest 0.13.2",
"reqwest 0.13.4",
"ring",
"rustls",
"scopeguard",
@ -2521,7 +2507,6 @@ dependencies = [
"chrono",
"fabro-api",
"fabro-http",
"fabro-model",
"fabro-static",
"fabro-types",
"fabro-util",
@ -2550,7 +2535,6 @@ dependencies = [
"clap",
"dirs",
"fabro-macros",
"fabro-model",
"fabro-options-metadata",
"fabro-proc",
"fabro-static",
@ -2704,7 +2688,6 @@ dependencies = [
"fabro-auth",
"fabro-http",
"fabro-llm",
"fabro-model",
"fabro-redact",
"fabro-types",
"fabro-util",
@ -2724,7 +2707,7 @@ version = "0.348.0-nightly.0"
dependencies = [
"fabro-static",
"http 1.4.0",
"reqwest 0.13.2",
"reqwest 0.13.4",
"thiserror 2.0.18",
]
@ -2768,40 +2751,30 @@ version = "0.348.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
"aws-config",
"aws-credential-types",
"aws-sigv4",
"aws-smithy-eventstream",
"aws-smithy-runtime-api",
"aws-smithy-types",
"base64",
"bytes",
"fabro-auth",
"fabro-config",
"fabro-http",
"fabro-llm",
"fabro-macros",
"fabro-model",
"fabro-redact",
"fabro-static",
"fabro-test",
"fabro-types",
"fabro-util",
"futures",
"http 1.4.0",
"httpmock",
"insta",
"rand 0.9.4",
"lithos-llm",
"mime_guess",
"serde",
"serde_json",
"sha2 0.10.9",
"strum 0.28.0",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-util",
"toml 0.8.23",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
@ -2869,7 +2842,6 @@ dependencies = [
"fabro-client",
"fabro-config",
"fabro-manifest",
"fabro-model",
"fabro-server",
"fabro-tool",
"fabro-types",
@ -2905,23 +2877,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "fabro-model"
version = "0.348.0-nightly.0"
dependencies = [
"clap",
"fabro-static",
"http 1.4.0",
"insta",
"rust-embed",
"serde",
"serde_json",
"strum 0.28.0",
"thiserror 2.0.18",
"toml 0.8.23",
"tracing",
]
[[package]]
name = "fabro-oauth"
version = "0.348.0-nightly.0"
@ -3056,7 +3011,6 @@ dependencies = [
"fabro-macros",
"fabro-manifest",
"fabro-mcp-store",
"fabro-model",
"fabro-proc",
"fabro-redact",
"fabro-sandbox",
@ -3288,10 +3242,10 @@ dependencies = [
"chrono",
"clap",
"dirs",
"fabro-model",
"fabro-types",
"fabro-util",
"hex",
"lithos-llm",
"serde",
"serde_json",
"sha2 0.10.9",
@ -3333,7 +3287,7 @@ version = "0.348.0-nightly.0"
dependencies = [
"fabro-acp",
"fabro-graphviz",
"fabro-model",
"fabro-llm",
"fabro-types",
"serde",
"thiserror 2.0.18",
@ -3404,7 +3358,6 @@ dependencies = [
"fabro-llm",
"fabro-macros",
"fabro-mcp",
"fabro-model",
"fabro-redact",
"fabro-sandbox",
"fabro-static",
@ -4996,6 +4949,31 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "lithos-llm"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/lithos-llm?rev=38ccb14f08c56382e4af3888704de2f0951c910a#38ccb14f08c56382e4af3888704de2f0951c910a"
dependencies = [
"async-trait",
"aws-config",
"aws-credential-types",
"aws-sigv4",
"aws-smithy-runtime-api",
"aws-smithy-types",
"base64",
"crc32fast",
"futures-core",
"futures-util",
"reqwest 0.13.4",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"toml 0.8.23",
"tracing",
"uuid",
]
[[package]]
name = "litrs"
version = "1.0.0"
@ -6298,7 +6276,7 @@ dependencies = [
"bytes",
"futures-core",
"percent-encoding",
"reqwest 0.13.2",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_urlencoded",
@ -6699,9 +6677,9 @@ dependencies = [
[[package]]
name = "reqwest"
version = "0.13.2"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64",
"bytes",
@ -6757,7 +6735,7 @@ dependencies = [
"anyhow",
"async-trait",
"http 1.4.0",
"reqwest 0.13.2",
"reqwest 0.13.4",
"serde",
"thiserror 2.0.18",
"tower-service",
@ -6797,7 +6775,7 @@ dependencies = [
"pastey",
"pin-project-lite",
"process-wrap",
"reqwest 0.13.2",
"reqwest 0.13.4",
"rmcp-macros",
"schemars 1.2.1",
"serde",
@ -8603,6 +8581,21 @@ dependencies = [
"utf-8",
]
[[package]]
name = "twin-core"
version = "0.1.5"
source = "git+https://github.com/lithoscomputer/twins?rev=ca45f0e50a6716d716aa2f638ca3cf767e88f613#ca45f0e50a6716d716aa2f638ca3cf767e88f613"
dependencies = [
"anyhow",
"async-stream",
"axum",
"futures-util",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "twin-github"
version = "0.348.0-nightly.0"
@ -8612,7 +8605,7 @@ dependencies = [
"chrono",
"fabro-http",
"jsonwebtoken",
"reqwest 0.13.2",
"reqwest 0.13.4",
"serde",
"serde_json",
"tempfile",
@ -8624,21 +8617,21 @@ dependencies = [
[[package]]
name = "twin-openai"
version = "0.348.0-nightly.0"
version = "0.1.5"
source = "git+https://github.com/lithoscomputer/twins?rev=ca45f0e50a6716d716aa2f638ca3cf767e88f613#ca45f0e50a6716d716aa2f638ca3cf767e88f613"
dependencies = [
"anyhow",
"async-stream",
"axum",
"fabro-http",
"fabro-static",
"futures-util",
"http 1.4.0",
"reqwest 0.13.4",
"serde",
"serde_json",
"tokio",
"tower",
"tracing",
"tracing-subscriber",
"twin-core",
]
[[package]]

View file

@ -3,7 +3,6 @@ members = [
"lib/apps/*",
"lib/components/*",
"lib/foundation/*",
"test/twin/openai",
"test/twin/github",
]
default-members = ["lib/apps/fabro-cli"]
@ -93,7 +92,12 @@ sha2 = "0.10"
hex = "0.4"
insta = "1"
fabro-test = { path = "lib/foundation/fabro-test" }
twin-openai = { path = "test/twin/openai" }
# Provider-neutral LLM catalog and client. Pinned to a revision until 0.x is
# published to crates.io.
lithos-llm = { git = "https://github.com/lithoscomputer/lithos-llm", rev = "38ccb14f08c56382e4af3888704de2f0951c910a", default-features = false }
# Deterministic OpenAI twin used by twin-mode E2E tests; the same revision
# lithos-llm verifies its codecs against.
twin-openai = { git = "https://github.com/lithoscomputer/twins", rev = "ca45f0e50a6716d716aa2f638ca3cf767e88f613" }
twin-github = { path = "test/twin/github" }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"

View file

@ -66,8 +66,8 @@ describe("createScriptedAdapter", () => {
.map((p) => p.text ?? "")
.join("");
const expectedText = SCRIPTED_REPLIES[0]!.content
.filter((p) => p.kind === "text")
.map((p) => p.data.text)
.filter((p) => p.type === "text")
.map((p) => (p.type === "text" ? p.text : ""))
.join("");
expect(finalText).toBe(expectedText);
});
@ -89,7 +89,7 @@ describe("createScriptedAdapter", () => {
describe("toThreadMessages", () => {
test("converts a user text message", () => {
const out = toThreadMessages([
{ role: "user", content: [{ kind: "text", data: { text: "hi" } }] },
{ role: "user", content: [{ type: "text", text: "hi" }] },
]);
expect(out).toEqual([
{ role: "user", content: [{ type: "text", text: "hi" }] },
@ -102,16 +102,15 @@ describe("toThreadMessages", () => {
role: "assistant",
content: [
{
kind: "tool_call",
data: {
tool_call_id: "t1",
name: "search",
arguments: { q: "hello" },
},
type: "tool_call",
id: "t1",
name: "search",
input: { type: "function", arguments: { q: "hello" } },
},
{
kind: "tool_result",
data: { tool_call_id: "t1", content: { ok: true } },
type: "tool_result",
tool_call_id: "t1",
content: [{ type: "text", text: "{\"ok\":true}" }],
},
],
},
@ -126,6 +125,6 @@ describe("toThreadMessages", () => {
expect(first?.type).toBe("tool-call");
if (first?.type !== "tool-call") throw new Error("expected tool-call part");
expect(first.toolCallId).toBe("t1");
expect(first.result).toEqual({ ok: true });
expect(first.result).toEqual('{"ok":true}');
});
});

View file

@ -5,7 +5,12 @@ import type {
ThreadMessageLike,
} from "@assistant-ui/react";
import type { Chat, ChatContentPart, ChatMessage } from "./chats-types";
import type {
Chat,
ChatContentPart,
ChatMessage,
JsonValue,
} from "./chats-types";
import { pickReply } from "./chats-script";
const STREAM_CHUNK_CHARS = 28;
@ -29,29 +34,38 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
});
}
function toolResultValue(content: readonly ChatContentPart[]): JsonValue {
const texts = content.flatMap((part) =>
part.type === "text" ? [part.text] : [],
);
return texts.length === content.length
? texts.join("")
: (JSON.parse(JSON.stringify(content)) as JsonValue);
}
function toAssistantParts(
content: readonly ChatContentPart[],
): ThreadAssistantMessagePart[] {
const out: ThreadAssistantMessagePart[] = [];
for (const part of content) {
if (part.kind === "text") {
out.push({ type: "text", text: part.data.text });
} else if (part.kind === "tool_call") {
if (part.type === "text") {
out.push({ type: "text", text: part.text });
} else if (part.type === "tool_call") {
out.push({
type: "tool-call",
toolCallId: part.data.tool_call_id,
toolName: part.data.name,
args: part.data.arguments,
argsText: JSON.stringify(part.data.arguments),
toolCallId: part.id,
toolName: part.name,
args: part.input.arguments,
argsText: JSON.stringify(part.input.arguments),
});
} else if (part.kind === "tool_result") {
} else if (part.type === "tool_result") {
for (let i = out.length - 1; i >= 0; i--) {
const candidate = out[i];
if (
candidate?.type === "tool-call" &&
candidate.toolCallId === part.data.tool_call_id
candidate.toolCallId === part.tool_call_id
) {
out[i] = { ...candidate, result: part.data.content };
out[i] = { ...candidate, result: toolResultValue(part.content) };
break;
}
}
@ -71,16 +85,16 @@ export function createScriptedAdapter(args: {
const accumulated: ChatContentPart[] = [];
for (const part of reply.content) {
if (part.kind === "text") {
const text = part.data.text;
if (part.type === "text") {
const text = part.text;
let cursor = 0;
accumulated.push({ kind: "text", data: { text: "" } });
accumulated.push({ type: "text", text: "" });
const accIndex = accumulated.length - 1;
while (cursor < text.length) {
cursor = Math.min(cursor + STREAM_CHUNK_CHARS, text.length);
accumulated[accIndex] = {
kind: "text",
data: { text: text.slice(0, cursor) },
type: "text",
text: text.slice(0, cursor),
};
yield buildUpdate(accumulated);
if (cursor < text.length) {
@ -110,8 +124,8 @@ export function toThreadMessages(
if (msg.role === "user") {
const content = [];
for (const part of msg.content) {
if (part.kind === "text") {
content.push({ type: "text", text: part.data.text } as const);
if (part.type === "text") {
content.push({ type: "text", text: part.text } as const);
}
}
return {

View file

@ -1,4 +1,16 @@
import type { ChatMessage } from "./chats-types";
import type { ChatContentPart, ChatMessage } from "./chats-types";
function text(value: string): ChatContentPart {
return { type: "text", text: value };
}
function toolResult(toolCallId: string, value: unknown): ChatContentPart {
return {
type: "tool_result",
tool_call_id: toolCallId,
content: [text(JSON.stringify(value))],
};
}
/**
* Scripted assistant replies cycled through per chat. Generic content,
@ -10,176 +22,130 @@ export const SCRIPTED_REPLIES: ChatMessage[] = [
{
role: "assistant",
content: [
{
kind: "text",
data: {
text:
"Hi! I'm a scripted prototype reply. A few things I can show off:\n\n" +
"- Markdown rendering (lists, **bold**, *italics*, `code`)\n" +
"- Streaming text appearing incrementally\n" +
"- Tool calls with arguments and results\n" +
"- Multi-paragraph responses with code blocks\n\n" +
"Send another message to see the next response in the bank.",
},
},
text(
"Hi! I'm a scripted prototype reply. A few things I can show off:\n\n" +
"- Markdown rendering (lists, **bold**, *italics*, `code`)\n" +
"- Streaming text appearing incrementally\n" +
"- Tool calls with arguments and results\n" +
"- Multi-paragraph responses with code blocks\n\n" +
"Send another message to see the next response in the bank.",
),
],
},
{
role: "assistant",
content: [
{
kind: "text",
data: {
text:
"Here's a TypeScript snippet that debounces a function:\n\n" +
"```ts\n" +
"export function debounce<T extends (...args: any[]) => void>(\n" +
" fn: T,\n" +
" ms: number,\n" +
"): (...args: Parameters<T>) => void {\n" +
" let handle: ReturnType<typeof setTimeout> | undefined;\n" +
" return (...args) => {\n" +
" if (handle) clearTimeout(handle);\n" +
" handle = setTimeout(() => fn(...args), ms);\n" +
" };\n" +
"}\n" +
"```\n\n" +
"The trailing-edge variant is the most common; a leading-edge variant fires immediately then suppresses subsequent calls.",
},
},
text(
"Here's a TypeScript snippet that debounces a function:\n\n" +
"```ts\n" +
"export function debounce<T extends (...args: any[]) => void>(\n" +
" fn: T,\n" +
" ms: number,\n" +
"): (...args: Parameters<T>) => void {\n" +
" let handle: ReturnType<typeof setTimeout> | undefined;\n" +
" return (...args) => {\n" +
" if (handle) clearTimeout(handle);\n" +
" handle = setTimeout(() => fn(...args), ms);\n" +
" };\n" +
"}\n" +
"```\n\n" +
"The trailing-edge variant is the most common; a leading-edge variant fires immediately then suppresses subsequent calls.",
),
],
},
{
role: "assistant",
content: [
text("Let me search for that real quick."),
{
kind: "text",
data: {
text: "Let me search for that real quick.",
},
},
{
kind: "tool_call",
data: {
tool_call_id: "call_search_1",
name: "search_web",
type: "tool_call",
id: "call_search_1",
name: "search_web",
input: {
type: "function",
arguments: {
query: "current best practices for rate limiting an HTTP API",
max_results: 5,
},
},
},
{
kind: "tool_result",
data: {
tool_call_id: "call_search_1",
content: {
results: [
{
title: "Token bucket vs leaky bucket",
url: "https://example.com/rate-limit-algorithms",
snippet:
"Token bucket allows bursts, leaky bucket smooths traffic.",
},
{
title: "Distributed rate limiting with Redis",
url: "https://example.com/redis-rate-limit",
snippet:
"INCR + EXPIRE is the simplest fixed-window approach.",
},
],
toolResult("call_search_1", {
results: [
{
title: "Token bucket vs leaky bucket",
url: "https://example.com/rate-limit-algorithms",
snippet: "Token bucket allows bursts, leaky bucket smooths traffic.",
},
},
},
{
kind: "text",
data: {
text:
"\n\nTwo solid starting points. For most APIs, a Redis-backed sliding window keyed by API key gives you per-tenant fairness without a lot of moving parts. For burst tolerance, a token-bucket per route is a nice layer on top.",
},
},
{
title: "Distributed rate limiting with Redis",
url: "https://example.com/redis-rate-limit",
snippet: "INCR + EXPIRE is the simplest fixed-window approach.",
},
],
}),
text(
"\n\nTwo solid starting points. For most APIs, a Redis-backed sliding window keyed by API key gives you per-tenant fairness without a lot of moving parts. For burst tolerance, a token-bucket per route is a nice layer on top.",
),
],
},
{
role: "assistant",
content: [
{
kind: "text",
data: {
text:
"## The 4-fold path of refactoring a hook\n\n" +
"When a React hook starts feeling tangled, work the corners in order:\n\n" +
"### 1. Extract pure computation\n" +
"Anything that is a function of inputs (no side effects, no state) leaves the hook entirely.\n\n" +
"### 2. Collapse derived state into `useMemo`\n" +
"State that is computable from other state shouldn't be its own state.\n\n" +
"### 3. Split orthogonal concerns into sibling hooks\n" +
"If two effects don't share dependencies, they don't belong in the same hook.\n\n" +
"### 4. Promote to a reducer\n" +
"Once there are 3+ related `useState` calls coordinating updates, `useReducer` makes the state machine explicit.\n\n" +
"> The honest test: can you write a one-sentence description of what the hook is responsible for? If not, it's doing too much.",
},
},
text(
"## The 4-fold path of refactoring a hook\n\n" +
"When a React hook starts feeling tangled, work the corners in order:\n\n" +
"### 1. Extract pure computation\n" +
"Anything that is a function of inputs (no side effects, no state) leaves the hook entirely.\n\n" +
"### 2. Collapse derived state into `useMemo`\n" +
"State that is computable from other state shouldn't be its own state.\n\n" +
"### 3. Split orthogonal concerns into sibling hooks\n" +
"If two effects don't share dependencies, they don't belong in the same hook.\n\n" +
"### 4. Promote to a reducer\n" +
"Once there are 3+ related `useState` calls coordinating updates, `useReducer` makes the state machine explicit.\n\n" +
"> The honest test: can you write a one-sentence description of what the hook is responsible for? If not, it's doing too much.",
),
],
},
{
role: "assistant",
content: [
text("I'll compute that for you."),
{
kind: "text",
data: {
text: "I'll compute that for you.",
},
},
{
kind: "tool_call",
data: {
tool_call_id: "call_calc_1",
name: "run_calculation",
type: "tool_call",
id: "call_calc_1",
name: "run_calculation",
input: {
type: "function",
arguments: {
expression: "compound_interest(principal=10000, rate=0.05, years=10)",
},
},
},
{
kind: "tool_result",
data: {
tool_call_id: "call_calc_1",
content: {
value: 16288.95,
currency: "USD",
note: "Annual compounding; rounded to cents.",
},
},
},
{
kind: "text",
data: {
text:
"\n\n**$16,288.95** after 10 years. Bumping the rate to 7% would put you at roughly $19,672, and continuous compounding at 5% lands at $16,487 — so the extra two points of rate matters more than the compounding cadence.",
},
},
toolResult("call_calc_1", {
value: 16288.95,
currency: "USD",
note: "Annual compounding; rounded to cents.",
}),
text(
"\n\n**$16,288.95** after 10 years. Bumping the rate to 7% would put you at roughly $19,672, and continuous compounding at 5% lands at $16,487 — so the extra two points of rate matters more than the compounding cadence.",
),
],
},
{
role: "assistant",
content: [
{
kind: "text",
data: {
text:
"Good question. The short answer: it depends on whether you need transactions across multiple writes.\n\n" +
"If you do — Postgres. If everything you do is single-row, SQLite is faster, simpler to operate, and easier to back up. A surprising amount of production traffic can live happily on SQLite if you accept its one-writer-at-a-time constraint.\n\n" +
"Next step: tell me about your read/write ratio and I can be more specific.",
},
},
text(
"Good question. The short answer: it depends on whether you need transactions across multiple writes.\n\n" +
"If you do — Postgres. If everything you do is single-row, SQLite is faster, simpler to operate, and easier to back up. A surprising amount of production traffic can live happily on SQLite if you accept its one-writer-at-a-time constraint.\n\n" +
"Next step: tell me about your read/write ratio and I can be more specific.",
),
],
},
];
const FALLBACK_REPLY: ChatMessage = {
role: "assistant",
content: [{ kind: "text", data: { text: "(No reply available.)" } }],
content: [text("(No reply available.)")],
};
export function pickReply(scriptIndex: number): ChatMessage {

View file

@ -38,8 +38,8 @@ describe("chats-store reducer", () => {
expect(chat?.seedMessages).toHaveLength(1);
expect(chat?.seedMessages[0]?.role).toBe("user");
expect(chat?.seedMessages[0]?.content[0]).toEqual({
kind: "text",
data: { text: "Help me with React" },
type: "text",
text: "Help me with React",
});
});

View file

@ -38,7 +38,7 @@ function deriveTitle(text: string): string {
function userMessage(text: string): ChatMessage {
return {
role: "user",
content: [{ kind: "text", data: { text } }],
content: [{ type: "text", text }],
};
}

View file

@ -1,26 +1,22 @@
/**
* Stricter discriminated-union view over @qltysh/fabro-api-client's
* `CompletionContentPart` ({ kind: string; data: any }). Each variant in our
* union is assignable to the API client type at the boundary, but inside the
* chat code we get exhaustive switch checking.
* `CompletionContentPart`, the lithos `ContentPart` wire shape discriminated
* by `type`. Each variant in our union is assignable to the API client type
* at the boundary, but inside the chat code we get exhaustive switch checking.
*/
export type ChatContentPart =
| { kind: "text"; data: { text: string } }
| { type: "text"; text: string }
| {
kind: "tool_call";
data: {
tool_call_id: string;
name: string;
arguments: { [key: string]: JsonValue };
};
type: "tool_call";
id: string;
name: string;
input: { type: "function"; arguments: { [key: string]: JsonValue } };
}
| {
kind: "tool_result";
data: {
tool_call_id: string;
content: JsonValue;
is_error?: boolean;
};
type: "tool_result";
tool_call_id: string;
content: ChatContentPart[];
is_error?: boolean;
};
export type JsonValue =

View file

@ -35,7 +35,7 @@ function formatUsdMicrosOrDash(usdMicros?: number | null): string {
function formatModelRef(model?: BillingModelRef | null): string | null {
if (!model) return null;
const speed = model.speed && model.speed !== "standard" ? ` · ${model.speed}` : "";
const speed = model.speed ? ` · ${model.speed}` : "";
return `${model.provider}:${model.model_id}${speed}`;
}

View file

@ -10,8 +10,7 @@ This document defines how Fabro resolves LLM credentials and constructs `fabro-l
- Standalone setup and tests that use default settings build a default `Arc<Catalog>` locally, then pass it explicitly.
- `GenerateParams::new(model, client)` always receives an explicit `Arc<Client>`.
- When a caller needs diagnostics in runtime request-serving paths, call `source.resolve(catalog)` directly and consume both `credentials` and `auth_issues`.
- `EnvCredentialSource` is the env-backed source for env-only or no-vault contexts.
- `VaultCredentialSource` is the normal source for vault-backed runtime contexts.
- `VaultCredentialSource` is the normal source for vault-backed runtime contexts; `VaultCredentialSource::environment_only()` serves env-only or no-vault contexts.
## Why

View file

@ -43,11 +43,10 @@ the vault:
`FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY` are removed. `SESSION_SECRET` is the single auth root.
Provisioning into the vault is not the same as the resolver being vault-only. `CredentialResolver`
owns a documented process-env fallback that runs after the vault lookup
(`lib/foundation/fabro-auth/src/resolve.rs:198-204`), and `CredentialRef::Env(name)` is a
first-class credential source (`resolve.rs:350`). Which paths that fallback is live on is a
per-process question:
Provisioning into the vault is not the same as the resolver being vault-only. `VaultCredentialSource`
(`lib/foundation/fabro-auth/src/vault_source.rs`) reads each secret name lithos-llm asks for from
the process environment first and the vault second, under the same conventional names. Which paths
that environment lookup is live on is a per-process question:
- **Server process** — inert. `lib/apps/fabro-server/src/server.rs:2453` builds
`SqlVaultCredentialSource::vault_only(...)`, so the env lookup always returns `None`.
@ -85,11 +84,12 @@ consumption time) and `vars` (non-sensitive run variables, substituted early at
`{{ env.NAME }}` tokens still parse but never resolve; they fail loudly with a migration message. A
token whose namespace is unavailable in the resolution context also fails loudly.
The reference implementation is LLM provider `extra_headers`, resolved against the vault at
`lib/foundation/fabro-auth/src/resolve.rs:376-378`:
The reference implementation is LLM provider `default_headers`, whose `{{ secrets.* }}` values are
resolved against the vault in `lib/foundation/fabro-auth/src/vault_source.rs`
(`interpolated_headers`) and re-sent as credential headers:
```toml
[llm.providers.example.extra_headers]
[llm.providers.example.default_headers]
authorization = "Bearer {{ secrets.EXAMPLE_TOKEN }}"
```

View file

@ -5814,9 +5814,10 @@ paths:
description: |
Generate a text completion. Set `stream: true` for SSE streaming.
All SSE frames use `event: stream_event` with a JSON-serialized StreamEvent
payload. StreamEvent types: stream_start, text_start, text_delta, text_end,
tool_call_start, tool_call_delta, tool_call_end, finish, error.
All SSE frames use `event: stream_event` with a JSON-serialized lithos
`StreamEvent` payload, discriminated by `type`: started,
content_block_start, text_delta, reasoning_delta, tool_call_delta,
content_block_end, usage, rate_limits, ended, and error.
requestBody:
required: true
content:
@ -8430,6 +8431,7 @@ components:
- id
- display_name
- adapter
- base_url
- priority
- model_count
- configured
@ -8442,12 +8444,12 @@ components:
example: "Anthropic"
adapter:
type: string
enum: [anthropic, openai, gemini, openai_compatible]
description: Protocol adapter the provider speaks.
description: "lithos adapter id the provider speaks, such as `anthropic`, `openai`, `gemini`, or `openai-compatible`."
example: "anthropic"
base_url:
type: ["string", "null"]
description: Operator-set base URL override, if any.
type: string
description: Effective API base URL, including any operator override.
example: "https://api.anthropic.com"
api_key_url:
type: ["string", "null"]
description: URL where an operator can obtain an API key for this provider.
@ -8506,22 +8508,11 @@ components:
description: Maximum output tokens, if known.
example: 128000
ReasoningEffortFeature:
description: >-
Whether the model endpoint supports a native reasoning-effort
parameter. `levels` accepts discrete effort levels; `always_adaptive`
accepts effort levels with natively always-on adaptive thinking;
`none` has no native effort parameter.
type: string
enum:
- levels
- always_adaptive
- none
ReasoningEffort:
description: Native reasoning-effort level requested for an LLM call.
type: string
enum:
- minimal
- low
- medium
- high
@ -8529,38 +8520,28 @@ components:
- max
ModelFeatures:
description: Capability flags for a model.
description: "Capability flags for a model, from the lithos catalog."
type: object
required:
- tools
- vision
- reasoning
- reasoning_effort
- prompt_cache
- cache_control_breakpoints
- sampling_params
- sampling
properties:
tools:
type: boolean
description: Whether the model supports tool use.
vision:
type: boolean
description: Whether the model supports vision/image inputs.
description: Whether the model supports image inputs.
reasoning:
type: boolean
description: Whether the model supports extended reasoning.
reasoning_effort:
$ref: "#/components/schemas/ReasoningEffortFeature"
prompt_cache:
type: boolean
description: Whether the model endpoint supports prompt caching.
cache_control_breakpoints:
type: boolean
description: >-
Whether the endpoint only caches when the request marks the
cacheable prefix with Anthropic-style cache_control breakpoints
(e.g. Claude via OpenRouter).
sampling_params:
sampling:
type: boolean
description: Whether the model accepts classic sampling parameters (temperature, top_p).
@ -8713,13 +8694,13 @@ components:
# ── Completion Schemas ─────────────────────────────────────────────
CompletionMessage:
description: A message in the conversation.
description: "A lithos `Message`. `content` parts are discriminated by `type`."
type: object
required: [role, content]
properties:
role:
type: string
enum: [system, user, assistant, tool, developer]
enum: [system, developer, user, assistant, tool]
description: The role of the message author.
content:
type: array
@ -8734,20 +8715,27 @@ components:
description: Tool call ID for tool result messages.
CompletionContentPart:
description: A content part within a message, discriminated by `kind`.
description: >-
A lithos `ContentPart`, discriminated by `type`: `text` ({text}),
`image`, `audio`, `document` ({source, ...}), `reasoning` ({text,
signature, redacted}), `tool_call` ({id, name, input}), `tool_result`
({tool_call_id, content, is_error}), `json` ({value}), and `opaque`
({kind, data}).
type: object
required: [kind]
required: [type]
properties:
kind:
type:
type: string
description: "Content part type: text, image, tool_call, tool_result, thinking, etc."
data:
description: Content data, structure depends on kind.
description: Content part type.
additionalProperties: true
CompletionToolDefinition:
description: A tool available for the model to call.
description: >-
A lithos `ToolDefinition`. `kind` is `{type: function, input_schema}`
for JSON-argument tools or `{type: custom, format}` for free-form
input.
type: object
required: [name, description, parameters]
required: [name, description, kind]
properties:
name:
type: string
@ -8755,23 +8743,50 @@ components:
description:
type: string
description: Human-readable tool description.
parameters:
description: JSON Schema for the tool's parameters.
kind:
$ref: "#/components/schemas/CompletionToolDefinitionKind"
CompletionToolDefinitionKind:
description: >-
lithos `ToolDefinitionKind`: `{type: function, input_schema}` for
JSON-argument tools or `{type: custom, format}` for free-form input.
type: object
required: [type]
properties:
type:
type: string
enum: [function, custom]
additionalProperties: true
CompletionResponseFormat:
description: >-
lithos `ResponseFormat`, discriminated by `type`: `text`,
`json_object`, or `json_schema` ({name, schema}).
type: object
required: [type]
properties:
type:
type: string
enum: [text, json_object, json_schema]
additionalProperties: true
CompletionToolChoice:
description: Controls how the model selects tools.
description: "A lithos `ToolChoice`, discriminated by `type`."
type: object
required: [mode]
required: [type]
properties:
mode:
type:
type: string
enum: [auto, none, required, named]
enum: [auto, none, required, tool]
description: Tool selection mode.
tool_name:
name:
type: string
description: Required when mode is "named".
description: Required when type is `tool`.
CreateCompletionRequest:
description: >-
A lithos `Request` plus `stream`. Field names match the lithos wire
form so a serialized lithos request can be posted as-is.
type: object
required: [messages]
properties:
@ -8782,7 +8797,13 @@ components:
$ref: "#/components/schemas/CompletionMessage"
model:
type: string
description: Model ID or alias. Server picks a ready-provider default if omitted.
description: >-
Model selector: `provider/model`, a model id or alias, or a
provider id. The server picks a ready-provider default when
omitted.
provider:
type: string
description: Optional provider pin for a bare model selector.
system:
type: string
description: System prompt (convenience; prepended as a system message).
@ -8797,14 +8818,18 @@ components:
$ref: "#/components/schemas/CompletionToolDefinition"
tool_choice:
$ref: "#/components/schemas/CompletionToolChoice"
response_format:
$ref: "#/components/schemas/CompletionResponseFormat"
schema:
description: JSON Schema for structured output.
description: >-
JSON Schema for structured output. Forces a non-streaming
response whose `output` is the parsed object.
max_output_tokens:
type: integer
format: int64
temperature:
type: number
format: double
max_tokens:
type: integer
format: int64
top_p:
type: number
format: double
@ -8816,81 +8841,129 @@ components:
reasoning_effort:
$ref: "#/components/schemas/ReasoningEffort"
description: Reasoning effort level.
provider:
type: string
description: Optional provider pin.
speed:
$ref: "#/components/schemas/BillingSpeed"
description: Requested speed tier.
metadata:
type: object
description: Request tags forwarded to providers that accept them.
additionalProperties:
type: string
provider_options:
description: Provider-specific options.
type: object
description: Raw provider options keyed by provider id.
additionalProperties: true
CompletionUsage:
description: >
Five disjoint token buckets for one completion. `input_tokens` excludes
cache reads and writes, while `output_tokens` excludes reasoning tokens
when the provider reports them separately.
lithos `TokenCounts`: five disjoint token buckets for one completion.
`input` excludes cache reads and writes, while `output` excludes
reasoning tokens when the provider reports them separately.
type: object
required:
- input_tokens
- output_tokens
- reasoning_tokens
- cache_read_tokens
- cache_write_tokens
properties:
input_tokens:
input:
type: integer
format: int64
description: Number of uncached input tokens consumed.
output_tokens:
default: 0
description: Uncached prompt tokens.
output:
type: integer
format: int64
description: Number of non-reasoning output tokens generated.
reasoning_tokens:
default: 0
description: Non-reasoning completion tokens.
reasoning:
type: integer
format: int64
description: Number of separately reported reasoning tokens.
cache_read_tokens:
default: 0
description: Separately reported reasoning tokens.
cache_read:
type: integer
format: int64
description: Number of input tokens served from a provider cache.
cache_write_tokens:
default: 0
description: Prompt tokens served from a provider cache.
cache_write:
type: integer
format: int64
description: Number of input tokens written to a provider cache.
default: 0
description: Prompt tokens written to a provider cache.
CompletionResponse:
ModelHandle:
description: A resolved provider and model identity.
type: object
required: [id, model, provider, message, stop_reason, usage]
required: [provider, model]
properties:
id:
type: string
model:
type: string
description: Canonical model ID selected for the request.
provider:
$ref: "#/components/schemas/ProviderId"
message:
$ref: "#/components/schemas/CompletionMessage"
stop_reason:
model:
type: string
description: Why generation stopped (end_turn, max_tokens, tool_calls).
description: Canonical model id within the provider.
CompletionCost:
description: "lithos `Cost`: a USD amount in micros and where it came from."
type: object
required: [usd_micros, source]
properties:
usd_micros:
type: integer
format: int64
minimum: 0
source:
$ref: "#/components/schemas/CostSource"
CompletionResponse:
description: >-
A lithos `Response`, returned verbatim. The server is the billing
authority: `cost` is the catalog estimate or the provider's own
figure. When the request carried `schema`, `output` holds the parsed
object.
type: object
required: [model, content, finish_reason, usage]
properties:
output:
description: Parsed structured output when `schema` was provided.
id:
type: ["string", "null"]
model:
$ref: "#/components/schemas/ModelHandle"
content:
type: array
items:
$ref: "#/components/schemas/CompletionContentPart"
suppressed_tool_calls:
type: array
description: Tool calls withheld because the turn ended early.
items:
type: object
additionalProperties: true
finish_reason:
type: string
description: "Why generation stopped: stop, length, tool_call, content_filter, error, incomplete, or a provider-specific reason."
usage:
$ref: "#/components/schemas/CompletionUsage"
output:
description: Parsed structured output when schema was provided.
cost_usd:
type: number
format: double
description: >
USD cost of the completion when known: estimated from catalog
prices unless the provider returned authoritative billing data.
cost_source:
$ref: "#/components/schemas/CostSource"
cost:
$ref: "#/components/schemas/CompletionCost"
rate_limits:
type: object
additionalProperties: true
warnings:
type: array
items:
type: object
required: [code, message]
properties:
code:
type: string
message:
type: string
raw:
description: The provider's success payload, when available.
CostSource:
type: string
description: >
Whether `cost_usd` came from provider billing data (authoritative)
or catalog price estimation (estimated).
enum: [authoritative, estimated]
Where a cost came from: `catalog` (estimated from catalog prices),
`provider` (the provider's own billing data), or `application`.
enum: [catalog, provider, application]
PaginatedSavedQueryList:
description: Paginated list of saved queries.
@ -12470,11 +12543,12 @@ components:
- type: "null"
BillingSpeed:
description: Optional provider-specific model speed tier used for cost estimates.
description: "lithos `Speed`: the requested latency or cost tier."
type: string
enum:
- standard
- fast
- balanced
- economical
CodeLocation:
description: A file and line location in the codebase.

View file

@ -78,97 +78,74 @@ Claude Fable 5 is available as an explicit model but is not the default Anthropi
## Configuring providers and models
Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Models are nested under their provider, so two providers can expose the same model slug without overwriting each other.
Fabro's catalog is the [lithos-llm](https://docs.rs/lithos-llm) built-in catalog. The `[llm]` table in settings is a second layer over it: a lithos catalog overlay that adds providers and models or changes existing entries. Later layers win. Tables merge key by key and every other value replaces. Models are nested under their provider, so two providers can expose the same model id without overwriting each other.
Provider and model facts use lithos field names: `adapter`, `codec`, `base_url`, `auth`, `enabled`, `limits`, `capabilities`, `pricing`, `small_default`, `probe`, `family`, and the cutoffs. The coding harness a model expects lives under `metadata.agent`, a namespace lithos ships and other agents such as Pebble read too. See [Settings Configuration](/reference/user-configuration#llm) for every key.
```toml title="settings.toml"
[llm.providers.proxy]
display_name = "Acme Gateway"
adapter = "openai_compatible"
adapter = "openai-compatible"
codec = "openai-chat"
base_url = "https://llm-gateway.example.com/v1"
auth = { type = "bearer" }
aliases = ["gateway"]
default_model = "team-code-large"
[llm.providers.proxy.auth]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
[llm.providers.proxy.default_headers]
x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}"
x-portkey-config = "@bedrock-prod"
[llm.providers.proxy.metadata.agent]
profile = "anthropic"
[llm.providers.proxy.models."team-code-large"]
api_id = "provider-wire-model-name"
agent_profile = "anthropic"
display_name = "Team Code Large"
family = "team-code"
default = true
small_default = true
aliases = ["team-code"]
api_model = "provider-wire-model-name"
limits = { context_tokens = 200000, max_output_tokens = 32000 }
capabilities = { text = true, tools = true, reasoning = true, caching = true, reasoning_effort = { low = true, medium = true, high = true } }
protocol_options = { reasoning_effort_levels = true }
pricing = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 8000000, cached_input_usd_micros_per_million = 300000 }
family = "team-code"
small_default = true
estimated_output_tps = 80
[llm.providers.proxy.models."team-code-large".limits]
context_window = 200000
max_output = 32000
[llm.providers.proxy.models."team-code-large".features]
tools = true
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
[llm.providers.proxy.models."team-code-large".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.providers.proxy.models."team-code-large".costs]
input_cost_per_mtok = 1.50
output_cost_per_mtok = 8.00
cache_input_cost_per_mtok = 0.30
[llm.providers.proxy.models."team-code-large".costs.speed.fast]
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
cache_input_cost_per_mtok = 0.60
```
The gateway's API key is `PROXY_API_KEY`: lithos derives the secret name from the provider id (upper case, `-` and `.` as `_`, then `_API_KEY`). Store it with `fabro secret set PROXY_API_KEY ...`.
For [LiteLLM](/integrations/litellm), Fabro ships a disabled provider entry. Enable it in settings and declare the models your proxy exposes:
```toml title="settings.toml"
[llm.providers.litellm]
enabled = true
base_url = "http://localhost:4000/v1"
default_model = "litellm-gpt-5"
enabled = true
[llm.providers.litellm.models."litellm-gpt-5"]
api_id = "gpt-5"
display_name = "LiteLLM GPT-5"
family = "litellm"
default = true
[llm.providers.litellm.models."litellm-gpt-5".limits]
context_window = 128000
max_output = 8192
[llm.providers.litellm.models."litellm-gpt-5".features]
tools = true
vision = false
reasoning = false
api_model = "gpt-5"
limits = { context_tokens = 128000, max_output_tokens = 8192 }
capabilities = { text = true, tools = true }
```
`api_id` is the opaque model name sent to that provider's API. It defaults to the exact model slug, so omit it when the two strings match. Fabro does not infer vendor prefixes or rewrite the value.
`api_model` is the model name sent to that provider's API. It defaults to the exact model id, so omit it when the two strings match. Fabro does not infer vendor prefixes or rewrite the value.
<Note>
Historical built-in catalog keys that exposed provider API IDs remain accepted as compatibility selectors. Fabro normalizes a primary or node selector such as `openai/gpt-5.6-sol` to the canonical `gpt-5.6-sol` slug before normal provider-aware selection. With no provider pin, the highest-priority ready offering wins; a separate `provider = "openrouter"` pin selects the OpenRouter offering. Fabro also normalizes these keys in legacy top-level `[llm.models]` rows without rewriting the settings file.
A `provider/model` selector such as `openai/gpt-5.6-sol` pins the provider and names the model by id, alias, or wire id. A bare selector with no provider pin picks the highest-priority ready offering; a separate `provider = "openrouter"` pin selects the OpenRouter offering. Providers with `allow_passthrough = true` also accept `provider/model` selectors for models the catalog does not list.
</Note>
Model roles are separate: `default = true` controls normal model selection for workflow execution, while `small_default = true` marks the provider's small/cheap utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's normal default.
Model roles are separate: the provider's `default_model` controls normal model selection for workflow execution, while `small_default = true` on a model row marks the provider's small utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's default model.
Provider auth is declared in `[llm.providers.<id>.auth]` with ordered `env:<NAME>` or `vault:<NAME>` refs. The primary auth header defaults to `bearer`; override with `header = { custom = "Header-Name" }` for providers like Anthropic that use `x-api-key`. Omit the `[llm.providers.<id>.auth]` block entirely for providers that need no API key (e.g. Ollama). Custom headers for any provider — including providers that need only interpolation headers and no API-key auth — go in `extra_headers` as literal text or `{{ secrets.NAME }}` tokens. Put credentials in secrets and reference them with `{{ secrets.NAME }}` instead of a bare literal.
Provider auth has two parts. The lithos `auth` scheme says how a credential is sent: `{ type = "bearer" }`, `{ type = "header", name = "x-api-key" }`, `{ type = "headers" }` for providers that take several secret headers, `{ type = "none" }`, or `{ type = "aws" }`. lithos also says which secret names a provider reads: `OPENAI_API_KEY` for `openai`, `GEMINI_API_KEY` then `GOOGLE_API_KEY` for `gemini`, `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` for `modal`, and `<PROVIDER>_API_KEY` for a provider you define. Fabro looks each name up in the process environment first and the server vault second. Custom headers for any provider go in `default_headers` as literal text or `{{ secrets.NAME }}` tokens; put credentials in secrets and reference them with `{{ secrets.NAME }}` instead of a bare literal.
Workflow runs also add `x-session-id: <run-id>` to every LLM request so compatible gateways can group requests from the same run. An explicitly configured `x-session-id` in provider `extra_headers` takes precedence.
Workflow runs also add `x-session-id: <run-id>` to every LLM request so compatible gateways can group requests from the same run. An explicitly configured `x-session-id` in provider `default_headers` takes precedence.
Provider `agent_profile` defaults from `adapter` and controls profile-specific behavior such as which tools the agent registers, project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `openai`, `gemini`, `kimi`, and `gpt56`; model-level values override provider-level values.
Provider `metadata.agent.profile` defaults from `adapter` and controls profile-specific behavior such as which tools the agent registers, project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `claude-5`, `openai`, `gemini`, `kimi`, `gpt56`, and `gpt6`; model-level values override provider-level values.
Two profiles are selected per model rather than per provider, because they follow the model wherever it is served: `kimi` for Kimi models, and `gpt56` for the GPT-5.6 models (Sol, Terra, Luna). The `gpt56` profile uses Codex's narrow core surface — `shell_command`, `apply_patch`, and `update_plan`, plus optional credential-backed `web_search` — instead of fabro's dedicated file-read, discovery, and `web_fetch` tools. On OpenAI-compatible routes that cannot carry the freeform `apply_patch` grammar, it substitutes the JSON-schema `edit_file` tool. Session features may add their own question, skill, or subagent tools separately.
Three profiles are selected per model rather than per provider, because they follow the model wherever it is served: `claude-5` for Claude 5 models, `kimi` for Kimi models, and `gpt56` for the GPT-5.6 models (Sol, Terra, Luna); `gpt6` for GPT-6 Astra runs on the same harness as `gpt56`. The `gpt56` profile uses Codex's narrow core surface — `shell_command`, `apply_patch`, and `update_plan`, plus optional credential-backed `web_search` — instead of fabro's dedicated file-read, discovery, and `web_fetch` tools. On OpenAI-compatible routes that cannot carry the freeform `apply_patch` grammar, it substitutes the JSON-schema `edit_file` tool. Session features may add their own question, skill, or subagent tools separately.
Provider `billing_policy` defaults from `adapter` and controls usage-cost estimation. Use `openai`, `anthropic`, `gemini`, or `none`. Model rows may override it for models whose billing family differs from their provider's — for example, Claude models served through OpenRouter set `billing_policy = "anthropic"` so cache reads and writes price correctly.
Costs come from the lithos `pricing` table on each model row. Each token bucket (input, output, reasoning, cache read, cache write) prices at its own rate, with optional long-context and speed tiers. Providers that return an authoritative charge, such as OpenRouter, override the catalog estimate; the billing record says which source it came from.
<Note>
Provider fields in configuration, APIs, and model routing are provider ID strings. Built-in names like `anthropic`, `openai`, and `gemini` still work, but custom IDs like `proxy` work anywhere a provider ID is accepted.
@ -180,7 +157,7 @@ Fabro ships a built-in [Venice](/integrations/venice) provider with a curated ca
### Poolside
Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same model slugs are also available through the opt-in OpenRouter provider; its vendor-namespaced strings remain provider-only `api_id` values.
Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same model slugs are also available through the opt-in OpenRouter provider; its vendor-namespaced strings remain provider-only `api_model` values.
### OpenRouter
@ -197,8 +174,8 @@ Fabro ships a [Modal](/integrations/modal) provider definition for Kimi K3, disa
```toml title="settings.toml"
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
enabled = true
```
Store both token values in the Fabro server vault:
@ -214,8 +191,8 @@ Fabro ships an [Amazon Bedrock](/integrations/bedrock) provider definition with
```toml title="settings.toml"
[llm.providers.bedrock]
enabled = true
base_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
enabled = true
```
### Ollama
@ -227,7 +204,7 @@ Fabro ships an Ollama provider definition that is disabled by default. Enable it
enabled = true
```
Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.providers.ollama.models."<model-slug>"]` blocks for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`.
Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.providers.ollama.models."<model-id>"]` blocks for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`.
## Default models
@ -235,7 +212,7 @@ When no model or provider is specified, Fabro chooses the default offering on th
| Provider | Default model |
|---|---|
| `anthropic` | `claude-sonnet-4-6` |
| `anthropic` | `claude-sonnet-5` |
| `openai` | `gpt-5.6-sol` |
| `gemini` | `gemini-3.5-flash` |
| `moonshot` | `kimi-k3` |

View file

@ -195,7 +195,7 @@ speed = "fast"
| Field | Description |
|---|---|
| `reasoning_effort` | Native reasoning-effort value to request when the selected model allows it, such as `"low"`, `"medium"`, `"high"`, `"xhigh"`, or `"max"`. |
| `reasoning_effort` | Native reasoning-effort value to request when the selected model allows it, such as `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, or `"max"`. |
| `speed` | Native speed value to request when the selected model declares it, such as `"fast"`. The standard speed is implicit and does not need to be set. |
#### Fallback lists with splice

View file

@ -27,8 +27,8 @@ Add the provider override to `~/.fabro/settings.toml`:
_version = 1
[llm.providers.bedrock]
enabled = true
base_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
enabled = true
```
The SigV4 signing region is derived from `base_url` — change it to your Region's endpoint (`https://bedrock-runtime.<region>.amazonaws.com`, FIPS and China endpoints included).
@ -37,7 +37,7 @@ The SigV4 signing region is derived from `base_url` — change it to your Region
Two auth modes, tried in order:
**Bedrock API key** (simplest): store the key and Fabro sends it as a bearer token. The key is read from either `AWS_BEARER_TOKEN_BEDROCK` (AWS's canonical name, also honored by the AWS SDKs and CLI) or `BEDROCK_API_KEY` (Fabro's `<PROVIDER>_API_KEY` convention) — use whichever you prefer.
**Bedrock API key** (simplest): store the key and Fabro sends it as a bearer token. The key is read from either `AWS_BEARER_TOKEN_BEDROCK` (AWS's canonical name, also honored by the AWS SDKs and CLI) or `BEDROCK_API_KEY` (the `<PROVIDER>_API_KEY` convention) — use whichever you prefer.
```bash
fabro secret set AWS_BEARER_TOKEN_BEDROCK bedrock-api-key-...
@ -47,22 +47,12 @@ fabro secret set BEDROCK_API_KEY bedrock-api-key-...
Runs read the bearer token from the vault only. Workers start from a cleared environment and the bearer token is not on the inherited allowlist, so exporting it in the server's shell has no effect on runs. `fabro exec` and direct `fabro-llm` SDK usage do read it from process env.
**AWS SigV4** (IAM-scoped): with no API key configured, Fabro signs each request using the AWS default credential chain — environment keys, shared profile, EC2/ECS instance roles, IRSA/web identity, SSO. Expiring session credentials refresh automatically. The catalog declares this as the `aws_sigv4` credential source:
**AWS SigV4** (IAM-scoped): with no API key configured, Fabro signs each request using the AWS default credential chain — environment keys, shared profile, EC2/ECS instance roles, IRSA/web identity, SSO. Expiring session credentials refresh automatically.
```toml
[llm.providers.bedrock.auth]
credentials = ["env:AWS_BEARER_TOKEN_BEDROCK", "env:BEDROCK_API_KEY", "vault:AWS_BEARER_TOKEN_BEDROCK", "vault:BEDROCK_API_KEY", "aws_sigv4"]
```
The key resolves from the process environment first (either name), then the server vault (`fabro secret set`), then falls back to SigV4 — so on a server, prefer `secret set`. To select a non-default AWS profile for SigV4, set `AWS_PROFILE` (it, and the rest of the AWS credential-chain variables, are passed through to workflow workers).
The order is fixed by lithos-llm: `AWS_BEARER_TOKEN_BEDROCK`, then `BEDROCK_API_KEY`, then the AWS default chain. Each name is read from the process environment first and the server vault (`fabro secret set`) second — so on a server, prefer `secret set`. To select a non-default AWS profile for SigV4, set `AWS_PROFILE` (it, and the rest of the AWS credential-chain variables, are passed through to workflow workers).
<Warning>
**Bearer-vs-SigV4 precedence.** Because the bearer key is tried before SigV4, setting `AWS_BEARER_TOKEN_BEDROCK` makes the `bedrock` (Converse) provider authenticate with that key too — not just the `bedrock-openai` mantle provider below. If your key is valid only for mantle (it lacks `bedrock:InvokeModel*` on the runtime), every Converse model then fails with *"Authentication failed."* To run Converse models on SigV4 while using a mantle-only bearer key for GPT-5.x, pin the Converse provider to SigV4 explicitly:
```toml
[llm.providers.bedrock.auth]
credentials = ["aws_sigv4"]
```
**Bearer-vs-SigV4 precedence.** Because the bearer key is tried before SigV4, setting `AWS_BEARER_TOKEN_BEDROCK` makes the `bedrock` (Converse) provider authenticate with that key too — not just the `bedrock-openai` mantle provider below. If your key is valid only for mantle (it lacks `bedrock:InvokeModel*` on the runtime), every Converse model then fails with *"Authentication failed."* Use a key that covers both surfaces, or keep Converse on SigV4 by leaving both Bedrock secret names unset and enabling only `bedrock`.
</Warning>
## Included models
@ -83,7 +73,7 @@ The built-in catalog curates Converse-capable models, using cross-region inferen
| `moonshotai.kimi-k2.5`, `zai.glm-5` | |
| `minimax.minimax-m2.5`, `nvidia.nemotron-3-super` | |
Any other Converse-capable Bedrock model can be added as a settings model entry with `provider = "bedrock"` and the Bedrock model or inference-profile id as `api_id`.
Any other Converse-capable Bedrock model can be added under `[llm.providers.bedrock.models."<model-id>"]` with the Bedrock model or inference-profile id as `api_model`.
Not included on this provider: Claude Mythos 5 (Anthropic-Messages-only on `bedrock-mantle`, limited preview). OpenAI's frontier models live on the companion `bedrock-openai` provider below.
@ -113,7 +103,7 @@ fabro run workflow.fabro --model deepseek.v3-2
## Prompt caching
Claude models cache automatically when the catalog row declares `prompt_cache`: Fabro places Converse `cachePoint` blocks after the system prompt, the tool definitions, and the conversation prefix — the same placement as the direct Anthropic provider. Cache reads and writes price Anthropic-style via the per-model `billing_policy`.
Claude models cache automatically when the catalog row declares `prompt_cache`: Fabro places Converse `cachePoint` blocks after the system prompt, the tool definitions, and the conversation prefix — the same placement as the direct Anthropic provider. Cache reads and writes price at the row's `cached_input_usd_micros_per_million` and `cache_write_usd_micros_per_million` rates.
## Converse extensions
@ -142,7 +132,7 @@ Bedrock-specific request fields pass through verbatim via `provider_options.bedr
**"data retention mode 'default' is not available for this model"** — Fable 5 / Mythos-class models require opting into data sharing first; see [Model access and approvals](#model-access-and-approvals).
**"The provided model identifier is invalid"** — The wire id sent to Bedrock isn't a recognized model or inference-profile id. Set an explicit `api_id` (from `aws bedrock list-inference-profiles`) on the model entry.
**"The provided model identifier is invalid"** — The wire id sent to Bedrock isn't a recognized model or inference-profile id. Set an explicit `api_model` (from `aws bedrock list-inference-profiles`) on the model entry.
**`ValidationException` mentioning on-demand throughput** — The model requires an inference-profile id; use the `us.`/`global.`-prefixed id from the catalog rather than the bare model id.

View file

@ -44,7 +44,7 @@ export FIREWORKS_API_KEY=fw_...
## Included models
The built-in catalog gives Fireworks offerings the same human-facing model slugs used by other providers. Fireworks account-scoped model paths remain opaque `api_id` values:
The built-in catalog gives Fireworks offerings the same human-facing model slugs used by other providers. Fireworks account-scoped model paths remain opaque `api_model` values:
| Fabro model slug | Fireworks API ID / notes |
| --- | --- |
@ -59,21 +59,14 @@ The built-in catalog gives Fireworks offerings the same human-facing model slugs
| `gpt-oss-120b` | `accounts/fireworks/models/gpt-oss-120b` |
| `gpt-oss-20b` | `accounts/fireworks/models/gpt-oss-20b`; provider small default |
Any other Fireworks serverless model can be added under the provider. Choose a stable Fabro model slug as the table key and put the Fireworks account-scoped path in `api_id` (dots in upstream model names become `p`, e.g. `glm-5.2` → `glm-5p2`):
Any other Fireworks serverless model can be added under the provider. Choose a stable Fabro model slug as the table key and put the Fireworks account-scoped path in `api_model` (dots in upstream model names become `p`, e.g. `glm-5.2` → `glm-5p2`):
```toml title="settings.toml"
[llm.providers.fireworks.models."llama-4-maverick"]
api_id = "accounts/fireworks/models/llama4-maverick-instruct-basic"
display_name = "Llama 4 Maverick"
family = "llama-4"
[llm.providers.fireworks.models."llama-4-maverick".limits]
context_window = 1000000
[llm.providers.fireworks.models."llama-4-maverick".features]
tools = true
vision = false
reasoning = false
api_model = "accounts/fireworks/models/llama4-maverick-instruct-basic"
limits = { context_tokens = 1000000, max_output_tokens = 16384 }
capabilities = { text = true, tools = true }
```
Note that Fireworks' `GET /v1/models` endpoint only returns a featured subset of serverless models; a model absent from that list may still be servable. Verify custom additions with `fabro model test`.
@ -117,7 +110,7 @@ Fireworks caches prompt prefixes automatically — no cache breakpoints or reque
## Costs
Catalog prices mirror [Fireworks serverless pricing](https://docs.fireworks.ai/serverless/pricing). Fireworks does not return in-band billing, so Fabro reports `cost_source = "estimated"` from catalog rates. `kimi-k3-fast` uses the published 50% Fast tier premium. Other Fast model variants and the Priority service tier are not included in the built-in catalog.
Catalog prices mirror [Fireworks serverless pricing](https://docs.fireworks.ai/serverless/pricing). Fireworks does not return in-band billing, so Fabro reports the cost source as `catalog`. `kimi-k3-fast` uses the published 50% Fast tier premium. Other Fast model variants and the Priority service tier are not included in the built-in catalog.
## Troubleshooting
@ -127,7 +120,7 @@ Catalog prices mirror [Fireworks serverless pricing](https://docs.fireworks.ai/s
**402 / insufficient credits** — Serverless inference requires prepaid credit; check your balance in the [Fireworks billing dashboard](https://app.fireworks.ai/settings/billing).
**Unknown model** — Confirm the model's `api_id` matches a Fireworks account-scoped model or router path exactly (`accounts/fireworks/models/...` or `accounts/fireworks/routers/...`), then run `fabro model test --model <fabro-model-id>`. Remember that `GET /v1/models` only lists a featured subset, so absence from that list is not conclusive.
**Unknown model** — Confirm the model's `api_model` matches a Fireworks account-scoped model or router path exactly (`accounts/fireworks/models/...` or `accounts/fireworks/routers/...`), then run `fabro model test --model <fabro-model-id>`. Remember that `GET /v1/models` only lists a featured subset, so absence from that list is not conclusive.
## Further reading

View file

@ -21,26 +21,18 @@ Add the provider override and one or more model entries to `~/.fabro/settings.to
_version = 1
[llm.providers.litellm]
enabled = true
base_url = "http://localhost:4000/v1"
default_model = "litellm-gpt-5"
enabled = true
[llm.providers.litellm.models."litellm-gpt-5"]
api_id = "gpt-5"
display_name = "LiteLLM GPT-5"
family = "litellm"
default = true
[llm.providers.litellm.models."litellm-gpt-5".limits]
context_window = 128000
max_output = 8192
[llm.providers.litellm.models."litellm-gpt-5".features]
tools = true
vision = false
reasoning = false
api_model = "gpt-5"
limits = { context_tokens = 128000, max_output_tokens = 8192 }
capabilities = { text = true, tools = true }
```
`api_id` is the model name Fabro sends to LiteLLM. It should match a model name configured in your LiteLLM proxy.
`api_model` is the model name Fabro sends to LiteLLM. It should match a model name configured in your LiteLLM proxy.
## Configure credentials
@ -94,22 +86,14 @@ Declare each LiteLLM-routed model explicitly so Fabro knows its provider, contex
```toml title="settings.toml"
[llm.providers.litellm.models."litellm-fast"]
api_id = "fast-model"
display_name = "LiteLLM Fast"
family = "litellm"
aliases = ["fast"]
[llm.providers.litellm.models."litellm-fast".limits]
context_window = 64000
max_output = 4096
[llm.providers.litellm.models."litellm-fast".features]
tools = true
vision = false
reasoning = false
api_model = "fast-model"
limits = { context_tokens = 64000, max_output_tokens = 4096 }
capabilities = { text = true, tools = true }
```
Only one model for a provider should set `default = true`. You may also mark one small/cheap utility model with `small_default = true`; Fabro uses it for metadata tasks such as generated run titles and falls back to the provider default when it is omitted.
The provider's `default_model` names its default. You may also mark one small utility model with `small_default = true`; Fabro uses it for metadata tasks such as generated run titles and falls back to the provider default when it is omitted.
## Troubleshooting
@ -117,7 +101,7 @@ Only one model for a provider should set `default = true`. You may also mark one
**Connection refused** — Confirm the LiteLLM proxy is running and that `base_url` is reachable from the Fabro process. For Docker deployments, `localhost` means the Fabro container unless you point it at a host or service name.
**Unknown model from LiteLLM** — Check that the model's `api_id` matches the model name configured in LiteLLM, then run `fabro model test --model <fabro-model-id>`.
**Unknown model from LiteLLM** — Check that the model's `api_model` matches the model name configured in LiteLLM, then run `fabro model test --model <fabro-model-id>`.
## Further reading

View file

@ -45,8 +45,8 @@ Add the provider override to the settings file used by the Fabro server. Include
_version = 1
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
enabled = true
```
The endpoint URL is not built into Fabro because Modal assigns it to your Shared API or Auto Endpoint.
@ -113,45 +113,25 @@ digraph Example {
## Direct SDK environment credentials
The built-in Modal provider reads its two headers from the Fabro vault. `EnvCredentialSource` does not configure Modal automatically because Modal uses two headers instead of one API-key reference.
The built-in Modal provider authenticates with two headers, `Modal-Key` and `Modal-Secret`, read from the secrets `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`. Direct SDK use reads the same two names from the process environment.
For direct SDK use, enable Modal and set its endpoint URL in the catalog:
For direct SDK use, enable Modal and set its endpoint URL in the `[llm]` overlay, then build the client with `fabro_llm::build_client` over a `VaultCredentialSource` whose vault holds both secrets. The catalog you pass to the client must be built from the same settings file with `fabro_llm::build_catalog`.
```toml title="settings.toml"
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
```
Then read both environment variables explicitly and create a typed credential after constructing `catalog` from those settings:
```rust
use fabro_auth::ApiCredential;
use fabro_llm::client::Client;
use std::collections::HashMap;
let credential = ApiCredential::with_extra_headers(
"modal",
HashMap::from([
("Modal-Key".to_string(), std::env::var("MODAL_TOKEN_ID")?),
(
"Modal-Secret".to_string(),
std::env::var("MODAL_TOKEN_SECRET")?,
),
]),
);
let client = Client::from_credentials(vec![credential], catalog).await?;
enabled = true
```
## Costs
Fabro estimates Shared API costs from Modal's published Kimi K3 prices. Completion and reasoning tokens use the output rate. Modal responses do not include an authoritative charge, so Fabro reports `cost_source = "estimated"`.
Fabro estimates Shared API costs from Modal's published Kimi K3 prices. Completion and reasoning tokens use the output rate. Modal responses do not include an authoritative charge, so Fabro reports the cost source as `catalog`.
Dedicated Auto Endpoints use Modal compute billing instead of the Shared API token prices. The Fabro estimate does not represent that compute bill.
## Troubleshooting
**"provider 'modal' uses openai_compatible adapter but does not configure base_url"** — Add the Modal endpoint URL under `[llm.providers.modal]`. Include `/v1`.
**Modal requests fail with 404** — Add the Modal endpoint URL as `base_url` under `[llm.providers.modal]`. Include `/v1`.
**Modal is not configured** — Set both `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` in the target server vault. One value is not sufficient.

View file

@ -44,7 +44,7 @@ export OPENROUTER_API_KEY=sk-or-v1-...
## Included models
The built-in catalog gives OpenRouter offerings the same human-facing model slugs used by direct providers. Vendor-namespaced OpenRouter IDs remain opaque `api_id` values:
The built-in catalog gives OpenRouter offerings the same human-facing model slugs used by direct providers. Vendor-namespaced OpenRouter IDs remain opaque `api_model` values:
| Fabro model slug | OpenRouter API ID / notes |
| --- | --- |
@ -60,21 +60,14 @@ The built-in catalog gives OpenRouter offerings the same human-facing model slug
| `minimax-m2.7`, `mimo-v2.5-pro` | Vendor-prefixed API IDs |
| `nemotron-3-super-120b-a12b`, `devstral-2512` | Vendor-prefixed API IDs |
Any other OpenRouter model can be added under the provider. Choose a stable Fabro model slug as the table key and put OpenRouter's exact vendor/model string in `api_id`:
Any other OpenRouter model can be added under the provider. Choose a stable Fabro model slug as the table key and put OpenRouter's exact vendor/model string in `api_model`:
```toml title="settings.toml"
[llm.providers.openrouter.models."llama-4-maverick"]
api_id = "meta-llama/llama-4-maverick"
display_name = "Llama 4 Maverick"
family = "llama-4"
[llm.providers.openrouter.models."llama-4-maverick".limits]
context_window = 1000000
[llm.providers.openrouter.models."llama-4-maverick".features]
tools = true
vision = false
reasoning = false
api_model = "meta-llama/llama-4-maverick"
limits = { context_tokens = 1000000, max_output_tokens = 16384 }
capabilities = { text = true, tools = true }
```
## Use OpenRouter models
@ -112,7 +105,7 @@ digraph Example {
## Cost telemetry
Every OpenRouter response includes an inline `usage.cost` with authoritative USD billing. Fabro surfaces it as `cost_usd` with `cost_source = "authoritative"` on completion responses. Other providers populate the same fields from catalog price estimates with `cost_source = "estimated"`.
Every OpenRouter response includes an inline `usage.cost` with authoritative USD billing. Fabro surfaces it as the response `cost` with source `provider`. Other providers populate the same field from catalog price estimates with source `catalog`.
The catalog prices on OpenRouter model rows are best-effort estimates used only before the authoritative figure arrives (for example, mid-stream rollups).
@ -134,10 +127,10 @@ OpenRouter's [provider routing preferences](https://openrouter.ai/docs/guides/ro
## Attribution headers
Fabro does not send OpenRouter's optional attribution headers (`HTTP-Referer`, `X-Title`) by default, so self-hosted installations stay anonymous on OpenRouter's public app leaderboard. Workflow runs do send `x-session-id: <run-id>` for request grouping; an explicit provider `extra_headers` value for that header takes precedence. To opt in to attribution:
Fabro does not send OpenRouter's optional attribution headers (`HTTP-Referer`, `X-Title`) by default, so self-hosted installations stay anonymous on OpenRouter's public app leaderboard. Workflow runs do send `x-session-id: <run-id>` for request grouping; an explicit provider `default_headers` value for that header takes precedence. To opt in to attribution:
```toml title="settings.toml"
[llm.providers.openrouter.extra_headers]
[llm.providers.openrouter.default_headers]
"HTTP-Referer" = "https://your-site.example"
"X-Title" = "Your App"
```
@ -150,7 +143,7 @@ Fabro does not send OpenRouter's optional attribution headers (`HTTP-Referer`, `
**402 / insufficient credits** — Paid OpenRouter models require prepaid credit; check your balance at [openrouter.ai/credits](https://openrouter.ai/credits).
**Unknown model** — Confirm the model's `api_id` matches an OpenRouter slug exactly (including the vendor prefix), then run `fabro model test --model <fabro-model-id>`.
**Unknown model** — Confirm the model's `api_model` matches an OpenRouter slug exactly (including the vendor prefix), then run `fabro model test --model <fabro-model-id>`.
## Further reading

View file

@ -698,7 +698,7 @@ fabro model test [OPTIONS]
| `-j, --jobs <jobs>` | Number of model tests to run concurrently in bulk mode<br />Default: `4` |
| `-m, --model <model>` | Test a specific model |
| `-p, --provider <provider>` | Filter by provider |
| `--reasoning-effort <reasoning_effort>` | Request a reasoning-effort level<br />Values: `low`, `medium`, `high`, `xhigh`, `max` |
| `--reasoning-effort <reasoning_effort>` | Request a reasoning-effort level (minimal, low, medium, high, xhigh, max) |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--tools` | Run a multi-turn tool-use test |

View file

@ -19,30 +19,41 @@ The `fabro-agent` crate provides a session-based AI agent that runs an LLM with
fabro-auth = { git = "https://github.com/fabro-sh/fabro" }
fabro-agent = { git = "https://github.com/fabro-sh/fabro" }
fabro-llm = { git = "https://github.com/fabro-sh/fabro" }
fabro-model = { git = "https://github.com/fabro-sh/fabro" }
fabro-types = { git = "https://github.com/fabro-sh/fabro" }
tokio = { version = "1", features = ["full"] }
```
### Quick start
```rust
use fabro_agent::{
AnthropicProfile, LocalSandbox, Session, SessionOptions,
};
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::Catalog;
use std::path::PathBuf;
use std::sync::Arc;
use fabro_agent::{AgentProfile, AgentProfileBuilder, LocalSandbox, Session, SessionOptions};
use fabro_auth::VaultCredentialSource;
use fabro_llm::ClientOptions;
use fabro_types::{AgentProfileKind, provider_ids};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let source = EnvCredentialSource::new();
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?);
let client = Client::from_source(&source, Arc::clone(&catalog)).await?;
let catalog = Arc::new(fabro_llm::default_catalog());
let client = fabro_llm::build_client(
(*catalog).clone(),
Arc::new(VaultCredentialSource::environment_only()),
ClientOptions::standard(),
)
.await?
.client;
let sandbox = Arc::new(LocalSandbox::new(PathBuf::from(".")));
let profile = Arc::new(AnthropicProfile::new("claude-sonnet-4-5"));
let profile: Arc<dyn AgentProfile> = Arc::from(
AgentProfileBuilder::new(
AgentProfileKind::Anthropic,
provider_ids::anthropic(),
"claude-sonnet-4.5",
Arc::clone(&catalog),
)
.build(),
);
let config = SessionOptions::default();
let mut session = Session::new(client, profile, sandbox, config);
@ -181,7 +192,7 @@ pub trait AgentProfile: Send + Sync {
}
```
Built-in profiles: `AnthropicProfile`, `OpenAiProfile`, `GeminiProfile`.
Profiles are built with `AgentProfileBuilder::new(kind, provider, model, catalog)`. The `AgentProfileKind` values are `anthropic`, `claude-5`, `openai`, `gemini`, `kimi`, `gpt56`, and `gpt6`; the catalog's `metadata.agent.profile` picks one per provider or model.
### Events
@ -289,7 +300,7 @@ All fallible `Session` methods return `Result<T, AgentError>`:
| Variant | Description |
|---|---|
| `Llm(SdkError)` | An error from the LLM provider (wraps `fabro_llm::error::SdkError`). |
| `Llm(LlmError)` | An error from the LLM provider (the stored form of a lithos `Error`). |
| `SessionClosed` | `process_input` was called on a closed session. |
| `InvalidState(String)` | The session is in an unexpected state. |
| `ToolExecution(String)` | A tool execution failed. |
@ -299,410 +310,110 @@ All fallible `Session` methods return `Result<T, AgentError>`:
## LLM client (`fabro-llm`)
The `fabro-llm` crate is a standalone Rust library for calling LLM providers. It provides a unified client that routes requests to Anthropic, OpenAI, Gemini, and other providers, with built-in streaming, tool execution, retries, and middleware.
The `fabro-llm` crate is Fabro's integration layer over [lithos-llm](https://docs.rs/lithos-llm), a provider-neutral LLM catalog and client. lithos owns the request and response vocabulary, the provider catalog, the wire codecs, streaming, and retries. `fabro-llm` adds what Fabro needs on top: building the catalog from lithos built-ins plus Fabro policy and the operator `[llm]` overlay, constructing a client from a Fabro credential source, inlining local file attachments, normalizing reasoning output, one-shot structured output, model probes, and the `fabro exec` server gateway adapter.
You can use it independently of Fabro's workflow engine — add it as a dependency in any Rust project.
Everything below the Fabro layer is the lithos API. `fabro_llm` re-exports the pieces Fabro code touches most: `Client`, `Request`, `Response`, `StreamEvent`, `Error`, `ErrorKind`, `FinishReason`, and the `lithos_catalog`, `types`, `middleware`, `adapter`, and `credentials` modules. See the lithos-llm README for the full client, middleware, and streaming contract.
```toml title="Cargo.toml"
[dependencies]
fabro-auth = { git = "https://github.com/fabro-sh/fabro" }
fabro-llm = { git = "https://github.com/fabro-sh/fabro" }
fabro-model = { git = "https://github.com/fabro-sh/fabro" }
fabro-types = { git = "https://github.com/fabro-sh/fabro" }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
```
### Quick start
The simplest path is an environment-backed `CredentialSource`, an explicit `Arc<Catalog>`, then `Client::from_source(&source, catalog)`. That keeps credential and model resolution explicit while still auto-reading environment variables such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY`.
Build a catalog, build a client over a credential source, then send a lithos `Request`. `VaultCredentialSource::environment_only()` reads provider keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY` from the process environment.
```rust
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_llm::generate::{generate, GenerateParams};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::Catalog;
use std::sync::Arc;
use fabro_auth::VaultCredentialSource;
use fabro_llm::{ClientOptions, Request};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let source = EnvCredentialSource::new();
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?);
let client = Client::from_source(&source, Arc::clone(&catalog)).await?;
let catalog = fabro_llm::default_catalog();
let built = fabro_llm::build_client(
catalog,
Arc::new(VaultCredentialSource::environment_only()),
ClientOptions::standard(),
)
.await?;
for issue in &built.build_issues {
eprintln!("provider {} is unavailable: {}", issue.provider, issue.cause);
}
let client = built.client;
let result = generate(
GenerateParams::new("claude-sonnet-4-5", client.clone())
.prompt("Explain ownership in Rust in two sentences.")
).await?;
let request = Request::builder()
.model("claude-sonnet-4.5")
.user("Explain ownership in Rust in two sentences.")
.build()?;
let response = client.complete(request).await?;
println!("{}", result.text());
println!("Tokens used: {}", result.total_usage.total_tokens);
println!("{}", response.text());
println!("Tokens used: {}", response.usage.input + response.usage.billable_output());
Ok(())
}
```
### Catalog
`fabro_llm::default_catalog()` is the lithos built-in catalog with Fabro's policy layer applied. `fabro_llm::build_catalog(&overlay, &env_lookup)` adds an operator `[llm]` overlay on top, the same layering the server and CLI use. `fabro_config::load_llm_overlay(None)` reads that overlay from the active settings file.
```rust
use fabro_config::load_llm_overlay;
let overlay = load_llm_overlay(None)?;
let catalog = fabro_llm::build_catalog(&overlay, &|name| std::env::var(name).ok())?;
```
The `fabro_llm::catalog` module reads Fabro policy from the catalog: `enabled_providers`, `models`, `model_on_provider`, `default_model`, `probe_model`, `small_default_for_ready`, and `agent_profile`. Disabled providers and models are invisible to every query. `fabro_llm::selection` chooses a provider and model before a request exists, the way run creation and validation do: a known selector resolves to its canonical offering, `provider/model` pins the provider, and an unknown selector on a passthrough provider passes through verbatim.
### Client
`Client` is the core type that holds provider adapters and middleware. It routes each request to the appropriate provider.
`fabro_llm::build_client(catalog, source, options)` returns a `FabroClient`: the lithos `Client`, the providers that are ready, the providers whose credentials could not be used, and the providers lithos could not build an adapter for. Credentials are read from the `CredentialSource` on every provider attempt, so a refreshed OAuth token is picked up without rebuilding the client.
#### Creating from a credential source
`ClientOptions::standard()` turns on the lithos retry middleware (three attempts with short exponential backoff) and local attachment inlining. Add middleware with `with_middleware`, replace a provider's adapter with `with_adapter`, or set `http` to inject a configured HTTP client. `fabro_llm::build_offline_client(catalog, options)` builds a client whose only providers are custom adapters, which is how `fabro exec --server` routes every call through a Fabro server.
Credential sources live in `fabro-auth`: `VaultCredentialSource` reads a Fabro vault with an optional process-environment fallback (`VaultCredentialSource::environment_only()` for SDK callers with no vault), and `SqlVaultCredentialSource` reads the server's secret store. lithos-llm decides which secret names a provider reads (`OPENAI_API_KEY`, `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`, or `<PROVIDER>_API_KEY` for an operator-defined provider); Fabro's vault is keyed by those same names.
#### Requests and responses
`Request::builder()` is the lithos request builder. `model` takes a `provider/model` route, a model id or alias, or a provider id. `system`, `user`, and `message` add messages; `tool`, `tool_choice`, `response_format`, `max_output_tokens`, `temperature`, `reasoning_effort`, and `speed` set controls. `client.complete(request)` returns a `Response` whose `content` is a list of `ContentPart` values, with `text()` and `tool_calls()` helpers, plus `finish_reason`, `usage`, and `cost`.
```rust
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::Catalog;
use std::sync::Arc;
use fabro_llm::Request;
use fabro_types::{Message, Role};
let source = EnvCredentialSource::new();
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?);
let client = Client::from_source(&source, Arc::clone(&catalog)).await?;
```
let request = Request::builder()
.model("openai/gpt-5.4")
.system("You are a helpful assistant.")
.message(Message::text(Role::User, "What is the capital of France?"))
.temperature(0.0)
.build()?;
For env-backed usage, `EnvCredentialSource` checks for API key environment variables and registers adapters for each provider found:
| Environment variable | Provider |
|---|---|
| `ANTHROPIC_API_KEY` | Anthropic |
| `OPENAI_API_KEY` | OpenAI |
| `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Gemini |
| `MOONSHOT_API_KEY` or `KIMI_API_KEY` | Moonshot AI; `MOONSHOT_API_KEY` takes precedence |
| `ZAI_API_KEY` | ZAI |
| `MINIMAX_API_KEY` | Minimax |
| `INCEPTION_API_KEY` | Inception |
| `POOLSIDE_API_KEY` | Poolside |
| `DEEPSEEK_API_KEY` | DeepSeek |
| `OPENROUTER_API_KEY` | OpenRouter, when enabled in settings |
The first provider registered becomes the default. Provider base URLs come from the model catalog. For vault-backed usage inside Fabro, use `fabro_auth::VaultCredentialSource` instead.
The built-in Modal definition reads two proxy-token headers from the vault, so `EnvCredentialSource` does not configure it automatically. For direct SDK use, enable Modal and set its endpoint URL in the catalog:
```toml
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
```
Then read the two environment variables explicitly and create a typed credential after constructing `catalog` from those settings:
```rust
use fabro_auth::ApiCredential;
use fabro_llm::client::Client;
use std::collections::HashMap;
let credential = ApiCredential::with_extra_headers(
"modal",
HashMap::from([
("Modal-Key".to_string(), std::env::var("MODAL_TOKEN_ID")?),
(
"Modal-Secret".to_string(),
std::env::var("MODAL_TOKEN_SECRET")?,
),
]),
);
let client = Client::from_credentials(vec![credential], catalog).await?;
```
#### Creating manually
```rust
use fabro_llm::client::Client;
use fabro_llm::providers::AnthropicAdapter;
use std::collections::HashMap;
use std::sync::Arc;
let adapter = AnthropicAdapter::new("sk-ant-...")
.with_base_url("https://custom-proxy.example.com");
let mut providers = HashMap::new();
providers.insert("anthropic".to_string(), Arc::new(adapter) as _);
let client = Client::new(providers, Some("anthropic".to_string()), vec![]);
```
#### Low-level calls
For direct control without the tool loop, use `complete()` and `stream()` on the client:
```rust
use fabro_llm::types::{Request, Message};
let request = Request {
model: "claude-sonnet-4-5".into(),
messages: vec![Message::user("Hello")],
..Default::default()
};
let response = client.complete(&request).await?;
let response = client.complete(request).await?;
println!("{}", response.text());
```
### High-level generation
The `generate()` function wraps the client with automatic tool execution loops, retries, and timeouts. It is the recommended entry point for most use cases.
#### Basic completion
```rust
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_llm::generate::{generate, GenerateParams};
# let source = EnvCredentialSource::new();
# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap());
# let client = Client::from_source(&source, catalog).await?;
let result = generate(
GenerateParams::new("claude-sonnet-4-5", client.clone())
.system("You are a helpful assistant.")
.prompt("What is the capital of France?")
.temperature(0.0)
).await?;
println!("{}", result.text());
```
#### Multi-turn conversations
Use `.messages()` instead of `.prompt()` to pass a full conversation history:
```rust
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_llm::types::Message;
# let source = EnvCredentialSource::new();
# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap());
# let client = Client::from_source(&source, catalog).await?;
let result = generate(
GenerateParams::new("claude-sonnet-4-5", client.clone())
.messages(vec![
Message::user("My name is Alice."),
Message::assistant("Hello Alice! How can I help you?"),
Message::user("What's my name?"),
])
).await?;
```
<Note>
You cannot use both `.prompt()` and `.messages()` on the same request — this returns `SdkError::Configuration`.
</Note>
#### GenerateParams reference
| Method | Type | Description |
|---|---|---|
| `new(model, client)` | `(impl Into<String>, Arc<Client>)` | Required. Model ID or alias plus the client to use |
| `.prompt(text)` | `impl Into<String>` | Convenience: sends a single user message |
| `.messages(msgs)` | `Vec<Message>` | Full conversation history |
| `.system(text)` | `impl Into<String>` | System prompt |
| `.tools(tools)` | `Vec<Tool>` | Tools available to the model |
| `.tool_choice(choice)` | `ToolChoice` | How the model selects tools |
| `.max_tool_rounds(n)` | `u32` | Max tool execution rounds (default: 1) |
| `.temperature(t)` | `f64` | Sampling temperature |
| `.top_p(p)` | `f64` | Nucleus sampling |
| `.max_tokens(n)` | `i64` | Maximum output tokens |
| `.stop_sequences(seqs)` | `Vec<String>` | Stop sequences |
| `.reasoning_effort(level)` | `impl Into<String>` | e.g. `"low"`, `"medium"`, `"high"` |
| `.provider(name)` | `impl Into<String>` | Force a specific provider |
| `.max_retries(n)` | `u32` | Retry count for transient errors (default: 2) |
| `.timeout(config)` | `TimeoutConfig` | Total and per-step timeouts |
| `.abort_signal(token)` | `CancellationToken` | Cancel generation |
| `.stop_when(f)` | `Fn(&[StepResult]) -> bool` | Custom stop condition after each tool round |
#### GenerateResult
`GenerateResult` dereferences to `Response`, so you can call response methods directly:
```rust
let result = generate(params).await?;
// Response methods (via Deref)
result.text(); // concatenated text output
result.tool_calls(); // Vec<ToolCall> from the final response
result.reasoning(); // Option<String> — extended thinking content
// GenerateResult fields
result.response; // Response — the final LLM response
result.tool_results; // Vec<ToolResult> — from the final step
result.total_usage; // Usage — aggregated across all steps
result.steps; // Vec<StepResult> — one per tool round
result.output; // Option<Value> — for structured output
```
### Tools
Tools let the model call functions during generation. There are two kinds:
- **Active tools** have an execute handler — Fabro runs them automatically and feeds results back to the model.
- **Passive tools** have no handler — Fabro returns the tool calls to you in the response.
#### Defining an active tool
```rust
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_llm::tools::Tool;
use serde_json::json;
let weather = Tool::active(
"get_weather",
"Get the current weather for a city",
json!({
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name" }
},
"required": ["city"]
}),
|args, _ctx| async move {
let city = args["city"].as_str().unwrap_or("unknown");
Ok(json!({ "temperature": "72°F", "city": city }))
},
);
```
#### Using tools with generate
```rust
# use fabro_auth::EnvCredentialSource;
# use fabro_llm::client::Client;
# let source = EnvCredentialSource::new();
# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap());
# let client = Client::from_source(&source, catalog).await?;
let result = generate(
GenerateParams::new("claude-sonnet-4-5", client.clone())
.prompt("What's the weather in San Francisco?")
.tools(vec![weather])
.max_tool_rounds(5)
).await?;
// Inspect the tool execution history
for (i, step) in result.steps.iter().enumerate() {
let calls = step.response.tool_calls();
println!("Step {i}: {} tool calls, {} results", calls.len(), step.tool_results.len());
}
```
The `generate()` function loops automatically: the model calls tools, Fabro executes them, feeds results back, and repeats until the model stops or `max_tool_rounds` is reached.
#### Tool choice
Control how the model selects tools:
```rust
use fabro_llm::types::ToolChoice;
// Let the model decide (default)
# use fabro_auth::EnvCredentialSource;
# use fabro_llm::client::Client;
# let source = EnvCredentialSource::new();
# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap());
# let client = Client::from_source(&source, catalog).await?;
GenerateParams::new("opus", client.clone()).tool_choice(ToolChoice::Auto);
// Force a specific tool
GenerateParams::new("opus", client.clone()).tool_choice(ToolChoice::Named {
tool_name: "get_weather".into()
});
// Force the model to use some tool
GenerateParams::new("opus", client.clone()).tool_choice(ToolChoice::Required);
// Prevent tool use
GenerateParams::new("opus", client.clone()).tool_choice(ToolChoice::None);
```
#### Passive tools
Passive tools let you handle execution yourself:
```rust
# use fabro_auth::EnvCredentialSource;
# use fabro_llm::client::Client;
# let source = EnvCredentialSource::new();
# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap());
# let client = Client::from_source(&source, catalog).await?;
let search = Tool::passive(
"search",
"Search the codebase",
json!({
"type": "object",
"properties": {
"query": { "type": "string" }
},
"required": ["query"]
}),
);
let result = generate(
GenerateParams::new("claude-sonnet-4-5", client.clone())
.prompt("Find all uses of the Config struct")
.tools(vec![search])
).await?;
// Handle tool calls yourself
for call in result.tool_calls() {
println!("Model wants to call {} with {}", call.name, call.arguments);
}
```
There is no tool-execution loop in `fabro-llm`. The agent loop lives in `fabro-agent`, which decides when to run a tool and feeds results back as `Role::Tool` messages.
### Streaming
#### Text stream
For simple cases where you only need the text deltas:
`client.stream(request)` returns a lithos `ResponseStream`, a `Stream` of `StreamEvent` values. Events are discriminated by `type` on the wire: `started`, `content_block_start`, `text_delta`, `reasoning_delta`, `tool_call_delta`, `content_block_end`, `usage`, `rate_limits`, and `ended`, which carries the complete `Response`.
```rust
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_llm::generate::{stream, GenerateParams};
use fabro_llm::StreamEvent;
use futures::StreamExt;
# let source = EnvCredentialSource::new();
# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap());
# let client = Client::from_source(&source, catalog).await?;
let stream_result = stream(
GenerateParams::new("claude-sonnet-4-5", client.clone())
.prompt("Write a haiku about Rust")
).await?;
let mut text_stream = stream_result.text_stream();
while let Some(chunk) = text_stream.next().await {
print!("{}", chunk?);
}
```
#### Full event stream
For fine-grained control, consume `StreamEvent` variants directly:
```rust
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_llm::generate::{stream, GenerateParams};
use fabro_llm::types::StreamEvent;
use futures::StreamExt;
# let source = EnvCredentialSource::new();
# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap());
# let client = Client::from_source(&source, catalog).await?;
let mut stream_result = stream(
GenerateParams::new("claude-sonnet-4-5", client.clone())
.prompt("Explain monads")
).await?;
while let Some(event) = stream_result.next().await {
let mut stream = client.stream(request).await?;
while let Some(event) = stream.next().await {
match event? {
StreamEvent::TextDelta { delta, .. } => print!("{delta}"),
StreamEvent::ReasoningDelta { delta } => eprint!("[thinking] {delta}"),
StreamEvent::ToolCallStart { tool_call } => {
println!("\n> Calling tool: {}", tool_call.name);
}
StreamEvent::StepFinish { usage, .. } => {
println!("\n[step done, {} tokens]", usage.total_tokens);
}
StreamEvent::Finish { response, .. } => {
StreamEvent::TextDelta { text, .. } => print!("{text}"),
StreamEvent::Ended { response } => {
println!("\n[done: {:?}]", response.finish_reason);
}
_ => {}
@ -710,282 +421,95 @@ while let Some(event) = stream_result.next().await {
}
```
#### StreamEvent variants
| Variant | Description |
|---|---|
| `StreamStart` | Stream opened |
| `TextStart { text_id }` | Text block started |
| `TextDelta { delta, text_id }` | Incremental text chunk |
| `TextEnd { text_id }` | Text block ended |
| `ReasoningStart` | Extended thinking started |
| `ReasoningDelta { delta }` | Incremental reasoning chunk |
| `ReasoningEnd` | Extended thinking ended |
| `ToolCallStart { tool_call }` | Tool call started |
| `ToolCallDelta { tool_call }` | Incremental tool call arguments |
| `ToolCallEnd { tool_call }` | Tool call complete |
| `StepFinish { finish_reason, usage, response, tool_calls, tool_results }` | A tool round completed (more rounds may follow) |
| `Finish { finish_reason, usage, response }` | Generation complete |
| `Error { error, raw }` | Provider error |
A turn that ends with `FinishReason::Length` or `FinishReason::Incomplete` is not complete. Tool calls from such a turn arrive in `response.suppressed_tool_calls` and must not be executed. `fabro-agent` treats both as a retryable failure of the turn.
### Structured output
Generate typed JSON objects that conform to a JSON Schema:
`fabro_llm::structured::complete_object` attaches a JSON Schema as the request's response format and parses the reply:
```rust
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_llm::generate::{generate_object, GenerateParams};
use fabro_llm::{Request, structured};
use serde_json::json;
# let source = EnvCredentialSource::new();
# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap());
# let client = Client::from_source(&source, catalog).await?;
let schema = json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" },
"hobbies": {
"type": "array",
"items": { "type": "string" }
}
"age": { "type": "integer" }
},
"required": ["name", "age", "hobbies"]
"required": ["name", "age"]
});
let result = generate_object(
GenerateParams::new("claude-sonnet-4-5", client.clone())
.prompt("Generate a profile for a fictional character"),
schema,
).await?;
let profile = result.output.expect("structured output");
println!("Name: {}", profile["name"]);
let request = Request::builder()
.model("claude-sonnet-4.5")
.user("Generate a profile for a fictional character")
.build()?;
let completion = structured::complete_object(&client, request, "profile", schema).await?;
println!("Name: {}", completion.object["name"]);
```
### Reasoning
`fabro_llm::reasoning::normalize(&response.content)` folds a response's readable reasoning parts into a `fabro_types::ReasoningOutput` with a summary and a trace. Provider replay data such as signatures and encrypted reasoning never appears in it.
### Middleware
Middleware intercepts requests and responses for logging, caching, or transformation:
```rust
use fabro_llm::middleware::{Middleware, NextFn, NextStreamFn};
use fabro_llm::provider::StreamEventStream;
use fabro_llm::types::{Request, Response};
use fabro_llm::error::SdkError;
use async_trait::async_trait;
struct LoggingMiddleware;
#[async_trait]
impl Middleware for LoggingMiddleware {
async fn handle_complete(
&self,
request: Request,
next: NextFn,
) -> Result<Response, SdkError> {
println!("Request to model: {}", request.model);
let response = next(request).await?;
println!("Response: {} tokens", response.usage.total_tokens);
Ok(response)
}
async fn handle_stream(
&self,
request: Request,
next: NextStreamFn,
) -> Result<StreamEventStream, SdkError> {
println!("Streaming request to model: {}", request.model);
next(request).await
}
}
```
Add middleware to the client:
```rust
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::Catalog;
let source = EnvCredentialSource::new();
let catalog = std::sync::Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?);
let mut client = Client::from_source(&source, catalog).await?;
client.add_middleware(std::sync::Arc::new(LoggingMiddleware));
```
### Model catalog
The crate embeds a catalog of known models with metadata:
```rust
use fabro_llm::catalog;
// Look up a model by ID or alias
let info = catalog::get_model_info("opus").unwrap();
println!("{} ({})", info.display_name, info.provider);
println!("Context: {} tokens", info.limits.context_window);
println!("Tools: {}, Vision: {}", info.features.tools, info.features.vision);
// List all models for a provider
let models = catalog::list_models(Some("anthropic"));
// Get the default model for a provider
let default = catalog::default_model_for_provider("openai").unwrap();
// Find a capability-matched model on a different provider
let equivalent = catalog::closest_model("gemini", &info);
```
See [Models](/core-concepts/models) for the full catalog table.
Middleware is the lithos `Middleware` trait: `handle(&self, call: Call, next: Next)` sees the resolved route and request and returns an `Output` that is either a complete response or a stream. `fabro_llm::attachments::InlineLocalAttachments` is Fabro's own middleware; it rewrites local file references in messages into inline media before dispatch.
### Error handling
All fallible operations return `Result<T, SdkError>`. The error type classifies failures to enable retry and failover decisions:
Every fallible operation returns `Result<T, fabro_llm::Error>`, the lithos error. `error.kind()` is an `ErrorKind` such as `Authentication`, `RateLimit`, `Server`, `ContextLength`, `ContentFilter`, `Timeout`, `StreamDecode`, or `Cancelled`. `error.data()` is the `ErrorData` snapshot Fabro stores in run events; `fabro_llm::LlmError` wraps it.
```rust
use fabro_llm::error::SdkError;
The `fabro_llm::ErrorFacts` trait is implemented for `Error`, `ErrorData`, and `LlmError`, and the classification helpers take any of them:
match result {
Err(SdkError::Provider { kind, detail }) => {
println!("Provider error ({}): {}", detail.provider, detail.message);
if let Some(code) = detail.status_code {
println!("HTTP {code}");
}
}
Err(SdkError::RequestTimeout { message, .. }) => println!("Timeout: {message}"),
Err(SdkError::Network { message, .. }) => println!("Network: {message}"),
Err(SdkError::Interrupt { message }) => println!("Cancelled: {message}"),
Err(e) => println!("Other: {e}"),
Ok(_) => {}
}
```
#### Error classification
Every `SdkError` exposes classification methods:
| Method | Returns | Description |
|---|---|---|
| `retryable()` | `bool` | Safe to retry with the same provider (e.g. rate limit, server error) |
| `failover_eligible()` | `bool` | Safe to try a different provider |
| `retry_after()` | `Option<f64>` | Seconds to wait before retrying (from provider `Retry-After` header) |
| `status_code()` | `Option<u16>` | HTTP status code, if applicable |
| `provider_name()` | `&str` | Which provider returned the error |
#### Provider error kinds
| Kind | HTTP status | Retryable | Failover |
|---|---|---|---|
| `Authentication` | 401 | No | No |
| `AccessDenied` | 403 | No | No |
| `NotFound` | 404 | No | No |
| `InvalidRequest` | 400 | No | No |
| `RateLimit` | 429 | Yes | Yes |
| `Server` | 500, 502, 503 | Yes | Yes |
| `ContentFilter` | varies | No | No |
| `ContextLength` | varies | No | No |
| `QuotaExceeded` | varies | No | Yes |
| Function | Description |
|---|---|
| `is_retryable(&error)` | Safe to retry with the same provider, from lithos's retry classification |
| `failover_eligible(&error)` | Safe to try a different provider |
| `is_auth_error(&error)` | The credential was missing or rejected |
| `is_cancelled(&error)` | The caller cancelled the call |
| `failure_signature_hint(&error)` | A stable string for loop and restart detection |
### Retries
The `generate()` function retries automatically based on `max_retries` (default: 2). For low-level use, the `retry` function wraps any async operation:
```rust
use fabro_llm::retry::retry;
use fabro_llm::types::RetryPolicy;
let policy = RetryPolicy {
max_retries: 3,
base_delay: 1.0,
max_delay: 60.0,
backoff_multiplier: 2.0,
jitter: true,
on_retry: None,
};
let response = retry(&policy, || {
let c = client.clone();
let r = request.clone();
async move { c.complete(&r).await }
}).await?;
```
Retry only fires when `error.retryable()` returns `true` and respects `Retry-After` headers.
The lithos `RetryMiddleware` installed by `ClientOptions::standard()` retries a request until its stream delivers visible output. After visible output the client never replays on its own; `fabro-agent` decides whether to replay a turn using `RetryPolicy::next_delay`, the same decision the middleware uses. Insert a `fabro_llm::RetryListener` into a call's context extensions to be told about each retry the middleware performs.
### Cancellation
Pass a `CancellationToken` to interrupt long-running generation:
Pass a `CallContext` with a cancellation token through `complete_with_context` or `stream_with_context`. Cancelling the token ends the call with `ErrorKind::Cancelled`.
```rust
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use tokio_util::sync::CancellationToken;
use fabro_llm::CallContext;
# let source = EnvCredentialSource::new();
# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap());
# let client = Client::from_source(&source, catalog).await?;
let token = CancellationToken::new();
let token_clone = token.clone();
// Cancel after 30 seconds
let context = CallContext::new();
let cancel = context.cancellation().clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
token_clone.cancel();
cancel.cancel();
});
let result = generate(
GenerateParams::new("opus", client.clone())
.prompt("Write a novel")
.abort_signal(token)
).await;
// Returns SdkError::Interrupt if cancelled
let result = client.complete_with_context(request, context).await;
```
### Probes
`fabro_llm::probe::run_model_test(&client, "provider/model", mode, reasoning_effort, timeout)` sends the lithos model probe: one word in `Basic` mode, a two-step tool exchange in `Deep` mode. `probe_provider_with_api_key` validates an operator-supplied key against a provider's probe model before it is stored.
### Provider adapters
Each provider has a dedicated adapter. All adapters implement the `ProviderAdapter` trait and are interchangeable.
Providers are lithos adapters selected by the catalog `adapter` id: `anthropic`, `openai`, `gemini`, `openai-compatible`, and `bedrock`. A new OpenAI-compatible endpoint needs a catalog entry, not code.
| Adapter | Provider | Constructor |
|---|---|---|
| `AnthropicAdapter` | Anthropic Messages API | `::new(api_key)` |
| `OpenAiAdapter` | OpenAI Responses API | `::new(api_key)` |
| `GeminiAdapter` | Google Gemini API | `::new(api_key)` |
| `OpenAiCompatibleAdapter` | Any OpenAI-compatible endpoint | `::new(api_key, base_url)` |
All adapters support `.with_base_url()` for proxies or custom endpoints. `OpenAiAdapter` also supports `.with_org_id()` and `.with_project_id()`.
#### Custom provider
Implement the `ProviderAdapter` trait to add a new provider:
To add a custom transport, implement the lithos `ProviderAdapter` trait and register it with `ClientOptions::with_adapter`. `fabro_llm::gateway::GatewayAdapter` is Fabro's own example: it posts each request to a Fabro server's completions endpoint, which returns lithos `Response` JSON and streams lithos `StreamEvent` JSON verbatim.
```rust
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::types::{Request, Response};
use fabro_llm::error::SdkError;
use async_trait::async_trait;
use std::sync::Arc;
struct MyProvider;
use fabro_llm::ClientOptions;
use fabro_llm::gateway::GatewayAdapter;
use fabro_types::ProviderId;
#[async_trait]
impl ProviderAdapter for MyProvider {
fn name(&self) -> &str { "my-provider" }
async fn complete(&self, request: &Request) -> Result<Response, SdkError> {
// Call your provider's API
todo!()
}
async fn stream(&self, request: &Request) -> Result<StreamEventStream, SdkError> {
// Return a stream of events
todo!()
}
}
```
Register it on the client:
```rust
client.register_provider(Arc::new(MyProvider)).await?;
let adapter = Arc::new(GatewayAdapter::new(Box::new(my_transport)));
let built = fabro_llm::build_offline_client(
catalog,
ClientOptions::default().with_adapter(ProviderId::new("anthropic"), adapter),
)?;
```

View file

@ -35,7 +35,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver
|---|---|
| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` |
| Server-side run policy | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github]`, `[run.hooks]`, `[run.agent.mcps]` |
| Shared LLM catalog | `[llm.providers.<id>]`, provider-scoped `[llm.providers.<id>.models.<slug>]` offerings, limits, features, controls, and costs |
| Shared LLM catalog | `[llm]`, a lithos-llm catalog overlay: `[llm.providers.<id>]`, `[llm.providers.<id>.models.<id>]`, and the agent harness under `metadata.agent` |
| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
`[cli.*]` and `[server.*]` stanzas are owner-specific: they are only consumed from `~/.fabro/settings.toml` (plus process-local flags and env overrides). The same stanzas in `.fabro/project.toml` or `workflow.toml` remain schema-valid but runtime-inert.
@ -89,36 +89,28 @@ level = "info"
[llm.providers.proxy]
display_name = "Acme Gateway"
adapter = "openai_compatible"
adapter = "openai-compatible"
codec = "openai-chat"
base_url = "https://llm-gateway.example.com/v1"
auth = { type = "bearer" }
aliases = ["gateway"]
default_model = "team-code-large"
[llm.providers.proxy.auth]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
[llm.providers.proxy.default_headers]
x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}"
x-portkey-config = "@bedrock-prod"
[llm.providers.proxy.metadata.agent]
profile = "anthropic"
[llm.providers.proxy.models."team-code-large"]
api_id = "provider-wire-model-name"
agent_profile = "anthropic"
display_name = "Team Code Large"
default = true
aliases = ["team-code"]
[llm.providers.proxy.models."team-code-large".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.providers.proxy.models."team-code-large".costs]
input_cost_per_mtok = 1.50
output_cost_per_mtok = 8.00
[llm.providers.proxy.models."team-code-large".costs.speed.fast]
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
api_model = "provider-wire-model-name"
limits = { context_tokens = 200000, max_output_tokens = 32000 }
capabilities = { text = true, tools = true, reasoning = true, reasoning_effort = { low = true, medium = true, high = true } }
protocol_options = { reasoning_effort_levels = true }
pricing = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 8000000 }
```
All fields are optional. Include only the sections and keys you want to override. A single file can still include both CLI and server sections when you run both processes on one machine, but explicit remote targets do not read remote server state from the local machine.
@ -147,146 +139,113 @@ url = "https://fabro.example.com/api/v1"
| `url` | string | None | Required for `type = "http"`; the API base URL. |
| `path` | string | None | Required for `type = "unix"`; the absolute Unix socket path. |
## `[llm.providers.<id>]`
## `[llm]`
Define or override an LLM provider. Provider IDs are strings, so custom
providers can be added when they use an adapter Fabro already supports.
The `[llm]` table is a [lithos-llm](https://docs.rs/lithos-llm) catalog
overlay. Fabro builds its model catalog from two layers: the lithos built-in
providers and models, and this table. Later layers win; tables merge key by
key and every other value replaces. Fabro does not interpret the table itself.
lithos validates it when the catalog is built, and rejects unknown provider or
model fields.
Several built-in providers ship with `enabled = false`. Turn one on by setting
`enabled = true` on its provider table.
```toml title="settings.toml"
[llm.providers.proxy]
display_name = "Acme Gateway"
adapter = "openai_compatible"
adapter = "openai-compatible"
codec = "openai-chat"
base_url = "https://llm-gateway.example.com/v1"
auth = { type = "bearer" }
priority = 50
enabled = true
aliases = ["gateway"]
default_model = "team-code-large"
[llm.providers.proxy.auth]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = "{{ secrets.portkey_api_key }}"
[llm.providers.proxy.default_headers]
x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}"
x-portkey-config = "@bedrock-prod"
x-team-secret = "{{ secrets.gateway_team_secret }}"
[llm.providers.proxy.metadata.agent]
profile = "anthropic"
[llm.providers.proxy.models."team-code-large"]
display_name = "Team Code Large"
aliases = ["team-code"]
api_model = "provider-wire-model-name"
limits = { context_tokens = 200000, max_output_tokens = 32000 }
capabilities = { text = true, tools = true, reasoning = true, caching = true, reasoning_effort = { low = true, medium = true, high = true } }
protocol_options = { reasoning_effort_levels = true }
pricing = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 8000000, cached_input_usd_micros_per_million = 300000 }
family = "team-code"
small_default = true
estimated_output_tps = 80
```
A provider's API key is the secret lithos names for it: `OPENAI_API_KEY` for
`openai`, `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` for `modal`, and
`<PROVIDER>_API_KEY` (upper case, `-` and `.` as `_`) for a provider you
define, so the gateway above reads `PROXY_API_KEY`. Store it in the server
vault with `fabro secret set`, or export it for `fabro exec` and SDK use.
## `[llm.providers.<id>]`
Define or override an LLM provider. The keys are the lithos provider record.
| Key | Type / values | Default | Description |
|---|---|---|---|
| `display_name` | string | provider ID | Human-readable provider name. |
| `adapter` | string | built-in value | Adapter registry key, such as `"anthropic"`, `"openai"`, `"gemini"`, or `"openai_compatible"`. Required for new providers. |
| `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` | derived from `adapter` | Agent profile used for project memory, CLI/ACP command selection, and native session routing. Override only when a provider needs profile behavior different from its adapter. |
| `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | derived from `adapter` | Provider-owned billing algorithm for usage estimates. Override for exceptional providers such as local no-billing runtimes. |
| `base_url` | string | built-in value or adapter runtime default | Provider API base URL. Required for most custom OpenAI-compatible providers. |
| `auth` | table | omitted | API-key auth config. Omit the table entirely for providers that need no API key; any `extra_headers` are still attached. |
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>`, `env:<NAME>`, and `aws_sigv4` (sign requests from the AWS default credential chain — Bedrock). Literal secret strings are rejected. |
| `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. |
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values are literal text or `{{ secrets.NAME }}` interpolation strings. Put credentials in a secret and reference them with a token, not a bare literal. |
| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection; ties use canonical provider ID. |
| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. |
| `display_name` | string | required for new providers | Human-readable provider name. |
| `adapter` | string | required for new providers | lithos adapter id: `anthropic`, `openai`, `gemini`, `openai-compatible`, or `bedrock`. |
| `codec` | string | required for new providers | Wire codec: `anthropic-messages`, `openai-responses`, `openai-chat`, `gemini-generate`, or `bedrock-converse`. |
| `base_url` | string | required for new providers | Provider API base URL. The `openai-compatible` adapter appends `/v1/chat/completions` unless the URL already ends in a version segment. |
| `auth` | table | required for new providers | Auth scheme: `{ type = "bearer" }`, `{ type = "header", name = "x-api-key" }`, `{ type = "headers" }`, `{ type = "none" }`, or `{ type = "aws" }`. |
| `enabled` | boolean | `true` | Set `false` to hide a provider from Fabro. `bedrock`, `bedrock-openai`, `fireworks`, `litellm`, `modal`, `ollama`, and `openrouter` ship disabled. |
| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection. |
| `aliases` | array<string> | `[]` | Additional provider names accepted by model routing and fallback config. |
| `default_model` | string | None | The provider's default model id. |
| `allow_passthrough` | boolean | `false` | Whether `provider/model` selectors may name models the catalog does not list. |
| `api_key_url` | string | None | Where an operator obtains an API key. Shown by `fabro provider login` and the install flow. |
| `stands_in_for` | string | None | Another provider this one answers for when that provider has no credentials. `openai-codex` stands in for `openai`. |
| `default_headers` | table | `{}` | Headers attached to every request. A value may be literal text or a `{{ secrets.NAME }}` token resolved against the vault. |
## `[llm.providers.<provider>.models.<model-slug>]`
## `[llm.providers.<id>.metadata.agent]`
Which coding harness the provider's models expect. Pebble reads the same
namespace. Every key is optional; a model row overrides the provider.
| Key | Type / values | Default | Description |
|---|---|---|---|
| `profile` | `"anthropic"` \| `"claude-5"` \| `"openai"` \| `"gemini"` \| `"kimi"` \| `"gpt56"` \| `"gpt6"` | derived from `adapter` | Agent profile for models on this provider. |
| `reasoning_by_default` | boolean | reasoning models with effort levels: `true` | Whether requests reason when no `reasoning_effort` is supplied. |
## `[llm.providers.<provider>.models.<model-id>]`
Define or override one provider's offering of a model. The table key is the
canonical model slug Fabro users reference. An offering's identity is the
pair `(provider, model slug)`, so different providers may use the same slug
and aliases. `api_id` is the opaque model string sent to this provider's API
and defaults to the exact model slug.
```toml title="settings.toml"
[llm.providers.proxy.models."team-code-large"]
api_id = "provider-wire-model-name"
agent_profile = "anthropic"
display_name = "Team Code Large"
family = "team-code"
default = true
probe = true
enabled = true
aliases = ["team-code"]
estimated_output_tps = 80
[llm.providers.proxy.models."team-code-large".limits]
context_window = 200000
max_output = 32000
[llm.providers.proxy.models."team-code-large".features]
tools = true
vision = false
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
[llm.providers.proxy.models."team-code-large".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.providers.proxy.models."team-code-large".costs]
input_cost_per_mtok = 1.50
output_cost_per_mtok = 8.00
cache_input_cost_per_mtok = 0.30
[llm.providers.proxy.models."team-code-large".costs.speed.fast]
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
cache_input_cost_per_mtok = 0.60
```
model id Fabro users reference. An offering's identity is the pair
`(provider, model id)`, so different providers may use the same id and
aliases. `api_model` is the string sent to the provider and defaults to the id.
| Key | Type / values | Default | Description |
|---|---|---|---|
| `api_id` | string | model slug | Opaque identifier sent to this provider's API. An explicitly empty value is invalid. |
| `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` | provider profile | Agent profile override for this model. Model overrides take precedence over provider overrides. |
| `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | provider policy | Billing algorithm override for this model — for models whose billing family differs from their provider's (e.g. Claude served through OpenRouter bills Anthropic-style cache reads/writes). |
| `display_name` | string | model ID | Human-readable model name. |
| `family` | string | model ID | Family label used for catalog display and matching. |
| `training` | string | None | Training data cutoff label. |
| `knowledge_cutoff` | string or TOML date | None | Public knowledge cutoff label; TOML dates normalize to `YYYY-MM-DD`. |
| `default` | boolean | `false` | Whether this is the provider default model. |
| `probe` | boolean | `false` | Whether this model should be preferred for provider connectivity probes. Set `false` in a higher-precedence layer to clear an inherited probe marker. |
| `enabled` | boolean | `true` | Set `false` to disable a model after lower-precedence layers define it. |
| `aliases` | array<string> | `[]` | Additional model selectors accepted by routing and fallback config. Aliases may repeat across providers, but one selector cannot identify two models within the same provider. |
| `estimated_output_tps` | number | None | Estimated output tokens per second for catalog display and planning. |
| `display_name` | string | required for new models | Human-readable model name. |
| `aliases` | array<string> | `[]` | Additional selectors. Aliases may repeat across providers. |
| `api_model` | string | model id | Wire model identifier sent to this provider. |
| `limits` | `{ context_tokens, max_output_tokens }` | None | Token limits. |
| `capabilities` | table | unknown | Per-capability `true`, `false`, or `"unknown"`: `text`, `images`, `audio`, `documents`, `tools`, `reasoning`, `caching`, `cache_routing`, `sampling`, plus `tool_choice = { required, named }`, `response_format = { json_object, json_schema }`, `reasoning_effort = { minimal, low, medium, high, xhigh, max }`, and `speed = { fast, balanced, economical }`. |
| `protocol_options` | table | `{}` | Encoding flags: `reasoning_effort_levels`, `cache_breakpoints`, `system_turns`. |
| `pricing` | table | None | USD micros per million tokens: `input_usd_micros_per_million`, `output_usd_micros_per_million`, `cached_input_usd_micros_per_million`, `cache_write_usd_micros_per_million`, plus optional `long_context` and `speed` tiers. |
| `family` | string | model id | Family label for display and grouping. |
| `training_cutoff` | string | None | Training data cutoff, as the provider states it. |
| `knowledge_cutoff` | string | None | Public knowledge cutoff label, as a person would write it. |
| `estimated_output_tps` | number | None | Estimated output tokens per second. |
| `small_default` | boolean | `false` | Preferred for small utility calls such as generated run titles. |
| `probe` | boolean | `false` | Preferred for provider connectivity probes. |
## `[llm.providers.<provider>.models.<model-slug>.limits]`
## `[llm.providers.<provider>.models.<model-id>.metadata.agent]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `context_window` | integer | None | Maximum context window size in tokens. |
| `max_output` | integer | None | Maximum output tokens, if known. |
## `[llm.providers.<provider>.models.<model-slug>.features]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `tools` | boolean | `false` | Whether the model supports tool calls. |
| `vision` | boolean | `false` | Whether the model accepts image inputs. |
| `reasoning` | boolean | `false` | Whether the model has reasoning behavior. |
| `reasoning_by_default` | boolean | effort-capable models: `true`; other models: `false` | Whether requests reason when no `reasoning_effort` is supplied. Set this explicitly for always-reasoning routes that do not expose an effort control, or for effort-capable routes whose provider defaults reasoning off. |
| `reasoning_effort` | `"levels"` \| `"always_adaptive"` \| `"none"` | `"none"` | Whether the model endpoint supports a native reasoning-effort parameter. `levels` accepts discrete effort levels; `always_adaptive` accepts effort levels with natively always-on adaptive thinking; `none` has no native effort parameter. |
| `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. |
| `sampling_params` | boolean | `true` | Whether the model accepts classic sampling parameters (`temperature`, `top_p`). |
## `[llm.providers.<provider>.models.<model-slug>.controls]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `reasoning_effort` | array<string> | all standard levels when feature is `"levels"` or `"always_adaptive"` | User-facing reasoning effort values Fabro may send for this model. Can be set explicitly for reasoning models whose provider adapter maps effort to a non-native API shape. |
| `speed` | array<string> | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. |
## `[llm.providers.<provider>.models.<model-slug>.costs]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `input_cost_per_mtok` | number | None | Input cost in USD per million tokens. |
| `output_cost_per_mtok` | number | None | Output cost in USD per million tokens. |
| `cache_input_cost_per_mtok` | number | None | Cached input/read cost in USD per million tokens. |
## `[llm.providers.<provider>.models.<model-slug>.costs.speed.<speed>]`
Per-speed cost overrides use the same keys as
`[llm.providers.<provider>.models.<model-slug>.costs]`. Each `<speed>` key
must be declared in
`[llm.providers.<provider>.models.<model-slug>.controls].speed`.
The `standard` speed is implicit and always uses the base cost table.
The same keys as the provider-level `metadata.agent` table, applied to one
model. `profile` here is how a Kimi or GPT-5.6 model keeps its own harness on
a gateway whose other models use the provider default.
## `[cli.updates]`

View file

@ -22,7 +22,6 @@ fabro-auth = { path = "../../foundation/fabro-auth" }
fabro-config = { path = "../../foundation/fabro-config" }
fabro-environment = { path = "../../components/fabro-environment" }
fabro-llm = { path = "../../components/fabro-llm" }
fabro-model = { path = "../../foundation/fabro-model", features = ["clap"] }
fabro-oauth = { path = "../../foundation/fabro-oauth" }
fabro-github = { path = "../../components/fabro-github" }
fabro-agent = { path = "../../components/fabro-agent" }

View file

@ -5,9 +5,9 @@ use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand, ValueEnum};
use fabro_agent::cli::AgentArgs;
use fabro_config::{CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer};
use fabro_model::ReasoningEffort;
use fabro_server::serve::DEFAULT_TCP_PORT;
use fabro_static::EnvVars;
use fabro_types::ReasoningEffort;
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
use fabro_types::settings::run::MergeStrategy;
use fabro_util::printer::Printer;
@ -836,7 +836,7 @@ pub(crate) struct ProviderLoginArgs {
/// LLM provider to authenticate with
#[arg(long)]
pub(crate) provider: fabro_model::ProviderId,
pub(crate) provider: fabro_types::ProviderId,
/// Read an API key from stdin instead of prompting
#[arg(long)]
@ -1101,8 +1101,9 @@ pub(crate) struct ModelTestArgs {
#[arg(long, alias = "deep")]
pub(crate) tools: bool,
/// Request a reasoning-effort level
#[arg(long, value_enum)]
/// Request a reasoning-effort level (minimal, low, medium, high, xhigh,
/// max)
#[arg(long, value_parser = parse_reasoning_effort_arg)]
pub(crate) reasoning_effort: Option<ReasoningEffort>,
}
@ -1727,7 +1728,7 @@ pub(crate) struct InstallGithubArgs {
#[derive(Args, Debug, Clone, Default)]
pub(crate) struct InstallNonInteractiveArgs {
#[arg(long, hide = true)]
pub(crate) llm_provider: Option<fabro_model::ProviderId>,
pub(crate) llm_provider: Option<fabro_types::ProviderId>,
#[arg(long, hide = true)]
pub(crate) llm_api_key_stdin: bool,
@ -1854,3 +1855,16 @@ pub(crate) struct CompletionArgs {
/// Shell to generate completions for
pub shell: clap_complete::Shell,
}
fn parse_reasoning_effort_arg(value: &str) -> Result<ReasoningEffort, String> {
value.parse().map_err(|_| {
format!(
"unknown reasoning effort '{value}'; expected one of: {}",
ReasoningEffort::ALL
.into_iter()
.map(ReasoningEffort::as_str)
.collect::<Vec<_>>()
.join(", ")
)
})
}

View file

@ -3,8 +3,8 @@ use std::sync::{Arc, OnceLock};
use anyhow::{Context as _, Result, bail};
use fabro_auth::{CredentialSource, SqlVaultCredentialSource};
use fabro_config::{CliLayer, Storage, load_llm_catalog_settings};
use fabro_model::Catalog;
use fabro_config::{CliLayer, Storage, load_llm_overlay};
use fabro_llm::lithos_catalog::Catalog;
use fabro_types::UserSettings;
use fabro_types::settings::RunNamespace;
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
@ -187,12 +187,7 @@ impl CommandContext {
return Ok(Arc::clone(catalog));
}
let llm_catalog_settings =
load_llm_catalog_settings(None).context("loading LLM catalog")?;
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
.context("building LLM catalog")?,
);
let catalog = Arc::new(load_cli_catalog().context("building LLM catalog")?);
if self.catalog.set(Arc::clone(&catalog)).is_ok() {
return Ok(catalog);
}
@ -244,6 +239,18 @@ fn load_merged_settings(cli_layer: &CliLayer, server_mode: &ServerMode) -> Resul
}
}
/// The catalog CLI commands run against: lithos built-ins, Fabro policy, and
/// the operator `[llm]` overlay from the active settings file.
#[expect(
clippy::disallowed_methods,
reason = "The CLI honors OPENAI_BASE_URL from the process environment."
)]
pub(crate) fn load_cli_catalog() -> Result<Catalog> {
let overlay = load_llm_overlay(None).context("loading the LLM settings overlay")?;
fabro_llm::build_catalog(&overlay, &|name| std::env::var(name).ok())
.context("building the LLM catalog")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;

View file

@ -2,25 +2,19 @@ use std::collections::HashMap;
use std::sync::Arc;
use anyhow::{Context as _, Result as AnyResult};
use async_trait::async_trait;
use fabro_agent::cli::{
OutputFormat, run_with_args_and_client_and_catalog, run_with_args_and_source_and_catalog,
};
use fabro_llm::client::Client;
use fabro_llm::error::{
Error as LlmError, ProviderErrorDetail, ProviderErrorKind, error_from_status_code,
};
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::providers::common::{LineReader, parse_retry_after};
use fabro_llm::types::{
CostSource, FinishReason, Message, Request, Response as LlmResponse, StreamEvent, TokenCounts,
OutputFormat, diagnostic_client_options, run_with_args_and_client_and_catalog,
run_with_args_and_source_and_catalog,
};
use fabro_llm::gateway::{GatewayAdapter, GatewayError, GatewayTransport};
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::{ErrorFacts, ErrorKind, catalog};
use fabro_mcp::config::McpServerSettings;
use fabro_model::ProviderId;
use fabro_types::ProviderId;
use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat;
use fabro_types::settings::run::ResolvedMcpEntry;
use fabro_util::exit::{self, ErrorExt, ExitClass};
use futures::stream;
use serde::Deserialize;
use crate::args::ExecArgs;
use crate::command_context::CommandContext;
@ -28,110 +22,43 @@ use crate::command_context::CommandContext;
use crate::sleep_inhibitor;
use crate::{server_client, user_config};
struct AuthenticatedFabroServerAdapter {
client: server_client::Client,
base_url: String,
provider_name: String,
/// Posts completions to a Fabro server through the authenticated CLI client.
struct ServerCompletionTransport {
client: server_client::Client,
base_url: String,
}
impl AuthenticatedFabroServerAdapter {
fn new(client: server_client::Client, provider_name: impl Into<String>) -> Self {
let base_url = client.base_url().clone();
Self {
client,
base_url,
provider_name: provider_name.into(),
}
impl ServerCompletionTransport {
fn new(client: server_client::Client) -> Self {
let base_url = client.base_url();
Self { client, base_url }
}
}
#[derive(Deserialize)]
struct ServerCompletionResponse {
id: String,
model: String,
message: Message,
stop_reason: String,
usage: ServerUsage,
cost_usd: Option<f64>,
cost_source: Option<CostSource>,
}
#[derive(Deserialize)]
struct ServerUsage {
input_tokens: i64,
output_tokens: i64,
}
fn map_stop_reason(reason: &str) -> FinishReason {
match reason {
"end_turn" | "stop" => FinishReason::Stop,
"max_tokens" | "length" => FinishReason::Length,
"tool_calls" => FinishReason::ToolCalls,
other => FinishReason::Other(other.to_string()),
}
}
fn build_body(request: &Request, stream: bool) -> std::result::Result<serde_json::Value, LlmError> {
let mut body = serde_json::to_value(request).map_err(|err| {
LlmError::configuration_error(format!("failed to serialize request: {err}"), err)
})?;
body["stream"] = serde_json::Value::Bool(stream);
Ok(body)
}
fn parse_server_error_body(body: &str) -> (String, Option<String>, Option<serde_json::Value>) {
serde_json::from_str::<serde_json::Value>(body).map_or_else(
|_| (body.to_string(), None, None),
|value| {
let first = value
.get("errors")
.and_then(serde_json::Value::as_array)
.and_then(|errors| errors.first());
let detail = first
.and_then(|entry| entry.get("detail"))
.and_then(serde_json::Value::as_str)
.or_else(|| value.get("detail").and_then(serde_json::Value::as_str))
.or_else(|| {
value
.get("error")
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
})
.unwrap_or("Unknown error")
.to_string();
let code = first
.and_then(|entry| entry.get("code"))
.and_then(serde_json::Value::as_str)
.or_else(|| {
value
.get("error")
.and_then(|error| error.get("type"))
.and_then(serde_json::Value::as_str)
})
.map(ToOwned::to_owned);
(detail, code, Some(value))
},
)
}
fn transport_error(provider: &str, err: &anyhow::Error) -> LlmError {
let message = err.to_string();
if exit::exit_class_for(err) == Some(ExitClass::AuthRequired) {
return LlmError::Provider {
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail {
message,
provider: provider.to_string(),
status_code: Some(401),
error_code: None,
retry_after: None,
raw: None,
}),
};
}
LlmError::Configuration {
message,
source: None,
#[async_trait]
impl GatewayTransport for ServerCompletionTransport {
async fn post_completion(
&self,
body: serde_json::Value,
) -> Result<fabro_http::Response, GatewayError> {
let url = format!("{}/api/v1/completions", self.base_url);
let response = self
.client
.send_http_response(|http_client| {
let body = body.clone();
let url = url.clone();
async move { http_client.post(url).json(&body).send().await }
})
.await
.map_err(|err| GatewayError::Transport {
auth: exit::exit_class_for(&err) == Some(ExitClass::AuthRequired),
message: err.to_string(),
})?;
response.map_err(|failure| GatewayError::Status {
status: failure.status.as_u16(),
headers: failure.headers,
body: failure.body,
})
}
}
@ -142,8 +69,7 @@ fn classify_server_agent_auth(err: anyhow::Error) -> anyhow::Error {
.is_some_and(|error| {
matches!(
error,
fabro_agent::Error::Llm(llm)
if llm.provider_kind() == Some(ProviderErrorKind::Authentication)
fabro_agent::Error::Llm(llm) if llm.kind() == ErrorKind::Authentication
)
})
});
@ -154,134 +80,6 @@ fn classify_server_agent_auth(err: anyhow::Error) -> anyhow::Error {
}
}
fn map_response_failure(provider: &str, failure: &fabro_client::ApiError) -> LlmError {
let retry_after = parse_retry_after(&failure.headers);
let (message, code, raw) = parse_server_error_body(&failure.body);
error_from_status_code(
failure.status.as_u16(),
message,
provider.to_string(),
code,
raw,
retry_after,
)
}
fn parse_sse_block(block: &str) -> Option<(String, String)> {
let mut event_type = None;
let mut data_lines = Vec::new();
for line in block.lines() {
if let Some(value) = line.strip_prefix("event:") {
event_type = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("data:") {
data_lines.push(value.trim());
}
}
let event_type = event_type?;
if data_lines.is_empty() {
return None;
}
Some((event_type, data_lines.join("\n")))
}
#[async_trait::async_trait]
impl ProviderAdapter for AuthenticatedFabroServerAdapter {
fn name(&self) -> &str {
&self.provider_name
}
async fn complete(&self, request: &Request) -> std::result::Result<LlmResponse, LlmError> {
let url = format!("{}/api/v1/completions", self.base_url);
let body = build_body(request, false)?;
let response = self
.client
.send_http_response(|http_client| {
let body = body.clone();
let url = url.clone();
async move { http_client.post(url).json(&body).send().await }
})
.await
.map_err(|err| transport_error(&self.provider_name, &err))?;
let response =
response.map_err(|failure| map_response_failure(&self.provider_name, &failure))?;
let response_body = response
.text()
.await
.map_err(|err| LlmError::network(err.to_string(), err))?;
let server_response: ServerCompletionResponse = serde_json::from_str(&response_body)
.map_err(|err| {
LlmError::stream_error(format!("failed to parse completion response: {err}"), err)
})?;
Ok(LlmResponse {
id: server_response.id,
model: server_response.model,
provider: self.provider_name.clone(),
message: server_response.message,
finish_reason: map_stop_reason(&server_response.stop_reason),
usage: TokenCounts {
input_tokens: server_response.usage.input_tokens,
output_tokens: server_response.usage.output_tokens,
..Default::default()
},
raw: None,
warnings: vec![],
rate_limit: None,
// Carry the server's cost through; the local client's stamping
// never overwrites an already-set cost.
cost_usd: server_response.cost_usd,
cost_source: server_response.cost_source,
})
}
async fn stream(&self, request: &Request) -> std::result::Result<StreamEventStream, LlmError> {
let url = format!("{}/api/v1/completions", self.base_url);
let body = build_body(request, true)?;
let response = self
.client
.send_http_response(|http_client| {
let body = body.clone();
let url = url.clone();
async move { http_client.post(url).json(&body).send().await }
})
.await
.map_err(|err| transport_error(&self.provider_name, &err))?;
let response =
response.map_err(|failure| map_response_failure(&self.provider_name, &failure))?;
let stream = stream::unfold(LineReader::new(response, None), |mut reader| async move {
loop {
match reader.read_next_chunk("\n\n").await {
Ok(Some(block)) => {
if let Some((event_type, data)) = parse_sse_block(&block) {
if event_type == "stream_event" {
match serde_json::from_str::<StreamEvent>(&data) {
Ok(event) => return Some((Ok(event), reader)),
Err(err) => {
return Some((
Err(LlmError::stream_error(
format!("failed to parse stream event: {err}"),
err,
)),
reader,
));
}
}
}
}
}
Ok(None) => return None,
Err(err) => return Some((Err(err), reader)),
}
}
});
Ok(Box::pin(stream))
}
}
fn run_mcp_servers_for_exec(
mcps: &HashMap<String, ResolvedMcpEntry>,
) -> AnyResult<Vec<McpServerSettings>> {
@ -345,20 +143,22 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu
.clone()
.unwrap_or_else(|| "anthropic".to_string());
let catalog = ctx.catalog()?;
let provider_id = ProviderId::from(provider_name.as_str());
let adapter_provider_name = catalog
.provider(&provider_id)
.map_or(provider_name.as_str(), |provider| provider.id.as_str());
let provider_id = catalog::canonical_provider_id(&catalog, &provider_name)
.unwrap_or_else(|| ProviderId::new(provider_name.as_str()));
let server_client = server_client::connect_server_target(&target).await?;
let adapter = Arc::new(AuthenticatedFabroServerAdapter::new(
server_client,
adapter_provider_name,
));
let mut client = Client::new(HashMap::new(), None, vec![]);
client
.register_provider(adapter)
.await
.context("Failed to register fabro server adapter")?;
let adapter = Arc::new(GatewayAdapter::new(Box::new(
ServerCompletionTransport::new(server_client),
)));
// The server inlines attachments and is the billing authority, so the
// local client only routes and reports diagnostics.
let mut options = diagnostic_client_options(&args.agent);
options.inline_attachments = false;
let client = fabro_llm::build_offline_client(
Catalog::clone(&catalog),
options.with_adapter(provider_id, adapter),
)
.context("Failed to register fabro server adapter")?
.client;
run_with_args_and_client_and_catalog(args.agent, client, mcp_servers, catalog)
.await
.map_err(classify_server_agent_auth)?;

View file

@ -34,13 +34,13 @@ use fabro_install::{
restore_optional_file, rollback_dev_token_write, seed_environments_in_storage,
write_github_app_settings, write_token_settings,
};
use fabro_model::catalog::CatalogProvider;
use fabro_model::{Catalog, CredentialRef, ProviderId};
use fabro_llm::catalog;
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
use fabro_server::serve;
use fabro_store::ArtifactStore;
use fabro_types::ServerSettings;
use fabro_types::settings::server::ServerAuthMethod;
use fabro_types::settings::validate_public_url_with_label;
use fabro_types::{ProviderId, ServerSettings, provider_ids};
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use fabro_util::version::FABRO_VERSION;
@ -75,46 +75,31 @@ const GITHUB_APP_PRIVATE_KEY_KEY: &str = fabro_static::EnvVars::GITHUB_APP_PRIVA
const GITHUB_APP_CLIENT_SECRET_KEY: &str = fabro_static::EnvVars::GITHUB_APP_CLIENT_SECRET;
const GITHUB_APP_WEBHOOK_SECRET_KEY: &str = fabro_static::EnvVars::GITHUB_APP_WEBHOOK_SECRET;
static INSTALL_CATALOG: LazyLock<Catalog> = LazyLock::new(|| {
Catalog::from_builtin().expect("embedded install model catalog should be valid")
});
static INSTALL_CATALOG: LazyLock<Catalog> = LazyLock::new(fabro_llm::default_catalog);
fn supports_install_api_key(provider: &CatalogProvider) -> bool {
provider.auth.is_some()
fabro_auth::accepts_api_key(provider)
}
fn install_llm_provider_ids(catalog: &Catalog) -> Vec<ProviderId> {
catalog
.providers()
.iter()
catalog::listed_providers(catalog)
.into_iter()
.filter(|provider| supports_install_api_key(provider))
.map(|provider| provider.id.clone())
.map(|provider| provider.id().clone())
.collect()
}
fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> String {
catalog
.provider(provider)
.and_then(|provider| provider.auth.as_ref())
.map(|auth| {
auth.credentials
.iter()
.filter_map(|credential| match credential {
CredentialRef::Env(name) => Some(name.as_str()),
CredentialRef::Vault(_) | CredentialRef::AwsSigv4 => None,
})
.collect::<Vec<_>>()
.join(" / ")
})
catalog::provider(catalog, provider.as_str())
.map(|provider| fabro_auth::secret_names(provider).join(" / "))
.filter(|label| !label.is_empty())
.unwrap_or_else(|| "API_KEY".to_string())
}
fn provider_vault_secret_name(provider: &ProviderId, catalog: &Catalog) -> String {
catalog.provider_vault_secret_name(provider).map_or_else(
|| format!("{}_API_KEY", provider.to_string().to_uppercase()),
str::to_string,
)
catalog::provider(catalog, provider.as_str())
.and_then(fabro_auth::expected_secret_name)
.unwrap_or_else(|| format!("{}_API_KEY", provider.to_string().to_uppercase()))
}
// ---------------------------------------------------------------------------
@ -442,14 +427,14 @@ impl InstallInputSource for InteractiveInstallInputSource {
if use_device_auth {
let credential = authenticate_provider_with_method(
ProviderId::openai(),
provider_ids::openai(),
AuthMethod::CodexDevice(codex_oauth_config()),
s,
printer,
)
.await?;
credentials.push(credential);
configured_providers.push(ProviderId::openai());
configured_providers.push(provider_ids::openai());
openai_configured = true;
}
}
@ -2711,7 +2696,7 @@ client_id = "client-id"
description: None,
},
credential_secret_request(&LoginResult::ApiKey {
provider: ProviderId::anthropic(),
provider: fabro_types::provider_ids::anthropic(),
key: "anthropic-key".to_string(),
})
.unwrap(),
@ -3515,11 +3500,11 @@ root = "{}"
#[test]
fn install_llm_providers_come_from_catalog_api_key_providers() {
let ids = install_llm_provider_ids(Catalog::builtin());
let ids = install_llm_provider_ids(&INSTALL_CATALOG);
assert!(ids.contains(&ProviderId::anthropic()));
assert!(ids.contains(&ProviderId::openai()));
assert!(ids.contains(&ProviderId::gemini()));
assert!(ids.contains(&fabro_types::provider_ids::anthropic()));
assert!(ids.contains(&fabro_types::provider_ids::openai()));
assert!(ids.contains(&fabro_types::provider_ids::gemini()));
assert!(ids.contains(&ProviderId::new("moonshot")));
assert!(ids.contains(&ProviderId::new("zai")));
assert!(ids.contains(&ProviderId::new("minimax")));
@ -3527,7 +3512,6 @@ root = "{}"
assert!(ids.contains(&ProviderId::new("venice")));
assert!(ids.contains(&ProviderId::new("poolside")));
assert!(ids.contains(&ProviderId::new("deepseek")));
assert!(!ids.contains(&ProviderId::new("fireworks")));
assert!(!ids.contains(&ProviderId::new("ollama")));
assert!(!ids.contains(&ProviderId::new("litellm")));
}
@ -3545,7 +3529,7 @@ root = "{}"
#[test]
fn non_interactive_source_rejects_hidden_args_without_switch() {
let args = install_args(false, InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(fabro_types::provider_ids::anthropic()),
..InstallNonInteractiveArgs::default()
});
let err = NonInteractiveInstallInputSource::new(&args).unwrap_err();
@ -3558,7 +3542,7 @@ root = "{}"
#[test]
fn non_interactive_source_rejects_conflicting_api_key_inputs() {
let args = install_args(true, InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(fabro_types::provider_ids::anthropic()),
llm_api_key_stdin: true,
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::Token),
@ -3654,7 +3638,7 @@ root = "{}"
fn non_interactive_source_rejects_missing_github_strategy() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(fabro_types::provider_ids::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_username: Some("brynary".to_string()),
..InstallNonInteractiveArgs::default()
@ -3672,7 +3656,7 @@ root = "{}"
fn non_interactive_source_rejects_missing_github_username_for_new_config() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(fabro_types::provider_ids::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::Token),
..InstallNonInteractiveArgs::default()
@ -3689,7 +3673,7 @@ root = "{}"
fn non_interactive_source_allows_keep_existing_settings_without_username() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(fabro_types::provider_ids::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::Token),
keep_existing_settings: true,
@ -3704,7 +3688,7 @@ root = "{}"
fn non_interactive_source_rejects_missing_github_owner_for_app() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(fabro_types::provider_ids::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::App),
..InstallNonInteractiveArgs::default()
@ -3723,7 +3707,7 @@ root = "{}"
fn non_interactive_source_rejects_github_owner_for_token() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(fabro_types::provider_ids::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::Token),
github_owner: Some("personal".to_string()),
@ -3743,7 +3727,7 @@ root = "{}"
fn non_interactive_source_rejects_github_username_for_app() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(fabro_types::provider_ids::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::App),
github_owner: Some("personal".to_string()),
@ -3763,7 +3747,7 @@ root = "{}"
fn non_interactive_source_allows_github_app_setup() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(fabro_types::provider_ids::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::App),
github_owner: Some("personal".to_string()),
@ -3778,7 +3762,7 @@ root = "{}"
async fn non_interactive_source_requires_config_choice_when_settings_exist() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(fabro_types::provider_ids::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::Token),
github_username: Some("brynary".to_string()),

View file

@ -2,7 +2,7 @@ use anyhow::{Context, Result, bail};
use cli_table::format::{Border, Justify, Separator};
use cli_table::{Cell, CellStruct, Color, Style, Table};
use fabro_api::types as api_types;
use fabro_model::{Model, ModelTestMode, ProviderId};
use fabro_types::{Model, ModelTestMode, ProviderId};
use fabro_util::terminal::Styles;
use futures::{StreamExt, stream};
use serde::Serialize;
@ -46,7 +46,7 @@ struct CompletedModelTest {
}
fn model_matches_selector(model: &Model, selector: &str) -> bool {
model.id == selector || model.aliases.iter().any(|alias| alias == selector)
model.id.as_str() == selector || model.aliases.iter().any(|alias| alias == selector)
}
fn find_model_by_id_or_alias(
@ -363,7 +363,7 @@ async fn test_models_via_server(
for info in &unconfigured {
skipped += 1;
let provider_name = info.provider.display_name();
let provider_name = info.provider.to_string();
if !skipped_providers.contains(&provider_name) {
skipped_providers.push(provider_name);
}
@ -513,9 +513,8 @@ impl Default for ModelsCommand {
#[cfg(test)]
mod tests {
use fabro_model::{
ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffort,
ReasoningEffortFeature,
use fabro_types::{
ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffort, provider_ids,
};
use super::*;
@ -537,13 +536,11 @@ mod tests {
training: None,
knowledge_cutoff: None,
features: ModelFeatures {
tools: true,
vision: false,
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
cache_control_breakpoints: false,
sampling_params: true,
tools: true,
vision: false,
reasoning: false,
prompt_cache: false,
sampling: true,
},
controls: ModelControls::default(),
costs: ModelCosts {
@ -573,13 +570,11 @@ mod tests {
training: None,
knowledge_cutoff: None,
features: ModelFeatures {
tools: true,
vision: false,
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
cache_control_breakpoints: false,
sampling_params: true,
tools: true,
vision: false,
reasoning: false,
prompt_cache: false,
sampling: true,
},
controls: ModelControls::default(),
costs: ModelCosts {
@ -907,7 +902,7 @@ mod tests {
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("test-model", ProviderId::anthropic())],
"data": [test_model_json("test-model", provider_ids::anthropic())],
"meta": { "has_more": false }
})
.to_string(),
@ -920,8 +915,8 @@ mod tests {
mock.assert_async().await;
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, "test-model");
assert_eq!(models[0].provider, ProviderId::anthropic());
assert_eq!(models[0].id.as_str(), "test-model");
assert_eq!(models[0].provider, provider_ids::anthropic());
}
#[tokio::test]
@ -938,7 +933,7 @@ mod tests {
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("model-a", ProviderId::anthropic())],
"data": [test_model_json("model-a", provider_ids::anthropic())],
"meta": { "has_more": false }
})
.to_string(),
@ -950,7 +945,7 @@ mod tests {
let models = client.list_models(Some("anthropic"), None).await.unwrap();
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, "model-a");
assert_eq!(models[0].id.as_str(), "model-a");
}
#[tokio::test]
@ -966,12 +961,12 @@ mod tests {
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("claude-sonnet-4-5", ProviderId::anthropic())],
"meta": { "has_more": false }
})
.to_string(),
);
serde_json::json!({
"data": [test_model_json("claude-sonnet-4-5", provider_ids::anthropic())],
"meta": { "has_more": false }
})
.to_string(),
);
})
.await;
@ -980,7 +975,7 @@ mod tests {
mock.assert_async().await;
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, "claude-sonnet-4-5");
assert_eq!(models[0].id.as_str(), "claude-sonnet-4-5");
}
#[tokio::test]
@ -996,7 +991,7 @@ mod tests {
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("model-a", ProviderId::anthropic())],
"data": [test_model_json("model-a", provider_ids::anthropic())],
"meta": { "has_more": true }
})
.to_string(),
@ -1013,7 +1008,7 @@ mod tests {
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("model-b", ProviderId::openai())],
"data": [test_model_json("model-b", provider_ids::openai())],
"meta": { "has_more": false }
})
.to_string(),
@ -1027,8 +1022,8 @@ mod tests {
first_page.assert_async().await;
second_page.assert_async().await;
assert_eq!(models.len(), 2);
assert_eq!(models[0].id, "model-a");
assert_eq!(models[1].id, "model-b");
assert_eq!(models[0].id.as_str(), "model-a");
assert_eq!(models[1].id.as_str(), "model-b");
}
#[tokio::test]

View file

@ -1,7 +1,7 @@
use anyhow::{Context, Result};
use fabro_api::types;
use fabro_auth::{AuthContextRequest, AuthMethod, LoginResult, OPENAI_CODEX_VAULT_SECRET_NAME};
use fabro_model::ProviderId;
use fabro_types::ProviderId;
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use tokio::task::spawn_blocking;

View file

@ -1,5 +1,3 @@
use std::convert::TryFrom;
use chrono::{DateTime, Utc};
use fabro_agent::Error as AgentError;
use fabro_types::{BilledModelUsage, EventBody, LlmOutputKind, RunEvent};
@ -15,13 +13,13 @@ pub(super) struct ProgressUsage {
}
impl ProgressUsage {
pub(super) fn from_stage_usage(usage: &BilledModelUsage) -> Option<Self> {
pub(super) fn from_stage_usage(usage: &BilledModelUsage) -> Self {
let tokens = usage.tokens();
Some(Self {
input_tokens: u64::try_from(tokens.input_tokens).ok()?,
output_tokens: u64::try_from(tokens.billable_output_tokens()).ok()?,
Self {
input_tokens: tokens.input,
output_tokens: tokens.billable_output(),
cost: usage.total_usd_micros.map(|cost| cost as f64 / 1_000_000.0),
})
}
}
pub(super) fn total_tokens(&self) -> u64 {
@ -313,10 +311,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
name: node_label,
timing: props.timing,
status: props.status.to_string(),
usage: props
.billing
.as_ref()
.and_then(ProgressUsage::from_stage_usage),
usage: props.billing.as_ref().map(ProgressUsage::from_stage_usage),
}),
EventBody::StageFailed(props) => Some(ProgressEvent::StageFailed {
node_id,

View file

@ -458,12 +458,10 @@ mod tests {
use chrono::{DateTime, Utc};
use fabro_agent::{AgentEvent, SandboxEvent};
use fabro_llm::types::TokenCounts;
use fabro_model::{Catalog, ModelRef, ProviderId};
use fabro_types::run_event::CliEnsureCompletedProps;
use fabro_types::{
MetadataSnapshotFailureKind, MetadataSnapshotPhase, ParallelBranchId, SandboxProviderKind,
StageId, fixtures,
MetadataSnapshotFailureKind, MetadataSnapshotPhase, ModelId, ModelRef, ParallelBranchId,
SandboxProviderKind, StageId, TokenCounts, fixtures, provider_ids,
};
use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at};
use fabro_workflow::outcome::billed_model_usage_from_llm;
@ -572,14 +570,9 @@ mod tests {
fn assistant_event(model: &str, text: &str) -> AgentEvent {
AgentEvent::AssistantMessage {
text: text.into(),
model: ModelRef {
provider: ProviderId::openai(),
model_id: model.into(),
speed: None,
},
model: ModelRef::new(provider_ids::openai(), ModelId::new(model)),
usage: TokenCounts::default(),
cost_usd: None,
cost_source: None,
cost: None,
tool_call_count: 0,
context_window: None,
reasoning: None,
@ -596,11 +589,7 @@ mod tests {
fn llm_request_started(stage: &str, model: &str) -> Event {
agent_event(stage, AgentEvent::LlmRequestStarted {
requested_model: ModelRef {
provider: ProviderId::anthropic(),
model_id: model.into(),
speed: None,
},
requested_model: ModelRef::new(provider_ids::anthropic(), ModelId::new(model)),
})
}
@ -615,15 +604,11 @@ mod tests {
suggested_next_ids: Vec::new(),
billing: Some(
billed_model_usage_from_llm(
Catalog::builtin(),
&ModelRef {
provider: ProviderId::openai(),
model_id: "gpt-5-mini".into(),
speed: None,
},
&TokenCounts {
input_tokens: 1200,
output_tokens: 300,
&fabro_llm::test_support::test_catalog(),
&ModelRef::new(provider_ids::openai(), ModelId::new("gpt-5.4")),
TokenCounts {
input: 1200,
output: 300,
..TokenCounts::default()
},
)
@ -851,10 +836,10 @@ mod tests {
attempt: 1,
delay_secs: 0.1,
phase: fabro_types::LlmRetryPhase::Consume,
error: fabro_llm::Error::Configuration {
message: "retry".into(),
source: None,
},
error: fabro_llm::LlmError::from(fabro_llm::Error::new(
fabro_llm::ErrorKind::Configuration,
"retry",
)),
}),
);
@ -970,10 +955,10 @@ mod tests {
attempt: 2,
delay_secs: 1.5,
phase: fabro_types::LlmRetryPhase::Open,
error: fabro_llm::Error::Configuration {
message: "busy".into(),
source: None,
},
error: fabro_llm::LlmError::from(fabro_llm::Error::new(
fabro_llm::ErrorKind::Configuration,
"busy",
)),
}),
agent_event("code", AgentEvent::SubAgentSpawned {
agent_id: "a1".into(),
@ -1040,7 +1025,7 @@ mod tests {
);
emit(&mut ui, stage_completed("plan", "Plan"));
insta::assert_snapshot!(rendered(&buffer), @" ✓ Plan 5s");
insta::assert_snapshot!(rendered(&buffer), @" ✓ Plan $0.01 5s");
}
#[test]
@ -1334,10 +1319,10 @@ mod tests {
attempt: 2,
delay_secs: 1.5,
phase: fabro_types::LlmRetryPhase::Open,
error: fabro_llm::Error::Configuration {
message: "busy".into(),
source: None,
},
error: fabro_llm::LlmError::from(fabro_llm::Error::new(
fabro_llm::ErrorKind::Configuration,
"busy",
)),
}),
);
emit(
@ -1399,7 +1384,7 @@ mod tests {
subagent[a1] (2 turns)
[1/1] bun install 2s
Setup: 1 command (2s)
Code 5s (1 turns, 0 tools, 1.5k toks)
Code $0.01 5s (1 turns, 0 tools, 1.5k toks)
"#);
}

View file

@ -8,14 +8,13 @@ use async_trait::async_trait;
use fabro_api::types::RunManifest;
use fabro_client::ServerTarget;
use fabro_config::user::active_settings_path;
use fabro_config::{ServerSettingsBuilder, Storage, load_llm_catalog_settings};
use fabro_config::{ServerSettingsBuilder, Storage};
use fabro_interview::{
AnswerSubmission, ControlInterviewer, WORKER_CONTROL_INVALID_CURSOR_REASON,
WORKER_CONTROL_PONG_TIMEOUT_REASON, WORKER_CONTROL_WS_LIVENESS_TIMEOUT,
WORKER_CONTROL_WS_PING_INTERVAL, WorkerControlDeliveryFrame, WorkerControlEnvelope,
WorkerControlMessage,
};
use fabro_model::Catalog;
use fabro_server::run_tool_manifest;
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
use fabro_tool::fabro_client::ClientBackend;
@ -51,8 +50,8 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungsten
use tokio_util::sync::CancellationToken;
use crate::args::RunWorkerMode;
use crate::server_client;
use crate::shared::github::build_github_credentials;
use crate::{command_context, server_client};
const RUN_STORE_RETRY_DELAYS: [Duration; 3] = [
Duration::from_millis(50),
@ -92,11 +91,8 @@ pub(crate) async fn execute(
.await
.with_context(|| format!("failed to load run state for {run_id}"))?;
let run_spec = &run_state.spec;
let llm_catalog_settings =
load_llm_catalog_settings(None).context("failed to load worker LLM catalog settings")?;
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
.context("failed to build worker LLM catalog")?,
command_context::load_cli_catalog().context("failed to build worker LLM catalog")?,
);
let artifact_sink = Some(ArtifactSink::Uploader(build_artifact_uploader(
run_id,

View file

@ -21,7 +21,7 @@ pub(crate) async fn dispatch(cmd: RunsCommands, base_ctx: &CommandContext) -> Re
list::list_command(&args, &styles, base_ctx).await
}
RunsCommands::Rm(args) => rm::remove_command(&args, base_ctx).await,
RunsCommands::Inspect(args) => inspect::run(&args, base_ctx).await,
RunsCommands::Inspect(args) => Box::pin(inspect::run(&args, base_ctx)).await,
RunsCommands::Approve(args) => approval::approve_command(&args, base_ctx).await,
RunsCommands::Deny(args) => approval::deny_command(&args, base_ctx).await,
RunsCommands::Archive(args) => archive::archive_command(&args, base_ctx).await,

View file

@ -586,6 +586,7 @@ mod tests {
ProviderCommand, ProviderNamespace,
};
use clap::error::ErrorKind;
use fabro_types::provider_ids;
use temp_env::with_var;
use tokio::runtime::Runtime;
@ -657,7 +658,7 @@ destination = "{destination}"
Commands::Provider(ProviderNamespace {
command: ProviderCommand::Login(args),
}) => {
assert_eq!(args.provider, fabro_model::ProviderId::openai());
assert_eq!(args.provider, provider_ids::openai());
}
_ => panic!("unexpected command variant"),
}
@ -671,7 +672,7 @@ destination = "{destination}"
Commands::Provider(ProviderNamespace {
command: ProviderCommand::Login(args),
}) => {
assert_eq!(args.provider, fabro_model::ProviderId::anthropic());
assert_eq!(args.provider, provider_ids::anthropic());
}
_ => panic!("unexpected command variant"),
}
@ -692,7 +693,7 @@ destination = "{destination}"
Commands::Provider(ProviderNamespace {
command: ProviderCommand::Login(args),
}) => {
assert_eq!(args.provider, fabro_model::ProviderId::anthropic());
assert_eq!(args.provider, provider_ids::anthropic());
assert!(args.api_key_stdin);
}
_ => panic!("unexpected command variant"),
@ -1201,7 +1202,7 @@ destination = "{destination}"
Commands::Provider(ProviderNamespace {
command: ProviderCommand::Login(args),
}) => {
assert_eq!(args.provider, fabro_model::ProviderId::new("bogus"));
assert_eq!(args.provider, fabro_types::ProviderId::new("bogus"));
}
_ => panic!("expected provider login command"),
}

View file

@ -15,17 +15,16 @@ use dialoguer::console::Term;
use dialoguer::theme::ColorfulTheme;
use dialoguer::{Confirm, Password};
use fabro_auth::{
ApiCredential, AuthContextRequest, AuthContextResponse, AuthMethod, LoginResult,
codex_oauth_config, strategy_for,
AuthContextRequest, AuthContextResponse, AuthMethod, LoginResult, codex_oauth_config,
strategy_for,
};
use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{GenerateParams, generate};
use fabro_model::catalog::CatalogProvider;
use fabro_model::{Catalog, ProviderId};
use fabro_llm::catalog;
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus};
use fabro_types::{ProviderId, provider_ids};
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use tokio::task::spawn_blocking;
use tokio::time::timeout;
// ---------------------------------------------------------------------------
// Interactive prompts
@ -55,16 +54,14 @@ pub(crate) enum ApiKeySource {
// API key validation
// ---------------------------------------------------------------------------
fn default_catalog_for_provider_auth() -> Result<Arc<Catalog>> {
Ok(Arc::new(
Catalog::from_builtin().context("failed to build provider auth catalog")?,
))
fn default_catalog_for_provider_auth() -> Arc<Catalog> {
Arc::new(fabro_llm::default_catalog())
}
pub(crate) fn provider_display_name(provider: &ProviderId, catalog: &Catalog) -> String {
catalog.provider(provider).map_or_else(
|| provider.display_name(),
|provider| provider.display_name.clone(),
catalog::provider(catalog, provider.as_str()).map_or_else(
|| provider.to_string(),
|provider| provider.display_name().to_string(),
)
}
@ -72,15 +69,14 @@ fn api_key_catalog_provider<'a>(
provider: &ProviderId,
catalog: &'a Catalog,
) -> Result<&'a CatalogProvider> {
let catalog_provider = catalog
.provider(provider)
let provider = catalog::provider(catalog, provider.as_str())
.with_context(|| format!("provider '{provider}' is not configured in the model catalog"))?;
anyhow::ensure!(
catalog_provider.auth.is_some(),
fabro_auth::accepts_api_key(provider),
"provider '{}' does not define an API-key credential path",
catalog_provider.id
provider.id()
);
Ok(catalog_provider)
Ok(provider)
}
pub(crate) async fn validate_api_key(
@ -89,33 +85,28 @@ pub(crate) async fn validate_api_key(
catalog: Arc<Catalog>,
) -> Result<()> {
api_key_catalog_provider(provider, catalog.as_ref())?;
let client = LlmClient::from_credentials(
vec![ApiCredential::from_api_key(
provider.clone(),
api_key.to_string(),
catalog.as_ref(),
)?],
Arc::clone(&catalog),
let outcome = probe::probe_provider_with_api_key(
Catalog::clone(&catalog),
provider,
api_key.to_string(),
std::time::Duration::from_secs(30),
)
.await
.context("failed to create LLM client")?;
let probe_model = catalog.probe_for_provider(provider).map_or_else(
|| format!("unknown-{provider}"),
|model| model.id.to_string(),
);
let params = GenerateParams::new(probe_model, Arc::new(client))
.provider(provider.to_string())
.prompt("Say OK")
.max_tokens(16);
let response = timeout(std::time::Duration::from_secs(30), generate(params))
.await
.context("API key validation timed out")?;
response
.map(|_| ())
.context("API key validation request failed")
.map_err(|err| match err {
ApiKeyProbeError::Setup(err) => {
anyhow::Error::new(err).context("failed to create LLM client")
}
other => anyhow::Error::msg(other.to_string()),
})?;
match outcome.status {
ModelTestStatus::Ok => Ok(()),
ModelTestStatus::Error => Err(anyhow::anyhow!(
"API key validation request failed: {}",
outcome
.error_message
.unwrap_or_else(|| "unknown error".to_string())
)),
}
}
fn normalize_api_key_input(raw: &str) -> Result<String> {
@ -193,7 +184,7 @@ async fn read_and_validate_api_key(
}
pub(crate) async fn pick_auth_method(provider: &ProviderId) -> Result<AuthMethod> {
if provider != &ProviderId::openai() {
if provider != &provider_ids::openai() {
return Ok(AuthMethod::ApiKey);
}
@ -212,7 +203,7 @@ pub(crate) async fn authenticate_provider(
s: &Styles,
printer: Printer,
) -> Result<LoginResult> {
authenticate_provider_with_catalog(provider, s, printer, default_catalog_for_provider_auth()?)
authenticate_provider_with_catalog(provider, s, printer, default_catalog_for_provider_auth())
.await
}
@ -238,7 +229,7 @@ pub(crate) async fn authenticate_provider_with_api_key_source(
source,
s,
printer,
default_catalog_for_provider_auth()?,
default_catalog_for_provider_auth(),
)
.await
}
@ -269,7 +260,7 @@ pub(crate) async fn authenticate_provider_with_method(
method,
s,
printer,
default_catalog_for_provider_auth()?,
default_catalog_for_provider_auth(),
)
.await
}
@ -381,29 +372,29 @@ mod tests {
#[test]
fn builtin_api_key_providers_have_key_urls() {
let catalog = Catalog::builtin();
let catalog = fabro_llm::default_catalog();
for provider in [
ProviderId::anthropic(),
ProviderId::openai(),
ProviderId::gemini(),
provider_ids::anthropic(),
provider_ids::openai(),
provider_ids::gemini(),
ProviderId::new("moonshot"),
ProviderId::new("zai"),
ProviderId::new("minimax"),
ProviderId::new("inception"),
] {
let provider = api_key_catalog_provider(&provider, catalog).unwrap();
let url = provider.api_key_url.as_deref().unwrap_or_default();
assert!(!url.is_empty(), "{} has empty URL", provider.id);
assert!(url.starts_with("https://"), "{} URL: {url}", provider.id);
let provider = api_key_catalog_provider(&provider, &catalog).unwrap();
let url = provider.api_key_url().unwrap_or_default();
assert!(!url.is_empty(), "{} has empty URL", provider.id());
assert!(url.starts_with("https://"), "{} URL: {url}", provider.id());
}
}
#[test]
fn api_key_catalog_provider_rejects_unconfigured_provider() {
let catalog = Catalog::builtin();
let catalog = fabro_llm::default_catalog();
let provider = ProviderId::new("bogus");
let err = api_key_catalog_provider(&provider, catalog).unwrap_err();
let err = api_key_catalog_provider(&provider, &catalog).unwrap_err();
assert!(
err.to_string()
@ -417,9 +408,9 @@ mod tests {
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
async fn validate_api_key_rejects_invalid_key() {
let result = validate_api_key(
&ProviderId::anthropic(),
&provider_ids::anthropic(),
"sk-invalid-key-12345",
default_catalog_for_provider_auth().unwrap(),
default_catalog_for_provider_auth(),
)
.await;
assert!(result.is_err(), "expected invalid key to be rejected");

View file

@ -333,7 +333,7 @@ fn exec_accepts_configured_custom_provider_from_settings() {
let context = test_context!();
context.write_home(
".fabro/settings.toml",
"_version = 1\n\n[llm.providers.acme-aws]\nadapter = \"openai_compatible\"\nagent_profile = \"openai\"\nbase_url = \"https://bedrock.example.invalid/v1\"\n\n[llm.providers.acme-aws.auth]\ncredentials = [\"env:ACME_AWS_API_KEY\"]\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n",
"_version = 1\n\n[llm.providers.acme-aws]\ndisplay_name = \"Acme AWS\"\nadapter = \"openai-compatible\"\ncodec = \"openai-chat\"\nbase_url = \"https://bedrock.example.invalid/v1\"\nauth = { type = \"bearer\" }\nallow_passthrough = true\n\n[llm.providers.acme-aws.metadata.agent]\nprofile = \"openai\"\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n",
);
let mut cmd = context.exec_cmd();
@ -398,7 +398,7 @@ fn exec_server_target_accepts_configured_custom_provider_from_settings() {
let context = test_context!();
context.write_home(
".fabro/settings.toml",
"_version = 1\n\n[llm.providers.acme-aws]\nadapter = \"openai_compatible\"\nagent_profile = \"openai\"\nbase_url = \"https://bedrock.example.invalid/v1\"\n\n[llm.providers.acme-aws.auth]\ncredentials = [\"env:ACME_AWS_API_KEY\"]\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n",
"_version = 1\n\n[llm.providers.acme-aws]\ndisplay_name = \"Acme AWS\"\nadapter = \"openai-compatible\"\ncodec = \"openai-chat\"\nbase_url = \"https://bedrock.example.invalid/v1\"\nauth = { type = \"bearer\" }\nallow_passthrough = true\n\n[llm.providers.acme-aws.metadata.agent]\nprofile = \"openai\"\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n",
);
let server = MockServer::start();
server.mock(|when, then| {
@ -594,7 +594,7 @@ fn exec_server_target_auth_failure_exits_with_4() {
assert_eq!(output.status.code(), Some(4));
assert_eq!(
fatal_error_line(&output.stderr),
"LLM error: Authentication error for openai: Authentication required."
"LLM error: Authentication required."
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = console::strip_ansi_codes(&stderr);
@ -650,7 +650,7 @@ fn exec_direct_provider_auth_failure_stays_exit_1() {
assert_eq!(output.status.code(), Some(1));
assert_eq!(
fatal_error_line(&output.stderr),
"LLM error: Authentication error for anthropic: bad key"
"LLM error: provider anthropic bad key"
);
}

View file

@ -106,7 +106,9 @@ fn list_with_filters_renders_server_models_table() {
"features": {
"tools": true,
"vision": false,
"reasoning": false
"reasoning": false,
"prompt_cache": false,
"sampling": true
},
"controls": {
"reasoning_effort": []
@ -135,7 +137,9 @@ fn list_with_filters_renders_server_models_table() {
"features": {
"tools": false,
"vision": true,
"reasoning": true
"reasoning": true,
"prompt_cache": false,
"sampling": true
},
"controls": {
"reasoning_effort": []
@ -210,7 +214,9 @@ fn list_uses_configured_server_target_without_server_flag() {
"features": {
"tools": true,
"vision": false,
"reasoning": false
"reasoning": false,
"prompt_cache": false,
"sampling": true
},
"controls": {
"reasoning_effort": []
@ -267,7 +273,9 @@ fn list_uses_fabro_config_for_machine_settings() {
"features": {
"tools": true,
"vision": false,
"reasoning": false
"reasoning": false,
"prompt_cache": false,
"sampling": true
},
"controls": {
"reasoning_effort": []

View file

@ -41,7 +41,9 @@ fn model_json(id: &str, provider: &str, configured: bool) -> serde_json::Value {
"features": {
"tools": true,
"vision": false,
"reasoning": false
"reasoning": false,
"prompt_cache": false,
"sampling": true
},
"controls": {
"reasoning_effort": []
@ -107,7 +109,7 @@ fn help() {
--verbose
Enable verbose output [env: FABRO_VERBOSE=]
--reasoning-effort <REASONING_EFFORT>
Request a reasoning-effort level [possible values: low, medium, high, xhigh, max]
Request a reasoning-effort level (minimal, low, medium, high, xhigh, max)
-h, --help
Print help
----- stderr -----

View file

@ -19,7 +19,6 @@ fabro-api = { path = "../../foundation/fabro-api" }
fabro-client = { path = "../../foundation/fabro-client" }
fabro-manifest = { path = "../../components/fabro-manifest" }
fabro-config = { path = "../../foundation/fabro-config" }
fabro-model = { path = "../../foundation/fabro-model" }
fabro-server = { path = "../fabro-server" }
fabro-tool = { path = "../../components/fabro-tool" }
fabro-types = { path = "../../foundation/fabro-types" }

View file

@ -10,7 +10,7 @@ description = "HTTP server for Fabro pipelines"
doctest = false
[features]
test-support = ["fabro-store/test-support"]
test-support = ["fabro-store/test-support", "fabro-llm/test-support", "fabro-auth/test-support"]
[[test]]
name = "it"
@ -40,7 +40,6 @@ fabro-agent = { path = "../../components/fabro-agent" }
fabro-llm = { path = "../../components/fabro-llm" }
fabro-manifest = { path = "../../components/fabro-manifest" }
fabro-mcp-store = { path = "../../components/fabro-mcp-store" }
fabro-model = { path = "../../foundation/fabro-model" }
fabro-proc = { path = "../../foundation/fabro-proc" }
fabro-template = { path = "../../foundation/fabro-template" }
fabro-tool = { path = "../../components/fabro-tool" }
@ -113,6 +112,7 @@ chrono = { workspace = true }
[dev-dependencies]
fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] }
fabro-llm = { path = "../../components/fabro-llm", features = ["test-support"] }
git2.workspace = true
tokio = { workspace = true, features = ["test-util", "macros"] }
tower = "0.5"

View file

@ -1115,7 +1115,7 @@ mod runs {
.collect()
}
fn billing_model(provider: fabro_model::ProviderId, model_id: &str) -> BillingModelRef {
fn billing_model(provider: fabro_types::ProviderId, model_id: &str) -> BillingModelRef {
BillingModelRef {
provider,
model_id: model_id.into(),
@ -1445,12 +1445,11 @@ mod runs {
}
pub(super) fn stage_events() -> Vec<fabro_types::EventEnvelope> {
use fabro_model::BilledTokenCounts;
use fabro_types::run_event::agent::{
AgentMessageProps, AgentToolCompletedProps, AgentToolStartedProps,
};
use fabro_types::run_event::stage::StagePromptProps;
use fabro_types::{EventBody, EventEnvelope, RunEvent};
use fabro_types::{BilledTokenCounts, EventBody, EventEnvelope, RunEvent};
let run_id = demo_run_id(1);
let node_id = "detect-drift";
@ -1495,11 +1494,10 @@ mod runs {
"evt-detect-drift-2",
EventBody::AgentMessage(AgentMessageProps {
text: "I'll start by loading the environment configurations for both production and staging to compare them.".into(),
model: fabro_model::ModelRef {
provider: fabro_model::ProviderId::anthropic(),
model_id: "claude-opus-4-6".into(),
speed: None,
},
model: fabro_types::ModelRef::new(
fabro_types::provider_ids::anthropic(),
fabro_types::ModelId::new("claude-opus-4.6"),
),
billing: BilledTokenCounts::default(),
cost_source: None,
tool_call_count: 0,
@ -1572,11 +1570,10 @@ mod runs {
"evt-detect-drift-7",
EventBody::AgentMessage(AgentMessageProps {
text: "I've detected drift in 3 resources between production and staging:\n\n1. **redis.max_connections** — production has 200, staging has 100\n2. **redis.tls** — enabled in production, disabled in staging\n3. **iam.session_duration** — production uses 3600s, staging uses 1800s".into(),
model: fabro_model::ModelRef {
provider: fabro_model::ProviderId::anthropic(),
model_id: "claude-opus-4-6".into(),
speed: None,
},
model: fabro_types::ModelRef::new(
fabro_types::provider_ids::anthropic(),
fabro_types::ModelId::new("claude-opus-4.6"),
),
billing: BilledTokenCounts::default(),
cost_source: None,
tool_call_count: 0,
@ -1598,7 +1595,7 @@ mod runs {
name: "Detect Drift".into(),
},
model: Some(billing_model(
fabro_model::ProviderId::anthropic(),
fabro_types::provider_ids::anthropic(),
"claude-opus-4-6",
)),
billing: BilledTokenCounts {
@ -1620,7 +1617,7 @@ mod runs {
name: "Propose Changes".into(),
},
model: Some(billing_model(
fabro_model::ProviderId::gemini(),
fabro_types::provider_ids::gemini(),
"gemini-3.1-pro-preview",
)),
billing: BilledTokenCounts {
@ -1642,7 +1639,7 @@ mod runs {
name: "Review Changes".into(),
},
model: Some(billing_model(
fabro_model::ProviderId::openai(),
fabro_types::provider_ids::openai(),
"gpt-5.3-codex",
)),
billing: BilledTokenCounts {
@ -1664,7 +1661,7 @@ mod runs {
name: "Apply Changes".into(),
},
model: Some(billing_model(
fabro_model::ProviderId::anthropic(),
fabro_types::provider_ids::anthropic(),
"claude-opus-4-6",
)),
billing: BilledTokenCounts {
@ -1702,7 +1699,10 @@ mod runs {
total_tokens: 43470,
total_usd_micros: Some(1_350_000),
},
model: billing_model(fabro_model::ProviderId::anthropic(), "claude-opus-4-6"),
model: billing_model(
fabro_types::provider_ids::anthropic(),
"claude-opus-4-6",
),
stages: 2,
},
BillingByModel {
@ -1716,7 +1716,7 @@ mod runs {
total_usd_micros: Some(720_000),
},
model: billing_model(
fabro_model::ProviderId::gemini(),
fabro_types::provider_ids::gemini(),
"gemini-3.1-pro-preview",
),
stages: 1,
@ -1731,7 +1731,7 @@ mod runs {
total_tokens: 11760,
total_usd_micros: Some(190_000),
},
model: billing_model(fabro_model::ProviderId::openai(), "gpt-5.3-codex"),
model: billing_model(fabro_types::provider_ids::openai(), "gpt-5.3-codex"),
stages: 1,
},
],
@ -2075,7 +2075,7 @@ mod workflows {
mod billing {
use fabro_api::types::*;
fn billing_model(provider: fabro_model::ProviderId, model_id: &str) -> BillingModelRef {
fn billing_model(provider: fabro_types::ProviderId, model_id: &str) -> BillingModelRef {
BillingModelRef {
provider,
model_id: model_id.into(),
@ -2107,7 +2107,10 @@ mod billing {
total_tokens: 391_230,
total_usd_micros: Some(12_150_000),
},
model: billing_model(fabro_model::ProviderId::anthropic(), "claude-opus-4-6"),
model: billing_model(
fabro_types::provider_ids::anthropic(),
"claude-opus-4-6",
),
stages: 18,
},
BillingByModel {
@ -2121,7 +2124,7 @@ mod billing {
total_usd_micros: Some(6_480_000),
},
model: billing_model(
fabro_model::ProviderId::gemini(),
fabro_types::provider_ids::gemini(),
"gemini-3.1-pro-preview",
),
stages: 9,
@ -2136,7 +2139,7 @@ mod billing {
total_tokens: 105_840,
total_usd_micros: Some(1_710_000),
},
model: billing_model(fabro_model::ProviderId::openai(), "gpt-5.3-codex"),
model: billing_model(fabro_types::provider_ids::openai(), "gpt-5.3-codex"),
stages: 9,
},
],

View file

@ -6,12 +6,13 @@ use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_auth::auth_issue_message;
use fabro_http::Response;
use fabro_llm::client::Client as LlmClient;
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe_with_timeout};
use fabro_model::{Catalog, ProviderId};
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::probe::{self, ModelTestStatus};
use fabro_llm::{Client, catalog};
use fabro_redact::redact_string;
use fabro_sandbox::{DockerSandboxProvider, daytona};
use fabro_static::EnvVars;
use fabro_types::ProviderId;
use fabro_types::settings::ServerAuthMethod;
use fabro_types::settings::server::GithubIntegrationStrategy;
use fabro_util::check_report::{CheckDetail, CheckResult, CheckSection, CheckStatus};
@ -220,10 +221,10 @@ pub(crate) async fn test_llm_providers(state: &AppState) -> anyhow::Result<Provi
.find(|(issue_provider, _)| issue_provider == &provider)
.map(|(_, issue)| redact_string(&auth_issue_message(&provider, issue)));
let registration_issue = result
.registration_issues
.build_issues
.iter()
.find(|issue| issue.provider == provider)
.map(|issue| redact_string(&issue.error.to_string()));
.map(|issue| redact_string(&issue.cause.to_string()));
async move {
probe_single_provider(client, &catalog, provider, auth_issue, registration_issue).await
}
@ -234,7 +235,7 @@ pub(crate) async fn test_llm_providers(state: &AppState) -> anyhow::Result<Provi
}
async fn probe_single_provider(
client: Arc<LlmClient>,
client: Arc<Client>,
catalog: &Catalog,
provider: ProviderId,
auth_issue: Option<String>,
@ -249,7 +250,7 @@ async fn probe_single_provider(
return provider_probe_error(provider, None, message, None);
}
let Some(model) = catalog.probe_for_provider(&provider) else {
let Some(model) = catalog::probe_model(catalog, provider.as_str()) else {
return provider_probe_error(
provider,
None,
@ -257,12 +258,11 @@ async fn probe_single_provider(
None,
);
};
let model_id = model.id.to_string();
let model_id = model.model.id().to_string();
let outcome = run_basic_model_probe_with_timeout(
&model_id,
&provider,
client,
let outcome = probe::run_basic_probe(
&client,
&format!("{provider}/{model_id}"),
EXTERNAL_SERVICE_PROBE_TIMEOUT,
)
.await;
@ -1030,8 +1030,8 @@ mod tests {
"expected remediation to start with provider name, got: {remediation}"
);
assert!(
remediation.contains("Authentication"),
"expected typed Display 'Authentication' in remediation, got: {remediation}"
remediation.contains("invalid api key"),
"expected the provider's message in remediation, got: {remediation}"
);
assert!(!result.details.is_empty(), "details should be populated");
assert!(

View file

@ -179,9 +179,11 @@ impl From<Error> for ApiError {
/// middleware, and local configuration failures, return 502.
impl From<fabro_llm::Error> for ApiError {
fn from(err: fabro_llm::Error) -> Self {
match err {
fabro_llm::Error::InvalidRequest { message } => Self::bad_request(message),
err => Self::new(StatusCode::BAD_GATEWAY, format!("LLM error: {err}")),
match err.kind() {
fabro_llm::ErrorKind::InvalidRequest | fabro_llm::ErrorKind::ModelSelection => {
Self::bad_request(err.message().to_string())
}
_ => Self::new(StatusCode::BAD_GATEWAY, format!("LLM error: {err}")),
}
}
}

View file

@ -24,17 +24,16 @@ use fabro_install::{
write_github_app_settings, write_object_store_settings, write_sandbox_settings,
write_token_settings,
};
use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{GenerateParams, generate};
use fabro_model::catalog::CatalogProvider;
use fabro_model::{Catalog, ProviderId};
use fabro_llm::catalog as llm_catalog;
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus};
use fabro_sandbox::daytona;
use fabro_static::EnvVars;
use fabro_store::ArtifactStore;
use fabro_types::ServerSettings;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::settings::server::ObjectStoreSettings;
use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label};
use fabro_types::{ProviderId, ServerSettings};
use fabro_util::version::FABRO_VERSION;
use fabro_util::{Home, session_secret};
use fabro_vault::SecretType as VaultSecretType;
@ -97,9 +96,8 @@ const REDACTED_SECRET_VALUE: &str = "[REDACTED]";
const VALIDATION_TIMEOUT: Duration = Duration::from_secs(20);
const VALIDATION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
static INSTALL_CATALOG: LazyLock<Arc<Catalog>> = LazyLock::new(|| {
Arc::new(Catalog::from_builtin().expect("embedded install model catalog should be valid"))
});
static INSTALL_CATALOG: LazyLock<Arc<Catalog>> =
LazyLock::new(|| Arc::new(fabro_llm::default_catalog()));
impl InstallAppState {
#[must_use]
@ -848,24 +846,21 @@ async fn put_install_llm(
}
fn install_catalog_provider(provider: &ProviderId) -> Result<&'static CatalogProvider, String> {
let catalog_provider = INSTALL_CATALOG
.provider(provider)
let catalog_provider = llm_catalog::provider(&INSTALL_CATALOG, provider.as_str())
.ok_or_else(|| format!("provider '{provider}' is not configured in the model catalog"))?;
if catalog_provider.auth.is_some() {
if fabro_auth::accepts_api_key(catalog_provider) {
Ok(catalog_provider)
} else {
Err(format!(
"provider '{}' does not define an API-key credential path",
catalog_provider.id
catalog_provider.id()
))
}
}
fn provider_secret_name(provider: &ProviderId) -> Result<String, String> {
install_catalog_provider(provider)?;
INSTALL_CATALOG
.provider_vault_secret_name(provider)
.map(str::to_string)
let catalog_provider = install_catalog_provider(provider)?;
fabro_auth::expected_secret_name(catalog_provider)
.ok_or_else(|| format!("provider '{provider}' does not define a vault credential path"))
}
@ -2137,69 +2132,72 @@ async fn validate_llm_provider(
state: &InstallAppState,
input: &InstallLlmTestInput,
) -> anyhow::Result<()> {
let catalog = Arc::clone(&INSTALL_CATALOG);
let provider = catalog.provider(&input.provider).with_context(|| {
format!(
"provider '{}' is not configured in the model catalog",
input.provider
)
})?;
ensure_install_api_key_provider(provider)?;
let mut credential = fabro_auth::ApiCredential::from_api_key(
input.provider.clone(),
let provider = install_catalog_provider(&input.provider).map_err(anyhow::Error::msg)?;
let catalog = install_catalog_with_base_url(state, provider)?;
let outcome = probe::probe_provider_with_api_key(
catalog,
provider.id(),
input.api_key.clone(),
catalog.as_ref(),
)?;
if let Some(base_url) = provider_base_url_override(state, provider) {
credential.base_url = Some(base_url);
Duration::from_secs(30),
)
.await
.map_err(|err| match err {
ApiKeyProbeError::Setup(err) => {
anyhow::Error::new(err).context("failed to create LLM client for install validation")
}
other => anyhow::Error::msg(other.to_string()),
})?;
match outcome.status {
ModelTestStatus::Ok => Ok(()),
ModelTestStatus::Error => Err(anyhow::anyhow!(
"LLM provider validation request failed: {}",
outcome
.error_message
.unwrap_or_else(|| "unknown error".to_string())
)),
}
let client = LlmClient::from_credentials(vec![credential], Arc::clone(&catalog))
.await
.context("failed to create LLM client for install validation")?;
let probe_model = catalog
.probe_for_provider(&input.provider)
.with_context(|| {
format!(
"provider '{}' does not define a probe model",
input.provider
)
})?
.id
.clone();
let params = GenerateParams::new(probe_model.to_string(), Arc::new(client))
.provider(input.provider.to_string())
.prompt("Say OK")
.max_tokens(16);
timeout(Duration::from_secs(30), generate(params))
.await
.context("LLM provider validation timed out")?
.map(|_| ())
.context("LLM provider validation request failed")
}
fn ensure_install_api_key_provider(provider: &CatalogProvider) -> anyhow::Result<()> {
if provider.auth.is_none() {
bail!(
"provider '{}' does not define an API-key credential path",
provider.id
)
}
Ok(())
}
fn provider_base_url_override(
/// The install catalog with the provider's base URL replaced by the state
/// override, when the install flow points a provider at a test upstream.
fn install_catalog_with_base_url(
state: &InstallAppState,
provider: &CatalogProvider,
) -> Option<String> {
) -> anyhow::Result<Catalog> {
let Some(base_url) = state.upstreams.provider_base_urls.get(provider.id()) else {
return Ok(Catalog::clone(&INSTALL_CATALOG));
};
let overlay = fabro_config::LlmLayer(
toml::from_str(&format!(
"[providers.{}]\nbase_url = {}\n",
toml_key(provider.id().as_str()),
toml::Value::String(base_url.clone())
))
.context("install provider base URL overlay should parse")?,
);
fabro_llm::build_catalog(&overlay, &|_| None)
.context("install catalog with provider base URL override should build")
}
fn toml_key(key: &str) -> String {
if key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
key.to_string()
} else {
format!("{key:?}")
}
}
#[cfg(test)]
fn provider_base_url_override(state: &InstallAppState, provider: &CatalogProvider) -> String {
state
.upstreams
.provider_base_urls
.get(&provider.id)
.get(provider.id())
.cloned()
.or_else(|| provider.base_url.clone())
.unwrap_or_else(|| provider.base_url().to_string())
}
async fn validate_github_token(state: &InstallAppState, token: &str) -> anyhow::Result<String> {
@ -2343,7 +2341,6 @@ mod tests {
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_config::{Storage, envfile};
use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV};
use fabro_model::{Catalog, ProviderId};
use fabro_static::EnvVars;
use fabro_vault::SecretType as VaultSecretType;
use object_store::Error as ObjectStoreError;
@ -2356,9 +2353,9 @@ mod tests {
InstallObjectStoreState, InstallSandboxProviderState, InstallSandboxState,
InstallTokenQuery, LlmProvidersInput, PendingInstall, ServerConfigInput, ServerSecrets,
build_github_app_manifest, classify_object_store_validation_error, detect_canonical_url,
install_object_store_lookup, lock_unpoisoned, post_install_finish,
provider_base_url_override, resolve_install_object_store_state, token_is_valid,
write_artifact_store_metadata,
install_catalog_provider, install_object_store_lookup, lock_unpoisoned,
post_install_finish, provider_base_url_override, resolve_install_object_store_state,
token_is_valid, write_artifact_store_metadata,
};
#[test]
@ -2570,25 +2567,25 @@ mod tests {
#[test]
fn install_provider_base_url_falls_back_to_catalog_base_url() {
let state = InstallAppState::for_test("expected");
let catalog = Catalog::builtin();
let provider = catalog.provider(&ProviderId::openai()).unwrap();
let provider = install_catalog_provider(&fabro_types::provider_ids::openai()).unwrap();
assert_eq!(
provider_base_url_override(&state, provider).as_deref(),
Some("https://api.openai.com/v1")
provider_base_url_override(&state, provider),
"https://api.openai.com"
);
}
#[test]
fn install_provider_base_url_prefers_state_override() {
let state = InstallAppState::for_test("expected")
.with_provider_base_url(ProviderId::openai(), "https://proxy.example.com/v1");
let catalog = Catalog::builtin();
let provider = catalog.provider(&ProviderId::openai()).unwrap();
let state = InstallAppState::for_test("expected").with_provider_base_url(
fabro_types::provider_ids::openai(),
"https://proxy.example.com/v1",
);
let provider = install_catalog_provider(&fabro_types::provider_ids::openai()).unwrap();
assert_eq!(
provider_base_url_override(&state, provider).as_deref(),
Some("https://proxy.example.com/v1")
provider_base_url_override(&state, provider),
"https://proxy.example.com/v1"
);
}

View file

@ -33,12 +33,12 @@ use fabro_config::{
CliLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, MergeMap,
RunLayer, SettingsLayer, WorkflowSettingsBuilder,
};
use fabro_model::{Catalog, ProviderId};
use fabro_llm::lithos_catalog::Catalog;
use fabro_types::settings::interp::{InterpString, ResolveError};
use fabro_types::settings::run::{McpServerSettings, RunGoal};
use fabro_types::{
AutomationRef, GitContext, ManifestPath, RunId, RunProvenance, RunTarget, WorkflowSettings,
WorkflowVersionId,
AutomationRef, GitContext, ManifestPath, ProviderId, RunId, RunProvenance, RunTarget,
WorkflowSettings, WorkflowVersionId,
};
use fabro_util::workspace_glob::{WorkspaceGlob, WorkspaceGlobError};
use fabro_workflow::Error as WorkflowError;
@ -683,7 +683,6 @@ mod tests {
use fabro_config::EnvironmentDockerfileLayer;
use fabro_graphviz::graph::AttrValue;
use fabro_model::Catalog;
use fabro_types::settings::interp::ResolveCtx;
use fabro_types::settings::run::RunGoal;
use fabro_types::{AutomationRef, Principal, RunProvenance, SystemActorKind};
@ -766,7 +765,9 @@ mod tests {
}
fn test_provider_ids() -> Vec<ProviderId> {
Catalog::builtin().all_provider_ids().into_iter().collect()
fabro_llm::catalog::enabled_provider_ids(&fabro_llm::test_support::test_catalog())
.into_iter()
.collect()
}
fn prepare_run(
@ -985,7 +986,7 @@ include = ["reports/{{ vars.path }}/*.json"]
#[test]
fn graph_vars_are_hard_errors_and_successfully_render_when_present() {
let catalog = Arc::new(Catalog::from_builtin().unwrap());
let catalog = Arc::new(fabro_llm::test_support::test_catalog());
let missing = prepare_run(raw_input(None, HashMap::new()), HashMap::new())
.expect("settings preparation should not compile graph vars");
let Err(error) = compile_graph(missing, test_provider_ids(), Arc::clone(&catalog)) else {
@ -1046,7 +1047,7 @@ include = ["reports/{{ vars.path }}/*.json"]
toml::Value::String("checkout".to_string()),
);
let expected_entrypoint = input.entrypoint.clone();
let catalog = Arc::new(Catalog::from_builtin().unwrap());
let catalog = Arc::new(fabro_llm::test_support::test_catalog());
let prepared = prepare_run(
input,

View file

@ -15,8 +15,9 @@ use fabro_config::{
use fabro_github::token_source::{InstallationTokenSource, ResolvedToken, TokenSnapshot};
use fabro_graphviz::graph::{Graph, is_llm_handler_type};
use fabro_graphviz::render::apply_direction;
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe};
use fabro_model::{Catalog, ProviderId};
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::probe::{self, ModelTestStatus};
use fabro_llm::{FabroClient, catalog};
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::from_environment::{
daytona_config_from_environment, docker_config_from_environment,
@ -30,7 +31,8 @@ use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{EnvironmentProvider, McpServerSettings, RunGoal, RunNamespace};
use fabro_types::{
ManifestPath, RunId, RunNoticeLevel, SandboxProviderKind, ServerSettings, WorkflowSettings,
ManifestPath, ProviderId, RunId, RunNoticeLevel, SandboxProviderKind, ServerSettings,
WorkflowSettings,
};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
@ -48,7 +50,6 @@ use tokio::time;
use crate::run_compiler;
use crate::server::AppState;
use crate::server_secrets::LlmClientResult;
#[derive(Clone)]
pub(crate) struct PreparedManifest {
@ -227,7 +228,7 @@ pub(crate) async fn run_preflight(
state: &AppState,
prepared: &PreparedManifest,
validated: &Validated,
llm_result: Result<LlmClientResult>,
llm_result: Result<FabroClient>,
) -> Result<(types::PreflightResponse, bool)> {
let (report, checks_ok) =
build_preflight_report(state, prepared, validated, llm_result).await?;
@ -401,7 +402,7 @@ async fn build_preflight_report(
state: &AppState,
prepared: &PreparedManifest,
validated: &Validated,
llm_result: Result<LlmClientResult>,
llm_result: Result<FabroClient>,
) -> Result<(CheckReport, bool)> {
let graph = validated.graph();
let mut checks = base_preflight_checks(prepared, graph);
@ -421,7 +422,7 @@ async fn build_preflight_report(
let catalog = state.catalog();
let ready_providers = llm_result
.as_ref()
.map(LlmClientResult::provider_ids)
.map(FabroClient::provider_ids)
.unwrap_or_default();
let materialized = materialize_run_with_ready_providers(
prepared.settings.clone(),
@ -1071,7 +1072,7 @@ async fn run_llm_check(
model: &str,
default_provider: &str,
catalog: &Catalog,
llm_result: Result<LlmClientResult>,
llm_result: Result<FabroClient>,
) -> bool {
let mut model_providers = std::collections::BTreeSet::new();
let mut has_llm_nodes = false;
@ -1093,7 +1094,7 @@ async fn run_llm_check(
match llm_result {
Ok(result) => {
let auth_issues = result.auth_issues;
let registration_issues = result.registration_issues;
let registration_issues = result.build_issues;
let client = Arc::new(result.client);
let mut all_ok = true;
@ -1123,9 +1124,9 @@ async fn run_llm_check(
status: CheckStatus::Warning,
summary: model_id.clone(),
details: vec![CheckDetail::new(format!("Provider: {provider_name}"))],
remediation: Some(issue.error.to_string()),
remediation: Some(issue.cause.to_string()),
}));
} else if !client.has_provider(provider_name) {
} else if !client.available_providers().contains(&provider_id) {
all_ok = false;
completed_checks.push((index, CheckResult {
name: "LLM".into(),
@ -1149,9 +1150,12 @@ async fn run_llm_check(
.map(|probe| {
let client = Arc::clone(&client);
async move {
let outcome =
run_basic_model_probe(&probe.model_id, &probe.provider_name, client)
.await;
let outcome = probe::run_basic_probe(
&client,
&format!("{}/{}", probe.provider_name, probe.model_id),
Duration::from_secs(fabro_types::ModelTestMode::Basic.timeout_secs()),
)
.await;
let (status, remediation) = if outcome.status == ModelTestStatus::Ok {
(CheckStatus::Pass, None)
} else {
@ -1206,10 +1210,8 @@ async fn run_llm_check(
}
fn canonical_provider_id(catalog: &Catalog, provider_name: &str) -> ProviderId {
let provider_id = ProviderId::from(provider_name);
catalog
.provider(&provider_id)
.map_or(provider_id, |provider| provider.id.clone())
catalog::canonical_provider_id(catalog, provider_name)
.unwrap_or_else(|| ProviderId::new(provider_name))
}
async fn run_github_token_check(
@ -1664,8 +1666,7 @@ fn report_to_api(report: &CheckReport) -> types::PreflightCheckReport {
#[cfg(test)]
mod tests {
use fabro_model::ProviderId;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_types::ProviderId;
use fabro_workflow::run_materialization::materialize_run;
use super::*;
@ -1757,18 +1758,13 @@ mod tests {
}
fn test_catalog() -> Arc<Catalog> {
Arc::new(Catalog::from_builtin().unwrap())
Arc::new(fabro_llm::test_support::test_catalog())
}
fn openrouter_catalog() -> Catalog {
let overrides = toml::from_str(
r"
[providers.openrouter]
enabled = true
",
fabro_llm::test_support::test_catalog_with_overlay(
"[providers.openrouter]\nenabled = true\n",
)
.expect("catalog override should parse");
Catalog::from_builtin_with_overrides(&overrides).expect("catalog should build")
}
fn model_refs(values: &[&str]) -> Vec<fabro_types::settings::ModelRef> {
@ -1879,20 +1875,18 @@ enabled = true
) -> Arc<crate::server::AppState> {
let moonshot_url = server.url("/moonshot/v1");
let openrouter_url = server.url("/openrouter/v1");
let llm_catalog_settings: LlmCatalogSettings = toml::from_str(&format!(
r#"
crate::test_support::TestAppStateBuilder::new()
.llm_overlay_toml(&format!(
r#"
[providers.moonshot]
base_url = "{moonshot_url}"
[providers.openrouter]
base_url = "{openrouter_url}"
enabled = true
"#
))
.expect("catalog overrides should parse");
crate::test_support::TestAppStateBuilder::new()
.llm_catalog_settings(llm_catalog_settings)
"#
))
.vault_entries([
(EnvVars::KIMI_API_KEY, "test-moonshot-key"),
(EnvVars::OPENROUTER_API_KEY, "test-openrouter-key"),
@ -1907,7 +1901,7 @@ enabled = true
let llm_result = state.resolve_llm_client().await;
let mut ready_providers = llm_result
.as_ref()
.map(LlmClientResult::provider_ids)
.map(FabroClient::provider_ids)
.unwrap_or_default();
ready_providers.sort();
assert_eq!(ready_providers, vec![
@ -2021,8 +2015,8 @@ enabled = {clone_enabled}
let resolved = materialize_run(
prepared.settings.clone(),
validated.graph(),
Catalog::builtin(),
&[ProviderId::anthropic()],
test_catalog().as_ref(),
&[fabro_types::provider_ids::anthropic()],
)
.unwrap()
.run;
@ -2913,7 +2907,7 @@ digraph Demo {
.remediation
.as_deref()
.unwrap_or_default()
.contains("Rate limited by openai: quota limited")
.contains("quota limited")
);
assert!(response_mock.calls_async().await >= 1);
}
@ -3015,7 +3009,7 @@ digraph Demo {
assert!(matches!(
error,
WorkflowError::ModelSelection(fabro_model::ModelSelectionError::UnknownProvider {
WorkflowError::ModelSelection(fabro_llm::ModelSelectionError::UnknownProvider {
provider
}) if provider.as_str() == "missing-provider"
));
@ -3023,35 +3017,28 @@ digraph Demo {
#[tokio::test]
async fn preflight_resolves_model_aliases_from_app_state_catalog() {
let llm_catalog_settings: LlmCatalogSettings = toml::from_str(
r#"
let state = crate::test_support::TestAppStateBuilder::new()
.llm_overlay_toml(
r#"
[providers.acme]
display_name = "Acme"
adapter = "openai_compatible"
agent_profile = "openai"
adapter = "openai-compatible"
codec = "openai-chat"
base_url = "https://api.acme.test/v1"
auth = { type = "bearer" }
default_model = "acme-large"
[providers.acme.auth]
credentials = ["env:ACME_API_KEY"]
[providers.acme.metadata.agent]
profile = "openai"
[providers.acme.models."acme-large"]
display_name = "Acme Large"
family = "acme"
default = true
aliases = ["vl"]
[providers.acme.models."acme-large".limits]
context_window = 128000
[providers.acme.models."acme-large".features]
tools = true
vision = false
reasoning = false
api_model = "acme-large"
limits = { context_tokens = 128000, max_output_tokens = 8192 }
capabilities = { text = true, tools = true }
"#,
)
.expect("catalog fixture should parse");
let state = crate::test_support::TestAppStateBuilder::new()
.llm_catalog_settings(llm_catalog_settings)
)
.build();
let mut manifest = minimal_manifest();
manifest.workflows.get_mut("workflow.fabro").unwrap().source = r#"
@ -3071,7 +3058,7 @@ digraph Demo {
let llm_result = state.resolve_llm_client().await;
let ready_providers = llm_result
.as_ref()
.map(LlmClientResult::provider_ids)
.map(FabroClient::provider_ids)
.unwrap_or_default();
assert!(ready_providers.is_empty());
let validated = validate_prepared_manifest_for_preflight(

View file

@ -1,12 +1,10 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use fabro_llm::client::Client;
use fabro_llm::generate::{self, GenerateParams};
use fabro_llm::types::TimeoutOptions;
use fabro_model::ProviderId;
use fabro_llm::{Client, Request, structured};
use fabro_template::{TemplateContext, TemplateError};
use fabro_types::{Graph, MAX_RUN_TITLE_CHARS, RunId};
use fabro_types::{Graph, MAX_RUN_TITLE_CHARS, ProviderId, RunId};
use fabro_util::error;
use serde::Serialize;
use toml::Value as TomlValue;
@ -44,27 +42,37 @@ pub(crate) async fn generate_title_or_current(input: GenerateTitleInput<'_>) ->
return current_title;
}
};
let params = GenerateParams::new(input.model_id, input.client)
.provider(input.provider_id.to_string())
.prompt(prompt)
.max_tokens(64)
.max_retries(0)
.timeout(TimeoutOptions {
total: Some(10.0),
per_step: Some(5.0),
});
let request = match Request::builder()
.model(format!("{}/{}", input.provider_id, input.model_id))
.user(prompt)
.max_output_tokens(64)
.timeout(Duration::from_secs(10))
.build()
{
Ok(request) => request,
Err(err) => {
tracing::warn!(run_id = %input.prompt.run_id, error = %err, "Run title request is invalid");
return current_title;
}
};
let result = match generate::generate_object(params, title_response_schema()).await {
Ok(result) => result,
let completion = match structured::complete_object(
&input.client,
request,
"run_title",
title_response_schema(),
)
.await
{
Ok(completion) => completion,
Err(err) => {
tracing::warn!(run_id = %input.prompt.run_id, error = %err, "Run title generation failed");
return current_title;
}
};
result
.output
.as_ref()
.and_then(|output| output.get("title"))
completion
.object
.get("title")
.and_then(serde_json::Value::as_str)
.and_then(normalize_generated_title)
.unwrap_or(current_title)
@ -183,19 +191,14 @@ fn truncate_section(value: &str, max_chars: usize) -> String {
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use fabro_graphviz::parser;
use fabro_llm::client::Client;
use fabro_llm::error::Error as LlmError;
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::token_count::InputTokenCount;
use fabro_llm::types::{FinishReason, Message, Request, Response, StreamEvent, TokenCounts};
use fabro_model::ProviderId;
use fabro_types::RunId;
use futures_util::stream;
use fabro_llm::adapter::{ProviderAdapter, ResolvedCall};
use fabro_llm::lithos_catalog::AdapterId;
use fabro_llm::{Error as LlmError, Response, ResponseStream};
use fabro_types::{RunId, provider_ids};
use toml::Value as TomlValue;
use super::*;
@ -319,9 +322,9 @@ mod tests {
assert_eq!(title, "Generated title");
let captured = captured.lock().unwrap();
assert_eq!(captured[0].model, "small-model");
assert_eq!(captured[0].provider.as_deref(), Some("openai"));
assert_eq!(captured[0].max_tokens, Some(64));
assert_eq!(captured[0].provider, "openai");
assert_eq!(captured[0].model, "gpt-5.4");
assert_eq!(captured[0].max_output_tokens, Some(64));
}
#[tokio::test]
@ -333,16 +336,23 @@ mod tests {
assert_eq!(invalid_shape, "Current");
}
async fn title_with_mocked_response(response_text: &str) -> (String, Arc<Mutex<Vec<Request>>>) {
struct CapturedCall {
provider: String,
model: String,
max_output_tokens: Option<u32>,
}
async fn title_with_mocked_response(
response_text: &str,
) -> (String, Arc<Mutex<Vec<CapturedCall>>>) {
let captured = Arc::new(Mutex::new(Vec::new()));
let provider = Arc::new(CapturingProvider {
let provider: Arc<dyn ProviderAdapter> = Arc::new(CapturingProvider {
id: AdapterId::new("capturing"),
captured: Arc::clone(&captured),
response_text: response_text.to_string(),
});
let client = Arc::new(Client::new(
HashMap::from([("openai".to_string(), provider as Arc<dyn ProviderAdapter>)]),
Some("openai".to_string()),
Vec::new(),
let client = Arc::new(fabro_llm::test_support::client_with_adapter(
"openai", provider,
));
let run_id = RunId::new();
let graph = title_test_graph();
@ -350,8 +360,8 @@ mod tests {
let inputs = HashMap::new();
let title = generate_title_or_current(GenerateTitleInput {
client,
model_id: "small-model".to_string(),
provider_id: ProviderId::openai(),
model_id: "gpt-5.4".to_string(),
provider_id: provider_ids::openai(),
prompt: TitlePromptInput {
run_id: &run_id,
current_title: "Current",
@ -365,48 +375,33 @@ mod tests {
}
struct CapturingProvider {
captured: Arc<Mutex<Vec<Request>>>,
id: AdapterId,
captured: Arc<Mutex<Vec<CapturedCall>>>,
response_text: String,
}
#[async_trait]
impl ProviderAdapter for CapturingProvider {
#[expect(
clippy::unnecessary_literal_bound,
reason = "ProviderAdapter trait signature returns &str."
)]
fn name(&self) -> &str {
"openai"
fn id(&self) -> &AdapterId {
&self.id
}
async fn complete(&self, request: &Request) -> Result<Response, LlmError> {
self.captured.lock().unwrap().push(request.clone());
Ok(Response {
id: "resp_title".to_string(),
model: request.model.clone(),
provider: "openai".to_string(),
message: Message::assistant(self.response_text.clone()),
finish_reason: FinishReason::Stop,
usage: TokenCounts::default(),
raw: None,
warnings: Vec::new(),
rate_limit: None,
cost_usd: None,
cost_source: None,
})
async fn complete(&self, call: &ResolvedCall) -> Result<Response, LlmError> {
self.captured.lock().unwrap().push(CapturedCall {
provider: call.route().provider().id().to_string(),
model: call.route().model().id().to_string(),
max_output_tokens: call.request().max_output_tokens(),
});
Ok(fabro_llm::test_support::text_response(
call.route().provider().id().as_str(),
call.route().model().id().as_str(),
&self.response_text,
))
}
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, LlmError> {
Ok(Pin::from(Box::new(stream::empty::<
Result<StreamEvent, LlmError>,
>())))
}
async fn count_input_tokens(
&self,
_request: &Request,
) -> Result<Option<InputTokenCount>, LlmError> {
Ok(None)
async fn stream(&self, call: &ResolvedCall) -> Result<ResponseStream, LlmError> {
let response = self.complete(call).await?;
Ok(fabro_llm::test_support::response_to_stream(response))
}
}
}

View file

@ -661,7 +661,7 @@ where
let resolved_app_settings = ResolvedAppStateSettings {
server_settings: runtime_settings.server_settings,
manifest_run_defaults: runtime_settings.manifest_run_defaults,
llm_catalog_settings: runtime_settings.llm_catalog_settings,
llm_overlay: runtime_settings.llm_overlay,
};
let resolved_server_settings = resolved_app_settings.server_settings.server.clone();
validate_startup_configuration(&resolved_server_settings)?;
@ -880,7 +880,7 @@ where
ResolvedAppStateSettings {
server_settings: resolved.server_settings,
manifest_run_defaults: resolved.manifest_run_defaults,
llm_catalog_settings: resolved.llm_catalog_settings,
llm_overlay: resolved.llm_overlay,
}
});
match resolved {
@ -1263,7 +1263,7 @@ mod tests {
ResolvedAppStateSettings {
manifest_run_defaults: manifest_run_defaults(source),
server_settings: server_settings(source),
llm_catalog_settings: fabro_model::catalog::LlmCatalogSettings::default(),
llm_overlay: fabro_config::LlmLayer::default(),
}
}

View file

@ -28,10 +28,10 @@ pub use fabro_api::types::{
BatchDeleteRunsResultOutcome, BatchDeleteRunsSummary, BatchRunLifecycleRequest,
BatchRunLifecycleResponse, BatchRunLifecycleResult, BatchRunLifecycleResultOutcome,
BatchRunLifecycleSummary, BillingByModel, BillingStageRef, CloseRunPullRequestResponse,
CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,
CreateRunPullRequestRequest, CreateSecretRequest, CreateVariableRequest, DeleteRunResponse,
DeleteRunSandbox, DeleteSecretRequest, DenyRunRequest, DiskUsageResponse, DiskUsageRunRow,
DiskUsageSummaryRow, ErrorResponseEntry, ForkRequest, ForkResponse, IntegrationConnectionKind,
CompletionResponse, CompletionUsage, CreateCompletionRequest, CreateRunPullRequestRequest,
CreateSecretRequest, CreateVariableRequest, DeleteRunResponse, DeleteRunSandbox,
DeleteSecretRequest, DenyRunRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow,
ErrorResponseEntry, ForkRequest, ForkResponse, IntegrationConnectionKind,
IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider,
IntegrationStatus, LinkRunPullRequestRequest, MergeRunPullRequestRequest,
MergeRunPullRequestResponse, ModelReference, PaginatedEventList, PaginatedRunList,
@ -51,21 +51,15 @@ pub use fabro_api::types::{
use fabro_auth::{CredentialSource, SqlVaultCredentialSource, auth_issue_message};
use fabro_automation::{self, AutomationStore};
use fabro_config::daemon::ServerDaemon;
use fabro_config::{RunLayer, Storage, WorkflowSettingsBuilder};
use fabro_config::{LlmLayer, RunLayer, Storage, WorkflowSettingsBuilder};
use fabro_db::DbPool;
use fabro_environment::EnvironmentStore;
use fabro_interview::{
Answer, AnswerSubmission, ControlInterviewer, Interviewer, Question, WorkerControlEnvelope,
};
use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{GenerateParams, generate_object};
use fabro_llm::model_test::run_model_test;
use fabro_llm::types::{
FinishReason, Message as LlmMessage, Request as LlmRequest, ToolChoice, ToolDefinition,
};
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::{ClientOptions, FabroClient, catalog};
use fabro_mcp_store::McpServerStore;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{BilledTokenCounts, Catalog, ModelRef, ModelTestMode, ProviderId};
use fabro_redact::redact_jsonl_line;
use fabro_sandbox::daytona::{self, DaytonaSandbox};
use fabro_sandbox::details::sandbox_details;
@ -96,10 +90,11 @@ use fabro_types::settings::server::{
GithubIntegrationSettings, GithubIntegrationStrategy, LogDestination,
};
use fabro_types::{
AgentBackend, AskFabro, AskFabroUnavailableReason, BlobHash, EventBody,
InterviewQuestionRecord, PairId, PairMessageId, PairTarget, PendingReason, Principal,
PullRequestLink, QuestionType, RunControlAction, RunEvent, RunId, RunRunnableSource,
RunStatusKind, SandboxProviderKind, ServerSettings, SessionCapability,
AgentBackend, AskFabro, AskFabroUnavailableReason, BilledTokenCounts, BlobHash, EventBody,
InterviewQuestionRecord, ModelRef, ModelTestMode, PairId, PairMessageId, PairTarget,
PendingReason, Principal, ProviderId, PullRequestLink, QuestionType, RunControlAction,
RunEvent, RunId, RunRunnableSource, RunStatusKind, SandboxProviderKind, ServerSettings,
SessionCapability,
};
use fabro_util::error::{
SharedError, collect_causes, render_compact_with_causes, render_with_causes,
@ -139,7 +134,6 @@ use tower::{ServiceExt, service_fn};
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
use tower_http::compression::{CompressionLayer, CompressionLevel};
use tracing::{Instrument, debug, error, info, warn};
use ulid::Ulid;
use crate::auth::{self, GithubEndpoints, auth_translation_middleware, demo_routing_middleware};
use crate::automation_materializer::{
@ -160,7 +154,7 @@ use crate::principal_middleware::{
};
use crate::request_id::{self, RequestId};
use crate::run_files::{FilesInFlight, new_files_in_flight};
use crate::server_secrets::{LlmClientResult, ServerSecrets};
use crate::server_secrets::ServerSecrets;
use crate::spawn_env::apply_render_graph_env;
use crate::worker_control::{LocalWorkerControlBus, WorkerControlBus, WorkerControlBusError};
use crate::worker_runtime::{
@ -1299,7 +1293,7 @@ pub(crate) struct AppStateConfig {
pub(crate) struct ResolvedAppStateSettings {
pub(crate) server_settings: ServerSettings,
pub(crate) manifest_run_defaults: RunLayer,
pub(crate) llm_catalog_settings: LlmCatalogSettings,
pub(crate) llm_overlay: LlmLayer,
}
fn accumulate_billing_rollup(
@ -1396,8 +1390,13 @@ impl AppState {
Some(format!("{}/runs/{run_id}", base.trim_end_matches('/')))
}
pub(crate) async fn resolve_llm_client(&self) -> anyhow::Result<LlmClientResult> {
resolve_llm_client_from_source(self.llm_source.as_ref(), self.catalog()).await
pub(crate) async fn resolve_llm_client(&self) -> anyhow::Result<FabroClient> {
resolve_llm_client_from_source(
Arc::clone(&self.llm_source),
self.catalog(),
self.http_client.clone(),
)
.await
}
pub(crate) async fn configured_llm_provider_ids(&self) -> Vec<ProviderId> {
@ -1411,14 +1410,14 @@ impl AppState {
/// resolved twice.
pub(crate) async fn resolve_llm_client_with_ready_ids(
&self,
) -> (anyhow::Result<LlmClientResult>, Vec<ProviderId>) {
) -> (anyhow::Result<FabroClient>, Vec<ProviderId>) {
let llm_result = self.resolve_llm_client().await;
if let Err(err) = &llm_result {
warn!(error = ?err, "Failed to resolve LLM client while checking ready providers");
}
let ready_provider_ids = llm_result
.as_ref()
.map(LlmClientResult::provider_ids)
.map(FabroClient::provider_ids)
.unwrap_or_default();
(llm_result, ready_provider_ids)
}
@ -1446,12 +1445,9 @@ impl AppState {
let default_model = if provider_ids.is_empty() {
None
} else {
Some(
self.catalog()
.default_for_configured_ids(&provider_ids)
.id
.to_string(),
)
let ready = provider_ids.iter().cloned().collect::<HashSet<_>>();
catalog::default_for_ready(&self.catalog(), &ready)
.map(|entry| entry.model.id().to_string())
};
AskFabroReadiness { default_model }
}
@ -1642,7 +1638,7 @@ impl AppState {
let ResolvedAppStateSettings {
server_settings,
manifest_run_defaults,
llm_catalog_settings,
llm_overlay,
} = resolved_settings;
let server_settings = Arc::new(server_settings);
let manifest_run_defaults = Arc::new(manifest_run_defaults);
@ -1654,7 +1650,7 @@ impl AppState {
&self.stores.mcp_servers,
);
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
fabro_llm::build_catalog(&llm_overlay, &|name| (self.env_lookup)(name))
.context("building LLM model catalog")?,
);
canonical_origin_from_effective_web_url(&effective_web_url).map_err(anyhow::Error::msg)?;
@ -1680,21 +1676,18 @@ impl AppState {
}
}
/// Builds the server's LLM client: retries and attachment inlining on, the
/// server's HTTP client for provider requests when one is configured.
async fn resolve_llm_client_from_source(
source: &dyn CredentialSource,
source: Arc<dyn CredentialSource>,
catalog: Arc<Catalog>,
) -> anyhow::Result<LlmClientResult> {
let resolved = source
.resolve(catalog.as_ref())
http_client: Option<fabro_http::HttpClient>,
) -> anyhow::Result<FabroClient> {
let mut options = ClientOptions::standard();
options.http = http_client;
fabro_llm::build_client(Catalog::clone(&catalog), source, options)
.await
.context("resolving LLM credentials")?;
let report = LlmClient::from_credentials_report(resolved.credentials, catalog).await;
Ok(LlmClientResult {
client: report.client,
auth_issues: resolved.auth_issues,
registration_issues: report.registration_issues,
})
.context("building the LLM client")
}
fn decode_secret_pem(name: &str, raw: &str) -> Result<String, String> {
@ -2476,7 +2469,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
&mcp_server_store,
);
let current_catalog = Arc::new(
Catalog::from_builtin_with_overrides(&resolved_settings.llm_catalog_settings)
fabro_llm::build_catalog(&resolved_settings.llm_overlay, &|name| env_lookup(name))
.context("building LLM model catalog")?,
);
let sandbox_provider_registry = sandbox_provider_registry.unwrap_or_else(|| {
@ -4220,7 +4213,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
.expect("aggregate_billing lock poisoned");
accumulate_billing_rollup(
&mut agg,
&fabro_workflow::billing_rollup_from_projection(projection, None),
&fabro_workflow::billing_rollup_from_projection(projection),
);
}
}
@ -4465,7 +4458,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
.expect("aggregate_billing lock poisoned");
accumulate_billing_rollup(
&mut agg,
&fabro_workflow::billing_rollup_from_projection(&final_state, None),
&fabro_workflow::billing_rollup_from_projection(&final_state),
);
}

View file

@ -2,7 +2,6 @@ use std::collections::HashMap;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use fabro_model::Catalog;
use fabro_types::{
Graph, RunProjection, StageHandler, StageId, StageProjection, StageState, StageTiming,
};
@ -23,7 +22,6 @@ fn run_stage_from_projection(
stage_id: &StageId,
stage: &StageProjection,
graph: &Graph,
catalog: &Catalog,
now: DateTime<Utc>,
) -> RunStage {
let handler = stage.handler.unwrap_or_else(|| {
@ -43,7 +41,7 @@ fn run_stage_from_projection(
id: stage_id.clone(),
name: stage_id.node_id().to_owned(),
handler,
billing: stage.billed_usage(Some(catalog)).into_owned(),
billing: stage.usage.clone(),
status: stage.effective_state(),
wall_time_ms: stage.live_wall_time_ms(now),
node_id: stage_id.node_id().to_owned(),
@ -76,10 +74,9 @@ async fn list_run_stages(
let now = Utc::now();
let graph = projection.spec().graph();
let catalog = state.catalog();
let stages = projection
.iter_stages()
.map(|(stage_id, stage)| run_stage_from_projection(stage_id, stage, graph, &catalog, now))
.map(|(stage_id, stage)| run_stage_from_projection(stage_id, stage, graph, now))
.collect::<Vec<_>>();
(StatusCode::OK, Json(ListResponse::new(stages))).into_response()
@ -95,8 +92,7 @@ async fn get_run_billing(
Err(err) => return err.into_response(),
};
let catalog = state.catalog();
let rollup = fabro_workflow::billing_rollup_from_projection(&projection, Some(&catalog));
let rollup = fabro_workflow::billing_rollup_from_projection(&projection);
let by_model = rollup
.by_model
.iter()

View file

@ -1,13 +1,13 @@
use std::collections::HashSet;
use std::sync::Arc;
use fabro_model::{Catalog, ModelSelectionError};
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::{ModelSelectionError, Request, selection, structured};
use fabro_types::{Message, Role};
use super::super::{
ApiError, AppState, CompletionResponse, CompletionToolChoiceMode, CreateCompletionRequest,
FinishReason, GenerateParams, IntoResponse, Json, LlmMessage, LlmRequest, ProviderId,
RequiredUser, Response, Router, State, StatusCode, ToolChoice, ToolDefinition, Ulid, error,
generate_object, info, post, warn,
ApiError, AppState, CreateCompletionRequest, IntoResponse, Json, ProviderId, RequiredUser,
Response, Router, State, StatusCode, error, info, post, warn,
};
use super::llm_sse;
@ -15,17 +15,6 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new().route("/completions", post(create_completion))
}
fn finish_reason_to_api_stop_reason(reason: &FinishReason) -> String {
match reason {
FinishReason::Stop => "end_turn".to_string(),
FinishReason::Length => "max_tokens".to_string(),
FinishReason::ToolCalls => "tool_calls".to_string(),
FinishReason::ContentFilter => "content_filter".to_string(),
FinishReason::Error => "error".to_string(),
FinishReason::Other(s) => s.clone(),
}
}
async fn create_completion(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
@ -46,13 +35,14 @@ async fn create_completion(
for (provider, issue) in &llm_result.auth_issues {
warn!(provider = %provider, error = %issue, "LLM provider unavailable due to auth issue");
}
for issue in &llm_result.registration_issues {
warn!(provider = %issue.provider, error = %issue.error, "LLM provider unavailable due to registration issue");
for issue in &llm_result.build_issues {
warn!(provider = %issue.provider, error = %issue.cause, "LLM provider unavailable due to build issue");
}
let client = llm_result.client;
let eligible: HashSet<ProviderId> = client.available_providers().iter().cloned().collect();
let (model_id, selected_provider) = match resolve_request_model(
catalog.as_ref(),
&client.provider_ids(),
&eligible,
req.model.as_deref(),
req.provider,
) {
@ -60,59 +50,75 @@ async fn create_completion(
Err(error) => return ApiError::bad_request(error.to_string()).into_response(),
};
// Build messages list. Request messages are already the canonical
// `fabro_types::Message` — the API schema reuses it via build.rs
// `with_replacement`, so no conversion is needed.
let mut messages: Vec<LlmMessage> = Vec::new();
// The request body is a lithos `Request` plus `stream`, `system`, and
// `schema`. Rebuild it on the resolved `provider/model` route so the
// server and the caller agree on the offering.
let mut builder = Request::builder().model(format!("{selected_provider}/{model_id}"));
if let Some(system) = req.system {
messages.push(LlmMessage::system(system));
builder = builder.message(Message::text(Role::System, system));
}
messages.extend(req.messages);
// Convert tools
let tools: Option<Vec<ToolDefinition>> = if req.tools.is_empty() {
None
} else {
Some(
req.tools
.into_iter()
.map(|t| ToolDefinition {
name: t.name,
description: t.description,
parameters: t.parameters,
})
.collect(),
)
};
// Convert tool_choice
let tool_choice: Option<ToolChoice> = req.tool_choice.map(|tc| match tc.mode {
CompletionToolChoiceMode::Auto => ToolChoice::Auto,
CompletionToolChoiceMode::None => ToolChoice::None,
CompletionToolChoiceMode::Required => ToolChoice::Required,
CompletionToolChoiceMode::Named => ToolChoice::named(tc.tool_name.unwrap_or_default()),
});
// Build the LLM request
let request = LlmRequest {
model: model_id.clone(),
messages,
provider: Some(selected_provider.to_string()),
tools,
tool_choice,
response_format: None,
temperature: req.temperature,
top_p: req.top_p,
max_tokens: req.max_tokens,
stop_sequences: if req.stop_sequences.is_empty() {
None
} else {
Some(req.stop_sequences)
},
reasoning_effort: req.reasoning_effort,
speed: None,
metadata: None,
provider_options: req.provider_options,
for message in req.messages {
builder = builder.message(message);
}
for tool in req.tools {
builder = builder.tool(tool);
}
if let Some(choice) = req.tool_choice {
builder = builder.tool_choice(choice);
}
if let Some(format) = req.response_format {
builder = builder.response_format(format);
}
if let Some(max_output_tokens) = req.max_output_tokens {
match u32::try_from(max_output_tokens) {
Ok(tokens) => builder = builder.max_output_tokens(tokens),
Err(_) => {
return ApiError::bad_request("max_output_tokens is out of range").into_response();
}
}
}
if let Some(temperature) = req.temperature {
#[allow(
clippy::cast_possible_truncation,
reason = "Sampling parameters are low-precision by nature."
)]
{
builder = builder.temperature(temperature as f32);
}
}
if let Some(top_p) = req.top_p {
#[allow(
clippy::cast_possible_truncation,
reason = "Sampling parameters are low-precision by nature."
)]
{
builder = builder.top_p(top_p as f32);
}
}
if !req.stop_sequences.is_empty() {
builder = builder.stop_sequences(req.stop_sequences);
}
if let Some(effort) = req.reasoning_effort {
builder = builder.reasoning_effort(effort);
}
if let Some(speed) = req.speed {
builder = builder.speed(speed);
}
for (key, value) in req.metadata {
builder = builder.metadata_entry(key, value);
}
for (provider, options) in req.provider_options {
let Some(options) = options.as_object() else {
return ApiError::bad_request(format!(
"provider_options.{provider} must be a JSON object"
))
.into_response();
};
builder = builder.provider_options(ProviderId::new(provider), options.clone());
}
let request = match builder.build() {
Ok(request) => request,
Err(error) => return ApiError::bad_request(error.to_string()).into_response(),
};
info!(
model = %model_id,
@ -120,82 +126,40 @@ async fn create_completion(
"Completion request received"
);
// Force non-streaming for structured output
// Structured output is a complete response by construction.
let use_stream = req.stream && req.schema.is_none();
if use_stream {
// Streaming path: forward all StreamEvents as SSE
let stream_result = match client.stream(&request).await {
Ok(s) => s,
let stream_result = match client.stream(request).await {
Ok(stream) => stream,
Err(error) => return ApiError::from(error).into_response(),
};
return llm_sse::stream_response(stream_result, state.shutdown_token());
}
llm_sse::stream_response(stream_result, state.shutdown_token())
} else {
// Non-streaming path
let msg_id = Ulid::new().to_string();
if let Some(schema) = req.schema {
return match structured::complete_object(&client, request, "output_schema", schema).await {
Ok(completion) => {
let mut body = match serde_json::to_value(&completion.response) {
Ok(body) => body,
Err(error) => {
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to serialize completion: {error}"),
)
.into_response();
}
};
body["output"] = completion.object;
Json(body).into_response()
}
Err(error) => ApiError::from(error).into_response(),
};
}
if let Some(schema) = req.schema {
// Structured output uses generate_object for JSON parsing logic.
// tools/tool_choice are not forwarded: GenerateParams carries
// executable Arc<Tool>s, not wire ToolDefinitions, and
// generate_object sets response_format from the schema itself.
let params = GenerateParams {
messages: Some(request.messages),
provider: request.provider,
temperature: request.temperature,
top_p: request.top_p,
max_tokens: request.max_tokens,
stop_sequences: request.stop_sequences,
reasoning_effort: request.reasoning_effort,
speed: request.speed,
metadata: request.metadata,
provider_options: request.provider_options,
..GenerateParams::new(request.model, std::sync::Arc::new(client.clone()))
};
match generate_object(params, schema).await {
Ok(result) => {
// `result.finish_reason` / `result.usage` resolve through
// GenerateResult's Deref to the inner Response; move the
// Response out once so `message` can be taken by value.
let output = result.output;
let response = result.response;
let stop_reason = finish_reason_to_api_stop_reason(&response.finish_reason);
Json(CompletionResponse {
id: msg_id,
model: model_id,
provider: selected_provider,
message: response.message,
stop_reason,
usage: response.usage,
output,
cost_usd: response.cost_usd,
cost_source: response.cost_source,
})
.into_response()
}
Err(error) => ApiError::from(error).into_response(),
}
} else {
match client.complete(&request).await {
Ok(response) => {
let stop_reason = finish_reason_to_api_stop_reason(&response.finish_reason);
Json(CompletionResponse {
id: response.id,
model: response.model,
provider: ProviderId::new(response.provider),
message: response.message,
stop_reason,
usage: response.usage,
output: None,
cost_usd: response.cost_usd,
cost_source: response.cost_source,
})
.into_response()
}
Err(error) => ApiError::from(error).into_response(),
}
}
match client.complete(request).await {
Ok(response) => Json(response).into_response(),
Err(error) => ApiError::from(error).into_response(),
}
}
@ -206,7 +170,11 @@ pub(super) fn resolve_request_model(
explicit_provider: Option<String>,
) -> Result<(String, ProviderId), ModelSelectionError> {
let explicit_provider = explicit_provider.map(ProviderId::new);
let selected =
catalog.resolve_selection(requested_model, explicit_provider.as_ref(), eligible)?;
let selected = selection::resolve_selection(
catalog,
requested_model,
explicit_provider.as_ref(),
eligible,
)?;
Ok((selected.model, selected.provider))
}

View file

@ -2,8 +2,8 @@
//!
//! `POST /api/v1/completions` forwards every `StreamEvent` to the browser as a
//! `stream_event` SSE frame. Serialization failures and stream errors are
//! shaped into the same `{"type": "error", ...}` frame vocabulary, the stream
//! ends when the LLM stream ends or the server shuts down, and a `ping`
//! shaped into a `{"type": "error", "error": <lithos ErrorData>}` frame, the
//! stream ends when the LLM stream ends or the server shuts down, and a `ping`
//! keep-alive frame goes out every 15 seconds.
use std::convert::Infallible;
@ -11,7 +11,7 @@ use std::time::Duration;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use fabro_llm::types::StreamEvent;
use fabro_llm::StreamEvent;
use futures_util::{Stream, StreamExt};
use serde_json::json;
use tokio_util::sync::CancellationToken;
@ -29,8 +29,10 @@ pub(super) fn stream_response(
Err(e) => Ok(Event::default().event("stream_event").data(
json!({
"type": "error",
"error": {"Stream": {"message": format!("failed to serialize event: {e}")}},
"raw": null
"error": {
"kind": "stream_decode",
"message": format!("failed to serialize event: {e}"),
},
})
.to_string(),
)),
@ -40,8 +42,7 @@ pub(super) fn stream_response(
Ok(Event::default().event("stream_event").data(
json!({
"type": "error",
"error": {"Stream": {"message": e.to_string()}},
"raw": null
"error": e.data(),
})
.to_string(),
))
@ -76,24 +77,25 @@ mod tests {
#[tokio::test]
async fn forwards_events_as_stream_event_frames() {
let stream = futures_util::stream::iter(vec![
Ok(StreamEvent::StreamStart),
Ok(StreamEvent::Started { id: None }),
Ok(StreamEvent::TextDelta {
delta: "hi".to_string(),
text_id: None,
id: fabro_llm::types::ContentBlockId::new("0"),
text: "hi".to_string(),
}),
]);
let body = body_text(stream_response(stream, CancellationToken::new())).await;
assert!(body.contains("event: stream_event"), "body: {body}");
assert!(body.contains(r#""type":"stream_start""#), "body: {body}");
assert!(body.contains(r#""delta":"hi""#), "body: {body}");
assert!(body.contains(r#""type":"started""#), "body: {body}");
assert!(body.contains(r#""text":"hi""#), "body: {body}");
}
#[tokio::test]
async fn shapes_stream_errors_into_error_frames() {
let stream = futures_util::stream::iter(vec![Err(fabro_llm::Error::Interrupt {
message: "boom".to_string(),
})]);
let stream = futures_util::stream::iter(vec![Err(fabro_llm::Error::new(
fabro_llm::ErrorKind::Cancelled,
"boom",
))]);
let body = body_text(stream_response(stream, CancellationToken::new())).await;
assert!(body.contains("event: stream_event"), "body: {body}");

View file

@ -1,19 +1,23 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use fabro_auth::ApiCredential;
use fabro_llm::client::Client as LlmClient;
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe};
use fabro_model::{ModelSelectionError, ReasoningEffort};
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus};
use fabro_llm::{ModelSelectionError, api, catalog, selection};
use fabro_redact::redact_string;
use fabro_types::ReasoningEffort;
use super::super::{
ApiError, AppState, FromStr, HashSet, IntoResponse, Json, MAX_PAGE_OFFSET, ModelTestMode, Path,
ApiError, AppState, FromStr, IntoResponse, Json, MAX_PAGE_OFFSET, ModelTestMode, Path,
ProviderCredentialTestRequest, ProviderCredentialTestResponse, ProviderId, ProviderList, Query,
RequiredUser, Response, Router, State, StatusCode, auth_issue_message, default_page_limit,
error, get, post, run_model_test,
error, get, post,
};
use crate::diagnostics;
const CREDENTIAL_TEST_TIMEOUT: Duration = Duration::from_secs(30);
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/models", get(list_models))
@ -53,18 +57,26 @@ async fn list_models(
State(state): State<Arc<AppState>>,
Query(params): Query<ModelListParams>,
) -> Response {
let provider_id = params.provider.as_deref().map(ProviderId::from);
let catalog = state.catalog();
// An unknown provider filter matches nothing rather than erroring.
let provider_id = params.provider.as_deref().map(|selector| {
catalog::canonical_provider_id(&catalog, selector)
.unwrap_or_else(|| ProviderId::new(selector))
});
let query = params.query.as_ref().map(|value| value.to_lowercase());
let limit = params.limit.clamp(1, 100) as usize;
let offset = params.offset.min(MAX_PAGE_OFFSET) as usize;
let catalog = state.catalog();
let configured: HashSet<ProviderId> =
state.ready_llm_provider_ids().await.into_iter().collect();
let mut data = catalog
.list(provider_id.as_ref())
let mut data = api::models(&catalog, &configured)
.into_iter()
.filter(|model| {
provider_id
.as_ref()
.is_none_or(|provider| &model.provider == provider)
})
.filter(|model| match &query {
Some(query) => {
model.id.as_str().to_lowercase().contains(query)
@ -78,11 +90,6 @@ async fn list_models(
})
.skip(offset)
.take(limit + 1)
.cloned()
.map(|mut model| {
model.configured = configured.contains(&model.provider);
model
})
.collect::<Vec<_>>();
let has_more = data.len() > limit;
@ -105,7 +112,7 @@ async fn list_providers(_auth: RequiredUser, State(state): State<Arc<AppState>>)
.await
.into_iter()
.collect();
let data = catalog.provider_summaries(&configured);
let data = api::providers(&catalog, &configured);
(StatusCode::OK, Json(ProviderList { data })).into_response()
}
@ -122,30 +129,24 @@ async fn test_provider_credentials(
let requested_provider = ProviderId::new(provider);
let catalog = state.catalog();
let Some(catalog_provider) = catalog.provider(&requested_provider) else {
return ApiError::not_found(format!("Provider not found: {requested_provider}"))
.into_response();
};
if catalog_provider.auth.is_none() {
return ApiError::bad_request(format!(
"provider '{}' does not define an API-key credential path",
catalog_provider.id,
))
.into_response();
}
let provider_id = catalog_provider.id.clone();
let credential =
match ApiCredential::from_api_key(provider_id.clone(), body.api_key, catalog.as_ref()) {
Ok(credential) => credential,
Err(err) => {
return ApiError::bad_request(err.to_string()).into_response();
}
};
let client = match LlmClient::from_credentials(vec![credential], Arc::clone(&catalog)).await {
Ok(client) => Arc::new(client),
Err(err) => {
error!(provider = %provider_id, error = ?err, "Failed to create LLM client for provider credential validation");
let outcome = match probe::probe_provider_with_api_key(
Catalog::clone(&catalog),
&requested_provider,
body.api_key,
CREDENTIAL_TEST_TIMEOUT,
)
.await
{
Ok(outcome) => outcome,
Err(ApiKeyProbeError::UnknownProvider(_)) => {
return ApiError::not_found(format!("Provider not found: {requested_provider}"))
.into_response();
}
Err(err @ (ApiKeyProbeError::NoApiKeyPath(_) | ApiKeyProbeError::NoProbeModel(_))) => {
return ApiError::bad_request(err.to_string()).into_response();
}
Err(ApiKeyProbeError::Setup(err)) => {
error!(provider = %requested_provider, error = ?err, "Failed to create LLM client for provider credential validation");
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create LLM client: {err}"),
@ -153,14 +154,6 @@ async fn test_provider_credentials(
.into_response();
}
};
let Some(model) = catalog.probe_for_provider(&provider_id) else {
return ApiError::bad_request(format!(
"provider '{provider_id}' does not define a probe model"
))
.into_response();
};
let outcome = run_basic_model_probe(model.id.as_str(), &provider_id, client).await;
match outcome.status {
ModelTestStatus::Ok => (
StatusCode::OK,
@ -210,12 +203,18 @@ async fn test_model(
Ok(mode) => mode.unwrap_or(ModelTestMode::Basic),
Err(error) => return error.into_response(),
};
let reasoning_effort = match parse_query_enum::<ReasoningEffort>(
params.reasoning_effort.as_deref(),
"reasoning effort",
) {
Ok(reasoning_effort) => reasoning_effort,
Err(error) => return error.into_response(),
let reasoning_effort = match params.reasoning_effort.as_deref() {
Some(value) => match value.parse::<ReasoningEffort>() {
Ok(effort) => Some(effort),
Err(_) => {
return ApiError::new(
StatusCode::BAD_REQUEST,
format!("invalid reasoning effort: {value}"),
)
.into_response();
}
},
None => None,
};
let llm_result = match state.resolve_llm_client().await {
Ok(result) => result,
@ -235,38 +234,60 @@ async fn test_model(
.collect::<HashSet<_>>();
let explicit_provider = params.provider.map(ProviderId::new);
let info = if let Some(provider) = explicit_provider.as_ref() {
match catalog.resolve_on_provider(provider, &id) {
match selection::resolve_on_provider(&catalog, provider, &id) {
Ok(info) => info,
Err(error) => return model_selection_response(&error),
}
} else {
match catalog.select(&id, None, &eligible) {
match selection::select(&catalog, &id, None, &eligible) {
Ok(info) => info,
Err(error) => return model_selection_response(&error),
}
};
let provider_id = info.provider.id().clone();
let model_id = info.model.id().clone();
if let Some((_, issue)) = llm_result
.auth_issues
.iter()
.find(|(provider, _)| provider == &info.provider)
.find(|(provider, _)| provider == &provider_id)
{
return ApiError::bad_request(auth_issue_message(&info.provider, issue)).into_response();
return ApiError::bad_request(auth_issue_message(&provider_id, issue)).into_response();
}
let provider_name = info.provider.as_str();
if !llm_result.client.has_provider(provider_name) {
if !llm_result.has_provider(&provider_id) {
return Json(serde_json::json!({
"model_id": info.id,
"provider": info.provider,
"model_id": model_id,
"provider": provider_id,
"status": "skip",
}))
.into_response();
}
let client = Arc::new(llm_result.client);
if let Some(effort) = reasoning_effort {
let capabilities = info.model.capabilities();
if !capabilities.reasoning_effort(effort).is_supported() {
let allowed = ReasoningEffort::ALL
.into_iter()
.filter(|candidate| capabilities.reasoning_effort(*candidate).is_supported())
.map(ReasoningEffort::as_str)
.collect::<Vec<_>>()
.join(", ");
return ApiError::bad_request(format!(
"model '{model_id}' does not support reasoning_effort '{effort}'; allowed values: {allowed}"
))
.into_response();
}
}
let outcome = run_model_test(info, mode, reasoning_effort, client).await;
let outcome = probe::run_model_test(
&llm_result.client,
&format!("{provider_id}/{model_id}"),
mode,
reasoning_effort,
None,
)
.await;
Json(serde_json::json!({
"model_id": info.id,
"provider": info.provider,
"model_id": model_id,
"provider": provider_id,
"status": <&'static str>::from(outcome.status),
"error_message": outcome.error_message,
}))

View file

@ -846,11 +846,10 @@ mod tests {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use chrono::{TimeZone, Utc};
use fabro_model::{ModelRef, ProviderId};
use fabro_types::run_event::AgentMessageProps;
use fabro_types::{
BilledTokenCounts, EventEnvelope, Graph, PairMessageId, RunEvent, StageId,
WorkflowSettings, fixtures, test_support,
BilledTokenCounts, EventEnvelope, Graph, ModelId, ModelRef, PairMessageId, ProviderId,
RunEvent, StageId, WorkflowSettings, fixtures, test_support,
};
use fabro_workflow::event as workflow_event;
use tower::ServiceExt;
@ -881,11 +880,10 @@ mod tests {
Some(StageId::new("code", 1)),
EventBody::AgentMessage(AgentMessageProps {
text: "I found the issue.".to_string(),
model: ModelRef {
provider: ProviderId::new("openai"),
model_id: "gpt-5.4".into(),
speed: None,
},
model: ModelRef::new(
ProviderId::new("openai"),
ModelId::new("gpt-5.4"),
),
billing: BilledTokenCounts::default(),
cost_source: None,
tool_call_count: 0,
@ -915,11 +913,10 @@ mod tests {
Some(StageId::new("other", 1)),
EventBody::AgentMessage(AgentMessageProps {
text: "wrong stage".to_string(),
model: ModelRef {
provider: ProviderId::new("openai"),
model_id: "gpt-5.4".into(),
speed: None,
},
model: ModelRef::new(
ProviderId::new("openai"),
ModelId::new("gpt-5.4"),
),
billing: BilledTokenCounts::default(),
cost_source: None,
tool_call_count: 0,

View file

@ -2,6 +2,7 @@ use std::sync::Arc;
use std::time::Duration;
use axum::http::{HeaderValue, header};
use fabro_llm::catalog;
use super::super::{
ApiError, AppState, CloseRunPullRequestResponse, CreateRunPullRequestRequest, IntoResponse,
@ -343,11 +344,18 @@ async fn create_run_pull_request(
model
} else {
let catalog = state.catalog();
let configured = state.ready_llm_provider_ids().await;
catalog
.default_for_configured_ids(&configured)
.id
.to_string()
let configured = state
.ready_llm_provider_ids()
.await
.into_iter()
.collect::<std::collections::HashSet<_>>();
match catalog::default_for_ready(&catalog, &configured) {
Some(entry) => entry.model.id().to_string(),
None => {
return ApiError::bad_request("no LLM model is available for PR generation")
.into_response();
}
}
};
let _create_guard = state.pull_request_create_locks.lock(id).await;
let creation_id = fabro_types::PullRequestCreationId::new();

View file

@ -21,7 +21,7 @@ use fabro_api::types::{
use fabro_config::{CliLayer, RunLayer, Storage, project};
use fabro_environment::{DEFAULT_ENVIRONMENT_ID, EnvironmentId};
use fabro_interview::AnswerSubmission;
use fabro_llm::client::Client as LlmClient;
use fabro_llm::{Client as LlmClient, catalog};
use fabro_manifest::RunOverrideInput;
use fabro_static::EnvVars;
use fabro_store::{
@ -953,18 +953,23 @@ async fn finalize_created_run(
let workflow = run_title_generation::workflow_summary(&run_spec.graph);
let run_inputs = run_spec.settings.run.inputs.clone();
let title_catalog = state.catalog();
let title_model = title_catalog.small_default_for_configured_ids(&ready_provider_ids);
spawn_generated_title_task(GeneratedTitleTask {
state: Arc::clone(&state),
run_id: created.run_id,
deterministic_title,
workflow_target: title_generation_target.to_string(),
workflow,
run_inputs,
client: llm_result.client,
model_id: title_model.id.to_string(),
provider_id: title_model.provider.clone(),
});
let ready = ready_provider_ids
.iter()
.cloned()
.collect::<std::collections::HashSet<_>>();
if let Some(title_model) = catalog::small_default_for_ready(&title_catalog, &ready) {
spawn_generated_title_task(GeneratedTitleTask {
state: Arc::clone(&state),
run_id: created.run_id,
deterministic_title,
workflow_target: title_generation_target.to_string(),
workflow,
run_inputs,
client: llm_result.client,
model_id: title_model.model.id().to_string(),
provider_id: title_model.provider.id().clone(),
});
}
}
}
style.log_created(created.run_id);
@ -1408,7 +1413,7 @@ struct GeneratedTitleTask {
run_inputs: std::collections::HashMap<String, toml::Value>,
client: LlmClient,
model_id: String,
provider_id: fabro_model::ProviderId,
provider_id: fabro_types::ProviderId,
}
fn spawn_generated_title_task(task: GeneratedTitleTask) {

View file

@ -20,8 +20,8 @@ use fabro_agent::{
use fabro_api::types::{
CreateRunSessionRequest, PaginatedEventList, PaginationMeta, SubmitTurnRequest,
};
use fabro_llm::types::ToolDefinition;
use fabro_model::{AgentProfileKind, Catalog, ModelSelectionError, ProviderId, catalog};
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::{FabroClient, ModelSelectionError, catalog, selection};
use fabro_sandbox::reconnect::reconnect_for_run;
use fabro_static::EnvVars;
use fabro_store::{
@ -35,7 +35,10 @@ use fabro_types::run_event::{
RunSessionTurnSucceededProps, RunSessionUserMessageProps,
};
use fabro_types::settings::ModelRef as SettingsModelRef;
use fabro_types::{EventBody, EventEnvelope, RunEvent, RunId, SessionDetail, SessionId, TurnId};
use fabro_types::{
AgentProfileKind, EventBody, EventEnvelope, ProviderId, RunEvent, RunId, SessionDetail,
SessionId, ToolDefinition, TurnId,
};
use fabro_workflow::handler::llm::api::register_named_fabro_run_tools;
use fabro_workflow::services::FabroRunToolServices;
use serde_json::Value;
@ -52,7 +55,6 @@ use super::super::{
};
use crate::error::ApiError;
use crate::principal_middleware::RequiredUser;
use crate::server_secrets::LlmClientResult;
use crate::worker_token::issue_worker_token;
const SESSION_SSE_BUFFER_CAPACITY: usize = 1024;
@ -685,12 +687,12 @@ async fn build_agent_session(
for (provider, issue) in &llm_result.auth_issues {
warn!(provider = %provider, error = %issue, "LLM provider unavailable due to auth issue");
}
for issue in &llm_result.registration_issues {
warn!(provider = %issue.provider, error = %issue.error, "LLM provider unavailable due to registration issue");
for issue in &llm_result.build_issues {
warn!(provider = %issue.provider, error = %issue.cause, "LLM provider unavailable due to build issue");
}
let (provider_id, model, profile_kind) =
selected_session_model(&catalog, &llm_result, session)?;
if !llm_result.client.has_provider(provider_id.as_str()) {
if !llm_result.has_provider(&provider_id) {
let message = format!("LLM credentials not configured for provider '{provider_id}'");
return if session.record.model.is_some() {
Err(AskFabroBuildError::ModelUnavailable(message))
@ -784,7 +786,7 @@ async fn build_agent_session(
fn selected_session_model(
catalog: &Catalog,
llm_result: &LlmClientResult,
llm_result: &FabroClient,
session: &ProjectedRunSession,
) -> Result<(ProviderId, String, AgentProfileKind), AskFabroBuildError> {
let eligible = llm_result
@ -792,23 +794,25 @@ fn selected_session_model(
.into_iter()
.collect::<std::collections::HashSet<_>>();
let record = &session.record;
let selected = catalog
.resolve_selection(record.model.as_deref(), record.provider.as_ref(), &eligible)
.map_err(|error| {
// A missing default with no provider pin means no LLM is
// configured at all; every other failure is about the requested
// model/provider.
if record.provider.is_none()
&& matches!(error, ModelSelectionError::NoDefaultModel { .. })
{
AskFabroBuildError::LlmUnconfigured(error.to_string())
} else {
AskFabroBuildError::ModelUnavailable(error.to_string())
}
})?;
let selected = selection::resolve_selection(
catalog,
record.model.as_deref(),
record.provider.as_ref(),
&eligible,
)
.map_err(|error| {
// A missing default with no provider pin means no LLM is
// configured at all; every other failure is about the requested
// model/provider.
if record.provider.is_none() && matches!(error, ModelSelectionError::NoDefaultModel { .. })
{
AskFabroBuildError::LlmUnconfigured(error.to_string())
} else {
AskFabroBuildError::ModelUnavailable(error.to_string())
}
})?;
let (provider_id, model) = (selected.provider, selected.model);
let profile_kind = catalog
.effective_agent_profile(&provider_id, Some(&model))
let profile_kind = catalog::agent_profile(catalog, provider_id.as_str(), Some(&model))
.ok_or_else(|| {
AskFabroBuildError::ModelUnavailable(format!(
"provider '{provider_id}' is not configured"
@ -825,31 +829,29 @@ fn canonical_session_model(
) -> Result<(ProviderId, String), ApiError> {
let explicit_provider = explicit_provider
.map(|provider| {
catalog
.provider(provider)
.map(|provider| provider.id.clone())
.ok_or_else(|| {
session_selection_error(&ModelSelectionError::UnknownProvider {
provider: provider.clone(),
})
catalog::canonical_provider_id(catalog, provider.as_str()).ok_or_else(|| {
session_selection_error(&ModelSelectionError::UnknownProvider {
provider: provider.to_string(),
})
})
})
.transpose()?;
let Some(requested) = requested else {
let selected = catalog
.resolve_selection(None, explicit_provider.as_ref(), eligible)
.map_err(|error| session_selection_error(&error))?;
let selected =
selection::resolve_selection(catalog, None, explicit_provider.as_ref(), eligible)
.map_err(|error| session_selection_error(&error))?;
return Ok((selected.provider, selected.model));
};
let requested = requested.trim();
if requested.is_empty() {
return Err(ApiError::bad_request("Session model must not be empty."));
}
if catalog::legacy_builtin_model(requested).is_some() {
let selected = catalog
.resolve_selection(Some(requested), explicit_provider.as_ref(), eligible)
.map_err(|error| session_selection_error(&error))?;
return Ok((selected.provider, selected.model));
// An aggregator's wire id (`openai/gpt-5.6-sol` on OpenRouter) is matched
// whole on a pinned provider before its prefix is read as a provider.
if let Some(explicit) = explicit_provider.as_ref().filter(|p| eligible.contains(*p)) {
if let Some(entry) = catalog::model_on_provider(catalog, explicit.as_str(), requested) {
return Ok((explicit.clone(), entry.model.id().to_string()));
}
}
let model_ref = requested
.parse::<SettingsModelRef>()
@ -857,15 +859,16 @@ fn canonical_session_model(
.qualify(catalog);
let (qualified_provider, selector) = match model_ref {
SettingsModelRef::Qualified { provider, selector } => {
let requested_provider = ProviderId::new(provider);
let provider = catalog
.provider(&requested_provider)
.map(|provider| provider.id.clone())
.ok_or_else(|| {
session_selection_error(&ModelSelectionError::UnknownProvider {
provider: requested_provider,
})
})?;
let provider = catalog::canonical_provider_id(catalog, &provider).ok_or_else(|| {
session_selection_error(&ModelSelectionError::UnknownProvider { provider })
})?;
// When the prefixed provider is not ready, the whole string may
// still be an eligible aggregator's wire id for the same model.
if explicit_provider.is_none() && !eligible.contains(&provider) {
if let Some(found) = api_model_on_eligible(catalog, requested, eligible) {
return Ok(found);
}
}
if let Some(explicit) = explicit_provider.as_ref() {
if explicit != &provider {
return Err(ApiError::bad_request(format!(
@ -877,10 +880,8 @@ fn canonical_session_model(
(Some(provider), selector)
}
SettingsModelRef::Bare(selector) => {
if explicit_provider.is_none()
&& catalog.provider(&ProviderId::new(&selector)).is_some()
{
let detail = if catalog.is_model_selector(&selector) {
if explicit_provider.is_none() && catalog::is_provider_selector(catalog, &selector) {
let detail = if catalog::is_model_selector(catalog, &selector) {
format!(
"Session model reference '{selector}' is ambiguous between a provider and \
a model selector; supply `provider` or use `provider:model`."
@ -896,12 +897,28 @@ fn canonical_session_model(
}
};
let provider = qualified_provider.as_ref().or(explicit_provider.as_ref());
let selected = catalog
.resolve_selection(Some(&selector), provider, eligible)
let selected = selection::resolve_selection(catalog, Some(&selector), provider, eligible)
.map_err(|error| session_selection_error(&error))?;
Ok((selected.provider, selected.model))
}
/// The highest-priority eligible provider offering `api_model` as a wire id.
fn api_model_on_eligible(
catalog: &Catalog,
api_model: &str,
eligible: &std::collections::HashSet<ProviderId>,
) -> Option<(ProviderId, String)> {
catalog::enabled_providers(catalog)
.into_iter()
.filter(|provider| eligible.contains(provider.id()))
.find_map(|provider| {
catalog::provider_models(provider)
.into_iter()
.find(|model| model.model.api_model() == api_model)
.map(|model| (provider.id().clone(), model.model.id().to_string()))
})
}
fn session_selection_error(error: &ModelSelectionError) -> ApiError {
ApiError::bad_request(error.to_string())
}
@ -1100,7 +1117,7 @@ impl AgentProfile for AskFabroProfile {
self.inner.model()
}
fn catalog(&self) -> Option<&Catalog> {
fn catalog(&self) -> Option<&Arc<Catalog>> {
self.inner.catalog()
}
@ -1486,19 +1503,17 @@ mod tests {
use fabro_agent::config::ToolAccess;
use fabro_agent::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource};
use fabro_llm::types::{ToolCall, ToolDefinition};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_types::test_support;
use fabro_types::{ToolCall, ToolDefinition, test_support};
use super::*;
fn stub_tool(name: &str) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: name.to_string(),
description: format!("{name} test tool"),
parameters: serde_json::json!({"type": "object"}),
},
definition: ToolDefinition::function(
name.to_string(),
format!("{name} test tool"),
serde_json::json!({"type": "object"}),
),
executor: Arc::new(|_args, _ctx: ToolContext| {
Box::pin(async { Ok("ok".to_string()) })
}),
@ -1528,59 +1543,27 @@ mod tests {
registry
}
/// OpenAI and OpenRouter both offer `gpt-5.6-sol` under the `gpt-56-sol`
/// alias; OpenRouter ships disabled, so enable it the way an operator
/// would.
fn portable_session_catalog() -> Catalog {
let settings: LlmCatalogSettings = toml::from_str(
fabro_llm::test_support::test_catalog_with_overlay(
r#"
[providers.openai]
display_name = "OpenAI"
adapter = "openai"
agent_profile = "openai"
priority = 90
[providers.openai.models."gpt-5.6-sol"]
display_name = "GPT-5.6 Sol"
family = "gpt-5"
aliases = ["gpt-56-sol"]
default = true
[providers.openai.models."gpt-5.6-sol".limits]
context_window = 1000
[providers.openai.models."gpt-5.6-sol".features]
tools = true
vision = false
reasoning = false
default_model = "gpt-5.6-sol"
[providers.openrouter]
display_name = "OpenRouter"
adapter = "openai_compatible"
agent_profile = "openai"
priority = 25
default_model = "gpt-5.6-sol"
enabled = true
[providers.openrouter.models."gpt-5.6-sol"]
api_id = "openai/gpt-5.6-sol"
display_name = "GPT-5.6 Sol (via OpenRouter)"
family = "gpt-5"
aliases = ["gpt-56-sol"]
default = true
[providers.openrouter.models."gpt-5.6-sol".limits]
context_window = 1000
[providers.openrouter.models."gpt-5.6-sol".features]
tools = true
vision = false
reasoning = false
"#,
)
.unwrap();
Catalog::from_settings(&settings).unwrap()
}
#[test]
fn canonical_session_model_uses_readiness_priority_and_explicit_pins() {
let catalog = portable_session_catalog();
let openai = ProviderId::openai();
let openai = fabro_types::provider_ids::openai();
let openrouter = ProviderId::new("openrouter");
assert_eq!(
@ -1627,7 +1610,7 @@ reasoning = false
#[test]
fn canonical_session_model_preserves_unknown_passthrough_on_selected_provider() {
let catalog = portable_session_catalog();
let openai = ProviderId::openai();
let openai = fabro_types::provider_ids::openai();
let openrouter = ProviderId::new("openrouter");
let both = std::collections::HashSet::from([openai.clone(), openrouter.clone()]);
@ -1647,7 +1630,7 @@ reasoning = false
#[test]
fn canonical_session_model_passes_through_colon_bearing_model_ids() {
let catalog = portable_session_catalog();
let openai = ProviderId::openai();
let openai = fabro_types::provider_ids::openai();
let openrouter = ProviderId::new("openrouter");
let both = std::collections::HashSet::from([openai.clone(), openrouter.clone()]);
@ -1672,7 +1655,7 @@ reasoning = false
let catalog = portable_session_catalog();
let error = canonical_session_model(
&catalog,
&std::collections::HashSet::from([ProviderId::openai()]),
&std::collections::HashSet::from([fabro_types::provider_ids::openai()]),
Some("gpt-56-sol"),
Some(&ProviderId::new("openrouter")),
)
@ -1684,7 +1667,7 @@ reasoning = false
#[test]
fn canonical_session_model_normalizes_legacy_builtin_selector_before_qualification() {
let catalog = portable_session_catalog();
let openai = ProviderId::openai();
let openai = fabro_types::provider_ids::openai();
let openrouter = ProviderId::new("openrouter");
let both = std::collections::HashSet::from([openai.clone(), openrouter.clone()]);
@ -1722,7 +1705,7 @@ reasoning = false
assert_eq!(
canonical_session_model(
&catalog,
&catalog.all_provider_ids(),
&fabro_llm::catalog::enabled_provider_ids(&catalog),
Some("openrouter:gpt-56-sol"),
None,
)
@ -1736,9 +1719,9 @@ reasoning = false
let catalog = portable_session_catalog();
let error = canonical_session_model(
&catalog,
&catalog.all_provider_ids(),
&fabro_llm::catalog::enabled_provider_ids(&catalog),
Some("openrouter:gpt-56-sol"),
Some(&ProviderId::openai()),
Some(&fabro_types::provider_ids::openai()),
)
.unwrap_err();
@ -2010,11 +1993,11 @@ reasoning = false
for tool_name in denied_tools {
let executions = Arc::clone(&executions);
registry.register(RegisteredTool {
definition: ToolDefinition {
name: tool_name.to_string(),
description: format!("{tool_name} test tool"),
parameters: serde_json::json!({"type": "object"}),
},
definition: ToolDefinition::function(
tool_name.to_string(),
format!("{tool_name} test tool"),
serde_json::json!({"type": "object"}),
),
executor: Arc::new(move |_args, _ctx: ToolContext| {
let executions = Arc::clone(&executions);
Box::pin(async move {
@ -2036,7 +2019,7 @@ reasoning = false
for tool_name in denied_tools {
let result = fabro_agent::tool_execution::execute_and_emit_one_tool(
&ToolCall::new("call_1", tool_name, serde_json::json!({})),
&ToolCall::function("call_1", tool_name, serde_json::json!({})),
&registry,
Arc::clone(&sandbox),
None,
@ -2050,12 +2033,13 @@ reasoning = false
.await;
assert!(result.is_error, "{tool_name} should be blocked");
let output = fabro_types::tool_result_to_json(&result);
assert!(
result
.content
output
.as_str()
.unwrap_or_default()
.contains("denied by tool access policy")
.contains("denied by tool access policy"),
"{output}"
);
}
assert_eq!(executions.load(Ordering::SeqCst), 0);

View file

@ -196,7 +196,7 @@ async fn attempt_pull_request_creation(
draft: true,
auto_merge: None,
run_store: &run_store_handle,
llm_source: state.llm_source.as_ref(),
llm_source: Arc::clone(&state.llm_source),
catalog,
conclusion: Some(inputs.conclusion),
run_state: Some(run_state),

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,7 @@
use std::collections::HashMap;
use std::path::Path;
use fabro_auth::ResolveError;
use fabro_config::envfile;
use fabro_llm::client::{Client, ProviderRegistrationIssue};
use fabro_model::ProviderId;
#[expect(
clippy::disallowed_methods,
@ -56,22 +53,6 @@ impl std::fmt::Debug for ServerSecrets {
}
}
pub(crate) struct LlmClientResult {
pub client: Client,
pub auth_issues: Vec<(ProviderId, ResolveError)>,
pub registration_issues: Vec<ProviderRegistrationIssue>,
}
impl LlmClientResult {
pub(crate) fn provider_ids(&self) -> Vec<ProviderId> {
self.client
.provider_names()
.into_iter()
.map(ProviderId::new)
.collect()
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;

View file

@ -15,17 +15,17 @@ use axum::response::Response;
use axum::{Router, middleware};
use chrono::Duration as ChronoDuration;
use fabro_config::user::default_storage_dir;
use fabro_config::{RunLayer, ServerSettingsBuilder, Storage, envfile};
use fabro_config::{LlmLayer, RunLayer, ServerSettingsBuilder, Storage, envfile};
use fabro_db::DbPool;
use fabro_interview::Interviewer;
use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings};
use fabro_model::{Catalog, ProviderId};
use fabro_llm::catalog;
use fabro_llm::lithos_catalog::Catalog;
use fabro_sandbox::SandboxProviderRegistry;
use fabro_static::EnvVars;
use fabro_store::{ArtifactStore, Database, test_support as store_test_support};
use fabro_types::settings::ServerAuthMethod;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::{AuthMethod, IdpIdentity, ServerSettings};
use fabro_types::{AuthMethod, IdpIdentity, ProviderId, ServerSettings};
use fabro_vault::{SecretType, Vault};
use fabro_workflow::handler::HandlerRegistry;
use object_store::memory::InMemory as MemoryObjectStore;
@ -67,7 +67,7 @@ pub(crate) fn test_run_materialization_provider_ids(
let assume_ready = process_env_var(FABRO_TEST_ASSUME_LLM_READY)
.is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "no"));
if assume_ready {
catalog.all_provider_ids().into_iter().collect()
catalog::enabled_provider_ids(catalog).into_iter().collect()
} else {
ready_provider_ids.to_vec()
}
@ -100,7 +100,7 @@ pub struct TestAppStateBuilder {
server_secret_env: HashMap<String, String>,
default_environment_provider: Option<EnvironmentProvider>,
env_lookup: EnvLookup,
llm_catalog_settings: LlmCatalogSettings,
llm_overlay: LlmLayer,
automation_materializer: Option<TestAutomationRunMaterializer>,
#[cfg(test)]
worker_runtime: Option<Arc<dyn WorkerRuntime>>,
@ -122,7 +122,7 @@ impl Default for TestAppStateBuilder {
server_secret_env: HashMap::new(),
default_environment_provider: Some(EnvironmentProvider::Docker),
env_lookup: default_env_lookup(),
llm_catalog_settings: LlmCatalogSettings::default(),
llm_overlay: LlmLayer::default(),
automation_materializer: None,
#[cfg(test)]
worker_runtime: None,
@ -177,11 +177,18 @@ impl TestAppStateBuilder {
self
}
pub fn llm_catalog_settings(mut self, settings: LlmCatalogSettings) -> Self {
self.llm_catalog_settings = settings;
/// Replaces the operator `[llm]` overlay applied above the built-in and
/// policy layers.
pub fn llm_overlay(mut self, overlay: LlmLayer) -> Self {
self.llm_overlay = overlay;
self
}
/// Parses `toml` as the operator `[llm]` overlay.
pub fn llm_overlay_toml(self, toml: &str) -> Self {
self.llm_overlay(llm_overlay_from_toml(toml))
}
pub fn automation_materializer(mut self, materializer: TestAutomationRunMaterializer) -> Self {
self.automation_materializer = Some(materializer);
self
@ -198,12 +205,13 @@ impl TestAppStateBuilder {
provider: impl Into<String>,
base_url: impl Into<String>,
) -> Self {
self.llm_catalog_settings
.providers
.insert(provider.into(), ProviderCatalogSettings {
base_url: Some(base_url.into()),
..ProviderCatalogSettings::default()
});
let overlay = llm_overlay_with_provider_base_url(provider, base_url);
let mut merged = toml::Value::Table(std::mem::take(&mut self.llm_overlay).0);
merge_toml(&mut merged, toml::Value::Table(overlay.0));
let toml::Value::Table(table) = merged else {
unreachable!("merging two tables yields a table");
};
self.llm_overlay = LlmLayer(table);
self
}
@ -290,7 +298,7 @@ impl TestAppStateBuilder {
resolved_settings: resolved_runtime_settings_for_tests(
self.server_settings,
self.manifest_run_defaults,
self.llm_catalog_settings,
self.llm_overlay,
),
registry_factory_override: self.registry_factory_override,
max_concurrent_runs: self.max_concurrent_runs,
@ -334,18 +342,45 @@ pub(crate) fn test_secret_snapshot(pool: DbPool) -> anyhow::Result<Vault> {
.expect("test secret snapshot thread should not panic")
}
pub fn llm_catalog_settings_with_provider_base_url(
/// Merges `overlay` into `base` the way lithos layers merge: tables merge
/// key by key and every other value replaces.
fn merge_toml(base: &mut toml::Value, overlay: toml::Value) {
match (base, overlay) {
(toml::Value::Table(base), toml::Value::Table(overlay)) => {
for (key, value) in overlay {
if let Some(existing) = base.get_mut(&key) {
merge_toml(existing, value);
} else {
base.insert(key, value);
}
}
}
(base, overlay) => *base = overlay,
}
}
/// Parses `toml` as an operator `[llm]` overlay.
pub fn llm_overlay_from_toml(toml: &str) -> LlmLayer {
LlmLayer(toml::from_str(toml).expect("test llm overlay should parse"))
}
/// An overlay that points one provider at `base_url`, the way an operator
/// repoints a provider at a proxy or a test double.
pub fn llm_overlay_with_provider_base_url(
provider: impl Into<String>,
base_url: impl Into<String>,
) -> LlmCatalogSettings {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert(provider.into(), ProviderCatalogSettings {
base_url: Some(base_url.into()),
..ProviderCatalogSettings::default()
});
settings
) -> LlmLayer {
let provider = provider.into();
llm_overlay_from_toml(&format!(
"[providers.{}]\nbase_url = {}\n",
toml::Value::String(provider),
toml::Value::String(base_url.into())
))
}
/// The catalog a test app state builds from `overlay`.
pub fn test_catalog_with_overlay(overlay: &LlmLayer) -> Catalog {
fabro_llm::build_catalog(overlay, &|_| None).expect("test catalog should build")
}
pub fn test_app_state() -> Arc<AppState> {
@ -402,12 +437,12 @@ fn ready_test_app_state_builder() -> TestAppStateBuilder {
pub(crate) fn resolved_runtime_settings_for_tests(
server_settings: ServerSettings,
manifest_run_defaults: RunLayer,
llm_catalog_settings: LlmCatalogSettings,
llm_overlay: LlmLayer,
) -> ResolvedAppStateSettings {
ResolvedAppStateSettings {
server_settings,
manifest_run_defaults,
llm_catalog_settings,
llm_overlay,
}
}

View file

@ -13,11 +13,11 @@ use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_config::{ServerSettingsBuilder, Storage, envfile};
use fabro_install::OBJECT_STORE_MANAGED_COMMENT;
use fabro_model::ProviderId;
use fabro_server::install::{
InstallAppState, InstallFinishHook, InstallFinishInfo, build_install_router,
};
use fabro_server::test_support::test_environment_from_storage_dir;
use fabro_types::ProviderId;
use fabro_util::Home;
use fabro_vault::Vault;
use httpmock::Method::GET;
@ -1415,7 +1415,10 @@ async fn install_validation_endpoints_validate_credentials_and_github_token() {
let app = build_install_router(
InstallAppState::for_test("test-install-token")
.with_provider_base_url(ProviderId::anthropic(), format!("{}/v1", llm_mock.url("")))
.with_provider_base_url(
fabro_types::provider_ids::anthropic(),
format!("{}/v1", llm_mock.url("")),
)
.with_github_api_base_url(github_mock.url("")),
);

View file

@ -1,6 +1,5 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_model::{Catalog, ProviderId};
use fabro_types::settings::run::EnvironmentProvider;
use tower::ServiceExt;
@ -161,12 +160,12 @@ _version = 1
created["ask_fabro"]["unavailable_reason"],
"sandbox_not_ready"
);
let default_openai_model = Catalog::builtin()
.default_for_provider(&ProviderId::openai())
let catalog = fabro_llm::test_support::test_catalog();
let default_openai_model = fabro_llm::catalog::default_model(&catalog, "openai")
.expect("the built-in OpenAI provider should have a default model");
assert_eq!(
created["ask_fabro"]["default_model"].as_str(),
Some(default_openai_model.id())
Some(default_openai_model.model.id().as_str())
);
let get_request = Request::builder()

View file

@ -305,16 +305,8 @@ async fn invalid_session_model_refs_are_rejected_at_creation() {
#[tokio::test]
async fn ambiguous_session_model_refs_are_rejected_at_creation() {
let mut catalog_settings = fabro_model::catalog::LlmCatalogSettings::default();
catalog_settings.providers.insert(
"openai".to_string(),
fabro_model::catalog::ProviderCatalogSettings {
aliases: Some(vec!["gpt54".to_string()]),
..fabro_model::catalog::ProviderCatalogSettings::default()
},
);
let state = fabro_server::test_support::TestAppStateBuilder::new()
.llm_catalog_settings(catalog_settings)
.llm_overlay_toml("[providers.openai]\naliases = [\"gpt54\"]\n")
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
let app = fabro_server::test_support::build_test_router(state);

View file

@ -7,7 +7,7 @@ use axum::http::{Request, StatusCode};
use fabro_config::{RunEnvironmentLayer, RunLayer, ServerSettingsBuilder};
use fabro_server::server::{AppState, spawn_scheduler};
use fabro_server::test_support::{
TestAppStateBuilder, build_test_router, llm_catalog_settings_with_provider_base_url,
TestAppStateBuilder, build_test_router, llm_overlay_with_provider_base_url,
test_app_state as server_test_app_state, test_app_state_with_runtime_settings_and_env_lookup,
test_app_state_with_runtime_settings_and_options_and_registry_factory,
};
@ -125,7 +125,7 @@ pub(crate) fn test_app_with_mock_anthropic(mock_base_url: &str) -> axum::Router
let state = TestAppStateBuilder::new()
.runtime_settings(settings.server_settings, settings.manifest_run_defaults)
.max_concurrent_runs(5)
.llm_catalog_settings(llm_catalog_settings_with_provider_base_url(
.llm_overlay(llm_overlay_with_provider_base_url(
"anthropic",
mock_base_url,
))

View file

@ -17,7 +17,7 @@ fn completion_request(stream: bool) -> Request<Body> {
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"messages": [{"role": "user", "content": [{"kind": "text", "data": "Hello"}]}],
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
"stream": stream
}))
.expect("completion fixture should serialize"),
@ -33,7 +33,7 @@ fn completion_request_with_model(stream: bool, model: &str) -> Request<Body> {
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"model": model,
"messages": [{"role": "user", "content": [{"kind": "text", "data": "Hi"}]}],
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hi"}]}],
"stream": stream
}))
.expect("model completion fixture should serialize"),
@ -60,7 +60,7 @@ async fn test_model_known_but_unavailable_returns_bad_request() {
let req = Request::builder()
.method("POST")
.uri(api("/models/claude-opus-4-6/test"))
.uri(api("/models/claude-opus-4.6/test"))
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
@ -69,7 +69,7 @@ async fn test_model_known_but_unavailable_returns_bad_request() {
let body = response_json(
response,
StatusCode::BAD_REQUEST,
"POST /api/v1/models/claude-opus-4-6/test",
"POST /api/v1/models/claude-opus-4.6/test",
)
.await;
assert!(
@ -173,11 +173,12 @@ async fn completion_non_streaming_returns_valid_json() {
.unwrap();
let body = response_json(response, StatusCode::OK, "POST /api/v1/completions").await;
assert!(body["id"].is_string());
assert_eq!(body["model"], "claude-sonnet-4-5");
assert_eq!(body["stop_reason"], "end_turn");
assert!(body["message"].is_object());
assert!(body["usage"]["input_tokens"].is_number());
assert!(body["usage"]["output_tokens"].is_number());
assert_eq!(body["model"]["provider"], "anthropic");
assert_eq!(body["model"]["model"], "claude-sonnet-4.5");
assert_eq!(body["finish_reason"], "stop");
assert!(body["content"].is_array());
assert!(body["usage"]["input"].is_number());
assert!(body["usage"]["output"].is_number());
}
#[tokio::test]

View file

@ -3,7 +3,6 @@ use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_auth::test_support;
use fabro_model::{Catalog, ProviderId};
use fabro_static::EnvVars;
use fabro_test::{TwinScenario, TwinScenarios, twin_openai};
use fabro_types::RunId;
@ -32,15 +31,11 @@ const PROJECT_SKILL_AGENT_DOT: &str = r#"digraph ProjectSkillAgent {
fn test_app_with_openai_agent_backend(openai_base_url: String, api_key: String) -> axum::Router {
let settings = test_settings();
let llm_catalog_settings =
fabro_server::test_support::llm_catalog_settings_with_provider_base_url(
"openai",
openai_base_url,
);
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
.expect("test catalog should build"),
);
let llm_overlay =
fabro_server::test_support::llm_overlay_with_provider_base_url("openai", openai_base_url);
let catalog = Arc::new(fabro_server::test_support::test_catalog_with_overlay(
&llm_overlay,
));
let source_api_key = api_key.clone();
let env_api_key = api_key.clone();
let llm_source: Arc<dyn fabro_auth::CredentialSource> =
@ -51,7 +46,7 @@ fn test_app_with_openai_agent_backend(openai_base_url: String, api_key: String)
let state = fabro_server::test_support::TestAppStateBuilder::new()
.runtime_settings(settings.server_settings, settings.manifest_run_defaults)
.max_concurrent_runs(5)
.llm_catalog_settings(llm_catalog_settings)
.llm_overlay(llm_overlay)
.vault_entries([(EnvVars::OPENAI_API_KEY, api_key)])
.registry_factory(move |interviewer| {
let catalog = Arc::clone(&catalog);
@ -62,7 +57,7 @@ fn test_app_with_openai_agent_backend(openai_base_url: String, api_key: String)
Some(Box::new(
fabro_workflow::handler::llm::AgentApiBackend::new_with_catalog(
OPENAI_AGENT_MODEL.to_string(),
ProviderId::openai(),
fabro_types::provider_ids::openai(),
fabro_workflow::model_fallback::ModelFallbackPolicy::default(),
Arc::clone(&llm_source),
Arc::clone(&steering_hub),

View file

@ -28,7 +28,6 @@ fabro-auth = { path = "../../foundation/fabro-auth" }
fabro-config = { path = "../../foundation/fabro-config", features = ["clap"] }
fabro-types = { path = "../../foundation/fabro-types", features = ["clap"] }
fabro-llm = { path = "../fabro-llm" }
fabro-model = { path = "../../foundation/fabro-model" }
fabro-mcp = { path = "../fabro-mcp" }
fabro-sandbox = { path = "../fabro-sandbox" }
fabro-static.workspace = true
@ -60,6 +59,7 @@ libc = "0.2"
[dev-dependencies]
fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] }
fabro-llm = { path = "../fabro-llm", features = ["test-support"] }
insta.workspace = true
tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3"

View file

@ -1,5 +1,8 @@
use fabro_llm::types::ToolDefinition;
use fabro_model::{AgentProfileKind, Catalog, Model, ProviderId};
use std::sync::Arc;
use fabro_llm::catalog::{self, ModelEntry};
use fabro_llm::lithos_catalog::Catalog;
use fabro_types::{AgentProfileKind, ProviderId, ToolDefinition};
use crate::profiles::EnvContext;
use crate::sandbox::Sandbox;
@ -10,11 +13,14 @@ use crate::subagent::{
};
use crate::tool_registry::ToolRegistry;
/// Context window assumed for a model the catalog does not describe.
pub const DEFAULT_CONTEXT_WINDOW_TOKENS: usize = 200_000;
pub trait AgentProfile: Send + Sync {
fn profile_kind(&self) -> AgentProfileKind;
fn provider_id(&self) -> ProviderId;
fn model(&self) -> &str;
fn catalog(&self) -> Option<&Catalog> {
fn catalog(&self) -> Option<&Arc<Catalog>> {
None
}
fn tool_registry(&self) -> &ToolRegistry;
@ -34,31 +40,32 @@ pub trait AgentProfile: Send + Sync {
fn knowledge_cutoff(&self) -> Option<String> {
self.catalog_model()
.and_then(|m| m.knowledge_cutoff().map(str::to_string))
.and_then(|entry| entry.model.knowledge_cutoff().map(str::to_string))
}
fn catalog_model(&self) -> Option<&Model> {
/// The catalog row for this profile's route, when the catalog knows it.
fn catalog_model(&self) -> Option<ModelEntry<'_>> {
let catalog = self.catalog()?;
catalog.get_on_provider(&self.provider_id(), self.model())
catalog::model_on_provider(catalog, self.provider_id().as_str(), self.model())
}
fn context_window_size(&self) -> usize {
self.catalog_model().map_or(200_000, |m| {
usize::try_from(m.context_window()).unwrap_or(usize::MAX)
})
self.catalog_model()
.and_then(|entry| entry.model.limits())
.map_or(DEFAULT_CONTEXT_WINDOW_TOKENS, |limits| {
usize::try_from(limits.context_tokens).unwrap_or(usize::MAX)
})
}
fn max_output_tokens(&self) -> Option<i64> {
self.catalog_model().and_then(Model::max_output)
fn max_output_tokens(&self) -> Option<u32> {
self.catalog_model()
.and_then(|entry| entry.model.limits())
.map(|limits| u32::try_from(limits.max_output_tokens).unwrap_or(u32::MAX))
}
fn reasons_by_default(&self) -> bool {
let Some(catalog) = self.catalog() else {
return false;
};
catalog
.model_settings_on_provider(&self.provider_id(), self.model())
.is_some_and(|settings| settings.reasoning_by_default)
self.catalog_model()
.is_some_and(|entry| entry.reasons_by_default())
}
fn register_subagent_tools(
@ -83,7 +90,7 @@ pub trait AgentProfile: Send + Sync {
#[cfg(test)]
mod tests {
use fabro_model::{AgentProfileKind, ProviderId};
use fabro_types::{AgentProfileKind, provider_ids};
use super::*;
use crate::test_support::{MockSandbox, TestProfile};
@ -92,7 +99,7 @@ mod tests {
fn profile_provider_and_model() {
let profile = TestProfile::new();
assert_eq!(profile.profile_kind(), AgentProfileKind::Anthropic);
assert_eq!(profile.provider_id(), ProviderId::anthropic());
assert_eq!(profile.provider_id(), provider_ids::anthropic());
assert_eq!(profile.model(), "mock-model");
}

View file

@ -5,7 +5,7 @@
use std::fmt::Write as _;
use std::sync::Arc;
use fabro_llm::types::ToolDefinition;
use fabro_types::ToolDefinition;
use crate::sandbox::Sandbox;
use crate::tool_registry::{RegisteredTool, ToolSource};
@ -502,16 +502,14 @@ pub fn make_apply_patch_tool() -> RegisteredTool {
mod tests {
use std::collections::HashMap;
use fabro_llm::types::{
ContentPart, FinishReason, Message as LlmMessage, Response, Role, TokenCounts, ToolCall,
};
use fabro_types::{ContentPart, ToolCall, tool_result_to_json};
use tokio::fs;
use tokio_util::sync::CancellationToken;
use super::*;
use crate::LocalSandbox;
use crate::test_support::MutableMockSandbox;
use crate::tool_registry::ToolContext;
use crate::tool_registry::{ToolContext, ToolDefinitionExt};
#[test]
fn parse_apply_patch_add_file() {
@ -1689,7 +1687,9 @@ def gamma():
async fn e2e_through_tool_executor() {
use crate::config::SessionOptions;
use crate::session::Session;
use crate::test_support::{MockLlmProvider, TestProfile, make_client, text_response};
use crate::test_support::{
MockLlmProvider, TestProfile, make_client, response_with_parts, text_response,
};
use crate::tool_registry::ToolRegistry;
// Set up sandbox with a file
@ -1731,28 +1731,9 @@ def farewell(name):
*** Delete File: src/obsolete.py
*** End Patch";
let mut tool_call = ToolCall::new("call_1", "apply_patch", serde_json::json!(patch_text));
tool_call.tool_type = "custom".to_string();
tool_call.raw_arguments = Some(patch_text.to_string());
let tool_call = ToolCall::custom("call_1", "apply_patch", patch_text);
let responses = vec![
Response {
id: "resp_call_1".to_string(),
model: "mock-model".to_string(),
provider: "mock".to_string(),
message: LlmMessage {
role: Role::Assistant,
content: vec![ContentPart::ToolCall(tool_call)],
name: None,
tool_call_id: None,
},
finish_reason: FinishReason::ToolCalls,
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
cost_usd: None,
cost_source: None,
},
response_with_parts("resp_call_1", vec![ContentPart::ToolCall(tool_call)]),
text_response("Done! Updated greet and farewell functions."),
];
@ -1787,7 +1768,9 @@ def farewell(name):
async fn failed_custom_tool_call_returns_codex_style_error_to_session_history() {
use crate::config::SessionOptions;
use crate::session::Session;
use crate::test_support::{MockLlmProvider, TestProfile, make_client, text_response};
use crate::test_support::{
MockLlmProvider, TestProfile, make_client, response_with_parts, text_response,
};
use crate::tool_registry::ToolRegistry;
use crate::types::Message as AgentMessage;
@ -1808,29 +1791,10 @@ def farewell(name):
- return 1
+ return 2
*** End Patch";
let mut tool_call = ToolCall::new("call_1", "apply_patch", serde_json::json!(patch_text));
tool_call.tool_type = "custom".to_string();
tool_call.raw_arguments = Some(patch_text.to_string());
let tool_call = ToolCall::custom("call_1", "apply_patch", patch_text);
let responses = vec![
Response {
id: "resp_call_1".to_string(),
model: "mock-model".to_string(),
provider: "mock".to_string(),
message: LlmMessage {
role: Role::Assistant,
content: vec![ContentPart::ToolCall(tool_call)],
name: None,
tool_call_id: None,
},
finish_reason: FinishReason::ToolCalls,
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
cost_usd: None,
cost_source: None,
},
response_with_parts("resp_call_1", vec![ContentPart::ToolCall(tool_call)]),
text_response("I will correct the patch."),
];
@ -1850,7 +1814,7 @@ def farewell(name):
assert_eq!(results.len(), 1);
assert!(results[0].is_error);
assert_eq!(
results[0].content.as_str(),
tool_result_to_json(&results[0]).as_str(),
Some("Failed to find context 'def missing():' in src/app.py")
);
}

View file

@ -12,16 +12,12 @@ use clap::{Args, Parser};
use fabro_auth::{CredentialSource, SqlVaultCredentialSource};
use fabro_config::Storage;
use fabro_config::user::default_storage_dir;
use fabro_llm::Error as LlmError;
use fabro_llm::client::Client;
use fabro_llm::middleware::{Middleware, NextFn, NextStreamFn};
use fabro_llm::provider::StreamEventStream;
use fabro_llm::types::{Request, Response};
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::middleware::{Call, Middleware, Next, Output};
use fabro_llm::{Client, ClientOptions, Error as LlmError, catalog};
use fabro_mcp::config::McpServerSettings;
#[cfg(test)]
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{AgentProfileKind, Catalog, ModelHandle, ModelSelectionError, ProviderId};
use fabro_static::EnvVars;
use fabro_types::{AgentProfileKind, ModelHandle, ModelId, ProviderId};
use fabro_util::terminal::Styles;
use fabro_vault::SecretStore;
use tokio::io::{AsyncWriteExt, stdout};
@ -196,20 +192,15 @@ fn summarizer_model_id(
catalog: &Catalog,
selected_model: &str,
) -> ModelHandle {
ModelHandle::ByName {
provider: provider_id.clone(),
model: catalog
.default_for_provider(provider_id)
let model =
catalog::small_default_for_ready(catalog, &std::iter::once(provider_id.clone()).collect())
.filter(|entry| entry.provider.id() == provider_id)
.or_else(|| catalog::default_model(catalog, provider_id.as_str()))
.map_or_else(
|| match provider_id.as_str() {
ProviderId::ANTHROPIC => "claude-haiku-4-5",
ProviderId::GEMINI => "gemini-2.0-flash",
_ => selected_model,
},
|model| model.id.as_str(),
)
.to_string(),
}
|| selected_model.to_string(),
|entry| entry.model.id().to_string(),
);
ModelHandle::new(provider_id.clone(), ModelId::new(model))
}
fn build_summarizer(
@ -224,33 +215,33 @@ fn build_summarizer(
}
}
fn parse_provider(args: &AgentArgs) -> anyhow::Result<ProviderId> {
let provider_str = args.provider.as_deref().unwrap_or("anthropic");
Ok(provider_str.parse()?)
fn parse_provider(args: &AgentArgs) -> ProviderId {
ProviderId::new(args.provider.as_deref().unwrap_or("anthropic"))
}
fn resolve_provider_id(
catalog: &Catalog,
args: &AgentArgs,
eligible_providers: &std::collections::HashSet<ProviderId>,
) -> anyhow::Result<ProviderId> {
) -> ProviderId {
if args.provider.is_some() {
let requested = parse_provider(args)?;
return Ok(catalog
.provider(&requested)
.map_or(requested, |provider| provider.id.clone()));
let requested = parse_provider(args);
return catalog::canonical_provider_id(catalog, requested.as_str()).unwrap_or(requested);
}
if let Some(model_id) = args.model.as_deref() {
match catalog.select(model_id, None, eligible_providers) {
Ok(model) => return Ok(model.provider.clone()),
Err(ModelSelectionError::UnknownSelector { .. }) => {}
Err(error) => return Err(error.into()),
// A bare model selector picks the highest-priority eligible provider
// offering it, matching how the client resolves the request.
let matches = catalog::models_matching(catalog, model_id);
if let Some(entry) = matches
.iter()
.find(|entry| eligible_providers.contains(entry.provider.id()))
.or_else(|| matches.first())
{
return entry.provider.id().clone();
}
}
let requested = parse_provider(args)?;
Ok(catalog
.provider(&requested)
.map_or(requested, |provider| provider.id.clone()))
let requested = parse_provider(args);
catalog::canonical_provider_id(catalog, requested.as_str()).unwrap_or(requested)
}
async fn standalone_llm_source() -> anyhow::Result<Arc<dyn CredentialSource>> {
@ -266,17 +257,12 @@ fn profile_kind_for_provider(
provider_id: &ProviderId,
model: Option<&str>,
) -> anyhow::Result<AgentProfileKind> {
catalog
.effective_agent_profile(provider_id, model)
catalog::agent_profile(catalog, provider_id.as_str(), model)
.ok_or_else(|| anyhow::anyhow!("provider '{provider_id}' is not configured"))
}
fn ensure_provider_registered(client: &Client, provider_id: &ProviderId) -> anyhow::Result<()> {
if client
.provider_names()
.iter()
.any(|name| *name == provider_id.as_str())
{
if client.available_providers().contains(provider_id) {
return Ok(());
}
@ -328,7 +314,7 @@ fn print_output(session: &Session, styles: &Styles) {
reason = "Session summaries are diagnostic metadata, not assistant output."
)]
fn print_summary(session: &Session, styles: &Styles) {
let (mut turn_count, mut tool_call_count, mut total_tokens) = (0usize, 0usize, 0i64);
let (mut turn_count, mut tool_call_count, mut total_tokens) = (0usize, 0usize, 0u64);
for turn in session.history().turns() {
if let Message::Assistant {
tool_calls, usage, ..
@ -336,7 +322,7 @@ fn print_summary(session: &Session, styles: &Styles) {
{
turn_count += 1;
tool_call_count += tool_calls.len();
total_tokens += usage.total_tokens();
total_tokens = total_tokens.saturating_add(usage.total());
}
}
let token_str = if total_tokens >= 1_000_000 {
@ -365,38 +351,32 @@ impl Middleware for DebugMiddleware {
clippy::print_stderr,
reason = "Debug middleware logs request and response summaries to stderr."
)]
async fn handle_complete(&self, request: Request, next: NextFn) -> Result<Response, LlmError> {
async fn handle(&self, call: Call, next: Next) -> Result<Output, LlmError> {
let s = self.styles;
eprintln!(
"{}",
s.dim.apply_to(format!(
"[debug] request: model={} messages={} tools={}",
request.model,
request.messages.len(),
request.tools.as_ref().map_or(0, Vec::len),
call.route().handle(),
call.request().messages().len(),
call.request().tools().len(),
)),
);
let response = next(request).await?;
eprintln!(
"{}",
s.dim.apply_to(format!(
"[debug] response: model={} finish={:?} usage=({}/{}/{})",
response.model,
response.finish_reason,
response.usage.input_tokens,
response.usage.output_tokens,
response.usage.total_tokens(),
)),
);
Ok(response)
}
async fn handle_stream(
&self,
request: Request,
next: NextStreamFn,
) -> Result<StreamEventStream, LlmError> {
next(request).await
let output = next.run(call).await?;
if let Output::Complete(response) = &output {
eprintln!(
"{}",
s.dim.apply_to(format!(
"[debug] response: model={} finish={:?} usage=({}/{}/{})",
response.model,
response.finish_reason,
response.usage.input,
response.usage.output,
response.usage.total(),
)),
);
}
Ok(output)
}
}
@ -411,55 +391,60 @@ impl Middleware for VerboseMiddleware {
clippy::print_stderr,
reason = "Verbose middleware dumps full request and response JSON to stderr."
)]
async fn handle_complete(&self, request: Request, next: NextFn) -> Result<Response, LlmError> {
async fn handle(&self, call: Call, next: Next) -> Result<Output, LlmError> {
let s = self.styles;
eprintln!(
"{}\n{}",
s.dim.apply_to("[verbose] request:"),
serde_json::to_string_pretty(&request)
serde_json::to_string_pretty(call.request())
.unwrap_or_else(|e| format!("<serialize error: {e}>"))
);
let response = next(request).await?;
eprintln!(
"{}\n{}",
s.dim.apply_to("[verbose] response:"),
serde_json::to_string_pretty(&response)
.unwrap_or_else(|e| format!("<serialize error: {e}>"))
);
Ok(response)
let output = next.run(call).await?;
if let Output::Complete(response) = &output {
eprintln!(
"{}\n{}",
s.dim.apply_to("[verbose] response:"),
serde_json::to_string_pretty(response)
.unwrap_or_else(|e| format!("<serialize error: {e}>"))
);
}
Ok(output)
}
}
async fn handle_stream(
&self,
request: Request,
next: NextStreamFn,
) -> Result<StreamEventStream, LlmError> {
next(request).await
/// Client options for the standalone agent: standard retries plus the
/// requested diagnostic middleware.
fn cli_client_options(args: &AgentArgs, styles: &'static Styles) -> ClientOptions {
let options = ClientOptions::standard();
if args.verbose {
options.with_middleware(Arc::new(VerboseMiddleware { styles }))
} else if args.debug {
options.with_middleware(Arc::new(DebugMiddleware { styles }))
} else {
options
}
}
/// The catalog the standalone agent runs against: the lithos built-ins and
/// the operator's `[llm]` overlay from the active settings file.
#[expect(
clippy::disallowed_methods,
reason = "Standalone agent honors OPENAI_BASE_URL from the process environment."
)]
fn standalone_catalog() -> anyhow::Result<Arc<Catalog>> {
let overlay =
fabro_config::load_llm_overlay(None).context("failed to load the LLM settings overlay")?;
let catalog = fabro_llm::build_catalog(&overlay, &|name| std::env::var(name).ok())
.context("failed to build standalone agent LLM catalog")?;
Ok(Arc::new(catalog))
}
pub async fn run_with_args(
args: AgentArgs,
mcp_servers: Vec<McpServerSettings>,
) -> anyhow::Result<()> {
let llm_source = standalone_llm_source().await?;
let catalog =
Arc::new(Catalog::from_builtin().context("failed to build standalone agent LLM catalog")?);
run_with_args_and_source_and_catalog(args, llm_source, mcp_servers, catalog).await
}
#[allow(
clippy::print_stdout,
clippy::print_stderr,
reason = "Assistant output stays on stdout while prompts and diagnostics use stderr."
)]
pub async fn run_with_args_and_source(
args: AgentArgs,
llm_source: Arc<dyn CredentialSource>,
mcp_servers: Vec<McpServerSettings>,
) -> anyhow::Result<()> {
let catalog =
Arc::new(Catalog::from_builtin().context("failed to build standalone agent LLM catalog")?);
let catalog = standalone_catalog()?;
run_with_args_and_source_and_catalog(args, llm_source, mcp_servers, catalog).await
}
@ -474,27 +459,31 @@ pub async fn run_with_args_and_source_and_catalog(
mcp_servers: Vec<McpServerSettings>,
catalog: Arc<Catalog>,
) -> anyhow::Result<()> {
let client = Client::from_source(llm_source.as_ref(), Arc::clone(&catalog))
// Resolve color support once, leak to get 'static lifetime for use across
// threads
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let built = fabro_llm::build_client(
Catalog::clone(&catalog),
llm_source,
cli_client_options(&args, styles),
)
.await
.context("Failed to create LLM client")?;
for issue in &built.build_issues {
eprintln!(
"{}",
styles.dim.apply_to(format!(
"[llm] provider '{}' is unavailable: {}",
issue.provider, issue.cause
))
);
}
run_with_args_and_client_and_catalog_styled(args, built.client, mcp_servers, catalog, styles)
.await
.context("Failed to create LLM client")?;
run_with_args_and_client_and_catalog(args, client, mcp_servers, catalog).await
}
#[allow(
clippy::print_stdout,
clippy::print_stderr,
reason = "Assistant output stays on stdout while prompts and diagnostics use stderr."
)]
pub async fn run_with_args_and_client(
args: AgentArgs,
client: Client,
mcp_servers: Vec<McpServerSettings>,
) -> anyhow::Result<()> {
let catalog =
Arc::new(Catalog::from_builtin().context("failed to build standalone agent LLM catalog")?);
run_with_args_and_client_and_catalog(args, client, mcp_servers, catalog).await
}
/// Run against an already-built client, such as the `fabro exec` gateway
/// client. Diagnostic middleware is the caller's responsibility.
#[allow(
clippy::print_stdout,
clippy::print_stderr,
@ -502,35 +491,49 @@ pub async fn run_with_args_and_client(
)]
pub async fn run_with_args_and_client_and_catalog(
args: AgentArgs,
mut client: Client,
client: Client,
mcp_servers: Vec<McpServerSettings>,
catalog: Arc<Catalog>,
) -> anyhow::Result<()> {
// Resolve color support once, leak to get 'static lifetime for use across
// threads
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
run_with_args_and_client_and_catalog_styled(args, client, mcp_servers, catalog, styles).await
}
let provider_id = resolve_provider_id(&catalog, &args, &client.provider_ids())?;
/// Client options a caller building its own client can use so `--debug` and
/// `--verbose` behave the same as with the standalone client.
#[must_use]
pub fn diagnostic_client_options(args: &AgentArgs) -> ClientOptions {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
cli_client_options(args, styles)
}
#[allow(
clippy::print_stdout,
clippy::print_stderr,
reason = "Assistant output stays on stdout while prompts and diagnostics use stderr."
)]
async fn run_with_args_and_client_and_catalog_styled(
args: AgentArgs,
client: Client,
mcp_servers: Vec<McpServerSettings>,
catalog: Arc<Catalog>,
styles: &'static Styles,
) -> anyhow::Result<()> {
let available: std::collections::HashSet<ProviderId> =
client.available_providers().iter().cloned().collect();
let provider_id = resolve_provider_id(&catalog, &args, &available);
ensure_provider_registered(&client, &provider_id)?;
if args.verbose {
client.add_middleware(Arc::new(VerboseMiddleware { styles }));
} else if args.debug {
client.add_middleware(Arc::new(DebugMiddleware { styles }));
}
let model = if let Some(model) = args.model.clone() {
model
} else {
catalog
.default_for_provider(&provider_id)
.map(|model| model.id.clone())
catalog::default_model(&catalog, provider_id.as_str())
.map(|entry| entry.model.id().to_string())
.ok_or_else(|| {
anyhow::anyhow!(
"provider '{provider_id}' has no default model in the catalog; pass --model explicitly"
)
})?
.to_string()
};
let profile_kind = profile_kind_for_provider(&catalog, &provider_id, Some(&model))?;
eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}")));
@ -541,7 +544,7 @@ pub async fn run_with_args_and_client_and_catalog(
&model,
Arc::clone(&catalog),
);
let profile_builder = if profile_kind == AgentProfileKind::Gpt56 {
let profile_builder = if profile_kind.uses_codex_core_tools() {
profile_builder
} else {
profile_builder.with_web_fetch_summarizer(Some(build_summarizer(
@ -801,11 +804,10 @@ pub async fn run() -> anyhow::Result<()> {
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use fabro_model::catalog::{
ModelCatalogSettings, ProviderCatalogSettings, SettingsModelFeatures, SettingsModelLimits,
use fabro_llm::test_support::{
client_with_adapters, test_catalog as fabro_test_catalog, test_catalog_with_overlay,
};
use fabro_types::provider_ids;
use serde_json::json;
use super::*;
@ -941,13 +943,72 @@ mod tests {
}
fn test_catalog() -> Arc<Catalog> {
Arc::new(Catalog::from_builtin().unwrap())
Arc::new(fabro_test_catalog())
}
/// An operator-defined OpenAI-compatible provider with one Claude model,
/// the shape an `[llm]` overlay produces.
const ACME_OVERLAY: &str = r#"
[providers.acme-aws]
display_name = "Acme AWS"
aliases = ["br"]
adapter = "openai-compatible"
codec = "openai-chat"
base_url = "https://example.invalid/v1"
auth = { type = "bearer" }
default_model = "acme-aws-claude"
[providers.acme-aws.metadata.agent]
profile = "openai"
[providers.acme-aws.models.acme-aws-claude]
display_name = "Acme AWS Claude"
api_model = "acme-aws-claude"
limits = { context_tokens = 1000, max_output_tokens = 500 }
capabilities = { text = true, tools = true }
family = "claude"
[providers.acme-aws.models.acme-aws-claude.metadata.agent]
profile = "anthropic"
"#;
/// The same provider with no models, so its default comes from the
/// operator's `--model` alone.
const ACME_OVERLAY_WITHOUT_MODELS: &str = r#"
[providers.acme-aws]
display_name = "Acme AWS"
adapter = "openai-compatible"
codec = "openai-chat"
base_url = "https://example.invalid/v1"
auth = { type = "bearer" }
allow_passthrough = true
[providers.acme-aws.metadata.agent]
profile = "openai"
"#;
fn acme_catalog() -> Catalog {
test_catalog_with_overlay(ACME_OVERLAY)
}
fn args_with(provider: Option<&str>, model: Option<&str>) -> AgentArgs {
AgentArgs {
prompt: "test".to_string(),
provider: provider.map(str::to_string),
model: model.map(str::to_string),
permissions: None,
auto_approve: false,
debug: false,
verbose: false,
skills_dir: None,
output_format: None,
}
}
#[test]
fn ensure_provider_registered_reports_missing_credentials() {
let client = Client::new(HashMap::new(), None, vec![]);
let error = ensure_provider_registered(&client, &ProviderId::anthropic()).unwrap_err();
let client = client_with_adapters(Vec::new(), ClientOptions::default());
let error = ensure_provider_registered(&client, &provider_ids::anthropic()).unwrap_err();
assert_eq!(
error.to_string(),
"LLM credentials not configured for provider 'anthropic'"
@ -956,30 +1017,10 @@ mod tests {
#[test]
fn profile_kind_accepts_custom_catalog_provider() {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert("acme-aws".to_string(), ProviderCatalogSettings {
display_name: Some("Acme AWS".to_string()),
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://example.invalid/v1".to_string()),
agent_profile: Some(AgentProfileKind::OpenAi),
..ProviderCatalogSettings::default()
});
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
let args = AgentArgs {
prompt: "test".to_string(),
provider: Some("acme-aws".to_string()),
model: None,
permissions: None,
auto_approve: false,
debug: false,
verbose: false,
skills_dir: None,
output_format: None,
};
let catalog = acme_catalog();
let args = args_with(Some("acme-aws"), None);
let provider_id = parse_provider(&args).unwrap();
let provider_id = parse_provider(&args);
assert_eq!(provider_id, ProviderId::new("acme-aws"));
assert_eq!(
profile_kind_for_provider(&catalog, &provider_id, None).unwrap(),
@ -989,127 +1030,29 @@ mod tests {
#[test]
fn standalone_provider_resolution_uses_catalog_model_provider_when_provider_omitted() {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert("acme-aws".to_string(), ProviderCatalogSettings {
display_name: Some("Acme AWS".to_string()),
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://example.invalid/v1".to_string()),
agent_profile: Some(AgentProfileKind::OpenAi),
..ProviderCatalogSettings::default()
});
settings
.models
.insert("acme-aws-claude".to_string(), ModelCatalogSettings {
provider: Some("acme-aws".to_string()),
display_name: Some("Acme AWS Claude".to_string()),
family: Some("claude".to_string()),
default: Some(true),
limits: Some(SettingsModelLimits {
context_window: Some(1000),
max_output: None,
}),
features: Some(SettingsModelFeatures {
tools: Some(true),
vision: Some(false),
reasoning: Some(false),
reasoning_by_default: None,
reasoning_effort: None,
prompt_cache: None,
cache_control_breakpoints: None,
sampling_params: None,
}),
..ModelCatalogSettings::default()
});
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
let args = AgentArgs {
prompt: "test".to_string(),
provider: None,
model: Some("acme-aws-claude".to_string()),
permissions: None,
auto_approve: false,
debug: false,
verbose: false,
skills_dir: None,
output_format: None,
};
let catalog = acme_catalog();
let args = args_with(None, Some("acme-aws-claude"));
assert_eq!(
resolve_provider_id(&catalog, &args, &catalog.all_provider_ids()).unwrap(),
resolve_provider_id(&catalog, &args, &catalog::enabled_provider_ids(&catalog)),
ProviderId::new("acme-aws")
);
}
#[test]
fn standalone_provider_resolution_canonicalizes_explicit_provider_alias() {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert("acme-aws".to_string(), ProviderCatalogSettings {
display_name: Some("Acme AWS".to_string()),
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://example.invalid/v1".to_string()),
agent_profile: Some(AgentProfileKind::OpenAi),
aliases: Some(vec!["br".to_string()]),
..ProviderCatalogSettings::default()
});
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
let args = AgentArgs {
prompt: "test".to_string(),
provider: Some("br".to_string()),
model: None,
permissions: None,
auto_approve: false,
debug: false,
verbose: false,
skills_dir: None,
output_format: None,
};
let catalog = acme_catalog();
let args = args_with(Some("br"), None);
assert_eq!(
resolve_provider_id(&catalog, &args, &catalog.all_provider_ids()).unwrap(),
resolve_provider_id(&catalog, &args, &catalog::enabled_provider_ids(&catalog)),
ProviderId::new("acme-aws")
);
}
#[test]
fn standalone_profile_kind_uses_model_agent_profile_override() {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert("acme-aws".to_string(), ProviderCatalogSettings {
display_name: Some("Acme AWS".to_string()),
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://example.invalid/v1".to_string()),
agent_profile: Some(AgentProfileKind::OpenAi),
..ProviderCatalogSettings::default()
});
settings
.models
.insert("acme-aws-claude".to_string(), ModelCatalogSettings {
provider: Some("acme-aws".to_string()),
display_name: Some("Acme AWS Claude".to_string()),
family: Some("claude".to_string()),
default: Some(true),
agent_profile: Some(AgentProfileKind::Anthropic),
limits: Some(SettingsModelLimits {
context_window: Some(1000),
max_output: None,
}),
features: Some(SettingsModelFeatures {
tools: Some(true),
vision: Some(false),
reasoning: Some(false),
reasoning_by_default: None,
reasoning_effort: None,
prompt_cache: None,
cache_control_breakpoints: None,
sampling_params: None,
}),
..ModelCatalogSettings::default()
});
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
let catalog = acme_catalog();
assert_eq!(
profile_kind_for_provider(
@ -1124,44 +1067,22 @@ mod tests {
#[test]
fn summarizer_model_id_uses_selected_model_for_custom_provider_without_default() {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert("acme-aws".to_string(), ProviderCatalogSettings {
display_name: Some("Acme AWS".to_string()),
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://example.invalid/v1".to_string()),
agent_profile: Some(AgentProfileKind::OpenAi),
..ProviderCatalogSettings::default()
});
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
let catalog = test_catalog_with_overlay(ACME_OVERLAY_WITHOUT_MODELS);
let provider_id = ProviderId::new("acme-aws");
let model_id = summarizer_model_id(&provider_id, &catalog, "acme-aws-claude-sonnet-4-6");
assert_eq!(model_id.provider(), &provider_id);
assert_eq!(model_id.model_id(), "acme-aws-claude-sonnet-4-6");
assert_eq!(model_id.model().as_str(), "acme-aws-claude-sonnet-4-6");
}
#[test]
fn summarizer_model_id_ignores_profile_for_custom_provider_without_default() {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert("acme-aws".to_string(), ProviderCatalogSettings {
display_name: Some("Acme AWS".to_string()),
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://example.invalid/v1".to_string()),
agent_profile: Some(AgentProfileKind::Anthropic),
..ProviderCatalogSettings::default()
});
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
let provider_id = ProviderId::new("acme-aws");
fn summarizer_model_id_prefers_the_provider_small_default() {
let catalog = test_catalog();
let model_id = summarizer_model_id(&provider_ids::openai(), &catalog, "gpt-5.4");
let model_id = summarizer_model_id(&provider_id, &catalog, "acme-aws-claude-sonnet-4-6");
assert_eq!(model_id.provider(), &provider_id);
assert_eq!(model_id.model_id(), "acme-aws-claude-sonnet-4-6");
assert_eq!(model_id.provider(), &provider_ids::openai());
assert_eq!(model_id.model().as_str(), "gpt-5.4-mini");
}
// subagent tool registration tests
@ -1170,7 +1091,7 @@ mod tests {
fn build_profile_can_register_subagent_tools() {
let mut profile = AgentProfileBuilder::new(
AgentProfileKind::Anthropic,
ProviderId::anthropic(),
provider_ids::anthropic(),
"model",
test_catalog(),
)

View file

@ -1,7 +1,7 @@
use std::fmt::Write;
use fabro_llm::client::Client;
use fabro_llm::types::{Message as LlmMessage, Request};
use fabro_llm::{Client, Request};
use fabro_types::{tool_call_arguments, tool_result_to_json};
use tracing::debug;
use crate::agent_profile::AgentProfile;
@ -14,13 +14,13 @@ use crate::types::{AgentEvent, Message};
const APPROX_CHARS_PER_TOKEN: usize = 4;
/// Maximum output budget for the visible summary text itself.
const SUMMARY_MAX_TOKENS: i64 = 4096;
const SUMMARY_MAX_TOKENS: u32 = 4096;
/// Extra output budget for models that reason on every request. `max_tokens`
/// bounds reasoning *plus* visible output, so a reasoning model handed only
/// `SUMMARY_MAX_TOKENS` can spend the whole budget thinking and return a
/// successful response with empty content — a silently empty summary.
const REASONING_HEADROOM_TOKENS: i64 = 16_384;
const REASONING_HEADROOM_TOKENS: u32 = 16_384;
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)]
#[strum(serialize_all = "snake_case")]
@ -138,32 +138,29 @@ function names, error messages, and exact values. Omit pleasantries and conversa
{file_ops_section}"
);
let summary_request = Request {
model: provider_profile.model().to_string(),
messages: vec![
LlmMessage::system(summarization_prompt),
LlmMessage::user(format!(
"Here is the conversation to summarize:\n\n{rendered}"
)),
],
provider: Some(provider_profile.provider_id().to_string()),
tools: None,
tool_choice: None,
response_format: None,
temperature: None,
top_p: None,
max_tokens: Some(max_tokens),
stop_sequences: None,
reasoning_effort: None,
speed: None,
metadata: None,
provider_options: None,
};
let summary_request = Request::builder()
.model(format!(
"{}/{}",
provider_profile.provider_id(),
provider_profile.model()
))
.system(summarization_prompt)
.user(format!(
"Here is the conversation to summarize:\n\n{rendered}"
))
.max_output_tokens(max_tokens)
.build()
.map_err(|err| {
CompactionError::from(fabro_llm::Error::new(
fabro_llm::ErrorKind::InvalidRequest,
format!("invalid summarization request: {err}"),
))
})?;
let response = llm_client
.complete(&summary_request)
.complete(summary_request)
.await
.map_err(CompactionError::Llm)?;
.map_err(CompactionError::from)?;
let response_text = response.text();
let summary_text = response_text.trim();
@ -208,7 +205,7 @@ Build on their progress — do not repeat completed steps.\n\n{summary_text}"
/// as well as the summary. Provider routes that reason by default get headroom
/// on top of the summary allowance. Every known model budget is capped at its
/// declared `max_output`.
fn summary_max_tokens(reasoning_by_default: bool, max_output: Option<i64>) -> i64 {
fn summary_max_tokens(reasoning_by_default: bool, max_output: Option<u32>) -> u32 {
let budget = if reasoning_by_default {
SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS
} else {
@ -264,7 +261,7 @@ pub(crate) fn estimate_active_context_usage(
fn latest_assistant_usage_baseline(turns: &[Message]) -> Option<(usize, usize)> {
turns.iter().enumerate().rev().find_map(|(index, turn)| {
if let Message::Assistant { usage, .. } = turn {
let total_tokens = usage.total_tokens();
let total_tokens = usage.total();
if total_tokens > 0 {
return Some((index, usize::try_from(total_tokens).unwrap_or(usize::MAX)));
}
@ -300,13 +297,14 @@ fn estimate_turn_chars(turn: &Message) -> usize {
let reasoning_chars = turn.reasoning_text().map_or(0, str::len);
let tool_call_chars: usize = tool_calls
.iter()
.map(|tc| tc.name.len() + tc.arguments.to_string().len())
.map(|tc| tc.name.len() + tc.input.raw().len())
.sum();
content.len() + reasoning_chars + tool_call_chars
}
Message::ToolResults { results, .. } => {
results.iter().map(|r| r.content.to_string().len()).sum()
}
Message::ToolResults { results, .. } => results
.iter()
.map(|r| tool_result_to_json(r).to_string().len())
.sum(),
}
}
@ -328,7 +326,7 @@ pub fn render_turns_for_summary(turns: &[Message]) -> String {
let _ = writeln!(out, "Assistant: {content}");
}
for tc in tool_calls {
let args_str = tc.arguments.to_string();
let args_str = tool_call_arguments(tc).to_string();
let truncated = if args_str.len() > 500 {
format!("{}...", &args_str[..args_str.floor_char_boundary(500)])
} else {
@ -339,7 +337,7 @@ pub fn render_turns_for_summary(turns: &[Message]) -> String {
}
Message::ToolResults { results, .. } => {
for r in results {
let content_str = r.content.to_string();
let content_str = tool_result_to_json(r).to_string();
let truncated = if content_str.len() > 500 {
format!(
"{}...",
@ -367,8 +365,10 @@ mod tests {
use std::sync::Arc;
use std::time::SystemTime;
use fabro_llm::types::{TokenCounts, ToolCall, ToolResult};
use fabro_model::{Catalog, Model, ProviderId};
use fabro_llm::catalog::model_on_provider;
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::test_support::test_catalog;
use fabro_types::{TokenCounts, ToolCall, tool_result_from_json};
use super::*;
use crate::event::Emitter;
@ -377,19 +377,18 @@ mod tests {
use crate::tool_registry::ToolRegistry;
use crate::types::Message;
fn catalog_model(provider: &ProviderId, id: &str) -> &'static Model {
Catalog::builtin()
.get_on_provider(provider, id)
.unwrap_or_else(|| panic!("{provider}/{id} missing from builtin catalog"))
fn catalog() -> Catalog {
test_catalog()
}
fn builtin_summary_max_tokens(provider: &ProviderId, id: &str) -> i64 {
let catalog = Catalog::builtin();
let model = catalog_model(provider, id);
let settings = catalog
.settings_for(model)
.unwrap_or_else(|| panic!("{provider}/{id} missing catalog settings"));
summary_max_tokens(settings.reasoning_by_default, model.max_output())
fn builtin_summary_max_tokens(catalog: &Catalog, provider: &str, id: &str) -> u32 {
let entry = model_on_provider(catalog, provider, id)
.unwrap_or_else(|| panic!("{provider}/{id} missing from the catalog"));
let max_output = entry
.model
.limits()
.map(|limits| u32::try_from(limits.max_output_tokens).unwrap_or(u32::MAX));
summary_max_tokens(entry.reasons_by_default(), max_output)
}
#[test]
@ -405,22 +404,23 @@ mod tests {
#[test]
fn summary_budget_for_non_reasoning_model_is_summary_allowance() {
// claude-haiku-4-5: reasoning = false.
// claude-haiku-4.5: reasoning = false.
assert_eq!(
builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-haiku-4-5"),
builtin_summary_max_tokens(&catalog(), "anthropic", "claude-haiku-4.5"),
SUMMARY_MAX_TOKENS
);
}
#[test]
fn summary_budget_for_model_without_effort_feature_is_summary_allowance() {
// claude-sonnet-4-5 reasons only when a request asks for it, and
// compaction never sends a reasoning effort.
let model = catalog_model(&ProviderId::anthropic(), "claude-sonnet-4-5");
assert!(model.supports_reasoning());
assert!(!model.supports_reasoning_effort());
// claude-sonnet-4.5 reasons only when a request asks for a thinking
// budget, and compaction never sends one.
let catalog = catalog();
let entry = model_on_provider(&catalog, "anthropic", "claude-sonnet-4.5").unwrap();
assert!(entry.model.capabilities().reasoning().is_supported());
assert!(!entry.model.protocol_options().reasoning_effort_levels);
assert_eq!(
builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-sonnet-4-5"),
builtin_summary_max_tokens(&catalog, "anthropic", "claude-sonnet-4.5"),
SUMMARY_MAX_TOKENS
);
}
@ -428,7 +428,7 @@ mod tests {
#[test]
fn summary_budget_for_always_adaptive_model_adds_reasoning_headroom() {
assert_eq!(
builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-fable-5"),
builtin_summary_max_tokens(&catalog(), "anthropic", "claude-fable-5"),
SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS
);
}
@ -436,19 +436,20 @@ mod tests {
#[test]
fn summary_budget_for_effort_levels_model_adds_reasoning_headroom() {
assert_eq!(
builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-opus-5"),
builtin_summary_max_tokens(&catalog(), "anthropic", "claude-opus-5"),
SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS
);
}
#[test]
fn summary_budget_for_always_reasoning_route_without_effort_adds_headroom() {
let moonshot = ProviderId::new("moonshot");
let model = catalog_model(&moonshot, "kimi-k2.5");
assert!(model.supports_reasoning());
assert!(!model.supports_reasoning_effort());
// Kimi K2.5 takes no effort levels but always reasons, which Fabro
// policy states outright.
let catalog = catalog();
let entry = model_on_provider(&catalog, "moonshot", "kimi-k2.5").unwrap();
assert!(!entry.model.protocol_options().reasoning_effort_levels);
assert_eq!(
builtin_summary_max_tokens(&moonshot, "kimi-k2.5"),
builtin_summary_max_tokens(&catalog, "moonshot", "kimi-k2.5"),
SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS
);
}
@ -468,24 +469,22 @@ mod tests {
},
Message::Assistant {
content: "Let me check".into(),
tool_calls: vec![ToolCall::new(
tool_calls: vec![ToolCall::function(
"c1",
"read_file",
serde_json::json!({"path": "foo.rs"}),
)],
provider_parts: vec![],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
},
Message::ToolResults {
results: vec![ToolResult {
tool_call_id: "c1".into(),
content: serde_json::json!("file contents here"),
is_error: false,
image_data: None,
image_media_type: None,
}],
results: vec![tool_result_from_json(
"c1",
serde_json::json!("file contents here"),
false,
)],
timestamp: SystemTime::now(),
},
];
@ -502,13 +501,11 @@ mod tests {
fn render_turns_truncates_long_tool_output() {
let long_output = "x".repeat(1000);
let turns = vec![Message::ToolResults {
results: vec![ToolResult {
tool_call_id: "c1".into(),
content: serde_json::json!(long_output),
is_error: false,
image_data: None,
image_media_type: None,
}],
results: vec![tool_result_from_json(
"c1",
serde_json::json!(long_output),
false,
)],
timestamp: SystemTime::now(),
}];
let rendered = render_turns_for_summary(&turns);
@ -540,19 +537,23 @@ mod tests {
history.push(Message::Assistant {
// 18 chars content + tool call name (9) + args (16) = 43 chars => 10 tokens
content: "No usage available".into(),
tool_calls: vec![ToolCall::new(
tool_calls: vec![ToolCall::function(
"call_1",
"read_file",
serde_json::json!({"path": "foo.rs"}),
)],
provider_parts: vec![],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
history.push(Message::ToolResults {
// 4 chars => 1 token
results: vec![ToolResult::success("call_1", serde_json::json!(1234))],
results: vec![tool_result_from_json(
"call_1",
serde_json::json!(1234),
false,
)],
timestamp: SystemTime::now(),
});
@ -588,16 +589,20 @@ mod tests {
content: "baseline response".into(),
tool_calls: vec![],
provider_parts: vec![],
usage: Box::new(TokenCounts {
input_tokens: 50,
usage: TokenCounts {
input: 50,
..TokenCounts::default()
}),
},
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
history.push(Message::ToolResults {
// JSON number renders as 4 chars => 1 local token.
results: vec![ToolResult::success("call_1", serde_json::json!(1234))],
results: vec![tool_result_from_json(
"call_1",
serde_json::json!(1234),
false,
)],
timestamp: SystemTime::now(),
});
history.push(Message::User {
@ -627,13 +632,13 @@ mod tests {
content: "short".into(),
tool_calls: vec![],
provider_parts: vec![],
usage: Box::new(TokenCounts {
input_tokens: 10,
output_tokens: 20,
reasoning_tokens: 30,
cache_read_tokens: 40,
cache_write_tokens: 50,
}),
usage: TokenCounts {
input: 10,
output: 20,
reasoning: 30,
cache_read: 40,
cache_write: 50,
},
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
@ -654,10 +659,10 @@ mod tests {
content: "older response".into(),
tool_calls: vec![],
provider_parts: vec![],
usage: Box::new(TokenCounts {
input_tokens: 1_000,
usage: TokenCounts {
input: 1_000,
..TokenCounts::default()
}),
},
response_id: "resp_old".into(),
timestamp: SystemTime::now(),
});
@ -669,10 +674,10 @@ mod tests {
content: "latest response".into(),
tool_calls: vec![],
provider_parts: vec![],
usage: Box::new(TokenCounts {
input_tokens: 20,
usage: TokenCounts {
input: 20,
..TokenCounts::default()
}),
},
response_id: "resp_new".into(),
timestamp: SystemTime::now(),
});

View file

@ -2,10 +2,10 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use fabro_llm::types::{ReasoningEffort, Speed};
use fabro_llm::RetryPolicy;
use fabro_llm::client::default_retry_policy;
use fabro_mcp::config::McpServerSettings;
use fabro_model::AgentProfileKind;
use fabro_types::PermissionLevel;
use fabro_types::{AgentProfileKind, PermissionLevel, ReasoningEffort, Speed};
/// Callback invoked before each tool execution. Return `Ok(())` to allow,
/// `Err(message)` to deny with the given message.
@ -139,9 +139,10 @@ impl NativeToolOptions {
AgentProfileKind::Kimi => 60_000,
// Codex's `shell_command` documents a 10s default, which is
// already fabro's, so GPT-5.6 budgets against the same number.
AgentProfileKind::OpenAi | AgentProfileKind::Gemini | AgentProfileKind::Gpt56 => {
defaults.default_command_timeout_ms
}
AgentProfileKind::OpenAi
| AgentProfileKind::Gemini
| AgentProfileKind::Gpt56
| AgentProfileKind::Gpt6 => defaults.default_command_timeout_ms,
};
Self {
default_command_timeout_ms,
@ -168,7 +169,11 @@ pub struct SessionOptions {
pub tool_line_limits: HashMap<String, usize>,
/// Override the provider's default max_tokens when set.
/// Node-level attribute takes priority over the model catalog default.
pub max_tokens: Option<i64>,
pub max_tokens: Option<u32>,
/// Same-route retry policy for replaying a turn whose stream failed after
/// visible output was already shown. Retries before visible output are
/// the client's; this bounds the agent's own replays.
pub replay_retry_policy: RetryPolicy,
pub enable_loop_detection: bool,
pub loop_detection_window: usize,
pub max_subagent_depth: usize,
@ -200,6 +205,7 @@ impl std::fmt::Debug for SessionOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionOptions")
.field("max_tokens", &self.max_tokens)
.field("replay_retry_policy", &self.replay_retry_policy)
.field("reasoning_effort", &self.reasoning_effort)
.field("speed", &self.speed)
.field("tool_output_limits", &self.tool_output_limits)
@ -236,6 +242,7 @@ impl Default for SessionOptions {
fn default() -> Self {
Self {
max_tokens: None,
replay_retry_policy: default_retry_policy(),
reasoning_effort: None,
speed: None,
tool_output_limits: HashMap::new(),

View file

@ -1,14 +1,12 @@
use std::collections::{BTreeMap, HashSet};
use chrono::Utc;
use fabro_llm::token_count::{
estimate_message_tokens, estimate_request_control_tokens, estimate_text_tokens,
estimate_tool_definition_tokens, is_local_estimator_warning,
};
use fabro_llm::types::{Request, Role, TokenCounts, Warning as LlmWarning};
use fabro_llm::Request;
use fabro_llm::estimate::{self, EstimateWarning, TokenEstimate};
use fabro_types::{
StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod,
StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowWarning,
Role, StageContextWindowBreakdownItem, StageContextWindowCategory,
StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,
StageContextWindowWarning, TokenCounts, text_of,
};
use crate::memory::MemoryDocument;
@ -126,34 +124,64 @@ pub(crate) fn context_window_from_response_usage(
usage: &TokenCounts,
) -> StageContextWindowProjection {
let input_tokens = usage
.input_tokens
.saturating_add(usage.cache_read_tokens)
.saturating_add(usage.cache_write_tokens);
if input_tokens <= 0 {
.input
.saturating_add(usage.cache_read)
.saturating_add(usage.cache_write);
if input_tokens == 0 {
return local_snapshot.clone();
}
scaled_snapshot(
local_snapshot,
u64::try_from(input_tokens).unwrap_or(u64::MAX),
input_tokens,
StageContextWindowCountMethod::ResponseUsageScaledBreakdown,
local_snapshot.warnings.clone(),
)
}
/// Warning code for media parts sized by bytes rather than tokenized.
pub(crate) const MEDIA_ESTIMATE_WARNING: &str = "media_token_estimate";
/// Warning code for provider-native opaque parts measured as JSON text.
pub(crate) const OPAQUE_CONTEXT_ESTIMATE_WARNING: &str = "opaque_context_estimate";
/// Warning code for provider options measured as JSON text.
const PROVIDER_OPTIONS_ESTIMATE_WARNING: &str = "provider_options_estimate";
/// Fabro's stable code for a lithos estimator warning.
fn warning_code(warning: EstimateWarning) -> &'static str {
match warning {
EstimateWarning::Media => MEDIA_ESTIMATE_WARNING,
EstimateWarning::OpaqueContent => OPAQUE_CONTEXT_ESTIMATE_WARNING,
EstimateWarning::ProviderOptions => PROVIDER_OPTIONS_ESTIMATE_WARNING,
_ => "token_count_warning",
}
}
/// Whether a warning code describes local-estimator imprecision rather than
/// a fact about the conversation.
fn is_local_estimator_warning(code: &str) -> bool {
matches!(
code,
MEDIA_ESTIMATE_WARNING
| OPAQUE_CONTEXT_ESTIMATE_WARNING
| PROVIDER_OPTIONS_ESTIMATE_WARNING
| "token_count_warning"
)
}
#[must_use]
fn warnings_from_llm(warnings: &[LlmWarning]) -> Vec<StageContextWindowWarning> {
warnings
.iter()
fn warnings_from_estimate(estimate: &TokenEstimate) -> Vec<StageContextWindowWarning> {
estimate
.warnings()
.map(|warning| StageContextWindowWarning {
code: warning
.code
.clone()
.unwrap_or_else(|| "token_count_warning".to_string()),
message: warning.message.clone(),
code: warning_code(warning).to_string(),
message: warning.to_string(),
})
.collect()
}
fn to_usize(tokens: u64) -> usize {
usize::try_from(tokens).unwrap_or(usize::MAX)
}
fn add_message_breakdown(
builder: &mut BreakdownBuilder,
warnings: &mut Vec<StageContextWindowWarning>,
@ -161,34 +189,35 @@ fn add_message_breakdown(
) {
let memory_text = memory_prompt_suffix(input.memory);
let skills_text = skills_prompt_suffix(input.skills, input.tool_vocabulary);
let memory_tokens = estimate_text_tokens(&memory_text);
let skills_tokens = estimate_text_tokens(&skills_text);
let memory_tokens = to_usize(estimate::text_tokens(&memory_text));
let skills_tokens = to_usize(estimate::text_tokens(&skills_text));
let mut system_parts_seen = false;
for message in &input.request.messages {
let estimate = estimate_message_tokens(message);
warnings.extend(warnings_from_llm(&estimate.warnings));
if message.role == Role::System
for message in input.request.messages() {
let estimate = estimate::message_tokens(message);
warnings.extend(warnings_from_estimate(&estimate));
let tokens = to_usize(estimate.tokens());
if message.role() == Role::System
&& !system_parts_seen
&& message.text() == input.system_prompt
&& text_of(message.content()) == input.system_prompt
{
system_parts_seen = true;
let attributed_suffix = memory_tokens.saturating_add(skills_tokens);
builder.add(
StageContextWindowCategory::SystemPrompt,
estimate.tokens.saturating_sub(attributed_suffix),
tokens.saturating_sub(attributed_suffix),
);
builder.add(StageContextWindowCategory::Memory, memory_tokens);
builder.add(StageContextWindowCategory::Skills, skills_tokens);
} else {
builder.add(StageContextWindowCategory::Conversation, estimate.tokens);
builder.add(StageContextWindowCategory::Conversation, tokens);
}
}
}
fn add_tool_breakdown(builder: &mut BreakdownBuilder, tools: &[ToolDefinitionWithSource]) {
for tool in tools {
let tokens = estimate_tool_definition_tokens(&tool.definition);
let tokens = to_usize(estimate::tool_definition_tokens(&tool.definition));
match &tool.source {
ToolSource::Native => builder.add(StageContextWindowCategory::Tools, tokens),
ToolSource::Mcp { .. } => builder.add(StageContextWindowCategory::McpTools, tokens),
@ -202,9 +231,12 @@ fn add_request_control_breakdown(
warnings: &mut Vec<StageContextWindowWarning>,
request: &Request,
) {
let estimate = estimate_request_control_tokens(request);
warnings.extend(warnings_from_llm(&estimate.warnings));
builder.add(StageContextWindowCategory::Other, estimate.tokens);
let estimate = estimate::request_control_tokens(request);
warnings.extend(warnings_from_estimate(&estimate));
builder.add(
StageContextWindowCategory::Other,
to_usize(estimate.tokens()),
);
}
fn memory_prompt_suffix(memory: &[MemoryDocument]) -> String {
@ -340,28 +372,24 @@ fn usage_percent(tokens: u64, denominator: u64) -> f64 {
#[cfg(test)]
mod tests {
use fabro_llm::types::{Message as LlmMessage, Request, ToolChoice, ToolDefinition};
use fabro_types::{Message as LlmMessage, ToolChoice, ToolDefinition};
use super::*;
use crate::tool_registry::ToolDefinitionWithSource;
fn request(messages: Vec<LlmMessage>, tools: Vec<ToolDefinition>) -> Request {
Request {
model: "model-a".to_string(),
messages,
provider: Some("test".to_string()),
tools: (!tools.is_empty()).then_some(tools),
tool_choice: Some(ToolChoice::Auto),
response_format: None,
temperature: None,
top_p: None,
max_tokens: None,
stop_sequences: None,
reasoning_effort: None,
speed: None,
metadata: None,
provider_options: None,
let mut builder = Request::builder().model("test/model-a");
for message in messages {
builder = builder.message(message);
}
let has_tools = !tools.is_empty();
for tool in tools {
builder = builder.tool(tool);
}
if has_tools {
builder = builder.tool_choice(ToolChoice::Auto);
}
builder.build().expect("test request should build")
}
fn tool(name: &str, source: ToolSource) -> ToolDefinitionWithSource {
@ -404,8 +432,8 @@ mod tests {
];
let req = request(
vec![
LlmMessage::system(system_prompt.clone()),
LlmMessage::user("hello"),
LlmMessage::text(Role::System, system_prompt.clone()),
LlmMessage::text(Role::User, "hello"),
],
tools.iter().map(|tool| tool.definition.clone()).collect(),
);
@ -525,7 +553,6 @@ mod tests {
}
fn warnings_in() -> Vec<StageContextWindowWarning> {
use fabro_llm::token_count::{MEDIA_ESTIMATE_WARNING, OPAQUE_CONTEXT_ESTIMATE_WARNING};
vec![
StageContextWindowWarning {
code: OPAQUE_CONTEXT_ESTIMATE_WARNING.to_string(),
@ -593,7 +620,6 @@ mod tests {
#[test]
fn scaled_snapshot_dedupes_repeated_warning_codes() {
use fabro_llm::token_count::OPAQUE_CONTEXT_ESTIMATE_WARNING;
let local = snapshot_for_warning_test();
// Simulate the real bug: build_local_snapshot walks N messages and
// adds the same `opaque_context_estimate` warning once per turn that

View file

@ -1,4 +1,4 @@
use fabro_llm::Error as LlmError;
use fabro_llm::LlmError;
/// Why a session was interrupted.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@ -33,6 +33,8 @@ pub enum CompactionError {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, thiserror::Error)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum Error {
/// A provider call failed. Carries lithos's stored error projection so
/// the failure stays cloneable and serializable.
#[error("LLM error: {0}")]
Llm(#[from] LlmError),
@ -52,21 +54,40 @@ pub enum Error {
Interrupted(InterruptReason),
}
impl From<fabro_llm::Error> for Error {
fn from(error: fabro_llm::Error) -> Self {
Self::Llm(LlmError::from(error))
}
}
impl From<fabro_llm::Error> for CompactionError {
fn from(error: fabro_llm::Error) -> Self {
Self::Llm(LlmError::from(error))
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use fabro_llm::{ProviderErrorDetail, ProviderErrorKind};
use std::time::Duration;
use fabro_llm::{ErrorFacts, ErrorKind, RetryClassification};
use fabro_types::provider_ids;
use fabro_util::error;
use super::*;
fn network_error(message: &str) -> LlmError {
LlmError::from(
fabro_llm::Error::new(ErrorKind::Network, message)
.with_retry(RetryClassification::Safe),
)
}
#[test]
fn agent_error_from_sdk_error() {
let sdk_err = LlmError::Network {
message: "connection refused".into(),
source: None,
};
let sdk_err = network_error("connection refused");
let agent_err = Error::from(sdk_err);
assert!(matches!(agent_err, Error::Llm(_)));
assert!(agent_err.to_string().contains("connection refused"));
@ -74,10 +95,7 @@ mod tests {
#[test]
fn compaction_error_preserves_llm_source_chain() {
let err = Error::Compaction(CompactionError::Llm(LlmError::Network {
message: "connection refused".into(),
source: None,
}));
let err = Error::Compaction(CompactionError::Llm(network_error("connection refused")));
let chain = error::collect_chain(&err);
@ -139,10 +157,7 @@ mod tests {
#[test]
fn serde_roundtrip_llm_network() {
let err = Error::Llm(LlmError::Network {
message: "connection refused".into(),
source: None,
});
let err = Error::Llm(network_error("connection refused"));
let json = serde_json::to_string(&err).unwrap();
let deserialized: Error = serde_json::from_str(&json).unwrap();
assert_eq!(err.to_string(), deserialized.to_string());
@ -150,20 +165,21 @@ mod tests {
#[test]
fn serde_roundtrip_llm_provider() {
let err = Error::Llm(LlmError::Provider {
kind: ProviderErrorKind::RateLimit,
detail: Box::new(ProviderErrorDetail {
message: "too fast".into(),
provider: "openai".into(),
status_code: Some(429),
error_code: None,
retry_after: Some(2.0),
raw: None,
}),
});
let err = Error::Llm(LlmError::from(
fabro_llm::Error::new(ErrorKind::RateLimit, "too fast")
.with_provider(provider_ids::openai())
.with_status(429)
.with_retry(RetryClassification::after(Duration::from_secs(2))),
));
let json = serde_json::to_string(&err).unwrap();
let deserialized: Error = serde_json::from_str(&json).unwrap();
assert_eq!(err.to_string(), deserialized.to_string());
let Error::Llm(decoded) = deserialized else {
panic!("expected an LLM error");
};
assert_eq!(decoded.kind(), ErrorKind::RateLimit);
assert_eq!(decoded.status(), Some(429));
assert_eq!(decoded.retry_after(), Some(Duration::from_secs(2)));
}
#[test]
@ -213,10 +229,7 @@ mod tests {
#[test]
fn clone_all_variants() {
let errors: Vec<Error> = vec![
Error::Llm(LlmError::Network {
message: "refused".into(),
source: None,
}),
Error::Llm(network_error("refused")),
Error::Compaction(CompactionError::EmptySummary {
summarized_turn_count: 3,
}),
@ -234,10 +247,7 @@ mod tests {
#[test]
fn serde_tag_format_llm() {
let err = Error::Llm(LlmError::Network {
message: "refused".into(),
source: None,
});
let err = Error::Llm(network_error("refused"));
let json = serde_json::to_string(&err).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["type"], "llm");

View file

@ -1,7 +1,7 @@
use std::collections::BTreeMap;
use std::fmt::Write;
use fabro_llm::types::{ToolCall, ToolResult};
use fabro_types::{ToolCall, ToolResult, tool_call_arguments, tool_result_to_json};
use crate::native_tool::NativeTool;
use crate::tool_permissions::canonical_tool_name;
@ -71,24 +71,25 @@ impl FileTracker {
}
match canonical_tool_name(&tc.name) {
name if name == NativeTool::ReadFile.canonical_name() => {
if let Some(path) = file_path(&tc.arguments) {
if let Some(path) = file_path(&tool_call_arguments(tc)) {
self.record_read(path);
}
}
name if name == NativeTool::WriteFile.canonical_name() => {
if let Some(path) = file_path(&tc.arguments) {
if let Some(path) = file_path(&tool_call_arguments(tc)) {
self.record_write(path);
}
}
name if name == NativeTool::EditFile.canonical_name() => {
if let Some(path) = file_path(&tc.arguments) {
if let Some(path) = file_path(&tool_call_arguments(tc)) {
self.record_edit(path);
}
}
name if name == NativeTool::ApplyPatch.canonical_name() => {
let content = match result.content.as_str() {
let output = tool_result_to_json(result);
let content = match output.as_str() {
Some(s) => s.to_string(),
None => result.content.to_string(),
None => output.to_string(),
};
for line in content.lines() {
let line = line.trim();
@ -107,6 +108,8 @@ impl FileTracker {
#[cfg(test)]
mod tests {
use fabro_types::tool_result_from_json;
use super::*;
#[test]
@ -137,14 +140,15 @@ mod tests {
#[test]
fn record_from_tool_calls_read_file() {
let mut tracker = FileTracker::default();
let tool_calls = vec![ToolCall::new(
let tool_calls = vec![ToolCall::function(
"tc1",
"read_file",
serde_json::json!({"file_path": "/tmp/foo.rs"}),
)];
let results = vec![ToolResult::success(
let results = vec![tool_result_from_json(
"tc1",
serde_json::json!("file contents"),
false,
)];
tracker.record_from_tool_calls(&tool_calls, &results);
assert_eq!(tracker.render(), "- /tmp/foo.rs (read)\n");
@ -153,12 +157,12 @@ mod tests {
#[test]
fn record_from_tool_calls_write_file() {
let mut tracker = FileTracker::default();
let tool_calls = vec![ToolCall::new(
let tool_calls = vec![ToolCall::function(
"tc1",
"write_file",
serde_json::json!({"file_path": "/tmp/bar.rs", "content": "hello"}),
)];
let results = vec![ToolResult::success("tc1", serde_json::json!("ok"))];
let results = vec![tool_result_from_json("tc1", serde_json::json!("ok"), false)];
tracker.record_from_tool_calls(&tool_calls, &results);
assert_eq!(tracker.render(), "- /tmp/bar.rs (written)\n");
}
@ -166,12 +170,12 @@ mod tests {
#[test]
fn record_from_tool_calls_edit_file() {
let mut tracker = FileTracker::default();
let tool_calls = vec![ToolCall::new(
let tool_calls = vec![ToolCall::function(
"tc1",
"edit_file",
serde_json::json!({"file_path": "/tmp/baz.rs"}),
)];
let results = vec![ToolResult::success("tc1", serde_json::json!("ok"))];
let results = vec![tool_result_from_json("tc1", serde_json::json!("ok"), false)];
tracker.record_from_tool_calls(&tool_calls, &results);
assert_eq!(tracker.render(), "- /tmp/baz.rs (edited)\n");
}
@ -180,17 +184,17 @@ mod tests {
fn record_from_kimi_tool_calls_uses_path_argument() {
let mut tracker = FileTracker::default();
let tool_calls = vec![
ToolCall::new("tc1", "Read", serde_json::json!({"path": "/tmp/a.rs"})),
ToolCall::new(
ToolCall::function("tc1", "Read", serde_json::json!({"path": "/tmp/a.rs"})),
ToolCall::function(
"tc2",
"Write",
serde_json::json!({"path": "/tmp/b.rs", "content": "x"}),
),
ToolCall::new("tc3", "Edit", serde_json::json!({"path": "/tmp/c.rs"})),
ToolCall::function("tc3", "Edit", serde_json::json!({"path": "/tmp/c.rs"})),
];
let results = ["tc1", "tc2", "tc3"]
.into_iter()
.map(|id| ToolResult::success(id, serde_json::json!("ok")))
.map(|id| tool_result_from_json(id, serde_json::json!("ok"), false))
.collect::<Vec<_>>();
tracker.record_from_tool_calls(&tool_calls, &results);
@ -204,12 +208,16 @@ mod tests {
#[test]
fn record_from_tool_calls_skips_errors() {
let mut tracker = FileTracker::default();
let tool_calls = vec![ToolCall::new(
let tool_calls = vec![ToolCall::function(
"tc1",
"read_file",
serde_json::json!({"file_path": "/tmp/missing.rs"}),
)];
let results = vec![ToolResult::error("tc1", "File not found")];
let results = vec![tool_result_from_json(
"tc1",
serde_json::Value::String("File not found".into()),
true,
)];
tracker.record_from_tool_calls(&tool_calls, &results);
assert!(tracker.is_empty());
}
@ -217,16 +225,17 @@ mod tests {
#[test]
fn record_from_tool_calls_apply_patch_added() {
let mut tracker = FileTracker::default();
let tool_calls = vec![ToolCall::new(
let tool_calls = vec![ToolCall::function(
"tc1",
"apply_patch",
serde_json::json!({"patch": "..."}),
)];
let results = vec![ToolResult::success(
let results = vec![tool_result_from_json(
"tc1",
serde_json::json!(
"Success. Updated the following files:\nA src/new.rs\nM src/old.rs\n"
),
false,
)];
tracker.record_from_tool_calls(&tool_calls, &results);
assert_eq!(
@ -250,14 +259,15 @@ mod tests {
#[test]
fn record_from_tool_calls_ignores_unknown_tools() {
let mut tracker = FileTracker::default();
let tool_calls = vec![ToolCall::new(
let tool_calls = vec![ToolCall::function(
"tc1",
"shell",
serde_json::json!({"command": "ls"}),
)];
let results = vec![ToolResult::success(
let results = vec![tool_result_from_json(
"tc1",
serde_json::json!("file1\nfile2"),
false,
)];
tracker.record_from_tool_calls(&tool_calls, &results);
assert!(tracker.is_empty());

View file

@ -1,7 +1,7 @@
use std::collections::HashSet;
use fabro_llm::types::{Message as LlmMessage, TokenCounts};
use fabro_types::SessionMessage;
use fabro_llm::reasoning;
use fabro_types::{Message as LlmMessage, SessionMessage, TokenCounts};
use crate::types::Message;
@ -73,7 +73,7 @@ impl History {
fn invalidate_preserved_usage(preserved: &mut [Message]) {
for turn in preserved {
if let Message::Assistant { usage, .. } = turn {
**usage = TokenCounts::default();
*usage = TokenCounts::default();
}
}
}
@ -87,7 +87,7 @@ impl History {
fn strip_opaque_provider_items(&mut self) {
for turn in &mut self.turns {
if let Message::Assistant { provider_parts, .. } = turn {
provider_parts.retain(|p| !p.is_opaque_openai());
provider_parts.retain(|p| !reasoning::is_opaque_openai(p));
}
}
}
@ -164,10 +164,22 @@ fn add_tool_result_call_ids<'a>(turns: &'a [Message], call_ids: &mut HashSet<&'a
mod tests {
use std::time::SystemTime;
use fabro_llm::types::{ContentPart, Role, ThinkingData, TokenCounts, ToolCall, ToolResult};
use fabro_llm::reasoning::OPENAI_REASONING_KIND;
use fabro_types::{
ContentPart, ReasoningContent, Role, TokenCounts, ToolCall, text_of, tool_result_from_json,
};
use super::*;
fn thinking(text: &str, signature: Option<&str>) -> ContentPart {
ContentPart::Reasoning(ReasoningContent {
text: text.into(),
signature: signature.map(str::to_string),
signature_origin: signature.map(|_| "anthropic".to_string()),
redacted: false,
})
}
#[test]
fn compact_replaces_old_turns_with_summary() {
let mut history = History::default();
@ -229,30 +241,34 @@ mod tests {
let call_id = format!("call_{index}");
history.push(Message::Assistant {
content: String::new(),
tool_calls: vec![ToolCall::new(
tool_calls: vec![ToolCall::function(
&call_id,
"read_file",
serde_json::json!({ "file_path": format!("{index}.txt") }),
)],
provider_parts: vec![],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: format!("resp_{index}"),
timestamp: SystemTime::now(),
});
history.push(Message::ToolResults {
results: vec![ToolResult::success(&call_id, serde_json::json!("ok"))],
results: vec![tool_result_from_json(
&call_id,
serde_json::json!("ok"),
false,
)],
timestamp: SystemTime::now(),
});
}
history.push(Message::Assistant {
content: String::new(),
tool_calls: vec![ToolCall::new(
tool_calls: vec![ToolCall::function(
"call_3",
"read_file",
serde_json::json!({ "file_path": "3.txt" }),
)],
provider_parts: vec![],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "resp_3".into(),
timestamp: SystemTime::now(),
});
@ -261,7 +277,7 @@ mod tests {
let messages = history.convert_to_messages();
let mut seen_tool_calls = Vec::new();
for message in messages {
for part in message.content {
for part in message.content().iter().cloned() {
match part {
ContentPart::ToolCall(tool_call) => seen_tool_calls.push(tool_call.id),
ContentPart::ToolResult(result) => assert!(
@ -280,14 +296,22 @@ mod tests {
let mut history = History::default();
history.push(Message::Assistant {
content: String::new(),
tool_calls: vec![ToolCall::new("call_1", "read_file", serde_json::json!({}))],
tool_calls: vec![ToolCall::function(
"call_1",
"read_file",
serde_json::json!({}),
)],
provider_parts: vec![],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
history.push(Message::ToolResults {
results: vec![ToolResult::success("call_1", serde_json::json!("ok"))],
results: vec![tool_result_from_json(
"call_1",
serde_json::json!("ok"),
false,
)],
timestamp: SystemTime::now(),
});
@ -308,8 +332,8 @@ mod tests {
}
history.compact(2, "[Context Summary]\nThis is a summary".into());
let messages = history.convert_to_messages();
assert_eq!(messages[0].role, Role::System);
assert!(messages[0].text().contains("[Context Summary]"));
assert_eq!(messages[0].role(), Role::System);
assert!(text_of(messages[0].content()).contains("[Context Summary]"));
}
#[test]
@ -328,8 +352,8 @@ mod tests {
});
let messages = history.convert_to_messages();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, Role::User);
assert_eq!(messages[0].text(), "Hello");
assert_eq!(messages[0].role(), Role::User);
assert_eq!(text_of(messages[0].content()), "Hello");
}
#[test]
@ -339,32 +363,32 @@ mod tests {
content: "Hi there".into(),
tool_calls: vec![],
provider_parts: vec![],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
let messages = history.convert_to_messages();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, Role::Assistant);
assert_eq!(messages[0].text(), "Hi there");
assert_eq!(messages[0].role(), Role::Assistant);
assert_eq!(text_of(messages[0].content()), "Hi there");
}
#[test]
fn assistant_turn_with_tool_calls() {
let mut history = History::default();
let tc = ToolCall::new("call_1", "read_file", serde_json::json!({"path": "foo.rs"}));
let tc = ToolCall::function("call_1", "read_file", serde_json::json!({"path": "foo.rs"}));
history.push(Message::Assistant {
content: "Let me read that".into(),
tool_calls: vec![tc],
provider_parts: vec![],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "resp_2".into(),
timestamp: SystemTime::now(),
});
let messages = history.convert_to_messages();
assert_eq!(messages[0].role, Role::Assistant);
assert_eq!(messages[0].role(), Role::Assistant);
let tool_call_parts: Vec<_> = messages[0]
.content
.content()
.iter()
.filter(|p| matches!(p, ContentPart::ToolCall(_)))
.collect();
@ -374,24 +398,20 @@ mod tests {
#[test]
fn assistant_turn_with_reasoning_in_provider_parts() {
let mut history = History::default();
let thinking = ContentPart::Thinking(ThinkingData {
text: "Let me think about this...".into(),
signature: None,
redacted: false,
});
let thinking = thinking("Let me think about this...", None);
history.push(Message::Assistant {
content: "The answer is 42".into(),
tool_calls: vec![],
provider_parts: vec![thinking],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "resp_3".into(),
timestamp: SystemTime::now(),
});
let messages = history.convert_to_messages();
let thinking_parts: Vec<_> = messages[0]
.content
.content()
.iter()
.filter(|p| matches!(p, ContentPart::Thinking(_)))
.filter(|p| matches!(p, ContentPart::Reasoning(_)))
.collect();
assert_eq!(thinking_parts.len(), 1);
}
@ -399,25 +419,21 @@ mod tests {
#[test]
fn thinking_with_signature_preserved_via_provider_parts() {
let mut history = History::default();
let thinking = ContentPart::Thinking(ThinkingData {
text: "Let me think...".into(),
signature: Some("sig_abc123".into()),
redacted: false,
});
let thinking = thinking("Let me think...", Some("sig_abc123"));
history.push(Message::Assistant {
content: "The answer".into(),
tool_calls: vec![],
provider_parts: vec![thinking],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "resp_4".into(),
timestamp: SystemTime::now(),
});
let messages = history.convert_to_messages();
let thinking_parts: Vec<_> = messages[0]
.content
.content()
.iter()
.filter_map(|p| match p {
ContentPart::Thinking(td) => Some(td),
ContentPart::Reasoning(td) => Some(td),
_ => None,
})
.collect();
@ -430,16 +446,16 @@ mod tests {
#[test]
fn assistant_turn_preserves_provider_parts() {
let mut history = History::default();
let reasoning_item = ContentPart::Other {
kind: ContentPart::OPENAI_REASONING.to_string(),
data: serde_json::json!({"type": "reasoning", "id": "rs_abc"}),
};
let tc = ToolCall::new("call_1", "search", serde_json::json!({}));
let reasoning_item = ContentPart::opaque(
OPENAI_REASONING_KIND,
serde_json::json!({"type": "reasoning", "id": "rs_abc"}),
);
let tc = ToolCall::function("call_1", "search", serde_json::json!({}));
history.push(Message::Assistant {
content: String::new(),
tool_calls: vec![tc],
provider_parts: vec![reasoning_item],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
@ -447,23 +463,27 @@ mod tests {
assert_eq!(messages.len(), 1);
// Provider parts come first, then tool calls
assert!(
matches!(&messages[0].content[0], ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_REASONING)
matches!(&messages[0].content()[0], ContentPart::Opaque { kind, .. } if kind == OPENAI_REASONING_KIND)
);
assert!(matches!(&messages[0].content[1], ContentPart::ToolCall(_)));
assert!(matches!(
&messages[0].content()[1],
ContentPart::ToolCall(_)
));
}
#[test]
fn tool_results_turn_maps_to_tool_message() {
let mut history = History::default();
let result = ToolResult::success("call_1", serde_json::json!("file contents here"));
let result =
tool_result_from_json("call_1", serde_json::json!("file contents here"), false);
history.push(Message::ToolResults {
results: vec![result],
timestamp: SystemTime::now(),
});
let messages = history.convert_to_messages();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, Role::Tool);
assert_eq!(messages[0].tool_call_id, Some("call_1".into()));
assert_eq!(messages[0].role(), Role::Tool);
assert_eq!(messages[0].tool_call_id(), Some("call_1"));
}
#[test]
@ -475,8 +495,8 @@ mod tests {
});
let messages = history.convert_to_messages();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, Role::System);
assert_eq!(messages[0].text(), "You are a coding assistant");
assert_eq!(messages[0].role(), Role::System);
assert_eq!(text_of(messages[0].content()), "You are a coding assistant");
}
#[test]
@ -488,15 +508,16 @@ mod tests {
});
let messages = history.convert_to_messages();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, Role::User);
assert_eq!(messages[0].text(), "Focus on the main task");
assert_eq!(messages[0].role(), Role::User);
assert_eq!(text_of(messages[0].content()), "Focus on the main task");
}
#[test]
fn session_message_roundtrip_preserves_runtime_history() {
let mut history = History::default();
let tool_call = ToolCall::new("call_1", "read_file", serde_json::json!({"path": "a.rs"}));
let tool_result = ToolResult::success("call_1", serde_json::json!("ok"));
let tool_call =
ToolCall::function("call_1", "read_file", serde_json::json!({"path": "a.rs"}));
let tool_result = tool_result_from_json("call_1", serde_json::json!("ok"), false);
history.push(Message::User {
content: "Read a file".into(),
timestamp: SystemTime::now(),
@ -505,11 +526,11 @@ mod tests {
content: "Reading".into(),
tool_calls: vec![tool_call],
provider_parts: vec![],
usage: Box::new(TokenCounts {
input_tokens: 10,
output_tokens: 3,
usage: TokenCounts {
input: 10,
output: 3,
..TokenCounts::default()
}),
},
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
@ -528,7 +549,7 @@ mod tests {
);
assert!(
matches!(&restored.turns()[1], Message::Assistant { content, tool_calls, usage, .. }
if content == "Reading" && tool_calls.len() == 1 && usage.input_tokens == 10)
if content == "Reading" && tool_calls.len() == 1 && usage.input == 10)
);
assert!(
matches!(&restored.turns()[2], Message::ToolResults { results, .. } if results.len() == 1)
@ -548,7 +569,7 @@ mod tests {
content: "Second".into(),
tool_calls: vec![],
provider_parts: vec![],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
@ -564,37 +585,34 @@ mod tests {
});
history.push(Message::Assistant {
content: "Hi".into(),
tool_calls: vec![ToolCall::new(
tool_calls: vec![ToolCall::function(
"c1",
"shell",
serde_json::json!({"cmd": "ls"}),
)],
provider_parts: vec![ContentPart::Thinking(ThinkingData {
text: "thinking...".into(),
signature: None,
redacted: false,
})],
usage: Box::new(TokenCounts {
input_tokens: 10,
output_tokens: 5,
provider_parts: vec![thinking("thinking...", None)],
usage: TokenCounts {
input: 10,
output: 5,
..Default::default()
}),
},
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
history.push(Message::ToolResults {
results: vec![ToolResult::success(
results: vec![tool_result_from_json(
"c1",
serde_json::json!("file1.rs\nfile2.rs"),
false,
)],
timestamp: SystemTime::now(),
});
let messages = history.convert_to_messages();
assert_eq!(messages.len(), 3);
assert_eq!(messages[0].role, Role::User);
assert_eq!(messages[1].role, Role::Assistant);
assert_eq!(messages[2].role, Role::Tool);
assert_eq!(messages[0].role(), Role::User);
assert_eq!(messages[1].role(), Role::Assistant);
assert_eq!(messages[2].role(), Role::Tool);
}
#[test]
@ -608,16 +626,16 @@ mod tests {
content: "recent msg".into(),
timestamp: SystemTime::now(),
});
let reasoning = ContentPart::Other {
kind: ContentPart::OPENAI_REASONING.into(),
data: serde_json::json!({"type": "reasoning", "id": "rs_abc"}),
};
let tc = ToolCall::new("call_1", "search", serde_json::json!({}));
let reasoning = ContentPart::opaque(
OPENAI_REASONING_KIND,
serde_json::json!({"type": "reasoning", "id": "rs_abc"}),
);
let tc = ToolCall::function("call_1", "search", serde_json::json!({}));
history.push(Message::Assistant {
content: "response".into(),
tool_calls: vec![tc],
provider_parts: vec![reasoning],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
@ -656,16 +674,12 @@ mod tests {
content: "recent msg".into(),
timestamp: SystemTime::now(),
});
let thinking = ContentPart::Thinking(ThinkingData {
text: "deep thought".into(),
signature: Some("sig_xyz".into()),
redacted: false,
});
let thinking = thinking("deep thought", Some("sig_xyz"));
history.push(Message::Assistant {
content: "answer".into(),
tool_calls: vec![],
provider_parts: vec![thinking],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
@ -681,7 +695,7 @@ mod tests {
1,
"thinking block should be preserved"
);
assert!(matches!(&provider_parts[0], ContentPart::Thinking(_)));
assert!(matches!(&provider_parts[0], ContentPart::Reasoning(_)));
} else {
panic!("expected Assistant turn");
}
@ -694,23 +708,20 @@ mod tests {
content: "old msg".into(),
timestamp: SystemTime::now(),
});
let tool_call = ToolCall::new("call_1", "search", serde_json::json!({"query": "fabro"}));
let thinking = ContentPart::Thinking(ThinkingData {
text: "deep thought".into(),
signature: Some("sig_xyz".into()),
redacted: false,
});
let tool_call =
ToolCall::function("call_1", "search", serde_json::json!({"query": "fabro"}));
let thinking = thinking("deep thought", Some("sig_xyz"));
history.push(Message::Assistant {
content: "answer".into(),
tool_calls: vec![tool_call.clone()],
provider_parts: vec![thinking.clone()],
usage: Box::new(TokenCounts {
input_tokens: 10,
output_tokens: 20,
reasoning_tokens: 30,
cache_read_tokens: 40,
cache_write_tokens: 50,
}),
usage: TokenCounts {
input: 10,
output: 20,
reasoning: 30,
cache_read: 40,
cache_write: 50,
},
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
@ -735,7 +746,7 @@ mod tests {
assert_eq!(tool_calls, &[tool_call]);
assert_eq!(provider_parts, &[thinking]);
assert_eq!(response_id, "resp_1");
assert_eq!(**usage, TokenCounts::default());
assert_eq!(*usage, TokenCounts::default());
} else {
panic!("expected Assistant turn");
}
@ -753,11 +764,11 @@ mod tests {
history.push(Message::Assistant {
content: format!("response {i}"),
tool_calls: vec![],
provider_parts: vec![ContentPart::Other {
kind: ContentPart::OPENAI_REASONING.into(),
data: serde_json::json!({"type": "reasoning", "id": format!("rs_{i}")}),
}],
usage: Box::new(TokenCounts::default()),
provider_parts: vec![ContentPart::opaque(
OPENAI_REASONING_KIND,
serde_json::json!({"type": "reasoning", "id": format!("rs_{i}")}),
)],
usage: TokenCounts::default(),
response_id: format!("resp_{i}"),
timestamp: SystemTime::now(),
});
@ -786,7 +797,7 @@ mod tests {
content: "reply".into(),
tool_calls: vec![],
provider_parts: vec![],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "r1".into(),
timestamp: SystemTime::now(),
},
@ -831,7 +842,7 @@ mod tests {
content: "assistant msg".into(),
tool_calls: vec![],
provider_parts: vec![],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "r1".into(),
timestamp: SystemTime::now(),
});

View file

@ -76,7 +76,7 @@ pub use todo_tools::{
make_todo_list_tool, make_update_plan_tool,
};
pub use tool_permissions::canonical_tool_name;
pub use tool_registry::{AgentEventEmitter, ToolRegistry};
pub use tool_registry::{AgentEventEmitter, ToolDefinitionExt, ToolRegistry};
pub use tools::{
WebFetchSummarizer, make_edit_file_tool, make_glob_tool, make_grep_tool, make_read_file_tool,
make_shell_tool, make_shell_tool_with_options, make_write_file_tool, register_core_tools,

View file

@ -1,6 +1,8 @@
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use fabro_types::tool_call_arguments;
use crate::history::History;
use crate::types::Message;
@ -18,7 +20,7 @@ fn extract_signatures_from_assistant(turn: &Message) -> Vec<u64> {
};
tool_calls
.iter()
.map(|tc| tool_call_signature(&tc.name, &tc.arguments))
.map(|tc| tool_call_signature(&tc.name, &tool_call_arguments(tc)))
.collect()
}
@ -97,16 +99,16 @@ fn is_repeating_pattern(signatures: &[u64], pattern_len: usize) -> bool {
mod tests {
use std::time::SystemTime;
use fabro_llm::types::{TokenCounts, ToolCall};
use fabro_types::{TokenCounts, ToolCall};
use super::*;
fn assistant_with_tool(name: &str, args: serde_json::Value) -> Message {
Message::Assistant {
content: String::new(),
tool_calls: vec![ToolCall::new("call_1", name, args)],
tool_calls: vec![ToolCall::function("call_1", name, args)],
provider_parts: vec![],
usage: Box::new(TokenCounts::default()),
usage: TokenCounts::default(),
response_id: "resp".into(),
timestamp: SystemTime::now(),
}

View file

@ -1,7 +1,7 @@
use std::sync::Arc;
use fabro_llm::types::ToolDefinition;
use fabro_mcp::connection_manager::{McpConnectionManager, call_result_to_string};
use fabro_types::ToolDefinition;
use crate::tool_registry::{RegisteredTool, ToolSource};
@ -18,11 +18,11 @@ pub fn make_mcp_tools(manager: &Arc<McpConnectionManager>) -> Vec<RegisteredTool
let original_name = info.original_tool_name.clone();
RegisteredTool {
definition: ToolDefinition {
name: qualified_name.clone(),
description: info.description.clone(),
parameters: info.input_schema.clone(),
},
definition: ToolDefinition::function(
qualified_name.clone(),
info.description.clone(),
info.input_schema.clone(),
),
executor: Arc::new(move |args, _ctx| {
let mgr = Arc::clone(&mgr);
let name = name.clone();

View file

@ -1,6 +1,6 @@
use std::collections::HashSet;
use fabro_model::AgentProfileKind;
use fabro_types::AgentProfileKind;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
@ -34,7 +34,7 @@ pub async fn discover_memory(
AgentProfileKind::Anthropic | AgentProfileKind::Claude5 => {
vec!["AGENTS.md", "CLAUDE.md"]
}
AgentProfileKind::OpenAi | AgentProfileKind::Gpt56 => {
AgentProfileKind::OpenAi | AgentProfileKind::Gpt56 | AgentProfileKind::Gpt6 => {
vec!["AGENTS.md", ".codex/instructions.md"]
}
AgentProfileKind::Gemini => vec!["AGENTS.md", "GEMINI.md"],

View file

@ -1,6 +1,7 @@
use std::sync::Arc;
use fabro_model::{AgentProfileKind, Catalog, ProviderId};
use fabro_llm::lithos_catalog::Catalog;
use fabro_types::{AgentProfileKind, ProviderId, provider_ids};
use super::EnvContext;
use crate::agent_profile::AgentProfile;
@ -47,7 +48,7 @@ impl AnthropicProfile {
Self {
base: BaseProfile {
profile_kind: AgentProfileKind::Anthropic,
provider_id: ProviderId::anthropic(),
provider_id: provider_ids::anthropic(),
model: model.into(),
catalog: None,
registry,
@ -101,19 +102,21 @@ impl AgentProfile for AnthropicProfile {
mod tests {
use std::sync::Arc;
use fabro_llm::test_support::test_catalog as fabro_test_catalog;
use super::*;
use crate::subagent::{SessionFactory, SubAgentSupervisor};
use crate::test_support::MockSandbox;
fn test_catalog() -> Arc<Catalog> {
Arc::new(Catalog::from_builtin().unwrap())
Arc::new(fabro_test_catalog())
}
#[test]
fn anthropic_profile_identity() {
let profile = AnthropicProfile::new("claude-sonnet-4-20250514");
assert_eq!(profile.profile_kind(), AgentProfileKind::Anthropic);
assert_eq!(profile.provider_id(), ProviderId::anthropic());
assert_eq!(profile.provider_id(), provider_ids::anthropic());
assert_eq!(profile.model(), "claude-sonnet-4-20250514");
}
@ -122,7 +125,7 @@ mod tests {
let profile = AnthropicProfile::new("claude-opus-4-6").with_catalog(test_catalog());
assert_eq!(profile.context_window_size(), 1_000_000);
let profile = AnthropicProfile::new("claude-sonnet-4-6").with_catalog(test_catalog());
let profile = AnthropicProfile::new("claude-sonnet-4.5").with_catalog(test_catalog());
assert_eq!(profile.context_window_size(), 200_000);
}

View file

@ -2,7 +2,8 @@
use std::sync::Arc;
use fabro_model::{AgentProfileKind, Catalog, ProviderId};
use fabro_llm::lithos_catalog::Catalog;
use fabro_types::{AgentProfileKind, ProviderId, provider_ids};
use super::EnvContext;
use crate::agent_profile::AgentProfile;
@ -64,7 +65,7 @@ impl Claude5Profile {
Self {
base: BaseProfile {
profile_kind: AgentProfileKind::Claude5,
provider_id: ProviderId::anthropic(),
provider_id: provider_ids::anthropic(),
model: model.into(),
catalog: None,
registry,
@ -165,7 +166,7 @@ mod tests {
fn profile_identity() {
let profile = Claude5Profile::new("claude-fable-5");
assert_eq!(profile.profile_kind(), AgentProfileKind::Claude5);
assert_eq!(profile.provider_id(), ProviderId::anthropic());
assert_eq!(profile.provider_id(), provider_ids::anthropic());
assert_eq!(profile.model(), "claude-fable-5");
}

View file

@ -7,7 +7,7 @@
use std::sync::Arc;
use std::time::Duration;
use fabro_llm::types::ToolDefinition;
use fabro_types::{ToolDefinition, ToolDefinitionKind};
use fabro_util::error as util_error;
use serde_json::Value;
use tokio::time;
@ -26,19 +26,16 @@ fn definition(
description: impl Into<String>,
parameters: Value,
) -> ToolDefinition {
ToolDefinition {
name: tool.canonical_name().to_string(),
description: description.into(),
parameters,
}
ToolDefinition::function(tool.canonical_name(), description, parameters)
}
/// Reject unknown top-level fields while retaining a shared executor.
#[must_use]
pub(crate) fn strict_object_tool(mut tool: RegisteredTool) -> RegisteredTool {
let object = tool
.definition
.parameters
let ToolDefinitionKind::Function { input_schema } = &mut tool.definition.kind else {
panic!("native JSON-schema tools should use a function definition");
};
let object = input_schema
.as_object_mut()
.expect("native JSON-schema tools should use an object schema");
object.insert("additionalProperties".to_string(), Value::Bool(false));
@ -469,9 +466,10 @@ mod tests {
use crate::todo_tools::{
make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool,
};
use crate::tool_registry::ToolDefinitionExt;
fn property_names(tool: &RegisteredTool) -> BTreeSet<&str> {
tool.definition.parameters["properties"]
tool.definition.parameters()["properties"]
.as_object()
.unwrap()
.keys()
@ -480,7 +478,7 @@ mod tests {
}
fn required_names(tool: &RegisteredTool) -> BTreeSet<&str> {
tool.definition.parameters["required"]
tool.definition.parameters()["required"]
.as_array()
.map(|required| {
required
@ -492,9 +490,9 @@ mod tests {
}
fn assert_schema(tool: &RegisteredTool, properties: &[&str], required: &[&str]) {
assert_eq!(tool.definition.parameters["type"], "object");
assert_eq!(tool.definition.parameters()["type"], "object");
assert_eq!(
tool.definition.parameters["additionalProperties"],
tool.definition.parameters()["additionalProperties"],
Value::Bool(false)
);
assert_eq!(property_names(tool), properties.iter().copied().collect());
@ -515,7 +513,7 @@ mod tests {
#[test]
fn core_adapter_schemas_match_the_claude5_contract() {
let options = NativeToolOptions::for_profile(fabro_model::AgentProfileKind::Claude5);
let options = NativeToolOptions::for_profile(fabro_types::AgentProfileKind::Claude5);
assert_schema(&make_read_tool(), &["file_path", "limit", "offset"], &[
"file_path",
]);
@ -531,7 +529,7 @@ mod tests {
let bash = make_bash_tool(&options);
assert_schema(&bash, &["command", "description", "timeout"], &["command"]);
assert_eq!(
bash.definition.parameters["properties"]["timeout"]["maximum"],
bash.definition.parameters()["properties"]["timeout"]["maximum"],
600_000
);
assert_schema(&make_web_fetch_tool(None), &["prompt", "url"], &[

View file

@ -1,6 +1,7 @@
use std::sync::Arc;
use fabro_model::{AgentProfileKind, Catalog, ProviderId};
use fabro_llm::lithos_catalog::Catalog;
use fabro_types::{AgentProfileKind, ProviderId, provider_ids};
use super::EnvContext;
use crate::agent_profile::AgentProfile;
@ -41,7 +42,7 @@ impl GeminiProfile {
Self {
base: BaseProfile {
profile_kind: AgentProfileKind::Gemini,
provider_id: ProviderId::gemini(),
provider_id: provider_ids::gemini(),
model: model.into(),
catalog: None,
registry,
@ -93,19 +94,21 @@ impl AgentProfile for GeminiProfile {
mod tests {
use std::sync::Arc;
use fabro_llm::test_support::test_catalog as fabro_test_catalog;
use super::*;
use crate::subagent::{SessionFactory, SubAgentSupervisor};
use crate::test_support::MockSandbox;
fn test_catalog() -> Arc<Catalog> {
Arc::new(Catalog::from_builtin().unwrap())
Arc::new(fabro_test_catalog())
}
#[test]
fn gemini_profile_identity() {
let profile = GeminiProfile::new("gemini-2.0-flash");
assert_eq!(profile.profile_kind(), AgentProfileKind::Gemini);
assert_eq!(profile.provider_id(), ProviderId::gemini());
assert_eq!(profile.provider_id(), provider_ids::gemini());
assert_eq!(profile.model(), "gemini-2.0-flash");
}

View file

@ -16,8 +16,8 @@
use std::sync::Arc;
use fabro_llm::types::ToolDefinition;
use fabro_model::{AgentProfileKind, Catalog, ProviderId};
use fabro_llm::lithos_catalog::Catalog;
use fabro_types::{AgentProfileKind, ProviderId, ToolDefinition, provider_ids};
use serde_json::Value;
use super::EnvContext;
@ -69,7 +69,7 @@ impl Gpt56Profile {
Self {
base: BaseProfile {
profile_kind: AgentProfileKind::Gpt56,
provider_id: ProviderId::openai(),
provider_id: provider_ids::openai(),
model: model.into(),
catalog: None,
registry,
@ -137,12 +137,12 @@ fn make_shell_command_tool(options: &NativeToolOptions) -> RegisteredTool {
);
RegisteredTool {
definition: ToolDefinition {
definition: ToolDefinition::function(
// Supply the canonical identity; registry insertion rewrites the
// stored and wire name to `shell_command`.
name: NativeTool::Shell.canonical_name().to_string(),
NativeTool::Shell.canonical_name(),
description,
parameters: serde_json::json!({
serde_json::json!({
"type": "object",
"properties": {
"command": {
@ -162,7 +162,7 @@ fn make_shell_command_tool(options: &NativeToolOptions) -> RegisteredTool {
},
"required": ["command"]
}),
},
),
executor: Arc::new(move |args, ctx| {
Box::pin(async move {
let command = tools::required_str(&args, "command")?;
@ -222,21 +222,25 @@ impl AgentProfile for Gpt56Profile {
mod tests {
use std::sync::Arc;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_llm::catalog;
use fabro_llm::test_support::{test_catalog as fabro_test_catalog, test_catalog_with_overlay};
use super::*;
use crate::subagent::{SessionFactory, SubAgentSupervisor};
use crate::test_support::MockSandbox;
use crate::tool_registry::ToolDefinitionExt;
fn test_catalog() -> Arc<Catalog> {
Arc::new(Catalog::from_builtin().unwrap())
Arc::new(fabro_test_catalog())
}
/// OpenRouter ships disabled in the built-in catalog.
fn catalog_with_openrouter() -> Arc<Catalog> {
let overrides: LlmCatalogSettings =
toml::from_str("[providers.openrouter]\nenabled = true\n").unwrap();
Arc::new(Catalog::from_builtin_with_overrides(&overrides).unwrap())
Arc::new(test_catalog_with_overlay(
"[providers.openrouter]
enabled = true
",
))
}
fn prompt(profile: &Gpt56Profile) -> String {
@ -248,7 +252,7 @@ mod tests {
fn gpt56_profile_identity() {
let profile = Gpt56Profile::new("gpt-5.6-sol");
assert_eq!(profile.profile_kind(), AgentProfileKind::Gpt56);
assert_eq!(profile.provider_id(), ProviderId::openai());
assert_eq!(profile.provider_id(), provider_ids::openai());
assert_eq!(profile.model(), "gpt-5.6-sol");
}
@ -285,14 +289,14 @@ mod tests {
fn shell_command_accepts_a_workdir() {
let profile = Gpt56Profile::new("gpt-5.6-sol");
let shell = profile.tool_registry().get("shell_command").unwrap();
assert_eq!(shell.definition.parameters["type"], "object");
assert!(shell.definition.parameters["properties"]["workdir"].is_object());
assert_eq!(shell.definition.parameters()["type"], "object");
assert!(shell.definition.parameters()["properties"]["workdir"].is_object());
assert_eq!(
shell.definition.parameters["required"],
shell.definition.parameters()["required"],
serde_json::json!(["command"])
);
assert_eq!(
shell.definition.parameters["properties"]["command"]["description"],
shell.definition.parameters()["properties"]["command"]["description"],
"Bash source to evaluate, run by a non-login Bash shell."
);
}
@ -315,7 +319,7 @@ mod tests {
"tool '{}' must not be a custom definition on an openai_compatible route",
definition.name
);
assert_eq!(definition.parameters["type"], "object");
assert_eq!(definition.parameters()["type"], "object");
}
}
@ -324,7 +328,7 @@ mod tests {
#[test]
fn shell_description_names_the_editor_actually_registered() {
let direct =
Gpt56Profile::new("gpt-5.6-sol").with_route(ProviderId::openai(), test_catalog());
Gpt56Profile::new("gpt-5.6-sol").with_route(provider_ids::openai(), test_catalog());
let shell = direct.tool_registry().get("shell_command").unwrap();
assert!(shell.definition.description.contains("`apply_patch`"));
assert!(!shell.definition.description.contains("`edit_file`"));
@ -346,7 +350,7 @@ mod tests {
assert!(!rendered.contains("*** Begin Patch"));
let direct =
Gpt56Profile::new("gpt-5.6-sol").with_route(ProviderId::openai(), test_catalog());
Gpt56Profile::new("gpt-5.6-sol").with_route(provider_ids::openai(), test_catalog());
let rendered = prompt(&direct);
assert!(rendered.contains("Use `apply_patch` for local file edits"));
assert!(rendered.contains("*** Begin Patch"));
@ -424,7 +428,7 @@ mod tests {
#[test]
fn provider_prompt_uses_catalog_display_name() {
let direct =
Gpt56Profile::new("gpt-5.6-sol").with_route(ProviderId::openai(), test_catalog());
Gpt56Profile::new("gpt-5.6-sol").with_route(provider_ids::openai(), test_catalog());
assert!(prompt(&direct).contains("powered by OpenAI"));
let gateway = Gpt56Profile::new("gpt-5.6-sol")
@ -443,14 +447,14 @@ mod tests {
let provider_id = ProviderId::new(provider);
for model in ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
assert_eq!(
catalog.effective_agent_profile(&provider_id, Some(model)),
catalog::agent_profile(&catalog, provider_id.as_str(), Some(model)),
Some(AgentProfileKind::Gpt56),
"{provider}/{model} should use the gpt56 profile"
);
}
for model in ["gpt-5.5", "gpt-5.4"] {
assert_eq!(
catalog.effective_agent_profile(&provider_id, Some(model)),
catalog::agent_profile(&catalog, provider_id.as_str(), Some(model)),
Some(AgentProfileKind::OpenAi),
"{provider}/{model} should keep the openai profile"
);
@ -461,7 +465,7 @@ mod tests {
#[test]
fn catalog_reports_the_5_6_context_window() {
let profile =
Gpt56Profile::new("gpt-5.6-sol").with_route(ProviderId::openai(), test_catalog());
assert_eq!(profile.context_window_size(), 272_000);
Gpt56Profile::new("gpt-5.6-sol").with_route(provider_ids::openai(), test_catalog());
assert_eq!(profile.context_window_size(), 1_050_000);
}
}

View file

@ -1,6 +1,7 @@
use std::sync::Arc;
use fabro_model::{AgentProfileKind, Catalog, ProviderId};
use fabro_llm::lithos_catalog::Catalog;
use fabro_types::{AgentProfileKind, ProviderId};
use super::EnvContext;
use crate::agent_profile::AgentProfile;
@ -152,7 +153,8 @@ impl AgentProfile for KimiProfile {
#[cfg(test)]
mod tests {
use fabro_model::catalog::LlmCatalogSettings;
use fabro_llm::catalog;
use fabro_llm::test_support::{test_catalog as fabro_test_catalog, test_catalog_with_overlay};
use fabro_types::AgentToolCategory;
use super::*;
@ -160,17 +162,20 @@ mod tests {
use crate::subagent::{SessionFactory, SubAgentSupervisor};
use crate::test_support::MockSandbox;
use crate::tool_permissions::{known_tool_category, tool_category};
use crate::tool_registry::ToolDefinitionExt;
fn catalog() -> Arc<Catalog> {
Arc::new(Catalog::from_builtin().unwrap())
Arc::new(fabro_test_catalog())
}
/// OpenRouter ships disabled, so an operator opts in before its models are
/// selectable. Enable it the way they would, to observe gateway routing.
fn catalog_with_openrouter() -> Arc<Catalog> {
let overrides: LlmCatalogSettings =
toml::from_str("[providers.openrouter]\nenabled = true\n").unwrap();
Arc::new(Catalog::from_builtin_with_overrides(&overrides).unwrap())
Arc::new(test_catalog_with_overlay(
"[providers.openrouter]
enabled = true
",
))
}
/// Kimi models must resolve to the Kimi profile whether they are reached
@ -184,7 +189,7 @@ mod tests {
(catalog_with_openrouter(), "openrouter", "kimi-k2.6"),
] {
assert_eq!(
catalog.effective_agent_profile(&ProviderId::new(provider), Some(model)),
catalog::agent_profile(&catalog, provider, Some(model)),
Some(AgentProfileKind::Kimi),
"{provider}/{model} should use the Kimi profile"
);
@ -199,8 +204,7 @@ mod tests {
// Deliberately not a GPT-5.6 model: those carry their own per-model
// profile override, so they would not show that the provider default
// is what applies here.
let profile =
catalog.effective_agent_profile(&ProviderId::new("openrouter"), Some("gpt-5.4"));
let profile = catalog::agent_profile(&catalog, "openrouter", Some("gpt-5.4"));
assert_eq!(profile, Some(AgentProfileKind::OpenAi));
}
@ -273,7 +277,7 @@ mod tests {
.get("Skill")
.unwrap()
.definition
.parameters;
.parameters();
assert!(skill_parameters["properties"].get("skill").is_some());
assert!(skill_parameters["properties"].get("args").is_some());
assert!(skill_parameters["properties"].get("skill_name").is_none());
@ -352,7 +356,7 @@ mod tests {
.get("Edit")
.unwrap()
.definition
.parameters;
.parameters();
assert!(parameters["properties"].get("path").is_some());
assert!(parameters["properties"].get("file_path").is_none());

View file

@ -22,7 +22,7 @@ use std::fmt::Write as _;
use std::str::FromStr;
use std::sync::Arc;
use fabro_llm::types::ToolDefinition;
use fabro_types::ToolDefinition;
use serde_json::Value;
use strum::EnumString;
@ -39,13 +39,9 @@ const MAX_GREP_RESULTS: usize = 2000;
const MAX_GREP_MATCHES_SCANNED: usize = 20_000;
fn definition(tool: NativeTool, description: &str, parameters: Value) -> ToolDefinition {
ToolDefinition {
// Supply the canonical identity; registry insertion rewrites the
// stored and wire name for the active vocabulary.
name: tool.canonical_name().to_string(),
description: description.to_string(),
parameters,
}
// Supply the canonical identity; registry insertion rewrites the
// stored and wire name for the active vocabulary.
ToolDefinition::function(tool.canonical_name(), description, parameters)
}
/// `Bash`, taking `timeout` in seconds and an optional `cwd`.
@ -377,7 +373,7 @@ mod tests {
use super::*;
use crate::sandbox::{ExecResult, Sandbox};
use crate::test_support::{MockSandbox, MutableMockSandbox};
use crate::tool_registry::ToolContext;
use crate::tool_registry::{ToolContext, ToolDefinitionExt};
fn ctx(env: Arc<dyn Sandbox>) -> ToolContext {
ToolContext {
@ -523,12 +519,12 @@ mod tests {
assert_eq!(env.read_file_text("/f.txt").await.unwrap(), "after");
assert!(
tool.definition.parameters["properties"]
tool.definition.parameters()["properties"]
.get("path")
.is_some()
);
assert!(
tool.definition.parameters["properties"]
tool.definition.parameters()["properties"]
.get("file_path")
.is_none()
);
@ -645,7 +641,8 @@ mod tests {
#[test]
fn grep_schema_uses_kimi_code_modes_and_flags() {
let parameters = make_kimi_grep_tool().definition.parameters;
let tool = make_kimi_grep_tool();
let parameters = tool.definition.parameters();
assert_eq!(
parameters["properties"]["output_mode"]["enum"],
json!(["content", "files_with_matches", "count_matches"])
@ -659,7 +656,7 @@ mod tests {
#[test]
fn bash_schema_states_seconds_and_quotes_real_limits() {
let tool = make_kimi_bash_tool(60_000, 600_000);
let params = &tool.definition.parameters;
let params = &tool.definition.parameters();
let timeout = params["properties"]["timeout"]["description"]
.as_str()
.unwrap();

View file

@ -1,7 +1,10 @@
use std::collections::HashMap;
use std::sync::Arc;
use fabro_model::{AgentProfileKind, Catalog, CodecKind, ProviderId};
use fabro_llm::lithos_catalog::Catalog;
#[cfg(test)]
use fabro_types::provider_ids;
use fabro_types::{AgentProfileKind, ProviderId};
pub mod anthropic;
pub mod claude5;
@ -101,7 +104,7 @@ impl AgentProfileBuilder {
/// `web_fetch` discard it instead of retaining an unused LLM client.
#[must_use]
pub fn with_web_fetch_summarizer(mut self, summarizer: Option<WebFetchSummarizer>) -> Self {
if self.profile_kind != AgentProfileKind::Gpt56 {
if !self.profile_kind.uses_codex_core_tools() {
self.summarizer = summarizer;
}
self
@ -112,7 +115,7 @@ impl AgentProfileBuilder {
let model = self.model.as_str();
let deps = ProfileDeps {
options: self.native_tool_options.clone(),
summarizer: if self.profile_kind == AgentProfileKind::Gpt56 {
summarizer: if self.profile_kind.uses_codex_core_tools() {
None
} else {
self.summarizer.clone()
@ -144,7 +147,7 @@ impl AgentProfileBuilder {
.with_provider_id(self.provider_id.clone())
.with_catalog(Arc::clone(&self.catalog)),
),
AgentProfileKind::Gpt56 => Box::new(
AgentProfileKind::Gpt56 | AgentProfileKind::Gpt6 => Box::new(
Gpt56Profile::with_native_tools(model, &deps)
.with_route(self.provider_id.clone(), Arc::clone(&self.catalog)),
),
@ -169,9 +172,12 @@ pub(crate) enum FileEditToolKind {
EditFile,
}
/// The lithos codec that carries freeform (custom) tool definitions.
pub(crate) const OPENAI_RESPONSES_CODEC: &str = "openai-responses";
impl FileEditToolKind {
pub(crate) fn for_codec(codec: CodecKind) -> Self {
if codec == CodecKind::OpenAiResponses {
pub(crate) fn for_codec(codec: &str) -> Self {
if codec == OPENAI_RESPONSES_CODEC {
Self::ApplyPatch
} else {
Self::EditFile
@ -210,11 +216,11 @@ impl FileEditToolKind {
/// trait defaults: there is no sensible default for a profile that has no base.
macro_rules! impl_base_profile_accessors {
() => {
fn profile_kind(&self) -> ::fabro_model::AgentProfileKind {
fn profile_kind(&self) -> ::fabro_types::AgentProfileKind {
self.base.profile_kind
}
fn provider_id(&self) -> ::fabro_model::ProviderId {
fn provider_id(&self) -> ::fabro_types::ProviderId {
self.base.provider_id.clone()
}
@ -222,8 +228,8 @@ macro_rules! impl_base_profile_accessors {
&self.base.model
}
fn catalog(&self) -> Option<&::fabro_model::Catalog> {
self.base.catalog.as_deref()
fn catalog(&self) -> Option<&::std::sync::Arc<::fabro_llm::lithos_catalog::Catalog>> {
self.base.catalog.as_ref()
}
fn tool_registry(&self) -> &$crate::tool_registry::ToolRegistry {
@ -259,10 +265,10 @@ impl BaseProfile {
fn provider_display_name(&self) -> String {
self.catalog
.as_ref()
.and_then(|catalog| catalog.provider(&self.provider_id))
.and_then(|catalog| catalog.provider(self.provider_id.as_str()).ok())
.map_or_else(
|| self.provider_id.display_name(),
|provider| provider.display_name.clone(),
|| self.provider_id.to_string(),
|provider| provider.display_name().to_string(),
)
}
@ -274,11 +280,9 @@ impl BaseProfile {
///
/// Returns the newly selected editor when the registry changed.
fn configure_file_edit_tool(&mut self) -> Option<FileEditToolKind> {
let codec = self
.catalog
.as_ref()?
.effective_codec(&self.provider_id, Some(&self.model))?;
let desired = FileEditToolKind::for_codec(codec);
let catalog = self.catalog.as_ref()?;
let provider = catalog.provider(self.provider_id.as_str()).ok()?;
let desired = FileEditToolKind::for_codec(provider.codec().as_str());
if self.file_edit_tool() == Some(desired) {
return None;
}
@ -451,8 +455,8 @@ pub fn build_env_context_block_with(env: &dyn Sandbox, ctx: &EnvContext) -> Stri
#[cfg(test)]
mod tests {
use fabro_llm::types::ToolDefinition;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_llm::test_support::{test_catalog, test_catalog_with_overlay};
use fabro_types::ToolDefinition;
use tokio_util::sync::CancellationToken;
use super::*;
@ -461,6 +465,10 @@ mod tests {
use crate::test_support::MockSandbox;
use crate::tool_registry::ToolContext;
/// OpenRouter ships disabled, so an operator opts in before its models are
/// selectable.
const OPENROUTER_ENABLED: &str = "[providers.openrouter]\nenabled = true\n";
fn native_tool_options(
profile_kind: AgentProfileKind,
has_web_search: bool,
@ -536,21 +544,17 @@ mod tests {
fn gpt56_edit_file_profile(has_web_search: bool) -> Gpt56Profile {
let options = native_tool_options(AgentProfileKind::Gpt56, has_web_search);
let deps = ProfileDeps::standalone(options);
let overrides: LlmCatalogSettings =
toml::from_str("[providers.openrouter]\nenabled = true\n").unwrap();
Gpt56Profile::with_native_tools("gpt-5.6-sol", &deps).with_route(
ProviderId::new("openrouter"),
Arc::new(Catalog::from_builtin_with_overrides(&overrides).unwrap()),
Arc::new(test_catalog_with_overlay(OPENROUTER_ENABLED)),
)
}
fn openai_edit_file_profile(has_web_search: bool) -> OpenAiProfile {
let options = native_tool_options(AgentProfileKind::OpenAi, has_web_search);
let deps = ProfileDeps::standalone(options);
OpenAiProfile::with_native_tools("kimi-k2.5", &deps).with_route(
ProviderId::new("moonshot"),
Arc::new(Catalog::from_builtin().unwrap()),
)
OpenAiProfile::with_native_tools("kimi-k2.5", &deps)
.with_route(ProviderId::new("moonshot"), Arc::new(test_catalog()))
}
/// Profiles using fabro's native tool vocabulary get the same `shell`
@ -575,7 +579,7 @@ mod tests {
.collect();
for definition in &definitions {
assert_eq!(definition.parameters, definitions[0].parameters);
assert_eq!(definition.kind, definitions[0].kind);
assert_eq!(definition.description, definitions[0].description);
assert!(
definition.description.contains("Bash"),
@ -694,30 +698,34 @@ mod tests {
#[test]
fn profile_builder_keeps_tool_availability_and_prompt_guidance_in_sync() {
let catalog = Arc::new(Catalog::from_builtin().unwrap());
let catalog = Arc::new(test_catalog());
let env = MockSandbox::linux();
let cases = [
(
AgentProfileKind::OpenAi,
ProviderId::openai(),
provider_ids::openai(),
"gpt-5.4-mini",
),
(
AgentProfileKind::Anthropic,
ProviderId::anthropic(),
provider_ids::anthropic(),
"claude-haiku-4-5",
),
(
AgentProfileKind::Gemini,
ProviderId::gemini(),
provider_ids::gemini(),
"gemini-3-flash-preview",
),
(
AgentProfileKind::Claude5,
ProviderId::anthropic(),
provider_ids::anthropic(),
"claude-sonnet-5",
),
(AgentProfileKind::Gpt56, ProviderId::openai(), "gpt-5.6-sol"),
(
AgentProfileKind::Gpt56,
provider_ids::openai(),
"gpt-5.6-sol",
),
];
for (profile_kind, provider_id, model) in cases {
@ -774,9 +782,9 @@ mod tests {
) {
let builder = AgentProfileBuilder::new(
profile_kind,
ProviderId::anthropic(),
provider_ids::anthropic(),
model,
Arc::new(Catalog::from_builtin().unwrap()),
Arc::new(test_catalog()),
);
let root = builder.build();
let child = builder.build();
@ -844,9 +852,7 @@ mod tests {
#[test]
fn profile_builder_selects_a_codec_compatible_gpt56_editor() {
let overrides: LlmCatalogSettings =
toml::from_str("[providers.openrouter]\nenabled = true\n").unwrap();
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&overrides).unwrap());
let catalog = Arc::new(test_catalog_with_overlay(OPENROUTER_ENABLED));
let profile = AgentProfileBuilder::new(
AgentProfileKind::Gpt56,
ProviderId::new("openrouter"),

View file

@ -1,6 +1,7 @@
use std::sync::Arc;
use fabro_model::{AgentProfileKind, Catalog, ProviderId};
use fabro_llm::lithos_catalog::Catalog;
use fabro_types::{AgentProfileKind, ProviderId, provider_ids};
use super::EnvContext;
use crate::agent_profile::AgentProfile;
@ -42,7 +43,7 @@ impl OpenAiProfile {
Self {
base: BaseProfile {
profile_kind: AgentProfileKind::OpenAi,
provider_id: ProviderId::openai(),
provider_id: provider_ids::openai(),
model: model.into(),
catalog: None,
registry,
@ -101,19 +102,22 @@ impl AgentProfile for OpenAiProfile {
mod tests {
use std::sync::Arc;
use fabro_llm::test_support::test_catalog as fabro_test_catalog;
use super::*;
use crate::subagent::{SessionFactory, SubAgentSupervisor};
use crate::test_support::MockSandbox;
use crate::tool_registry::ToolDefinitionExt;
fn test_catalog() -> Arc<Catalog> {
Arc::new(Catalog::from_builtin().unwrap())
Arc::new(fabro_test_catalog())
}
#[test]
fn openai_profile_identity() {
let profile = OpenAiProfile::new("o3-mini");
assert_eq!(profile.profile_kind(), AgentProfileKind::OpenAi);
assert_eq!(profile.provider_id(), ProviderId::openai());
assert_eq!(profile.provider_id(), provider_ids::openai());
assert_eq!(profile.model(), "o3-mini");
}
@ -249,10 +253,11 @@ mod tests {
let edit_file = profile.tool_registry().get("edit_file").unwrap();
assert!(!edit_file.definition.is_custom());
assert_eq!(edit_file.definition.parameters["type"], "object");
assert_eq!(edit_file.definition.parameters()["type"], "object");
for definition in profile.tool_registry().definitions() {
assert_eq!(
definition.parameters["type"], "object",
definition.parameters()["type"],
"object",
"tool '{}' must use an object parameter schema",
definition.name
);

View file

@ -6,9 +6,7 @@ use std::ops::RangeInclusive;
use std::sync::Arc;
use async_trait::async_trait;
use fabro_llm::types::ToolDefinition;
use fabro_model::AgentProfileKind;
use fabro_types::{InterviewOption, QuestionType};
use fabro_types::{AgentProfileKind, InterviewOption, QuestionType, ToolDefinition};
use serde::Deserialize;
use serde_json::json;
use tokio_util::sync::CancellationToken;
@ -196,8 +194,8 @@ pub fn is_question_tool(name: &str) -> bool {
pub fn register_question_tools(profile_kind: AgentProfileKind, registry: &mut ToolRegistry) {
match profile_kind {
// Codex names this tool `request_user_input` for GPT-5.6 too.
AgentProfileKind::OpenAi | AgentProfileKind::Gpt56 => {
// Codex names this tool `request_user_input` for GPT-5.6 and GPT-6 too.
AgentProfileKind::OpenAi | AgentProfileKind::Gpt56 | AgentProfileKind::Gpt6 => {
registry.register(make_openai_question_tool());
}
// Kimi Code names this tool `AskUserQuestion` with the same
@ -214,10 +212,10 @@ pub fn register_question_tools(profile_kind: AgentProfileKind, registry: &mut To
fn make_openai_question_tool() -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: OPENAI_REQUEST_USER_INPUT_TOOL.to_string(),
description: "Ask the human one or more questions and wait for their answers before continuing this stage.".to_string(),
parameters: json!({
definition: ToolDefinition::function(
OPENAI_REQUEST_USER_INPUT_TOOL.to_string(),
"Ask the human one or more questions and wait for their answers before continuing this stage.",
json!({
"type": "object",
"required": ["questions"],
"properties": {
@ -247,7 +245,7 @@ fn make_openai_question_tool() -> RegisteredTool {
}
}
}),
},
),
executor: Arc::new(|args, ctx| {
Box::pin(async move {
let parsed: OpenAiQuestionToolArgs = parse_tool_args(args)?;
@ -262,10 +260,10 @@ fn make_openai_question_tool() -> RegisteredTool {
fn make_anthropic_question_tool() -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: ANTHROPIC_ASK_USER_QUESTION_TOOL.to_string(),
description: "Ask the human one or more questions and wait for their answers before continuing this stage.".to_string(),
parameters: json!({
definition: ToolDefinition::function(
ANTHROPIC_ASK_USER_QUESTION_TOOL.to_string(),
"Ask the human one or more questions and wait for their answers before continuing this stage.",
json!({
"type": "object",
"required": ["questions"],
"properties": {
@ -296,12 +294,11 @@ fn make_anthropic_question_tool() -> RegisteredTool {
}
}
}),
},
),
executor: Arc::new(|args, ctx| {
Box::pin(async move {
let parsed: AnthropicQuestionToolArgs = parse_tool_args(args)?;
let questions =
normalize_anthropic_questions(parsed, &ANTHROPIC_QUESTION_LIMITS)?;
let questions = normalize_anthropic_questions(parsed, &ANTHROPIC_QUESTION_LIMITS)?;
let answers = execute_question_tool(ctx, questions).await?;
format_anthropic_answers(&answers)
})
@ -312,10 +309,10 @@ fn make_anthropic_question_tool() -> RegisteredTool {
fn make_claude5_question_tool() -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: ANTHROPIC_ASK_USER_QUESTION_TOOL.to_string(),
description: "Ask the human up to four questions when a decision is genuinely theirs to make. The UI automatically provides an Other option for custom text.".to_string(),
parameters: json!({
definition: ToolDefinition::function(
ANTHROPIC_ASK_USER_QUESTION_TOOL.to_string(),
"Ask the human up to four questions when a decision is genuinely theirs to make. The UI automatically provides an Other option for custom text.",
json!({
"type": "object",
"properties": {
"questions": {
@ -373,12 +370,11 @@ fn make_claude5_question_tool() -> RegisteredTool {
"required": ["questions"],
"additionalProperties": false
}),
},
),
executor: Arc::new(|args, ctx| {
Box::pin(async move {
let parsed: AnthropicQuestionToolArgs = parse_tool_args(args)?;
let questions =
normalize_anthropic_questions(parsed, &CLAUDE5_QUESTION_LIMITS)?;
let questions = normalize_anthropic_questions(parsed, &CLAUDE5_QUESTION_LIMITS)?;
let answers = execute_question_tool(ctx, questions).await?;
format_anthropic_answers(&answers)
})
@ -652,6 +648,7 @@ mod tests {
use super::*;
use crate::native_tool::ToolVocabulary;
use crate::test_support::MockSandbox;
use crate::tool_registry::ToolDefinitionExt;
fn answered(
original_id: Option<&str>,
@ -784,9 +781,9 @@ mod tests {
let mut claude5 = ToolRegistry::with_vocabulary(ToolVocabulary::Claude5);
register_question_tools(AgentProfileKind::Claude5, &mut claude5);
let tool = claude5.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).unwrap();
assert_eq!(tool.definition.parameters["additionalProperties"], false);
assert_eq!(tool.definition.parameters()["additionalProperties"], false);
assert_eq!(
tool.definition.parameters["properties"]
tool.definition.parameters()["properties"]
.as_object()
.unwrap()
.keys()
@ -795,7 +792,7 @@ mod tests {
vec!["questions"]
);
assert_eq!(
tool.definition.parameters["properties"]["questions"]["maxItems"],
tool.definition.parameters()["properties"]["questions"]["maxItems"],
4
);
assert!(claude5.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_none());

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
use std::sync::Arc;
use fabro_llm::types::ToolDefinition;
use fabro_types::ToolDefinition;
use tokio_util::sync::CancellationToken;
use crate::error::{Error, InterruptReason};
@ -226,13 +226,12 @@ pub fn make_use_skill_tool_for_vocabulary(
),
};
RegisteredTool {
definition: ToolDefinition {
name: NativeTool::UseSkill.canonical_name().into(),
description: "Load a skill's instructions by name. Call this when the user's \
request matches an available skill."
.into(),
definition: ToolDefinition::function(
NativeTool::UseSkill.canonical_name(),
"Load a skill's instructions by name. Call this when the user's \
request matches an available skill.",
parameters,
},
),
executor: Arc::new(move |args, ctx| {
let skills = skills.clone();
Box::pin(async move {
@ -351,7 +350,7 @@ mod tests {
use super::*;
use crate::sandbox::Sandbox;
use crate::test_support::MockSandbox;
use crate::tool_registry::ToolContext;
use crate::tool_registry::{ToolContext, ToolDefinitionExt};
// --- parse_skill tests ---
@ -733,17 +732,17 @@ name: trimmed
assert!(result.contains("only staged files"), "{result}");
assert!(
tool.definition.parameters["properties"]
tool.definition.parameters()["properties"]
.get("skill")
.is_some()
);
assert!(
tool.definition.parameters["properties"]
tool.definition.parameters()["properties"]
.get("args")
.is_some()
);
assert!(
tool.definition.parameters["properties"]
tool.definition.parameters()["properties"]
.get("skill_name")
.is_none()
);
@ -770,22 +769,22 @@ name: trimmed
assert!(result.contains("only staged files"), "{result}");
assert_eq!(
tool.definition.parameters["required"],
tool.definition.parameters()["required"],
serde_json::json!(["skill"])
);
assert_eq!(tool.definition.parameters["additionalProperties"], false);
assert_eq!(tool.definition.parameters()["additionalProperties"], false);
assert!(
tool.definition.parameters["properties"]
tool.definition.parameters()["properties"]
.get("skill")
.is_some()
);
assert!(
tool.definition.parameters["properties"]
tool.definition.parameters()["properties"]
.get("args")
.is_some()
);
assert!(
tool.definition.parameters["properties"]
tool.definition.parameters()["properties"]
.get("skill_name")
.is_none()
);

View file

@ -3,8 +3,7 @@ use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex, RwLock, Weak};
use std::time::Duration;
use fabro_llm::types::ToolDefinition;
use fabro_types::INITIAL_SUBAGENT_GENERATION;
use fabro_types::{INITIAL_SUBAGENT_GENERATION, ToolDefinition};
use fabro_util::error as util_error;
use futures::future;
use tokio::sync::{broadcast, mpsc, oneshot, watch};
@ -1202,10 +1201,10 @@ pub fn make_spawn_agent_tool(
current_depth: usize,
) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "spawn_agent".into(),
description: "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.".into(),
parameters: serde_json::json!({
definition: ToolDefinition::function(
"spawn_agent",
"Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.",
serde_json::json!({
"type": "object",
"properties": {
"task": {
@ -1215,7 +1214,7 @@ pub fn make_spawn_agent_tool(
},
"required": ["task"]
}),
},
),
executor: Arc::new(move |args, ctx| {
let supervisor = supervisor.clone();
let session_factory = session_factory.clone();
@ -1240,10 +1239,10 @@ pub fn make_spawn_agent_tool(
pub fn make_send_input_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "send_input".into(),
description: "Send a follow-up message to a subagent. A running agent receives it at a safe turn boundary. A completed agent starts another turn in the same session with its existing history.".into(),
parameters: serde_json::json!({
definition: ToolDefinition::function(
"send_input",
"Send a follow-up message to a subagent. A running agent receives it at a safe turn boundary. A completed agent starts another turn in the same session with its existing history.",
serde_json::json!({
"type": "object",
"properties": {
"agent_id": {
@ -1257,7 +1256,7 @@ pub fn make_send_input_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
},
"required": ["agent_id", "message"]
}),
},
),
executor: Arc::new(move |args, _ctx| {
let supervisor = supervisor.clone();
Box::pin(async move {
@ -1276,10 +1275,10 @@ pub fn make_send_input_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
pub fn make_wait_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "wait".into(),
description: "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.".into(),
parameters: serde_json::json!({
definition: ToolDefinition::function(
"wait",
"Wait for a subagent to complete, then use the result to synthesize the outcome for the user.",
serde_json::json!({
"type": "object",
"properties": {
"agent_id": {
@ -1289,7 +1288,7 @@ pub fn make_wait_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
},
"required": ["agent_id"]
}),
},
),
executor: Arc::new(move |args, ctx| {
let supervisor = supervisor.clone();
Box::pin(async move {
@ -1313,10 +1312,10 @@ pub fn make_wait_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
pub fn make_close_agent_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "close_agent".into(),
description: "Close a running or completed subagent that is no longer needed.".into(),
parameters: serde_json::json!({
definition: ToolDefinition::function(
"close_agent",
"Close a running or completed subagent that is no longer needed.",
serde_json::json!({
"type": "object",
"properties": {
"agent_id": {
@ -1326,7 +1325,7 @@ pub fn make_close_agent_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
},
"required": ["agent_id"]
}),
},
),
executor: Arc::new(move |args, _ctx| {
let supervisor = supervisor.clone();
Box::pin(async move {
@ -1344,15 +1343,15 @@ pub fn make_close_agent_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
#[cfg(test)]
mod tests {
use fabro_llm::provider::ProviderAdapter;
use fabro_llm::types::Role;
use fabro_llm::adapter::ProviderAdapter;
use fabro_types::{Role, text_of};
use tokio::task::yield_now;
use tokio::time;
use super::*;
use crate::config::SessionOptions;
use crate::test_support::*;
use crate::tool_registry::ToolContext;
use crate::tool_registry::{ToolContext, ToolDefinitionExt};
// --- Tests ---
@ -1657,13 +1656,13 @@ mod tests {
.as_ref()
.expect("request should have been captured");
let system_message = request
.messages
.messages()
.iter()
.find(|message| message.role == Role::System)
.find(|message| message.role() == Role::System)
.expect("subagent request should include system message");
assert!(
!system_message.text().trim().is_empty(),
!text_of(system_message.content()).trim().is_empty(),
"subagent system prompt should not be empty"
);
}
@ -1791,21 +1790,21 @@ mod tests {
let spawn_tool = make_spawn_agent_tool(manager.clone(), factory, 0);
assert_eq!(spawn_tool.definition.name, "spawn_agent");
let spawn_properties = spawn_tool.definition.parameters["properties"]
let spawn_properties = spawn_tool.definition.parameters()["properties"]
.as_object()
.unwrap();
assert_eq!(spawn_properties.len(), 1);
assert!(spawn_properties["task"].is_object());
let spawn_required = spawn_tool.definition.parameters["required"]
let spawn_required = spawn_tool.definition.parameters()["required"]
.as_array()
.unwrap();
assert!(spawn_required.contains(&serde_json::json!("task")));
let send_tool = make_send_input_tool(manager.clone());
assert_eq!(send_tool.definition.name, "send_input");
assert!(send_tool.definition.parameters["properties"]["agent_id"].is_object());
assert!(send_tool.definition.parameters["properties"]["message"].is_object());
let send_required = send_tool.definition.parameters["required"]
assert!(send_tool.definition.parameters()["properties"]["agent_id"].is_object());
assert!(send_tool.definition.parameters()["properties"]["message"].is_object());
let send_required = send_tool.definition.parameters()["required"]
.as_array()
.unwrap();
assert!(send_required.contains(&serde_json::json!("agent_id")));
@ -1813,16 +1812,16 @@ mod tests {
let wait_tool = make_wait_tool(manager.clone());
assert_eq!(wait_tool.definition.name, "wait");
assert!(wait_tool.definition.parameters["properties"]["agent_id"].is_object());
let wait_required = wait_tool.definition.parameters["required"]
assert!(wait_tool.definition.parameters()["properties"]["agent_id"].is_object());
let wait_required = wait_tool.definition.parameters()["required"]
.as_array()
.unwrap();
assert!(wait_required.contains(&serde_json::json!("agent_id")));
let close_tool = make_close_agent_tool(manager);
assert_eq!(close_tool.definition.name, "close_agent");
assert!(close_tool.definition.parameters["properties"]["agent_id"].is_object());
let close_required = close_tool.definition.parameters["required"]
assert!(close_tool.definition.parameters()["properties"]["agent_id"].is_object());
let close_required = close_tool.definition.parameters()["required"]
.as_array()
.unwrap();
assert!(close_required.contains(&serde_json::json!("agent_id")));
@ -2075,14 +2074,15 @@ mod tests {
let request = captured
.as_ref()
.expect("second request should be captured");
assert!(request.messages.iter().any(|message| {
message.role == Role::User && message.text().contains("Do something")
assert!(request.messages().iter().any(|message| {
message.role() == Role::User && text_of(message.content()).contains("Do something")
}));
assert!(request.messages.iter().any(|message| {
message.role == Role::Assistant && message.text().contains("captured")
assert!(request.messages().iter().any(|message| {
message.role() == Role::Assistant && text_of(message.content()).contains("captured")
}));
assert!(request.messages.iter().any(|message| {
message.role == Role::User && message.text().contains("Fix the review findings")
assert!(request.messages().iter().any(|message| {
message.role() == Role::User
&& text_of(message.content()).contains("Fix the review findings")
}));
}

View file

@ -74,18 +74,18 @@ fn is_task_reminder(content: &str) -> bool {
mod tests {
use std::time::SystemTime;
use fabro_llm::types::{TokenCounts, ToolCall};
use fabro_types::{TokenCounts, ToolCall};
use super::*;
fn assistant(tool_name: Option<&str>) -> Message {
let tool_calls = tool_name
.map(|name| vec![ToolCall::new("call_1", name, serde_json::json!({}))])
.map(|name| vec![ToolCall::function("call_1", name, serde_json::json!({}))])
.unwrap_or_default();
Message::Assistant {
content: String::new(),
tool_calls,
provider_parts: Vec::new(),
usage: Box::<TokenCounts>::default(),
usage: TokenCounts::default(),
response_id: "resp".into(),
timestamp: SystemTime::now(),
}

View file

@ -1,17 +1,18 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use fabro_llm::Error as LlmError;
use fabro_llm::client::Client;
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::types::{
ContentPart, FinishReason, Message, Request, Response, StreamEvent, TokenCounts,
use fabro_llm::adapter::{ProviderAdapter, ResolvedCall};
use fabro_llm::lithos_catalog::AdapterId;
use fabro_llm::test_support::client_with_adapters;
pub use fabro_llm::test_support::{response_to_stream, test_retry_policy};
use fabro_llm::{
Client, ClientOptions, Error as LlmError, FinishReason, Request, Response, ResponseStream,
};
use fabro_model::{AgentProfileKind, ProviderId};
pub use fabro_sandbox::test_support::{MockSandbox, MutableMockSandbox};
use futures::stream;
use fabro_types::{
AgentProfileKind, ContentPart, ModelId, ProviderId, TokenCounts, ToolCall, provider_ids,
};
use crate::agent_profile::AgentProfile;
use crate::config::SessionOptions;
@ -22,6 +23,12 @@ use crate::session::Session;
use crate::skills::{Skill, format_skills_prompt_section};
use crate::tool_registry::{RegisteredTool, ToolRegistry, ToolSource};
/// The provider every test profile routes to.
pub const TEST_PROVIDER: &str = provider_ids::ANTHROPIC;
/// The model every test profile requests. It is not in the catalog, so the
/// provider's passthrough route serves it.
pub const TEST_MODEL: &str = "mock-model";
// --- TestProfile ---
pub struct TestProfile {
@ -58,11 +65,11 @@ impl AgentProfile for TestProfile {
}
fn provider_id(&self) -> ProviderId {
ProviderId::anthropic()
provider_ids::anthropic()
}
fn model(&self) -> &'static str {
"mock-model"
TEST_MODEL
}
fn tool_registry(&self) -> &ToolRegistry {
@ -102,9 +109,11 @@ impl AgentProfile for TestProfile {
// --- MockLlmProvider ---
/// Answers from a script of responses, repeating the last one.
pub struct MockLlmProvider {
pub responses: Vec<Response>,
pub call_index: AtomicUsize,
id: AdapterId,
}
impl MockLlmProvider {
@ -112,94 +121,110 @@ impl MockLlmProvider {
Self {
responses,
call_index: AtomicUsize::new(0),
id: AdapterId::new("mock"),
}
}
fn next_response(&self) -> Response {
let idx = self.call_index.fetch_add(1, Ordering::SeqCst);
self.responses[idx.min(self.responses.len() - 1)].clone()
}
}
#[async_trait]
impl ProviderAdapter for MockLlmProvider {
fn name(&self) -> &'static str {
"mock"
fn id(&self) -> &AdapterId {
&self.id
}
async fn complete(&self, _request: &Request) -> Result<Response, LlmError> {
let idx = self.call_index.fetch_add(1, Ordering::SeqCst);
if idx < self.responses.len() {
Ok(self.responses[idx].clone())
} else {
Ok(self.responses[self.responses.len() - 1].clone())
}
async fn complete(&self, _call: &ResolvedCall) -> Result<Response, LlmError> {
Ok(self.next_response())
}
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, LlmError> {
let idx = self.call_index.fetch_add(1, Ordering::SeqCst);
let response = if idx < self.responses.len() {
self.responses[idx].clone()
} else {
self.responses[self.responses.len() - 1].clone()
};
Ok(response_to_stream(response))
async fn stream(&self, _call: &ResolvedCall) -> Result<ResponseStream, LlmError> {
Ok(response_to_stream(self.next_response()))
}
}
/// Convert a canned `Response` into a `StreamEventStream` for mock streaming.
pub fn response_to_stream(response: Response) -> StreamEventStream {
let mut events: Vec<Result<StreamEvent, LlmError>> = Vec::new();
// Emit text deltas for text content
let text = response.text();
if !text.is_empty() {
events.push(Ok(StreamEvent::text_delta(text, None)));
}
// Emit tool call events
for part in &response.message.content {
if let ContentPart::ToolCall(tc) = part {
events.push(Ok(StreamEvent::ToolCallEnd {
tool_call: tc.clone(),
}));
}
}
// Emit finish
events.push(Ok(StreamEvent::finish(
response.finish_reason.clone(),
response.usage.clone(),
response,
)));
Box::pin(stream::iter(events))
}
// --- Helper functions ---
pub fn text_response(text: &str) -> Response {
Response {
id: format!("resp_{text}"),
model: "mock-model".into(),
provider: "mock".into(),
message: Message::assistant(text),
finish_reason: FinishReason::Stop,
usage: TokenCounts {
input_tokens: 10,
output_tokens: 5,
..Default::default()
},
raw: None,
warnings: vec![],
rate_limit: None,
cost_usd: None,
cost_source: None,
}
/// A response attributed to the test route with the given content parts.
pub fn response_with_parts(id: &str, parts: Vec<ContentPart>) -> Response {
let has_tool_calls = parts
.iter()
.any(|part| matches!(part, ContentPart::ToolCall(_)));
let mut response = Response::new(
ProviderId::new(TEST_PROVIDER),
ModelId::new(TEST_MODEL),
parts,
);
response.id = Some(id.to_string());
response.finish_reason = if has_tool_calls {
FinishReason::ToolCall
} else {
FinishReason::Stop
};
response.usage = TokenCounts {
input: 10,
output: 5,
..TokenCounts::default()
};
response
}
pub fn text_response(text: &str) -> Response {
response_with_parts(&format!("resp_{text}"), vec![ContentPart::Text {
text: text.to_string(),
}])
}
pub fn tool_call_response(
tool_name: &str,
tool_call_id: &str,
args: serde_json::Value,
) -> Response {
response_with_parts(&format!("resp_{tool_call_id}"), vec![
ContentPart::Text {
text: "Let me use a tool.".to_string(),
},
ContentPart::ToolCall(ToolCall::function(tool_call_id, tool_name, args)),
])
}
pub fn multi_tool_call_response(calls: Vec<(&str, &str, serde_json::Value)>) -> Response {
let mut content = vec![ContentPart::Text {
text: "Let me use multiple tools.".to_string(),
}];
for (tool_name, tool_call_id, args) in calls {
content.push(ContentPart::ToolCall(ToolCall::function(
tool_call_id,
tool_name,
args,
)));
}
response_with_parts("resp_multi", content)
}
/// A client over the Fabro test catalog that routes the test provider to
/// `provider`, with client-side retries but no delay between attempts.
pub async fn make_client(provider: Arc<dyn ProviderAdapter>) -> Client {
let mut providers = HashMap::new();
providers.insert(provider.name().to_string(), provider.clone());
// Also register under "anthropic" so TestProfile (ProviderId::anthropic())
// routes correctly
providers.insert("anthropic".to_string(), provider);
Client::new(providers, Some("mock".into()), vec![])
make_client_with_options(
provider,
ClientOptions::default().with_retry(Some(test_retry_policy())),
)
}
/// A client over the Fabro test catalog with no client-side retries. Tests
/// that count provider calls made by the agent's own replay loop use this.
pub fn make_client_without_retries(provider: Arc<dyn ProviderAdapter>) -> Client {
make_client_with_options(provider, ClientOptions::default())
}
pub fn make_client_with_options(
provider: Arc<dyn ProviderAdapter>,
options: ClientOptions,
) -> Client {
client_with_adapters(vec![(TEST_PROVIDER, provider)], options)
}
pub async fn make_session(responses: Vec<Response>) -> Session {
@ -245,47 +270,14 @@ pub async fn make_session_with_tools_and_config(
Session::new(client, profile, env, config, None)
}
pub fn tool_call_response(
tool_name: &str,
tool_call_id: &str,
args: serde_json::Value,
) -> Response {
use fabro_llm::types::{ContentPart, Role, ToolCall};
Response {
id: format!("resp_{tool_call_id}"),
model: "mock-model".into(),
provider: "mock".into(),
message: Message {
role: Role::Assistant,
content: vec![
ContentPart::text("Let me use a tool."),
ContentPart::ToolCall(ToolCall::new(tool_call_id, tool_name, args)),
],
name: None,
tool_call_id: None,
},
finish_reason: FinishReason::ToolCalls,
usage: TokenCounts {
input_tokens: 10,
output_tokens: 5,
..Default::default()
},
raw: None,
warnings: vec![],
rate_limit: None,
cost_usd: None,
cost_source: None,
}
}
pub fn make_echo_tool() -> RegisteredTool {
use fabro_llm::types::ToolDefinition;
use fabro_types::ToolDefinition;
RegisteredTool {
definition: ToolDefinition {
name: "echo".into(),
description: "Echoes the input".into(),
parameters: serde_json::json!({"type": "object", "properties": {"text": {"type": "string"}}}),
},
definition: ToolDefinition::function(
"echo",
"Echoes the input",
serde_json::json!({"type": "object", "properties": {"text": {"type": "string"}}}),
),
executor: Arc::new(|args, _ctx| {
Box::pin(async move {
let text = args
@ -300,13 +292,13 @@ pub fn make_echo_tool() -> RegisteredTool {
}
pub fn make_error_tool() -> RegisteredTool {
use fabro_llm::types::ToolDefinition;
use fabro_types::ToolDefinition;
RegisteredTool {
definition: ToolDefinition {
name: "fail_tool".into(),
description: "Always fails".into(),
parameters: serde_json::json!({"type": "object"}),
},
definition: ToolDefinition::function(
"fail_tool",
"Always fails",
serde_json::json!({"type": "object"}),
),
executor: Arc::new(|_args, _ctx| {
Box::pin(async move { Err("tool execution failed".to_string()) })
}),
@ -316,22 +308,41 @@ pub fn make_error_tool() -> RegisteredTool {
// --- MockErrorProvider ---
/// Fails every call with a fresh error from `factory`.
pub struct MockErrorProvider {
pub error: LlmError,
factory: Box<dyn Fn() -> LlmError + Send + Sync>,
calls: AtomicUsize,
id: AdapterId,
}
impl MockErrorProvider {
pub fn new(factory: impl Fn() -> LlmError + Send + Sync + 'static) -> Self {
Self {
factory: Box::new(factory),
calls: AtomicUsize::new(0),
id: AdapterId::new("mock"),
}
}
pub fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait]
impl ProviderAdapter for MockErrorProvider {
fn name(&self) -> &'static str {
"mock"
fn id(&self) -> &AdapterId {
&self.id
}
async fn complete(&self, _request: &Request) -> Result<Response, LlmError> {
Err(self.error.clone())
async fn complete(&self, _call: &ResolvedCall) -> Result<Response, LlmError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Err((self.factory)())
}
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, LlmError> {
Err(self.error.clone())
async fn stream(&self, _call: &ResolvedCall) -> Result<ResponseStream, LlmError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Err((self.factory)())
}
}
@ -340,69 +351,37 @@ impl ProviderAdapter for MockErrorProvider {
/// A mock LLM provider that captures the full Request for test assertions.
pub struct CapturingLlmProvider {
pub captured_request: Mutex<Option<Request>>,
id: AdapterId,
}
impl CapturingLlmProvider {
pub fn new() -> Self {
Self {
captured_request: Mutex::new(None),
id: AdapterId::new("mock"),
}
}
}
#[async_trait]
impl ProviderAdapter for CapturingLlmProvider {
fn name(&self) -> &'static str {
"mock"
fn id(&self) -> &AdapterId {
&self.id
}
async fn complete(&self, request: &Request) -> Result<Response, LlmError> {
async fn complete(&self, call: &ResolvedCall) -> Result<Response, LlmError> {
*self
.captured_request
.lock()
.expect("captured_request lock poisoned") = Some(request.clone());
.expect("captured_request lock poisoned") = Some(call.request().clone());
Ok(text_response("captured"))
}
async fn stream(&self, request: &Request) -> Result<StreamEventStream, LlmError> {
async fn stream(&self, call: &ResolvedCall) -> Result<ResponseStream, LlmError> {
*self
.captured_request
.lock()
.expect("captured_request lock poisoned") = Some(request.clone());
.expect("captured_request lock poisoned") = Some(call.request().clone());
Ok(response_to_stream(text_response("captured")))
}
}
pub fn multi_tool_call_response(calls: Vec<(&str, &str, serde_json::Value)>) -> Response {
use fabro_llm::types::{ContentPart, Role, ToolCall};
let mut content = vec![ContentPart::text("Let me use multiple tools.")];
for (tool_name, tool_call_id, args) in calls {
content.push(ContentPart::ToolCall(ToolCall::new(
tool_call_id,
tool_name,
args,
)));
}
Response {
id: "resp_multi".into(),
model: "mock-model".into(),
provider: "mock".into(),
message: Message {
role: Role::Assistant,
content,
name: None,
tool_call_id: None,
},
finish_reason: FinishReason::ToolCalls,
usage: TokenCounts {
input_tokens: 10,
output_tokens: 5,
..Default::default()
},
raw: None,
warnings: vec![],
rate_limit: None,
cost_usd: None,
cost_source: None,
}
}

View file

@ -12,8 +12,7 @@ use std::fmt::Write;
use std::str::FromStr;
use std::sync::Arc;
use fabro_llm::types::ToolDefinition;
use fabro_types::{TodoListKind, TodoProjection, TodoStatus, TodoUpdatedProps};
use fabro_types::{TodoListKind, TodoProjection, TodoStatus, TodoUpdatedProps, ToolDefinition};
use serde_json::Value;
use strum::{EnumString, IntoStaticStr};
@ -151,12 +150,11 @@ fn reconcile_replacement_list(
#[must_use]
pub fn make_update_plan_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "update_plan".into(),
description: "Update the multi-step plan for the current task. Submit the entire \
plan; existing steps are reconciled by exact step text."
.into(),
parameters: serde_json::json!({
definition: ToolDefinition::function(
"update_plan",
"Update the multi-step plan for the current task. Submit the entire \
plan; existing steps are reconciled by exact step text.",
serde_json::json!({
"type": "object",
"properties": {
"explanation": {
@ -181,7 +179,7 @@ pub fn make_update_plan_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
},
"required": ["plan"]
}),
},
),
executor: Arc::new(move |args, ctx| {
let runtime = runtime.clone();
Box::pin(async move {
@ -298,16 +296,15 @@ fn render_kimi_todos<'a>(items: impl IntoIterator<Item = (TodoStatus, &'a str)>)
/// same [`TodoRuntime`] backs it, so projections and events are unchanged.
pub fn make_todo_list_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "TodoList".into(),
description: "Maintain a structured TODO list for the current task. Use it \
definition: ToolDefinition::function(
"TodoList",
"Maintain a structured TODO list for the current task. Use it \
proactively for multi-step work. Pass `todos` to replace the entire \
list, omit `todos` to read the current list without changing it, and \
pass an empty array to clear it. Keep exactly one item `in_progress` \
while work is underway, and mark an item `done` as soon as it is \
finished rather than batching completions at the end."
.into(),
parameters: serde_json::json!({
finished rather than batching completions at the end.",
serde_json::json!({
"type": "object",
"properties": {
"todos": {
@ -332,7 +329,7 @@ pub fn make_todo_list_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
}
}
}),
},
),
executor: Arc::new(move |args, ctx| {
let runtime = runtime.clone();
Box::pin(async move {
@ -449,10 +446,10 @@ fn format_task_details(todo: &TodoProjection) -> String {
#[must_use]
pub fn make_task_create_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "TaskCreate".into(),
description: TASK_CREATE_DESCRIPTION.into(),
parameters: serde_json::json!({
definition: ToolDefinition::function(
"TaskCreate",
TASK_CREATE_DESCRIPTION,
serde_json::json!({
"type": "object",
"properties": {
"subject": {"type": "string"},
@ -462,7 +459,7 @@ pub fn make_task_create_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
},
"required": ["subject", "description"]
}),
},
),
executor: Arc::new(move |args, ctx| {
let runtime = runtime.clone();
Box::pin(async move {
@ -498,10 +495,10 @@ pub fn make_task_create_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
#[must_use]
pub fn make_task_update_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "TaskUpdate".into(),
description: TASK_UPDATE_DESCRIPTION.into(),
parameters: serde_json::json!({
definition: ToolDefinition::function(
"TaskUpdate",
TASK_UPDATE_DESCRIPTION,
serde_json::json!({
"type": "object",
"properties": {
"taskId": {"type": "string"},
@ -519,7 +516,7 @@ pub fn make_task_update_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
},
"required": ["taskId"]
}),
},
),
executor: Arc::new(move |args, ctx| {
let runtime = runtime.clone();
Box::pin(async move {
@ -567,17 +564,17 @@ pub fn make_task_update_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
#[must_use]
pub fn make_task_get_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "TaskGet".into(),
description: TASK_GET_DESCRIPTION.into(),
parameters: serde_json::json!({
definition: ToolDefinition::function(
"TaskGet",
TASK_GET_DESCRIPTION,
serde_json::json!({
"type": "object",
"properties": {
"taskId": {"type": "string"}
},
"required": ["taskId"]
}),
},
),
executor: Arc::new(move |args, ctx| {
let runtime = runtime.clone();
Box::pin(async move {
@ -604,15 +601,15 @@ pub fn make_task_get_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
#[must_use]
pub fn make_task_list_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "TaskList".into(),
description: TASK_LIST_DESCRIPTION.into(),
parameters: serde_json::json!({
definition: ToolDefinition::function(
"TaskList",
TASK_LIST_DESCRIPTION,
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
},
),
executor: Arc::new(move |_args, ctx| {
let runtime = runtime.clone();
Box::pin(async move {

View file

@ -1,7 +1,7 @@
use std::borrow::Cow;
use std::sync::Arc;
use fabro_llm::types::{ToolCall, ToolResult};
use fabro_types::{ToolCall, ToolInput, ToolResult, tool_call_arguments, tool_result_from_json};
use futures::future;
use tokio_util::sync::CancellationToken;
use tracing::debug;
@ -108,7 +108,7 @@ async fn execute_tool_calls_sequential(
let mut results = Vec::new();
for tc in tool_calls {
if cancel_token.is_cancelled() {
results.push(ToolResult::error(tc.id.clone(), "Cancelled"));
results.push(error_result(&tc.id, "Cancelled"));
continue;
}
@ -218,7 +218,7 @@ async fn execute_question_tool_round(
for (index, tc) in tool_calls.iter().enumerate() {
if cancel_token.is_cancelled() {
results.push(ToolResult::error(tc.id.clone(), "Cancelled"));
results.push(error_result(&tc.id, "Cancelled"));
continue;
}
@ -281,7 +281,7 @@ fn finish_error_result(
config: &SessionOptions,
message: &str,
) -> ToolResult {
let retained = retain_tool_result(ToolResult::error(&tc.id, message), None);
let retained = retain_tool_result(error_result(&tc.id, message), None);
emit_tool_call_result(
emitter,
session_id,
@ -292,11 +292,30 @@ fn finish_error_result(
truncate_tool_result(&retained.result, &tc.name, config)
}
/// A tool result carrying one error message.
fn error_result(tool_call_id: &str, message: impl Into<String>) -> ToolResult {
tool_result_from_json(
tool_call_id,
serde_json::Value::String(message.into()),
true,
)
}
/// A successful tool result carrying one output value.
fn success_result(tool_call_id: &str, output: serde_json::Value) -> ToolResult {
tool_result_from_json(tool_call_id, output, false)
}
/// The single JSON value a tool result carries: a string for text output.
fn result_output(result: &ToolResult) -> serde_json::Value {
fabro_types::tool_result_to_json(result)
}
fn emit_tool_call_started(emitter: &Emitter, session_id: &str, tc: &ToolCall) {
emitter.emit(session_id.to_owned(), AgentEvent::ToolCallStarted {
tool_name: tc.name.clone(),
tool_call_id: tc.id.clone(),
arguments: tc.arguments.clone(),
arguments: tool_call_arguments(tc),
});
}
@ -307,17 +326,18 @@ fn emit_tool_call_result(
result: &ToolResult,
output_stats: OutputCaptureStats,
) {
let output = result_output(result);
emitter.emit(session_id.to_owned(), AgentEvent::ToolCallOutputDelta {
delta: result.content.to_string(),
delta: output.to_string(),
});
emitter.emit(session_id.to_owned(), AgentEvent::ToolCallCompleted {
tool_name: tc.name.clone(),
tool_call_id: tc.id.clone(),
output: result.content.clone(),
is_error: result.is_error,
tool_name: tc.name.clone(),
tool_call_id: tc.id.clone(),
output,
is_error: result.is_error,
output_bytes_observed: output_stats.observed_bytes,
output_bytes_retained: output_stats.retained_bytes,
output_bytes_omitted: output_stats.omitted_bytes,
output_bytes_omitted: output_stats.omitted_bytes,
});
}
@ -424,7 +444,7 @@ async fn execute_and_emit_one_tool_with_lookup(
if let Some(hooks) = tool_hooks {
debug!(tool = %tc.name, hook_event = "pre_tool_use", "Calling tool hook");
let start = std::time::Instant::now();
let decision = hooks.pre_tool_use(&tc.name, &tc.arguments).await;
let decision = hooks.pre_tool_use(&tc.name, &tool_call_arguments(tc)).await;
let elapsed = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
debug!(tool = %tc.name, hook_event = "pre_tool_use", ?decision, duration_ms = elapsed, "Tool hook complete");
@ -452,11 +472,12 @@ async fn execute_and_emit_one_tool_with_lookup(
// Post-tool-use hooks
if let Some(hooks) = tool_hooks {
let output = result_output(&result);
let fallback;
let content_str = if let Some(s) = result.content.as_str() {
let content_str = if let Some(s) = output.as_str() {
s
} else {
fallback = result.content.to_string();
fallback = output.to_string();
&fallback
};
if result.is_error {
@ -485,8 +506,8 @@ fn retain_tool_result(
mut result: ToolResult,
previous_stats: Option<OutputCaptureStats>,
) -> RetainedToolResult {
let output_stats = match &mut result.content {
serde_json::Value::String(output) => {
let output_stats = match result.content.as_mut_slice() {
[fabro_types::ContentPart::Text { text: output }] => {
let previously_omitted = previous_stats.map_or(0, |stats| stats.omitted_bytes);
let previewed =
preview_tool_output(output, MAX_RETAINED_TOOL_OUTPUT_BYTES, previously_omitted);
@ -496,7 +517,7 @@ fn retain_tool_result(
}
stats
}
other => OutputCaptureStats::complete(serialized_json_bytes(other)),
_ => OutputCaptureStats::complete(serialized_json_bytes(&result_output(&result))),
};
RetainedToolResult {
@ -528,14 +549,31 @@ async fn execute_one_tool(
) -> ExecutedToolResult {
match registered_tool {
Some(tool) => {
if tc.tool_type != "custom" {
if let Err(validation_error) =
validate_tool_args(&tool.definition.parameters, &tc.arguments)
let arguments = match &tc.input {
ToolInput::Function(arguments) => match arguments.json() {
Ok(value) => value.clone(),
Err(err) => {
return ExecutedToolResult {
result: error_result(
&tc.id,
format!("Tool arguments are not valid JSON: {err}"),
),
output_stats: None,
};
}
},
_ => tool_call_arguments(tc),
};
if matches!(tc.input, ToolInput::Function(_)) {
if let fabro_types::ToolDefinitionKind::Function { input_schema } =
&tool.definition.kind
{
return ExecutedToolResult {
result: ToolResult::error(&tc.id, validation_error),
output_stats: None,
};
if let Err(validation_error) = validate_tool_args(input_schema, &arguments) {
return ExecutedToolResult {
result: error_result(&tc.id, validation_error),
output_stats: None,
};
}
}
}
@ -555,15 +593,15 @@ async fn execute_one_tool(
tool_call_id: Some(tc.id.clone()),
agent_event_emitter,
};
let execution = (tool.executor)(tc.arguments.clone(), ctx);
let execution = (tool.executor)(arguments, ctx);
let result = match question_tools::scope_agent_tool_runtime(
agent_tool_runtime.clone(),
execution,
)
.await
{
Ok(output) => ToolResult::success(&tc.id, serde_json::json!(output)),
Err(err) => ToolResult::error(&tc.id, err),
Ok(output) => success_result(&tc.id, serde_json::Value::String(output)),
Err(err) => error_result(&tc.id, err),
};
ExecutedToolResult {
result,
@ -571,7 +609,7 @@ async fn execute_one_tool(
}
}
None => ExecutedToolResult {
result: ToolResult::error(&tc.id, format!("Unknown tool: {}", tc.name)),
result: error_result(&tc.id, format!("Unknown tool: {}", tc.name)),
output_stats: None,
},
}
@ -583,19 +621,18 @@ fn truncate_tool_result(
tool_name: &str,
config: &SessionOptions,
) -> ToolResult {
let truncated_content = match &result.content {
serde_json::Value::String(s) => {
serde_json::json!(truncate_tool_output(s, tool_name, config))
}
other => other.clone(),
let content = match result.content.as_slice() {
[fabro_types::ContentPart::Text { text }] => vec![fabro_types::ContentPart::Text {
text: truncate_tool_output(text, tool_name, config),
}],
other => other.to_vec(),
};
ToolResult {
tool_call_id: result.tool_call_id.clone(),
content: truncated_content,
is_error: result.is_error,
image_data: result.image_data.clone(),
image_media_type: result.image_media_type.clone(),
tool_call_id: result.tool_call_id.clone(),
name: result.name.clone(),
content,
is_error: result.is_error,
}
}
@ -634,9 +671,8 @@ mod tests {
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use fabro_llm::types::{ToolCall, ToolDefinition};
use fabro_model::AgentProfileKind;
use fabro_types::run_event::{AgentToolCompletedProps, MAX_RUN_EVENT_BODY_BYTES};
use fabro_types::{AgentProfileKind, ToolCall, ToolDefinition, tool_result_to_json};
use tokio::sync::broadcast;
use super::*;
@ -681,17 +717,17 @@ mod tests {
fn make_echo_tool() -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "echo".to_string(),
description: "Echo input".to_string(),
parameters: serde_json::json!({
definition: ToolDefinition::function(
"echo",
"Echo input",
serde_json::json!({
"type": "object",
"properties": {
"text": {"type": "string"}
},
"required": ["text"]
}),
},
),
executor: Arc::new(|args: serde_json::Value, _ctx: ToolContext| {
Box::pin(async move {
let text = args["text"].as_str().unwrap_or("").to_string();
@ -704,11 +740,11 @@ mod tests {
fn make_fail_tool() -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "fail_tool".to_string(),
description: "Always fails".to_string(),
parameters: serde_json::json!({}),
},
definition: ToolDefinition::function(
"fail_tool",
"Always fails",
serde_json::json!({}),
),
executor: Arc::new(|_args: serde_json::Value, _ctx: ToolContext| {
Box::pin(async move { Err("tool failed".to_string()) })
}),
@ -717,14 +753,7 @@ mod tests {
}
fn make_tool_call(name: &str, id: &str, args: serde_json::Value) -> ToolCall {
ToolCall {
id: id.to_string(),
name: name.to_string(),
tool_type: "function".to_string(),
arguments: args,
raw_arguments: None,
provider_metadata: None,
}
ToolCall::function(id, name, args)
}
struct StubQuestionRuntime;
@ -793,8 +822,7 @@ mod tests {
assert_eq!(results[1].tool_call_id, "call_echo");
assert!(results[1].is_error);
assert!(
results[1]
.content
tool_result_to_json(&results[1])
.as_str()
.unwrap()
.contains("human-question tools must run alone")
@ -838,8 +866,7 @@ mod tests {
assert!(!results[0].is_error);
assert!(results[1].is_error);
assert!(
results[1]
.content
tool_result_to_json(&results[1])
.as_str()
.unwrap()
.contains("Combine all questions into a single questions[] batch")
@ -922,8 +949,8 @@ mod tests {
.await;
assert!(result.is_error);
let content = result.content.as_str().unwrap();
assert!(content.contains("blocked by hook"));
let content = tool_result_to_json(&result);
assert!(content.as_str().unwrap().contains("blocked by hook"));
}
#[tokio::test]
@ -953,7 +980,7 @@ mod tests {
.await;
assert!(!result.is_error);
let content = result.content.to_string();
let content = tool_result_to_json(&result).to_string();
assert!(content.contains("echo: hello"));
}
@ -980,7 +1007,8 @@ mod tests {
)
.await;
let result_output = result.content.as_str().expect("string tool output");
let result_output = tool_result_to_json(&result);
let result_output = result_output.as_str().expect("string tool output");
assert!(result_output.len() <= MAX_RETAINED_TOOL_OUTPUT_BYTES);
assert!(result_output.starts_with("Warning: truncated output"));
assert!(result_output.contains("bytes omitted"));
@ -1210,7 +1238,7 @@ mod tests {
.await;
assert!(!result.is_error);
let content = result.content.to_string();
let content = tool_result_to_json(&result).to_string();
assert!(content.contains("echo: hello"));
}
@ -1220,11 +1248,11 @@ mod tests {
let mut registry = ToolRegistry::new();
let executions_for_tool = Arc::clone(&executions);
registry.register(RegisteredTool {
definition: ToolDefinition {
name: "write_file".to_string(),
description: "Writes a file".to_string(),
parameters: serde_json::json!({"type": "object"}),
},
definition: ToolDefinition::function(
"write_file",
"Writes a file",
serde_json::json!({"type": "object"}),
),
executor: Arc::new(move |_args: serde_json::Value, _ctx: ToolContext| {
let executions = Arc::clone(&executions_for_tool);
Box::pin(async move {
@ -1260,8 +1288,7 @@ mod tests {
assert!(result.is_error);
assert!(
result
.content
tool_result_to_json(&result)
.as_str()
.unwrap_or_default()
.contains("denied by tool access policy")
@ -1275,11 +1302,11 @@ mod tests {
let mut registry = ToolRegistry::new();
let executions_for_tool = Arc::clone(&executions);
registry.register(RegisteredTool {
definition: ToolDefinition {
name: "shell".to_string(),
description: "Runs a command".to_string(),
parameters: serde_json::json!({"type": "object"}),
},
definition: ToolDefinition::function(
"shell",
"Runs a command",
serde_json::json!({"type": "object"}),
),
executor: Arc::new(move |_args: serde_json::Value, _ctx: ToolContext| {
let executions = Arc::clone(&executions_for_tool);
Box::pin(async move {
@ -1315,8 +1342,7 @@ mod tests {
assert!(result.is_error);
assert!(
result
.content
tool_result_to_json(&result)
.as_str()
.unwrap_or_default()
.contains("requires approval")
@ -1394,9 +1420,12 @@ mod tests {
assert!(result.is_error);
assert!(
result.content.as_str().unwrap().contains("Exit code: 7"),
tool_result_to_json(&result)
.as_str()
.unwrap()
.contains("Exit code: 7"),
"got: {}",
result.content
tool_result_to_json(&result)
);
}
@ -1554,12 +1583,16 @@ mod tests {
#[test]
fn truncation_preserves_tool_call_id_and_error_state() {
let result = ToolResult::error("call_1", "x".repeat(60_000));
let result = tool_result_from_json(
"call_1",
serde_json::Value::String("x".repeat(60_000)),
true,
);
let truncated = truncate_tool_result(&result, "shell", &SessionOptions::default());
assert_eq!(truncated.tool_call_id, "call_1");
assert!(truncated.is_error);
assert!(truncated.content.as_str().unwrap().len() < 60_000);
assert!(tool_result_to_json(&truncated).as_str().unwrap().len() < 60_000);
}
}

Some files were not shown because too many files have changed in this diff Show more