diff --git a/.config/nextest.toml b/.config/nextest.toml index 3af76c17c..0580f3f5a 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -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 } diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index b3686f872..dededfa2b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -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) diff --git a/Cargo.lock b/Cargo.lock index c0d32aa79..336485026 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index c642467a5..194d53a90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/apps/fabro-web/app/lib/chats-runtime.test.ts b/apps/fabro-web/app/lib/chats-runtime.test.ts index fe22c2be9..b670dfcc1 100644 --- a/apps/fabro-web/app/lib/chats-runtime.test.ts +++ b/apps/fabro-web/app/lib/chats-runtime.test.ts @@ -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}'); }); }); diff --git a/apps/fabro-web/app/lib/chats-runtime.ts b/apps/fabro-web/app/lib/chats-runtime.ts index 79cbae8ab..484d3e063 100644 --- a/apps/fabro-web/app/lib/chats-runtime.ts +++ b/apps/fabro-web/app/lib/chats-runtime.ts @@ -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 { }); } +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 { diff --git a/apps/fabro-web/app/lib/chats-script.ts b/apps/fabro-web/app/lib/chats-script.ts index 9eb39661e..52dc2d8b3 100644 --- a/apps/fabro-web/app/lib/chats-script.ts +++ b/apps/fabro-web/app/lib/chats-script.ts @@ -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 void>(\n" + - " fn: T,\n" + - " ms: number,\n" + - "): (...args: Parameters) => void {\n" + - " let handle: ReturnType | 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 void>(\n" + + " fn: T,\n" + + " ms: number,\n" + + "): (...args: Parameters) => void {\n" + + " let handle: ReturnType | 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 { diff --git a/apps/fabro-web/app/lib/chats-store.test.tsx b/apps/fabro-web/app/lib/chats-store.test.tsx index 1b7082b3a..f55642073 100644 --- a/apps/fabro-web/app/lib/chats-store.test.tsx +++ b/apps/fabro-web/app/lib/chats-store.test.tsx @@ -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", }); }); diff --git a/apps/fabro-web/app/lib/chats-store.tsx b/apps/fabro-web/app/lib/chats-store.tsx index 3c617dd1a..9619f379c 100644 --- a/apps/fabro-web/app/lib/chats-store.tsx +++ b/apps/fabro-web/app/lib/chats-store.tsx @@ -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 }], }; } diff --git a/apps/fabro-web/app/lib/chats-types.ts b/apps/fabro-web/app/lib/chats-types.ts index 0272ead32..45302456c 100644 --- a/apps/fabro-web/app/lib/chats-types.ts +++ b/apps/fabro-web/app/lib/chats-types.ts @@ -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 = diff --git a/apps/fabro-web/app/routes/run-billing.tsx b/apps/fabro-web/app/routes/run-billing.tsx index 1973ffb10..648b1796b 100644 --- a/apps/fabro-web/app/routes/run-billing.tsx +++ b/apps/fabro-web/app/routes/run-billing.tsx @@ -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}`; } diff --git a/docs/internal/llm-client-resolution.md b/docs/internal/llm-client-resolution.md index 575ca69bb..3187b22de 100644 --- a/docs/internal/llm-client-resolution.md +++ b/docs/internal/llm-client-resolution.md @@ -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` locally, then pass it explicitly. - `GenerateParams::new(model, client)` always receives an explicit `Arc`. - 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 diff --git a/docs/internal/server-secrets-strategy.md b/docs/internal/server-secrets-strategy.md index a6546f751..3f492ea46 100644 --- a/docs/internal/server-secrets-strategy.md +++ b/docs/internal/server-secrets-strategy.md @@ -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 }}" ``` diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 041c28dbb..76c304648 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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. diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx index d04232b55..3bbf7524c 100644 --- a/docs/public/core-concepts/models.mdx +++ b/docs/public/core-concepts/models.mdx @@ -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. -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. -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..auth]` with ordered `env:` or `vault:` 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..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 `_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: ` 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: ` 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. 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.""]` 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.""]` 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` | diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx index 699c3b3c4..587c92d8e 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -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 diff --git a/docs/public/integrations/bedrock.mdx b/docs/public/integrations/bedrock.mdx index 5852dfdb7..78b5a6737 100644 --- a/docs/public/integrations/bedrock.mdx +++ b/docs/public/integrations/bedrock.mdx @@ -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..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 `_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 `_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). -**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`. ## 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.""]` 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. diff --git a/docs/public/integrations/fireworks.mdx b/docs/public/integrations/fireworks.mdx index e18198851..17c70bf2d 100644 --- a/docs/public/integrations/fireworks.mdx +++ b/docs/public/integrations/fireworks.mdx @@ -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 `. 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 `. Remember that `GET /v1/models` only lists a featured subset, so absence from that list is not conclusive. ## Further reading diff --git a/docs/public/integrations/litellm.mdx b/docs/public/integrations/litellm.mdx index c79ba275e..3ca268d32 100644 --- a/docs/public/integrations/litellm.mdx +++ b/docs/public/integrations/litellm.mdx @@ -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 `. +**Unknown model from LiteLLM** — Check that the model's `api_model` matches the model name configured in LiteLLM, then run `fabro model test --model `. ## Further reading diff --git a/docs/public/integrations/modal.mdx b/docs/public/integrations/modal.mdx index 6edb816a1..749cba7a6 100644 --- a/docs/public/integrations/modal.mdx +++ b/docs/public/integrations/modal.mdx @@ -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. diff --git a/docs/public/integrations/openrouter.mdx b/docs/public/integrations/openrouter.mdx index d82dbc4e7..ec61ec565 100644 --- a/docs/public/integrations/openrouter.mdx +++ b/docs/public/integrations/openrouter.mdx @@ -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: ` 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: ` 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 `. +**Unknown model** — Confirm the model's `api_model` matches an OpenRouter slug exactly (including the vendor prefix), then run `fabro model test --model `. ## Further reading diff --git a/docs/public/reference/cli.mdx b/docs/public/reference/cli.mdx index a24a8b389..c5a0ff1d4 100644 --- a/docs/public/reference/cli.mdx +++ b/docs/public/reference/cli.mdx @@ -698,7 +698,7 @@ fabro model test [OPTIONS] | `-j, --jobs ` | Number of model tests to run concurrently in bulk mode
Default: `4` | | `-m, --model ` | Test a specific model | | `-p, --provider ` | Filter by provider | -| `--reasoning-effort ` | Request a reasoning-effort level
Values: `low`, `medium`, `high`, `xhigh`, `max` | +| `--reasoning-effort ` | Request a reasoning-effort level (minimal, low, medium, high, xhigh, max) | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--tools` | Run a multi-turn tool-use test | diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx index 12dee0001..05668b258 100644 --- a/docs/public/reference/sdk.mdx +++ b/docs/public/reference/sdk.mdx @@ -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> { - 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 = 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`: | 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`: ## 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`, 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> { - 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 `_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?; -``` - - -You cannot use both `.prompt()` and `.messages()` on the same request — this returns `SdkError::Configuration`. - - -#### GenerateParams reference - -| Method | Type | Description | -|---|---|---| -| `new(model, client)` | `(impl Into, Arc)` | Required. Model ID or alias plus the client to use | -| `.prompt(text)` | `impl Into` | Convenience: sends a single user message | -| `.messages(msgs)` | `Vec` | Full conversation history | -| `.system(text)` | `impl Into` | System prompt | -| `.tools(tools)` | `Vec` | 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` | Stop sequences | -| `.reasoning_effort(level)` | `impl Into` | e.g. `"low"`, `"medium"`, `"high"` | -| `.provider(name)` | `impl Into` | 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 from the final response -result.reasoning(); // Option — extended thinking content - -// GenerateResult fields -result.response; // Response — the final LLM response -result.tool_results; // Vec — from the final step -result.total_usage; // Usage — aggregated across all steps -result.steps; // Vec — one per tool round -result.output; // Option — 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 { - 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 { - 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`. The error type classifies failures to enable retry and failover decisions: +Every fallible operation returns `Result`, 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` | Seconds to wait before retrying (from provider `Retry-After` header) | -| `status_code()` | `Option` | 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 { - // Call your provider's API - todo!() - } - - async fn stream(&self, request: &Request) -> Result { - // 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), +)?; ``` diff --git a/docs/public/reference/user-configuration.mdx b/docs/public/reference/user-configuration.mdx index 853f6a549..5e0fe714e 100644 --- a/docs/public/reference/user-configuration.mdx +++ b/docs/public/reference/user-configuration.mdx @@ -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.]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github]`, `[run.hooks]`, `[run.agent.mcps]` | -| Shared LLM catalog | `[llm.providers.]`, provider-scoped `[llm.providers..models.]` offerings, limits, features, controls, and costs | +| Shared LLM catalog | `[llm]`, a lithos-llm catalog overlay: `[llm.providers.]`, `[llm.providers..models.]`, 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.]` +## `[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 +`_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.]` + +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 | required when `auth` present | Ordered credential refs. Accepted forms are `vault:`, `env:`, 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 | `[]` | 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..models.]` +## `[llm.providers..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..models.]` 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 | `[]` | 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 | `[]` | 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..models..limits]` +## `[llm.providers..models..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..models..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..models..controls]` - -| Key | Type / values | Default | Description | -|---|---|---|---| -| `reasoning_effort` | array | 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 | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. | - -## `[llm.providers..models..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..models..costs.speed.]` - -Per-speed cost overrides use the same keys as -`[llm.providers..models..costs]`. Each `` key -must be declared in -`[llm.providers..models..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]` diff --git a/lib/apps/fabro-cli/Cargo.toml b/lib/apps/fabro-cli/Cargo.toml index fa16a7375..2b9c78d48 100644 --- a/lib/apps/fabro-cli/Cargo.toml +++ b/lib/apps/fabro-cli/Cargo.toml @@ -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" } diff --git a/lib/apps/fabro-cli/src/args.rs b/lib/apps/fabro-cli/src/args.rs index b9790885d..f44d554f5 100644 --- a/lib/apps/fabro-cli/src/args.rs +++ b/lib/apps/fabro-cli/src/args.rs @@ -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, } @@ -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, + pub(crate) llm_provider: Option, #[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 { + value.parse().map_err(|_| { + format!( + "unknown reasoning effort '{value}'; expected one of: {}", + ReasoningEffort::ALL + .into_iter() + .map(ReasoningEffort::as_str) + .collect::>() + .join(", ") + ) + }) +} diff --git a/lib/apps/fabro-cli/src/command_context.rs b/lib/apps/fabro-cli/src/command_context.rs index bbd0b6d59..fea71ff73 100644 --- a/lib/apps/fabro-cli/src/command_context.rs +++ b/lib/apps/fabro-cli/src/command_context.rs @@ -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 { + 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; diff --git a/lib/apps/fabro-cli/src/commands/exec.rs b/lib/apps/fabro-cli/src/commands/exec.rs index be868c179..1363f3185 100644 --- a/lib/apps/fabro-cli/src/commands/exec.rs +++ b/lib/apps/fabro-cli/src/commands/exec.rs @@ -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) -> 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, - cost_source: Option, -} - -#[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 { - 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, Option) { - serde_json::from_str::(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 { + 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 { - 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 { - 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::(&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, ) -> AnyResult> { @@ -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)?; diff --git a/lib/apps/fabro-cli/src/commands/install.rs b/lib/apps/fabro-cli/src/commands/install.rs index 2a3f401b4..f7817bf2f 100644 --- a/lib/apps/fabro-cli/src/commands/install.rs +++ b/lib/apps/fabro-cli/src/commands/install.rs @@ -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 = LazyLock::new(|| { - Catalog::from_builtin().expect("embedded install model catalog should be valid") -}); +static INSTALL_CATALOG: LazyLock = 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 { - 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::>() - .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()), diff --git a/lib/apps/fabro-cli/src/commands/model.rs b/lib/apps/fabro-cli/src/commands/model.rs index 425fcd175..6a1eae9f5 100644 --- a/lib/apps/fabro-cli/src/commands/model.rs +++ b/lib/apps/fabro-cli/src/commands/model.rs @@ -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] diff --git a/lib/apps/fabro-cli/src/commands/provider/login.rs b/lib/apps/fabro-cli/src/commands/provider/login.rs index ea5e710f5..13369584f 100644 --- a/lib/apps/fabro-cli/src/commands/provider/login.rs +++ b/lib/apps/fabro-cli/src/commands/provider/login.rs @@ -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; diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs index a8596e6f1..accf27a4f 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs @@ -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 { + 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 { 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, diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs index 62bb37cd6..6aaebce34 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs @@ -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) "#); } diff --git a/lib/apps/fabro-cli/src/commands/run/runner.rs b/lib/apps/fabro-cli/src/commands/run/runner.rs index 9d5bb5388..d8cb67679 100644 --- a/lib/apps/fabro-cli/src/commands/run/runner.rs +++ b/lib/apps/fabro-cli/src/commands/run/runner.rs @@ -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, diff --git a/lib/apps/fabro-cli/src/commands/runs/mod.rs b/lib/apps/fabro-cli/src/commands/runs/mod.rs index 86d9d4967..cb658450a 100644 --- a/lib/apps/fabro-cli/src/commands/runs/mod.rs +++ b/lib/apps/fabro-cli/src/commands/runs/mod.rs @@ -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, diff --git a/lib/apps/fabro-cli/src/main.rs b/lib/apps/fabro-cli/src/main.rs index 9ab51b332..8374eaafb 100644 --- a/lib/apps/fabro-cli/src/main.rs +++ b/lib/apps/fabro-cli/src/main.rs @@ -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"), } diff --git a/lib/apps/fabro-cli/src/shared/provider_auth.rs b/lib/apps/fabro-cli/src/shared/provider_auth.rs index f61663e84..6de3d0652 100644 --- a/lib/apps/fabro-cli/src/shared/provider_auth.rs +++ b/lib/apps/fabro-cli/src/shared/provider_auth.rs @@ -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> { - Ok(Arc::new( - Catalog::from_builtin().context("failed to build provider auth catalog")?, - )) +fn default_catalog_for_provider_auth() -> Arc { + 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, ) -> 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 { @@ -193,7 +184,7 @@ async fn read_and_validate_api_key( } pub(crate) async fn pick_auth_method(provider: &ProviderId) -> Result { - 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 { - 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"); diff --git a/lib/apps/fabro-cli/tests/it/cmd/exec.rs b/lib/apps/fabro-cli/tests/it/cmd/exec.rs index 860fb65a4..7f2d74720 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/exec.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/exec.rs @@ -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" ); } diff --git a/lib/apps/fabro-cli/tests/it/cmd/model.rs b/lib/apps/fabro-cli/tests/it/cmd/model.rs index 28fe1fa01..0ed232758 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/model.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/model.rs @@ -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": [] diff --git a/lib/apps/fabro-cli/tests/it/cmd/model_test.rs b/lib/apps/fabro-cli/tests/it/cmd/model_test.rs index 01c57c32a..64c48781b 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/model_test.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/model_test.rs @@ -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 - 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 ----- diff --git a/lib/apps/fabro-mcp-server/Cargo.toml b/lib/apps/fabro-mcp-server/Cargo.toml index 758a11fd3..9267e133e 100644 --- a/lib/apps/fabro-mcp-server/Cargo.toml +++ b/lib/apps/fabro-mcp-server/Cargo.toml @@ -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" } diff --git a/lib/apps/fabro-server/Cargo.toml b/lib/apps/fabro-server/Cargo.toml index ab4475c15..97bf82e42 100644 --- a/lib/apps/fabro-server/Cargo.toml +++ b/lib/apps/fabro-server/Cargo.toml @@ -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" diff --git a/lib/apps/fabro-server/src/demo/mod.rs b/lib/apps/fabro-server/src/demo/mod.rs index 7a8b1b347..f3001e045 100644 --- a/lib/apps/fabro-server/src/demo/mod.rs +++ b/lib/apps/fabro-server/src/demo/mod.rs @@ -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 { - 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, }, ], diff --git a/lib/apps/fabro-server/src/diagnostics.rs b/lib/apps/fabro-server/src/diagnostics.rs index 297ee3347..c5d325175 100644 --- a/lib/apps/fabro-server/src/diagnostics.rs +++ b/lib/apps/fabro-server/src/diagnostics.rs @@ -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 anyhow::Result, + client: Arc, catalog: &Catalog, provider: ProviderId, auth_issue: Option, @@ -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!( diff --git a/lib/apps/fabro-server/src/error.rs b/lib/apps/fabro-server/src/error.rs index 6979b8bc1..35a4e5c39 100644 --- a/lib/apps/fabro-server/src/error.rs +++ b/lib/apps/fabro-server/src/error.rs @@ -179,9 +179,11 @@ impl From for ApiError { /// middleware, and local configuration failures, return 502. impl From 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}")), } } } diff --git a/lib/apps/fabro-server/src/install.rs b/lib/apps/fabro-server/src/install.rs index 0fae34bbb..63cc592b1 100644 --- a/lib/apps/fabro-server/src/install.rs +++ b/lib/apps/fabro-server/src/install.rs @@ -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> = LazyLock::new(|| { - Arc::new(Catalog::from_builtin().expect("embedded install model catalog should be valid")) -}); +static INSTALL_CATALOG: LazyLock> = + 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 { - 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 { +) -> anyhow::Result { + 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 { @@ -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" ); } diff --git a/lib/apps/fabro-server/src/run_compiler.rs b/lib/apps/fabro-server/src/run_compiler.rs index 732d68bbc..cfcbe14d5 100644 --- a/lib/apps/fabro-server/src/run_compiler.rs +++ b/lib/apps/fabro-server/src/run_compiler.rs @@ -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 { - 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, diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index 46acefeba..6b94bfc89 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -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, + llm_result: Result, ) -> 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, + llm_result: Result, ) -> 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, + llm_result: Result, ) -> 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 { - 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 { @@ -1879,20 +1875,18 @@ enabled = true ) -> Arc { 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( diff --git a/lib/apps/fabro-server/src/run_title_generation.rs b/lib/apps/fabro-server/src/run_title_generation.rs index 899442376..070f18d09 100644 --- a/lib/apps/fabro-server/src/run_title_generation.rs +++ b/lib/apps/fabro-server/src/run_title_generation.rs @@ -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>>) { + struct CapturedCall { + provider: String, + model: String, + max_output_tokens: Option, + } + + async fn title_with_mocked_response( + response_text: &str, + ) -> (String, Arc>>) { let captured = Arc::new(Mutex::new(Vec::new())); - let provider = Arc::new(CapturingProvider { + let provider: Arc = 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)]), - 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>>, + id: AdapterId, + captured: Arc>>, 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 { - 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 { + 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 { - Ok(Pin::from(Box::new(stream::empty::< - Result, - >()))) - } - - async fn count_input_tokens( - &self, - _request: &Request, - ) -> Result, LlmError> { - Ok(None) + async fn stream(&self, call: &ResolvedCall) -> Result { + let response = self.complete(call).await?; + Ok(fabro_llm::test_support::response_to_stream(response)) } } } diff --git a/lib/apps/fabro-server/src/serve.rs b/lib/apps/fabro-server/src/serve.rs index bcb4432b1..0c1c49aaf 100644 --- a/lib/apps/fabro-server/src/serve.rs +++ b/lib/apps/fabro-server/src/serve.rs @@ -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(), } } diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index d2f6ef193..70e22b006 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -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 { - resolve_llm_client_from_source(self.llm_source.as_ref(), self.catalog()).await + pub(crate) async fn resolve_llm_client(&self) -> anyhow::Result { + 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 { @@ -1411,14 +1410,14 @@ impl AppState { /// resolved twice. pub(crate) async fn resolve_llm_client_with_ready_ids( &self, - ) -> (anyhow::Result, Vec) { + ) -> (anyhow::Result, Vec) { 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::>(); + 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, catalog: Arc, -) -> anyhow::Result { - let resolved = source - .resolve(catalog.as_ref()) + http_client: Option, +) -> anyhow::Result { + 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 { @@ -2476,7 +2469,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result, 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, 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), ); } diff --git a/lib/apps/fabro-server/src/server/handler/billing.rs b/lib/apps/fabro-server/src/server/handler/billing.rs index cb0783088..aea19a325 100644 --- a/lib/apps/fabro-server/src/server/handler/billing.rs +++ b/lib/apps/fabro-server/src/server/handler/billing.rs @@ -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, ) -> 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::>(); (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() diff --git a/lib/apps/fabro-server/src/server/handler/completions.rs b/lib/apps/fabro-server/src/server/handler/completions.rs index b6d94d864..b043e54fc 100644 --- a/lib/apps/fabro-server/src/server/handler/completions.rs +++ b/lib/apps/fabro-server/src/server/handler/completions.rs @@ -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> { 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>, @@ -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 = 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 = 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> = 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 = 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 Arcs, 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, ) -> 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)) } diff --git a/lib/apps/fabro-server/src/server/handler/llm_sse.rs b/lib/apps/fabro-server/src/server/handler/llm_sse.rs index 36b826607..74afec73e 100644 --- a/lib/apps/fabro-server/src/server/handler/llm_sse.rs +++ b/lib/apps/fabro-server/src/server/handler/llm_sse.rs @@ -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": }` 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}"); diff --git a/lib/apps/fabro-server/src/server/handler/models.rs b/lib/apps/fabro-server/src/server/handler/models.rs index b66180f9f..cb47cea3f 100644 --- a/lib/apps/fabro-server/src/server/handler/models.rs +++ b/lib/apps/fabro-server/src/server/handler/models.rs @@ -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> { Router::new() .route("/models", get(list_models)) @@ -53,18 +57,26 @@ async fn list_models( State(state): State>, Query(params): Query, ) -> 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 = 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::>(); let has_more = data.len() > limit; @@ -105,7 +112,7 @@ async fn list_providers(_auth: RequiredUser, State(state): State>) .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::( - 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::() { + 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::>(); 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::>() + .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, })) diff --git a/lib/apps/fabro-server/src/server/handler/pair.rs b/lib/apps/fabro-server/src/server/handler/pair.rs index 6a8bf55f7..6047eadef 100644 --- a/lib/apps/fabro-server/src/server/handler/pair.rs +++ b/lib/apps/fabro-server/src/server/handler/pair.rs @@ -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, diff --git a/lib/apps/fabro-server/src/server/handler/pull_requests.rs b/lib/apps/fabro-server/src/server/handler/pull_requests.rs index 99e460b88..46b088d76 100644 --- a/lib/apps/fabro-server/src/server/handler/pull_requests.rs +++ b/lib/apps/fabro-server/src/server/handler/pull_requests.rs @@ -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::>(); + 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(); diff --git a/lib/apps/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs index 2c9076e89..4b4f28612 100644 --- a/lib/apps/fabro-server/src/server/handler/runs.rs +++ b/lib/apps/fabro-server/src/server/handler/runs.rs @@ -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::>(); + 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, client: LlmClient, model_id: String, - provider_id: fabro_model::ProviderId, + provider_id: fabro_types::ProviderId, } fn spawn_generated_title_task(task: GeneratedTitleTask) { diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index 871b94114..35203846b 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -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::>(); 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::() @@ -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, +) -> 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> { 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!({})), ®istry, 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); diff --git a/lib/apps/fabro-server/src/server/pull_request_supervisor.rs b/lib/apps/fabro-server/src/server/pull_request_supervisor.rs index a84371054..9cf91bf4f 100644 --- a/lib/apps/fabro-server/src/server/pull_request_supervisor.rs +++ b/lib/apps/fabro-server/src/server/pull_request_supervisor.rs @@ -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), diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index ff8604b88..68b2503a7 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -14,24 +14,23 @@ use chrono::{Duration as ChronoDuration, SubsecRound as _, Utc}; use fabro_automation::AutomationId; use fabro_config::bind::Bind; use fabro_config::{ - EnvironmentLayer, MergeMap, RunLayer, ServerSettingsBuilder, WorkflowSettingsBuilder, + EnvironmentLayer, LlmLayer, MergeMap, RunLayer, ServerSettingsBuilder, WorkflowSettingsBuilder, }; use fabro_interview::{ AnswerValue, ControlInterviewer, Interviewer, Question, WorkerControlDeliveryFrame, WorkerControlEnvelope, WorkerControlMessage, }; -use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest, TokenCounts}; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{Catalog, ModelRef, ProviderId, ReasoningEffort, Speed}; +use fabro_llm::lithos_catalog::Catalog; use fabro_types::settings::ServerAuthMethod; use fabro_types::settings::run::{ApprovalMode, EnvironmentProvider}; use fabro_types::{ AgentBackend, AttrValue, AuthMethod, BlobHash, CommandTermination, FailureCategory, - FailureDetail, GitRunTarget, Graph, InterviewQuestionRecord, Node, Outcome, ParallelBranchId, - QuestionType, RunId, RunSpec, RunTarget, SandboxProviderKind, StageContextWindowBreakdownItem, - StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection, - StageContextWindowStaleness, StageContextWindowWarning, StageModelUsage, StageTiming, - SuccessReason, SystemActorKind, WorkflowSettings, fixtures, test_support, + FailureDetail, GitRunTarget, Graph, InterviewQuestionRecord, ModelId, ModelRef, Node, Outcome, + ParallelBranchId, QuestionType, ReasoningEffort, RunId, RunSpec, RunTarget, + SandboxProviderKind, Speed, StageContextWindowBreakdownItem, StageContextWindowCategory, + StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness, + StageContextWindowWarning, StageModelUsage, StageTiming, SuccessReason, SystemActorKind, + TokenCounts, WorkflowSettings, fixtures, test_support, }; use fabro_util::check_report::CheckStatus; use fabro_workflow::records::CheckpointExt; @@ -48,6 +47,7 @@ use tracing::{Event as TracingEvent, Subscriber, subscriber}; use tracing_subscriber::layer::Context as SubscriberContext; use tracing_subscriber::prelude::*; use tracing_subscriber::{Layer, Registry}; +use ulid::Ulid; use super::*; use crate::automation_materializer::AutomationRunMaterializeInput; @@ -162,7 +162,7 @@ fn resolved_runtime_settings_from_toml(source: &str) -> ResolvedAppStateSettings resolved_runtime_settings_for_tests( server_settings_from_toml(source), manifest_run_defaults_from_toml(source), - LlmCatalogSettings::default(), + LlmLayer::default(), ) } @@ -179,7 +179,7 @@ fn spa_fixture_root() -> PathBuf { } fn state_test_catalog() -> Arc { - Arc::new(Catalog::from_builtin().expect("default catalog should build")) + Arc::new(fabro_llm::test_support::test_catalog()) } fn test_app_with_scheduler(state: Arc) -> Router { @@ -321,6 +321,38 @@ fn openai_responses_payload(text: &str) -> serde_json::Value { }) } +/// An operator-defined OpenAI-compatible provider `acme` offering one model, +/// `acme-large`, with `credential` (`env:NAME` or `vault:NAME`). +/// An operator-defined provider. Its API key is `ACME_API_KEY`, the name +/// lithos derives from the provider id, whether it lives in the vault or the +/// environment. +fn acme_overlay(base_url: &str) -> String { + format!( + r#" +[providers.acme] +display_name = "Acme" +adapter = "openai-compatible" +codec = "openai-chat" +base_url = {base_url} +auth = {{ type = "bearer" }} +priority = 120 +default_model = "acme-large" + +[providers.acme.metadata.agent] +profile = "openai" + +[providers.acme.models."acme-large"] +display_name = "Acme Large" +api_model = "acme-large" +limits = {{ context_tokens = 128000, max_output_tokens = 8192 }} +capabilities = {{ text = true, tools = true }} +probe = true + +"#, + base_url = toml::Value::String(base_url.to_string()), + ) +} + macro_rules! assert_status { ($response:expr, $expected:expr) => { fabro_test::assert_axum_status($response, $expected, concat!(file!(), ":", line!())) @@ -1754,7 +1786,9 @@ async fn resolve_llm_client_reads_openai_token_from_vault() { let llm_result = state.resolve_llm_client().await.unwrap(); - assert_eq!(llm_result.client.provider_names(), vec!["openai"]); + assert_eq!(llm_result.provider_ids(), vec![ + fabro_types::provider_ids::openai() + ]); assert!(llm_result.auth_issues.is_empty()); } @@ -1770,7 +1804,7 @@ async fn resolve_llm_client_ignores_env_lookup_provider_tokens() { let llm_result = state.resolve_llm_client().await.unwrap(); assert!( - llm_result.client.provider_names().is_empty(), + llm_result.provider_ids().is_empty(), "server LLM credentials should come from vault only" ); assert!(llm_result.auth_issues.is_empty()); @@ -1780,42 +1814,34 @@ struct FailingCredentialSource; #[async_trait::async_trait] impl CredentialSource for FailingCredentialSource { - async fn resolve( + async fn credentials( &self, - catalog: &fabro_model::Catalog, - ) -> anyhow::Result { - let _ = catalog; - Err(anyhow::Error::new(std::io::Error::other("credential leaf")) - .context("credential source context")) + provider: &fabro_llm::lithos_catalog::CatalogProvider, + ) -> Result { + Err(fabro_auth::ResolveError::NotConfigured( + provider.id().clone(), + )) } async fn configured_providers( &self, - catalog: &fabro_model::Catalog, - ) -> Vec { + catalog: &fabro_llm::lithos_catalog::Catalog, + ) -> Vec { let _ = catalog; Vec::new() } } #[tokio::test] -async fn resolve_llm_client_from_source_preserves_credential_source_chain() { +async fn resolve_llm_client_from_source_with_no_credentials_has_no_ready_providers() { let catalog = state_test_catalog(); - let Err(err) = resolve_llm_client_from_source(&FailingCredentialSource, catalog).await else { - panic!("expected credential resolution to fail"); - }; - let chain = err.chain().map(ToString::to_string).collect::>(); + let built = resolve_llm_client_from_source(Arc::new(FailingCredentialSource), catalog, None) + .await + .expect("a client with no credentials still builds"); - assert!( - chain - .iter() - .any(|cause| cause == "credential source context"), - "expected context in chain, got {chain:#?}" - ); - assert!( - chain.iter().any(|cause| cause == "credential leaf"), - "expected source in chain, got {chain:#?}" - ); + assert!(built.ready.is_empty()); + assert!(built.auth_issues.is_empty()); + assert!(built.provider_ids().is_empty()); } #[tokio::test] @@ -1844,7 +1870,7 @@ async fn llm_source_configured_providers_reads_openai_token_from_vault() { .llm_source .configured_providers(catalog.as_ref()) .await, - vec![ProviderId::openai()] + vec![fabro_types::provider_ids::openai()] ); } @@ -1885,22 +1911,13 @@ async fn resolve_llm_client_uses_vault_key_without_env_lookup_openai_settings() let llm_result = state.resolve_llm_client().await.unwrap(); let response = llm_result .client - .complete(&LlmRequest { - model: "gpt-5.4".to_string(), - messages: vec![LlmMessage::user("Hello")], - provider: Some("openai".to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - }) + .complete( + fabro_types::Request::builder() + .model("openai/gpt-5.4") + .user("Hello") + .build() + .unwrap(), + ) .await .unwrap(); @@ -2080,7 +2097,7 @@ fn slack_app_state_with_settings_and_secret_sources( resolved_settings: resolved_runtime_settings_for_tests( settings, RunLayer::default(), - LlmCatalogSettings::default(), + LlmLayer::default(), ), registry_factory_override: None, max_concurrent_runs: 5, @@ -2238,7 +2255,7 @@ fn slack_service_respects_disabled_server_config_even_with_vault_tokens() { resolved_settings: resolved_runtime_settings_for_tests( settings, RunLayer::default(), - LlmCatalogSettings::default(), + LlmLayer::default(), ), registry_factory_override: None, max_concurrent_runs: 5, @@ -2595,7 +2612,7 @@ methods = ["dev-token"] resolved_settings: resolved_runtime_settings_for_tests( server_settings, RunLayer::default(), - LlmCatalogSettings::default(), + LlmLayer::default(), ), registry_factory_override: None, max_concurrent_runs: 5, @@ -5573,35 +5590,8 @@ async fn validate_endpoint_returns_workflow_summary_without_preflight_checks() { #[tokio::test] async fn validate_endpoint_uses_app_state_catalog_for_model_diagnostics() { - let llm_catalog_settings: LlmCatalogSettings = toml::from_str( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" - -[providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[models."acme-large"] -provider = "acme" -display_name = "Acme Large" -family = "acme" -default = true - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - ) - .expect("catalog fixture should parse"); let state = TestAppStateBuilder::new() - .llm_catalog_settings(llm_catalog_settings) + .llm_overlay_toml(&acme_overlay("https://api.acme.test/v1")) .build(); let app = crate::test_support::build_test_router(state); let dot = r#"digraph Test { @@ -6139,14 +6129,12 @@ fn context_window_event( visit, event: fabro_agent::AgentEvent::AssistantMessage { text: "assistant response".to_string(), - model: ModelRef { - provider: ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: None, - }, + model: ModelRef::new( + fabro_types::provider_ids::openai(), + ModelId::new("gpt-5.4"), + ), usage: TokenCounts::default(), - cost_usd: None, - cost_source: None, + cost: None, tool_call_count: 0, context_window: Some(context_window), reasoning: None, @@ -7218,26 +7206,20 @@ async fn list_run_stages_includes_stage_model_usage() { fn test_billed_usage( model_id: &str, - input_tokens: i64, - output_tokens: i64, -) -> fabro_model::BilledModelUsage { - serde_json::from_value(json!({ - "input": { - "usage": { - "model": { - "provider": "openai", - "model_id": model_id - }, - "tokens": { - "input_tokens": input_tokens, - "output_tokens": output_tokens - } - }, - "facts": { "algorithm": "openai" } + input_tokens: u64, + output_tokens: u64, +) -> fabro_types::BilledModelUsage { + let mut usage = fabro_types::BilledModelUsage::new( + ModelRef::new(fabro_types::provider_ids::openai(), ModelId::new(model_id)), + TokenCounts { + input: input_tokens, + output: output_tokens, + ..TokenCounts::default() }, - "total_usd_micros": input_tokens + output_tokens - })) - .unwrap() + None, + ); + usage.total_usd_micros = Some(i64::try_from(input_tokens + output_tokens).unwrap()); + usage } async fn create_billed_retry_run(state: &Arc, run_id: RunId) { @@ -7812,7 +7794,7 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() { create_billed_retry_run(&state, run_id).await; let success_usage = test_billed_usage("gpt-new", 200, 20); - let mut latest_outcome: Outcome> = Outcome::success(); + let mut latest_outcome: Outcome> = Outcome::success(); latest_outcome.usage = Some(success_usage); latest_outcome.timing = Some(fabro_types::StageTiming::wall_only(800)); let run_store = state.stores.runs.open_run(&run_id).await.unwrap(); @@ -8409,7 +8391,7 @@ fn create_github_token_app_state_with_env_lookup( token, github_api_base_url, env_lookup, - LlmCatalogSettings::default(), + LlmLayer::default(), ) } @@ -8417,7 +8399,7 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings( token: Option<&str>, github_api_base_url: Option, env_lookup: impl Fn(&str) -> Option + Send + Sync + 'static, - llm_catalog_settings: LlmCatalogSettings, + llm_overlay: LlmLayer, ) -> Arc { let (store, artifact_store) = test_store_bundle(); let vault_path = test_secret_store_path(); @@ -8445,7 +8427,7 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings( resolved_settings: resolved_runtime_settings_for_tests( github_token_settings(), RunLayer::default(), - llm_catalog_settings, + llm_overlay, ), registry_factory_override: None, max_concurrent_runs: 5, @@ -8792,7 +8774,7 @@ async fn model_api_keeps_duplicate_ids_provider_scoped_and_selects_ready_priorit let aggregator_upstream = MockServer::start(); let direct_probe = direct_upstream.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .json_body_includes(r#"{"model":"portable-model"}"#); then.status(200) .header("content-type", "application/json") @@ -8812,7 +8794,7 @@ async fn model_api_keeps_duplicate_ids_provider_scoped_and_selects_ready_priorit }); let aggregator_probe = aggregator_upstream.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .json_body_includes(r#"{"model":"vendor/portable-model"}"#); then.status(200) .header("content-type", "application/json") @@ -8830,63 +8812,51 @@ async fn model_api_keeps_duplicate_ids_provider_scoped_and_selects_ready_priorit } })); }); - let settings: LlmCatalogSettings = toml::from_str(&format!( + let overlay = format!( r#" [providers.direct] display_name = "Direct" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "{}" +adapter = "openai-compatible" +codec = "openai-chat" +base_url = {direct} +auth = {{ type = "bearer" }} priority = 120 +default_model = "portable-model" -[providers.direct.auth] -credentials = ["vault:DIRECT_API_KEY"] +[providers.direct.metadata.agent] +profile = "openai" [providers.direct.models.portable-model] display_name = "Portable (direct)" -family = "portable" aliases = ["portable"] -default = true - -[providers.direct.models.portable-model.limits] -context_window = 1000 - -[providers.direct.models.portable-model.features] -tools = false -vision = false -reasoning = false +api_model = "portable-model" +limits = {{ context_tokens = 1000, max_output_tokens = 500 }} +capabilities = {{ text = true }} [providers.aggregator] display_name = "Aggregator" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "{}" +adapter = "openai-compatible" +codec = "openai-chat" +base_url = {aggregator} +auth = {{ type = "bearer" }} priority = 110 +default_model = "portable-model" -[providers.aggregator.auth] -credentials = ["vault:AGGREGATOR_API_KEY"] +[providers.aggregator.metadata.agent] +profile = "openai" [providers.aggregator.models.portable-model] -api_id = "vendor/portable-model" display_name = "Portable (aggregator)" -family = "portable" aliases = ["portable"] -default = true - -[providers.aggregator.models.portable-model.limits] -context_window = 1000 - -[providers.aggregator.models.portable-model.features] -tools = false -vision = false -reasoning = false +api_model = "vendor/portable-model" +limits = {{ context_tokens = 1000, max_output_tokens = 500 }} +capabilities = {{ text = true }} "#, - direct_upstream.base_url(), - aggregator_upstream.base_url(), - )) - .unwrap(); + direct = toml::Value::String(direct_upstream.base_url()), + aggregator = toml::Value::String(aggregator_upstream.base_url()), + ); let state = TestAppStateBuilder::new() - .llm_catalog_settings(settings) + .llm_overlay_toml(&overlay) .vault_entries([ ("DIRECT_API_KEY", "direct-test-key"), ("AGGREGATOR_API_KEY", "aggregator-test-key"), @@ -9007,7 +8977,7 @@ async fn test_model_forwards_and_validates_reasoning_effort() { let upstream = MockServer::start(); let completion = upstream.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .json_body_includes(r#"{"model":"acme-reasoner","reasoning_effort":"low"}"#); then.status(200) .header("content-type", "application/json") @@ -9025,40 +8995,31 @@ async fn test_model_forwards_and_validates_reasoning_effort() { } })); }); - let settings: LlmCatalogSettings = toml::from_str(&format!( + let overlay = format!( r#" [providers.acme] display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "{}" +adapter = "openai-compatible" +codec = "openai-chat" +base_url = {base_url} +auth = {{ type = "bearer" }} priority = 120 +default_model = "acme-reasoner" -[providers.acme.auth] -credentials = ["vault:ACME_API_KEY"] +[providers.acme.metadata.agent] +profile = "openai" [providers.acme.models.acme-reasoner] display_name = "Acme Reasoner" -family = "acme" -default = true - -[providers.acme.models.acme-reasoner.limits] -context_window = 128000 - -[providers.acme.models.acme-reasoner.features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" - -[providers.acme.models.acme-reasoner.controls] -reasoning_effort = ["low", "high"] +api_model = "acme-reasoner" +limits = {{ context_tokens = 128000, max_output_tokens = 8192 }} +capabilities = {{ text = true, tools = true, reasoning = true, reasoning_effort = {{ minimal = false, low = true, medium = false, high = true, xhigh = false, max = false }} }} +protocol_options = {{ reasoning_effort_levels = true }} "#, - upstream.base_url() - )) - .expect("catalog fixture should parse"); + base_url = toml::Value::String(upstream.base_url()), + ); let state = TestAppStateBuilder::new() - .llm_catalog_settings(settings) + .llm_overlay_toml(&overlay) .vault_entries([("ACME_API_KEY", "acme-test-key")]) .build(); let app = crate::test_support::build_test_router(state); @@ -9083,11 +9044,10 @@ reasoning_effort = ["low", "high"] .body(Body::empty()) .unwrap(); let response = app.oneshot(unsupported).await.unwrap(); - let body = response_json!(response, StatusCode::OK).await; - assert_eq!(body["status"], "error"); + let body = response_json!(response, StatusCode::BAD_REQUEST).await; assert_eq!( - body["error_message"], - "Invalid request: model 'acme-reasoner' does not support reasoning_effort 'medium'; allowed values: low, high" + body["errors"][0]["detail"], + "model 'acme-reasoner' does not support reasoning_effort 'medium'; allowed values: low, high" ); completion.assert_calls(1); } @@ -9097,7 +9057,7 @@ async fn test_provider_credentials_uses_app_state_catalog() { let upstream = MockServer::start(); let completion = upstream.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .header("authorization", "Bearer sk-test"); then.status(200) .header("content-type", "application/json") @@ -9114,41 +9074,11 @@ async fn test_provider_credentials_uses_app_state_catalog() { "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} })); }); - let llm_catalog_settings: LlmCatalogSettings = toml::from_str(&format!( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "{}" -priority = 120 - -[providers.acme.auth] -credentials = ["vault:ACME_API_KEY"] - -[models."acme-probe"] -provider = "acme" -api_id = "test-model" -display_name = "Acme Probe" -family = "acme" -default = true -probe = true - -[models."acme-probe".limits] -context_window = 128000 - -[models."acme-probe".features] -tools = false -vision = false -reasoning = false -"#, - upstream.base_url() - )) - .expect("catalog fixture should parse"); + let overlay = acme_overlay(&upstream.base_url()); let state = TestAppStateBuilder::new() .runtime_settings(default_test_server_settings(), RunLayer::default()) .max_concurrent_runs(5) - .llm_catalog_settings(llm_catalog_settings) + .llm_overlay_toml(&overlay) .build(); let app = crate::test_support::build_test_router(state); @@ -9264,38 +9194,12 @@ async fn list_models_marks_configured_true_when_provider_has_credential_material #[tokio::test] async fn list_models_marks_configured_false_when_provider_cannot_register() { - let llm_catalog_settings: LlmCatalogSettings = toml::from_str( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -priority = 120 - -[providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[models."acme-large"] -provider = "acme" -display_name = "Acme Large" -family = "acme" -default = true - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - ) - .expect("catalog fixture should parse"); + let overlay = acme_overlay("https://api.acme.test/v1"); let state = TestAppStateBuilder::new() .runtime_settings(default_test_server_settings(), RunLayer::default()) .max_concurrent_runs(5) .env_lookup(|name| (name == "ACME_API_KEY").then(|| "acme-key".to_string())) - .llm_catalog_settings(llm_catalog_settings) + .llm_overlay_toml(&overlay) .build(); let app = crate::test_support::build_test_router(state); @@ -9360,36 +9264,9 @@ async fn list_models_unknown_provider_returns_empty_page() { #[tokio::test] async fn list_models_uses_app_state_catalog_overrides() { - let llm_catalog_settings: LlmCatalogSettings = toml::from_str( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" -priority = 120 - -[providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[models."acme-large"] -provider = "acme" -display_name = "Acme Large" -family = "acme" -default = true - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - ) - .expect("catalog fixture should parse"); + let overlay = acme_overlay("https://api.acme.test/v1"); let state = TestAppStateBuilder::new() - .llm_catalog_settings(llm_catalog_settings) + .llm_overlay_toml(&overlay) .build(); let app = crate::test_support::build_test_router(state); @@ -9453,19 +9330,21 @@ async fn list_providers_marks_configured_per_provider_and_omits_secrets() { // `model_count` and `default_model` must reflect the catalog truth for // this exact provider, not merely be populated. - let catalog = Catalog::builtin(); - let expected_model_count = catalog.list(Some(&ProviderId::anthropic())).len(); + let catalog = state_test_catalog(); + let expected_model_count = fabro_llm::catalog::provider_models( + fabro_llm::catalog::provider(&catalog, "anthropic").expect("anthropic should be listed"), + ) + .len(); assert_eq!( anthropic["model_count"].as_u64(), Some(expected_model_count as u64), "anthropic model_count should match the catalog" ); - let expected_default = catalog - .default_for_provider(&ProviderId::anthropic()) + let expected_default = fabro_llm::catalog::default_model(&catalog, "anthropic") .expect("anthropic should have a catalog default model"); assert_eq!( anthropic["default_model"].as_str(), - Some(expected_default.id.as_str()), + Some(expected_default.model.id().as_str()), "anthropic default_model should match the catalog" ); @@ -9650,7 +9529,7 @@ async fn test_providers_auth_issue_returns_error_without_upstream_call() { let results = body["data"].as_array().unwrap(); assert_eq!(results.len(), 1); - assert_eq!(results[0]["provider"], "openai"); + assert_eq!(results[0]["provider"], "openai-codex"); assert!(results[0]["model_id"].is_null()); assert_eq!(results[0]["status"], "error"); assert!( @@ -9668,36 +9547,16 @@ async fn test_providers_auth_issue_returns_error_without_upstream_call() { #[tokio::test] async fn test_providers_registration_issue_returns_error_without_probe() { - let llm_catalog_settings: LlmCatalogSettings = toml::from_str( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" - -[providers.acme.auth] -credentials = ["vault:ACME_API_KEY"] - -[models."acme-probe"] -provider = "acme" -display_name = "Acme Probe" -family = "acme" -default = true -probe = true - -[models."acme-probe".limits] -context_window = 128000 - -[models."acme-probe".features] -tools = true -vision = false -reasoning = false -"#, - ) - .expect("catalog fixture should parse"); + // An adapter lithos does not ship cannot be built, so the provider is + // configured (it has a vault key) yet unavailable. + let overlay = acme_overlay("https://api.acme.test/v1").replace( + "adapter = \"openai-compatible\"", + "adapter = \"not-an-adapter\"", + ); let state = TestAppStateBuilder::new() .runtime_settings(default_test_server_settings(), RunLayer::default()) .max_concurrent_runs(5) - .llm_catalog_settings(llm_catalog_settings) + .llm_overlay_toml(&overlay) .build(); state .stores @@ -9725,7 +9584,7 @@ reasoning = false results[0]["error_message"] .as_str() .unwrap() - .contains("does not configure base_url") + .contains("not-an-adapter") ); assert_eq!(body["summary"]["status"], "error"); assert_eq!(body["summary"]["total"], 1); @@ -9761,61 +9620,47 @@ async fn test_providers_mixed_results_preserve_catalog_order_and_counts() { })); }) .await; - let llm_catalog_settings: LlmCatalogSettings = toml::from_str(&format!( + let overlay = format!( r#" [providers.zeta] display_name = "Zeta" adapter = "openai" -base_url = "{base_url}" +codec = "openai-responses" +base_url = {base_url} +auth = {{ type = "bearer" }} +priority = 50 +default_model = "zeta-probe" -[providers.zeta.auth] -credentials = ["vault:ZETA_API_KEY"] +[providers.zeta.models.zeta-probe] +display_name = "Zeta Probe" +api_model = "zeta-probe" +limits = {{ context_tokens = 128000, max_output_tokens = 8192 }} +capabilities = {{ text = true, tools = true }} +probe = true [providers.alpha] display_name = "Alpha" adapter = "openai" -base_url = "{base_url}" +codec = "openai-responses" +base_url = {base_url} +auth = {{ type = "bearer" }} +priority = 40 +default_model = "alpha-probe" -[providers.alpha.auth] -credentials = ["vault:ALPHA_API_KEY"] - -[models."zeta-probe"] -provider = "zeta" -display_name = "Zeta Probe" -family = "zeta" -default = true -probe = true - -[models."zeta-probe".limits] -context_window = 128000 - -[models."zeta-probe".features] -tools = true -vision = false -reasoning = false - -[models."alpha-probe"] -provider = "alpha" +[providers.alpha.models.alpha-probe] display_name = "Alpha Probe" -family = "alpha" -default = true +api_model = "alpha-probe" +limits = {{ context_tokens = 128000, max_output_tokens = 8192 }} +capabilities = {{ text = true, tools = true }} probe = true -[models."alpha-probe".limits] -context_window = 128000 - -[models."alpha-probe".features] -tools = true -vision = false -reasoning = false "#, - base_url = server.url("/v1") - )) - .expect("catalog fixture should parse"); + base_url = toml::Value::String(server.base_url()), + ); let state = TestAppStateBuilder::new() .runtime_settings(default_test_server_settings(), RunLayer::default()) .max_concurrent_runs(5) - .llm_catalog_settings(llm_catalog_settings) + .llm_overlay_toml(&overlay) .build(); state .stores @@ -11449,7 +11294,7 @@ async fn pull_request_creation_recovers_durable_request_after_crash_gap() { Some("ghu_test"), Some(github.base_url()), |_| None, - llm_catalog_settings_with_provider_base_url("openai", llm.url("/v1")), + llm_overlay_with_provider_base_url("openai", llm.url("/v1")), ); state .stores @@ -11546,12 +11391,17 @@ async fn pull_request_creation_returns_the_active_durable_request() { )) .await; - let configured_provider_ids = state.ready_llm_provider_ids().await; - let expected_default_model = state - .catalog() - .default_for_configured_ids(&configured_provider_ids) - .id - .to_string(); + let configured_provider_ids = state + .ready_llm_provider_ids() + .await + .into_iter() + .collect::>(); + let expected_default_model = + fabro_llm::catalog::default_for_ready(&state.catalog(), &configured_provider_ids) + .expect("a ready provider should have a default model") + .model + .id() + .to_string(); let request_body = json!({ "force": false, "model": null @@ -16924,11 +16774,10 @@ async fn get_aggregate_billing_returns_provider_model_speed_identity() { .expect("aggregate billing lock"); agg.total_runs = 1; agg.by_model.insert( - ModelRef { - provider: ProviderId::anthropic(), - model_id: "claude-opus-4-6".into(), - speed: None, - }, + ModelRef::new( + fabro_types::provider_ids::anthropic(), + ModelId::new("claude-opus-4-6"), + ), ModelBillingTotals { stages: 1, billing: BilledTokenCounts { @@ -16943,11 +16792,11 @@ async fn get_aggregate_billing_returns_provider_model_speed_identity() { }, ); agg.by_model.insert( - ModelRef { - provider: ProviderId::anthropic(), - model_id: "claude-opus-4-6".into(), - speed: Some(Speed::Fast), - }, + ModelRef::new( + fabro_types::provider_ids::anthropic(), + ModelId::new("claude-opus-4-6"), + ) + .with_speed(Some(Speed::Fast)), ModelBillingTotals { stages: 1, billing: BilledTokenCounts { @@ -17004,11 +16853,7 @@ async fn get_aggregate_billing_saturates_total_cost_across_models() { .expect("aggregate billing lock"); for (model_id, total_usd_micros) in [("maximum", i64::MAX), ("one", 1)] { agg.by_model.insert( - ModelRef { - provider: ProviderId::openai(), - model_id: model_id.into(), - speed: None, - }, + ModelRef::new(fabro_types::provider_ids::openai(), ModelId::new(model_id)), ModelBillingTotals { stages: 1, billing: BilledTokenCounts { @@ -17052,11 +16897,10 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { }, by_model: vec![ fabro_workflow::ProjectionBillingByModel { - model: ModelRef { - provider: ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: None, - }, + model: ModelRef::new( + fabro_types::provider_ids::openai(), + ModelId::new("gpt-5.4"), + ), stages: 1, billing: BilledTokenCounts { input_tokens: 100, @@ -17069,11 +16913,11 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { }, }, fabro_workflow::ProjectionBillingByModel { - model: ModelRef { - provider: ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: Some(Speed::Fast), - }, + model: ModelRef::new( + fabro_types::provider_ids::openai(), + ModelId::new("gpt-5.4"), + ) + .with_speed(Some(Speed::Fast)), stages: 1, billing: BilledTokenCounts { input_tokens: 200, @@ -17096,39 +16940,33 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { assert_eq!(accumulator.total_timing.wall_time_ms, 2000); assert_eq!(accumulator.by_model.len(), 2); assert_eq!( - accumulator.by_model[&ModelRef { - provider: ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: None, - }] + accumulator.by_model + [&ModelRef::new(fabro_types::provider_ids::openai(), ModelId::new("gpt-5.4"))] .stages, 1 ); assert_eq!( - accumulator.by_model[&ModelRef { - provider: ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: None, - }] + accumulator.by_model + [&ModelRef::new(fabro_types::provider_ids::openai(), ModelId::new("gpt-5.4"))] .billing .input_tokens, 100 ); assert_eq!( - accumulator.by_model[&ModelRef { - provider: ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: Some(Speed::Fast), - }] + accumulator.by_model[&ModelRef::new( + fabro_types::provider_ids::openai(), + ModelId::new("gpt-5.4") + ) + .with_speed(Some(Speed::Fast))] .stages, 1 ); assert_eq!( - accumulator.by_model[&ModelRef { - provider: ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: Some(Speed::Fast), - }] + accumulator.by_model[&ModelRef::new( + fabro_types::provider_ids::openai(), + ModelId::new("gpt-5.4") + ) + .with_speed(Some(Speed::Fast))] .billing .input_tokens, 200 @@ -17273,7 +17111,7 @@ level = "debug" ); assert_eq!( resolved_run.model.name.as_deref(), - Some("claude-sonnet-4-5"), + Some("claude-sonnet-4.5"), ); // Server-operational fields (auth, integrations, etc.) deliberately @@ -18468,14 +18306,12 @@ async fn attach_stream_replays_agent_message_reasoning() { visit: 1, event: fabro_agent::AgentEvent::AssistantMessage { text: String::new(), - model: ModelRef { - provider: ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: None, - }, + model: ModelRef::new( + fabro_types::provider_ids::openai(), + ModelId::new("gpt-5.4"), + ), usage: TokenCounts::default(), - cost_usd: None, - cost_source: None, + cost: None, tool_call_count: 1, context_window: None, reasoning: Some(fabro_types::ReasoningOutput::new( @@ -18683,7 +18519,7 @@ async fn create_completion_unknown_provider_returns_clear_error() { "messages": [ { "role": "user", - "content": [{"kind": "text", "data": "hi"}] + "content": [{"type": "text", "text": "hi"}] } ] }) @@ -18727,7 +18563,7 @@ async fn create_completion_unsupported_reasoning_efforts_return_bad_request() { "messages": [ { "role": "user", - "content": [{"kind": "text", "data": "hi"}] + "content": [{"type": "text", "text": "hi"}] } ] }) @@ -18738,11 +18574,8 @@ async fn create_completion_unsupported_reasoning_efforts_return_bad_request() { let response = app.clone().oneshot(req).await.unwrap(); let body = response_json!(response, StatusCode::BAD_REQUEST).await; assert_eq!( - body["errors"][0]["detail"], - format!( - "model 'kimi-k3' does not support reasoning_effort '{effort}'; allowed values: low, high, max" - ), - "stream={stream}" + body["errors"][0]["detail"], "model moonshot/kimi-k3 does not support reasoning", + "stream={stream} effort={effort}" ); } } @@ -18754,7 +18587,7 @@ async fn create_completion_unsupported_reasoning_efforts_return_bad_request() { async fn create_completion_returns_disjoint_usage_buckets() { let upstream = MockServer::start(); let completion = upstream.mock(|when, then| { - when.method(POST).path("/chat/completions"); + when.method(POST).path("/v1/chat/completions"); then.status(200) .header("content-type", "application/json") .json_body(json!({ @@ -18795,7 +18628,7 @@ async fn create_completion_returns_disjoint_usage_buckets() { "stream": false, "messages": [{ "role": "user", - "content": [{"kind": "text", "data": "hi"}] + "content": [{"type": "text", "text": "hi"}] }] }) .to_string(), @@ -18807,11 +18640,11 @@ async fn create_completion_returns_disjoint_usage_buckets() { assert_eq!( body["usage"], json!({ - "input_tokens": 50, - "output_tokens": 10, - "reasoning_tokens": 20, - "cache_read_tokens": 50, - "cache_write_tokens": 100 + "input": 50, + "output": 10, + "reasoning": 20, + "cache_read": 50, + "cache_write": 100 }) ); completion.assert(); @@ -18822,42 +18655,15 @@ async fn create_completion_default_model_uses_app_state_catalog() { let upstream = MockServer::start(); let completion = upstream.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .json_body_includes(r#"{"model":"acme-large"}"#); then.status(500) .header("content-type", "application/json") .json_body(json!({"error": {"message": "expected test failure"}})); }); - let llm_catalog_settings: LlmCatalogSettings = toml::from_str(&format!( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "{}" -priority = 120 - -[providers.acme.auth] -credentials = ["vault:ACME_API_KEY"] - -[providers.acme.models."acme-large"] -display_name = "Acme Large" -family = "acme" -default = true - -[providers.acme.models."acme-large".limits] -context_window = 128000 - -[providers.acme.models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - upstream.base_url() - )) - .expect("catalog fixture should parse"); + let overlay = acme_overlay(&upstream.base_url()); let state = TestAppStateBuilder::new() - .llm_catalog_settings(llm_catalog_settings) + .llm_overlay_toml(&overlay) .vault_entries([("ACME_API_KEY", "acme-test-key")]) .build(); let app = crate::test_support::build_test_router(state); @@ -18872,7 +18678,7 @@ reasoning = false "messages": [ { "role": "user", - "content": [{"kind": "text", "data": "hi"}] + "content": [{"type": "text", "text": "hi"}] } ] }) @@ -18889,7 +18695,7 @@ reasoning = false .contains("expected test failure"), "unexpected error body: {body:?}" ); - completion.assert(); + assert!(completion.calls() >= 1); } #[tokio::test] @@ -18897,7 +18703,7 @@ async fn create_completion_structured_output_forwards_reasoning_effort() { let upstream = MockServer::start(); let completion = upstream.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .json_body_includes(r#"{"model":"kimi-k3","reasoning_effort":"high"}"#); then.status(200) .header("content-type", "application/json") @@ -18944,7 +18750,7 @@ async fn create_completion_structured_output_forwards_reasoning_effort() { "messages": [ { "role": "user", - "content": [{"kind": "text", "data": "Return the answer."}] + "content": [{"type": "text", "text": "Return the answer."}] } ] }) diff --git a/lib/apps/fabro-server/src/server_secrets.rs b/lib/apps/fabro-server/src/server_secrets.rs index e6097712e..a72949618 100644 --- a/lib/apps/fabro-server/src/server_secrets.rs +++ b/lib/apps/fabro-server/src/server_secrets.rs @@ -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, -} - -impl LlmClientResult { - pub(crate) fn provider_ids(&self) -> Vec { - self.client - .provider_names() - .into_iter() - .map(ProviderId::new) - .collect() - } -} - #[cfg(test)] mod tests { use std::collections::HashMap; diff --git a/lib/apps/fabro-server/src/test_support.rs b/lib/apps/fabro-server/src/test_support.rs index 3593fad6a..3b78dc2ef 100644 --- a/lib/apps/fabro-server/src/test_support.rs +++ b/lib/apps/fabro-server/src/test_support.rs @@ -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, default_environment_provider: Option, env_lookup: EnvLookup, - llm_catalog_settings: LlmCatalogSettings, + llm_overlay: LlmLayer, automation_materializer: Option, #[cfg(test)] worker_runtime: Option>, @@ -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, base_url: impl Into, ) -> 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 { .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, base_url: impl Into, -) -> 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 { @@ -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, } } diff --git a/lib/apps/fabro-server/tests/it/api/install.rs b/lib/apps/fabro-server/tests/it/api/install.rs index cfd23dc14..75fa128be 100644 --- a/lib/apps/fabro-server/tests/it/api/install.rs +++ b/lib/apps/fabro-server/tests/it/api/install.rs @@ -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("")), ); diff --git a/lib/apps/fabro-server/tests/it/api/runs.rs b/lib/apps/fabro-server/tests/it/api/runs.rs index ecdf66ebc..c3167a7a7 100644 --- a/lib/apps/fabro-server/tests/it/api/runs.rs +++ b/lib/apps/fabro-server/tests/it/api/runs.rs @@ -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() diff --git a/lib/apps/fabro-server/tests/it/api/sessions.rs b/lib/apps/fabro-server/tests/it/api/sessions.rs index 9508e07fc..257700f8f 100644 --- a/lib/apps/fabro-server/tests/it/api/sessions.rs +++ b/lib/apps/fabro-server/tests/it/api/sessions.rs @@ -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); diff --git a/lib/apps/fabro-server/tests/it/helpers.rs b/lib/apps/fabro-server/tests/it/helpers.rs index ba093b2f2..790fc8dd2 100644 --- a/lib/apps/fabro-server/tests/it/helpers.rs +++ b/lib/apps/fabro-server/tests/it/helpers.rs @@ -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, )) diff --git a/lib/apps/fabro-server/tests/it/scenario/dry_run.rs b/lib/apps/fabro-server/tests/it/scenario/dry_run.rs index 355d7e96d..a8b81c715 100644 --- a/lib/apps/fabro-server/tests/it/scenario/dry_run.rs +++ b/lib/apps/fabro-server/tests/it/scenario/dry_run.rs @@ -17,7 +17,7 @@ fn completion_request(stream: bool) -> Request { .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::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] diff --git a/lib/apps/fabro-server/tests/it/scenario/run_completion.rs b/lib/apps/fabro-server/tests/it/scenario/run_completion.rs index 55ba6c938..3f2073886 100644 --- a/lib/apps/fabro-server/tests/it/scenario/run_completion.rs +++ b/lib/apps/fabro-server/tests/it/scenario/run_completion.rs @@ -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 = @@ -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), diff --git a/lib/components/fabro-agent/Cargo.toml b/lib/components/fabro-agent/Cargo.toml index 91f2e0e98..6ecc8ca81 100644 --- a/lib/components/fabro-agent/Cargo.toml +++ b/lib/components/fabro-agent/Cargo.toml @@ -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" diff --git a/lib/components/fabro-agent/src/agent_profile.rs b/lib/components/fabro-agent/src/agent_profile.rs index 15140a3e2..a8021f886 100644 --- a/lib/components/fabro-agent/src/agent_profile.rs +++ b/lib/components/fabro-agent/src/agent_profile.rs @@ -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> { None } fn tool_registry(&self) -> &ToolRegistry; @@ -34,31 +40,32 @@ pub trait AgentProfile: Send + Sync { fn knowledge_cutoff(&self) -> Option { 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> { 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 { - self.catalog_model().and_then(Model::max_output) + fn max_output_tokens(&self) -> Option { + 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"); } diff --git a/lib/components/fabro-agent/src/apply_patch.rs b/lib/components/fabro-agent/src/apply_patch.rs index f3d264c66..b237e4a06 100644 --- a/lib/components/fabro-agent/src/apply_patch.rs +++ b/lib/components/fabro-agent/src/apply_patch.rs @@ -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") ); } diff --git a/lib/components/fabro-agent/src/cli.rs b/lib/components/fabro-agent/src/cli.rs index f7cfdd7f9..84f257831 100644 --- a/lib/components/fabro-agent/src/cli.rs +++ b/lib/components/fabro-agent/src/cli.rs @@ -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 { - 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, -) -> anyhow::Result { +) -> 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> { @@ -266,17 +257,12 @@ fn profile_kind_for_provider( provider_id: &ProviderId, model: Option<&str>, ) -> anyhow::Result { - 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 { + async fn handle(&self, call: Call, next: Next) -> Result { 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 { - 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 { + async fn handle(&self, call: Call, next: Next) -> Result { 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!("")) ); - let response = next(request).await?; - eprintln!( - "{}\n{}", - s.dim.apply_to("[verbose] response:"), - serde_json::to_string_pretty(&response) - .unwrap_or_else(|e| format!("")) - ); - 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!("")) + ); + } + Ok(output) } +} - async fn handle_stream( - &self, - request: Request, - next: NextStreamFn, - ) -> Result { - 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> { + 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, ) -> 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, - mcp_servers: Vec, -) -> 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, catalog: Arc, ) -> 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, -) -> 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, catalog: Arc, ) -> 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, + catalog: Arc, + styles: &'static Styles, +) -> anyhow::Result<()> { + let available: std::collections::HashSet = + 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 { - 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(), ) diff --git a/lib/components/fabro-agent/src/compaction.rs b/lib/components/fabro-agent/src/compaction.rs index ad634955d..168c908fe 100644 --- a/lib/components/fabro-agent/src/compaction.rs +++ b/lib/components/fabro-agent/src/compaction.rs @@ -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 { +fn summary_max_tokens(reasoning_by_default: bool, max_output: Option) -> 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(), }); diff --git a/lib/components/fabro-agent/src/config.rs b/lib/components/fabro-agent/src/config.rs index cc207c3f4..b23c9b89c 100644 --- a/lib/components/fabro-agent/src/config.rs +++ b/lib/components/fabro-agent/src/config.rs @@ -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, /// Override the provider's default max_tokens when set. /// Node-level attribute takes priority over the model catalog default. - pub max_tokens: Option, + pub max_tokens: Option, + /// 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(), diff --git a/lib/components/fabro-agent/src/context_window.rs b/lib/components/fabro-agent/src/context_window.rs index 851a51ea6..1d7964ee5 100644 --- a/lib/components/fabro-agent/src/context_window.rs +++ b/lib/components/fabro-agent/src/context_window.rs @@ -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 { - warnings - .iter() +fn warnings_from_estimate(estimate: &TokenEstimate) -> Vec { + 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, @@ -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, 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, tools: Vec) -> 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 { - 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 diff --git a/lib/components/fabro-agent/src/error.rs b/lib/components/fabro-agent/src/error.rs index b274bd4b7..b3fb48592 100644 --- a/lib/components/fabro-agent/src/error.rs +++ b/lib/components/fabro-agent/src/error.rs @@ -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 for Error { + fn from(error: fabro_llm::Error) -> Self { + Self::Llm(LlmError::from(error)) + } +} + +impl From for CompactionError { + fn from(error: fabro_llm::Error) -> Self { + Self::Llm(LlmError::from(error)) + } +} + pub type Result = std::result::Result; #[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 = 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"); diff --git a/lib/components/fabro-agent/src/file_tracker.rs b/lib/components/fabro-agent/src/file_tracker.rs index 1420822eb..f9982a513 100644 --- a/lib/components/fabro-agent/src/file_tracker.rs +++ b/lib/components/fabro-agent/src/file_tracker.rs @@ -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::>(); 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()); diff --git a/lib/components/fabro-agent/src/history.rs b/lib/components/fabro-agent/src/history.rs index 23f8ab7a7..c898d0dce 100644 --- a/lib/components/fabro-agent/src/history.rs +++ b/lib/components/fabro-agent/src/history.rs @@ -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(), }); diff --git a/lib/components/fabro-agent/src/lib.rs b/lib/components/fabro-agent/src/lib.rs index f1c9c5f17..cf714c9b1 100644 --- a/lib/components/fabro-agent/src/lib.rs +++ b/lib/components/fabro-agent/src/lib.rs @@ -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, diff --git a/lib/components/fabro-agent/src/loop_detection.rs b/lib/components/fabro-agent/src/loop_detection.rs index c81996c78..0b313e731 100644 --- a/lib/components/fabro-agent/src/loop_detection.rs +++ b/lib/components/fabro-agent/src/loop_detection.rs @@ -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 { }; 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(), } diff --git a/lib/components/fabro-agent/src/mcp_integration.rs b/lib/components/fabro-agent/src/mcp_integration.rs index fe766562e..afd62ec2b 100644 --- a/lib/components/fabro-agent/src/mcp_integration.rs +++ b/lib/components/fabro-agent/src/mcp_integration.rs @@ -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) -> Vec { 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"], diff --git a/lib/components/fabro-agent/src/profiles/anthropic.rs b/lib/components/fabro-agent/src/profiles/anthropic.rs index 338c141fa..dfcd73340 100644 --- a/lib/components/fabro-agent/src/profiles/anthropic.rs +++ b/lib/components/fabro-agent/src/profiles/anthropic.rs @@ -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 { - 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); } diff --git a/lib/components/fabro-agent/src/profiles/claude5.rs b/lib/components/fabro-agent/src/profiles/claude5.rs index 97cdeff1d..66ad7a841 100644 --- a/lib/components/fabro-agent/src/profiles/claude5.rs +++ b/lib/components/fabro-agent/src/profiles/claude5.rs @@ -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"); } diff --git a/lib/components/fabro-agent/src/profiles/claude5_tools.rs b/lib/components/fabro-agent/src/profiles/claude5_tools.rs index 6f53622b0..069dad511 100644 --- a/lib/components/fabro-agent/src/profiles/claude5_tools.rs +++ b/lib/components/fabro-agent/src/profiles/claude5_tools.rs @@ -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, 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"], &[ diff --git a/lib/components/fabro-agent/src/profiles/gemini.rs b/lib/components/fabro-agent/src/profiles/gemini.rs index f6b3d494d..d4e812b78 100644 --- a/lib/components/fabro-agent/src/profiles/gemini.rs +++ b/lib/components/fabro-agent/src/profiles/gemini.rs @@ -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 { - 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"); } diff --git a/lib/components/fabro-agent/src/profiles/gpt56.rs b/lib/components/fabro-agent/src/profiles/gpt56.rs index d0fb93ed1..2bb2dd1c4 100644 --- a/lib/components/fabro-agent/src/profiles/gpt56.rs +++ b/lib/components/fabro-agent/src/profiles/gpt56.rs @@ -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 { - Arc::new(Catalog::from_builtin().unwrap()) + Arc::new(fabro_test_catalog()) } /// OpenRouter ships disabled in the built-in catalog. fn catalog_with_openrouter() -> Arc { - 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); } } diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs index e04a7d19c..ee2dc4d87 100644 --- a/lib/components/fabro-agent/src/profiles/kimi.rs +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -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 { - 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 { - 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()); diff --git a/lib/components/fabro-agent/src/profiles/kimi_tools.rs b/lib/components/fabro-agent/src/profiles/kimi_tools.rs index 07b6ec05e..885943ef9 100644 --- a/lib/components/fabro-agent/src/profiles/kimi_tools.rs +++ b/lib/components/fabro-agent/src/profiles/kimi_tools.rs @@ -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) -> 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(); diff --git a/lib/components/fabro-agent/src/profiles/mod.rs b/lib/components/fabro-agent/src/profiles/mod.rs index 2ecc237bb..27905fa86 100644 --- a/lib/components/fabro-agent/src/profiles/mod.rs +++ b/lib/components/fabro-agent/src/profiles/mod.rs @@ -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) -> 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 { - 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"), diff --git a/lib/components/fabro-agent/src/profiles/openai.rs b/lib/components/fabro-agent/src/profiles/openai.rs index d3010c859..29018cfb5 100644 --- a/lib/components/fabro-agent/src/profiles/openai.rs +++ b/lib/components/fabro-agent/src/profiles/openai.rs @@ -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 { - 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 ); diff --git a/lib/components/fabro-agent/src/question_tools.rs b/lib/components/fabro-agent/src/question_tools.rs index 63eb58522..4a7e0d8f8 100644 --- a/lib/components/fabro-agent/src/question_tools.rs +++ b/lib/components/fabro-agent/src/question_tools.rs @@ -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()); diff --git a/lib/components/fabro-agent/src/session.rs b/lib/components/fabro-agent/src/session.rs index 3d0cf6ef0..cc562da08 100644 --- a/lib/components/fabro-agent/src/session.rs +++ b/lib/components/fabro-agent/src/session.rs @@ -2,23 +2,19 @@ use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime}; -use fabro_auth::CredentialSource; -use fabro_llm::client::Client; -use fabro_llm::error::ProviderErrorKind; -use fabro_llm::generate::StreamAccumulator; -use fabro_llm::provider::StreamEventStream; -use fabro_llm::types::{ - ContentPart, Message as LlmMessage, ReasoningEffort, Request, RetryPolicy, StreamEvent, - TokenCounts, ToolChoice, +use fabro_llm::types::ContentBlockKind; +use fabro_llm::{ + CallContext, Client, FinishReason, LlmError, Request, Response, RetryClassification, + RetryListener, RetryStage, StreamEvent, reasoning, }; -use fabro_llm::{Error as LlmError, retry}; use fabro_mcp::config::{McpServerSettings, McpTransport}; use fabro_mcp::connection_manager::McpConnectionManager; use fabro_mcp::http_transport; -use fabro_model::{AgentProfileKind, Catalog, ModelId, ModelRef, Speed, UsdMicros}; use fabro_types::{ - AgentToolSummary, LlmOutputKind, LlmRetryPhase, PermissionLevel, Principal, SessionMessage, - SessionRecord, StageContextWindowProjection, SteeringMessage, + AgentProfileKind, AgentToolSummary, LlmOutputKind, LlmRetryPhase, Message as LlmMessage, + ModelId, ModelRef, PermissionLevel, Principal, ReasoningEffort, Role, SessionMessage, + SessionRecord, Speed, StageContextWindowProjection, SteeringMessage, TokenCounts, ToolCall, + ToolChoice, UsdMicros, billing, }; use fabro_util::shell; use futures::StreamExt; @@ -104,17 +100,69 @@ fn record_elapsed(start: &mut Option, total: &mut Duration) { /// events below identify the first observed content kind. fn first_output_kind(event: &StreamEvent) -> Option { match event { - StreamEvent::ReasoningStart | StreamEvent::ReasoningDelta { .. } => { - Some(LlmOutputKind::Reasoning) - } - StreamEvent::TextStart { .. } | StreamEvent::TextDelta { .. } => Some(LlmOutputKind::Text), - StreamEvent::ToolCallStart { .. } - | StreamEvent::ToolCallDelta { .. } - | StreamEvent::ToolCallEnd { .. } => Some(LlmOutputKind::ToolCall), + StreamEvent::ContentBlockStart { kind, .. } => match kind { + ContentBlockKind::Text => Some(LlmOutputKind::Text), + ContentBlockKind::Reasoning => Some(LlmOutputKind::Reasoning), + ContentBlockKind::ToolCall { .. } => Some(LlmOutputKind::ToolCall), + _ => None, + }, + StreamEvent::ReasoningDelta { .. } => Some(LlmOutputKind::Reasoning), + StreamEvent::TextDelta { .. } => Some(LlmOutputKind::Text), + StreamEvent::ToolCallDelta { .. } => Some(LlmOutputKind::ToolCall), + StreamEvent::ContentBlockEnd { part, .. } => match part { + fabro_types::ContentPart::Text { .. } => Some(LlmOutputKind::Text), + fabro_types::ContentPart::Reasoning(_) => Some(LlmOutputKind::Reasoning), + fabro_types::ContentPart::ToolCall(_) => Some(LlmOutputKind::ToolCall), + _ => None, + }, _ => None, } } +/// A stream ended with a response the agent cannot act on: the provider +/// stopped at its output limit or before the response was complete. Replayed +/// like a transient failure so provisional tool calls never run. +fn incomplete_response_error(response: &Response) -> fabro_llm::Error { + let (code, message) = match response.finish_reason { + FinishReason::Length => ( + "length", + "the provider stopped at its output limit before completing the response", + ), + _ => ( + "incomplete_response", + "the provider ended without a complete response", + ), + }; + fabro_llm::Error::new(fabro_llm::ErrorKind::StreamDecode, message) + .with_provider(response.model.provider().clone()) + .with_provider_code(code) + .with_retry(RetryClassification::Safe) +} + +/// How one inference turn ended. +enum TurnOutcome { + Completed(Box), + /// A steer interrupt cancelled the round; the caller re-iterates. + Interrupted, + /// The session was cancelled. + Cancelled, + Failed(fabro_llm::Error), +} + +/// How one stream attempt within a turn ended. +enum AttemptOutcome { + Completed(Box), + Interrupted, + Cancelled, + Failed(fabro_llm::Error), +} + +struct StreamAttempt { + /// Whether this attempt delivered text or reasoning to the user. + visible_output: bool, + outcome: AttemptOutcome, +} + impl SteeringItem { #[must_use] pub fn actor(&self) -> Option<&Principal> { @@ -457,34 +505,6 @@ impl Session { } } - /// Build a session from a credential source and catalog. Resolves the LLM - /// client once at construction and caches it for the session's lifetime. - /// Sessions are bounded (≤ 1 hour); cached client is fine within that - /// window. For longer-lived contexts (workflow runs) hold a source and - /// catalog, not a session. - /// - /// # Errors - /// - /// Returns an error if `Client::from_source` fails (e.g. vault unreachable, - /// OAuth refresh failed). - pub async fn from_source( - source: &dyn CredentialSource, - catalog: Arc, - provider_profile: Arc, - sandbox: Arc, - config: SessionOptions, - subagent_supervisor: Option, - ) -> Result { - let client = Client::from_source(source, catalog).await?; - Ok(Self::new( - client, - provider_profile, - sandbox, - config, - subagent_supervisor, - )) - } - pub fn from_record( record: &SessionRecord, runtime_context: &[SessionMessage], @@ -545,7 +565,7 @@ impl Session { } #[must_use] - pub fn provider_id(&self) -> fabro_model::ProviderId { + pub fn provider_id(&self) -> fabro_types::ProviderId { self.provider_profile.provider_id() } @@ -1117,35 +1137,17 @@ impl Session { Error::Interrupted(reason) } - fn emit_llm_error(&mut self, err: LlmError) -> Error { + fn emit_llm_error(&mut self, err: fabro_llm::Error) -> Error { + let err = LlmError::from(err); self.event_emitter.emit(self.id.clone(), AgentEvent::Error { error: Error::Llm(err.clone()), }); - if is_auth_error(&err) { + if err.is_auth_error() { self.transition(SessionState::Closed); } Error::Llm(err) } - async fn open_stream_with_retry( - &mut self, - client: &Client, - request: &Request, - retry_policy: &RetryPolicy, - ) -> Result { - let stream_result = retry::retry(retry_policy, || { - let client = client.clone(); - let request = request.clone(); - async move { client.stream(&request).await } - }) - .await; - - match stream_result { - Ok(stream) => Ok(stream), - Err(err) => Err(self.emit_llm_error(err)), - } - } - #[must_use] pub fn followup_queue_handle(&self) -> Arc>> { self.followup_queue.clone() @@ -1275,8 +1277,8 @@ impl Session { } #[must_use] - pub fn last_input_usage(&self) -> TokenCounts { - self.last_input_usage.clone() + pub const fn last_input_usage(&self) -> TokenCounts { + self.last_input_usage } #[must_use] @@ -1423,8 +1425,6 @@ impl Session { usage_accumulator: &mut TokenCounts, cost_accumulator: &mut Option, ) -> Result, Error> { - const STREAM_CONSUME_RETRIES: usize = 3; - if self.state == SessionState::Closed { return Err(Error::SessionClosed); } @@ -1538,15 +1538,15 @@ impl Session { let pending_task_reminder = self.task_reminder_if_needed(); // Build request - let built_request = self.build_request(pending_task_reminder.as_ref()); + let built_request = self.build_request(pending_task_reminder.as_ref())?; let local_context_window = built_request.context_window.clone(); let request = built_request.request; - let requested_model = ModelRef { - provider: self.provider_profile.provider_id(), - model_id: ModelId::new(self.provider_profile.model()), - speed: self.config.speed, - }; + let requested_model = ModelRef::new( + self.provider_profile.provider_id(), + ModelId::new(self.provider_profile.model()), + ) + .with_speed(self.config.speed); // Open the inference bracket for this round. The request is built // and compaction has run, so this is the last point before the @@ -1557,338 +1557,51 @@ impl Session { requested_model: requested_model.clone(), }); - // Call LLM (streaming) with retry for transient errors - let retry_emitter = self.event_emitter.clone(); - let retry_session_id = self.id.clone(); - let retry_provider = requested_model.provider.to_string(); - let retry_model = requested_model.model_id.to_string(); - let retry_policy = RetryPolicy { - max_retries: 3, - on_retry: Some(std::sync::Arc::new(move |err, attempt, delay| { - retry_emitter.emit(retry_session_id.clone(), AgentEvent::LlmRetry { - provider: retry_provider.clone(), - model: retry_model.clone(), - attempt: attempt as usize, - delay_secs: delay.as_secs_f64(), - error: err.clone(), - phase: LlmRetryPhase::Open, - }); - })), - ..Default::default() - }; - let client = self.llm_client.clone(); - let cancel_token_for_select = self.cancel_token.clone(); let mut inference_start = Some(Instant::now()); - let stream_outcome: Option> = tokio::select! { - biased; - () = round_token.cancelled() => None, - () = cancel_token_for_select.cancelled() => None, - stream = self.open_stream_with_retry(&client, &request, &retry_policy) => Some(stream), - }; - let mut event_stream = if let Some(stream) = stream_outcome { - match stream { - Ok(stream) => stream, - Err(err) => { - record_elapsed(&mut inference_start, &mut timing.inference); - return Err(err); - } - } - } else { - record_elapsed(&mut inference_start, &mut timing.inference); - if self.cancel_token.is_cancelled() { - self.shutdown(SessionShutdownReason::Cancelled).await; - return Err(self.interrupted_error()); - } - // Round-only cancel before stream opened — re-iterate to - // pick up the steer. - continue; - }; - - // Consume the stream, retrying up to 3 times if the provider - // closes the stream without sending a Finish event. If visible - // output was already emitted, clear it before replaying the turn. - let mut response = None; - // Set true if a steer-interrupt cancelled the round mid-stream so - // we can clear partial output and `continue` after the loop. - let mut steer_interrupted = false; - let mut visible_output_present = false; - - 'streamattempts: for stream_attempt in 0..=STREAM_CONSUME_RETRIES { - let mut accumulator = StreamAccumulator::new(); - let mut attempt_emitted_output = false; - let mut stream_error = None; - // Re-armed per attempt: a replayed turn discards everything - // the previous attempt produced, so its first output is a new - // observation rather than a continuation. - let mut first_output_emitted = false; - - loop { - let chunk = tokio::select! { - biased; - () = round_token.cancelled() => None, - () = self.cancel_token.cancelled() => None, - next = event_stream.next() => Some(next), - }; - let Some(event_opt) = chunk else { - // One of the cancellation tokens fired. - break; - }; - let Some(event_result) = event_opt else { - // Stream ended normally. - break; - }; - match event_result { - Ok(event) => { - if !first_output_emitted { - if let Some(kind) = first_output_kind(&event) { - first_output_emitted = true; - self.event_emitter - .emit(self.id.clone(), AgentEvent::LlmFirstOutput { kind }); - } - } - match &event { - StreamEvent::TextDelta { ref delta, .. } => { - attempt_emitted_output = true; - visible_output_present = true; - self.event_emitter.emit( - self.id.clone(), - AgentEvent::TextDelta { - delta: delta.clone(), - }, - ); - } - StreamEvent::ReasoningDelta { ref delta } => { - attempt_emitted_output = true; - visible_output_present = true; - self.event_emitter.emit( - self.id.clone(), - AgentEvent::ReasoningDelta { - delta: delta.clone(), - }, - ); - } - _ => {} - } - accumulator.process(&event); - } - Err(err) => { - stream_error = Some(err); - break; - } - } - } - - // If terminal cancel fired, drop the stream and bail out. - if self.cancel_token.is_cancelled() { - drop(event_stream); - record_elapsed(&mut inference_start, &mut timing.inference); - self.shutdown(SessionShutdownReason::Cancelled).await; - return Err(self.interrupted_error()); - } - - // If only the round token fired (steer interrupt), drop the - // stream now; we'll clear partial output and continue below. - if round_token.is_cancelled() { - drop(event_stream); - steer_interrupted = true; - break 'streamattempts; - } - - if let Some(resp) = accumulator.response().cloned() { - response = Some(resp); - break; - } - - if let Some(err) = stream_error { - let can_retry = err.retryable() && stream_attempt < STREAM_CONSUME_RETRIES; - let retry_attempt = u32::try_from(stream_attempt).unwrap_or(u32::MAX); - let retry_delay = can_retry - .then(|| retry::retry_delay(&retry_policy, &err, retry_attempt)) - .flatten(); - - if let Some(delay) = retry_delay { - tracing::warn!( - attempt = stream_attempt + 1, - max = STREAM_CONSUME_RETRIES, - error = %err, - delay_secs = delay.as_secs_f64(), - "LLM stream failed mid-turn, retrying turn" - ); - if attempt_emitted_output { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }, - ); - visible_output_present = false; - } - // Emitted directly rather than through - // `retry_policy.on_retry` so the event can name the - // consume loop as the source of `attempt`; the policy - // callback only ever runs for stream-open failures. - self.event_emitter - .emit(self.id.clone(), AgentEvent::LlmRetry { - provider: requested_model.provider.to_string(), - model: requested_model.model_id.to_string(), - attempt: stream_attempt, - delay_secs: delay.as_secs_f64(), - error: err, - phase: LlmRetryPhase::Consume, - }); - - let delay_outcome = tokio::select! { - biased; - () = round_token.cancelled() => None, - () = self.cancel_token.cancelled() => None, - () = time::sleep(delay) => Some(()), - }; - if delay_outcome.is_none() { - steer_interrupted = - round_token.is_cancelled() && !self.cancel_token.is_cancelled(); - break 'streamattempts; - } - - let cancel_token_for_select = self.cancel_token.clone(); - let retry_outcome: Option> = tokio::select! { - biased; - () = round_token.cancelled() => None, - () = cancel_token_for_select.cancelled() => None, - stream = self.open_stream_with_retry(&client, &request, &retry_policy) => Some(stream), - }; - event_stream = if let Some(stream) = retry_outcome { - match stream { - Ok(stream) => stream, - Err(err) => { - record_elapsed(&mut inference_start, &mut timing.inference); - return Err(err); - } - } - } else { - steer_interrupted = - round_token.is_cancelled() && !self.cancel_token.is_cancelled(); - break 'streamattempts; - }; - continue 'streamattempts; - } - - if visible_output_present { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }, - ); - } - record_elapsed(&mut inference_start, &mut timing.inference); - return Err(self.emit_llm_error(err)); - } - - // No Finish event — retry if we have attempts left - if stream_attempt < STREAM_CONSUME_RETRIES { - tracing::warn!( - attempt = stream_attempt + 1, - max = STREAM_CONSUME_RETRIES, - "Stream ended without Finish event, retrying turn" - ); - if attempt_emitted_output { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }, - ); - visible_output_present = false; - } - // The only mid-turn restart that reaches no error handler: - // without this the round replays and discards its output - // with nothing on the durable stream to show for it. - self.event_emitter - .emit(self.id.clone(), AgentEvent::LlmRetry { - provider: requested_model.provider.to_string(), - model: requested_model.model_id.to_string(), - attempt: stream_attempt, - delay_secs: 0.0, - error: LlmError::Stream { - message: "Stream ended without a finish event".to_string(), - source: None, - }, - phase: LlmRetryPhase::Consume, - }); - let cancel_token_for_select = self.cancel_token.clone(); - let retry_outcome: Option> = tokio::select! { - biased; - () = round_token.cancelled() => None, - () = cancel_token_for_select.cancelled() => None, - stream = self.open_stream_with_retry(&client, &request, &retry_policy) => Some(stream), - }; - event_stream = if let Some(stream) = retry_outcome { - match stream { - Ok(stream) => stream, - Err(err) => { - record_elapsed(&mut inference_start, &mut timing.inference); - return Err(err); - } - } - } else { - steer_interrupted = - round_token.is_cancelled() && !self.cancel_token.is_cancelled(); - break 'streamattempts; - }; - } - } + let turn = self + .run_inference_turn(&request, &requested_model, &round_token) + .await; record_elapsed(&mut inference_start, &mut timing.inference); - // Mid-LLM steer interrupt: drop the unrecorded turn, clear any - // partial visible output, and re-iterate. The next turn's - // top-of-loop drain delivers the steer as the next user message. - if steer_interrupted { - if visible_output_present { - self.event_emitter - .emit(self.id.clone(), AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }); + let response = match turn { + TurnOutcome::Completed(response) => *response, + TurnOutcome::Interrupted => { + // Mid-LLM steer interrupt: the unrecorded turn is dropped + // and any partial visible output has been cleared. The + // next turn's top-of-loop drain delivers the steer as the + // next user message. + continue; } - continue; - } - - let Some(response) = response else { - if visible_output_present { - self.event_emitter - .emit(self.id.clone(), AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }); + TurnOutcome::Cancelled => { + self.shutdown(SessionShutdownReason::Cancelled).await; + return Err(self.interrupted_error()); + } + TurnOutcome::Failed(error) => { + return Err(self.emit_llm_error(error)); } - return Err(self.emit_llm_error(LlmError::Stream { - message: "Stream ended without a Finish event (after retries)".into(), - source: None, - })); }; // Record assistant turn let text = response.text(); - let tool_calls = response.tool_calls(); + let tool_calls: Vec = response.tool_calls().cloned().collect(); // Normalize before the response's content moves into history. - let reasoning = response.reasoning_output(); + let reasoning = reasoning::normalize(&response.content); let provider_parts: Vec<_> = response - .message .content .iter() - .filter(|p| matches!(p, ContentPart::Other { .. } | ContentPart::Thinking(_))) + .filter(|part| reasoning::is_provider_part(part)) .cloned() .collect(); - let usage = response.usage.clone(); + let usage = response.usage; let context_window = Some(context_window_from_response_usage( &local_context_window, &usage, )); - *usage_accumulator += usage.clone(); - UsdMicros::accumulate(cost_accumulator, response.cost_usd.map(UsdMicros::from_usd)); + billing::add_usage(usage_accumulator, usage); + UsdMicros::accumulate( + cost_accumulator, + response.cost.as_ref().map(UsdMicros::from_cost), + ); if let Some(reminder) = pending_task_reminder { self.history.push(reminder); @@ -1897,28 +1610,21 @@ impl Session { content: text.clone(), tool_calls: tool_calls.clone(), provider_parts, - usage: Box::new(usage), - response_id: response.id.clone(), + usage, + response_id: response.id.clone().unwrap_or_default(), timestamp: SystemTime::now(), }); - // Emit AssistantMessage with enriched data from the response - let model = ModelRef { - provider: self.provider_profile.provider_id(), - model_id: if response.model.is_empty() { - self.provider_profile.model().into() - } else { - response.model.clone().into() - }, - speed: self.config.speed, - }; + // Emit AssistantMessage with enriched data from the response. The + // response names the route that actually answered, which failover + // or a stand-in provider can make differ from the request. + let model = ModelRef::from_handle(&response.model, self.config.speed); self.event_emitter .emit(self.id.clone(), AgentEvent::AssistantMessage { text: text.clone(), model, - usage: response.usage.clone(), - cost_usd: response.cost_usd, - cost_source: response.cost_source, + usage, + cost: response.cost, tool_call_count: tool_calls.len(), context_window, reasoning, @@ -2029,6 +1735,284 @@ impl Session { } } + /// Run one inference turn to a final response, replaying the turn when a + /// stream fails after it already produced visible output. + /// + /// Failures before visible output are the client's to retry: the lithos + /// retry middleware reconnects them and reports each attempt through the + /// call's [`RetryListener`], which this method turns into `LlmRetry` + /// events. Once text or reasoning has reached the user no middleware can + /// replay the turn without duplicating output, so the agent does it here: + /// it clears the shown output with `AssistantOutputReplace`, waits the + /// delay the same policy computes, and streams the turn again. A stream + /// whose final response ends `Length` or `Incomplete` is not a completed + /// turn: it is replayed like a failure, and its provisional tool calls + /// are never executed. + async fn run_inference_turn( + &mut self, + request: &Request, + requested_model: &ModelRef, + round_token: &CancellationToken, + ) -> TurnOutcome { + let policy = self.config.replay_retry_policy; + let mut replay_attempt: u32 = 1; + // Whether text or reasoning from an earlier attempt is still shown. + let mut visible_output_present = false; + + loop { + let attempt = self + .stream_attempt(request, requested_model, round_token) + .await; + let visible_this_attempt = attempt.visible_output; + visible_output_present |= visible_this_attempt; + + let error = match attempt.outcome { + AttemptOutcome::Completed(response) => return TurnOutcome::Completed(response), + AttemptOutcome::Cancelled => { + if visible_output_present { + self.clear_visible_output(); + } + return TurnOutcome::Cancelled; + } + AttemptOutcome::Interrupted => { + if visible_output_present { + self.clear_visible_output(); + } + return TurnOutcome::Interrupted; + } + AttemptOutcome::Failed(error) => error, + }; + + // A failure before any visible output already went through the + // client's retry middleware; replaying it here would multiply the + // attempts. Only a turn the user has seen part of is replayed. + let delay = if visible_this_attempt { + policy.next_delay(replay_attempt, &error) + } else { + None + }; + let Some(delay) = delay else { + if visible_output_present { + self.clear_visible_output(); + } + return TurnOutcome::Failed(error); + }; + + tracing::warn!( + attempt = replay_attempt, + error = %error, + delay_secs = delay.as_secs_f64(), + "LLM stream failed after visible output, replaying turn" + ); + if visible_output_present { + self.clear_visible_output(); + visible_output_present = false; + } + self.event_emitter + .emit(self.id.clone(), AgentEvent::LlmRetry { + provider: requested_model.provider.to_string(), + model: requested_model.model_id.to_string(), + attempt: usize::try_from(replay_attempt).unwrap_or(usize::MAX), + delay_secs: delay.as_secs_f64(), + error: LlmError::from(&error), + phase: LlmRetryPhase::Consume, + }); + + let delay_outcome = tokio::select! { + biased; + () = round_token.cancelled() => None, + () = self.cancel_token.cancelled() => None, + () = time::sleep(delay) => Some(()), + }; + if delay_outcome.is_none() { + return if self.cancel_token.is_cancelled() { + TurnOutcome::Cancelled + } else { + TurnOutcome::Interrupted + }; + } + replay_attempt = replay_attempt.saturating_add(1); + } + } + + /// Open one stream and consume it to its final response. + async fn stream_attempt( + &mut self, + request: &Request, + requested_model: &ModelRef, + round_token: &CancellationToken, + ) -> StreamAttempt { + let mut attempt = StreamAttempt { + visible_output: false, + outcome: AttemptOutcome::Cancelled, + }; + + // Bind the lithos call to the agent's cancellation so the client + // releases the provider connection when the round or session ends. + let mut context = CallContext::new(); + let call_cancellation = context.cancellation().clone(); + context + .extensions_mut() + .insert(self.retry_listener(requested_model)); + let cancel_watcher = { + let round_token = round_token.clone(); + let cancel_token = self.cancel_token.clone(); + tokio::spawn(async move { + tokio::select! { + () = round_token.cancelled() => {} + () = cancel_token.cancelled() => {} + } + call_cancellation.cancel(); + }) + }; + + let client = self.llm_client.clone(); + let stream_outcome = tokio::select! { + biased; + () = round_token.cancelled() => None, + () = self.cancel_token.cancelled() => None, + stream = client.stream_with_context(request.clone(), context) => Some(stream), + }; + let mut event_stream = match stream_outcome { + Some(Ok(stream)) => stream, + Some(Err(error)) => { + cancel_watcher.abort(); + attempt.outcome = self.classify_stream_end(Err(error), round_token); + return attempt; + } + None => { + cancel_watcher.abort(); + attempt.outcome = self.cancellation_outcome(round_token); + return attempt; + } + }; + + // Re-armed per attempt: a replayed turn discards everything the + // previous attempt produced, so its first output is a new observation + // rather than a continuation. + let mut first_output_emitted = false; + let outcome = loop { + let chunk = tokio::select! { + biased; + () = round_token.cancelled() => None, + () = self.cancel_token.cancelled() => None, + next = event_stream.next() => Some(next), + }; + let Some(item) = chunk else { + break self.cancellation_outcome(round_token); + }; + let Some(item) = item else { + // `ResponseStream` turns a stream that ends without `Ended` + // into an error item, so a bare end follows a terminal item + // that was already handled. + break self.cancellation_outcome(round_token); + }; + let event = match item { + Ok(event) => event, + Err(error) => break self.classify_stream_end(Err(error), round_token), + }; + if !first_output_emitted { + if let Some(kind) = first_output_kind(&event) { + first_output_emitted = true; + self.event_emitter + .emit(self.id.clone(), AgentEvent::LlmFirstOutput { kind }); + } + } + match event { + StreamEvent::TextDelta { text, .. } => { + attempt.visible_output = true; + self.event_emitter + .emit(self.id.clone(), AgentEvent::TextDelta { delta: text }); + } + StreamEvent::ReasoningDelta { text, .. } => { + attempt.visible_output = true; + self.event_emitter + .emit(self.id.clone(), AgentEvent::ReasoningDelta { delta: text }); + } + StreamEvent::Ended { response } => { + break self.classify_stream_end(Ok(*response), round_token); + } + _ => {} + } + }; + drop(event_stream); + cancel_watcher.abort(); + attempt.outcome = outcome; + attempt + } + + /// Classify how a stream ended, preferring the agent's own cancellation + /// signals over whatever error the cancelled call reported. + fn classify_stream_end( + &self, + end: Result, + round_token: &CancellationToken, + ) -> AttemptOutcome { + if self.cancel_token.is_cancelled() || round_token.is_cancelled() { + return self.cancellation_outcome(round_token); + } + match end { + Ok(response) => match response.finish_reason { + FinishReason::Length | FinishReason::Incomplete => { + AttemptOutcome::Failed(incomplete_response_error(&response)) + } + _ => AttemptOutcome::Completed(Box::new(response)), + }, + Err(error) => AttemptOutcome::Failed(error), + } + } + + fn cancellation_outcome(&self, round_token: &CancellationToken) -> AttemptOutcome { + if self.cancel_token.is_cancelled() { + AttemptOutcome::Cancelled + } else if round_token.is_cancelled() { + AttemptOutcome::Interrupted + } else { + // Neither token fired, so the stream itself ended. `ResponseStream` + // reports a completion-less end as an error item, so reaching + // here means the terminal item was consumed already. + AttemptOutcome::Failed( + fabro_llm::Error::new( + fabro_llm::ErrorKind::StreamDecode, + "the response stream ended without completion", + ) + .with_retry(RetryClassification::Safe), + ) + } + } + + /// Emit the event that clears partial assistant output shown to the user. + fn clear_visible_output(&self) { + self.event_emitter + .emit(self.id.clone(), AgentEvent::AssistantOutputReplace { + text: String::new(), + reasoning: None, + }); + } + + /// The listener that records the client's own retries, which happen + /// before any visible output, as `LlmRetry` events. + fn retry_listener(&self, requested_model: &ModelRef) -> RetryListener { + let emitter = self.event_emitter.clone(); + let session_id = self.id.clone(); + let provider = requested_model.provider.to_string(); + let model = requested_model.model_id.to_string(); + RetryListener::new(move |notice| { + let phase = match notice.stage { + RetryStage::Stream => LlmRetryPhase::Consume, + _ => LlmRetryPhase::Open, + }; + emitter.emit(session_id.clone(), AgentEvent::LlmRetry { + provider: provider.clone(), + model: model.clone(), + attempt: usize::try_from(notice.attempt).unwrap_or(usize::MAX), + delay_secs: notice.delay.as_secs_f64(), + error: notice.error, + phase, + }); + }) + } + /// Attempt context compaction when the configured threshold is exceeded. /// /// Returns `true` when an attempted compaction failed so the current input @@ -2129,10 +2113,13 @@ impl Session { } } - fn build_request(&self, pending_task_reminder: Option<&Message>) -> BuiltRequest { + fn build_request( + &self, + pending_task_reminder: Option<&Message>, + ) -> Result { let mut messages = Vec::new(); if !self.system_prompt.trim().is_empty() { - messages.push(LlmMessage::system(self.system_prompt.clone())); + messages.push(LlmMessage::text(Role::System, self.system_prompt.clone())); } messages.extend(self.history.convert_to_messages()); if let Some(reminder) = pending_task_reminder { @@ -2140,37 +2127,36 @@ impl Session { } let tools_with_source = self.effective_tools(); - let tools: Vec<_> = tools_with_source - .iter() - .map(|tool| tool.definition.clone()) - .collect(); - let has_tools = !tools.is_empty(); + let has_tools = !tools_with_source.is_empty(); - let request = Request { - model: self.provider_profile.model().to_string(), - messages, - provider: Some(self.provider_profile.provider_id().to_string()), - tools: if has_tools { Some(tools) } else { None }, - tool_choice: if has_tools { - Some(ToolChoice::Auto) - } else { - None - }, - response_format: None, - temperature: None, - top_p: None, - max_tokens: self - .config - .max_tokens - .or_else(|| self.provider_profile.max_output_tokens()), - stop_sequences: None, - reasoning_effort: self.config.reasoning_effort, - speed: self.config.speed, - metadata: None, - provider_options: None, - }; let provider = self.provider_profile.provider_id().to_string(); let model = self.provider_profile.model().to_string(); + let mut builder = Request::builder().model(format!("{provider}/{model}")); + for message in messages { + builder = builder.message(message); + } + for tool in &tools_with_source { + builder = builder.tool(tool.definition.clone()); + } + if has_tools { + builder = builder.tool_choice(ToolChoice::Auto); + } + if let Some(max_tokens) = self + .config + .max_tokens + .or_else(|| self.provider_profile.max_output_tokens()) + { + builder = builder.max_output_tokens(max_tokens); + } + if let Some(effort) = self.config.reasoning_effort { + builder = builder.reasoning_effort(effort); + } + if let Some(speed) = self.config.speed { + builder = builder.speed(speed); + } + let request = builder + .build() + .map_err(|err| Error::InvalidState(format!("invalid LLM request: {err}")))?; let context_window = build_local_snapshot(ContextWindowInput { request: &request, tools: &tools_with_source, @@ -2183,10 +2169,10 @@ impl Session { model: &model, context_window_tokens: self.provider_profile.context_window_size(), }); - BuiltRequest { + Ok(BuiltRequest { request, context_window, - } + }) } fn task_reminder_if_needed(&self) -> Option { @@ -2202,13 +2188,6 @@ impl Session { } } -const fn is_auth_error(err: &LlmError) -> bool { - matches!( - err.provider_kind(), - Some(ProviderErrorKind::Authentication | ProviderErrorKind::AccessDenied) - ) -} - /// Build the script that launches a sandbox MCP server detached and echoes its /// PID. /// @@ -2262,13 +2241,16 @@ mod tests { use std::time::Duration; use anyhow::Context as _; - use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; - use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; - use fabro_llm::types::{ - ContentPart, ReasoningEffort, Request, Response, Role, StreamEvent, TokenCounts, ToolCall, - ToolDefinition, + use fabro_llm::adapter::{ProviderAdapter, ResolvedCall}; + use fabro_llm::lithos_catalog::AdapterId; + use fabro_llm::reasoning::OPENAI_COMPAT_REASONING_DETAILS_KIND; + use fabro_llm::test_support::response_to_stream; + use fabro_llm::types::{ContentBlockId, ContentBlockKind, ToolCallKind}; + use fabro_llm::{ErrorFacts, ErrorKind, ResponseStream, RetryPolicy}; + use fabro_types::{ + ContentPart, Cost, CostSource, ReasoningOutput, StageContextWindowCountMethod, + ToolDefinition, provider_ids, text_of, tool_result_to_json, }; - use fabro_types::{ReasoningOutput, StageContextWindowCountMethod}; use futures::stream; use tokio::time::{sleep, timeout}; @@ -2381,29 +2363,114 @@ mod tests { fn make_named_noop_tool(name: &str) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: name.to_string(), - description: format!("Tool {name}"), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + name.to_string(), + format!("Tool {name}"), + serde_json::json!({"type": "object"}), + ), executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("ok".to_string()) })), source: ToolSource::Native, } } + /// A cloneable recipe for a lithos error, since the live error itself + /// carries a source chain and cannot be cloned. + #[derive(Clone)] + struct ScriptedError { + kind: ErrorKind, + message: String, + retry: RetryClassification, + } + + impl ScriptedError { + fn build(&self) -> fabro_llm::Error { + fabro_llm::Error::new(self.kind.clone(), self.message.clone()) + .with_provider(provider_ids::anthropic()) + .with_retry(self.retry) + } + } + + /// A transient stream failure the provider may be asked to repeat. + fn stream_error(message: &str) -> ScriptedError { + ScriptedError { + kind: ErrorKind::StreamDecode, + message: message.to_string(), + retry: RetryClassification::Safe, + } + } + + /// A deterministic provider failure that repeating cannot fix. + fn provider_error(kind: ErrorKind, message: &str) -> ScriptedError { + ScriptedError { + kind, + message: message.to_string(), + retry: RetryClassification::Never, + } + } + + fn block(index: usize) -> ContentBlockId { + ContentBlockId::new(format!("block_{index}")) + } + + fn text_delta(text: &str) -> StreamEvent { + StreamEvent::TextDelta { + id: block(0), + text: text.to_string(), + } + } + + fn reasoning_delta(text: &str) -> StreamEvent { + StreamEvent::ReasoningDelta { + id: block(0), + text: text.to_string(), + } + } + + fn tool_call_start(tool_call: &ToolCall) -> StreamEvent { + StreamEvent::ContentBlockStart { + id: block(1), + kind: ContentBlockKind::ToolCall { + id: tool_call.id.clone(), + name: Some(tool_call.name.clone()), + kind: ToolCallKind::Function, + }, + } + } + + fn tool_call_delta(arguments: &str) -> StreamEvent { + StreamEvent::ToolCallDelta { + id: block(1), + arguments: arguments.to_string(), + } + } + + fn tool_call_end(tool_call: &ToolCall) -> StreamEvent { + StreamEvent::ContentBlockEnd { + id: block(1), + part: ContentPart::ToolCall(tool_call.clone()), + } + } + + fn finish(response: Response) -> StreamEvent { + StreamEvent::Ended { + response: Box::new(response), + } + } + #[derive(Clone)] enum ScriptedStreamCall { Response(Box), - Events(Vec>), + Events(Vec>), /// Emit the events, then hang until the round is cancelled. - EventsThenPending(Vec>), - Error(LlmError), + EventsThenPending(Vec>), + Error(ScriptedError), } struct ScriptedStreamProvider { calls: Vec, requests: Mutex>, call_index: AtomicUsize, + id: AdapterId, } impl ScriptedStreamProvider { @@ -2416,67 +2483,50 @@ mod tests { calls, requests: Mutex::new(Vec::new()), call_index: AtomicUsize::new(0), + id: AdapterId::new("mock"), } } - fn events_for_response(response: Response) -> Vec> { - let mut events = Vec::new(); - let text = response.text(); - if !text.is_empty() { - events.push(Ok(StreamEvent::text_delta(text, None))); - } - - for part in &response.message.content { - if let ContentPart::ToolCall(tool_call) = part { - events.push(Ok(StreamEvent::ToolCallEnd { - tool_call: tool_call.clone(), - })); - } - } - - events.push(Ok(StreamEvent::finish( - response.finish_reason.clone(), - response.usage.clone(), - response, - ))); - events + fn events( + scripted: Vec>, + ) -> Vec> { + scripted + .into_iter() + .map(|item| item.map_err(|error| error.build())) + .collect() } } #[async_trait::async_trait] impl ProviderAdapter for ScriptedStreamProvider { - fn name(&self) -> &'static str { - "mock" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, _request: &Request) -> Result { - Err(LlmError::Configuration { - message: "ScriptedStreamProvider does not implement complete()".into(), - source: None, - }) + async fn complete(&self, _call: &ResolvedCall) -> Result { + Err(fabro_llm::Error::new( + ErrorKind::Configuration, + "ScriptedStreamProvider does not implement complete()", + )) } - async fn stream(&self, request: &Request) -> Result { + async fn stream(&self, call: &ResolvedCall) -> Result { self.requests .lock() .expect("request capture lock poisoned") - .push(request.clone()); + .push(call.request().clone()); let idx = self.call_index.fetch_add(1, Ordering::SeqCst); - let scripted = if idx < self.calls.len() { - self.calls[idx].clone() - } else { - self.calls[self.calls.len() - 1].clone() - }; + let scripted = self.calls[idx.min(self.calls.len() - 1)].clone(); match scripted { - ScriptedStreamCall::Response(response) => { - Ok(Box::pin(stream::iter(Self::events_for_response(*response)))) + ScriptedStreamCall::Response(response) => Ok(response_to_stream(*response)), + ScriptedStreamCall::Events(events) => { + Ok(ResponseStream::new(stream::iter(Self::events(events)))) } - ScriptedStreamCall::Events(events) => Ok(Box::pin(stream::iter(events))), - ScriptedStreamCall::EventsThenPending(events) => { - Ok(Box::pin(stream::iter(events).chain(stream::pending()))) - } - ScriptedStreamCall::Error(err) => Err(err), + ScriptedStreamCall::EventsThenPending(events) => Ok(ResponseStream::new( + stream::iter(Self::events(events)).chain(stream::pending()), + )), + ScriptedStreamCall::Error(err) => Err(err.build()), } } } @@ -2485,6 +2535,7 @@ mod tests { responses: Vec, delay: Duration, call_index: AtomicUsize, + id: AdapterId, } impl DelayedStreamProvider { @@ -2493,31 +2544,28 @@ mod tests { responses, delay, call_index: AtomicUsize::new(0), + id: AdapterId::new("mock"), } } } #[async_trait::async_trait] impl ProviderAdapter for DelayedStreamProvider { - fn name(&self) -> &'static str { - "mock" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, _request: &Request) -> Result { - Err(LlmError::Configuration { - message: "DelayedStreamProvider does not implement complete()".into(), - source: None, - }) + async fn complete(&self, _call: &ResolvedCall) -> Result { + Err(fabro_llm::Error::new( + ErrorKind::Configuration, + "DelayedStreamProvider does not implement complete()", + )) } - async fn stream(&self, _request: &Request) -> Result { + async fn stream(&self, _call: &ResolvedCall) -> Result { sleep(self.delay).await; 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() - }; + let response = self.responses[idx.min(self.responses.len() - 1)].clone(); Ok(response_to_stream(response)) } } @@ -2526,6 +2574,7 @@ mod tests { first_started: Arc, response: Response, call_index: AtomicUsize, + id: AdapterId, } impl BlockingFirstStreamProvider { @@ -2534,24 +2583,25 @@ mod tests { first_started: Arc::new(Notify::new()), response, call_index: AtomicUsize::new(0), + id: AdapterId::new("mock"), } } } #[async_trait::async_trait] impl ProviderAdapter for BlockingFirstStreamProvider { - fn name(&self) -> &'static str { - "mock" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, _request: &Request) -> Result { - Err(LlmError::Configuration { - message: "BlockingFirstStreamProvider does not implement complete()".into(), - source: None, - }) + async fn complete(&self, _call: &ResolvedCall) -> Result { + Err(fabro_llm::Error::new( + ErrorKind::Configuration, + "BlockingFirstStreamProvider does not implement complete()", + )) } - async fn stream(&self, _request: &Request) -> Result { + async fn stream(&self, _call: &ResolvedCall) -> Result { if self.call_index.fetch_add(1, Ordering::SeqCst) == 0 { self.first_started.notify_one(); return std::future::pending().await; @@ -2658,11 +2708,11 @@ mod tests { async fn last_input_timing_reports_inference_and_tool_per_call() { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { - definition: ToolDefinition { - name: "slow_tool".into(), - description: "Sleeps before returning".into(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "slow_tool", + "Sleeps before returning", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(|_args, _ctx| { Box::pin(async move { sleep(Duration::from_millis(30)).await; @@ -2730,11 +2780,11 @@ mod tests { let seen_tokens = Arc::new(Mutex::new(Vec::new())); let seen_tokens_for_tool = Arc::clone(&seen_tokens); let record_env_tool = RegisteredTool { - definition: ToolDefinition { - name: "record_env".into(), - description: "Records resolved env".into(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "record_env", + "Records resolved env", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(move |_args, ctx| { let seen_tokens = Arc::clone(&seen_tokens_for_tool); Box::pin(async move { @@ -2941,14 +2991,19 @@ mod tests { #[tokio::test] async fn interrupted_round_does_not_commit_task_reminder() { + // The block start alone is protocol bookkeeping the client holds back + // until the stream shows something; the argument delta is what makes + // the tool call observable mid-flight. + let pending_call = ToolCall::function( + "call_1", + "TaskUpdate", + serde_json::json!({"taskId": "1", "status": "completed"}), + ); let provider = Arc::new(ScriptedStreamProvider::new(vec![ - ScriptedStreamCall::EventsThenPending(vec![Ok(StreamEvent::ToolCallStart { - tool_call: ToolCall::new( - "call_1", - "TaskUpdate", - serde_json::json!({"taskId": "1", "status": "completed"}), - ), - })]), + ScriptedStreamCall::EventsThenPending(vec![ + Ok(tool_call_start(&pending_call)), + Ok(tool_call_delta("{\"taskId\": \"1\"")), + ]), ScriptedStreamCall::Response(Box::new(text_response("resumed"))), ])); let mut registry = ToolRegistry::new(); @@ -2964,7 +3019,7 @@ mod tests { content: "done".into(), tool_calls: Vec::new(), provider_parts: Vec::new(), - usage: Box::::default(), + usage: TokenCounts::default(), response_id: format!("response_{index}"), timestamp: SystemTime::now(), }); @@ -3002,25 +3057,28 @@ mod tests { .first() .expect("the interrupted request should be captured"); let staged = interrupted - .messages + .messages() .last() .expect("the interrupted request should not be empty"); - assert_eq!(staged.role, Role::System); - assert_eq!(staged.text(), task_reminder::TASK_REMINDER_TEXT); + assert_eq!(staged.role(), Role::System); + assert_eq!(text_of(staged.content()), task_reminder::TASK_REMINDER_TEXT); let resumed = requests .get(1) .expect("steering should trigger a second provider request"); - let [.., steering, reminder] = resumed.messages.as_slice() else { + let [.., steering, reminder] = resumed.messages() else { panic!( "the resumed request should end with steering and a restaged reminder: {:?}", - resumed.messages + resumed.messages() ); }; - assert_eq!(steering.role, Role::User); - assert_eq!(steering.text(), "wrap up now"); - assert_eq!(reminder.role, Role::System); - assert_eq!(reminder.text(), task_reminder::TASK_REMINDER_TEXT); + assert_eq!(steering.role(), Role::User); + assert_eq!(text_of(steering.content()), "wrap up now"); + assert_eq!(reminder.role(), Role::System); + assert_eq!( + text_of(reminder.content()), + task_reminder::TASK_REMINDER_TEXT + ); let [ .., @@ -3042,11 +3100,11 @@ mod tests { #[tokio::test] async fn interrupt_during_tool_settles_once_after_balancing_tool_result() { let blocking_tool = RegisteredTool { - definition: ToolDefinition { - name: "block".into(), - description: "Blocks until interrupted".into(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "block", + "Blocks until interrupted", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(|_args, ctx| { Box::pin(async move { ctx.cancel.cancelled().await; @@ -3430,7 +3488,7 @@ mod tests { if let Message::ToolResults { results, .. } = &turns[2] { assert!(results[0].is_error); assert_eq!( - results[0].content, + tool_result_to_json(&results[0]), serde_json::json!("Unknown tool: nonexistent_tool") ); } else { @@ -3455,7 +3513,7 @@ mod tests { if let Message::ToolResults { results, .. } = &turns[2] { assert!(results[0].is_error); assert_eq!( - results[0].content, + tool_result_to_json(&results[0]), serde_json::json!("tool execution failed") ); } else { @@ -3540,11 +3598,11 @@ mod tests { // Tool that cancels the token when executed let abort_tool = RegisteredTool { - definition: ToolDefinition { - name: "set_abort".into(), - description: "Sets interrupt flag".into(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "set_abort", + "Sets interrupt flag", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(move |_args, _ctx| { let token = cancel_token_for_tool.clone(); Box::pin(async move { @@ -3595,12 +3653,9 @@ mod tests { #[tokio::test] async fn auth_error_closes_session() { - let error_provider = Arc::new(MockErrorProvider { - error: LlmError::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail::new("invalid api key", "mock")), - }, - }); + let error_provider = Arc::new(MockErrorProvider::new(|| { + fabro_llm::Error::new(ErrorKind::Authentication, "invalid api key") + })); let client = make_client(error_provider).await; let profile = Arc::new(TestProfile::new()); let env = Arc::new(MockSandbox::default()); @@ -3784,7 +3839,7 @@ mod tests { let request = captured .as_ref() .expect("request should have been captured"); - assert_eq!(request.reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!(request.reasoning_effort(), Some(ReasoningEffort::High)); } #[tokio::test] @@ -3815,17 +3870,17 @@ mod tests { async fn invalid_tool_args_returns_validation_error() { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { - definition: ToolDefinition { - name: "strict_tool".into(), - description: "Tool with required params".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "strict_tool", + "Tool with required params", + serde_json::json!({ "type": "object", "properties": { "text": {"type": "string"} }, "required": ["text"] }), - }, + ), executor: Arc::new(|_args, _ctx| { Box::pin(async move { Ok("should not reach".to_string()) }) }), @@ -3843,7 +3898,7 @@ mod tests { let turns = session.history().turns(); if let Message::ToolResults { results, .. } = &turns[2] { assert!(results[0].is_error); - let content_str = results[0].content.to_string(); + let content_str = tool_result_to_json(&results[0]).to_string(); assert!( content_str.contains("text") && content_str.contains("required"), "Expected validation error mentioning 'text' and 'required', got: {content_str}" @@ -3857,17 +3912,17 @@ mod tests { async fn valid_tool_args_passes_validation() { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { - definition: ToolDefinition { - name: "strict_tool".into(), - description: "Tool with required params".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "strict_tool", + "Tool with required params", + serde_json::json!({ "type": "object", "properties": { "text": {"type": "string"} }, "required": ["text"] }), - }, + ), executor: Arc::new(|_args, _ctx| { Box::pin(async move { Ok("tool executed".to_string()) }) }), @@ -3942,8 +3997,8 @@ mod tests { let request = captured .as_ref() .expect("request should have been captured"); - let system_msg = &request.messages[0]; - let system_text = system_msg.text(); + let system_msg = &request.messages()[0]; + let system_text = text_of(system_msg.content()); assert!( system_text.contains("Always use TDD"), "System prompt should contain user instructions" @@ -3968,13 +4023,13 @@ mod tests { .expect("request should have been captured"); assert!( request - .messages + .messages() .iter() - .all(|message| message.role != Role::System), + .all(|message| message.role() != Role::System), "request should not contain an empty system message" ); assert!( - matches!(request.messages.first(), Some(message) if message.role == Role::User), + matches!(request.messages().first(), Some(message) if message.role() == Role::User), "first request message should be user input" ); } @@ -3997,7 +4052,8 @@ mod tests { let request = captured .as_ref() .expect("request should have been captured"); - let tools = request.tools.as_ref().expect("tools should be exposed"); + let tools = request.tools(); + assert!(!tools.is_empty(), "tools should be exposed"); let tool_names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect(); assert_eq!(tool_names.len(), 2); assert!(tool_names.contains(&"read_file")); @@ -4026,8 +4082,9 @@ mod tests { .as_ref() .expect("request should have been captured"); assert!( - request.messages.iter().any(|message| { - message.role == Role::System && message.text() == task_reminder::TASK_REMINDER_TEXT + request.messages().iter().any(|message| { + message.role() == Role::System + && text_of(message.content()) == task_reminder::TASK_REMINDER_TEXT }), "request should include task reminder system message" ); @@ -4059,7 +4116,8 @@ mod tests { let request = captured .as_ref() .expect("request should have been captured"); - let tools = request.tools.as_ref().expect("tools should be exposed"); + let tools = request.tools(); + assert!(!tools.is_empty(), "tools should be exposed"); assert_eq!(tools.len(), 1); assert_eq!(tools[0].name, "read_file"); } @@ -4122,7 +4180,8 @@ mod tests { let request = captured .as_ref() .expect("request should have been captured"); - let tools = request.tools.as_ref().expect("tools should be exposed"); + let tools = request.tools(); + assert!(!tools.is_empty(), "tools should be exposed"); let tool_names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect(); assert_eq!(tool_names.len(), 2); assert!(tool_names.contains(&"read_file")); @@ -4156,7 +4215,7 @@ mod tests { if let Message::ToolResults { results, .. } = &turns[2] { assert!(results[0].is_error); - let content_str = results[0].content.to_string(); + let content_str = tool_result_to_json(&results[0]).to_string(); assert!( content_str.contains("denied by policy"), "Expected denial message in content, got: {content_str}" @@ -4193,7 +4252,7 @@ mod tests { let turns = session.history().turns(); if let Message::ToolResults { results, .. } = &turns[2] { assert!(!results[0].is_error); - let content_str = results[0].content.to_string(); + let content_str = tool_result_to_json(&results[0]).to_string(); assert!( content_str.contains("echo: hello"), "Expected echo output in content, got: {content_str}" @@ -4258,7 +4317,7 @@ mod tests { let turns = session.history().turns(); if let Message::ToolResults { results, .. } = &turns[2] { assert!(!results[0].is_error); - let content_str = results[0].content.to_string(); + let content_str = tool_result_to_json(&results[0]).to_string(); assert!( content_str.contains("echo: hello"), "Expected echo output in content, got: {content_str}" @@ -4331,11 +4390,8 @@ mod tests { async fn stream_retries_retryable_mid_stream_error_and_records_recovered_response() { let provider = Arc::new(ScriptedStreamProvider::new(vec![ ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::text_delta("partial", None)), - Err(LlmError::Stream { - message: "connection reset".into(), - source: None, - }), + Ok(text_delta("partial")), + Err(stream_error("connection reset")), ]), ScriptedStreamCall::Response(Box::new(text_response("Recovered"))), ])); @@ -4361,7 +4417,7 @@ mod tests { } AgentEvent::LlmRetry { error, .. } => { retry_count += 1; - assert!(error.retryable()); + assert!(error.is_retryable()); } AgentEvent::AssistantMessage { text, .. } => { observed.push(format!("message:{text}")); @@ -4383,15 +4439,15 @@ mod tests { /// Builds a response whose provider parts carry both reasoning channels. fn reasoning_response(text: &str, summary: &str, trace: &str) -> Response { let mut response = text_response(text); - let mut content = vec![ContentPart::Other { - kind: ContentPart::OPENAI_COMPAT_REASONING_DETAILS.to_string(), - data: serde_json::json!([ + let mut content = vec![ContentPart::opaque( + OPENAI_COMPAT_REASONING_DETAILS_KIND, + serde_json::json!([ {"type": "reasoning.summary", "summary": summary}, {"type": "reasoning.text", "text": trace}, ]), - }]; - content.extend(response.message.content); - response.message.content = content; + )]; + content.extend(response.content); + response.content = content; response } @@ -4430,12 +4486,12 @@ mod tests { async fn tool_call_response_with_no_visible_text_still_carries_reasoning() { let mut tool_call = tool_call_response("nonexistent_tool", "call_1", serde_json::json!({})); // Drop the visible text so only the tool call and reasoning remain. - tool_call.message.content = vec![ - ContentPart::Other { - kind: ContentPart::OPENAI_COMPAT_REASONING_DETAILS.to_string(), - data: serde_json::json!([{"type": "reasoning.summary", "summary": "call the tool"}]), - }, - ContentPart::ToolCall(ToolCall::new( + tool_call.content = vec![ + ContentPart::opaque( + OPENAI_COMPAT_REASONING_DETAILS_KIND, + serde_json::json!([{"type": "reasoning.summary", "summary": "call the tool"}]), + ), + ContentPart::ToolCall(ToolCall::function( "call_1", "nonexistent_tool", serde_json::json!({}), @@ -4458,13 +4514,8 @@ mod tests { async fn only_the_final_response_contributes_reasoning_after_a_retry() { let provider = Arc::new(ScriptedStreamProvider::new(vec![ ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::ReasoningDelta { - delta: "discarded thinking".to_string(), - }), - Err(LlmError::Stream { - message: "connection reset".into(), - source: None, - }), + Ok(reasoning_delta("discarded thinking")), + Err(stream_error("connection reset")), ]), ScriptedStreamCall::Response(Box::new(reasoning_response( "Recovered", @@ -4487,48 +4538,29 @@ mod tests { #[tokio::test(start_paused = true)] async fn stream_quota_error_does_not_replay() { - let quota_error = LlmError::Provider { - kind: ProviderErrorKind::QuotaExceeded, - detail: Box::new(ProviderErrorDetail { - error_code: Some("insufficient_quota".into()), - ..ProviderErrorDetail::new("You exceeded your current quota", "mock") - }), - }; + let quota_error = + provider_error(ErrorKind::QuotaExceeded, "You exceeded your current quota"); let provider = Arc::new(ScriptedStreamProvider::new(vec![ - ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::text_delta("partial", None)), - Err(quota_error.clone()), - ]), + ScriptedStreamCall::Events(vec![Ok(text_delta("partial")), Err(quota_error.clone())]), ])); let mut session = make_session_with_provider(provider.clone()).await; let result = session.process_input("Hello").await; assert!(matches!( - result, - Err(Error::Llm(LlmError::Provider { - kind: ProviderErrorKind::QuotaExceeded, - .. - })) + &result, + Err(Error::Llm(error)) if error.kind() == ErrorKind::QuotaExceeded )); assert_eq!(provider.call_index.load(Ordering::SeqCst), 1); } - async fn assert_non_retryable_mid_stream_provider_error_does_not_replay( - kind: ProviderErrorKind, - ) { - let llm_error = LlmError::Provider { - kind, - detail: Box::new(ProviderErrorDetail::new( - format!("deterministic provider error: {kind:?}"), - "mock", - )), - }; + async fn assert_non_retryable_mid_stream_provider_error_does_not_replay(kind: ErrorKind) { + let llm_error = provider_error( + kind.clone(), + &format!("deterministic provider error: {kind:?}"), + ); let provider = Arc::new(ScriptedStreamProvider::new(vec![ - ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::text_delta("partial", None)), - Err(llm_error.clone()), - ]), + ScriptedStreamCall::Events(vec![Ok(text_delta("partial")), Err(llm_error.clone())]), ScriptedStreamCall::Response(Box::new(text_response("should not replay"))), ])); let mut session = make_session_with_provider(provider.clone()).await; @@ -4537,11 +4569,8 @@ mod tests { let result = session.process_input("Hello").await; assert!(matches!( - result, - Err(Error::Llm(LlmError::Provider { - kind: actual_kind, - .. - })) if actual_kind == kind + &result, + Err(Error::Llm(error)) if error.kind() == kind )); assert_eq!(provider.call_index.load(Ordering::SeqCst), 1); assert_eq!(session.history().turns().len(), 1); @@ -4557,11 +4586,8 @@ mod tests { AgentEvent::LlmRetry { .. } => retry_count += 1, AgentEvent::Error { error } => { assert!(matches!( - error, - Error::Llm(LlmError::Provider { - kind: actual_kind, - .. - }) if actual_kind == kind + &error, + Error::Llm(error) if error.kind() == kind )); observed.push("error".to_string()); } @@ -4580,36 +4606,25 @@ mod tests { #[tokio::test(start_paused = true)] async fn stream_non_retryable_mid_stream_errors_do_not_replay() { - assert_non_retryable_mid_stream_provider_error_does_not_replay( - ProviderErrorKind::Authentication, - ) - .await; - assert_non_retryable_mid_stream_provider_error_does_not_replay( - ProviderErrorKind::ContextLength, - ) - .await; - assert_non_retryable_mid_stream_provider_error_does_not_replay( - ProviderErrorKind::QuotaExceeded, - ) - .await; + assert_non_retryable_mid_stream_provider_error_does_not_replay(ErrorKind::Authentication) + .await; + assert_non_retryable_mid_stream_provider_error_does_not_replay(ErrorKind::ContextLength) + .await; + assert_non_retryable_mid_stream_provider_error_does_not_replay(ErrorKind::QuotaExceeded) + .await; } #[tokio::test(start_paused = true)] async fn stream_retry_exhaustion_emits_one_error_without_committing_assistant_or_tools() { - let retryable_error = LlmError::Stream { - message: "connection reset".into(), - source: None, - }; + let retryable_error = stream_error("connection reset"); let provider = Arc::new(ScriptedStreamProvider::new(vec![ ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::text_delta("partial", None)), - Ok(StreamEvent::ToolCallEnd { - tool_call: ToolCall::new( - "call_1", - "echo", - serde_json::json!({"text": "should not run"}), - ), - }), + Ok(text_delta("partial")), + Ok(tool_call_end(&ToolCall::function( + "call_1", + "echo", + serde_json::json!({"text": "should not run"}), + ))), Err(retryable_error.clone()), ]), ])); @@ -4618,8 +4633,13 @@ mod tests { let result = session.process_input("Hello").await; - assert!(matches!(result, Err(Error::Llm(LlmError::Stream { .. })))); - assert_eq!(provider.call_index.load(Ordering::SeqCst), 4); + assert!(matches!( + &result, + Err(Error::Llm(error)) if error.kind() == ErrorKind::StreamDecode + )); + // Visible output was shown on every attempt, so only the agent's + // bounded replay loop runs: three attempts under the default policy. + assert_eq!(provider.call_index.load(Ordering::SeqCst), 3); assert_eq!(session.history().turns().len(), 1); let mut retry_count = 0; @@ -4632,7 +4652,7 @@ mod tests { match event.event { AgentEvent::LlmRetry { error, .. } => { retry_count += 1; - assert!(error.retryable()); + assert!(error.is_retryable()); } AgentEvent::AssistantOutputReplace { text, reasoning } => { assert_eq!(text, ""); @@ -4640,7 +4660,10 @@ mod tests { replace_count += 1; } AgentEvent::Error { error } => { - assert!(matches!(error, Error::Llm(LlmError::Stream { .. }))); + assert!(matches!( + &error, + Error::Llm(error) if error.kind() == ErrorKind::StreamDecode + )); error_count += 1; } AgentEvent::AssistantMessage { .. } => assistant_message_count += 1, @@ -4650,8 +4673,8 @@ mod tests { } } - assert_eq!(retry_count, 3); - assert_eq!(replace_count, 4); + assert_eq!(retry_count, 2); + assert_eq!(replace_count, 3); assert_eq!(error_count, 1); assert_eq!(assistant_message_count, 0); assert_eq!(tool_started_count, 0); @@ -4706,15 +4729,9 @@ mod tests { let response = text_response("Hello"); let provider = Arc::new(ScriptedStreamProvider::new(vec![ ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::ReasoningDelta { - delta: "weighing options".to_string(), - }), - Ok(StreamEvent::text_delta("Hello", None)), - Ok(StreamEvent::finish( - response.finish_reason.clone(), - response.usage.clone(), - response, - )), + Ok(reasoning_delta("weighing options")), + Ok(text_delta("Hello")), + Ok(finish(response)), ]), ])); let mut session = make_session_with_provider(provider).await; @@ -4732,26 +4749,18 @@ mod tests { #[tokio::test] async fn first_output_reports_tool_call_for_a_turn_with_no_text_or_reasoning() { - let tool_call = ToolCall::new("call_1", "nonexistent_tool", serde_json::json!({})); + let tool_call = ToolCall::function("call_1", "nonexistent_tool", serde_json::json!({})); let mut response = tool_call_response("nonexistent_tool", "call_1", serde_json::json!({})); // Strip the visible text so the turn produces neither a text nor a // reasoning delta — the case a latch keyed on those two would miss // entirely, leaving tool-heavy rounds silent. - response.message.content = vec![ContentPart::ToolCall(tool_call.clone())]; + response.content = vec![ContentPart::ToolCall(tool_call.clone())]; let provider = Arc::new(ScriptedStreamProvider::new(vec![ ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::ToolCallStart { - tool_call: tool_call.clone(), - }), - Ok(StreamEvent::ToolCallEnd { - tool_call: tool_call.clone(), - }), - Ok(StreamEvent::finish( - response.finish_reason.clone(), - response.usage.clone(), - response, - )), + Ok(tool_call_start(&tool_call.clone())), + Ok(tool_call_end(&tool_call.clone())), + Ok(finish(response)), ]), ScriptedStreamCall::Response(Box::new(text_response("Done"))), ])); @@ -4819,15 +4828,16 @@ mod tests { assert_eq!(replace_count, 0); assert_eq!(deltas, vec!["Recovered".to_string()]); assert_eq!(assistant_messages, vec!["Recovered".to_string()]); - // The finish-less restart is the one mid-turn path with no error to - // report; without this event it would be invisible downstream. - assert_eq!(consume_retries, vec![(0, LlmRetryPhase::Consume)]); + // A stream that ends before any visible output is reconnected by the + // client's retry middleware; the agent records that retry too, so the + // restart is not invisible downstream. + assert_eq!(consume_retries, vec![(1, LlmRetryPhase::Consume)]); } #[tokio::test] async fn stream_retries_with_output_replace_after_partial_text() { let provider = Arc::new(ScriptedStreamProvider::new(vec![ - ScriptedStreamCall::Events(vec![Ok(StreamEvent::text_delta("Hel", None))]), + ScriptedStreamCall::Events(vec![Ok(text_delta("Hel"))]), ScriptedStreamCall::Response(Box::new(text_response("Hello"))), ])); let mut session = make_session_with_provider(provider.clone()).await; @@ -4877,15 +4887,9 @@ mod tests { #[tokio::test] async fn retry_open_auth_error_emits_error_and_closes_session() { - let auth_error = LlmError::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail { - status_code: Some(401), - ..ProviderErrorDetail::new("bad key", "mock") - }), - }; + let auth_error = provider_error(ErrorKind::Authentication, "bad key"); let provider = Arc::new(ScriptedStreamProvider::new(vec![ - ScriptedStreamCall::Events(vec![Ok(StreamEvent::text_delta("Hel", None))]), + ScriptedStreamCall::Events(vec![Ok(text_delta("Hel"))]), ScriptedStreamCall::Error(auth_error.clone()), ])); let mut session = make_session_with_provider(provider.clone()).await; @@ -4893,11 +4897,8 @@ mod tests { let result = session.process_input("Hello").await; assert!(matches!( - result, - Err(Error::Llm(LlmError::Provider { - kind: ProviderErrorKind::Authentication, - .. - })) + &result, + Err(Error::Llm(error)) if error.kind() == ErrorKind::Authentication )); assert_eq!(provider.call_index.load(Ordering::SeqCst), 2); @@ -4915,11 +4916,8 @@ mod tests { AgentEvent::Error { error } => { observed.push("error".to_string()); found_auth_error_event = matches!( - error, - Error::Llm(LlmError::Provider { - kind: ProviderErrorKind::Authentication, - .. - }) + &error, + Error::Llm(error) if error.kind() == ErrorKind::Authentication ); } AgentEvent::AssistantMessage { .. } => observed.push("message".to_string()), @@ -4936,20 +4934,207 @@ mod tests { assert!(found_auth_error_event, "expected auth error event"); } + /// A tool whose executions are counted, for tests that must prove a call + /// never ran. + fn counting_tool(name: &str, executions: Arc) -> RegisteredTool { + RegisteredTool { + definition: ToolDefinition::function( + name, + format!("Counts executions of {name}"), + serde_json::json!({"type": "object"}), + ), + executor: Arc::new(move |_args, _ctx| { + let executions = Arc::clone(&executions); + Box::pin(async move { + executions.fetch_add(1, Ordering::SeqCst); + Ok("ran".to_string()) + }) + }), + source: ToolSource::Native, + } + } + + /// A provisional tool call followed by an `Incomplete` end is not a + /// completed turn: the tool must never run and the input must not + /// complete successfully. + #[tokio::test] + async fn incomplete_stream_never_executes_provisional_tool_calls() { + let executions = Arc::new(AtomicUsize::new(0)); + let tool_call = ToolCall::function("call_1", "echo", serde_json::json!({})); + let mut ended = tool_call_response("echo", "call_1", serde_json::json!({})); + ended.content = vec![ContentPart::ToolCall(tool_call.clone())]; + ended.finish_reason = FinishReason::Incomplete; + let provider = Arc::new(ScriptedStreamProvider::new(vec![ + ScriptedStreamCall::Events(vec![ + Ok(tool_call_start(&tool_call)), + Ok(tool_call_delta("{}")), + Ok(tool_call_end(&tool_call)), + Ok(finish(ended)), + ]), + ])); + let mut registry = ToolRegistry::new(); + registry.register(counting_tool("echo", Arc::clone(&executions))); + let client = make_client_without_retries(provider.clone() as Arc); + let profile = Arc::new(TestProfile::with_tools(registry)); + let env = Arc::new(MockSandbox::default()); + let mut session = Session::new(client, profile, env, SessionOptions::default(), None); + let mut rx = session.subscribe(); + + let result = session.process_input("Use echo").await; + + assert!( + matches!( + &result, + Err(Error::Llm(error)) if error.kind() == ErrorKind::StreamDecode + ), + "an incomplete stream must not complete the input: {result:?}" + ); + assert_eq!(executions.load(Ordering::SeqCst), 0); + assert_eq!(session.history().turns().len(), 1); + let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()) + .map(|event| event.event) + .collect(); + assert!(!events.iter().any(|event| matches!( + event, + AgentEvent::AssistantMessage { .. } | AgentEvent::ToolCallStarted { .. } + ))); + } + + /// A failure before any visible output is the client's to retry: the + /// provider is called once per policy attempt and the agent adds nothing. + #[tokio::test] + async fn open_failure_is_retried_by_the_client_exactly_per_policy() { + let provider = Arc::new(MockErrorProvider::new(|| { + stream_error("connection refused").build() + })); + // `make_client` installs a three-attempt policy with no delay. + let client = make_client(provider.clone() as Arc).await; + let profile = Arc::new(TestProfile::new()); + let env = Arc::new(MockSandbox::default()); + let mut session = Session::new(client, profile, env, SessionOptions::default(), None); + let mut rx = session.subscribe(); + + let result = session.process_input("Hello").await; + + assert!(matches!(&result, Err(Error::Llm(_)))); + assert_eq!(provider.calls(), 3); + let mut retries = Vec::new(); + let mut errors = 0; + while let Ok(event) = rx.try_recv() { + match event.event { + AgentEvent::LlmRetry { attempt, phase, .. } => retries.push((attempt, phase)), + AgentEvent::Error { .. } => errors += 1, + _ => {} + } + } + assert_eq!(retries, vec![ + (1, LlmRetryPhase::Open), + (2, LlmRetryPhase::Open) + ]); + assert_eq!(errors, 1); + } + + /// A failure after visible output cannot be retried by any middleware, so + /// the agent replays the turn itself, bounded by its own policy. + #[tokio::test] + async fn failure_after_visible_output_is_replayed_by_the_agent_exactly_per_policy() { + let provider = Arc::new(ScriptedStreamProvider::new(vec![ + ScriptedStreamCall::Events(vec![ + Ok(text_delta("partial")), + Err(stream_error("connection reset")), + ]), + ])); + let client = make_client(provider.clone() as Arc).await; + let profile = Arc::new(TestProfile::new()); + let env = Arc::new(MockSandbox::default()); + let config = SessionOptions { + replay_retry_policy: test_retry_policy(), + ..SessionOptions::default() + }; + let mut session = Session::new(client, profile, env, config, None); + let mut rx = session.subscribe(); + + let result = session.process_input("Hello").await; + + assert!(matches!(&result, Err(Error::Llm(_)))); + // Every attempt showed output before failing, so the client's retry + // layer never fires and only the agent's three replays run. + assert_eq!(provider.call_index.load(Ordering::SeqCst), 3); + let mut retries = Vec::new(); + let mut replaces = 0; + while let Ok(event) = rx.try_recv() { + match event.event { + AgentEvent::LlmRetry { attempt, phase, .. } => retries.push((attempt, phase)), + AgentEvent::AssistantOutputReplace { .. } => replaces += 1, + _ => {} + } + } + assert_eq!(retries, vec![ + (1, LlmRetryPhase::Consume), + (2, LlmRetryPhase::Consume) + ]); + assert_eq!(replaces, 3); + } + + /// Cancelling the session while a replay waits out its backoff stops the + /// turn without another provider call. + #[tokio::test(start_paused = true)] + async fn cancellation_during_replay_backoff_makes_no_further_provider_calls() { + let provider = Arc::new(ScriptedStreamProvider::new(vec![ + ScriptedStreamCall::Events(vec![ + Ok(text_delta("partial")), + Err(stream_error("connection reset")), + ]), + ])); + let client = make_client(provider.clone() as Arc).await; + let profile = Arc::new(TestProfile::new()); + let env = Arc::new(MockSandbox::default()); + let config = SessionOptions { + replay_retry_policy: RetryPolicy::exponential() + .max_attempts(3) + .initial_delay(Duration::from_secs(30)) + .max_delay(Duration::from_secs(30)) + .jitter(false), + ..SessionOptions::default() + }; + let mut session = Session::new(client, profile, env, config, None); + let mut events = session.subscribe(); + let cancel = session.cancel_token(); + let controller = tokio::spawn(async move { + wait_for_agent_event(&mut events, |event| { + matches!(event, AgentEvent::LlmRetry { .. }) + }) + .await; + cancel.cancel(); + }); + + let result = session.process_input("Hello").await; + controller.await.unwrap(); + + assert!(matches!( + result, + Err(Error::Interrupted(InterruptReason::Cancelled)) + )); + assert_eq!(provider.call_index.load(Ordering::SeqCst), 1); + assert_eq!(session.state(), SessionState::Closed); + } + fn response_with_usage(mut response: Response, usage: TokenCounts) -> Response { response.usage = usage; response } fn response_with_cost(mut response: Response, cost_usd: f64) -> Response { - response.cost_usd = Some(cost_usd); - response.cost_source = Some(fabro_model::CostSource::Authoritative); + response.cost = Some(Cost { + usd_micros: u64::try_from(UsdMicros::from_usd(cost_usd).0).unwrap(), + source: CostSource::Provider, + }); response } - fn response_with_input_tokens(response: Response, input_tokens: i64) -> Response { + fn response_with_input_tokens(response: Response, input: u64) -> Response { response_with_usage(response, TokenCounts { - input_tokens, + input, ..TokenCounts::default() }) } @@ -5173,48 +5358,27 @@ mod tests { responses: Vec, stream_index: AtomicUsize, complete_calls: AtomicUsize, + id: AdapterId, } #[async_trait::async_trait] impl ProviderAdapter for StreamOnlyProvider { - fn name(&self) -> &'static str { - "mock" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, _request: &Request) -> Result { + async fn complete(&self, _call: &ResolvedCall) -> Result { self.complete_calls.fetch_add(1, Ordering::SeqCst); - Err(LlmError::Stream { - message: "summarization failed".into(), - source: None, - }) + Err(stream_error("summarization failed").build()) } - async fn stream(&self, _request: &Request) -> Result { + async fn stream( + &self, + _call: &ResolvedCall, + ) -> Result { let idx = self.stream_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() - }; - // Reuse response_to_stream helper from test_support - let mut events: Vec> = Vec::new(); - let text = response.text(); - if !text.is_empty() { - events.push(Ok(StreamEvent::text_delta(text, None))); - } - for part in &response.message.content { - if let ContentPart::ToolCall(tc) = part { - events.push(Ok(StreamEvent::ToolCallEnd { - tool_call: tc.clone(), - })); - } - } - events.push(Ok(StreamEvent::finish( - response.finish_reason.clone(), - response.usage.clone(), - response, - ))); - Ok(Box::pin(stream::iter(events))) + let response = self.responses[idx.min(self.responses.len() - 1)].clone(); + Ok(response_to_stream(response)) } } @@ -5231,8 +5395,11 @@ mod tests { responses, stream_index: AtomicUsize::new(0), complete_calls: AtomicUsize::new(0), + id: AdapterId::new("mock"), }); - let client = make_client(provider.clone() as Arc).await; + // The client's own retries would repeat the failed summarization; the + // agent-level suppression is what this test observes. + let client = make_client_without_retries(provider.clone() as Arc); let registry = ToolRegistry::new(); let profile = Arc::new(TestProfile::with_context_window(registry, 100)); let env = Arc::new(MockSandbox::default()); @@ -5271,7 +5438,7 @@ mod tests { #[tokio::test] async fn compaction_includes_structured_prompt_and_file_tracking() { - use fabro_llm::types::ToolDefinition; + use fabro_types::ToolDefinition; use crate::tool_registry::{RegisteredTool, ToolSource}; @@ -5281,37 +5448,38 @@ mod tests { stream_responses: Vec, stream_index: AtomicUsize, captured_complete: Mutex>, + id: AdapterId, } #[async_trait::async_trait] impl ProviderAdapter for CompactionCapturingProvider { - fn name(&self) -> &'static str { - "mock" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, request: &Request) -> Result { - *self.captured_complete.lock().unwrap() = Some(request.clone()); + async fn complete(&self, call: &ResolvedCall) -> Result { + *self.captured_complete.lock().unwrap() = Some(call.request().clone()); Ok(text_response("## Goal\nSummary goes here.")) } - async fn stream(&self, _request: &Request) -> Result { + async fn stream( + &self, + _call: &ResolvedCall, + ) -> Result { let idx = self.stream_index.fetch_add(1, Ordering::SeqCst); - let response = if idx < self.stream_responses.len() { - self.stream_responses[idx].clone() - } else { - self.stream_responses[self.stream_responses.len() - 1].clone() - }; + let response = + self.stream_responses[idx.min(self.stream_responses.len() - 1)].clone(); Ok(response_to_stream(response)) } } // read_file tool that always succeeds let read_tool = RegisteredTool { - definition: ToolDefinition { - name: "read_file".into(), - description: "Read a file".into(), - parameters: serde_json::json!({"type": "object", "properties": {"file_path": {"type": "string"}}}), - }, + definition: ToolDefinition::function( + "read_file", + "Read a file", + serde_json::json!({"type": "object", "properties": {"file_path": {"type": "string"}}}), + ), executor: Arc::new(|_args, _ctx| { Box::pin(async move { Ok("file contents".to_string()) }) }), @@ -5341,6 +5509,7 @@ mod tests { stream_responses, stream_index: AtomicUsize::new(0), captured_complete: Mutex::new(None), + id: AdapterId::new("mock"), }); let client = make_client(provider.clone() as Arc).await; @@ -5375,7 +5544,7 @@ mod tests { let request = captured .as_ref() .expect("compaction request should have been captured"); - let system_text = request.messages[0].text(); + let system_text = text_of(request.messages()[0].content()); assert!( system_text.contains("## Goal"), "Compaction system prompt should contain structured '## Goal' section" @@ -5498,8 +5667,8 @@ mod tests { if let Message::ToolResults { results, .. } = &turns[2] { assert_eq!(results[0].tool_call_id, "mcp_call_1"); assert!(!results[0].is_error); - let output = results[0].content.as_str().unwrap_or(""); - assert_eq!(output, "hello from llm"); + let output = tool_result_to_json(&results[0]); + assert_eq!(output.as_str().unwrap_or(""), "hello from llm"); } else { panic!("expected ToolResults turn"); } @@ -5539,11 +5708,11 @@ mod tests { async fn wall_clock_timeout_aborts_session() { // Register a tool that loops until the cancel token fires let slow_tool = RegisteredTool { - definition: ToolDefinition { - name: "slow_tool".into(), - description: "Waits until cancelled".into(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "slow_tool", + "Waits until cancelled", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(|_args, ctx| { Box::pin(async move { ctx.cancel.cancelled().await; @@ -5604,11 +5773,11 @@ mod tests { async fn make_parent_waiting_on_blocked_subagent() -> (Session, SubAgentSupervisor, String, CancellationToken) { let block_until_cancelled = RegisteredTool { - definition: ToolDefinition { - name: "block_until_cancelled".into(), - description: "Waits until cancelled".into(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "block_until_cancelled", + "Waits until cancelled", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(|_args, ctx| { Box::pin(async move { ctx.cancel.cancelled().await; diff --git a/lib/components/fabro-agent/src/skills.rs b/lib/components/fabro-agent/src/skills.rs index f1aff7031..98e7ef296 100644 --- a/lib/components/fabro-agent/src/skills.rs +++ b/lib/components/fabro-agent/src/skills.rs @@ -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() ); diff --git a/lib/components/fabro-agent/src/subagent.rs b/lib/components/fabro-agent/src/subagent.rs index 697aa8893..8aa6de9b6 100644 --- a/lib/components/fabro-agent/src/subagent.rs +++ b/lib/components/fabro-agent/src/subagent.rs @@ -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") })); } diff --git a/lib/components/fabro-agent/src/task_reminder.rs b/lib/components/fabro-agent/src/task_reminder.rs index 0f1c59859..51d8145c4 100644 --- a/lib/components/fabro-agent/src/task_reminder.rs +++ b/lib/components/fabro-agent/src/task_reminder.rs @@ -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::::default(), + usage: TokenCounts::default(), response_id: "resp".into(), timestamp: SystemTime::now(), } diff --git a/lib/components/fabro-agent/src/test_support.rs b/lib/components/fabro-agent/src/test_support.rs index b68c9ed8a..bb3820aab 100644 --- a/lib/components/fabro-agent/src/test_support.rs +++ b/lib/components/fabro-agent/src/test_support.rs @@ -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, 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 { - 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 { + Ok(self.next_response()) } - async fn stream(&self, _request: &Request) -> Result { - 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 { + 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> = 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) -> 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) -> 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) -> Client { + make_client_with_options(provider, ClientOptions::default()) +} + +pub fn make_client_with_options( + provider: Arc, + options: ClientOptions, +) -> Client { + client_with_adapters(vec![(TEST_PROVIDER, provider)], options) } pub async fn make_session(responses: Vec) -> 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 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 { - Err(self.error.clone()) + async fn complete(&self, _call: &ResolvedCall) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Err((self.factory)()) } - async fn stream(&self, _request: &Request) -> Result { - Err(self.error.clone()) + async fn stream(&self, _call: &ResolvedCall) -> Result { + 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>, + 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 { + async fn complete(&self, call: &ResolvedCall) -> Result { *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 { + async fn stream(&self, call: &ResolvedCall) -> Result { *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, - } -} diff --git a/lib/components/fabro-agent/src/todo_tools.rs b/lib/components/fabro-agent/src/todo_tools.rs index 2eb136ed8..f30d94aa8 100644 --- a/lib/components/fabro-agent/src/todo_tools.rs +++ b/lib/components/fabro-agent/src/todo_tools.rs @@ -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) -> 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) -> 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) /// same [`TodoRuntime`] backs it, so projections and events are unchanged. pub fn make_todo_list_tool(runtime: Arc) -> 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) -> 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) -> 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) -> 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) -> RegisteredTool { #[must_use] pub fn make_task_update_tool(runtime: Arc) -> 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) -> 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) -> RegisteredTool { #[must_use] pub fn make_task_get_tool(runtime: Arc) -> 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) -> RegisteredTool { #[must_use] pub fn make_task_list_tool(runtime: Arc) -> 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 { diff --git a/lib/components/fabro-agent/src/tool_execution.rs b/lib/components/fabro-agent/src/tool_execution.rs index cc2943aaf..b444bde38 100644 --- a/lib/components/fabro-agent/src/tool_execution.rs +++ b/lib/components/fabro-agent/src/tool_execution.rs @@ -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) -> 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, ) -> 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); } } diff --git a/lib/components/fabro-agent/src/tool_registry.rs b/lib/components/fabro-agent/src/tool_registry.rs index 7e955aed0..c8b02e267 100644 --- a/lib/components/fabro-agent/src/tool_registry.rs +++ b/lib/components/fabro-agent/src/tool_registry.rs @@ -3,8 +3,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use fabro_llm::types::ToolDefinition; -use fabro_types::{AgentToolCategory, AgentToolSource, AgentToolSummary}; +use fabro_types::{AgentToolCategory, AgentToolSource, AgentToolSummary, ToolDefinition}; use tokio_util::sync::CancellationToken; use crate::config::{ToolAccessPolicy, ToolExposureMode}; @@ -66,6 +65,37 @@ impl ToolContext { } } +/// Schema accessors over the lithos tool definition. +/// +/// lithos keeps the schema inside [`ToolDefinitionKind`] so a custom tool can +/// never leak a JSON Schema onto the wire. Fabro's tool code reads the +/// function schema often enough to want a direct accessor. +pub trait ToolDefinitionExt { + /// The JSON Schema of a function tool. Panics for a custom tool, which + /// has no schema; Fabro registers custom tools only where the codec + /// accepts them. + fn parameters(&self) -> &serde_json::Value; + + /// The provider-specific format of a custom tool. + fn custom_format(&self) -> Option<&serde_json::Value>; +} + +impl ToolDefinitionExt for ToolDefinition { + fn parameters(&self) -> &serde_json::Value { + match &self.kind { + fabro_types::ToolDefinitionKind::Function { input_schema } => input_schema, + _ => panic!("custom tool '{}' has no parameter schema", self.name), + } + } + + fn custom_format(&self) -> Option<&serde_json::Value> { + match &self.kind { + fabro_types::ToolDefinitionKind::Custom { format } => Some(format), + _ => None, + } + } +} + pub type ToolExecutor = Arc< dyn Fn( serde_json::Value, @@ -306,11 +336,11 @@ mod tests { fn make_tool(name: &str) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: name.into(), - description: format!("Tool {name}"), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + name, + format!("Tool {name}"), + serde_json::json!({"type": "object"}), + ), executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("ok".into()) })), source: ToolSource::Native, } @@ -391,20 +421,12 @@ mod tests { fn name_collision_overrides() { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { - definition: ToolDefinition { - name: "tool_a".into(), - description: "version 1".into(), - parameters: serde_json::json!({}), - }, + definition: ToolDefinition::function("tool_a", "version 1", serde_json::json!({})), executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v1".into()) })), source: ToolSource::Native, }); registry.register(RegisteredTool { - definition: ToolDefinition { - name: "tool_a".into(), - description: "version 2".into(), - parameters: serde_json::json!({}), - }, + definition: ToolDefinition::function("tool_a", "version 2", serde_json::json!({})), executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v2".into()) })), source: ToolSource::Native, }); @@ -530,14 +552,14 @@ mod tests { fn tool_with_source(name: &str, source: ToolSource) -> ToolDefinitionWithSource { ToolDefinitionWithSource { - definition: ToolDefinition { - name: name.to_string(), - description: format!("{name} description"), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + name.to_string(), + format!("{name} description"), + serde_json::json!({ "type": "object", "properties": { "path": { "type": "string" } } }), - }, + ), source, } } diff --git a/lib/components/fabro-agent/src/tools.rs b/lib/components/fabro-agent/src/tools.rs index 24ad2c80f..7e284954a 100644 --- a/lib/components/fabro-agent/src/tools.rs +++ b/lib/components/fabro-agent/src/tools.rs @@ -2,11 +2,10 @@ use std::borrow::Cow; use std::fmt::Write; use std::sync::Arc; -use fabro_llm::client::Client; -use fabro_llm::types::{Message, Request, ToolDefinition}; -use fabro_model::ModelHandle; +use fabro_llm::{Client, Request}; #[cfg(test)] use fabro_static::EnvVars; +use fabro_types::{ModelHandle, ToolDefinition}; use futures::{StreamExt, stream}; use tokio::task; @@ -115,10 +114,10 @@ pub(crate) fn optional_usize_arg( #[must_use] pub fn make_read_file_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "read_file".into(), - description: "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "read_file", + "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.", + serde_json::json!({ "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute path to the file"}, @@ -127,13 +126,12 @@ pub fn make_read_file_tool() -> RegisteredTool { }, "required": ["file_path"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let file_path = required_str(&args, "file_path")?; let offset_usize = optional_usize_arg(&args, "offset")?; - let limit_usize = - optional_usize_arg(&args, "limit")?.or(Some(DEFAULT_READ_LINES)); + let limit_usize = optional_usize_arg(&args, "limit")?.or(Some(DEFAULT_READ_LINES)); let content = ctx .env @@ -150,10 +148,10 @@ pub fn make_read_file_tool() -> RegisteredTool { #[must_use] pub fn make_write_file_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "write_file".into(), - description: "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "write_file", + "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.", + serde_json::json!({ "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute path to the file"}, @@ -161,7 +159,7 @@ pub fn make_write_file_tool() -> RegisteredTool { }, "required": ["file_path", "content"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let file_path = required_str(&args, "file_path")?; @@ -181,10 +179,10 @@ pub fn make_write_file_tool() -> RegisteredTool { #[must_use] pub fn make_edit_file_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "edit_file".into(), - description: "Edit a file by replacing an exact string. The old_string must be an exact match and unique unless replace_all is true; include surrounding context when needed. Read the file first and preserve existing indentation.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "edit_file", + "Edit a file by replacing an exact string. The old_string must be an exact match and unique unless replace_all is true; include surrounding context when needed. Read the file first and preserve existing indentation.", + serde_json::json!({ "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute path to the file"}, @@ -194,7 +192,7 @@ pub fn make_edit_file_tool() -> RegisteredTool { }, "required": ["file_path", "old_string", "new_string"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let file_path = required_str(&args, "file_path")?; @@ -248,10 +246,10 @@ pub fn make_shell_tool_with_options(options: &NativeToolOptions) -> RegisteredTo let default_timeout = options.default_command_timeout_ms; let max_timeout = options.max_command_timeout_ms; RegisteredTool { - definition: ToolDefinition { - name: "shell".into(), - description: "Execute Bash commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "shell", + "Execute Bash commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.", + serde_json::json!({ "type": "object", "properties": { "command": {"type": "string", "description": "Bash source to evaluate, run by a non-login Bash shell"}, @@ -260,7 +258,7 @@ pub fn make_shell_tool_with_options(options: &NativeToolOptions) -> RegisteredTo }, "required": ["command"] }), - }, + ), executor: Arc::new(move |args, ctx| { Box::pin(async move { let command = required_str(&args, "command")?; @@ -412,10 +410,10 @@ fn render_shell_result(streaming: &ExecStreamingResult) -> String { #[must_use] pub fn make_grep_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "grep".into(), - description: "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "grep", + "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.", + serde_json::json!({ "type": "object", "properties": { "pattern": {"type": "string", "description": "Regex pattern to search for"}, @@ -426,7 +424,7 @@ pub fn make_grep_tool() -> RegisteredTool { }, "required": ["pattern"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let pattern = required_str(&args, "pattern")?; @@ -501,10 +499,10 @@ pub(crate) fn grep_result_path<'a>(line: &'a str, searched: &'a str) -> &'a str #[must_use] pub fn make_glob_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "glob".into(), - description: "Find files by search-root-relative path using a glob pattern. Use path to choose the search root. `*` stays within one path segment and `**` searches recursively. Prefer this over shell find or ls when locating repository files.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "glob", + "Find files by search-root-relative path using a glob pattern. Use path to choose the search root. `*` stays within one path segment and `**` searches recursively. Prefer this over shell find or ls when locating repository files.", + serde_json::json!({ "type": "object", "properties": { "pattern": {"type": "string", "description": "Glob pattern relative to the search root"}, @@ -512,7 +510,7 @@ pub fn make_glob_tool() -> RegisteredTool { }, "required": ["pattern"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let pattern = required_str(&args, "pattern")?; @@ -533,10 +531,10 @@ pub fn make_glob_tool() -> RegisteredTool { #[must_use] pub(crate) fn make_read_many_files_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "read_many_files".into(), - description: "Read multiple files at once".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "read_many_files", + "Read multiple files at once", + serde_json::json!({ "type": "object", "properties": { "paths": { @@ -547,7 +545,7 @@ pub(crate) fn make_read_many_files_tool() -> RegisteredTool { }, "required": ["paths"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let paths: Vec = args["paths"] @@ -594,10 +592,10 @@ pub(crate) fn make_read_many_files_tool() -> RegisteredTool { #[must_use] pub(crate) fn make_list_dir_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "list_dir".into(), - description: "List directory contents with depth control".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "list_dir", + "List directory contents with depth control", + serde_json::json!({ "type": "object", "properties": { "path": {"type": "string", "description": "Directory path to list"}, @@ -605,7 +603,7 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool { }, "required": ["path"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let path = required_str(&args, "path")?; @@ -636,10 +634,10 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool { #[must_use] pub(crate) fn make_web_fetch_tool(summarizer: Option) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "web_fetch".into(), - description: "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "web_fetch", + "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.", + serde_json::json!({ "type": "object", "properties": { "url": {"type": "string", "description": "URL to fetch (must be http:// or https://)"}, @@ -648,8 +646,8 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option) -> Reg }, "required": ["url"] }), - }, - executor: Arc::new(move |args, ctx| { + ), + executor: Arc::new(move |args, ctx| { let summarizer = summarizer.clone(); Box::pin(async move { let url = required_str(&args, "url")?; @@ -702,36 +700,30 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option) -> Reg let summarization_prompt = format!( "Content from {url}:\n---\n{content}\n---\n\n{user_prompt}\n\nRespond concisely based only on the content above." ); - let request = Request { - model: s.model_id.model_id().to_string(), - messages: vec![Message::user(summarization_prompt)], - provider: Some(s.model_id.provider().to_string()), - tools: None, - tool_choice: None, - 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 response = s.client.complete(&request).await.map_err(|e| { - format!("web_fetch summarization (model={}) failed: {e}", s.model_id.model_id()) + let request = Request::builder() + .model(s.model_id.to_string()) + .user(summarization_prompt) + .build() + .map_err(|e| format!("web_fetch summarization request invalid: {e}"))?; + let response = s.client.complete(request).await.map_err(|e| { + format!( + "web_fetch summarization (model={}) failed: {e}", + s.model_id.model() + ) })?; Ok(response.text()) } (Some(_), None) => { // Graceful degradation: return content with a note - Ok(format!("[Note: prompt summarization unavailable, returning full content]\n\n{content}")) + Ok(format!( + "[Note: prompt summarization unavailable, returning full content]\n\n{content}" + )) } (None, _) => Ok(content), } }) }), - source: ToolSource::Native, + source: ToolSource::Native, } } @@ -739,9 +731,8 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option) -> Reg mod tests { use std::collections::HashMap; - use fabro_llm::provider::ProviderAdapter; - use fabro_model::ProviderId; - use fabro_types::CommandTermination; + use fabro_llm::adapter::ProviderAdapter; + use fabro_types::{CommandTermination, ModelId, provider_ids}; use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; @@ -751,7 +742,7 @@ mod tests { use crate::local_sandbox::LocalSandbox; use crate::sandbox::*; use crate::test_support::MockSandbox; - use crate::tool_registry::ToolContext; + use crate::tool_registry::{ToolContext, ToolDefinitionExt}; use crate::truncation; use crate::types::SessionEvent; use crate::web_search::make_web_search_tool_with_api_key; @@ -818,7 +809,7 @@ mod tests { assert_eq!(tool.definition.name, "shell"); assert_eq!( - tool.definition.parameters, + *tool.definition.parameters(), serde_json::json!({ "type": "object", "properties": { @@ -1987,10 +1978,7 @@ mod tests { let client = make_client(provider).await; let summarizer = WebFetchSummarizer { client, - model_id: ModelHandle::ByName { - provider: ProviderId::anthropic(), - model: "mock-model".to_string(), - }, + model_id: ModelHandle::new(provider_ids::anthropic(), ModelId::new("mock-model")), }; let tool = make_web_fetch_tool(Some(summarizer)); @@ -2066,40 +2054,28 @@ mod tests { #[tokio::test] async fn web_fetch_summarizer_routes_to_specified_provider() { - use fabro_llm::Error as LlmError; - use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; + use fabro_llm::test_support::client_with_adapters; + use fabro_llm::{ClientOptions, ErrorKind}; use crate::test_support::{MockErrorProvider, MockLlmProvider, text_response}; - // "other_provider" is the default — it rejects all requests. - let default_provider: Arc = Arc::new(MockErrorProvider { - error: LlmError::Provider { - kind: ProviderErrorKind::NotFound, - detail: Box::new(ProviderErrorDetail::new( - "model not found", - "other_provider", - )), - }, - }); - // "anthropic" provider has the model we actually want. + // OpenAI rejects all requests, so a summary can only come from the + // provider the summarizer names. + let default_provider: Arc = Arc::new(MockErrorProvider::new(|| { + fabro_llm::Error::new(ErrorKind::NotFound, "model not found") + })); let target_provider: Arc = Arc::new(MockLlmProvider::new(vec![text_response( "summarized content", )])); - - let mut providers = HashMap::new(); - providers.insert("other_provider".to_string(), default_provider); - // Register under "anthropic" so ModelRef { provider: "anthropic", .. } routes - // here - providers.insert("anthropic".to_string(), target_provider); - let client = Client::new(providers, Some("other_provider".into()), vec![]); + let client = client_with_adapters( + vec![("openai", default_provider), ("anthropic", target_provider)], + ClientOptions::default(), + ); let summarizer = WebFetchSummarizer { client, - model_id: ModelHandle::ByName { - provider: ProviderId::anthropic(), - model: "target-model".to_string(), - }, + model_id: ModelHandle::new(provider_ids::anthropic(), ModelId::new("target-model")), }; let tool = make_web_fetch_tool(Some(summarizer)); diff --git a/lib/components/fabro-agent/src/truncation.rs b/lib/components/fabro-agent/src/truncation.rs index 719cda287..f3c0bfc4e 100644 --- a/lib/components/fabro-agent/src/truncation.rs +++ b/lib/components/fabro-agent/src/truncation.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use fabro_llm::token_count; +use fabro_llm::estimate; use fabro_types::run_event::MAX_RUN_EVENT_BODY_BYTES; use serde::Serialize; @@ -181,8 +181,8 @@ fn render_truncated_segments( stats: OutputCaptureStats, line_count_omitted: Option, ) -> String { - let original_tokens = token_count::estimate_byte_tokens(stats.observed_bytes); - let omitted_tokens = token_count::estimate_byte_tokens(stats.omitted_bytes); + let original_tokens = estimate::byte_tokens(stats.observed_bytes); + let omitted_tokens = estimate::byte_tokens(stats.omitted_bytes); let middle_marker = line_count_omitted.map_or_else( || format!("... approximately {omitted_tokens} tokens truncated ..."), |lines| { diff --git a/lib/components/fabro-agent/src/types.rs b/lib/components/fabro-agent/src/types.rs index eba037472..cf1627e03 100644 --- a/lib/components/fabro-agent/src/types.rs +++ b/lib/components/fabro-agent/src/types.rs @@ -1,14 +1,11 @@ use std::time::SystemTime; use chrono::{DateTime, Utc}; -use fabro_llm::Error as LlmError; -use fabro_llm::types::{ - ContentPart, Message as LlmMessage, Role, ThinkingData, TokenCounts, ToolCall, ToolResult, -}; -use fabro_model::{CostSource, ModelRef}; +use fabro_llm::LlmError; use fabro_types::{ - CommandTermination, ExecOutputTail, LlmOutputKind, LlmRetryPhase, ReasoningOutput, - SessionMessage, StageContextWindowProjection, + CommandTermination, ContentPart, Cost, ExecOutputTail, LlmOutputKind, LlmRetryPhase, + Message as LlmMessage, ModelRef, ReasoningOutput, Role, SessionMessage, Speed, + StageContextWindowProjection, TokenCounts, ToolCall, ToolResult, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -52,9 +49,9 @@ pub enum Message { /// Provider-specific content parts (e.g. `OpenAI` reasoning items, /// `Anthropic` thinking blocks with signatures) preserved for /// round-tripping. Reasoning/thinking text is stored here as - /// `ContentPart::Thinking`. + /// `ContentPart::Reasoning`. provider_parts: Vec, - usage: Box, + usage: TokenCounts, response_id: String, timestamp: SystemTime, }, @@ -86,11 +83,9 @@ impl Message { return None; }; provider_parts.iter().find_map(|p| match p { - ContentPart::Thinking(ThinkingData { - text, - redacted: false, - .. - }) => Some(text.as_str()), + ContentPart::Reasoning(reasoning) if !reasoning.redacted => { + Some(reasoning.text.as_str()) + } _ => None, }) } @@ -101,7 +96,9 @@ impl Message { #[must_use] pub fn to_llm_message(&self) -> LlmMessage { match self { - Self::User { content, .. } => LlmMessage::user(content), + Self::User { content, .. } | Self::Steering { content, .. } => { + LlmMessage::text(Role::User, content) + } Self::Assistant { content, tool_calls, @@ -114,39 +111,28 @@ impl Message { // function calls for correct round-tripping. parts.extend(provider_parts.iter().cloned()); if !content.is_empty() { - parts.push(ContentPart::text(content)); + parts.push(ContentPart::Text { + text: content.clone(), + }); } for tc in tool_calls { parts.push(ContentPart::ToolCall(tc.clone())); } - LlmMessage { - role: Role::Assistant, - content: parts, - name: None, - tool_call_id: None, - } + LlmMessage::new(Role::Assistant, parts) } Self::ToolResults { results, .. } => { let content: Vec = results .iter() .map(|r| ContentPart::ToolResult(r.clone())) .collect(); + let message = LlmMessage::new(Role::Tool, content); // Use the first result's tool_call_id if available - let tool_call_id = results.first().map(|r| r.tool_call_id.clone()); - LlmMessage { - role: Role::Tool, - content, - name: None, - tool_call_id, + match results.first() { + Some(first) => message.with_tool_call_id(first.tool_call_id.clone()), + None => message, } } - Self::System { content, .. } => LlmMessage::system(content), - Self::Steering { content, .. } => LlmMessage { - role: Role::User, - content: vec![ContentPart::text(content)], - name: None, - tool_call_id: None, - }, + Self::System { content, .. } => LlmMessage::text(Role::System, content), } } @@ -168,7 +154,7 @@ impl Message { content: content.clone(), tool_calls: values_or_empty(tool_calls), provider_parts: values_or_empty(provider_parts), - usage: value_or_null(&**usage), + usage: value_or_null(usage), response_id: response_id.clone(), timestamp: system_time_to_utc(*timestamp), }, @@ -204,7 +190,7 @@ impl Message { content: content.clone(), tool_calls: values_from_json(tool_calls)?, provider_parts: values_from_json(provider_parts)?, - usage: Box::new(serde_json::from_value(usage.clone())?), + usage: serde_json::from_value(usage.clone())?, response_id: response_id.clone(), timestamp: utc_to_system_time(*timestamp), }, @@ -318,12 +304,10 @@ pub enum AgentEvent { text: String, model: ModelRef, usage: TokenCounts, - /// USD cost reported or estimated for this individual response. + /// Cost reported or estimated for this individual response, with its + /// provenance. #[serde(default, skip_serializing_if = "Option::is_none")] - cost_usd: Option, - /// Provenance of `cost_usd`. - #[serde(default, skip_serializing_if = "Option::is_none")] - cost_source: Option, + cost: Option, tool_call_count: usize, #[serde(default, skip_serializing_if = "Option::is_none")] context_window: Option, @@ -527,7 +511,7 @@ impl AgentEvent { session_id, provider = %requested_model.provider, model = %requested_model.model_id, - speed = requested_model.speed.map_or("", <&'static str>::from), + speed = requested_model.speed.map_or("", Speed::as_str), "LLM request started" ); } @@ -544,8 +528,8 @@ impl AgentEvent { session_id, provider = %model.provider, model = model.model_id.as_str(), - input_tokens = usage.input_tokens, - output_tokens = usage.output_tokens, + input_tokens = usage.input, + output_tokens = usage.output, tool_call_count, "Assistant message" ); @@ -832,10 +816,18 @@ pub struct SessionEvent { #[cfg(test)] mod tests { - use fabro_model::ProviderId; + use fabro_llm::{ErrorFacts, ErrorKind, RetryClassification}; + use fabro_types::{CostSource, ModelId, ProviderId, provider_ids}; use super::*; + fn network_error(message: &str) -> LlmError { + LlmError::from( + fabro_llm::Error::new(ErrorKind::Network, message) + .with_retry(RetryClassification::Safe), + ) + } + #[test] fn session_event_construction() { let event = SessionEvent { @@ -1102,40 +1094,37 @@ mod tests { #[test] fn agent_event_assistant_message() { let usage = TokenCounts { - input_tokens: 100, - output_tokens: 50, - cache_read_tokens: 80, - cache_write_tokens: 10, - reasoning_tokens: 20, + input: 100, + output: 50, + cache_read: 80, + cache_write: 10, + reasoning: 20, }; let event = AgentEvent::AssistantMessage { - text: "Hello".into(), - model: ModelRef { - provider: ProviderId::openai(), - model_id: "test-model".into(), - speed: None, - }, - usage: usage.clone(), - cost_usd: Some(0.125), - cost_source: Some(CostSource::Authoritative), + text: "Hello".into(), + model: ModelRef::new(provider_ids::openai(), ModelId::new("test-model")), + usage, + cost: Some(Cost { + usd_micros: 125_000, + source: CostSource::Provider, + }), tool_call_count: 2, - context_window: None, - reasoning: None, + context_window: None, + reasoning: None, }; match &event { AgentEvent::AssistantMessage { usage, - cost_usd, - cost_source, + cost, tool_call_count, .. } => { assert_eq!(*tool_call_count, 2); - assert_eq!(usage.input_tokens, 100); - assert_eq!(usage.cache_read_tokens, 80); - assert_eq!(usage.reasoning_tokens, 20); - assert_eq!(*cost_usd, Some(0.125)); - assert_eq!(*cost_source, Some(CostSource::Authoritative)); + assert_eq!(usage.input, 100); + assert_eq!(usage.cache_read, 80); + assert_eq!(usage.reasoning, 20); + assert_eq!(cost.map(|cost| cost.usd_micros), Some(125_000)); + assert_eq!(cost.map(|cost| cost.source), Some(CostSource::Provider)); } _ => panic!("expected AssistantMessage"), } @@ -1163,10 +1152,7 @@ mod tests { #[test] fn error_event_serde_roundtrip_with_agent_error() { let event = AgentEvent::Error { - error: Error::Llm(LlmError::Network { - message: "refused".into(), - source: None, - }), + error: Error::Llm(network_error("refused")), }; let json = serde_json::to_string(&event).unwrap(); let deserialized: AgentEvent = serde_json::from_str(&json).unwrap(); @@ -1180,31 +1166,27 @@ mod tests { #[test] fn llm_retry_event_carries_sdk_error() { - use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; let event = AgentEvent::LlmRetry { provider: "openai".into(), model: "gpt-4".into(), attempt: 1, delay_secs: 2.0, phase: LlmRetryPhase::Open, - error: 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, - }), - }, + error: LlmError::from( + fabro_llm::Error::new(ErrorKind::RateLimit, "too fast") + .with_provider(ProviderId::new("openai")) + .with_status(429) + .with_retry(RetryClassification::after(std::time::Duration::from_secs( + 2, + ))), + ), }; let json = serde_json::to_string(&event).unwrap(); let deserialized: AgentEvent = serde_json::from_str(&json).unwrap(); match deserialized { AgentEvent::LlmRetry { error, .. } => { - assert!(error.retryable()); - assert_eq!(error.retry_after(), Some(2.0)); + assert!(error.is_retryable()); + assert_eq!(error.retry_after(), Some(std::time::Duration::from_secs(2))); } _ => panic!("expected LlmRetry variant"), } diff --git a/lib/components/fabro-agent/src/web_search.rs b/lib/components/fabro-agent/src/web_search.rs index 08c23863a..6c9fdf151 100644 --- a/lib/components/fabro-agent/src/web_search.rs +++ b/lib/components/fabro-agent/src/web_search.rs @@ -7,7 +7,7 @@ use std::fmt::Write; use std::sync::OnceLock; use std::time::Duration; -use fabro_llm::types::ToolDefinition; +use fabro_types::ToolDefinition; use crate::config::ToolSecrets; use crate::tool_registry::{RegisteredTool, ToolSource}; @@ -269,10 +269,10 @@ fn max_results_arg(args: &serde_json::Value) -> u64 { #[must_use] pub(crate) fn make_web_search_tool(backend: SearchBackend) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: WEB_SEARCH_TOOL_NAME.into(), - description: "Search the web when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + WEB_SEARCH_TOOL_NAME, + "Search the web when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.", + serde_json::json!({ "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, @@ -280,7 +280,7 @@ pub(crate) fn make_web_search_tool(backend: SearchBackend) -> RegisteredTool { }, "required": ["query"] }), - }, + ), executor: std::sync::Arc::new(move |args, _ctx| { let backend = backend.clone(); Box::pin(async move { @@ -310,7 +310,7 @@ mod tests { use crate::config::ToolSecrets; use crate::sandbox::Sandbox; use crate::test_support::MockSandbox; - use crate::tool_registry::ToolContext; + use crate::tool_registry::{ToolContext, ToolDefinitionExt}; fn secrets(brave: Option<&str>, venice: Option<&str>) -> ToolSecrets { ToolSecrets { @@ -403,7 +403,10 @@ mod tests { fn brave_and_venice_use_the_same_tool_schema() { let brave = make_web_search_tool(SearchBackend::brave("key".into())); let venice = make_web_search_tool(SearchBackend::venice("key".into())); - assert_eq!(brave.definition.parameters, venice.definition.parameters); + assert_eq!( + brave.definition.parameters(), + venice.definition.parameters() + ); } #[tokio::test] diff --git a/lib/components/fabro-agent/tests/it/compaction.rs b/lib/components/fabro-agent/tests/it/compaction.rs index 14cb88d5e..64e46b68c 100644 --- a/lib/components/fabro-agent/tests/it/compaction.rs +++ b/lib/components/fabro-agent/tests/it/compaction.rs @@ -1,12 +1,9 @@ -use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use fabro_agent::{AgentProfile, LocalSandbox, OpenAiProfile, Session, SessionOptions}; -use fabro_llm::client::Client; -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::OpenAiAdapter; -use fabro_model::ProviderId; +use fabro_llm::test_support::client_from_env; +use fabro_llm::{Client, ClientOptions}; use fabro_test::{TwinScenario, TwinScenarios, TwinToolCall, twin_openai}; use tokio::fs::read_to_string; @@ -23,7 +20,7 @@ async fn openai_twin_compaction_preserves_tool_call_pairs() { load_compaction_scenarios(&api_key).await; - let mut session = make_openai_session(tmp.path(), base_url, api_key); + let mut session = make_openai_session(tmp.path(), base_url, api_key).await; session.initialize().await.unwrap(); let result = session @@ -44,12 +41,8 @@ async fn openai_twin_compaction_preserves_tool_call_pairs() { ); } -fn make_openai_session(cwd: &Path, base_url: String, api_key: String) -> Session { - let adapter: Arc = - Arc::new(OpenAiAdapter::new(api_key).with_base_url(base_url)); - let mut providers = HashMap::new(); - providers.insert(ProviderId::OPENAI.to_string(), adapter); - let client = Client::new(providers, Some(ProviderId::OPENAI.to_string()), Vec::new()); +async fn make_openai_session(cwd: &Path, base_url: String, api_key: String) -> Session { + let client = openai_client(base_url, api_key).await; let profile: Arc = Arc::new(OpenAiProfile::new(MODEL)); let sandbox = Arc::new(LocalSandbox::new(cwd.to_path_buf())); let options = SessionOptions { @@ -96,3 +89,18 @@ async fn load_compaction_scenarios(namespace: &str) { .load(twin_openai().await) .await; } + +/// A client whose `openai` provider points at `base_url` and authenticates +/// with `api_key`, the way the twin expects. +async fn openai_client(base_url: String, api_key: String) -> Client { + let catalog = fabro_llm::build_catalog(&fabro_config::LlmLayer::default(), &move |name| { + (name == fabro_static::EnvVars::OPENAI_BASE_URL).then(|| base_url.clone()) + }) + .expect("catalog should build"); + client_from_env( + catalog, + move |name| (name == fabro_static::EnvVars::OPENAI_API_KEY).then(|| api_key.clone()), + ClientOptions::standard(), + ) + .await +} diff --git a/lib/components/fabro-agent/tests/it/guardrails.rs b/lib/components/fabro-agent/tests/it/guardrails.rs index 0e6136473..e84599131 100644 --- a/lib/components/fabro-agent/tests/it/guardrails.rs +++ b/lib/components/fabro-agent/tests/it/guardrails.rs @@ -1,23 +1,28 @@ use std::sync::Arc; use fabro_agent::{AgentProfile, AgentProfileBuilder}; -use fabro_model::Catalog; +use fabro_llm::catalog; +use fabro_llm::test_support::test_catalog; #[test] fn profile_context_window_matches_catalog_for_default_models() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - for provider in catalog.providers() { - let catalog_info = catalog - .default_for_provider(&provider.id) - .cloned() - .unwrap_or_else(|| panic!("no default model for {:?} in catalog", provider.id)); - let model = &catalog_info.id; - let context_window = usize::try_from(catalog_info.context_window()) - .expect("catalog context window should be non-negative and fit in usize"); + let catalog = Arc::new(test_catalog()); + for provider in catalog::listed_providers(&catalog) { + let provider_id = provider.id().clone(); + let Some(default) = catalog::default_model(&catalog, provider_id.as_str()) else { + // Deployment-defined providers (LiteLLM, Modal, Ollama) carry no + // built-in default model. + continue; + }; + let model = default.model.id().clone(); + let context_window = default.model.limits().map_or_else( + || panic!("no limits for {provider_id}/{model} in catalog"), + |limits| usize::try_from(limits.context_tokens).expect("context fits usize"), + ); let profile: Box = AgentProfileBuilder::new( - provider.agent_profile, - provider.id.clone(), + default.agent_profile(), + provider_id.clone(), model.as_str(), Arc::clone(&catalog), ) @@ -26,9 +31,7 @@ fn profile_context_window_matches_catalog_for_default_models() { assert_eq!( profile.context_window_size(), context_window, - "context_window_size mismatch for {:?} model '{}': profile={} catalog={}", - provider.id, - model, + "context_window_size mismatch for {provider_id} model '{model}': profile={} catalog={}", profile.context_window_size(), context_window ); diff --git a/lib/components/fabro-agent/tests/it/parity_matrix.rs b/lib/components/fabro-agent/tests/it/parity_matrix.rs index 3f8f9e55e..f85b0b5f2 100644 --- a/lib/components/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/components/fabro-agent/tests/it/parity_matrix.rs @@ -3,7 +3,6 @@ reason = "agent parity test harness: sync std::fs for staging fixture trees and reading captured outputs" )] -use std::collections::HashMap; use std::fmt::Write as _; use std::path::Path; use std::sync::Arc; @@ -13,13 +12,13 @@ use fabro_agent::{ AgentEvent, AgentProfile, AgentProfileBuilder, LocalSandbox, OpenAiProfile, Session, SessionOptions, SubAgentSupervisor, ToolSecrets, WebFetchSummarizer, }; -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::{OpenAiAdapter, OpenAiCompatibleAdapter}; -use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings}; -use fabro_model::{Catalog, ModelHandle, ProviderId}; +use fabro_auth::VaultCredentialSource; +use fabro_config::LlmLayer; +use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::test_support::client_from_env; +use fabro_llm::{Client, ClientOptions, catalog}; use fabro_test::{EnvVars, TwinScenario, TwinScenarios, TwinToolCall, twin_openai}; +use fabro_types::{ModelHandle, ModelId, ProviderId, provider_ids}; type Provider = ProviderId; @@ -30,21 +29,15 @@ struct OpenAiTwinOptions { } fn summarizer_model_id(provider: &Provider) -> ModelHandle { - match provider.as_str() { - ProviderId::OPENAI | "moonshot" | "zai" | "minimax" | "inception" => ModelHandle::ByName { - provider: ProviderId::openai(), - model: "gpt-5.4-mini".to_string(), - }, - ProviderId::GEMINI => ModelHandle::ByName { - provider: ProviderId::gemini(), - model: "gemini-3-flash-preview".to_string(), - }, - ProviderId::ANTHROPIC => ModelHandle::ByName { - provider: ProviderId::anthropic(), - model: "claude-haiku-4-5".to_string(), - }, + let (provider, model) = match provider.as_str() { + provider_ids::OPENAI | "moonshot" | "zai" | "minimax" | "inception" => { + (provider_ids::openai(), "gpt-5.4-mini") + } + provider_ids::GEMINI => (provider_ids::gemini(), "gemini-3-flash-preview"), + provider_ids::ANTHROPIC => (provider_ids::anthropic(), "claude-haiku-4.5"), other => panic!("unexpected provider {other}"), - } + }; + ModelHandle::new(provider, ModelId::new(model)) } fn build_summarizer(provider: &Provider, client: &Client) -> WebFetchSummarizer { @@ -61,11 +54,10 @@ fn profile_builder( tool_secrets: ToolSecrets, ) -> AgentProfileBuilder { let summarizer = Some(build_summarizer(provider, client)); - let catalog = Arc::new(Catalog::from_builtin().expect("default catalog should build")); + let catalog = Arc::new(live_catalog()); // Ask the catalog rather than keeping a provider->profile list in the test, // so adding a provider to the catalog cannot silently skip this matrix. - let profile_kind = catalog - .effective_agent_profile(provider, Some(model)) + let profile_kind = catalog::agent_profile(&catalog, provider.as_str(), Some(model)) .unwrap_or_else(|| panic!("no agent profile for provider {provider:?} in catalog")); AgentProfileBuilder::new(profile_kind, provider.clone(), model, Arc::clone(&catalog)) .with_web_fetch_summarizer(summarizer) @@ -127,61 +119,81 @@ async fn make_session_with_config( Session::new(client, profile, env, config, None) } +/// The catalog live tests run against: built-ins plus Fabro policy, with the +/// `openai` provider repointed at `OPENAI_BASE_URL` when the environment sets +/// it. +#[expect( + clippy::disallowed_methods, + reason = "live parity tests read provider endpoints from the process environment" +)] +fn live_catalog() -> Catalog { + fabro_llm::build_catalog(&LlmLayer::default(), &|name| std::env::var(name).ok()) + .expect("default catalog should build") +} + +/// A catalog whose `openai` provider is served by the twin at `base_url`. +fn twin_catalog(base_url: &str, overlay: &str) -> Catalog { + let base_url = base_url.to_string(); + let overlay = LlmLayer(toml::from_str(overlay).expect("overlay should parse")); + fabro_llm::build_catalog(&overlay, &move |name| { + (name == EnvVars::OPENAI_BASE_URL).then(|| base_url.clone()) + }) + .expect("twin catalog should build") +} + async fn make_client(provider: &Provider, twin: Option<&OpenAiTwinOptions>) -> Client { - if provider == &ProviderId::openai() && fabro_test::TestMode::from_env().is_twin() { - return make_twin_client(twin.expect("openai twin config should be provided")); + if provider == &provider_ids::openai() && fabro_test::TestMode::from_env().is_twin() { + return make_twin_client(twin.expect("openai twin config should be provided")).await; } - let source = EnvCredentialSource::new(); - let catalog = Arc::new(Catalog::from_builtin().expect("default catalog should build")); - Client::from_source(&source, catalog) + let source = Arc::new(VaultCredentialSource::environment_only()); + fabro_llm::build_client(live_catalog(), source, ClientOptions::standard()) .await - .expect("Client::from_source failed") + .expect("LLM client should build") + .client } -fn make_twin_client(twin: &OpenAiTwinOptions) -> Client { - let adapter: Arc = - Arc::new(OpenAiAdapter::new(twin.api_key.clone()).with_base_url(twin.base_url.clone())); - let mut providers: HashMap> = HashMap::new(); - providers.insert("openai".to_string(), adapter); - Client::new(providers, Some("openai".to_string()), Vec::new()) +async fn make_twin_client(twin: &OpenAiTwinOptions) -> Client { + let api_key = twin.api_key.clone(); + client_from_env( + twin_catalog(&twin.base_url, ""), + move |name| (name == EnvVars::OPENAI_API_KEY).then(|| api_key.clone()), + ClientOptions::standard(), + ) + .await } -fn make_openai_compatible_twin_client(provider: &Provider, twin: &OpenAiTwinOptions) -> Client { - let provider_name = provider.to_string(); - let adapter: Arc = Arc::new( - OpenAiCompatibleAdapter::new(twin.api_key.clone(), twin.base_url.clone()) - .with_name(provider_name.clone()), - ); - let mut providers: HashMap> = HashMap::new(); - providers.insert(provider_name.clone(), adapter); - Client::new(providers, Some(provider_name), Vec::new()) +/// LiteLLM is opt-in in the built-in catalog and has no fixed endpoint. Enable +/// it and point it at the twin's Chat Completions endpoint so the profile +/// resolves the OpenAI-compatible codec the twin speaks. +fn litellm_twin_overlay(base_url: &str) -> String { + format!( + "[providers.litellm]\nbase_url = {}\nenabled = true\n", + toml::Value::String(base_url.to_string()) + ) } -fn make_openai_compatible_twin_session( +async fn make_openai_compatible_twin_client(catalog: Catalog, twin: &OpenAiTwinOptions) -> Client { + let api_key = twin.api_key.clone(); + client_from_env( + catalog, + move |name| (name == "LITELLM_API_KEY").then(|| api_key.clone()), + ClientOptions::standard(), + ) + .await +} + +async fn make_openai_compatible_twin_session( provider: Provider, model: &str, cwd: &Path, config: SessionOptions, twin: &OpenAiTwinOptions, ) -> Session { - let client = make_openai_compatible_twin_client(&provider, twin); - // LiteLLM is opt-in in the built-in catalog. Enable the provider in this - // twin fixture so the profile can resolve the same OpenAI-compatible - // codec that the manually registered adapter uses. - let mut settings = LlmCatalogSettings::default(); - settings - .providers - .insert(provider.to_string(), ProviderCatalogSettings { - enabled: Some(true), - ..ProviderCatalogSettings::default() - }); - let catalog = Arc::new( - Catalog::from_builtin_with_overrides(&settings) - .expect("OpenAI-compatible twin catalog should build"), - ); + let catalog = twin_catalog(&twin.base_url, &litellm_twin_overlay(&twin.base_url)); + let client = make_openai_compatible_twin_client(catalog.clone(), twin).await; let profile: Arc = - Arc::new(OpenAiProfile::new(model).with_route(provider, catalog)); + Arc::new(OpenAiProfile::new(model).with_route(provider, Arc::new(catalog))); let env = Arc::new(LocalSandbox::new(cwd.to_path_buf())); Session::new(client, profile, env, config, None) } @@ -249,7 +261,7 @@ macro_rules! openai_twin_provider_test { .await; } let mut session = make_session( - ProviderId::openai(), + provider_ids::openai(), "gpt-5.4-mini", tmp.path(), ToolSecrets::default(), @@ -266,14 +278,14 @@ macro_rules! provider_tests { ($scenario:ident) => { provider_test!( $scenario, - ProviderId::anthropic(), - "claude-haiku-4-5", + provider_ids::anthropic(), + "claude-haiku-4.5", anthropic, keys = ["ANTHROPIC_API_KEY"] ); provider_test!( $scenario, - ProviderId::gemini(), + provider_ids::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY"] @@ -342,7 +354,8 @@ async fn openai_compatible_twin_uses_json_edit_file_tool() { tmp.path(), SessionOptions::default(), &twin, - ); + ) + .await; session.initialize().await.unwrap(); let mut rx = session.subscribe(); @@ -393,21 +406,21 @@ provider_tests!(subagent_spawn); provider_test!( web_fetch, - ProviderId::anthropic(), + provider_ids::anthropic(), "claude-haiku-4-5", anthropic, keys = ["ANTHROPIC_API_KEY"] ); provider_test!( web_fetch, - ProviderId::openai(), + provider_ids::openai(), "gpt-5.4-mini", openai, keys = ["OPENAI_API_KEY"] ); provider_test!( web_fetch, - ProviderId::gemini(), + provider_ids::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY"] @@ -444,19 +457,19 @@ provider_test!( ); web_search_provider_test!( - ProviderId::anthropic(), + provider_ids::anthropic(), "claude-haiku-4-5", anthropic, keys = ["ANTHROPIC_API_KEY", "BRAVE_SEARCH_API_KEY"] ); web_search_provider_test!( - ProviderId::openai(), + provider_ids::openai(), "gpt-5.4-mini", openai, keys = ["OPENAI_API_KEY", "BRAVE_SEARCH_API_KEY"] ); web_search_provider_test!( - ProviderId::gemini(), + provider_ids::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY", "BRAVE_SEARCH_API_KEY"] @@ -505,14 +518,14 @@ macro_rules! non_openai_provider_tests { ($scenario:ident) => { provider_test!( $scenario, - ProviderId::anthropic(), - "claude-haiku-4-5", + provider_ids::anthropic(), + "claude-haiku-4.5", anthropic, keys = ["ANTHROPIC_API_KEY"] ); provider_test!( $scenario, - ProviderId::gemini(), + provider_ids::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY"] @@ -772,7 +785,7 @@ macro_rules! reasoning_effort_tests { async fn $test_name() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); let config = SessionOptions { - reasoning_effort: Some(fabro_llm::types::ReasoningEffort::Low), + reasoning_effort: Some(fabro_types::ReasoningEffort::Low), ..SessionOptions::default() }; let mut session = @@ -787,15 +800,15 @@ macro_rules! reasoning_effort_tests { } reasoning_effort_tests!( - ProviderId::anthropic(), - "claude-haiku-4-5", + provider_ids::anthropic(), + "claude-haiku-4.5", anthropic_reasoning_effort, keys = ["ANTHROPIC_API_KEY"] ); // gpt-5-mini does not support the reasoning.effort parameter, so no OpenAI // test. reasoning_effort_tests!( - ProviderId::gemini(), + provider_ids::gemini(), "gemini-3-flash-preview", gemini_reasoning_effort, keys = ["GEMINI_API_KEY"] @@ -865,19 +878,19 @@ macro_rules! loop_detection_tests { } loop_detection_tests!( - ProviderId::anthropic(), + provider_ids::anthropic(), "claude-haiku-4-5", anthropic_loop_detection, keys = ["ANTHROPIC_API_KEY"] ); loop_detection_tests!( - ProviderId::openai(), + provider_ids::openai(), "gpt-5.4-mini", openai_loop_detection, keys = ["OPENAI_API_KEY"] ); loop_detection_tests!( - ProviderId::gemini(), + provider_ids::gemini(), "gemini-3-flash-preview", gemini_loop_detection, keys = ["GEMINI_API_KEY"] diff --git a/lib/components/fabro-hooks/Cargo.toml b/lib/components/fabro-hooks/Cargo.toml index 2c1a68422..fe9659c76 100644 --- a/lib/components/fabro-hooks/Cargo.toml +++ b/lib/components/fabro-hooks/Cargo.toml @@ -16,7 +16,6 @@ workspace = true fabro-agent = { path = "../fabro-agent" } fabro-auth = { path = "../../foundation/fabro-auth" } fabro-llm = { path = "../fabro-llm" } -fabro-model = { path = "../../foundation/fabro-model" } fabro-redact.workspace = true fabro-types = { path = "../../foundation/fabro-types" } fabro-util = { path = "../../foundation/fabro-util" } diff --git a/lib/components/fabro-hooks/src/bridge.rs b/lib/components/fabro-hooks/src/bridge.rs index 6aa7e60f6..6f49a6a00 100644 --- a/lib/components/fabro-hooks/src/bridge.rs +++ b/lib/components/fabro-hooks/src/bridge.rs @@ -78,7 +78,7 @@ mod tests { use std::path::PathBuf; use std::sync::Mutex; - use fabro_model::Catalog; + use fabro_llm::lithos_catalog::Catalog; use fabro_types::fixtures; use super::*; @@ -100,7 +100,7 @@ mod tests { context: &HookContext, _sandbox: Arc, execution_context: &HookExecutionContext, - _llm_source: &dyn fabro_auth::CredentialSource, + _llm_source: Arc, _catalog: Arc, ) -> HookResult { self.captured_contexts.lock().unwrap().push(context.clone()); diff --git a/lib/components/fabro-hooks/src/executor.rs b/lib/components/fabro-hooks/src/executor.rs index 4aa87d168..3d208ed7e 100644 --- a/lib/components/fabro-hooks/src/executor.rs +++ b/lib/components/fabro-hooks/src/executor.rs @@ -7,12 +7,11 @@ use async_trait::async_trait; use fabro_agent::Sandbox; use fabro_agent::tool_registry::ToolContext; use fabro_auth::CredentialSource; -use fabro_llm::client::Client as LlmClient; -use fabro_llm::generate::{GenerateParams, generate_object}; -use fabro_llm::types::{Message, Request, ToolResult}; -use fabro_model::Catalog; +use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::{Client, ClientOptions, Request, structured}; use fabro_redact::redacted_url_for_log; use fabro_types::settings::{InterpString, ResolveCtx, ResolveError}; +use fabro_types::{Message, Role, ToolCall, tool_call_arguments, tool_result_from_json}; use tokio::process::Command as TokioCommand; use tokio::time::timeout as tokio_timeout; use tokio_util::sync::CancellationToken; @@ -49,7 +48,7 @@ pub trait HookExecutor: Send + Sync { context: &HookContext, sandbox: Arc, execution_context: &HookExecutionContext, - llm_source: &dyn CredentialSource, + llm_source: Arc, catalog: Arc, ) -> HookResult; } @@ -282,7 +281,7 @@ impl HookExecutorImpl { prompt: &InterpString, model: Option<&InterpString>, context: &HookContext, - llm_source: &dyn CredentialSource, + llm_source: Arc, catalog: Arc, ) -> HookDecision { let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model) { @@ -299,33 +298,48 @@ impl HookExecutorImpl { let user_msg = Self::build_hook_user_message(&prompt, context); Self::execute_llm_with_timeout(definition.timeout(), "prompt", || async move { - let client = match LlmClient::from_source(llm_source, catalog).await { - Ok(client) => Arc::new(client), + let client = match Self::build_client(catalog, llm_source).await { + Ok(client) => client, Err(e) => { tracing::warn!(error = %e, "prompt hook client creation failed, proceeding"); return HookDecision::Proceed; } }; - let params = GenerateParams::new(&resolved_model, client) + let request = Request::builder() + .model(&resolved_model) .system(HOOK_EVALUATOR_SYSTEM_PROMPT) - .prompt(user_msg) - .max_tokens(1024); + .user(user_msg) + .max_output_tokens(1024) + .build(); + let request = match request { + Ok(request) => request, + Err(e) => { + tracing::warn!(error = %e, "prompt hook request invalid, proceeding"); + return HookDecision::Proceed; + } + }; - match generate_object(params, HOOK_RESPONSE_SCHEMA.clone()).await { - Ok(result) => if let Some(obj) = result.output { match serde_json::from_value::(obj) { - Ok(resp) if resp.ok => HookDecision::Proceed, - Ok(resp) => HookDecision::Block { - reason: resp.reason, - }, - Err(e) => { - tracing::warn!(error = %e, "prompt hook response deserialize failed, proceeding"); - HookDecision::Proceed + match structured::complete_object( + &client, + request, + "hook_response", + HOOK_RESPONSE_SCHEMA.clone(), + ) + .await + { + Ok(completion) => { + match serde_json::from_value::(completion.object) { + Ok(resp) if resp.ok => HookDecision::Proceed, + Ok(resp) => HookDecision::Block { + reason: resp.reason, + }, + Err(e) => { + tracing::warn!(error = %e, "prompt hook response deserialize failed, proceeding"); + HookDecision::Proceed + } } - } } else { - tracing::warn!("prompt hook returned no structured output, proceeding"); - HookDecision::Proceed - }, + } Err(e) => { tracing::warn!(error = %e, "prompt hook LLM call failed, proceeding"); HookDecision::Proceed @@ -347,7 +361,7 @@ impl HookExecutorImpl { max_tool_rounds: Option, context: &HookContext, sandbox: Arc, - llm_source: &dyn CredentialSource, + llm_source: Arc, catalog: Arc, ) -> HookDecision { let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model) { @@ -364,7 +378,7 @@ impl HookExecutorImpl { let user_msg = Self::build_hook_user_message(&prompt, context); Self::execute_llm_with_timeout(definition.timeout(), "agent", || async move { - let client = match LlmClient::from_source(llm_source, catalog).await { + let client = match Self::build_client(catalog, llm_source).await { Ok(c) => c, Err(e) => { tracing::warn!(error = %e, "agent hook client creation failed, proceeding"); @@ -378,32 +392,30 @@ impl HookExecutorImpl { let tool_defs = registry.definitions(); let mut messages = vec![ - Message::system(HOOK_EVALUATOR_SYSTEM_PROMPT), - Message::user(user_msg), + Message::text(Role::System, HOOK_EVALUATOR_SYSTEM_PROMPT), + Message::text(Role::User, user_msg), ]; let rounds = max_tool_rounds.unwrap_or(50); let cancel = CancellationToken::new(); for _ in 0..rounds { - let request = Request { - model: resolved_model.clone(), - messages: messages.clone(), - provider: None, - tools: Some(tool_defs.clone()), - tool_choice: None, - 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(&resolved_model); + for message in &messages { + builder = builder.message(message.clone()); + } + for tool in &tool_defs { + builder = builder.tool(tool.clone()); + } + let request = match builder.build() { + Ok(request) => request, + Err(e) => { + tracing::warn!(error = %e, "agent hook request invalid, proceeding"); + return HookDecision::Proceed; + } }; - let response = match client.complete(&request).await { + let response = match client.complete(request).await { Ok(r) => r, Err(e) => { tracing::warn!(error = %e, "agent hook LLM call failed, proceeding"); @@ -411,13 +423,14 @@ impl HookExecutorImpl { } }; - let tool_calls = response.tool_calls(); + let tool_calls: Vec = response.tool_calls().cloned().collect(); if tool_calls.is_empty() { return Self::parse_prompt_response(&response.text()); } - messages.push(response.message.clone()); + messages.push(response.into_message()); + let mut results = Vec::with_capacity(tool_calls.len()); for tc in &tool_calls { let tool = registry.get(&tc.name).cloned(); let ctx = ToolContext { @@ -430,22 +443,27 @@ impl HookExecutorImpl { agent_event_emitter: None, }; let result = match tool { - Some(t) => match (t.executor)(tc.arguments.clone(), ctx).await { - Ok(output) => { - ToolResult::success(tc.id.clone(), serde_json::json!(output)) - } - Err(err) => ToolResult::error(tc.id.clone(), err), + Some(t) => match (t.executor)(tool_call_arguments(tc), ctx).await { + Ok(output) => tool_result_from_json( + tc.id.clone(), + serde_json::Value::String(output), + false, + ), + Err(err) => tool_result_from_json( + tc.id.clone(), + serde_json::Value::String(err), + true, + ), }, - None => { - ToolResult::error(tc.id.clone(), format!("Unknown tool: {}", tc.name)) - } + None => tool_result_from_json( + tc.id.clone(), + serde_json::Value::String(format!("Unknown tool: {}", tc.name)), + true, + ), }; - messages.push(Message::tool_result( - result.tool_call_id, - result.content, - result.is_error, - )); + results.push(fabro_types::ContentPart::ToolResult(result)); } + messages.push(Message::new(Role::Tool, results)); } tracing::warn!("agent hook exhausted max tool rounds, proceeding"); @@ -454,6 +472,21 @@ impl HookExecutorImpl { .await } + /// The LLM client hooks dispatch through: every provider the source can + /// serve, with standard retries. + async fn build_client( + catalog: Arc, + llm_source: Arc, + ) -> Result { + fabro_llm::build_client( + Catalog::clone(&catalog), + llm_source, + ClientOptions::standard(), + ) + .await + .map(|built| built.client) + } + /// Build an HTTP client for the given TLS mode. fn build_http_client(tls: TlsMode) -> fabro_http::HttpClient { let accept_invalid = matches!(tls, TlsMode::NoVerify | TlsMode::Off); @@ -629,7 +662,7 @@ impl HookExecutor for HookExecutorImpl { context: &HookContext, sandbox: Arc, execution_context: &HookExecutionContext, - llm_source: &dyn CredentialSource, + llm_source: Arc, catalog: Arc, ) -> HookResult { use std::sync::OnceLock; @@ -751,7 +784,7 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin().unwrap()) + Arc::new(fabro_llm::default_catalog()) } fn test_http_client() -> fabro_http::HttpClient { @@ -841,7 +874,7 @@ mod tests { &ctx, sandbox, &HookExecutionContext::default(), - source.as_ref(), + Arc::clone(&source), test_catalog(), ) .await; @@ -862,7 +895,7 @@ mod tests { &ctx, sandbox, &HookExecutionContext::default(), - source.as_ref(), + Arc::clone(&source), test_catalog(), ) .await; @@ -882,7 +915,7 @@ mod tests { &ctx, sandbox, &HookExecutionContext::default(), - source.as_ref(), + Arc::clone(&source), test_catalog(), ) .await; @@ -902,7 +935,7 @@ mod tests { &ctx, sandbox, &HookExecutionContext::default(), - source.as_ref(), + Arc::clone(&source), test_catalog(), ) .await; @@ -926,7 +959,7 @@ mod tests { &ctx, sandbox, &HookExecutionContext::default(), - source.as_ref(), + Arc::clone(&source), test_catalog(), ) .await; @@ -955,7 +988,7 @@ mod tests { &ctx, sandbox, &HookExecutionContext::default(), - source.as_ref(), + Arc::clone(&source), test_catalog(), ) .await; @@ -1441,7 +1474,7 @@ mod tests { &ctx, sandbox, &HookExecutionContext::default(), - source.as_ref(), + Arc::clone(&source), test_catalog(), ) .await; @@ -1475,7 +1508,7 @@ mod tests { &interp("{{ env.MISSING_HOOK_VALUE }}"), None, &make_context(), - test_llm_source().as_ref(), + test_llm_source(), test_catalog(), ) .await; @@ -1503,7 +1536,7 @@ mod tests { Some(1), &make_context(), make_sandbox(), - test_llm_source().as_ref(), + test_llm_source(), test_catalog(), ) .await; diff --git a/lib/components/fabro-hooks/src/runner.rs b/lib/components/fabro-hooks/src/runner.rs index c3ec53987..489edd0dd 100644 --- a/lib/components/fabro-hooks/src/runner.rs +++ b/lib/components/fabro-hooks/src/runner.rs @@ -5,7 +5,7 @@ use fabro_agent::Sandbox; use fabro_auth::CredentialSource; #[cfg(test)] use fabro_auth::test_support; -use fabro_model::Catalog; +use fabro_llm::lithos_catalog::Catalog; use crate::config::{HookDefinition, HookSettings}; use crate::executor::{HookExecutor, HookExecutorImpl}; @@ -47,7 +47,7 @@ impl HookRunner { config, executor, llm_source: test_support::vault_only_credential_source(), - catalog: Arc::new(Catalog::from_builtin().expect("default catalog should build")), + catalog: Arc::new(fabro_llm::default_catalog()), compiled_matchers, } } @@ -158,7 +158,7 @@ impl HookRunner { context, sandbox.clone(), execution_context, - self.llm_source.as_ref(), + Arc::clone(&self.llm_source), Arc::clone(&self.catalog), ) .await; @@ -213,7 +213,7 @@ impl HookRunner { context, sandbox.clone(), execution_context, - self.llm_source.as_ref(), + Arc::clone(&self.llm_source), Arc::clone(&self.catalog), ) .await; @@ -256,7 +256,7 @@ mod tests { _context: &HookContext, _sandbox: Arc, _execution_context: &HookExecutionContext, - _llm_source: &dyn CredentialSource, + _llm_source: Arc, _catalog: Arc, ) -> HookResult { HookResult { @@ -282,7 +282,7 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin().expect("default catalog should build")) + Arc::new(fabro_llm::default_catalog()) } fn make_hook(event: HookEvent, name: &str) -> HookDefinition { diff --git a/lib/components/fabro-hooks/tests/host_command_hooks.rs b/lib/components/fabro-hooks/tests/host_command_hooks.rs index fe73371dd..74cacd33c 100644 --- a/lib/components/fabro-hooks/tests/host_command_hooks.rs +++ b/lib/components/fabro-hooks/tests/host_command_hooks.rs @@ -7,7 +7,7 @@ use fabro_hooks::{ HookContext, HookDecision, HookDefinition, HookEvent, HookExecutionContext, HookRunner, HookSettings, InterpString, }; -use fabro_model::Catalog; +use fabro_llm::lithos_catalog::Catalog; use fabro_types::RunId; use tokio::fs; @@ -16,7 +16,7 @@ fn test_llm_source() -> Arc { } fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin().expect("default catalog should build")) + Arc::new(fabro_llm::default_catalog()) } fn local_sandbox() -> Arc { diff --git a/lib/components/fabro-llm/Cargo.toml b/lib/components/fabro-llm/Cargo.toml index d7bcb8df7..546fd1f1e 100644 --- a/lib/components/fabro-llm/Cargo.toml +++ b/lib/components/fabro-llm/Cargo.toml @@ -4,56 +4,46 @@ edition.workspace = true version.workspace = true publish = false license.workspace = true -description = "A unified client library for multiple LLM providers" -repository = "https://github.com/brynary/arc" -readme = "README.md" -keywords = ["llm", "ai", "openai", "anthropic"] -categories = ["api-bindings"] +description = "Fabro's integration layer over the lithos-llm catalog and client" [lib] doctest = false +[features] +default = [] +test-support = ["fabro-auth/test-support"] + [lints] workspace = true [dependencies] anyhow.workspace = true -thiserror.workspace = true -serde.workspace = true -serde_json.workspace = true -sha2.workspace = true -strum.workspace = true -tokio.workspace = true -uuid.workspace = true -rand.workspace = true -futures.workspace = true -tokio-stream.workspace = true async-trait.workspace = true base64.workspace = true bytes.workspace = true -tokio-util.workspace = true -tracing.workspace = true -aws-config.workspace = true -aws-credential-types.workspace = true -aws-sigv4.workspace = true -aws-smithy-eventstream.workspace = true -aws-smithy-runtime-api.workspace = true -aws-smithy-types.workspace = true -fabro-http.workspace = true fabro-auth = { path = "../../foundation/fabro-auth" } -fabro-model = { path = "../../foundation/fabro-model" } +fabro-config = { path = "../../foundation/fabro-config" } +fabro-http.workspace = true fabro-redact.workspace = true fabro-static.workspace = true fabro-types = { path = "../../foundation/fabro-types" } -fabro-util = { path = "../../foundation/fabro-util" } +futures.workspace = true +lithos-llm = { workspace = true, features = ["builtin-catalog", "openai", "anthropic", "gemini", "openai-compatible", "bedrock", "bedrock-aws"] } +mime_guess = "2" +serde.workspace = true +serde_json.workspace = true +strum.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-util.workspace = true +toml.workspace = true +tracing.workspace = true [dev-dependencies] -http = "1" -insta = { workspace = true } -tokio = { workspace = true, features = ["test-util", "macros"] } -httpmock = "0.8" -serde_json.workspace = true -toml.workspace = true +fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] } +fabro-llm = { path = ".", features = ["test-support"] } fabro-macros = { path = "../../foundation/fabro-macros" } fabro-test = { workspace = true } -tracing-subscriber.workspace = true +httpmock = "0.8" +tempfile = "3" +tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/lib/components/fabro-llm/README.md b/lib/components/fabro-llm/README.md deleted file mode 100644 index aa992daaa..000000000 --- a/lib/components/fabro-llm/README.md +++ /dev/null @@ -1,301 +0,0 @@ -# fabro-llm - -A unified async Rust client library for multiple LLM providers. Write your LLM integration code once and switch between Anthropic, OpenAI, and Google Gemini without changing your application logic. - -## Key concepts - -- **Client** -- Routes requests to registered provider adapters. Build it from a `CredentialSource` or explicit typed credentials. -- **ProviderAdapter** -- The trait every provider implements (`complete` and `stream`). Built-in adapters: `AnthropicAdapter`, `OpenAiAdapter`, `GeminiAdapter`, `OpenAiCompatibleAdapter`. -- **Middleware** -- Intercepts requests/responses for logging, caching, or transformation. Supports both blocking and streaming paths. -- **generate()** -- High-level function that wraps `Client.complete()` with automatic tool execution loops, retries, timeouts, and cancellation. -- **Tool** -- Active tools (with an execute handler) run automatically in the tool loop. Passive tools (no handler) surface tool calls back to the caller. -- **Model catalog** -- Built-in metadata for common models. Advisory only; unknown model strings pass through. - -## Providers - -| Provider | Adapter | API | Env var | -|----------|---------|-----|---------| -| Anthropic | `AnthropicAdapter` | Messages API | `ANTHROPIC_API_KEY` | -| OpenAI | `OpenAiAdapter` | Responses API | `OPENAI_API_KEY` | -| Google Gemini | `GeminiAdapter` | generateContent | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | -| OpenAI-compatible | `OpenAiCompatibleAdapter` | Chat Completions | (custom) | - -All adapters support streaming, tool calling, structured output (`response_format`), and provider-specific options via `provider_options`. - -## Usage - -### Create from an environment-backed credential source - -```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::types::{Message, Request}; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::Catalog; -use std::sync::Arc; - -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 { - model: "claude-sonnet-4-5".to_string(), - messages: vec![Message::user("What is the capital of France?")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: Some(0.0), - top_p: None, - max_tokens: Some(100), - stop_sequences: None, - reasoning_effort: None, - metadata: None, - provider_options: None, -}; - -let response = client.complete(&request).await?; -println!("{}", response.text()); -``` - -### High-level generate() - -```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; - -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 result = generate( - GenerateParams::new("claude-sonnet-4-5", client.clone()) - .prompt("Explain monads in one sentence") - .system("You are a concise programming tutor.") - .max_tokens(200) -).await?; - -println!("{}", result.text()); -``` - -### Tool calling - -```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::generate::{generate, GenerateParams}; -use fabro_llm::tools::Tool; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::Catalog; -use std::sync::Arc; - -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 weather_tool = Tool::active( - "get_weather", - "Get the current weather for a city", - serde_json::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(serde_json::json!({"temp": "72F", "city": city})) - }, -); - -let result = generate( - GenerateParams::new("claude-sonnet-4-5", client.clone()) - .prompt("What's the weather in San Francisco?") - .tools(vec![weather_tool]) - .max_tool_rounds(3) -).await?; -``` - -### Streaming - -```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::types::{Message, Request, StreamEvent}; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::Catalog; -use futures::StreamExt; -use std::sync::Arc; - -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 { - model: "claude-sonnet-4-5".to_string(), - messages: vec![Message::user("Tell me a joke")], - // ...other fields set to None/defaults - # provider: None, tools: None, tool_choice: None, - # response_format: None, temperature: None, top_p: None, - # max_tokens: None, stop_sequences: None, reasoning_effort: None, - # metadata: None, provider_options: None, -}; - -let mut stream = client.stream(&request).await?; -while let Some(event) = stream.next().await { - match event? { - StreamEvent::TextDelta { delta, .. } => print!("{delta}"), - StreamEvent::Finish { response, .. } => { - println!("\nTokens used: {}", response.usage.total_tokens); - } - _ => {} - } -} -``` - -### Middleware - -```rust -use fabro_llm::error::Error; -use fabro_llm::middleware::{Middleware, NextFn, NextStreamFn}; -use fabro_llm::provider::StreamEventStream; -use fabro_llm::types::{Request, Response}; - -struct LoggingMiddleware; - -#[async_trait::async_trait] -impl Middleware for LoggingMiddleware { - async fn handle_complete( - &self, - request: Request, - next: NextFn, - ) -> Result { - eprintln!("Request to model: {}", request.model); - let response = next(request).await?; - eprintln!("Response tokens: {}", response.usage.total_tokens); - Ok(response) - } - - async fn handle_stream( - &self, - request: Request, - next: NextStreamFn, - ) -> Result { - next(request).await - } -} -``` - -### OpenAI-compatible providers - -```rust -use fabro_llm::providers::OpenAiCompatibleAdapter; -use std::sync::Arc; - -let adapter = OpenAiCompatibleAdapter::new("your-api-key", "https://api.groq.com/openai/v1") - .with_name("groq"); -``` - -### Model catalog - -```rust -use fabro_llm::catalog::{get_latest_model, get_model_info, list_models}; - -let info = get_model_info("claude-opus-4-6"); -let anthropic_models = list_models(Some("anthropic")); -let best_reasoner = get_latest_model("anthropic", Some("reasoning")); -``` - -### Input token counting - -Use `count_input_tokens` when you need the current model-visible context size -without creating a completion: - -```rust -use fabro_llm::{InputTokenCountPreference, Client}; - -let count = client - .count_input_tokens(&request, InputTokenCountPreference::PreferProvider) - .await?; -``` - -`InputTokenCountPreference` controls precision and data exposure: - -- `PreferProvider` sends the provider-serialized request to the upstream - token-count endpoint when supported, then falls back to a local estimate only - for unsupported adapters, network/timeout failures, rate limits, and provider - server errors. -- `RequireProvider` sends the provider-serialized request and returns either a - provider count or an error. It never returns a local estimate. -- `EstimateOnly` validates and resolves the provider locally, does not call the - adapter count endpoint, and returns a deterministic local estimate. - -Provider-native counting sends model-visible request content to the provider's -token-count endpoint. That can include messages, system/developer instructions, -tools, schemas, structured content, and media metadata/content after provider -serialization. Use `EstimateOnly` when that extra upstream exposure is not -acceptable. - -`InputTokenCount` is for input/context sizing. It is not billing usage and does -not include output, reasoning-output, cache-read, or cache-write token buckets. - -## Key types - -| Type | Description | -|------|-------------| -| `Request` | Unified request with model, messages, tools, temperature, etc. | -| `Response` | Unified response with message, finish reason, usage, rate limit info | -| `Message` | A message with role, content parts, and optional tool call ID | -| `ContentPart` | Text, Image, Audio, Document, ToolCall, ToolResult, Thinking | -| `StreamEvent` | Events for streaming: TextDelta, ToolCallStart/Delta/End, Finish, etc. | -| `SdkError` | Typed errors with retryability, status codes, and provider error kinds | -| `GenerateParams` | Builder for the high-level `generate()` function | -| `GenerateResult` | Result containing response, tool results, total usage, and step history | -| `ToolDefinition` | Tool name, description, and JSON Schema parameters | -| `ToolChoice` | Auto, None, Required, or Named tool selection | -| `InputTokenCount` | Input/context token count from a provider count API or local estimate | -| `TokenCounts` | Billing-oriented token counts including input, output, reasoning, and cache tokens | -| `RetryPolicy` | Configurable retry with exponential backoff, jitter, and max delay | -| `Model` | Metadata about a model (context window, capabilities, costs) | - -## Error handling - -`SdkError` provides structured error variants with built-in retryability classification: - -- **Retryable**: `RateLimit`, `Server`, `Network`, `Stream`, `RequestTimeout` -- **Non-retryable**: `Authentication`, `AccessDenied`, `InvalidRequest`, `ContextLength`, `Configuration` - -The `retry()` function and `generate()` respect `Retry-After` headers and use exponential backoff with jitter. - -## Provider-specific options - -Pass provider-specific parameters via `provider_options` without losing portability: - -```rust -use fabro_llm::types::Request; - -let request = Request { - provider_options: Some(serde_json::json!({ - "anthropic": { - "thinking": {"type": "enabled", "budget_tokens": 10000}, - "auto_cache": true - }, - "openai": { - "store": true, - "previous_response_id": "resp_abc123" - }, - "gemini": { - "safetySettings": [ - {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"} - ] - } - })), - // ...other fields - # model: String::new(), messages: vec![], provider: None, tools: None, - # tool_choice: None, response_format: None, temperature: None, - # top_p: None, max_tokens: None, stop_sequences: None, - # reasoning_effort: None, metadata: None, -}; -``` diff --git a/lib/components/fabro-llm/src/adapter_registry.rs b/lib/components/fabro-llm/src/adapter_registry.rs deleted file mode 100644 index 60ad8ec42..000000000 --- a/lib/components/fabro-llm/src/adapter_registry.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! Adapter factory registry keyed by [`fabro_model::AdapterKind`]. -//! -//! Every adapter kind ships with a matching factory in this module. Tests in -//! this file enforce that the registry covers every adapter kind. -//! -//! Factories take a pre-built [`AdapterConfig`] derived from resolved -//! credentials + provider settings, and produce a boxed -//! [`ProviderAdapter`] ready to register with the [`crate::Client`]. -use std::collections::HashMap; -use std::sync::Arc; - -use fabro_auth::ApiKeyHeader; -use fabro_model::{ - AdapterKind, AgentProfileKind, BillingPolicy, Catalog, CodecKind, Model, ProviderId, -}; - -use crate::error::Error; -use crate::provider::ProviderAdapter; -use crate::providers; - -/// Configuration passed to an adapter factory. All values are pre-resolved -/// from settings + credentials; factories never touch the environment or the -/// vault directly. -#[derive(Debug, Clone)] -pub struct AdapterConfig { - /// Provider ID this adapter will register under (used as the registry - /// name on the resulting adapter). - pub provider_id: String, - /// Authentication header constructed by `fabro-auth` from the provider's - /// catalog auth policy and resolved credential. - pub auth_header: Option, - /// Provider base URL. Native adapters can use their direct-constructor - /// defaults when this is `None`; OpenAI-compatible providers require it. - pub base_url: Option, - /// Extra HTTP headers attached to every outgoing request. - pub extra_headers: HashMap, - /// Adapter-kind-specific options; factories for other kinds ignore - /// options that are not theirs. - pub kind_options: AdapterKindOptions, - pub catalog: Option>, -} - -/// Construction options that only apply to one adapter kind, kept out of the -/// shared [`AdapterConfig`] fields. -#[derive(Debug, Clone, Default)] -pub enum AdapterKindOptions { - /// No kind-specific options. - #[default] - None, - OpenAi(OpenAiAdapterOptions), -} - -/// OpenAI-only construction options. -#[derive(Debug, Clone, Default)] -pub struct OpenAiAdapterOptions { - /// Route through the ChatGPT Codex backend. - pub codex_mode: bool, - /// Organization ID. - pub org_id: Option, - /// Project ID. - pub project_id: Option, -} - -impl AdapterConfig { - /// Construct a minimal config with just provider ID and auth header. - pub fn new(provider_id: impl Into, auth_header: ApiKeyHeader) -> Self { - Self { - provider_id: provider_id.into(), - auth_header: Some(auth_header), - base_url: None, - extra_headers: HashMap::new(), - kind_options: AdapterKindOptions::None, - catalog: None, - } - } -} - -/// Factory function signature. Takes a fully-resolved [`AdapterConfig`] and -/// returns a registered-ready [`ProviderAdapter`]. -/// -/// Adapter constructors validate provider-specific construction requirements -/// before a provider is registered with the client. -pub type AdapterFactory = fn(AdapterConfig) -> Result, Error>; - -fn apply_primary_auth_header( - auth_header: Option, - extra_headers: &mut HashMap, -) -> Option { - match auth_header { - Some(ApiKeyHeader::Bearer(value)) => Some(value), - Some(ApiKeyHeader::Custom { name, value }) => { - extra_headers.insert(name, value); - None - } - // SigV4 is not a static header; only the Bedrock adapter consumes - // the marker (it signs at request time). - Some(ApiKeyHeader::AwsSigv4) | None => None, - } -} - -fn build_anthropic_adapter(mut config: AdapterConfig) -> providers::AnthropicAdapter { - let api_key = apply_primary_auth_header(config.auth_header.take(), &mut config.extra_headers); - let mut adapter = providers::AnthropicAdapter::new_optional_auth(api_key) - .with_name(config.provider_id.clone()); - if let Some(base_url) = config.base_url { - adapter = adapter.with_base_url(base_url); - } - if !config.extra_headers.is_empty() { - adapter = adapter.with_default_headers(config.extra_headers); - } - if let Some(catalog) = config.catalog { - adapter = adapter.with_catalog(catalog); - } - adapter -} - -#[expect( - clippy::unnecessary_wraps, - reason = "Adapter factories share a fallible signature; openai_compatible validates base_url." -)] -fn build_anthropic(config: AdapterConfig) -> Result, Error> { - Ok(Arc::new(build_anthropic_adapter(config))) -} - -fn build_openai_adapter(mut config: AdapterConfig) -> providers::OpenAiAdapter { - let api_key = apply_primary_auth_header(config.auth_header.take(), &mut config.extra_headers); - let options = match config.kind_options { - AdapterKindOptions::OpenAi(options) => options, - AdapterKindOptions::None => OpenAiAdapterOptions::default(), - }; - let mut adapter = - providers::OpenAiAdapter::new_optional_auth(api_key).with_name(config.provider_id.clone()); - if let Some(base_url) = config.base_url { - adapter = adapter.with_base_url(base_url); - } - if !config.extra_headers.is_empty() { - adapter = adapter.with_default_headers(config.extra_headers); - } - if options.codex_mode { - adapter = adapter.with_codex_mode(); - } - if let Some(org_id) = options.org_id { - adapter = adapter.with_org_id(org_id); - } - if let Some(project_id) = options.project_id { - adapter = adapter.with_project_id(project_id); - } - if let Some(catalog) = config.catalog { - adapter = adapter.with_catalog(catalog); - } - adapter -} - -#[expect( - clippy::unnecessary_wraps, - reason = "Adapter factories share a fallible signature; openai_compatible validates base_url." -)] -fn build_openai(config: AdapterConfig) -> Result, Error> { - Ok(Arc::new(build_openai_adapter(config))) -} - -fn build_gemini_adapter(mut config: AdapterConfig) -> providers::GeminiAdapter { - let api_key = apply_primary_auth_header(config.auth_header.take(), &mut config.extra_headers); - let mut adapter = - providers::GeminiAdapter::new_optional_auth(api_key).with_name(config.provider_id.clone()); - if let Some(base_url) = config.base_url { - adapter = adapter.with_base_url(base_url); - } - if !config.extra_headers.is_empty() { - adapter = adapter.with_default_headers(config.extra_headers); - } - if let Some(catalog) = config.catalog { - adapter = adapter.with_catalog(catalog); - } - adapter -} - -#[expect( - clippy::unnecessary_wraps, - reason = "Adapter factories share a fallible signature; openai_compatible validates base_url." -)] -fn build_gemini(config: AdapterConfig) -> Result, Error> { - Ok(Arc::new(build_gemini_adapter(config))) -} - -fn build_openai_compatible_adapter( - mut config: AdapterConfig, -) -> Result { - let base_url = config.base_url.ok_or_else(|| Error::Configuration { - message: format!( - "provider '{}' uses openai_compatible adapter but does not configure base_url", - config.provider_id - ), - source: None, - })?; - let api_key = apply_primary_auth_header(config.auth_header.take(), &mut config.extra_headers); - let mut adapter = providers::OpenAiCompatibleAdapter::new_optional_auth(api_key, base_url) - .with_name(config.provider_id); - if !config.extra_headers.is_empty() { - adapter = adapter.with_default_headers(config.extra_headers); - } - if let Some(catalog) = config.catalog { - adapter = adapter.with_catalog(catalog); - } - Ok(adapter) -} - -fn build_openai_compatible(config: AdapterConfig) -> Result, Error> { - Ok(Arc::new(build_openai_compatible_adapter(config)?)) -} - -/// Return the factory for a known adapter kind. -#[must_use] -pub fn factory_for(adapter_kind: AdapterKind) -> AdapterFactory { - match adapter_kind { - AdapterKind::Anthropic => build_anthropic, - AdapterKind::OpenAi => build_openai, - AdapterKind::Gemini => build_gemini, - AdapterKind::OpenAiCompatible => build_openai_compatible, - AdapterKind::Bedrock => providers::bedrock::build, - } -} - -/// A resolved route for one catalog model: the transport+auth key, wire -/// dialect, and provider-facing identifiers a request for that model travels -/// with. -/// -/// `(provider row, model row)` → route. Codec/transport pairings are -/// validated at catalog build, so any model in a successfully built catalog -/// resolves. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Route { - /// Canonical provider this route belongs to. - pub provider: ProviderId, - /// Transport + auth scheme (the adapter registry key). - pub transport: AdapterKind, - /// Wire dialect spoken on this route. - pub codec: CodecKind, - /// Identifier sent to the provider API (the catalog `api_id`). - pub deployment_id: String, - /// Billing family used to translate usage into billed tokens. - pub billing_policy: BillingPolicy, - /// Agent profile driving profile-specific behavior. - pub agent_profile: AgentProfileKind, -} - -/// Resolve the route for one already-selected catalog offering. -#[must_use] -pub fn resolve_route(catalog: &Catalog, model: &Model) -> Option { - let provider = catalog.provider(&model.provider)?; - let settings = catalog.settings_for(model)?; - Some(Route { - provider: provider.id.clone(), - transport: provider.adapter, - codec: settings.codec, - deployment_id: settings.api_id.clone(), - billing_policy: settings.billing_policy, - agent_profile: settings.agent_profile, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn select_from_all<'a>(catalog: &'a Catalog, selector: &str) -> &'a Model { - catalog - .select(selector, None, &catalog.all_provider_ids()) - .unwrap_or_else(|error| panic!("built-in model '{selector}' should resolve: {error}")) - } - - #[test] - fn every_builtin_catalog_offering_resolves() { - let catalog = Catalog::builtin(); - - for model in catalog.list(None) { - let route = resolve_route(catalog, model).unwrap_or_else(|| { - panic!( - "built-in offering '{}/{}' should resolve", - model.provider, model.id - ) - }); - assert_eq!(route.provider, model.provider); - assert!(!route.deployment_id.is_empty()); - } - } - - #[test] - fn resolve_route_follows_model_aliases() { - let catalog = Catalog::builtin(); - - let by_alias = resolve_route(catalog, select_from_all(catalog, "sonnet")) - .expect("alias should resolve"); - let by_id = resolve_route(catalog, select_from_all(catalog, "claude-sonnet-5")) - .expect("id should resolve"); - - assert_eq!(by_alias, by_id); - assert_eq!(by_alias.provider, ProviderId::anthropic()); - } - - #[test] - fn resolve_route_resolves_by_id_for_model_from_another_catalog_instance() { - let other = Catalog::from_builtin().unwrap(); - let model = select_from_all(&other, "gpt-5.4"); - assert!(resolve_route(Catalog::builtin(), model).is_some()); - } - - #[test] - fn anthropic_factory_builds_anthropic_adapter() { - let config = AdapterConfig::new("anthropic", ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "test-key".to_string(), - }); - let adapter = factory_for(AdapterKind::Anthropic)(config).unwrap(); - assert_eq!(adapter.name(), "anthropic"); - } - - #[test] - fn custom_primary_auth_header_is_preserved() { - let config = AdapterConfig::new("anthropic", ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "test-key".to_string(), - }); - - let adapter = build_anthropic_adapter(config); - - assert!(adapter.http.api_key.is_none()); - assert_eq!( - adapter.http.default_headers.get("x-api-key"), - Some(&"test-key".to_string()) - ); - } - - #[test] - fn custom_primary_auth_header_overrides_extra_header() { - let config = AdapterConfig { - base_url: Some("https://api.custom.test/v1".to_string()), - extra_headers: HashMap::from([("x-api-key".to_string(), "secondary-key".to_string())]), - ..AdapterConfig::new("custom", ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "primary-key".to_string(), - }) - }; - - let adapter = build_openai_compatible_adapter(config).unwrap(); - - assert!(adapter.http.api_key.is_none()); - assert_eq!( - adapter.http.default_headers.get("x-api-key"), - Some(&"primary-key".to_string()) - ); - } - - #[test] - fn openai_compatible_factory_uses_provider_id_for_name() { - let config = AdapterConfig { - base_url: Some("https://api.moonshot.ai/v1".to_string()), - ..AdapterConfig::new("moonshot", ApiKeyHeader::Bearer("k".to_string())) - }; - let adapter = factory_for(AdapterKind::OpenAiCompatible)(config).unwrap(); - assert_eq!(adapter.name(), "moonshot"); - } - - #[test] - fn openai_compatible_factory_preserves_extra_headers() { - let config = AdapterConfig { - base_url: Some("https://api.portkey.ai/v1".to_string()), - extra_headers: HashMap::from([ - ( - "x-portkey-api-key".to_string(), - "resolved-portkey-key".to_string(), - ), - ( - "x-portkey-provider".to_string(), - "@bedrock-prod".to_string(), - ), - ]), - ..AdapterConfig::new( - "portkey", - ApiKeyHeader::Bearer("unused-primary-key".to_string()), - ) - }; - - let adapter = build_openai_compatible_adapter(config).unwrap(); - - assert_eq!(adapter.name(), "portkey"); - assert_eq!( - adapter.http.default_headers.get("x-portkey-api-key"), - Some(&"resolved-portkey-key".to_string()), - ); - assert_eq!( - adapter.http.default_headers.get("x-portkey-provider"), - Some(&"@bedrock-prod".to_string()), - ); - } - - #[test] - fn anthropic_factory_preserves_extra_headers() { - let config = AdapterConfig { - base_url: Some("https://api.portkey.ai/v1".to_string()), - extra_headers: HashMap::from([( - "x-portkey-api-key".to_string(), - "resolved-portkey-key".to_string(), - )]), - ..AdapterConfig::new("anthropic-through-portkey", ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "unused-primary-key".to_string(), - }) - }; - - let adapter = build_anthropic_adapter(config); - - assert_eq!(adapter.name(), "anthropic-through-portkey"); - assert_eq!( - adapter.http.default_headers.get("x-portkey-api-key"), - Some(&"resolved-portkey-key".to_string()), - ); - } - - #[test] - fn openai_compatible_factory_errors_without_base_url() { - let config = AdapterConfig::new("moonshot", ApiKeyHeader::Bearer("k".to_string())); - let Err(err) = factory_for(AdapterKind::OpenAiCompatible)(config) else { - panic!("expected missing base_url error"); - }; - assert!( - err.to_string() - .contains("uses openai_compatible adapter but does not configure base_url") - ); - } -} diff --git a/lib/components/fabro-llm/src/api.rs b/lib/components/fabro-llm/src/api.rs new file mode 100644 index 000000000..e08f6ec7b --- /dev/null +++ b/lib/components/fabro-llm/src/api.rs @@ -0,0 +1,160 @@ +//! API projections of the catalog for `GET /models` and `GET /providers`. +//! +//! Every row is a lithos catalog entry stamped with whether the caller holds +//! credential material for the provider. + +use std::collections::HashSet; + +use fabro_types::{ + Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, Provider, ProviderId, + ReasoningEffort, +}; +use lithos_llm::catalog::{Catalog, CatalogProvider}; + +use crate::catalog::{self, ModelEntry}; + +const USD_MICROS_PER_USD: f64 = 1_000_000.0; + +/// Every enabled model on every listed provider, provider priority order. +#[must_use] +pub fn models(catalog: &Catalog, configured: &HashSet) -> Vec { + catalog::models(catalog) + .iter() + .map(|entry| model_view(entry, configured.contains(entry.provider.id()))) + .collect() +} + +/// Every listed provider, priority order. +#[must_use] +pub fn providers(catalog: &Catalog, configured: &HashSet) -> Vec { + catalog::listed_providers(catalog) + .iter() + .map(|provider| provider_view(provider, configured.contains(provider.id()))) + .collect() +} + +fn model_view(entry: &ModelEntry<'_>, configured: bool) -> Model { + let model = entry.model; + let capabilities = model.capabilities(); + let pricing = model.pricing(); + let limits = model.limits(); + Model { + id: model.id().clone(), + provider: entry.provider.id().clone(), + family: model + .family() + .map_or_else(|| model.id().to_string(), str::to_string), + display_name: model.display_name().to_string(), + limits: ModelLimits { + context_window: limits.map_or(0, |limits| saturating_i64(limits.context_tokens)), + max_output: limits + .map(|limits| limits.max_output_tokens) + .filter(|tokens| *tokens > 0) + .map(saturating_i64), + }, + training: model.training_cutoff().map(str::to_string), + knowledge_cutoff: model.knowledge_cutoff().map(str::to_string), + features: ModelFeatures { + tools: capabilities.tools().is_supported(), + vision: capabilities.images().is_supported(), + reasoning: capabilities.reasoning().is_supported(), + prompt_cache: capabilities.caching().is_supported(), + sampling: capabilities.sampling().is_supported(), + }, + controls: ModelControls { + reasoning_effort: ReasoningEffort::ALL + .into_iter() + .filter(|effort| capabilities.reasoning_effort(*effort).is_supported()) + .collect(), + }, + costs: ModelCosts { + input_cost_per_mtok: pricing + .and_then(|pricing| pricing.input_usd_micros_per_million) + .map(usd_per_million), + output_cost_per_mtok: pricing + .and_then(|pricing| pricing.output_usd_micros_per_million) + .map(usd_per_million), + cache_input_cost_per_mtok: pricing + .and_then(|pricing| pricing.cached_input_usd_micros_per_million) + .map(usd_per_million), + }, + estimated_output_tps: model.estimated_output_tps(), + aliases: model.aliases().to_vec(), + default: entry.provider.default_model() == Some(model.id().as_str()), + small_default: model.is_small_default(), + configured, + } +} + +fn provider_view(provider: &CatalogProvider, configured: bool) -> Provider { + Provider { + id: provider.id().clone(), + display_name: provider.display_name().to_string(), + adapter: provider.adapter().as_str().to_string(), + base_url: provider.base_url().to_string(), + api_key_url: provider.api_key_url().map(str::to_string), + priority: provider.priority(), + aliases: provider.aliases().to_vec(), + model_count: u32::try_from(catalog::provider_models(provider).len()).unwrap_or(u32::MAX), + default_model: provider.default_model().map(str::to_string), + configured, + expected_secret_name: fabro_auth::expected_secret_name(provider), + } +} + +#[allow( + clippy::cast_precision_loss, + reason = "Catalog prices are display values; micros fit f64 exactly at these magnitudes." +)] +fn usd_per_million(micros: u64) -> f64 { + micros as f64 / USD_MICROS_PER_USD +} + +fn saturating_i64(value: u64) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests { + use fabro_types::provider_ids; + + use super::*; + use crate::test_support::test_catalog; + + #[test] + fn models_are_stamped_with_configured_providers() { + let catalog = test_catalog(); + let configured = HashSet::from([provider_ids::openai()]); + let models = models(&catalog, &configured); + let openai = models + .iter() + .find(|model| model.provider == provider_ids::openai()) + .expect("openai models listed"); + assert!(openai.configured); + assert!(openai.limits.context_window > 0); + let anthropic = models + .iter() + .find(|model| model.provider == provider_ids::anthropic()) + .expect("anthropic models listed"); + assert!(!anthropic.configured); + assert!(models.iter().any(|model| model.default)); + } + + #[test] + fn providers_skip_stand_ins_and_disabled_entries() { + let catalog = test_catalog(); + let providers = providers(&catalog, &HashSet::new()); + assert!(providers.iter().any(|p| p.id == provider_ids::openai())); + assert!(providers.iter().all(|p| p.id.as_str() != "openai-codex")); + assert!(providers.iter().all(|p| p.id.as_str() != "ollama")); + let openai = providers + .iter() + .find(|p| p.id == provider_ids::openai()) + .unwrap(); + assert_eq!( + openai.expected_secret_name.as_deref(), + Some("OPENAI_API_KEY") + ); + assert!(openai.model_count > 0); + } +} diff --git a/lib/components/fabro-llm/src/attachments.rs b/lib/components/fabro-llm/src/attachments.rs index c650d3ddd..37c9c8432 100644 --- a/lib/components/fabro-llm/src/attachments.rs +++ b/lib/components/fabro-llm/src/attachments.rs @@ -1,109 +1,268 @@ -//! Resolve file-backed attachments to inline data before a codec encodes. +//! Inlines local file attachments before a request reaches a codec. //! -//! Codec `encode` is sync and never touches the filesystem, so any -//! `Image`/`Document`/`Audio` part whose `url` is a local file path is loaded -//! here (async) and rewritten to inline bytes + MIME, per the codec's policy. -//! Loads that fail drop the part silently — the long-standing contract — and -//! non-file URLs and already-inline data pass through untouched. -//! -//! Shared infra for the per-dialect codecs (anthropic/openai_responses/gemini): -//! each constructs its own [`AttachmentPolicy`] and calls [`resolve`] from its -//! adapter shell. +//! lithos accepts media as a URL or as base64. Fabro lets a caller point an +//! image, document, or audio part at a local path; this middleware reads the +//! file and rewrites the part to inline base64 with an inferred media type. +//! A part whose file cannot be read is dropped, so the model sees the rest of +//! the message rather than a request that fails outright. -use std::borrow::Cow; +use std::sync::Arc; -use crate::providers::common; -use crate::types::{AudioData, ContentPart, DocumentData, ImageData, Request}; +use async_trait::async_trait; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use fabro_static::EnvVars; +use lithos_llm::middleware::{Call, Middleware, Next, Output}; +use lithos_llm::types::{ + AudioContent, ContentPart, DocumentContent, Error, ImageContent, MediaSource, Message, Request, + ToolResult, +}; +use tokio::fs; -/// Which attachment kinds a codec loads from local file paths. Each dialect -/// adapter constructs the policy it wants (e.g. images + documents but not -/// audio for Anthropic, which renders audio as a text placeholder). -#[derive(Clone, Copy)] -pub(crate) struct AttachmentPolicy { - pub images: bool, - pub documents: bool, - pub audio: bool, +/// Resolves an environment variable name to its value. +type EnvLookup = Arc Option + Send + Sync>; + +/// Middleware that inlines local-path media parts. +#[derive(Clone, Default)] +pub struct InlineLocalAttachments { + env_lookup: Option, } -/// Resolve file-path attachments (per `policy`) to inline data. Parts whose -/// file fails to load are dropped. Borrows the request untouched in the common -/// case where nothing needs loading; only requests with policy-matching -/// local-file parts pay for a copy. -pub(crate) async fn resolve(request: &Request, policy: AttachmentPolicy) -> Cow<'_, Request> { - if !needs_resolution(request, policy) { - return Cow::Borrowed(request); +impl std::fmt::Debug for InlineLocalAttachments { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InlineLocalAttachments") + .finish_non_exhaustive() + } +} + +impl InlineLocalAttachments { + #[must_use] + pub fn new() -> Self { + Self::default() } - let mut resolved = request.clone(); - for message in &mut resolved.messages { - let mut new_content = Vec::with_capacity(message.content.len()); - for part in std::mem::take(&mut message.content) { - if let Some(part) = resolve_part(part, policy).await { - new_content.push(part); + /// Resolves `~/` against this lookup instead of the process environment. + #[must_use] + pub fn with_env_lookup(env_lookup: EnvLookup) -> Self { + Self { + env_lookup: Some(env_lookup), + } + } + + #[expect( + clippy::disallowed_methods, + reason = "Attachment path expansion supports the conventional HOME env var." + )] + fn home(&self) -> Option { + match &self.env_lookup { + Some(lookup) => lookup(EnvVars::HOME), + None => std::env::var(EnvVars::HOME).ok(), + } + } + + fn expand(&self, path: &str) -> String { + path.strip_prefix("~/").map_or_else( + || path.to_string(), + |rest| format!("{}/{rest}", self.home().unwrap_or_else(|| "/".to_string())), + ) + } + + async fn load(&self, path: &str) -> Option { + let expanded = self.expand(path); + match fs::read(&expanded).await { + Ok(bytes) => Some(MediaSource::base64( + BASE64_STANDARD.encode(bytes), + media_type_for_path(&expanded), + )), + Err(err) => { + tracing::warn!(path = %expanded, error = %err, "dropping unreadable attachment"); + None } } - message.content = new_content; } - Cow::Owned(resolved) + + async fn inline_part(&self, part: ContentPart) -> Option { + match part { + ContentPart::Image(ImageContent { source, detail }) if is_local_file(&source) => { + let source = self.load(url_of(&source)).await?; + Some(ContentPart::Image(ImageContent { source, detail })) + } + ContentPart::Document(DocumentContent { source, name }) if is_local_file(&source) => { + let source = self.load(url_of(&source)).await?; + Some(ContentPart::Document(DocumentContent { source, name })) + } + ContentPart::Audio(AudioContent { source }) if is_local_file(&source) => { + let source = self.load(url_of(&source)).await?; + Some(ContentPart::Audio(AudioContent { source })) + } + ContentPart::ToolResult(result) if result.content.iter().any(part_is_local_file) => { + let mut content = Vec::with_capacity(result.content.len()); + for part in result.content { + if let Some(part) = Box::pin(self.inline_part(part)).await { + content.push(part); + } + } + Some(ContentPart::ToolResult(ToolResult { content, ..result })) + } + other => Some(other), + } + } + + async fn inline_request(&self, request: Request) -> Request { + let mut messages = Vec::with_capacity(request.messages().len()); + for message in request.messages() { + let mut content = Vec::with_capacity(message.content().len()); + for part in message.content() { + if let Some(part) = self.inline_part(part.clone()).await { + content.push(part); + } + } + let mut rebuilt = Message::new(message.role(), content); + if let Some(name) = message.name() { + rebuilt = rebuilt.with_name(name); + } + if let Some(id) = message.tool_call_id() { + rebuilt = rebuilt.with_tool_call_id(id); + } + messages.push(rebuilt); + } + replace_messages(&request, messages).unwrap_or(request) + } } -/// Whether any part is a policy-matching local-file attachment. -fn needs_resolution(request: &Request, policy: AttachmentPolicy) -> bool { - request - .messages - .iter() - .flat_map(|message| &message.content) - .any(|part| match part { - ContentPart::Image(img) => policy.images && is_local_file(img.url.as_deref()), - ContentPart::Document(doc) => policy.documents && is_local_file(doc.url.as_deref()), - ContentPart::Audio(audio) => policy.audio && is_local_file(audio.url.as_deref()), - _ => false, - }) +/// Rebuilds `request` with `messages` in place of its own. +/// +/// The request builder appends messages and has no way to clear them, so the +/// swap goes through the request's serde form. +fn replace_messages(request: &Request, messages: Vec) -> Option { + let mut value = serde_json::to_value(request).ok()?; + value["messages"] = serde_json::to_value(messages).ok()?; + serde_json::from_value(value).ok() } -/// Resolve a single part. `None` means the part was dropped (load error). -async fn resolve_part(part: ContentPart, policy: AttachmentPolicy) -> Option { +fn part_is_local_file(part: &ContentPart) -> bool { match part { - ContentPart::Image(img) if policy.images && is_local_file(img.url.as_deref()) => { - // `is_local_file` guarantees `url` is `Some`. - let url = img.url.as_deref().unwrap_or_default(); - match common::load_file_bytes(url).await { - Ok((data, mime)) => Some(ContentPart::Image(ImageData { - url: None, - data: Some(data), - media_type: Some(mime), - detail: img.detail, - })), - Err(_) => None, - } - } - ContentPart::Document(doc) if policy.documents && is_local_file(doc.url.as_deref()) => { - let url = doc.url.as_deref().unwrap_or_default(); - match common::load_file_bytes(url).await { - Ok((data, mime)) => Some(ContentPart::Document(DocumentData { - url: None, - data: Some(data), - media_type: Some(mime), - file_name: doc.file_name, - })), - Err(_) => None, - } - } - ContentPart::Audio(audio) if policy.audio && is_local_file(audio.url.as_deref()) => { - let url = audio.url.as_deref().unwrap_or_default(); - match common::load_file_bytes(url).await { - Ok((data, mime)) => Some(ContentPart::Audio(AudioData { - url: None, - data: Some(data), - media_type: Some(mime), - })), - Err(_) => None, - } - } - other => Some(other), + ContentPart::Image(ImageContent { source, .. }) + | ContentPart::Document(DocumentContent { source, .. }) + | ContentPart::Audio(AudioContent { source }) => is_local_file(source), + _ => false, } } -fn is_local_file(url: Option<&str>) -> bool { - url.is_some_and(common::is_file_path) +fn url_of(source: &MediaSource) -> &str { + match source { + MediaSource::Url { url, .. } => url, + _ => "", + } +} + +fn is_local_file(source: &MediaSource) -> bool { + matches!( + source, + MediaSource::Url { url, .. } + if url.starts_with('/') || url.starts_with("./") || url.starts_with("~/") + ) +} + +fn needs_inlining(request: &Request) -> bool { + request.messages().iter().any(|message| { + message.content().iter().any(|part| match part { + ContentPart::ToolResult(result) => result.content.iter().any(part_is_local_file), + part => part_is_local_file(part), + }) + }) +} + +/// Media type for a local path, from its extension. +#[must_use] +pub fn media_type_for_path(path: &str) -> String { + mime_guess::from_path(path) + .first_raw() + .unwrap_or("application/octet-stream") + .to_string() +} + +#[async_trait] +impl Middleware for InlineLocalAttachments { + async fn handle(&self, call: Call, next: Next) -> Result { + if !needs_inlining(call.request()) { + return next.run(call).await; + } + let inlined = self.inline_request(call.request().clone()).await; + let call = call.map_request(|_| Ok(inlined))?; + next.run(call).await + } +} + +#[cfg(test)] +mod tests { + use lithos_llm::types::Role; + + use super::*; + + fn request_with(part: ContentPart) -> Request { + Request::builder() + .model("openai/gpt-5.4") + .message(Message::new(Role::User, [ + ContentPart::Text { + text: "look".to_string(), + }, + part, + ])) + .build() + .unwrap() + } + + #[tokio::test] + async fn inlines_local_images_and_drops_missing_files() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pixel.png"); + fs::write(&path, b"\x89PNG").await.unwrap(); + let middleware = InlineLocalAttachments::new(); + + let request = request_with(ContentPart::Image(ImageContent::new(MediaSource::url( + path.to_string_lossy().to_string(), + )))); + let inlined = middleware.inline_request(request).await; + match &inlined.messages()[0].content()[1] { + ContentPart::Image(image) => { + assert_eq!(image.source.media_type(), Some("image/png")); + assert_eq!( + image.source.base64_data(), + Some(BASE64_STANDARD.encode(b"\x89PNG").as_str()) + ); + } + other => panic!("expected inlined image, got {other:?}"), + } + + let missing = request_with(ContentPart::Document(DocumentContent::new( + MediaSource::url("/definitely/missing.pdf"), + ))); + let inlined = middleware.inline_request(missing).await; + assert_eq!(inlined.messages()[0].content().len(), 1); + } + + #[test] + fn remote_urls_and_inline_data_pass_through() { + let request = request_with(ContentPart::Image(ImageContent::new(MediaSource::url( + "https://example.com/a.png", + )))); + assert!(!needs_inlining(&request)); + let request = request_with(ContentPart::Image(ImageContent::new(MediaSource::base64( + "AAAA", + "image/png", + )))); + assert!(!needs_inlining(&request)); + let request = request_with(ContentPart::Image(ImageContent::new(MediaSource::url( + "~/shot.png", + )))); + assert!(needs_inlining(&request)); + } + + #[test] + fn media_types_follow_extensions() { + assert_eq!(media_type_for_path("a.jpg"), "image/jpeg"); + assert_eq!(media_type_for_path("a.pdf"), "application/pdf"); + assert_eq!(media_type_for_path("a.bin"), "application/octet-stream"); + } } diff --git a/lib/components/fabro-llm/src/catalog.rs b/lib/components/fabro-llm/src/catalog.rs new file mode 100644 index 000000000..a12b27c8a --- /dev/null +++ b/lib/components/fabro-llm/src/catalog.rs @@ -0,0 +1,534 @@ +//! Catalog construction and the queries Fabro's dispatch boundaries share. +//! +//! Layer order is fixed: lithos built-ins, then the operator's `[llm]` +//! overlay. Provider and model facts, `enabled`, `stands_in_for`, +//! `small_default`, and `probe` are lithos core fields. The agent harness a +//! model expects lives in the shared `metadata.agent` namespace, which Pebble +//! reads too. Every query here skips disabled providers. + +use std::collections::{BTreeMap, HashSet}; + +use fabro_config::LlmLayer; +use fabro_static::EnvVars; +use fabro_types::{AgentProfileKind, Cost, ModelId, ModelRef, ProviderId, TokenCounts}; +use lithos_llm::catalog::{Catalog, CatalogError, CatalogModel, CatalogProvider, Metadata}; +use lithos_llm::resolver::ResolvedRoute; +use serde::Deserialize; + +/// The metadata namespace agent harnesses read. +const AGENT_METADATA_NAMESPACE: &str = "agent"; + +/// Builds the effective catalog. +/// +/// `env_lookup` supplies `OPENAI_BASE_URL`, the one environment override +/// Fabro honors: it repoints the `openai` provider so test doubles and +/// gateways can stand in for the real API without editing settings. +pub fn build_catalog( + overlay: &LlmLayer, + env_lookup: &dyn Fn(&str) -> Option, +) -> Result { + let mut builder = Catalog::builder().with_builtin(); + if !overlay.is_empty() { + let mut document = overlay.to_overlay_toml(); + document.insert_str(0, "schema_version = 1\n"); + builder = builder.toml_layer("settings [llm]", &document)?; + } + if let Some(base_url) = env_lookup(EnvVars::OPENAI_BASE_URL) { + let document = format!( + "schema_version = 1\n[providers.openai]\nbase_url = {}\n", + toml::Value::String(base_url.trim_end_matches("/v1").to_string()) + ); + builder = builder.toml_layer("OPENAI_BASE_URL", &document)?; + } + builder.build() +} + +/// The catalog with no operator overlay: the lithos built-ins. +/// +/// Used where no settings file is in play, such as the standalone hook +/// runner. Servers and the CLI build from the operator's `[llm]` overlay +/// with [`build_catalog`] instead. +#[must_use] +pub fn default_catalog() -> Catalog { + build_catalog(&LlmLayer::default(), &|_| None).expect("the built-in catalog always builds") +} + +/// A model on the provider that offers it. +#[derive(Debug, Clone)] +pub struct ModelEntry<'a> { + pub provider: &'a CatalogProvider, + pub model: &'a CatalogModel, +} + +impl ModelEntry<'_> { + /// Whether requests to this model reason when no effort is requested. + /// + /// The catalog can state it outright under `metadata.agent`. Otherwise a + /// model that supports reasoning and takes named effort levels reasons by + /// default, while one that needs an explicit thinking budget does not. + #[must_use] + pub fn reasons_by_default(&self) -> bool { + agent_metadata(self.model.metadata()) + .reasoning_by_default + .or(agent_metadata(self.provider.metadata()).reasoning_by_default) + .unwrap_or_else(|| { + self.model.capabilities().reasoning().is_supported() + && self.model.protocol_options().reasoning_effort_levels + }) + } + + /// The agent harness this model runs under: the model's own answer, then + /// the provider's, then the profile implied by the provider's adapter. + #[must_use] + pub fn agent_profile(&self) -> AgentProfileKind { + agent_metadata(self.model.metadata()) + .profile + .unwrap_or_else(|| provider_agent_profile(self.provider)) + } +} + +/// The `metadata.agent` namespace on a catalog entry. Malformed metadata +/// falls back to the defaults; the lithos built-ins are validated in lithos. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct AgentMetadata { + profile: Option, + reasoning_by_default: Option, +} + +fn agent_metadata(metadata: &Metadata) -> AgentMetadata { + metadata + .namespace::(AGENT_METADATA_NAMESPACE) + .ok() + .flatten() + .unwrap_or_default() +} + +/// The agent profile a provider's models run under unless a model row says +/// otherwise: the provider's `metadata.agent.profile`, else the profile +/// implied by its wire protocol. +fn provider_agent_profile(provider: &CatalogProvider) -> AgentProfileKind { + agent_metadata(provider.metadata()) + .profile + .unwrap_or_else(|| match provider.adapter().as_str() { + "anthropic" | "bedrock" => AgentProfileKind::Anthropic, + "gemini" => AgentProfileKind::Gemini, + _ => AgentProfileKind::OpenAi, + }) +} + +/// Estimates the catalog cost of `usage` on `model`, when the catalog prices +/// that route. Passthrough models and unknown providers have no price. +#[must_use] +pub fn estimate_cost(catalog: &Catalog, model: &ModelRef, usage: TokenCounts) -> Option { + let entry = model_on_provider(catalog, model.provider.as_str(), model.model_id.as_str())?; + ResolvedRoute::try_new(entry.provider.clone(), entry.model.clone()) + .ok()? + .estimate_cost(usage, model.speed) +} + +/// Enabled providers, highest priority first, ties broken by id. +#[must_use] +pub fn enabled_providers(catalog: &Catalog) -> Vec<&CatalogProvider> { + let mut providers: Vec<_> = catalog + .providers() + .filter(|provider| provider.is_enabled()) + .collect(); + providers.sort_by(|left, right| { + right + .priority() + .cmp(&left.priority()) + .then_with(|| left.id().cmp(right.id())) + }); + providers +} + +/// Enabled providers that Fabro lists to operators. Stand-in providers such +/// as `openai-codex` route requests but are not offerings of their own. +#[must_use] +pub fn listed_providers(catalog: &Catalog) -> Vec<&CatalogProvider> { + enabled_providers(catalog) + .into_iter() + .filter(|provider| provider.stands_in_for().is_none()) + .collect() +} + +/// The ids of every enabled provider. +#[must_use] +pub fn enabled_provider_ids(catalog: &Catalog) -> HashSet { + enabled_providers(catalog) + .into_iter() + .map(|provider| provider.id().clone()) + .collect() +} + +/// Looks up an enabled provider by id or alias. +#[must_use] +pub fn provider<'a>(catalog: &'a Catalog, selector: &str) -> Option<&'a CatalogProvider> { + catalog + .provider(selector) + .ok() + .filter(|provider| provider.is_enabled()) +} + +/// Canonicalizes a provider id or alias to its catalog id, when enabled. +#[must_use] +pub fn canonical_provider_id(catalog: &Catalog, selector: &str) -> Option { + provider(catalog, selector).map(|provider| provider.id().clone()) +} + +/// The models of a provider, in catalog order. +#[must_use] +pub fn provider_models(provider: &CatalogProvider) -> Vec> { + provider + .models() + .map(|model| ModelEntry { provider, model }) + .collect() +} + +/// Every model across listed providers, provider priority order. +#[must_use] +pub fn models(catalog: &Catalog) -> Vec> { + listed_providers(catalog) + .into_iter() + .flat_map(provider_models) + .collect() +} + +/// Finds a model on an enabled provider by id, alias, or wire id. +#[must_use] +pub fn model_on_provider<'a>( + catalog: &'a Catalog, + provider_selector: &str, + model_selector: &str, +) -> Option> { + let provider = provider(catalog, provider_selector)?; + // lithos matches ids and aliases. The provider's wire id (an aggregator's + // `vendor/model`) is accepted too, so a selector copied from the + // provider's own listing lands on the catalog row instead of passing + // through unknown. + let model = provider.model(model_selector).or_else(|| { + provider + .models() + .find(|model| model.api_model() == model_selector) + })?; + Some(ModelEntry { provider, model }) +} + +/// Models matching `selector` by id or alias, ordered like lithos selection: +/// exact ids before aliases, then provider priority. +#[must_use] +pub fn models_matching<'a>(catalog: &'a Catalog, selector: &str) -> Vec> { + let mut matches: Vec<_> = enabled_providers(catalog) + .into_iter() + .flat_map(provider_models) + .filter(|entry| { + entry.model.id().as_str() == selector + || entry.model.aliases().iter().any(|alias| alias == selector) + }) + .collect(); + matches.sort_by_key(|entry| entry.model.id().as_str() != selector); + matches +} + +/// Whether `selector` names a model on any enabled provider. +#[must_use] +pub fn is_model_selector(catalog: &Catalog, selector: &str) -> bool { + !models_matching(catalog, selector).is_empty() +} + +/// Whether `selector` names an enabled provider. +#[must_use] +pub fn is_provider_selector(catalog: &Catalog, selector: &str) -> bool { + provider(catalog, selector).is_some() +} + +/// The default model of an enabled provider. +#[must_use] +pub fn default_model<'a>(catalog: &'a Catalog, provider_selector: &str) -> Option> { + let provider = provider(catalog, provider_selector)?; + let default = provider.default_model()?; + model_on_provider(catalog, provider.id().as_str(), default) +} + +/// The model Fabro probes a provider with: the `probe` model, else the +/// provider default. +#[must_use] +pub fn probe_model<'a>(catalog: &'a Catalog, provider_selector: &str) -> Option> { + let provider = provider(catalog, provider_selector)?; + provider_models(provider) + .into_iter() + .find(|entry| entry.model.is_probe()) + .or_else(|| default_model(catalog, provider_selector)) +} + +/// The default model across `ready` providers: the highest-priority ready +/// provider's default. Falls back to any enabled provider's default when no +/// provider is ready, so callers always have a model to name. +#[must_use] +pub fn default_for_ready<'a>( + catalog: &'a Catalog, + ready: &HashSet, +) -> Option> { + let providers = enabled_providers(catalog); + providers + .iter() + .filter(|provider| ready.contains(provider.id())) + .chain(providers.iter()) + .find_map(|provider| default_model(catalog, provider.id().as_str())) +} + +/// The small utility model across `ready` providers: the first +/// `small_default` model in provider priority order, else the ready default. +#[must_use] +pub fn small_default_for_ready<'a>( + catalog: &'a Catalog, + ready: &HashSet, +) -> Option> { + enabled_providers(catalog) + .into_iter() + .filter(|provider| ready.contains(provider.id())) + .flat_map(provider_models) + .find(|entry| entry.model.is_small_default()) + .or_else(|| default_for_ready(catalog, ready)) +} + +/// Canonicalizes a model selector to a catalog model id, preferring +/// `provider`'s offering. Unknown selectors pass through verbatim so +/// passthrough models keep their names. +#[must_use] +pub fn canonical_model_id(catalog: &Catalog, provider: &ProviderId, selector: &str) -> String { + model_on_provider(catalog, provider.as_str(), selector) + .map(|entry| entry.model.id().to_string()) + .or_else(|| { + models_matching(catalog, selector) + .first() + .map(|entry| entry.model.id().to_string()) + }) + .unwrap_or_else(|| selector.to_string()) +} + +/// The agent profile for a route. Unknown (passthrough) models take the +/// provider default. +#[must_use] +pub fn agent_profile( + catalog: &Catalog, + provider_selector: &str, + model_selector: Option<&str>, +) -> Option { + let provider = provider(catalog, provider_selector)?; + let model = model_selector.and_then(|selector| provider.model(selector)); + Some(match model { + Some(model) => ModelEntry { provider, model }.agent_profile(), + None => provider_agent_profile(provider), + }) +} + +/// The `target` provider's model closest to `reference` in capability and +/// input price, for provider-level fallbacks. +#[must_use] +pub fn closest_model<'a>( + catalog: &'a Catalog, + target: &str, + reference: &CatalogModel, +) -> Option> { + let target = provider(catalog, target)?; + let reference_caps = reference.capabilities(); + let reference_price = reference + .pricing() + .and_then(|pricing| pricing.input_usd_micros_per_million) + .unwrap_or(0); + provider_models(target) + .into_iter() + .filter(|entry| { + let caps = entry.model.capabilities(); + caps.tools().is_supported() == reference_caps.tools().is_supported() + && caps.images().is_supported() == reference_caps.images().is_supported() + && caps.reasoning().is_supported() == reference_caps.reasoning().is_supported() + }) + .min_by_key(|entry| { + let price = entry + .model + .pricing() + .and_then(|pricing| pricing.input_usd_micros_per_million) + .unwrap_or(0); + price.abs_diff(reference_price) + }) +} + +/// Model ids grouped by provider, for diagnostics and documentation. +#[must_use] +pub fn model_ids_by_provider(catalog: &Catalog) -> BTreeMap> { + listed_providers(catalog) + .into_iter() + .map(|provider| { + ( + provider.id().clone(), + provider_models(provider) + .into_iter() + .map(|entry| entry.model.id().clone()) + .collect(), + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::test_catalog; + + #[test] + fn builtins_ship_fabro_defaults() { + let catalog = test_catalog(); + let ids: Vec<_> = enabled_providers(&catalog) + .iter() + .map(|provider| provider.id().to_string()) + .collect(); + assert_eq!(ids[0], "anthropic"); + assert!(ids.contains(&"openai".to_string())); + assert!( + !ids.contains(&"bedrock".to_string()), + "bedrock ships disabled" + ); + assert!( + !listed_providers(&catalog) + .iter() + .any(|provider| provider.id().as_str() == "openai-codex"), + "stand-in providers are not listed" + ); + } + + #[test] + fn operator_overlay_applies_last() { + let overlay = LlmLayer( + toml::from_str( + r" +[providers.openai] +priority = 500 +enabled = false +", + ) + .unwrap(), + ); + let catalog = build_catalog(&overlay, &|_| None).unwrap(); + assert!(provider(&catalog, "openai").is_none()); + assert_eq!( + catalog.provider("openai").unwrap().priority(), + 500, + "overlay values win over the built-ins" + ); + } + + #[test] + fn openai_base_url_env_repoints_the_openai_provider() { + let catalog = build_catalog(&LlmLayer::default(), &|name| { + (name == EnvVars::OPENAI_BASE_URL).then(|| "http://127.0.0.1:1234/v1".to_string()) + }) + .unwrap(); + assert_eq!( + catalog.provider("openai").unwrap().base_url(), + "http://127.0.0.1:1234" + ); + } + + #[test] + fn probe_and_small_default_follow_the_catalog() { + let catalog = test_catalog(); + assert_eq!( + probe_model(&catalog, "openai").unwrap().model.id().as_str(), + "gpt-5.4-mini" + ); + assert_eq!( + probe_model(&catalog, "anthropic") + .unwrap() + .model + .id() + .as_str(), + "claude-haiku-4.5" + ); + let ready = HashSet::from([ProviderId::new("openai")]); + assert_eq!( + small_default_for_ready(&catalog, &ready) + .unwrap() + .model + .id() + .as_str(), + "gpt-5.4-mini" + ); + assert_eq!( + default_for_ready(&catalog, &ready) + .unwrap() + .model + .id() + .as_str(), + "gpt-5.6-sol" + ); + assert_eq!( + default_for_ready(&catalog, &HashSet::new()) + .unwrap() + .provider + .id() + .as_str(), + "anthropic" + ); + } + + #[test] + fn selectors_resolve_aliases_and_canonical_ids() { + let catalog = test_catalog(); + assert!(is_model_selector(&catalog, "sonnet")); + assert!(is_model_selector(&catalog, "gpt-5.4-mini")); + assert!(!is_model_selector(&catalog, "nope")); + assert_eq!( + canonical_model_id(&catalog, &ProviderId::new("openai"), "codex"), + "gpt-5.4" + ); + assert_eq!( + canonical_model_id(&catalog, &ProviderId::new("openai"), "unknown-model"), + "unknown-model" + ); + assert_eq!( + agent_profile(&catalog, "openai", Some("gpt-5.6-sol")), + Some(AgentProfileKind::Gpt56) + ); + assert_eq!( + agent_profile(&catalog, "moonshot", None), + Some(AgentProfileKind::Kimi), + "a passthrough model on Moonshot takes the provider's Kimi profile" + ); + assert_eq!( + agent_profile(&catalog, "deepseek", None), + Some(AgentProfileKind::OpenAi) + ); + assert_eq!( + agent_profile(&catalog, "openrouter", None), + None, + "disabled providers have no profile to offer" + ); + assert_eq!( + agent_profile(&catalog, "moonshot", Some("kimi-k3")), + Some(AgentProfileKind::Kimi) + ); + assert_eq!( + agent_profile(&catalog, "openai", Some("gpt-6-astra")), + Some(AgentProfileKind::Gpt6) + ); + assert_eq!( + agent_profile(&catalog, "anthropic", Some("claude-sonnet-4.5")), + Some(AgentProfileKind::Anthropic) + ); + } + + #[test] + fn reasoning_by_default_reads_agent_metadata_then_capabilities() { + let catalog = test_catalog(); + let kimi = model_on_provider(&catalog, "moonshot", "kimi-k2.5").unwrap(); + assert!(kimi.reasons_by_default(), "the catalog row says so"); + let sonnet = model_on_provider(&catalog, "anthropic", "claude-sonnet-4.5").unwrap(); + assert!( + !sonnet.reasons_by_default(), + "a thinking-budget model reasons only when asked" + ); + } +} diff --git a/lib/components/fabro-llm/src/client.rs b/lib/components/fabro-llm/src/client.rs index 59cfa421d..412456ffb 100644 --- a/lib/components/fabro-llm/src/client.rs +++ b/lib/components/fabro-llm/src/client.rs @@ -1,2248 +1,270 @@ -use std::collections::{HashMap, HashSet}; +//! Client construction from Fabro configuration and credentials. + use std::sync::Arc; +use std::time::Duration; -use fabro_auth::{ApiCredential, CredentialSource}; -use fabro_model::{AdapterKind, Catalog, ModelSelectionError, ProviderId}; -use tracing::debug; - -use crate::adapter_registry::{ - AdapterConfig, AdapterKindOptions, OpenAiAdapterOptions, factory_for, +use fabro_auth::{CredentialSource, ResolveError, lithos_credentials}; +use fabro_types::ProviderId; +use lithos_llm::adapter::ProviderAdapter; +use lithos_llm::catalog::Catalog; +use lithos_llm::client::{Client, ClientBuildError, ClientBuilder, ProviderBuildIssue}; +use lithos_llm::middleware::{ + Call, Middleware, Observer, RetryMiddleware, RetryPolicy, RetryStage, }; -use crate::cost; -use crate::error::{Error, ProviderErrorKind}; -use crate::middleware::{Middleware, NextFn, NextStreamFn}; -use crate::provider::{ProviderAdapter, StreamEventStream}; -use crate::token_count::{ - InputTokenCount, InputTokenCountMethod, InputTokenCountPreference, estimate_input_tokens, -}; -use crate::types::{Request, Response, Speed, StreamEvent, Warning}; +use lithos_llm::types::Error; -/// The core client that routes requests to provider adapters (Section 2.2, 3). +use crate::attachments::InlineLocalAttachments; +use crate::error::LlmError; + +/// The application name lithos reports to providers that ask, such as the +/// `originator` header on the OpenAI Codex deployment. +const APPLICATION_NAME: &str = "fabro"; + +/// Default same-provider retry policy applied before visible output. +/// +/// Three attempts with short exponential backoff, capped at five seconds. +/// fabro-agent replays after visible output with the same policy. +pub fn default_retry_policy() -> RetryPolicy { + RetryPolicy::exponential() + .max_attempts(3) + .initial_delay(Duration::from_millis(500)) + .max_delay(Duration::from_secs(5)) + .jitter(true) +} + +/// One retry the lithos retry middleware decided on. +#[derive(Clone, Debug)] +pub struct RetryNotice { + /// The failure that ended the attempt. + pub error: LlmError, + /// The attempt that failed, counted from 1. + pub attempt: u32, + /// How long the middleware waits before the next attempt. + pub delay: Duration, + /// Whether the request or its stream failed. + pub stage: RetryStage, +} + +/// A per-call hook that receives the retries the client performs. +/// +/// The retry middleware is shared by every call through a client, so a +/// caller that turns retries into its own events — the agent's durable +/// `LlmRetry` event — inserts a listener into the call's context extensions +/// before dispatch. Calls without a listener are retried silently. #[derive(Clone)] -pub struct Client { - providers: HashMap>, - default_provider: Option, - middleware: Vec>, - catalog: Option>, +pub struct RetryListener(Arc); + +impl RetryListener { + pub fn new(listener: impl Fn(RetryNotice) + Send + Sync + 'static) -> Self { + Self(Arc::new(listener)) + } + + fn notify(&self, notice: RetryNotice) { + (self.0)(notice); + } } -#[derive(Debug, Clone)] -pub struct ProviderRegistrationIssue { - pub provider: ProviderId, - pub error: Error, -} +/// Forwards middleware retries to the call's [`RetryListener`], if any. +struct RetryNotifier; -#[derive(Clone)] -pub struct ClientRegistrationReport { - pub client: Client, - pub registration_issues: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RegistrationMode { - FailFast, - CollectIssues, -} - -struct ResolvedRequest { - provider: Arc, - request: Request, -} - -impl Client { - /// Create a new Client with explicit configuration. - #[must_use] - pub fn new( - providers: HashMap>, - default_provider: Option, - middleware: Vec>, - ) -> Self { - Self { - providers, - default_provider, - middleware, - catalog: None, - } - } - - /// Create a Client from a credential source. - /// - /// # Errors - /// - /// Returns `Error` if the source cannot resolve credentials or any provider - /// adapter fails to initialize. - pub async fn from_source( - source: &dyn CredentialSource, - catalog: Arc, - ) -> Result { - let resolved = source - .resolve(&catalog) - .await - .map_err(|err| Error::Configuration { - message: format!("Failed to resolve LLM credentials: {err}"), - source: None, - })?; - Self::from_credentials(resolved.credentials, catalog).await - } - - /// Create a Client report from a credential source. - /// - /// # Errors - /// - /// Returns `Error` only when the credential source itself fails. Provider - /// adapter construction/registration failures are recorded on the report. - pub async fn from_source_report( - source: &dyn CredentialSource, - catalog: Arc, - ) -> Result { - let resolved = source - .resolve(&catalog) - .await - .map_err(|err| Error::Configuration { - message: format!("Failed to resolve LLM credentials: {err}"), - source: None, - })?; - Ok(Self::from_credentials_report(resolved.credentials, catalog).await) - } - - /// Create a Client from typed provider credentials. - /// - /// # Errors - /// - /// Returns `Error` if any provider adapter fails to initialize. - pub async fn from_credentials( - credentials: Vec, - catalog: Arc, - ) -> Result { - let (report, error) = - Self::from_credentials_internal(credentials, catalog, RegistrationMode::FailFast).await; - if let Some(error) = error { - return Err(error); - } - Ok(report.client) - } - - /// Create a Client while collecting provider adapter registration failures. - /// - /// Providers whose credentials resolve but whose adapter cannot be - /// constructed or initialized are omitted from the returned client and - /// reported in `registration_issues`. - pub async fn from_credentials_report( - credentials: Vec, - catalog: Arc, - ) -> ClientRegistrationReport { - let (report, _) = - Self::from_credentials_internal(credentials, catalog, RegistrationMode::CollectIssues) - .await; - report - } - - async fn from_credentials_internal( - credentials: Vec, - catalog: Arc, - mode: RegistrationMode, - ) -> (ClientRegistrationReport, Option) { - let mut client = Self { - providers: HashMap::new(), - default_provider: None, - middleware: Vec::new(), - catalog: Some(Arc::clone(&catalog)), - }; - let mut registration_issues = Vec::new(); - - for credential in credentials { - let provider_id = credential.provider.clone(); - let adapter = if let Some(provider) = catalog.provider(&provider_id) { - let factory = factory_for(provider.adapter); - let kind_options = match provider.adapter { - AdapterKind::OpenAi => AdapterKindOptions::OpenAi(OpenAiAdapterOptions { - codex_mode: credential.codex_mode, - org_id: credential.org_id, - project_id: credential.project_id, - }), - _ => AdapterKindOptions::None, - }; - factory(AdapterConfig { - provider_id: provider.id.to_string(), - auth_header: credential.auth_header, - base_url: credential.base_url.or_else(|| provider.base_url.clone()), - extra_headers: credential.extra_headers, - kind_options, - catalog: Some(Arc::clone(&catalog)), - }) - } else { - Err(Error::Configuration { - message: format!( - "Provider \"{provider_id}\" is not supported by credential-only registration" - ), - source: None, - }) - }; - match adapter { - Ok(adapter) => { - if let Err(error) = client.register_provider(adapter).await { - if mode == RegistrationMode::FailFast { - return ( - ClientRegistrationReport { - client, - registration_issues, - }, - Some(error), - ); - } - registration_issues.push(ProviderRegistrationIssue { - provider: provider_id, - error, - }); - } - } - Err(error) => { - if mode == RegistrationMode::FailFast { - return ( - ClientRegistrationReport { - client, - registration_issues, - }, - Some(error), - ); - } - registration_issues.push(ProviderRegistrationIssue { - provider: provider_id, - error, - }); - } - } - } - - debug!( - providers = ?client.provider_names(), - default = ?client.default_provider(), - "LLM client initialized from typed credentials" - ); - - ( - ClientRegistrationReport { - client, - registration_issues, - }, - None, - ) - } - - /// Register a provider adapter. Calls `initialize()` on the adapter - /// (Section 2.4). - /// - /// # Errors - /// - /// Returns `Error` if the adapter's `initialize()` method fails. - pub async fn register_provider( - &mut self, - adapter: Arc, - ) -> Result<(), Error> { - adapter.initialize().await?; - let name = adapter.name().to_string(); - if self.default_provider.is_none() { - self.default_provider = Some(name.clone()); - } - self.providers.insert(name.clone(), adapter); - debug!(provider = %name, "Provider registered"); - Ok(()) - } - - /// Add middleware. - pub fn add_middleware(&mut self, mw: Arc) { - self.middleware.push(mw); - } - - fn canonical_provider_name(&self, provider_name: &str) -> String { - self.catalog - .as_ref() - .and_then(|catalog| catalog.provider(&ProviderId::new(provider_name))) - .map_or_else( - || provider_name.to_string(), - |provider| provider.id.to_string(), - ) - } - - fn provider_adapter(&self, provider_name: &str) -> Option> { - let canonical = self.canonical_provider_name(provider_name); - self.providers.get(&canonical).cloned().or_else(|| { - self.providers.iter().find_map(|(name, adapter)| { - (self.canonical_provider_name(name) == canonical).then(|| Arc::clone(adapter)) - }) - }) - } - - fn eligible_provider_ids(&self) -> HashSet { - self.providers - .keys() - .map(|provider| ProviderId::new(self.canonical_provider_name(provider))) - .collect() - } - - /// Resolve one concrete provider/model offering and canonicalize a cloned - /// request. Explicit-provider unknown models remain passthrough values. - fn resolve_request_with_adapter(&self, request: &Request) -> Result { - let mut resolved = request.clone(); - let Some(catalog) = &self.catalog else { - let provider_name = request - .provider - .as_deref() - .or(self.default_provider.as_deref()) - .ok_or_else(|| Error::Configuration { - message: "No provider specified and no default provider set".into(), - source: None, - })?; - let provider = - self.provider_adapter(provider_name) - .ok_or_else(|| Error::Configuration { - message: format!("Provider '{provider_name}' not registered"), - source: None, - })?; - resolved.provider = Some(provider.name().to_string()); - return Ok(ResolvedRequest { - provider, - request: resolved, - }); - }; - - let eligible = self.eligible_provider_ids(); - if let Some(explicit) = request.provider.as_deref() { - let explicit = ProviderId::new(explicit); - if catalog.provider(&explicit).is_some() { - let selected = catalog - .resolve_selection(Some(&request.model), Some(&explicit), &eligible) - .map_err(selection_error)?; - let provider = self - .provider_adapter(selected.provider.as_str()) - .ok_or_else(|| { - selection_error(ModelSelectionError::ProviderUnavailable { - provider: selected.provider.clone(), - }) - })?; - resolved.model = selected.model; - resolved.provider = Some(selected.provider.into_inner()); - return Ok(ResolvedRequest { - provider, - request: resolved, - }); - } - - let provider = - self.provider_adapter(explicit.as_str()) - .ok_or_else(|| Error::Configuration { - message: format!("Provider '{explicit}' not registered"), - source: None, - })?; - resolved.provider = Some(provider.name().to_string()); - return Ok(ResolvedRequest { - provider, - request: resolved, - }); - } - - match catalog.select(&request.model, None, &eligible) { - Ok(model) => { - let provider = self - .provider_adapter(model.provider.as_str()) - .ok_or_else(|| { - selection_error(ModelSelectionError::ProviderUnavailable { - provider: model.provider.clone(), - }) - })?; - resolved.model = model.id.to_string(); - resolved.provider = Some(model.provider.to_string()); - Ok(ResolvedRequest { - provider, - request: resolved, - }) - } - Err(ModelSelectionError::UnknownSelector { .. }) => { - let provider_name = - self.default_provider - .as_deref() - .ok_or_else(|| Error::Configuration { - message: "No provider specified and no default provider set".into(), - source: None, - })?; - let provider = - self.provider_adapter(provider_name) - .ok_or_else(|| Error::Configuration { - message: format!("Provider '{provider_name}' not registered"), - source: None, - })?; - resolved.provider = Some(self.canonical_provider_name(provider.name())); - Ok(ResolvedRequest { - provider, - request: resolved, - }) - } - Err(error) => Err(selection_error(error)), - } - } - - /// Resolve the concrete provider/model route and return a canonicalized - /// clone of the request without dispatching it. - /// - /// This is useful at persistence and API boundaries that must expose the - /// selected provider alongside the canonical model ID. The caller-owned - /// request is never modified. - pub fn resolve_request(&self, request: &Request) -> Result { - self.resolve_request_with_adapter(request) - .map(|resolved| resolved.request) - } - - fn validate_request_controls(&self, request: &Request) -> Result<(), Error> { - let Some(catalog) = &self.catalog else { - return Ok(()); - }; - let Some(provider) = request.provider.as_deref() else { - return Ok(()); - }; - let Some(model) = catalog.get_on_provider(&ProviderId::new(provider), &request.model) - else { - return Ok(()); - }; - let Some(settings) = catalog.settings_for(model) else { - return Ok(()); - }; - let model_id = model.id.as_str(); - - if let Some(effort) = request.reasoning_effort { - if !settings.controls.reasoning_effort.contains(&effort) { - return Err(Error::InvalidRequest { - message: format!( - "model '{model_id}' does not support reasoning_effort '{effort}'; allowed values: {}", - format_control_values(&settings.controls.reasoning_effort), - ), - }); - } - } - - if let Some(speed) = request.speed { - if speed != Speed::Standard && !settings.controls.speed.contains(&speed) { - return Err(Error::InvalidRequest { - message: format!( - "model '{model_id}' does not support speed '{speed}'; allowed values: standard{}", - format_additional_speeds(&settings.controls.speed), - ), - }); - } - } - - Ok(()) - } - - /// Send a blocking request (Section 4.1). - /// - /// # Errors - /// - /// Returns `Error::InvalidRequest` when a catalog-declared request control - /// is unsupported, `Error::Configuration` if no provider is specified or - /// registered, or any provider/middleware error encountered during the - /// request. - pub async fn complete(&self, request: &Request) -> Result { - let ResolvedRequest { provider, request } = self.resolve_request_with_adapter(request)?; - self.validate_request_controls(&request)?; - - if self.middleware.is_empty() { - return complete_stamped(&provider, self.catalog.as_deref(), &request).await; - } - - // Build middleware chain. Cost is stamped at the base so middleware - // observes the final response. - let catalog = self.catalog.clone(); - let base: NextFn = Arc::new(move |req: Request| { - let provider = provider.clone(); - let catalog = catalog.clone(); - Box::pin(async move { complete_stamped(&provider, catalog.as_deref(), &req).await }) - }); - - let chain = self.middleware.iter().rev().fold(base, |next, mw| { - let mw = mw.clone(); - Arc::new(move |req: Request| { - let mw = mw.clone(); - let next = next.clone(); - Box::pin(async move { mw.handle_complete(req, next).await }) - }) - }); - - chain(request).await - } - - /// Send a streaming request (Section 4.2). - /// - /// # Errors - /// - /// Returns `Error::InvalidRequest` when a catalog-declared request control - /// is unsupported, `Error::Configuration` if no provider is specified or - /// registered, or any provider/middleware error encountered during the - /// request. - pub async fn stream(&self, request: &Request) -> Result { - let ResolvedRequest { provider, request } = self.resolve_request_with_adapter(request)?; - self.validate_request_controls(&request)?; - - if self.middleware.is_empty() { - return stream_stamped(&provider, self.catalog.clone(), &request).await; - } - - // Build streaming middleware chain. Cost is stamped at the base so - // middleware observes the final Finish events. - let catalog = self.catalog.clone(); - let base: NextStreamFn = Arc::new(move |req: Request| { - let provider = provider.clone(); - let catalog = catalog.clone(); - Box::pin(async move { stream_stamped(&provider, catalog, &req).await }) - }); - - let chain = self.middleware.iter().rev().fold(base, |next, mw| { - let mw = mw.clone(); - Arc::new(move |req: Request| { - let mw = mw.clone(); - let next = next.clone(); - Box::pin(async move { mw.handle_stream(req, next).await }) - }) - }); - - chain(request).await - } - - /// Count the model-visible input/context tokens for a request without - /// creating a completion. - /// - /// # Errors - /// - /// Returns request validation/provider resolution errors, and returns - /// provider count errors when the selected preference requires provider - /// semantics or when the error is not fallback-eligible. - pub async fn count_input_tokens( +impl Observer for RetryNotifier { + fn on_retry( &self, - request: &Request, - preference: InputTokenCountPreference, - ) -> Result { - let ResolvedRequest { provider, request } = self.resolve_request_with_adapter(request)?; - self.validate_request_controls(&request)?; - provider.validate_request(&request)?; - - if preference == InputTokenCountPreference::EstimateOnly { - return Ok(estimate_input_tokens(&request, provider.name())); + call: &Call, + error: &Error, + attempt: u32, + delay: Duration, + stage: RetryStage, + ) { + if let Some(listener) = call.context().extensions().get::() { + listener.notify(RetryNotice { + error: LlmError::from(error), + attempt, + delay, + stage, + }); } + } +} - match provider.count_input_tokens(&request).await { - Ok(Some(count)) => Ok(count), - Ok(None) if preference == InputTokenCountPreference::PreferProvider => { - Ok(fallback_estimate( - &request, - provider.name(), - "provider_token_count_unsupported", - "provider does not support input token counting; returned local estimate", - )) - } - Ok(None) => Err(Error::Configuration { - message: format!( - "provider '{}' does not support input token counting", - provider.name() - ), - source: None, - }), - Err(error) - if preference == InputTokenCountPreference::PreferProvider - && token_count_fallback_eligible(&error) => - { - Ok(fallback_estimate( - &request, - provider.name(), - "provider_token_count_failed", - "provider input token counting failed; returned local estimate", - )) - } - Err(error) => Err(error), +/// The retry middleware Fabro installs: `policy`, reporting each retry to +/// the call's [`RetryListener`]. +pub fn retry_middleware(policy: RetryPolicy) -> RetryMiddleware { + RetryMiddleware::new(policy).observer(RetryNotifier) +} + +/// Options for [`build_client`] and [`build_offline_client`]. +#[derive(Default)] +pub struct ClientOptions { + /// Retry policy for the lithos retry middleware. `None` disables retries. + pub retry: Option, + /// Extra middleware, run after retry and attachment inlining. + pub middleware: Vec>, + /// Custom adapters that replace the built-in adapter for a provider. + pub adapters: Vec<(ProviderId, Arc)>, + /// HTTP client for provider requests. `None` builds lithos's default. + pub http: Option, + /// Whether to inline local file attachments. Off for gateway clients that + /// forward requests to a Fabro server, which inlines them itself. + pub inline_attachments: bool, +} + +impl ClientOptions { + /// Retries and attachment inlining on, nothing else. + #[must_use] + pub fn standard() -> Self { + Self { + retry: Some(default_retry_policy()), + inline_attachments: true, + ..Self::default() } } - /// Close all provider adapters. - /// - /// # Errors - /// - /// Returns any error from a provider adapter's `close()` method. - pub async fn close(&self) -> Result<(), Error> { - for provider in self.providers.values() { - provider.close().await?; + #[must_use] + pub fn with_retry(mut self, policy: Option) -> Self { + self.retry = policy; + self + } + + #[must_use] + pub fn with_middleware(mut self, middleware: Arc) -> Self { + self.middleware.push(middleware); + self + } + + #[must_use] + pub fn with_adapter(mut self, provider: ProviderId, adapter: Arc) -> Self { + self.adapters.push((provider, adapter)); + self + } + + fn adapter_providers(&self) -> impl Iterator { + self.adapters.iter().map(|(provider, _)| provider) + } + + fn apply(self, mut builder: ClientBuilder) -> ClientBuilder { + if let Some(http) = self.http { + builder = builder.http(http); } - Ok(()) + if let Some(policy) = self.retry { + builder = builder.middleware(retry_middleware(policy)); + } + if self.inline_attachments { + builder = builder.middleware(InlineLocalAttachments::new()); + } + for middleware in self.middleware { + builder = builder.middleware_arc(middleware); + } + for (provider, adapter) in self.adapters { + builder = builder.adapter_arc(provider, adapter); + } + builder } +} - /// Get the list of registered provider names. +/// A built client plus what the build learned about provider readiness. +pub struct FabroClient { + pub client: Client, + /// Enabled providers with working credentials, in catalog order. + pub ready: Vec, + /// Enabled providers whose credential material could not be used. + pub auth_issues: Vec<(ProviderId, ResolveError)>, + /// Ready providers lithos could not build an adapter for. + pub build_issues: Vec, +} + +impl FabroClient { + /// Whether `provider` can serve requests through this client. #[must_use] - pub fn provider_names(&self) -> Vec<&str> { - self.providers - .keys() - .map(std::string::String::as_str) - .collect() + pub fn has_provider(&self, provider: &ProviderId) -> bool { + self.client.available_providers().contains(provider) } - /// Canonical IDs for provider adapters that registered successfully. + /// The providers this client can route to. #[must_use] - pub fn provider_ids(&self) -> HashSet { - self.eligible_provider_ids() - } - - /// Check whether a provider adapter is registered. - #[must_use] - pub fn has_provider(&self, name: &str) -> bool { - self.providers.contains_key(name) - || self - .catalog - .as_ref() - .and_then(|catalog| catalog.provider(&ProviderId::new(name))) - .is_some_and(|provider| self.providers.contains_key(provider.id.as_str())) - } - - /// Get the default provider name. - #[must_use] - pub fn default_provider(&self) -> Option<&str> { - self.default_provider.as_deref() + pub fn provider_ids(&self) -> Vec { + self.client.available_providers().iter().cloned().collect() } } -fn selection_error(error: ModelSelectionError) -> Error { - Error::configuration_error(error.to_string(), error) +#[derive(Debug, thiserror::Error)] +pub enum LlmSetupError { + #[error("failed to build the LLM client")] + Build(#[from] ClientBuildError), } -/// Validate, run, and cost-stamp a blocking request. Shared by -/// [`Client::complete`]'s direct path and its middleware-chain base so cost -/// stamping stays single-sited. -async fn complete_stamped( - provider: &Arc, - catalog: Option<&Catalog>, - request: &Request, -) -> Result { - provider.validate_request(request)?; - let mut response = provider.complete(request).await?; - let selected_provider = request - .provider - .as_deref() - .unwrap_or_else(|| provider.name()); - response.model.clone_from(&request.model); - response.provider = selected_provider.to_string(); - cost::apply_estimated_cost( - catalog, - selected_provider, - &request.model, - request.speed, - &mut response, - ); - Ok(response) -} - -/// Validate and run a streaming request, cost-stamping terminal -/// [`StreamEvent::Finish`] responses. Shared by [`Client::stream`]'s direct -/// path and its middleware-chain base so cost stamping stays single-sited. -async fn stream_stamped( - provider: &Arc, - catalog: Option>, - request: &Request, -) -> Result { - provider.validate_request(request)?; - let stream = provider.stream(request).await?; - Ok(stamp_stream_costs( - catalog, - request - .provider - .clone() - .unwrap_or_else(|| provider.name().to_string()), - request.model.clone(), - request.speed, - stream, - )) -} - -/// Wrap a provider event stream so terminal [`StreamEvent::Finish`] -/// responses carry a catalog-estimated cost, mirroring what -/// [`Client::complete`] stamps on blocking responses. -fn stamp_stream_costs( - catalog: Option>, - provider: String, - model: String, - speed: Option, - stream: StreamEventStream, -) -> StreamEventStream { - use futures::StreamExt; - - Box::pin(stream.map(move |event| { - event.map(|mut event| { - if let StreamEvent::Finish { response, .. } | StreamEvent::StepFinish { response, .. } = - &mut event - { - response.model.clone_from(&model); - response.provider.clone_from(&provider); - cost::apply_estimated_cost(catalog.as_deref(), &provider, &model, speed, response); - } - event - }) - })) -} - -fn token_count_fallback_eligible(error: &Error) -> bool { - matches!( - error, - Error::Network { .. } - | Error::RequestTimeout { .. } - | Error::Provider { - kind: ProviderErrorKind::RateLimit | ProviderErrorKind::Server, - .. - } - ) -} - -fn fallback_estimate( - request: &Request, - provider: &str, - code: &'static str, - message: &'static str, -) -> InputTokenCount { - let mut count = estimate_input_tokens(request, provider); - if count.method == InputTokenCountMethod::LocalEstimate - && !count - .warnings - .iter() - .any(|warning| warning.code.as_deref() == Some(code)) - { - count.warnings.push(Warning { - message: message.to_string(), - code: Some(code.to_string()), - }); +/// Builds a client whose ready providers are those the credential source can +/// serve. Credentials are re-read from `source` on every provider attempt. +pub async fn build_client( + catalog: Catalog, + source: Arc, + options: ClientOptions, +) -> Result { + let resolved = source.resolve_all(&catalog).await; + let mut ready = resolved.ready; + for provider in options.adapter_providers() { + if !ready.contains(provider) { + ready.push(provider.clone()); + } } - count + let builder = Client::builder() + .catalog(catalog) + .application(APPLICATION_NAME) + .credentials_arc(lithos_credentials(source)) + .enabled_providers(ready.iter().cloned()); + let build = options.apply(builder).build()?; + Ok(FabroClient { + client: build.client, + ready, + auth_issues: resolved.auth_issues, + build_issues: build.issues, + }) } -fn format_control_values(values: &[T]) -> String { - if values.is_empty() { - "none".to_string() - } else { - values - .iter() - .map(ToString::to_string) - .collect::>() - .join(", ") - } -} - -fn format_additional_speeds(values: &[Speed]) -> String { - if values.is_empty() { - String::new() - } else { - format!(", {}", format_control_values(values)) - } +/// Builds a client that needs no credentials: every available provider is +/// served by a custom adapter from `options.adapters`, such as the +/// `fabro exec` gateway or a test double. +pub fn build_offline_client( + catalog: Catalog, + options: ClientOptions, +) -> Result { + let ready: Vec = options.adapter_providers().cloned().collect(); + let builder = Client::builder() + .catalog(catalog) + .application(APPLICATION_NAME) + .enabled_providers(ready.iter().cloned()); + let build = options.apply(builder).build()?; + Ok(FabroClient { + client: build.client, + ready, + auth_issues: Vec::new(), + build_issues: build.issues, + }) } #[cfg(test)] mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - - use async_trait::async_trait; - use fabro_auth::{ApiKeyHeader, CredentialSource, ResolvedCredentials}; - use fabro_model::ProviderId; - use fabro_model::catalog::LlmCatalogSettings; - use futures::stream; + use fabro_auth::test_support::env_credential_source; use super::*; - use crate::adapter_registry; - use crate::error::ProviderErrorDetail; - use crate::providers::openai_compatible; - use crate::types::*; - - /// A mock provider for testing. - struct MockProvider { - provider_name: String, - response_text: String, - } - - impl MockProvider { - fn new(name: &str, response: &str) -> Self { - Self { - provider_name: name.to_string(), - response_text: response.to_string(), - } - } - } - - #[async_trait::async_trait] - impl ProviderAdapter for MockProvider { - fn name(&self) -> &str { - &self.provider_name - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_mock".into(), - model: "mock-model".into(), - provider: self.provider_name.clone(), - message: Message::assistant(&self.response_text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - let text = self.response_text.clone(); - let provider = self.provider_name.clone(); - let events = vec![ - Ok(StreamEvent::text_delta(&text, Some("t1".into()))), - Ok(StreamEvent::finish( - FinishReason::Stop, - TokenCounts::default(), - Response { - id: "resp_mock".into(), - model: "mock-model".into(), - provider, - message: Message::assistant(&text), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } - } - - fn test_request() -> Request { - Request { - model: "mock-model".into(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - struct CountingProvider { - provider_name: String, - count_result: std::sync::Mutex, Error>>, - count_calls: Arc, - reject_named: bool, - } - - impl CountingProvider { - fn new(result: Result, Error>) -> Self { - Self { - provider_name: "counter".to_string(), - count_result: std::sync::Mutex::new(result), - count_calls: Arc::new(AtomicUsize::new(0)), - reject_named: false, - } - } - - fn with_name(mut self, name: &str) -> Self { - self.provider_name = name.to_string(); - self - } - - fn count_calls(&self) -> Arc { - Arc::clone(&self.count_calls) - } - - fn rejecting_named(mut self) -> Self { - self.reject_named = true; - self - } - } - - #[async_trait::async_trait] - impl ProviderAdapter for CountingProvider { - fn name(&self) -> &str { - &self.provider_name - } - - async fn complete(&self, _request: &Request) -> Result { - unimplemented!() - } - - async fn stream(&self, _request: &Request) -> Result { - unimplemented!() - } - - fn supports_tool_choice(&self, mode: &str) -> bool { - !(self.reject_named && mode == "named") - } - - async fn count_input_tokens( - &self, - _request: &Request, - ) -> Result, Error> { - self.count_calls.fetch_add(1, Ordering::SeqCst); - self.count_result.lock().unwrap().clone() - } - } - - fn provider_count(tokens: i64) -> InputTokenCount { - InputTokenCount { - input_tokens: tokens, - method: InputTokenCountMethod::ProviderApi, - provider: "counter".to_string(), - model: "mock-model".to_string(), - warnings: vec![], - } - } - - fn warning_codes(count: &InputTokenCount) -> Vec<&str> { - count - .warnings - .iter() - .filter_map(|warning| warning.code.as_deref()) - .collect() - } - - fn provider_error(kind: ProviderErrorKind) -> Error { - Error::Provider { - kind, - detail: Box::new(ProviderErrorDetail::new("provider failed", "counter")), - } - } - - async fn client_with_counting_provider( - provider: CountingProvider, - ) -> (Client, Arc) { - let calls = provider.count_calls(); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.register_provider(Arc::new(provider)).await.unwrap(); - (client, calls) - } - - struct StubSource { - credentials: Vec, - } - - fn catalog_with(overrides: &str) -> Arc { - let settings: LlmCatalogSettings = toml::from_str(overrides).unwrap(); - Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()) - } - - #[async_trait] - impl CredentialSource for StubSource { - async fn resolve(&self, catalog: &Catalog) -> anyhow::Result { - let _ = catalog; - Ok(ResolvedCredentials { - credentials: self.credentials.clone(), - auth_issues: Vec::new(), - }) - } - - async fn configured_providers(&self, catalog: &Catalog) -> Vec { - let _ = catalog; - self.credentials - .iter() - .map(|credential| credential.provider.clone()) - .collect() - } - } + use crate::test_support::test_catalog; #[tokio::test] - async fn complete_routes_to_default_provider() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "Hello!"))) + async fn ready_providers_follow_credentials_and_policy() { + let source = env_credential_source(|name| match name { + "OPENAI_API_KEY" | "BEDROCK_API_KEY" => Some("key".to_string()), + _ => None, + }); + let built = build_client(test_catalog(), source, ClientOptions::standard()) .await .unwrap(); - - let response = client.complete(&test_request()).await.unwrap(); - assert_eq!(response.text(), "Hello!"); - assert_eq!(response.provider, "test"); - } - - /// Hermetic catalog pricing `mock-model` under the `test` provider so - /// cost stamping has something to estimate from. - fn priced_mock_catalog() -> Arc { - let settings: LlmCatalogSettings = toml::from_str( - r#" -[providers.test] -display_name = "Test" -adapter = "openai_compatible" -base_url = "https://test.invalid/v1" - -[models."mock-model"] -provider = "test" -display_name = "Mock" -family = "mock" -default = true - -[models."mock-model".limits] -context_window = 100000 - -[models."mock-model".features] -tools = false -vision = false -reasoning = false - -[models."mock-model".costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 2.0 -"#, - ) - .unwrap(); - Arc::new(Catalog::from_settings(&settings).unwrap()) - } - - fn portable_model_catalog(openrouter_base_url: &str) -> Arc { - let settings: LlmCatalogSettings = toml::from_str(&format!( - r#" -[providers.openai] -display_name = "OpenAI" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://openai.invalid/v1" -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 - -[providers.openai.models."gpt-5.6-sol".costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 2.0 - -[providers.openrouter] -display_name = "OpenRouter" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "{openrouter_base_url}" -priority = 25 - -[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 - -[providers.openrouter.models."gpt-5.6-sol".costs] -input_cost_per_mtok = 10.0 -output_cost_per_mtok = 20.0 -"#, - )) - .unwrap(); - Arc::new(Catalog::from_settings(&settings).unwrap()) - } - - async fn portable_mock_client(catalog: &Arc, providers: &[&str]) -> Client { - let mut client = Client::new(HashMap::new(), None, vec![]); - for provider in providers { - client - .register_provider(Arc::new(MockProvider::new(provider, provider))) - .await - .unwrap(); - } - client.catalog = Some(Arc::clone(catalog)); - client - } - - #[tokio::test] - async fn shared_alias_selects_by_ready_providers_and_priority_without_mutating_request() { - let catalog = portable_model_catalog("https://openrouter.invalid/v1"); - let mut original = test_request(); - original.model = "gpt-56-sol".to_string(); - - let direct = portable_mock_client(&catalog, &["openai"]).await; - let direct_request = direct.resolve_request(&original).unwrap(); - assert_eq!(direct_request.model, "gpt-5.6-sol"); - assert_eq!(direct_request.provider.as_deref(), Some("openai")); - - let aggregator = portable_mock_client(&catalog, &["openrouter"]).await; - let aggregator_request = aggregator.resolve_request(&original).unwrap(); - assert_eq!(aggregator_request.model, "gpt-5.6-sol"); - assert_eq!(aggregator_request.provider.as_deref(), Some("openrouter")); - - let both = portable_mock_client(&catalog, &["openrouter", "openai"]).await; - let both_request = both.resolve_request(&original).unwrap(); - assert_eq!(both_request.provider.as_deref(), Some("openai")); - - assert_eq!(original.model, "gpt-56-sol"); - assert_eq!(original.provider, None); - - let response = aggregator.complete(&original).await.unwrap(); - assert_eq!(response.model, "gpt-5.6-sol"); - assert_eq!(response.provider, "openrouter"); - assert_eq!(response.cost_source, Some(CostSource::Estimated)); - assert_eq!(response.cost_usd, Some(0.0005)); - } - - #[tokio::test] - async fn explicit_provider_pins_shared_alias_and_preserves_unknown_passthrough() { - let catalog = portable_model_catalog("https://openrouter.invalid/v1"); - let client = portable_mock_client(&catalog, &["openai", "openrouter"]).await; - - let mut aliased = test_request(); - aliased.model = "gpt-56-sol".to_string(); - aliased.provider = Some("openrouter".to_string()); - let resolved = client.resolve_request(&aliased).unwrap(); - assert_eq!(resolved.model, "gpt-5.6-sol"); - assert_eq!(resolved.provider.as_deref(), Some("openrouter")); - - let mut unknown = test_request(); - unknown.model = "provider-private-preview".to_string(); - unknown.provider = Some("openrouter".to_string()); - let resolved = client.resolve_request(&unknown).unwrap(); - assert_eq!(resolved.model, "provider-private-preview"); - assert_eq!(resolved.provider.as_deref(), Some("openrouter")); - } - - #[tokio::test] - async fn selected_offering_api_id_reaches_openai_compatible_wire_request() { - let upstream = httpmock::MockServer::start_async().await; - let completion = upstream - .mock_async(|when, then| { - when.method(httpmock::Method::POST) - .path("/chat/completions") - .json_body_includes(r#"{"model":"openai/gpt-5.6-sol"}"#); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "id": "chatcmpl-portable", - "model": "openai/gpt-5.6-sol", - "choices": [{ - "message": {"role": "assistant", "content": "OK"}, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 1, - "completion_tokens": 1, - "total_tokens": 2 - } - })); - }) - .await; - let catalog = portable_model_catalog(&upstream.base_url()); - let adapter = openai_compatible::Adapter::new("test-key", upstream.base_url()) - .with_name("openrouter") - .with_catalog(Arc::clone(&catalog)); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.register_provider(Arc::new(adapter)).await.unwrap(); - client.catalog = Some(catalog); - let mut request = test_request(); - request.model = "gpt-56-sol".to_string(); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.model, "gpt-5.6-sol"); - assert_eq!(response.provider, "openrouter"); - completion.assert_async().await; - } - - #[tokio::test] - async fn modal_routes_kimi_k3_with_proxy_headers_and_no_bearer_auth() { - let upstream = httpmock::MockServer::start_async().await; - let completion = upstream - .mock_async(|when, then| { - when.method(httpmock::Method::POST) - .path("/v1/chat/completions") - .header("Modal-Key", "wk-test") - .header("Modal-Secret", "ws-test") - .header_missing("Authorization") - .json_body_includes(r#"{"model":"moonshotai/Kimi-K3"}"#); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "id": "chatcmpl-modal", - "model": "moonshotai/Kimi-K3", - "choices": [{ - "message": {"role": "assistant", "content": "OK"}, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 1, - "completion_tokens": 1, - "total_tokens": 2 - } - })); - }) - .await; - let catalog = catalog_with(&format!( - r#" -[providers.modal] -enabled = true -base_url = "{}/v1" -"#, - upstream.base_url() - )); - let modal = ProviderId::new("modal"); - let client = Client::from_credentials( - vec![ApiCredential::with_extra_headers( - modal.clone(), - HashMap::from([ - ("Modal-Key".to_string(), "wk-test".to_string()), - ("Modal-Secret".to_string(), "ws-test".to_string()), - ]), - )], - catalog, - ) - .await - .unwrap(); - let mut request = test_request(); - request.model = "kimi-k3".to_string(); - request.provider = Some(modal.to_string()); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.text(), "OK"); - assert_eq!(response.model, "kimi-k3"); - assert_eq!(response.provider, "modal"); - completion.assert_async().await; - } - - #[tokio::test] - async fn fireworks_routes_kimi_k3_fast_to_router_model_id() { - let upstream = httpmock::MockServer::start_async().await; - let completion = upstream - .mock_async(|when, then| { - when.method(httpmock::Method::POST) - .path("/inference/v1/chat/completions") - .header("Authorization", "Bearer test-key") - .json_body_includes(r#"{"model":"accounts/fireworks/routers/kimi-k3-fast"}"#); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "id": "chatcmpl-fireworks", - "model": "accounts/fireworks/routers/kimi-k3-fast", - "choices": [{ - "message": {"role": "assistant", "content": "OK"}, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 1, - "completion_tokens": 1, - "total_tokens": 2 - } - })); - }) - .await; - let catalog = catalog_with(&format!( - r#" -[providers.fireworks] -enabled = true -base_url = "{}/inference/v1" -"#, - upstream.base_url() - )); - let fireworks = ProviderId::new("fireworks"); - let client = Client::from_credentials( - vec![ - ApiCredential::from_api_key(fireworks.clone(), "test-key".to_string(), &catalog) - .unwrap(), - ], - catalog, - ) - .await - .unwrap(); - let mut request = test_request(); - request.model = "kimi-k3-fast".to_string(); - request.provider = Some(fireworks.to_string()); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.text(), "OK"); - assert_eq!(response.model, "kimi-k3-fast"); - assert_eq!(response.provider, "fireworks"); - completion.assert_async().await; - } - - #[tokio::test] - async fn complete_stamps_estimated_cost_from_catalog() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "Hello!"))) - .await - .unwrap(); - client.catalog = Some(priced_mock_catalog()); - - let response = client.complete(&test_request()).await.unwrap(); - - // 10 input tokens at $1/MTok + 20 output tokens at $2/MTok. - assert_eq!(response.cost_source, Some(CostSource::Estimated)); - let cost = response.cost_usd.expect("cost should be stamped"); - assert!((cost - 0.000_05).abs() < 1e-12, "got {cost}"); - } - - #[tokio::test] - async fn complete_leaves_cost_unset_without_catalog() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "Hello!"))) - .await - .unwrap(); - - let response = client.complete(&test_request()).await.unwrap(); - - assert_eq!(response.cost_usd, None); - assert_eq!(response.cost_source, None); - } - - #[tokio::test] - async fn complete_stamps_cost_beneath_middleware() { - struct Passthrough; - - #[async_trait] - impl Middleware for Passthrough { - async fn handle_complete( - &self, - request: Request, - next: NextFn, - ) -> Result { - next(request).await - } - - async fn handle_stream( - &self, - request: Request, - next: NextStreamFn, - ) -> Result { - next(request).await - } - } - - let mut client = Client::new(HashMap::new(), None, vec![Arc::new(Passthrough)]); - client - .register_provider(Arc::new(MockProvider::new("test", "Hello!"))) - .await - .unwrap(); - client.catalog = Some(priced_mock_catalog()); - - let response = client.complete(&test_request()).await.unwrap(); - - assert_eq!(response.cost_source, Some(CostSource::Estimated)); - } - - #[tokio::test] - async fn stream_stamps_estimated_cost_on_finish() { - use futures::StreamExt; - - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "Hello!"))) - .await - .unwrap(); - client.catalog = Some(priced_mock_catalog()); - - let mut stream = client.stream(&test_request()).await.unwrap(); - let mut finish_response = None; - while let Some(event) = stream.next().await { - if let StreamEvent::Finish { response, .. } = event.unwrap() { - finish_response = Some(response); - } - } - - let response = finish_response.expect("stream should yield a Finish event"); - // MockProvider's Finish usage is zero tokens — priced, just $0. - assert_eq!(response.cost_source, Some(CostSource::Estimated)); - assert_eq!(response.cost_usd, Some(0.0)); - } - - #[tokio::test] - async fn count_input_tokens_returns_provider_result() { - let (client, calls) = - client_with_counting_provider(CountingProvider::new(Ok(Some(provider_count(42))))) - .await; - - let count = client - .count_input_tokens(&test_request(), InputTokenCountPreference::PreferProvider) - .await - .unwrap(); - - assert_eq!(count.input_tokens, 42); - assert_eq!(count.method, InputTokenCountMethod::ProviderApi); - assert_eq!(calls.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn count_input_tokens_prefer_provider_falls_back_for_unsupported_adapter() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", ""))) - .await - .unwrap(); - - let count = client - .count_input_tokens(&test_request(), InputTokenCountPreference::PreferProvider) - .await - .unwrap(); - - assert_eq!(count.method, InputTokenCountMethod::LocalEstimate); - assert!(warning_codes(&count).contains(&"provider_token_count_unsupported")); - } - - #[tokio::test] - async fn count_input_tokens_require_provider_errors_for_unsupported_adapter() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", ""))) - .await - .unwrap(); - - let error = client - .count_input_tokens(&test_request(), InputTokenCountPreference::RequireProvider) - .await - .unwrap_err(); - - assert!(matches!(error, Error::Configuration { .. })); - } - - #[tokio::test] - async fn count_input_tokens_prefer_provider_falls_back_for_eligible_errors() { - let errors = vec![ - Error::Network { - message: "network down".to_string(), - source: None, - }, - Error::RequestTimeout { - message: "timed out".to_string(), - source: None, - }, - provider_error(ProviderErrorKind::RateLimit), - provider_error(ProviderErrorKind::Server), - ]; - - for error in errors { - let (client, _) = - client_with_counting_provider(CountingProvider::new(Err(error))).await; - let count = client - .count_input_tokens(&test_request(), InputTokenCountPreference::PreferProvider) - .await - .unwrap(); - - assert_eq!(count.method, InputTokenCountMethod::LocalEstimate); - assert!(warning_codes(&count).contains(&"provider_token_count_failed")); - } - } - - #[tokio::test] - async fn count_input_tokens_prefer_provider_returns_non_fallback_errors() { - let errors = vec![ - provider_error(ProviderErrorKind::InvalidRequest), - provider_error(ProviderErrorKind::Authentication), - provider_error(ProviderErrorKind::AccessDenied), - provider_error(ProviderErrorKind::NotFound), - provider_error(ProviderErrorKind::ContextLength), - provider_error(ProviderErrorKind::ContentFilter), - provider_error(ProviderErrorKind::QuotaExceeded), - Error::Configuration { - message: "bad config".to_string(), - source: None, - }, - Error::UnsupportedToolChoice { - message: "bad tool choice".to_string(), - }, - ]; - - for error in errors { - let (client, _) = - client_with_counting_provider(CountingProvider::new(Err(error))).await; - let err = client - .count_input_tokens(&test_request(), InputTokenCountPreference::PreferProvider) - .await - .unwrap_err(); - - assert!(!token_count_fallback_eligible(&err)); - } - } - - #[tokio::test] - async fn count_input_tokens_require_provider_returns_fallback_eligible_errors() { - let (client, _) = client_with_counting_provider(CountingProvider::new(Err( - provider_error(ProviderErrorKind::RateLimit), - ))) - .await; - - let err = client - .count_input_tokens(&test_request(), InputTokenCountPreference::RequireProvider) - .await - .unwrap_err(); - - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::RateLimit, - .. - })); - } - - #[tokio::test] - async fn count_input_tokens_estimate_only_does_not_call_adapter() { - let provider = CountingProvider::new(Ok(Some(provider_count(99)))); - let calls = provider.count_calls(); - let (client, _) = client_with_counting_provider(provider).await; - - let count = client - .count_input_tokens(&test_request(), InputTokenCountPreference::EstimateOnly) - .await - .unwrap(); - - assert_eq!(count.method, InputTokenCountMethod::LocalEstimate); - assert_eq!(calls.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn count_input_tokens_validation_errors_still_return_err() { - let (client, calls) = client_with_counting_provider( - CountingProvider::new(Ok(Some(provider_count(1)))) - .with_name("restricted") - .rejecting_named(), - ) - .await; - let mut request = test_request(); - request.tool_choice = Some(ToolChoice::named("search")); - - let err = client - .count_input_tokens(&request, InputTokenCountPreference::PreferProvider) - .await - .unwrap_err(); - - assert!(matches!(err, Error::UnsupportedToolChoice { .. })); - assert_eq!(calls.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn complete_routes_to_named_provider() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("provider_a", "from A"))) - .await - .unwrap(); - client - .register_provider(Arc::new(MockProvider::new("provider_b", "from B"))) - .await - .unwrap(); - - let mut req = test_request(); - req.provider = Some("provider_b".into()); - let response = client.complete(&req).await.unwrap(); - assert_eq!(response.text(), "from B"); - } - - #[tokio::test] - async fn complete_errors_on_missing_provider() { - let client = Client::new(HashMap::new(), None, vec![]); - let result = client.complete(&test_request()).await; - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), Error::Configuration { .. })); - } - - #[tokio::test] - async fn complete_errors_on_unknown_provider() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "Hello"))) - .await - .unwrap(); - - let mut req = test_request(); - req.provider = Some("nonexistent".into()); - let result = client.complete(&req).await; - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), Error::Configuration { .. })); - } - - #[tokio::test] - async fn complete_rejects_unsupported_reasoning_effort_before_dispatch() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new( - "moonshot", - "should not dispatch", - ))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "kimi-k2.5".to_string(); - request.provider = Some("moonshot".to_string()); - request.reasoning_effort = Some(ReasoningEffort::High); - - let err = client.complete(&request).await.unwrap_err(); - - assert!(matches!( - err, - Error::InvalidRequest { - ref message, - } if message.contains("model 'kimi-k2.5' does not support reasoning_effort 'high'") - )); - } - - #[tokio::test] - async fn complete_accepts_supported_kimi_k3_reasoning_effort() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new("moonshot", "accepted"))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "kimi-k3".to_string(); - request.provider = Some("moonshot".to_string()); - request.reasoning_effort = Some(ReasoningEffort::High); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.text(), "accepted"); - } - - #[tokio::test] - async fn complete_rejects_unsupported_speed_before_dispatch() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new("openai", "should not dispatch"))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "gpt-5.4".to_string(); - request.provider = Some("openai".to_string()); - request.speed = Some(Speed::Fast); - - let err = client.complete(&request).await.unwrap_err(); - - assert!(matches!( - err, - Error::InvalidRequest { - ref message, - } if message.contains("model 'gpt-5.4' does not support speed 'fast'") - )); - } - - #[tokio::test] - async fn complete_accepts_standard_speed_without_catalog_declaration() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new("openai", "standard"))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "gpt-5.4".to_string(); - request.provider = Some("openai".to_string()); - request.speed = Some(Speed::Standard); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.text(), "standard"); - } - - #[tokio::test] - async fn complete_accepts_reasoning_effort_for_anthropic_budget_fallback_model() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new("anthropic", "accepted"))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "claude-sonnet-4-5".to_string(); - request.provider = Some("anthropic".to_string()); - request.reasoning_effort = Some(ReasoningEffort::Low); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.text(), "accepted"); - } - - #[tokio::test] - async fn complete_skips_control_validation_for_unknown_model_passthrough() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new("openai", "passthrough"))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "custom-model".to_string(); - request.provider = Some("openai".to_string()); - request.reasoning_effort = Some(ReasoningEffort::High); - request.speed = Some(Speed::Fast); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.text(), "passthrough"); - } - - #[tokio::test] - async fn stream_rejects_unsupported_speed_before_dispatch() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new("openai", "should not dispatch"))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "gpt-5.4".to_string(); - request.provider = Some("openai".to_string()); - request.speed = Some(Speed::Fast); - - let Err(err) = client.stream(&request).await else { - panic!("unsupported speed should fail before stream dispatch"); - }; - - assert!(matches!( - err, - Error::InvalidRequest { - ref message, - } if message.contains("model 'gpt-5.4' does not support speed 'fast'") - )); - } - - #[tokio::test] - async fn from_credentials_registers_multiple_providers() { - let catalog = catalog_with(""); - let client = Client::from_credentials( - vec![ - ApiCredential { - provider: ProviderId::anthropic(), - auth_header: Some(ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "anthropic-key".to_string(), - }), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }, - ApiCredential { - provider: ProviderId::openai(), - auth_header: Some(ApiKeyHeader::Bearer("openai-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }, - ], - catalog, - ) - .await - .unwrap(); - - let mut providers = client.provider_names(); - providers.sort_unstable(); - assert_eq!(providers, vec!["anthropic", "openai"]); - assert_eq!(client.default_provider(), Some("anthropic")); - } - - #[tokio::test] - async fn from_credentials_supports_builtin_openai_compatible_providers() { - let catalog = catalog_with(""); - let client = Client::from_credentials( - vec![ApiCredential { - provider: ProviderId::new("moonshot"), - auth_header: Some(ApiKeyHeader::Bearer("kimi-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }], - catalog, - ) - .await - .unwrap(); - - assert_eq!(client.provider_names(), vec!["moonshot"]); - assert_eq!(client.default_provider(), Some("moonshot")); - } - - #[tokio::test] - async fn from_credentials_rejects_custom_provider_id_without_adapter() { - let catalog = catalog_with(""); - let result = Client::from_credentials( - vec![ApiCredential { - provider: fabro_model::ProviderId::new("custom"), - auth_header: Some(ApiKeyHeader::Bearer("custom-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }], - catalog, - ) - .await; - let Err(err) = result else { - panic!("custom provider credentials should fail without a registered adapter"); - }; - - assert!(matches!( - err, - Error::Configuration { - ref message, - .. - } if message == "Provider \"custom\" is not supported by credential-only registration" - )); - } - - #[tokio::test] - async fn from_credentials_report_skips_provider_that_cannot_register() { - let catalog = catalog_with( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" - -[providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[models."acme-large"] -provider = "acme" -display_name = "Acme Large" -family = "acme" -default = true - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - ); - let report = Client::from_credentials_report( - vec![ - ApiCredential { - provider: ProviderId::new("acme"), - auth_header: Some(ApiKeyHeader::Bearer("acme-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }, - ApiCredential { - provider: ProviderId::openai(), - auth_header: Some(ApiKeyHeader::Bearer("openai-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }, - ], - Arc::clone(&catalog), - ) - .await; - - assert_eq!(report.client.provider_names(), vec!["openai"]); - assert_eq!(report.registration_issues.len(), 1); - assert_eq!( - report.registration_issues[0].provider, - ProviderId::new("acme") - ); + assert!(built.has_provider(&ProviderId::new("openai"))); assert!( - report.registration_issues[0] - .error - .to_string() - .contains("uses openai_compatible adapter but does not configure base_url") + !built.has_provider(&ProviderId::new("bedrock")), + "disabled providers never become ready" ); - } - - #[tokio::test] - async fn from_source_registers_provider_from_resolved_credentials() { - let source = StubSource { - credentials: vec![ApiCredential { - provider: ProviderId::anthropic(), - auth_header: Some(ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "anthropic-key".to_string(), - }), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }], - }; - let catalog = catalog_with(""); - - let client = Client::from_source(&source, catalog).await.unwrap(); - - assert_eq!(client.provider_names(), vec!["anthropic"]); - } - - #[tokio::test] - async fn from_credentials_registers_custom_openai_compatible_provider() { - let catalog = catalog_with( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" -aliases = ["acme-ai"] - -[providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[models."acme-large"] -provider = "acme" -display_name = "Acme Large" -family = "acme" -default = true - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - ); - - let client = Client::from_credentials( - vec![ApiCredential { - provider: fabro_model::ProviderId::new("acme"), - auth_header: Some(ApiKeyHeader::Bearer("acme-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }], - Arc::clone(&catalog), - ) - .await - .unwrap(); - - assert_eq!(client.provider_names(), vec!["acme"]); - assert!(client.has_provider("acme")); - assert!(client.has_provider("acme-ai")); - } - - #[tokio::test] - async fn resolve_provider_accepts_catalog_provider_alias() { - let catalog = catalog_with( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" -aliases = ["acme-ai"] - -[providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[models."acme-large"] -provider = "acme" -display_name = "Acme Large" -family = "acme" -default = true - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - ); - - let client = Client::from_credentials( - vec![ApiCredential { - provider: fabro_model::ProviderId::new("acme"), - auth_header: Some(ApiKeyHeader::Bearer("acme-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }], - Arc::clone(&catalog), - ) - .await - .unwrap(); - let mut request = test_request(); - request.provider = Some("acme-ai".to_string()); - - let provider = client - .resolve_request_with_adapter(&request) - .unwrap() - .provider; - - assert_eq!(provider.name(), "acme"); - } - - /// Build a Client with one registered mock per catalog provider, so - /// dispatch tests can observe which provider a request resolves to. - async fn client_with_all_catalog_providers(catalog: &Arc) -> Client { - let mut client = Client::new(HashMap::new(), None, vec![]); - for provider in catalog.providers() { - client - .register_provider(Arc::new(MockProvider::new(provider.id.as_str(), "ok"))) - .await - .unwrap(); - } - client.catalog = Some(Arc::clone(catalog)); - client - } - - /// For every built-in model selector, live dispatch and catalog selection - /// choose the same provider from the same ready-provider set. - #[tokio::test] - async fn dispatch_agrees_with_resolve_route_for_every_builtin_model() { - let catalog = catalog_with(""); - let client = client_with_all_catalog_providers(&catalog).await; - let ready_providers = catalog.all_provider_ids(); - - for model in catalog.list(None) { - let selected = catalog - .select(model.id.as_str(), None, &ready_providers) - .expect("built-in model should be selectable"); - let route = adapter_registry::resolve_route(&catalog, selected) - .expect("selected built-in model should resolve to a route"); - let mut request = test_request(); - request.model = model.id.to_string(); - - let provider = client - .resolve_request_with_adapter(&request) - .unwrap() - .provider; - - assert_eq!(provider.name(), route.provider.as_str(), "{}", model.id); - } - } - - #[tokio::test] - async fn explicit_provider_wins_over_the_model_route() { - let catalog = catalog_with(""); - let client = client_with_all_catalog_providers(&catalog).await; - - let mut request = test_request(); - request.model = "gpt-5.4-mini".to_string(); - request.provider = Some("anthropic".to_string()); - - let provider = client - .resolve_request_with_adapter(&request) - .unwrap() - .provider; - - assert_eq!(provider.name(), "anthropic"); - } - - #[tokio::test] - async fn unknown_model_falls_back_to_default_provider() { - let catalog = catalog_with(""); - let client = client_with_all_catalog_providers(&catalog).await; - let default = client.default_provider().unwrap().to_string(); - - let mut request = test_request(); - request.model = "model-not-in-any-catalog".to_string(); - - let provider = client - .resolve_request_with_adapter(&request) - .unwrap() - .provider; - - assert_eq!(provider.name(), default); - } - - #[tokio::test] - async fn from_credentials_registers_no_auth_provider_with_extra_headers() { - let catalog = catalog_with( - r#" -[providers.portkey] -display_name = "Portkey Bedrock" -adapter = "anthropic" -agent_profile = "anthropic" -base_url = "https://api.portkey.ai/v1" - -[providers.portkey.extra_headers] -x-portkey-api-key = "pk-live" - -[models."portkey-claude"] -provider = "portkey" -display_name = "Portkey Claude" -family = "claude" -default = true - -[models."portkey-claude".limits] -context_window = 200000 - -[models."portkey-claude".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -"#, - ); - - let client = Client::from_credentials( - vec![ApiCredential { - provider: fabro_model::ProviderId::new("portkey"), - auth_header: None, - extra_headers: HashMap::from([( - "x-portkey-api-key".to_string(), - "pk-live".to_string(), - )]), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }], - Arc::clone(&catalog), - ) - .await - .unwrap(); - - assert_eq!(client.provider_names(), vec!["portkey"]); - } - - #[tokio::test] - async fn from_source_supports_empty_credentials() { - let source = StubSource { - credentials: Vec::new(), - }; - let catalog = catalog_with(""); - - let client = Client::from_source(&source, catalog).await.unwrap(); - - assert!(client.provider_names().is_empty()); - } - - #[tokio::test] - async fn register_sets_first_as_default() { - let mut client = Client::new(HashMap::new(), None, vec![]); - assert_eq!(client.default_provider(), None); - - client - .register_provider(Arc::new(MockProvider::new("first", "1"))) - .await - .unwrap(); - assert_eq!(client.default_provider(), Some("first")); - - client - .register_provider(Arc::new(MockProvider::new("second", "2"))) - .await - .unwrap(); - assert_eq!(client.default_provider(), Some("first")); - } - - #[tokio::test] - async fn stream_routes_to_provider() { - use futures::StreamExt; - - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "streamed"))) - .await - .unwrap(); - - let mut stream = client.stream(&test_request()).await.unwrap(); - let first = stream.next().await.unwrap().unwrap(); - match &first { - StreamEvent::TextDelta { delta, .. } => assert_eq!(delta, "streamed"), - other => panic!("Expected TextDelta, got {other:?}"), - } - } - - #[tokio::test] - async fn provider_names_returns_registered() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("alpha", ""))) - .await - .unwrap(); - client - .register_provider(Arc::new(MockProvider::new("beta", ""))) - .await - .unwrap(); - let mut names = client.provider_names(); - names.sort_unstable(); - assert_eq!(names, vec!["alpha", "beta"]); - } - - /// Test middleware gets called - struct UppercaseMiddleware; - - #[async_trait::async_trait] - impl Middleware for UppercaseMiddleware { - async fn handle_complete(&self, request: Request, next: NextFn) -> Result { - let mut response = next(request).await?; - let text = response.text().to_uppercase(); - response.message = Message::assistant(text); - Ok(response) - } - - async fn handle_stream( - &self, - request: Request, - next: NextStreamFn, - ) -> Result { - next(request).await - } - } - - #[tokio::test] - async fn middleware_wraps_complete() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "hello"))) - .await - .unwrap(); - client.add_middleware(Arc::new(UppercaseMiddleware)); - - let response = client.complete(&test_request()).await.unwrap(); - assert_eq!(response.text(), "HELLO"); + assert!(!built.has_provider(&ProviderId::new("anthropic"))); + assert!(built.auth_issues.is_empty()); + assert!(built.build_issues.is_empty(), "{:?}", built.build_issues); } } diff --git a/lib/components/fabro-llm/src/codec/anthropic_messages/decode.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/decode.rs deleted file mode 100644 index dd8f4a0f2..000000000 --- a/lib/components/fabro-llm/src/codec/anthropic_messages/decode.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! Response decoding: Anthropic Messages body → canonical `Response`. - -use serde::Deserialize; - -use super::SYNTHETIC_TOOL_NAME; -use super::wire::{ApiResponse, ApiUsage, CountTokensResponse}; -use crate::codec::CodecCtx; -use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind}; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Request, Response, ResponseFormatType, Role, - ThinkingData, TokenCounts, ToolCall, -}; - -pub(super) fn token_counts_from_api_usage(usage: &ApiUsage) -> TokenCounts { - // Anthropic does not expose a separate billed thinking/reasoning token - // count. Thinking tokens are billed as part of `output_tokens`. When - // Anthropic adds a real thinking token field, wire it through and subtract - // it here. - TokenCounts { - input_tokens: usage.input_tokens, - output_tokens: usage.output_tokens, - reasoning_tokens: 0, - cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0), - cache_write_tokens: usage.cache_creation_input_tokens.unwrap_or(0), - } -} - -pub(super) fn map_finish_reason(stop_reason: Option<&str>) -> FinishReason { - match stop_reason { - Some("end_turn" | "stop_sequence") | None => FinishReason::Stop, - Some("max_tokens") => FinishReason::Length, - Some("tool_use") => FinishReason::ToolCalls, - Some(other) => FinishReason::Other(other.to_string()), - } -} - -pub(super) fn parse_content_block(block: &serde_json::Value) -> Option { - match block.get("type")?.as_str()? { - "text" => Some(ContentPart::text(block.get("text")?.as_str()?)), - "tool_use" => Some(ContentPart::ToolCall(ToolCall::new( - block.get("id")?.as_str()?, - block.get("name")?.as_str()?, - block.get("input")?.clone(), - ))), - "thinking" => Some(ContentPart::Thinking(ThinkingData { - text: block.get("thinking")?.as_str()?.to_string(), - signature: block - .get("signature") - .and_then(serde_json::Value::as_str) - .map(String::from), - redacted: false, - })), - "redacted_thinking" => Some(ContentPart::Thinking(ThinkingData { - text: block - .get("data") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(), - signature: None, - redacted: true, - })), - _ => None, - } -} - -/// Convert synthetic `tool_use` content blocks back to text content parts. -/// -/// When `response_format` uses `JsonSchema` mode, the model responds with a -/// `tool_use` block for our synthetic tool. We extract its arguments as a JSON -/// text string. -pub(super) fn convert_synthetic_tool_to_text(content_parts: Vec) -> Vec { - content_parts - .into_iter() - .map(|part| match &part { - ContentPart::ToolCall(tc) if tc.name == SYNTHETIC_TOOL_NAME => { - ContentPart::text(tc.arguments.to_string()) - } - _ => part, - }) - .collect() -} - -/// Check if the request uses `JsonSchema` `response_format`. -pub(super) fn uses_json_schema_format(request: &Request) -> bool { - request - .response_format - .as_ref() - .is_some_and(|f| matches!(f.kind, ResponseFormatType::JsonSchema)) -} - -/// Map a refusal stop reason (Claude Fable 5) to a content-filter provider -/// error. Shared by the response decoder and the stream decoder; the -/// `error_code = "refusal"` marker is what makes it failover-eligible. -pub(super) fn refusal_error( - provider_name: &str, - model: &str, - raw: serde_json::Value, - stop_details: Option<&serde_json::Value>, -) -> Error { - let model_label = if model.is_empty() { "The model" } else { model }; - let message = stop_details - .and_then(|details| details.get("explanation")) - .and_then(serde_json::Value::as_str) - .map_or_else( - || format!("{model_label} refused the request"), - |explanation| format!("{model_label} refused the request: {explanation}"), - ); - - Error::Provider { - kind: ProviderErrorKind::ContentFilter, - detail: Box::new(ProviderErrorDetail { - message, - provider: provider_name.to_string(), - status_code: None, - error_code: Some("refusal".to_string()), - retry_after: None, - raw: Some(raw), - }), - } -} - -pub(super) fn decode_response( - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, -) -> Result { - let raw: serde_json::Value = serde_json::from_str(body).map_err(|e| { - Error::network( - format!("failed to parse {} response: {e}", ctx.provider_name), - e, - ) - })?; - let api_resp = ApiResponse::deserialize(&raw).map_err(|e| { - Error::network( - format!("failed to parse {} response: {e}", ctx.provider_name), - e, - ) - })?; - - if api_resp.stop_reason.as_deref() == Some("refusal") { - return Err(refusal_error( - ctx.provider_name, - &api_resp.model, - raw, - api_resp.stop_details.as_ref(), - )); - } - - let content_parts: Vec = api_resp - .content - .iter() - .filter_map(parse_content_block) - .collect(); - - // If we used JsonSchema mode, convert the synthetic tool call back to text. - let json_schema_mode = uses_json_schema_format(ctx.request); - let content_parts = if json_schema_mode { - convert_synthetic_tool_to_text(content_parts) - } else { - content_parts - }; - - let finish_reason = if json_schema_mode { - // The model was forced to call a tool, so stop_reason is "tool_use", - // but from the caller's perspective, the request completed normally. - FinishReason::Stop - } else { - map_finish_reason(api_resp.stop_reason.as_deref()) - }; - - Ok(Response { - id: api_resp.id, - model: api_resp.model, - provider: ctx.provider_name.to_string(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason, - usage: token_counts_from_api_usage(&api_resp.usage), - raw: Some(raw), - warnings: vec![], - rate_limit, - cost_usd: None, - cost_source: None, - }) -} - -pub(super) fn decode_count_tokens(body: &str) -> Result { - let response: CountTokensResponse = - serde_json::from_str(body).map_err(|e| Error::Configuration { - message: format!("failed to parse token count response: {e}"), - source: None, - })?; - Ok(response.input_tokens) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn api_token_counts_leaves_reasoning_zero_and_output_full() { - let body = serde_json::json!({ - "id": "msg_test", - "model": "claude-sonnet-4-5", - "content": [ - { "type": "thinking", "thinking": "summary text", "signature": "" }, - { "type": "text", "text": "answer" } - ], - "stop_reason": "end_turn", - "usage": { - "input_tokens": 50, - "output_tokens": 1200, - "cache_read_input_tokens": 9000, - "cache_creation_input_tokens": 1000 - } - }); - let api: ApiResponse = serde_json::from_value(body).unwrap(); - let usage = token_counts_from_api_usage(&api.usage); - - assert_eq!(usage.input_tokens, 50); - assert_eq!(usage.cache_read_tokens, 9000); - assert_eq!(usage.cache_write_tokens, 1000); - assert_eq!(usage.output_tokens, 1200); - assert_eq!(usage.reasoning_tokens, 0); - assert_eq!(usage.total_tokens(), 11_250); - } - - #[test] - fn convert_synthetic_tool_to_text_replaces_synthetic_tool() { - let parts = vec![ContentPart::ToolCall(ToolCall::new( - "id1", - SYNTHETIC_TOOL_NAME, - serde_json::json!({"name": "Alice"}), - ))]; - let result = convert_synthetic_tool_to_text(parts); - assert_eq!(result.len(), 1); - match &result[0] { - ContentPart::Text(text) => { - assert!(text.contains("Alice")); - } - other => panic!("expected Text, got {other:?}"), - } - } - - #[test] - fn convert_synthetic_tool_to_text_preserves_other_tool_calls() { - let parts = vec![ContentPart::ToolCall(ToolCall::new( - "id1", - "real_tool", - serde_json::json!({"key": "value"}), - ))]; - let result = convert_synthetic_tool_to_text(parts); - assert_eq!(result.len(), 1); - match &result[0] { - ContentPart::ToolCall(tc) => { - assert_eq!(tc.name, "real_tool"); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } -} diff --git a/lib/components/fabro-llm/src/codec/anthropic_messages/encode.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/encode.rs deleted file mode 100644 index 5ed999f87..000000000 --- a/lib/components/fabro-llm/src/codec/anthropic_messages/encode.rs +++ /dev/null @@ -1,1594 +0,0 @@ -//! Request encoding: canonical request → Anthropic Messages body + headers. -//! -//! Pure and sync. File-backed attachments are resolved to inline data by -//! `attachments::resolve` in the adapter *before* encode runs, so the content -//! translation here never touches the filesystem. - -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; - -use super::SYNTHETIC_TOOL_NAME; -use super::wire::{ApiMessage, ApiRequest, ApiToolDef, CountTokensRequest}; -use crate::codec::cache::{self, CacheControl}; -use crate::codec::{AnthropicVersion, CodecCtx, EncodedRequest, extract_system_prompt}; -use crate::types::{ - ContentPart, Message, ReasoningEffort, ReasoningEffortFeature, Request, ResponseFormatType, - Role, Speed, ThinkingData, ToolChoice, ToolDefinition, -}; - -const CACHE_BETA_HEADER: &str = "prompt-caching-2024-07-31"; -const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; - -/// Known `provider_options.anthropic` keys handled directly by the codec; not -/// re-merged into the body. -const KNOWN_ANTHROPIC_OPTION_KEYS: &[&str] = &["thinking", "auto_cache", "beta_headers"]; - -// --- Public entry points ----------------------------------------------------- - -pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> EncodedRequest { - let request = build_request(ctx, stream); - let body = merge_provider_options(&request, ctx.request.provider_options.as_ref()); - EncodedRequest { - body, - endpoint: "/messages".to_string(), - headers: build_headers(ctx), - } -} - -pub(super) fn encode_count_tokens(ctx: &CodecCtx<'_>) -> EncodedRequest { - let count_request = CountTokensRequest::from(build_request(ctx, false)); - let body = serde_json::to_value(&count_request).unwrap_or_else(|_| serde_json::json!({})); - EncodedRequest { - body, - endpoint: "/messages/count_tokens".to_string(), - headers: build_headers(ctx), - } -} - -/// Whether auto prompt-caching applies: the model supports it and the request -/// hasn't opted out. -fn auto_cache(ctx: &CodecCtx<'_>) -> bool { - ctx.model.is_some_and(|m| m.features.prompt_cache) - && cache::auto_cache_enabled(ctx.request.provider_options.as_ref(), "anthropic") -} - -fn build_headers(ctx: &CodecCtx<'_>) -> Vec<(String, String)> { - let mut headers = Vec::new(); - if let AnthropicVersion::Header(version) = ctx.params.anthropic_version { - headers.push(("anthropic-version".to_string(), version.to_string())); - } - if ctx.params.anthropic_beta { - if let Some(beta) = build_beta_header( - ctx.request.provider_options.as_ref(), - auto_cache(ctx), - ctx.request.speed == Some(Speed::Fast), - ) { - headers.push(("anthropic-beta".to_string(), beta)); - } - } - headers -} - -fn build_request(ctx: &CodecCtx<'_>, stream: bool) -> ApiRequest { - let request = ctx.request; - let (system, other_messages) = extract_system_prompt(&request.messages); - let mut api_messages = translate_messages(&other_messages); - - // `ToolChoice::None` omits the tools entirely instead of sending a choice. - let omit_tools = matches!(request.tool_choice, Some(ToolChoice::None)); - let mut tool_choice_json = if omit_tools { - None - } else { - request.tool_choice.as_ref().and_then(translate_tool_choice) - }; - - let mut api_tools = if omit_tools { - None - } else { - request.tools.as_ref().map(|t| translate_tools(t)) - }; - - let model_info = ctx.model; - let auto_cache = auto_cache(ctx); - - let mut system_value = system.and_then(|s| { - if s.trim().is_empty() { - None - } else if auto_cache { - Some(system_with_cache_control(&s)) - } else { - Some(serde_json::Value::String(s)) - } - }); - - // Apply response_format (may inject synthetic tool or system prompt suffix). - apply_response_format( - request, - &mut api_tools, - &mut tool_choice_json, - &mut system_value, - ); - - if auto_cache { - if let Some(ref mut tools) = api_tools { - apply_cache_control_to_last_tool(tools); - } - apply_cache_control_to_conversation_prefix(&mut api_messages); - } - - let explicit_thinking = extract_thinking_config(request.provider_options.as_ref()); - - // Older reasoning models (e.g. claude-sonnet-4-5) need `thinking` with - // `budget_tokens` instead of `output_config.effort`. - let supports_effort = model_info.is_none_or(fabro_model::Model::supports_reasoning_effort); - - let mut resolved_max_tokens = request - .max_tokens - .or_else(|| model_info.and_then(|m| m.limits.max_output)) - .unwrap_or(65536); - - // Default thinking when none is configured explicitly: adaptive for - // `levels` models, with or without an effort level — effort is guidance - // for thinking allocation, not a replacement for it. Natively adaptive - // models don't need one injected (and reject a manual on/off toggle). - let default_thinking = || { - if model_info.is_some_and(|m| m.features.reasoning_effort == ReasoningEffortFeature::Levels) - { - Some(serde_json::json!({"type": "adaptive"})) - } else { - None - } - }; - - let (mut thinking, mut output_config) = if let Some(effort) = &request.reasoning_effort { - if supports_effort { - ( - explicit_thinking.or_else(default_thinking), - Some(serde_json::json!({"effort": <&'static str>::from(*effort)})), - ) - } else if explicit_thinking.is_none() { - let budget = effort_to_budget_tokens(*effort, resolved_max_tokens); - if resolved_max_tokens <= budget { - resolved_max_tokens = budget + 1024; - } - ( - Some(serde_json::json!({"type": "enabled", "budget_tokens": budget})), - None, - ) - } else { - (explicit_thinking, None) - } - } else { - (explicit_thinking.or_else(default_thinking), None) - }; - - if tool_choice_forces_tool_use(tool_choice_json.as_ref()) { - thinking = None; - output_config = None; - } - - // Models with `sampling_params = false` reject classic sampling knobs. - // This gate covers only the typed request fields; values injected through - // `provider_options.anthropic` (e.g. `top_k`) are a raw escape hatch and - // pass through unfiltered. - let (temperature, top_p) = - if model_info.is_none_or(fabro_model::Model::supports_sampling_params) { - (request.temperature, request.top_p) - } else { - (None, None) - }; - - ApiRequest { - model: ctx.deployment_id.to_string(), - messages: api_messages, - max_tokens: resolved_max_tokens, - system: system_value, - temperature, - top_p, - stop_sequences: request.stop_sequences.clone().unwrap_or_default(), - tools: api_tools, - tool_choice: tool_choice_json, - thinking, - output_config, - speed: request - .speed - .filter(|speed| *speed != Speed::Standard) - .map(<&'static str>::from) - .map(str::to_string), - metadata: request.metadata.clone(), - stream, - } -} - -// --- Content / message / tool translation ------------------------------------ - -/// Translate a unified `ContentPart` to an Anthropic content block. Sync: -/// file-backed attachments are already resolved to inline data upstream. -fn content_part_to_api(part: &ContentPart) -> Option { - match part { - ContentPart::Text(text) => Some(serde_json::json!({"type": "text", "text": text})), - ContentPart::ToolCall(tc) => Some(serde_json::json!({ - "type": "tool_use", - "id": tc.id, - "name": tc.name, - "input": tc.arguments, - })), - ContentPart::ToolResult(tr) => { - let content = tr - .content - .as_str() - .map_or_else(|| tr.content.to_string(), str::to_string); - Some(serde_json::json!({ - "type": "tool_result", - "tool_use_id": tr.tool_call_id, - "content": content, - "is_error": tr.is_error, - })) - } - ContentPart::Thinking(td) if td.redacted => Some(serde_json::json!({ - "type": "redacted_thinking", - "data": td.text, - })), - ContentPart::Thinking(ThinkingData { - text, signature, .. - }) => { - let mut block = serde_json::json!({ "type": "thinking", "thinking": text }); - if let Some(sig) = signature { - block["signature"] = serde_json::Value::String(sig.clone()); - } - Some(block) - } - ContentPart::Image(img) => media_block( - "image", - img.url.as_deref(), - img.data.as_deref(), - img.media_type.as_deref().unwrap_or("image/png"), - ), - ContentPart::Document(doc) => media_block( - "document", - doc.url.as_deref(), - doc.data.as_deref(), - doc.media_type.as_deref().unwrap_or("application/pdf"), - ), - ContentPart::Audio(_) => Some( - serde_json::json!({"type": "text", "text": "[Audio content not supported by this provider]"}), - ), - ContentPart::Other { .. } => None, - } -} - -/// An `image`/`document` content block: URL source when present, otherwise -/// base64-encoded inline data. -fn media_block( - kind: &str, - url: Option<&str>, - data: Option<&[u8]>, - mime: &str, -) -> Option { - if let Some(url) = url { - Some(serde_json::json!({"type": kind, "source": {"type": "url", "url": url}})) - } else { - data.map(|data| { - let b64 = BASE64_STANDARD.encode(data); - serde_json::json!({"type": kind, "source": {"type": "base64", "media_type": mime, "data": b64}}) - }) - } -} - -/// Convert unified messages to Anthropic API messages (role mapping, strict -/// alternation, tool results folded into user turns). -fn translate_messages(messages: &[&Message]) -> Vec { - let mut api_messages: Vec = Vec::new(); - - for msg in messages { - let role = match msg.role { - Role::Assistant => "assistant", - Role::User | Role::Tool => "user", - Role::System | Role::Developer => continue, - }; - - let mut content = Vec::new(); - for part in &msg.content { - if let Some(block) = content_part_to_api(part) { - content.push(block); - } - } - - if content.is_empty() { - continue; - } - - if let Some(last) = api_messages.last_mut() { - if last.role == role { - last.content.extend(content); - continue; - } - } - - api_messages.push(ApiMessage { - role: role.to_string(), - content, - }); - } - - api_messages -} - -fn translate_tools(tools: &[ToolDefinition]) -> Vec { - tools - .iter() - .map(|t| ApiToolDef { - name: t.name.clone(), - description: t.description.clone(), - input_schema: t.parameters.clone(), - cache_control: None, - }) - .collect() -} - -fn translate_tool_choice(choice: &ToolChoice) -> Option { - match choice { - ToolChoice::Auto => Some(serde_json::json!({"type": "auto"})), - // Anthropic does not support tool_choice none with tools present; the - // caller omits tools instead. - ToolChoice::None => None, - ToolChoice::Required => Some(serde_json::json!({"type": "any"})), - ToolChoice::Named { tool_name } => { - Some(serde_json::json!({"type": "tool", "name": tool_name})) - } - } -} - -fn tool_choice_forces_tool_use(tool_choice: Option<&serde_json::Value>) -> bool { - matches!( - tool_choice - .and_then(|value| value.get("type")) - .and_then(serde_json::Value::as_str), - Some("any" | "tool") - ) -} - -// --- Structured output (response_format) ------------------------------------- - -fn apply_response_format( - request: &Request, - api_tools: &mut Option>, - tool_choice: &mut Option, - system: &mut Option, -) { - let Some(format) = &request.response_format else { - return; - }; - - match format.kind { - ResponseFormatType::JsonSchema => { - let schema = format - .json_schema - .clone() - .unwrap_or_else(|| serde_json::json!({"type": "object"})); - let synthetic_tool = ApiToolDef { - name: SYNTHETIC_TOOL_NAME.to_string(), - description: "Output the requested structured data".to_string(), - input_schema: schema, - cache_control: None, - }; - match api_tools { - Some(tools) => tools.push(synthetic_tool), - None => *api_tools = Some(vec![synthetic_tool]), - } - *tool_choice = Some(serde_json::json!({"type": "tool", "name": SYNTHETIC_TOOL_NAME})); - } - ResponseFormatType::JsonObject => { - let json_instruction = "\n\nYou must respond with valid JSON only, no other text."; - match system { - Some(serde_json::Value::Array(blocks)) => { - if let Some(last) = blocks.last_mut() { - if let Some(text) = last.get("text").and_then(serde_json::Value::as_str) { - let mut new_text = text.to_string(); - new_text.push_str(json_instruction); - last["text"] = serde_json::Value::String(new_text); - } - } else { - blocks.push( - serde_json::json!({"type": "text", "text": json_instruction.trim()}), - ); - } - } - Some(serde_json::Value::String(s)) => { - s.push_str(json_instruction); - } - None => { - *system = Some(serde_json::Value::String( - json_instruction.trim().to_string(), - )); - } - _ => {} - } - } - ResponseFormatType::Text => {} - } -} - -// --- Prompt caching / thinking / beta headers -------------------------------- - -/// The `provider_options.anthropic` namespace object, if any. -fn anthropic_options(provider_options: Option<&serde_json::Value>) -> Option<&serde_json::Value> { - provider_options.and_then(|opts| opts.get("anthropic")) -} - -/// A single `provider_options.anthropic.` value, if any. `pub(crate)` so -/// the adapter's `validate_request` reads the same namespace the same way. -pub(crate) fn anthropic_option<'a>( - provider_options: Option<&'a serde_json::Value>, - key: &str, -) -> Option<&'a serde_json::Value> { - anthropic_options(provider_options).and_then(|anthropic| anthropic.get(key)) -} - -fn extract_thinking_config( - provider_options: Option<&serde_json::Value>, -) -> Option { - anthropic_option(provider_options, "thinking").cloned() -} - -fn effort_to_budget_tokens(effort: ReasoningEffort, max_tokens: i64) -> i64 { - let budget = match effort { - ReasoningEffort::Low => max_tokens / 4, - ReasoningEffort::Medium => max_tokens / 2, - ReasoningEffort::High => max_tokens * 3 / 4, - ReasoningEffort::XHigh => max_tokens * 7 / 8, - ReasoningEffort::Max => max_tokens, - }; - budget.max(1024) -} - -fn system_with_cache_control(system: &str) -> serde_json::Value { - serde_json::json!([{ - "type": "text", - "text": system, - "cache_control": {"type": "ephemeral"} - }]) -} - -fn apply_cache_control_to_last_tool(tools: &mut [ApiToolDef]) { - if let Some(last) = tools.last_mut() { - last.cache_control = Some(CacheControl::ephemeral()); - } -} - -fn apply_cache_control_to_conversation_prefix(messages: &mut [ApiMessage]) { - let user_turns: Vec = messages.iter().map(|m| m.role == "user").collect(); - let Some(target_idx) = cache::conversation_breakpoint_index(&user_turns) else { - return; - }; - if let Some(serde_json::Value::Object(map)) = messages[target_idx].content.last_mut() { - map.insert( - "cache_control".to_string(), - serde_json::json!({"type": "ephemeral"}), - ); - } -} - -fn build_beta_header( - provider_options: Option<&serde_json::Value>, - include_cache_header: bool, - include_fast_mode_header: bool, -) -> Option { - let mut headers: Vec = Vec::new(); - - if let Some(beta_array) = - anthropic_option(provider_options, "beta_headers").and_then(serde_json::Value::as_array) - { - headers.extend( - beta_array - .iter() - .filter_map(serde_json::Value::as_str) - .map(String::from), - ); - } - - if include_cache_header && !headers.iter().any(|h| h == CACHE_BETA_HEADER) { - headers.push(CACHE_BETA_HEADER.to_string()); - } - - if include_fast_mode_header && !headers.iter().any(|h| h == FAST_MODE_BETA_HEADER) { - headers.push(FAST_MODE_BETA_HEADER.to_string()); - } - - if headers.is_empty() { - None - } else { - Some(headers.join(",")) - } -} - -/// Serialize the API request and merge any unknown `provider_options.anthropic` -/// keys into the body. -fn merge_provider_options( - api_request: &ApiRequest, - provider_options: Option<&serde_json::Value>, -) -> serde_json::Value { - let mut body = serde_json::to_value(api_request).unwrap_or_else(|_| serde_json::json!({})); - - if let Some(anthropic_opts) = anthropic_options(provider_options) { - if let (Some(base), Some(overrides)) = (body.as_object_mut(), anthropic_opts.as_object()) { - for (key, value) in overrides { - if !KNOWN_ANTHROPIC_OPTION_KEYS.contains(&key.as_str()) { - base.insert(key.clone(), value.clone()); - } - } - } - } - - body -} - -#[cfg(test)] -mod tests { - use fabro_model::Catalog; - use fabro_model::catalog::LlmCatalogSettings; - - use super::*; - use crate::codec::CodecParams; - use crate::providers::common; - use crate::types::{AudioData, DocumentData, ResponseFormat}; - - // --- Test helpers -------------------------------------------------------- - - fn make_base_request() -> Request { - Request { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![Message::user("Hello")], - provider: Some("anthropic".to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: Some(128), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - fn make_request_with_format(format: ResponseFormat) -> Request { - Request { - provider: None, - response_format: Some(format), - max_tokens: None, - ..make_base_request() - } - } - - fn catalog_with_anthropic_model(features: &str) -> Catalog { - let settings: LlmCatalogSettings = toml::from_str(&format!( - r#" -[providers.anthropic] -display_name = "Anthropic" -adapter = "anthropic" -agent_profile = "anthropic" - -[models."test-claude"] -provider = "anthropic" -display_name = "Test Claude" -family = "claude" -default = true - -[models."test-claude".limits] -context_window = 200000 -max_output = 4096 - -[models."test-claude".features] -tools = true -vision = true -reasoning = true -{features} -"# - )) - .unwrap(); - Catalog::from_settings(&settings).unwrap() - } - - /// Direct-Anthropic route params (version header + beta headers enabled), - /// matching what the adapter's `route_config()` resolves for "anthropic". - fn direct_params() -> CodecParams { - CodecParams { - anthropic_version: AnthropicVersion::Header("2023-06-01"), - anthropic_beta: true, - ..CodecParams::default() - } - } - - /// Encode `request` on the direct-Anthropic route, optionally with a - /// catalog (for capability-driven behavior like prompt-cache/effort). - fn encode_direct(request: &Request, catalog: Option<&Catalog>, stream: bool) -> EncodedRequest { - let deployment_id = common::api_model_id(catalog, "anthropic", &request.model); - let params = direct_params(); - let ctx = CodecCtx { - request, - provider_name: "anthropic", - deployment_id: &deployment_id, - model: common::catalog_model(catalog, "anthropic", &request.model), - params: ¶ms, - }; - encode(&ctx, stream) - } - - fn encode_count_direct(request: &Request, catalog: Option<&Catalog>) -> EncodedRequest { - let deployment_id = common::api_model_id(catalog, "anthropic", &request.model); - let params = direct_params(); - let ctx = CodecCtx { - request, - provider_name: "anthropic", - deployment_id: &deployment_id, - model: common::catalog_model(catalog, "anthropic", &request.model), - params: ¶ms, - }; - encode_count_tokens(&ctx) - } - - fn header_value<'a>(encoded: &'a EncodedRequest, name: &str) -> Option<&'a str> { - encoded - .headers - .iter() - .find(|(key, _)| key == name) - .map(|(_, value)| value.as_str()) - } - - // --- prompt-cache helpers ------------------------------------------------ - - #[test] - fn system_prompt_cache_control_wraps_as_array() { - let result = system_with_cache_control("You are helpful."); - let arr = result.as_array().expect("should be an array"); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["type"], "text"); - assert_eq!(arr[0]["text"], "You are helpful."); - assert_eq!(arr[0]["cache_control"]["type"], "ephemeral"); - } - - #[test] - fn tool_cache_control_applied_to_last_tool() { - let mut tools = vec![ - ApiToolDef { - name: "tool_a".to_string(), - description: "first".to_string(), - input_schema: serde_json::json!({}), - cache_control: None, - }, - ApiToolDef { - name: "tool_b".to_string(), - description: "second".to_string(), - input_schema: serde_json::json!({}), - cache_control: None, - }, - ]; - apply_cache_control_to_last_tool(&mut tools); - - assert!(tools[0].cache_control.is_none()); - assert!(tools[1].cache_control.is_some()); - assert_eq!(tools[1].cache_control.as_ref().unwrap().kind, "ephemeral"); - } - - #[test] - fn tool_cache_control_empty_slice() { - let mut tools: Vec = vec![]; - apply_cache_control_to_last_tool(&mut tools); - assert!(tools.is_empty()); - } - - #[test] - fn tool_cache_control_single_tool() { - let mut tools = vec![ApiToolDef { - name: "only_tool".to_string(), - description: "the one".to_string(), - input_schema: serde_json::json!({}), - cache_control: None, - }]; - apply_cache_control_to_last_tool(&mut tools); - assert!(tools[0].cache_control.is_some()); - } - - #[test] - fn conversation_prefix_cache_control_with_two_user_messages() { - let mut messages = vec![ - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }, - ApiMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hi there"})], - }, - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "How are you?"})], - }, - ]; - - apply_cache_control_to_conversation_prefix(&mut messages); - - // First user message should have cache_control - assert_eq!(messages[0].content[0]["cache_control"]["type"], "ephemeral"); - // Last user message should NOT have cache_control - assert!(messages[2].content[0].get("cache_control").is_none()); - // Assistant message should NOT have cache_control - assert!(messages[1].content[0].get("cache_control").is_none()); - } - - #[test] - fn conversation_prefix_cache_control_with_multiple_content_blocks() { - let mut messages = vec![ - ApiMessage { - role: "user".to_string(), - content: vec![ - serde_json::json!({"type": "text", "text": "Part 1"}), - serde_json::json!({"type": "text", "text": "Part 2"}), - ], - }, - ApiMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Reply"})], - }, - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Follow up"})], - }, - ]; - - apply_cache_control_to_conversation_prefix(&mut messages); - - // Only the LAST content block of the first user message should have - // cache_control - assert!(messages[0].content[0].get("cache_control").is_none()); - assert_eq!(messages[0].content[1]["cache_control"]["type"], "ephemeral"); - } - - #[test] - fn conversation_prefix_cache_control_single_user_message() { - let mut messages = vec![ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }]; - - apply_cache_control_to_conversation_prefix(&mut messages); - - // With only one user message, no cache_control should be added - assert!(messages[0].content[0].get("cache_control").is_none()); - } - - #[test] - fn conversation_prefix_cache_control_no_user_messages() { - let mut messages: Vec = vec![]; - // Should not panic on empty messages - apply_cache_control_to_conversation_prefix(&mut messages); - } - - #[test] - fn conversation_prefix_cache_control_three_user_messages() { - let mut messages = vec![ - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "First"})], - }, - ApiMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Reply 1"})], - }, - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Second"})], - }, - ApiMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Reply 2"})], - }, - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Third"})], - }, - ]; - - apply_cache_control_to_conversation_prefix(&mut messages); - - // Only the second-to-last user message (index 2) should get cache_control - assert!(messages[0].content[0].get("cache_control").is_none()); - assert_eq!(messages[2].content[0]["cache_control"]["type"], "ephemeral"); - assert!(messages[4].content[0].get("cache_control").is_none()); - } - - // --- beta headers -------------------------------------------------------- - - #[test] - fn beta_header_includes_cache_header() { - let result = build_beta_header(None, true, false); - assert_eq!(result, Some(CACHE_BETA_HEADER.to_string())); - } - - #[test] - fn beta_header_no_cache_no_user_headers() { - let result = build_beta_header(None, false, false); - assert_eq!(result, None); - } - - #[test] - fn beta_header_merges_user_headers_with_cache() { - let opts = serde_json::json!({ - "anthropic": { - "beta_headers": ["interleaved-thinking-2025-05-14"] - } - }); - let result = build_beta_header(Some(&opts), true, false); - assert_eq!( - result, - Some(format!( - "interleaved-thinking-2025-05-14,{CACHE_BETA_HEADER}" - )) - ); - } - - #[test] - fn beta_header_no_duplicate_cache_header() { - let opts = serde_json::json!({ - "anthropic": { - "beta_headers": [CACHE_BETA_HEADER] - } - }); - let result = build_beta_header(Some(&opts), true, false); - // Should not duplicate the header - assert_eq!(result, Some(CACHE_BETA_HEADER.to_string())); - } - - #[test] - fn beta_header_user_headers_only_when_cache_disabled() { - let opts = serde_json::json!({ - "anthropic": { - "beta_headers": ["interleaved-thinking-2025-05-14"] - } - }); - let result = build_beta_header(Some(&opts), false, false); - assert_eq!(result, Some("interleaved-thinking-2025-05-14".to_string())); - } - - /// Regression test: deprecated beta header values must not be sent. - /// The Anthropic API rejects requests containing these old headers. - #[test] - fn beta_header_rejects_deprecated_values() { - let deprecated = [ - "extended-thinking-2025-04-14", - "max-tokens-3-5-sonnet-2025-04-14", - ]; - - // No user headers — only cache header should appear - let header = build_beta_header(None, true, false).unwrap_or_default(); - for dep in &deprecated { - assert!( - !header.contains(dep), - "default header must not contain deprecated value {dep}" - ); - } - - // With a valid user header - let opts = serde_json::json!({ - "anthropic": { - "beta_headers": ["interleaved-thinking-2025-05-14"] - } - }); - let header = build_beta_header(Some(&opts), true, false).unwrap_or_default(); - for dep in &deprecated { - assert!( - !header.contains(dep), - "header with user values must not contain deprecated value {dep}" - ); - } - } - - #[test] - fn beta_header_includes_both_cache_and_fast_mode() { - let result = build_beta_header(None, true, true); - let header = result.expect("should produce a header"); - assert!( - header.contains(CACHE_BETA_HEADER), - "should contain cache header" - ); - assert!( - header.contains(FAST_MODE_BETA_HEADER), - "should contain fast-mode header" - ); - } - - // --- effort → thinking budget -------------------------------------------- - - #[test] - fn effort_to_budget_tokens_xhigh_maps_to_seven_eighths() { - assert_eq!( - effort_to_budget_tokens(ReasoningEffort::XHigh, 16_000), - 14_000 - ); - } - - #[test] - fn effort_to_budget_tokens_max_maps_to_full_budget() { - assert_eq!( - effort_to_budget_tokens(ReasoningEffort::Max, 16_000), - 16_000 - ); - } - - // --- system prompt serialization ----------------------------------------- - - #[test] - fn system_prompt_as_string_when_cache_disabled() { - let system = "You are helpful.".to_string(); - let value = serde_json::Value::String(system); - assert_eq!(value.as_str(), Some("You are helpful.")); - } - - #[test] - fn api_request_serialization_with_cached_system() { - let api_request = ApiRequest { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }], - max_tokens: 4096, - system: Some(system_with_cache_control("You are helpful.")), - temperature: None, - top_p: None, - stop_sequences: Vec::new(), - tools: None, - tool_choice: None, - thinking: None, - output_config: None, - speed: None, - metadata: None, - stream: false, - }; - - let json = serde_json::to_value(&api_request).expect("should serialize"); - let system = json.get("system").expect("system should be present"); - let arr = system.as_array().expect("system should be an array"); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["cache_control"]["type"], "ephemeral"); - } - - // --- response_format ------------------------------------------------------ - - #[test] - fn response_format_json_schema_injects_synthetic_tool() { - let schema = serde_json::json!({ - "type": "object", - "properties": {"name": {"type": "string"}}, - "required": ["name"] - }); - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(schema.clone()), - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system: Option = None; - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let tools = tools.expect("tools should be set"); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].name, SYNTHETIC_TOOL_NAME); - assert_eq!(tools[0].input_schema, schema); - - let tc = tool_choice.expect("tool_choice should be set"); - assert_eq!(tc["type"], "tool"); - assert_eq!(tc["name"], SYNTHETIC_TOOL_NAME); - - // System should not be modified - assert!(system.is_none()); - } - - #[test] - fn tool_choice_forces_tool_use_detects_forced_modes() { - assert!(tool_choice_forces_tool_use(Some( - &serde_json::json!({"type": "any"}) - ))); - assert!(tool_choice_forces_tool_use(Some( - &serde_json::json!({"type": "tool", "name": "json_output"}) - ))); - - assert!(!tool_choice_forces_tool_use(Some( - &serde_json::json!({"type": "auto"}) - ))); - assert!(!tool_choice_forces_tool_use(Some( - &serde_json::json!({"type": "none"}) - ))); - assert!(!tool_choice_forces_tool_use(None)); - } - - #[test] - fn response_format_json_schema_appends_to_existing_tools() { - let schema = serde_json::json!({"type": "object"}); - let mut request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(schema), - strict: false, - }); - request.tools = Some(vec![ToolDefinition { - name: "existing_tool".to_string(), - description: "An existing tool".to_string(), - parameters: serde_json::json!({}), - }]); - - let mut tools: Option> = - Some(translate_tools(request.tools.as_ref().unwrap())); - let mut tool_choice: Option = None; - let mut system: Option = None; - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let tools = tools.expect("tools should be set"); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0].name, "existing_tool"); - assert_eq!(tools[1].name, SYNTHETIC_TOOL_NAME); - } - - #[test] - fn response_format_json_object_appends_to_string_system() { - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system = Some(serde_json::Value::String("You are helpful.".to_string())); - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let sys = system.expect("system should be set"); - let text = sys.as_str().expect("should be a string"); - assert!(text.contains("You are helpful.")); - assert!(text.contains("valid JSON")); - - // Tools should not be modified - assert!(tools.is_none()); - assert!(tool_choice.is_none()); - } - - #[test] - fn response_format_json_object_sets_system_when_none() { - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system: Option = None; - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let sys = system.expect("system should be set"); - let text = sys.as_str().expect("should be a string"); - assert!(text.contains("valid JSON")); - } - - #[test] - fn response_format_json_object_appends_to_array_system() { - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system = Some(system_with_cache_control("You are helpful.")); - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let sys = system.expect("system should be set"); - let arr = sys.as_array().expect("should be an array"); - let text = arr[0]["text"].as_str().expect("should have text"); - assert!(text.contains("You are helpful.")); - assert!(text.contains("valid JSON")); - } - - #[test] - fn response_format_text_is_noop() { - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::Text, - json_schema: None, - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system: Option = None; - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - assert!(tools.is_none()); - assert!(tool_choice.is_none()); - assert!(system.is_none()); - } - - // --- merge_provider_options ---------------------------------------------- - - #[test] - fn merge_provider_options_passes_through_unknown_keys() { - let api_request = ApiRequest { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }], - max_tokens: 4096, - system: None, - temperature: None, - top_p: None, - stop_sequences: Vec::new(), - tools: None, - tool_choice: None, - thinking: None, - output_config: None, - speed: None, - metadata: None, - stream: false, - }; - - let opts = serde_json::json!({ - "anthropic": { - "top_k": 40, - "custom_field": "value" - } - }); - let body = merge_provider_options(&api_request, Some(&opts)); - assert_eq!(body["top_k"], 40); - assert_eq!(body["custom_field"], "value"); - } - - #[test] - fn merge_provider_options_skips_known_keys() { - let api_request = ApiRequest { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }], - max_tokens: 4096, - system: None, - temperature: None, - top_p: None, - stop_sequences: Vec::new(), - tools: None, - tool_choice: None, - thinking: None, - output_config: None, - speed: None, - metadata: None, - stream: false, - }; - - let opts = serde_json::json!({ - "anthropic": { - "thinking": {"type": "enabled", "budget_tokens": 10000}, - "auto_cache": false, - "beta_headers": ["some-header"], - "top_k": 40 - } - }); - let body = merge_provider_options(&api_request, Some(&opts)); - // Known keys should not be merged (they are handled separately) - assert!(body.get("auto_cache").is_none()); - assert!(body.get("beta_headers").is_none()); - // thinking is handled by the ApiRequest struct directly, should not be - // double-merged - assert!(body["thinking"].is_null()); - // Unknown keys should be merged - assert_eq!(body["top_k"], 40); - } - - // --- content_part_to_api (documents / audio) ----------------------------- - - #[test] - fn document_url_translates_to_url_source() { - let part = ContentPart::Document(DocumentData { - url: Some("https://example.com/doc.pdf".to_string()), - data: None, - media_type: None, - file_name: None, - }); - let result = content_part_to_api(&part).expect("should produce JSON"); - assert_eq!(result["type"], "document"); - assert_eq!(result["source"]["type"], "url"); - assert_eq!(result["source"]["url"], "https://example.com/doc.pdf"); - } - - #[test] - fn document_base64_data_translates_to_base64_source() { - let part = ContentPart::Document(DocumentData { - url: None, - data: Some(vec![0x25, 0x50, 0x44, 0x46]), - media_type: Some("application/pdf".to_string()), - file_name: Some("test.pdf".to_string()), - }); - let result = content_part_to_api(&part).expect("should produce JSON"); - assert_eq!(result["type"], "document"); - assert_eq!(result["source"]["type"], "base64"); - assert_eq!(result["source"]["media_type"], "application/pdf"); - assert!(result["source"]["data"].as_str().is_some()); - } - - #[test] - fn document_base64_defaults_to_pdf_mime() { - let part = ContentPart::Document(DocumentData { - url: None, - data: Some(vec![1, 2, 3]), - media_type: None, - file_name: None, - }); - let result = content_part_to_api(&part).expect("should produce JSON"); - assert_eq!(result["source"]["media_type"], "application/pdf"); - } - - #[test] - fn audio_produces_text_fallback() { - let part = ContentPart::Audio(AudioData { - url: Some("https://example.com/audio.wav".to_string()), - data: None, - media_type: None, - }); - let result = content_part_to_api(&part).expect("should produce JSON"); - assert_eq!(result["type"], "text"); - assert_eq!( - result["text"], - "[Audio content not supported by this provider]" - ); - } - - // --- end-to-end encode (formerly build_api_request) ---------------------- - - #[test] - fn build_request_omits_whitespace_only_system_prompt() { - let request = Request { - messages: vec![Message::system(" \n\t"), Message::user("Hello")], - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - assert!( - encoded.body.get("system").is_none(), - "whitespace-only system prompts should be omitted" - ); - } - - #[test] - fn build_request_maps_reasoning_effort_to_output_config() { - let request = Request { - reasoning_effort: Some(ReasoningEffort::Medium), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - assert_eq!( - encoded.body["output_config"], - serde_json::json!({"effort": "medium"}) - ); - } - - #[test] - fn build_request_disables_prompt_cache_when_model_feature_is_false() { - let catalog = catalog_with_anthropic_model( - r#" -reasoning_effort = "levels" -prompt_cache = false -"#, - ); - let request = Request { - model: "test-claude".to_string(), - messages: vec![ - Message::system("Use the cache if supported."), - Message::user("Hello"), - ], - provider_options: Some(serde_json::json!({ - "anthropic": {"auto_cache": true} - })), - ..make_base_request() - }; - - let encoded = encode_direct(&request, Some(&catalog), false); - assert_eq!( - encoded.body["system"], - serde_json::json!("Use the cache if supported.") - ); - let beta = header_value(&encoded, "anthropic-beta"); - assert!( - beta.is_none_or(|value| !value.contains(CACHE_BETA_HEADER)), - "cache beta header must not be sent when the model disables prompt cache" - ); - } - - #[test] - fn build_request_without_injected_catalog_does_not_use_builtin_model_metadata() { - let request = Request { - model: "claude-sonnet-4-5".to_string(), - messages: vec![ - Message::system("Do not infer cache support from built-ins."), - Message::user("Hello"), - ], - provider_options: Some(serde_json::json!({ - "anthropic": {"auto_cache": true} - })), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - assert_eq!( - encoded.body["system"], - serde_json::json!("Do not infer cache support from built-ins.") - ); - let beta = header_value(&encoded, "anthropic-beta"); - assert!( - beta.is_none_or(|value| !value.contains(CACHE_BETA_HEADER)), - "cache beta header must require injected model metadata" - ); - } - - #[test] - fn build_request_enables_prompt_cache_when_model_feature_is_true() { - let catalog = catalog_with_anthropic_model( - r#" -reasoning_effort = "levels" -prompt_cache = true -"#, - ); - let request = Request { - model: "test-claude".to_string(), - messages: vec![ - Message::system("Use the cache if supported."), - Message::user("Hello"), - ], - ..make_base_request() - }; - - let encoded = encode_direct(&request, Some(&catalog), false); - assert_eq!( - encoded.body["system"][0]["cache_control"]["type"], - "ephemeral" - ); - let beta = - header_value(&encoded, "anthropic-beta").expect("cache beta header should be present"); - assert!(beta.contains(CACHE_BETA_HEADER)); - } - - #[test] - fn build_request_uses_adaptive_thinking_for_injected_effort_model_without_forced_tools() { - let catalog = catalog_with_anthropic_model( - r#" -reasoning_effort = "levels" -"#, - ); - let request = Request { - model: "test-claude".to_string(), - ..make_base_request() - }; - - let encoded = encode_direct(&request, Some(&catalog), false); - assert_eq!( - encoded.body["thinking"], - serde_json::json!({"type": "adaptive"}) - ); - } - - #[test] - fn build_request_omits_thinking_for_opus_4_7_json_schema() { - let request = Request { - model: "claude-opus-4-7".to_string(), - response_format: Some(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(serde_json::json!({ - "type": "object", - "properties": {"title": {"type": "string"}}, - "required": ["title"] - })), - strict: true, - }), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - let tool_choice = encoded - .body - .get("tool_choice") - .expect("json schema response format should force synthetic tool"); - assert_eq!(tool_choice["type"], "tool"); - assert_eq!(tool_choice["name"], SYNTHETIC_TOOL_NAME); - assert!( - encoded.body.get("thinking").is_none(), - "forced tool calls must omit thinking" - ); - assert!( - encoded.body.get("output_config").is_none(), - "forced tool calls must omit output_config effort" - ); - } - - #[test] - fn build_request_omits_thinking_for_explicit_named_tool_choice() { - let request = Request { - tools: Some(vec![ToolDefinition { - name: "json_output".to_string(), - description: "Output JSON".to_string(), - parameters: serde_json::json!({"type": "object"}), - }]), - tool_choice: Some(ToolChoice::Named { - tool_name: "json_output".to_string(), - }), - provider_options: Some(serde_json::json!({ - "anthropic": { - "thinking": {"type": "adaptive"} - } - })), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - let tool_choice = encoded - .body - .get("tool_choice") - .expect("named tool choice should be translated"); - assert_eq!(tool_choice["type"], "tool"); - assert_eq!(tool_choice["name"], "json_output"); - assert!( - encoded.body.get("thinking").is_none(), - "forced named tool choice must omit explicit thinking" - ); - } - - #[test] - fn build_request_omits_effort_for_required_tool_choice() { - let request = Request { - model: "claude-opus-4-7".to_string(), - tools: Some(vec![ToolDefinition { - name: "json_output".to_string(), - description: "Output JSON".to_string(), - parameters: serde_json::json!({"type": "object"}), - }]), - tool_choice: Some(ToolChoice::Required), - reasoning_effort: Some(ReasoningEffort::Medium), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - let tool_choice = encoded - .body - .get("tool_choice") - .expect("required tool choice should be translated"); - assert_eq!(tool_choice["type"], "any"); - assert!( - encoded.body.get("output_config").is_none(), - "required tool choice must omit output_config effort" - ); - } - - #[test] - fn build_request_omits_output_config_when_no_reasoning_effort() { - let request = make_base_request(); - let encoded = encode_direct(&request, None, false); - assert!(encoded.body.get("output_config").is_none()); - } - - #[test] - fn build_request_sets_speed() { - let request = Request { - speed: Some(Speed::Fast), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - assert_eq!(encoded.body["speed"], "fast"); - } - - #[test] - fn build_request_serializes_absent_stop_sequences_as_empty_array() { - let request = make_base_request(); - let encoded = encode_direct(&request, None, false); - assert_eq!(encoded.body["stop_sequences"], serde_json::json!([])); - } - - #[test] - fn build_request_injects_fast_mode_beta_header() { - let request = Request { - speed: Some(Speed::Fast), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - let beta = header_value(&encoded, "anthropic-beta") - .expect("anthropic-beta header should be present"); - assert!( - beta.contains(FAST_MODE_BETA_HEADER), - "beta header should contain fast-mode header, got: {beta}" - ); - } - - #[test] - fn build_request_falls_back_to_thinking_budget_for_non_effort_model() { - let catalog = catalog_with_anthropic_model(""); - let request = Request { - model: "test-claude".to_string(), - max_tokens: Some(16_000), - reasoning_effort: Some(ReasoningEffort::XHigh), - ..make_base_request() - }; - - let encoded = encode_direct(&request, Some(&catalog), false); - assert!( - encoded.body.get("output_config").is_none(), - "non-effort models must not receive output_config" - ); - let thinking = encoded - .body - .get("thinking") - .expect("thinking must be set for fallback path"); - assert_eq!(thinking["type"], "enabled"); - assert_eq!(thinking["budget_tokens"], 14_000); - } - - // --- count_tokens encoding ----------------------------------------------- - - #[test] - fn count_request_omits_generation_only_fields_for_reasoning_effort() { - let catalog = catalog_with_anthropic_model( - r#" -reasoning_effort = "levels" -"#, - ); - let request = Request { - model: "test-claude".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - temperature: Some(0.2), - top_p: Some(0.9), - metadata: Some(std::collections::HashMap::from([( - "trace".to_string(), - "abc".to_string(), - )])), - ..make_base_request() - }; - - // The full request carries generation-only fields... - let full = encode_direct(&request, Some(&catalog), false); - assert!(full.body.get("output_config").is_some()); - - // ...but the count request strips them. - let count = encode_count_direct(&request, Some(&catalog)); - assert!(count.body.get("output_config").is_none()); - assert!(count.body.get("max_tokens").is_none()); - assert!(count.body.get("temperature").is_none()); - assert!(count.body.get("top_p").is_none()); - assert!(count.body.get("metadata").is_none()); - assert!(count.body.get("stream").is_none()); - } - - #[test] - fn count_request_includes_explicit_thinking_when_translated_request_has_it() { - let request = Request { - provider_options: Some(serde_json::json!({ - "anthropic": { - "thinking": {"type": "enabled", "budget_tokens": 1024} - } - })), - ..make_base_request() - }; - - let count = encode_count_direct(&request, None); - assert_eq!(count.body["thinking"]["type"], "enabled"); - assert_eq!(count.body["thinking"]["budget_tokens"], 1024); - } -} diff --git a/lib/components/fabro-llm/src/codec/anthropic_messages/mod.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/mod.rs deleted file mode 100644 index 9cd0e8fa0..000000000 --- a/lib/components/fabro-llm/src/codec/anthropic_messages/mod.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! The Anthropic Messages (`/messages`) codec. -//! -//! Serves Anthropic direct today, and (via route config + `CodecParams`) -//! Kimi-over-anthropic; the Bedrock and OpenRouter-skin routes pair the same -//! codec with different transports later. Pure translation: no HTTP, auth, or -//! base URL — the adapter shell owns those. -//! -//! HTTP error bodies use the shared `decode_error` default (anthropic uses the -//! standard `error_from_status_code` + `parse_error_body` path); streaming -//! `error` events are mapped inside the decoder (`on_event` → `Err`). - -mod decode; -mod encode; -mod stream; -mod wire; - -pub(crate) use encode::anthropic_option; - -use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder}; -use crate::error::Error; -use crate::types::{RateLimitInfo, Response}; - -/// Synthetic tool injected to coerce structured (`JsonSchema`) output. Shared -/// across encode (injection), decode (extraction), and stream (rewrite). -pub(super) const SYNTHETIC_TOOL_NAME: &str = "json_output"; - -/// Codec for the Anthropic Messages wire dialect. -pub(crate) struct AnthropicMessages; - -impl Codec for AnthropicMessages { - fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result { - Ok(encode::encode(ctx, stream)) - } - - fn decode_response( - &self, - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Result { - decode::decode_response(body, ctx, rate_limit) - } - - fn stream_decoder( - &self, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Box { - Box::new(stream::SseAccumulator::new( - ctx.provider_name, - decode::uses_json_schema_format(ctx.request), - rate_limit, - )) - } - - fn encode_count_tokens(&self, ctx: &CodecCtx<'_>) -> Option> { - Some(Ok(encode::encode_count_tokens(ctx))) - } - - fn decode_count_tokens(&self, body: &str) -> Result { - decode::decode_count_tokens(body) - } -} diff --git a/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs deleted file mode 100644 index bb8d79027..000000000 --- a/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs +++ /dev/null @@ -1,653 +0,0 @@ -//! Streaming decoder: Anthropic SSE events → canonical `StreamEvent`s. -//! -//! Byte reading and SSE block framing live in the transport; this decoder is -//! fed framed `RawEvent`s (`event:` type + `data:` JSON). Anthropic never -//! synthesizes a finish on byte-stream end — `message_stop` is the finisher — -//! so `finish()` returns nothing. - -use super::SYNTHETIC_TOOL_NAME; -use super::decode::{convert_synthetic_tool_to_text, map_finish_reason, refusal_error}; -use crate::codec::{RawEvent, StreamDecoder, parse_tool_arguments_or_empty}; -use crate::error::{self, Error, ProviderErrorDetail, ProviderErrorKind}; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData, - TokenCounts, ToolCall, -}; - -/// The type of the current content block being streamed. -#[derive(Clone)] -enum ContentBlockKind { - Text, - ToolUse { id: String, name: String }, - Thinking { signature: Option }, -} - -/// Accumulated state across SSE events during streaming. -pub(super) struct SseAccumulator { - id: String, - model: String, - /// Configured provider name stamped into the final `Response.provider`. - provider: String, - /// When true, synthetic-tool events are rewritten to text events. - json_schema_mode: bool, - content_parts: Vec, - usage: TokenCounts, - finish_reason: FinishReason, - current_block: Option, - current_text: String, - current_thinking: String, - current_tool_args: String, - rate_limit: Option, -} - -impl SseAccumulator { - pub(super) fn new( - provider: &str, - json_schema_mode: bool, - rate_limit: Option, - ) -> Self { - Self { - id: String::new(), - model: String::new(), - provider: provider.to_string(), - json_schema_mode, - content_parts: Vec::new(), - usage: TokenCounts::default(), - finish_reason: FinishReason::Stop, - current_block: None, - current_text: String::new(), - current_thinking: String::new(), - current_tool_args: String::new(), - rate_limit, - } - } - - fn take_response(&mut self) -> Response { - Response { - id: std::mem::take(&mut self.id), - model: std::mem::take(&mut self.model), - provider: self.provider.clone(), - message: Message { - role: Role::Assistant, - content: std::mem::take(&mut self.content_parts), - name: None, - tool_call_id: None, - }, - finish_reason: std::mem::replace(&mut self.finish_reason, FinishReason::Stop), - usage: std::mem::take(&mut self.usage), - raw: None, - warnings: vec![], - rate_limit: self.rate_limit.take(), - cost_usd: None, - cost_source: None, - } - } - - fn process_event(&mut self, event_type: &str, data: &serde_json::Value) -> Vec { - match event_type { - "message_start" => self.handle_message_start(data), - "content_block_start" => self.handle_content_block_start(data), - "content_block_delta" => self.handle_content_block_delta(data), - "content_block_stop" => self.handle_content_block_stop(data), - "message_delta" => { - self.handle_message_delta(data); - vec![] - } - "message_stop" => self.handle_message_stop(), - _ => vec![], - } - } - - fn handle_message_start(&mut self, data: &serde_json::Value) -> Vec { - if let Some(message) = data.get("message") { - if let Some(id) = message.get("id").and_then(serde_json::Value::as_str) { - self.id = id.to_string(); - } - if let Some(model) = message.get("model").and_then(serde_json::Value::as_str) { - self.model = model.to_string(); - } - if let Some(usage) = message.get("usage") { - self.usage.input_tokens = usage - .get("input_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - self.usage.cache_read_tokens = usage - .get("cache_read_input_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - self.usage.cache_write_tokens = usage - .get("cache_creation_input_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - } - } - // `StreamStart` is the driver's; this handler only captures metadata. - vec![] - } - - fn handle_content_block_start(&mut self, data: &serde_json::Value) -> Vec { - let block_type = data - .get("content_block") - .and_then(|b| b.get("type")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - - let text_id = Some(block_text_id(data)); - - match block_type { - "text" => { - self.current_block = Some(ContentBlockKind::Text); - self.current_text.clear(); - vec![StreamEvent::TextStart { text_id }] - } - "tool_use" => { - let content_block = data.get("content_block"); - let id = content_block - .and_then(|b| b.get("id")) - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - let name = content_block - .and_then(|b| b.get("name")) - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - self.current_block = Some(ContentBlockKind::ToolUse { - id: id.clone(), - name: name.clone(), - }); - self.current_tool_args.clear(); - vec![StreamEvent::ToolCallStart { - tool_call: ToolCall::new(id, name, serde_json::json!({})), - }] - } - "thinking" => { - let signature = data - .get("content_block") - .and_then(|b| b.get("signature")) - .and_then(serde_json::Value::as_str) - .map(String::from); - self.current_block = Some(ContentBlockKind::Thinking { signature }); - self.current_thinking.clear(); - vec![StreamEvent::ReasoningStart] - } - _ => vec![], - } - } - - fn handle_content_block_delta(&mut self, data: &serde_json::Value) -> Vec { - let delta = data.get("delta"); - let delta_type = delta - .and_then(|d| d.get("type")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - - match delta_type { - "text_delta" => { - let text = delta - .and_then(|d| d.get("text")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - self.current_text.push_str(text); - - vec![StreamEvent::TextDelta { - delta: text.to_string(), - text_id: Some(block_text_id(data)), - }] - } - "input_json_delta" => { - let partial_json = delta - .and_then(|d| d.get("partial_json")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - self.current_tool_args.push_str(partial_json); - - if let Some(ContentBlockKind::ToolUse { id, name }) = &self.current_block { - vec![StreamEvent::ToolCallDelta { - tool_call: ToolCall::new( - id.clone(), - name.clone(), - serde_json::json!(partial_json), - ), - }] - } else { - vec![] - } - } - "thinking_delta" => { - let thinking = delta - .and_then(|d| d.get("thinking")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - self.current_thinking.push_str(thinking); - vec![StreamEvent::ReasoningDelta { - delta: thinking.to_string(), - }] - } - "signature_delta" => { - let signature = delta - .and_then(|d| d.get("signature")) - .and_then(serde_json::Value::as_str) - .map(String::from); - if let Some(ContentBlockKind::Thinking { - signature: ref mut sig, - }) = self.current_block - { - *sig = signature; - } - vec![] - } - _ => vec![], - } - } - - fn handle_content_block_stop(&mut self, data: &serde_json::Value) -> Vec { - let current_block = self.current_block.take(); - match current_block { - Some(ContentBlockKind::Text) => { - let text = std::mem::take(&mut self.current_text); - self.content_parts.push(ContentPart::text(text)); - - vec![StreamEvent::TextEnd { - text_id: Some(block_text_id(data)), - }] - } - Some(ContentBlockKind::ToolUse { id, name }) => { - let raw_args = std::mem::take(&mut self.current_tool_args); - let arguments = parse_tool_arguments_or_empty(&raw_args); - let mut tool_call = ToolCall::new(id, name, arguments); - tool_call.raw_arguments = Some(raw_args); - self.content_parts - .push(ContentPart::ToolCall(tool_call.clone())); - vec![StreamEvent::ToolCallEnd { tool_call }] - } - Some(ContentBlockKind::Thinking { signature }) => { - let thinking_text = std::mem::take(&mut self.current_thinking); - // Prefer signature from content_block_stop if available, fall - // back to one captured at content_block_start. - let stop_signature = data - .get("content_block") - .and_then(|b| b.get("signature")) - .and_then(serde_json::Value::as_str) - .map(String::from); - self.content_parts.push(ContentPart::Thinking(ThinkingData { - text: thinking_text, - signature: stop_signature.or(signature), - redacted: false, - })); - vec![StreamEvent::ReasoningEnd] - } - None => vec![], - } - } - - fn handle_message_delta(&mut self, data: &serde_json::Value) { - if let Some(delta) = data.get("delta") { - let stop_reason = delta.get("stop_reason").and_then(serde_json::Value::as_str); - self.finish_reason = map_finish_reason(stop_reason); - } - if let Some(usage) = data.get("usage") { - self.usage.output_tokens = usage - .get("output_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - } - } - - fn handle_message_stop(&mut self) -> Vec { - let response = self.take_response(); - vec![StreamEvent::Finish { - finish_reason: response.finish_reason.clone(), - usage: response.usage.clone(), - response: Box::new(response), - }] - } -} - -/// The `text_id` for a content-block event: `block_`. -fn block_text_id(data: &serde_json::Value) -> String { - let index = data - .get("index") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - format!("block_{index}") -} - -/// Extract the `stop_details` from a refusal `message_delta`, if present. -fn refusal_stop_details(data: &serde_json::Value) -> Option<&serde_json::Value> { - data.get("delta") - .and_then(|delta| delta.get("stop_details")) -} - -/// Whether a `message_delta` event carries a refusal stop reason. -fn is_refusal_message_delta(event_type: &str, data: &serde_json::Value) -> bool { - event_type == "message_delta" - && data - .get("delta") - .and_then(|delta| delta.get("stop_reason")) - .and_then(serde_json::Value::as_str) - == Some("refusal") -} - -/// Wrap a refusal stream event in the same raw shape the non-streaming -/// refusal error carries (`stop_reason` + `stop_details` + the event). -fn refusal_stream_raw(data: &serde_json::Value) -> serde_json::Value { - serde_json::json!({ - "stop_reason": "refusal", - "stop_details": refusal_stop_details(data) - .cloned() - .unwrap_or(serde_json::Value::Null), - "stream_event": data, - }) -} - -/// Map an Anthropic `error` stream event to a provider error. -fn stream_error_event_to_provider_error(data: &serde_json::Value, provider_name: &str) -> Error { - let error = data.get("error").unwrap_or(data); - let message = error - .get("message") - .and_then(serde_json::Value::as_str) - .or_else(|| data.get("message").and_then(serde_json::Value::as_str)) - .unwrap_or("Unknown Anthropic stream error") - .to_string(); - let error_code = error - .get("type") - .and_then(serde_json::Value::as_str) - .map(String::from); - - // overloaded_error, api_error, and unknown stream errors are transient. - let kind = error_code - .as_deref() - .and_then(error::kind_from_error_code) - .unwrap_or(ProviderErrorKind::Server); - - Error::Provider { - kind, - detail: Box::new(ProviderErrorDetail { - message, - provider: provider_name.to_string(), - status_code: None, - error_code, - retry_after: None, - raw: Some(data.clone()), - }), - } -} - -/// Rewrite a streaming event for `JsonSchema` mode: synthetic-tool events -/// become text events, and the Finish event's content + finish_reason are -/// adjusted. -fn convert_stream_event_for_json_schema(event: StreamEvent) -> StreamEvent { - match event { - StreamEvent::ToolCallStart { tool_call } if tool_call.name == SYNTHETIC_TOOL_NAME => { - StreamEvent::TextStart { text_id: None } - } - StreamEvent::ToolCallDelta { tool_call } if tool_call.name == SYNTHETIC_TOOL_NAME => { - let delta = match tool_call.arguments { - serde_json::Value::String(s) => s, - other => other.to_string(), - }; - StreamEvent::TextDelta { - delta, - text_id: None, - } - } - StreamEvent::ToolCallEnd { tool_call } if tool_call.name == SYNTHETIC_TOOL_NAME => { - StreamEvent::TextEnd { text_id: None } - } - StreamEvent::Finish { - mut response, - usage, - .. - } => { - response.message.content = - convert_synthetic_tool_to_text(std::mem::take(&mut response.message.content)); - response.finish_reason = FinishReason::Stop; - StreamEvent::Finish { - finish_reason: FinishReason::Stop, - usage, - response, - } - } - other => other, - } -} - -impl StreamDecoder for SseAccumulator { - fn on_event(&mut self, ev: RawEvent<'_>) -> Result, Error> { - let event_type = ev.event.unwrap_or(""); - let data: serde_json::Value = serde_json::from_str(ev.data) - .map_err(|e| Error::stream_error(format!("failed to parse SSE data: {e}"), e))?; - - if event_type == "error" { - return Err(stream_error_event_to_provider_error(&data, &self.provider)); - } - - // A refusal (Claude Fable 5) arrives as a `message_delta` stop reason; - // surface it as an error instead of letting `message_stop` emit a - // normal Finish. - if is_refusal_message_delta(event_type, &data) { - return Err(refusal_error( - &self.provider, - &self.model, - refusal_stream_raw(&data), - refusal_stop_details(&data), - )); - } - - let events = self.process_event(event_type, &data); - if self.json_schema_mode { - Ok(events - .into_iter() - .map(convert_stream_event_for_json_schema) - .collect()) - } else { - Ok(events) - } - } - - fn finish(&mut self) -> Vec { - // Anthropic relies on `message_stop` to finish; nothing to synthesize. - Vec::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn new_accumulator(provider: &str, json_schema_mode: bool) -> SseAccumulator { - SseAccumulator::new(provider, json_schema_mode, None) - } - - #[test] - fn stream_token_counts_leaves_reasoning_zero_and_output_full() { - let mut acc = new_accumulator("anthropic", false); - acc.content_parts.push(ContentPart::Thinking(ThinkingData { - text: "summary text".to_string(), - signature: Some(String::new()), - redacted: false, - })); - acc.content_parts.push(ContentPart::text("answer")); - acc.usage = TokenCounts { - input_tokens: 50, - output_tokens: 1200, - reasoning_tokens: 0, - cache_read_tokens: 9000, - cache_write_tokens: 1000, - }; - - let events = acc.handle_message_stop(); - let StreamEvent::Finish { - usage, response, .. - } = &events[0] - else { - panic!("expected finish event"); - }; - - assert_eq!(usage.input_tokens, 50); - assert_eq!(usage.cache_read_tokens, 9000); - assert_eq!(usage.cache_write_tokens, 1000); - assert_eq!(usage.output_tokens, 1200); - assert_eq!(usage.reasoning_tokens, 0); - assert_eq!(usage.total_tokens(), 11_250); - assert_eq!(response.usage, *usage); - } - - #[test] - fn stream_error_event_overloaded_becomes_retryable_server_error() { - let mut acc = new_accumulator("anthropic", false); - let data = serde_json::json!({ - "type": "error", - "error": { - "type": "overloaded_error", - "message": "Overloaded" - } - }); - let raw = data.to_string(); - - let err = acc - .on_event(RawEvent { - event: Some("error"), - data: &raw, - }) - .unwrap_err(); - - assert!(err.retryable()); - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::Server); - assert_eq!(detail.provider, "anthropic"); - assert_eq!(detail.message, "Overloaded"); - assert_eq!(detail.error_code.as_deref(), Some("overloaded_error")); - assert_eq!(detail.raw.as_ref(), Some(&data)); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn stream_error_event_invalid_request_remains_non_retryable() { - let mut acc = new_accumulator("anthropic", false); - let data = serde_json::json!({ - "type": "error", - "error": { - "type": "invalid_request_error", - "message": "max_tokens is required" - } - }); - let raw = data.to_string(); - - let err = acc - .on_event(RawEvent { - event: Some("error"), - data: &raw, - }) - .unwrap_err(); - - assert!(!err.retryable()); - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::InvalidRequest); - assert_eq!(detail.error_code.as_deref(), Some("invalid_request_error")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn unknown_sse_events_remain_ignored() { - let mut acc = new_accumulator("anthropic", false); - let data = serde_json::json!({ - "type": "content_block_delta", - "delta": { "type": "text_delta", "text": "ignored" } - }); - let raw = data.to_string(); - - let events = acc - .on_event(RawEvent { - event: Some("some_future_event"), - data: &raw, - }) - .unwrap(); - - assert!(events.is_empty()); - } - - #[test] - fn convert_stream_event_converts_tool_start_for_synthetic() { - let event = StreamEvent::ToolCallStart { - tool_call: ToolCall::new("id1", SYNTHETIC_TOOL_NAME, serde_json::json!({})), - }; - let result = convert_stream_event_for_json_schema(event); - assert!(matches!(result, StreamEvent::TextStart { .. })); - } - - #[test] - fn convert_stream_event_preserves_real_tool_start() { - let event = StreamEvent::ToolCallStart { - tool_call: ToolCall::new("id1", "real_tool", serde_json::json!({})), - }; - let result = convert_stream_event_for_json_schema(event); - assert!(matches!(result, StreamEvent::ToolCallStart { .. })); - } - - #[test] - fn convert_stream_event_converts_tool_delta_for_synthetic() { - let event = StreamEvent::ToolCallDelta { - tool_call: ToolCall::new("id1", SYNTHETIC_TOOL_NAME, serde_json::json!("{\"name\"")), - }; - let result = convert_stream_event_for_json_schema(event); - match result { - StreamEvent::TextDelta { delta, .. } => { - assert_eq!(delta, "{\"name\""); - } - other => panic!("expected TextDelta, got {other:?}"), - } - } - - #[test] - fn convert_stream_event_converts_finish_reason() { - let response = Box::new(Response { - id: "test".to_string(), - model: "claude".to_string(), - provider: "anthropic".to_string(), - message: Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "id1", - SYNTHETIC_TOOL_NAME, - serde_json::json!({"data": "value"}), - ))], - 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, - }); - let event = StreamEvent::Finish { - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts::default(), - response, - }; - let result = convert_stream_event_for_json_schema(event); - match result { - StreamEvent::Finish { - finish_reason, - response, - .. - } => { - assert_eq!(finish_reason, FinishReason::Stop); - assert_eq!(response.finish_reason, FinishReason::Stop); - // Content should be converted from tool call to text - assert!(matches!(&response.message.content[0], ContentPart::Text(_))); - } - other => panic!("expected Finish, got {other:?}"), - } - } -} diff --git a/lib/components/fabro-llm/src/codec/anthropic_messages/wire.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/wire.rs deleted file mode 100644 index 70fb0ede6..000000000 --- a/lib/components/fabro-llm/src/codec/anthropic_messages/wire.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Serde types mirroring the Anthropic Messages wire shapes. - -use crate::codec::cache::CacheControl; - -#[derive(serde::Serialize)] -pub(super) struct ApiRequest { - pub model: String, - pub messages: Vec, - pub max_tokens: i64, - /// System prompt: either a plain string or an array of content blocks - /// (with optional `cache_control` annotations for prompt caching). - #[serde(skip_serializing_if = "Option::is_none")] - pub system: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub top_p: Option, - /// Always serialized, even when empty (pinned by wire tests). - pub stop_sequences: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_choice: Option, - /// Extended thinking configuration (e.g. `{"type": "enabled", - /// "budget_tokens": 10000}`). - #[serde(skip_serializing_if = "Option::is_none")] - pub thinking: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub output_config: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub speed: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option>, - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub stream: bool, -} - -#[derive(serde::Serialize)] -pub(super) struct CountTokensRequest { - pub model: String, - pub messages: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub system: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_choice: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub thinking: Option, -} - -impl From for CountTokensRequest { - fn from(request: ApiRequest) -> Self { - Self { - model: request.model, - messages: request.messages, - system: request.system, - tools: request.tools, - tool_choice: request.tool_choice, - thinking: request.thinking, - } - } -} - -/// Anthropic messages use structured content blocks, not plain strings. -#[derive(serde::Serialize)] -pub(super) struct ApiMessage { - pub role: String, - pub content: Vec, -} - -/// Anthropic tool definition format. -#[derive(serde::Serialize)] -pub(super) struct ApiToolDef { - pub name: String, - pub description: String, - pub input_schema: serde_json::Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_control: Option, -} - -// --- Response types --- - -#[derive(serde::Deserialize)] -pub(super) struct ApiResponse { - pub id: String, - pub model: String, - pub content: Vec, - pub stop_reason: Option, - #[serde(default)] - pub stop_details: Option, - pub usage: ApiUsage, -} - -#[derive(serde::Deserialize)] -#[allow( - clippy::struct_field_names, - reason = "Field names mirror the provider API payload." -)] -pub(super) struct ApiUsage { - pub input_tokens: i64, - pub output_tokens: i64, - #[serde(default)] - pub cache_read_input_tokens: Option, - #[serde(default)] - pub cache_creation_input_tokens: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct CountTokensResponse { - pub input_tokens: i64, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tool_serialization_includes_cache_control() { - let tool = ApiToolDef { - name: "test_tool".to_string(), - description: "A test tool".to_string(), - input_schema: serde_json::json!({"type": "object"}), - cache_control: Some(CacheControl::ephemeral()), - }; - let json = serde_json::to_value(&tool).expect("should serialize"); - assert_eq!(json["cache_control"]["type"], "ephemeral"); - } - - #[test] - fn tool_serialization_omits_cache_control_when_none() { - let tool = ApiToolDef { - name: "test_tool".to_string(), - description: "A test tool".to_string(), - input_schema: serde_json::json!({"type": "object"}), - cache_control: None, - }; - let json = serde_json::to_value(&tool).expect("should serialize"); - assert!(json.get("cache_control").is_none()); - } -} diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/decode.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/decode.rs deleted file mode 100644 index f9cf0f87e..000000000 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/decode.rs +++ /dev/null @@ -1,311 +0,0 @@ -//! Response decoding: Converse body → canonical `Response`. - -use serde_json::Value; - -use crate::codec::CodecCtx; -use crate::error::{Error, error_from_status_code}; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, ThinkingData, TokenCounts, - ToolCall, -}; - -/// Map a non-2xx Bedrock runtime response to an `Error`, pulling the human -/// reason out of AWS's error envelope. Bedrock uses several shapes for the -/// same field — top-level `message` (SigV4 path) and `Message` (API-key -/// path), occasionally nested `error.message` — and tags the type in -/// `__type`. The generic codec parser only reads `error.message`, so without -/// this these surface as "Unknown error". -pub(super) fn bedrock_error( - status: u16, - body: &str, - provider: &str, - retry_after: Option, -) -> Error { - let raw: Option = serde_json::from_str(body).ok(); - let message = raw - .as_ref() - .and_then(extract_error_message) - .unwrap_or_else(|| { - if body.trim().is_empty() { - "Unknown error".to_string() - } else { - body.to_string() - } - }); - // `__type` is often an ARN-ish `prefix#ThrottlingException`; keep the tail. - let code = raw - .as_ref() - .and_then(|v| { - v.get("__type") - .or_else(|| v.get("code")) - .and_then(Value::as_str) - }) - .map(|t| t.rsplit('#').next().unwrap_or(t).to_string()); - error_from_status_code( - status, - message, - provider.to_string(), - code, - raw, - retry_after, - ) -} - -fn extract_error_message(v: &Value) -> Option { - v.get("message") - .and_then(Value::as_str) - .or_else(|| v.get("Message").and_then(Value::as_str)) - .or_else(|| { - v.get("error") - .and_then(|e| e.get("message")) - .and_then(Value::as_str) - }) - .map(String::from) -} - -pub(super) fn decode_response( - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, -) -> Result { - let raw: Value = serde_json::from_str(body) - .map_err(|e| Error::network(format!("failed to parse converse response: {e}"), e))?; - - let content_parts = raw - .pointer("/output/message/content") - .and_then(Value::as_array) - .map(|blocks| blocks.iter().filter_map(decode_content_block).collect()) - .unwrap_or_default(); - - let finish_reason = map_stop_reason(raw.get("stopReason").and_then(Value::as_str)); - let usage = token_counts_from_usage(raw.get("usage")); - - Ok(Response { - // Converse responses carry no id; synthesize one like the gemini - // codec does so downstream consumers always see a non-empty id. - id: uuid::Uuid::new_v4().to_string(), - model: ctx.request.model.clone(), - provider: ctx.provider_name.to_string(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason, - usage, - raw: Some(raw), - warnings: vec![], - rate_limit, - cost_usd: None, - cost_source: None, - }) -} - -/// Decode one Converse content block into a canonical part. Unknown block -/// kinds are skipped (the union grows: `citationsContent`, `searchResult`, -/// `video`, ...). -pub(super) fn decode_content_block(block: &Value) -> Option { - if let Some(text) = block.get("text").and_then(Value::as_str) { - if text.is_empty() { - return None; - } - return Some(ContentPart::text(text)); - } - if let Some(tool_use) = block.get("toolUse") { - let id = tool_use.get("toolUseId").and_then(Value::as_str)?; - let name = tool_use.get("name").and_then(Value::as_str)?; - // A no-argument tool call is canonically `{}`, not null (so it - // re-encodes to a valid Converse `toolUse.input` object). - let input = match tool_use.get("input") { - Some(Value::Null) | None => Value::Object(serde_json::Map::new()), - Some(value) => value.clone(), - }; - return Some(ContentPart::ToolCall(ToolCall::new(id, name, input))); - } - if let Some(reasoning) = block.get("reasoningContent") { - if let Some(text_block) = reasoning.get("reasoningText") { - return Some(ContentPart::Thinking(ThinkingData { - text: text_block - .get("text") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - signature: text_block - .get("signature") - .and_then(Value::as_str) - .map(str::to_string), - redacted: false, - })); - } - if let Some(redacted) = reasoning.get("redactedContent").and_then(Value::as_str) { - return Some(ContentPart::Thinking(ThinkingData { - text: redacted.to_string(), - signature: None, - redacted: true, - })); - } - } - None -} - -/// Map a Converse `stopReason` onto the canonical finish vocabulary. -pub(super) fn map_stop_reason(reason: Option<&str>) -> FinishReason { - match reason { - None | Some("end_turn" | "stop_sequence") => FinishReason::Stop, - Some("max_tokens" | "model_context_window_exceeded") => FinishReason::Length, - Some("tool_use") => FinishReason::ToolCalls, - // `refusal` is the Claude 5 blocking-classifier stop, passed through - // by Bedrock for Fable-class models. - Some("guardrail_intervened" | "content_filtered" | "refusal") => { - FinishReason::ContentFilter - } - Some(other) => FinishReason::Other(other.to_string()), - } -} - -/// Converse usage maps directly onto the disjoint buckets: `inputTokens` -/// already excludes cached tokens (documented), so no subtraction applies. -pub(super) fn token_counts_from_usage(usage: Option<&Value>) -> TokenCounts { - let Some(usage) = usage else { - return TokenCounts::default(); - }; - let count = |key: &str| usage.get(key).and_then(Value::as_i64).unwrap_or(0); - TokenCounts { - input_tokens: count("inputTokens"), - output_tokens: count("outputTokens"), - reasoning_tokens: 0, - cache_read_tokens: count("cacheReadInputTokens"), - cache_write_tokens: count("cacheWriteInputTokens"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stop_reasons_map_to_canonical_vocabulary() { - assert_eq!(map_stop_reason(Some("end_turn")), FinishReason::Stop); - assert_eq!(map_stop_reason(Some("stop_sequence")), FinishReason::Stop); - assert_eq!(map_stop_reason(Some("max_tokens")), FinishReason::Length); - assert_eq!( - map_stop_reason(Some("model_context_window_exceeded")), - FinishReason::Length - ); - assert_eq!(map_stop_reason(Some("tool_use")), FinishReason::ToolCalls); - assert_eq!( - map_stop_reason(Some("guardrail_intervened")), - FinishReason::ContentFilter - ); - assert_eq!( - map_stop_reason(Some("content_filtered")), - FinishReason::ContentFilter - ); - assert_eq!( - map_stop_reason(Some("refusal")), - FinishReason::ContentFilter - ); - assert_eq!( - map_stop_reason(Some("malformed_tool_use")), - FinishReason::Other("malformed_tool_use".to_string()) - ); - assert_eq!(map_stop_reason(None), FinishReason::Stop); - } - - #[test] - fn usage_maps_without_subtraction() { - let usage = serde_json::json!({ - "inputTokens": 30, - "outputTokens": 628, - "totalTokens": 658, - "cacheReadInputTokens": 1024, - "cacheWriteInputTokens": 512, - }); - let counts = token_counts_from_usage(Some(&usage)); - assert_eq!(counts.input_tokens, 30); - assert_eq!(counts.output_tokens, 628); - assert_eq!(counts.cache_read_tokens, 1024); - assert_eq!(counts.cache_write_tokens, 512); - assert_eq!(counts.reasoning_tokens, 0); - } - - #[test] - fn bedrock_error_extracts_aws_message_shapes() { - // SigV4 path: top-level lowercase `message`. - let sigv4 = bedrock_error( - 403, - r#"{"message":"Model access is denied due to IAM ..."}"#, - "bedrock", - None, - ); - assert!( - sigv4.to_string().contains("Model access is denied"), - "{sigv4}" - ); - - // API-key path: top-level capitalized `Message`. - let api_key = bedrock_error( - 403, - r#"{"Message":"Authentication failed: Please make sure your API Key is valid."}"#, - "bedrock", - None, - ); - assert!( - api_key.to_string().contains("Authentication failed"), - "{api_key}" - ); - - // `__type` becomes the error code (tail after `#`). - let typed = bedrock_error( - 429, - r#"{"__type":"com.amazon.coral.service#ThrottlingException","message":"slow down"}"#, - "bedrock", - None, - ); - let Error::Provider { detail, .. } = &typed else { - panic!("expected provider error: {typed}"); - }; - assert_eq!(detail.error_code.as_deref(), Some("ThrottlingException")); - - // Garbage body falls back rather than panicking. - let opaque = bedrock_error(500, "not json", "bedrock", None); - assert!(opaque.to_string().contains("not json"), "{opaque}"); - } - - #[test] - fn unknown_content_blocks_are_skipped() { - assert!(decode_content_block(&serde_json::json!({"citationsContent": {}})).is_none()); - assert!(decode_content_block(&serde_json::json!({"text": ""})).is_none()); - } - - #[test] - fn tool_use_names_are_preserved_verbatim() { - let block = serde_json::json!({ - "toolUse": { - "toolUseId": "tool-1", - "name": "search???", - "input": {} - } - }); - let Some(ContentPart::ToolCall(tool_call)) = decode_content_block(&block) else { - panic!("expected tool call"); - }; - assert_eq!(tool_call.name, "search???"); - } - - #[test] - fn reasoning_text_block_round_trips_signature() { - let block = serde_json::json!({ - "reasoningContent": { - "reasoningText": { "text": "thinking...", "signature": "sig-1" } - } - }); - let Some(ContentPart::Thinking(thinking)) = decode_content_block(&block) else { - panic!("expected thinking part"); - }; - assert_eq!(thinking.text, "thinking..."); - assert_eq!(thinking.signature.as_deref(), Some("sig-1")); - assert!(!thinking.redacted); - } -} diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs deleted file mode 100644 index bb1a4ba3b..000000000 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs +++ /dev/null @@ -1,765 +0,0 @@ -//! Request encoding: canonical `Request` → Converse envelope. - -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64; -use serde_json::{Map, Value, json}; - -use super::sanitize; -use crate::codec::{CodecCtx, EncodedRequest, extract_system_prompt, merge_named_provider_options}; -use crate::error::Error; -use crate::types::{ContentPart, Message, Request, Role, ToolChoice}; - -pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> Result { - let request = ctx.request; - if request.response_format.is_some() { - return Err(Error::Configuration { - message: format!( - "provider '{}' does not support response_format yet (Bedrock Converse \ - structured output is a named follow-up)", - ctx.provider_name - ), - source: None, - }); - } - - let caching = supports_prompt_cache(ctx); - let (system, conversation) = extract_system_prompt(&request.messages); - - let mut body = Map::new(); - - if let Some(system) = system { - let mut blocks = vec![json!({ "text": system })]; - if caching { - blocks.push(cache_point()); - } - body.insert("system".to_string(), Value::Array(blocks)); - } - - let mut messages = Vec::new(); - for message in conversation { - if let Some(value) = encode_message(message) { - messages.push(value); - } - } - if caching { - apply_cache_point_to_conversation_prefix(&mut messages); - } - body.insert("messages".to_string(), Value::Array(messages)); - - // Models with `sampling_params = false` reject classic sampling knobs - // (Claude Fable 5 pins temperature on Bedrock too). - let (temperature, top_p) = if ctx - .model - .is_none_or(fabro_model::Model::supports_sampling_params) - { - (request.temperature, request.top_p) - } else { - (None, None) - }; - - let mut inference = Map::new(); - if let Some(max_tokens) = request.max_tokens { - inference.insert("maxTokens".to_string(), json!(max_tokens)); - } - if let Some(temperature) = temperature { - inference.insert("temperature".to_string(), json!(temperature)); - } - if let Some(top_p) = top_p { - inference.insert("topP".to_string(), json!(top_p)); - } - if let Some(stop) = &request.stop_sequences { - if !stop.is_empty() { - inference.insert("stopSequences".to_string(), json!(stop)); - } - } - if !inference.is_empty() { - body.insert("inferenceConfig".to_string(), Value::Object(inference)); - } - - if let Some(tool_config) = encode_tool_config(request, caching) { - body.insert("toolConfig".to_string(), tool_config); - } - - let mut body = Value::Object(body); - merge_provider_options( - &mut body, - request.provider_options.as_ref(), - ctx.provider_name, - ); - - let action = if stream { - "converse-stream" - } else { - "converse" - }; - Ok(EncodedRequest { - body, - endpoint: format!("/model/{}/{action}", ctx.deployment_id), - headers: Vec::new(), - }) -} - -fn supports_prompt_cache(ctx: &CodecCtx<'_>) -> bool { - ctx.model.is_some_and(|m| m.features.prompt_cache) -} - -fn cache_point() -> Value { - json!({ "cachePoint": { "type": "default" } }) -} - -/// Encode one conversation message. Tool-role messages carry their results in -/// user-role messages (Converse has no tool role). Returns `None` when no -/// block survives translation. -fn encode_message(message: &Message) -> Option { - let role = match message.role { - Role::Assistant => "assistant", - // Tool results ride in user messages on the Converse wire. - _ => "user", - }; - - let mut blocks: Vec = message - .content - .iter() - .filter_map(encode_content_part) - .collect(); - - // Tool-role messages whose result lives on the message rather than in a - // ToolResult part. - if blocks.is_empty() && message.role == Role::Tool { - if let Some(tool_call_id) = &message.tool_call_id { - let text = message.text(); - blocks.push(tool_result_block( - tool_call_id, - json!([{ "text": text }]), - false, - )); - } - } - - if blocks.is_empty() { - return None; - } - Some(json!({ "role": role, "content": blocks })) -} - -fn encode_content_part(part: &ContentPart) -> Option { - match part { - ContentPart::Text(text) => { - if text.is_empty() { - None - } else { - Some(json!({ "text": text })) - } - } - // Converse has no URL sources; the adapter's attachment resolution - // inlines file-backed parts ahead of encoding, and URL-only parts are - // dropped (the established drop-don't-fail attachment contract). - ContentPart::Image(image) => { - let bytes = image.data.as_ref()?; - Some(json!({ - "image": { - "format": media_format(image.media_type.as_deref(), "png"), - "source": { "bytes": BASE64.encode(bytes) }, - } - })) - } - ContentPart::Document(document) => { - let bytes = document.data.as_ref()?; - Some(json!({ - "document": { - "format": media_format(document.media_type.as_deref(), "pdf"), - "name": document.file_name.as_deref().unwrap_or("document"), - "source": { "bytes": BASE64.encode(bytes) }, - } - })) - } - ContentPart::ToolCall(tool_call) => { - // Converse requires `toolUse.input` to be a JSON object document. - // A no-argument tool call carries `Null` (the stream decoder gets - // no input fragments to parse), which Bedrock rejects as - // "toolUse.input is empty". Coerce any non-object to `{}` so the - // wire is always valid, regardless of where the call originated. - let input = match &tool_call.arguments { - Value::Object(_) => tool_call.arguments.clone(), - _ => json!({}), - }; - Some(tool_use_block(&tool_call.id, &tool_call.name, input)) - } - ContentPart::ToolResult(result) => { - let content = match &result.content { - Value::String(text) => json!([{ "text": text }]), - other => json!([{ "json": other }]), - }; - Some(tool_result_block( - &result.tool_call_id, - content, - result.is_error, - )) - } - ContentPart::Thinking(thinking) => { - if thinking.redacted { - Some(json!({ - "reasoningContent": { "redactedContent": thinking.text } - })) - } else { - let mut text_block = Map::new(); - text_block.insert("text".to_string(), json!(thinking.text)); - if let Some(signature) = &thinking.signature { - // Echoed back unmodified — Bedrock validates it. - text_block.insert("signature".to_string(), json!(signature)); - } - Some(json!({ - "reasoningContent": { "reasoningText": Value::Object(text_block) } - })) - } - } - // Audio input and opaque foreign parts have no Converse encoding. - ContentPart::Audio(_) | ContentPart::Other { .. } => None, - } -} - -/// Build a `toolUse` block. All tool blocks must be constructed through -/// [`tool_use_block`] and [`tool_result_block`] so identifier sanitization -/// keeps `toolUse` and `toolResult` paired on the wire. -fn tool_use_block(id: &str, name: &str, input: Value) -> Value { - let mut block = Map::new(); - block.insert("toolUseId".to_string(), json!(sanitize::tool_use_id(id))); - block.insert("name".to_string(), json!(sanitize::tool_name(name))); - block.insert("input".to_string(), input); - json!({ "toolUse": Value::Object(block) }) -} - -/// Build a `toolResult` block; see [`tool_use_block`] for the pairing contract. -fn tool_result_block(id: &str, content: Value, is_error: bool) -> Value { - let mut block = Map::new(); - block.insert("toolUseId".to_string(), json!(sanitize::tool_use_id(id))); - block.insert("content".to_string(), content); - if is_error { - block.insert("status".to_string(), json!("error")); - } - json!({ "toolResult": Value::Object(block) }) -} - -/// Convert common MIME types into Bedrock's media `format` enum values. -fn media_format<'a>(media_type: Option<&str>, default: &'a str) -> &'a str { - match media_type { - Some("image/png") => "png", - Some("image/jpeg" | "image/jpg") => "jpeg", - Some("image/gif") => "gif", - Some("image/webp") => "webp", - Some("application/pdf") => "pdf", - Some("text/plain") => "txt", - Some("text/markdown") => "md", - Some("text/html") => "html", - Some("text/csv") => "csv", - Some( - "application/msword" - | "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ) => "docx", - Some( - "application/vnd.ms-excel" - | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ) => "xlsx", - _ => default, - } -} - -fn encode_tool_config(request: &Request, caching: bool) -> Option { - let tools = request.tools.as_ref()?; - if tools.is_empty() { - return None; - } - // `tool_choice: none` is rejected at the adapter's validate_request; - // defensively drop the toolConfig if it slips through. - if request.tool_choice == Some(ToolChoice::None) { - return None; - } - - let mut entries: Vec = tools - .iter() - .map(|tool| { - json!({ - "toolSpec": { - "name": tool.name, - "description": tool.description, - "inputSchema": { "json": tool_input_schema(&tool.parameters) }, - } - }) - }) - .collect(); - if caching { - entries.push(cache_point()); - } - - let mut config = Map::new(); - config.insert("tools".to_string(), Value::Array(entries)); - match &request.tool_choice { - Some(ToolChoice::Required) => { - config.insert("toolChoice".to_string(), json!({ "any": {} })); - } - Some(ToolChoice::Named { tool_name }) => { - config.insert( - "toolChoice".to_string(), - json!({ "tool": { "name": tool_name } }), - ); - } - // Auto is the wire default; ToolChoice::None dropped the config above. - Some(ToolChoice::Auto | ToolChoice::None) | None => {} - } - Some(Value::Object(config)) -} - -/// Normalize a tool's JSON-Schema for Bedrock's `toolSpec.inputSchema.json`. -/// Converse strictly validates the schema and requires a top-level `type`; -/// some model families (e.g. DeepSeek) reject a typeless schema that Claude -/// tolerates. Tools may arrive with a loose schema (no top-level `type`, or a -/// bare `{}` for a no-argument tool), so default the type to `object`. -fn tool_input_schema(parameters: &Value) -> Value { - match parameters { - Value::Object(map) => { - let mut map = map.clone(); - map.entry("type").or_insert_with(|| json!("object")); - Value::Object(map) - } - // A non-object schema is not a valid tool input schema; substitute the - // empty-object schema Bedrock accepts. - _ => json!({ "type": "object", "properties": {} }), - } -} - -/// Mirror the anthropic codec's conversation-prefix cache placement: a -/// `cachePoint` at the end of the second-to-last user message, so the prior -/// turns stay cached while the newest turn streams. -fn apply_cache_point_to_conversation_prefix(messages: &mut [Value]) { - let mut previous_user = None; - let mut last_user = None; - for (index, message) in messages.iter().enumerate() { - if message.get("role").and_then(Value::as_str) == Some("user") { - previous_user = last_user; - last_user = Some(index); - } - } - - let Some(target) = previous_user else { - return; - }; - if let Some(content) = messages[target] - .get_mut("content") - .and_then(Value::as_array_mut) - { - content.push(cache_point()); - } -} - -/// Merge `provider_options.` keys into the top level of the -/// body (the same adapter-name-keyed contract as the openai_compatible -/// codec). This is the passthrough for `additionalModelRequestFields`, -/// `guardrailConfig`, `serviceTier`, and other Converse extensions. -fn merge_provider_options(body: &mut Value, provider_options: Option<&Value>, provider_name: &str) { - merge_named_provider_options(body, provider_options, provider_name, &[]); -} - -#[cfg(test)] -mod tests { - use fabro_model::catalog::LlmCatalogSettings; - use fabro_model::{Catalog, ProviderId}; - use serde_json::json; - - use super::*; - use crate::codec::CodecParams; - use crate::types::{ - ResponseFormat, ResponseFormatType, ThinkingData, ToolCall, ToolDefinition, ToolResult, - }; - - fn base_request(model: &str) -> Request { - Request { - model: model.to_string(), - messages: vec![Message::user("Hello")], - provider: Some("bedrock".to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: Some(0.5), - top_p: None, - max_tokens: Some(256), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - fn encode_with(request: &Request) -> EncodedRequest { - let params = CodecParams::default(); - let ctx = CodecCtx { - request, - provider_name: "bedrock", - deployment_id: "us.anthropic.claude-sonnet-4-6", - model: None, - params: ¶ms, - }; - encode(&ctx, false).unwrap() - } - - #[test] - fn endpoint_carries_model_and_action() { - let request = base_request("claude"); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "bedrock", - deployment_id: "us.anthropic.claude-sonnet-4-6", - model: None, - params: ¶ms, - }; - assert_eq!( - encode(&ctx, false).unwrap().endpoint, - "/model/us.anthropic.claude-sonnet-4-6/converse" - ); - assert_eq!( - encode(&ctx, true).unwrap().endpoint, - "/model/us.anthropic.claude-sonnet-4-6/converse-stream" - ); - } - - #[test] - fn system_messages_become_top_level_system_blocks() { - let mut request = base_request("claude"); - request.messages = vec![Message::system("Be brief"), Message::user("Hi")]; - let encoded = encode_with(&request); - assert_eq!(encoded.body["system"][0]["text"], "Be brief"); - assert_eq!(encoded.body["messages"][0]["role"], "user"); - assert_eq!(encoded.body["messages"][0]["content"][0]["text"], "Hi"); - } - - #[test] - fn inference_config_uses_camel_case() { - let encoded = encode_with(&base_request("claude")); - assert_eq!(encoded.body["inferenceConfig"]["maxTokens"], 256); - assert_eq!(encoded.body["inferenceConfig"]["temperature"], 0.5); - } - - #[test] - fn tools_encode_as_tool_specs_with_choice() { - let mut request = base_request("claude"); - request.tools = Some(vec![ToolDefinition::function( - "search", - "Search things", - json!({"type": "object"}), - )]); - request.tool_choice = Some(ToolChoice::named("search")); - let encoded = encode_with(&request); - let spec = &encoded.body["toolConfig"]["tools"][0]["toolSpec"]; - assert_eq!(spec["name"], "search"); - assert_eq!(spec["inputSchema"]["json"]["type"], "object"); - assert_eq!( - encoded.body["toolConfig"]["toolChoice"]["tool"]["name"], - "search" - ); - } - - #[test] - fn typeless_tool_schema_gains_object_type() { - // Bedrock rejects a tool inputSchema without a top-level `type` (some - // model families validate strictly); the encoder must default it. - let mut request = base_request("claude"); - request.tools = Some(vec![ - ToolDefinition::function("no_type", "schema without a type", json!({})), - ToolDefinition::function( - "props_only", - "properties but no top-level type", - json!({"properties": {"q": {"type": "string"}}}), - ), - ]); - let encoded = encode_with(&request); - let tools = &encoded.body["toolConfig"]["tools"]; - assert_eq!( - tools[0]["toolSpec"]["inputSchema"]["json"]["type"], - "object" - ); - assert_eq!( - tools[1]["toolSpec"]["inputSchema"]["json"]["type"], - "object" - ); - // An existing nested schema is preserved, not clobbered. - assert_eq!( - tools[1]["toolSpec"]["inputSchema"]["json"]["properties"]["q"]["type"], - "string" - ); - } - - #[test] - fn tool_results_ride_in_user_messages() { - let mut request = base_request("claude"); - request.messages = vec![Message { - role: Role::Tool, - content: vec![ContentPart::ToolResult(ToolResult { - tool_call_id: "tool-1".to_string(), - content: json!("42"), - is_error: false, - image_data: None, - image_media_type: None, - })], - name: None, - tool_call_id: Some("tool-1".to_string()), - }]; - let encoded = encode_with(&request); - let message = &encoded.body["messages"][0]; - assert_eq!(message["role"], "user"); - assert_eq!(message["content"][0]["toolResult"]["toolUseId"], "tool-1"); - assert_eq!( - message["content"][0]["toolResult"]["content"][0]["text"], - "42" - ); - } - - #[test] - fn no_argument_tool_call_encodes_empty_object_input() { - // A no-arg tool call decodes to `Null` arguments; Bedrock rejects a - // null/empty `toolUse.input`, so the encoder must emit `{}`. - let mut request = base_request("claude"); - request.messages = vec![Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "tool-1", - "TaskList", - Value::Null, - ))], - name: None, - tool_call_id: None, - }]; - let encoded = encode_with(&request); - let tool_use = &encoded.body["messages"][0]["content"][0]["toolUse"]; - assert_eq!(tool_use["toolUseId"], "tool-1"); - assert_eq!(tool_use["name"], "TaskList"); - assert_eq!(tool_use["input"], json!({})); - } - - #[test] - fn historical_tool_names_are_sanitized_on_the_wire() { - let mut request = base_request("claude"); - request.messages = vec![Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "tool-1", - "search???", - json!({}), - ))], - name: None, - tool_call_id: None, - }]; - - let encoded = encode_with(&request); - let tool_use = &encoded.body["messages"][0]["content"][0]["toolUse"]; - assert_eq!(tool_use["name"], sanitize::tool_name("search???")); - } - - #[test] - fn sanitized_tool_use_ids_remain_paired() { - for id in ["bad id!".to_string(), "x".repeat(100)] { - let mut request = base_request("claude"); - request.messages = vec![ - Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - &id, - "search", - json!({}), - ))], - name: None, - tool_call_id: None, - }, - Message { - role: Role::Tool, - content: vec![ContentPart::ToolResult(ToolResult::success( - &id, - json!("done"), - ))], - name: None, - tool_call_id: Some(id.clone()), - }, - ]; - - let encoded = encode_with(&request); - let tool_use_id = &encoded.body["messages"][0]["content"][0]["toolUse"]["toolUseId"]; - let tool_result_id = - &encoded.body["messages"][1]["content"][0]["toolResult"]["toolUseId"]; - assert_eq!(tool_use_id, tool_result_id); - assert!(tool_use_id.as_str().is_some_and(|value| value.len() <= 64)); - } - } - - #[test] - fn tool_role_fallback_sanitizes_the_tool_use_id() { - let mut request = base_request("claude"); - request.messages = vec![Message { - role: Role::Tool, - content: vec![], - name: None, - tool_call_id: Some("bad id!".to_string()), - }]; - - let encoded = encode_with(&request); - assert_eq!( - encoded.body["messages"][0]["content"][0]["toolResult"]["toolUseId"], - sanitize::tool_use_id("bad id!") - ); - } - - #[test] - fn overlength_tool_names_encode_within_the_bedrock_limit() { - let mut request = base_request("claude"); - request.messages = vec![Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "tool-1", - "x".repeat(100), - json!({}), - ))], - name: None, - tool_call_id: None, - }]; - - let encoded = encode_with(&request); - let name = encoded.body["messages"][0]["content"][0]["toolUse"]["name"] - .as_str() - .unwrap(); - assert_eq!(name.len(), 64); - } - - #[test] - fn tool_definition_names_remain_unsanitized() { - let mut request = base_request("claude"); - request.tools = Some(vec![ToolDefinition::function( - "weird.name", - "Deliberately invalid for Bedrock", - json!({"type": "object"}), - )]); - - let encoded = encode_with(&request); - assert_eq!( - encoded.body["toolConfig"]["tools"][0]["toolSpec"]["name"], - "weird.name" - ); - } - - #[test] - fn thinking_parts_restructure_into_reasoning_text_blocks() { - let mut request = base_request("claude"); - request.messages = vec![Message { - role: Role::Assistant, - content: vec![ContentPart::Thinking(ThinkingData { - text: "prior thoughts".to_string(), - signature: Some("sig-1".to_string()), - redacted: false, - })], - name: None, - tool_call_id: None, - }]; - let encoded = encode_with(&request); - let block = &encoded.body["messages"][0]["content"][0]["reasoningContent"]["reasoningText"]; - assert_eq!(block["text"], "prior thoughts"); - assert_eq!(block["signature"], "sig-1"); - } - - #[test] - fn media_format_maps_common_mime_types_to_bedrock_formats() { - assert_eq!(media_format(Some("image/jpeg"), "png"), "jpeg"); - assert_eq!(media_format(Some("text/plain"), "pdf"), "txt"); - assert_eq!(media_format(Some("text/markdown"), "pdf"), "md"); - assert_eq!(media_format(Some("application/octet-stream"), "pdf"), "pdf"); - } - - #[test] - fn provider_options_merge_top_level() { - let mut request = base_request("claude"); - request.provider_options = Some(json!({ - "bedrock": { - "additionalModelRequestFields": {"top_k": 200}, - "serviceTier": {"type": "flex"} - } - })); - let encoded = encode_with(&request); - assert_eq!(encoded.body["additionalModelRequestFields"]["top_k"], 200); - assert_eq!(encoded.body["serviceTier"]["type"], "flex"); - } - - #[test] - fn response_format_is_rejected() { - let mut request = base_request("claude"); - request.response_format = Some(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(json!({"type": "object"})), - strict: false, - }); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "bedrock", - deployment_id: "m", - model: None, - params: ¶ms, - }; - assert!(encode(&ctx, false).is_err()); - } - - #[test] - fn sampling_params_false_drops_temperature_and_top_p() { - let settings: LlmCatalogSettings = toml::from_str( - r#" -[providers.bedrock] -adapter = "bedrock" -enabled = true -base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" - -[models."pinned-model"] -provider = "bedrock" -display_name = "Pinned" -family = "claude-5" -default = true - -[models."pinned-model".limits] -context_window = 100000 - -[models."pinned-model".features] -tools = true -vision = false -reasoning = true -sampling_params = false -"#, - ) - .unwrap(); - let catalog = Catalog::from_settings(&settings).unwrap(); - - let mut request = base_request("pinned-model"); - request.top_p = Some(0.9); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "bedrock", - deployment_id: "pinned-model", - model: catalog.get_on_provider(&ProviderId::new("bedrock"), "pinned-model"), - params: ¶ms, - }; - let encoded = encode(&ctx, false).unwrap(); - - let inference = &encoded.body["inferenceConfig"]; - assert!(inference.get("temperature").is_none()); - assert!(inference.get("topP").is_none()); - assert_eq!(inference["maxTokens"], 256); - } - - #[test] - fn cache_points_follow_the_anthropic_placement() { - let mut messages = vec![ - json!({"role": "user", "content": [{"text": "turn 1"}]}), - json!({"role": "assistant", "content": [{"text": "reply 1"}]}), - json!({"role": "user", "content": [{"text": "turn 2"}]}), - ]; - apply_cache_point_to_conversation_prefix(&mut messages); - // Second-to-last user message gains the cachePoint. - assert!(messages[0]["content"][1].get("cachePoint").is_some()); - assert_eq!(messages[2]["content"].as_array().unwrap().len(), 1); - } -} diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/mod.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/mod.rs deleted file mode 100644 index c55c0a3b3..000000000 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/mod.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! The Amazon Bedrock Converse codec. -//! -//! Pure translation: no HTTP, auth, signing, or event-stream framing — the -//! Bedrock adapter shell owns those. Converse is Bedrock's model-agnostic -//! envelope (AWS translates it to each hosted family's native dialect -//! server-side), which is what makes this one codec serve Claude, Nova, -//! Llama, Mistral, DeepSeek, Qwen, Kimi, GLM, MiniMax, Nemotron, and -//! gpt-oss alike. The codec fully forms its endpoints (model-in-path, -//! `/converse` vs `/converse-stream`), mirrors the anthropic codec's prompt -//! cache placement with `cachePoint` blocks, and round-trips -//! `reasoningContent` thinking signatures unmodified. - -mod decode; -mod encode; -mod sanitize; -mod stream; - -use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder}; -use crate::error::Error; -use crate::types::{RateLimitInfo, Response}; - -/// Codec for the Bedrock Converse wire dialect. -pub(crate) struct BedrockConverse; - -impl Codec for BedrockConverse { - fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result { - encode::encode(ctx, stream) - } - - fn decode_response( - &self, - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Result { - decode::decode_response(body, ctx, rate_limit) - } - - fn stream_decoder( - &self, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Box { - Box::new(stream::ConverseStreamDecoder::new(ctx, rate_limit)) - } - - /// Bedrock error bodies are AWS-shaped (top-level `message`/`Message`, - /// `__type`), which the default parser misses — extract them so failures - /// surface the real reason instead of "Unknown error". - fn decode_error( - &self, - status: u16, - body: &str, - ctx: &CodecCtx<'_>, - retry_after: Option, - ) -> Error { - decode::bedrock_error(status, body, ctx.provider_name, retry_after) - } -} diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs deleted file mode 100644 index 5ba139fc2..000000000 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Bedrock Converse tool identifier sanitization. -//! -//! Tool names must match `[a-zA-Z0-9_-]+`; tool-use IDs additionally allow -//! `.` and `:`. Both are limited to 64 characters. These helpers rewrite only -//! the Bedrock wire view: the canonical transcript retains provider output -//! verbatim. The encoder routes every tool block through its -//! `tool_use_block`/`tool_result_block` constructors so `toolUse` and -//! `toolResult` blocks remain paired. - -use sha2::{Digest, Sha256}; - -const MAX_LENGTH: usize = 64; -const HASH_HEX_LENGTH: usize = 16; -const PREFIX_LENGTH: usize = MAX_LENGTH - 1 - HASH_HEX_LENGTH; - -pub(super) fn tool_name(name: &str) -> String { - sanitize(name, "unknown_tool", is_tool_name_char) -} - -pub(super) fn tool_use_id(id: &str) -> String { - sanitize(id, "unknown_tool_use_id", is_tool_use_id_char) -} - -fn sanitize(value: &str, empty_fallback: &'static str, is_allowed: fn(char) -> bool) -> String { - if value.is_empty() { - return empty_fallback.to_string(); - } - - let sanitized: String = value - .chars() - .map(|character| { - if is_allowed(character) { - character - } else { - '_' - } - }) - .collect(); - - if sanitized.len() <= MAX_LENGTH { - sanitized - } else { - truncate_with_hash(&sanitized, value) - } -} - -fn is_tool_name_char(character: char) -> bool { - character.is_ascii_alphanumeric() || matches!(character, '_' | '-') -} - -fn is_tool_use_id_char(character: char) -> bool { - is_tool_name_char(character) || matches!(character, '.' | ':') -} - -fn truncate_with_hash(sanitized: &str, original: &str) -> String { - debug_assert!(sanitized.is_ascii()); - let digest = Sha256::digest(original.as_bytes()); - let digest_hex = format!("{digest:x}"); - format!( - "{}-{}", - &sanitized[..PREFIX_LENGTH], - &digest_hex[..HASH_HEX_LENGTH] - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn valid_values_pass_through_unchanged() { - for name in ["search", "TaskList", "a-b_c9"] { - assert_eq!(tool_name(name), name); - } - - let max_length = "a".repeat(64); - assert_eq!(tool_name(&max_length), max_length); - - let id = "functions.read_file:4"; - assert_eq!(tool_use_id(id), id); - assert_eq!(tool_name(id), "functions_read_file_4"); - } - - #[test] - fn invalid_characters_are_replaced() { - assert_eq!(tool_name("search???"), "search___"); - assert_eq!(tool_name("bad name"), "bad_name"); - assert_eq!(tool_use_id("bad id!"), "bad_id_"); - } - - #[test] - fn non_ascii_characters_become_single_underscores() { - let sanitized = tool_name("before🙂after"); - assert_eq!(sanitized, "before_after"); - assert!(sanitized.is_ascii()); - } - - #[test] - fn empty_values_use_nonempty_fallbacks() { - assert_eq!(tool_name(""), "unknown_tool"); - assert_eq!(tool_use_id(""), "unknown_tool_use_id"); - } - - #[test] - fn overlength_values_use_deterministic_hash_suffixes() { - let boundary = "a".repeat(65); - let first = tool_name(&boundary); - let second = tool_name(&boundary); - assert_eq!(first, second); - assert_eq!(first.len(), 64); - assert!( - first - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) - ); - - let shared_prefix = "x".repeat(99); - let left = tool_name(&format!("{shared_prefix}a")); - let right = tool_name(&format!("{shared_prefix}b")); - assert_ne!(left, right); - } -} diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/stream.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/stream.rs deleted file mode 100644 index 0d413cabf..000000000 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/stream.rs +++ /dev/null @@ -1,527 +0,0 @@ -//! Streaming decoder: ConverseStream events → canonical `StreamEvent`s. -//! -//! Event names arrive in the transport's `RawEvent::event` (the frame's -//! `:event-type` header); payloads are the event JSON. The documented -//! sequence is `messageStart` → per content block (`contentBlockStart` -//! [tool use only] → `contentBlockDelta`* → `contentBlockStop`) → -//! `messageStop{stopReason}` → `metadata{usage}`. Usage arrives ONLY in the -//! terminal `metadata` event, which is also where the final `Finish` is -//! synthesized. - -use std::collections::BTreeMap; - -use serde_json::Value; - -use super::decode::{map_stop_reason, token_counts_from_usage}; -use crate::codec::{CodecCtx, RawEvent, StreamDecoder, parse_tool_arguments_or_empty}; -use crate::error::Error; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData, - TokenCounts, ToolCall, -}; - -/// Per-content-block accumulation state, keyed by `contentBlockIndex`. -enum BlockState { - Text(String), - Reasoning { - text: String, - signature: Option, - redacted: Option, - }, - ToolUse { - id: String, - name: String, - input: String, - }, -} - -/// Accumulated state while decoding one ConverseStream response. -pub(super) struct ConverseStreamDecoder { - provider_name: String, - model: String, - blocks: BTreeMap, - /// Completed blocks in arrival order, for the final response message. - parts: Vec, - finish_reason: FinishReason, - usage: TokenCounts, - text_started: bool, - finished: bool, - rate_limit: Option, -} - -impl ConverseStreamDecoder { - pub(super) fn new(ctx: &CodecCtx<'_>, rate_limit: Option) -> Self { - Self { - provider_name: ctx.provider_name.to_string(), - model: ctx.request.model.clone(), - blocks: BTreeMap::new(), - parts: Vec::new(), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - text_started: false, - finished: false, - rate_limit, - } - } - - fn block_index(payload: &Value) -> u64 { - payload - .get("contentBlockIndex") - .and_then(Value::as_u64) - .unwrap_or(0) - } - - fn on_block_start(&mut self, payload: &Value) -> Vec { - let index = Self::block_index(payload); - if let Some(tool_use) = payload.pointer("/start/toolUse") { - let id = tool_use - .get("toolUseId") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let name = tool_use - .get("name") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let started = ToolCall::new(&id, &name, Value::Null); - self.blocks.insert(index, BlockState::ToolUse { - id, - name, - input: String::new(), - }); - return vec![StreamEvent::ToolCallStart { tool_call: started }]; - } - Vec::new() - } - - fn on_block_delta(&mut self, payload: &Value) -> Vec { - let index = Self::block_index(payload); - let Some(delta) = payload.get("delta") else { - return Vec::new(); - }; - - if let Some(text) = delta.get("text").and_then(Value::as_str) { - if text.is_empty() { - return Vec::new(); - } - let mut events = Vec::new(); - if !self.text_started { - self.text_started = true; - events.push(StreamEvent::TextStart { text_id: None }); - } - match self - .blocks - .entry(index) - .or_insert_with(|| BlockState::Text(String::new())) - { - BlockState::Text(buffer) => buffer.push_str(text), - // A text delta against a non-text block: tolerate by ignoring - // the mismatch rather than corrupting tool/reasoning state. - _ => return events, - } - events.push(StreamEvent::text_delta(text, None)); - return events; - } - - if let Some(input) = delta.pointer("/toolUse/input").and_then(Value::as_str) { - if let Some(BlockState::ToolUse { - id, - name, - input: buffer, - }) = self.blocks.get_mut(&index) - { - buffer.push_str(input); - let partial = ToolCall::new(id.as_str(), name.as_str(), Value::Null); - return vec![StreamEvent::ToolCallDelta { tool_call: partial }]; - } - return Vec::new(); - } - - if let Some(reasoning) = delta.get("reasoningContent") { - let entry = self - .blocks - .entry(index) - .or_insert_with(|| BlockState::Reasoning { - text: String::new(), - signature: None, - redacted: None, - }); - let BlockState::Reasoning { - text, - signature, - redacted, - } = entry - else { - return Vec::new(); - }; - let mut events = Vec::new(); - if text.is_empty() && signature.is_none() && redacted.is_none() { - events.push(StreamEvent::ReasoningStart); - } - // Streaming reasoning deltas carry text/signature as FLAT union - // members (unlike the nested request-side reasoningText block). - if let Some(fragment) = reasoning.get("text").and_then(Value::as_str) { - text.push_str(fragment); - events.push(StreamEvent::ReasoningDelta { - delta: fragment.to_string(), - }); - } - if let Some(sig) = reasoning.get("signature").and_then(Value::as_str) { - *signature = Some(sig.to_string()); - } - if let Some(blob) = reasoning.get("redactedContent").and_then(Value::as_str) { - *redacted = Some(blob.to_string()); - } - return events; - } - - Vec::new() - } - - fn on_block_stop(&mut self, payload: &Value) -> Vec { - let index = Self::block_index(payload); - let Some(block) = self.blocks.remove(&index) else { - return Vec::new(); - }; - match block { - BlockState::Text(text) => { - let mut events = Vec::new(); - if self.text_started { - self.text_started = false; - events.push(StreamEvent::TextEnd { text_id: None }); - } - if !text.is_empty() { - self.parts.push(ContentPart::text(&text)); - } - events - } - BlockState::Reasoning { - text, - signature, - redacted, - } => { - let part = if let Some(blob) = redacted { - ThinkingData { - text: blob, - signature: None, - redacted: true, - } - } else { - ThinkingData { - text, - signature, - redacted: false, - } - }; - self.parts.push(ContentPart::Thinking(part)); - vec![StreamEvent::ReasoningEnd] - } - BlockState::ToolUse { id, name, input } => { - // A no-argument tool call streams no input fragments, leaving - // the buffer empty; canonically that is an empty object, not - // null (matching the anthropic/openai codecs, and what Bedrock - // wants back on re-encode). - let arguments = parse_tool_arguments_or_empty(&input); - let mut tool_call = ToolCall::new(&id, &name, arguments); - tool_call.raw_arguments = Some(input); - self.parts.push(ContentPart::ToolCall(tool_call.clone())); - vec![StreamEvent::ToolCallEnd { tool_call }] - } - } - } - - /// Build the final `Finish` from accumulated state. - fn finish_event(&mut self) -> StreamEvent { - self.finished = true; - // Flush any blocks that never saw a contentBlockStop. - let dangling: Vec = self.blocks.keys().copied().collect(); - for index in dangling { - let _ = self.on_block_stop(&serde_json::json!({ "contentBlockIndex": index })); - } - - let response = Response { - id: uuid::Uuid::new_v4().to_string(), - model: self.model.clone(), - provider: self.provider_name.clone(), - message: Message { - role: Role::Assistant, - content: std::mem::take(&mut self.parts), - name: None, - tool_call_id: None, - }, - finish_reason: self.finish_reason.clone(), - usage: self.usage.clone(), - raw: None, - warnings: vec![], - rate_limit: self.rate_limit.clone(), - cost_usd: None, - cost_source: None, - }; - StreamEvent::finish(self.finish_reason.clone(), self.usage.clone(), response) - } -} - -impl StreamDecoder for ConverseStreamDecoder { - fn on_event(&mut self, ev: RawEvent<'_>) -> Result, Error> { - let Some(event_type) = ev.event else { - return Ok(Vec::new()); - }; - let payload: Value = serde_json::from_str(ev.data) - .map_err(|e| Error::stream_error(format!("converse stream event json: {e}"), e))?; - - Ok(match event_type { - "contentBlockStart" => self.on_block_start(&payload), - "contentBlockDelta" => self.on_block_delta(&payload), - "contentBlockStop" => self.on_block_stop(&payload), - "messageStop" => { - self.finish_reason = - map_stop_reason(payload.get("stopReason").and_then(Value::as_str)); - Vec::new() - } - "metadata" => { - self.usage = token_counts_from_usage(payload.get("usage")); - vec![self.finish_event()] - } - // `messageStart` carries nothing this decoder needs — the driving - // loop owns `StreamStart` — and unknown event types are tolerated - // because the union grows. - _ => Vec::new(), - }) - } - - /// Byte-stream end: `metadata` is the documented terminus, but if the - /// stream ends without one, synthesize the `Finish` from accumulated - /// state so callers still receive a response (mirrors the gemini - /// decoder's unconditional synthesis). - fn finish(&mut self) -> Vec { - if self.finished { - return Vec::new(); - } - vec![self.finish_event()] - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codec::CodecParams; - use crate::types::{Message as RequestMessage, Request}; - - fn decoder() -> ConverseStreamDecoder { - let request = Request { - model: "us.anthropic.claude-sonnet-4-6".to_string(), - messages: vec![RequestMessage::user("hi")], - provider: Some("bedrock".to_string()), - tools: None, - tool_choice: None, - 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 params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "bedrock", - deployment_id: "us.anthropic.claude-sonnet-4-6", - model: None, - params: ¶ms, - }; - ConverseStreamDecoder::new(&ctx, None) - } - - fn feed(decoder: &mut ConverseStreamDecoder, event: &str, data: &str) -> Vec { - decoder - .on_event(RawEvent { - event: Some(event), - data, - }) - .unwrap() - } - - #[test] - fn text_happy_path_finishes_on_metadata() { - let mut d = decoder(); - // `StreamStart` belongs to the driving loop, not the decoder. - assert!(feed(&mut d, "messageStart", r#"{"role":"assistant"}"#).is_empty()); - let events = feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"text":"Hel"},"contentBlockIndex":0}"#, - ); - assert!(matches!(events[0], StreamEvent::TextStart { .. })); - assert!(matches!(events[1], StreamEvent::TextDelta { .. })); - feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"text":"lo"},"contentBlockIndex":0}"#, - ); - let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#); - assert!(matches!(stop[0], StreamEvent::TextEnd { .. })); - assert!(feed(&mut d, "messageStop", r#"{"stopReason":"end_turn"}"#).is_empty()); - - let finish = feed( - &mut d, - "metadata", - r#"{"usage":{"inputTokens":12,"outputTokens":5,"totalTokens":17}}"#, - ); - let StreamEvent::Finish { - finish_reason, - usage, - response, - } = &finish[0] - else { - panic!("expected Finish"); - }; - assert_eq!(*finish_reason, FinishReason::Stop); - assert_eq!(usage.input_tokens, 12); - assert_eq!(response.text(), "Hello"); - assert_eq!(response.provider, "bedrock"); - // Byte-stream end after metadata adds nothing. - assert!(d.finish().is_empty()); - } - - #[test] - fn no_argument_tool_call_decodes_empty_object_not_null() { - // A no-arg tool call (e.g. TaskList) streams no input fragments; the - // arguments must be `{}` so it re-encodes to a valid Converse input. - let mut d = decoder(); - feed(&mut d, "messageStart", r#"{"role":"assistant"}"#); - feed( - &mut d, - "contentBlockStart", - r#"{"start":{"toolUse":{"toolUseId":"tool-1","name":"TaskList"}},"contentBlockIndex":0}"#, - ); - let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#); - let StreamEvent::ToolCallEnd { tool_call } = &stop[0] else { - panic!("expected ToolCallEnd"); - }; - assert_eq!(tool_call.arguments, serde_json::json!({})); - assert!(!tool_call.arguments.is_null()); - } - - #[test] - fn streamed_tool_use_names_are_preserved_verbatim() { - let mut d = decoder(); - feed(&mut d, "messageStart", r#"{"role":"assistant"}"#); - feed( - &mut d, - "contentBlockStart", - r#"{"start":{"toolUse":{"toolUseId":"tool-1","name":"search???"}},"contentBlockIndex":0}"#, - ); - let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#); - let StreamEvent::ToolCallEnd { tool_call } = &stop[0] else { - panic!("expected ToolCallEnd"); - }; - assert_eq!(tool_call.name, "search???"); - } - - #[test] - fn tool_use_accumulates_string_input_fragments() { - let mut d = decoder(); - feed(&mut d, "messageStart", r#"{"role":"assistant"}"#); - let start = feed( - &mut d, - "contentBlockStart", - r#"{"start":{"toolUse":{"toolUseId":"tool-1","name":"search"}},"contentBlockIndex":0}"#, - ); - assert!(matches!(start[0], StreamEvent::ToolCallStart { .. })); - feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"toolUse":{"input":"{\"que"}},"contentBlockIndex":0}"#, - ); - feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"toolUse":{"input":"ry\":\"foo\"}"}},"contentBlockIndex":0}"#, - ); - let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#); - let StreamEvent::ToolCallEnd { tool_call } = &stop[0] else { - panic!("expected ToolCallEnd"); - }; - assert_eq!(tool_call.id, "tool-1"); - assert_eq!(tool_call.arguments["query"], "foo"); - - feed(&mut d, "messageStop", r#"{"stopReason":"tool_use"}"#); - let finish = feed( - &mut d, - "metadata", - r#"{"usage":{"inputTokens":1,"outputTokens":1}}"#, - ); - let StreamEvent::Finish { finish_reason, .. } = &finish[0] else { - panic!("expected Finish"); - }; - assert_eq!(*finish_reason, FinishReason::ToolCalls); - } - - #[test] - fn reasoning_deltas_round_trip_signature() { - let mut d = decoder(); - let events = feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"reasoningContent":{"text":"thinking"}},"contentBlockIndex":0}"#, - ); - assert!(matches!(events[0], StreamEvent::ReasoningStart)); - assert!(matches!(events[1], StreamEvent::ReasoningDelta { .. })); - feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"reasoningContent":{"signature":"sig-9"}},"contentBlockIndex":0}"#, - ); - let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#); - assert!(matches!(stop[0], StreamEvent::ReasoningEnd)); - - let finish = feed( - &mut d, - "metadata", - r#"{"usage":{"inputTokens":1,"outputTokens":1}}"#, - ); - let StreamEvent::Finish { response, .. } = &finish[0] else { - panic!("expected Finish"); - }; - let ContentPart::Thinking(thinking) = &response.message.content[0] else { - panic!("expected thinking part"); - }; - assert_eq!(thinking.text, "thinking"); - assert_eq!(thinking.signature.as_deref(), Some("sig-9")); - } - - #[test] - fn stream_end_without_metadata_synthesizes_finish() { - let mut d = decoder(); - feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"text":"partial"},"contentBlockIndex":0}"#, - ); - let events = d.finish(); - let StreamEvent::Finish { response, .. } = &events[0] else { - panic!("expected synthesized Finish"); - }; - assert_eq!(response.text(), "partial"); - // Synthesis happens once. - assert!(d.finish().is_empty()); - } - - #[test] - fn unknown_events_are_tolerated() { - let mut d = decoder(); - assert!(feed(&mut d, "futureEventKind", r#"{"anything":1}"#).is_empty()); - assert!( - d.on_event(RawEvent { - event: None, - data: "{}", - }) - .unwrap() - .is_empty() - ); - } -} diff --git a/lib/components/fabro-llm/src/codec/cache.rs b/lib/components/fabro-llm/src/codec/cache.rs deleted file mode 100644 index ed1afe8ab..000000000 --- a/lib/components/fabro-llm/src/codec/cache.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Shared prompt-cache policy: whether a request opts into explicit -//! Anthropic-style caching and where the conversation breakpoint lands. -//! Dialect codecs apply these decisions to their own wire shapes. - -/// Anthropic-style `cache_control` annotation. -#[derive(serde::Serialize, Clone)] -pub(crate) struct CacheControl { - #[serde(rename = "type")] - pub kind: String, -} - -impl CacheControl { - pub(crate) fn ephemeral() -> Self { - Self { - kind: "ephemeral".to_string(), - } - } -} - -/// Whether automatic prompt caching applies to this request: the -/// `provider_options..auto_cache` opt-out defaults to enabled. -pub(crate) fn auto_cache_enabled( - provider_options: Option<&serde_json::Value>, - namespace: &str, -) -> bool { - provider_options - .and_then(|opts| opts.get(namespace)) - .and_then(|ns| ns.get("auto_cache")) - .and_then(serde_json::Value::as_bool) - .unwrap_or(true) -} - -/// Index of the message carrying the conversation-prefix breakpoint: the -/// second-to-last user turn, so each iteration of an agent loop reuses the -/// prefix cached by the previous one. `user_turns[i]` is true when message -/// `i` advances the user side of the conversation (plain user messages, plus -/// tool results on dialects where they are separate messages). `None` until -/// the conversation has at least two user turns. -pub(crate) fn conversation_breakpoint_index(user_turns: &[bool]) -> Option { - let indices: Vec = user_turns - .iter() - .enumerate() - .filter_map(|(i, &is_user)| is_user.then_some(i)) - .collect(); - indices.len().checked_sub(2).map(|nth| indices[nth]) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn auto_cache_enabled_by_default() { - assert!(auto_cache_enabled(None, "anthropic")); - } - - #[test] - fn auto_cache_enabled_when_true() { - let opts = serde_json::json!({"anthropic": {"auto_cache": true}}); - assert!(auto_cache_enabled(Some(&opts), "anthropic")); - } - - #[test] - fn auto_cache_disabled_when_false() { - let opts = serde_json::json!({"openrouter": {"auto_cache": false}}); - assert!(!auto_cache_enabled(Some(&opts), "openrouter")); - } - - #[test] - fn auto_cache_enabled_when_key_missing() { - let opts = serde_json::json!({"anthropic": {}}); - assert!(auto_cache_enabled(Some(&opts), "anthropic")); - } - - #[test] - fn auto_cache_reads_only_its_own_namespace() { - let opts = serde_json::json!({"openrouter": {"auto_cache": false}}); - assert!(auto_cache_enabled(Some(&opts), "anthropic")); - } - - #[test] - fn conversation_breakpoint_none_below_two_user_turns() { - assert_eq!(conversation_breakpoint_index(&[]), None); - assert_eq!(conversation_breakpoint_index(&[true]), None); - assert_eq!(conversation_breakpoint_index(&[true, false, false]), None); - } - - #[test] - fn conversation_breakpoint_with_exactly_two_user_turns() { - assert_eq!(conversation_breakpoint_index(&[true, false, true]), Some(0)); - } - - #[test] - fn conversation_breakpoint_targets_second_to_last_user_turn() { - let turns = [true, false, true, false, true]; - assert_eq!(conversation_breakpoint_index(&turns), Some(2)); - } -} diff --git a/lib/components/fabro-llm/src/codec/gemini_generate/decode.rs b/lib/components/fabro-llm/src/codec/gemini_generate/decode.rs deleted file mode 100644 index c0bfdd5bc..000000000 --- a/lib/components/fabro-llm/src/codec/gemini_generate/decode.rs +++ /dev/null @@ -1,370 +0,0 @@ -//! Response decoding: Gemini `generateContent` body → canonical `Response`, -//! plus the gRPC-status error mapping behind the codec's `decode_error`. - -use serde::Deserialize; - -use super::wire::{ApiResponse, CountTokensResponse, UsageMetadata}; -use crate::codec::CodecCtx; -use crate::error::{ - Error, ProviderErrorDetail, ProviderErrorKind, error_from_grpc_status, error_from_status_code, -}; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, ThinkingData, TokenCounts, - ToolCall, -}; - -/// Map Gemini's finish reason, inferring `ToolCalls` from content when needed. -pub(super) fn map_finish_reason(reason: Option<&str>, has_function_calls: bool) -> FinishReason { - if has_function_calls { - return FinishReason::ToolCalls; - } - match reason { - Some("STOP") | None => FinishReason::Stop, - Some("MAX_TOKENS") => FinishReason::Length, - Some("SAFETY" | "RECITATION") => FinishReason::ContentFilter, - Some(other) => FinishReason::Other(other.to_string()), - } -} - -pub(super) fn parse_part(part: &serde_json::Value) -> Option { - if let Some(text) = part.get("text").and_then(serde_json::Value::as_str) { - let is_thought = part - .get("thought") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - if is_thought { - return Some(ContentPart::Thinking(ThinkingData { - text: text.to_string(), - signature: None, - redacted: false, - })); - } - return Some(ContentPart::text(text)); - } - if let Some(fc) = part.get("functionCall") { - let name = fc.get("name")?.as_str()?.to_string(); - let args = fc - .get("args") - .cloned() - .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); - let mut tc = ToolCall::new(uuid::Uuid::new_v4().to_string(), name, args); - // Preserve thought_signature for Gemini 3 models (sibling of functionCall in - // the part) - if let Some(sig) = part.get("thoughtSignature") { - tc.provider_metadata = Some(serde_json::json!({"thoughtSignature": sig})); - } - return Some(ContentPart::ToolCall(tc)); - } - None -} - -/// Check if any parts contain function calls. -pub(super) fn parts_have_function_calls(parts: &[serde_json::Value]) -> bool { - parts.iter().any(|p| p.get("functionCall").is_some()) -} - -/// Convert `UsageMetadata` from the Gemini API into a unified `TokenCounts`. -pub(super) fn parse_usage(metadata: Option<&UsageMetadata>) -> TokenCounts { - metadata.map_or_else(TokenCounts::default, |u| { - let cache_read_tokens = u.cached_content_token_count.unwrap_or(0); - let reasoning_tokens = u.thoughts_token_count.unwrap_or(0); - let tool_use_prompt_tokens = u.tool_use_prompt_token_count.unwrap_or(0); - TokenCounts { - input_tokens: u - .prompt_token_count - .unwrap_or(0) - .saturating_sub(cache_read_tokens) - + tool_use_prompt_tokens, - output_tokens: u.candidates_token_count.unwrap_or(0), - reasoning_tokens, - cache_read_tokens, - ..TokenCounts::default() - } - }) -} - -/// Map a Gemini error response using gRPC status when available, falling back -/// to HTTP status. -pub(super) fn gemini_error( - status_code: u16, - msg: String, - provider: &str, - grpc_status: Option, - raw: Option, - retry_after: Option, -) -> Error { - match grpc_status { - Some(grpc_code) => error_from_grpc_status( - &grpc_code, - msg, - provider.to_string(), - Some(grpc_code.clone()), - raw, - retry_after, - ), - None => error_from_status_code( - status_code, - msg, - provider.to_string(), - None, - raw, - retry_after, - ), - } -} - -pub(super) fn decode_response( - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, -) -> Result { - let raw: serde_json::Value = serde_json::from_str(body) - .map_err(|e| Error::network(format!("failed to parse Gemini response: {e}"), e))?; - let api_resp = ApiResponse::deserialize(&raw) - .map_err(|e| Error::network(format!("failed to parse Gemini response: {e}"), e))?; - - let candidate = api_resp - .candidates - .as_ref() - .and_then(|c| c.first()) - .ok_or_else(|| Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail::new( - "no candidates in Gemini response", - ctx.provider_name, - )), - })?; - - let raw_parts = candidate.content.as_ref().and_then(|c| c.parts.as_ref()); - - let content_parts: Vec = raw_parts - .map(|parts| parts.iter().filter_map(parse_part).collect()) - .unwrap_or_default(); - - // Gemini has no dedicated tool_calls finish reason; infer from parts - let has_tool_calls = raw_parts.is_some_and(|p| parts_have_function_calls(p)); - let finish_reason = map_finish_reason(candidate.finish_reason.as_deref(), has_tool_calls); - - let usage = parse_usage(api_resp.usage_metadata.as_ref()); - - Ok(Response { - id: uuid::Uuid::new_v4().to_string(), - model: ctx.request.model.clone(), - provider: ctx.provider_name.to_string(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason, - usage, - raw: Some(raw), - warnings: vec![], - rate_limit, - cost_usd: None, - cost_source: None, - }) -} - -pub(super) fn decode_count_tokens(body: &str) -> Result { - let response: CountTokensResponse = - serde_json::from_str(body).map_err(|e| Error::Configuration { - message: format!("failed to parse Gemini token count: {e}"), - source: None, - })?; - Ok(response.total_tokens) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn token_counts_disjoint_with_cache_thoughts_and_tool_use() { - let body = serde_json::json!({ - "promptTokenCount": 200, - "cachedContentTokenCount": 180, - "candidatesTokenCount": 200, - "thoughtsTokenCount": 300, - "toolUsePromptTokenCount": 400 - }); - let meta: UsageMetadata = serde_json::from_value(body).unwrap(); - let usage = parse_usage(Some(&meta)); - - assert_eq!(usage.input_tokens, 420); - assert_eq!(usage.cache_read_tokens, 180); - assert_eq!(usage.output_tokens, 200); - assert_eq!(usage.reasoning_tokens, 300); - assert_eq!(usage.cache_write_tokens, 0); - assert_eq!(usage.total_tokens(), 1100); - } - - #[test] - fn gemini_error_uses_grpc_status_when_available() { - let err = gemini_error( - 400, - "model not found".into(), - "gemini", - Some("NOT_FOUND".into()), - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::NotFound, - .. - })); - - let err = gemini_error( - 400, - "bad args".into(), - "gemini", - Some("INVALID_ARGUMENT".into()), - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::InvalidRequest, - .. - })); - - let err = gemini_error( - 429, - "rate limited".into(), - "gemini", - Some("RESOURCE_EXHAUSTED".into()), - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::RateLimit, - .. - })); - - let err = gemini_error( - 401, - "bad key".into(), - "gemini", - Some("UNAUTHENTICATED".into()), - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Authentication, - .. - })); - - let err = gemini_error( - 403, - "denied".into(), - "gemini", - Some("PERMISSION_DENIED".into()), - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::AccessDenied, - .. - })); - - let err = gemini_error( - 504, - "timeout".into(), - "gemini", - Some("DEADLINE_EXCEEDED".into()), - None, - None, - ); - assert!(matches!(err, Error::RequestTimeout { .. })); - } - - #[test] - fn gemini_error_falls_back_to_http_status_without_grpc() { - let err = gemini_error(429, "rate limited".into(), "gemini", None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::RateLimit, - .. - })); - - let err = gemini_error(500, "internal".into(), "gemini", None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Server, - .. - })); - } - - #[test] - fn parse_part_handles_thought_text() { - let part = serde_json::json!({"text": "Let me think about this...", "thought": true}); - let result = parse_part(&part).expect("should parse thought part"); - match result { - ContentPart::Thinking(td) => { - assert_eq!(td.text, "Let me think about this..."); - assert!(td.signature.is_none()); - assert!(!td.redacted); - } - other => panic!("expected Thinking, got {other:?}"), - } - } - - #[test] - fn parse_part_text_without_thought_flag() { - let part = serde_json::json!({"text": "Hello world"}); - let result = parse_part(&part).expect("should parse text part"); - match result { - ContentPart::Text(text) => assert_eq!(text, "Hello world"), - other => panic!("expected Text, got {other:?}"), - } - } - - #[test] - fn parse_part_function_call() { - let part = serde_json::json!({ - "functionCall": { - "name": "get_weather", - "args": {"location": "NYC"} - } - }); - let result = parse_part(&part).expect("should parse function call"); - match result { - ContentPart::ToolCall(tc) => { - assert_eq!(tc.name, "get_weather"); - assert_eq!(tc.arguments, serde_json::json!({"location": "NYC"})); - assert!(tc.provider_metadata.is_none()); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - #[test] - fn parse_part_function_call_with_thought_signature() { - let part = serde_json::json!({ - "functionCall": { - "name": "get_weather", - "args": {"location": "NYC"} - }, - "thoughtSignature": "abc123sig" - }); - let result = parse_part(&part).expect("should parse function call with thought signature"); - match result { - ContentPart::ToolCall(tc) => { - assert_eq!(tc.name, "get_weather"); - let meta = tc - .provider_metadata - .expect("provider_metadata should be set"); - assert_eq!(meta["thoughtSignature"], "abc123sig"); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - #[test] - fn parse_part_thought_false_is_regular_text() { - let part = serde_json::json!({"text": "Regular text", "thought": false}); - let result = parse_part(&part).expect("should parse text part"); - match result { - ContentPart::Text(text) => assert_eq!(text, "Regular text"), - other => panic!("expected Text, got {other:?}"), - } - } -} diff --git a/lib/components/fabro-llm/src/codec/gemini_generate/encode.rs b/lib/components/fabro-llm/src/codec/gemini_generate/encode.rs deleted file mode 100644 index d85ab691e..000000000 --- a/lib/components/fabro-llm/src/codec/gemini_generate/encode.rs +++ /dev/null @@ -1,656 +0,0 @@ -//! Request encoding: canonical request → Gemini `generateContent` body + -//! fully-formed endpoint (model-in-path, `?alt=sse` for streaming). -//! -//! Pure and sync. File-backed Image/Audio/Document attachments are resolved -//! to inline data by `attachments::resolve` in the adapter *before* encode -//! runs, so the content translation here never touches the filesystem. - -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; - -use super::wire::{ - ApiRequest, Content, GeminiFunctionDecl, GeminiToolGroup, GenerationOptions, SystemInstruction, -}; -use crate::codec::{CodecCtx, EncodedRequest, extract_system_prompt}; -use crate::types::{ - ContentPart, Message, ResponseFormat, ResponseFormatType, Role, ToolChoice, ToolDefinition, -}; - -// --- Public entry points ----------------------------------------------------- - -pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> EncodedRequest { - let endpoint = if stream { - format!( - "/models/{}:streamGenerateContent?alt=sse", - ctx.deployment_id - ) - } else { - format!("/models/{}:generateContent", ctx.deployment_id) - }; - EncodedRequest { - body: build_body(ctx), - endpoint, - headers: Vec::new(), - } -} - -pub(super) fn encode_count_tokens(ctx: &CodecCtx<'_>) -> EncodedRequest { - EncodedRequest { - body: serde_json::json!({ "generateContentRequest": build_body(ctx) }), - endpoint: format!("/models/{}:countTokens", ctx.deployment_id), - headers: Vec::new(), - } -} - -/// Build the Gemini API request body from the canonical request. -/// -/// Returns a `serde_json::Value` so that `provider_options.gemini` fields can -/// be merged into the request before sending. -pub(super) fn build_body(ctx: &CodecCtx<'_>) -> serde_json::Value { - let request = ctx.request; - let (system_text, other_messages) = extract_system_prompt(&request.messages); - - let system_instruction = system_text.map(|text| SystemInstruction { - parts: vec![serde_json::json!({"text": text})], - }); - - let contents = translate_messages(&other_messages); - - let (response_mime_type, response_schema) = request - .response_format - .as_ref() - .map_or((None, None), translate_response_format); - - let generation_config = GenerationOptions { - temperature: request.temperature, - max_output_tokens: request.max_tokens, - top_p: request.top_p, - stop_sequences: request.stop_sequences.clone(), - response_mime_type, - response_schema, - }; - - let api_tools = request.tools.as_ref().map(|t| translate_tools(t)); - let tool_config = request.tool_choice.as_ref().map(translate_tool_choice); - - let api_request = ApiRequest { - contents, - system_instruction, - generation_config: Some(generation_config), - tools: api_tools, - tool_config, - }; - - let mut body = serde_json::to_value(&api_request).unwrap_or_default(); - merge_provider_options(&mut body, request.provider_options.as_ref()); - apply_default_safety_settings(&mut body); - body -} - -// --- Content / message / tool translation ------------------------------------ - -/// Build a mapping from tool call ID to function name by scanning assistant -/// messages. -/// -/// Gemini uses function names (not call IDs) in `functionResponse`. Since the -/// decoder generates synthetic UUIDs as tool call IDs, we need this mapping to -/// recover the original function name when sending tool results back. -fn build_tool_call_id_to_name(messages: &[&Message]) -> std::collections::HashMap { - let mut map = std::collections::HashMap::new(); - for msg in messages { - if msg.role == Role::Assistant { - for part in &msg.content { - if let ContentPart::ToolCall(tc) = part { - map.insert(tc.id.clone(), tc.name.clone()); - } - } - } - } - map -} - -/// Encode a media attachment part: URL-backed attachments become `fileData`, -/// inline bytes become base64 `inlineData`. -fn media_part( - url: Option<&str>, - data: Option<&[u8]>, - media_type: Option<&str>, - default_mime: &str, -) -> Option { - let mime = media_type.unwrap_or(default_mime); - match url { - Some(url) => Some(serde_json::json!({ - "fileData": {"mimeType": mime, "fileUri": url} - })), - None => data.map(|data| { - let b64 = BASE64_STANDARD.encode(data); - serde_json::json!({"inlineData": {"mimeType": mime, "data": b64}}) - }), - } -} - -/// Translate unified messages to Gemini content format. Sync: file-backed -/// attachments are already resolved to inline data upstream. -pub(super) fn translate_messages(messages: &[&Message]) -> Vec { - let id_to_name = build_tool_call_id_to_name(messages); - let mut contents: Vec = Vec::new(); - - for msg in messages { - let role = match msg.role { - Role::Assistant => "model", - Role::User | Role::Tool => "user", - Role::System | Role::Developer => continue, - }; - - let mut parts = Vec::new(); - for part in &msg.content { - let maybe_part = match part { - ContentPart::Text(text) => Some(serde_json::json!({"text": text})), - ContentPart::ToolCall(tc) => { - let mut part_json = serde_json::json!({ - "functionCall": { - "name": tc.name, - "args": tc.arguments, - } - }); - // Re-attach thought_signature as sibling of functionCall - if let Some(sig) = tc - .provider_metadata - .as_ref() - .and_then(|m| m.get("thoughtSignature")) - { - part_json["thoughtSignature"] = sig.clone(); - } - Some(part_json) - } - ContentPart::Image(img) => media_part( - img.url.as_deref(), - img.data.as_deref(), - img.media_type.as_deref(), - "image/png", - ), - ContentPart::Audio(audio) => media_part( - audio.url.as_deref(), - audio.data.as_deref(), - audio.media_type.as_deref(), - "audio/wav", - ), - ContentPart::Document(doc) => media_part( - doc.url.as_deref(), - doc.data.as_deref(), - doc.media_type.as_deref(), - "application/pdf", - ), - ContentPart::ToolResult(tr) => { - // Gemini's functionResponse uses the function *name*, not the call ID. - // Look up the original function name from the tool call mapping. - let function_name = id_to_name - .get(&tr.tool_call_id) - .cloned() - .unwrap_or_else(|| tr.tool_call_id.clone()); - let response = tr.content.as_str().map_or_else( - || { - if tr.content.is_object() { - tr.content.clone() - } else { - serde_json::json!({"result": tr.content.to_string()}) - } - }, - |s| serde_json::json!({"result": s}), - ); - Some(serde_json::json!({ - "functionResponse": { - "name": function_name, - "response": response, - } - })) - } - _ => None, - }; - if let Some(part_json) = maybe_part { - parts.push(part_json); - } - } - - if parts.is_empty() { - continue; - } - - contents.push(Content { - role: role.to_string(), - parts, - }); - } - - contents -} - -/// Translate unified tool definitions to Gemini's format. -fn translate_tools(tools: &[ToolDefinition]) -> Vec { - vec![GeminiToolGroup { - function_declarations: tools - .iter() - .map(|t| GeminiFunctionDecl { - name: t.name.clone(), - description: t.description.clone(), - parameters: t.parameters.clone(), - }) - .collect(), - }] -} - -/// Translate unified `ToolChoice` to Gemini's `toolConfig`. -fn translate_tool_choice(choice: &ToolChoice) -> serde_json::Value { - match choice { - ToolChoice::Auto => serde_json::json!({ - "functionCallingConfig": {"mode": "AUTO"} - }), - ToolChoice::None => serde_json::json!({ - "functionCallingConfig": {"mode": "NONE"} - }), - ToolChoice::Required => serde_json::json!({ - "functionCallingConfig": {"mode": "ANY"} - }), - ToolChoice::Named { tool_name } => serde_json::json!({ - "functionCallingConfig": { - "mode": "ANY", - "allowedFunctionNames": [tool_name], - } - }), - } -} - -/// Translate unified `ResponseFormat` to Gemini generation config fields. -/// -/// Returns `(response_mime_type, response_schema)`. -fn translate_response_format( - format: &ResponseFormat, -) -> (Option, Option) { - match format.kind { - ResponseFormatType::Text => (None, None), - ResponseFormatType::JsonObject => (Some("application/json".to_string()), None), - ResponseFormatType::JsonSchema => ( - Some("application/json".to_string()), - format.json_schema.clone(), - ), - } -} - -/// Merge `provider_options.gemini` fields into the serialized API request body. -/// -/// Known fields like `safety_settings` and `cached_content` are set directly. -/// Any other fields are merged at the top level, allowing pass-through of -/// Gemini-specific options not covered by the unified schema. -fn merge_provider_options( - body: &mut serde_json::Value, - provider_options: Option<&serde_json::Value>, -) { - let Some(gemini_opts) = provider_options.and_then(|opts| opts.get("gemini")) else { - return; - }; - let Some(body_map) = body.as_object_mut() else { - return; - }; - let Some(gemini_map) = gemini_opts.as_object() else { - return; - }; - - for (key, value) in gemini_map { - body_map.insert(key.clone(), value.clone()); - } -} - -/// Apply default safety settings if none were provided via provider_options. -fn apply_default_safety_settings(body: &mut serde_json::Value) { - if body.get("safety_settings").is_some() { - return; - } - if let Some(body_map) = body.as_object_mut() { - body_map.insert( - "safety_settings".to_string(), - serde_json::json!([{ - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - }]), - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codec::CodecParams; - use crate::types::{AudioData, DocumentData, Request, ToolCall}; - - fn minimal_request() -> Request { - Request { - model: "gemini-2.0-flash".to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - /// Build the request body the way the adapter's encode path does (no - /// catalog: the wire model id is the request model). - fn body_for(request: &Request) -> serde_json::Value { - let params = CodecParams::default(); - let ctx = CodecCtx { - request, - provider_name: "gemini", - deployment_id: &request.model, - model: None, - params: ¶ms, - }; - build_body(&ctx) - } - - #[test] - fn provider_options_none_produces_standard_body() { - let request = minimal_request(); - let body = body_for(&request); - assert!(body.get("safetySettings").is_none()); - assert!(body.get("cachedContent").is_none()); - } - - #[test] - fn encode_endpoints_carry_model_and_streaming_variant() { - let request = minimal_request(); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "gemini", - deployment_id: &request.model, - model: None, - params: ¶ms, - }; - - assert_eq!( - encode(&ctx, false).endpoint, - "/models/gemini-2.0-flash:generateContent" - ); - assert_eq!( - encode(&ctx, true).endpoint, - "/models/gemini-2.0-flash:streamGenerateContent?alt=sse" - ); - assert_eq!( - encode_count_tokens(&ctx).endpoint, - "/models/gemini-2.0-flash:countTokens" - ); - } - - #[test] - fn count_tokens_body_uses_only_generate_content_request_top_level() { - let mut request = minimal_request(); - request.tools = Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object"}), - )]); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "gemini", - deployment_id: &request.model, - model: None, - params: ¶ms, - }; - let count_body = encode_count_tokens(&ctx).body; - - assert!(count_body.get("generateContentRequest").is_some()); - assert!(count_body.get("contents").is_none()); - assert!( - count_body["generateContentRequest"] - .get("contents") - .is_some() - ); - assert!(count_body["generateContentRequest"].get("tools").is_some()); - } - - #[test] - fn provider_options_gemini_safety_settings_merged() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "gemini": { - "safetySettings": [ - {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"} - ] - } - })); - - let body = body_for(&request); - let safety = body - .get("safetySettings") - .expect("safetySettings should be present"); - let arr = safety.as_array().expect("should be an array"); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["category"], "HARM_CATEGORY_HARASSMENT"); - } - - #[test] - fn provider_options_gemini_cached_content_merged() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "gemini": { - "cachedContent": "projects/my-project/cachedContents/abc123" - } - })); - - let body = body_for(&request); - assert_eq!( - body.get("cachedContent") - .and_then(serde_json::Value::as_str), - Some("projects/my-project/cachedContents/abc123") - ); - } - - #[test] - fn provider_options_gemini_multiple_fields_merged() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "gemini": { - "safetySettings": [{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_LOW_AND_ABOVE"}], - "cachedContent": "cache-id", - "customField": "custom-value" - } - })); - - let body = body_for(&request); - assert!(body.get("safetySettings").is_some()); - assert_eq!( - body.get("cachedContent") - .and_then(serde_json::Value::as_str), - Some("cache-id") - ); - assert_eq!( - body.get("customField").and_then(serde_json::Value::as_str), - Some("custom-value") - ); - } - - #[test] - fn provider_options_other_provider_ignored() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "anthropic": { - "auto_cache": false - } - })); - - let body = body_for(&request); - assert!(body.get("auto_cache").is_none()); - } - - #[test] - fn provider_options_gemini_preserves_standard_fields() { - let mut request = minimal_request(); - request.temperature = Some(0.5); - request.max_tokens = Some(100); - request.provider_options = Some(serde_json::json!({ - "gemini": { - "cachedContent": "cache-id" - } - })); - - let body = body_for(&request); - let gen_config = body - .get("generationConfig") - .expect("generationConfig should exist"); - assert_eq!( - gen_config - .get("temperature") - .and_then(serde_json::Value::as_f64), - Some(0.5) - ); - assert_eq!( - gen_config - .get("maxOutputTokens") - .and_then(serde_json::Value::as_i64), - Some(100) - ); - assert_eq!( - body.get("cachedContent") - .and_then(serde_json::Value::as_str), - Some("cache-id") - ); - } - - #[test] - fn merge_provider_options_with_non_object_gemini_value() { - let mut body = serde_json::json!({"contents": []}); - let opts = serde_json::json!({"gemini": "not-an-object"}); - merge_provider_options(&mut body, Some(&opts)); - // Should not crash and body should be unchanged - assert!(body.get("contents").is_some()); - } - - #[test] - fn audio_url_translates_to_file_data() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Audio(AudioData { - url: Some("https://example.com/audio.wav".to_string()), - data: None, - media_type: Some("audio/wav".to_string()), - })], - name: None, - tool_call_id: None, - }; - let contents = translate_messages(&[&msg]); - assert_eq!(contents.len(), 1); - let part = &contents[0].parts[0]; - assert_eq!(part["fileData"]["mimeType"], "audio/wav"); - assert_eq!(part["fileData"]["fileUri"], "https://example.com/audio.wav"); - } - - #[test] - fn audio_base64_translates_to_inline_data() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Audio(AudioData { - url: None, - data: Some(vec![0xFF, 0xFB, 0x90]), - media_type: None, - })], - name: None, - tool_call_id: None, - }; - let contents = translate_messages(&[&msg]); - let part = &contents[0].parts[0]; - assert_eq!(part["inlineData"]["mimeType"], "audio/wav"); - assert!(part["inlineData"]["data"].as_str().is_some()); - } - - #[test] - fn document_url_translates_to_file_data() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Document(DocumentData { - url: Some("https://example.com/doc.pdf".to_string()), - data: None, - media_type: Some("application/pdf".to_string()), - file_name: Some("doc.pdf".to_string()), - })], - name: None, - tool_call_id: None, - }; - let contents = translate_messages(&[&msg]); - let part = &contents[0].parts[0]; - assert_eq!(part["fileData"]["mimeType"], "application/pdf"); - assert_eq!(part["fileData"]["fileUri"], "https://example.com/doc.pdf"); - } - - #[test] - fn document_base64_translates_to_inline_data() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Document(DocumentData { - url: None, - data: Some(vec![0x25, 0x50, 0x44, 0x46]), - media_type: None, - file_name: None, - })], - name: None, - tool_call_id: None, - }; - let contents = translate_messages(&[&msg]); - let part = &contents[0].parts[0]; - assert_eq!(part["inlineData"]["mimeType"], "application/pdf"); - assert!(part["inlineData"]["data"].as_str().is_some()); - } - - #[test] - fn translate_messages_function_call_includes_thought_signature() { - let mut tc = ToolCall::new( - "call-1", - "get_weather", - serde_json::json!({"location": "NYC"}), - ); - tc.provider_metadata = Some(serde_json::json!({"thoughtSignature": "sig456"})); - - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - let contents = translate_messages(&[&msg]); - assert_eq!(contents.len(), 1); - - let part = &contents[0].parts[0]; - assert!(part.get("functionCall").is_some()); - assert_eq!(part["thoughtSignature"], "sig456"); - } - - #[test] - fn translate_messages_function_call_without_thought_signature() { - let tc = ToolCall::new( - "call-1", - "get_weather", - serde_json::json!({"location": "NYC"}), - ); - - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - let contents = translate_messages(&[&msg]); - assert_eq!(contents.len(), 1); - - let part = &contents[0].parts[0]; - assert!(part.get("functionCall").is_some()); - assert!(part.get("thoughtSignature").is_none()); - } -} diff --git a/lib/components/fabro-llm/src/codec/gemini_generate/mod.rs b/lib/components/fabro-llm/src/codec/gemini_generate/mod.rs deleted file mode 100644 index dab98c4c9..000000000 --- a/lib/components/fabro-llm/src/codec/gemini_generate/mod.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! The Gemini `generateContent` codec. -//! -//! Pure translation: no HTTP, auth, or base URL — the adapter shell owns -//! those. The codec is distinctive in two ways: it fully forms its endpoints -//! (model-in-path plus `?alt=sse` for streaming), and it overrides -//! `decode_error` to map Gemini's gRPC status codes out of error bodies -//! (falling back to the HTTP status). Tool-call ids are synthetic UUIDs — -//! Gemini keys `functionResponse` on the function *name*, recovered via an -//! id→name map built from the request's assistant turns. - -mod decode; -mod encode; -mod stream; -mod wire; - -use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder, parse_error_body}; -use crate::error::Error; -use crate::types::{RateLimitInfo, Response}; - -/// Codec for the Gemini `generateContent` wire dialect. -pub(crate) struct GeminiGenerate; - -impl Codec for GeminiGenerate { - fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result { - Ok(encode::encode(ctx, stream)) - } - - fn decode_response( - &self, - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Result { - decode::decode_response(body, ctx, rate_limit) - } - - fn stream_decoder( - &self, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Box { - Box::new(stream::SseAccumulator::new(ctx, rate_limit)) - } - - fn encode_count_tokens(&self, ctx: &CodecCtx<'_>) -> Option> { - Some(Ok(encode::encode_count_tokens(ctx))) - } - - fn decode_count_tokens(&self, body: &str) -> Result { - decode::decode_count_tokens(body) - } - - /// Gemini errors carry a gRPC status in the body's `status` field; map it - /// when present, falling back to the HTTP status code. - fn decode_error( - &self, - status: u16, - body: &str, - ctx: &CodecCtx<'_>, - retry_after: Option, - ) -> Error { - let (msg, code, raw) = parse_error_body(body, "status"); - decode::gemini_error(status, msg, ctx.provider_name, code, raw, retry_after) - } -} diff --git a/lib/components/fabro-llm/src/codec/gemini_generate/stream.rs b/lib/components/fabro-llm/src/codec/gemini_generate/stream.rs deleted file mode 100644 index 51c39eb99..000000000 --- a/lib/components/fabro-llm/src/codec/gemini_generate/stream.rs +++ /dev/null @@ -1,458 +0,0 @@ -//! Streaming decoder: Gemini SSE chunks → canonical `StreamEvent`s. -//! -//! Byte reading and line framing live in the transport; this decoder is fed -//! framed `RawEvent`s carrying bare `data:` payloads (Gemini uses data-only -//! SSE — no event types, no `[DONE]` sentinel). Gemini has no terminal wire -//! event, so `finish()` synthesizes the `Finish` from accumulated state -//! unconditionally at byte-stream end. - -use super::decode::{map_finish_reason, parse_usage}; -use super::wire::ApiResponse; -use crate::codec::{CodecCtx, RawEvent, StreamDecoder}; -use crate::error::Error; -use crate::types::{ - ContentPart, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData, TokenCounts, - ToolCall, -}; - -/// Accumulated state across SSE chunks during streaming. -pub(super) struct SseAccumulator { - /// Requested model, stamped into the synthesized final `Response`. - model: String, - /// Configured provider name stamped into the final `Response.provider`. - provider: String, - /// Whether we have emitted a `TextStart` event. - text_started: bool, - /// Whether we are currently inside a reasoning (thought) segment. - reasoning_started: bool, - /// Accumulated thinking text across all chunks. - accumulated_thinking: String, - /// Accumulated text across all chunks. - accumulated_text: String, - /// Accumulated tool calls across all chunks. - accumulated_tool_calls: Vec, - /// The `text_id` used for `TextStart`/`TextDelta`/`TextEnd`. - text_id: String, - /// Latest usage metadata (updated per chunk; final chunk has totals). - usage: TokenCounts, - /// The finish reason string from the candidate, if received. - finish_reason_str: Option, - /// Whether we have emitted the `Finish` event. - finished: bool, - /// Rate limit info parsed from HTTP response headers. - rate_limit: Option, -} - -impl SseAccumulator { - pub(super) fn new(ctx: &CodecCtx<'_>, rate_limit: Option) -> Self { - Self { - model: ctx.request.model.clone(), - provider: ctx.provider_name.to_string(), - text_started: false, - reasoning_started: false, - accumulated_thinking: String::new(), - accumulated_text: String::new(), - accumulated_tool_calls: Vec::new(), - text_id: uuid::Uuid::new_v4().to_string(), - usage: TokenCounts::default(), - finish_reason_str: None, - finished: false, - rate_limit, - } - } - - /// Extract stream events from a parsed SSE chunk. - fn process_chunk(&mut self, chunk: &ApiResponse) -> Vec { - let mut events = Vec::new(); - - let parts = chunk - .candidates - .as_ref() - .and_then(|c| c.first()) - .and_then(|c| c.content.as_ref()) - .and_then(|c| c.parts.as_ref()); - - if let Some(parts) = parts { - for part in parts { - let is_thought = part - .get("thought") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - - if let Some(text) = part.get("text").and_then(serde_json::Value::as_str) { - if is_thought { - if !self.reasoning_started { - self.reasoning_started = true; - events.push(StreamEvent::ReasoningStart); - } - self.accumulated_thinking.push_str(text); - events.push(StreamEvent::ReasoningDelta { - delta: text.to_string(), - }); - } else { - // Transition from reasoning to text: close reasoning segment. - if self.reasoning_started { - self.reasoning_started = false; - events.push(StreamEvent::ReasoningEnd); - } - if !self.text_started { - self.text_started = true; - events.push(StreamEvent::TextStart { - text_id: Some(self.text_id.clone()), - }); - } - self.accumulated_text.push_str(text); - events.push(StreamEvent::text_delta(text, Some(self.text_id.clone()))); - } - } else if let Some(fc) = part.get("functionCall") { - let name = fc - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - let args = fc - .get("args") - .cloned() - .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); - let mut tool_call = ToolCall::new(uuid::Uuid::new_v4().to_string(), name, args); - // Preserve thought_signature for Gemini 3 models (sibling of - // functionCall) - if let Some(sig) = part.get("thoughtSignature") { - tool_call.provider_metadata = - Some(serde_json::json!({"thoughtSignature": sig})); - } - - // Gemini delivers function calls as complete objects in a single - // chunk. - events.push(StreamEvent::ToolCallStart { - tool_call: tool_call.clone(), - }); - events.push(StreamEvent::ToolCallEnd { - tool_call: tool_call.clone(), - }); - self.accumulated_tool_calls.push(tool_call); - } - } - } - - // If a finish reason is present on this chunk's candidate, emit TextEnd. - let has_finish_reason = chunk - .candidates - .as_ref() - .and_then(|c| c.first()) - .and_then(|c| c.finish_reason.as_ref()) - .is_some(); - - if has_finish_reason { - if self.reasoning_started { - self.reasoning_started = false; - events.push(StreamEvent::ReasoningEnd); - } - if self.text_started { - events.push(StreamEvent::TextEnd { - text_id: Some(self.text_id.clone()), - }); - } - } - - events - } - - /// Build the final `Finish` event from accumulated state. - fn build_finish_event(&self) -> StreamEvent { - let has_tool_calls = !self.accumulated_tool_calls.is_empty(); - let finish_reason = map_finish_reason(self.finish_reason_str.as_deref(), has_tool_calls); - - let mut content_parts: Vec = Vec::new(); - if !self.accumulated_thinking.is_empty() { - content_parts.push(ContentPart::Thinking(ThinkingData { - text: self.accumulated_thinking.clone(), - signature: None, - redacted: false, - })); - } - if !self.accumulated_text.is_empty() { - content_parts.push(ContentPart::text(&self.accumulated_text)); - } - for tc in &self.accumulated_tool_calls { - content_parts.push(ContentPart::ToolCall(tc.clone())); - } - - let response = Response { - id: uuid::Uuid::new_v4().to_string(), - model: self.model.clone(), - provider: self.provider.clone(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason: finish_reason.clone(), - usage: self.usage.clone(), - raw: None, - warnings: vec![], - rate_limit: self.rate_limit.clone(), - cost_usd: None, - cost_source: None, - }; - - StreamEvent::finish(finish_reason, self.usage.clone(), response) - } -} - -impl StreamDecoder for SseAccumulator { - fn on_event(&mut self, ev: RawEvent<'_>) -> Result, Error> { - // Parse the JSON chunk. - let chunk: ApiResponse = serde_json::from_str(ev.data).map_err(|e| { - Error::stream_error(format!("failed to parse Gemini SSE chunk: {e}"), e) - })?; - - let events = self.process_chunk(&chunk); - - // Track usage from every chunk; the final one will have the totals. - if let Some(ref usage_meta) = chunk.usage_metadata { - self.usage = parse_usage(Some(usage_meta)); - } - - // Extract finish reason from the candidate if present. - let candidate_finish = chunk - .candidates - .as_ref() - .and_then(|c| c.first()) - .and_then(|c| c.finish_reason.clone()); - if let Some(reason) = candidate_finish { - self.finish_reason_str = Some(reason); - } - - Ok(events) - } - - fn finish(&mut self) -> Vec { - // Gemini has no terminal wire event: synthesize the Finish from - // accumulated state, exactly once, at byte-stream end. - if self.finished { - return Vec::new(); - } - self.finished = true; - vec![self.build_finish_event()] - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::FinishReason; - - /// Build an accumulator without threading a `CodecCtx`/`Request`: the test - /// module sees the private fields, so the few that matter are set - /// directly. - fn empty_accumulator() -> SseAccumulator { - SseAccumulator { - model: "gemini-2.0-flash".to_string(), - provider: "gemini".to_string(), - text_started: false, - reasoning_started: false, - accumulated_thinking: String::new(), - accumulated_text: String::new(), - accumulated_tool_calls: Vec::new(), - text_id: "text-1".to_string(), - usage: TokenCounts::default(), - finish_reason_str: None, - finished: false, - rate_limit: None, - } - } - - fn on_data(acc: &mut SseAccumulator, data: &str) -> Result, Error> { - acc.on_event(RawEvent { event: None, data }) - } - - #[test] - fn first_chunk_opens_text_without_a_decoder_level_stream_start() { - let mut acc = empty_accumulator(); - - let events = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}]}}]}"#, - ) - .expect("chunk should parse"); - - // `StreamStart` is the driving loop's, so the decoder's first event - // is the content itself. - assert!(matches!(events[0], StreamEvent::TextStart { .. })); - assert!(matches!(events[1], StreamEvent::TextDelta { .. })); - } - - #[test] - fn text_deltas_accumulate_with_stable_text_id() { - let mut acc = empty_accumulator(); - - let first = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"Hel"}]}}]}"#, - ) - .expect("first chunk should parse"); - let second = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"lo"}]}}]}"#, - ) - .expect("second chunk should parse"); - - assert!( - matches!(&first[0], StreamEvent::TextStart { text_id: Some(id) } if id == "text-1") - ); - assert!( - matches!(&first[1], StreamEvent::TextDelta { delta, text_id: Some(id) } if delta == "Hel" && id == "text-1") - ); - // Second chunk: no duplicate TextStart. - assert_eq!(second.len(), 1); - assert!(matches!(&second[0], StreamEvent::TextDelta { delta, .. } if delta == "lo")); - assert_eq!(acc.accumulated_text, "Hello"); - } - - #[test] - fn thought_then_text_transitions_reasoning_to_text() { - let mut acc = empty_accumulator(); - - let thought = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"Pondering...","thought":true}]}}]}"#, - ) - .expect("thought chunk should parse"); - let text = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"Answer"}]}}]}"#, - ) - .expect("text chunk should parse"); - - assert!(matches!(thought[0], StreamEvent::ReasoningStart)); - assert!( - matches!(&thought[1], StreamEvent::ReasoningDelta { delta } if delta == "Pondering...") - ); - // Transition closes the reasoning segment before text begins. - assert!(matches!(text[0], StreamEvent::ReasoningEnd)); - assert!(matches!(text[1], StreamEvent::TextStart { .. })); - assert!(matches!(&text[2], StreamEvent::TextDelta { delta, .. } if delta == "Answer")); - assert_eq!(acc.accumulated_thinking, "Pondering..."); - } - - #[test] - fn function_call_emits_start_and_end_in_one_chunk() { - let mut acc = empty_accumulator(); - - let events = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"NYC"}},"thoughtSignature":"sig1"}]}}]}"#, - ) - .expect("function call chunk should parse"); - - assert_eq!(events.len(), 2); - let (start_tc, end_tc) = match (&events[0], &events[1]) { - ( - StreamEvent::ToolCallStart { tool_call: start }, - StreamEvent::ToolCallEnd { tool_call: end }, - ) => (start, end), - other => panic!("expected ToolCallStart + ToolCallEnd, got {other:?}"), - }; - assert_eq!(start_tc.name, "get_weather"); - assert_eq!(start_tc.id, end_tc.id); - assert_eq!( - start_tc.provider_metadata.as_ref().unwrap()["thoughtSignature"], - "sig1" - ); - assert_eq!(acc.accumulated_tool_calls.len(), 1); - } - - #[test] - fn finish_reason_chunk_emits_text_end_and_records_reason() { - let mut acc = empty_accumulator(); - on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}]}}]}"#, - ) - .expect("text chunk should parse"); - - let events = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}"#, - ) - .expect("finish chunk should parse"); - - assert!(matches!(&events[0], StreamEvent::TextEnd { text_id: Some(id) } if id == "text-1")); - assert_eq!(acc.finish_reason_str.as_deref(), Some("STOP")); - assert_eq!(acc.usage.input_tokens, 10); - assert_eq!(acc.usage.output_tokens, 5); - } - - #[test] - fn finish_synthesizes_final_response_exactly_once() { - let mut acc = empty_accumulator(); - on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}]},"finishReason":"STOP"}]}"#, - ) - .expect("chunk should parse"); - - let events = acc.finish(); - assert_eq!(events.len(), 1); - match &events[0] { - StreamEvent::Finish { - finish_reason, - response, - .. - } => { - assert_eq!(*finish_reason, FinishReason::Stop); - assert_eq!(response.text(), "Hello"); - assert_eq!(response.provider, "gemini"); - assert_eq!(response.model, "gemini-2.0-flash"); - } - other => panic!("expected Finish, got {other:?}"), - } - - // A second finish() (defensive) synthesizes nothing. - assert!(acc.finish().is_empty()); - } - - #[test] - fn finish_without_any_finish_reason_still_synthesizes() { - // Gemini has no terminal wire event; byte-stream end must produce a - // Finish even when no chunk carried a finishReason. - let mut acc = empty_accumulator(); - on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"partial"}]}}]}"#, - ) - .expect("chunk should parse"); - - let events = acc.finish(); - assert_eq!(events.len(), 1); - assert!( - matches!(&events[0], StreamEvent::Finish { finish_reason, .. } - if *finish_reason == FinishReason::Stop) - ); - } - - #[test] - fn finish_infers_tool_calls_finish_reason() { - let mut acc = empty_accumulator(); - on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"search","args":{}}}]},"finishReason":"STOP"}]}"#, - ) - .expect("chunk should parse"); - - let events = acc.finish(); - assert!( - matches!(&events[0], StreamEvent::Finish { finish_reason, .. } - if *finish_reason == FinishReason::ToolCalls) - ); - } - - #[test] - fn malformed_chunk_yields_stream_error() { - let mut acc = empty_accumulator(); - let err = on_data(&mut acc, "not json").expect_err("bad chunk should error"); - assert!(matches!(err, Error::Stream { .. })); - } -} diff --git a/lib/components/fabro-llm/src/codec/gemini_generate/wire.rs b/lib/components/fabro-llm/src/codec/gemini_generate/wire.rs deleted file mode 100644 index d091d1464..000000000 --- a/lib/components/fabro-llm/src/codec/gemini_generate/wire.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Serde types mirroring the Gemini `generateContent` wire shapes. - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct ApiRequest { - pub contents: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub system_instruction: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub generation_config: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_config: Option, -} - -#[derive(serde::Serialize)] -pub(super) struct Content { - pub role: String, - pub parts: Vec, -} - -#[derive(serde::Serialize)] -pub(super) struct SystemInstruction { - pub parts: Vec, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct GenerationOptions { - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_output_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub top_p: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stop_sequences: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub response_mime_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub response_schema: Option, -} - -/// Gemini groups function declarations under a `tools` array. -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct GeminiToolGroup { - pub function_declarations: Vec, -} - -#[derive(serde::Serialize)] -pub(super) struct GeminiFunctionDecl { - pub name: String, - pub description: String, - pub parameters: serde_json::Value, -} - -// --- Response types --- - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct ApiResponse { - pub candidates: Option>, - pub usage_metadata: Option, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct Candidate { - pub content: Option, - pub finish_reason: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct CandidateContent { - pub parts: Option>, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -#[allow( - clippy::struct_field_names, - reason = "Field names mirror the provider API payload." -)] -pub(super) struct UsageMetadata { - pub prompt_token_count: Option, - pub candidates_token_count: Option, - pub thoughts_token_count: Option, - pub cached_content_token_count: Option, - pub tool_use_prompt_token_count: Option, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct CountTokensResponse { - pub total_tokens: i64, -} diff --git a/lib/components/fabro-llm/src/codec/mod.rs b/lib/components/fabro-llm/src/codec/mod.rs deleted file mode 100644 index 0e90c471f..000000000 --- a/lib/components/fabro-llm/src/codec/mod.rs +++ /dev/null @@ -1,419 +0,0 @@ -//! The codec seam: pure, sync translation between the canonical core -//! (`Request`/`Response`/`StreamEvent`) and a provider wire dialect. -//! -//! A codec knows *what the bytes say*. It does NOT know how they travel -//! (auth, base URL, retries, streaming transport) — that's the adapter/ -//! transport layer. Everything a codec varies on arrives as data in -//! [`CodecCtx`] / [`CodecParams`]; codecs hold no per-request state. -//! -//! The trait is intentionally complete (count-tokens + error mapping have -//! defaults) so the per-dialect codecs that follow only ever *override* -//! methods, never extend the contract. - -pub(crate) mod anthropic_messages; -pub(crate) mod bedrock_converse; -pub(crate) mod cache; -pub(crate) mod gemini_generate; -pub(crate) mod openai_compatible; -pub(crate) mod openai_responses; - -use fabro_model::Model; - -use crate::error::{Error, error_from_status_code}; -use crate::types::{Message, RateLimitInfo, Request, Response, Role, StreamEvent}; - -/// Parse a streamed/generated tool-argument JSON string, defaulting malformed -/// or absent arguments to the canonical no-argument object. -pub(crate) fn parse_tool_arguments_or_empty(raw_arguments: &str) -> serde_json::Value { - serde_json::from_str(raw_arguments).unwrap_or_else(|_| serde_json::json!({})) -} - -/// Split an inclusive provider token total into disjoint base and detail -/// buckets. Provider detail counts are advisory and occasionally exceed their -/// parent total, so bound both values while preserving the nonnegative total. -pub(crate) fn split_inclusive_token_total(total: i64, detail: i64) -> (i64, i64) { - let total = total.max(0); - let detail = detail.clamp(0, total); - (total - detail, detail) -} - -/// Merge `provider_options.` fields into an encoded request -/// body. Used by codecs whose provider-options namespace is adapter-name keyed -/// rather than a single fixed provider. `known_keys` are control keys the -/// codec consumed itself (e.g. `auto_cache`); they are not re-merged into the -/// body. -pub(crate) fn merge_named_provider_options( - body: &mut serde_json::Value, - provider_options: Option<&serde_json::Value>, - provider_name: &str, - known_keys: &[&str], -) { - let Some(opts) = provider_options.and_then(|opts| opts.get(provider_name)) else { - return; - }; - let Some(body_map) = body.as_object_mut() else { - return; - }; - let Some(opts_map) = opts.as_object() else { - return; - }; - - for (key, value) in opts_map { - if known_keys.contains(&key.as_str()) { - continue; - } - body_map.insert(key.clone(), value.clone()); - } -} - -/// Per-request context. Borrowed — the codec reads what it needs and returns. -pub(crate) struct CodecCtx<'a> { - /// The canonical request being translated. Decoders read it too - /// (e.g. tool-argument parsing keys off the request's tool definitions; - /// the stream model fallback uses `request.model`). - pub request: &'a Request, - /// Identity stamped into `Response.provider`, and the `provider_options` - /// namespace key for the openai_compatible codec (moonshot/zai/…). - pub provider_name: &'a str, - /// The model id to send on the wire — catalog `api_id`, resolved by the - /// route (today `api_id == id` everywhere). - pub deployment_id: &'a str, - /// Model row for capability lookups (prompt_cache, reasoning levels, - /// max_output). `None` when no catalog is injected. - pub model: Option<&'a Model>, - /// Per-route dialect data (model/version placement, …). Defaulted to - /// today's direct-route values; Bedrock/OpenRouter add variants later. - pub params: &'a CodecParams, -} - -/// Per-route dialect knobs, expressed as data so one codec can serve several -/// routes. The default is inert ("nothing special"); a route that needs a -/// dialect quirk sets the relevant field. Grows as codecs need it — #459 adds -/// `ModelPlacement` for Bedrock. Inert for codecs that don't read a given -/// field. -#[derive(Debug, Default, Clone)] -pub(crate) struct CodecParams { - /// Where/whether to place the Anthropic API version. Direct Anthropic uses - /// `Header("2023-06-01")`; Kimi-over-anthropic uses `None`; the Bedrock - /// redo will add a body-field variant. Inert for non-anthropic codecs. - pub anthropic_version: AnthropicVersion, - /// Whether to emit Anthropic beta headers (prompt-caching / fast-mode / - /// 1M-context). True on the direct route, false for Kimi-over-anthropic. - pub anthropic_beta: bool, - /// Codex-endpoint dialect for the openai_responses codec: omit the - /// sampling params (`temperature`/`top_p`/`max_output_tokens`) the Codex - /// endpoint rejects and always send `instructions` (empty string when the - /// request has none). The transport-side half of codex mode (forced - /// streaming) is route config, not codec data. - pub openai_codex: bool, -} - -/// Placement of the Anthropic API version on the wire. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) enum AnthropicVersion { - /// No version sent (Kimi-over-anthropic; also the inert default). - #[default] - None, - /// `anthropic-version` request header (direct Anthropic). - Header(&'static str), - // BodyField(&'static str) arrives with the Bedrock redo (#459). -} - -/// What [`Codec::encode`] produces. The transport applies `endpoint` + -/// `headers` on top of the route's base URL and auth; the codec never touches -/// HTTP. -pub(crate) struct EncodedRequest { - /// Request body. - pub body: serde_json::Value, - /// Path appended to the route base URL, fully formed by the codec - /// (incl. model-in-path and `?alt=sse` for gemini). e.g. - /// `/chat/completions`. - pub endpoint: String, - /// Dialect headers as data (e.g. `anthropic-version`, beta headers). - /// NOT auth or `content-type` — those are the transport's job. Empty for - /// the openai_compatible codec. - pub headers: Vec<(String, String)>, -} - -/// One framed item off the byte stream, handed to a [`StreamDecoder`]. -pub(crate) struct RawEvent<'a> { - /// SSE `event:` type — `Some` when the framing carries one (anthropic, - /// openai responses); `None` for the data-only framing - /// openai_compatible/gemini use. - pub event: Option<&'a str>, - /// The `data:` payload, or a bare JSON line. The sentinel `[DONE]` is - /// passed through verbatim for the decoder to recognize. - pub data: &'a str, -} - -/// Stateless translator for one wire dialect. -pub(crate) trait Codec: Send + Sync { - /// Canonical request (`ctx.request`) → wire request. `stream` selects the - /// streaming shape (`stream: true` in the body, gemini's - /// `:streamGenerateContent` endpoint). Fallible: attachment/parameter - /// encoding can reject. - fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result; - - /// Wire response body → canonical `Response` (content parts, finish - /// reason, usage). Each dialect's finish-reason map and usage arithmetic - /// live here. Stamps `ctx.provider_name` into `Response.provider` and the - /// transport-parsed `rate_limit` into the response. - fn decode_response( - &self, - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Result; - - /// A fresh stateful decoder for one streaming response. `rate_limit` is the - /// transport-parsed header value to embed in the synthesized `Finish`. - fn stream_decoder( - &self, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Box; - - /// The third route, if the dialect has one (`/messages/count_tokens`, - /// `/responses/input_tokens`, `:countTokens`). `None` = the dialect has no - /// such route. Whether a given *deployment* may use it is a separate - /// route-level gate (Kimi-over-anthropic) decided before this is called. - fn encode_count_tokens(&self, _ctx: &CodecCtx<'_>) -> Option> { - None - } - - /// Parse the token count out of a count-tokens response. Only called when - /// [`Codec::encode_count_tokens`] returned `Some`; the default guards the - /// invariant for codecs without a count route. - fn decode_count_tokens(&self, _body: &str) -> Result { - Err(Error::Configuration { - message: "codec has no count_tokens route".to_string(), - source: None, - }) - } - - /// Map a non-2xx response to an `Error`. `retry_after` is the - /// transport-parsed `retry-after` header value in seconds (header parsing - /// is the transport's job, like `rate_limit` on the decode methods). - /// Default = shared HTTP-status mapping, which openai_compatible and - /// anthropic use as-is; a codec overrides when its dialect's error bodies - /// need more (e.g. gemini's gRPC status). - fn decode_error( - &self, - status: u16, - body: &str, - ctx: &CodecCtx<'_>, - retry_after: Option, - ) -> Error { - let (message, code, raw) = parse_error_body(body, "type"); - error_from_status_code( - status, - message, - ctx.provider_name.to_string(), - code, - raw, - retry_after, - ) - } -} - -/// Stateful per-stream decoder, driven by the shared transport loop. -/// `'static` because it is boxed into the stream's unfold state. -pub(crate) trait StreamDecoder: Send + 'static { - /// One framed event → zero or more canonical `StreamEvent`s. Returns - /// `Err` for dialect error events (anthropic `error`, openai - /// `response.failed`), which the transport yields as a stream error. - /// - /// Decoders must **not** emit [`StreamEvent::StreamStart`]. The driving - /// loop emits exactly one, immediately before handing over the first - /// framed event, so `StreamStart` means the same thing for every - /// provider: the provider is responding, whatever it turns out to say. - /// Leaving it to decoders made it depend on each dialect's opening frame - /// — anthropic and bedrock keyed it on `message_start`/`messageStart`, - /// and `openai_compatible` had no such frame and so emitted it never. - fn on_event(&mut self, ev: RawEvent<'_>) -> Result, Error>; - - /// Byte-stream-end hook. Semantics are per-decoder, not shared: - /// anthropic — return nothing (`message_stop` already finished it); - /// openai_compatible — synthesize `Finish` iff content started (minimax); - /// gemini — synthesize `Finish` unconditionally if not yet finished. - fn finish(&mut self) -> Vec; -} - -// --- Dialect-neutral translation helpers -// --------------------------------------- - -/// Parse an error response body, extracting the message and error code. -/// -/// `error_code_field` is the JSON field name for the error code (e.g. "type" or -/// "status"). -#[must_use] -pub(crate) fn parse_error_body( - body: &str, - error_code_field: &str, -) -> (String, Option, Option) { - serde_json::from_str::(body).map_or_else( - |_| (body.to_string(), None, None), - |v| { - let message = v - .get("error") - .and_then(|e| e.get("message")) - .and_then(serde_json::Value::as_str) - // Codex endpoint returns {"detail": "..."} instead of {"error": {"message": "..."}} - .or_else(|| v.get("detail").and_then(serde_json::Value::as_str)) - .unwrap_or("Unknown error") - .to_string(); - let error_code = v - .get("error") - .and_then(|e| e.get(error_code_field)) - .and_then(serde_json::Value::as_str) - .map(String::from); - (message, error_code, Some(v)) - }, - ) -} - -/// Extract system and developer messages from a message list. -/// -/// Returns the joined system prompt and the remaining messages. -/// Per spec, Developer role messages are merged with system messages -/// for Anthropic and Gemini. -#[must_use] -pub(crate) fn extract_system_prompt(messages: &[Message]) -> (Option, Vec<&Message>) { - let mut system_parts = Vec::new(); - let mut other = Vec::new(); - for msg in messages { - if msg.role == Role::System || msg.role == Role::Developer { - let text = msg.text(); - if !text.trim().is_empty() { - system_parts.push(text); - } - } else { - other.push(msg); - } - } - let system = if system_parts.is_empty() { - None - } else { - Some(system_parts.join("\n")) - }; - (system, other) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::ContentPart; - - // --- parse_error_body --- - - #[test] - fn parse_error_body_valid_json() { - let body = r#"{"error":{"message":"rate limited","type":"rate_limit_error"}}"#; - let (msg, code, raw) = parse_error_body(body, "type"); - assert_eq!(msg, "rate limited"); - assert_eq!(code.as_deref(), Some("rate_limit_error")); - assert!(raw.is_some()); - } - - #[test] - fn parse_error_body_missing_error_field() { - let body = r#"{"status":"fail"}"#; - let (msg, code, raw) = parse_error_body(body, "type"); - assert_eq!(msg, "Unknown error"); - assert_eq!(code, None); - assert!(raw.is_some()); - } - - #[test] - fn parse_error_body_not_json() { - let body = "Internal Server Error"; - let (msg, code, raw) = parse_error_body(body, "type"); - assert_eq!(msg, "Internal Server Error"); - assert_eq!(code, None); - assert!(raw.is_none()); - } - - #[test] - fn parse_error_body_different_code_field() { - let body = r#"{"error":{"message":"bad","status":"INVALID_ARGUMENT"}}"#; - let (msg, code, _) = parse_error_body(body, "status"); - assert_eq!(msg, "bad"); - assert_eq!(code.as_deref(), Some("INVALID_ARGUMENT")); - } - - #[test] - fn parse_error_body_no_message() { - let body = r#"{"error":{"type":"server_error"}}"#; - let (msg, code, _) = parse_error_body(body, "type"); - assert_eq!(msg, "Unknown error"); - assert_eq!(code.as_deref(), Some("server_error")); - } - - // --- extract_system_prompt --- - - #[test] - fn extract_system_prompt_no_system() { - let msgs = vec![Message::user("hello")]; - let (sys, other) = extract_system_prompt(&msgs); - assert_eq!(sys, None); - assert_eq!(other.len(), 1); - } - - #[test] - fn extract_system_prompt_system_only() { - let msgs = vec![Message::system("Be helpful"), Message::user("hi")]; - let (sys, other) = extract_system_prompt(&msgs); - assert_eq!(sys.as_deref(), Some("Be helpful")); - assert_eq!(other.len(), 1); - assert_eq!(other[0].role, Role::User); - } - - #[test] - fn extract_system_prompt_multiple_system() { - let msgs = vec![ - Message::system("Rule 1"), - Message::system("Rule 2"), - Message::user("hi"), - ]; - let (sys, other) = extract_system_prompt(&msgs); - assert_eq!(sys.as_deref(), Some("Rule 1\nRule 2")); - assert_eq!(other.len(), 1); - } - - #[test] - fn extract_system_prompt_developer_role() { - let dev = Message { - role: Role::Developer, - content: vec![ContentPart::text("dev instructions")], - name: None, - tool_call_id: None, - }; - let msgs = vec![dev, Message::user("hi")]; - let (sys, other) = extract_system_prompt(&msgs); - assert_eq!(sys.as_deref(), Some("dev instructions")); - assert_eq!(other.len(), 1); - } - - #[test] - fn extract_system_prompt_ignores_whitespace_system_and_developer() { - let dev = Message { - role: Role::Developer, - content: vec![ContentPart::text(" \n\t ")], - name: None, - tool_call_id: None, - }; - let msgs = vec![Message::system(" "), dev, Message::user("hi")]; - let (sys, other) = extract_system_prompt(&msgs); - assert_eq!(sys, None); - assert_eq!(other.len(), 1); - assert_eq!(other[0].role, Role::User); - } - - #[test] - fn extract_system_prompt_empty() { - let msgs: Vec = vec![]; - let (sys, other) = extract_system_prompt(&msgs); - assert_eq!(sys, None); - assert!(other.is_empty()); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/mod.rs b/lib/components/fabro-llm/src/codec/openai_compatible/mod.rs deleted file mode 100644 index 6bcae4492..000000000 --- a/lib/components/fabro-llm/src/codec/openai_compatible/mod.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! The OpenAI Chat Completions (`/chat/completions`) codec. -//! -//! Serves every "OpenAI-compatible" route (moonshot, zai, minimax, venice, -//! inception, ollama, litellm, …). Pure translation: no HTTP, auth, or base -//! URL — the adapter shell owns those. Count-tokens and error mapping use the -//! `Codec` trait defaults (this dialect has no count route and uses the shared -//! HTTP-status error mapping). - -mod request; -mod response; -mod stream; -mod translate; -mod wire; - -use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder}; -use crate::error::Error; -use crate::types::{RateLimitInfo, Response}; - -/// Codec for the OpenAI Chat Completions wire dialect. -pub(crate) struct OpenAiCompatible; - -impl Codec for OpenAiCompatible { - fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result { - request::encode(ctx, stream) - } - - fn decode_response( - &self, - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Result { - response::decode_response(body, ctx, rate_limit) - } - - fn stream_decoder( - &self, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Box { - Box::new(stream::StreamState::new(ctx, rate_limit)) - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/request.rs b/lib/components/fabro-llm/src/codec/openai_compatible/request.rs deleted file mode 100644 index 8db03603f..000000000 --- a/lib/components/fabro-llm/src/codec/openai_compatible/request.rs +++ /dev/null @@ -1,427 +0,0 @@ -//! Request encoding: canonical `Request` → Chat Completions body. - -use super::translate; -use super::wire::{ApiRequest, ChatMessage, StreamOptions}; -use crate::codec::{CodecCtx, EncodedRequest, cache, merge_named_provider_options}; -use crate::error::Error; - -/// Known `provider_options.` keys the codec consumes itself; -/// not re-merged into the body. -const KNOWN_OPTION_KEYS: &[&str] = &["auto_cache"]; - -/// Build the Chat Completions request for `ctx.request`. `stream` toggles the -/// `stream` body field and the `stream_options.include_usage` opt-in that makes -/// providers emit the trailing usage chunk. The body is assembled as a -/// `serde_json::Value` so `provider_options.` fields can be -/// merged in before sending. -/// -/// Returns an error when the request contains a custom tool definition, which -/// the Chat Completions tool envelope cannot represent. -pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> Result { - let request = ctx.request; - let mut chat_messages = translate::translate_messages(&request.messages); - if explicit_cache_breakpoints(ctx) { - apply_cache_breakpoints(&mut chat_messages); - } - let tools = request - .tools - .as_ref() - .map(|t| translate::translate_tools(t)) - .transpose()?; - let tool_choice = request - .tool_choice - .as_ref() - .map(translate::translate_tool_choice); - let response_format = request - .response_format - .as_ref() - .map(translate::translate_response_format); - let (temperature, top_p) = if ctx - .model - .is_none_or(fabro_model::Model::supports_sampling_params) - { - (request.temperature, request.top_p) - } else { - (None, None) - }; - - let api_request = ApiRequest { - model: ctx.deployment_id.to_string(), - messages: chat_messages, - temperature, - max_tokens: request.max_tokens, - top_p, - reasoning_effort: request.reasoning_effort, - stop: request.stop_sequences.clone(), - tools, - tool_choice, - response_format, - stream: stream.then_some(true), - stream_options: stream.then_some(StreamOptions { - include_usage: true, - }), - }; - - let mut body = serde_json::to_value(&api_request).unwrap_or_default(); - merge_provider_options( - &mut body, - request.provider_options.as_ref(), - ctx.provider_name, - ); - - Ok(EncodedRequest { - body, - endpoint: "/chat/completions".to_string(), - headers: Vec::new(), - }) -} - -/// Whether this request opts into Anthropic-style explicit cache breakpoints: -/// the catalog row declares the mechanism and the request hasn't disabled -/// `auto_cache` under this provider's options namespace. -fn explicit_cache_breakpoints(ctx: &CodecCtx<'_>) -> bool { - ctx.model - .is_some_and(|m| m.features.prompt_cache && m.features.cache_control_breakpoints) - && cache::auto_cache_enabled(ctx.request.provider_options.as_ref(), ctx.provider_name) -} - -/// Mark the cacheable prefix: the last system message (upstream, tools and -/// system precede the conversation, so this breakpoint covers them too) and -/// the second-to-last user turn. Tool results count as user turns — they ride -/// in user messages on the upstream Anthropic wire. -fn apply_cache_breakpoints(messages: &mut [ChatMessage]) { - if let Some(system) = messages.iter_mut().rev().find(|m| m.role == "system") { - if let Some(content) = system.content.as_mut() { - content.mark_cache_breakpoint(); - } - } - - let user_turns: Vec = messages - .iter() - .map(|m| m.role == "user" || m.role == "tool") - .collect(); - if let Some(idx) = cache::conversation_breakpoint_index(&user_turns) { - if let Some(content) = messages[idx].content.as_mut() { - content.mark_cache_breakpoint(); - } - } -} - -/// Merge `provider_options.` fields into the serialized API -/// request body. -/// -/// The provider name is configurable (e.g. "groq", "together", "moonshot"), -/// allowing each instance to have its own namespace in `provider_options`. -pub(super) fn merge_provider_options( - body: &mut serde_json::Value, - provider_options: Option<&serde_json::Value>, - provider_name: &str, -) { - merge_named_provider_options(body, provider_options, provider_name, KNOWN_OPTION_KEYS); -} - -#[cfg(test)] -mod tests { - use fabro_model::{Catalog, ProviderId}; - - use super::super::wire::ApiRequest; - use super::*; - use crate::codec::CodecParams; - use crate::types::{Message, ReasoningEffort, Request, ToolDefinition}; - - fn minimal_request() -> Request { - Request { - model: "llama-3.1-70b".to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - /// Encode `request` through the codec with `deployment_id == request.model` - /// (the no-catalog case) and return the body. - fn encode_body(request: &Request, provider_name: &str, stream: bool) -> serde_json::Value { - let params = CodecParams::default(); - let deployment_id = request.model.clone(); - let ctx = CodecCtx { - request, - provider_name, - deployment_id: &deployment_id, - model: None, - params: ¶ms, - }; - encode(&ctx, stream).unwrap().body - } - - #[test] - fn api_request_stream_field_serialization() { - let req = ApiRequest { - model: "test".into(), - messages: vec![], - temperature: None, - max_tokens: None, - top_p: None, - reasoning_effort: None, - stop: None, - tools: None, - tool_choice: None, - response_format: None, - stream: Some(true), - stream_options: None, - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["stream"], true); - - let req_no_stream = ApiRequest { - model: "test".into(), - messages: vec![], - temperature: None, - max_tokens: None, - top_p: None, - reasoning_effort: None, - stop: None, - tools: None, - tool_choice: None, - response_format: None, - stream: None, - stream_options: None, - }; - let json_no_stream = serde_json::to_value(&req_no_stream).unwrap(); - assert!(json_no_stream.get("stream").is_none()); - } - - #[test] - fn encode_uses_deployment_id_as_model() { - let request = minimal_request(); - let params = CodecParams::default(); - let deployment_id = "acme/model-large".to_string(); - let ctx = CodecCtx { - request: &request, - provider_name: "acme", - deployment_id: &deployment_id, - model: None, - params: ¶ms, - }; - let body = encode(&ctx, false).unwrap().body; - assert_eq!(body["model"], "acme/model-large"); - } - - #[test] - fn encode_serializes_reasoning_effort_at_top_level() { - let mut request = minimal_request(); - request.reasoning_effort = Some(ReasoningEffort::High); - - let body = encode_body(&request, "moonshot", false); - - assert_eq!(body["reasoning_effort"], "high"); - } - - #[test] - fn encode_omits_sampling_params_for_models_that_reject_them() { - let model = Catalog::builtin() - .get_on_provider(&ProviderId::new("moonshot"), "kimi-k3") - .unwrap(); - let mut request = minimal_request(); - request.model = model.id.to_string(); - request.temperature = Some(0.7); - request.top_p = Some(0.9); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "moonshot", - deployment_id: model.id.as_str(), - model: Some(model), - params: ¶ms, - }; - - let body = encode(&ctx, false).unwrap().body; - - assert!(body.get("temperature").is_none()); - assert!(body.get("top_p").is_none()); - } - - #[test] - fn encode_rejects_custom_tool_definitions() { - let mut request = minimal_request(); - request.tools = Some(vec![ToolDefinition::custom( - "apply_patch", - "Apply a patch", - serde_json::json!({"type": "grammar"}), - )]); - let params = CodecParams::default(); - let deployment_id = request.model.clone(); - let ctx = CodecCtx { - request: &request, - provider_name: "moonshot", - deployment_id: &deployment_id, - model: None, - params: ¶ms, - }; - - let Err(error) = encode(&ctx, false) else { - panic!("custom tool definition should be rejected"); - }; - assert!(matches!( - error, - Error::Configuration { message, source: None } - if message.contains("custom tool definition 'apply_patch'") - )); - } - - #[test] - fn provider_options_none_produces_standard_body() { - let request = minimal_request(); - let body = encode_body(&request, "groq", false); - assert_eq!(body["model"], "llama-3.1-70b"); - assert!(body.get("stream").is_none()); - } - - #[test] - fn provider_options_matching_name_merged() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "groq": { - "frequency_penalty": 0.5, - "presence_penalty": 0.3 - } - })); - let body = encode_body(&request, "groq", false); - assert_eq!(body["frequency_penalty"], 0.5); - assert_eq!(body["presence_penalty"], 0.3); - } - - #[test] - fn provider_options_different_name_ignored() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "together": { - "repetition_penalty": 1.2 - } - })); - let body = encode_body(&request, "groq", false); - assert!(body.get("repetition_penalty").is_none()); - } - - #[test] - fn provider_options_uses_adapter_name() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "together": { - "repetition_penalty": 1.2 - } - })); - let body = encode_body(&request, "together", false); - assert_eq!(body["repetition_penalty"], 1.2); - } - - #[test] - fn provider_options_preserves_standard_fields() { - let mut request = minimal_request(); - request.temperature = Some(0.7); - request.max_tokens = Some(200); - request.provider_options = Some(serde_json::json!({ - "groq": { - "frequency_penalty": 0.5 - } - })); - let body = encode_body(&request, "groq", true); - assert_eq!(body["temperature"], 0.7); - assert_eq!(body["max_tokens"], 200); - assert_eq!(body["stream"], true); - assert_eq!(body["frequency_penalty"], 0.5); - } - - #[test] - fn provider_options_can_override_model() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "groq": { - "model": "custom-model" - } - })); - let body = encode_body(&request, "groq", false); - assert_eq!(body["model"], "custom-model"); - } - - #[test] - fn merge_provider_options_with_non_object_value() { - let mut body = serde_json::json!({"model": "test"}); - let opts = serde_json::json!({"groq": "not-an-object"}); - merge_provider_options(&mut body, Some(&opts), "groq"); - assert_eq!(body["model"], "test"); - } - - #[test] - fn merge_provider_options_consumes_auto_cache_control_key() { - let mut body = serde_json::json!({"model": "test"}); - let opts = serde_json::json!({"groq": {"auto_cache": false, "top_k": 5}}); - merge_provider_options(&mut body, Some(&opts), "groq"); - assert!(body.get("auto_cache").is_none()); - assert_eq!(body["top_k"], 5); - } - - // --- apply_cache_breakpoints --------------------------------------------- - - fn chat_message(role: &str, text: &str) -> ChatMessage { - ChatMessage { - role: role.to_string(), - content: Some(super::super::wire::ChatContent::Text(text.to_string())), - reasoning_content: None, - tool_call_id: None, - tool_calls: None, - } - } - - fn marked(message: &ChatMessage) -> bool { - let json = serde_json::to_value(message).unwrap(); - json["content"].is_array() && json["content"][0]["cache_control"]["type"] == "ephemeral" - } - - #[test] - fn cache_breakpoints_on_first_turn_mark_only_the_system_prompt() { - let mut messages = vec![chat_message("system", "sys"), chat_message("user", "task")]; - apply_cache_breakpoints(&mut messages); - assert!(marked(&messages[0])); - assert!(!marked(&messages[1])); - } - - #[test] - fn cache_breakpoints_count_tool_results_as_user_turns() { - let mut messages = vec![ - chat_message("system", "sys"), - chat_message("user", "task"), - chat_message("assistant", "calling a tool"), - chat_message("tool", "tool output"), - chat_message("assistant", "one more"), - chat_message("tool", "more output"), - ]; - apply_cache_breakpoints(&mut messages); - assert!(marked(&messages[0])); - // Second-to-last user turn: the first tool result, not the user task. - assert!(!marked(&messages[1])); - assert!(marked(&messages[3])); - assert!(!marked(&messages[5])); - } - - #[test] - fn cache_breakpoints_without_system_mark_only_the_conversation() { - let mut messages = vec![ - chat_message("user", "task"), - chat_message("assistant", "answer"), - chat_message("user", "follow-up"), - ]; - apply_cache_breakpoints(&mut messages); - assert!(marked(&messages[0])); - assert!(!marked(&messages[2])); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/response.rs b/lib/components/fabro-llm/src/codec/openai_compatible/response.rs deleted file mode 100644 index dc2779bf1..000000000 --- a/lib/components/fabro-llm/src/codec/openai_compatible/response.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Response decoding: Chat Completions body → canonical `Response`. - -use super::translate::{self, map_finish_reason}; -use super::wire::{ApiResponse, ApiUsage, ReasoningDetails}; -use crate::codec::CodecCtx; -use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind}; -use crate::types::{ - ContentPart, Message, RateLimitInfo, Response, Role, ThinkingData, TokenCounts, ToolCall, -}; - -pub(super) fn decode_response( - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, -) -> Result { - let mut api_resp: ApiResponse = serde_json::from_str(body) - .map_err(|e| Error::network(format!("failed to parse response: {e}"), e))?; - - let choice = api_resp - .choices - .first_mut() - .ok_or_else(|| Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail::new( - "no choices in response", - ctx.provider_name, - )), - })?; - - let mut content_parts = Vec::new(); - if let Some(payload) = choice.message.reasoning_details.take() { - content_parts.extend(ReasoningDetails::from_complete_payload(payload).into_content_part()); - } - if let Some(reasoning) = choice.message.reasoning() { - if !reasoning.is_empty() { - content_parts.push(ContentPart::Thinking(ThinkingData { - text: reasoning.to_string(), - signature: None, - redacted: false, - })); - } - } - if let Some(text) = &choice.message.content { - if !text.is_empty() { - content_parts.push(ContentPart::text(text)); - } - } - if let Some(tool_calls) = &choice.message.tool_calls { - let custom_tool_names = translate::custom_tool_names(ctx.request); - for tc in tool_calls { - let arguments = translate::parse_tool_arguments( - &tc.function.name, - &tc.function.arguments, - &custom_tool_names, - ); - let mut tool_call = ToolCall::new(&tc.id, &tc.function.name, arguments); - tool_call.raw_arguments = Some(tc.function.arguments.clone()); - content_parts.push(ContentPart::ToolCall(tool_call)); - } - } - - let finish_reason = map_finish_reason(choice.finish_reason.as_deref()); - - let wire_usage = api_resp.usage.as_ref(); - let usage = wire_usage.map_or_else(TokenCounts::default, ApiUsage::token_counts); - let cost_usd = wire_usage - .and_then(|usage| usage.cost) - .or_else(|| api_resp.cost.as_ref().and_then(|cost| cost.usd)); - let cost_source = translate::authoritative_cost_source(cost_usd); - - Ok(Response { - id: api_resp.id, - model: api_resp.model, - provider: ctx.provider_name.to_string(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason, - usage, - raw: serde_json::from_str(body).ok(), - warnings: vec![], - rate_limit, - cost_usd, - cost_source, - }) -} diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/stream.rs b/lib/components/fabro-llm/src/codec/openai_compatible/stream.rs deleted file mode 100644 index 3207c2744..000000000 --- a/lib/components/fabro-llm/src/codec/openai_compatible/stream.rs +++ /dev/null @@ -1,544 +0,0 @@ -//! Streaming decoder: Chat Completions SSE chunks → canonical `StreamEvent`s. -//! -//! Byte reading and `data:` framing live in the transport; this decoder is fed -//! already-stripped payloads (including the `[DONE]` sentinel) via `on_event`. - -use super::translate::{map_finish_reason, parse_tool_arguments}; -use super::wire::{AccumulatedToolCall, ReasoningDetails, StreamChunk}; -use crate::codec::{CodecCtx, RawEvent, StreamDecoder}; -use crate::error::Error; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData, - TokenCounts, ToolCall, -}; - -/// Accumulated state while decoding the Chat Completions SSE stream. -pub(super) struct StreamState { - provider_name: String, - model: String, - response_id: String, - response_model: String, - accumulated_text: String, - accumulated_reasoning: String, - reasoning_details: ReasoningDetails, - tool_calls: Vec, - usage: TokenCounts, - finish_reason: FinishReason, - text_started: bool, - custom_tool_names: Vec, - /// True after `finish_events()` has run (guards against duplicates). - finished: bool, - rate_limit: Option, - /// In-band USD cost from the response, surfaced as authoritative on the - /// final response. - cost_usd: Option, -} - -impl StreamState { - pub(super) fn new(ctx: &CodecCtx<'_>, rate_limit: Option) -> Self { - Self { - provider_name: ctx.provider_name.to_string(), - model: ctx.request.model.clone(), - response_id: String::new(), - response_model: String::new(), - accumulated_text: String::new(), - accumulated_reasoning: String::new(), - reasoning_details: ReasoningDetails::default(), - tool_calls: Vec::new(), - usage: TokenCounts::default(), - finish_reason: FinishReason::Stop, - text_started: false, - custom_tool_names: super::translate::custom_tool_names(ctx.request), - finished: false, - rate_limit, - cost_usd: None, - } - } - - /// Process a parsed SSE chunk and return events to emit, if any. - fn process_chunk(&mut self, mut chunk: StreamChunk) -> Result>, Error> { - // Capture response metadata from the first chunk. - if let Some(id) = &chunk.id { - if self.response_id.is_empty() { - self.response_id.clone_from(id); - } - } - if let Some(model) = &chunk.model { - if self.response_model.is_empty() { - self.response_model.clone_from(model); - } - } - - // Capture usage if present (often in a dedicated chunk). - if let Some(usage) = &chunk.usage { - self.usage = usage.token_counts(); - } - let cost_usd = chunk - .usage - .as_ref() - .and_then(|usage| usage.cost) - .or_else(|| chunk.cost.as_ref().and_then(|cost| cost.usd)); - self.cost_usd = cost_usd.or(self.cost_usd); - - let Some(choices) = chunk.choices.as_mut() else { - return Ok(None); - }; - let Some(choice) = choices.first_mut() else { - return Ok(None); - }; - - let mut events = Vec::new(); - - // Check for finish_reason. - if let Some(reason) = &choice.finish_reason { - self.finish_reason = map_finish_reason(Some(reason.as_str())); - } - - let Some(delta) = choice.delta.as_mut() else { - return Ok(None); - }; - - // Accumulate reasoning/thinking content (Kimi, etc.). - if let Some(reasoning) = delta.reasoning() { - if !reasoning.is_empty() { - self.accumulated_reasoning.push_str(reasoning); - } - } - - // Accumulate structured reasoning detail fragments in wire order. - if let Some(payload) = delta.reasoning_details.take() { - self.reasoning_details.push_stream_payload(payload); - } - - // Handle text content delta. - if let Some(content) = &delta.content { - if !content.is_empty() { - if !self.text_started { - self.text_started = true; - events.push(StreamEvent::TextStart { text_id: None }); - } - self.accumulated_text.push_str(content); - events.push(StreamEvent::text_delta(content, None)); - } - } - - // Handle tool call deltas. - if let Some(tool_calls) = &delta.tool_calls { - for tc in tool_calls { - let index = tc.index; - - // A delta may only continue an already-started tool call or - // open the next slot. Padding a skipped slot would materialize - // a phantom tool call with an empty id and name, which poisons - // the conversation once echoed back to the provider. - if index > self.tool_calls.len() { - return Err(Error::Stream { - message: format!( - "malformed tool call stream from {}: delta for tool_calls[{index}] \ - arrived before tool_calls[{}] was started", - self.provider_name, - self.tool_calls.len() - ), - source: None, - }); - } - if index == self.tool_calls.len() { - self.tool_calls.push(AccumulatedToolCall { - id: String::new(), - name: String::new(), - arguments: String::new(), - started: false, - }); - } - - let accumulated = &mut self.tool_calls[index]; - - // First chunk for this tool call carries id and name. - if let Some(id) = &tc.id { - accumulated.id.clone_from(id); - } - if let Some(func) = &tc.function { - if let Some(name) = &func.name { - accumulated.name.clone_from(name); - } - if let Some(args) = &func.arguments { - accumulated.arguments.push_str(args); - } - } - - let partial_tool_call = - ToolCall::new(&accumulated.id, &accumulated.name, serde_json::json!(null)); - - if accumulated.started { - events.push(StreamEvent::ToolCallDelta { - tool_call: partial_tool_call, - }); - } else { - accumulated.started = true; - events.push(StreamEvent::ToolCallStart { - tool_call: partial_tool_call, - }); - } - } - } - - if events.is_empty() { - Ok(None) - } else { - Ok(Some(events)) - } - } - - /// Generate the final events when `[DONE]` (or end-of-stream) is received. - fn finish_events(&mut self) -> Vec { - self.finished = true; - let mut events = Vec::new(); - - // End text segment if it was started. - if self.text_started { - events.push(StreamEvent::TextEnd { text_id: None }); - } - - let mut content_parts = Vec::new(); - - // Preserve the structured reasoning channel verbatim. - content_parts.extend(std::mem::take(&mut self.reasoning_details).into_content_part()); - - // Include reasoning/thinking content if present (Kimi, etc.). - if !self.accumulated_reasoning.is_empty() { - content_parts.push(ContentPart::Thinking(ThinkingData { - text: std::mem::take(&mut self.accumulated_reasoning), - signature: None, - redacted: false, - })); - } - - if !self.accumulated_text.is_empty() { - content_parts.push(ContentPart::text(&self.accumulated_text)); - } - - for accumulated in &self.tool_calls { - let arguments = parse_tool_arguments( - &accumulated.name, - &accumulated.arguments, - &self.custom_tool_names, - ); - let mut tool_call = ToolCall::new(&accumulated.id, &accumulated.name, arguments); - tool_call.raw_arguments = Some(accumulated.arguments.clone()); - - events.push(StreamEvent::ToolCallEnd { - tool_call: tool_call.clone(), - }); - content_parts.push(ContentPart::ToolCall(tool_call)); - } - - // Infer finish reason from tool calls if not explicitly set. - if !self.tool_calls.is_empty() && self.finish_reason == FinishReason::Stop { - self.finish_reason = FinishReason::ToolCalls; - } - - let response_model = if self.response_model.is_empty() { - self.model.clone() - } else { - self.response_model.clone() - }; - - let response = Response { - id: self.response_id.clone(), - model: response_model, - provider: self.provider_name.clone(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason: self.finish_reason.clone(), - usage: self.usage.clone(), - raw: None, - warnings: vec![], - rate_limit: self.rate_limit.clone(), - cost_usd: self.cost_usd, - cost_source: super::translate::authoritative_cost_source(self.cost_usd), - }; - - events.push(StreamEvent::finish( - self.finish_reason.clone(), - self.usage.clone(), - response, - )); - - events - } -} - -impl StreamDecoder for StreamState { - fn on_event(&mut self, ev: RawEvent<'_>) -> Result, Error> { - // Chat Completions uses data-only framing; the `event:` field is unused. - if ev.data == "[DONE]" { - return Ok(self.finish_events()); - } - - let chunk: StreamChunk = serde_json::from_str(ev.data) - .map_err(|e| Error::stream_error(format!("failed to parse SSE chunk: {e}"), e))?; - - Ok(self.process_chunk(chunk)?.unwrap_or_default()) - } - - fn finish(&mut self) -> Vec { - // Stream ended without `[DONE]`. Some providers (e.g. Minimax) omit the - // sentinel; emit accumulated finish events if we have content and - // haven't already finished. - if !self.finished && (self.text_started || !self.tool_calls.is_empty()) { - self.finish_events() - } else { - Vec::new() - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codec::CodecParams; - use crate::types::Request; - - /// Build a decoder through `StreamState::new` (with a minimal request) for - /// unit tests that drive `process_chunk` / `finish_events`. - fn test_state(provider: &str, model: &str) -> StreamState { - let request = Request { - model: model.to_string(), - messages: Vec::new(), - provider: None, - tools: None, - tool_choice: None, - 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 params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: provider, - deployment_id: model, - model: None, - params: ¶ms, - }; - StreamState::new(&ctx, None) - } - - #[test] - fn stream_chunk_minimax_format() { - let json = r#"{"id":"abc","choices":[{"index":0,"delta":{"content":"hello","role":"assistant","name":"MiniMax AI","audio_content":""}}],"created":1772268546,"model":"MiniMax-M2.5","object":"chat.completion.chunk","usage":null,"input_sensitive":false,"output_sensitive":false}"#; - let chunk: StreamChunk = serde_json::from_str(json).unwrap(); - let choices = chunk.choices.unwrap(); - let delta = choices[0].delta.as_ref().unwrap(); - assert_eq!(delta.content.as_deref(), Some("hello")); - } - - #[test] - fn stream_chunk_text_delta_parsing() { - let json = r#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}"#; - let chunk: StreamChunk = serde_json::from_str(json).unwrap(); - assert_eq!(chunk.id.as_deref(), Some("chatcmpl-1")); - assert_eq!(chunk.model.as_deref(), Some("gpt-4")); - let choices = chunk.choices.unwrap(); - assert_eq!(choices.len(), 1); - let delta = choices[0].delta.as_ref().unwrap(); - assert_eq!(delta.content.as_deref(), Some("Hello")); - assert!(choices[0].finish_reason.is_none()); - } - - #[test] - fn stream_chunk_tool_call_parsing() { - let json = r#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"ci"}}]},"finish_reason":null}]}"#; - let chunk: StreamChunk = serde_json::from_str(json).unwrap(); - let choices = chunk.choices.unwrap(); - let delta = choices[0].delta.as_ref().unwrap(); - let tc = &delta.tool_calls.as_ref().unwrap()[0]; - assert_eq!(tc.index, 0); - assert_eq!(tc.id.as_deref(), Some("call_1")); - let func = tc.function.as_ref().unwrap(); - assert_eq!(func.name.as_deref(), Some("get_weather")); - assert_eq!(func.arguments.as_deref(), Some("{\"ci")); - } - - #[test] - fn stream_chunk_usage_parsing() { - let json = r#"{"id":"chatcmpl-1","model":"gpt-4","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}"#; - let chunk: StreamChunk = serde_json::from_str(json).unwrap(); - let usage = chunk.usage.unwrap(); - assert_eq!(usage.prompt_tokens, 10); - assert_eq!(usage.completion_tokens, 20); - } - - #[test] - fn stream_chunk_finish_reason_parsing() { - let json = r#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"delta":{},"finish_reason":"stop"}]}"#; - let chunk: StreamChunk = serde_json::from_str(json).unwrap(); - let choices = chunk.choices.unwrap(); - assert_eq!(choices[0].finish_reason.as_deref(), Some("stop")); - } - - #[test] - fn process_text_chunks() { - let mut state = test_state("test", "model"); - - let chunk1: StreamChunk = serde_json::from_str( - r#"{"id":"c1","model":"m1","choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}"#, - ).unwrap(); - let events1 = state.process_chunk(chunk1).unwrap().unwrap(); - assert_eq!(events1.len(), 2); - assert!(matches!(events1[0], StreamEvent::TextStart { .. })); - assert!(matches!(events1[1], StreamEvent::TextDelta { .. })); - - let chunk2: StreamChunk = serde_json::from_str( - r#"{"id":"c1","model":"m1","choices":[{"delta":{"content":" world"},"finish_reason":null}]}"#, - ).unwrap(); - let events2 = state.process_chunk(chunk2).unwrap().unwrap(); - assert_eq!(events2.len(), 1); - assert!(matches!(events2[0], StreamEvent::TextDelta { .. })); - - assert_eq!(state.accumulated_text, "Hello world"); - } - - #[test] - fn process_tool_call_chunks() { - let mut state = test_state("test", "model"); - - let chunk1: StreamChunk = serde_json::from_str( - r#"{"id":"c1","model":"m1","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"fn1","arguments":"{\"k"}}]},"finish_reason":null}]}"#, - ).unwrap(); - let events1 = state.process_chunk(chunk1).unwrap().unwrap(); - assert_eq!(events1.len(), 1); - assert!(matches!(events1[0], StreamEvent::ToolCallStart { .. })); - - let chunk2: StreamChunk = serde_json::from_str( - r#"{"id":"c1","model":"m1","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ey\"}"}}]},"finish_reason":null}]}"#, - ).unwrap(); - let events2 = state.process_chunk(chunk2).unwrap().unwrap(); - assert_eq!(events2.len(), 1); - assert!(matches!(events2[0], StreamEvent::ToolCallDelta { .. })); - - assert_eq!(state.tool_calls[0].arguments, r#"{"key"}"#); - } - - #[test] - fn finish_events_text_only() { - let mut state = test_state("test-provider", "test-model"); - state.response_id = "resp-1".into(); - state.response_model = "gpt-4".into(); - state.accumulated_text = "Hello world".into(); - state.text_started = true; - state.usage = TokenCounts { - input_tokens: 5, - output_tokens: 10, - ..TokenCounts::default() - }; - - let events = state.finish_events(); - assert_eq!(events.len(), 2); - assert!(matches!(events[0], StreamEvent::TextEnd { .. })); - match &events[1] { - StreamEvent::Finish { - finish_reason, - usage, - response, - } => { - assert_eq!(*finish_reason, FinishReason::Stop); - assert_eq!(usage.input_tokens, 5); - assert_eq!(usage.output_tokens, 10); - assert_eq!(response.text(), "Hello world"); - assert_eq!(response.id, "resp-1"); - assert_eq!(response.model, "gpt-4"); - assert_eq!(response.provider, "test-provider"); - } - other => panic!("Expected Finish, got {other:?}"), - } - } - - #[test] - fn finish_events_with_tool_calls() { - let mut state = test_state("test", "model"); - state.response_id = "resp-1".into(); - state.tool_calls.push(AccumulatedToolCall { - id: "call_1".into(), - name: "get_weather".into(), - arguments: r#"{"city":"SF"}"#.into(), - started: true, - }); - - let events = state.finish_events(); - assert_eq!(events.len(), 2); - match &events[0] { - StreamEvent::ToolCallEnd { tool_call } => { - assert_eq!(tool_call.id, "call_1"); - assert_eq!(tool_call.name, "get_weather"); - assert_eq!(tool_call.raw_arguments.as_deref(), Some(r#"{"city":"SF"}"#)); - } - other => panic!("Expected ToolCallEnd, got {other:?}"), - } - match &events[1] { - StreamEvent::Finish { - finish_reason, - response, - .. - } => { - assert_eq!(*finish_reason, FinishReason::ToolCalls); - let calls = response.tool_calls(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].name, "get_weather"); - } - other => panic!("Expected Finish, got {other:?}"), - } - } - - // Reproduces run 01M11JZVT7V507R56BCJJHZB1B: venice (proxying Anthropic) - // numbered tool_calls[].index by content block, so the first tool call - // arrived with index 1 when text preceded it. Padding the skipped slot - // used to materialize a phantom tool call with an empty id and name that - // the provider rejected once echoed back (tool_use.id must match - // '^[a-zA-Z0-9_-]+$'). A gap in the index sequence is indistinguishable - // from lost chunks, so the stream must fail instead. - #[test] - fn sparse_tool_call_index_is_a_stream_error() { - let mut state = test_state("venice", "claude-opus-5"); - - let text_chunk: StreamChunk = serde_json::from_str( - r#"{"id":"c1","model":"claude-opus-5","choices":[{"delta":{"content":"I'll start by reading the state file."},"finish_reason":null}]}"#, - ) - .unwrap(); - state.process_chunk(text_chunk).unwrap(); - - let tool_chunk: StreamChunk = serde_json::from_str( - r#"{"id":"c1","model":"claude-opus-5","choices":[{"delta":{"tool_calls":[{"index":1,"id":"toolu_01EgMidFVtGhitWE22jXQ9Eo","function":{"name":"Read","arguments":"{\"file_path\":\"state.json\"}"}}]},"finish_reason":null}]}"#, - ) - .unwrap(); - let err = state.process_chunk(tool_chunk).unwrap_err(); - - assert!(err.retryable(), "malformed stream should be retryable"); - let message = err.to_string(); - assert!( - message.contains("tool_calls[1]") && message.contains("venice"), - "unexpected error message: {message}" - ); - } - - #[test] - fn uses_request_model_as_fallback() { - let mut state = test_state("test", "fallback-model"); - let events = state.finish_events(); - match &events[0] { - StreamEvent::Finish { response, .. } => { - assert_eq!(response.model, "fallback-model"); - } - other => panic!("Expected Finish, got {other:?}"), - } - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs b/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs deleted file mode 100644 index b32fac567..000000000 --- a/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs +++ /dev/null @@ -1,486 +0,0 @@ -//! Pure mapping between canonical types and the Chat Completions wire shapes. - -use super::wire::{ChatContent, ChatFunction, ChatMessage, ChatToolCall}; -use crate::error::Error; -use crate::types::{ - ContentPart, CostSource, FinishReason, Message, Request, ResponseFormat, ResponseFormatType, - Role, ToolChoice, ToolDefinition, -}; - -/// In-band cost (OpenRouter) is authoritative billing data; the client's -/// catalog estimate never overwrites it. -pub(super) fn authoritative_cost_source(cost_usd: Option) -> Option { - cost_usd.is_some().then_some(CostSource::Authoritative) -} - -pub(super) fn map_finish_reason(reason: Option<&str>) -> FinishReason { - match reason { - Some("stop") | None => FinishReason::Stop, - Some("length") => FinishReason::Length, - Some("tool_calls") => FinishReason::ToolCalls, - Some("content_filter") => FinishReason::ContentFilter, - Some(other) => FinishReason::Other(other.to_string()), - } -} - -/// Build the content string from a message's parts, including fallback text -/// for unsupported content types (Audio, Document). -fn content_text_with_fallbacks(parts: &[ContentPart]) -> String { - let mut segments: Vec = Vec::new(); - for part in parts { - match part { - ContentPart::Text(text) => segments.push(text.clone()), - ContentPart::Audio(_) => { - segments.push("[Audio content not supported by this provider]".to_string()); - } - ContentPart::Document(doc) => { - let desc = doc.file_name.as_ref().map_or_else( - || "[Document content not supported by this provider]".to_string(), - |name| { - format!("[Document '{name}': content type not supported by this provider]") - }, - ); - segments.push(desc); - } - _ => {} - } - } - segments.join("") -} - -pub(super) fn translate_messages(messages: &[Message]) -> Vec { - messages - .iter() - .flat_map(|msg| { - // Tool messages must be split into one ChatMessage per ToolResult, - // each with its own tool_call_id. The Chat Completions API requires - // every tool_call_id from the assistant to have a matching tool message. - if msg.role == Role::Tool { - return msg - .content - .iter() - .filter_map(|part| { - if let ContentPart::ToolResult(tr) = part { - let output = tr - .content - .as_str() - .map_or_else(|| tr.content.to_string(), str::to_string); - Some(ChatMessage { - role: "tool".to_string(), - content: Some(ChatContent::Text(output)), - reasoning_content: None, - tool_call_id: Some(tr.tool_call_id.clone()), - tool_calls: None, - }) - } else { - None - } - }) - .collect::>(); - } - - let role = match msg.role { - Role::System | Role::Developer => "system", - Role::User => "user", - Role::Assistant => "assistant", - Role::Tool => unreachable!( - "Role::Tool is handled in the early-return branch above this match" - ), - }; - - let mut tool_calls: Vec = Vec::new(); - if msg.role == Role::Assistant { - for part in &msg.content { - if let ContentPart::ToolCall(tc) = part { - let arguments = tc - .raw_arguments - .clone() - .unwrap_or_else(|| tc.arguments.to_string()); - tool_calls.push(ChatToolCall { - id: tc.id.clone(), - kind: "function".to_string(), - function: ChatFunction { - name: tc.name.clone(), - arguments, - }, - }); - } - } - } - - let text = content_text_with_fallbacks(&msg.content); - let content = if text.is_empty() { - None - } else { - Some(ChatContent::Text(text)) - }; - let tool_calls = if tool_calls.is_empty() { - None - } else { - Some(tool_calls) - }; - - // Extract reasoning/thinking content for assistant messages. - let reasoning_content = if msg.role == Role::Assistant { - let reasoning: String = msg - .content - .iter() - .filter_map(|part| match part { - ContentPart::Thinking(t) if !t.redacted => Some(t.text.as_str()), - _ => None, - }) - .collect::>() - .join(""); - if reasoning.is_empty() { - None - } else { - Some(reasoning) - } - } else { - None - }; - - vec![ChatMessage { - role: role.to_string(), - content, - reasoning_content, - tool_call_id: msg.tool_call_id.clone(), - tool_calls, - }] - }) - .collect() -} - -pub(super) fn translate_tools(tools: &[ToolDefinition]) -> Result, Error> { - tools - .iter() - .map(|t| { - if t.is_custom() { - return Err(Error::Configuration { - message: format!( - "openai_compatible codec does not support custom tool definition '{}'", - t.name - ), - source: None, - }); - } - - Ok(serde_json::json!({ - "type": "function", - "function": { - "name": t.name, - "description": t.description, - "parameters": t.parameters, - } - })) - }) - .collect() -} - -pub(super) fn translate_tool_choice(choice: &ToolChoice) -> serde_json::Value { - match choice { - ToolChoice::Auto => serde_json::json!("auto"), - ToolChoice::None => serde_json::json!("none"), - ToolChoice::Required => serde_json::json!("required"), - ToolChoice::Named { tool_name } => { - serde_json::json!({"type": "function", "function": {"name": tool_name}}) - } - } -} - -pub(super) fn custom_tool_names(request: &Request) -> Vec { - request - .tools - .as_deref() - .unwrap_or_default() - .iter() - .filter(|tool| tool.is_custom()) - .map(|tool| tool.name.clone()) - .collect() -} - -pub(super) fn parse_tool_arguments( - tool_name: &str, - raw_arguments: &str, - custom_tool_names: &[String], -) -> serde_json::Value { - match serde_json::from_str(raw_arguments) { - Ok(arguments) => arguments, - Err(_) if custom_tool_names.iter().any(|name| name == tool_name) => { - serde_json::Value::String(raw_arguments.to_string()) - } - Err(_) => serde_json::json!({}), - } -} - -/// Translate unified `ResponseFormat` to Chat Completions `response_format`. -pub(super) fn translate_response_format(format: &ResponseFormat) -> serde_json::Value { - match format.kind { - ResponseFormatType::Text => serde_json::json!({"type": "text"}), - ResponseFormatType::JsonObject => serde_json::json!({"type": "json_object"}), - ResponseFormatType::JsonSchema => { - let mut json_schema = serde_json::json!({ - "name": "response", - "strict": format.strict, - }); - if let Some(schema) = &format.json_schema { - json_schema["schema"] = schema.clone(); - } - serde_json::json!({ - "type": "json_schema", - "json_schema": json_schema, - }) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::{ - AudioData, ContentPart, DocumentData, Message, Role, ThinkingData, ToolCall, - }; - - #[test] - fn translate_assistant_message_with_tool_calls_only() { - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "call_1", - "get_weather", - serde_json::json!({"city": "SF"}), - ))], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - assert_eq!(translated.len(), 1); - assert_eq!(translated[0].role, "assistant"); - assert!(translated[0].content.is_none()); - let tool_calls = translated[0].tool_calls.as_ref().unwrap(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].id, "call_1"); - assert_eq!(tool_calls[0].kind, "function"); - assert_eq!(tool_calls[0].function.name, "get_weather"); - assert_eq!(tool_calls[0].function.arguments, r#"{"city":"SF"}"#); - } - - #[test] - fn translate_assistant_message_with_text_and_tool_calls() { - let msg = Message { - role: Role::Assistant, - content: vec![ - ContentPart::text("Let me check the weather"), - ContentPart::ToolCall(ToolCall::new( - "call_2", - "get_weather", - serde_json::json!({"city": "NYC"}), - )), - ], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - assert_eq!( - translated[0] - .content - .as_ref() - .and_then(ChatContent::as_text), - Some("Let me check the weather") - ); - let tool_calls = translated[0].tool_calls.as_ref().unwrap(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].function.name, "get_weather"); - } - - #[test] - fn translate_assistant_tool_call_replays_reasoning_content() { - let msg = Message { - role: Role::Assistant, - content: vec![ - ContentPart::Thinking(ThinkingData { - text: "I need the weather tool.".to_string(), - signature: None, - redacted: false, - }), - ContentPart::ToolCall(ToolCall::new( - "call_2", - "get_weather", - serde_json::json!({"city": "NYC"}), - )), - ], - name: None, - tool_call_id: None, - }; - - let translated = translate_messages(&[msg]); - - assert_eq!( - translated[0].reasoning_content.as_deref(), - Some("I need the weather tool.") - ); - assert_eq!(translated[0].tool_calls.as_ref().unwrap().len(), 1); - let json = serde_json::to_value(&translated[0]).unwrap(); - assert_eq!(json["reasoning_content"], "I need the weather tool."); - } - - #[test] - fn translate_assistant_message_with_raw_arguments() { - let mut tc = ToolCall::new("call_3", "search", serde_json::json!({"q": "rust"})); - tc.raw_arguments = Some(r#"{"q": "rust"}"#.to_string()); - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - let tool_calls = translated[0].tool_calls.as_ref().unwrap(); - // Should prefer raw_arguments over serializing arguments - assert_eq!(tool_calls[0].function.arguments, r#"{"q": "rust"}"#); - } - - #[test] - fn translate_tool_message_has_tool_call_id() { - let msg = Message::tool_result( - "call_1", - serde_json::Value::String("72F and sunny".into()), - false, - ); - let translated = translate_messages(&[msg]); - assert_eq!(translated[0].role, "tool"); - assert_eq!(translated[0].tool_call_id.as_deref(), Some("call_1")); - assert!(translated[0].tool_calls.is_none()); - } - - #[test] - fn translate_user_message_has_no_tool_calls() { - let msg = Message::user("Hello"); - let translated = translate_messages(&[msg]); - assert_eq!(translated[0].role, "user"); - assert_eq!( - translated[0] - .content - .as_ref() - .and_then(ChatContent::as_text), - Some("Hello") - ); - assert!(translated[0].tool_calls.is_none()); - } - - #[test] - fn assistant_tool_calls_serialize_correctly() { - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "call_1", - "get_weather", - serde_json::json!({"city": "SF"}), - ))], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - let json = serde_json::to_value(&translated[0]).unwrap(); - assert!(json.get("content").is_none()); - assert!(json.get("tool_call_id").is_none()); - let tool_calls = json["tool_calls"].as_array().unwrap(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0]["type"], "function"); - assert_eq!(tool_calls[0]["id"], "call_1"); - assert_eq!(tool_calls[0]["function"]["name"], "get_weather"); - } - - #[test] - fn audio_content_produces_text_fallback() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Audio(AudioData { - url: Some("https://example.com/audio.wav".to_string()), - data: None, - media_type: None, - })], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - assert_eq!( - translated[0] - .content - .as_ref() - .and_then(ChatContent::as_text), - Some("[Audio content not supported by this provider]") - ); - } - - #[test] - fn document_content_produces_text_fallback_with_filename() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Document(DocumentData { - url: Some("https://example.com/doc.pdf".to_string()), - data: None, - media_type: None, - file_name: Some("report.pdf".to_string()), - })], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - assert_eq!( - translated[0] - .content - .as_ref() - .and_then(ChatContent::as_text), - Some("[Document 'report.pdf': content type not supported by this provider]") - ); - } - - #[test] - fn document_content_produces_text_fallback_without_filename() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Document(DocumentData { - url: None, - data: Some(vec![1, 2, 3]), - media_type: None, - file_name: None, - })], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - assert_eq!( - translated[0] - .content - .as_ref() - .and_then(ChatContent::as_text), - Some("[Document content not supported by this provider]") - ); - } - - #[test] - fn mixed_text_and_audio_content_concatenates() { - let msg = Message { - role: Role::User, - content: vec![ - ContentPart::text("Check this: "), - ContentPart::Audio(AudioData { - url: None, - data: Some(vec![1, 2]), - media_type: None, - }), - ], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - assert_eq!( - translated[0] - .content - .as_ref() - .and_then(ChatContent::as_text), - Some("Check this: [Audio content not supported by this provider]") - ); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs b/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs deleted file mode 100644 index 83823d5f7..000000000 --- a/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs +++ /dev/null @@ -1,669 +0,0 @@ -//! Serde types mirroring the OpenAI Chat Completions wire shapes. - -use crate::codec::cache::CacheControl; -use crate::codec::split_inclusive_token_total; -use crate::types::{ContentPart, ReasoningEffort, TokenCounts}; - -#[derive(serde::Serialize)] -pub(super) struct ApiRequest { - pub model: String, - pub messages: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub top_p: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning_effort: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stop: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_choice: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub response_format: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stream: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stream_options: Option, -} - -/// Streaming options. Chat Completions only emits the trailing usage chunk -/// when the request opts in, so without this a streamed response reports zero -/// tokens and costs are estimated at $0. -#[derive(serde::Serialize)] -pub(super) struct StreamOptions { - pub include_usage: bool, -} - -#[derive(serde::Serialize)] -pub(super) struct ChatMessage { - pub role: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, - /// Reasoning/thinking content echoed back for providers that require it - /// during tool-call continuations (including Kimi and DeepSeek). - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning_content: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, -} - -/// Message content: plain text, or text parts when a part carries a -/// `cache_control` breakpoint (aggregators fronting Anthropic models forward -/// it upstream). Unmarked messages keep the plain-string form for maximum -/// compatibility with strict Chat Completions servers. -#[derive(serde::Serialize)] -#[serde(untagged)] -pub(super) enum ChatContent { - Text(String), - Parts(Vec), -} - -#[derive(serde::Serialize)] -pub(super) struct ChatTextPart { - #[serde(rename = "type")] - pub kind: String, - pub text: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_control: Option, -} - -impl ChatContent { - /// Plain-text view for assertions. - #[cfg(test)] - pub(super) fn as_text(&self) -> Option<&str> { - match self { - Self::Text(text) => Some(text.as_str()), - Self::Parts(_) => None, - } - } - - /// Mark this content as a prompt-cache breakpoint, converting to parts - /// form so the annotation has somewhere to live. - pub(super) fn mark_cache_breakpoint(&mut self) { - match self { - Self::Text(text) => { - *self = Self::Parts(vec![ChatTextPart { - kind: "text".to_string(), - text: std::mem::take(text), - cache_control: Some(CacheControl::ephemeral()), - }]); - } - Self::Parts(parts) => { - if let Some(last) = parts.last_mut() { - last.cache_control = Some(CacheControl::ephemeral()); - } - } - } - } -} - -#[derive(serde::Serialize)] -pub(super) struct ChatToolCall { - pub id: String, - #[serde(rename = "type")] - pub kind: String, - pub function: ChatFunction, -} - -#[derive(serde::Serialize)] -pub(super) struct ChatFunction { - pub name: String, - pub arguments: String, -} - -// --- Response types (non-streaming) --- - -#[derive(serde::Deserialize)] -pub(super) struct ApiResponse { - pub id: String, - pub model: String, - pub choices: Vec, - pub usage: Option, - pub cost: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct ApiCost { - pub usd: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct ApiChoice { - pub message: ApiChoiceMessage, - pub finish_reason: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct ApiChoiceMessage { - pub content: Option, - pub reasoning_content: Option, - /// OpenRouter's normalized spelling for reasoning text. - pub reasoning: Option, - /// Structured reasoning channel (OpenRouter and compatible - /// aggregators). Kept as an untyped value so unknown detail variants - /// cannot fail an otherwise valid completion. - #[serde(default)] - pub reasoning_details: Option, - pub tool_calls: Option>, -} - -impl ApiChoiceMessage { - pub(super) fn reasoning(&self) -> Option<&str> { - self.reasoning_content - .as_deref() - .or(self.reasoning.as_deref()) - } -} - -/// Structured `reasoning_details` entries accumulated in wire order. -/// -/// The entries are preserved verbatim as an opaque content part so -/// encrypted material survives for future provider-aware replay; only known -/// readable members are ever normalized out of them. -#[derive(Default)] -pub(super) struct ReasoningDetails { - entries: Vec, -} - -impl ReasoningDetails { - /// Preserve a complete-response `reasoning_details` payload. - /// - /// Providers document an array of detail objects; a lone object is - /// accepted as a single entry. Complete entries retain their received - /// order and shape; scalars carry nothing replayable and are dropped. - pub(super) fn from_complete_payload(payload: serde_json::Value) -> Self { - let entries = match payload { - serde_json::Value::Array(entries) => entries - .into_iter() - .filter(serde_json::Value::is_object) - .collect(), - payload @ serde_json::Value::Object(_) => vec![payload], - _ => Vec::new(), - }; - Self { entries } - } - - /// Absorb one streamed `reasoning_details` payload. - /// - /// Fragments carrying the same `type` and `index` are coalesced even when - /// other logical details appear between them. Without an index, a fragment - /// continues the most recently seen detail of the same type. First-seen - /// detail order is retained. - pub(super) fn push_stream_payload(&mut self, payload: serde_json::Value) { - let incoming = match payload { - serde_json::Value::Array(entries) => entries, - payload @ serde_json::Value::Object(_) => vec![payload], - _ => Vec::new(), - }; - for entry in incoming { - if !entry.is_object() { - continue; - } - match self - .entries - .iter_mut() - .rev() - .find(|existing| continues_detail(existing, &entry)) - { - Some(existing) => merge_detail_fragment(existing, entry), - _ => self.entries.push(entry), - } - } - } - - /// Opaque content part holding the accumulated entries, or `None` when - /// nothing usable arrived. - pub(super) fn into_content_part(self) -> Option { - (!self.entries.is_empty()).then(|| ContentPart::Other { - kind: ContentPart::OPENAI_COMPAT_REASONING_DETAILS.to_string(), - data: serde_json::Value::Array(self.entries), - }) - } -} - -/// Text-bearing members whose fragments concatenate across stream chunks. -const DETAIL_TEXT_MEMBERS: [&str; 3] = ["text", "summary", "data"]; - -/// Whether `entry` continues the logical detail already in `last`. -/// -/// Aggregators tag each logical detail with a stable `type` and `index`; -/// fragment streams that omit `index` are matched on `type` alone. -fn continues_detail(last: &serde_json::Value, entry: &serde_json::Value) -> bool { - let (Some(last_type), Some(entry_type)) = ( - last.get("type").and_then(serde_json::Value::as_str), - entry.get("type").and_then(serde_json::Value::as_str), - ) else { - return false; - }; - if last_type != entry_type { - return false; - } - - match ( - last.get("index").and_then(serde_json::Value::as_u64), - entry.get("index").and_then(serde_json::Value::as_u64), - ) { - (Some(last_index), Some(entry_index)) => last_index == entry_index, - _ => true, - } -} - -/// Append `entry`'s text fragments onto `last` and fill in members `last` -/// has not seen yet. -fn merge_detail_fragment(last: &mut serde_json::Value, entry: serde_json::Value) { - let serde_json::Value::Object(entry_members) = entry else { - return; - }; - let Some(last_members) = last.as_object_mut() else { - return; - }; - for (key, value) in entry_members { - match last_members.get_mut(&key) { - Some(serde_json::Value::String(existing)) - if DETAIL_TEXT_MEMBERS.contains(&key.as_str()) => - { - if let Some(fragment) = value.as_str() { - existing.push_str(fragment); - } - } - Some(_) => {} - None => { - last_members.insert(key, value); - } - } - } -} - -#[derive(serde::Deserialize)] -pub(super) struct ApiToolCall { - pub id: String, - pub function: ApiFunction, -} - -#[derive(serde::Deserialize)] -pub(super) struct ApiFunction { - pub name: String, - pub arguments: String, -} - -#[derive(serde::Deserialize)] -#[allow( - clippy::struct_field_names, - reason = "Field names mirror the provider API payload." -)] -pub(super) struct ApiUsage { - pub prompt_tokens: i64, - pub completion_tokens: i64, - /// Tolerant superset: aggregator dialects (OpenRouter) report in-band - /// USD cost and cache/reasoning token detail. Absent on plain providers. - #[serde(default)] - pub cost: Option, - #[serde(default)] - pub prompt_tokens_details: Option, - /// DeepSeek-specific top-level count of prompt tokens served from its - /// automatic context cache. - #[serde(default)] - pub prompt_cache_hit_tokens: Option, - #[serde(default)] - pub completion_tokens_details: Option, - /// Modal reports reasoning tokens directly on `usage` instead of nesting - /// them under `completion_tokens_details`. - #[serde(default)] - pub reasoning_tokens: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct PromptTokensDetails { - #[serde(default)] - pub cached_tokens: Option, - /// OpenRouter-specific: explicit-cache write tokens. - #[serde(default)] - pub cache_write_tokens: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct CompletionTokensDetails { - #[serde(default)] - pub reasoning_tokens: Option, -} - -impl ApiUsage { - /// Normalize into disjoint [`TokenCounts`] buckets: cached and - /// cache-write detail tokens are subtracted out of `input_tokens`, and - /// reasoning tokens out of `output_tokens`, mirroring the - /// `openai_responses` convention. - /// - /// Nested detail fields win over the flat `prompt_cache_hit_tokens` and - /// `reasoning_tokens` spellings that some providers send instead. - pub(super) fn token_counts(&self) -> TokenCounts { - let cached_detail = self - .prompt_tokens_details - .as_ref() - .and_then(|d| d.cached_tokens) - .or(self.prompt_cache_hit_tokens) - .unwrap_or(0); - let cache_write_detail = self - .prompt_tokens_details - .as_ref() - .and_then(|d| d.cache_write_tokens) - .unwrap_or(0); - let reasoning_detail = self - .completion_tokens_details - .as_ref() - .and_then(|d| d.reasoning_tokens) - .or(self.reasoning_tokens) - .unwrap_or(0); - let (uncached_input, cached) = - split_inclusive_token_total(self.prompt_tokens, cached_detail); - let (input_tokens, cache_write) = - split_inclusive_token_total(uncached_input, cache_write_detail); - let (output_tokens, reasoning) = - split_inclusive_token_total(self.completion_tokens, reasoning_detail); - TokenCounts { - input_tokens, - output_tokens, - reasoning_tokens: reasoning, - cache_read_tokens: cached, - cache_write_tokens: cache_write, - } - } -} - -// --- Streaming response types --- - -#[derive(serde::Deserialize)] -pub(super) struct StreamChunk { - pub id: Option, - pub model: Option, - pub choices: Option>, - pub usage: Option, - pub cost: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct StreamChoice { - pub delta: Option, - pub finish_reason: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct StreamDelta { - pub content: Option, - /// Reasoning/thinking content (used by Kimi and other reasoning models). - pub reasoning_content: Option, - /// OpenRouter's normalized spelling for reasoning text. - pub reasoning: Option, - /// Structured reasoning channel, streamed as fragments of the entries - /// the non-streaming response returns whole. - #[serde(default)] - pub reasoning_details: Option, - pub tool_calls: Option>, -} - -impl StreamDelta { - pub(super) fn reasoning(&self) -> Option<&str> { - self.reasoning_content - .as_deref() - .or(self.reasoning.as_deref()) - } -} - -#[derive(serde::Deserialize)] -pub(super) struct StreamToolCall { - pub index: usize, - pub id: Option, - pub function: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct StreamFunction { - pub name: Option, - pub arguments: Option, -} - -// --- Accumulated tool call state for streaming --- - -pub(super) struct AccumulatedToolCall { - pub id: String, - pub name: String, - pub arguments: String, - pub started: bool, -} - -#[cfg(test)] -mod tests { - use super::{ - ApiResponse, ApiUsage, ChatContent, ChatTextPart, ReasoningDetails, StreamChunk, - continues_detail, - }; - use crate::codec::cache::CacheControl; - use crate::types::{ContentPart, TokenCounts}; - - #[test] - fn reasoning_detail_continuation_uses_type_when_either_index_is_missing() { - let indexed = serde_json::json!({"type": "reasoning.text", "index": 0}); - let unindexed = serde_json::json!({"type": "reasoning.text"}); - - assert!(continues_detail(&indexed, &unindexed)); - assert!(continues_detail(&unindexed, &indexed)); - assert!(continues_detail(&unindexed, &unindexed)); - } - - #[test] - fn reasoning_detail_continuation_requires_a_matching_string_type() { - let detail = serde_json::json!({"type": "reasoning.text", "index": 0}); - - assert!(!continues_detail( - &detail, - &serde_json::json!({"type": "reasoning.summary", "index": 0}) - )); - assert!(!continues_detail( - &serde_json::json!({"index": 0}), - &serde_json::json!({"index": 0}) - )); - assert!(!continues_detail( - &serde_json::json!({"type": 7, "index": 0}), - &serde_json::json!({"type": 7, "index": 0}) - )); - } - - #[test] - fn unindexed_reasoning_fragment_continues_the_latest_matching_type() { - let mut details = ReasoningDetails::default(); - details.push_stream_payload(serde_json::json!([ - {"type": "reasoning.text", "text": "first", "index": 0}, - {"type": "reasoning.text", "text": "second", "index": 1}, - ])); - details.push_stream_payload(serde_json::json!([ - {"type": "reasoning.text", "text": " continued"}, - ])); - - let ContentPart::Other { data, .. } = - details.into_content_part().expect("reasoning detail part") - else { - panic!("expected opaque reasoning detail part"); - }; - assert_eq!( - data, - serde_json::json!([ - {"type": "reasoning.text", "text": "first", "index": 0}, - {"type": "reasoning.text", "text": "second continued", "index": 1}, - ]) - ); - } - - #[test] - fn chat_content_text_serializes_as_plain_string() { - let content = ChatContent::Text("Hello".to_string()); - assert_eq!( - serde_json::to_value(&content).unwrap(), - serde_json::json!("Hello") - ); - } - - #[test] - fn mark_cache_breakpoint_converts_text_to_annotated_parts() { - let mut content = ChatContent::Text("Hello".to_string()); - content.mark_cache_breakpoint(); - assert_eq!( - serde_json::to_value(&content).unwrap(), - serde_json::json!([{ - "type": "text", - "text": "Hello", - "cache_control": {"type": "ephemeral"} - }]) - ); - } - - #[test] - fn mark_cache_breakpoint_annotates_last_existing_part() { - let mut content = ChatContent::Parts(vec![ - ChatTextPart { - kind: "text".to_string(), - text: "first".to_string(), - cache_control: None, - }, - ChatTextPart { - kind: "text".to_string(), - text: "second".to_string(), - cache_control: Some(CacheControl::ephemeral()), - }, - ]); - content.mark_cache_breakpoint(); - let json = serde_json::to_value(&content).unwrap(); - assert!(json[0].get("cache_control").is_none()); - assert_eq!(json[1]["cache_control"]["type"], "ephemeral"); - } - - #[test] - fn token_counts_bound_detail_to_parent_totals() { - let usage: ApiUsage = serde_json::from_value(serde_json::json!({ - "prompt_tokens": 53, - "completion_tokens": 59, - "completion_tokens_details": {"reasoning_tokens": 66} - })) - .unwrap(); - - assert_eq!(usage.token_counts(), TokenCounts { - input_tokens: 53, - output_tokens: 0, - reasoning_tokens: 59, - ..TokenCounts::default() - }); - } - - #[test] - fn token_counts_accept_deepseek_cache_hit_field() { - let usage: ApiUsage = serde_json::from_value(serde_json::json!({ - "prompt_tokens": 53, - "completion_tokens": 11, - "prompt_cache_hit_tokens": 41 - })) - .unwrap(); - - assert_eq!(usage.token_counts(), TokenCounts { - input_tokens: 12, - output_tokens: 11, - cache_read_tokens: 41, - ..TokenCounts::default() - }); - } - - #[test] - fn token_counts_accept_modal_reasoning_tokens_field() { - let usage: ApiUsage = serde_json::from_value(serde_json::json!({ - "prompt_tokens": 116, - "completion_tokens": 66, - "reasoning_tokens": 54 - })) - .unwrap(); - - assert_eq!(usage.token_counts(), TokenCounts { - input_tokens: 116, - output_tokens: 12, - reasoning_tokens: 54, - ..TokenCounts::default() - }); - } - - #[test] - fn token_counts_prefer_nested_reasoning_detail_over_top_level() { - let both_spellings: ApiUsage = serde_json::from_value(serde_json::json!({ - "prompt_tokens": 10, - "completion_tokens": 66, - "completion_tokens_details": {"reasoning_tokens": 20}, - "reasoning_tokens": 54 - })) - .unwrap(); - assert_eq!(both_spellings.token_counts().reasoning_tokens, 20); - - let empty_detail: ApiUsage = serde_json::from_value(serde_json::json!({ - "prompt_tokens": 10, - "completion_tokens": 66, - "completion_tokens_details": {}, - "reasoning_tokens": 54 - })) - .unwrap(); - assert_eq!(empty_detail.token_counts().reasoning_tokens, 54); - } - - #[test] - fn reasoning_accepts_provider_and_openrouter_spellings() { - let provider_response: ApiResponse = serde_json::from_value(serde_json::json!({ - "id": "response-1", - "model": "reasoning-model", - "choices": [{ - "message": { - "content": null, - "reasoning_content": "provider reasoning" - }, - "finish_reason": "stop" - }] - })) - .unwrap(); - assert_eq!( - provider_response.choices[0].message.reasoning(), - Some("provider reasoning") - ); - - let openrouter_response: ApiResponse = serde_json::from_value(serde_json::json!({ - "id": "response-2", - "model": "reasoning-model", - "choices": [{ - "message": { - "content": null, - "reasoning": "OpenRouter reasoning" - }, - "finish_reason": "stop" - }] - })) - .unwrap(); - assert_eq!( - openrouter_response.choices[0].message.reasoning(), - Some("OpenRouter reasoning") - ); - let openrouter_chunk: StreamChunk = serde_json::from_value(serde_json::json!({ - "id": "response-2", - "model": "reasoning-model", - "choices": [{ - "delta": {"reasoning": "OpenRouter reasoning"}, - "finish_reason": null - }] - })) - .unwrap(); - assert_eq!( - openrouter_chunk.choices.unwrap()[0] - .delta - .as_ref() - .unwrap() - .reasoning(), - Some("OpenRouter reasoning") - ); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_responses/decode.rs b/lib/components/fabro-llm/src/codec/openai_responses/decode.rs deleted file mode 100644 index 55fbd03d5..000000000 --- a/lib/components/fabro-llm/src/codec/openai_responses/decode.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! Response decoding: OpenAI Responses API body → canonical `Response`. - -use serde::Deserialize; - -use super::wire::{ApiResponse, ApiUsage, InputTokensResponse}; -use crate::codec::{CodecCtx, parse_tool_arguments_or_empty, split_inclusive_token_total}; -use crate::error::Error; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, TokenCounts, ToolCall, -}; - -pub(super) fn token_counts_from_api_usage(usage: Option<&ApiUsage>) -> TokenCounts { - usage.map_or_else(TokenCounts::default, |u| { - let cached_detail = u - .input_tokens_details - .as_ref() - .and_then(|d| d.cached_tokens) - .unwrap_or(0); - let reasoning_detail = u - .output_tokens_details - .as_ref() - .and_then(|d| d.reasoning_tokens) - .unwrap_or(0); - let (input_tokens, cached_tokens) = - split_inclusive_token_total(u.input_tokens, cached_detail); - let (output_tokens, reasoning_tokens) = - split_inclusive_token_total(u.output_tokens, reasoning_detail); - TokenCounts { - input_tokens, - output_tokens, - reasoning_tokens, - cache_read_tokens: cached_tokens, - ..TokenCounts::default() - } - }) -} - -/// Map the Responses API status to a `FinishReason`. -pub(super) fn map_finish_reason(status: Option<&str>, has_tool_calls: bool) -> FinishReason { - if has_tool_calls { - return FinishReason::ToolCalls; - } - match status { - Some("completed") | None => FinishReason::Stop, - Some("incomplete") => FinishReason::Length, - Some("failed") => FinishReason::Error, - Some(other) => FinishReason::Other(other.to_string()), - } -} - -/// Build a `ToolCall` from a `function_call` / `custom_tool_call` output item. -/// The call-id/item-id round-trip rules live here, shared by the blocking and -/// streaming decode paths. -pub(super) fn tool_call_from_item(item: &serde_json::Value, custom: bool) -> ToolCall { - let item_id = item - .get("id") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let call_id = item - .get("call_id") - .and_then(serde_json::Value::as_str) - .unwrap_or(item_id); - let name = item - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - - let mut tc = if custom { - let raw_input = item - .get("input") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let mut tc = ToolCall::new(call_id, name, serde_json::json!(raw_input)); - tc.tool_type = "custom".to_string(); - tc.raw_arguments = Some(raw_input.to_string()); - tc - } else { - let args_str = item - .get("arguments") - .and_then(serde_json::Value::as_str) - .unwrap_or("{}"); - let arguments = parse_tool_arguments_or_empty(args_str); - let mut tc = ToolCall::new(call_id, name, arguments); - tc.raw_arguments = Some(args_str.to_string()); - tc - }; - // Preserve item-level ID (fc_xxx) for Responses API round-trip - if !item_id.is_empty() { - tc.provider_metadata = Some(serde_json::json!({"id": item_id})); - } - tc -} - -/// Parse output items from the Responses API into content parts. -pub(super) fn parse_output(output: Vec) -> (Vec, bool) { - let mut parts = Vec::new(); - let mut has_tool_calls = false; - - for item in output { - let item_type = item - .get("type") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_string(); - match item_type.as_str() { - "message" => { - // Preserve the full message item for Responses API round-tripping. - // The item's `id` and `status` fields are required so that reasoning - // items preceding it can find their "required following item." - let mut texts = Vec::new(); - if let Some(content) = item.get("content").and_then(|c| c.as_array()) { - for block in content { - if block.get("type").and_then(serde_json::Value::as_str) - == Some("output_text") - { - if let Some(text) = - block.get("text").and_then(serde_json::Value::as_str) - { - texts.push(ContentPart::text(text)); - } - } - } - } - parts.push(ContentPart::Other { - kind: ContentPart::OPENAI_MESSAGE.to_string(), - data: item, - }); - parts.extend(texts); - } - "reasoning" => { - parts.push(ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: item, - }); - } - "function_call" | "custom_tool_call" => { - let tc = tool_call_from_item(&item, item_type == "custom_tool_call"); - // Skip tool calls with empty names (e.g. model-internal items) - if tc.name.is_empty() { - continue; - } - has_tool_calls = true; - parts.push(ContentPart::ToolCall(tc)); - } - _ => {} - } - } - - (parts, has_tool_calls) -} - -pub(super) fn decode_response( - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, -) -> Result { - let raw: serde_json::Value = serde_json::from_str(body) - .map_err(|e| Error::network(format!("failed to parse OpenAI response: {e}"), e))?; - let api_resp = ApiResponse::deserialize(&raw) - .map_err(|e| Error::network(format!("failed to parse OpenAI response: {e}"), e))?; - - let (content_parts, has_tool_calls) = parse_output(api_resp.output); - let finish_reason = map_finish_reason(api_resp.status.as_deref(), has_tool_calls); - - let usage = token_counts_from_api_usage(api_resp.usage.as_ref()); - - Ok(Response { - id: api_resp.id, - model: api_resp.model.unwrap_or_else(|| ctx.request.model.clone()), - provider: ctx.provider_name.to_string(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason, - usage, - raw: Some(raw), - warnings: vec![], - rate_limit, - cost_usd: None, - cost_source: None, - }) -} - -pub(super) fn decode_count_tokens(body: &str) -> Result { - let response: InputTokensResponse = - serde_json::from_str(body).map_err(|e| Error::Configuration { - message: format!("failed to parse OpenAI input token response: {e}"), - source: None, - })?; - - if response.object != "response.input_tokens" { - return Err(Error::Configuration { - message: format!( - "failed to parse OpenAI input token response: unexpected object '{}'", - response.object - ), - source: None, - }); - } - - Ok(response.input_tokens) -} - -#[cfg(test)] -mod tests { - use super::super::encode; - use super::*; - - #[test] - fn token_counts_bound_detail_to_parent_totals() { - let usage: ApiUsage = serde_json::from_value(serde_json::json!({ - "input_tokens": 53, - "output_tokens": 59, - "input_tokens_details": null, - "output_tokens_details": {"reasoning_tokens": 66} - })) - .unwrap(); - - assert_eq!(token_counts_from_api_usage(Some(&usage)), TokenCounts { - input_tokens: 53, - output_tokens: 0, - reasoning_tokens: 59, - ..TokenCounts::default() - }); - } - - #[test] - fn parse_output_preserves_both_ids_on_function_call() { - let output = vec![serde_json::json!({ - "type": "function_call", - "id": "fc_abc123", - "call_id": "call_xyz789", - "name": "get_weather", - "arguments": "{\"location\":\"NYC\"}" - })]; - let (parts, has_tool_calls) = parse_output(output); - assert!(has_tool_calls); - assert_eq!(parts.len(), 1); - match &parts[0] { - ContentPart::ToolCall(tc) => { - // call_id is used as the ToolCall.id (links to tool results) - assert_eq!(tc.id, "call_xyz789"); - // item-level id (fc_xxx) is preserved in provider_metadata - let meta = tc - .provider_metadata - .as_ref() - .expect("provider_metadata should be set"); - assert_eq!(meta["id"], "fc_abc123"); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - #[test] - fn parse_output_preserves_custom_tool_call_raw_input() { - let patch = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"; - let output = vec![serde_json::json!({ - "type": "custom_tool_call", - "id": "ctc_abc123", - "call_id": "call_xyz789", - "name": "apply_patch", - "input": patch, - })]; - - let (parts, has_tool_calls) = parse_output(output); - - assert!(has_tool_calls); - assert_eq!(parts.len(), 1); - match &parts[0] { - ContentPart::ToolCall(tc) => { - assert_eq!(tc.id, "call_xyz789"); - assert_eq!(tc.name, "apply_patch"); - assert_eq!(tc.tool_type, "custom"); - assert_eq!(tc.arguments, serde_json::json!(patch)); - assert_eq!(tc.raw_arguments.as_deref(), Some(patch)); - let meta = tc - .provider_metadata - .as_ref() - .expect("provider metadata should preserve item id"); - assert_eq!(meta["id"], "ctc_abc123"); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - #[test] - fn parse_output_preserves_reasoning_items() { - let output = vec![ - serde_json::json!({ - "type": "reasoning", - "id": "rs_abc123", - "summary": [{"type": "summary_text", "text": "Thinking..."}] - }), - serde_json::json!({ - "type": "function_call", - "id": "fc_def456", - "call_id": "call_789", - "name": "search", - "arguments": "{}" - }), - ]; - let (parts, has_tool_calls) = parse_output(output); - assert!(has_tool_calls); - assert_eq!(parts.len(), 2); - // First part is the reasoning item - match &parts[0] { - ContentPart::Other { kind, data } => { - assert_eq!(kind, ContentPart::OPENAI_REASONING); - assert_eq!(data["type"], "reasoning"); - assert_eq!(data["id"], "rs_abc123"); - } - other => panic!("expected Other, got {other:?}"), - } - // Second part is the function call - assert!(matches!(&parts[1], ContentPart::ToolCall(_))); - } - - #[test] - fn parse_output_preserves_message_items() { - let output = vec![ - serde_json::json!({ - "type": "reasoning", - "id": "rs_abc", - "summary": [] - }), - serde_json::json!({ - "type": "message", - "id": "msg_xyz", - "status": "completed", - "role": "assistant", - "content": [{"type": "output_text", "text": "Hello"}] - }), - serde_json::json!({ - "type": "function_call", - "id": "fc_123", - "call_id": "call_456", - "name": "search", - "arguments": "{}" - }), - ]; - let (parts, has_tool_calls) = parse_output(output); - assert!(has_tool_calls); - // reasoning + openai_message + text + function_call - assert_eq!(parts.len(), 4); - assert!( - matches!(&parts[0], ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_REASONING) - ); - assert!( - matches!(&parts[1], ContentPart::Other { kind, data } if kind == ContentPart::OPENAI_MESSAGE && data["id"] == "msg_xyz") - ); - assert!(matches!(&parts[2], ContentPart::Text(t) if t == "Hello")); - assert!(matches!(&parts[3], ContentPart::ToolCall(_))); - } - - #[test] - fn parse_output_round_trips_function_call_ids() { - // Simulate a response from the Responses API - let output = vec![serde_json::json!({ - "type": "function_call", - "id": "fc_item1", - "call_id": "call_001", - "name": "search", - "arguments": "{\"q\":\"test\"}" - })]; - let (parts, _) = parse_output(output); - - // Now translate back to input format - let msg = Message { - role: Role::Assistant, - content: parts, - name: None, - tool_call_id: None, - }; - let (_, input) = encode::translate_input(&[msg]); - let fc = &input[0]; - - // The round-tripped function call should have correct IDs - assert_eq!(fc["id"], "fc_item1"); - assert_eq!(fc["call_id"], "call_001"); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_responses/encode.rs b/lib/components/fabro-llm/src/codec/openai_responses/encode.rs deleted file mode 100644 index 8ede38ec3..000000000 --- a/lib/components/fabro-llm/src/codec/openai_responses/encode.rs +++ /dev/null @@ -1,949 +0,0 @@ -//! Request encoding: canonical request → OpenAI Responses API body. -//! -//! Pure and sync. File-backed image attachments are resolved to inline data by -//! `attachments::resolve` in the adapter *before* encode runs, so the content -//! translation here never touches the filesystem. - -use std::collections::HashSet; - -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; - -use super::wire::ApiRequest; -use crate::codec::{CodecCtx, EncodedRequest}; -use crate::types::{ - ContentPart, Message, ResponseFormat, ResponseFormatType, Role, ToolChoice, ToolDefinition, -}; - -// --- Public entry points ----------------------------------------------------- - -pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> EncodedRequest { - EncodedRequest { - body: build_body(ctx, stream), - endpoint: "/responses".to_string(), - headers: Vec::new(), - } -} - -pub(super) fn encode_count_tokens(ctx: &CodecCtx<'_>) -> EncodedRequest { - EncodedRequest { - body: filter_input_tokens_request_body(build_body(ctx, false)), - endpoint: "/responses/input_tokens".to_string(), - headers: Vec::new(), - } -} - -/// Serialize the API request and merge any `provider_options.openai` keys into -/// the body (overrides win, matching the long-standing contract). -fn build_body(ctx: &CodecCtx<'_>, stream: bool) -> serde_json::Value { - let api_request = build_api_request(ctx, stream); - let mut body = serde_json::to_value(&api_request).unwrap_or_else(|_| serde_json::json!({})); - - if let Some(openai_opts) = ctx - .request - .provider_options - .as_ref() - .and_then(|opts| opts.get("openai")) - { - if let (Some(base), Some(overrides)) = (body.as_object_mut(), openai_opts.as_object()) { - for (key, value) in overrides { - base.insert(key.clone(), value.clone()); - } - } - } - - body -} - -/// Build an `ApiRequest` from the canonical request. -/// -/// When the route is in codex mode (`ctx.params.openai_codex`), unsupported -/// fields (`temperature`, `max_output_tokens`, `top_p`) are omitted and empty -/// instructions are sent as `""` (required by the Codex endpoint). -fn build_api_request(ctx: &CodecCtx<'_>, stream: bool) -> ApiRequest { - let request = ctx.request; - let codex_mode = ctx.params.openai_codex; - - let (instructions, input) = translate_input(&request.messages); - let api_tools = request.tools.as_ref().map(|t| translate_tools(t)); - let tool_choice = request.tool_choice.as_ref().map(translate_tool_choice); - let reasoning = request - .reasoning_effort - .as_ref() - .map(|effort| serde_json::json!({"effort": <&'static str>::from(*effort)})); - let text = request - .response_format - .as_ref() - .and_then(translate_response_format); - - let include = vec!["reasoning.encrypted_content".to_string()]; - - let instructions = if codex_mode { - Some(instructions.unwrap_or_default()) - } else { - instructions - }; - - ApiRequest { - model: ctx.deployment_id.to_string(), - input, - instructions, - temperature: if codex_mode { - None - } else { - request.temperature - }, - max_output_tokens: if codex_mode { None } else { request.max_tokens }, - top_p: if codex_mode { None } else { request.top_p }, - tools: api_tools, - tool_choice, - reasoning, - text, - stop: request.stop_sequences.clone(), - metadata: request.metadata.clone(), - // store: false means output items are not persisted server-side. - // Request encrypted reasoning content on every turn so reasoning items - // from models that emit them by default can round-trip statelessly. - store: false, - include, - stream, - } -} - -/// Project a full request body down to the fields the -/// `/responses/input_tokens` endpoint accepts. -fn filter_input_tokens_request_body(mut body: serde_json::Value) -> serde_json::Value { - const ALLOWED_FIELDS: &[&str] = &[ - "conversation", - "input", - "instructions", - "model", - "parallel_tool_calls", - "previous_response_id", - "reasoning", - "text", - "tool_choice", - "tools", - "truncation", - ]; - - let Some(obj) = body.as_object_mut() else { - return serde_json::json!({}); - }; - obj.retain(|key, _| ALLOWED_FIELDS.contains(&key.as_str())); - body -} - -// --- Content / message / tool translation ------------------------------------ - -/// Translate unified messages to Responses API `input` array format. Sync: -/// file-backed image attachments are already resolved to inline data upstream. -pub(super) fn translate_input(messages: &[Message]) -> (Option, Vec) { - let mut instructions_parts: Vec = Vec::new(); - let mut input: Vec = Vec::new(); - let mut custom_call_ids: HashSet = HashSet::new(); - - for msg in messages { - match msg.role { - Role::System | Role::Developer => { - instructions_parts.push(msg.text()); - } - Role::User => { - let mut content = Vec::new(); - for part in &msg.content { - let maybe_content = match part { - ContentPart::Text(text) => { - Some(serde_json::json!({"type": "input_text", "text": text})) - } - ContentPart::Image(img) => match &img.url { - Some(url) => { - Some(serde_json::json!({"type": "input_image", "image_url": url})) - } - None => img.data.as_ref().map(|data| { - let mime = img.media_type.as_deref().unwrap_or("image/png"); - let b64 = BASE64_STANDARD.encode(data); - serde_json::json!({ - "type": "input_image", - "image_url": format!("data:{mime};base64,{b64}"), - }) - }), - }, - ContentPart::Audio(_) => Some( - serde_json::json!({"type": "input_text", "text": "[Audio content not supported by this provider]"}), - ), - ContentPart::Document(doc) => { - let desc = doc.file_name.as_ref().map_or_else( - || "[Document content not supported by this provider]".to_string(), - |name| format!("[Document '{name}': content type not supported by this provider]"), - ); - Some(serde_json::json!({"type": "input_text", "text": desc})) - } - _ => None, - }; - if let Some(content_part) = maybe_content { - content.push(content_part); - } - } - if !content.is_empty() { - input.push(serde_json::json!({ - "type": "message", - "role": "user", - "content": content, - })); - } - } - Role::Assistant => { - // If we have a preserved opaque message item (with id/status), use - // it instead of constructing a new message from Text parts. This is - // required so that reasoning items can find their "required following - // item" during Responses API round-tripping. - let has_opaque_message = msg.content.iter().any(|p| { - matches!(p, ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_MESSAGE) - }); - for part in &msg.content { - match part { - ContentPart::Text(text) if !has_opaque_message => { - input.push(serde_json::json!({ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": text}], - })); - } - ContentPart::ToolCall(tc) if !tc.name.is_empty() => { - // Use the item-level ID (fc_xxx) for the `id` field; - // fall back to tc.id if no provider_metadata was stored. - let item_id = tc - .provider_metadata - .as_ref() - .and_then(|m| m.get("id")) - .and_then(serde_json::Value::as_str) - .unwrap_or(&tc.id); - if tc.tool_type == "custom" { - custom_call_ids.insert(tc.id.clone()); - let raw_input = tc.raw_arguments.as_ref().map_or_else( - || { - tc.arguments.as_str().map_or_else( - || tc.arguments.to_string(), - str::to_string, - ) - }, - Clone::clone, - ); - input.push(serde_json::json!({ - "type": "custom_tool_call", - "id": item_id, - "call_id": tc.id, - "name": tc.name, - "input": raw_input, - })); - } else { - let args = tc - .raw_arguments - .as_ref() - .map_or_else(|| tc.arguments.to_string(), Clone::clone); - input.push(serde_json::json!({ - "type": "function_call", - "id": item_id, - "call_id": tc.id, - "name": tc.name, - "arguments": args, - })); - } - } - ContentPart::Other { data, .. } if part.is_opaque_openai() => { - input.push(data.clone()); - } - _ => {} - } - } - } - Role::Tool => { - for part in &msg.content { - if let ContentPart::ToolResult(tr) = part { - let output = tr - .content - .as_str() - .map_or_else(|| tr.content.to_string(), str::to_string); - let is_custom = custom_call_ids.contains(&tr.tool_call_id) - || msg.name.as_deref() == Some("apply_patch"); - let mut item = if is_custom { - serde_json::json!({ - "type": "custom_tool_call_output", - "call_id": tr.tool_call_id, - "output": output, - }) - } else { - serde_json::json!({ - "type": "function_call_output", - "call_id": tr.tool_call_id, - "output": output, - }) - }; - if tr.is_error && !is_custom { - item["status"] = serde_json::json!("incomplete"); - } - input.push(item); - } - } - } - } - } - - let instructions = if instructions_parts.is_empty() { - None - } else { - Some(instructions_parts.join("\n")) - }; - - (instructions, input) -} - -/// Translate unified tool definitions to Responses API tool format. -pub(super) fn translate_tools(tools: &[ToolDefinition]) -> Vec { - tools - .iter() - .map(|t| { - if t.is_custom() { - serde_json::json!({ - "type": "custom", - "name": t.name, - "description": t.description, - "format": t.custom_format().cloned().unwrap_or_else(|| serde_json::json!({})), - }) - } else { - serde_json::json!({ - "type": "function", - "name": t.name, - "description": t.description, - "parameters": t.parameters, - }) - } - }) - .collect() -} - -/// Translate unified `ToolChoice` to Responses API format. -fn translate_tool_choice(choice: &ToolChoice) -> serde_json::Value { - match choice { - ToolChoice::Auto => serde_json::json!("auto"), - ToolChoice::None => serde_json::json!("none"), - ToolChoice::Required => serde_json::json!("required"), - ToolChoice::Named { tool_name } => { - serde_json::json!({"type": "function", "name": tool_name}) - } - } -} - -/// Translate unified `ResponseFormat` to Responses API `text` field. -/// -/// The Responses API uses `"text": {"format": {...}}` for structured output. -fn translate_response_format(format: &ResponseFormat) -> Option { - match format.kind { - ResponseFormatType::Text => None, - ResponseFormatType::JsonObject => { - Some(serde_json::json!({"format": {"type": "json_object"}})) - } - ResponseFormatType::JsonSchema => { - let mut schema_obj = serde_json::json!({ - "type": "json_schema", - "name": "response", - "strict": format.strict, - }); - if let Some(schema) = &format.json_schema { - schema_obj["schema"] = schema.clone(); - } - Some(serde_json::json!({"format": schema_obj})) - } - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use super::*; - use crate::codec::CodecParams; - use crate::types::{AudioData, DocumentData, ReasoningEffort, Request, ToolCall, ToolResult}; - - fn minimal_request() -> Request { - Request { - model: "gpt-4o".to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - /// Encode `request` (no catalog: the wire model id is the request model) - /// and return the merged body, mirroring the adapter's encode path. - fn encode_body(request: &Request, stream: bool, codex: bool) -> serde_json::Value { - let params = CodecParams { - openai_codex: codex, - ..CodecParams::default() - }; - let ctx = CodecCtx { - request, - provider_name: "openai", - deployment_id: &request.model, - model: None, - params: ¶ms, - }; - encode(&ctx, stream).body - } - - #[test] - fn build_request_body_includes_metadata() { - let mut metadata = HashMap::new(); - metadata.insert("user_id".to_string(), "u123".to_string()); - metadata.insert("session".to_string(), "s456".to_string()); - - let mut request = minimal_request(); - request.metadata = Some(metadata); - - let body = encode_body(&request, false, false); - let meta = body.get("metadata").expect("metadata should be present"); - assert_eq!(meta["user_id"], "u123"); - assert_eq!(meta["session"], "s456"); - } - - #[test] - fn build_request_body_omits_metadata_when_none() { - let request = minimal_request(); - let body = encode_body(&request, false, false); - assert!(body.get("metadata").is_none()); - } - - #[test] - fn build_request_body_merges_provider_options_openai() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "openai": { - "store": true, - "previous_response_id": "resp_abc123" - } - })); - - let body = encode_body(&request, false, false); - assert_eq!(body["store"], true); - assert_eq!(body["previous_response_id"], "resp_abc123"); - } - - #[test] - fn build_request_body_provider_options_override_fields() { - let mut request = minimal_request(); - request.temperature = Some(0.5); - request.provider_options = Some(serde_json::json!({ - "openai": { - "temperature": 0.9 - } - })); - - let body = encode_body(&request, false, false); - // provider_options should override the base field - assert_eq!(body["temperature"], 0.9); - } - - #[test] - fn build_request_body_ignores_non_openai_provider_options() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "anthropic": { - "thinking": {"type": "enabled", "budget_tokens": 10000} - } - })); - - let body = encode_body(&request, false, false); - // anthropic options should not leak into the OpenAI request - assert!(body.get("thinking").is_none()); - } - - #[test] - fn build_request_body_no_provider_options() { - let request = minimal_request(); - let body = encode_body(&request, false, false); - assert_eq!(body["model"], "gpt-4o"); - // stream field is omitted when false (skip_serializing_if) - assert!(body.get("stream").is_none()); - } - - #[test] - fn filter_input_tokens_request_body_keeps_only_count_fields() { - let mut metadata = HashMap::new(); - metadata.insert("trace".to_string(), "abc".to_string()); - - let mut request = minimal_request(); - request.tools = Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object"}), - )]); - request.reasoning_effort = Some(ReasoningEffort::Low); - request.response_format = Some(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(serde_json::json!({"type": "object"})), - strict: true, - }); - request.temperature = Some(0.2); - request.top_p = Some(0.9); - request.max_tokens = Some(32); - request.stop_sequences = Some(vec!["END".to_string()]); - request.metadata = Some(metadata); - - let body = encode_body(&request, true, false); - let filtered = filter_input_tokens_request_body(body); - - assert_eq!( - filtered, - serde_json::json!({ - "input": [{"type": "message", "content": [{"text": "Hello", "type": "input_text"}], "role": "user"}], - "model": "gpt-4o", - "reasoning": {"effort": "low"}, - "text": {"format": {"name": "response", "schema": {"type": "object"}, "strict": true, "type": "json_schema"}}, - "tools": [{"description": "Search files", "name": "search", "parameters": {"type": "object"}, "type": "function"}] - }) - ); - assert!(filtered.get("store").is_none()); - assert!(filtered.get("include").is_none()); - assert!(filtered.get("stream").is_none()); - assert!(filtered.get("max_output_tokens").is_none()); - assert!(filtered.get("metadata").is_none()); - assert!(filtered.get("temperature").is_none()); - assert!(filtered.get("top_p").is_none()); - assert!(filtered.get("stop").is_none()); - } - - #[test] - fn filter_input_tokens_request_body_preserves_codex_serialization() { - let body = encode_body(&minimal_request(), false, true); - let filtered = filter_input_tokens_request_body(body); - - assert_eq!(filtered["instructions"], ""); - assert!(filtered.get("input").is_some()); - assert!(filtered.get("model").is_some()); - assert!(filtered.get("max_output_tokens").is_none()); - assert!(filtered.get("include").is_none()); - } - - #[test] - fn count_tokens_endpoint_carries_filtered_body() { - let request = minimal_request(); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "openai", - deployment_id: &request.model, - model: None, - params: ¶ms, - }; - - let encoded = encode_count_tokens(&ctx); - assert_eq!(encoded.endpoint, "/responses/input_tokens"); - assert!(encoded.body.get("store").is_none()); - assert!(encoded.body.get("include").is_none()); - assert_eq!(encoded.body["model"], "gpt-4o"); - } - - #[test] - fn build_request_body_includes_encrypted_reasoning_for_stateless_requests() { - let request = minimal_request(); - - let body = encode_body(&request, false, false); - - assert_eq!( - body["include"], - serde_json::json!(["reasoning.encrypted_content"]) - ); - } - - #[test] - fn build_request_body_emits_custom_apply_patch_tool() { - let mut request = minimal_request(); - request.tools = Some(vec![ - ToolDefinition::custom( - "apply_patch", - "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.", - serde_json::json!({ - "type": "grammar", - "syntax": "lark", - "definition": "start: begin_patch hunk+ end_patch", - }), - ), - ToolDefinition::function( - "read_file", - "Read file", - serde_json::json!({ - "type": "object", - "properties": {"file_path": {"type": "string"}}, - "required": ["file_path"], - }), - ), - ]); - - let body = encode_body(&request, false, false); - let tools = body["tools"].as_array().expect("tools should be present"); - let apply_patch = tools - .iter() - .find(|tool| tool["name"] == "apply_patch") - .expect("apply_patch tool should be present"); - let read_file = tools - .iter() - .find(|tool| tool["name"] == "read_file") - .expect("read_file tool should be present"); - - assert_eq!(apply_patch["type"], "custom"); - assert_eq!(apply_patch["format"]["type"], "grammar"); - assert_eq!(apply_patch["format"]["syntax"], "lark"); - assert!(apply_patch.get("parameters").is_none()); - assert_eq!(read_file["type"], "function"); - assert_eq!(read_file["parameters"]["type"], "object"); - } - - #[test] - fn build_request_body_stream_flag() { - let request = minimal_request(); - let body = encode_body(&request, true, false); - assert!(body["stream"].as_bool().unwrap_or(false)); - } - - #[test] - fn build_request_body_metadata_and_provider_options_together() { - let mut metadata = HashMap::new(); - metadata.insert("trace_id".to_string(), "t789".to_string()); - - let mut request = minimal_request(); - request.metadata = Some(metadata); - request.provider_options = Some(serde_json::json!({ - "openai": { - "store": true - } - })); - - let body = encode_body(&request, false, false); - assert_eq!(body["metadata"]["trace_id"], "t789"); - assert_eq!(body["store"], true); - } - - #[test] - fn build_request_body_includes_stop_sequences() { - let mut request = minimal_request(); - request.stop_sequences = Some(vec!["END".to_string(), "STOP".to_string()]); - - let body = encode_body(&request, false, false); - let stop = body.get("stop").expect("stop should be present"); - let arr = stop.as_array().expect("stop should be an array"); - assert_eq!(arr.len(), 2); - assert_eq!(arr[0], "END"); - assert_eq!(arr[1], "STOP"); - } - - #[test] - fn build_request_body_omits_stop_when_none() { - let request = minimal_request(); - let body = encode_body(&request, false, false); - assert!(body.get("stop").is_none()); - } - - #[test] - fn audio_content_produces_text_fallback() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Audio(AudioData { - url: Some("https://example.com/audio.wav".to_string()), - data: None, - media_type: None, - })], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - let content = input[0]["content"] - .as_array() - .expect("content should be array"); - assert_eq!(content[0]["type"], "input_text"); - assert_eq!( - content[0]["text"], - "[Audio content not supported by this provider]" - ); - } - - #[test] - fn document_content_produces_text_fallback_with_filename() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Document(DocumentData { - url: Some("https://example.com/doc.pdf".to_string()), - data: None, - media_type: None, - file_name: Some("report.pdf".to_string()), - })], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - let content = input[0]["content"] - .as_array() - .expect("content should be array"); - assert_eq!(content[0]["type"], "input_text"); - assert_eq!( - content[0]["text"], - "[Document 'report.pdf': content type not supported by this provider]" - ); - } - - #[test] - fn document_content_produces_text_fallback_without_filename() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Document(DocumentData { - url: None, - data: Some(vec![1, 2, 3]), - media_type: None, - file_name: None, - })], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - let content = input[0]["content"] - .as_array() - .expect("content should be array"); - assert_eq!(content[0]["type"], "input_text"); - assert_eq!( - content[0]["text"], - "[Document content not supported by this provider]" - ); - } - - #[test] - fn translate_input_uses_item_id_for_id_field() { - let mut tc = ToolCall::new( - "call_xyz789", - "get_weather", - serde_json::json!({"location": "NYC"}), - ); - tc.provider_metadata = Some(serde_json::json!({"id": "fc_abc123"})); - - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - let fc = &input[0]; - assert_eq!(fc["type"], "function_call"); - // id field uses the fc_ prefixed item ID - assert_eq!(fc["id"], "fc_abc123"); - // call_id field uses the call_ prefixed call ID - assert_eq!(fc["call_id"], "call_xyz789"); - } - - #[test] - fn translate_input_falls_back_to_tc_id_without_metadata() { - let tc = ToolCall::new("call_xyz789", "get_weather", serde_json::json!({})); - - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - let fc = &input[0]; - // Without provider_metadata, both fields use tc.id - assert_eq!(fc["id"], "call_xyz789"); - assert_eq!(fc["call_id"], "call_xyz789"); - } - - #[test] - fn reasoning_items_round_trip_through_translate_input() { - let reasoning = serde_json::json!({ - "type": "reasoning", - "id": "rs_abc123", - "summary": [{"type": "summary_text", "text": "Thinking..."}] - }); - let mut tc = ToolCall::new("call_789", "search", serde_json::json!({})); - tc.provider_metadata = Some(serde_json::json!({"id": "fc_def456"})); - - let msg = Message { - role: Role::Assistant, - content: vec![ - ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: reasoning, - }, - ContentPart::ToolCall(tc), - ], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - assert_eq!(input.len(), 2); - // Reasoning item is emitted first - assert_eq!(input[0]["type"], "reasoning"); - assert_eq!(input[0]["id"], "rs_abc123"); - // Function call follows - assert_eq!(input[1]["type"], "function_call"); - assert_eq!(input[1]["id"], "fc_def456"); - assert_eq!(input[1]["call_id"], "call_789"); - } - - #[test] - fn reasoning_message_function_call_round_trip() { - // Simulates an assistant turn with reasoning + text + tool call. - // The opaque message item (with id/status) must be used instead of - // constructing a new one from Text, so the reasoning item can find - // its "required following item." - let reasoning = serde_json::json!({ - "type": "reasoning", - "id": "rs_xyz789", - "summary": [{"type": "summary_text", "text": "Let me check..."}] - }); - let opaque_message = serde_json::json!({ - "type": "message", - "id": "msg_abc123", - "status": "completed", - "role": "assistant", - "content": [{"type": "output_text", "text": "Checking now."}] - }); - let mut tc = ToolCall::new("call_001", "shell", serde_json::json!({"cmd": "ls"})); - tc.provider_metadata = Some(serde_json::json!({"id": "fc_def456"})); - - let msg = Message { - role: Role::Assistant, - content: vec![ - ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: reasoning, - }, - ContentPart::Other { - kind: ContentPart::OPENAI_MESSAGE.to_string(), - data: opaque_message, - }, - ContentPart::text("Checking now."), - ContentPart::ToolCall(tc), - ], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - assert_eq!(input.len(), 3); - // Reasoning first - assert_eq!(input[0]["type"], "reasoning"); - assert_eq!(input[0]["id"], "rs_xyz789"); - // Opaque message with id/status (not a reconstructed one) - assert_eq!(input[1]["type"], "message"); - assert_eq!(input[1]["id"], "msg_abc123"); - assert_eq!(input[1]["status"], "completed"); - // Function call last - assert_eq!(input[2]["type"], "function_call"); - assert_eq!(input[2]["id"], "fc_def456"); - } - - #[test] - fn text_without_opaque_message_still_constructs_message() { - // For non-OpenAI turns or turns without preserved message items, - // Text parts should still produce a constructed message. - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::text("Hello")], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - assert_eq!(input.len(), 1); - assert_eq!(input[0]["type"], "message"); - assert_eq!(input[0]["role"], "assistant"); - // No id field on constructed messages - assert!(input[0].get("id").is_none()); - } - - #[test] - fn custom_tool_call_history_round_trips_through_translate_input() { - let patch = "*** Begin Patch\n*** Delete File: stale.txt\n*** End Patch\n"; - let mut tc = ToolCall::new("call_001", "apply_patch", serde_json::json!(patch)); - tc.tool_type = "custom".to_string(); - tc.raw_arguments = Some(patch.to_string()); - tc.provider_metadata = Some(serde_json::json!({"id": "ctc_def456"})); - - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - - let (_, input) = translate_input(&[msg]); - - assert_eq!(input.len(), 1); - assert_eq!(input[0]["type"], "custom_tool_call"); - assert_eq!(input[0]["id"], "ctc_def456"); - assert_eq!(input[0]["call_id"], "call_001"); - assert_eq!(input[0]["name"], "apply_patch"); - assert_eq!(input[0]["input"], patch); - } - - #[test] - fn custom_tool_result_history_round_trips_through_translate_input() { - let msg = Message { - role: Role::Tool, - content: vec![ContentPart::ToolResult(ToolResult::success( - "call_001", - serde_json::json!("Success. Updated the following files:\nA hello.txt\n"), - ))], - name: Some("apply_patch".to_string()), - tool_call_id: Some("call_001".to_string()), - }; - - let (_, input) = translate_input(&[msg]); - - assert_eq!(input.len(), 1); - assert_eq!(input[0]["type"], "custom_tool_call_output"); - assert_eq!(input[0]["call_id"], "call_001"); - assert_eq!( - input[0]["output"], - "Success. Updated the following files:\nA hello.txt\n" - ); - } - - #[test] - fn custom_tool_result_history_uses_prior_custom_call_without_tool_message_name() { - let patch = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"; - let mut tc = ToolCall::new("call_001", "apply_patch", serde_json::json!(patch)); - tc.tool_type = "custom".to_string(); - tc.raw_arguments = Some(patch.to_string()); - tc.provider_metadata = Some(serde_json::json!({"id": "ctc_def456"})); - - let assistant_msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - let tool_msg = Message::tool_result( - "call_001", - serde_json::json!("Success. Updated the following files:\nA hello.txt\n"), - false, - ); - - let (_, input) = translate_input(&[assistant_msg, tool_msg]); - - assert_eq!(input.len(), 2); - assert_eq!(input[1]["type"], "custom_tool_call_output"); - assert_eq!(input[1]["call_id"], "call_001"); - assert_eq!( - input[1]["output"], - "Success. Updated the following files:\nA hello.txt\n" - ); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_responses/mod.rs b/lib/components/fabro-llm/src/codec/openai_responses/mod.rs deleted file mode 100644 index 75491bf1e..000000000 --- a/lib/components/fabro-llm/src/codec/openai_responses/mod.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! The OpenAI Responses (`/responses`) codec. -//! -//! Serves OpenAI direct today, in two route flavors that share this codec: -//! the standard route and the Codex route (`CodecParams::openai_codex`, which -//! omits sampling params encode-side; its forced streaming lives in the -//! adapter's route config). Pure translation: no HTTP, auth, or base URL — -//! the adapter shell owns those. -//! -//! HTTP error bodies use the shared `decode_error` default (openai uses the -//! standard `error_from_status_code` + `parse_error_body` path); streaming -//! `error` / `response.failed` events are mapped inside the decoder -//! (`on_event` → `Err`). - -mod decode; -mod encode; -mod stream; -mod wire; - -use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder}; -use crate::error::Error; -use crate::types::{RateLimitInfo, Response}; - -/// Codec for the OpenAI Responses wire dialect. -pub(crate) struct OpenAiResponses; - -impl Codec for OpenAiResponses { - fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result { - Ok(encode::encode(ctx, stream)) - } - - fn decode_response( - &self, - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Result { - decode::decode_response(body, ctx, rate_limit) - } - - fn stream_decoder( - &self, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Box { - Box::new(stream::SseAccumulator::new(ctx, rate_limit)) - } - - fn encode_count_tokens(&self, ctx: &CodecCtx<'_>) -> Option> { - Some(Ok(encode::encode_count_tokens(ctx))) - } - - fn decode_count_tokens(&self, body: &str) -> Result { - decode::decode_count_tokens(body) - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_responses/stream.rs b/lib/components/fabro-llm/src/codec/openai_responses/stream.rs deleted file mode 100644 index 9bca719ec..000000000 --- a/lib/components/fabro-llm/src/codec/openai_responses/stream.rs +++ /dev/null @@ -1,836 +0,0 @@ -//! Streaming decoder: OpenAI Responses SSE events → canonical `StreamEvent`s. -//! -//! Byte reading and SSE block framing live in the transport; this decoder is -//! fed framed `RawEvent`s. The event type is resolved from the SSE `event:` -//! line or the JSON `type` field. The Responses API finishes via -//! `response.completed` / `response.incomplete`; byte-stream end synthesizes -//! nothing, so `finish()` returns an empty list. - -use serde::Deserialize; - -use super::decode::{map_finish_reason, token_counts_from_api_usage, tool_call_from_item}; -use super::wire::ApiUsage; -use crate::codec::{CodecCtx, RawEvent, StreamDecoder}; -use crate::error::{self, Error, ProviderErrorDetail, ProviderErrorKind}; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, TokenCounts, - ToolCall, -}; - -/// Map an OpenAI stream `error` / `response.failed` payload to a provider -/// error, classifying on `code` falling back to `type`. -fn provider_error_from_openai_error_json(error: &serde_json::Value, provider: &str) -> Error { - let classifier = error - .get("code") - .and_then(serde_json::Value::as_str) - .filter(|code| !code.is_empty()) - .or_else(|| { - error - .get("type") - .and_then(serde_json::Value::as_str) - .filter(|error_type| !error_type.is_empty()) - }); - let message = error - .get("message") - .and_then(serde_json::Value::as_str) - .filter(|message| !message.is_empty()) - .map_or_else(|| "OpenAI stream error".to_string(), str::to_string); - - // Unrecognized and absent codes are treated as transient. - let kind = classifier - .and_then(error::kind_from_error_code) - .unwrap_or(ProviderErrorKind::Server); - - Error::Provider { - kind, - detail: Box::new(ProviderErrorDetail { - message, - provider: provider.to_string(), - status_code: None, - error_code: classifier.map(str::to_string), - retry_after: None, - raw: Some(error.clone()), - }), - } -} - -/// Accumulated state across SSE events during streaming. -pub(super) struct SseAccumulator { - /// Requested model, used as the fallback when the response omits one. - model: String, - /// Configured provider name stamped into responses and error details. - provider: String, - response_id: String, - response_model: String, - accumulated_text: String, - tool_calls: Vec, - /// Raw reasoning output items to preserve for round-tripping. - reasoning_items: Vec, - /// Raw message output items to preserve for round-tripping. - message_items: Vec, - usage: TokenCounts, - finish_reason: FinishReason, - emitted_text_start: bool, - emitted_reasoning_start: bool, - rate_limit: Option, -} - -impl SseAccumulator { - pub(super) fn new(ctx: &CodecCtx<'_>, rate_limit: Option) -> Self { - Self { - model: ctx.request.model.clone(), - provider: ctx.provider_name.to_string(), - response_id: String::new(), - response_model: String::new(), - accumulated_text: String::new(), - tool_calls: Vec::new(), - reasoning_items: Vec::new(), - message_items: Vec::new(), - usage: TokenCounts::default(), - finish_reason: FinishReason::Stop, - emitted_text_start: false, - emitted_reasoning_start: false, - rate_limit, - } - } - - /// Process a single SSE event and return the corresponding - /// `StreamEvent`(s). - fn process_sse_event( - &mut self, - event_type: Option<&str>, - data: &str, - ) -> Result, Error> { - let mut events = Vec::new(); - - let json: serde_json::Value = match serde_json::from_str(data) { - Ok(v) => v, - Err(_) => return Ok(events), - }; - - // Resolve event type from the `event:` SSE line or from the JSON `type` - // field. - let resolved_type = event_type - .or_else(|| json.get("type").and_then(serde_json::Value::as_str)) - .unwrap_or_default(); - - match resolved_type { - "error" => { - let error = json.get("error").unwrap_or(&json); - return Err(provider_error_from_openai_error_json(error, &self.provider)); - } - "response.created" => self.handle_response_created(&json), - "response.output_text.delta" => self.handle_text_delta(&json, &mut events), - "response.function_call_arguments.delta" => { - self.handle_tool_call_delta(&json, &mut events, "function"); - } - "response.custom_tool_call_input.delta" => { - self.handle_tool_call_delta(&json, &mut events, "custom"); - } - "response.output_item.done" => self.handle_output_item_done(&json, &mut events), - "response.completed" | "response.incomplete" => { - self.handle_response_completed(&json, &mut events); - } - "response.failed" => { - let error = json - .get("response") - .and_then(|response| response.get("error")) - .unwrap_or(&json); - return Err(provider_error_from_openai_error_json(error, &self.provider)); - } - "response.reasoning_summary_text.delta" | "response.reasoning_text.delta" => { - if let Some(delta) = json.get("delta").and_then(serde_json::Value::as_str) { - if !self.emitted_reasoning_start { - self.emitted_reasoning_start = true; - events.push(StreamEvent::ReasoningStart); - } - events.push(StreamEvent::ReasoningDelta { - delta: delta.to_string(), - }); - } - } - // response.reasoning_summary_part.added and other unrecognized - // events are no-ops - _ => {} - } - - Ok(events) - } - - /// Handle `response.created` by extracting the response ID and model. - fn handle_response_created(&mut self, json: &serde_json::Value) { - if let Some(id) = json - .get("response") - .and_then(|r| r.get("id")) - .and_then(serde_json::Value::as_str) - { - self.response_id = id.to_string(); - } - if let Some(model) = json - .get("response") - .and_then(|r| r.get("model")) - .and_then(serde_json::Value::as_str) - { - self.response_model = model.to_string(); - } - } - - /// Handle `response.output_text.delta` by accumulating text and emitting - /// events. - fn handle_text_delta(&mut self, json: &serde_json::Value, events: &mut Vec) { - if let Some(delta) = json.get("delta").and_then(serde_json::Value::as_str) { - if !self.emitted_text_start { - self.emitted_text_start = true; - events.push(StreamEvent::TextStart { text_id: None }); - } - self.accumulated_text.push_str(delta); - events.push(StreamEvent::text_delta(delta, None)); - } - } - - /// Handle `response.function_call_arguments.delta` / - /// `response.custom_tool_call_input.delta` by accumulating args and - /// emitting events. - fn handle_tool_call_delta( - &mut self, - json: &serde_json::Value, - events: &mut Vec, - tool_type: &str, - ) { - let Some(delta) = json.get("delta").and_then(serde_json::Value::as_str) else { - return; - }; - - let call_id = json - .get("call_id") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let item_id = json - .get("item_id") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let lookup_id = if call_id.is_empty() { item_id } else { call_id }; - - let idx = if let Some(idx) = self.tool_calls.iter().position(|tc| tc.id == lookup_id) { - let tc = &mut self.tool_calls[idx]; - if let Some(raw) = &mut tc.raw_arguments { - raw.push_str(delta); - } - // Custom tool input is its raw string; keep `arguments` in sync as - // it accumulates. - if tool_type == "custom" { - if let serde_json::Value::String(args) = &mut tc.arguments { - args.push_str(delta); - } - } - idx - } else { - let name = json - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let mut tc = ToolCall::new( - lookup_id, - name, - if tool_type == "custom" { - serde_json::json!(delta) - } else { - serde_json::json!({}) - }, - ); - tc.tool_type = tool_type.to_string(); - tc.raw_arguments = Some(delta.to_string()); - // Preserve item-level ID (fc_xxx) for Responses API round-trip - if !item_id.is_empty() && item_id != lookup_id { - tc.provider_metadata = Some(serde_json::json!({"id": item_id})); - } - events.push(StreamEvent::ToolCallStart { - tool_call: tc.clone(), - }); - self.tool_calls.push(tc); - self.tool_calls.len() - 1 - }; - - // The delta event carries the call identity, the arguments - // accumulated so far, and this chunk in `raw_arguments`. - let current = &self.tool_calls[idx]; - let mut tool_call = ToolCall::new(&*current.id, &*current.name, current.arguments.clone()); - tool_call.tool_type = tool_type.to_string(); - tool_call.raw_arguments = Some(delta.to_string()); - tool_call - .provider_metadata - .clone_from(¤t.provider_metadata); - - events.push(StreamEvent::ToolCallDelta { tool_call }); - } - - /// Handle `response.output_item.done` for text and function call items. - fn handle_output_item_done(&mut self, json: &serde_json::Value, events: &mut Vec) { - let item = json.get("item").unwrap_or(json); - let item_type = item.get("type").and_then(serde_json::Value::as_str); - - match item_type { - Some("reasoning") => { - if self.emitted_reasoning_start { - self.emitted_reasoning_start = false; - events.push(StreamEvent::ReasoningEnd); - } - self.reasoning_items.push(item.clone()); - } - Some("message") => { - if self.emitted_text_start { - events.push(StreamEvent::TextEnd { text_id: None }); - self.emitted_text_start = false; - } - self.message_items.push(item.clone()); - } - Some(t @ ("function_call" | "custom_tool_call")) => { - let tc = tool_call_from_item(item, t == "custom_tool_call"); - - if let Some(existing) = self.tool_calls.iter_mut().find(|c| c.id == tc.id) { - existing.name.clone_from(&tc.name); - existing.tool_type.clone_from(&tc.tool_type); - existing.arguments = tc.arguments.clone(); - existing.raw_arguments.clone_from(&tc.raw_arguments); - existing.provider_metadata.clone_from(&tc.provider_metadata); - } else { - self.tool_calls.push(tc.clone()); - } - - events.push(StreamEvent::ToolCallEnd { tool_call: tc }); - } - _ => {} - } - } - - /// Handle `response.completed` / `response.incomplete` by extracting usage - /// and building the final response. - fn handle_response_completed( - &mut self, - json: &serde_json::Value, - events: &mut Vec, - ) { - let response_data = json.get("response").unwrap_or(json); - - if let Some(usage_data) = response_data.get("usage") { - if let Ok(u) = ApiUsage::deserialize(usage_data) { - self.usage = token_counts_from_api_usage(Some(&u)); - } - } - - if let Some(id) = response_data.get("id").and_then(serde_json::Value::as_str) { - self.response_id = id.to_string(); - } - if let Some(model) = response_data - .get("model") - .and_then(serde_json::Value::as_str) - { - self.response_model = model.to_string(); - } - - let status = response_data - .get("status") - .and_then(serde_json::Value::as_str); - let has_tool_calls = !self.tool_calls.is_empty(); - self.finish_reason = map_finish_reason(status, has_tool_calls); - - let mut content_parts = Vec::new(); - // Reasoning items must precede function calls for Responses API - // round-trip - for item in std::mem::take(&mut self.reasoning_items) { - content_parts.push(ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: item, - }); - } - // Preserve full message output items for Responses API round-tripping - for item in std::mem::take(&mut self.message_items) { - content_parts.push(ContentPart::Other { - kind: ContentPart::OPENAI_MESSAGE.to_string(), - data: item, - }); - } - if !self.accumulated_text.is_empty() { - content_parts.push(ContentPart::text(std::mem::take( - &mut self.accumulated_text, - ))); - } - for tc in std::mem::take(&mut self.tool_calls) { - // Skip tool calls with empty names (e.g. model-internal items) - if tc.name.is_empty() { - continue; - } - content_parts.push(ContentPart::ToolCall(tc)); - } - - let model = if self.response_model.is_empty() { - self.model.clone() - } else { - self.response_model.clone() - }; - - let response = Response { - id: self.response_id.clone(), - model, - provider: self.provider.clone(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason: self.finish_reason.clone(), - usage: self.usage.clone(), - raw: Some(response_data.clone()), - warnings: vec![], - rate_limit: self.rate_limit.clone(), - cost_usd: None, - cost_source: None, - }; - - events.push(StreamEvent::finish( - self.finish_reason.clone(), - self.usage.clone(), - response, - )); - } -} - -impl StreamDecoder for SseAccumulator { - fn on_event(&mut self, ev: RawEvent<'_>) -> Result, Error> { - self.process_sse_event(ev.event, ev.data) - } - - fn finish(&mut self) -> Vec { - // The Responses API finishes via `response.completed`/`.incomplete`; - // nothing is synthesized at byte-stream end. - Vec::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Build an accumulator without threading a `CodecCtx`/`Request`: the test - /// module sees the private fields, so the few that matter are set - /// directly. - fn empty_accumulator() -> SseAccumulator { - SseAccumulator { - model: String::new(), - provider: "openai".to_string(), - response_id: String::new(), - response_model: String::new(), - accumulated_text: String::new(), - tool_calls: Vec::new(), - reasoning_items: Vec::new(), - message_items: Vec::new(), - usage: TokenCounts::default(), - finish_reason: FinishReason::Stop, - emitted_text_start: false, - emitted_reasoning_start: false, - rate_limit: None, - } - } - - fn on_event( - acc: &mut SseAccumulator, - event: Option<&str>, - data: &str, - ) -> Result, Error> { - acc.on_event(RawEvent { event, data }) - } - - #[test] - fn token_counts_disjoint_with_cache_and_reasoning() { - let mut acc = empty_accumulator(); - let body = serde_json::json!({ - "response": { - "id": "resp_test", - "model": "gpt-5", - "output": [], - "status": "completed", - "usage": { - "input_tokens": 200, - "input_tokens_details": { "cached_tokens": 180 }, - "output_tokens": 500, - "output_tokens_details": { "reasoning_tokens": 300 }, - "total_tokens": 700 - } - } - }); - let mut events = Vec::new(); - - acc.handle_response_completed(&body, &mut events); - - assert_eq!(acc.usage.input_tokens, 20); - assert_eq!(acc.usage.cache_read_tokens, 180); - assert_eq!(acc.usage.output_tokens, 200); - assert_eq!(acc.usage.reasoning_tokens, 300); - assert_eq!(acc.usage.cache_write_tokens, 0); - assert_eq!(acc.usage.total_tokens(), 700); - } - - #[test] - fn custom_tool_call_streaming_delta_accumulates_raw_input() { - let mut acc = empty_accumulator(); - let first = r#"{ - "type": "response.custom_tool_call_input.delta", - "item_id": "ctc_abc", - "call_id": "call_001", - "delta": "*** Begin" - }"#; - let second = r#"{ - "type": "response.custom_tool_call_input.delta", - "item_id": "ctc_abc", - "call_id": "call_001", - "delta": " Patch\n" - }"#; - - let first_events = on_event( - &mut acc, - Some("response.custom_tool_call_input.delta"), - first, - ) - .expect("first custom delta should parse"); - let second_events = on_event( - &mut acc, - Some("response.custom_tool_call_input.delta"), - second, - ) - .expect("second custom delta should parse"); - - assert!(matches!( - first_events.iter().find(|event| matches!(event, StreamEvent::ToolCallStart { .. })), - Some(StreamEvent::ToolCallStart { tool_call }) - if tool_call.id == "call_001" && tool_call.tool_type == "custom" - )); - assert!(matches!( - second_events.last(), - Some(StreamEvent::ToolCallDelta { tool_call }) - if tool_call.raw_arguments.as_deref() == Some(" Patch\n") - && tool_call.tool_type == "custom" - )); - assert_eq!( - acc.tool_calls[0].raw_arguments.as_deref(), - Some("*** Begin Patch\n") - ); - } - - #[test] - fn custom_tool_call_output_item_done_emits_tool_call_end() { - let mut acc = empty_accumulator(); - let patch = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"; - let data = serde_json::json!({ - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "id": "ctc_abc", - "call_id": "call_001", - "name": "apply_patch", - "input": patch, - } - }); - - let events = on_event( - &mut acc, - Some("response.output_item.done"), - &data.to_string(), - ) - .expect("custom output item should parse"); - - assert!(matches!( - events.last(), - Some(StreamEvent::ToolCallEnd { tool_call }) - if tool_call.id == "call_001" - && tool_call.name == "apply_patch" - && tool_call.tool_type == "custom" - && tool_call.raw_arguments.as_deref() == Some(patch) - )); - } - - #[test] - fn error_event_with_insufficient_quota_returns_provider_error() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "error", - "error": { - "type": "insufficient_quota", - "code": "insufficient_quota", - "message": "You exceeded your current quota.", - "param": null - } - }"#; - - let err = on_event(&mut acc, Some("error"), data) - .expect_err("error event should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::QuotaExceeded); - assert!(detail.message.contains("exceeded your current quota")); - assert_eq!(detail.error_code.as_deref(), Some("insufficient_quota")); - assert!(detail.raw.is_some()); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn error_event_classifies_on_type_when_code_absent() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "error", - "error": { - "type": "insufficient_quota", - "message": "You exceeded your current quota." - } - }"#; - - let err = on_event(&mut acc, Some("error"), data) - .expect_err("error event should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::QuotaExceeded); - assert_eq!(detail.error_code.as_deref(), Some("insufficient_quota")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn response_failed_event_with_server_error_returns_provider_error() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "response.failed", - "response": { - "status": "failed", - "error": { - "type": "server_error", - "code": "server_error", - "message": "The server had an error while processing your request." - } - } - }"#; - - let err = on_event(&mut acc, Some("response.failed"), data) - .expect_err("response.failed should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::Server); - assert!(detail.message.contains("server had an error")); - assert_eq!(detail.error_code.as_deref(), Some("server_error")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn response_incomplete_preserves_partial_text() { - let mut acc = empty_accumulator(); - - on_event( - &mut acc, - Some("response.created"), - r#"{"type":"response.created","response":{"id":"resp_123","model":"gpt-5.4"}}"#, - ) - .expect("created event should parse"); - on_event( - &mut acc, - Some("response.output_text.delta"), - r#"{"type":"response.output_text.delta","delta":"Hel"}"#, - ) - .expect("first delta should parse"); - on_event( - &mut acc, - Some("response.output_text.delta"), - r#"{"type":"response.output_text.delta","delta":"lo"}"#, - ) - .expect("second delta should parse"); - - let events = on_event( - &mut acc, - Some("response.incomplete"), - r#"{ - "type": "response.incomplete", - "response": { - "id": "resp_123", - "model": "gpt-5.4", - "status": "incomplete" - } - }"#, - ) - .expect("incomplete response should finish normally"); - - let finish = events - .last() - .expect("incomplete response should emit finish"); - match finish { - StreamEvent::Finish { - finish_reason, - response, - .. - } => { - assert_eq!(finish_reason.clone(), FinishReason::Length); - assert_eq!(response.text(), "Hello"); - } - other => panic!("expected finish event, got {other:?}"), - } - } - - #[test] - fn error_event_with_invalid_api_key_returns_authentication_error() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "error", - "error": { - "type": "invalid_api_key", - "code": "invalid_api_key", - "message": "Incorrect API key provided." - } - }"#; - - let err = on_event(&mut acc, Some("error"), data) - .expect_err("error event should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::Authentication); - assert_eq!(detail.error_code.as_deref(), Some("invalid_api_key")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn error_event_with_rate_limit_error_returns_rate_limit() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "error", - "error": { - "type": "rate_limit_error", - "message": "Too many requests." - } - }"#; - - let err = on_event(&mut acc, Some("error"), data) - .expect_err("error event should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::RateLimit); - assert_eq!(detail.error_code.as_deref(), Some("rate_limit_error")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn error_event_with_unknown_invalid_prefix_returns_invalid_request() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "error", - "error": { - "type": "invalid_prompt", - "code": "invalid_prompt", - "message": "Prompt is invalid." - } - }"#; - - let err = on_event(&mut acc, Some("error"), data) - .expect_err("error event should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::InvalidRequest); - assert_eq!(detail.error_code.as_deref(), Some("invalid_prompt")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn error_event_with_unknown_code_falls_back_to_server_with_message() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "error", - "error": { - "type": "unexpected_stream_failure", - "code": "unexpected_stream_failure", - "message": "Unexpected stream failure." - } - }"#; - - let err = on_event(&mut acc, Some("error"), data) - .expect_err("error event should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::Server); - assert_eq!(detail.message, "Unexpected stream failure."); - assert_eq!( - detail.error_code.as_deref(), - Some("unexpected_stream_failure") - ); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn reasoning_summary_delta_emits_reasoning_events() { - let mut acc = empty_accumulator(); - let data = r#"{"type":"response.reasoning_summary_text.delta","delta":"Let me think"}"#; - let events = on_event( - &mut acc, - Some("response.reasoning_summary_text.delta"), - data, - ) - .expect("reasoning summary delta should parse"); - assert_eq!(events.len(), 2); - assert!(matches!(events[0], StreamEvent::ReasoningStart)); - assert!( - matches!(events[1], StreamEvent::ReasoningDelta { ref delta } if delta == "Let me think") - ); - } - - #[test] - fn reasoning_text_delta_emits_reasoning_events() { - let mut acc = empty_accumulator(); - - // First delta: should emit ReasoningStart + ReasoningDelta - let data1 = r#"{"type":"response.reasoning_text.delta","delta":"Step 1"}"#; - let events1 = on_event(&mut acc, Some("response.reasoning_text.delta"), data1) - .expect("first reasoning delta should parse"); - assert_eq!(events1.len(), 2); - assert!(matches!(events1[0], StreamEvent::ReasoningStart)); - assert!( - matches!(events1[1], StreamEvent::ReasoningDelta { ref delta } if delta == "Step 1") - ); - - // Second delta: should NOT emit duplicate ReasoningStart - let data2 = r#"{"type":"response.reasoning_text.delta","delta":"Step 2"}"#; - let events2 = on_event(&mut acc, Some("response.reasoning_text.delta"), data2) - .expect("second reasoning delta should parse"); - assert_eq!(events2.len(), 1); - assert!( - matches!(events2[0], StreamEvent::ReasoningDelta { ref delta } if delta == "Step 2") - ); - } - - #[test] - fn reasoning_end_emitted_on_item_done() { - let mut acc = empty_accumulator(); - acc.emitted_reasoning_start = true; - - let data = r#"{"item":{"type":"reasoning","id":"rs_abc","summary":[]}}"#; - let events = on_event(&mut acc, Some("response.output_item.done"), data) - .expect("output item done should parse"); - assert_eq!(events.len(), 1); - assert!(matches!(events[0], StreamEvent::ReasoningEnd)); - assert!(!acc.emitted_reasoning_start); - assert_eq!(acc.reasoning_items.len(), 1); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_responses/wire.rs b/lib/components/fabro-llm/src/codec/openai_responses/wire.rs deleted file mode 100644 index f1c1e82e6..000000000 --- a/lib/components/fabro-llm/src/codec/openai_responses/wire.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Serde types mirroring the OpenAI Responses API wire shapes. - -#[derive(serde::Serialize)] -pub(super) struct ApiRequest { - pub model: String, - pub input: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_output_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub top_p: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_choice: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub text: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stop: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option>, - pub store: bool, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub include: Vec, - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub stream: bool, -} - -// --- Response types --- - -#[derive(serde::Deserialize)] -pub(super) struct ApiResponse { - pub id: String, - pub model: Option, - pub output: Vec, - pub status: Option, - pub usage: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct InputTokensResponse { - pub input_tokens: i64, - pub object: String, -} - -#[derive(serde::Deserialize)] -pub(super) struct ApiUsage { - pub input_tokens: i64, - pub output_tokens: i64, - pub output_tokens_details: Option, - pub input_tokens_details: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct OutputTokenDetails { - pub reasoning_tokens: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct InputTokenDetails { - pub cached_tokens: Option, -} diff --git a/lib/components/fabro-llm/src/cost.rs b/lib/components/fabro-llm/src/cost.rs deleted file mode 100644 index 898dcb362..000000000 --- a/lib/components/fabro-llm/src/cost.rs +++ /dev/null @@ -1,250 +0,0 @@ -//! Catalog-derived cost estimation for completion responses. -//! -//! The estimate is a thin wrapper over the catalog's billing machinery -//! ([`Catalog::price_tokens`]), which is billing-policy- and speed-aware. -//! Costs are stamped onto responses by the [`Client`](crate::Client) as a -//! post-decode step, so codecs stay wire-translation-only and every -//! registered adapter (including custom ones) gets the same treatment. - -use fabro_model::billing::{ModelRef, Speed, TokenCounts}; -use fabro_model::{Catalog, ProviderId}; - -use crate::types::{CostSource, Response}; - -/// Estimate the USD cost of a completion from the catalog's per-token -/// pricing for the model. Returns `None` if the catalog is absent, the -/// model is not in the catalog, or the model has no pricing. -#[must_use] -pub(crate) fn estimate_cost_usd( - catalog: Option<&Catalog>, - provider: &str, - model: &str, - tokens: &TokenCounts, - speed: Option, -) -> Option { - let catalog = catalog?; - // The billing machinery compares ModelRefs against the catalog's - // canonical identity, so resolve model aliases and provider names first. - let provider = catalog.provider(&ProviderId::new(provider))?; - let model = catalog.get_on_provider(&provider.id, model)?; - let model_ref = ModelRef { - provider: provider.id.clone(), - model_id: model.id.clone(), - speed, - }; - let micros = catalog.price_tokens(&model_ref, tokens)?; - #[expect( - clippy::cast_precision_loss, - reason = "micros fit comfortably in f64 for any realistic completion cost" - )] - Some(micros as f64 / 1_000_000.0) -} - -/// Stamp a catalog-estimated cost onto `response` unless the provider -/// already supplied one (providers that return authoritative billing data -/// in-band set [`CostSource::Authoritative`] directly and take precedence). -/// `model` is the request's model id or alias (the catalog lookup resolves -/// aliases); the response's provider name selects the billing policy. -pub(crate) fn apply_estimated_cost( - catalog: Option<&Catalog>, - provider: &str, - model: &str, - speed: Option, - response: &mut Response, -) { - if response.cost_usd.is_some() { - return; - } - let estimate = estimate_cost_usd(catalog, provider, model, &response.usage, speed); - response.cost_usd = estimate; - response.cost_source = estimate.map(|_| CostSource::Estimated); -} - -#[cfg(test)] -mod tests { - use fabro_model::catalog::LlmCatalogSettings; - - use super::*; - use crate::types::{FinishReason, Message}; - - /// Single-provider catalog with one `gpt-test` model (alias `gpt-alias`) - /// and the given `[models."gpt-test".costs]` block (empty for unpriced). - fn test_catalog(costs_block: &str) -> Catalog { - let toml = format!( - r#" -[providers.openai] -display_name = "OpenAI" -adapter = "openai" -agent_profile = "openai" - -[models."gpt-test"] -provider = "openai" -display_name = "GPT Test" -family = "gpt" -default = true -aliases = ["gpt-alias"] - -[models."gpt-test".limits] -context_window = 200000 -max_output = 4096 - -[models."gpt-test".features] -tools = true -vision = false -reasoning = false - -{costs_block} -"# - ); - let settings: LlmCatalogSettings = toml::from_str(&toml).unwrap(); - Catalog::from_settings(&settings).unwrap() - } - - fn priced_catalog(input_cost_per_mtok: f64, output_cost_per_mtok: f64) -> Catalog { - test_catalog(&format!( - r#" -[models."gpt-test".costs] -input_cost_per_mtok = {input_cost_per_mtok} -output_cost_per_mtok = {output_cost_per_mtok} -"# - )) - } - - fn response_with_usage(tokens: TokenCounts) -> Response { - Response { - id: "resp".to_string(), - model: "gpt-test".to_string(), - provider: "openai".to_string(), - message: Message::assistant("hi"), - finish_reason: FinishReason::Stop, - usage: tokens, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - } - } - - #[test] - fn returns_none_when_catalog_is_none() { - let tokens = TokenCounts { - input_tokens: 1000, - output_tokens: 500, - ..TokenCounts::default() - }; - assert_eq!( - estimate_cost_usd(None, "openai", "gpt-test", &tokens, None), - None - ); - } - - #[test] - fn returns_estimated_when_model_priced() { - let catalog = priced_catalog(1.0, 2.0); - let tokens = TokenCounts { - input_tokens: 1_000_000, // 1M tokens at $1/Mtok = $1.00 - output_tokens: 500_000, // 500k tokens at $2/Mtok = $1.00 - ..TokenCounts::default() - }; - let cost = estimate_cost_usd(Some(&catalog), "openai", "gpt-test", &tokens, None) - .expect("cost should be Some"); - assert!((cost - 2.0).abs() < 1e-9, "expected ~$2.00, got {cost}"); - } - - #[test] - fn resolves_model_aliases() { - let catalog = priced_catalog(1.0, 2.0); - let tokens = TokenCounts { - input_tokens: 1_000_000, - output_tokens: 0, - ..TokenCounts::default() - }; - let cost = estimate_cost_usd(Some(&catalog), "openai", "gpt-alias", &tokens, None); - assert!(cost.is_some()); - } - - #[test] - fn returns_none_when_model_missing_from_catalog() { - let catalog = priced_catalog(1.0, 2.0); - let tokens = TokenCounts { - input_tokens: 1000, - output_tokens: 500, - ..TokenCounts::default() - }; - let cost = estimate_cost_usd(Some(&catalog), "openai", "nonexistent-model", &tokens, None); - assert_eq!(cost, None); - } - - #[test] - fn returns_none_when_model_has_no_pricing() { - let catalog = test_catalog(""); - let tokens = TokenCounts { - input_tokens: 1000, - output_tokens: 500, - ..TokenCounts::default() - }; - let cost = estimate_cost_usd(Some(&catalog), "openai", "gpt-test", &tokens, None); - assert_eq!(cost, None); - } - - #[test] - fn micros_to_usd_conversion_is_exact_for_integer_amounts() { - // input_cost_per_mtok = 1.5 USD; 1M input tokens with no output - // yields exactly 1_500_000 micros = $1.50 (representable as f64). - let catalog = priced_catalog(1.5, 0.0); - let tokens = TokenCounts { - input_tokens: 1_000_000, - output_tokens: 0, - ..TokenCounts::default() - }; - let cost = estimate_cost_usd(Some(&catalog), "openai", "gpt-test", &tokens, None) - .expect("cost should be Some"); - assert!( - (cost - 1.5).abs() < f64::EPSILON, - "expected $1.50 exact, got {cost}" - ); - } - - #[test] - fn apply_estimated_cost_stamps_estimate() { - let catalog = priced_catalog(1.0, 2.0); - let mut response = response_with_usage(TokenCounts { - input_tokens: 1_000_000, - output_tokens: 0, - ..TokenCounts::default() - }); - - apply_estimated_cost(Some(&catalog), "openai", "gpt-test", None, &mut response); - - assert_eq!(response.cost_source, Some(CostSource::Estimated)); - assert!(response.cost_usd.is_some()); - } - - #[test] - fn apply_estimated_cost_leaves_source_unset_without_estimate() { - let mut response = response_with_usage(TokenCounts::default()); - - apply_estimated_cost(None, "openai", "gpt-test", None, &mut response); - - assert_eq!(response.cost_usd, None); - assert_eq!(response.cost_source, None); - } - - #[test] - fn apply_estimated_cost_keeps_existing_cost() { - let catalog = priced_catalog(1.0, 2.0); - let mut response = response_with_usage(TokenCounts { - input_tokens: 1_000_000, - output_tokens: 0, - ..TokenCounts::default() - }); - response.cost_usd = Some(0.42); - response.cost_source = Some(CostSource::Authoritative); - - apply_estimated_cost(Some(&catalog), "openai", "gpt-test", None, &mut response); - - assert_eq!(response.cost_usd, Some(0.42)); - assert_eq!(response.cost_source, Some(CostSource::Authoritative)); - } -} diff --git a/lib/components/fabro-llm/src/error.rs b/lib/components/fabro-llm/src/error.rs index dd9a98347..05eec0e1c 100644 --- a/lib/components/fabro-llm/src/error.rs +++ b/lib/components/fabro-llm/src/error.rs @@ -1,1437 +1,325 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ProviderErrorKind { - Authentication, - AccessDenied, - NotFound, - InvalidRequest, - RateLimit, - Server, - ContentFilter, - ContextLength, - QuotaExceeded, -} +//! Classification of lithos errors for Fabro's retry, failover, and failure +//! signature policies, plus the stored form of a failure. +//! +//! lithos's live [`Error`] carries a source chain and is therefore neither +//! `Clone` nor serializable. Fabro records failures in events and agent +//! errors, so it works with [`LlmError`], a thin wrapper over lithos's own +//! [`ErrorData`] projection. Every policy here reads through [`ErrorFacts`] +//! and so applies to both forms. -impl std::fmt::Display for ProviderErrorKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Authentication => write!(f, "Authentication error for"), - Self::AccessDenied => write!(f, "Access denied by"), - Self::NotFound => write!(f, "Not found on"), - Self::InvalidRequest => write!(f, "Invalid request to"), - Self::RateLimit => write!(f, "Rate limited by"), - Self::Server => write!(f, "Server error from"), - Self::ContentFilter => write!(f, "Content filtered by"), - Self::ContextLength => write!(f, "Context length exceeded for"), - Self::QuotaExceeded => write!(f, "Quota exceeded for"), - } +use std::fmt; +use std::time::Duration; + +use fabro_types::ProviderId; +use lithos_llm::types::{Error, ErrorData, ErrorKind, RetryClassification}; +use serde::{Deserialize, Serialize}; + +/// The facts Fabro's policies read from an LLM failure. +pub trait ErrorFacts { + fn kind(&self) -> ErrorKind; + fn message(&self) -> &str; + fn provider(&self) -> Option<&ProviderId>; + fn provider_code(&self) -> Option<&str>; + fn status(&self) -> Option; + fn retry_classification(&self) -> RetryClassification; + + /// The delay the classification advises, when repeating is safe after + /// a wait. + fn retry_after(&self) -> Option { + self.retry_classification().delay() } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ProviderErrorDetail { - pub message: String, - pub provider: String, - pub status_code: Option, - pub error_code: Option, - pub retry_after: Option, - pub raw: Option, -} +impl ErrorFacts for Error { + fn kind(&self) -> ErrorKind { + Self::kind(self) + } -impl ProviderErrorDetail { - pub fn new(message: impl Into, provider: impl Into) -> Self { - Self { - message: message.into(), - provider: provider.into(), - status_code: None, - error_code: None, - retry_after: None, - raw: None, - } + fn message(&self) -> &str { + Self::message(self) + } + + fn provider(&self) -> Option<&ProviderId> { + Self::provider(self) + } + + fn provider_code(&self) -> Option<&str> { + Self::provider_code(self) + } + + fn status(&self) -> Option { + Self::status(self) + } + + fn retry_classification(&self) -> RetryClassification { + Self::retry_classification(self) } } -use std::sync::Arc; +impl ErrorFacts for ErrorData { + fn kind(&self) -> ErrorKind { + self.kind.clone() + } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, thiserror::Error)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Error { - #[error("{kind} {}: {}", .detail.provider, .detail.message)] - Provider { - kind: ProviderErrorKind, - detail: Box, - }, + fn message(&self) -> &str { + &self.message + } - #[error("Request timed out: {message}")] - RequestTimeout { - message: String, - #[source] - #[serde(skip)] - source: Option>, - }, + fn provider(&self) -> Option<&ProviderId> { + self.provider.as_ref() + } - #[error("Request interrupted: {message}")] - Interrupt { message: String }, + fn provider_code(&self) -> Option<&str> { + self.provider_code.as_deref() + } - #[error("Network error: {message}")] - Network { - message: String, - #[source] - #[serde(skip)] - source: Option>, - }, + fn status(&self) -> Option { + self.status + } - #[error("Stream error: {message}")] - Stream { - message: String, - #[source] - #[serde(skip)] - source: Option>, - }, - - #[error("Invalid tool call: {message}")] - InvalidToolCall { message: String }, - - #[error("No object generated: {message}")] - NoObjectGenerated { message: String }, - - #[error("Invalid request: {message}")] - InvalidRequest { message: String }, - - #[error("Configuration error: {message}")] - Configuration { - message: String, - #[source] - #[serde(skip)] - source: Option>, - }, - - #[error("Unsupported tool choice: {message}")] - UnsupportedToolChoice { message: String }, + fn retry_classification(&self) -> RetryClassification { + self.retry + } } -impl Error { - pub fn network( - message: impl Into, - source: impl std::error::Error + Send + Sync + 'static, - ) -> Self { - Self::Network { - message: message.into(), - source: Some(Arc::new(source)), - } - } +/// A cloneable, serializable LLM failure. +/// +/// This is lithos's [`ErrorData`] projection with Fabro's policy helpers +/// attached. It is what agent errors, run events, and API responses carry; +/// the live [`Error`] converts into it at the boundary where a failure stops +/// being handled and starts being recorded. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct LlmError(Box); - pub fn request_timeout( - message: impl Into, - source: impl std::error::Error + Send + Sync + 'static, - ) -> Self { - Self::RequestTimeout { - message: message.into(), - source: Some(Arc::new(source)), - } - } - - pub fn stream_error( - message: impl Into, - source: impl std::error::Error + Send + Sync + 'static, - ) -> Self { - Self::Stream { - message: message.into(), - source: Some(Arc::new(source)), - } - } - - pub fn configuration_error( - message: impl Into, - source: impl std::error::Error + Send + Sync + 'static, - ) -> Self { - Self::Configuration { - message: message.into(), - source: Some(Arc::new(source)), - } +impl LlmError { + /// A failure Fabro itself raises, never retried. + #[must_use] + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self::from(Error::new(kind, message)) } #[must_use] - pub const fn retryable(&self) -> bool { - match self { - Self::Provider { kind, .. } => !matches!( - kind, - ProviderErrorKind::Authentication - | ProviderErrorKind::AccessDenied - | ProviderErrorKind::NotFound - | ProviderErrorKind::InvalidRequest - | ProviderErrorKind::ContextLength - | ProviderErrorKind::QuotaExceeded - | ProviderErrorKind::ContentFilter - ), - Self::InvalidToolCall { .. } - | Self::NoObjectGenerated { .. } - | Self::Interrupt { .. } - | Self::InvalidRequest { .. } - | Self::Configuration { .. } - | Self::UnsupportedToolChoice { .. } - | Self::RequestTimeout { .. } => false, - _ => true, - } + pub fn data(&self) -> &ErrorData { + &self.0 } #[must_use] - pub const fn retry_after(&self) -> Option { - match self { - Self::Provider { detail, .. } => detail.retry_after, - _ => None, - } + pub fn into_data(self) -> ErrorData { + *self.0 + } + + /// The immediate source of the failure, rendered as text. + #[must_use] + pub fn source_message(&self) -> Option<&str> { + self.0.source_message.as_deref() + } + + /// The provider's advised wait, whatever the error kind. + #[must_use] + pub fn provider_retry_after(&self) -> Option { + self.0 + .provider_retry_after_millis + .map(Duration::from_millis) } #[must_use] - pub const fn status_code(&self) -> Option { - match self { - Self::Provider { detail, .. } => detail.status_code, - _ => None, - } + pub fn is_retryable(&self) -> bool { + is_retryable(self) } #[must_use] - pub const fn provider_kind(&self) -> Option { - match self { - Self::Provider { kind, .. } => Some(*kind), - _ => None, - } + pub fn is_auth_error(&self) -> bool { + is_auth_error(self) } #[must_use] - pub fn provider_name(&self) -> &str { - match self { - Self::Provider { detail, .. } => &detail.provider, - _ => "unknown", - } + pub fn is_cancelled(&self) -> bool { + is_cancelled(self) } - /// Whether this error is eligible for provider-level failover. - /// - /// Includes everything that is `retryable()` (transient errors good for - /// same-provider retry), provider-local availability failures, and - /// `QuotaExceeded`. A different provider has independent credentials, - /// access policy, model inventory, and quota. #[must_use] pub fn failover_eligible(&self) -> bool { - if self.retryable() { - return true; - } - matches!( - self, - Self::Provider { - kind: ProviderErrorKind::Authentication - | ProviderErrorKind::AccessDenied - | ProviderErrorKind::NotFound - | ProviderErrorKind::QuotaExceeded, - .. - } | Self::RequestTimeout { .. } - ) || self.refusal_content_filter() - } - - fn refusal_content_filter(&self) -> bool { - matches!( - self, - Self::Provider { - kind: ProviderErrorKind::ContentFilter, - detail, - } if detail.error_code.as_deref() == Some("refusal") - ) + failover_eligible(self) } #[must_use] pub fn failure_signature_hint(&self) -> String { - let provider = self.provider_name(); - match self { - Self::Provider { kind, .. } => { - let category = if self.retryable() { - "api_transient" - } else { - "api_deterministic" - }; - let detail = match kind { - ProviderErrorKind::RateLimit => "rate_limited", - ProviderErrorKind::Server => "server_error", - ProviderErrorKind::ContextLength => "context_length", - ProviderErrorKind::QuotaExceeded => "quota_exceeded", - ProviderErrorKind::Authentication => "authentication", - ProviderErrorKind::AccessDenied => "access_denied", - ProviderErrorKind::NotFound => "not_found", - ProviderErrorKind::InvalidRequest => "invalid_request", - ProviderErrorKind::ContentFilter => "content_filter", - }; - format!("{category}|{provider}|{detail}") - } - Self::RequestTimeout { .. } => format!("api_transient|{provider}|timeout"), - Self::Network { .. } => format!("api_transient|{provider}|network"), - Self::Stream { .. } => format!("api_transient|{provider}|stream"), - Self::Interrupt { .. } => format!("api_canceled|{provider}|interrupt"), - Self::Configuration { .. } => format!("api_deterministic|{provider}|configuration"), - Self::InvalidToolCall { .. } => { - format!("api_deterministic|{provider}|invalid_tool_call") - } - Self::NoObjectGenerated { .. } => { - format!("api_deterministic|{provider}|no_object") - } - Self::InvalidRequest { .. } => { - format!("api_deterministic|{provider}|invalid_request") - } - Self::UnsupportedToolChoice { .. } => { - format!("api_deterministic|{provider}|unsupported_tool_choice") - } - } + failure_signature_hint(self) } } -/// Provider error code to error kind mapping, for the codes that say more -/// than the transport-level status or stream event type does. +impl ErrorFacts for LlmError { + fn kind(&self) -> ErrorKind { + self.0.kind.clone() + } + + fn message(&self) -> &str { + &self.0.message + } + + fn provider(&self) -> Option<&ProviderId> { + self.0.provider.as_ref() + } + + fn provider_code(&self) -> Option<&str> { + self.0.provider_code.as_deref() + } + + fn status(&self) -> Option { + self.0.status + } + + fn retry_classification(&self) -> RetryClassification { + self.0.retry + } +} + +impl fmt::Display for LlmError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0.message) + } +} + +impl std::error::Error for LlmError {} + +impl From for LlmError { + fn from(error: Error) -> Self { + Self(Box::new(error.data())) + } +} + +impl From<&Error> for LlmError { + fn from(error: &Error) -> Self { + Self(Box::new(error.data())) + } +} + +impl From for LlmError { + fn from(data: ErrorData) -> Self { + Self(Box::new(data)) + } +} + +/// Whether repeating the same call on the same provider may succeed. +#[must_use] +pub fn is_retryable(error: &E) -> bool { + !matches!(error.retry_classification(), RetryClassification::Never) +} + +/// Whether the failure came from a credential problem. +#[must_use] +pub fn is_auth_error(error: &E) -> bool { + matches!( + error.kind(), + ErrorKind::Authentication | ErrorKind::AccessDenied + ) +} + +/// Whether the call was cancelled by Fabro rather than failed by the provider. +#[must_use] +pub fn is_cancelled(error: &E) -> bool { + error.kind() == ErrorKind::Cancelled +} + +/// Whether another provider is worth trying. /// -/// Returns `None` when the code adds nothing, so each caller keeps its own -/// default: the stream decoders treat an unrecognized code as transient, -/// while [`error_from_status_code`] falls back to the HTTP status. -/// -/// Every dialect classifies through this one table so a code such as -/// `insufficient_quota` means the same thing whether it arrives in an HTTP -/// error body or in a mid-stream error event. +/// Everything retryable qualifies, plus failures that are local to this +/// provider: credentials, access policy, model inventory, quota, and a +/// provider that ran out of time. A different provider has its own. #[must_use] -pub(crate) fn kind_from_error_code(code: &str) -> Option { - Some(match code { - // Out of credit, or over a billing cap. Distinct from RateLimit: - // backoff never clears it, but another provider has its own quota. - "insufficient_quota" | "billing_hard_limit_reached" | "exceeded_current_quota_error" => { - ProviderErrorKind::QuotaExceeded - } - "rate_limit_error" | "rate_limit_exceeded" | "too_many_requests" => { - ProviderErrorKind::RateLimit - } - "authentication_error" | "invalid_api_key" | "invalid_authentication" => { - ProviderErrorKind::Authentication - } - "access_denied" | "account_deactivated" | "permission_denied" | "permission_error" => { - ProviderErrorKind::AccessDenied - } - "content_filter" | "content_policy_violation" => ProviderErrorKind::ContentFilter, - // `request_too_large` is anthropic's oversized-input code, so it has - // to precede the `_too_large` suffix rule below. - "context_length_exceeded" | "request_too_large" => ProviderErrorKind::ContextLength, - "server_error" | "internal_error" | "service_unavailable" | "engine_overloaded" => { - ProviderErrorKind::Server - } - c if c == "not_found_error" || c.ends_with("_not_found") => ProviderErrorKind::NotFound, - c if c.starts_with("invalid_") - || c.starts_with("unsupported_") - || c.ends_with("_too_large") - || c.ends_with("_too_long") => - { - ProviderErrorKind::InvalidRequest - } - _ => return None, - }) -} - -/// HTTP status code to error type mapping (Section 6.4). -#[must_use] -pub fn error_from_status_code( - status_code: u16, - message: String, - provider: String, - error_code: Option, - raw: Option, - retry_after: Option, -) -> Error { - let detail = ProviderErrorDetail { - message, - provider, - status_code: Some(status_code), - error_code, - retry_after, - raw, - }; - - let code_kind = detail.error_code.as_deref().and_then(kind_from_error_code); - - // Check specific status codes first -- these always map to their designated - // error types - let kind = match status_code { - 401 => ProviderErrorKind::Authentication, - // A 412 is never about the request: no LLM request carries - // conditional-request preconditions. Fireworks documents it as - // "Account is suspended or there's an issue with account status", - // also emitted for a LoRA model that failed to load - // (https://docs.fireworks.ai/guides/inference-error-codes). The same - // family as `account_deactivated`: deterministic here, but another - // provider has independent billing and model inventory. - 403 | 412 => ProviderErrorKind::AccessDenied, - 404 => ProviderErrorKind::NotFound, - 408 => { - return Error::RequestTimeout { - message: detail.message, - source: None, - }; - } - 413 => ProviderErrorKind::ContextLength, - // A 429 means rate limited unless the body reports a spent quota, - // which retrying will never clear. - 429 if code_kind == Some(ProviderErrorKind::QuotaExceeded) => { - ProviderErrorKind::QuotaExceeded - } - 429 => ProviderErrorKind::RateLimit, - 500..=599 => ProviderErrorKind::Server, - // For ambiguous status codes (400, 422, etc.), the provider's error - // code is the better signal; fall back to the message only without one - _ => code_kind.unwrap_or_else(|| { - let lower_msg = detail.message.to_lowercase(); - if lower_msg.contains("not found") || lower_msg.contains("does not exist") { - ProviderErrorKind::NotFound - } else if lower_msg.contains("unauthorized") || lower_msg.contains("invalid key") { - ProviderErrorKind::Authentication - } else if lower_msg.contains("context length") || lower_msg.contains("too many tokens") - { - ProviderErrorKind::ContextLength - } else if lower_msg.contains("content filter") || lower_msg.contains("safety") { - ProviderErrorKind::ContentFilter - } else { - ProviderErrorKind::InvalidRequest - } - }), - }; - - Error::Provider { - kind, - detail: Box::new(detail), +pub fn failover_eligible(error: &E) -> bool { + if is_retryable(error) { + return true; } + matches!( + error.kind(), + ErrorKind::Authentication + | ErrorKind::AccessDenied + | ErrorKind::NotFound + | ErrorKind::QuotaExceeded + | ErrorKind::RateLimit + | ErrorKind::Server + | ErrorKind::Network + | ErrorKind::Timeout + | ErrorKind::StreamDecode + ) || (error.kind() == ErrorKind::ContentFilter && error.provider_code() == Some("refusal")) } -/// gRPC status code to error type mapping (Section 6.4, for Gemini). +/// A stable `category|provider|detail` string for loop and restart detection. #[must_use] -pub fn error_from_grpc_status( - grpc_code: &str, - message: String, - provider: String, - error_code: Option, - raw: Option, - retry_after: Option, -) -> Error { - let detail = ProviderErrorDetail { - message, - provider, - status_code: None, - error_code, - retry_after, - raw, +pub fn failure_signature_hint(error: &E) -> String { + let provider = error.provider().map_or("unknown", ProviderId::as_str); + let category = match error.kind() { + ErrorKind::Cancelled => "api_canceled", + _ if is_retryable(error) => "api_transient", + _ => "api_deterministic", }; - - let kind = match grpc_code { - "NOT_FOUND" => ProviderErrorKind::NotFound, - "INVALID_ARGUMENT" => ProviderErrorKind::InvalidRequest, - "UNAUTHENTICATED" => ProviderErrorKind::Authentication, - "PERMISSION_DENIED" => ProviderErrorKind::AccessDenied, - "RESOURCE_EXHAUSTED" => ProviderErrorKind::RateLimit, - "DEADLINE_EXCEEDED" => { - return Error::RequestTimeout { - message: detail.message, - source: None, - }; - } - _ => ProviderErrorKind::Server, - }; - - Error::Provider { - kind, - detail: Box::new(detail), - } + let detail = error.kind().as_str().to_string(); + format!("{category}|{provider}|{detail}") } -pub type Result = std::result::Result; - #[cfg(test)] mod tests { - use std::error::Error as _; - use super::*; - #[test] - fn retryable_classification() { - let auth_err = Error::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail { - status_code: Some(401), - ..ProviderErrorDetail::new("bad key", "openai") - }), - }; - assert!(!auth_err.retryable()); - - let rate_err = Error::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail { - status_code: Some(429), - retry_after: Some(2.0), - ..ProviderErrorDetail::new("too fast", "openai") - }), - }; - assert!(rate_err.retryable()); - assert_eq!(rate_err.retry_after(), Some(2.0)); - - let server_err = Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail { - status_code: Some(500), - ..ProviderErrorDetail::new("internal error", "anthropic") - }), - }; - assert!(server_err.retryable()); - - let timeout = Error::RequestTimeout { - message: "timed out".into(), - source: None, - }; - assert!(!timeout.retryable()); - - let network = Error::Network { - message: "connection refused".into(), - source: None, - }; - assert!(network.retryable()); - - let config = Error::Configuration { - message: "missing provider".into(), - source: None, - }; - assert!(!config.retryable()); + fn error(kind: ErrorKind) -> Error { + Error::new(kind, "boom").with_provider(ProviderId::new("openai")) } #[test] - fn non_retryable_provider_errors() { - let detail = || Box::new(ProviderErrorDetail::new("error", "openai")); - - let access_denied = Error::Provider { - kind: ProviderErrorKind::AccessDenied, - detail: detail(), - }; - assert!(!access_denied.retryable()); - - let not_found = Error::Provider { - kind: ProviderErrorKind::NotFound, - detail: detail(), - }; - assert!(!not_found.retryable()); - - let invalid_req = Error::Provider { - kind: ProviderErrorKind::InvalidRequest, - detail: detail(), - }; - assert!(!invalid_req.retryable()); - - let ctx_length = Error::Provider { - kind: ProviderErrorKind::ContextLength, - detail: detail(), - }; - assert!(!ctx_length.retryable()); - - let quota = Error::Provider { - kind: ProviderErrorKind::QuotaExceeded, - detail: detail(), - }; - assert!(!quota.retryable()); - - let content_filter = Error::Provider { - kind: ProviderErrorKind::ContentFilter, - detail: detail(), - }; - assert!(!content_filter.retryable()); - } - - #[test] - fn non_retryable_sdk_errors() { - let invalid_tool = Error::InvalidToolCall { - message: "bad tool".into(), - }; - assert!(!invalid_tool.retryable()); - - let no_object = Error::NoObjectGenerated { - message: "no output".into(), - }; - assert!(!no_object.retryable()); - - let interrupt = Error::Interrupt { - message: "interrupted".into(), - }; - assert!(!interrupt.retryable()); - } - - #[test] - fn error_from_status_code_mapping() { - let err = error_from_status_code( - 401, - "unauthorized".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Authentication, - .. - })); - assert!(!err.retryable()); - - let err = - error_from_status_code(403, "forbidden".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::AccessDenied, - .. - })); - - let err = - error_from_status_code(404, "not found".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::NotFound, - .. - })); - - let err = - error_from_status_code(400, "bad request".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::InvalidRequest, - .. - })); - - let err = error_from_status_code( - 422, - "unprocessable".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::InvalidRequest, - .. - })); - - let err = error_from_status_code(408, "timeout".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::RequestTimeout { .. })); - - let err = - error_from_status_code(413, "too large".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::ContextLength, - .. - })); - - let err = error_from_status_code( - 429, - "rate limited".into(), - "openai".into(), - None, - None, - Some(5.0), - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::RateLimit, - .. - })); - assert!(err.retryable()); - assert_eq!(err.retry_after(), Some(5.0)); - - let err = error_from_status_code(500, "internal".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Server, - .. - })); - assert!(err.retryable()); - - let err = - error_from_status_code(502, "bad gateway".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Server, - .. - })); - - let err = error_from_status_code( - 529, - "Overloaded".into(), - "anthropic".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Server, - .. - })); - assert!(err.retryable()); - } - - /// Every vendor spelling of "you are out of credit" arrives as a 429 and - /// has to classify as a spent quota, not as a rate limit. - #[test] - fn quota_codes_on_429_are_non_retryable_quota_failures() { - for (provider, code) in [ - ("kimi", "exceeded_current_quota_error"), - ("openai", "insufficient_quota"), - ("openai", "billing_hard_limit_reached"), - ] { - let err = error_from_status_code( - 429, - "Your account has insufficient balance".into(), - provider.into(), - Some(code.into()), - None, - None, - ); - - assert_eq!( - err.provider_kind(), - Some(ProviderErrorKind::QuotaExceeded), - "{code}" - ); - assert!(!err.retryable(), "{code}"); - assert!(err.failover_eligible(), "{code}"); - } - } - - /// A 429 that is a genuine rate limit stays retryable, whether the body - /// names it, names something unrecognized, or carries no code at all. - #[test] - fn non_quota_429_stays_a_retryable_rate_limit() { - for code in [ - Some("rate_limit_error"), - Some("rate_limit_reached_error"), - Some("invalid_request_error"), - None, - ] { - let err = error_from_status_code( - 429, - "slow down".into(), - "openai".into(), - code.map(String::from), - None, - None, - ); - - assert_eq!( - err.provider_kind(), - Some(ProviderErrorKind::RateLimit), - "{code:?}" - ); - assert!(err.retryable(), "{code:?}"); - } - } - - /// For a status with no fixed meaning, the structured code beats guessing - /// from the message text. - #[test] - fn ambiguous_status_prefers_error_code_over_message() { - let err = error_from_status_code( - 402, - "Payment required".into(), - "openai".into(), - Some("insufficient_quota".into()), - None, - None, - ); - assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded)); - } - - /// Fireworks reports an account suspension (spending cap reached or - /// unpaid invoices) as HTTP 412 with `code: "PRECONDITION_FAILED"` in - /// the body. A chat completion carries no conditional-request - /// preconditions, so a 412 is always an account-level lockout, never a - /// defect in the request: it must not classify as `InvalidRequest`, and - /// a fallback provider with independent billing must stay eligible. - #[test] - fn account_suspension_412_is_failover_eligible() { - let err = error_from_status_code( - 412, - "Account lithoscomputer is suspended, possibly due to reaching \ - the monthly spending limit or failure to pay past invoices." - .into(), - "fireworks".into(), - // The openai_compatible dialect reads `error.type` as the code, - // so the discriminating `PRECONDITION_FAILED` only reaches this - // mapping through the status code. - Some("error".into()), - Some(serde_json::json!({ - "error": { - "message": "Account lithoscomputer is suspended, possibly due to reaching the monthly spending limit or failure to pay past invoices. Please go to https://fireworks.ai/account/billing for more information.", - "param": null, - "code": "PRECONDITION_FAILED", - "type": "error" - }, - "request_id": "chatcmpl-d9652b89a6604931ac27dddd5ef5bdc0" - })), - None, - ); - - assert_eq!(err.provider_kind(), Some(ProviderErrorKind::AccessDenied)); - assert!(!err.retryable()); - assert!(err.failover_eligible()); - - // A bare 412 with no parseable body classifies the same way. - let err = error_from_status_code( - 412, - "Precondition Failed".into(), - "fireworks".into(), - None, - None, - None, - ); - assert_eq!(err.provider_kind(), Some(ProviderErrorKind::AccessDenied)); - assert!(err.failover_eligible()); - } - - #[test] - fn kind_from_error_code_covers_every_dialect() { - for (code, expected) in [ - ("insufficient_quota", ProviderErrorKind::QuotaExceeded), - ("rate_limit_error", ProviderErrorKind::RateLimit), - ("authentication_error", ProviderErrorKind::Authentication), - ("permission_error", ProviderErrorKind::AccessDenied), - ("content_policy_violation", ProviderErrorKind::ContentFilter), - ("context_length_exceeded", ProviderErrorKind::ContextLength), - ("engine_overloaded", ProviderErrorKind::Server), - // anthropic's oversized-input code beats the `_too_large` rule - ("request_too_large", ProviderErrorKind::ContextLength), - ("prompt_too_long", ProviderErrorKind::InvalidRequest), - ("invalid_request_error", ProviderErrorKind::InvalidRequest), - ("unsupported_parameter", ProviderErrorKind::InvalidRequest), - // both the anthropic and openai not-found spellings - ("not_found_error", ProviderErrorKind::NotFound), - ("model_not_found", ProviderErrorKind::NotFound), - ] { - assert_eq!(kind_from_error_code(code), Some(expected), "{code}"); - } - - // No opinion, so the caller keeps its own default. - assert_eq!(kind_from_error_code("overloaded_error"), None); - assert_eq!(kind_from_error_code("api_error"), None); - assert_eq!(kind_from_error_code(""), None); - } - - #[test] - fn error_message_classification_context_length() { - let err = error_from_status_code( - 400, - "This model's maximum context length is 4096 tokens".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::ContextLength, - .. - })); - } - - #[test] - fn error_message_classification_too_many_tokens() { - let err = error_from_status_code( - 400, - "too many tokens in the request".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::ContextLength, - .. - })); - } - - #[test] - fn error_message_classification_content_filter() { - let err = error_from_status_code( - 400, - "Output blocked by content filter".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::ContentFilter, - .. - })); - } - - #[test] - fn error_message_classification_safety() { - let err = error_from_status_code( - 400, - "Response blocked due to safety concerns".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::ContentFilter, - .. - })); - } - - #[test] - fn error_message_classification_not_found() { - let err = error_from_status_code( - 400, - "The model gpt-5 was not found".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::NotFound, - .. - })); - } - - #[test] - fn error_message_classification_does_not_exist() { - let err = error_from_status_code( - 400, - "The resource does not exist".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::NotFound, - .. - })); - } - - #[test] - fn error_message_classification_unauthorized() { - let err = error_from_status_code( - 400, - "Request unauthorized for this resource".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Authentication, - .. - })); - } - - #[test] - fn error_message_classification_invalid_key() { - let err = error_from_status_code( - 400, - "Provided invalid key for authentication".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Authentication, - .. - })); - } - - #[test] - fn grpc_status_mapping() { - let err = error_from_grpc_status( - "NOT_FOUND", - "model not found".into(), - "gemini".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::NotFound, - .. - })); - - let err = error_from_grpc_status( - "RESOURCE_EXHAUSTED", - "rate limited".into(), - "gemini".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::RateLimit, - .. - })); - assert!(err.retryable()); - - let err = error_from_grpc_status( - "UNAUTHENTICATED", - "bad key".into(), - "gemini".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Authentication, - .. - })); - - let err = error_from_grpc_status( - "DEADLINE_EXCEEDED", - "timeout".into(), - "gemini".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::RequestTimeout { .. })); - - let err = error_from_grpc_status( - "UNKNOWN_CODE", - "something".into(), - "gemini".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Server, - .. - })); - } - - #[test] - fn error_display_messages() { - let err = Error::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail { - status_code: Some(401), - ..ProviderErrorDetail::new("invalid api key", "openai") - }), - }; + fn signatures_name_category_provider_and_kind() { assert_eq!( - err.to_string(), - "Authentication error for openai: invalid api key" - ); - - let err = Error::Configuration { - message: "no provider".into(), - source: None, - }; - assert_eq!(err.to_string(), "Configuration error: no provider"); - - let err = Error::InvalidRequest { - message: "unsupported reasoning effort".into(), - }; - assert_eq!( - err.to_string(), - "Invalid request: unsupported reasoning effort" - ); - } - - #[test] - fn status_code_accessor() { - let err = Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail { - status_code: Some(503), - ..ProviderErrorDetail::new("error", "openai") - }), - }; - assert_eq!(err.status_code(), Some(503)); - - let err = Error::Network { - message: "refused".into(), - source: None, - }; - assert_eq!(err.status_code(), None); - } - - #[test] - fn provider_name_from_provider_variant() { - let err = Error::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail::new("bad key", "openai")), - }; - assert_eq!(err.provider_name(), "openai"); - } - - #[test] - fn provider_name_defaults_to_unknown() { - let err = Error::Network { - message: "refused".into(), - source: None, - }; - assert_eq!(err.provider_name(), "unknown"); - } - - #[test] - fn failover_eligible_transient_provider_errors() { - let detail = || Box::new(ProviderErrorDetail::new("error", "openai")); - - assert!( - Error::Provider { - kind: ProviderErrorKind::RateLimit, - detail: detail(), - } - .failover_eligible() - ); - - assert!( - Error::Provider { - kind: ProviderErrorKind::Server, - detail: detail(), - } - .failover_eligible() - ); - - assert!( - Error::Provider { - kind: ProviderErrorKind::QuotaExceeded, - detail: detail(), - } - .failover_eligible() - ); - } - - #[test] - fn failover_eligible_provider_local_availability_errors() { - let detail = || Box::new(ProviderErrorDetail::new("error", "openai")); - - for kind in [ - ProviderErrorKind::Authentication, - ProviderErrorKind::AccessDenied, - ProviderErrorKind::NotFound, - ] { - assert!( - Error::Provider { - kind, - detail: detail(), - } - .failover_eligible(), - "{kind:?} should permit another provider" - ); - } - } - - #[test] - fn failover_eligible_transient_non_provider_errors() { - assert!( - Error::RequestTimeout { - message: "timed out".into(), - source: None, - } - .failover_eligible() - ); - - assert!( - Error::Network { - message: "refused".into(), - source: None, - } - .failover_eligible() - ); - - assert!( - Error::Stream { - message: "broken".into(), - source: None, - } - .failover_eligible() - ); - } - - #[test] - fn failover_not_eligible_deterministic_errors() { - let detail = || Box::new(ProviderErrorDetail::new("error", "openai")); - - assert!( - !Error::Provider { - kind: ProviderErrorKind::InvalidRequest, - detail: detail(), - } - .failover_eligible() - ); - - assert!( - !Error::Provider { - kind: ProviderErrorKind::ContextLength, - detail: detail(), - } - .failover_eligible() - ); - - assert!( - !Error::Provider { - kind: ProviderErrorKind::ContentFilter, - detail: detail(), - } - .failover_eligible() - ); - } - - #[test] - fn failover_eligible_for_refusal_content_filter_only() { - assert!( - Error::Provider { - kind: ProviderErrorKind::ContentFilter, - detail: Box::new(ProviderErrorDetail { - error_code: Some("refusal".to_string()), - raw: Some(serde_json::json!({ - "stop_reason": "refusal", - "stop_details": {"type": "refusal", "category": "cyber"} - })), - ..ProviderErrorDetail::new("declined", "anthropic") - }), - } - .failover_eligible() - ); - - assert!( - !Error::Provider { - kind: ProviderErrorKind::ContentFilter, - detail: Box::new(ProviderErrorDetail { - error_code: Some("safety".to_string()), - ..ProviderErrorDetail::new("blocked", "anthropic") - }), - } - .failover_eligible() - ); - } - - #[test] - fn failover_not_eligible_non_provider_errors() { - assert!( - !Error::Configuration { - message: "bad".into(), - source: None, - } - .failover_eligible() - ); - - assert!( - !Error::Interrupt { - message: "cancelled".into(), - } - .failover_eligible() - ); - - assert!( - !Error::InvalidToolCall { - message: "bad".into(), - } - .failover_eligible() - ); - - assert!( - !Error::NoObjectGenerated { - message: "none".into(), - } - .failover_eligible() - ); - - assert!( - !Error::InvalidRequest { - message: "bad".into(), - } - .failover_eligible() - ); - - assert!( - !Error::UnsupportedToolChoice { - message: "nope".into(), - } - .failover_eligible() - ); - } - - #[test] - fn failure_signature_hint_provider_transient() { - let err = Error::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail::new("too fast", "openai")), - }; - assert_eq!( - err.failure_signature_hint(), - "api_transient|openai|rate_limited" - ); - - let err = Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail::new("500", "anthropic")), - }; - assert_eq!( - err.failure_signature_hint(), - "api_transient|anthropic|server_error" - ); - } - - #[test] - fn failure_signature_hint_provider_deterministic() { - let err = Error::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail::new("bad key", "openai")), - }; - assert_eq!( - err.failure_signature_hint(), - "api_deterministic|openai|authentication" - ); - - let err = Error::Provider { - kind: ProviderErrorKind::AccessDenied, - detail: Box::new(ProviderErrorDetail::new("denied", "anthropic")), - }; - assert_eq!( - err.failure_signature_hint(), - "api_deterministic|anthropic|access_denied" - ); - - let err = Error::Provider { - kind: ProviderErrorKind::NotFound, - detail: Box::new(ProviderErrorDetail::new("missing", "openai")), - }; - assert_eq!( - err.failure_signature_hint(), - "api_deterministic|openai|not_found" - ); - - let err = Error::Provider { - kind: ProviderErrorKind::InvalidRequest, - detail: Box::new(ProviderErrorDetail::new("bad", "openai")), - }; - assert_eq!( - err.failure_signature_hint(), + failure_signature_hint(&error(ErrorKind::InvalidRequest)), "api_deterministic|openai|invalid_request" ); - - let err = Error::Provider { - kind: ProviderErrorKind::ContentFilter, - detail: Box::new(ProviderErrorDetail::new("blocked", "openai")), - }; assert_eq!( - err.failure_signature_hint(), - "api_deterministic|openai|content_filter" + failure_signature_hint( + &error(ErrorKind::RateLimit).with_retry(RetryClassification::Safe) + ), + "api_transient|openai|rate_limit" ); - - let err = Error::Provider { - kind: ProviderErrorKind::ContextLength, - detail: Box::new(ProviderErrorDetail::new("too long", "openai")), - }; assert_eq!( - err.failure_signature_hint(), - "api_deterministic|openai|context_length" - ); - - let err = Error::Provider { - kind: ProviderErrorKind::QuotaExceeded, - detail: Box::new(ProviderErrorDetail::new("out of quota", "openai")), - }; - assert_eq!( - err.failure_signature_hint(), - "api_deterministic|openai|quota_exceeded" + failure_signature_hint(&error(ErrorKind::Cancelled)), + "api_canceled|openai|cancelled" ); } #[test] - fn failure_signature_hint_non_provider_variants() { - assert_eq!( - Error::RequestTimeout { - message: "timed out".into(), - source: None, - } - .failure_signature_hint(), - "api_transient|unknown|timeout" - ); - assert_eq!( - Error::Network { - message: "refused".into(), - source: None, - } - .failure_signature_hint(), - "api_transient|unknown|network" - ); - assert_eq!( - Error::Stream { - message: "broken".into(), - source: None, - } - .failure_signature_hint(), - "api_transient|unknown|stream" - ); - assert_eq!( - Error::Interrupt { - message: "cancelled".into(), - } - .failure_signature_hint(), - "api_canceled|unknown|interrupt" - ); - assert_eq!( - Error::Configuration { - message: "bad".into(), - source: None, - } - .failure_signature_hint(), - "api_deterministic|unknown|configuration" - ); - assert_eq!( - Error::InvalidToolCall { - message: "bad".into(), - } - .failure_signature_hint(), - "api_deterministic|unknown|invalid_tool_call" - ); - assert_eq!( - Error::NoObjectGenerated { - message: "none".into(), - } - .failure_signature_hint(), - "api_deterministic|unknown|no_object" - ); - assert_eq!( - Error::InvalidRequest { - message: "bad".into(), - } - .failure_signature_hint(), - "api_deterministic|unknown|invalid_request" - ); - assert_eq!( - Error::UnsupportedToolChoice { - message: "nope".into(), - } - .failure_signature_hint(), - "api_deterministic|unknown|unsupported_tool_choice" - ); + fn failover_covers_provider_local_failures() { + assert!(failover_eligible(&error(ErrorKind::Authentication))); + assert!(failover_eligible(&error(ErrorKind::QuotaExceeded))); + assert!(!failover_eligible(&error(ErrorKind::InvalidRequest))); + assert!(!failover_eligible(&error(ErrorKind::ContextLength))); + assert!(!failover_eligible(&error(ErrorKind::ContentFilter))); + assert!(failover_eligible( + &error(ErrorKind::ContentFilter).with_provider_code("refusal") + )); } #[test] - fn sdk_error_source_chaining() { - let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused"); - let err = Error::network("connection failed", io_err); - assert!(err.source().is_some()); - } + fn stored_errors_keep_the_facts_and_round_trip() { + let live = error(ErrorKind::RateLimit) + .with_status(429) + .with_provider_code("slow") + .with_retry(RetryClassification::after(Duration::from_secs(2))) + .with_source(std::io::Error::other("socket closed")); + let stored = LlmError::from(&live); + assert_eq!(stored.kind(), ErrorKind::RateLimit); + assert_eq!(stored.status(), Some(429)); + assert_eq!(stored.provider_code(), Some("slow")); + assert_eq!(stored.retry_after(), Some(Duration::from_secs(2))); + assert_eq!(stored.source_message(), Some("socket closed")); + assert_eq!(stored.to_string(), "boom"); + assert!(stored.is_retryable()); + assert_eq!( + stored.failure_signature_hint(), + failure_signature_hint(&live) + ); - #[test] - fn sdk_error_source_chain_walkable() { - let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused"); - let err = Error::network("connection failed", io_err); - // The source chain is walkable — the Arc wrapper preserves the inner error's - // display - let source = err.source().unwrap(); - assert!(source.to_string().contains("refused")); - } - - #[test] - fn sdk_error_serde_roundtrip_without_source() { - let io_err = std::io::Error::other("boom"); - let err = Error::network("network failed", io_err); - let json = serde_json::to_string(&err).unwrap(); - let deserialized: Error = serde_json::from_str(&json).unwrap(); - // source is lost through serde, message is preserved - assert!(deserialized.source().is_none()); - assert_eq!(deserialized.to_string(), "Network error: network failed"); + let json = serde_json::to_value(&stored).unwrap(); + assert_eq!(json["kind"], "rate_limit"); + let decoded: LlmError = serde_json::from_value(json).unwrap(); + assert_eq!(decoded, stored); } } diff --git a/lib/components/fabro-llm/src/gateway.rs b/lib/components/fabro-llm/src/gateway.rs new file mode 100644 index 000000000..21b267d67 --- /dev/null +++ b/lib/components/fabro-llm/src/gateway.rs @@ -0,0 +1,306 @@ +//! The Fabro server completions gateway as a lithos provider adapter. +//! +//! `fabro exec --server` sends every model call to `POST /api/v1/completions` +//! on a Fabro server, which holds the provider credentials and the catalog +//! and is the billing authority. The server returns lithos `Response` JSON +//! and streams lithos `StreamEvent` JSON verbatim, so this adapter decodes +//! the standard types and trusts the cost inside them. +//! +//! Transport (authentication, token refresh, base URL) belongs to the caller +//! through [`GatewayTransport`], so this crate does not depend on the CLI's +//! server client. + +use std::time::Duration; + +use async_trait::async_trait; +use fabro_http::HeaderMap; +use futures::{StreamExt as _, stream}; +use lithos_llm::adapter::{ProviderAdapter, ResolvedCall}; +use lithos_llm::catalog::{AdapterId, ProviderId}; +use lithos_llm::types::{ + Error, ErrorKind, Response, ResponseStream, RetryClassification, StreamEvent, +}; + +/// Adapter id reported for gateway routes. +pub const GATEWAY_ADAPTER_ID: &str = "fabro-gateway"; + +/// How the adapter reaches the server. +#[async_trait] +pub trait GatewayTransport: Send + Sync { + /// Posts a completion body and returns the raw HTTP response. + async fn post_completion( + &self, + body: serde_json::Value, + ) -> Result; +} + +/// A failure between the adapter and the server. +#[derive(Debug, thiserror::Error)] +pub enum GatewayError { + /// The request never produced an HTTP response. + #[error("{message}")] + Transport { + message: String, + /// Whether the failure was a missing or rejected Fabro login. + auth: bool, + }, + /// The server answered with an error status. + #[error("server returned HTTP {status}")] + Status { + status: u16, + headers: HeaderMap, + body: String, + }, +} + +pub struct GatewayAdapter { + id: AdapterId, + transport: Box, +} + +impl GatewayAdapter { + #[must_use] + pub fn new(transport: Box) -> Self { + Self { + id: AdapterId::new(GATEWAY_ADAPTER_ID), + transport, + } + } + + fn body(call: &ResolvedCall, stream: bool) -> Result { + let mut body = serde_json::to_value(call.request()).map_err(|source| { + Error::new(ErrorKind::InvalidRequest, "failed to serialize request").with_source(source) + })?; + // The gateway resolves models itself; send the canonical route so the + // server and the local catalog agree on the offering. + body["model"] = serde_json::Value::String(call.route().handle().to_string()); + body["stream"] = serde_json::Value::Bool(stream); + Ok(body) + } + + async fn send(&self, call: &ResolvedCall, stream: bool) -> Result { + let provider = call.route().provider().id().clone(); + self.transport + .post_completion(Self::body(call, stream)?) + .await + .map_err(|err| gateway_error(err, &provider)) + } +} + +fn gateway_error(err: GatewayError, provider: &ProviderId) -> Error { + match err { + GatewayError::Transport { message, auth } => { + let kind = if auth { + ErrorKind::Authentication + } else { + ErrorKind::Network + }; + let mut error = Error::new(kind, message).with_provider(provider.clone()); + if !auth { + error = error.with_retry(RetryClassification::Safe); + } + error + } + GatewayError::Status { + status, + headers, + body, + } => { + let (message, code) = parse_server_error_body(&body); + let kind = match status { + 400 | 422 => ErrorKind::InvalidRequest, + 401 => ErrorKind::Authentication, + 403 => ErrorKind::AccessDenied, + 404 => ErrorKind::NotFound, + 408 | 504 => ErrorKind::Timeout, + 429 => ErrorKind::RateLimit, + 500..=599 => ErrorKind::Server, + _ => ErrorKind::Provider, + }; + let mut error = Error::new(kind.clone(), message) + .with_provider(provider.clone()) + .with_status(status); + if let Some(code) = code { + error = error.with_provider_code(code); + } + match kind { + ErrorKind::RateLimit | ErrorKind::Server | ErrorKind::Timeout => { + error = error.with_retry(RetryClassification::Safe); + if let Some(after) = retry_after(&headers) { + error = error + .with_retry(RetryClassification::after(after)) + .with_provider_retry_after(after); + } + } + _ => {} + } + error + } + } +} + +fn retry_after(headers: &HeaderMap) -> Option { + headers + .get("retry-after") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .map(Duration::from_secs_f64) +} + +/// Reads the Fabro API error envelope (`errors[0].detail` / `code`), falling +/// back to the raw body. +#[must_use] +pub fn parse_server_error_body(body: &str) -> (String, Option) { + let Ok(value) = serde_json::from_str::(body) else { + return (body.to_string(), None); + }; + 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)) + .unwrap_or("Unknown error") + .to_string(); + let code = first + .and_then(|entry| entry.get("code")) + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned); + (detail, code) +} + +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?; + (!data_lines.is_empty()).then(|| (event_type, data_lines.join("\n"))) +} + +fn decode_error(message: String, source: impl std::error::Error + Send + Sync + 'static) -> Error { + Error::new(ErrorKind::StreamDecode, message).with_source(source) +} + +#[async_trait] +impl ProviderAdapter for GatewayAdapter { + fn id(&self) -> &AdapterId { + &self.id + } + + async fn complete(&self, call: &ResolvedCall) -> Result { + let response = self.send(call, false).await?; + let body = response.text().await.map_err(|source| { + Error::new(ErrorKind::Network, "failed to read completion body") + .with_source(source) + .with_retry(RetryClassification::Safe) + })?; + serde_json::from_str(&body) + .map_err(|source| decode_error("failed to parse completion response".into(), source)) + } + + async fn stream(&self, call: &ResolvedCall) -> Result { + let response = self.send(call, true).await?; + let state = SseState { + buffer: String::new(), + bytes: Box::pin(response.bytes_stream()), + }; + let events = stream::unfold(state, |mut state| async move { + loop { + if let Some(position) = state.buffer.find("\n\n") { + let block = state.buffer[..position].to_string(); + state.buffer = state.buffer[position + 2..].to_string(); + let Some((event_type, data)) = parse_sse_block(&block) else { + continue; + }; + if event_type != "stream_event" { + continue; + } + let event = serde_json::from_str::(&data).map_err(|source| { + decode_error("failed to parse stream event".into(), source) + }); + return Some((event, state)); + } + match state.bytes.next().await { + Some(Ok(chunk)) => state.buffer.push_str(&String::from_utf8_lossy(&chunk)), + Some(Err(source)) => { + let error = Error::new(ErrorKind::Network, "stream read failed") + .with_source(source) + .with_retry(RetryClassification::Safe); + return Some((Err(error), state)); + } + None => return None, + } + } + }); + Ok(ResponseStream::new(events)) + } +} + +type ByteStream = std::pin::Pin< + Box> + Send>, +>; + +struct SseState { + buffer: String, + bytes: ByteStream, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn server_error_envelope_is_parsed() { + let (detail, code) = parse_server_error_body( + r#"{"errors":[{"status":"429","title":"Too Many","detail":"slow down","code":"rate"}]}"#, + ); + assert_eq!(detail, "slow down"); + assert_eq!(code.as_deref(), Some("rate")); + let (detail, code) = parse_server_error_body("plain text"); + assert_eq!(detail, "plain text"); + assert!(code.is_none()); + } + + #[test] + fn sse_blocks_split_event_and_data() { + assert_eq!( + parse_sse_block("event: stream_event\ndata: {\"a\":1}"), + Some(("stream_event".to_string(), "{\"a\":1}".to_string())) + ); + assert_eq!(parse_sse_block(": comment"), None); + } + + #[test] + fn status_codes_map_to_error_kinds() { + let provider = ProviderId::new("openai"); + let mut headers = HeaderMap::new(); + headers.insert("retry-after", "2".parse().unwrap()); + let error = gateway_error( + GatewayError::Status { + status: 429, + headers, + body: String::new(), + }, + &provider, + ); + assert_eq!(error.kind(), ErrorKind::RateLimit); + assert_eq!(error.retry_after(), Some(Duration::from_secs(2))); + let error = gateway_error( + GatewayError::Transport { + message: "login required".into(), + auth: true, + }, + &provider, + ); + assert_eq!(error.kind(), ErrorKind::Authentication); + assert_eq!(error.retry_classification(), RetryClassification::Never); + } +} diff --git a/lib/components/fabro-llm/src/generate.rs b/lib/components/fabro-llm/src/generate.rs deleted file mode 100644 index 2e95d0070..000000000 --- a/lib/components/fabro-llm/src/generate.rs +++ /dev/null @@ -1,2634 +0,0 @@ -use std::pin::Pin; -use std::sync::Arc; -use std::task::{Context, Poll}; - -use fabro_util::backoff::BackoffPolicy; -use futures::{Stream, StreamExt, future, stream}; -use tokio::sync::mpsc; -use tokio::time; -use tokio_stream::wrappers::ReceiverStream; -use tokio_util::sync::CancellationToken; -use tracing::{debug, info, warn}; - -use crate::client::Client; -use crate::error::Error; -use crate::provider::StreamEventStream; -use crate::retry::retry; -use crate::tools::{RepairToolCallFn, Tool, execute_all_tools_with_repair}; -use crate::types::{ - FinishReason, GenerateResult, Message, ObjectStreamEvent, ReasoningEffort, Request, Response, - ResponseFormat, ResponseFormatType, RetryPolicy, Speed, StepResult, StreamEvent, - TimeoutOptions, TokenCounts, ToolCall, ToolChoice, ToolDefinition, -}; - -fn build_initial_messages(params: &GenerateParams) -> Result, Error> { - let mut messages = Vec::new(); - if let Some(system) = ¶ms.system { - messages.push(Message::system(system)); - } - if let Some(ref prompt) = params.prompt { - if params.messages.is_some() { - return Err(Error::Configuration { - message: "Cannot specify both 'prompt' and 'messages'".into(), - source: None, - }); - } - messages.push(Message::user(prompt)); - } else if let Some(ref msgs) = params.messages { - messages.extend(msgs.clone()); - } - Ok(messages) -} - -fn build_request( - params: &GenerateParams, - messages: &[Message], - tool_definitions: Option<&[ToolDefinition]>, -) -> Request { - Request { - model: params.model.clone(), - messages: messages.to_vec(), - provider: params.provider.clone(), - tools: tool_definitions.map(<[ToolDefinition]>::to_vec), - tool_choice: params.tool_choice.clone(), - response_format: params.response_format.clone(), - temperature: params.temperature, - top_p: params.top_p, - max_tokens: params.max_tokens, - stop_sequences: params.stop_sequences.clone(), - reasoning_effort: params.reasoning_effort, - speed: params.speed, - metadata: params.metadata.clone(), - provider_options: params.provider_options.clone(), - } -} - -fn build_generate_result(steps: Vec, total_usage: TokenCounts) -> GenerateResult { - let last = steps.last().expect("steps should not be empty"); - let response = last.response.clone(); - let tool_results = last.tool_results.clone(); - GenerateResult { - response, - tool_results, - total_usage, - steps, - output: None, - } -} - -/// High-level blocking generation function (Section 4.3). -/// -/// Wraps `Client.complete()` with tool execution loops, prompt standardization, -/// and automatic retries. -/// -/// # Errors -/// -/// Returns `Error::Configuration` if both `prompt` and `messages` are set, -/// or any provider error encountered during generation or tool execution. -/// -/// # Panics -/// -/// Panics if a tool's `execute` handler is `None` when matched during tool -/// execution. -pub async fn generate(params: GenerateParams) -> Result { - let client = Arc::clone(¶ms.client); - let retry_policy = RetryPolicy { - max_retries: params.max_retries, - backoff: BackoffPolicy { - initial_delay: std::time::Duration::from_micros(1), - jitter: false, - ..Default::default() - }, - ..Default::default() - }; - - let mut messages = build_initial_messages(¶ms)?; - let tool_definitions: Option> = params - .tools - .as_ref() - .map(|tools| tools.iter().map(|t| t.definition.clone()).collect()); - - let max_tool_rounds = params.max_tool_rounds; - - let abort_signal = params.abort_signal.clone(); - - let generate_future = async { - let mut steps: Vec = Vec::new(); - let mut total_usage = TokenCounts::default(); - - let mut round = 0u32; - loop { - if let Some(ref token) = abort_signal { - if token.is_cancelled() { - warn!("Generation interrupted by cancellation token"); - return Err(Error::Interrupt { - message: "Generation interrupted by cancellation token".into(), - }); - } - } - - let request = build_request(¶ms, &messages, tool_definitions.as_deref()); - - debug!( - model = %params.model, - provider = ?params.provider, - messages = messages.len(), - tools = tool_definitions.as_ref().map_or(0, std::vec::Vec::len), - "Sending LLM request" - ); - - let client_ref = client.clone(); - let response = if let Some(per_step) = params.timeout.as_ref().and_then(|t| t.per_step) - { - let duration = std::time::Duration::from_secs_f64(per_step); - time::timeout( - duration, - retry(&retry_policy, || { - let c = client_ref.clone(); - let r = request.clone(); - async move { c.complete(&r).await } - }), - ) - .await - .map_err(|_| { - warn!(timeout_secs = per_step, "Per-step timeout exceeded"); - Error::RequestTimeout { - message: format!("Per-step timeout of {per_step}s exceeded"), - source: None, - } - })? - } else { - retry(&retry_policy, || { - let c = client_ref.clone(); - let r = request.clone(); - async move { c.complete(&r).await } - }) - .await - }?; - - debug!( - model = %response.model, - provider = %response.provider, - input_tokens = response.usage.input_tokens, - output_tokens = response.usage.output_tokens, - finish_reason = ?response.finish_reason, - "LLM response received" - ); - - let tool_calls = response.tool_calls(); - let mut tool_results = Vec::new(); - - if let Some(tools) = ¶ms.tools { - if !tool_calls.is_empty() - && response.finish_reason == FinishReason::ToolCalls - && max_tool_rounds > 0 - { - debug!( - tool_calls = tool_calls.len(), - round = round, - "Executing tool calls" - ); - if tools.iter().any(|t| t.is_active()) { - let tool_refs: Vec<&Tool> = - tools.iter().map(std::convert::AsRef::as_ref).collect(); - tool_results = execute_all_tools_with_repair( - &tool_refs, - &tool_calls, - &messages, - abort_signal.as_ref(), - params.repair_tool_call.as_ref(), - ) - .await; - } - } - } - - total_usage += response.usage.clone(); - - steps.push(StepResult { - response, - tool_results, - }); - - let last = steps - .last() - .expect("steps is non-empty: element was pushed on the line above"); - let should_continue = !tool_calls.is_empty() - && last.response.finish_reason == FinishReason::ToolCalls - && round < max_tool_rounds - && !last.tool_results.is_empty() - && !params.stop_when.as_ref().is_some_and(|f| f(&steps)); - - if !should_continue { - break; - } - - if let Some(ref token) = abort_signal { - if token.is_cancelled() { - return Err(Error::Interrupt { - message: "Generation interrupted by cancellation token".into(), - }); - } - } - - let last = steps - .last() - .expect("steps is non-empty: element was pushed on the line above"); - messages.push(last.response.message.clone()); - for result in &last.tool_results { - messages.push(Message::tool_result( - &result.tool_call_id, - result.content.clone(), - result.is_error, - )); - } - - round += 1; - } - - Ok(build_generate_result(steps, total_usage)) - }; - - if let Some(total) = params.timeout.as_ref().and_then(|t| t.total) { - let duration = std::time::Duration::from_secs_f64(total); - time::timeout(duration, generate_future) - .await - .map_err(|_| { - warn!(timeout_secs = total, "Total generation timeout exceeded"); - Error::RequestTimeout { - message: format!("Total timeout of {total}s exceeded"), - source: None, - } - })? - } else { - generate_future.await - } -} - -/// Callback type for custom stop conditions in the tool loop. -pub type StopCondition = Arc bool + Send + Sync>; - -/// Parameters for `generate()` (Section 4.3). -#[derive(Clone)] -pub struct GenerateParams { - pub model: String, - pub prompt: Option, - pub messages: Option>, - pub system: Option, - pub tools: Option>>, - pub tool_choice: Option, - pub max_tool_rounds: u32, - pub response_format: Option, - pub temperature: Option, - pub top_p: Option, - pub max_tokens: Option, - pub stop_sequences: Option>, - pub reasoning_effort: Option, - pub speed: Option, - pub provider: Option, - pub provider_options: Option, - pub metadata: Option>, - pub max_retries: u32, - pub timeout: Option, - pub client: Arc, - /// Cancellation token to interrupt generation (Section 4.8). - pub abort_signal: Option, - /// Custom stop condition checked after each tool round (Section 4.3). - pub stop_when: Option, - /// Callback to repair invalid tool call arguments (Section 5.8). - pub repair_tool_call: Option, -} - -impl GenerateParams { - pub fn new(model: impl Into, client: Arc) -> Self { - Self { - model: model.into(), - prompt: None, - messages: None, - system: None, - tools: None, - tool_choice: None, - max_tool_rounds: 1, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - provider: None, - provider_options: None, - metadata: None, - max_retries: 2, - timeout: None, - client, - abort_signal: None, - stop_when: None, - repair_tool_call: None, - } - } - - #[must_use] - pub fn prompt(mut self, prompt: impl Into) -> Self { - self.prompt = Some(prompt.into()); - self - } - - #[must_use] - pub fn messages(mut self, messages: Vec) -> Self { - self.messages = Some(messages); - self - } - - #[must_use] - pub fn system(mut self, system: impl Into) -> Self { - self.system = Some(system.into()); - self - } - - #[must_use] - pub fn tools(mut self, tools: Vec) -> Self { - self.tools = Some(tools.into_iter().map(Arc::new).collect()); - self - } - - #[must_use] - pub const fn max_tool_rounds(mut self, rounds: u32) -> Self { - self.max_tool_rounds = rounds; - self - } - - #[must_use] - pub fn provider(mut self, provider: impl Into) -> Self { - self.provider = Some(provider.into()); - self - } - - #[must_use] - pub fn tool_choice(mut self, tool_choice: ToolChoice) -> Self { - self.tool_choice = Some(tool_choice); - self - } - - #[must_use] - pub fn response_format(mut self, response_format: ResponseFormat) -> Self { - self.response_format = Some(response_format); - self - } - - #[must_use] - pub const fn temperature(mut self, temperature: f64) -> Self { - self.temperature = Some(temperature); - self - } - - #[must_use] - pub const fn top_p(mut self, top_p: f64) -> Self { - self.top_p = Some(top_p); - self - } - - #[must_use] - pub const fn max_tokens(mut self, max_tokens: i64) -> Self { - self.max_tokens = Some(max_tokens); - self - } - - #[must_use] - pub fn stop_sequences(mut self, stop_sequences: Vec) -> Self { - self.stop_sequences = Some(stop_sequences); - self - } - - #[must_use] - pub fn reasoning_effort(mut self, reasoning_effort: ReasoningEffort) -> Self { - self.reasoning_effort = Some(reasoning_effort); - self - } - - #[must_use] - pub const fn speed(mut self, speed: Speed) -> Self { - self.speed = Some(speed); - self - } - - #[must_use] - pub fn provider_options(mut self, provider_options: serde_json::Value) -> Self { - self.provider_options = Some(provider_options); - self - } - - #[must_use] - pub fn metadata(mut self, metadata: std::collections::HashMap) -> Self { - self.metadata = Some(metadata); - self - } - - #[must_use] - pub const fn max_retries(mut self, max_retries: u32) -> Self { - self.max_retries = max_retries; - self - } - - #[must_use] - pub const fn timeout(mut self, timeout: TimeoutOptions) -> Self { - self.timeout = Some(timeout); - self - } - - #[must_use] - pub fn abort_signal(mut self, token: CancellationToken) -> Self { - self.abort_signal = Some(token); - self - } - - /// Set a custom stop condition for the tool loop (Section 4.3). - /// - /// The callback receives the accumulated steps so far and returns `true` - /// to stop the tool loop early. - #[must_use] - pub fn stop_when(mut self, f: impl Fn(&[StepResult]) -> bool + Send + Sync + 'static) -> Self { - self.stop_when = Some(Arc::new(f)); - self - } - - #[must_use] - pub fn repair_tool_call(mut self, repair: RepairToolCallFn) -> Self { - self.repair_tool_call = Some(repair); - self - } -} - -/// `StreamAccumulator` collects stream events into a complete Response (Section -/// 4.4). -pub struct StreamAccumulator { - text_parts: Vec, - reasoning_parts: Vec, - tool_calls: Vec, - finish_reason: Option, - usage: Option, - response: Option, -} - -impl StreamAccumulator { - #[must_use] - pub const fn new() -> Self { - Self { - text_parts: Vec::new(), - reasoning_parts: Vec::new(), - tool_calls: Vec::new(), - finish_reason: None, - usage: None, - response: None, - } - } - - pub fn process(&mut self, event: &StreamEvent) { - match event { - StreamEvent::TextDelta { delta, .. } => { - self.text_parts.push(delta.clone()); - } - StreamEvent::ReasoningDelta { delta } => { - self.reasoning_parts.push(delta.clone()); - } - StreamEvent::ToolCallEnd { tool_call } => { - self.tool_calls.push(tool_call.clone()); - } - StreamEvent::Finish { - finish_reason, - usage, - response, - } => { - self.finish_reason = Some(finish_reason.clone()); - self.usage = Some(usage.clone()); - self.response = Some(*response.clone()); - info!( - model = %response.model, - input_tokens = response.usage.input_tokens, - output_tokens = response.usage.output_tokens, - "LLM stream complete" - ); - } - _ => {} - } - } - - #[must_use] - pub const fn response(&self) -> Option<&Response> { - self.response.as_ref() - } - - #[must_use] - pub fn text(&self) -> String { - self.text_parts.join("") - } - - #[must_use] - pub fn reasoning(&self) -> Option { - if self.reasoning_parts.is_empty() { - None - } else { - Some(self.reasoning_parts.join("")) - } - } -} - -impl Default for StreamAccumulator { - fn default() -> Self { - Self::new() - } -} - -/// Wraps a streaming response with an internal `StreamAccumulator` and -/// convenience methods. -/// -/// Implements `Stream>` so it can be used -/// as a drop-in replacement for `StreamEventStream`. Also supports multi-step -/// tool loops when active tools are provided. -pub struct StreamResult { - inner: StreamEventStream, - accumulator: StreamAccumulator, -} - -impl StreamResult { - fn new(inner: StreamEventStream) -> Self { - Self { - inner, - accumulator: StreamAccumulator::new(), - } - } - - /// Returns the accumulated response after the stream has ended. - #[must_use] - pub const fn response(&self) -> Option<&Response> { - self.accumulator.response() - } - - /// Returns the current partially accumulated response state. - #[must_use] - pub const fn partial_response(&self) -> Option<&Response> { - self.accumulator.response() - } - - /// Returns a stream that yields only text delta strings. - #[must_use] - pub fn text_stream(self) -> Pin> + Send>> { - Box::pin(self.filter_map(|result| { - future::ready(match result { - Ok(StreamEvent::TextDelta { delta, .. }) => Some(Ok(delta)), - Err(e) => Some(Err(e)), - _ => None, - }) - })) - } -} - -impl Stream for StreamResult { - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let inner = self.inner.as_mut(); - match inner.poll_next(cx) { - Poll::Ready(Some(Ok(event))) => { - self.accumulator.process(&event); - Poll::Ready(Some(Ok(event))) - } - other => other, - } - } -} - -/// High-level streaming generation (Section 4.4). -/// Returns a `StreamResult` that the caller can iterate over. -/// Supports multi-step tool loops when active tools are provided. -/// -/// # Errors -/// -/// Returns `Error::Configuration` if both `prompt` and `messages` are set, -/// or any provider error encountered during streaming setup. -pub async fn stream(params: GenerateParams) -> Result { - let inner = stream_with_tool_loop(params).await?; - Ok(StreamResult::new(inner)) -} - -/// Streaming generation with multi-step tool loop support. -/// -/// When active tools are provided and the model returns tool calls: -/// - Collects the stream to get the complete first response -/// - Executes tools concurrently -/// - Starts a new stream with updated conversation -/// - Yields all events from all rounds seamlessly -/// - Continues until no more tool calls or `max_tool_rounds` reached -/// -/// # Errors -/// -/// Returns `Error::Configuration` if both `prompt` and `messages` are set, -/// or any provider error encountered during streaming setup. -async fn stream_with_tool_loop(params: GenerateParams) -> Result { - let client = Arc::clone(¶ms.client); - let mut messages = build_initial_messages(¶ms)?; - let tool_definitions: Option> = params - .tools - .as_ref() - .map(|tools| tools.iter().map(|t| t.definition.clone()).collect()); - let abort_signal = params.abort_signal.clone(); - let max_tool_rounds = params.max_tool_rounds; - let repair_tool_call = params.repair_tool_call.clone(); - - let has_active_tools = max_tool_rounds > 0 - && params - .tools - .as_ref() - .is_some_and(|tools| tools.iter().any(|t| t.is_active())); - - debug!(model = %params.model, "Starting LLM stream"); - - if !has_active_tools { - // No tool loop needed, just stream directly - return stream_generate_raw(&client, ¶ms, &messages, tool_definitions.as_deref()).await; - } - - // Tool loop: collect events from each round, execute tools, continue - let (tx, rx) = mpsc::channel::>(64); - - let tools = params.tools.clone(); - let retry_policy = RetryPolicy { - max_retries: params.max_retries, - backoff: BackoffPolicy { - initial_delay: std::time::Duration::from_micros(1), - jitter: false, - ..Default::default() - }, - ..Default::default() - }; - - tokio::spawn(async move { - let tool_loop_future = async { - let mut round = 0u32; - let mut steps: Vec = Vec::new(); - - loop { - if let Some(ref token) = abort_signal { - if token.is_cancelled() { - let _ = tx - .send(Err(Error::Interrupt { - message: "Stream interrupted by cancellation token".into(), - })) - .await; - return; - } - } - - let request = build_request(¶ms, &messages, tool_definitions.as_deref()); - - // Retry initial connection (Section 6.6), with optional per_step timeout - let stream_connect = retry(&retry_policy, || { - let c = client.clone(); - let r = request.clone(); - async move { c.stream(&r).await } - }); - - let stream_result = - if let Some(per_step) = params.timeout.as_ref().and_then(|t| t.per_step) { - let duration = std::time::Duration::from_secs_f64(per_step); - time::timeout(duration, stream_connect) - .await - .unwrap_or_else(|_| { - Err(Error::RequestTimeout { - message: format!("Per-step timeout of {per_step}s exceeded"), - source: None, - }) - }) - } else { - stream_connect.await - }; - - let mut inner_stream = match stream_result { - Ok(s) => s, - Err(e) => { - let _ = tx.send(Err(e)).await; - return; - } - }; - - // Collect stream and forward events, accumulating for tool call detection - let mut accumulator = StreamAccumulator::new(); - - while let Some(item) = inner_stream.next().await { - if let Some(ref token) = abort_signal { - if token.is_cancelled() { - let _ = tx - .send(Err(Error::Interrupt { - message: "Stream interrupted by cancellation token".into(), - })) - .await; - return; - } - } - - if let Ok(event) = &item { - accumulator.process(event); - } else { - let _ = tx.send(item).await; - return; - } - - // Forward the event to the consumer - if tx.send(item).await.is_err() { - return; // Consumer dropped - } - } - - // Check if we should continue with tool calls - let response = match accumulator.response() { - Some(r) => r.clone(), - None => return, // No response accumulated, stream ended - }; - - let tool_calls = response.tool_calls(); - if tool_calls.is_empty() - || response.finish_reason != FinishReason::ToolCalls - || round >= max_tool_rounds - { - return; // No more tool rounds needed - } - - // Execute tools - let Some(tool_list) = &tools else { return }; - - let tool_refs: Vec<&Tool> = - tool_list.iter().map(std::convert::AsRef::as_ref).collect(); - let tool_results = execute_all_tools_with_repair( - &tool_refs, - &tool_calls, - &messages, - abort_signal.as_ref(), - repair_tool_call.as_ref(), - ) - .await; - - if tool_results.is_empty() { - return; - } - - // Track step results for stop_when - steps.push(StepResult { - response: response.clone(), - tool_results: tool_results.clone(), - }); - - // Check stop_when condition (Section 4.3) - if params.stop_when.as_ref().is_some_and(|f| f(&steps)) { - // Emit StepFinish but do not continue to next round - let step_finish = StreamEvent::step_finish( - response.finish_reason.clone(), - response.usage.clone(), - response, - tool_calls, - tool_results, - ); - let _ = tx.send(Ok(step_finish)).await; - return; - } - - // Emit StepFinish event between steps - let step_finish = StreamEvent::step_finish( - response.finish_reason.clone(), - response.usage.clone(), - response.clone(), - tool_calls, - tool_results.clone(), - ); - if tx.send(Ok(step_finish)).await.is_err() { - return; // Consumer dropped - } - - // Append assistant message and tool results to conversation - messages.push(response.message.clone()); - for result in &tool_results { - messages.push(Message::tool_result( - &result.tool_call_id, - result.content.clone(), - result.is_error, - )); - } - - round += 1; - } - }; - - // Apply total timeout if configured (Section 4.7) - if let Some(total) = params.timeout.as_ref().and_then(|t| t.total) { - let duration = std::time::Duration::from_secs_f64(total); - if time::timeout(duration, tool_loop_future).await.is_err() { - let _ = tx - .send(Err(Error::RequestTimeout { - message: format!("Total timeout of {total}s exceeded"), - source: None, - })) - .await; - } - } else { - tool_loop_future.await; - } - }); - - Ok(Box::pin(ReceiverStream::new(rx))) -} - -/// Internal single-round streaming (no tool loop). Used by `stream_object()`. -async fn stream_generate_raw( - client: &Arc, - params: &GenerateParams, - messages: &[Message], - tool_definitions: Option<&[ToolDefinition]>, -) -> Result { - let request = build_request(params, messages, tool_definitions); - - // Apply per_step timeout to the initial connection (Section 4.7) - let inner_stream = if let Some(per_step) = params.timeout.as_ref().and_then(|t| t.per_step) { - let duration = std::time::Duration::from_secs_f64(per_step); - time::timeout(duration, client.stream(&request)) - .await - .map_err(|_| Error::RequestTimeout { - message: format!("Per-step timeout of {per_step}s exceeded"), - source: None, - })?? - } else { - client.stream(&request).await? - }; - - // Apply interrupt signal if present - let stream: StreamEventStream = if let Some(ref token) = params.abort_signal { - let token = token.clone(); - let mapped = inner_stream.map(move |item| { - if token.is_cancelled() { - return Err(Error::Interrupt { - message: "Stream interrupted by cancellation token".into(), - }); - } - item - }); - Box::pin(mapped) - } else { - inner_stream - }; - - // Apply total timeout to the stream (Section 4.7) - if let Some(total) = params.timeout.as_ref().and_then(|t| t.total) { - let duration = std::time::Duration::from_secs_f64(total); - let deadline = time::Instant::now() + duration; - let total_copy = total; - let timed_stream = stream::unfold((stream, false), move |(mut stream, done)| async move { - if done { - return None; - } - match time::timeout_at(deadline, stream.next()).await { - Ok(Some(item)) => Some((item, (stream, false))), - Ok(None) => None, // stream completed naturally - Err(_) => Some(( - Err(Error::RequestTimeout { - message: format!("Total timeout of {total_copy}s exceeded"), - source: None, - }), - (stream, true), - )), - } - }); - Ok(Box::pin(timed_stream)) - } else { - Ok(stream) - } -} - -/// High-level streaming generation (Section 4.4). -/// Returns a `StreamEventStream` that the caller can iterate over. -/// -/// Alias: prefer [`stream()`] for consistency with the spec. -/// -/// # Errors -/// -/// Returns `Error::Configuration` if both `prompt` and `messages` are set, -/// or any provider error encountered during streaming setup. -pub async fn stream_generate(params: GenerateParams) -> Result { - let client = Arc::clone(¶ms.client); - let messages = build_initial_messages(¶ms)?; - let tool_definitions: Option> = params - .tools - .as_ref() - .map(|tools| tools.iter().map(|t| t.definition.clone()).collect()); - - stream_generate_raw(&client, ¶ms, &messages, tool_definitions.as_deref()).await -} - -/// Structured output generation with schema validation (Section 4.5). -/// -/// # Errors -/// -/// Returns `Error::NoObjectGenerated` if the response is not valid JSON, -/// or any error from `generate()`. -pub async fn generate_object( - params: GenerateParams, - schema: serde_json::Value, -) -> Result { - let params = GenerateParams { - response_format: Some(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(schema), - strict: true, - }), - ..params - }; - - let mut result = generate(params).await?; - - // Try to parse the text as JSON - match serde_json::from_str::(&result.text()) { - Ok(parsed) => { - result.output = Some(parsed); - Ok(result) - } - Err(e) => Err(Error::NoObjectGenerated { - message: format!("Failed to parse response as JSON: {e}"), - }), - } -} - -/// Stream type for `stream_object()`. -pub type ObjectStream = - Pin> + Send>>; - -/// Wraps an `ObjectStream` with an `object()` accessor for the final parsed -/// value. -/// -/// Implements `Stream>` so it can be -/// used as a drop-in replacement for `ObjectStream`. Tracks the last `Complete` -/// event's object internally so callers can retrieve it after the stream ends. -pub struct ObjectStreamResult { - inner: ObjectStream, - object: Option, -} - -impl ObjectStreamResult { - fn new(inner: ObjectStream) -> Self { - Self { - inner, - object: None, - } - } - - /// Returns the final parsed object after the stream has yielded a - /// `Complete` event. - #[must_use] - pub const fn object(&self) -> Option<&serde_json::Value> { - self.object.as_ref() - } -} - -impl Stream for ObjectStreamResult { - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let inner = self.inner.as_mut(); - match inner.poll_next(cx) { - Poll::Ready(Some(Ok(event))) => { - if let ObjectStreamEvent::Complete { ref object, .. } = event { - self.object = Some(object.clone()); - } - Poll::Ready(Some(Ok(event))) - } - other => other, - } - } -} - -/// Streaming structured output with incremental JSON parsing (Section 4.6). -/// -/// Combines streaming with structured output: sets `response_format` to -/// `json_schema`, streams the response, and attempts to parse the accumulated -/// text as JSON on each text delta. Yields `ObjectStreamEvent::Partial` when a -/// new valid partial parse is obtained, `ObjectStreamEvent::Delta` for every -/// raw stream event, and `ObjectStreamEvent::Complete` when the stream finishes -/// with the final parsed object. -/// -/// # Errors -/// -/// Returns `Error::Configuration` if both `prompt` and `messages` are set, -/// `Error::NoObjectGenerated` if the final accumulated text is not valid -/// JSON, or any provider error encountered during streaming. -pub async fn stream_object( - params: GenerateParams, - schema: serde_json::Value, -) -> Result { - let params = GenerateParams { - response_format: Some(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(schema), - strict: true, - }), - ..params - }; - - let inner_stream = stream(params).await?; - - let mapped = inner_stream.scan( - (String::new(), Option::::None), - |(accumulated_text, last_parsed), event| { - let mut events: Vec> = Vec::new(); - - match &event { - Ok(stream_event) => { - // Accumulate text from TextDelta events - if let StreamEvent::TextDelta { delta, .. } = stream_event { - accumulated_text.push_str(delta); - - // Try incremental JSON parse - if let Ok(parsed) = - serde_json::from_str::(accumulated_text) - { - if last_parsed.as_ref() != Some(&parsed) { - *last_parsed = Some(parsed.clone()); - events.push(Ok(ObjectStreamEvent::Partial { object: parsed })); - } - } - } - - // On Finish, yield the Complete event with final parsed object - if let StreamEvent::Finish { response, .. } = stream_event { - match serde_json::from_str::(accumulated_text) { - Ok(final_object) => { - events.push(Ok(ObjectStreamEvent::Complete { - object: final_object, - response: response.clone(), - })); - } - Err(e) => { - events.push(Err(Error::NoObjectGenerated { - message: format!("Failed to parse final response as JSON: {e}"), - })); - } - } - } else { - // Yield the raw delta event - events.push(Ok(ObjectStreamEvent::Delta { - event: stream_event.clone(), - })); - } - } - Err(e) => { - events.push(Err(Error::Stream { - message: format!("{e}"), - source: None, - })); - } - } - - future::ready(Some(stream::iter(events))) - }, - ); - - Ok(ObjectStreamResult::new(Box::pin(mapped.flatten()))) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::sync::atomic::{AtomicU32, Ordering}; - - use futures::{StreamExt, stream}; - use tokio::time::sleep; - - use super::*; - use crate::client::Client; - use crate::error::{ProviderErrorDetail, ProviderErrorKind}; - use crate::provider::ProviderAdapter; - use crate::types::{ContentPart, Role, ToolResult}; - - /// Mock provider that returns configurable responses. - struct MockProvider { - response_text: String, - } - - impl MockProvider { - fn new(text: &str) -> Self { - Self { - response_text: text.to_string(), - } - } - } - - #[async_trait::async_trait] - impl ProviderAdapter for MockProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(&self.response_text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - let text = self.response_text.clone(); - let events = vec![ - Ok(StreamEvent::text_delta(&text, Some("t1".into()))), - Ok(StreamEvent::finish( - FinishReason::Stop, - TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(&text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } - } - - fn mock_client(text: &str) -> Arc { - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), Arc::new(MockProvider::new(text))); - Arc::new(Client::new(providers, Some("mock".to_string()), vec![])) - } - - #[tokio::test] - async fn generate_simple_text() { - let result = - generate(GenerateParams::new("mock-model", mock_client("Hi there!")).prompt("Hello")) - .await - .unwrap(); - - assert_eq!(result.text(), "Hi there!"); - assert_eq!(result.finish_reason, FinishReason::Stop); - assert_eq!(result.usage.input_tokens, 10); - assert_eq!(result.steps.len(), 1); - } - - #[tokio::test] - async fn generate_with_system_message() { - let result = generate( - GenerateParams::new("mock-model", mock_client("Greetings!")) - .system("You are helpful") - .prompt("Hello"), - ) - .await - .unwrap(); - - assert_eq!(result.text(), "Greetings!"); - } - - #[tokio::test] - async fn generate_with_messages() { - let result = generate( - GenerateParams::new("mock-model", mock_client("I'm doing well!")).messages(vec![ - Message::user("Hello"), - Message::assistant("Hi"), - Message::user("How are you?"), - ]), - ) - .await - .unwrap(); - - assert_eq!(result.text(), "I'm doing well!"); - } - - #[tokio::test] - async fn generate_errors_on_both_prompt_and_messages() { - let result = generate(GenerateParams { - model: "mock-model".into(), - prompt: Some("Hello".into()), - messages: Some(vec![Message::user("World")]), - client: mock_client("test"), - ..GenerateParams::new("mock-model", mock_client("base")) - }) - .await; - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), Error::Configuration { .. })); - } - - /// Mock provider that returns tool calls then text - struct ToolCallMockProvider { - call_count: Arc, - } - - #[async_trait::async_trait] - impl ProviderAdapter for ToolCallMockProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - let count = self.call_count.fetch_add(1, Ordering::SeqCst); - - if count == 0 { - // First call: return tool call - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "call_1", - "get_weather", - serde_json::json!({"city": "SF"}), - ))], - 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, - }) - } else { - // Second call: return text - Ok(Response { - id: "resp_2".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant("The weather in SF is 72F"), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 20, - output_tokens: 10, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - } - - async fn stream(&self, _request: &Request) -> Result { - Ok(Box::pin(stream::empty())) - } - } - - #[tokio::test] - async fn generate_with_tool_loop() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(ToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let result = generate( - GenerateParams::new("mock-model", client) - .prompt("What's the weather in SF?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |args, _ctx| async move { - let city = args["city"].as_str().unwrap_or("unknown"); - Ok(serde_json::json!(format!("72F in {}", city))) - }, - )]) - .max_tool_rounds(5), - ) - .await - .unwrap(); - - assert_eq!(result.text(), "The weather in SF is 72F"); - assert_eq!(result.steps.len(), 2); - assert_eq!(result.total_usage.input_tokens, 30); - assert_eq!(result.total_usage.output_tokens, 15); - assert_eq!(call_count.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn stream_accumulator_collects_events() { - let mut acc = StreamAccumulator::new(); - - acc.process(&StreamEvent::TextStart { - text_id: Some("t1".into()), - }); - - acc.process(&StreamEvent::text_delta("Hello", Some("t1".into()))); - acc.process(&StreamEvent::text_delta(" world", Some("t1".into()))); - - let resp = Response { - id: "r1".into(), - model: "m".into(), - provider: "p".into(), - message: Message::assistant("Hello world"), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 5, - output_tokens: 2, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - - acc.process(&StreamEvent::finish( - FinishReason::Stop, - resp.usage.clone(), - resp, - )); - - assert_eq!(acc.text(), "Hello world"); - assert_eq!(acc.reasoning(), None); - assert!(acc.response().is_some()); - assert_eq!(acc.response().unwrap().text(), "Hello world"); - } - - #[tokio::test] - async fn stream_accumulator_collects_reasoning() { - let mut acc = StreamAccumulator::new(); - - acc.process(&StreamEvent::ReasoningDelta { - delta: "Let me think...".into(), - }); - - assert_eq!(acc.reasoning(), Some("Let me think...".to_string())); - } - - #[tokio::test] - async fn stream_generate_returns_events() { - let client = mock_client("Hello stream!"); - let mut stream = stream_generate(GenerateParams::new("mock-model", client).prompt("Hi")) - .await - .unwrap(); - - let first = stream.next().await.unwrap().unwrap(); - match &first { - StreamEvent::TextDelta { delta, .. } => assert_eq!(delta, "Hello stream!"), - other => panic!("Expected TextDelta, got {other:?}"), - } - - let second = stream.next().await.unwrap().unwrap(); - assert!(matches!(second, StreamEvent::Finish { .. })); - } - - #[tokio::test] - async fn generate_object_parses_json() { - // Create a mock that returns valid JSON - let client = mock_client(r#"{"name": "Alice", "age": 30}"#); - - let schema = serde_json::json!({ - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"} - }, - "required": ["name", "age"] - }); - - let result = generate_object( - GenerateParams::new("mock-model", client).prompt("Extract name and age"), - schema, - ) - .await - .unwrap(); - - assert!(result.output.is_some()); - let output = result.output.unwrap(); - assert_eq!(output["name"], "Alice"); - assert_eq!(output["age"], 30); - } - - #[tokio::test] - async fn generate_object_errors_on_invalid_json() { - let client = mock_client("not valid json"); - - let result = generate_object( - GenerateParams::new("mock-model", client).prompt("Extract data"), - serde_json::json!({"type": "object"}), - ) - .await; - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - Error::NoObjectGenerated { .. } - )); - } - - #[tokio::test] - async fn generate_stop_when_halts_tool_loop() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(ToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let result = generate( - GenerateParams::new("mock-model", client) - .prompt("What's the weather in SF?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |args, _ctx| async move { - let city = args["city"].as_str().unwrap_or("unknown"); - Ok(serde_json::json!(format!("72F in {}", city))) - }, - )]) - .max_tool_rounds(5) - .stop_when(|_steps| true), // Stop immediately after first round - ) - .await - .unwrap(); - - // stop_when returned true, so the tool loop should stop after 1 step - assert_eq!(result.steps.len(), 1); - assert_eq!(call_count.load(Ordering::SeqCst), 1); - } - - #[test] - fn generate_params_builder_methods() { - let params = GenerateParams::new("test-model", mock_client("builder")) - .prompt("hello") - .system("you are helpful") - .temperature(0.7) - .top_p(0.9) - .max_tokens(100) - .stop_sequences(vec!["STOP".to_string()]) - .reasoning_effort(ReasoningEffort::High) - .speed(Speed::Fast) - .provider("anthropic") - .provider_options(serde_json::json!({"key": "value"})) - .max_retries(5) - .tool_choice(ToolChoice::Required) - .response_format(ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }) - .max_tool_rounds(3); - - assert_eq!(params.model, "test-model"); - assert_eq!(params.prompt.as_deref(), Some("hello")); - assert_eq!(params.system.as_deref(), Some("you are helpful")); - assert_eq!(params.temperature, Some(0.7)); - assert_eq!(params.top_p, Some(0.9)); - assert_eq!(params.max_tokens, Some(100)); - assert_eq!(params.stop_sequences, Some(vec!["STOP".to_string()])); - assert_eq!(params.reasoning_effort, Some(ReasoningEffort::High)); - assert_eq!(params.speed, Some(Speed::Fast)); - assert_eq!(params.provider.as_deref(), Some("anthropic")); - assert!(params.provider_options.is_some()); - assert_eq!(params.max_retries, 5); - assert_eq!(params.tool_choice, Some(ToolChoice::Required)); - assert!(params.response_format.is_some()); - assert_eq!(params.max_tool_rounds, 3); - } - - #[test] - fn generate_params_timeout_builder() { - let params = - GenerateParams::new("test-model", mock_client("timeout")).timeout(TimeoutOptions { - total: Some(30.0), - per_step: Some(10.0), - }); - assert!(params.timeout.is_some()); - let t = params.timeout.unwrap(); - assert_eq!(t.total, Some(30.0)); - assert_eq!(t.per_step, Some(10.0)); - } - - /// Mock provider that streams JSON tokens incrementally. - struct StreamingJsonMockProvider { - deltas: Vec, - full_text: String, - } - - impl StreamingJsonMockProvider { - fn new(deltas: Vec<&str>) -> Self { - let full_text: String = deltas.iter().copied().collect(); - Self { - deltas: deltas.into_iter().map(String::from).collect(), - full_text, - } - } - } - - #[async_trait::async_trait] - impl ProviderAdapter for StreamingJsonMockProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(&self.full_text), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - let mut events: Vec> = self - .deltas - .iter() - .map(|d| Ok(StreamEvent::text_delta(d.as_str(), Some("t1".into())))) - .collect(); - - events.push(Ok(StreamEvent::finish( - FinishReason::Stop, - TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(&self.full_text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }, - ))); - - Ok(Box::pin(stream::iter(events))) - } - } - - fn streaming_json_mock_client(deltas: Vec<&str>) -> Arc { - let mut providers: HashMap> = HashMap::new(); - providers.insert( - "mock".to_string(), - Arc::new(StreamingJsonMockProvider::new(deltas)), - ); - Arc::new(Client::new(providers, Some("mock".to_string()), vec![])) - } - - #[tokio::test] - async fn stream_object_yields_complete_event() { - let client = streaming_json_mock_client(vec![r#"{"name": "Alice", "age": 30}"#]); - - let schema = serde_json::json!({ - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"} - }, - "required": ["name", "age"] - }); - - let obj_stream = stream_object( - GenerateParams::new("mock-model", client).prompt("Extract info"), - schema, - ) - .await - .unwrap(); - - let events: Vec = obj_stream - .filter_map(|r| future::ready(r.ok())) - .collect() - .await; - - let complete = events - .iter() - .find(|e| matches!(e, ObjectStreamEvent::Complete { .. })); - assert!(complete.is_some(), "Expected a Complete event"); - - if let ObjectStreamEvent::Complete { object, .. } = complete.unwrap() { - assert_eq!(object["name"], "Alice"); - assert_eq!(object["age"], 30); - } - } - - #[tokio::test] - async fn stream_object_yields_partial_events_incrementally() { - let client = - streaming_json_mock_client(vec![r#"{"name""#, r#": "Bob""#, r#", "age": 25}"#]); - - let schema = serde_json::json!({ - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"} - } - }); - - let obj_stream = stream_object( - GenerateParams::new("mock-model", client).prompt("Extract info"), - schema, - ) - .await - .unwrap(); - - let events: Vec = obj_stream - .filter_map(|r| future::ready(r.ok())) - .collect() - .await; - - let partial_count = events - .iter() - .filter(|e| matches!(e, ObjectStreamEvent::Partial { .. })) - .count(); - - assert!( - partial_count >= 1, - "Expected at least one Partial event, got {partial_count}" - ); - - let delta_count = events - .iter() - .filter(|e| matches!(e, ObjectStreamEvent::Delta { .. })) - .count(); - - assert_eq!(delta_count, 3); - - let last_complete = events - .iter() - .rev() - .find(|e| matches!(e, ObjectStreamEvent::Complete { .. })); - assert!(last_complete.is_some(), "Expected a Complete event"); - if let ObjectStreamEvent::Complete { object, .. } = last_complete.unwrap() { - assert_eq!(object["name"], "Bob"); - assert_eq!(object["age"], 25); - } - } - - #[tokio::test] - async fn stream_object_errors_on_invalid_final_json() { - let client = streaming_json_mock_client(vec![r#"{"name": "Alice"#]); - - let schema = serde_json::json!({"type": "object"}); - - let obj_stream = stream_object( - GenerateParams::new("mock-model", client).prompt("Extract info"), - schema, - ) - .await - .unwrap(); - - let results: Vec> = obj_stream.collect().await; - - let has_error = results.iter().any(std::result::Result::is_err); - assert!(has_error, "Expected an error for invalid final JSON"); - } - - #[tokio::test] - async fn generate_abort_signal_before_call() { - let token = CancellationToken::new(); - token.cancel(); - - let result = generate( - GenerateParams::new("mock-model", mock_client("Hi")) - .prompt("Hello") - .abort_signal(token), - ) - .await; - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), Error::Interrupt { .. })); - } - - #[tokio::test] - async fn generate_abort_signal_between_tool_rounds() { - // Provider that always returns tool calls - struct AlwaysToolCallProvider { - call_count: Arc, - cancel_token: CancellationToken, - } - - #[async_trait::async_trait] - impl ProviderAdapter for AlwaysToolCallProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - let count = self.call_count.fetch_add(1, Ordering::SeqCst); - // Cancel after first call completes - if count == 0 { - self.cancel_token.cancel(); - } - Ok(Response { - id: format!("resp_{count}"), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - format!("call_{count}"), - "get_weather", - serde_json::json!({"city": "SF"}), - ))], - 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, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - Ok(Box::pin(stream::empty())) - } - } - - let call_count = Arc::new(AtomicU32::new(0)); - let token = CancellationToken::new(); - let token_clone = token.clone(); - - let provider: Arc = Arc::new(AlwaysToolCallProvider { - call_count: call_count.clone(), - cancel_token: token_clone, - }); - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let result = generate( - GenerateParams::new("mock-model", client) - .prompt("What's the weather?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(10) - .abort_signal(token), - ) - .await; - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), Error::Interrupt { .. })); - // Should have only made 1 call before aborting - assert_eq!(call_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn stream_abort_signal_terminates_stream() { - let token = CancellationToken::new(); - let token_clone = token.clone(); - - // Create a mock that produces events, but cancel after stream starts - let client = mock_client("Hello stream!"); - token_clone.cancel(); - - let mut stream_result = stream( - GenerateParams::new("mock-model", client) - .prompt("Hi") - .abort_signal(token), - ) - .await - .unwrap(); - - let first = stream_result.next().await.unwrap(); - assert!(first.is_err()); - assert!(matches!(first.unwrap_err(), Error::Interrupt { .. })); - } - - #[tokio::test] - async fn generate_max_tool_rounds_zero_skips_tool_execution() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(ToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let tool_executed = Arc::new(AtomicU32::new(0)); - let tool_executed_clone = tool_executed.clone(); - - let result = generate( - GenerateParams::new("mock-model", client) - .prompt("What's the weather in SF?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - move |_args, _ctx| { - let counter = tool_executed_clone.clone(); - async move { - counter.fetch_add(1, Ordering::SeqCst); - Ok(serde_json::json!("72F")) - } - }, - )]) - .max_tool_rounds(0), - ) - .await - .unwrap(); - - // Should return after first LLM call without executing any tools - assert_eq!(result.steps.len(), 1); - assert_eq!(call_count.load(Ordering::SeqCst), 1); - assert_eq!(tool_executed.load(Ordering::SeqCst), 0); - // The tool results should be empty since tools were not executed - assert!(result.tool_results.is_empty()); - } - - #[test] - fn generate_params_abort_signal_builder() { - let token = CancellationToken::new(); - let params = GenerateParams::new("test-model", mock_client("abort")).abort_signal(token); - assert!(params.abort_signal.is_some()); - } - - #[tokio::test] - async fn stream_result_accumulates_response() { - let client = mock_client("Hello!"); - let mut result = stream(GenerateParams::new("mock-model", client).prompt("Hi")) - .await - .unwrap(); - - assert!(result.response().is_none()); - assert!(result.partial_response().is_none()); - - // Consume all events - while result.next().await.is_some() {} - - assert!(result.response().is_some()); - assert_eq!(result.response().unwrap().text(), "Hello!"); - } - - #[tokio::test] - async fn stream_result_text_stream() { - let client = streaming_json_mock_client(vec!["Hello", " ", "world"]); - let result = stream(GenerateParams::new("mock-model", client).prompt("Hi")) - .await - .unwrap(); - - let texts: Vec = result - .text_stream() - .filter_map(|r| future::ready(r.ok())) - .collect() - .await; - - assert_eq!(texts, vec!["Hello", " ", "world"]); - } - - /// Mock provider that streams tool calls then text on second stream - struct StreamingToolCallMockProvider { - call_count: Arc, - } - - #[async_trait::async_trait] - impl ProviderAdapter for StreamingToolCallMockProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant("fallback"), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - let count = self.call_count.fetch_add(1, Ordering::SeqCst); - - if count == 0 { - // First stream: return tool call - let tool_call = - ToolCall::new("call_1", "get_weather", serde_json::json!({"city": "SF"})); - let response = Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tool_call.clone())], - 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, - }; - let events = vec![ - Ok(StreamEvent::ToolCallEnd { tool_call }), - Ok(StreamEvent::finish( - FinishReason::ToolCalls, - response.usage.clone(), - response, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } else { - // Second stream: return text - let text = "The weather in SF is 72F"; - let response = Response { - id: "resp_2".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 20, - output_tokens: 10, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - let events = vec![ - Ok(StreamEvent::text_delta(text, Some("t1".into()))), - Ok(StreamEvent::finish( - FinishReason::Stop, - response.usage.clone(), - response, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } - } - } - - #[tokio::test] - async fn stream_with_tool_loop_executes_tools() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(StreamingToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("What's the weather in SF?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(5), - ) - .await - .unwrap(); - - // Collect all events - let mut events = Vec::new(); - while let Some(item) = result.next().await { - events.push(item); - } - - // Should have events from both rounds - assert_eq!(call_count.load(Ordering::SeqCst), 2); - - // Should have text deltas from the second round - let text_deltas: Vec<_> = events - .iter() - .filter_map(|e| match e { - Ok(StreamEvent::TextDelta { delta, .. }) => Some(delta.as_str()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["The weather in SF is 72F"]); - - // The final response should be the text response - assert!(result.response().is_some()); - assert_eq!( - result.response().unwrap().text(), - "The weather in SF is 72F" - ); - } - - #[tokio::test] - async fn stream_no_tool_loop_when_max_rounds_zero() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(StreamingToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("What's the weather?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(0), - ) - .await - .unwrap(); - - // Consume all events - while result.next().await.is_some() {} - - // Only one stream call, no tool execution - assert_eq!(call_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn stream_accumulator_handles_step_finish() { - let mut acc = StreamAccumulator::new(); - - let response = Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant("tool step"), - 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, - }; - - let tool_calls = vec![ToolCall::new( - "call_1", - "get_weather", - serde_json::json!({"city": "SF"}), - )]; - - let tool_results = vec![ToolResult::success("call_1", serde_json::json!("72F"))]; - - // Processing StepFinish should not panic and should not set the final response - acc.process(&StreamEvent::step_finish( - FinishReason::ToolCalls, - response.usage.clone(), - response, - tool_calls, - tool_results, - )); - - // StepFinish should not set the final response (only Finish does that) - assert!(acc.response().is_none()); - assert_eq!(acc.text(), ""); - } - - #[tokio::test] - async fn stream_with_tool_loop_emits_step_finish() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(StreamingToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("What's the weather in SF?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(5), - ) - .await - .unwrap(); - - let mut events = Vec::new(); - while let Some(item) = result.next().await { - events.push(item); - } - - // Should have a StepFinish event between the tool call round and text round - let step_finish_count = events - .iter() - .filter(|e| matches!(e, Ok(StreamEvent::StepFinish { .. }))) - .count(); - assert_eq!( - step_finish_count, 1, - "Expected exactly one StepFinish event" - ); - - // Verify StepFinish contents - let step_finish = events - .iter() - .find_map(|e| match e { - Ok(StreamEvent::StepFinish { - finish_reason, - tool_calls, - tool_results, - .. - }) => Some((finish_reason, tool_calls, tool_results)), - _ => None, - }) - .expect("StepFinish event should exist"); - - assert_eq!(*step_finish.0, FinishReason::ToolCalls); - assert_eq!(step_finish.1.len(), 1); - assert_eq!(step_finish.1[0].name, "get_weather"); - assert_eq!(step_finish.2.len(), 1); - assert_eq!(step_finish.2[0].tool_call_id, "call_1"); - } - - #[tokio::test] - async fn stream_stop_when_halts_streaming_tool_loop() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(StreamingToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("What's the weather in SF?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(5) - .stop_when(|_steps| true), // Stop immediately after first round - ) - .await - .unwrap(); - - let mut events = Vec::new(); - while let Some(item) = result.next().await { - events.push(item); - } - - // stop_when returned true, so only 1 stream call should have been made - assert_eq!(call_count.load(Ordering::SeqCst), 1); - - // Should have a StepFinish event but no second round text - let step_finish_count = events - .iter() - .filter(|e| matches!(e, Ok(StreamEvent::StepFinish { .. }))) - .count(); - assert_eq!( - step_finish_count, 1, - "Expected StepFinish event from stopped round" - ); - - // Should NOT have any text deltas (second round never started) - let text_delta_count = events - .iter() - .filter(|e| matches!(e, Ok(StreamEvent::TextDelta { .. }))) - .count(); - assert_eq!( - text_delta_count, 0, - "Expected no text deltas since loop was stopped" - ); - } - - /// Mock provider that fails on stream N times then succeeds - struct FailThenStreamProvider { - call_count: Arc, - failures: u32, - } - - #[async_trait::async_trait] - impl ProviderAdapter for FailThenStreamProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant("fallback"), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - let count = self.call_count.fetch_add(1, Ordering::SeqCst); - - if count < self.failures { - return Err(Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail { - status_code: Some(500), - ..ProviderErrorDetail::new("server error", "mock") - }), - }); - } - - let text = "Hello after retry"; - let response = Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - let events = vec![ - Ok(StreamEvent::text_delta(text, Some("t1".into()))), - Ok(StreamEvent::finish( - FinishReason::Stop, - response.usage.clone(), - response, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } - } - - #[tokio::test] - async fn stream_retry_on_initial_connection() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(FailThenStreamProvider { - call_count: call_count.clone(), - failures: 2, // fail twice, succeed on third - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - // Need active tools so the tool loop path (with retry) is used - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("Hi") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(1) - .max_retries(3), - ) - .await - .unwrap(); - - let mut events = Vec::new(); - while let Some(item) = result.next().await { - events.push(item); - } - - // Should have called stream 3 times (2 failures + 1 success) - assert_eq!(call_count.load(Ordering::SeqCst), 3); - - // Should have received the text from the successful attempt - let text_deltas: Vec<_> = events - .iter() - .filter_map(|e| match e { - Ok(StreamEvent::TextDelta { delta, .. }) => Some(delta.as_str()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["Hello after retry"]); - } - - /// Mock provider that delays before returning stream - struct SlowStreamProvider { - delay: std::time::Duration, - } - - #[async_trait::async_trait] - impl ProviderAdapter for SlowStreamProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant("fallback"), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - sleep(self.delay).await; - let text = "Slow response"; - let response = Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(text), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - let events = vec![ - Ok(StreamEvent::text_delta(text, Some("t1".into()))), - Ok(StreamEvent::finish( - FinishReason::Stop, - TokenCounts::default(), - response, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } - } - - #[tokio::test] - async fn stream_per_step_timeout() { - let provider: Arc = Arc::new(SlowStreamProvider { - delay: std::time::Duration::from_secs(5), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - // Need active tools so the tool loop path (with timeout) is used - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("Hi") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(1) - .timeout(TimeoutOptions { - total: None, - per_step: Some(0.01), // 10ms timeout, provider takes 5s - }) - .max_retries(0), - ) - .await - .unwrap(); - - let mut events = Vec::new(); - while let Some(item) = result.next().await { - events.push(item); - } - - // Should have received a timeout error - let has_timeout = events - .iter() - .any(|e| matches!(e, Err(Error::RequestTimeout { .. }))); - assert!(has_timeout, "Expected a RequestTimeout error"); - } - - #[tokio::test] - async fn stream_total_timeout() { - // Use a streaming tool call provider with a slow tool to trigger total timeout - // across multiple rounds - /// Provider that always returns tool calls with a delay on the second - /// stream - struct SlowToolCallStreamProvider { - call_count: Arc, - } - - #[async_trait::async_trait] - impl ProviderAdapter for SlowToolCallStreamProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant("fallback"), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - let count = self.call_count.fetch_add(1, Ordering::SeqCst); - - if count == 0 { - // First stream: return tool call quickly - let tool_call = - ToolCall::new("call_1", "get_weather", serde_json::json!({"city": "SF"})); - let response = Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tool_call.clone())], - 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, - }; - let events = vec![ - Ok(StreamEvent::ToolCallEnd { tool_call }), - Ok(StreamEvent::finish( - FinishReason::ToolCalls, - TokenCounts::default(), - response, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } else { - // Second stream: delay long enough to exceed total timeout - sleep(std::time::Duration::from_secs(5)).await; - let text = "Should not arrive"; - let response = Response { - id: "resp_2".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(text), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - let events = vec![ - Ok(StreamEvent::text_delta(text, Some("t1".into()))), - Ok(StreamEvent::finish( - FinishReason::Stop, - TokenCounts::default(), - response, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } - } - } - - let call_count = Arc::new(AtomicU32::new(0)); - - let provider: Arc = Arc::new(SlowToolCallStreamProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("What's the weather?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(5) - .timeout(TimeoutOptions { - total: Some(0.05), // 50ms total timeout - per_step: None, - }) - .max_retries(0), - ) - .await - .unwrap(); - - let mut events = Vec::new(); - while let Some(item) = result.next().await { - events.push(item); - } - - // Should have received a total timeout error - let has_timeout = events - .iter() - .any(|e| matches!(e, Err(Error::RequestTimeout { .. }))); - assert!( - has_timeout, - "Expected a RequestTimeout error from total timeout" - ); - } -} diff --git a/lib/components/fabro-llm/src/lib.rs b/lib/components/fabro-llm/src/lib.rs index 0b33bfece..31cd9d74b 100644 --- a/lib/components/fabro-llm/src/lib.rs +++ b/lib/components/fabro-llm/src/lib.rs @@ -1,23 +1,53 @@ -pub mod adapter_registry; -mod attachments; -pub mod client; -mod codec; -pub(crate) mod cost; -pub mod error; -pub mod generate; -pub mod middleware; -pub mod model_test; -pub mod provider; -pub mod providers; -mod reasoning; -pub mod retry; -pub mod token_count; -pub mod tools; -pub(crate) mod transport; -pub mod types; +//! Fabro's integration layer over [`lithos_llm`]. +//! +//! lithos owns the LLM vocabulary, the provider catalog, the wire codecs, and +//! the client. This crate adds what is specific to Fabro: +//! +//! - building the catalog from the lithos built-ins and the operator `[llm]` +//! overlay, and the catalog queries Fabro's dispatch boundaries share +//! ([`catalog`]); +//! - Fabro's passthrough policy for selections made before a request exists +//! ([`selection`]); at request time the lithos resolver enforces `enabled` +//! and `stands_in_for` itself; +//! - constructing a client from a Fabro credential source ([`client`]); +//! - inlining local file attachments ([`attachments`]); +//! - normalizing readable reasoning into [`fabro_types::ReasoningOutput`] +//! ([`reasoning`]); +//! - one-shot structured output ([`structured`]); +//! - model and provider probes ([`probe`]), and the API views of the catalog +//! ([`api`]); +//! - the `fabro exec` gateway adapter that speaks to a Fabro server +//! ([`gateway`]); +//! - error classification for retries, failover, and failure signatures +//! ([`error`]). -pub use error::{Error, ProviderErrorDetail, ProviderErrorKind, Result}; -pub use fabro_model::{ModelHandle, ProviderId}; -pub use token_count::{ - InputTokenCount, InputTokenCountMethod, InputTokenCountPreference, estimate_input_tokens, +pub mod api; +pub mod attachments; +pub mod catalog; +pub mod client; +pub mod error; +pub mod gateway; +pub mod probe; +pub mod reasoning; +pub mod selection; +pub mod structured; +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; + +pub use catalog::{build_catalog, default_catalog}; +pub use client::{ + ClientOptions, FabroClient, LlmSetupError, RetryListener, RetryNotice, build_client, + build_offline_client, }; +pub use error::{ErrorFacts, LlmError}; +pub use lithos_llm::client::{Client, ClientBuild}; +pub use lithos_llm::middleware::{CallContext, CancellationToken, RetryPolicy, RetryStage}; +pub use lithos_llm::resolver::ModelSelectionError as RouteSelectionError; +pub use lithos_llm::types::{ + Error, ErrorData, ErrorKind, FinishReason, Request, Response, ResponseStream, + RetryClassification, StreamEvent, +}; +pub use lithos_llm::{ + adapter, catalog as lithos_catalog, credentials, estimate, middleware, types, +}; +pub use selection::{FallbackTarget, ModelSelectionError, SelectedModel}; diff --git a/lib/components/fabro-llm/src/middleware.rs b/lib/components/fabro-llm/src/middleware.rs deleted file mode 100644 index ba8fef42d..000000000 --- a/lib/components/fabro-llm/src/middleware.rs +++ /dev/null @@ -1,31 +0,0 @@ -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - -use crate::error::Error; -use crate::provider::StreamEventStream; -use crate::types::{Request, Response}; - -/// The next handler in the middleware chain. -pub type NextFn = Arc< - dyn Fn(Request) -> Pin> + Send>> + Send + Sync, ->; - -/// The next handler for streaming. -pub type NextStreamFn = Arc< - dyn Fn(Request) -> Pin> + Send>> - + Send - + Sync, ->; - -/// Middleware for intercepting `complete()` and streaming calls (Section 2.3). -#[async_trait::async_trait] -pub trait Middleware: Send + Sync { - async fn handle_complete(&self, request: Request, next: NextFn) -> Result; - - async fn handle_stream( - &self, - request: Request, - next: NextStreamFn, - ) -> Result; -} diff --git a/lib/components/fabro-llm/src/model_test.rs b/lib/components/fabro-llm/src/model_test.rs deleted file mode 100644 index 7498e5e64..000000000 --- a/lib/components/fabro-llm/src/model_test.rs +++ /dev/null @@ -1,400 +0,0 @@ -use std::future::Future; -use std::sync::Arc; -use std::time::Duration; - -use fabro_model::Model; -pub use fabro_model::ModelTestMode; -use strum::IntoStaticStr; -use tokio::time; - -use crate::client::Client; -use crate::generate::{self, GenerateParams}; -use crate::tools::Tool; -use crate::types::{GenerateResult, ReasoningEffort}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] -#[strum(serialize_all = "lowercase")] -pub enum ModelTestStatus { - Ok, - Error, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ModelTestOutcome { - pub status: ModelTestStatus, - pub error_message: Option, -} - -impl ModelTestOutcome { - #[must_use] - pub fn ok() -> Self { - Self { - status: ModelTestStatus::Ok, - error_message: None, - } - } - - #[must_use] - pub fn error(message: impl Into) -> Self { - Self { - status: ModelTestStatus::Error, - error_message: Some(message.into()), - } - } -} - -pub async fn run_model_test( - info: &Model, - mode: ModelTestMode, - reasoning_effort: Option, - client: Arc, -) -> ModelTestOutcome { - match mode { - ModelTestMode::Basic => run_basic_test(info, reasoning_effort, client).await, - ModelTestMode::Deep => run_tools_test(info, reasoning_effort, client).await, - } -} - -/// Output budget for tests where reasoning or tool rounds consume completion -/// tokens before the final answer. -const EXPANDED_MAX_TOKENS: i64 = 1024; - -async fn run_basic_test( - info: &Model, - reasoning_effort: Option, - client: Arc, -) -> ModelTestOutcome { - basic_probe( - info.id.as_str(), - info.provider.to_string(), - reasoning_effort, - client, - Duration::from_secs(ModelTestMode::Basic.timeout_secs()), - ) - .await -} - -/// Run the cheap single-prompt model availability probe without requiring a -/// catalog-backed [`Model`]. -pub async fn run_basic_model_probe( - model_id: &str, - provider: impl ToString, - client: Arc, -) -> ModelTestOutcome { - run_basic_model_probe_with_timeout( - model_id, - provider, - client, - Duration::from_secs(ModelTestMode::Basic.timeout_secs()), - ) - .await -} - -pub async fn run_basic_model_probe_with_timeout( - model_id: &str, - provider: impl ToString, - client: Arc, - probe_timeout: Duration, -) -> ModelTestOutcome { - basic_probe(model_id, provider.to_string(), None, client, probe_timeout).await -} - -async fn basic_probe( - model_id: &str, - provider: String, - reasoning_effort: Option, - client: Arc, - probe_timeout: Duration, -) -> ModelTestOutcome { - let params = build_basic_test_params(model_id, provider, reasoning_effort, client); - basic_model_probe_outcome(generate::generate(params), probe_timeout).await -} - -fn build_basic_test_params( - model_id: &str, - provider: String, - reasoning_effort: Option, - client: Arc, -) -> GenerateParams { - let max_tokens = if reasoning_effort.is_some() { - EXPANDED_MAX_TOKENS - } else { - 16 - }; - let mut params = GenerateParams::new(model_id, client) - .provider(provider) - .prompt("Say OK") - .max_tokens(max_tokens); - - if let Some(reasoning_effort) = reasoning_effort { - params = params.reasoning_effort(reasoning_effort); - } - - params -} - -async fn basic_model_probe_outcome(probe: F, probe_timeout: Duration) -> ModelTestOutcome -where - F: Future>, -{ - match time::timeout(probe_timeout, probe).await { - Ok(Ok(_)) => ModelTestOutcome::ok(), - Ok(Err(err)) => ModelTestOutcome::error(err.to_string()), - Err(_) => ModelTestOutcome::error(format!("timeout ({probe_timeout:?})")), - } -} - -async fn run_tools_test( - info: &Model, - reasoning_effort: Option, - client: Arc, -) -> ModelTestOutcome { - let Some(params) = build_tools_test_params(info, reasoning_effort, client) else { - return ModelTestOutcome::error("model does not support tools"); - }; - - let result = time::timeout( - Duration::from_secs(ModelTestMode::Deep.timeout_secs()), - generate::generate(params), - ) - .await; - - match result { - Ok(Ok(gen_result)) => match validate_tools_result(&gen_result) { - Ok(()) => ModelTestOutcome::ok(), - Err(message) => ModelTestOutcome::error(message), - }, - Ok(Err(err)) => ModelTestOutcome::error(err.to_string()), - Err(_) => ModelTestOutcome::error("timeout (90s)"), - } -} - -fn build_tools_test_params( - info: &Model, - reasoning_effort: Option, - client: Arc, -) -> Option { - if !info.features.tools { - return None; - } - - let add_tool = Tool::active( - "add", - "Add two integers and return the sum", - serde_json::json!({ - "type": "object", - "properties": { - "a": { "type": "integer", "description": "First number" }, - "b": { "type": "integer", "description": "Second number" } - }, - "required": ["a", "b"] - }), - |args, _ctx| async move { - let a = args - .get("a") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - let b = args - .get("b") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - Ok(serde_json::json!(a + b)) - }, - ); - - let mut params = GenerateParams::new(info.id.to_string(), client) - .provider(info.provider.to_string()) - .prompt( - "Use the add tool twice: first add 15 and 27, then add that result to 42. \ - Finally, tell me whether the grand total is even or odd and why.", - ) - .tools(vec![add_tool]) - .max_tool_rounds(5) - .max_tokens(EXPANDED_MAX_TOKENS); - - if let Some(reasoning_effort) = reasoning_effort { - params = params.reasoning_effort(reasoning_effort); - } - - Some(params) -} - -fn validate_tools_result(result: &GenerateResult) -> Result<(), String> { - if result.steps.len() < 2 { - return Err("model did not call tool".to_string()); - } - - if result.steps[0].tool_results.is_empty() { - return Err("tool was not executed".to_string()); - } - - if !result.response.text().contains("84") { - return Err("wrong answer".to_string()); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use fabro_model::{ - ModelControls, ModelCosts, ModelFeatures, ModelLimits, ProviderId, ReasoningEffortFeature, - }; - - use super::*; - use crate::types::{FinishReason, Message, Response, StepResult, TokenCounts, ToolResult}; - - fn test_model_with(features: ModelFeatures) -> Model { - Model { - id: "test-model".into(), - provider: ProviderId::anthropic(), - family: "test".to_string(), - display_name: "Test Model".to_string(), - limits: ModelLimits { - context_window: 200_000, - max_output: Some(8_000), - }, - training: None, - knowledge_cutoff: None, - features, - controls: ModelControls::default(), - costs: ModelCosts { - input_cost_per_mtok: None, - output_cost_per_mtok: None, - cache_input_cost_per_mtok: None, - }, - estimated_output_tps: None, - aliases: vec![], - default: false, - small_default: false, - configured: false, - } - } - - fn response_with_text(text: &str) -> Response { - Response { - id: "resp_1".to_string(), - model: "test-model".to_string(), - provider: "anthropic".to_string(), - message: Message::assistant(text), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - } - } - - fn empty_test_client() -> Arc { - Arc::new(Client::new(HashMap::new(), None, vec![])) - } - - #[tokio::test] - async fn run_model_test_tools_errors_when_model_lacks_tools() { - let info = test_model_with(ModelFeatures { - tools: false, - vision: false, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: false, - cache_control_breakpoints: false, - sampling_params: true, - }); - - let outcome = run_model_test(&info, ModelTestMode::Deep, None, empty_test_client()).await; - - assert_eq!(outcome.status, ModelTestStatus::Error); - assert_eq!( - outcome.error_message.as_deref(), - Some("model does not support tools") - ); - } - - #[tokio::test] - async fn basic_model_probe_reports_configured_timeout() { - let outcome = basic_model_probe_outcome( - std::future::pending::>(), - Duration::from_millis(1), - ) - .await; - - assert_eq!(outcome.status, ModelTestStatus::Error); - assert_eq!(outcome.error_message.as_deref(), Some("timeout (1ms)")); - } - - #[test] - fn basic_test_expands_output_budget_for_reasoning() { - let params = build_basic_test_params( - "test-model", - "anthropic".to_string(), - Some(ReasoningEffort::Max), - empty_test_client(), - ); - - assert_eq!(params.reasoning_effort, Some(ReasoningEffort::Max)); - assert_eq!(params.max_tokens, Some(1024)); - } - - #[test] - fn tools_test_omits_effort_when_not_requested() { - let info = test_model_with(ModelFeatures { - tools: true, - vision: false, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }); - - let params = build_tools_test_params(&info, None, empty_test_client()) - .expect("tool-capable model should produce tools-test params"); - - assert_eq!(params.reasoning_effort, None); - } - - #[test] - fn tools_test_uses_requested_effort() { - let info = test_model_with(ModelFeatures { - tools: true, - vision: false, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }); - - let params = - build_tools_test_params(&info, Some(ReasoningEffort::Low), empty_test_client()) - .expect("tool-capable model should produce tools-test params"); - - assert_eq!(params.reasoning_effort, Some(ReasoningEffort::Low)); - } - - #[test] - fn validate_tools_result_does_not_fail_only_for_missing_reasoning() { - let tool_results = vec![ToolResult::success("call_1", serde_json::json!(42))]; - let first_step = StepResult { - response: response_with_text("tool step"), - tool_results: tool_results.clone(), - }; - let second_step = StepResult { - response: response_with_text("84 is even"), - tool_results: vec![], - }; - let result = GenerateResult { - response: response_with_text("84 is even"), - tool_results, - total_usage: TokenCounts::default(), - steps: vec![first_step, second_step], - output: None, - }; - - assert_eq!(validate_tools_result(&result), Ok(())); - } -} diff --git a/lib/components/fabro-llm/src/probe.rs b/lib/components/fabro-llm/src/probe.rs new file mode 100644 index 000000000..1e2cbcb25 --- /dev/null +++ b/lib/components/fabro-llm/src/probe.rs @@ -0,0 +1,129 @@ +//! Model and provider probes for the server's test endpoints. + +use std::sync::Arc; +use std::time::Duration; + +use fabro_auth::ApiKeyCredentialSource; +use fabro_types::{ModelTestMode, ProviderId, ReasoningEffort}; +use lithos_llm::catalog::Catalog; +use lithos_llm::client::{Client, ProbeOptions, ProbeOutcome}; +use strum::IntoStaticStr; + +use crate::catalog; +use crate::client::{ClientOptions, LlmSetupError, build_client}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] +#[strum(serialize_all = "lowercase")] +pub enum ModelTestStatus { + Ok, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelTestOutcome { + pub status: ModelTestStatus, + pub error_message: Option, +} + +impl ModelTestOutcome { + #[must_use] + pub fn ok() -> Self { + Self { + status: ModelTestStatus::Ok, + error_message: None, + } + } + + #[must_use] + pub fn error(message: impl Into) -> Self { + Self { + status: ModelTestStatus::Error, + error_message: Some(message.into()), + } + } +} + +/// Probes `selector` (a `provider/model` route or bare selector) in `mode`. +/// +/// `Basic` asks for one word; `Deep` runs a two-step tool exchange and checks +/// the total. The probe resolves, authenticates, and retries exactly as a +/// real request would. +pub async fn run_model_test( + client: &Client, + selector: &str, + mode: ModelTestMode, + reasoning_effort: Option, + timeout: Option, +) -> ModelTestOutcome { + let mut options = ProbeOptions::new() + .tools(mode == ModelTestMode::Deep) + .timeout(timeout.unwrap_or_else(|| Duration::from_secs(mode.timeout_secs()))); + if let Some(effort) = reasoning_effort { + options = options.reasoning_effort(effort); + } + let report = client.probe(selector, options).await; + match report.outcome { + ProbeOutcome::Passed => ModelTestOutcome::ok(), + ProbeOutcome::Failed(data) => ModelTestOutcome::error(data.message), + ProbeOutcome::Incorrect { detail } => ModelTestOutcome::error(detail), + _ => ModelTestOutcome::error("probe ended in an unknown state"), + } +} + +/// A basic probe of `selector` bounded by `timeout`. +pub async fn run_basic_probe( + client: &Client, + selector: &str, + timeout: Duration, +) -> ModelTestOutcome { + run_model_test(client, selector, ModelTestMode::Basic, None, Some(timeout)).await +} + +/// Why an API key could not be probed. +#[derive(Debug, thiserror::Error)] +pub enum ApiKeyProbeError { + #[error("provider '{0}' is not configured in the model catalog")] + UnknownProvider(String), + #[error("provider '{0}' does not define an API-key credential path")] + NoApiKeyPath(ProviderId), + #[error("provider '{0}' does not define a probe model")] + NoProbeModel(ProviderId), + #[error(transparent)] + Setup(#[from] LlmSetupError), +} + +/// Probes `provider` with an operator-supplied `api_key`, the check behind +/// `fabro provider login`, the install flow, and the credential test API. +/// +/// The key is shaped into the provider's auth scheme and used for the +/// provider's probe model. The result says whether the key works; the caller +/// decides whether to store it. +pub async fn probe_provider_with_api_key( + catalog: Catalog, + provider: &ProviderId, + api_key: String, + timeout: Duration, +) -> Result { + let catalog_provider = catalog::provider(&catalog, provider.as_str()) + .ok_or_else(|| ApiKeyProbeError::UnknownProvider(provider.to_string()))?; + let provider_id = catalog_provider.id().clone(); + if !fabro_auth::accepts_api_key(catalog_provider) { + return Err(ApiKeyProbeError::NoApiKeyPath(provider_id)); + } + let model = catalog::probe_model(&catalog, provider_id.as_str()) + .ok_or_else(|| ApiKeyProbeError::NoProbeModel(provider_id.clone()))?; + let selector = format!("{provider_id}/{}", model.model.id()); + let source = Arc::new(ApiKeyCredentialSource::new(provider_id.clone(), api_key)); + let built = build_client(catalog, source, ClientOptions::standard()).await?; + if let Some((_, issue)) = built + .auth_issues + .iter() + .find(|(candidate, _)| candidate == &provider_id) + { + return Ok(ModelTestOutcome::error(fabro_auth::auth_issue_message( + &provider_id, + issue, + ))); + } + Ok(run_basic_probe(&built.client, &selector, timeout).await) +} diff --git a/lib/components/fabro-llm/src/provider.rs b/lib/components/fabro-llm/src/provider.rs deleted file mode 100644 index b49a70cdd..000000000 --- a/lib/components/fabro-llm/src/provider.rs +++ /dev/null @@ -1,185 +0,0 @@ -use std::pin::Pin; - -pub use fabro_model::{ModelHandle, ProviderId}; -use futures::Stream; - -use crate::error::Error; -use crate::token_count::InputTokenCount; -use crate::types::{Request, Response, Speed, StreamEvent, ToolChoice}; - -// --------------------------------------------------------------------------- -// ProviderAdapter trait -// --------------------------------------------------------------------------- - -/// Async stream of `StreamEvents` returned by streaming providers. -pub type StreamEventStream = Pin> + Send>>; - -/// The contract that every provider adapter must implement (Section 2.4). -#[async_trait::async_trait] -pub trait ProviderAdapter: Send + Sync { - /// Provider name, e.g. "openai", "anthropic", "gemini" - fn name(&self) -> &str; - - /// Send a request and block until the model finishes (Section 4.1). - async fn complete(&self, request: &Request) -> Result; - - /// Send a request and return an async stream of events (Section 4.2). - async fn stream(&self, request: &Request) -> Result; - - /// Count model-visible input/context tokens without creating a completion, - /// when the provider exposes a count endpoint. - async fn count_input_tokens( - &self, - _request: &Request, - ) -> Result, Error> { - Ok(None) - } - - /// Release resources. Called by `Client::close()`. - async fn close(&self) -> Result<(), Error> { - Ok(()) - } - - /// Validate configuration on startup. Called by Client on registration. - async fn initialize(&self) -> Result<(), Error> { - Ok(()) - } - - /// Query whether a particular tool choice mode is supported. - fn supports_tool_choice(&self, _mode: &str) -> bool { - true - } - - /// Validate the final request before dispatching it to the provider API. - fn validate_request(&self, request: &Request) -> Result<(), Error> { - if let Some(tool_choice) = &request.tool_choice { - let mode = tool_choice.mode_str(); - if !self.supports_tool_choice(mode) { - return Err(Error::UnsupportedToolChoice { - message: format!( - "provider '{}' does not support tool_choice mode '{mode}'", - self.name() - ), - }); - } - } - Ok(()) - } -} - -/// Validate that the adapter supports the requested tool choice mode. -/// -/// Returns `Err(Error::UnsupportedToolChoice)` if the adapter does not -/// support the given mode. -/// -/// # Errors -/// -/// Returns `Error::UnsupportedToolChoice` when the adapter does not -/// support the requested tool choice mode. -pub fn validate_tool_choice( - adapter: &dyn ProviderAdapter, - tool_choice: &ToolChoice, -) -> Result<(), Error> { - let mode = tool_choice.mode_str(); - if !adapter.supports_tool_choice(mode) { - return Err(Error::UnsupportedToolChoice { - message: format!( - "provider '{}' does not support tool_choice mode '{mode}'", - adapter.name() - ), - }); - } - Ok(()) -} - -/// Validate that an adapter without provider-native speed controls only sees -/// standard-speed requests. -pub fn validate_standard_speed( - adapter: &dyn ProviderAdapter, - request: &Request, -) -> Result<(), Error> { - if let Some(speed) = request.speed.filter(|speed| *speed != Speed::Standard) { - return Err(Error::Configuration { - message: format!( - "provider '{}' does not support speed '{}'", - adapter.name(), - speed - ), - source: None, - }); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // Mock adapter that supports all tool choices - struct MockAdapter; - - #[async_trait::async_trait] - impl ProviderAdapter for MockAdapter { - fn name(&self) -> &'static str { - "mock" - } - async fn complete(&self, _request: &Request) -> Result { - unimplemented!() - } - async fn stream(&self, _request: &Request) -> Result { - unimplemented!() - } - } - - // Mock adapter that rejects "named" tool choice - struct RestrictedAdapter; - - #[async_trait::async_trait] - impl ProviderAdapter for RestrictedAdapter { - fn name(&self) -> &'static str { - "restricted" - } - async fn complete(&self, _request: &Request) -> Result { - unimplemented!() - } - async fn stream(&self, _request: &Request) -> Result { - unimplemented!() - } - fn supports_tool_choice(&self, mode: &str) -> bool { - mode != "named" - } - } - - #[test] - fn validate_tool_choice_auto_accepted() { - assert!(validate_tool_choice(&MockAdapter, &ToolChoice::Auto).is_ok()); - } - - #[test] - fn validate_tool_choice_none_accepted() { - assert!(validate_tool_choice(&MockAdapter, &ToolChoice::None).is_ok()); - } - - #[test] - fn validate_tool_choice_required_accepted() { - assert!(validate_tool_choice(&MockAdapter, &ToolChoice::Required).is_ok()); - } - - #[test] - fn validate_tool_choice_named_rejected_by_restricted() { - let result = validate_tool_choice(&RestrictedAdapter, &ToolChoice::named("my_tool")); - assert!(result.is_err()); - match result.unwrap_err() { - Error::UnsupportedToolChoice { message } => { - assert!(message.contains("restricted")); - assert!(message.contains("named")); - } - other => panic!("expected UnsupportedToolChoice, got {other:?}"), - } - } - - #[test] - fn validate_tool_choice_named_accepted_by_default() { - assert!(validate_tool_choice(&MockAdapter, &ToolChoice::named("my_tool")).is_ok()); - } -} diff --git a/lib/components/fabro-llm/src/providers/anthropic.rs b/lib/components/fabro-llm/src/providers/anthropic.rs deleted file mode 100644 index 723e12441..000000000 --- a/lib/components/fabro-llm/src/providers/anthropic.rs +++ /dev/null @@ -1,414 +0,0 @@ -use std::sync::Arc; - -use fabro_model::{Catalog, ReasoningEffortFeature}; - -use crate::attachments::{self, AttachmentPolicy}; -use crate::codec::anthropic_messages::{AnthropicMessages, anthropic_option}; -use crate::codec::{AnthropicVersion, Codec, CodecCtx, CodecParams, EncodedRequest}; -use crate::error::Error; -use crate::provider::{self, ProviderAdapter, StreamEventStream}; -use crate::providers::common::{self as common, CatalogRoute}; -use crate::token_count::{InputTokenCount, InputTokenCountMethod}; -use crate::transport::{self, HttpTransport, SseFraming}; -use crate::types::{AdapterTimeout, Request, Response, StreamEvent}; - -const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1"; - -/// Provider adapter for the Anthropic Messages API. -/// -/// A thin transport shell over the `anthropic_messages` codec: it owns auth, -/// base URL, the streaming byte loop, and the route configuration that selects -/// between the direct-Anthropic and Kimi-over-anthropic behaviors. All wire -/// translation lives in the codec. -pub struct Adapter { - pub(crate) http: HttpTransport, - provider_name: String, - catalog: Option>, -} - -impl Adapter { - #[must_use] - pub fn new(api_key: impl Into) -> Self { - Self::new_optional_auth(Some(api_key.into())) - } - - #[must_use] - pub fn new_optional_auth(api_key: Option) -> Self { - Self { - http: HttpTransport::new_optional(api_key, DEFAULT_BASE_URL), - provider_name: "anthropic".to_string(), - catalog: None, - } - } - - #[must_use] - pub fn with_name(mut self, name: impl Into) -> Self { - self.provider_name = name.into(); - self - } - - #[must_use] - pub fn with_base_url(mut self, base_url: impl Into) -> Self { - self.http.base_url = base_url.into(); - self - } - - #[must_use] - pub fn with_default_headers(self, headers: std::collections::HashMap) -> Self { - Self { - http: self.http.with_default_headers(headers), - ..self - } - } - - #[must_use] - pub fn with_catalog(mut self, catalog: Arc) -> Self { - self.catalog = Some(catalog); - self - } - - #[must_use] - pub fn with_timeout(self, timeout: AdapterTimeout) -> Self { - Self { - http: self.http.with_timeout(timeout), - ..self - } - } - - /// Resolve the route configuration for this adapter. - /// - /// The direct-Anthropic route (`provider_name == "anthropic"`) - /// authenticates with `x-api-key`, emits the version + beta headers, - /// and supports the count-tokens endpoint. Every other name (e.g. - /// Kimi-over-anthropic) is a bearer-auth route with no anthropic - /// headers, no count-tokens route, and blocking requests served via - /// streaming. Resolved once here instead of string-comparing - /// `provider_name` at each request-time decision. - fn route_config(&self) -> RouteConfig { - if self.provider_name == "anthropic" { - RouteConfig { - auth: AuthScheme::ApiKey, - codec_params: CodecParams { - anthropic_version: AnthropicVersion::Header("2023-06-01"), - anthropic_beta: true, - ..CodecParams::default() - }, - supports_count_tokens: true, - force_streaming: false, - } - } else { - RouteConfig { - auth: AuthScheme::Bearer, - codec_params: CodecParams::default(), - supports_count_tokens: false, - force_streaming: true, - } - } - } - - /// Build the borrowed codec context. `deployment_id` and `params` are - /// created by the caller so their borrows outlive the context. - fn codec_ctx<'a>( - &'a self, - request: &'a Request, - deployment_id: &'a str, - params: &'a CodecParams, - ) -> CodecCtx<'a> { - CodecCtx { - request, - provider_name: &self.provider_name, - deployment_id, - model: self.catalog_model(&request.model), - params, - } - } - - /// Build the canonical request for the codec, resolving file-backed - /// attachments to inline data first. Borrowed when nothing needs loading. - async fn resolve_request<'a>(&self, request: &'a Request) -> std::borrow::Cow<'a, Request> { - // Anthropic loads images and documents inline; audio falls back to a - // text placeholder in the codec, so it is not loaded here. - let policy = AttachmentPolicy { - images: true, - documents: true, - audio: false, - }; - attachments::resolve(request, policy).await - } - - /// Apply the route base URL, auth, and codec-emitted dialect headers to an - /// encoded request. - fn build_http_request( - &self, - encoded: &EncodedRequest, - route: &RouteConfig, - ) -> fabro_http::RequestBuilder { - let url = format!("{}{}", self.http.base_url, encoded.endpoint); - let mut req = self.http.client.post(&url); - // default_headers first so codec/auth headers can override. - for (key, value) in &self.http.default_headers { - req = req.header(key, value); - } - match route.auth { - AuthScheme::ApiKey => { - if let Some(api_key) = &self.http.api_key { - req = req.header("x-api-key", api_key); - } - } - AuthScheme::Bearer => { - if let Some(api_key) = &self.http.api_key { - req = req.bearer_auth(api_key); - } - } - } - for (key, value) in &encoded.headers { - req = req.header(key, value); - } - req.json(&encoded.body) - } - - /// Collect a streaming response into a single [`Response`]. - /// - /// Used by non-Anthropic providers (e.g. Moonshot) that require - /// `stream=true`. - async fn complete_via_stream(&self, request: &Request) -> Result { - use futures::StreamExt; - - let mut stream = self.stream(request).await?; - let mut response: Option = None; - - while let Some(event) = stream.next().await { - if let StreamEvent::Finish { response: r, .. } = event? { - response = Some(*r); - } - } - - response.ok_or_else(|| Error::Stream { - message: "complete_via_stream: stream ended without a Finish event".to_string(), - source: None, - }) - } -} - -/// Resolved per-request routing decisions (auth, dialect headers, optional -/// routes) that used to be inline `provider_name == "anthropic"` branches. -struct RouteConfig { - auth: AuthScheme, - codec_params: CodecParams, - supports_count_tokens: bool, - force_streaming: bool, -} - -enum AuthScheme { - ApiKey, - Bearer, -} - -/// The `provider_options.anthropic.thinking.type` value, if any. -fn anthropic_thinking_type(provider_options: Option<&serde_json::Value>) -> Option<&str> { - anthropic_option(provider_options, "thinking") - .and_then(|thinking| thinking.get("type")) - .and_then(serde_json::Value::as_str) -} - -impl common::CatalogRoute for Adapter { - fn catalog(&self) -> Option<&Catalog> { - self.catalog.as_deref() - } - - fn provider_name(&self) -> &str { - &self.provider_name - } -} - -#[async_trait::async_trait] -impl ProviderAdapter for Adapter { - fn name(&self) -> &str { - &self.provider_name - } - - async fn count_input_tokens( - &self, - request: &Request, - ) -> Result, Error> { - let route = self.route_config(); - if !route.supports_count_tokens { - return Ok(None); - } - - self.validate_request(request)?; - let resolved = self.resolve_request(request).await; - let codec = AnthropicMessages; - let deployment_id = self.api_model_id(&resolved.model); - let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); - - let Some(encoded) = codec.encode_count_tokens(&ctx).transpose()? else { - return Ok(None); - }; - - let mut req = self.build_http_request(&encoded, &route); - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - let (body, _headers) = - transport::send_for_body(req, "input_token_count", &codec, &ctx).await?; - let input_tokens = codec.decode_count_tokens(&body)?; - - Ok(Some(InputTokenCount { - input_tokens, - method: InputTokenCountMethod::ProviderApi, - provider: self.provider_name.clone(), - model: request.model.clone(), - warnings: vec![], - })) - } - - async fn complete(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let route = self.route_config(); - // Non-Anthropic providers (e.g. Moonshot) require stream=true even for - // blocking calls. Collect the stream into a single Response. - if route.force_streaming { - return self.complete_via_stream(request).await; - } - - let resolved = self.resolve_request(request).await; - let codec = AnthropicMessages; - let deployment_id = self.api_model_id(&resolved.model); - let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); - - let encoded = codec.encode(&ctx, false)?; - let mut req = self.build_http_request(&encoded, &route); - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - transport::complete_via_http(req, &codec, &ctx).await - } - - async fn stream(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let route = self.route_config(); - let resolved = self.resolve_request(request).await; - let codec = AnthropicMessages; - let deployment_id = self.api_model_id(&resolved.model); - let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); - - let encoded = codec.encode(&ctx, true)?; - transport::stream_via_http( - self.build_http_request(&encoded, &route), - &codec, - &ctx, - SseFraming::EventBlocks, - self.http.stream_read_timeout, - ) - .await - } - - fn supports_tool_choice(&self, mode: &str) -> bool { - matches!(mode, "auto" | "none" | "required" | "named") - } - - fn validate_request(&self, request: &Request) -> Result<(), Error> { - if let Some(tool_choice) = &request.tool_choice { - provider::validate_tool_choice(self, tool_choice)?; - } - - // Always-adaptive models reject manual enabled/disabled thinking - // configs at the API, so fail them locally with a clear message - // instead. - let model_info = self.catalog_model(&request.model); - if let Some(model) = model_info - .filter(|m| m.features.reasoning_effort == ReasoningEffortFeature::AlwaysAdaptive) - { - if let Some(kind @ ("enabled" | "disabled")) = - anthropic_thinking_type(request.provider_options.as_ref()) - { - return Err(Error::Configuration { - message: format!( - "{} uses always-on adaptive thinking; provider_options.anthropic.thinking.type = \"{kind}\" is not supported. Omit thinking or set only display options.", - model.display_name() - ), - source: None, - }); - } - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use httpmock::prelude::*; - - use super::*; - use crate::token_count::InputTokenCountMethod; - use crate::types::{Message, ToolDefinition}; - - fn make_base_request() -> Request { - Request { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![Message::user("Hello")], - provider: Some("anthropic".to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: Some(128), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - #[test] - fn adapter_with_name() { - let adapter = Adapter::new("key").with_name("moonshot"); - assert_eq!(adapter.name(), "moonshot"); - } - - #[test] - fn adapter_default_name() { - let adapter = Adapter::new("key"); - assert_eq!(adapter.name(), "anthropic"); - } - - #[tokio::test] - async fn count_input_tokens_posts_count_request_and_parses_response() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST) - .path("/messages/count_tokens") - .header("x-api-key", "test-key") - .header("anthropic-version", "2023-06-01"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({"input_tokens": 123})); - }); - let adapter = Adapter::new("test-key").with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - tools: Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object"}), - )]), - ..make_base_request() - }; - - let count = adapter - .count_input_tokens(&request) - .await - .unwrap() - .expect("anthropic should count tokens"); - - mock.assert(); - assert_eq!(count.input_tokens, 123); - assert_eq!(count.method, InputTokenCountMethod::ProviderApi); - } -} diff --git a/lib/components/fabro-llm/src/providers/bedrock/eventstream.rs b/lib/components/fabro-llm/src/providers/bedrock/eventstream.rs deleted file mode 100644 index bb4015eff..000000000 --- a/lib/components/fabro-llm/src/providers/bedrock/eventstream.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! Decoder for Bedrock's `application/vnd.amazon.eventstream` streaming -//! responses. -//! -//! ConverseStream wraps each event in a binary event-stream frame: the event -//! name (`messageStart`, `contentBlockDelta`, `metadata`, ...) travels in the -//! frame's `:event-type` header and the payload is that event's JSON -//! directly. (The base64 `{"bytes": ...}` wrapping belongs to -//! `InvokeModelWithResponseStream`'s `PayloadPart` and does not apply here.) -//! Exception and error frames are surfaced as stream errors. - -use aws_smithy_eventstream::frame::{DecodedFrame, MessageFrameDecoder}; -use aws_smithy_types::event_stream::Message; -use aws_smithy_types::str_bytes::StrBytes; -use bytes::BytesMut; - -use crate::error::Error; - -/// One decoded ConverseStream event: the `:event-type` header value plus the -/// frame's JSON payload, ready to feed a stream decoder. -#[derive(Debug)] -pub(crate) struct DecodedEvent { - pub event_type: String, - pub payload: String, -} - -/// Incremental decoder over event-stream bytes. -pub(crate) struct FrameDecoder { - inner: MessageFrameDecoder, - buffer: BytesMut, -} - -impl FrameDecoder { - pub(crate) fn new() -> Self { - Self { - inner: MessageFrameDecoder::new(), - buffer: BytesMut::new(), - } - } - - /// Feed newly received bytes and return any complete events decoded from - /// them. Bedrock exception and error frames are surfaced as errors. - pub(crate) fn push(&mut self, bytes: &[u8]) -> Result, Error> { - self.buffer.extend_from_slice(bytes); - let mut events = Vec::new(); - loop { - // `decode_frame` advances `self.buffer` and retains partial-frame - // state internally, so repeated calls over a growing buffer work. - let frame = self.inner.decode_frame(&mut self.buffer).map_err(|e| { - Error::stream_error( - format!("bedrock event-stream decode: {e}"), - std::io::Error::other(e.to_string()), - ) - })?; - match frame { - DecodedFrame::Complete(message) => { - if let Some(event) = Self::message_to_event(&message)? { - events.push(event); - } - } - DecodedFrame::Incomplete => break, - } - } - Ok(events) - } - - /// Classify one event-stream message. - /// - /// `event` frames yield their `:event-type` name and JSON payload; - /// `exception` frames (modeled AWS errors such as `throttlingException`, - /// arriving in-band after HTTP 200) and `error` frames (unmodeled) are - /// turned into errors. Frames without an event type are skipped. - fn message_to_event(message: &Message) -> Result, Error> { - match header_str(message, ":message-type") { - Some("exception") => { - let kind = header_str(message, ":exception-type").unwrap_or("unknown"); - let body = String::from_utf8_lossy(message.payload()); - Err(Error::stream_error( - format!("bedrock stream exception ({kind}): {body}"), - std::io::Error::other("bedrock event-stream exception frame"), - )) - } - Some("error") => { - let code = header_str(message, ":error-code").unwrap_or("unknown"); - let detail = header_str(message, ":error-message").unwrap_or(""); - Err(Error::stream_error( - format!("bedrock stream error ({code}): {detail}"), - std::io::Error::other("bedrock event-stream error frame"), - )) - } - _ => { - let Some(event_type) = header_str(message, ":event-type") else { - return Ok(None); - }; - Ok(Some(DecodedEvent { - event_type: event_type.to_string(), - payload: String::from_utf8_lossy(message.payload()).into_owned(), - })) - } - } - } -} - -/// Read a string-valued event-stream header by name. -fn header_str<'a>(message: &'a Message, name: &str) -> Option<&'a str> { - message - .headers() - .iter() - .find(|header| header.name().as_str() == name) - .and_then(|header| header.value().as_string().ok()) - .map(StrBytes::as_str) -} - -#[cfg(test)] -pub(crate) mod tests { - use aws_smithy_eventstream::frame::write_message_to; - use aws_smithy_types::event_stream::{Header, HeaderValue, Message}; - - use super::*; - - /// Build one ConverseStream event frame: event name in `:event-type`, - /// payload = the event JSON directly. - fn encode_event_frame(event_type: &str, payload_json: &str) -> Vec { - let message = Message::new(payload_json.as_bytes().to_vec()) - .add_header(Header::new( - ":message-type", - HeaderValue::String("event".into()), - )) - .add_header(Header::new( - ":event-type", - HeaderValue::String(event_type.to_string().into()), - )) - .add_header(Header::new( - ":content-type", - HeaderValue::String("application/json".into()), - )); - let mut buf = Vec::new(); - write_message_to(&message, &mut buf).unwrap(); - buf - } - - /// Build a full streaming body from `(event_type, payload_json)` pairs. - pub(crate) fn build_stream_body(events: &[(&str, &str)]) -> Vec { - let mut body = Vec::new(); - for (event_type, payload) in events { - body.extend_from_slice(&encode_event_frame(event_type, payload)); - } - body - } - - #[test] - fn decodes_event_frame_to_typed_payload() { - let frame = encode_event_frame( - "contentBlockDelta", - r#"{"delta":{"text":"hi"},"contentBlockIndex":0}"#, - ); - let mut decoder = FrameDecoder::new(); - let events = decoder.push(&frame).unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, "contentBlockDelta"); - let payload: serde_json::Value = serde_json::from_str(&events[0].payload).unwrap(); - assert_eq!(payload["delta"]["text"], "hi"); - } - - #[test] - fn reassembles_frame_split_across_pushes() { - let frame = encode_event_frame("messageStop", r#"{"stopReason":"end_turn"}"#); - let split = frame.len() / 2; - let mut decoder = FrameDecoder::new(); - assert!(decoder.push(&frame[..split]).unwrap().is_empty()); - let events = decoder.push(&frame[split..]).unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, "messageStop"); - } - - #[test] - fn exception_frame_surfaces_as_error() { - let message = Message::new(br#"{"message":"Too many requests"}"#.to_vec()) - .add_header(Header::new( - ":message-type", - HeaderValue::String("exception".into()), - )) - .add_header(Header::new( - ":exception-type", - HeaderValue::String("throttlingException".into()), - )); - let mut buf = Vec::new(); - write_message_to(&message, &mut buf).unwrap(); - - let mut decoder = FrameDecoder::new(); - let err = decoder.push(&buf).unwrap_err(); - let rendered = err.to_string(); - assert!(rendered.contains("throttlingException"), "{rendered}"); - assert!(rendered.contains("Too many requests"), "{rendered}"); - } - - #[test] - fn unmodeled_error_frame_surfaces_as_error() { - let message = Message::new(Vec::new()) - .add_header(Header::new( - ":message-type", - HeaderValue::String("error".into()), - )) - .add_header(Header::new( - ":error-code", - HeaderValue::String("InternalError".into()), - )) - .add_header(Header::new( - ":error-message", - HeaderValue::String("stream broke".into()), - )); - let mut buf = Vec::new(); - write_message_to(&message, &mut buf).unwrap(); - - let mut decoder = FrameDecoder::new(); - let err = decoder.push(&buf).unwrap_err(); - let rendered = err.to_string(); - assert!(rendered.contains("InternalError"), "{rendered}"); - } -} diff --git a/lib/components/fabro-llm/src/providers/bedrock/mod.rs b/lib/components/fabro-llm/src/providers/bedrock/mod.rs deleted file mode 100644 index 6f10dadb2..000000000 --- a/lib/components/fabro-llm/src/providers/bedrock/mod.rs +++ /dev/null @@ -1,716 +0,0 @@ -//! Provider adapter for Amazon Bedrock (Converse/ConverseStream). -//! -//! A thin transport shell over the `bedrock_converse` codec: it owns auth -//! (SigV4 signing or a bearer Bedrock API key), the region derivation, and -//! the AWS event-stream byte loop. All wire translation lives in the codec; -//! one codec serves every Converse-capable family because AWS translates the -//! envelope server-side. - -pub(crate) mod eventstream; -pub(crate) mod sigv4; - -use std::collections::{HashMap, VecDeque}; -use std::sync::Arc; -use std::time::Duration; - -use eventstream::FrameDecoder; -use fabro_auth::ApiKeyHeader; -use fabro_model::Catalog; -use futures::stream; -use sigv4::Sigv4Signer; -use tokio::sync::OnceCell; -use tokio::time; - -use crate::adapter_registry::AdapterConfig; -#[cfg(test)] -use crate::adapter_registry::AdapterKindOptions; -use crate::attachments::{self, AttachmentPolicy}; -use crate::codec::bedrock_converse::BedrockConverse; -use crate::codec::{Codec, CodecCtx, CodecParams, EncodedRequest, RawEvent, StreamDecoder}; -use crate::error::Error; -use crate::provider::{self, ProviderAdapter, StreamEventStream}; -use crate::providers::common::{self as common, CatalogRoute}; -use crate::transport::{self, HttpTransport}; -use crate::types::{AdapterTimeout, Request, Response, StreamEvent}; - -/// How the adapter authenticates to Bedrock. -pub(crate) enum BedrockAuth { - /// Bedrock API key, sent as an `Authorization: Bearer` token. - ApiKey(String), - /// SigV4 signing. The signer (holding the AWS default credential chain) - /// is resolved on first use and cached; the chain itself re-resolves - /// expiring credentials per request. Tests pre-seed the cell with a - /// static signer. - Sigv4(OnceCell), -} - -/// Build a boxed Bedrock adapter from a resolved [`AdapterConfig`]. -/// -/// Kept in this module (rather than the generic adapter registry) so that -/// Bedrock-specific construction stays encapsulated here. The auth mode is -/// implied by the resolved credential: an `aws_sigv4` credential signs with -/// the AWS chain; a static token is sent as a bearer API key. -pub(crate) fn build(config: AdapterConfig) -> Result, Error> { - let base_url = config - .base_url - .clone() - .ok_or_else(|| Error::Configuration { - message: format!( - "bedrock provider '{}' requires a base_url (the Bedrock runtime endpoint)", - config.provider_id - ), - source: None, - })?; - let adapter = match config.auth_header { - Some(ApiKeyHeader::AwsSigv4) => Adapter::new_sigv4(base_url)?, - Some(ApiKeyHeader::Bearer(token)) => Adapter::new_api_key(token, base_url)?, - Some(ApiKeyHeader::Custom { name, .. }) => { - return Err(Error::Configuration { - message: format!( - "bedrock provider '{}' does not support custom auth header '{}' (use bearer \ - credentials or aws_sigv4)", - config.provider_id, name - ), - source: None, - }); - } - None => { - return Err(Error::Configuration { - message: format!( - "bedrock provider '{}' has no resolved credential (configure `aws_sigv4` or \ - an API key)", - config.provider_id - ), - source: None, - }); - } - }; - let mut adapter = adapter.with_name(config.provider_id); - if !config.extra_headers.is_empty() { - adapter = adapter.with_default_headers(config.extra_headers); - } - if let Some(catalog) = config.catalog { - adapter = adapter.with_catalog(catalog); - } - Ok(Arc::new(adapter)) -} - -/// Provider adapter for Amazon Bedrock. -pub struct Adapter { - pub(crate) http: HttpTransport, - provider_name: String, - region: String, - auth: BedrockAuth, - catalog: Option>, -} - -impl Adapter { - /// Construct an adapter that authenticates with a Bedrock API key. - /// `base_url` is the Bedrock runtime endpoint; the signing region is - /// parsed from it. - pub fn new_api_key( - token: impl Into, - base_url: impl Into, - ) -> Result { - Self::with_auth(base_url, BedrockAuth::ApiKey(token.into())) - } - - /// Construct a SigV4 adapter. Credentials resolve lazily from the AWS - /// default chain on the first request, so construction stays synchronous. - pub fn new_sigv4(base_url: impl Into) -> Result { - Self::with_auth(base_url, BedrockAuth::Sigv4(OnceCell::new())) - } - - fn with_auth(base_url: impl Into, auth: BedrockAuth) -> Result { - let base_url = base_url.into(); - let region = region_from_base_url(&base_url)?; - Ok(Self { - http: HttpTransport::new_optional(None, base_url), - provider_name: "bedrock".to_string(), - region, - auth, - catalog: None, - }) - } - - #[must_use] - pub fn with_name(mut self, name: impl Into) -> Self { - self.provider_name = name.into(); - self - } - - #[must_use] - pub fn with_catalog(mut self, catalog: Arc) -> Self { - self.catalog = Some(catalog); - self - } - - #[must_use] - pub fn with_default_headers(mut self, headers: HashMap) -> Self { - self.http = self.http.with_default_headers(headers); - self - } - - #[must_use] - pub fn with_timeout(self, timeout: AdapterTimeout) -> Self { - Self { - http: self.http.with_timeout(timeout), - ..self - } - } - - fn codec_ctx<'a>( - &'a self, - request: &'a Request, - deployment_id: &'a str, - params: &'a CodecParams, - ) -> CodecCtx<'a> { - CodecCtx { - request, - provider_name: &self.provider_name, - deployment_id, - model: self.catalog_model(&request.model), - params, - } - } - - /// Resolve file-backed attachments to inline data first: Converse takes - /// inline image and document bytes (no URL sources). - async fn resolve_request<'a>(&self, request: &'a Request) -> std::borrow::Cow<'a, Request> { - let policy = AttachmentPolicy { - images: true, - documents: true, - audio: false, - }; - attachments::resolve(request, policy).await - } - - /// Build the signed/bearer HTTP request for an encoded Converse call. - async fn build_http_request( - &self, - encoded: &EncodedRequest, - stream: bool, - ) -> Result { - let url = format!("{}{}", self.http.base_url, encoded.endpoint); - let body = serde_json::to_vec(&encoded.body).map_err(|e| Error::Configuration { - message: format!("failed to serialize converse request: {e}"), - source: None, - })?; - - let mut req = self.http.client.post(&url); - for (key, value) in &self.http.default_headers { - req = req.header(key, value); - } - for (key, value) in &encoded.headers { - req = req.header(key, value); - } - - req = match &self.auth { - BedrockAuth::ApiKey(token) => req.bearer_auth(token).body(body), - BedrockAuth::Sigv4(cell) => { - let signer = cell - .get_or_try_init(Sigv4Signer::from_default_chain) - .await?; - signer.sign_post(req, &self.region, &url, body).await? - } - }; - - req = req.header("content-type", "application/json"); - if stream { - req = req.header("accept", "application/vnd.amazon.eventstream"); - } - if let Some(t) = self.http.request_timeout { - if !stream { - req = req.timeout(t); - } - } - Ok(req) - } -} - -impl common::CatalogRoute for Adapter { - fn catalog(&self) -> Option<&Catalog> { - self.catalog.as_deref() - } - - fn provider_name(&self) -> &str { - &self.provider_name - } -} - -#[async_trait::async_trait] -impl ProviderAdapter for Adapter { - fn name(&self) -> &str { - &self.provider_name - } - - async fn complete(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = BedrockConverse; - let deployment_id = self.api_model_id(&resolved.model); - let params = CodecParams::default(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let encoded = codec.encode(&ctx, false)?; - let req = self.build_http_request(&encoded, false).await?; - transport::complete_via_http(req, &codec, &ctx).await - } - - async fn stream(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = BedrockConverse; - let deployment_id = self.api_model_id(&resolved.model); - let params = CodecParams::default(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let encoded = codec.encode(&ctx, true)?; - let req = self.build_http_request(&encoded, true).await?; - - let http_resp = req - .send() - .await - .map_err(|e| Error::network(e.to_string(), e))?; - let status = http_resp.status(); - if !status.is_success() { - let retry_after = transport::parse_retry_after(http_resp.headers()); - let body = http_resp - .text() - .await - .map_err(|e| Error::network(e.to_string(), e))?; - return Err(codec.decode_error(status.as_u16(), &body, &ctx, retry_after)); - } - - let rate_limit = transport::parse_rate_limit_headers(http_resp.headers()); - let decoder = codec.stream_decoder(&ctx, rate_limit); - Ok(decode_eventstream( - http_resp, - decoder, - self.http.stream_read_timeout, - )) - } - - fn supports_tool_choice(&self, mode: &str) -> bool { - // Converse has no `none` tool choice on the wire. - matches!(mode, "auto" | "required" | "named") - } - - fn validate_request(&self, request: &Request) -> Result<(), Error> { - if let Some(tool_choice) = &request.tool_choice { - provider::validate_tool_choice(self, tool_choice)?; - } - Ok(()) - } -} - -/// State driving the event-stream byte loop: the codec's decoder plus the -/// frame decoder, with a buffer that flattens batched events. -struct EventStreamLoop { - response: fabro_http::Response, - frames: FrameDecoder, - decoder: Box, - pending: VecDeque>, - done: bool, - /// `finish()` already drained. - finished: bool, - /// [`StreamEvent::StreamStart`] already emitted for this stream. - stream_started: bool, - timeout: Option, -} - -/// Drive `decoder` over the AWS event-stream byte stream of `response`: the -/// event-stream sibling of the transport's shared SSE loop, anticipated by -/// the transport consolidation notes. -fn decode_eventstream( - response: fabro_http::Response, - decoder: Box, - timeout: Option, -) -> StreamEventStream { - let out = stream::unfold( - EventStreamLoop { - response, - frames: FrameDecoder::new(), - decoder, - pending: VecDeque::new(), - done: false, - finished: false, - stream_started: false, - timeout, - }, - move |mut state| async move { - loop { - if let Some(event) = state.pending.pop_front() { - return Some((event, state)); - } - - if state.done { - if state.finished { - return None; - } - state.finished = true; - state - .pending - .extend(state.decoder.finish().into_iter().map(Ok)); - if state.pending.is_empty() { - return None; - } - continue; - } - - let chunk_result = match state.timeout { - Some(timeout) => time::timeout(timeout, state.response.chunk()).await, - None => Ok(state.response.chunk().await), - }; - match chunk_result { - Ok(Ok(Some(bytes))) => { - let frames = match state.frames.push(&bytes) { - Ok(frames) => frames, - Err(e) => return Some((Err(e), state)), - }; - for frame in frames { - let raw = RawEvent { - event: Some(frame.event_type.as_str()), - data: frame.payload.as_str(), - }; - // Mirrors the SSE loop: the first decoded frame is - // the liveness edge, independent of which event - // type the provider happens to open with. - if !state.stream_started { - state.stream_started = true; - state.pending.push_back(Ok(StreamEvent::StreamStart)); - } - match state.decoder.on_event(raw) { - Ok(events) => state.pending.extend(events.into_iter().map(Ok)), - Err(error) => { - state.pending.push_back(Err(error)); - break; - } - } - } - } - Ok(Ok(None)) => state.done = true, - Ok(Err(e)) => { - return Some((Err(Error::stream_error(e.to_string(), e)), state)); - } - Err(_) => { - return Some(( - Err(Error::Stream { - message: "stream read timed out waiting for next event".to_string(), - source: None, - }), - state, - )); - } - } - } - }, - ); - Box::pin(out) -} - -/// Derive the AWS region from a Bedrock runtime endpoint URL. -/// -/// The region is a SigV4 signing parameter, so it is parsed from the -/// configured base URL rather than carried as a separate AWS-specific config -/// field. It is validated as `[a-z0-9-]` since it ultimately appears in a -/// signed request. -fn region_from_base_url(base_url: &str) -> Result { - let invalid = || Error::Configuration { - message: format!( - "bedrock base_url '{base_url}' is not a recognized Bedrock runtime endpoint \ - (expected https://bedrock-runtime[-fips]..amazonaws.com[.cn])" - ), - source: None, - }; - #[expect( - clippy::disallowed_types, - reason = "Bedrock region derivation needs URL host parsing; the raw URL is not logged or rendered." - )] - let parsed = fabro_http::Url::parse(base_url).map_err(|_| invalid())?; - let host = parsed.host_str().ok_or_else(invalid)?; - let rest = host - .strip_prefix("bedrock-runtime-fips.") - .or_else(|| host.strip_prefix("bedrock-runtime.")) - .ok_or_else(invalid)?; - let region = rest - .strip_suffix(".amazonaws.com.cn") - .or_else(|| rest.strip_suffix(".amazonaws.com")) - .ok_or_else(invalid)?; - let valid = !region.is_empty() - && region - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); - if valid { - Ok(region.to_string()) - } else { - Err(invalid()) - } -} - -#[cfg(test)] -mod tests { - use futures::StreamExt; - use httpmock::prelude::*; - - use super::*; - use crate::types::{FinishReason, Message}; - - fn make_request(model: &str) -> Request { - Request { - model: model.to_string(), - messages: vec![Message::user("Hello")], - provider: Some("bedrock".to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: Some(64), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - /// Adapter pointed at httpmock: region parsing only applies to real - /// bedrock-runtime URLs, so the test constructor sets the region field - /// directly. - fn test_adapter(server: &MockServer) -> Adapter { - Adapter { - http: HttpTransport::new_optional(None, server.base_url()), - provider_name: "bedrock".to_string(), - region: "us-east-1".to_string(), - auth: BedrockAuth::ApiKey("test-bedrock-key".to_string()), - catalog: None, - } - } - - #[test] - fn region_parses_from_standard_endpoint() { - assert_eq!( - region_from_base_url("https://bedrock-runtime.eu-west-1.amazonaws.com").unwrap(), - "eu-west-1" - ); - } - - #[test] - fn region_parses_from_fips_endpoint() { - assert_eq!( - region_from_base_url("https://bedrock-runtime-fips.us-gov-west-1.amazonaws.com") - .unwrap(), - "us-gov-west-1" - ); - } - - #[test] - fn region_parses_from_china_endpoint() { - assert_eq!( - region_from_base_url("https://bedrock-runtime.cn-north-1.amazonaws.com.cn").unwrap(), - "cn-north-1" - ); - } - - #[test] - fn region_rejects_non_bedrock_hosts() { - for url in [ - "https://example.com", - "https://bedrock.us-east-1.amazonaws.com", - "https://bedrock-runtime.amazonaws.com", - ] { - assert!(region_from_base_url(url).is_err(), "{url}"); - } - } - - #[test] - fn region_normalizes_hostname_case() { - assert_eq!( - region_from_base_url("https://bedrock-runtime.US-EAST-1.amazonaws.com").unwrap(), - "us-east-1" - ); - } - - #[tokio::test] - async fn complete_posts_converse_body_with_bearer_auth() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST) - .path("/model/us.anthropic.claude-sonnet-4-6/converse") - .header("authorization", "Bearer test-bedrock-key") - .json_body_includes( - r#"{"messages":[{"role":"user","content":[{"text":"Hello"}]}],"inferenceConfig":{"maxTokens":64}}"#, - ); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "output": {"message": {"role": "assistant", "content": [{"text": "Hi!"}]}}, - "stopReason": "end_turn", - "usage": {"inputTokens": 8, "outputTokens": 2, "totalTokens": 10} - })); - }); - - let adapter = test_adapter(&server); - let response = adapter - .complete(&make_request("us.anthropic.claude-sonnet-4-6")) - .await - .unwrap(); - - mock.assert(); - assert_eq!(response.text(), "Hi!"); - assert_eq!(response.finish_reason, FinishReason::Stop); - assert_eq!(response.usage.input_tokens, 8); - assert_eq!(response.provider, "bedrock"); - } - - #[tokio::test] - async fn complete_applies_default_headers() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST) - .path("/model/m/converse") - .header("x-fabro-test", "present"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}}, - "stopReason": "end_turn", - "usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2} - })); - }); - - let adapter = test_adapter(&server).with_default_headers(HashMap::from([( - "x-fabro-test".to_string(), - "present".to_string(), - )])); - let response = adapter.complete(&make_request("m")).await.unwrap(); - - mock.assert(); - assert_eq!(response.text(), "ok"); - } - - #[test] - fn factory_rejects_custom_auth_header() { - let result = build(AdapterConfig { - provider_id: "bedrock".to_string(), - auth_header: Some(ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "secret".to_string(), - }), - base_url: Some("https://bedrock-runtime.us-east-1.amazonaws.com".to_string()), - extra_headers: HashMap::new(), - kind_options: AdapterKindOptions::None, - catalog: None, - }); - - let Err(err) = result else { - panic!("expected custom auth header to be rejected"); - }; - assert!( - err.to_string() - .contains("does not support custom auth header") - ); - } - - #[tokio::test] - async fn complete_signs_with_sigv4_when_configured() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST) - .path("/model/m/converse") - .header_exists("authorization") - .header_exists("x-amz-date"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}}, - "stopReason": "end_turn", - "usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2} - })); - }); - - let mut adapter = test_adapter(&server); - let cell = OnceCell::new(); - cell.set(Sigv4Signer::from_static("AKIDEXAMPLE", "secret", None)) - .ok(); - adapter.auth = BedrockAuth::Sigv4(cell); - - let response = adapter.complete(&make_request("m")).await.unwrap(); - mock.assert(); - assert_eq!(response.text(), "ok"); - } - - #[tokio::test] - async fn stream_decodes_eventstream_frames() { - let server = MockServer::start(); - let body = eventstream::tests::build_stream_body(&[ - ("messageStart", r#"{"role":"assistant"}"#), - ( - "contentBlockDelta", - r#"{"delta":{"text":"Hel"},"contentBlockIndex":0}"#, - ), - ( - "contentBlockDelta", - r#"{"delta":{"text":"lo"},"contentBlockIndex":0}"#, - ), - ("contentBlockStop", r#"{"contentBlockIndex":0}"#), - ("messageStop", r#"{"stopReason":"end_turn"}"#), - ( - "metadata", - r#"{"usage":{"inputTokens":9,"outputTokens":3,"totalTokens":12}}"#, - ), - ]); - server.mock(|when, then| { - when.method(POST) - .path("/model/m/converse-stream") - .header("accept", "application/vnd.amazon.eventstream"); - then.status(200) - .header("content-type", "application/vnd.amazon.eventstream") - .body(body); - }); - - let adapter = test_adapter(&server); - let mut stream = adapter.stream(&make_request("m")).await.unwrap(); - - let mut text = String::new(); - let mut finish: Option = None; - while let Some(event) = stream.next().await { - match event.unwrap() { - StreamEvent::TextDelta { delta, .. } => text.push_str(&delta), - StreamEvent::Finish { response, .. } => finish = Some(*response), - _ => {} - } - } - assert_eq!(text, "Hello"); - let response = finish.expect("stream should finish"); - assert_eq!(response.text(), "Hello"); - assert_eq!(response.usage.input_tokens, 9); - } - - #[tokio::test] - async fn stream_surfaces_http_error_before_bytes() { - let server = MockServer::start(); - server.mock(|when, then| { - when.method(POST).path("/model/m/converse-stream"); - then.status(429) - .json_body(serde_json::json!({"message": "Too many requests"})); - }); - - let adapter = test_adapter(&server); - let Err(err) = adapter.stream(&make_request("m")).await else { - panic!("expected an HTTP error before any stream bytes"); - }; - assert_eq!(err.status_code(), Some(429)); - } - - #[test] - fn tool_choice_none_is_rejected() { - let server = MockServer::start(); - let adapter = test_adapter(&server); - assert!(!adapter.supports_tool_choice("none")); - assert!(adapter.supports_tool_choice("auto")); - } -} diff --git a/lib/components/fabro-llm/src/providers/bedrock/sigv4.rs b/lib/components/fabro-llm/src/providers/bedrock/sigv4.rs deleted file mode 100644 index 54a31849d..000000000 --- a/lib/components/fabro-llm/src/providers/bedrock/sigv4.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! AWS Signature Version 4 signing for Bedrock requests. -//! -//! Wraps the `aws-sigv4` crate to compute the `Authorization`, `x-amz-date`, -//! and (for temporary credentials) `x-amz-security-token` headers for a fully -//! built request. The headers are then attached to the shared `fabro-http` -//! request builder, so signed Bedrock requests still flow through the same -//! retry/redaction/transport layers as every other adapter. - -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use aws_credential_types::Credentials; -use aws_credential_types::provider::SharedCredentialsProvider; -use aws_sigv4::http_request::{SignableBody, SignableRequest, SigningSettings, sign}; -use aws_sigv4::sign::v4; -use aws_smithy_runtime_api::client::identity::Identity; - -use crate::error::Error; - -/// Service name used in the SigV4 credential scope for Bedrock runtime calls. -pub(crate) const SERVICE: &str = "bedrock"; - -/// Where the signer's credentials come from. -enum CredentialSource { - /// Fixed credentials (tests / explicitly supplied keys). - #[cfg(test)] - Static(Credentials), - /// The AWS default provider chain. Credentials are resolved per request - /// so expiring session credentials (STS, IRSA, instance roles) refresh - /// through the chain's identity cache instead of being snapshotted once - /// at startup. - Chain(SharedCredentialsProvider), -} - -/// Signs HTTP requests for AWS services with SigV4. -pub(crate) struct Sigv4Signer { - credentials: CredentialSource, -} - -impl Sigv4Signer { - /// Build a signer from static keys. Test-only: production paths resolve - /// credentials through the AWS chain. - #[cfg(test)] - pub(crate) fn from_static( - access_key_id: &str, - secret_access_key: &str, - session_token: Option, - ) -> Self { - Self { - credentials: CredentialSource::Static(Credentials::from_keys( - access_key_id, - secret_access_key, - session_token, - )), - } - } - - /// Build a signer over the standard AWS provider chain (environment, - /// IRSA/web identity, EC2/ECS instance profile, SSO, assume-role). The - /// chain is resolved once; the credentials it yields are fetched per - /// signing call so they stay fresh over long-lived adapters. - pub(crate) async fn from_default_chain() -> Result { - let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) - .load() - .await; - let provider = config - .credentials_provider() - .ok_or_else(|| Error::Configuration { - message: "no AWS credentials provider found in the default chain".to_string(), - source: None, - })?; - Ok(Self { - credentials: CredentialSource::Chain(provider), - }) - } - - /// The credentials to sign the next request with. - async fn current_credentials(&self) -> Result { - use aws_credential_types::provider::ProvideCredentials; - - match &self.credentials { - #[cfg(test)] - CredentialSource::Static(credentials) => Ok(credentials.clone()), - CredentialSource::Chain(provider) => { - provider - .provide_credentials() - .await - .map_err(|e| Error::Configuration { - message: format!("failed to resolve AWS credentials: {e}"), - source: None, - }) - } - } - } - - /// Compute the SigV4 headers for a request: `Authorization`, `x-amz-date`, - /// and `x-amz-security-token` when the credentials carry a session token. - fn signed_headers( - credentials: &Credentials, - region: &str, - service: &str, - method: &str, - url: &str, - body: &[u8], - epoch_secs: u64, - ) -> Result, Error> { - let identity: Identity = credentials.clone().into(); - let signing_params = v4::SigningParams::builder() - .identity(&identity) - .region(region) - .name(service) - .time(UNIX_EPOCH + Duration::from_secs(epoch_secs)) - .settings(SigningSettings::default()) - .build() - .map_err(|e| Error::Configuration { - message: format!("sigv4 params: {e}"), - source: None, - })? - .into(); - - let signable = - SignableRequest::new(method, url, std::iter::empty(), SignableBody::Bytes(body)) - .map_err(|e| Error::Configuration { - message: format!("sigv4 signable request: {e}"), - source: None, - })?; - - let (instructions, _signature) = sign(signable, &signing_params) - .map_err(|e| Error::Configuration { - message: format!("sigv4 signing failed: {e}"), - source: None, - })? - .into_parts(); - - Ok(instructions - .headers() - .map(|(name, value)| (name.to_string(), value.to_string())) - .collect()) - } - - /// Apply SigV4 signed headers to a `fabro-http` request builder for a - /// `POST` to `url` carrying `body`. - pub(crate) async fn sign_post( - &self, - mut req: fabro_http::RequestBuilder, - region: &str, - url: &str, - body: Vec, - ) -> Result { - let credentials = self.current_credentials().await?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|e| Error::Configuration { - message: format!("system clock before epoch: {e}"), - source: None, - })? - .as_secs(); - for (name, value) in - Self::signed_headers(&credentials, region, SERVICE, "POST", url, &body, now)? - { - req = req.header(name, value); - } - Ok(req.body(body)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Fixed credentials + time produce a deterministic Authorization header. - // The expected value is locked below after the first green run so the test - // guards against accidental changes to the signing logic. - const ACCESS_KEY: &str = "AKIDEXAMPLE"; - const SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; - const FIXED_EPOCH: u64 = 1_716_960_000; // 2024-05-29T04:00:00Z - const URL: &str = "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-sonnet-4-6/converse"; - - fn static_credentials(signer: &Sigv4Signer) -> Credentials { - match &signer.credentials { - CredentialSource::Static(credentials) => credentials.clone(), - CredentialSource::Chain(_) => panic!("test signer should hold static credentials"), - } - } - - fn auth_header(headers: &[(String, String)]) -> &str { - headers - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case("authorization")) - .map(|(_, value)| value.as_str()) - .expect("authorization header must be present") - } - - fn sign_fixed(signer: &Sigv4Signer, body: &[u8]) -> Vec<(String, String)> { - Sigv4Signer::signed_headers( - &static_credentials(signer), - "us-east-1", - SERVICE, - "POST", - URL, - body, - FIXED_EPOCH, - ) - .unwrap() - } - - #[test] - fn produces_authorization_and_date_headers() { - let signer = Sigv4Signer::from_static(ACCESS_KEY, SECRET_KEY, None); - let headers = sign_fixed(&signer, br#"{"messages":[]}"#); - - assert!( - headers - .iter() - .any(|(n, _)| n.eq_ignore_ascii_case("authorization")) - ); - assert!( - headers - .iter() - .any(|(n, _)| n.eq_ignore_ascii_case("x-amz-date")) - ); - let auth = auth_header(&headers); - assert!(auth.starts_with("AWS4-HMAC-SHA256 ")); - assert!(auth.contains("Credential=AKIDEXAMPLE/20240529/us-east-1/bedrock/aws4_request")); - assert!(auth.contains("SignedHeaders=")); - assert!(auth.contains("Signature=")); - } - - #[test] - fn deterministic_signature_is_stable() { - let signer = Sigv4Signer::from_static(ACCESS_KEY, SECRET_KEY, None); - // Same inputs must yield an identical signature (regression lock). - assert_eq!( - auth_header(&sign_fixed(&signer, br#"{"messages":[]}"#)), - auth_header(&sign_fixed(&signer, br#"{"messages":[]}"#)), - ); - } - - #[test] - fn session_token_adds_security_token_header() { - let signer = - Sigv4Signer::from_static(ACCESS_KEY, SECRET_KEY, Some("session-tok".to_string())); - let headers = sign_fixed(&signer, b"{}"); - assert!( - headers - .iter() - .any(|(n, v)| n.eq_ignore_ascii_case("x-amz-security-token") && v == "session-tok") - ); - } -} diff --git a/lib/components/fabro-llm/src/providers/common.rs b/lib/components/fabro-llm/src/providers/common.rs deleted file mode 100644 index 6eff3b727..000000000 --- a/lib/components/fabro-llm/src/providers/common.rs +++ /dev/null @@ -1,145 +0,0 @@ -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use fabro_model::{Catalog, Model, ProviderId}; -use fabro_static::EnvVars; -use tokio::fs; - -#[must_use] -pub fn catalog_model<'a>( - catalog: Option<&'a Catalog>, - provider: &str, - model: &str, -) -> Option<&'a Model> { - catalog.and_then(|catalog| catalog.get_on_provider(&ProviderId::new(provider), model)) -} - -#[must_use] -pub fn api_model_id(catalog: Option<&Catalog>, provider: &str, model: &str) -> String { - catalog - .and_then(|catalog| catalog.model_settings_on_provider(&ProviderId::new(provider), model)) - .map_or_else(|| model.to_string(), |settings| settings.api_id.clone()) -} - -/// Adapters that route models through an optional catalog scoped to one -/// provider name. -pub trait CatalogRoute { - fn catalog(&self) -> Option<&Catalog>; - fn provider_name(&self) -> &str; - - /// Catalog offering for a canonical ID or alias on this provider. - fn catalog_model(&self, model: &str) -> Option<&Model> { - catalog_model(self.catalog(), self.provider_name(), model) - } - - /// Identifier sent to the provider API for a model. - fn api_model_id(&self, model: &str) -> String { - api_model_id(self.catalog(), self.provider_name(), model) - } -} - -/// Check if a URL string looks like a local file path. -#[must_use] -pub fn is_file_path(url: &str) -> bool { - url.starts_with('/') || url.starts_with("./") || url.starts_with("~/") -} - -/// Infer MIME type from a file extension. -#[must_use] -pub fn mime_from_extension(path: &str) -> &str { - match path.rsplit('.').next().map(str::to_lowercase).as_deref() { - Some("png") => "image/png", - Some("jpg" | "jpeg") => "image/jpeg", - Some("gif") => "image/gif", - Some("webp") => "image/webp", - Some("heic") => "image/heic", - Some("heif") => "image/heif", - Some("pdf") => "application/pdf", - Some("wav") => "audio/wav", - Some("mp3") => "audio/mp3", - _ => "application/octet-stream", - } -} - -/// Load a local file, returning (`base64_data`, `mime_type`). -/// Expands ~ to home directory. -/// -/// # Errors -/// Returns an error if the file cannot be read. -#[expect( - clippy::disallowed_methods, - reason = "Attachment path expansion supports the conventional HOME env var." -)] -pub async fn load_file_bytes(path: &str) -> Result<(Vec, String), std::io::Error> { - let expanded = path.strip_prefix("~/").map_or_else( - || path.to_string(), - |rest| { - let home = std::env::var(EnvVars::HOME).unwrap_or_else(|_| "/".to_string()); - format!("{home}/{rest}") - }, - ); - let data = fs::read(&expanded).await.map_err(|err| { - std::io::Error::new(err.kind(), format!("read attachment {expanded}: {err}")) - })?; - let mime = mime_from_extension(&expanded).to_string(); - Ok((data, mime)) -} - -/// Read a file and return base64-encoded contents plus the inferred MIME type. -/// -/// # Errors -/// -/// Returns an error if the file cannot be read. -pub async fn load_file_as_base64(path: &str) -> Result<(String, String), std::io::Error> { - let (data, mime) = load_file_bytes(path).await?; - Ok((BASE64_STANDARD.encode(&data), mime)) -} - -// Transport pieces moved to `crate::transport`; re-exported here because -// fabro-cli imports them from this path (frozen public surface). -pub use crate::transport::{LineReader, parse_rate_limit_headers, parse_retry_after}; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn is_file_path_absolute() { - assert!(is_file_path("/tmp/image.png")); - assert!(is_file_path("/home/user/photo.jpg")); - } - - #[test] - fn is_file_path_relative() { - assert!(is_file_path("./image.png")); - assert!(is_file_path("./subdir/photo.jpg")); - } - - #[test] - fn is_file_path_tilde() { - assert!(is_file_path("~/image.png")); - assert!(is_file_path("~/Documents/photo.jpg")); - } - - #[test] - fn is_file_path_url() { - assert!(!is_file_path("https://example.com/image.png")); - assert!(!is_file_path("http://example.com/image.png")); - assert!(!is_file_path("data:image/png;base64,abc")); - } - - #[test] - fn mime_from_extension_known() { - assert_eq!(mime_from_extension("photo.png"), "image/png"); - assert_eq!(mime_from_extension("photo.jpg"), "image/jpeg"); - assert_eq!(mime_from_extension("photo.jpeg"), "image/jpeg"); - assert_eq!(mime_from_extension("photo.gif"), "image/gif"); - assert_eq!(mime_from_extension("photo.webp"), "image/webp"); - assert_eq!(mime_from_extension("doc.pdf"), "application/pdf"); - } - - #[test] - fn mime_from_extension_unknown() { - assert_eq!(mime_from_extension("file.xyz"), "application/octet-stream"); - assert_eq!(mime_from_extension("noext"), "application/octet-stream"); - } -} diff --git a/lib/components/fabro-llm/src/providers/fabro_server.rs b/lib/components/fabro-llm/src/providers/fabro_server.rs deleted file mode 100644 index 5e4d6d990..000000000 --- a/lib/components/fabro-llm/src/providers/fabro_server.rs +++ /dev/null @@ -1,529 +0,0 @@ -use fabro_redact::DisplaySafeUrl; -use futures::stream; -use tracing::{debug, error}; - -use crate::error::{Error, error_from_status_code}; -use crate::provider::{ProviderAdapter, StreamEventStream}; -use crate::transport::{LineReader, parse_sse_block}; -use crate::types::{ - CostSource, FinishReason, Message, Request, Response, StreamEvent, TokenCounts, -}; - -/// Provider adapter that routes LLM requests through an fabro server's -/// `/completions` endpoint, delegating to whatever real provider the server -/// is configured with. -pub struct Adapter { - client: fabro_http::HttpClient, - base_url: String, - provider_name: String, -} - -impl Adapter { - pub fn new( - client: fabro_http::HttpClient, - base_url: impl Into, - provider_name: impl Into, - ) -> Self { - Self { - client, - base_url: base_url.into(), - provider_name: provider_name.into(), - } - } -} - -// --------------------------------------------------------------------------- -// Server response deserialization types -// --------------------------------------------------------------------------- - -#[derive(serde::Deserialize)] -struct ServerCompletionResponse { - id: String, - model: String, - message: Message, - stop_reason: String, - usage: ServerUsage, - cost_usd: Option, - cost_source: Option, -} - -#[derive(serde::Deserialize)] -struct ServerUsage { - input_tokens: i64, - output_tokens: i64, -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -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()), - } -} - -/// Build the JSON request body by serializing the `Request` and injecting -/// the `stream` flag. -fn build_body(request: &Request, stream: bool) -> Result { - let mut body = serde_json::to_value(request) - .map_err(|e| Error::configuration_error(format!("failed to serialize request: {e}"), e))?; - body["stream"] = serde_json::Value::Bool(stream); - Ok(body) -} - -/// Send a POST request and return the validated response. -/// -/// Handles timeout/network error mapping and non-2xx status codes. -async fn send_request( - client: &fabro_http::HttpClient, - url: &str, - body: &serde_json::Value, - provider: &str, -) -> Result { - let http_resp = client.post(url).json(body).send().await.map_err(|e| { - if e.is_timeout() { - Error::request_timeout(e.to_string(), e) - } else { - Error::network(e.to_string(), e) - } - })?; - - let status = http_resp.status(); - debug!(status = %status, "Fabro server response received"); - - if !status.is_success() { - let status_code = status.as_u16(); - let body = http_resp.text().await.unwrap_or_default(); - error!(status = %status_code, body = %body, "Fabro server request failed"); - return Err(error_from_status_code( - status_code, - body, - provider.to_string(), - None, - None, - None, - )); - } - - Ok(http_resp) -} - -// --------------------------------------------------------------------------- -// ProviderAdapter implementation -// --------------------------------------------------------------------------- - -#[async_trait::async_trait] -impl ProviderAdapter for Adapter { - fn name(&self) -> &str { - &self.provider_name - } - - async fn complete(&self, request: &Request) -> Result { - let url = format!("{}/completions", self.base_url); - let safe_url = redacted_url_for_log(&url); - debug!(base_url = %safe_url, provider = %self.provider_name, "Sending completion to fabro server"); - - let body = build_body(request, false)?; - let http_resp = send_request(&self.client, &url, &body, &self.provider_name).await?; - - let resp_body = http_resp - .text() - .await - .map_err(|e| Error::network(e.to_string(), e))?; - - let server_resp: ServerCompletionResponse = - serde_json::from_str(&resp_body).map_err(|e| { - Error::stream_error(format!("failed to parse completion response: {e}"), e) - })?; - - let finish_reason = map_stop_reason(&server_resp.stop_reason); - Ok(Response { - id: server_resp.id, - model: server_resp.model, - provider: self.provider_name.clone(), - message: server_resp.message, - finish_reason, - usage: TokenCounts { - input_tokens: server_resp.usage.input_tokens, - output_tokens: server_resp.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_resp.cost_usd, - cost_source: server_resp.cost_source, - }) - } - - async fn stream(&self, request: &Request) -> Result { - let url = format!("{}/completions", self.base_url); - let safe_url = redacted_url_for_log(&url); - debug!(base_url = %safe_url, provider = %self.provider_name, "Sending completion to fabro server"); - - let body = build_body(request, true)?; - let http_resp = send_request(&self.client, &url, &body, &self.provider_name).await?; - - let stream = stream::unfold(LineReader::new(http_resp, None), |mut reader| async move { - loop { - match reader.read_next_chunk("\n\n").await { - Ok(Some(block)) => { - if let Some((Some("stream_event"), data)) = parse_sse_block(&block) { - match serde_json::from_str::(&data) { - Ok(event) => return Some((Ok(event), reader)), - Err(e) => { - return Some(( - Err(Error::stream_error( - format!("failed to parse stream event: {e}"), - e, - )), - reader, - )); - } - } - } - // Empty, unparsable, or non-stream_event block — keep - // reading. - } - Ok(None) => return None, - Err(e) => return Some((Err(e), reader)), - } - } - }); - - Ok(Box::pin(stream)) - } -} - -fn redacted_url_for_log(url: &str) -> String { - DisplaySafeUrl::parse(url) - .map_or_else(|_| "".to_string(), |url| url.redacted_string()) -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use futures::StreamExt; - use httpmock::prelude::*; - - use super::*; - use crate::error::ProviderErrorKind; - use crate::types::Message; - - fn make_request() -> Request { - Request { - model: "test-model".to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - #[test] - fn redacted_url_for_log_masks_provider_query_credentials() { - assert_eq!( - redacted_url_for_log("https://fabro.example.test?api_key=secret&project=demo"), - "https://fabro.example.test/?api_key=****&project=demo" - ); - } - - #[tokio::test] - async fn stream_parses_sse_events() { - let server = MockServer::start(); - - let sse_body = "\ -event: stream_event\n\ -data: {\"type\":\"stream_start\"}\n\ -\n\ -event: stream_event\n\ -data: {\"type\":\"text_delta\",\"delta\":\"Hello\",\"text_id\":null}\n\ -\n\ -event: stream_event\n\ -data: {\"type\":\"text_delta\",\"delta\":\" world\",\"text_id\":null}\n\ -\n"; - - server.mock(|when, then| { - when.method(POST).path("/completions"); - then.status(200) - .header("content-type", "text/event-stream") - .body(sse_body); - }); - - let adapter = Adapter::new( - fabro_test::test_http_client(), - server.base_url(), - "test-provider", - ); - - let mut stream = adapter.stream(&make_request()).await.unwrap(); - - // First event: StreamStart - let event = stream.next().await.unwrap().unwrap(); - assert!(matches!(event, StreamEvent::StreamStart)); - - // Second event: TextDelta "Hello" - let event = stream.next().await.unwrap().unwrap(); - match &event { - StreamEvent::TextDelta { delta, .. } => assert_eq!(delta, "Hello"), - other => panic!("expected TextDelta, got {other:?}"), - } - - // Third event: TextDelta " world" - let event = stream.next().await.unwrap().unwrap(); - match &event { - StreamEvent::TextDelta { delta, .. } => assert_eq!(delta, " world"), - other => panic!("expected TextDelta, got {other:?}"), - } - - // Stream should end - assert!(stream.next().await.is_none()); - } - - #[tokio::test] - async fn complete_parses_response() { - let server = MockServer::start(); - - let response_json = serde_json::json!({ - "id": "resp-123", - "model": "test-model", - "message": { - "role": "assistant", - "content": [{"kind": "text", "data": "Hello there!"}], - "name": null, - "tool_call_id": null - }, - "stop_reason": "end_turn", - "usage": { - "input_tokens": 10, - "output_tokens": 5 - }, - "cost_usd": 0.000_25, - "cost_source": "estimated" - }); - - server.mock(|when, then| { - when.method(POST).path("/completions"); - then.status(200) - .header("content-type", "application/json") - .json_body(response_json); - }); - - let adapter = Adapter::new( - fabro_test::test_http_client(), - server.base_url(), - "test-provider", - ); - - let response = adapter.complete(&make_request()).await.unwrap(); - - assert_eq!(response.id, "resp-123"); - assert_eq!(response.model, "test-model"); - assert_eq!(response.provider, "test-provider"); - assert_eq!(response.text(), "Hello there!"); - assert_eq!(response.finish_reason, FinishReason::Stop); - assert_eq!(response.usage.input_tokens, 10); - assert_eq!(response.usage.output_tokens, 5); - assert_eq!(response.usage.total_tokens(), 15); - assert_eq!(response.cost_usd, Some(0.000_25)); - assert_eq!(response.cost_source, Some(CostSource::Estimated)); - } - - /// Reasoning needs no dedicated wire field on this hop: the canonical - /// message already transports the provider parts it is derived from. - #[tokio::test] - async fn complete_normalizes_reasoning_from_the_transported_message() { - let server = MockServer::start(); - - server.mock(|when, then| { - when.method(POST).path("/completions"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "id": "resp-123", - "model": "test-model", - "message": { - "role": "assistant", - "content": [ - { - "kind": "openai_compat_reasoning_details", - "data": [ - {"type": "reasoning.summary", "summary": "weighed both"}, - {"type": "reasoning.text", "text": "step one"}, - ] - }, - {"kind": "text", "data": "Hello there!"}, - ], - "name": null, - "tool_call_id": null - }, - "stop_reason": "end_turn", - "usage": {"input_tokens": 10, "output_tokens": 5} - })); - }); - - let adapter = Adapter::new( - fabro_test::test_http_client(), - server.base_url(), - "test-provider", - ); - - let response = adapter.complete(&make_request()).await.unwrap(); - - assert_eq!(response.text(), "Hello there!"); - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("weighed both")); - assert_eq!(reasoning.trace(), Some("step one")); - } - - #[tokio::test] - async fn complete_returns_error_on_502() { - let server = MockServer::start(); - - server.mock(|when, then| { - when.method(POST).path("/completions"); - then.status(502).body("Bad Gateway"); - }); - - let adapter = Adapter::new( - fabro_test::test_http_client(), - server.base_url(), - "test-provider", - ); - - let err = adapter.complete(&make_request()).await.unwrap_err(); - match &err { - Error::Provider { kind, detail } => { - assert_eq!(*kind, ProviderErrorKind::Server); - assert_eq!(detail.status_code, Some(502)); - } - other => panic!("expected Provider error, got {other:?}"), - } - } - - #[tokio::test] - async fn stream_returns_error_on_502() { - let server = MockServer::start(); - - server.mock(|when, then| { - when.method(POST).path("/completions"); - then.status(502).body("Bad Gateway"); - }); - - let adapter = Adapter::new( - fabro_test::test_http_client(), - server.base_url(), - "test-provider", - ); - - let result = adapter.stream(&make_request()).await; - let Err(err) = result else { - panic!("expected error"); - }; - match &err { - Error::Provider { kind, detail } => { - assert_eq!(*kind, ProviderErrorKind::Server); - assert_eq!(detail.status_code, Some(502)); - } - other => panic!("expected Provider error, got {other:?}"), - } - } - - #[tokio::test] - async fn stream_skips_non_stream_event_types() { - let server = MockServer::start(); - - let sse_body = "\ -event: ping\n\ -data: {}\n\ -\n\ -event: stream_event\n\ -data: {\"type\":\"stream_start\"}\n\ -\n"; - - server.mock(|when, then| { - when.method(POST).path("/completions"); - then.status(200) - .header("content-type", "text/event-stream") - .body(sse_body); - }); - - let adapter = Adapter::new( - fabro_test::test_http_client(), - server.base_url(), - "test-provider", - ); - - let mut stream = adapter.stream(&make_request()).await.unwrap(); - - // The ping event should be skipped, only StreamStart yielded - let event = stream.next().await.unwrap().unwrap(); - assert!(matches!(event, StreamEvent::StreamStart)); - - assert!(stream.next().await.is_none()); - } - - #[test] - fn map_stop_reason_variants() { - assert_eq!(map_stop_reason("end_turn"), FinishReason::Stop); - assert_eq!(map_stop_reason("stop"), FinishReason::Stop); - assert_eq!(map_stop_reason("max_tokens"), FinishReason::Length); - assert_eq!(map_stop_reason("length"), FinishReason::Length); - assert_eq!(map_stop_reason("tool_calls"), FinishReason::ToolCalls); - assert_eq!( - map_stop_reason("something_else"), - FinishReason::Other("something_else".to_string()) - ); - } - - #[test] - fn parse_sse_block_valid() { - let block = "event: stream_event\ndata: {\"type\":\"stream_start\"}"; - let (event_type, data) = parse_sse_block(block).unwrap(); - assert_eq!(event_type, Some("stream_event")); - assert_eq!(data, "{\"type\":\"stream_start\"}"); - } - - #[test] - fn parse_sse_block_missing_data() { - let block = "event: stream_event"; - assert!(parse_sse_block(block).is_none()); - } - - /// A block without an `event:` line parses with `event = None`; the - /// stream loop's `Some("stream_event")` match is what filters it out. - #[test] - fn parse_sse_block_missing_event() { - let block = "data: {\"type\":\"stream_start\"}"; - let (event_type, _) = parse_sse_block(block).unwrap(); - assert_eq!(event_type, None); - } - - #[test] - fn adapter_name() { - let adapter = Adapter::new( - fabro_test::test_http_client(), - "http://localhost", - "anthropic", - ); - assert_eq!(adapter.name(), "anthropic"); - } -} diff --git a/lib/components/fabro-llm/src/providers/gemini.rs b/lib/components/fabro-llm/src/providers/gemini.rs deleted file mode 100644 index e2a33337b..000000000 --- a/lib/components/fabro-llm/src/providers/gemini.rs +++ /dev/null @@ -1,272 +0,0 @@ -use std::sync::Arc; - -use fabro_model::Catalog; - -use crate::attachments::{self, AttachmentPolicy}; -use crate::codec::gemini_generate::GeminiGenerate; -use crate::codec::{Codec, CodecCtx, CodecParams, EncodedRequest}; -use crate::error::Error; -use crate::provider::{ - ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice, -}; -use crate::providers::common::{self as common, CatalogRoute}; -use crate::token_count::{InputTokenCount, InputTokenCountMethod}; -use crate::transport::{self, HttpTransport, SseFraming}; -use crate::types::{AdapterTimeout, Request, Response}; - -const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta"; - -/// Provider adapter for the Google Gemini `generateContent` API. -/// -/// A thin transport shell over the `gemini_generate` codec: it owns auth -/// (`x-goog-api-key`), base URL, and the streaming byte loop. All wire -/// translation — including the model-in-path endpoints — lives in the codec. -/// Gemini has no route variance (single auth scheme, count-tokens always -/// available, no forced streaming), so there is no route config. -pub struct Adapter { - pub(crate) http: HttpTransport, - provider_name: String, - catalog: Option>, -} - -impl Adapter { - #[must_use] - pub fn new(api_key: impl Into) -> Self { - Self::new_optional_auth(Some(api_key.into())) - } - - #[must_use] - pub fn new_optional_auth(api_key: Option) -> Self { - Self { - http: HttpTransport::new_optional(api_key, DEFAULT_BASE_URL), - provider_name: "gemini".to_string(), - catalog: None, - } - } - - #[must_use] - pub fn with_name(mut self, name: impl Into) -> Self { - self.provider_name = name.into(); - self - } - - #[must_use] - pub fn with_base_url(mut self, base_url: impl Into) -> Self { - self.http.base_url = base_url.into(); - self - } - - #[must_use] - pub fn with_default_headers(self, headers: std::collections::HashMap) -> Self { - Self { - http: self.http.with_default_headers(headers), - ..self - } - } - - #[must_use] - pub fn with_catalog(mut self, catalog: Arc) -> Self { - self.catalog = Some(catalog); - self - } - - #[must_use] - pub fn with_timeout(self, timeout: AdapterTimeout) -> Self { - Self { - http: self.http.with_timeout(timeout), - ..self - } - } - - /// Build the canonical request for the codec, resolving file-backed - /// attachments to inline data first. Borrowed when nothing needs loading. - async fn resolve_request<'a>(&self, request: &'a Request) -> std::borrow::Cow<'a, Request> { - // Gemini loads all three attachment kinds inline. - let policy = AttachmentPolicy { - images: true, - documents: true, - audio: true, - }; - attachments::resolve(request, policy).await - } - - /// Build the borrowed codec context. `deployment_id` and `params` are - /// created by the caller so their borrows outlive the context. - fn codec_ctx<'a>( - &'a self, - request: &'a Request, - deployment_id: &'a str, - params: &'a CodecParams, - ) -> CodecCtx<'a> { - CodecCtx { - request, - provider_name: &self.provider_name, - deployment_id, - model: self.catalog_model(&request.model), - params, - } - } - - /// Apply the base URL, auth (`x-goog-api-key`), and codec-emitted headers - /// to an encoded request. - fn build_http_request(&self, encoded: &EncodedRequest) -> fabro_http::RequestBuilder { - let url = format!("{}{}", self.http.base_url, encoded.endpoint); - let mut req = self.http.client.post(&url); - if let Some(api_key) = &self.http.api_key { - req = req.header("x-goog-api-key", api_key); - } - for (key, value) in &self.http.default_headers { - req = req.header(key, value); - } - for (key, value) in &encoded.headers { - req = req.header(key, value); - } - req.json(&encoded.body) - } -} - -impl common::CatalogRoute for Adapter { - fn catalog(&self) -> Option<&Catalog> { - self.catalog.as_deref() - } - - fn provider_name(&self) -> &str { - &self.provider_name - } -} - -#[async_trait::async_trait] -impl ProviderAdapter for Adapter { - fn name(&self) -> &str { - &self.provider_name - } - - fn validate_request(&self, request: &Request) -> Result<(), Error> { - validate_standard_speed(self, request)?; - if let Some(tc) = &request.tool_choice { - validate_tool_choice(self, tc)?; - } - Ok(()) - } - - async fn count_input_tokens( - &self, - request: &Request, - ) -> Result, Error> { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = GeminiGenerate; - let deployment_id = self.api_model_id(&resolved.model); - let params = CodecParams::default(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let Some(encoded) = codec.encode_count_tokens(&ctx).transpose()? else { - return Ok(None); - }; - - let mut req = self.build_http_request(&encoded); - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - let (body, _headers) = - transport::send_for_body(req, "input_token_count", &codec, &ctx).await?; - let input_tokens = codec.decode_count_tokens(&body)?; - - Ok(Some(InputTokenCount { - input_tokens, - method: InputTokenCountMethod::ProviderApi, - provider: self.provider_name.clone(), - model: request.model.clone(), - warnings: vec![], - })) - } - - async fn complete(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = GeminiGenerate; - let deployment_id = self.api_model_id(&resolved.model); - let params = CodecParams::default(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let encoded = codec.encode(&ctx, false)?; - let mut req = self.build_http_request(&encoded); - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - transport::complete_via_http(req, &codec, &ctx).await - } - - async fn stream(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = GeminiGenerate; - let deployment_id = self.api_model_id(&resolved.model); - let params = CodecParams::default(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let encoded = codec.encode(&ctx, true)?; - transport::stream_via_http( - self.build_http_request(&encoded), - &codec, - &ctx, - SseFraming::DataLines, - self.http.stream_read_timeout, - ) - .await - } -} - -#[cfg(test)] -mod tests { - use httpmock::prelude::*; - - use super::*; - use crate::types::Message; - - fn minimal_request() -> Request { - Request { - model: "gemini-2.0-flash".to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - #[tokio::test] - async fn count_input_tokens_posts_generate_content_request_and_parses_response() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST) - .path("/models/gemini-2.0-flash:countTokens") - .header("x-goog-api-key", "test-key"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({"totalTokens": 456})); - }); - let adapter = Adapter::new("test-key").with_base_url(server.base_url()); - - let count = adapter - .count_input_tokens(&minimal_request()) - .await - .unwrap() - .expect("gemini should count tokens"); - - mock.assert(); - assert_eq!(count.input_tokens, 456); - assert_eq!(count.method, InputTokenCountMethod::ProviderApi); - } -} diff --git a/lib/components/fabro-llm/src/providers/mod.rs b/lib/components/fabro-llm/src/providers/mod.rs deleted file mode 100644 index d0c72788d..000000000 --- a/lib/components/fabro-llm/src/providers/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod anthropic; -pub(crate) mod bedrock; -pub mod common; -pub mod fabro_server; -pub mod gemini; -pub mod openai; -pub mod openai_compatible; - -pub use anthropic::Adapter as AnthropicAdapter; -pub use bedrock::Adapter as BedrockAdapter; -pub use fabro_server::Adapter as FabroServerAdapter; -pub use gemini::Adapter as GeminiAdapter; -pub use openai::Adapter as OpenAiAdapter; -pub use openai_compatible::Adapter as OpenAiCompatibleAdapter; diff --git a/lib/components/fabro-llm/src/providers/openai.rs b/lib/components/fabro-llm/src/providers/openai.rs deleted file mode 100644 index b56159c2c..000000000 --- a/lib/components/fabro-llm/src/providers/openai.rs +++ /dev/null @@ -1,590 +0,0 @@ -use std::sync::Arc; - -use fabro_model::Catalog; - -use crate::attachments::{self, AttachmentPolicy}; -use crate::codec::openai_responses::OpenAiResponses; -use crate::codec::{Codec, CodecCtx, CodecParams, EncodedRequest}; -use crate::error::Error; -use crate::provider::{ - ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice, -}; -use crate::providers::common::{self as common, CatalogRoute}; -use crate::token_count::{InputTokenCount, InputTokenCountMethod}; -use crate::transport::{self, HttpTransport, SseFraming}; -use crate::types::{AdapterTimeout, Request, Response, StreamEvent}; - -const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; - -/// Provider adapter for the `OpenAI` Responses API (`/v1/responses`). -/// -/// A thin transport shell over the `openai_responses` codec: it owns auth -/// (bearer + org/project headers), base URL, the streaming byte loop, and the -/// route configuration for codex mode. All wire translation lives in the -/// codec. -/// -/// Per spec Section 2.7, this adapter uses the Responses API (not Chat -/// Completions) to properly surface reasoning tokens, built-in tools, and -/// server-side state. -pub struct Adapter { - pub(crate) http: HttpTransport, - org_id: Option, - project_id: Option, - provider_name: String, - catalog: Option>, - /// When true, always use streaming (required by the Codex endpoint). - codex_mode: bool, -} - -impl Adapter { - #[must_use] - pub fn new(api_key: impl Into) -> Self { - Self::new_optional_auth(Some(api_key.into())) - } - - #[must_use] - pub fn new_optional_auth(api_key: Option) -> Self { - Self { - http: HttpTransport::new_optional(api_key, DEFAULT_BASE_URL), - org_id: None, - project_id: None, - provider_name: "openai".to_string(), - catalog: None, - codex_mode: false, - } - } - - #[must_use] - pub fn with_name(mut self, name: impl Into) -> Self { - self.provider_name = name.into(); - self - } - - #[must_use] - pub fn with_codex_mode(mut self) -> Self { - self.codex_mode = true; - self - } - - #[must_use] - pub fn with_base_url(mut self, base_url: impl Into) -> Self { - self.http.base_url = base_url.into(); - self - } - - #[must_use] - pub fn with_org_id(mut self, org_id: impl Into) -> Self { - self.org_id = Some(org_id.into()); - self - } - - #[must_use] - pub fn with_project_id(mut self, project_id: impl Into) -> Self { - self.project_id = Some(project_id.into()); - self - } - - #[must_use] - pub fn with_default_headers(self, headers: std::collections::HashMap) -> Self { - Self { - http: self.http.with_default_headers(headers), - ..self - } - } - - #[must_use] - pub fn with_catalog(mut self, catalog: Arc) -> Self { - self.catalog = Some(catalog); - self - } - - #[must_use] - pub fn with_timeout(self, timeout: AdapterTimeout) -> Self { - Self { - http: self.http.with_timeout(timeout), - ..self - } - } - - /// Per-route dialect knobs for the codec. - /// - /// OpenAI has a single auth scheme (bearer + org/project headers), so the - /// only route variation is codex mode: its encode-side half (param - /// omission) rides on `CodecParams`; its transport-side half (forced - /// streaming) is checked directly off `codex_mode` in `complete`. - fn codec_params(&self) -> CodecParams { - CodecParams { - openai_codex: self.codex_mode, - ..CodecParams::default() - } - } - - /// Build the borrowed codec context. `deployment_id` and `params` are - /// created by the caller so their borrows outlive the context. - fn codec_ctx<'a>( - &'a self, - request: &'a Request, - deployment_id: &'a str, - params: &'a CodecParams, - ) -> CodecCtx<'a> { - CodecCtx { - request, - provider_name: &self.provider_name, - deployment_id, - model: self.catalog_model(&request.model), - params, - } - } - - /// Build the canonical request for the codec, resolving file-backed - /// attachments to inline data first. Borrowed when nothing needs loading. - async fn resolve_request<'a>(&self, request: &'a Request) -> std::borrow::Cow<'a, Request> { - // OpenAI loads images inline; audio and documents render as text - // placeholders in the codec, so they are not loaded here. - let policy = AttachmentPolicy { - images: true, - documents: false, - audio: false, - }; - attachments::resolve(request, policy).await - } - - /// Apply the base URL, auth (bearer + org/project headers), and - /// codec-emitted headers to an encoded request. - fn build_http_request(&self, encoded: &EncodedRequest) -> fabro_http::RequestBuilder { - let url = format!("{}{}", self.http.base_url, encoded.endpoint); - let mut req = self.http.client.post(&url); - // Apply default_headers first so adapter-specific headers can override - for (key, value) in &self.http.default_headers { - req = req.header(key, value); - } - if let Some(api_key) = &self.http.api_key { - req = req.bearer_auth(api_key); - } - if let Some(org_id) = &self.org_id { - req = req.header("OpenAI-Organization", org_id); - } - if let Some(project_id) = &self.project_id { - req = req.header("OpenAI-Project", project_id); - } - for (key, value) in &encoded.headers { - req = req.header(key, value); - } - req.json(&encoded.body) - } - - /// Complete a request by streaming and collecting the final response. - /// Used for the Codex endpoint which requires `stream: true`. - async fn complete_via_stream(&self, request: &Request) -> Result { - use futures::StreamExt; - let mut event_stream = self.stream(request).await?; - let mut last_response: Option = None; - while let Some(event) = event_stream.next().await { - if let StreamEvent::Finish { response, .. } = event? { - last_response = Some(*response); - break; - } - } - last_response.ok_or_else(|| Error::Network { - message: "Stream ended without a finish event".into(), - source: None, - }) - } -} - -impl common::CatalogRoute for Adapter { - fn catalog(&self) -> Option<&Catalog> { - self.catalog.as_deref() - } - - fn provider_name(&self) -> &str { - &self.provider_name - } -} - -#[async_trait::async_trait] -impl ProviderAdapter for Adapter { - fn name(&self) -> &str { - &self.provider_name - } - - fn validate_request(&self, request: &Request) -> Result<(), Error> { - validate_standard_speed(self, request)?; - if let Some(tc) = &request.tool_choice { - validate_tool_choice(self, tc)?; - } - Ok(()) - } - - async fn count_input_tokens( - &self, - request: &Request, - ) -> Result, Error> { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = OpenAiResponses; - let deployment_id = self.api_model_id(&resolved.model); - let params = self.codec_params(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let Some(encoded) = codec.encode_count_tokens(&ctx).transpose()? else { - return Ok(None); - }; - - let mut req = self.build_http_request(&encoded); - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - let (body, _headers) = - transport::send_for_body(req, "input_token_count", &codec, &ctx).await?; - let input_tokens = codec.decode_count_tokens(&body)?; - - Ok(Some(InputTokenCount { - input_tokens, - method: InputTokenCountMethod::ProviderApi, - provider: self.provider_name.clone(), - model: request.model.clone(), - warnings: vec![], - })) - } - - async fn complete(&self, request: &Request) -> Result { - self.validate_request(request)?; - - // Codex endpoint requires streaming; collect the stream into a - // response. - if self.codex_mode { - return self.complete_via_stream(request).await; - } - - let resolved = self.resolve_request(request).await; - let codec = OpenAiResponses; - let deployment_id = self.api_model_id(&resolved.model); - let params = self.codec_params(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let encoded = codec.encode(&ctx, false)?; - let mut req = self.build_http_request(&encoded); - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - transport::complete_via_http(req, &codec, &ctx).await - } - - async fn stream(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = OpenAiResponses; - let deployment_id = self.api_model_id(&resolved.model); - let params = self.codec_params(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let encoded = codec.encode(&ctx, true)?; - transport::stream_via_http( - self.build_http_request(&encoded), - &codec, - &ctx, - SseFraming::EventBlocks, - self.http.stream_read_timeout, - ) - .await - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::sync::{Arc, Mutex}; - - use httpmock::prelude::*; - use tracing::field::{Field, Visit}; - use tracing::{Event, Subscriber, subscriber}; - use tracing_subscriber::layer::{Context as SubscriberContext, SubscriberExt}; - use tracing_subscriber::{Layer, Registry}; - - use super::*; - use crate::error::ProviderErrorKind; - use crate::types::Message; - - fn minimal_request() -> Request { - Request { - model: "gpt-4o".to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - #[derive(Clone, Default)] - struct CapturedLogEvents(Arc>>); - - #[derive(Clone, Debug, Default)] - struct CapturedLogEvent { - message: Option, - fields: HashMap, - } - - struct CaptureLayer { - events: CapturedLogEvents, - } - - impl Layer for CaptureLayer - where - S: Subscriber, - { - fn on_event(&self, event: &Event<'_>, _ctx: SubscriberContext<'_, S>) { - let mut visitor = LogFieldVisitor::default(); - event.record(&mut visitor); - self.events.0.lock().unwrap().push(CapturedLogEvent { - message: visitor.message, - fields: visitor.fields, - }); - } - } - - #[derive(Default)] - struct LogFieldVisitor { - message: Option, - fields: HashMap, - } - - impl LogFieldVisitor { - fn record_value(&mut self, field: &Field, value: String) { - if field.name() == "message" { - self.message = Some(value); - } else { - self.fields.insert(field.name().to_string(), value); - } - } - } - - impl Visit for LogFieldVisitor { - fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { - self.record_value(field, format!("{value:?}")); - } - - fn record_str(&mut self, field: &Field, value: &str) { - self.record_value(field, value.to_string()); - } - - fn record_u64(&mut self, field: &Field, value: u64) { - self.record_value(field, value.to_string()); - } - - fn record_i64(&mut self, field: &Field, value: i64) { - self.record_value(field, value.to_string()); - } - } - - #[test] - fn adapter_with_org_id_sets_field() { - let adapter = Adapter::new("sk-test").with_org_id("org-123"); - assert_eq!(adapter.org_id.as_deref(), Some("org-123")); - } - - #[test] - fn adapter_with_project_id_sets_field() { - let adapter = Adapter::new("sk-test").with_project_id("proj-456"); - assert_eq!(adapter.project_id.as_deref(), Some("proj-456")); - } - - #[test] - fn adapter_with_default_headers_sets_field() { - let mut headers = HashMap::new(); - headers.insert("X-Custom".to_string(), "value".to_string()); - let adapter = Adapter::new("sk-test").with_default_headers(headers); - assert_eq!( - adapter - .http - .default_headers - .get("X-Custom") - .map(String::as_str), - Some("value") - ); - } - - #[test] - fn adapter_defaults_have_no_org_project_or_headers() { - let adapter = Adapter::new("sk-test"); - assert!(adapter.org_id.is_none()); - assert!(adapter.project_id.is_none()); - assert!(adapter.http.default_headers.is_empty()); - } - - #[tokio::test] - async fn count_input_tokens_posts_count_request_and_parses_response() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST).path("/responses/input_tokens"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "object": "response.input_tokens", - "input_tokens": 789 - })); - }); - let adapter = Adapter::new("sk-test").with_base_url(server.base_url()); - - let count = adapter - .count_input_tokens(&minimal_request()) - .await - .unwrap() - .expect("openai should count tokens"); - - mock.assert(); - assert_eq!(count.input_tokens, 789); - assert_eq!(count.method, InputTokenCountMethod::ProviderApi); - } - - #[tokio::test] - async fn count_input_tokens_logs_operation_on_provider_error() { - let events = CapturedLogEvents::default(); - let subscriber = Registry::default().with(CaptureLayer { - events: events.clone(), - }); - let _guard = subscriber::set_default(subscriber); - - let server = MockServer::start(); - server.mock(|when, then| { - when.method(POST).path("/responses/input_tokens"); - then.status(403) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "error": { - "message": "input token counts are not enabled", - "type": "permission_error", - "code": "insufficient_permissions" - } - })); - }); - let adapter = Adapter::new("sk-test").with_base_url(server.base_url()); - - let err = adapter - .count_input_tokens(&minimal_request()) - .await - .unwrap_err(); - - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::AccessDenied, - .. - })); - - let captured = events.0.lock().unwrap(); - let event = captured - .iter() - .find(|event| event.message.as_deref() == Some("Provider returned error")) - .expect("provider error log should be captured"); - - assert_eq!( - event.fields.get("provider").map(String::as_str), - Some("openai") - ); - assert_eq!(event.fields.get("status").map(String::as_str), Some("403")); - assert_eq!( - event.fields.get("operation").map(String::as_str), - Some("input_token_count") - ); - } - - #[tokio::test] - async fn count_input_tokens_rejects_wrong_response_object() { - let server = MockServer::start(); - server.mock(|when, then| { - when.method(POST).path("/responses/input_tokens"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "object": "other", - "input_tokens": 789 - })); - }); - let adapter = Adapter::new("sk-test").with_base_url(server.base_url()); - - let err = adapter - .count_input_tokens(&minimal_request()) - .await - .unwrap_err(); - - assert!(matches!(err, Error::Configuration { .. })); - } - - #[tokio::test] - async fn complete_classifies_insufficient_quota_as_quota_exceeded() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST).path("/responses"); - then.status(429) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "error": { - "message": "You exceeded your current quota.", - "type": "insufficient_quota" - } - })); - }); - let adapter = Adapter::new("sk-test").with_base_url(server.base_url()); - - let err = adapter - .complete(&minimal_request()) - .await - .expect_err("spent quota should fail the completion"); - - mock.assert(); - assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded)); - assert_eq!(err.status_code(), Some(429)); - assert!(!err.retryable()); - assert!(err.failover_eligible()); - match err { - Error::Provider { detail, .. } => { - assert_eq!(detail.error_code.as_deref(), Some("insufficient_quota")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[tokio::test] - async fn codex_complete_via_stream_propagates_stream_errors() { - let server = MockServer::start(); - let sse_body = r#"event: error -data: {"type":"error","error":{"type":"insufficient_quota","code":"insufficient_quota","message":"You exceeded your current quota."}} - -"#; - - server.mock(|when, then| { - when.method(POST).path("/responses"); - then.status(200) - .header("content-type", "text/event-stream") - .body(sse_body); - }); - - let adapter = Adapter::new("sk-test") - .with_base_url(server.base_url()) - .with_codex_mode(); - - let err = adapter - .complete(&minimal_request()) - .await - .expect_err("codex streaming completion should propagate stream errors"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::QuotaExceeded); - assert_eq!(detail.error_code.as_deref(), Some("insufficient_quota")); - assert!(detail.message.contains("exceeded your current quota")); - } - other => panic!("expected provider error, got {other:?}"), - } - } -} diff --git a/lib/components/fabro-llm/src/providers/openai_compatible.rs b/lib/components/fabro-llm/src/providers/openai_compatible.rs deleted file mode 100644 index b46444636..000000000 --- a/lib/components/fabro-llm/src/providers/openai_compatible.rs +++ /dev/null @@ -1,188 +0,0 @@ -use std::sync::Arc; - -use fabro_model::Catalog; - -use crate::codec::openai_compatible::OpenAiCompatible; -use crate::codec::{Codec, CodecCtx, CodecParams}; -use crate::error::Error; -use crate::provider::{ - ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice, -}; -use crate::providers::common::{self as common, CatalogRoute}; -use crate::transport::{self, HttpTransport, SseFraming}; -use crate::types::{AdapterTimeout, Request, Response}; - -/// `OpenAI`-compatible Chat Completions adapter (Section 7.10). -/// -/// Use this for third-party services (vLLM, Ollama, Together AI, Groq, etc.) -/// that implement the `OpenAI` Chat Completions API (`/v1/chat/completions`). -/// -/// Does NOT support reasoning tokens, built-in tools, or other Responses API -/// features. Use the primary `OpenAiAdapter` for `OpenAI`'s own API. -/// -/// This is a thin transport shell over the `openai_compatible` codec: it owns -/// auth, base URL, and the streaming byte loop, and delegates all wire -/// translation to the codec. -pub struct Adapter { - pub(crate) http: HttpTransport, - provider_name: String, - catalog: Option>, -} - -impl Adapter { - #[must_use] - pub fn new(api_key: impl Into, base_url: impl Into) -> Self { - Self::new_optional_auth(Some(api_key.into()), base_url) - } - - #[must_use] - pub fn new_optional_auth(api_key: Option, base_url: impl Into) -> Self { - Self { - http: HttpTransport::new_optional(api_key, base_url), - provider_name: "openai-compatible".to_string(), - catalog: None, - } - } - - #[must_use] - pub fn with_name(mut self, name: impl Into) -> Self { - self.provider_name = name.into(); - self - } - - #[must_use] - pub fn with_default_headers(self, headers: std::collections::HashMap) -> Self { - Self { - http: self.http.with_default_headers(headers), - ..self - } - } - - #[must_use] - pub fn with_catalog(mut self, catalog: Arc) -> Self { - self.catalog = Some(catalog); - self - } - - #[must_use] - pub fn with_timeout(self, timeout: AdapterTimeout) -> Self { - Self { - http: self.http.with_timeout(timeout), - ..self - } - } - - /// Build a `fabro_http::RequestBuilder` with default headers and auth. - fn build_request(&self, url: &str) -> fabro_http::RequestBuilder { - let mut req = self.http.client.post(url); - // Apply default_headers first so adapter-specific headers can override - for (key, value) in &self.http.default_headers { - req = req.header(key, value); - } - if let Some(api_key) = &self.http.api_key { - req = req.bearer_auth(api_key); - } - req - } - - /// Resolve the wire model id (catalog `api_id`, falling back to the - /// requested model). - fn deployment_id(&self, request: &Request) -> String { - self.api_model_id(&request.model) - } - - /// Build the borrowed codec context. `deployment_id` and `params` are - /// created by the caller so their borrows outlive the context. - fn codec_ctx<'a>( - &'a self, - request: &'a Request, - deployment_id: &'a str, - params: &'a CodecParams, - ) -> CodecCtx<'a> { - CodecCtx { - request, - provider_name: &self.provider_name, - deployment_id, - model: self.catalog_model(&request.model), - params, - } - } - - /// Encode `ctx.request` through the codec and assemble the HTTP request: - /// base URL + codec endpoint, default headers, auth, body, and dialect - /// headers. - fn encoded_request( - &self, - codec: &OpenAiCompatible, - ctx: &CodecCtx<'_>, - stream: bool, - ) -> Result { - let encoded = codec.encode(ctx, stream)?; - let url = format!("{}{}", self.http.base_url, encoded.endpoint); - let mut req = self.build_request(&url).json(&encoded.body); - for (key, value) in &encoded.headers { - req = req.header(key, value); - } - Ok(req) - } -} - -impl common::CatalogRoute for Adapter { - fn catalog(&self) -> Option<&Catalog> { - self.catalog.as_deref() - } - - fn provider_name(&self) -> &str { - &self.provider_name - } -} - -#[async_trait::async_trait] -impl ProviderAdapter for Adapter { - fn name(&self) -> &str { - &self.provider_name - } - - fn validate_request(&self, request: &Request) -> Result<(), Error> { - validate_standard_speed(self, request)?; - if let Some(tc) = &request.tool_choice { - validate_tool_choice(self, tc)?; - } - Ok(()) - } - - async fn complete(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let codec = OpenAiCompatible; - let deployment_id = self.deployment_id(request); - let params = CodecParams::default(); - let ctx = self.codec_ctx(request, &deployment_id, ¶ms); - - let mut req = self.encoded_request(&codec, &ctx, false)?; - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - - transport::complete_via_http(req, &codec, &ctx).await - } - - async fn stream(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let codec = OpenAiCompatible; - let deployment_id = self.deployment_id(request); - let params = CodecParams::default(); - let ctx = self.codec_ctx(request, &deployment_id, ¶ms); - - let req = self.encoded_request(&codec, &ctx, true)?; - transport::stream_via_http( - req, - &codec, - &ctx, - SseFraming::DataLines, - self.http.stream_read_timeout, - ) - .await - } -} diff --git a/lib/components/fabro-llm/src/reasoning.rs b/lib/components/fabro-llm/src/reasoning.rs index 6c12ee762..ca34594d2 100644 --- a/lib/components/fabro-llm/src/reasoning.rs +++ b/lib/components/fabro-llm/src/reasoning.rs @@ -1,10 +1,10 @@ //! Normalization of provider reasoning material into [`ReasoningOutput`]. //! //! Every provider that returns readable reasoning does it differently, and -//! several return more than one channel at once. This module reduces the -//! final response's content parts to the two normalized fields without -//! reaching into opaque material (signatures, item IDs, encrypted payloads) -//! and without failing a completion it cannot classify. +//! several return more than one channel at once. This module reduces a final +//! response's content parts to the two normalized fields without reaching +//! into opaque material (signatures, item ids, encrypted payloads) and +//! without failing a completion it cannot classify. //! //! Parsing is deliberately tolerant: provider payloads are read as //! `serde_json::Value` with optional lookups, so unknown detail variants, @@ -13,17 +13,16 @@ use fabro_types::{ContentPart, ReasoningOutput}; -/// Separator between distinct complete reasoning blocks. Fragments of one -/// logical block are coalesced by the streaming decoders before they reach -/// this module. +/// OpenAI Responses reasoning items, as lithos stores them. +pub const OPENAI_REASONING_KIND: &str = "openai.reasoning"; +/// OpenAI Responses message items, as lithos stores them. +pub const OPENAI_MESSAGE_KIND: &str = "openai.message"; +/// OpenAI-compatible `reasoning_details` arrays, as lithos stores them. +pub const OPENAI_COMPAT_REASONING_DETAILS_KIND: &str = "openai_compatible.reasoning_details"; + +/// Separator between distinct complete reasoning blocks. const BLOCK_SEPARATOR: &str = "\n\n"; -/// Readable blocks collected per normalized field. -/// -/// Explicit blocks come from a channel with documented reasoning semantics. -/// Fallback blocks come from flattened provider strings, which aggregators -/// commonly duplicate alongside a structured channel. They only fill a trace -/// that no explicit trace produced. #[derive(Default)] struct Blocks<'a> { explicit_summary: Vec<&'a str>, @@ -47,8 +46,6 @@ impl Blocks<'_> { } } -/// Join retained complete blocks in provider order. Text is never trimmed or -/// rewritten. fn join_blocks(blocks: &[&str]) -> Option { (!blocks.is_empty()).then(|| blocks.join(BLOCK_SEPARATOR)) } @@ -59,16 +56,10 @@ fn push_block<'a>(blocks: &mut Vec<&'a str>, block: &'a str) { } } -/// Read a text-bearing member with the provider's documented semantics. fn readable_member<'a>(entry: &'a serde_json::Value, member: &str) -> Option<&'a str> { entry.get(member).and_then(serde_json::Value::as_str) } -/// Extract readable text from an OpenAI Responses `reasoning` output item. -/// -/// `summary[].text` is the model-authored summary; `content[]` entries typed -/// `reasoning_text` are the verbatim trace. `encrypted_content`, `id`, and -/// `status` are opaque and ignored. fn collect_openai_reasoning_item<'a>(item: &'a serde_json::Value, blocks: &mut Blocks<'a>) { if let Some(entries) = item.get("summary").and_then(serde_json::Value::as_array) { for entry in entries { @@ -95,7 +86,6 @@ fn collect_openai_reasoning_item<'a>(item: &'a serde_json::Value, blocks: &mut B } } -/// Extract readable text from OpenAI-compatible `reasoning_details` entries. fn collect_reasoning_details<'a>(details: &'a serde_json::Value, blocks: &mut Blocks<'a>) { let Some(entries) = details.as_array() else { return; @@ -121,23 +111,21 @@ fn collect_reasoning_details<'a>(details: &'a serde_json::Value, blocks: &mut Bl } } -/// Normalize the content parts of a final response into readable reasoning. +/// Normalizes the content parts of a final response into readable reasoning. /// -/// Returns `None` when the response carries no readable reasoning, so an -/// event without reasoning keeps its previous serialized shape. -pub(crate) fn normalize(content: &[ContentPart]) -> Option { +/// Returns `None` when the response carries no readable reasoning. +#[must_use] +pub fn normalize(content: &[ContentPart]) -> Option { let mut blocks = Blocks::default(); for part in content { match part { - ContentPart::Thinking(thinking) if !thinking.redacted => { - push_block(&mut blocks.fallback_trace, &thinking.text); + ContentPart::Reasoning(reasoning) if !reasoning.redacted => { + push_block(&mut blocks.fallback_trace, &reasoning.text); } - ContentPart::Other { kind, data } if kind == ContentPart::OPENAI_REASONING => { + ContentPart::Opaque { kind, data } if kind == OPENAI_REASONING_KIND => { collect_openai_reasoning_item(data, &mut blocks); } - ContentPart::Other { kind, data } - if kind == ContentPart::OPENAI_COMPAT_REASONING_DETAILS => - { + ContentPart::Opaque { kind, data } if kind == OPENAI_COMPAT_REASONING_DETAILS_KIND => { collect_reasoning_details(data, &mut blocks); } _ => {} @@ -146,33 +134,47 @@ pub(crate) fn normalize(content: &[ContentPart]) -> Option { blocks.into_output() } +/// Whether a part is provider-native replay material Fabro keeps in history +/// but never renders. +#[must_use] +pub fn is_provider_part(part: &ContentPart) -> bool { + matches!(part, ContentPart::Reasoning(_) | ContentPart::Opaque { .. }) +} + +/// Whether a part is an OpenAI Responses item tied to one specific API +/// response. Such items become invalid once compaction replaces their +/// surrounding context. +#[must_use] +pub fn is_opaque_openai(part: &ContentPart) -> bool { + matches!( + part, + ContentPart::Opaque { kind, .. } + if kind == OPENAI_REASONING_KIND || kind == OPENAI_MESSAGE_KIND + ) +} + #[cfg(test)] mod tests { - use fabro_types::ThinkingData; + use fabro_types::ReasoningContent; use serde_json::json; use super::*; fn thinking(text: &str) -> ContentPart { - ContentPart::Thinking(ThinkingData { - text: text.to_string(), - signature: None, - redacted: false, + ContentPart::Reasoning(ReasoningContent { + text: text.to_string(), + signature: None, + signature_origin: None, + redacted: false, }) } fn openai_reasoning(item: serde_json::Value) -> ContentPart { - ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: item, - } + ContentPart::opaque(OPENAI_REASONING_KIND, item) } fn reasoning_details(details: serde_json::Value) -> ContentPart { - ContentPart::Other { - kind: ContentPart::OPENAI_COMPAT_REASONING_DETAILS.to_string(), - data: details, - } + ContentPart::opaque(OPENAI_COMPAT_REASONING_DETAILS_KIND, details) } #[test] @@ -184,10 +186,11 @@ mod tests { #[test] fn redacted_thinking_yields_no_readable_reasoning() { - let redacted = ContentPart::Thinking(ThinkingData { - text: "AAAAopaque".to_string(), - signature: Some("sig".to_string()), - redacted: true, + let redacted = ContentPart::Reasoning(ReasoningContent { + text: "AAAAopaque".to_string(), + signature: Some("sig".to_string()), + signature_origin: Some("anthropic".to_string()), + redacted: true, }); assert!(normalize(&[redacted]).is_none()); } @@ -218,58 +221,18 @@ mod tests { assert_eq!(output.summary(), Some("first\n\nsecond")); } - #[test] - fn unknown_responses_content_types_remain_opaque() { - assert!( - normalize(&[openai_reasoning(json!({ - "content": [{"type": "reasoning_future", "text": "not classified"}], - }))]) - .is_none() - ); - } - #[test] fn structured_details_produce_summary_and_trace() { let output = normalize(&[reasoning_details(json!([ {"type": "reasoning.summary", "summary": "checked the parser"}, {"type": "reasoning.text", "text": "read convert.rs", "signature": "sig"}, + {"type": "reasoning.encrypted", "data": "gAAAAAsecret"}, ]))]) .unwrap(); assert_eq!(output.summary(), Some("checked the parser")); assert_eq!(output.trace(), Some("read convert.rs")); } - #[test] - fn encrypted_details_are_excluded() { - let output = normalize(&[reasoning_details(json!([ - {"type": "reasoning.encrypted", "data": "gAAAAAsecret", "format": "openai-responses-v1"}, - {"type": "reasoning.summary", "summary": "visible"}, - ])),]) - .unwrap(); - assert_eq!(output.summary(), Some("visible")); - assert!(output.trace().is_none()); - } - - #[test] - fn encrypted_only_details_produce_no_reasoning() { - assert!( - normalize(&[reasoning_details(json!([ - {"type": "reasoning.encrypted", "data": "gAAAAAsecret"}, - ]))]) - .is_none() - ); - } - - #[test] - fn unknown_detail_variants_remain_opaque() { - assert!( - normalize(&[reasoning_details(json!([ - {"type": "reasoning.future", "text": "new channel"}, - ]))]) - .is_none() - ); - } - #[test] fn malformed_details_are_ignored_without_failing() { assert!(normalize(&[reasoning_details(json!("not-an-array"))]).is_none()); @@ -303,40 +266,27 @@ mod tests { thinking("flattened"), ]) .unwrap(); - assert!(output.summary().is_none()); assert_eq!(output.trace(), Some("verbatim")); } - #[test] - fn structured_summary_keeps_a_distinct_flattened_trace() { - let output = normalize(&[ - reasoning_details(json!([ - {"type": "reasoning.summary", "summary": "short summary"}, - ])), - thinking("full verbatim trace"), - ]) - .unwrap(); - assert_eq!(output.summary(), Some("short summary")); - assert_eq!(output.trace(), Some("full verbatim trace")); - } - #[test] fn whitespace_only_fragments_do_not_create_reasoning() { assert!(normalize(&[thinking(" \n ")]).is_none()); - } - - #[test] - fn non_empty_text_is_preserved_verbatim() { let output = normalize(&[thinking(" indented thought\n")]).unwrap(); assert_eq!(output.trace(), Some(" indented thought\n")); } #[test] - fn unrelated_content_parts_are_ignored() { - let parts = vec![ContentPart::text("answer"), ContentPart::Other { - kind: ContentPart::OPENAI_MESSAGE.to_string(), - data: json!({"type": "message", "content": [{"text": "answer"}]}), - }]; - assert!(normalize(&parts).is_none()); + fn opaque_openai_items_are_recognized() { + assert!(is_opaque_openai(&openai_reasoning(json!({})))); + assert!(is_opaque_openai(&ContentPart::opaque( + OPENAI_MESSAGE_KIND, + json!({}) + ))); + assert!(!is_opaque_openai(&thinking("x"))); + assert!(is_provider_part(&thinking("x"))); + assert!(!is_provider_part(&ContentPart::Text { + text: "x".to_string(), + })); } } diff --git a/lib/components/fabro-llm/src/retry.rs b/lib/components/fabro-llm/src/retry.rs deleted file mode 100644 index b036c73b4..000000000 --- a/lib/components/fabro-llm/src/retry.rs +++ /dev/null @@ -1,337 +0,0 @@ -use std::future::Future; -use std::time::Duration; - -use tokio::time; -use tracing::warn; - -use crate::error::Error; -use crate::types::RetryPolicy; - -/// Retry a fallible async operation according to the given policy (Section -/// 6.6). -/// -/// - Only retries if the error is retryable. -/// - Respects Retry-After from the error if less than `max_delay`. -/// - If Retry-After exceeds `max_delay`, does NOT retry. -/// -/// # Errors -/// -/// Returns the last `Error` if all retries are exhausted or the error is -/// non-retryable. -pub async fn retry(policy: &RetryPolicy, mut operation: F) -> Result -where - F: FnMut() -> Fut, - Fut: Future>, -{ - let mut attempt = 0u32; - - loop { - match operation().await { - Ok(result) => return Ok(result), - Err(err) => { - if !err.retryable() || attempt >= policy.max_retries { - return Err(err); - } - - let Some(delay) = retry_delay(policy, &err, attempt) else { - return Err(err); - }; - - warn!( - attempt = attempt, - delay_secs = delay.as_secs_f64(), - error = %err, - "LLM request failed, retrying" - ); - - if let Some(ref on_retry) = policy.on_retry { - on_retry(&err, attempt, delay); - } - - time::sleep(delay).await; - - attempt += 1; - } - } - } -} - -/// Return the delay for a retryable attempt, or `None` when `Retry-After` -/// exceeds the configured maximum delay. -#[must_use] -pub fn retry_delay(policy: &RetryPolicy, err: &Error, attempt: u32) -> Option { - if let Some(retry_after) = err.retry_after() { - let retry_after_dur = Duration::from_secs_f64(retry_after); - if retry_after_dur > policy.backoff.max_delay { - return None; - } - Some(retry_after_dur) - } else { - // Convert from 0-indexed (fabro-llm convention) to 1-indexed (BackoffPolicy). - Some(policy.backoff.delay_for_attempt(attempt + 1)) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - use std::sync::atomic::{AtomicU32, Ordering}; - - use fabro_util::backoff::BackoffPolicy; - use tokio::time::Instant; - - use super::*; - use crate::error::{ProviderErrorDetail, ProviderErrorKind}; - use crate::types::RetryPolicy; - - fn fast_backoff() -> BackoffPolicy { - BackoffPolicy { - initial_delay: Duration::from_micros(1), - factor: 2.0, - max_delay: Duration::from_mins(1), - jitter: false, - } - } - - #[tokio::test] - async fn retry_succeeds_first_try() { - let policy = RetryPolicy { - max_retries: 2, - backoff: BackoffPolicy { - jitter: false, - ..BackoffPolicy::default() - }, - ..Default::default() - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - cc.fetch_add(1, Ordering::SeqCst); - Ok::<_, Error>(42) - } - }) - .await; - - assert_eq!(result.unwrap(), 42); - assert_eq!(call_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn retry_succeeds_after_retries() { - let policy = RetryPolicy { - max_retries: 3, - backoff: fast_backoff(), - ..Default::default() - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - let count = cc.fetch_add(1, Ordering::SeqCst); - if count < 2 { - Err(Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail { - status_code: Some(500), - ..ProviderErrorDetail::new("error", "test") - }), - }) - } else { - Ok(99) - } - } - }) - .await; - - assert_eq!(result.unwrap(), 99); - assert_eq!(call_count.load(Ordering::SeqCst), 3); - } - - #[tokio::test] - async fn retry_gives_up_after_max_retries() { - let policy = RetryPolicy { - max_retries: 2, - backoff: fast_backoff(), - ..Default::default() - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - cc.fetch_add(1, Ordering::SeqCst); - Err::(Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail { - status_code: Some(500), - ..ProviderErrorDetail::new("error", "test") - }), - }) - } - }) - .await; - - assert!(result.is_err()); - assert_eq!(call_count.load(Ordering::SeqCst), 3); // 1 initial + 2 retries - } - - #[tokio::test] - async fn retry_does_not_retry_non_retryable() { - let policy = RetryPolicy { - max_retries: 3, - backoff: fast_backoff(), - ..Default::default() - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - cc.fetch_add(1, Ordering::SeqCst); - Err::(Error::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail { - status_code: Some(401), - ..ProviderErrorDetail::new("bad key", "test") - }), - }) - } - }) - .await; - - assert!(result.is_err()); - assert_eq!(call_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn retry_skips_when_retry_after_exceeds_max_delay() { - let policy = RetryPolicy { - max_retries: 3, - backoff: BackoffPolicy { - initial_delay: Duration::from_micros(1), - factor: 2.0, - max_delay: Duration::from_secs(5), - jitter: false, - }, - ..Default::default() - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - cc.fetch_add(1, Ordering::SeqCst); - Err::(Error::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail { - status_code: Some(429), - retry_after: Some(100.0), // Way beyond max_delay - ..ProviderErrorDetail::new("rate limited", "test") - }), - }) - } - }) - .await; - - assert!(result.is_err()); - assert_eq!(call_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn retry_uses_retry_after_when_within_limit() { - let policy = RetryPolicy { - max_retries: 1, - backoff: BackoffPolicy { - initial_delay: Duration::from_secs(10), // high, but retry_after is low - factor: 2.0, - max_delay: Duration::from_mins(1), - jitter: false, - }, - ..Default::default() - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let start = Instant::now(); - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - let count = cc.fetch_add(1, Ordering::SeqCst); - if count < 1 { - Err(Error::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail { - status_code: Some(429), - retry_after: Some(0.01), - ..ProviderErrorDetail::new("rate limited", "test") - }), - }) - } else { - Ok(42) - } - } - }) - .await; - - let elapsed = start.elapsed(); - assert_eq!(result.unwrap(), 42); - assert_eq!(call_count.load(Ordering::SeqCst), 2); - // Should have waited ~0.01s, not ~10s - assert!(elapsed.as_secs_f64() < 1.0); - } - - #[tokio::test] - async fn retry_invokes_on_retry_callback() { - let retry_attempts = Arc::new(AtomicU32::new(0)); - let retry_attempts_clone = retry_attempts.clone(); - - let policy = RetryPolicy { - max_retries: 2, - backoff: fast_backoff(), - on_retry: Some(Arc::new(move |_err, _attempt, _delay| { - retry_attempts_clone.fetch_add(1, Ordering::SeqCst); - })), - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - let count = cc.fetch_add(1, Ordering::SeqCst); - if count < 2 { - Err(Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail { - status_code: Some(500), - ..ProviderErrorDetail::new("error", "test") - }), - }) - } else { - Ok(99) - } - } - }) - .await; - - assert_eq!(result.unwrap(), 99); - assert_eq!(call_count.load(Ordering::SeqCst), 3); - // on_retry should have been called twice (before each retry) - assert_eq!(retry_attempts.load(Ordering::SeqCst), 2); - } -} diff --git a/lib/components/fabro-llm/src/selection.rs b/lib/components/fabro-llm/src/selection.rs new file mode 100644 index 000000000..80c6d6fbd --- /dev/null +++ b/lib/components/fabro-llm/src/selection.rs @@ -0,0 +1,423 @@ +//! Model selection shared by every Fabro dispatch boundary. +//! +//! lithos resolves a request's selector at call time. Fabro also has to pick +//! a provider and model before there is a request: when a run is created, +//! when a workflow is validated, when a fallback chain is compiled. Those +//! boundaries share one passthrough policy: +//! +//! - A selector known to the catalog resolves to its canonical offering. +//! - `provider/model` pins the provider, as the lithos resolver reads it. +//! - An unknown selector pinned to a provider passes through verbatim on that +//! provider. +//! - An unqualified unknown selector passes through on the default provider. +//! - No selector picks the default offering (of the pinned provider, when one +//! is given). +//! +//! Only enabled providers take part. Disabled ones are invisible here, +//! exactly as they are to the lithos resolver at request time. + +use std::collections::HashSet; +use std::fmt; + +use fabro_types::{ModelId, ProviderId}; +use lithos_llm::catalog::Catalog; +use thiserror::Error; + +use crate::catalog::{self, ModelEntry}; + +/// A provider/model pair one of the selection functions chose. +/// +/// `model` is the canonical catalog id when the selector matched an offering, +/// or the caller's selector passed through verbatim when it did not. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectedModel { + pub provider: ProviderId, + pub model: String, +} + +/// A resolved fallback target: provider id plus model id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FallbackTarget { + pub provider: ProviderId, + pub model: ModelId, +} + +impl FallbackTarget { + /// Builds a target from anything that renders as a provider id and model + /// id, so callers holding typed ids or bare passthrough selectors all use + /// one constructor. + pub fn new(provider: impl fmt::Display, model: impl fmt::Display) -> Self { + Self { + provider: ProviderId::new(provider.to_string()), + model: ModelId::new(model.to_string()), + } + } +} + +impl fmt::Display for FallbackTarget { + /// Renders as `provider:model`, the qualified form model references + /// accept. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.provider, self.model) + } +} + +/// Why a selection could not be made. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ModelSelectionError { + #[error("unknown model provider '{provider}'")] + UnknownProvider { provider: String }, + #[error("model provider '{provider}' is unavailable")] + ProviderUnavailable { provider: ProviderId }, + #[error("unknown model selector '{selector}'")] + UnknownSelector { selector: String }, + #[error("model selector '{selector}' is unknown on provider '{provider}'")] + UnknownSelectorOnProvider { + selector: String, + provider: ProviderId, + }, + #[error( + "model selector '{selector}' is known but has no offering on an eligible provider; available providers: {providers:?}" + )] + NoEligibleOffering { + selector: String, + providers: Vec, + }, + #[error( + "no default model is available on an eligible provider; providers with defaults: {providers:?}" + )] + NoDefaultModel { providers: Vec }, +} + +/// Canonicalizes a provider id or alias, requiring an enabled provider. +pub fn require_provider( + catalog: &Catalog, + selector: &str, +) -> Result { + catalog::canonical_provider_id(catalog, selector).ok_or_else(|| { + ModelSelectionError::UnknownProvider { + provider: selector.to_string(), + } + }) +} + +/// Canonicalizes a provider and requires it to be in the eligible set. +pub fn ready_provider( + catalog: &Catalog, + provider: &ProviderId, + eligible: &HashSet, +) -> Result { + let provider = require_provider(catalog, provider.as_str())?; + if canonical_eligible(catalog, eligible).contains(&provider) { + Ok(provider) + } else { + Err(ModelSelectionError::ProviderUnavailable { provider }) + } +} + +/// Finds `selector` as a model on an enabled provider. +pub fn resolve_on_provider<'a>( + catalog: &'a Catalog, + provider: &ProviderId, + selector: &str, +) -> Result, ModelSelectionError> { + let provider = require_provider(catalog, provider.as_str())?; + catalog::model_on_provider(catalog, provider.as_str(), selector).ok_or( + ModelSelectionError::UnknownSelectorOnProvider { + selector: selector.to_string(), + provider, + }, + ) +} + +/// Selects a catalog model for `selector`, requiring a real offering. +/// +/// With an explicit provider the model must exist there. Otherwise models +/// named `selector` are preferred over aliases, and the highest-priority +/// eligible offering wins. +pub fn select<'a>( + catalog: &'a Catalog, + selector: &str, + explicit_provider: Option<&ProviderId>, + eligible: &HashSet, +) -> Result, ModelSelectionError> { + if let Some(explicit) = explicit_provider { + let provider = ready_provider(catalog, explicit, eligible)?; + return resolve_on_provider(catalog, &provider, selector); + } + // `provider/model` pins the provider, exactly as the lithos resolver reads + // it at request time. A slash whose prefix is not a provider (an + // aggregator's `vendor/model` api id) falls through to plain matching. + if let Some((prefix, rest)) = selector.split_once('/') { + if let Some(provider) = catalog::canonical_provider_id(catalog, prefix) { + let provider = ready_provider(catalog, &provider, eligible)?; + return resolve_on_provider(catalog, &provider, rest); + } + } + let matches = catalog::models_matching(catalog, selector); + if matches.is_empty() { + return Err(ModelSelectionError::UnknownSelector { + selector: selector.to_string(), + }); + } + let eligible = canonical_eligible(catalog, eligible); + let providers: Vec = matches + .iter() + .map(|entry| entry.provider.id().clone()) + .collect(); + matches + .into_iter() + .find(|entry| eligible.contains(entry.provider.id())) + .ok_or(ModelSelectionError::NoEligibleOffering { + selector: selector.to_string(), + providers, + }) +} + +/// The default offering of the highest-priority eligible provider. +pub fn select_default<'a>( + catalog: &'a Catalog, + eligible: &HashSet, +) -> Result, ModelSelectionError> { + let eligible = canonical_eligible(catalog, eligible); + let providers_with_defaults: Vec<_> = catalog::enabled_providers(catalog) + .into_iter() + .filter_map(|provider| { + catalog::default_model(catalog, provider.id().as_str()) + .map(|model| (provider.id().clone(), model)) + }) + .collect(); + providers_with_defaults + .iter() + .find(|(provider, _)| eligible.contains(provider)) + .map(|(_, model)| model.clone()) + .ok_or_else(|| ModelSelectionError::NoDefaultModel { + providers: providers_with_defaults + .into_iter() + .map(|(provider, _)| provider) + .collect(), + }) +} + +/// Resolves an optional selector to one provider/model pair under Fabro's +/// passthrough policy (see the module docs). +pub fn resolve_selection( + catalog: &Catalog, + selector: Option<&str>, + explicit_provider: Option<&ProviderId>, + eligible: &HashSet, +) -> Result { + let Some(selector) = selector else { + let eligible = match explicit_provider { + Some(provider) => HashSet::from([ready_provider(catalog, provider, eligible)?]), + None => eligible.clone(), + }; + let offering = select_default(catalog, &eligible)?; + return Ok(SelectedModel { + provider: offering.provider.id().clone(), + model: offering.model.id().to_string(), + }); + }; + match select(catalog, selector, explicit_provider, eligible) { + Ok(offering) => Ok(SelectedModel { + provider: offering.provider.id().clone(), + model: offering.model.id().to_string(), + }), + Err(ModelSelectionError::UnknownSelectorOnProvider { provider, selector }) => { + Ok(SelectedModel { + provider, + model: selector, + }) + } + Err(ModelSelectionError::UnknownSelector { .. }) => { + let default = select_default(catalog, eligible)?; + Ok(SelectedModel { + provider: default.provider.id().clone(), + model: selector.to_string(), + }) + } + Err(error) => Err(error), + } +} + +/// Resolves against `preferred` providers first, falling back to every enabled +/// provider only when the preferred set cannot supply the requested provider +/// or model. Semantic failures such as an unknown provider do not fall back. +pub fn resolve_selection_with_catalog_fallback( + catalog: &Catalog, + selector: Option<&str>, + explicit_provider: Option<&ProviderId>, + preferred: &HashSet, +) -> Result { + match resolve_selection(catalog, selector, explicit_provider, preferred) { + Err( + ModelSelectionError::ProviderUnavailable { .. } + | ModelSelectionError::NoEligibleOffering { .. } + | ModelSelectionError::NoDefaultModel { .. }, + ) => resolve_selection( + catalog, + selector, + explicit_provider, + &catalog::enabled_provider_ids(catalog), + ), + result => result, + } +} + +fn canonical_eligible(catalog: &Catalog, eligible: &HashSet) -> HashSet { + eligible + .iter() + .filter_map(|id| catalog::canonical_provider_id(catalog, id.as_str())) + .collect() +} + +#[cfg(test)] +mod tests { + use fabro_types::provider_ids; + + use super::*; + use crate::test_support::{test_catalog, test_catalog_with_overlay}; + + fn eligible(ids: &[&str]) -> HashSet { + ids.iter().map(|id| ProviderId::new(*id)).collect() + } + + #[test] + fn known_alias_resolves_to_canonical_offering_on_an_eligible_provider() { + let catalog = test_catalog(); + let selected = + resolve_selection(&catalog, Some("sonnet"), None, &eligible(&["anthropic"])).unwrap(); + assert_eq!(selected, SelectedModel { + provider: provider_ids::anthropic(), + model: "claude-sonnet-5".to_string(), + }); + } + + #[test] + fn unknown_selector_passes_through_on_the_default_provider() { + let catalog = test_catalog(); + let selected = resolve_selection( + &catalog, + Some("totally-new-model"), + None, + &eligible(&["openai", "anthropic"]), + ) + .unwrap(); + assert_eq!(selected.provider, provider_ids::anthropic()); + assert_eq!(selected.model, "totally-new-model"); + } + + #[test] + fn slash_qualified_selector_pins_the_provider_like_the_lithos_resolver() { + let catalog = test_catalog(); + let selected = resolve_selection( + &catalog, + Some("openai/gpt-5.6-sol"), + None, + &eligible(&["openai", "anthropic"]), + ) + .unwrap(); + assert_eq!(selected, SelectedModel { + provider: provider_ids::openai(), + model: "gpt-5.6-sol".to_string(), + }); + + let unknown = resolve_selection( + &catalog, + Some("openai/brand-new-model"), + None, + &eligible(&["openai", "anthropic"]), + ) + .unwrap(); + assert_eq!(unknown, SelectedModel { + provider: provider_ids::openai(), + model: "brand-new-model".to_string(), + }); + + let unavailable = resolve_selection( + &catalog, + Some("openai/gpt-5.6-sol"), + None, + &eligible(&["anthropic"]), + ); + assert_eq!( + unavailable, + Err(ModelSelectionError::ProviderUnavailable { + provider: provider_ids::openai(), + }) + ); + } + + #[test] + fn slash_selector_with_a_non_provider_prefix_matches_api_ids_on_a_pinned_provider() { + let catalog = test_catalog_with_overlay("[providers.openrouter]\nenabled = true\n"); + let selected = resolve_selection( + &catalog, + Some("openai/gpt-5.6-sol"), + Some(&ProviderId::new("openrouter")), + &eligible(&["openrouter"]), + ) + .unwrap(); + assert_eq!(selected, SelectedModel { + provider: ProviderId::new("openrouter"), + model: "gpt-5.6-sol".to_string(), + }); + } + + #[test] + fn pinned_provider_must_be_eligible() { + let catalog = test_catalog(); + let error = resolve_selection( + &catalog, + Some("gpt-5.4"), + Some(&provider_ids::openai()), + &eligible(&["anthropic"]), + ) + .unwrap_err(); + assert!(matches!( + error, + ModelSelectionError::ProviderUnavailable { provider } if provider == provider_ids::openai() + )); + } + + #[test] + fn catalog_fallback_recovers_from_readiness_failures_only() { + let catalog = test_catalog(); + let selected = resolve_selection_with_catalog_fallback( + &catalog, + Some("gpt-5.4"), + Some(&provider_ids::openai()), + &eligible(&["anthropic"]), + ) + .unwrap(); + assert_eq!(selected.provider, provider_ids::openai()); + let error = resolve_selection_with_catalog_fallback( + &catalog, + None, + Some(&ProviderId::new("nope")), + &eligible(&["anthropic"]), + ) + .unwrap_err(); + assert!(matches!(error, ModelSelectionError::UnknownProvider { .. })); + } + + #[test] + fn disabled_providers_are_not_selectable() { + let catalog = test_catalog(); + assert!(matches!( + select(&catalog, "gpt-5.4", None, &eligible(&["openrouter"])), + Err(ModelSelectionError::NoEligibleOffering { .. }) + )); + let enabled = test_catalog_with_overlay("[providers.openrouter]\nenabled = true\n"); + let entry = select(&enabled, "gpt-5.4", None, &eligible(&["openrouter"])).unwrap(); + assert_eq!(entry.provider.id(), &ProviderId::new("openrouter")); + } + + #[test] + fn fallback_targets_render_qualified() { + assert_eq!( + FallbackTarget::new("openai", "gpt-5.4").to_string(), + "openai:gpt-5.4" + ); + } +} diff --git a/lib/components/fabro-llm/src/structured.rs b/lib/components/fabro-llm/src/structured.rs new file mode 100644 index 000000000..3bc5b730e --- /dev/null +++ b/lib/components/fabro-llm/src/structured.rs @@ -0,0 +1,103 @@ +//! One-shot structured output. + +use lithos_llm::client::Client; +use lithos_llm::middleware::CallContext; +use lithos_llm::types::{Error, ErrorKind, Request, Response, ResponseFormat}; + +/// A completion whose text parsed as the requested JSON object. +#[derive(Debug, Clone)] +pub struct StructuredCompletion { + pub response: Response, + pub object: serde_json::Value, +} + +/// Completes `request` under a JSON schema and parses the reply. +/// +/// The schema is attached as the request's response format, so providers +/// with native structured output enforce it. The reply text must still parse +/// as JSON; a reply that does not is a `ResponseDecode` error. +pub async fn complete_object( + client: &Client, + request: Request, + schema_name: &str, + schema: serde_json::Value, +) -> Result { + complete_object_with_context(client, request, schema_name, schema, CallContext::new()).await +} + +pub async fn complete_object_with_context( + client: &Client, + request: Request, + schema_name: &str, + schema: serde_json::Value, + context: CallContext, +) -> Result { + let request = request + .into_builder() + .response_format(ResponseFormat::JsonSchema { + name: schema_name.to_string(), + schema, + }) + .build() + .map_err(|source| { + Error::new( + ErrorKind::InvalidRequest, + "structured output request is invalid", + ) + .with_source(source) + })?; + let response = client.complete_with_context(request, context).await?; + let object = parse_object(&response)?; + Ok(StructuredCompletion { response, object }) +} + +/// Parses a response's JSON output: a `Json` part when the provider returned +/// one, else the concatenated text. +pub fn parse_object(response: &Response) -> Result { + if let Some(value) = response.content.iter().find_map(|part| match part { + fabro_types::ContentPart::Json { value } => Some(value.clone()), + _ => None, + }) { + return Ok(value); + } + let text = response.text(); + serde_json::from_str(text.trim()).map_err(|source| { + Error::new( + ErrorKind::ResponseDecode, + format!("the model did not return a JSON object: {source}"), + ) + .with_provider(response.model.provider().clone()) + .with_source(source) + }) +} + +#[cfg(test)] +mod tests { + use fabro_types::{ContentPart, ModelId, ProviderId}; + use serde_json::json; + + use super::*; + + fn response(parts: Vec) -> Response { + Response::new(ProviderId::new("openai"), ModelId::new("gpt-5.4"), parts) + } + + #[test] + fn parses_text_or_json_parts() { + let text = response(vec![ContentPart::Text { + text: " {\"title\": \"x\"} ".to_string(), + }]); + assert_eq!(parse_object(&text).unwrap(), json!({"title": "x"})); + let json = response(vec![ContentPart::Json { + value: json!({"a": 1}), + }]); + assert_eq!(parse_object(&json).unwrap(), json!({"a": 1})); + let prose = response(vec![ContentPart::Text { + text: "sorry".to_string(), + }]); + assert_eq!( + parse_object(&prose).unwrap_err().kind(), + ErrorKind::ResponseDecode + ); + } +} diff --git a/lib/components/fabro-llm/src/test_support.rs b/lib/components/fabro-llm/src/test_support.rs new file mode 100644 index 000000000..ac008e96f --- /dev/null +++ b/lib/components/fabro-llm/src/test_support.rs @@ -0,0 +1,253 @@ +//! Test doubles for crates that drive the LLM client. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use async_trait::async_trait; +use fabro_auth::test_support::env_credential_source; +use fabro_config::LlmLayer; +use fabro_types::{ContentPart, ModelId, ProviderId, TokenCounts}; +use futures::stream; +use lithos_llm::adapter::{ProviderAdapter, ResolvedCall}; +use lithos_llm::catalog::{AdapterId, Catalog}; +use lithos_llm::client::Client; +use lithos_llm::middleware::RetryPolicy; +use lithos_llm::types::{ + ContentBlockId, ContentBlockKind, Error, FinishReason, Response, ResponseStream, StreamEvent, + ToolCallKind, +}; + +use crate::client::{ClientOptions, build_client, build_offline_client}; + +/// The lithos built-in catalog, as Fabro ships it. +#[must_use] +pub fn test_catalog() -> Catalog { + crate::build_catalog(&LlmLayer::default(), &|_| None).expect("test catalog should build") +} + +/// The test catalog with an operator overlay applied. +#[must_use] +pub fn test_catalog_with_overlay(overlay: &str) -> Catalog { + let overlay = LlmLayer(toml::from_str(overlay).expect("overlay should parse")); + crate::build_catalog(&overlay, &|_| None).expect("test catalog with overlay should build") +} + +/// The test catalog with one provider's base URL pointed elsewhere. +#[must_use] +pub fn test_catalog_with_provider_base_url(provider: &str, base_url: &str) -> Catalog { + test_catalog_with_overlay(&format!( + "[providers.{provider}]\nbase_url = {}\n", + toml::Value::String(base_url.to_string()) + )) +} + +/// Builds a text response attributed to `provider/model`. +#[must_use] +pub fn text_response(provider: &str, model: &str, text: &str) -> Response { + let mut response = Response::new(ProviderId::new(provider), ModelId::new(model), vec![ + ContentPart::Text { + text: text.to_string(), + }, + ]); + response.usage = TokenCounts { + input: 10, + output: 5, + ..TokenCounts::default() + }; + response +} + +/// Replays a response as the event stream a codec would produce. +#[must_use] +pub fn response_to_stream(response: Response) -> ResponseStream { + let mut events: Vec> = vec![Ok(StreamEvent::Started { + id: response.id.clone(), + })]; + for (index, part) in response.content.iter().enumerate() { + let id = ContentBlockId::new(format!("block_{index}")); + match part { + ContentPart::Text { text } => { + events.push(Ok(StreamEvent::ContentBlockStart { + id: id.clone(), + kind: ContentBlockKind::Text, + })); + events.push(Ok(StreamEvent::TextDelta { + id: id.clone(), + text: text.clone(), + })); + } + ContentPart::Reasoning(reasoning) => { + events.push(Ok(StreamEvent::ContentBlockStart { + id: id.clone(), + kind: ContentBlockKind::Reasoning, + })); + events.push(Ok(StreamEvent::ReasoningDelta { + id: id.clone(), + text: reasoning.text.clone(), + })); + } + ContentPart::ToolCall(call) => { + events.push(Ok(StreamEvent::ContentBlockStart { + id: id.clone(), + kind: ContentBlockKind::ToolCall { + id: call.id.clone(), + name: Some(call.name.clone()), + kind: match call.input { + fabro_types::ToolInput::Custom(_) => ToolCallKind::Custom, + _ => ToolCallKind::Function, + }, + }, + })); + events.push(Ok(StreamEvent::ToolCallDelta { + id: id.clone(), + arguments: call.input.raw().to_string(), + })); + } + _ => {} + } + events.push(Ok(StreamEvent::ContentBlockEnd { + id, + part: part.clone(), + })); + } + events.push(Ok(StreamEvent::Usage { + usage: response.usage, + })); + events.push(Ok(StreamEvent::Ended { + response: Box::new(response), + })); + ResponseStream::new(stream::iter(events)) +} + +/// An adapter that answers from a script of responses, repeating the last. +pub struct ScriptedAdapter { + id: AdapterId, + responses: Vec, + call_index: AtomicUsize, +} + +impl ScriptedAdapter { + #[must_use] + pub fn new(responses: Vec) -> Self { + Self { + id: AdapterId::new("scripted"), + responses, + call_index: AtomicUsize::new(0), + } + } + + fn next_response(&self) -> Response { + let index = self.call_index.fetch_add(1, Ordering::SeqCst); + self.responses[index.min(self.responses.len() - 1)].clone() + } + + #[must_use] + pub fn calls(&self) -> usize { + self.call_index.load(Ordering::SeqCst) + } +} + +#[async_trait] +impl ProviderAdapter for ScriptedAdapter { + fn id(&self) -> &AdapterId { + &self.id + } + + async fn complete(&self, _call: &ResolvedCall) -> Result { + Ok(self.next_response()) + } + + async fn stream(&self, _call: &ResolvedCall) -> Result { + Ok(response_to_stream(self.next_response())) + } +} + +/// A retry policy for tests: three attempts with no delay between them, so a +/// test counts provider calls without waiting. +pub fn test_retry_policy() -> RetryPolicy { + RetryPolicy::exponential() + .max_attempts(3) + .initial_delay(Duration::ZERO) + .max_delay(Duration::ZERO) + .jitter(false) +} + +/// An adapter that fails every call with a fresh error from `factory`. +pub struct FailingAdapter { + id: AdapterId, + factory: Box Error + Send + Sync>, + calls: AtomicUsize, +} + +impl FailingAdapter { + pub fn new(factory: impl Fn() -> Error + Send + Sync + 'static) -> Self { + Self { + id: AdapterId::new("failing"), + factory: Box::new(factory), + calls: AtomicUsize::new(0), + } + } + + #[must_use] + pub fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +#[async_trait] +impl ProviderAdapter for FailingAdapter { + fn id(&self) -> &AdapterId { + &self.id + } + + async fn complete(&self, _call: &ResolvedCall) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Err((self.factory)()) + } + + async fn stream(&self, _call: &ResolvedCall) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Err((self.factory)()) + } +} + +/// A client over the test catalog that routes `provider` to `adapter`, with +/// no retries. +#[must_use] +pub fn client_with_adapter(provider: &str, adapter: Arc) -> Client { + client_with_adapters(vec![(provider, adapter)], ClientOptions::default()) +} + +/// A client over the test catalog whose providers are all served by the given +/// adapters, built with `options`. +#[must_use] +pub fn client_with_adapters( + adapters: Vec<(&str, Arc)>, + mut options: ClientOptions, +) -> Client { + for (provider, adapter) in adapters { + options.adapters.push((ProviderId::new(provider), adapter)); + } + build_offline_client(test_catalog(), options) + .expect("test client should build") + .client +} + +/// A client whose ready providers come only from `env_lookup`. +pub async fn client_from_env(catalog: Catalog, env_lookup: F, options: ClientOptions) -> Client +where + F: Fn(&str) -> Option + Send + Sync + 'static, +{ + build_client(catalog, env_credential_source(env_lookup), options) + .await + .expect("test client should build") + .client +} + +/// A finished response marker for tests that need a finish reason. +#[must_use] +pub fn with_finish_reason(mut response: Response, finish_reason: FinishReason) -> Response { + response.finish_reason = finish_reason; + response +} diff --git a/lib/components/fabro-llm/src/token_count.rs b/lib/components/fabro-llm/src/token_count.rs deleted file mode 100644 index fa64abda7..000000000 --- a/lib/components/fabro-llm/src/token_count.rs +++ /dev/null @@ -1,460 +0,0 @@ -use std::collections::HashSet; - -use serde::{Deserialize, Serialize}; - -use crate::types::{ - AudioData, ContentPart, DocumentData, ImageData, Message, Request, Role, ToolDefinition, - ToolResult, Warning, -}; - -/// Warning code emitted when the entire request was tokenized locally -/// (no provider-side count available). -pub const LOCAL_ESTIMATE_WARNING: &str = "local_token_estimate"; -/// Warning code emitted when media (image/audio/document) tokens were -/// estimated from byte counts rather than counted by the provider. -pub const MEDIA_ESTIMATE_WARNING: &str = "media_token_estimate"; -/// Warning code emitted when an opaque `ContentPart::Other` block was -/// estimated by JSON-stringifying it (e.g. OpenAI reasoning items). -pub const OPAQUE_CONTEXT_ESTIMATE_WARNING: &str = "opaque_context_estimate"; -/// Warning code emitted when provider-specific request options were -/// estimated by JSON-stringifying them. -pub const PROVIDER_OPTIONS_ESTIMATE_WARNING: &str = "provider_options_estimate"; - -/// True if a warning code is "local estimator noise" — the warning is only -/// meaningful when the displayed total comes from the local estimator. When -/// the total is provider-authoritative (e.g. scaled to `usage.input_tokens`), -/// these warnings only describe imprecision in the per-category breakdown -/// split, not in the total — and so they tend to alarm users about a number -/// that's actually correct. -#[must_use] -pub fn is_local_estimator_warning(code: &str) -> bool { - matches!( - code, - LOCAL_ESTIMATE_WARNING - | MEDIA_ESTIMATE_WARNING - | OPAQUE_CONTEXT_ESTIMATE_WARNING - | PROVIDER_OPTIONS_ESTIMATE_WARNING - ) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum InputTokenCountPreference { - PreferProvider, - RequireProvider, - EstimateOnly, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum InputTokenCountMethod { - ProviderApi, - LocalEstimate, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct InputTokenCount { - pub input_tokens: i64, - pub method: InputTokenCountMethod, - pub provider: String, - pub model: String, - #[serde(default)] - pub warnings: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct LocalTokenEstimate { - pub tokens: usize, - pub warnings: Vec, -} - -#[must_use] -pub fn estimate_input_tokens(request: &Request, provider: impl Into) -> InputTokenCount { - let mut estimator = Estimator::default(); - let mut tokens = 0usize; - - for message in &request.messages { - tokens += estimator.estimate_message(message); - } - - if let Some(tools) = &request.tools { - tokens += tools.iter().map(estimate_tool).sum::(); - } - - tokens += estimator.estimate_request_controls(request); - - estimator.warn( - LOCAL_ESTIMATE_WARNING, - "Provider didn't report a token count; total is approximate.", - ); - - InputTokenCount { - input_tokens: i64::try_from(tokens).unwrap_or(i64::MAX), - method: InputTokenCountMethod::LocalEstimate, - provider: provider.into(), - model: request.model.clone(), - warnings: estimator.warnings, - } -} - -#[must_use] -pub fn estimate_text_tokens(text: &str) -> usize { - text.chars().count().div_ceil(4) -} - -#[must_use] -pub fn estimate_json_tokens(value: &serde_json::Value) -> usize { - serde_json::to_string(value).map_or(0, |json| json.len().div_ceil(4)) -} - -#[must_use] -pub fn estimate_message_tokens(message: &Message) -> LocalTokenEstimate { - let mut estimator = Estimator::default(); - let tokens = estimator.estimate_message(message); - LocalTokenEstimate { - tokens, - warnings: estimator.warnings, - } -} - -#[must_use] -pub fn estimate_content_part_tokens(part: &ContentPart) -> LocalTokenEstimate { - let mut estimator = Estimator::default(); - let tokens = estimator.estimate_content_part(part); - LocalTokenEstimate { - tokens, - warnings: estimator.warnings, - } -} - -#[must_use] -pub fn estimate_tool_definition_tokens(tool: &ToolDefinition) -> usize { - estimate_tool(tool) -} - -#[must_use] -pub fn estimate_request_control_tokens(request: &Request) -> LocalTokenEstimate { - let mut estimator = Estimator::default(); - let tokens = estimator.estimate_request_controls(request); - LocalTokenEstimate { - tokens, - warnings: estimator.warnings, - } -} - -#[derive(Default)] -struct Estimator { - warnings: Vec, - seen_codes: HashSet<&'static str>, -} - -impl Estimator { - fn estimate_message(&mut self, message: &Message) -> usize { - let mut tokens = 4 + estimate_text_tokens(message.role_name()); - if let Some(name) = &message.name { - tokens += estimate_text_tokens(name); - } - if let Some(tool_call_id) = &message.tool_call_id { - tokens += estimate_text_tokens(tool_call_id); - } - for part in &message.content { - tokens += 1 + self.estimate_content_part(part); - } - tokens - } - - fn estimate_request_controls(&mut self, request: &Request) -> usize { - let mut tokens = 0; - if let Some(tool_choice) = &request.tool_choice { - if let Ok(value) = serde_json::to_value(tool_choice) { - tokens += estimate_json_tokens(&value); - } - } - - if let Some(response_format) = &request.response_format { - if let Ok(value) = serde_json::to_value(response_format) { - tokens += estimate_json_tokens(&value); - } - } - - if let Some(reasoning_effort) = request.reasoning_effort { - tokens += estimate_text_tokens(reasoning_effort.to_string().as_str()); - } - - if let Some(provider_options) = &request.provider_options { - tokens += estimate_json_tokens(provider_options); - self.warn( - PROVIDER_OPTIONS_ESTIMATE_WARNING, - "Provider options couldn't be precisely tokenized; total is approximate.", - ); - } - tokens - } - - fn estimate_content_part(&mut self, part: &ContentPart) -> usize { - match part { - ContentPart::Text(text) => estimate_text_tokens(text), - ContentPart::Image(image) => self.estimate_image(image), - ContentPart::Audio(audio) => self.estimate_audio(audio), - ContentPart::Document(document) => self.estimate_document(document), - ContentPart::ToolCall(tool_call) => estimate_json_tokens(&serde_json::json!(tool_call)), - ContentPart::ToolResult(result) => self.estimate_tool_result(result), - ContentPart::Thinking(thinking) => { - estimate_text_tokens(&thinking.text) - + thinking - .signature - .as_deref() - .map_or(0, estimate_text_tokens) - + usize::from(thinking.redacted) - } - ContentPart::Other { kind, data } => { - self.warn( - OPAQUE_CONTEXT_ESTIMATE_WARNING, - "Some content couldn't be precisely tokenized; total is approximate.", - ); - estimate_text_tokens(kind) + estimate_json_tokens(data) - } - } - } - - fn estimate_tool_result(&mut self, result: &ToolResult) -> usize { - let mut tokens = - estimate_text_tokens(&result.tool_call_id) + estimate_json_tokens(&result.content); - if let Some(image_data) = &result.image_data { - tokens += estimate_byte_tokens(image_data.len()); - self.warn( - MEDIA_ESTIMATE_WARNING, - "Media content couldn't be precisely tokenized; total is approximate.", - ); - } - if let Some(media_type) = &result.image_media_type { - tokens += estimate_text_tokens(media_type); - } - tokens + usize::from(result.is_error) - } - - fn estimate_image(&mut self, image: &ImageData) -> usize { - let mut tokens = - self.estimate_media_common(image.url.as_deref(), image.media_type.as_deref()); - if let Some(detail) = &image.detail { - tokens += estimate_text_tokens(detail); - } - tokens - + image - .data - .as_ref() - .map_or(2000, |data| estimate_byte_tokens(data.len()).max(2000)) - } - - fn estimate_audio(&mut self, audio: &AudioData) -> usize { - let tokens = self.estimate_media_common(audio.url.as_deref(), audio.media_type.as_deref()); - tokens - + audio - .data - .as_ref() - .map_or(2000, |data| estimate_byte_tokens(data.len())) - } - - fn estimate_document(&mut self, document: &DocumentData) -> usize { - let mut tokens = - self.estimate_media_common(document.url.as_deref(), document.media_type.as_deref()); - if let Some(file_name) = &document.file_name { - tokens += estimate_text_tokens(file_name); - } - tokens - + document - .data - .as_ref() - .map_or(2000, |data| estimate_byte_tokens(data.len())) - } - - fn estimate_media_common(&mut self, url: Option<&str>, media_type: Option<&str>) -> usize { - self.warn( - MEDIA_ESTIMATE_WARNING, - "Media content couldn't be precisely tokenized; total is approximate.", - ); - url.map_or(0, estimate_text_tokens) + media_type.map_or(0, estimate_text_tokens) - } - - fn warn(&mut self, code: &'static str, message: &'static str) { - if self.seen_codes.insert(code) { - self.warnings.push(Warning { - message: message.to_string(), - code: Some(code.to_string()), - }); - } - } -} - -fn estimate_tool(tool: &ToolDefinition) -> usize { - 8 + estimate_text_tokens(&tool.name) - + estimate_text_tokens(&tool.description) - + estimate_json_tokens(&tool.parameters) -} - -/// Approximate the token cost of a raw byte payload (4 bytes per token). -#[must_use] -pub fn estimate_byte_tokens(byte_len: usize) -> usize { - byte_len.div_ceil(4) -} - -trait RoleName { - fn role_name(&self) -> &'static str; -} - -impl RoleName for Message { - fn role_name(&self) -> &'static str { - match self.role { - Role::System => "system", - Role::User => "user", - Role::Assistant => "assistant", - Role::Tool => "tool", - Role::Developer => "developer", - } - } -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - use crate::types::{ - DocumentData, ImageData, Request, ResponseFormat, ResponseFormatType, ToolDefinition, - }; - - fn request(messages: Vec) -> Request { - Request { - model: "model-a".to_string(), - messages, - provider: Some("test".to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - fn warning_codes(count: &InputTokenCount) -> Vec<&str> { - count - .warnings - .iter() - .filter_map(|warning| warning.code.as_deref()) - .collect() - } - - #[test] - fn text_only_request_returns_positive_local_estimate() { - let count = estimate_input_tokens(&request(vec![Message::user("hello world")]), "test"); - - assert!(count.input_tokens > 0); - assert_eq!(count.method, InputTokenCountMethod::LocalEstimate); - assert!(warning_codes(&count).contains(&LOCAL_ESTIMATE_WARNING)); - } - - #[test] - fn adding_tool_increases_estimate() { - let mut with_tool = request(vec![Message::user("hello")]); - let without_tool = estimate_input_tokens(&with_tool, "test"); - - with_tool.tools = Some(vec![ToolDefinition::function( - "search", - "Search files", - json!({"type": "object", "properties": {"query": {"type": "string"}}}), - )]); - let with_tool = estimate_input_tokens(&with_tool, "test"); - - assert!(with_tool.input_tokens > without_tool.input_tokens); - } - - #[test] - fn adding_response_format_increases_estimate() { - let mut with_schema = request(vec![Message::user("hello")]); - let without_schema = estimate_input_tokens(&with_schema, "test"); - - with_schema.response_format = Some(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some( - json!({"type": "object", "properties": {"answer": {"type": "string"}}}), - ), - strict: true, - }); - let with_schema = estimate_input_tokens(&with_schema, "test"); - - assert!(with_schema.input_tokens > without_schema.input_tokens); - } - - #[test] - fn media_content_gets_media_warning_and_sized_estimate() { - let count = estimate_input_tokens( - &request(vec![Message { - role: Role::User, - content: vec![ - ContentPart::Image(ImageData { - url: Some("https://example.test/image.png".to_string()), - data: None, - media_type: Some("image/png".to_string()), - detail: Some("high".to_string()), - }), - ContentPart::Document(DocumentData { - url: None, - data: Some(vec![0; 4096]), - media_type: Some("application/pdf".to_string()), - file_name: Some("doc.pdf".to_string()), - }), - ], - name: None, - tool_call_id: None, - }]), - "test", - ); - - assert!(count.input_tokens >= 3000); - assert!(warning_codes(&count).contains(&MEDIA_ESTIMATE_WARNING)); - } - - #[test] - fn provider_options_produce_provider_options_warning() { - let mut req = request(vec![Message::user("hello")]); - req.provider_options = Some(json!({"gemini": {"cached_content": "cachedContents/1"}})); - - let count = estimate_input_tokens(&req, "test"); - - assert!(warning_codes(&count).contains(&PROVIDER_OPTIONS_ESTIMATE_WARNING)); - } - - #[test] - fn opaque_content_produces_opaque_warning() { - let count = estimate_input_tokens( - &request(vec![Message { - role: Role::Assistant, - content: vec![ContentPart::Other { - kind: "openai_reasoning".to_string(), - data: json!({"id": "rs_123", "summary": []}), - }], - name: None, - tool_call_id: None, - }]), - "test", - ); - - assert!(warning_codes(&count).contains(&OPAQUE_CONTEXT_ESTIMATE_WARNING)); - } - - #[test] - fn estimate_is_deterministic() { - let req = request(vec![Message::user("repeatable")]); - - assert_eq!( - estimate_input_tokens(&req, "test"), - estimate_input_tokens(&req, "test") - ); - } -} diff --git a/lib/components/fabro-llm/src/tools.rs b/lib/components/fabro-llm/src/tools.rs deleted file mode 100644 index 589488115..000000000 --- a/lib/components/fabro-llm/src/tools.rs +++ /dev/null @@ -1,523 +0,0 @@ -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - -use tokio_util::sync::CancellationToken; -use tracing::{debug, warn}; - -use crate::types::{Message, ToolCall, ToolDefinition, ToolResult}; - -/// Context passed to tool execute handlers (Section 5.2). -#[derive(Clone)] -pub struct ToolContext { - pub tool_call_id: String, - pub messages: Vec, - pub abort_signal: Option, -} - -/// An execute handler for a tool. -pub type ExecuteHandler = Arc< - dyn Fn( - serde_json::Value, - ToolContext, - ) -> Pin> + Send>> - + Send - + Sync, ->; - -/// A tool with an optional execute handler (Section 5.1, 5.5). -/// "Active" tools have an execute handler and are automatically executed. -/// "Passive" tools have no handler and are returned to the caller. -pub struct Tool { - pub definition: ToolDefinition, - pub execute: Option, -} - -impl Tool { - /// Create a passive tool (no execute handler). - /// - /// # Panics - /// - /// Panics if the tool name is invalid (see [`validate_tool_name`]). - /// Tool names are always hardcoded string literals in this codebase; this - /// guards against programming errors where a constant would fail - /// validation. - #[must_use] - pub fn passive(name: &str, description: &str, parameters: serde_json::Value) -> Self { - if let Err(e) = validate_tool_name(name) { - panic!( - "tool name `{name}` must be a valid identifier ([a-zA-Z][a-zA-Z0-9_]*, ≤64 chars): {e}" - ); - } - Self { - definition: ToolDefinition { - name: name.to_string(), - description: description.to_string(), - parameters, - }, - execute: None, - } - } - - /// Create an active tool with an execute handler. - /// - /// # Panics - /// - /// Panics if the tool name is invalid (see [`validate_tool_name`]). - /// Tool names are always hardcoded string literals in this codebase; this - /// guards against programming errors where a constant would fail - /// validation. - pub fn active( - name: &str, - description: &str, - parameters: serde_json::Value, - handler: F, - ) -> Self - where - F: Fn(serde_json::Value, ToolContext) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, - { - if let Err(e) = validate_tool_name(name) { - panic!( - "tool name `{name}` must be a valid identifier ([a-zA-Z][a-zA-Z0-9_]*, ≤64 chars): {e}" - ); - } - Self { - definition: ToolDefinition { - name: name.to_string(), - description: description.to_string(), - parameters, - }, - execute: Some(Arc::new(move |args, ctx| Box::pin(handler(args, ctx)))), - } - } - - #[must_use] - pub fn is_active(&self) -> bool { - self.execute.is_some() - } -} - -/// Validate a tool name: [a-zA-Z][a-zA-Z0-9_]* max 64 chars (Section 5.1). -/// -/// # Errors -/// -/// Returns a description of the validation failure if the name is empty, -/// too long, starts with a non-letter, or contains invalid characters. -pub fn validate_tool_name(name: &str) -> Result<(), String> { - if name.is_empty() { - return Err("Tool name cannot be empty".to_string()); - } - if name.len() > 64 { - return Err(format!("Tool name '{name}' exceeds 64 character limit")); - } - let mut chars = name.chars(); - if let Some(first) = chars.next() { - if !first.is_ascii_alphabetic() { - return Err(format!("Tool name '{name}' must start with a letter")); - } - } - for ch in chars { - if !ch.is_ascii_alphanumeric() && ch != '_' { - return Err(format!( - "Tool name '{name}' contains invalid character '{ch}'" - )); - } - } - Ok(()) -} - -/// A callback to repair invalid tool call arguments (Section 5.8). -/// Receives the tool call and the validation error message, returns repaired -/// arguments or an error if repair is not possible. -pub type RepairToolCallFn = Arc< - dyn Fn( - ToolCall, - String, - ) -> Pin> + Send>> - + Send - + Sync, ->; - -/// Validate tool call arguments against the tool's parameter schema. -/// Performs a lightweight structural check: verifies that when the schema -/// specifies `"type": "object"`, the arguments are a JSON object, and that -/// required properties are present. -fn validate_tool_args(args: &serde_json::Value, schema: &serde_json::Value) -> Result<(), String> { - let schema_type = schema.get("type").and_then(serde_json::Value::as_str); - if schema_type == Some("object") && !args.is_object() { - return Err(format!( - "Expected object arguments, got {}", - args_type_name(args) - )); - } - if let (Some(obj), Some(required)) = ( - args.as_object(), - schema.get("required").and_then(serde_json::Value::as_array), - ) { - let missing: Vec<&str> = required - .iter() - .filter_map(serde_json::Value::as_str) - .filter(|key| !obj.contains_key(*key)) - .collect(); - if !missing.is_empty() { - return Err(format!( - "Missing required properties: {}", - missing.join(", ") - )); - } - } - Ok(()) -} - -const fn args_type_name(value: &serde_json::Value) -> &'static str { - match value { - serde_json::Value::Null => "null", - serde_json::Value::Bool(_) => "boolean", - serde_json::Value::Number(_) => "number", - serde_json::Value::String(_) => "string", - serde_json::Value::Array(_) => "array", - serde_json::Value::Object(_) => "object", - } -} - -/// Execute all tool calls with optional schema validation and repair (Section -/// 5.8). -/// -/// Before calling a tool's execute handler, validates the arguments against the -/// tool's parameter schema. If validation fails and a `repair` callback is -/// provided, calls it to attempt repair. If repair succeeds, uses the repaired -/// arguments. If repair fails or is not configured, returns an error -/// `ToolResult`. -pub async fn execute_all_tools_with_repair( - tools: &[&Tool], - tool_calls: &[ToolCall], - messages: &[Message], - abort_signal: Option<&CancellationToken>, - repair: Option<&RepairToolCallFn>, -) -> Vec { - use futures::future::join_all; - - let futures: Vec<_> = tool_calls - .iter() - .map(|call| { - let tool = tools.iter().find(|t| t.definition.name == call.name).copied(); - let call_id = call.id.clone(); - let call_name = call.name.clone(); - let args = call.arguments.clone(); - let call_clone = call.clone(); - let ctx = ToolContext { - tool_call_id: call_id.clone(), - messages: messages.to_vec(), - abort_signal: abort_signal.cloned(), - }; - - async move { - let Some(t) = tool else { - return ToolResult::error(call_id, format!("Unknown tool: {call_name}")); - }; - - let Some(handler) = &t.execute else { - return ToolResult::error(call_id, format!("Unknown tool: {call_name}")); - }; - - let validated_args = if call_clone.tool_type == "custom" { - args - } else { - match validate_tool_args(&args, &t.definition.parameters) { - Ok(()) => args, - Err(validation_error) => { - debug!(tool = %call_name, "Tool call validation failed"); - if let Some(repair_fn) = repair { - match repair_fn(call_clone, validation_error).await { - Ok(repaired) => repaired, - Err(repair_error) => { - warn!(tool = %call_name, "Tool call repair failed"); - return ToolResult::error( - call_id, - format!("Tool call validation failed and repair failed: {repair_error}"), - ); - } - } - } else { - return ToolResult::error( - call_id, - format!("Tool call validation failed: {validation_error}"), - ); - } - } - } - }; - - match handler(validated_args, ctx).await { - Ok(result) => ToolResult::success(call_id, result), - Err(err_msg) => { - warn!(tool = %call_name, "Tool execution returned error"); - ToolResult::error(call_id, err_msg) - } - } - } - }) - .collect(); - - join_all(futures).await -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn validate_tool_name_valid() { - assert!(validate_tool_name("get_weather").is_ok()); - assert!(validate_tool_name("a").is_ok()); - assert!(validate_tool_name("myTool123").is_ok()); - assert!(validate_tool_name("A_B_C").is_ok()); - } - - #[test] - fn validate_tool_name_empty() { - assert!(validate_tool_name("").is_err()); - } - - #[test] - fn validate_tool_name_starts_with_number() { - assert!(validate_tool_name("1tool").is_err()); - } - - #[test] - fn validate_tool_name_starts_with_underscore() { - assert!(validate_tool_name("_tool").is_err()); - } - - #[test] - fn validate_tool_name_contains_dash() { - assert!(validate_tool_name("my-tool").is_err()); - } - - #[test] - fn validate_tool_name_too_long() { - let name = "a".repeat(65); - assert!(validate_tool_name(&name).is_err()); - } - - #[test] - fn validate_tool_name_max_length_ok() { - let name = "a".repeat(64); - assert!(validate_tool_name(&name).is_ok()); - } - - #[test] - fn passive_tool_is_not_active() { - let tool = Tool::passive( - "test", - "test tool", - serde_json::json!({"type": "object", "properties": {}}), - ); - assert!(!tool.is_active()); - } - - #[test] - fn active_tool_is_active() { - let tool = Tool::active( - "test", - "test tool", - serde_json::json!({"type": "object", "properties": {}}), - |_args, _ctx| async { Ok(serde_json::json!("result")) }, - ); - assert!(tool.is_active()); - } - - #[test] - #[should_panic(expected = "must be a valid identifier")] - fn passive_tool_panics_on_invalid_name() { - let _ = Tool::passive( - "1invalid", - "bad name", - serde_json::json!({"type": "object"}), - ); - } - - #[test] - #[should_panic(expected = "must be a valid identifier")] - fn active_tool_panics_on_invalid_name() { - Tool::active( - "my-tool", - "bad name", - serde_json::json!({"type": "object"}), - |_args, _ctx| async { Ok(serde_json::json!("result")) }, - ); - } - - #[test] - fn validate_tool_args_valid_object() { - let schema = - serde_json::json!({"type": "object", "properties": {"name": {"type": "string"}}}); - let args = serde_json::json!({"name": "Alice"}); - assert!(validate_tool_args(&args, &schema).is_ok()); - } - - #[test] - fn validate_tool_args_non_object_when_object_expected() { - let schema = serde_json::json!({"type": "object", "properties": {}}); - let args = serde_json::json!("not an object"); - let result = validate_tool_args(&args, &schema); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Expected object")); - } - - #[test] - fn validate_tool_args_missing_required_properties() { - let schema = serde_json::json!({ - "type": "object", - "properties": {"name": {"type": "string"}, "age": {"type": "number"}}, - "required": ["name", "age"] - }); - let args = serde_json::json!({"name": "Alice"}); - let result = validate_tool_args(&args, &schema); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("age")); - } - - #[test] - fn validate_tool_args_no_schema_type_passes() { - let schema = serde_json::json!({}); - let args = serde_json::json!("anything"); - assert!(validate_tool_args(&args, &schema).is_ok()); - } - - #[tokio::test] - async fn execute_with_repair_valid_args_no_repair_needed() { - let tools = [Tool::active( - "greet", - "Greet someone", - serde_json::json!({"type": "object", "properties": {"name": {"type": "string"}}}), - |args, _ctx| async move { - let name = args["name"].as_str().unwrap_or("world"); - Ok(serde_json::json!(format!("Hello, {}!", name))) - }, - )]; - let calls = vec![ToolCall::new( - "call_1", - "greet", - serde_json::json!({"name": "Alice"}), - )]; - let tool_refs: Vec<&Tool> = tools.iter().collect(); - - let results = execute_all_tools_with_repair(&tool_refs, &calls, &[], None, None).await; - assert_eq!(results.len(), 1); - assert!(!results[0].is_error); - assert_eq!(results[0].content, serde_json::json!("Hello, Alice!")); - } - - #[tokio::test] - async fn execute_with_repair_invalid_args_no_repair_fn() { - let tools = [Tool::active( - "greet", - "Greet someone", - serde_json::json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}), - |args, _ctx| async move { - let name = args["name"].as_str().unwrap_or("world"); - Ok(serde_json::json!(format!("Hello, {}!", name))) - }, - )]; - let calls = vec![ToolCall::new("call_1", "greet", serde_json::json!({}))]; - let tool_refs: Vec<&Tool> = tools.iter().collect(); - - let results = execute_all_tools_with_repair(&tool_refs, &calls, &[], None, None).await; - assert_eq!(results.len(), 1); - assert!(results[0].is_error); - assert!( - results[0] - .content - .as_str() - .unwrap() - .contains("validation failed") - ); - } - - #[tokio::test] - async fn execute_with_repair_invalid_args_repair_succeeds() { - let tools = [Tool::active( - "greet", - "Greet someone", - serde_json::json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}), - |args, _ctx| async move { - let name = args["name"].as_str().unwrap_or("world"); - Ok(serde_json::json!(format!("Hello, {}!", name))) - }, - )]; - let calls = vec![ToolCall::new("call_1", "greet", serde_json::json!({}))]; - let tool_refs: Vec<&Tool> = tools.iter().collect(); - - let repair: RepairToolCallFn = Arc::new(|_call, _error| { - Box::pin(async { Ok(serde_json::json!({"name": "Repaired"})) }) - }); - let results = - execute_all_tools_with_repair(&tool_refs, &calls, &[], None, Some(&repair)).await; - assert_eq!(results.len(), 1); - assert!(!results[0].is_error); - assert_eq!(results[0].content, serde_json::json!("Hello, Repaired!")); - } - - #[tokio::test] - async fn execute_with_repair_invalid_args_repair_fails() { - let tools = [Tool::active( - "greet", - "Greet someone", - serde_json::json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}), - |args, _ctx| async move { - let name = args["name"].as_str().unwrap_or("world"); - Ok(serde_json::json!(format!("Hello, {}!", name))) - }, - )]; - let calls = vec![ToolCall::new("call_1", "greet", serde_json::json!({}))]; - let tool_refs: Vec<&Tool> = tools.iter().collect(); - - let repair: RepairToolCallFn = - Arc::new(|_call, _error| Box::pin(async { Err("cannot repair".to_string()) })); - let results = - execute_all_tools_with_repair(&tool_refs, &calls, &[], None, Some(&repair)).await; - assert_eq!(results.len(), 1); - assert!(results[0].is_error); - assert!( - results[0] - .content - .as_str() - .unwrap() - .contains("repair failed") - ); - } - - // --- args_type_name --- - - #[test] - fn args_type_name_null() { - assert_eq!(args_type_name(&serde_json::Value::Null), "null"); - } - - #[test] - fn args_type_name_bool() { - assert_eq!(args_type_name(&serde_json::json!(true)), "boolean"); - } - - #[test] - fn args_type_name_number() { - assert_eq!(args_type_name(&serde_json::json!(42)), "number"); - } - - #[test] - fn args_type_name_string() { - assert_eq!(args_type_name(&serde_json::json!("hello")), "string"); - } - - #[test] - fn args_type_name_array() { - assert_eq!(args_type_name(&serde_json::json!([1, 2])), "array"); - } - - #[test] - fn args_type_name_object() { - assert_eq!(args_type_name(&serde_json::json!({})), "object"); - } -} diff --git a/lib/components/fabro-llm/src/transport.rs b/lib/components/fabro-llm/src/transport.rs deleted file mode 100644 index 8fc62a32f..000000000 --- a/lib/components/fabro-llm/src/transport.rs +++ /dev/null @@ -1,610 +0,0 @@ -//! The HTTP transport shared by every provider adapter: how request bytes -//! travel, not what they say. -//! -//! A transport owns the HTTP client, timeouts, the streaming byte loop, and -//! SSE framing. It knows nothing about wire dialects — bodies, endpoints, and -//! error shapes arrive from (and return to) a [`Codec`]. Adapters shrink to -//! auth + route config composed over these helpers. -//! -//! The split mirrors `codec/mod.rs`: a codec knows *what the bytes say*; this -//! module knows *how they travel*. - -use std::borrow::Cow; -use std::collections::{HashMap, VecDeque}; -use std::time::Duration; - -use fabro_http::HeaderMap; -use futures::stream; -use tokio::time; -use tracing::warn; - -use crate::codec::{Codec, CodecCtx, RawEvent, StreamDecoder}; -use crate::error::Error; -use crate::provider::StreamEventStream; -use crate::types::{AdapterTimeout, RateLimitInfo, Response, StreamEvent}; - -// --- HTTP client + configuration -// ---------------------------------------------- - -/// Shared HTTP infrastructure for provider adapters. -/// -/// Holds the API key, base URL, reqwest client, default headers, and timeout -/// configuration that every provider needs. Provider-specific fields live on -/// the adapter struct itself. -pub(crate) struct HttpTransport { - pub(crate) api_key: Option, - pub(crate) base_url: String, - pub(crate) default_headers: HashMap, - pub(crate) client: fabro_http::HttpClient, - pub(crate) request_timeout: Option, - pub(crate) stream_read_timeout: Option, -} - -impl HttpTransport { - fn build_client(timeout: AdapterTimeout) -> fabro_http::HttpClient { - fabro_http::HttpClientBuilder::new() - .connect_timeout(Duration::from_secs_f64(timeout.connect)) - .build() - .expect("LLM HTTP client should build") - } - - #[must_use] - pub(crate) fn new_optional(api_key: Option, base_url: impl Into) -> Self { - let timeout = AdapterTimeout::default(); - let client = Self::build_client(timeout); - Self { - api_key, - base_url: base_url.into(), - default_headers: HashMap::new(), - client, - request_timeout: timeout.request.map(Duration::from_secs_f64), - stream_read_timeout: timeout.stream_read.map(Duration::from_secs_f64), - } - } - - #[must_use] - pub(crate) fn with_timeout(mut self, timeout: AdapterTimeout) -> Self { - self.client = Self::build_client(timeout); - self.request_timeout = timeout.request.map(Duration::from_secs_f64); - self.stream_read_timeout = timeout.stream_read.map(Duration::from_secs_f64); - self - } - - #[must_use] - pub(crate) fn with_default_headers(mut self, headers: HashMap) -> Self { - self.default_headers = headers; - self - } -} - -// --- Response header parsing -// --------------------------------------------------- - -/// Extract the `Retry-After` header value from an HTTP response as seconds. -#[must_use] -pub fn parse_retry_after(headers: &HeaderMap) -> Option { - headers - .get("retry-after") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) -} - -/// Parse `x-ratelimit-*` headers into a `RateLimitInfo`. -/// -/// Returns `None` if no rate limit headers are present. -#[must_use] -pub fn parse_rate_limit_headers(headers: &HeaderMap) -> Option { - fn header_i64(headers: &HeaderMap, name: &str) -> Option { - headers - .get(name) - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) - } - - fn header_str(headers: &HeaderMap, name: &str) -> Option { - headers - .get(name) - .and_then(|v| v.to_str().ok()) - .map(String::from) - } - - let requests_remaining = header_i64(headers, "x-ratelimit-remaining-requests"); - let requests_limit = header_i64(headers, "x-ratelimit-limit-requests"); - let tokens_remaining = header_i64(headers, "x-ratelimit-remaining-tokens"); - let tokens_limit = header_i64(headers, "x-ratelimit-limit-tokens"); - let reset_at = header_str(headers, "x-ratelimit-reset-requests") - .or_else(|| header_str(headers, "x-ratelimit-reset-tokens")); - - if requests_remaining.is_none() - && requests_limit.is_none() - && tokens_remaining.is_none() - && tokens_limit.is_none() - && reset_at.is_none() - { - return None; - } - - Some(RateLimitInfo { - requests_remaining, - requests_limit, - tokens_remaining, - tokens_limit, - reset_at, - }) -} - -// --- Blocking requests -// ----------------------------------------------------------- - -/// Send a blocking request and decode the response through the codec: -/// `send_for_body` + rate-limit headers + [`Codec::decode_response`]. -pub(crate) async fn complete_via_http( - request: fabro_http::RequestBuilder, - codec: &dyn Codec, - ctx: &CodecCtx<'_>, -) -> Result { - let (body, headers) = send_for_body(request, "provider_request", codec, ctx).await?; - let rate_limit = parse_rate_limit_headers(&headers); - codec.decode_response(&body, ctx, rate_limit) -} - -/// Send an HTTP request and read the response body plus headers, mapping -/// non-2xx responses through [`Codec::decode_error`]. `operation` tags the -/// warning logs (`provider_request`, `input_token_count`). -pub(crate) async fn send_for_body( - request: fabro_http::RequestBuilder, - operation: &str, - codec: &dyn Codec, - ctx: &CodecCtx<'_>, -) -> Result<(String, HeaderMap), Error> { - let provider = ctx.provider_name; - let http_resp = request.send().await.map_err(|e| { - if e.is_timeout() { - warn!(provider = %provider, operation = %operation, error = %e, "Provider request timed out"); - Error::request_timeout(format!("{provider}: {e}"), e) - } else { - warn!(provider = %provider, operation = %operation, error = %e, "Provider network error"); - Error::network(e.to_string(), e) - } - })?; - - let status = http_resp.status(); - let retry_after = parse_retry_after(http_resp.headers()); - let headers = http_resp.headers().clone(); - let body = http_resp - .text() - .await - .map_err(|e| Error::network(e.to_string(), e))?; - - if !status.is_success() { - warn!(provider = %provider, operation = %operation, status = status.as_u16(), "Provider returned error"); - return Err(codec.decode_error(status.as_u16(), &body, ctx, retry_after)); - } - - Ok((body, headers)) -} - -// --- Streaming -// ------------------------------------------------------------------- - -/// How a route frames its SSE byte stream into decoder events. -#[derive(Clone, Copy, Debug)] -pub(crate) enum SseFraming { - /// `\n\n`-delimited blocks carrying `event:` + `data:` lines (anthropic, - /// openai responses). - EventBlocks, - /// Newline-delimited `data:` lines; comments, blank lines, and non-data - /// fields are skipped (openai_compatible, gemini). - DataLines, -} - -impl SseFraming { - fn delimiter(self) -> &'static str { - match self { - Self::EventBlocks => "\n\n", - Self::DataLines => "\n", - } - } -} - -/// Send a streaming request and decode its SSE byte stream through the -/// codec's [`StreamDecoder`]. A non-2xx response is mapped through -/// [`Codec::decode_error`] before any bytes flow. -pub(crate) async fn stream_via_http( - request: fabro_http::RequestBuilder, - codec: &dyn Codec, - ctx: &CodecCtx<'_>, - framing: SseFraming, - stream_read_timeout: Option, -) -> Result { - let http_resp = request - .send() - .await - .map_err(|e| Error::network(e.to_string(), e))?; - - let status = http_resp.status(); - if !status.is_success() { - let retry_after = parse_retry_after(http_resp.headers()); - let body = http_resp - .text() - .await - .map_err(|e| Error::network(e.to_string(), e))?; - return Err(codec.decode_error(status.as_u16(), &body, ctx, retry_after)); - } - - let rate_limit = parse_rate_limit_headers(http_resp.headers()); - let decoder = codec.stream_decoder(ctx, rate_limit); - Ok(decode_sse_stream( - http_resp, - decoder, - framing, - stream_read_timeout, - )) -} - -/// State driving the streaming byte loop: the codec's decoder plus the line -/// reader, with a buffer that flattens batched events into individual items. -struct StreamLoop { - decoder: Box, - line_reader: LineReader, - /// Events or decoder errors not yet yielded. - pending: VecDeque>, - /// Byte stream exhausted. - done: bool, - /// `finish()` already drained. - finished_emitted: bool, - /// [`StreamEvent::StreamStart`] already emitted for this stream. - stream_started: bool, -} - -/// Drive `decoder` over the SSE byte stream of `response`: frame each chunk, -/// feed it to the decoder, flatten batched events, and drain -/// [`StreamDecoder::finish`] at byte-stream end. -fn decode_sse_stream( - response: fabro_http::Response, - decoder: Box, - framing: SseFraming, - stream_read_timeout: Option, -) -> StreamEventStream { - let out = stream::unfold( - StreamLoop { - decoder, - line_reader: LineReader::new(response, stream_read_timeout), - pending: VecDeque::new(), - done: false, - finished_emitted: false, - stream_started: false, - }, - move |mut state| async move { - loop { - if let Some(event) = state.pending.pop_front() { - return Some((event, state)); - } - - if state.done { - if state.finished_emitted { - return None; - } - state.finished_emitted = true; - state - .pending - .extend(state.decoder.finish().into_iter().map(Ok)); - if state.pending.is_empty() { - return None; - } - continue; - } - - match state.line_reader.read_next_chunk(framing.delimiter()).await { - Ok(Some(chunk)) => { - let Some((event, data)) = frame_sse_chunk(framing, &chunk) else { - continue; - }; - // Provider-independent liveness edge: the first framed - // event proves the provider is responding, whatever it - // turns out to contain. Owned here rather than in each - // decoder so it cannot depend on a provider sending a - // particular opening frame. - if !state.stream_started { - state.stream_started = true; - state.pending.push_back(Ok(StreamEvent::StreamStart)); - } - match state.decoder.on_event(RawEvent { event, data: &data }) { - Ok(events) => state.pending.extend(events.into_iter().map(Ok)), - Err(error) => state.pending.push_back(Err(error)), - } - } - Ok(None) => state.done = true, - Err(e) => return Some((Err(e), state)), - } - } - }, - ); - Box::pin(out) -} - -/// Frame one delimiter-separated chunk into an SSE `(event, data)` pair. -/// Returns `None` for chunks with no payload to decode: heartbeat comments, -/// blank lines, non-data fields, and empty `data:` payloads. -fn frame_sse_chunk(framing: SseFraming, chunk: &str) -> Option<(Option<&str>, Cow<'_, str>)> { - match framing { - SseFraming::EventBlocks => parse_sse_block(chunk), - SseFraming::DataLines => { - let data = chunk.trim().strip_prefix("data:")?.trim(); - if data.is_empty() { - return None; - } - Some((None, Cow::Borrowed(data))) - } - } -} - -/// Parse an SSE event block (lines within a `\n\n`-delimited chunk) into -/// `(event_type, data)`. Multi-line `data:` payloads are joined with `\n`; -/// the common single-line case borrows from the block. Returns `None` for -/// blocks with no non-empty payload (e.g. heartbeat comments). -pub(crate) fn parse_sse_block(block: &str) -> Option<(Option<&str>, Cow<'_, str>)> { - let mut event: Option<&str> = None; - let mut data: Option> = None; - - for line in block.lines() { - if let Some(rest) = line.strip_prefix("event:") { - event = Some(rest.trim()); - } else if let Some(rest) = line.strip_prefix("data:") { - let rest = rest.trim(); - data = Some(match data { - None => Cow::Borrowed(rest), - Some(prev) => { - let mut joined = prev.into_owned(); - joined.push('\n'); - joined.push_str(rest); - Cow::Owned(joined) - } - }); - } - } - - let data = data?; - if data.is_empty() { - return None; - } - Some((event, data)) -} - -// --- Byte-stream reading ----------------------------------------------------- - -/// Shared line reader for SSE streams. -/// -/// Buffers bytes from a `fabro_http::Response` and splits them by a -/// configurable delimiter (e.g. `"\n"` for Gemini/OpenAI-compatible, `"\n\n"` -/// for Anthropic/OpenAI SSE event blocks). -pub struct LineReader { - response: fabro_http::Response, - buffer: String, - stream_read_timeout: Option, -} - -impl LineReader { - pub fn new(response: fabro_http::Response, stream_read_timeout: Option) -> Self { - Self { - response, - buffer: String::new(), - stream_read_timeout, - } - } - - /// Read the next complete segment delimited by `delimiter`. - /// - /// Returns `Ok(Some(segment))` for each complete segment, `Ok(None)` when - /// the stream is exhausted, or `Err` on I/O or timeout errors. When the - /// stream ends with data remaining in the buffer, the leftover is returned - /// as a final segment. - pub async fn read_next_chunk(&mut self, delimiter: &str) -> Result, Error> { - loop { - if let Some(pos) = self.buffer.find(delimiter) { - let segment = self.buffer[..pos].to_string(); - self.buffer = self.buffer[pos + delimiter.len()..].to_string(); - return Ok(Some(segment)); - } - - let chunk_result = match self.stream_read_timeout { - Some(timeout) => time::timeout(timeout, self.response.chunk()).await, - None => Ok(self.response.chunk().await), - }; - match chunk_result { - Ok(Ok(Some(bytes))) => { - let text = String::from_utf8_lossy(&bytes); - self.buffer.push_str(&text); - } - Ok(Ok(None)) => { - if self.buffer.is_empty() { - return Ok(None); - } - let remaining = std::mem::take(&mut self.buffer); - return Ok(Some(remaining)); - } - Ok(Err(e)) => { - return Err(Error::stream_error(e.to_string(), e)); - } - Err(_) => { - warn!("Stream read timed out waiting for next event"); - return Err(Error::Stream { - message: "stream read timed out waiting for next event".to_string(), - source: None, - }); - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_rate_limit_headers_all_present() { - let mut headers = HeaderMap::new(); - headers.insert("x-ratelimit-remaining-requests", "99".parse().unwrap()); - headers.insert("x-ratelimit-limit-requests", "100".parse().unwrap()); - headers.insert("x-ratelimit-remaining-tokens", "9000".parse().unwrap()); - headers.insert("x-ratelimit-limit-tokens", "10000".parse().unwrap()); - headers.insert( - "x-ratelimit-reset-requests", - "2024-01-01T00:00:00Z".parse().unwrap(), - ); - - let info = parse_rate_limit_headers(&headers).unwrap(); - assert_eq!(info.requests_remaining, Some(99)); - assert_eq!(info.requests_limit, Some(100)); - assert_eq!(info.tokens_remaining, Some(9000)); - assert_eq!(info.tokens_limit, Some(10000)); - assert_eq!(info.reset_at.as_deref(), Some("2024-01-01T00:00:00Z")); - } - - #[test] - fn parse_rate_limit_headers_none_present() { - let headers = HeaderMap::new(); - assert!(parse_rate_limit_headers(&headers).is_none()); - } - - #[test] - fn parse_rate_limit_headers_partial() { - let mut headers = HeaderMap::new(); - headers.insert("x-ratelimit-remaining-requests", "50".parse().unwrap()); - - let info = parse_rate_limit_headers(&headers).unwrap(); - assert_eq!(info.requests_remaining, Some(50)); - assert_eq!(info.requests_limit, None); - assert_eq!(info.tokens_remaining, None); - assert_eq!(info.tokens_limit, None); - assert_eq!(info.reset_at, None); - } - - #[test] - fn parse_rate_limit_headers_reset_tokens_fallback() { - let mut headers = HeaderMap::new(); - headers.insert("x-ratelimit-limit-tokens", "5000".parse().unwrap()); - headers.insert( - "x-ratelimit-reset-tokens", - "2024-06-01T12:00:00Z".parse().unwrap(), - ); - - let info = parse_rate_limit_headers(&headers).unwrap(); - assert_eq!(info.tokens_limit, Some(5000)); - assert_eq!(info.reset_at.as_deref(), Some("2024-06-01T12:00:00Z")); - } - - #[test] - fn parse_rate_limit_headers_invalid_values_ignored() { - let mut headers = HeaderMap::new(); - headers.insert( - "x-ratelimit-remaining-requests", - "not-a-number".parse().unwrap(), - ); - headers.insert("x-ratelimit-limit-tokens", "10000".parse().unwrap()); - - let info = parse_rate_limit_headers(&headers).unwrap(); - assert_eq!(info.requests_remaining, None); - assert_eq!(info.tokens_limit, Some(10000)); - } - - // --- parse_retry_after --- - - #[test] - fn parse_retry_after_valid() { - let mut headers = HeaderMap::new(); - headers.insert("retry-after", "2.5".parse().unwrap()); - assert_eq!(parse_retry_after(&headers), Some(2.5)); - } - - #[test] - fn parse_retry_after_missing() { - let headers = HeaderMap::new(); - assert_eq!(parse_retry_after(&headers), None); - } - - #[test] - fn parse_retry_after_invalid() { - let mut headers = HeaderMap::new(); - headers.insert("retry-after", "not-a-number".parse().unwrap()); - assert_eq!(parse_retry_after(&headers), None); - } - - #[test] - fn parse_retry_after_integer() { - let mut headers = HeaderMap::new(); - headers.insert("retry-after", "5".parse().unwrap()); - assert_eq!(parse_retry_after(&headers), Some(5.0)); - } - - // --- frame_sse_chunk: event blocks --- - - #[test] - fn parse_sse_block_event_and_data() { - let block = "event: message_start\ndata: {\"a\":1}"; - let (event, data) = parse_sse_block(block).unwrap(); - assert_eq!(event, Some("message_start")); - assert_eq!(data, "{\"a\":1}"); - } - - #[test] - fn parse_sse_block_data_without_event() { - let block = "data: {\"a\":1}"; - let (event, data) = parse_sse_block(block).unwrap(); - assert_eq!(event, None); - assert_eq!(data, "{\"a\":1}"); - } - - #[test] - fn parse_sse_block_joins_multiple_data_lines() { - let block = "event: e\ndata: line1\ndata: line2"; - let (event, data) = parse_sse_block(block).unwrap(); - assert_eq!(event, Some("e")); - assert_eq!(data, "line1\nline2"); - } - - #[test] - fn parse_sse_block_skips_comment_only_block() { - assert!(parse_sse_block(": heartbeat").is_none()); - assert!(parse_sse_block("event: ping").is_none()); - assert!(parse_sse_block("").is_none()); - } - - #[test] - fn parse_sse_block_skips_empty_data_payload() { - assert!(parse_sse_block("data:").is_none()); - assert!(parse_sse_block("event: e\ndata: ").is_none()); - } - - #[test] - fn parse_sse_block_trims_crlf() { - let block = "event: e\r\ndata: {\"a\":1}\r"; - let (event, data) = parse_sse_block(block).unwrap(); - assert_eq!(event, Some("e")); - assert_eq!(data, "{\"a\":1}"); - } - - // --- frame_sse_chunk: data lines --- - - #[test] - fn data_lines_strips_prefix_and_trims() { - let (event, data) = frame_sse_chunk(SseFraming::DataLines, "data: {\"a\":1}\r").unwrap(); - assert_eq!(event, None); - assert_eq!(data, "{\"a\":1}"); - } - - #[test] - fn data_lines_passes_done_sentinel() { - let (_, data) = frame_sse_chunk(SseFraming::DataLines, "data: [DONE]").unwrap(); - assert_eq!(data, "[DONE]"); - } - - #[test] - fn data_lines_skips_comments_blanks_and_other_fields() { - assert!(frame_sse_chunk(SseFraming::DataLines, ": keep-alive").is_none()); - assert!(frame_sse_chunk(SseFraming::DataLines, "").is_none()); - assert!(frame_sse_chunk(SseFraming::DataLines, "event: x").is_none()); - assert!(frame_sse_chunk(SseFraming::DataLines, "data:").is_none()); - } -} diff --git a/lib/components/fabro-llm/src/types.rs b/lib/components/fabro-llm/src/types.rs deleted file mode 100644 index 9c41263fd..000000000 --- a/lib/components/fabro-llm/src/types.rs +++ /dev/null @@ -1,1041 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -// --- 3.1 / 3.2 / 3.5 Canonical chat + content data structures --- -// -// `Message`, `Role`, `ContentPart`, `ImageData`, `AudioData`, -// `DocumentData`, `ThinkingData`, `ToolCall`, and `ToolResult` are the -// canonical provider-neutral replay primitives. They live in `fabro-types` -// so the event stream, API responses, and runtime history can share one -// model. They are re-exported here so existing `fabro_llm::types::*` -// imports keep working. -pub use fabro_types::{ - AudioData, ContentPart, DocumentData, ImageData, Message, ReasoningOutput, Role, ThinkingData, - ToolCall, ToolResult, -}; -use fabro_util::backoff::BackoffPolicy; -use serde::{Deserialize, Serialize}; - -use crate::error::Error; -use crate::reasoning; - -// --- 3.8 FinishReason --- - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum FinishReason { - Stop, - Length, - ToolCalls, - ContentFilter, - Error, - Other(String), -} - -impl FinishReason { - #[must_use] - pub const fn as_str(&self) -> &str { - match self { - Self::Stop => "stop", - Self::Length => "length", - Self::ToolCalls => "tool_calls", - Self::ContentFilter => "content_filter", - Self::Error => "error", - Self::Other(s) => s.as_str(), - } - } -} - -impl Serialize for FinishReason { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(self.as_str()) - } -} - -impl<'de> Deserialize<'de> for FinishReason { - fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - Ok(match s.as_str() { - "stop" => Self::Stop, - "length" => Self::Length, - "tool_calls" => Self::ToolCalls, - "content_filter" => Self::ContentFilter, - "error" => Self::Error, - _ => Self::Other(s), - }) - } -} - -// --- 3.9 TokenCounts --- - -pub use fabro_model::{Speed, TokenCounts}; - -// --- 3.10 ResponseFormat --- - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ResponseFormatType { - Text, - #[serde(rename = "json")] - JsonObject, - JsonSchema, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ResponseFormat { - #[serde(rename = "type")] - pub kind: ResponseFormatType, - pub json_schema: Option, - #[serde(default)] - pub strict: bool, -} - -// --- 3.11 Warning --- - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Warning { - pub message: String, - pub code: Option, -} - -// --- 3.12 RateLimitInfo --- - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RateLimitInfo { - pub requests_remaining: Option, - pub requests_limit: Option, - pub tokens_remaining: Option, - pub tokens_limit: Option, - pub reset_at: Option, -} - -// --- 3.8 ReasoningEffort --- -// -// Re-exported from `fabro-model` so catalog data, request validation, OpenAPI -// replacement types, and the LLM client share one enum. -pub use fabro_model::ReasoningEffort; - -// --- 3.6 Request --- - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Request { - pub model: String, - pub messages: Vec, - pub provider: Option, - pub tools: Option>, - pub tool_choice: Option, - pub response_format: Option, - pub temperature: Option, - pub top_p: Option, - pub max_tokens: Option, - pub stop_sequences: Option>, - pub reasoning_effort: Option, - pub speed: Option, - pub metadata: Option>, - pub provider_options: Option, -} - -// --- 5.1 ToolDefinition --- - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolDefinition { - pub name: String, - pub description: String, - pub parameters: serde_json::Value, -} - -const CUSTOM_TOOL_TYPE_KEY: &str = "x-fabro-tool-type"; -const CUSTOM_TOOL_FORMAT_KEY: &str = "x-fabro-custom-tool-format"; - -impl ToolDefinition { - #[must_use] - pub fn function( - name: impl Into, - description: impl Into, - parameters: serde_json::Value, - ) -> Self { - Self { - name: name.into(), - description: description.into(), - parameters, - } - } - - #[must_use] - pub fn custom( - name: impl Into, - description: impl Into, - format: impl Into, - ) -> Self { - Self { - name: name.into(), - description: description.into(), - parameters: serde_json::json!({ - CUSTOM_TOOL_TYPE_KEY: "custom", - CUSTOM_TOOL_FORMAT_KEY: format.into(), - }), - } - } - - #[must_use] - pub fn is_custom(&self) -> bool { - self.parameters - .get(CUSTOM_TOOL_TYPE_KEY) - .and_then(serde_json::Value::as_str) - == Some("custom") - } - - #[must_use] - pub fn custom_format(&self) -> Option<&serde_json::Value> { - self.parameters.get(CUSTOM_TOOL_FORMAT_KEY) - } -} - -// --- 5.3 ToolChoice --- - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "mode", rename_all = "snake_case")] -pub enum ToolChoice { - Auto, - None, - Required, - Named { tool_name: String }, -} - -impl ToolChoice { - pub fn named(name: impl Into) -> Self { - Self::Named { - tool_name: name.into(), - } - } - - /// Return the mode string used by `ProviderAdapter::supports_tool_choice`. - #[must_use] - pub const fn mode_str(&self) -> &'static str { - match self { - Self::Auto => "auto", - Self::None => "none", - Self::Required => "required", - Self::Named { .. } => "named", - } - } -} - -// --- 3.7 Response --- - -// Billing vocabulary shared with the catalog/billing layer and the API -// surface; re-exported here so `fabro_llm::types::*` imports keep working. -pub use fabro_model::CostSource; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Response { - pub id: String, - pub model: String, - pub provider: String, - pub message: Message, - pub finish_reason: FinishReason, - pub usage: TokenCounts, - pub raw: Option, - pub warnings: Vec, - pub rate_limit: Option, - /// USD cost of this completion, when known or estimable. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cost_usd: Option, - /// Whether `cost_usd` came from provider billing data or a catalog - /// estimate. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cost_source: Option, -} - -impl Response { - #[must_use] - pub fn text(&self) -> String { - self.message.text() - } - - #[must_use] - pub fn tool_calls(&self) -> Vec { - self.message - .content - .iter() - .filter_map(|part| match part { - ContentPart::ToolCall(tc) => Some(tc.clone()), - _ => None, - }) - .collect() - } - - #[must_use] - pub fn reasoning(&self) -> Option { - let reasoning: String = self - .message - .content - .iter() - .filter_map(|part| match part { - ContentPart::Thinking(t) => Some(t.text.as_str()), - _ => None, - }) - .collect(); - - if reasoning.is_empty() { - None - } else { - Some(reasoning) - } - } - - /// Readable reasoning normalized from this response's canonical message - /// content, or `None` when the provider returned none. - /// - /// The message is the single source of truth: opaque provider reasoning - /// items are already preserved there, so normalization needs no second - /// stored field and cannot drift from what will be replayed. Deriving it - /// from the final response also keeps retried or replaced streaming - /// buffers out of the durable result. - #[must_use] - pub fn reasoning_output(&self) -> Option { - reasoning::normalize(&self.message.content) - } -} - -// --- 3.13 StreamEvent --- - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum StreamEvent { - StreamStart, - TextStart { - text_id: Option, - }, - TextDelta { - delta: String, - text_id: Option, - }, - TextEnd { - text_id: Option, - }, - ReasoningStart, - ReasoningDelta { - delta: String, - }, - ReasoningEnd, - ToolCallStart { - tool_call: ToolCall, - }, - ToolCallDelta { - tool_call: ToolCall, - }, - ToolCallEnd { - tool_call: ToolCall, - }, - StepFinish { - finish_reason: FinishReason, - usage: TokenCounts, - response: Box, - tool_calls: Vec, - tool_results: Vec, - }, - Finish { - finish_reason: FinishReason, - usage: TokenCounts, - response: Box, - }, - Error { - error: Error, - raw: Option, - }, -} - -impl StreamEvent { - pub fn text_delta(delta: impl Into, text_id: Option) -> Self { - Self::TextDelta { - delta: delta.into(), - text_id, - } - } - - #[must_use] - pub fn step_finish( - reason: FinishReason, - usage: TokenCounts, - response: Response, - tool_calls: Vec, - tool_results: Vec, - ) -> Self { - Self::StepFinish { - finish_reason: reason, - usage, - response: Box::new(response), - tool_calls, - tool_results, - } - } - - #[must_use] - pub fn finish(reason: FinishReason, usage: TokenCounts, response: Response) -> Self { - Self::Finish { - finish_reason: reason, - usage, - response: Box::new(response), - } - } - - #[must_use] - pub const fn error(error: Error) -> Self { - Self::Error { error, raw: None } - } -} - -// --- 2.9 Model (re-exported from fabro-model) --- - -pub use fabro_model::{Model, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature}; - -// --- 4.7 Timeouts --- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TimeoutOptions { - pub total: Option, - pub per_step: Option, -} - -impl From for TimeoutOptions { - fn from(total: f64) -> Self { - Self { - total: Some(total), - per_step: None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct AdapterTimeout { - pub connect: f64, - pub request: Option, - pub stream_read: Option, -} - -impl Default for AdapterTimeout { - fn default() -> Self { - Self { - connect: 30.0, - request: None, - stream_read: Some(300.0), - } - } -} - -// --- 6.6 RetryPolicy --- - -/// Callback invoked before each retry attempt with (error, attempt, delay as -/// Duration). -pub type OnRetryCallback = Arc; - -#[derive(Clone)] -pub struct RetryPolicy { - pub max_retries: u32, - pub backoff: BackoffPolicy, - /// Called before each retry with (error, attempt number, delay). - pub on_retry: Option, -} - -impl std::fmt::Debug for RetryPolicy { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("RetryPolicy") - .field("max_retries", &self.max_retries) - .field("backoff", &self.backoff) - .field("on_retry", &self.on_retry.as_ref().map(|_| "...")) - .finish() - } -} - -impl Default for RetryPolicy { - fn default() -> Self { - Self { - max_retries: 2, - backoff: BackoffPolicy { - initial_delay: std::time::Duration::from_secs(1), - factor: 2.0, - max_delay: std::time::Duration::from_mins(1), - jitter: true, - }, - on_retry: None, - } - } -} - -// --- 4.6 ObjectStreamEvent --- - -/// Events yielded by `stream_object()` for streaming structured output. -#[derive(Debug, Clone)] -pub enum ObjectStreamEvent { - /// A new partial parse of the accumulated JSON text. - Partial { object: serde_json::Value }, - /// A raw stream event from the underlying provider stream. - Delta { event: StreamEvent }, - /// The stream completed with a fully parsed object and response. - Complete { - object: serde_json::Value, - response: Box, - }, -} - -// --- 4.3 GenerateResult / StepResult --- - -#[derive(Debug, Clone)] -pub struct GenerateResult { - pub response: Response, - pub tool_results: Vec, - pub total_usage: TokenCounts, - pub steps: Vec, - pub output: Option, -} - -impl std::ops::Deref for GenerateResult { - type Target = Response; - fn deref(&self) -> &Response { - &self.response - } -} - -#[derive(Debug, Clone)] -pub struct StepResult { - pub response: Response, - pub tool_results: Vec, -} - -impl std::ops::Deref for StepResult { - type Target = Response; - fn deref(&self) -> &Response { - &self.response - } -} - -#[cfg(test)] -mod tests { - use fabro_util::backoff::BackoffPolicy; - - use super::*; - - #[test] - fn message_system_constructor() { - let msg = Message::system("You are helpful."); - assert_eq!(msg.role, Role::System); - assert_eq!(msg.text(), "You are helpful."); - } - - #[test] - fn message_user_constructor() { - let msg = Message::user("Hello"); - assert_eq!(msg.role, Role::User); - assert_eq!(msg.text(), "Hello"); - } - - #[test] - fn message_assistant_constructor() { - let msg = Message::assistant("Hi there"); - assert_eq!(msg.role, Role::Assistant); - assert_eq!(msg.text(), "Hi there"); - } - - #[test] - fn message_tool_result_constructor() { - let msg = Message::tool_result( - "call_123", - serde_json::Value::String("72F and sunny".into()), - false, - ); - assert_eq!(msg.role, Role::Tool); - assert_eq!(msg.tool_call_id, Some("call_123".to_string())); - match &msg.content[0] { - ContentPart::ToolResult(tr) => { - assert_eq!(tr.tool_call_id, "call_123"); - assert!(!tr.is_error); - } - other => panic!("Expected ToolResult, got {other:?}"), - } - } - - #[test] - fn message_text_concatenates_text_parts() { - let msg = Message { - role: Role::Assistant, - content: vec![ - ContentPart::text("Hello "), - ContentPart::ToolCall(ToolCall::new("c1", "test", serde_json::json!({}))), - ContentPart::text("world"), - ], - name: None, - tool_call_id: None, - }; - assert_eq!(msg.text(), "Hello world"); - } - - #[test] - fn message_text_returns_empty_for_no_text_parts() { - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "c1", - "test", - serde_json::json!({}), - ))], - name: None, - tool_call_id: None, - }; - assert_eq!(msg.text(), ""); - } - - #[test] - fn finish_reason_variants() { - assert_eq!(FinishReason::Stop.as_str(), "stop"); - assert_eq!(FinishReason::Length.as_str(), "length"); - assert_eq!(FinishReason::ToolCalls.as_str(), "tool_calls"); - assert_eq!(FinishReason::ContentFilter.as_str(), "content_filter"); - assert_eq!(FinishReason::Error.as_str(), "error"); - assert_eq!( - FinishReason::Other("custom_reason".into()).as_str(), - "custom_reason" - ); - } - - #[test] - fn finish_reason_serde_roundtrip() { - let reasons = vec![ - FinishReason::Stop, - FinishReason::Length, - FinishReason::ToolCalls, - FinishReason::Other("custom".into()), - ]; - for reason in &reasons { - let json = serde_json::to_string(reason).unwrap(); - let deserialized: FinishReason = serde_json::from_str(&json).unwrap(); - assert_eq!(&deserialized, reason); - } - } - - #[test] - fn usage_serialization_skips_none_optional_fields() { - let usage = TokenCounts { - input_tokens: 100, - output_tokens: 50, - ..TokenCounts::default() - }; - insta::assert_snapshot!(serde_json::to_string_pretty(&usage).unwrap(), @r#" - { - "input_tokens": 100, - "output_tokens": 50, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - } - "#); - } - - #[test] - fn usage_serialization_includes_present_optional_fields() { - let usage = TokenCounts { - input_tokens: 100, - output_tokens: 30, - reasoning_tokens: 20, - cache_read_tokens: 80, - cache_write_tokens: 10, - }; - insta::assert_snapshot!(serde_json::to_string_pretty(&usage).unwrap(), @r#" - { - "input_tokens": 100, - "output_tokens": 30, - "reasoning_tokens": 20, - "cache_read_tokens": 80, - "cache_write_tokens": 10 - } - "#); - } - - #[test] - fn usage_deserialization_without_optional_fields() { - let json = r#"{"input_tokens":100,"output_tokens":50}"#; - let usage: TokenCounts = serde_json::from_str(json).unwrap(); - assert_eq!(usage.input_tokens, 100); - assert_eq!(usage.reasoning_tokens, 0); - assert_eq!(usage.cache_read_tokens, 0); - assert_eq!(usage.total_tokens(), 150); - } - - #[test] - fn usage_addition_both_filled() { - let a = TokenCounts { - input_tokens: 10, - output_tokens: 15, - reasoning_tokens: 5, - cache_read_tokens: 3, - cache_write_tokens: 1, - }; - let b = TokenCounts { - input_tokens: 15, - output_tokens: 15, - reasoning_tokens: 10, - cache_read_tokens: 7, - cache_write_tokens: 2, - }; - let sum = a + b; - assert_eq!(sum.input_tokens, 25); - assert_eq!(sum.output_tokens, 30); - assert_eq!(sum.total_tokens(), 83); - assert_eq!(sum.reasoning_tokens, 15); - assert_eq!(sum.cache_read_tokens, 10); - assert_eq!(sum.cache_write_tokens, 3); - } - - #[test] - fn usage_addition_one_none() { - let a = TokenCounts { - input_tokens: 10, - output_tokens: 15, - reasoning_tokens: 5, - ..TokenCounts::default() - }; - let b = TokenCounts { - input_tokens: 15, - output_tokens: 25, - cache_read_tokens: 7, - ..TokenCounts::default() - }; - let sum = a + b; - assert_eq!(sum.reasoning_tokens, 5); - assert_eq!(sum.cache_read_tokens, 7); - assert_eq!(sum.cache_write_tokens, 0); - } - - #[test] - fn tool_choice_variants() { - assert_eq!(ToolChoice::Auto, ToolChoice::Auto); - assert_eq!(ToolChoice::None, ToolChoice::None); - assert_eq!(ToolChoice::Required, ToolChoice::Required); - let named = ToolChoice::named("get_weather"); - assert_eq!(named, ToolChoice::Named { - tool_name: "get_weather".to_string(), - }); - } - - #[test] - fn response_text_accessor() { - let response = Response { - id: "resp_1".into(), - model: "test-model".into(), - provider: "test".into(), - message: Message::assistant("Hello world"), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - assert_eq!(response.text(), "Hello world"); - } - - #[test] - fn response_tool_calls_accessor() { - let response = Response { - id: "resp_1".into(), - model: "test-model".into(), - provider: "test".into(), - message: Message { - role: Role::Assistant, - content: vec![ - ContentPart::text("Let me check"), - ContentPart::ToolCall(ToolCall::new( - "call_1", - "get_weather", - serde_json::json!({"city": "SF"}), - )), - ], - 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, - }; - let calls = response.tool_calls(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].name, "get_weather"); - assert_eq!(calls[0].id, "call_1"); - } - - #[test] - fn response_reasoning_accessor() { - let response = Response { - id: "resp_1".into(), - model: "test-model".into(), - provider: "test".into(), - message: Message { - role: Role::Assistant, - content: vec![ - ContentPart::Thinking(ThinkingData { - text: "Let me think...".into(), - signature: Some("sig_123".into()), - redacted: false, - }), - ContentPart::text("The answer is 42."), - ], - name: None, - tool_call_id: None, - }, - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - assert_eq!(response.reasoning(), Some("Let me think...".to_string())); - assert_eq!(response.text(), "The answer is 42."); - } - - #[test] - fn response_reasoning_returns_none_when_absent() { - let response = Response { - id: "resp_1".into(), - model: "test-model".into(), - provider: "test".into(), - message: Message::assistant("Hello"), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - assert_eq!(response.reasoning(), None); - } - - #[test] - fn stream_event_text_delta() { - let event = StreamEvent::text_delta("hello", Some("t1".into())); - match &event { - StreamEvent::TextDelta { delta, text_id } => { - assert_eq!(delta, "hello"); - assert_eq!(text_id, &Some("t1".to_string())); - } - other => panic!("Expected TextDelta, got {other:?}"), - } - } - - #[test] - fn stream_event_error() { - let event = StreamEvent::error(Error::Stream { - message: "something went wrong".into(), - source: None, - }); - match &event { - StreamEvent::Error { error, .. } => { - assert_eq!(error.to_string(), "Stream error: something went wrong"); - } - other => panic!("Expected Error, got {other:?}"), - } - } - - #[test] - fn retry_policy_delay_no_jitter() { - use std::time::Duration; - let policy = RetryPolicy { - max_retries: 3, - backoff: BackoffPolicy { - initial_delay: Duration::from_secs(1), - factor: 2.0, - max_delay: Duration::from_mins(1), - jitter: false, - }, - ..Default::default() - }; - // BackoffPolicy is 1-indexed: attempt 1 = base, attempt 2 = base*factor, etc. - assert_eq!(policy.backoff.delay_for_attempt(1), Duration::from_secs(1)); - assert_eq!(policy.backoff.delay_for_attempt(2), Duration::from_secs(2)); - assert_eq!(policy.backoff.delay_for_attempt(3), Duration::from_secs(4)); - assert_eq!(policy.backoff.delay_for_attempt(4), Duration::from_secs(8)); - } - - #[test] - fn retry_policy_delay_respects_max() { - use std::time::Duration; - let policy = RetryPolicy { - max_retries: 10, - backoff: BackoffPolicy { - initial_delay: Duration::from_secs(1), - factor: 2.0, - max_delay: Duration::from_secs(5), - jitter: false, - }, - ..Default::default() - }; - assert_eq!(policy.backoff.delay_for_attempt(6), Duration::from_secs(5)); - } - - #[test] - fn retry_policy_delay_with_jitter_in_range() { - use std::time::Duration; - let policy = RetryPolicy { - max_retries: 3, - backoff: BackoffPolicy { - initial_delay: Duration::from_secs(1), - factor: 2.0, - max_delay: Duration::from_mins(1), - jitter: true, - }, - ..Default::default() - }; - let delay = policy.backoff.delay_for_attempt(1); - // base * 0.5 to base * 1.5 => 0.5s to 1.5s - assert!(delay >= Duration::from_millis(500)); - assert!(delay <= Duration::from_millis(1500)); - } - - #[test] - fn adapter_timeout_defaults() { - let timeout = AdapterTimeout::default(); - assert!((timeout.connect - 30.0).abs() < f64::EPSILON); - assert!(timeout.request.is_none()); - assert!((timeout.stream_read.unwrap() - 300.0).abs() < f64::EPSILON); - } - - #[test] - fn content_part_text_constructor() { - let part = ContentPart::text("hello"); - assert_eq!(part, ContentPart::Text("hello".to_string())); - } - - #[test] - fn content_part_image_constructor() { - let part = ContentPart::Image(ImageData { - url: Some("https://example.com/img.png".into()), - data: None, - media_type: None, - detail: None, - }); - assert!(matches!(part, ContentPart::Image(_))); - } - - #[test] - fn tool_call_serde_roundtrip() { - let tc = ToolCall::new("c1", "test", serde_json::json!({})); - let json = serde_json::to_string(&tc).unwrap(); - let deserialized: ToolCall = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized, tc); - } - - #[test] - fn tool_result_with_image_data() { - let result = ToolResult { - tool_call_id: "call_1".into(), - content: serde_json::json!("screenshot taken"), - is_error: false, - image_data: Some(vec![0x89, 0x50, 0x4E, 0x47]), - image_media_type: Some("image/png".into()), - }; - assert!(result.image_data.is_some()); - assert_eq!(result.image_media_type.as_deref(), Some("image/png")); - } - - #[test] - fn tool_call_new_constructor() { - let tc = ToolCall::new("c1", "test", serde_json::json!({})); - assert_eq!(tc.id, "c1"); - assert_eq!(tc.name, "test"); - assert_eq!(tc.tool_type, "function"); - assert_eq!(tc.raw_arguments, None); - } - - #[test] - fn tool_call_deserialize_without_type_defaults_to_function() { - let json = r#"{"id":"c1","name":"test","arguments":{}}"#; - let tc: ToolCall = serde_json::from_str(json).unwrap(); - assert_eq!(tc.tool_type, "function"); - } - - #[test] - fn tool_call_serializes_type_field() { - let tc = ToolCall::new("c1", "test", serde_json::json!({})); - let json = serde_json::to_value(&tc).unwrap(); - assert_eq!(json["type"], "function"); - } - - #[test] - fn stream_event_step_finish_constructor() { - let response = Response { - id: "resp_1".into(), - model: "test-model".into(), - provider: "test".into(), - message: Message::assistant("tool response"), - 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, - }; - let tool_calls = vec![ToolCall::new( - "call_1", - "get_weather", - serde_json::json!({"city": "SF"}), - )]; - let tool_results = vec![ToolResult::success("call_1", serde_json::json!("72F"))]; - - let event = StreamEvent::step_finish( - FinishReason::ToolCalls, - response.usage.clone(), - response, - tool_calls, - tool_results, - ); - - match &event { - StreamEvent::StepFinish { - finish_reason, - usage, - tool_calls, - tool_results, - .. - } => { - assert_eq!(*finish_reason, FinishReason::ToolCalls); - assert_eq!(usage.input_tokens, 10); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].name, "get_weather"); - assert_eq!(tool_results.len(), 1); - assert_eq!(tool_results[0].tool_call_id, "call_1"); - } - other => panic!("Expected StepFinish, got {other:?}"), - } - } - - #[test] - fn tool_choice_mode_str_auto() { - assert_eq!(ToolChoice::Auto.mode_str(), "auto"); - } - - #[test] - fn tool_choice_mode_str_none() { - assert_eq!(ToolChoice::None.mode_str(), "none"); - } - - #[test] - fn tool_choice_mode_str_required() { - assert_eq!(ToolChoice::Required.mode_str(), "required"); - } - - #[test] - fn tool_choice_mode_str_named() { - assert_eq!(ToolChoice::named("get_weather").mode_str(), "named"); - } -} diff --git a/lib/components/fabro-llm/tests/integration.rs b/lib/components/fabro-llm/tests/integration.rs deleted file mode 100644 index c91cad136..000000000 --- a/lib/components/fabro-llm/tests/integration.rs +++ /dev/null @@ -1,814 +0,0 @@ -#![expect( - clippy::disallowed_methods, - reason = "Live provider integration tests read required API keys from process env." -)] - -use std::collections::HashMap; -use std::sync::Arc; - -use fabro_auth::ApiCredential; -use fabro_llm::client::Client; -use fabro_llm::error::ProviderErrorKind; -use fabro_llm::model_test::{ModelTestStatus, run_model_test}; -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::{ - AnthropicAdapter, BedrockAdapter, GeminiAdapter, OpenAiAdapter, OpenAiCompatibleAdapter, -}; -use fabro_llm::types::{ - CostSource, FinishReason, Message, ReasoningEffort, Request, ToolChoice, ToolDefinition, -}; -use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings}; -use fabro_model::{Catalog, ModelTestMode, ProviderId}; -use fabro_static::EnvVars; - -fn make_request(model: &str) -> Request { - Request { - model: model.to_string(), - messages: vec![Message::user("Say hello in exactly one word")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: Some(0.0), - top_p: None, - max_tokens: Some(50), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } -} - -/// Build the built-in catalog with `provider` enabled, plus an operator base -/// URL for providers such as Modal that do not ship one. -fn enabled_provider_catalog(provider: &ProviderId, base_url: Option) -> Arc { - let mut settings = LlmCatalogSettings::default(); - settings - .providers - .insert(provider.to_string(), ProviderCatalogSettings { - enabled: Some(true), - base_url, - ..ProviderCatalogSettings::default() - }); - Arc::new( - Catalog::from_builtin_with_overrides(&settings) - .unwrap_or_else(|err| panic!("enabled {provider} catalog should build: {err}")), - ) -} - -/// Drive the shared deep tool round trip for one catalog offering. -async fn assert_deep_tool_round_trip( - catalog: &Arc, - provider: &ProviderId, - model_id: &str, - credential: ApiCredential, -) { - let client = Arc::new( - Client::from_credentials(vec![credential], Arc::clone(catalog)) - .await - .unwrap_or_else(|err| panic!("{provider} client should build from the catalog: {err}")), - ); - let model = catalog - .get_on_provider(provider, model_id) - .unwrap_or_else(|| panic!("{provider} {model_id} should be present")); - - let outcome = run_model_test(model, ModelTestMode::Deep, None, client).await; - assert_eq!( - outcome.status, - ModelTestStatus::Ok, - "{provider} {model_id} deep test failed: {:?}", - outcome.error_message - ); -} - -#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))] -async fn anthropic_complete() { - let api_key = std::env::var(EnvVars::ANTHROPIC_API_KEY).expect("ANTHROPIC_API_KEY must be set"); - let adapter = AnthropicAdapter::new(api_key); - let request = make_request("claude-haiku-4-5"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert_eq!(response.finish_reason, FinishReason::Stop); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "anthropic"); -} - -#[fabro_macros::e2e_test(twin, live("OPENAI_API_KEY"))] -async fn openai_complete() { - let (base_url, api_key) = fabro_test::e2e_openai!(); - let adapter = OpenAiAdapter::new(api_key).with_base_url(base_url); - let request = Request { - temperature: None, - ..make_request("gpt-5.2") - }; - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert_eq!(response.finish_reason, FinishReason::Stop); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "openai"); -} - -#[fabro_macros::e2e_test(twin, live("OPENAI_API_KEY"))] -async fn openai_gpt_5_3_codex_complete() { - let (base_url, api_key) = fabro_test::e2e_openai!(); - let adapter = OpenAiAdapter::new(api_key).with_base_url(base_url); - let request = make_request("gpt-5.3-codex"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "openai"); -} - -#[fabro_macros::e2e_test(live("OPENAI_API_KEY"))] -async fn openai_gpt_5_5_complete() { - let api_key = std::env::var(EnvVars::OPENAI_API_KEY).expect("OPENAI_API_KEY must be set"); - let adapter = OpenAiAdapter::new(api_key); - let request = Request { - temperature: None, - ..make_request("gpt-5.5") - }; - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "openai"); -} - -#[fabro_macros::e2e_test(live("OPENAI_GPT_5_5_PRO_API_KEY"))] -async fn openai_gpt_5_5_pro_complete() { - let api_key = std::env::var("OPENAI_GPT_5_5_PRO_API_KEY") - .expect("OPENAI_GPT_5_5_PRO_API_KEY must be set"); - let adapter = OpenAiAdapter::new(api_key); - let request = Request { - temperature: None, - ..make_request("gpt-5.5-pro") - }; - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "openai"); -} - -#[fabro_macros::e2e_test(live("KIMI_API_KEY"))] -async fn kimi_k3_reasoning_tool_round_trip() { - let api_key = std::env::var(EnvVars::KIMI_API_KEY).expect("KIMI_API_KEY must be set"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://api.moonshot.ai/v1") - .with_name("moonshot") - .with_catalog(Arc::new(Catalog::from_builtin().unwrap())); - let tool = ToolDefinition::function( - "multiply", - "Multiply two integers", - serde_json::json!({ - "type": "object", - "properties": { - "a": {"type": "integer"}, - "b": {"type": "integer"} - }, - "required": ["a", "b"] - }), - ); - let request = Request { - model: "kimi-k3".to_string(), - messages: vec![Message::user( - "Use the multiply tool to calculate 19 times 23. Do not calculate it yourself.", - )], - tools: Some(vec![tool]), - tool_choice: Some(ToolChoice::Required), - temperature: Some(0.0), - max_tokens: Some(4096), - reasoning_effort: Some(ReasoningEffort::Low), - ..make_request("kimi-k3") - }; - - let tool_response = adapter.complete(&request).await.unwrap(); - assert_eq!(tool_response.finish_reason, FinishReason::ToolCalls); - assert!( - tool_response.reasoning().is_some(), - "K3 should return reasoning content before its tool call" - ); - let tool_call = tool_response - .tool_calls() - .into_iter() - .next() - .expect("K3 should call the required tool"); - assert_eq!(tool_call.name, "multiply"); - - let mut messages = request.messages.clone(); - messages.push(tool_response.message); - messages.push(Message::tool_result( - tool_call.id, - serde_json::json!({"product": 437}), - false, - )); - let final_request = Request { - model: "kimi-k3".to_string(), - messages, - temperature: Some(0.0), - max_tokens: Some(2048), - reasoning_effort: Some(ReasoningEffort::Low), - ..make_request("kimi-k3") - }; - - let final_response = adapter.complete(&final_request).await.unwrap(); - assert_eq!(final_response.finish_reason, FinishReason::Stop); - assert!( - final_response.text().contains("437"), - "K3 should incorporate the replayed tool result" - ); -} - -#[fabro_macros::e2e_test(twin)] -async fn openai_server_error() { - let (base_url, api_key) = fabro_test::e2e_openai!(); - let admin_url = base_url - .strip_suffix("/v1") - .expect("OpenAI base URL should end with /v1"); - - fabro_test::test_http_client() - .post(format!("{admin_url}/__admin/scenarios")) - .bearer_auth(&api_key) - .json(&serde_json::json!({ - "scenarios": [{ - "matcher": { "endpoint": "responses" }, - "script": { - "kind": "error", - "status": 500, - "message": "internal server error", - "error_type": "server_error", - "code": "server_error" - } - }] - })) - .send() - .await - .unwrap(); - - let adapter = OpenAiAdapter::new(api_key).with_base_url(base_url); - let request = make_request("gpt-4o-mini"); - let err = adapter.complete(&request).await.unwrap_err(); - - assert_eq!(err.provider_kind(), Some(ProviderErrorKind::Server)); - assert_eq!(err.status_code(), Some(500)); -} - -#[fabro_macros::e2e_test(live("GEMINI_API_KEY"))] -async fn gemini_complete() { - let api_key = std::env::var(EnvVars::GEMINI_API_KEY).expect("GEMINI_API_KEY must be set"); - let adapter = GeminiAdapter::new(api_key); - let request = make_request("gemini-2.5-flash"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert_eq!(response.finish_reason, FinishReason::Stop); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "gemini"); -} - -#[fabro_macros::e2e_test(live("AWS_BEARER_TOKEN_BEDROCK"))] -async fn bedrock_complete_with_api_key() { - let token = std::env::var(EnvVars::AWS_BEARER_TOKEN_BEDROCK) - .expect("AWS_BEARER_TOKEN_BEDROCK must be set"); - let adapter = - BedrockAdapter::new_api_key(token, "https://bedrock-runtime.us-east-1.amazonaws.com") - .unwrap() - .with_name("bedrock"); - // Amazon Nova: first-party, no Anthropic-approval gate and no third-party - // marketplace subscription, so this runs on any Bedrock-enabled account. - let request = make_request("us.amazon.nova-2-lite-v1:0"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "bedrock"); -} - -#[fabro_macros::e2e_test(live("AWS_ACCESS_KEY_ID"))] -async fn bedrock_complete_with_sigv4() { - let adapter = BedrockAdapter::new_sigv4("https://bedrock-runtime.us-east-1.amazonaws.com") - .unwrap() - .with_name("bedrock"); - // First-party Nova — see bedrock_complete_with_api_key for why. - let request = make_request("us.amazon.nova-2-lite-v1:0"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert_eq!(response.provider, "bedrock"); -} - -#[fabro_macros::e2e_test(live("AWS_BEARER_TOKEN_BEDROCK"))] -async fn bedrock_openai_frontier_complete() { - let token = std::env::var(EnvVars::AWS_BEARER_TOKEN_BEDROCK) - .expect("AWS_BEARER_TOKEN_BEDROCK must be set"); - // GPT-5.x on Bedrock is the bedrock-mantle Responses surface: the plain - // openai adapter pointed at the mantle endpoint with the Bedrock key as - // the bearer token. - let adapter = OpenAiAdapter::new(token) - .with_base_url("https://bedrock-mantle.us-east-1.api.aws/openai/v1") - .with_name("bedrock-openai"); - let request = Request { - temperature: None, - ..make_request("openai.gpt-5.5") - }; - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert_eq!(response.provider, "bedrock-openai"); -} - -#[fabro_macros::e2e_test(live("POOLSIDE_API_KEY"))] -async fn poolside_laguna_xs_deep_tool_round_trip() { - let api_key = std::env::var(EnvVars::POOLSIDE_API_KEY).expect("POOLSIDE_API_KEY must be set"); - let provider = ProviderId::new("poolside"); - let catalog = enabled_provider_catalog(&provider, None); - let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog) - .expect("Poolside credential should resolve from the catalog"); - - assert_deep_tool_round_trip(&catalog, &provider, "laguna-xs-2.1", credential).await; -} - -#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))] -async fn openrouter_complete() { - let api_key = - std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1") - .with_name("openrouter"); - let request = make_request("deepseek/deepseek-v4-flash-0731"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "openrouter"); - assert!( - response.cost_usd.is_some(), - "OpenRouter responses should carry an authoritative usage.cost", - ); - assert_eq!(response.cost_source, Some(CostSource::Authoritative)); -} - -#[fabro_macros::e2e_test(live("ZAI_API_KEY"))] -async fn zai_glm_5_2_reasoning_tool_round_trip() { - let api_key = std::env::var(EnvVars::ZAI_API_KEY).expect("ZAI_API_KEY must be set"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://api.z.ai/api/coding/paas/v4") - .with_name("zai") - .with_catalog(Arc::new(Catalog::from_builtin().unwrap())); - let tool = ToolDefinition::function( - "multiply", - "Multiply two integers", - serde_json::json!({ - "type": "object", - "properties": { - "a": {"type": "integer"}, - "b": {"type": "integer"} - }, - "required": ["a", "b"] - }), - ); - let request = Request { - model: "glm-5.2".to_string(), - messages: vec![Message::user( - "Use the multiply tool to calculate 19 times 23. Do not calculate it yourself.", - )], - tools: Some(vec![tool]), - tool_choice: Some(ToolChoice::Required), - temperature: Some(0.0), - max_tokens: Some(4096), - reasoning_effort: Some(ReasoningEffort::High), - ..make_request("glm-5.2") - }; - - let tool_response = adapter.complete(&request).await.unwrap(); - assert_eq!(tool_response.finish_reason, FinishReason::ToolCalls); - let raw_message_keys = tool_response - .raw - .as_ref() - .and_then(|raw| raw.pointer("/choices/0/message")) - .and_then(serde_json::Value::as_object) - .map(|message| message.keys().cloned().collect::>()) - .unwrap_or_default(); - assert!( - tool_response.reasoning().is_some(), - "GLM 5.2 should return reasoning content before its tool call; raw message keys: \ - {raw_message_keys:?}" - ); - let tool_call = tool_response - .tool_calls() - .into_iter() - .next() - .expect("GLM 5.2 should call the required tool"); - assert_eq!(tool_call.name, "multiply"); - - let mut messages = request.messages.clone(); - messages.push(tool_response.message); - messages.push(Message::tool_result( - tool_call.id, - serde_json::json!({"product": 437}), - false, - )); - let final_request = Request { - model: "glm-5.2".to_string(), - messages, - temperature: Some(0.0), - max_tokens: Some(2048), - reasoning_effort: Some(ReasoningEffort::High), - ..make_request("glm-5.2") - }; - - let final_response = adapter.complete(&final_request).await.unwrap(); - assert_eq!(final_response.finish_reason, FinishReason::Stop); - assert!( - final_response.text().contains("437"), - "GLM 5.2 should incorporate the replayed tool result" - ); -} - -#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))] -async fn openrouter_glm_5_2_reasoning_tool_round_trip() { - let api_key = - std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set"); - let overrides: LlmCatalogSettings = toml::from_str( - r" -[providers.openrouter] -enabled = true -", - ) - .expect("OpenRouter catalog override should parse"); - let catalog = Catalog::from_builtin_with_overrides(&overrides) - .expect("enabled OpenRouter catalog should build"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1") - .with_name("openrouter") - .with_catalog(Arc::new(catalog)); - let tool = ToolDefinition::function( - "multiply", - "Multiply two integers", - serde_json::json!({ - "type": "object", - "properties": { - "a": {"type": "integer"}, - "b": {"type": "integer"} - }, - "required": ["a", "b"] - }), - ); - let request = Request { - model: "z-ai/glm-5.2".to_string(), - messages: vec![Message::user( - "Use the multiply tool to calculate 19 times 23. Do not calculate it yourself.", - )], - tools: Some(vec![tool]), - tool_choice: Some(ToolChoice::Required), - temperature: Some(0.0), - max_tokens: Some(4096), - reasoning_effort: Some(ReasoningEffort::High), - ..make_request("z-ai/glm-5.2") - }; - - let tool_response = adapter.complete(&request).await.unwrap(); - assert_eq!(tool_response.finish_reason, FinishReason::ToolCalls); - let raw_message_keys = tool_response - .raw - .as_ref() - .and_then(|raw| raw.pointer("/choices/0/message")) - .and_then(serde_json::Value::as_object) - .map(|message| message.keys().cloned().collect::>()) - .unwrap_or_default(); - assert!( - tool_response.reasoning().is_some(), - "GLM 5.2 should return reasoning content before its tool call; raw message keys: \ - {raw_message_keys:?}" - ); - assert_eq!(tool_response.cost_source, Some(CostSource::Authoritative)); - let tool_call = tool_response - .tool_calls() - .into_iter() - .next() - .expect("GLM 5.2 should call the required tool"); - assert_eq!(tool_call.name, "multiply"); - - let mut messages = request.messages.clone(); - messages.push(tool_response.message); - messages.push(Message::tool_result( - tool_call.id, - serde_json::json!({"product": 437}), - false, - )); - let final_request = Request { - model: "z-ai/glm-5.2".to_string(), - messages, - temperature: Some(0.0), - max_tokens: Some(2048), - reasoning_effort: Some(ReasoningEffort::High), - ..make_request("z-ai/glm-5.2") - }; - - let final_response = adapter.complete(&final_request).await.unwrap(); - assert_eq!(final_response.finish_reason, FinishReason::Stop); - assert!( - final_response.text().contains("437"), - "GLM 5.2 should incorporate the replayed tool result" - ); - assert_eq!(final_response.cost_source, Some(CostSource::Authoritative)); -} - -#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))] -async fn openrouter_poolside_laguna_complete() { - let api_key = - std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1") - .with_name("openrouter"); - let request = make_request("poolside/laguna-xs-2.1"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "openrouter"); - assert!( - response.cost_usd.is_some(), - "OpenRouter responses should carry an authoritative usage.cost", - ); - assert_eq!(response.cost_source, Some(CostSource::Authoritative)); -} - -#[fabro_macros::e2e_test(live("FIREWORKS_API_KEY"))] -async fn fireworks_complete() { - let api_key = std::env::var(EnvVars::FIREWORKS_API_KEY).expect("FIREWORKS_API_KEY must be set"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://api.fireworks.ai/inference/v1") - .with_name("fireworks"); - // gpt-oss models spend reasoning tokens before the final text, so the - // completion budget must cover both. - let request = Request { - max_tokens: Some(2048), - ..make_request("accounts/fireworks/models/gpt-oss-20b") - }; - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "fireworks"); -} - -#[fabro_macros::e2e_test(live("DEEPSEEK_API_KEY"))] -async fn deepseek_complete() { - let api_key = std::env::var(EnvVars::DEEPSEEK_API_KEY).expect("DEEPSEEK_API_KEY must be set"); - let adapter = - OpenAiCompatibleAdapter::new(api_key, "https://api.deepseek.com").with_name("deepseek"); - let request = Request { - // Thinking mode is enabled by default and shares this budget with the - // visible answer. - max_tokens: Some(1024), - ..make_request("deepseek-v4-flash") - }; - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0 || response.usage.reasoning_tokens > 0); - assert_eq!(response.provider, "deepseek"); -} - -#[fabro_macros::e2e_test(live("DEEPSEEK_API_KEY"))] -async fn deepseek_v4_flash_deep_tool_round_trip() { - let api_key = std::env::var(EnvVars::DEEPSEEK_API_KEY).expect("DEEPSEEK_API_KEY must be set"); - let provider = ProviderId::new("deepseek"); - let catalog = enabled_provider_catalog(&provider, None); - let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog) - .expect("DeepSeek credential should resolve from the catalog"); - - assert_deep_tool_round_trip(&catalog, &provider, "deepseek-v4-flash", credential).await; -} - -#[fabro_macros::e2e_test(live("FIREWORKS_API_KEY"))] -async fn fireworks_kimi_k2_7_code_deep_tool_round_trip() { - let api_key = std::env::var(EnvVars::FIREWORKS_API_KEY).expect("FIREWORKS_API_KEY must be set"); - let provider = ProviderId::new("fireworks"); - let catalog = enabled_provider_catalog(&provider, None); - let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog) - .expect("Fireworks credential should resolve from the catalog"); - - assert_deep_tool_round_trip(&catalog, &provider, "kimi-k2.7-code", credential).await; -} - -#[fabro_macros::e2e_test(live("FIREWORKS_API_KEY"))] -async fn fireworks_kimi_k3_fast_deep_tool_round_trip() { - let api_key = std::env::var(EnvVars::FIREWORKS_API_KEY).expect("FIREWORKS_API_KEY must be set"); - let provider = ProviderId::new("fireworks"); - let catalog = enabled_provider_catalog(&provider, None); - let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog) - .expect("Fireworks credential should resolve from the catalog"); - - assert_deep_tool_round_trip(&catalog, &provider, "kimi-k3-fast", credential).await; -} - -#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))] -async fn openrouter_kimi_k3_deep_tool_round_trip() { - let api_key = - std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set"); - let provider = ProviderId::new("openrouter"); - let catalog = enabled_provider_catalog(&provider, None); - let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog) - .expect("OpenRouter credential should resolve from the catalog"); - - assert_deep_tool_round_trip(&catalog, &provider, "kimi-k3", credential).await; -} - -#[fabro_macros::e2e_test( - live("MODAL_KIMI_K3_BASE_URL"), - live("MODAL_TOKEN_ID"), - live("MODAL_TOKEN_SECRET") -)] -async fn modal_kimi_k3_deep_tool_round_trip() { - let base_url = - std::env::var("MODAL_KIMI_K3_BASE_URL").expect("MODAL_KIMI_K3_BASE_URL must be set"); - let token_id = std::env::var(EnvVars::MODAL_TOKEN_ID).expect("MODAL_TOKEN_ID must be set"); - let token_secret = - std::env::var(EnvVars::MODAL_TOKEN_SECRET).expect("MODAL_TOKEN_SECRET must be set"); - let provider = ProviderId::new("modal"); - let catalog = enabled_provider_catalog(&provider, Some(base_url)); - let credential = ApiCredential::with_extra_headers( - provider.clone(), - HashMap::from([ - ("Modal-Key".to_string(), token_id), - ("Modal-Secret".to_string(), token_secret), - ]), - ); - - assert_deep_tool_round_trip(&catalog, &provider, "kimi-k3", credential).await; -} - -async fn run_multi_turn_cache_test( - adapter: &dyn ProviderAdapter, - model: &str, - min_cache_ratio: f64, - temperature: Option, -) { - // Claude Haiku 4.5 requires 4096 tokens minimum for prompt caching. - // Each repeat is ~78 tokens; 70 repeats ≈ 5460 tokens, safely above the - // threshold. - let padding = "This is a detailed context paragraph that provides background information \ - about the conversation. It contains various facts and details that the model should \ - remember throughout the multi-turn interaction. The purpose of this padding is to \ - ensure the system prompt exceeds the minimum cache threshold for the provider. \ - We include information about mathematics, science, history, and general knowledge. \ - The model should use this context when answering questions. " - .repeat(70); - - let system_message = Message::system(format!( - "You are a helpful math assistant. Answer briefly.\n\n{padding}" - )); - - let questions = [ - "What is 1+1?", - "What is 2+2?", - "What is 3+3?", - "What is 4+4?", - "What is 5+5?", - "What is 6+6?", - ]; - - let mut messages = vec![system_message, Message::user(questions[0])]; - let mut best_cache_ratio = 0.0_f64; - - for turn in 0..6 { - let request = Request { - model: model.to_string(), - messages: messages.clone(), - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature, - top_p: None, - max_tokens: Some(100), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - }; - - let response = adapter - .complete(&request) - .await - .expect("provider adapter should return a response"); - let text = response.text(); - assert!( - !text.is_empty(), - "response text should not be empty on turn {turn}" - ); - - let cache_read = response.usage.cache_read_tokens as f64; - let input = response.usage.input_tokens as f64; - let ratio = cache_read / input; - best_cache_ratio = best_cache_ratio.max(ratio); - - messages.push(Message::assistant(text)); - if turn < 5 { - messages.push(Message::user(questions[turn + 1])); - } - } - - assert!( - best_cache_ratio >= min_cache_ratio, - "best cache ratio {best_cache_ratio:.3} should be at least {min_cache_ratio} across all turns" - ); -} - -#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))] -async fn anthropic_multi_turn_cache() { - let api_key = std::env::var(EnvVars::ANTHROPIC_API_KEY).expect("ANTHROPIC_API_KEY must be set"); - let adapter = - AnthropicAdapter::new(api_key).with_catalog(Arc::new(Catalog::from_builtin().unwrap())); - run_multi_turn_cache_test(&adapter, "claude-haiku-4-5", 0.5, Some(0.0)).await; -} - -#[fabro_macros::e2e_test(live("OPENAI_API_KEY"))] -async fn openai_multi_turn_cache() { - let api_key = std::env::var(EnvVars::OPENAI_API_KEY).expect("OPENAI_API_KEY must be set"); - let adapter = OpenAiAdapter::new(api_key); - run_multi_turn_cache_test(&adapter, "gpt-5.2", 0.5, None).await; -} - -#[fabro_macros::e2e_test(live("GEMINI_API_KEY"))] -async fn gemini_multi_turn_cache() { - let api_key = std::env::var(EnvVars::GEMINI_API_KEY).expect("GEMINI_API_KEY must be set"); - let adapter = GeminiAdapter::new(api_key); - run_multi_turn_cache_test(&adapter, "gemini-2.5-flash", 0.5, Some(0.0)).await; -} - -/// Prompt caching for Claude routed through OpenRouter: the catalog row opts -/// into explicit `cache_control` breakpoints, and OpenRouter must forward -/// them to Anthropic for cache reads to appear. Guards the end-to-end -/// passthrough the wire tests can't see. -#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))] -async fn openrouter_claude_multi_turn_cache() { - let api_key = - std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set"); - let overrides: LlmCatalogSettings = toml::from_str( - r" -[providers.openrouter] -enabled = true -", - ) - .expect("OpenRouter catalog override should parse"); - let catalog = Catalog::from_builtin_with_overrides(&overrides) - .expect("enabled OpenRouter catalog should build"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1") - .with_name("openrouter") - .with_catalog(Arc::new(catalog)); - run_multi_turn_cache_test(&adapter, "claude-haiku-4-5", 0.5, Some(0.0)).await; -} diff --git a/lib/components/fabro-llm/tests/it/main.rs b/lib/components/fabro-llm/tests/it/main.rs deleted file mode 100644 index 66dc027b4..000000000 --- a/lib/components/fabro-llm/tests/it/main.rs +++ /dev/null @@ -1,7 +0,0 @@ -#![allow( - clippy::absolute_paths, - reason = "This test module prefers explicit type paths over extra imports." -)] - -mod support; -mod wire; diff --git a/lib/components/fabro-llm/tests/it/support.rs b/lib/components/fabro-llm/tests/it/support.rs deleted file mode 100644 index 3c1e3a62c..000000000 --- a/lib/components/fabro-llm/tests/it/support.rs +++ /dev/null @@ -1,482 +0,0 @@ -//! Shared helpers for capturing the wire requests adapters send, plus the -//! canonical request corpus pinned across all four provider dialects. - -use std::sync::{Arc, Mutex}; - -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::types::{ - AudioData, ContentPart, DocumentData, ImageData, Message, Request, ResponseFormat, Role, - ThinkingData, ToolCall, ToolChoice, ToolDefinition, ToolResult, -}; -use fabro_model::Catalog; -use fabro_model::catalog::LlmCatalogSettings; -use httpmock::prelude::*; - -// --------------------------------------------------------------------------- -// Wire capture -// --------------------------------------------------------------------------- - -/// One captured wire request, normalized for snapshot stability. -#[derive(Debug, Clone, serde::Serialize)] -pub(crate) struct WireCapture { - pub(crate) method: String, - pub(crate) path: String, - pub(crate) headers: Vec<(String, String)>, - pub(crate) body: serde_json::Value, -} - -/// Shared slot the matcher closure writes the captured request into. -pub(crate) type CaptureSlot = Arc>>; - -fn capture_request(req: &HttpMockRequest) -> WireCapture { - let mut headers: Vec<(String, String)> = req - .headers_vec() - .iter() - .map(|(name, value)| { - let name = name.to_ascii_lowercase(); - let value = match name.as_str() { - // The mock server binds a random port. - "host" => "[host]".to_string(), - // Carries a client version that would churn snapshots. - "user-agent" => "[user-agent]".to_string(), - _ => value.clone(), - }; - (name, value) - }) - .collect(); - headers.sort(); - - let path = match req.uri().query() { - Some(query) => format!("{}?{}", req.uri().path(), query), - None => req.uri().path().to_string(), - }; - - WireCapture { - method: req.method_str().to_string(), - path, - headers, - body: serde_json::from_str(&req.body_string()).expect("request body should be JSON"), - } -} - -/// Mounts a mock on `path` that captures the full request into the returned -/// slot and responds with the JSON `response_body`. -pub(crate) fn mount_capture<'a>( - server: &'a MockServer, - path: &'static str, - response_body: serde_json::Value, -) -> (httpmock::Mock<'a>, CaptureSlot) { - let slot: CaptureSlot = Arc::new(Mutex::new(None)); - let writer = Arc::clone(&slot); - let mock = server.mock(move |when, then| { - when.method(POST) - .path(path) - .is_true(move |req: &HttpMockRequest| { - *writer.lock().unwrap() = Some(capture_request(req)); - true - }); - then.status(200) - .header("content-type", "application/json") - .json_body(response_body); - }); - (mock, slot) -} - -/// Like [`mount_capture`] but responds with a raw SSE transcript. -pub(crate) fn mount_capture_sse<'a>( - server: &'a MockServer, - path: &'static str, - sse_body: &str, -) -> (httpmock::Mock<'a>, CaptureSlot) { - let slot: CaptureSlot = Arc::new(Mutex::new(None)); - let writer = Arc::clone(&slot); - let body = sse_body.to_string(); - let mock = server.mock(move |when, then| { - when.method(POST) - .path(path) - .is_true(move |req: &HttpMockRequest| { - *writer.lock().unwrap() = Some(capture_request(req)); - true - }); - then.status(200) - .header("content-type", "text/event-stream") - .body(body.clone()); - }); - (mock, slot) -} - -pub(crate) fn take_capture(slot: &CaptureSlot) -> WireCapture { - slot.lock() - .unwrap() - .take() - .expect("matcher should have captured the request") -} - -/// Drives `adapter.stream(request)` to completion and returns every emitted -/// item as JSON: `Ok` events serialize verbatim (the public SSE wire shape); -/// `Err` items pin the message plus the failover/retry flags consumers key on. -pub(crate) async fn collect_stream_events( - adapter: &dyn ProviderAdapter, - request: &Request, -) -> Vec { - use futures::StreamExt; - - let mut stream = adapter.stream(request).await.expect("stream should start"); - let mut events = Vec::new(); - while let Some(item) = stream.next().await { - events.push(match item { - Ok(event) => serde_json::to_value(&event).expect("event should serialize"), - Err(error) => serde_json::json!({ - "stream_item_error": error.to_string(), - "retryable": error.retryable(), - "failover_eligible": error.failover_eligible(), - }), - }); - } - events -} - -/// Pin the transport-level liveness contract independently of snapshots. -pub(crate) fn assert_stream_starts(events: &[serde_json::Value]) { - assert_eq!( - events - .first() - .and_then(|event| event.get("type")) - .and_then(serde_json::Value::as_str), - Some("stream_start"), - "the first decoded provider frame must open with stream_start" - ); -} - -/// Builds a catalog from inline TOML (same `LlmCatalogSettings` schema as the -/// shipped catalog files). -pub(crate) fn catalog_from_toml(source: &str) -> Arc { - let settings: LlmCatalogSettings = toml::from_str(source).expect("catalog TOML should parse"); - Arc::new(Catalog::from_settings(&settings).expect("catalog should build")) -} - -fn is_uuid(s: &str) -> bool { - s.len() == 36 - && s.bytes().enumerate().all(|(i, b)| match i { - 8 | 13 | 18 | 23 => b == b'-', - _ => b.is_ascii_hexdigit(), - }) -} - -/// Replaces UUID-shaped strings with `[UUID]` for snapshot stability — the -/// Gemini decoder mints synthetic `Uuid::new_v4()` tool-call ids. -pub(crate) fn normalize_uuids(value: &mut serde_json::Value) { - match value { - serde_json::Value::String(s) if is_uuid(s) => "[UUID]".clone_into(s), - serde_json::Value::Array(items) => items.iter_mut().for_each(normalize_uuids), - serde_json::Value::Object(map) => map.values_mut().for_each(normalize_uuids), - _ => {} - } -} - -/// Renders `(event, data)` pairs as an SSE transcript with `event:` lines -/// (the Anthropic framing). -pub(crate) fn sse_transcript(events: &[(&str, &str)]) -> String { - use std::fmt::Write; - - events.iter().fold(String::new(), |mut out, (event, data)| { - let _ = writeln!(out, "event: {event}\ndata: {data}\n"); - out - }) -} - -/// Renders data-only SSE lines (the OpenAI/Gemini framing). -pub(crate) fn sse_data_transcript(lines: &[&str]) -> String { - use std::fmt::Write; - - lines.iter().fold(String::new(), |mut out, data| { - let _ = writeln!(out, "data: {data}\n"); - out - }) -} - -// --------------------------------------------------------------------------- -// Canonical request corpus -// -// Each constructor returns one canonical `Request` shape that every dialect -// file pins through its own adapter. Keep these stable: editing a corpus -// request invalidates the pinned wire snapshots in all four dialect files. -// --------------------------------------------------------------------------- - -pub(crate) fn base_request(model: &str) -> Request { - Request { - model: model.to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: Some(128), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } -} - -/// Multi-turn conversation: system + user/assistant/user. -pub(crate) fn corpus_multi_turn(model: &str) -> Request { - Request { - messages: vec![ - Message::system("You are a terse assistant."), - Message::user("What is the capital of France?"), - Message::assistant("Paris."), - Message::user("And of Spain?"), - ], - ..base_request(model) - } -} - -/// Two tools plus an optional tool choice. -pub(crate) fn corpus_tools(model: &str, tool_choice: Option) -> Request { - Request { - tools: Some(vec![ - ToolDefinition::function( - "search", - "Search files", - serde_json::json!({ - "type": "object", - "properties": {"query": {"type": "string"}}, - "required": ["query"] - }), - ), - ToolDefinition::function( - "read_file", - "Read a file by path", - serde_json::json!({ - "type": "object", - "properties": {"path": {"type": "string"}} - }), - ), - ]), - tool_choice, - ..base_request(model) - } -} - -/// A full tool round trip: assistant emits two tool calls, the tool turn -/// returns one success carrying an image and one error result. -pub(crate) fn corpus_tool_round_trip(model: &str) -> Request { - let mut image_result = ToolResult::success("call_1", serde_json::json!({"matches": 2})); - image_result.image_data = Some(b"fake-screenshot-bytes".to_vec()); - image_result.image_media_type = Some("image/png".to_string()); - - let mut request = corpus_tools(model, None); - request.messages = vec![ - Message::user("Find foo and read /tmp/x"), - Message { - role: Role::Assistant, - content: vec![ - ContentPart::text("Let me check."), - ContentPart::ToolCall(ToolCall::new( - "call_1", - "search", - serde_json::json!({"query": "foo"}), - )), - ContentPart::ToolCall(ToolCall::new( - "call_2", - "read_file", - serde_json::json!({"path": "/tmp/x"}), - )), - ], - name: None, - tool_call_id: None, - }, - Message { - role: Role::Tool, - content: vec![ContentPart::ToolResult(image_result)], - name: None, - tool_call_id: Some("call_1".to_string()), - }, - Message::tool_result( - "call_2", - serde_json::Value::String("file not found".to_string()), - true, - ), - ]; - request -} - -/// Assistant thinking block with a signature, round-tripped back as history. -pub(crate) fn corpus_thinking_round_trip(model: &str) -> Request { - Request { - messages: vec![ - Message::user("Think step by step: what is 2+2?"), - Message { - role: Role::Assistant, - content: vec![ - ContentPart::Thinking(ThinkingData { - text: "The user wants 2+2, which is 4.".to_string(), - signature: Some("sig_test_abc123".to_string()), - redacted: false, - }), - ContentPart::text("4."), - ], - name: None, - tool_call_id: None, - }, - Message::user("Now 3+3?"), - ], - ..base_request(model) - } -} - -/// Image and document attachments as inline bytes (no file I/O involved). -pub(crate) fn corpus_inline_attachments(model: &str) -> Request { - Request { - messages: vec![Message { - role: Role::User, - content: vec![ - ContentPart::text("Describe these attachments."), - ContentPart::Image(ImageData { - url: None, - data: Some(b"fake-png-bytes".to_vec()), - media_type: Some("image/png".to_string()), - detail: None, - }), - ContentPart::Document(DocumentData { - url: None, - data: Some(b"fake-pdf-bytes".to_vec()), - media_type: Some("application/pdf".to_string()), - file_name: Some("report.pdf".to_string()), - }), - ], - name: None, - tool_call_id: None, - }], - ..base_request(model) - } -} - -/// Image and document attachments as non-file https URLs. Each dialect has -/// its own URL-passthrough wire shape; resolving these to inline data would -/// be a wire change. -pub(crate) fn corpus_url_attachments(model: &str) -> Request { - Request { - messages: vec![Message { - role: Role::User, - content: vec![ - ContentPart::text("Describe these attachments."), - ContentPart::Image(ImageData { - url: Some("https://example.com/picture.png".to_string()), - data: None, - media_type: Some("image/png".to_string()), - detail: None, - }), - ContentPart::Document(DocumentData { - url: Some("https://example.com/report.pdf".to_string()), - data: None, - media_type: Some("application/pdf".to_string()), - file_name: Some("report.pdf".to_string()), - }), - ], - name: None, - tool_call_id: None, - }], - ..base_request(model) - } -} - -/// Attachments referencing file paths that do not exist. Today every adapter -/// silently drops the part on load failure (`Err(_) => None`) and sends the -/// rest of the request; these requests pin that contract. -pub(crate) fn corpus_bad_file_path_attachments(model: &str) -> Request { - Request { - messages: vec![Message { - role: Role::User, - content: vec![ - ContentPart::text("Describe these attachments."), - ContentPart::Image(ImageData { - url: Some("/nonexistent/fabro-wire-pin.png".to_string()), - data: None, - media_type: Some("image/png".to_string()), - detail: None, - }), - ContentPart::Document(DocumentData { - url: Some("/nonexistent/fabro-wire-pin.pdf".to_string()), - data: None, - media_type: Some("application/pdf".to_string()), - file_name: Some("missing.pdf".to_string()), - }), - ], - name: None, - tool_call_id: None, - }], - ..base_request(model) - } -} - -/// Inline audio attachment (support differs per dialect: gemini sends it, -/// openai-responses falls back to text, anthropic/compat drop or warn). -pub(crate) fn corpus_audio_attachment(model: &str) -> Request { - Request { - messages: vec![Message { - role: Role::User, - content: vec![ - ContentPart::text("Transcribe this."), - ContentPart::Audio(AudioData { - url: None, - data: Some(b"fake-wav-bytes".to_vec()), - media_type: Some("audio/wav".to_string()), - }), - ], - name: None, - tool_call_id: None, - }], - ..base_request(model) - } -} - -/// Response-format request (callers pass each of the three kinds). -pub(crate) fn corpus_response_format(model: &str, format: ResponseFormat) -> Request { - Request { - response_format: Some(format), - ..base_request(model) - } -} - -/// A JSON-schema response format with `strict` set. The schema is passed raw -/// (no name/schema wrapper) — the shape `generate_object` produces. -pub(crate) fn json_schema_format() -> ResponseFormat { - ResponseFormat { - kind: fabro_llm::types::ResponseFormatType::JsonSchema, - json_schema: Some(serde_json::json!({ - "type": "object", - "properties": {"answer": {"type": "string"}}, - "required": ["answer"] - })), - strict: true, - } -} - -/// Sampling parameters: temperature, top_p, stop sequences, and metadata. -/// Metadata deliberately holds a single key — `HashMap` iteration order would -/// make multi-key snapshots nondeterministic. -pub(crate) fn corpus_sampling_params(model: &str) -> Request { - Request { - temperature: Some(0.7), - top_p: Some(0.9), - stop_sequences: Some(vec!["END".to_string()]), - metadata: Some(std::collections::HashMap::from([( - "trace_id".to_string(), - "trace-123".to_string(), - )])), - ..base_request(model) - } -} - -/// Provider-options escape hatch (callers pass the dialect's namespace key). -pub(crate) fn corpus_provider_options(model: &str, options: serde_json::Value) -> Request { - Request { - provider_options: Some(options), - ..base_request(model) - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/anthropic.rs b/lib/components/fabro-llm/tests/it/wire/anthropic.rs deleted file mode 100644 index 706893f16..000000000 --- a/lib/components/fabro-llm/tests/it/wire/anthropic.rs +++ /dev/null @@ -1,912 +0,0 @@ -//! Wire snapshots for the Anthropic Messages dialect. - -use std::sync::Arc; - -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::AnthropicAdapter; -use fabro_llm::types::{ - Message, ReasoningEffort, Request, ResponseFormat, ResponseFormatType, StreamEvent, ToolChoice, - ToolDefinition, -}; -use fabro_llm::{Error, ProviderErrorKind}; -use fabro_model::Catalog; -use futures::StreamExt; -use httpmock::prelude::*; - -use crate::support::{ - self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments, - corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format, - corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools, - corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture, -}; - -const MODEL: &str = "claude-sonnet-4-20250514"; - -/// Minimal valid Messages API body for encode-side tests that only assert on -/// the captured request. -fn minimal_body() -> serde_json::Value { - serde_json::json!({ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": MODEL, - "content": [{"type": "text", "text": "ok"}], - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": {"input_tokens": 1, "output_tokens": 1} - }) -} - -/// Runs `complete()` against a capture mock and returns the captured wire -/// request. -async fn encode_capture(adapter: AnthropicAdapter, request: &Request) -> WireCapture { - let server = MockServer::start(); - let (mock, slot) = mount_capture(&server, "/messages", minimal_body()); - let adapter = adapter.with_base_url(server.base_url()); - adapter - .complete(request) - .await - .expect("complete should succeed"); - mock.assert(); - take_capture(&slot) -} - -/// Runs `stream()` against an SSE transcript and returns the captured wire -/// request plus every emitted stream item as JSON. -async fn stream_capture( - adapter: AnthropicAdapter, - request: &Request, - sse_body: &str, -) -> (WireCapture, Vec) { - let server = MockServer::start(); - let (mock, slot) = mount_capture_sse(&server, "/messages", sse_body); - let adapter = adapter.with_base_url(server.base_url()); - let events = support::collect_stream_events(&adapter, request).await; - mock.assert(); - (take_capture(&slot), events) -} - -fn adapter() -> AnthropicAdapter { - AnthropicAdapter::new("test-key") -} - -fn builtin_catalog() -> Arc { - Arc::new(Catalog::from_builtin().expect("built-in catalog should build")) -} - -fn header_value<'a>(capture: &'a WireCapture, name: &str) -> Option<&'a str> { - capture - .headers - .iter() - .find(|(header, _)| header == name) - .map(|(_, value)| value.as_str()) -} - -// --------------------------------------------------------------------------- -// Round trip (encode + decode) -// --------------------------------------------------------------------------- - -/// Shared setup for the system+tools round trip: runs `complete()` against a -/// canned response and returns both the captured request and decoded response -/// so the encode and decode halves can be pinned by separate tests. -async fn system_and_tools_roundtrip() -> (WireCapture, fabro_llm::types::Response) { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - "/messages", - serde_json::json!({ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": "claude-sonnet-4-20250514", - "content": [{"type": "text", "text": "Hello back"}], - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { - "input_tokens": 42, - "output_tokens": 7, - "cache_read_input_tokens": 10, - "cache_creation_input_tokens": 3 - } - }), - ); - - let adapter = AnthropicAdapter::new("test-key").with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - tools: Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), - )]), - temperature: Some(0.5), - ..base_request("claude-sonnet-4-20250514") - }; - - let response = adapter - .complete(&request) - .await - .expect("complete should succeed"); - mock.assert(); - (take_capture(&slot), response) -} - -#[tokio::test] -async fn system_and_tools_encode() { - let (capture, _) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn system_and_tools_decode() { - let (_, response) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Encode -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn encode_multi_turn() { - let capture = encode_capture(adapter(), &corpus_multi_turn(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn encode_tool_choice_auto() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_required() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Required))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_named() { - let capture = encode_capture( - adapter(), - &corpus_tools(MODEL, Some(ToolChoice::named("search"))), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_none() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::None))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_round_trip() { - let capture = encode_capture(adapter(), &corpus_tool_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_thinking_round_trip() { - let capture = encode_capture(adapter(), &corpus_thinking_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_inline_attachments() { - let capture = encode_capture(adapter(), &corpus_inline_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_url_attachments() { - let capture = encode_capture(adapter(), &corpus_url_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_bad_file_path_attachments_dropped() { - let capture = encode_capture(adapter(), &corpus_bad_file_path_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_audio_attachment() { - let capture = encode_capture(adapter(), &corpus_audio_attachment(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_object() { - let format = ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }; - let capture = encode_capture(adapter(), &corpus_response_format(MODEL, format)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_schema() { - let capture = encode_capture( - adapter(), - &corpus_response_format(MODEL, json_schema_format()), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_sampling_params() { - let capture = encode_capture(adapter(), &corpus_sampling_params(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_provider_options_anthropic_namespace() { - let capture = encode_capture( - adapter(), - &corpus_provider_options(MODEL, serde_json::json!({"anthropic": {"top_k": 5}})), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_reasoning_effort_with_levels_catalog() { - let catalog = support::catalog_from_toml( - r#" -[providers.anthropic] -display_name = "Anthropic" -adapter = "anthropic" -agent_profile = "anthropic" - -[models."test-claude"] -provider = "anthropic" -display_name = "Test Claude" -family = "claude" -default = true - -[models."test-claude".limits] -context_window = 200000 -max_output = 4096 - -[models."test-claude".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = false -"#, - ); - let request = Request { - reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High), - ..base_request("test-claude") - }; - let capture = encode_capture(adapter().with_catalog(catalog), &request).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_fable_uses_api_id_effort_and_omits_1m_beta() { - let request = Request { - reasoning_effort: Some(ReasoningEffort::XHigh), - temperature: Some(0.0), - top_p: Some(0.5), - ..base_request("fable") - }; - - let capture = encode_capture(adapter().with_catalog(builtin_catalog()), &request).await; - - assert_eq!(capture.body["model"], "claude-fable-5"); - assert_eq!(capture.body["output_config"]["effort"], "xhigh"); - assert!(capture.body.get("thinking").is_none()); - assert!(capture.body.get("temperature").is_none()); - assert!(capture.body.get("top_p").is_none()); - assert!( - !header_value(&capture, "anthropic-beta") - .unwrap_or("") - .contains("context-1m-2025-08-07"), - "Fable has 1M context by default and must not receive the legacy beta header" - ); -} - -#[tokio::test] -async fn encode_opus_omits_1m_beta_header() { - let capture = encode_capture( - adapter().with_catalog(builtin_catalog()), - &base_request("claude-opus-4-8"), - ) - .await; - - assert!( - !header_value(&capture, "anthropic-beta") - .unwrap_or("") - .contains("context-1m-2025-08-07"), - "1M context is GA on opus; the legacy beta opt-in must not be sent" - ); -} - -#[tokio::test] -async fn encode_opus_drops_sampling_params() { - let request = Request { - temperature: Some(0.0), - top_p: Some(0.5), - ..base_request("claude-opus-4-8") - }; - - let capture = encode_capture(adapter().with_catalog(builtin_catalog()), &request).await; - - assert!( - capture.body.get("temperature").is_none(), - "Opus 4.7/4.8 reject temperature; it must not be sent" - ); - assert!( - capture.body.get("top_p").is_none(), - "Opus 4.7/4.8 reject top_p; it must not be sent" - ); -} - -#[tokio::test] -async fn encode_opus_effort_keeps_adaptive_thinking() { - let request = Request { - reasoning_effort: Some(ReasoningEffort::High), - ..base_request("claude-opus-4-8") - }; - - let capture = encode_capture(adapter().with_catalog(builtin_catalog()), &request).await; - - assert_eq!(capture.body["output_config"]["effort"], "high"); - assert_eq!( - capture.body["thinking"]["type"], "adaptive", - "asking for effort must not turn thinking off; Opus 4.7/4.8 run without thinking unless adaptive is sent" - ); -} - -#[tokio::test] -async fn encode_opus_without_effort_injects_adaptive_thinking() { - let capture = encode_capture( - adapter().with_catalog(builtin_catalog()), - &base_request("claude-opus-4-8"), - ) - .await; - - assert_eq!(capture.body["thinking"]["type"], "adaptive"); -} - -#[tokio::test] -async fn encode_fable_without_effort_omits_default_thinking() { - let capture = encode_capture( - adapter().with_catalog(builtin_catalog()), - &base_request("claude-fable-5"), - ) - .await; - - assert_eq!(capture.body["model"], "claude-fable-5"); - assert!(capture.body.get("thinking").is_none()); -} - -#[test] -fn fable_rejects_manual_enabled_or_disabled_thinking() { - let adapter = adapter().with_catalog(builtin_catalog()); - - for kind in ["enabled", "disabled"] { - let request = Request { - provider_options: Some(serde_json::json!({ - "anthropic": { - "thinking": {"type": kind, "budget_tokens": 1024} - } - })), - ..base_request("claude-fable-5") - }; - - let err = adapter - .validate_request(&request) - .expect_err("manual Fable thinking mode should be rejected locally"); - assert!( - err.to_string().contains("Claude Fable 5") - && err.to_string().contains("thinking") - && err.to_string().contains(kind), - "unexpected error: {err}" - ); - } -} - -#[tokio::test] -async fn encode_prompt_cache_with_catalog() { - let catalog = support::catalog_from_toml( - r#" -[providers.anthropic] -display_name = "Anthropic" -adapter = "anthropic" -agent_profile = "anthropic" - -[models."test-claude"] -provider = "anthropic" -display_name = "Test Claude" -family = "claude" -default = true - -[models."test-claude".limits] -context_window = 200000 -max_output = 4096 - -[models."test-claude".features] -tools = true -vision = true -reasoning = true -prompt_cache = true -"#, - ); - let request = Request { - messages: vec![ - Message::system("You are a careful reviewer."), - Message::user("Review this."), - ], - ..corpus_tools("test-claude", None) - }; - // Full capture: the prompt-cache path also controls the beta header. - let capture = encode_capture(adapter().with_catalog(catalog), &request).await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn count_tokens_wire_shape() { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - "/messages/count_tokens", - serde_json::json!({"input_tokens": 123}), - ); - - let adapter = adapter().with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - ..corpus_tools(MODEL, None) - }; - let count = adapter - .count_input_tokens(&request) - .await - .unwrap() - .expect("anthropic should count tokens"); - - mock.assert(); - assert_eq!(count.input_tokens, 123); - fabro_test::fabro_json_snapshot!(take_capture(&slot)); -} - -// --------------------------------------------------------------------------- -// Decode -// --------------------------------------------------------------------------- - -/// Runs `complete()` against a canned body and returns the decoded response. -async fn decode_response(body: serde_json::Value) -> fabro_llm::types::Response { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, "/messages", body); - let adapter = adapter().with_base_url(server.base_url()); - let response = adapter - .complete(&base_request(MODEL)) - .await - .expect("complete should succeed"); - mock.assert(); - response -} - -/// Runs `complete()` against a canned body and returns the adapter result. -async fn complete_result(body: serde_json::Value) -> Result { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, "/messages", body); - let adapter = adapter().with_base_url(server.base_url()); - let result = adapter.complete(&base_request(MODEL)).await; - mock.assert(); - result -} - -#[tokio::test] -async fn decode_tool_use_stop_reason() { - let response = decode_response(serde_json::json!({ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": MODEL, - "content": [ - {"type": "text", "text": "Let me search."}, - { - "type": "tool_use", - "id": "toolu_01", - "name": "search", - "input": {"query": "foo"} - } - ], - "stop_reason": "tool_use", - "stop_sequence": null, - "usage": {"input_tokens": 30, "output_tokens": 12} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -#[tokio::test] -async fn decode_thinking_and_redacted_thinking() { - let response = decode_response(serde_json::json!({ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": MODEL, - "content": [ - {"type": "thinking", "thinking": "Step one.", "signature": "sig_decode_abc"}, - {"type": "redacted_thinking", "data": "opaque-blob"}, - {"type": "text", "text": "Done."} - ], - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": {"input_tokens": 25, "output_tokens": 40} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -#[tokio::test] -async fn decode_max_tokens_stop_reason() { - let response = decode_response(serde_json::json!({ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": MODEL, - "content": [{"type": "text", "text": "Truncated answe"}], - "stop_reason": "max_tokens", - "stop_sequence": null, - "usage": {"input_tokens": 10, "output_tokens": 128} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -#[tokio::test] -async fn decode_refusal_returns_failover_eligible_content_filter_error() { - let err = complete_result(serde_json::json!({ - "id": "msg_refusal", - "type": "message", - "role": "assistant", - "model": "claude-fable-5", - "content": [], - "stop_reason": "refusal", - "stop_details": { - "type": "refusal", - "category": "cyber", - "explanation": "This request was declined because it could enable cyber harm." - }, - "usage": {"input_tokens": 412, "output_tokens": 0} - })) - .await - .expect_err("refusal should be returned as an LLM error"); - - assert!(err.failover_eligible()); - match &err { - Error::Provider { kind, detail } => { - assert_eq!(*kind, ProviderErrorKind::ContentFilter); - assert_eq!(detail.provider, "anthropic"); - assert_eq!(detail.error_code.as_deref(), Some("refusal")); - assert!(detail.message.contains("claude-fable-5")); - assert!(detail.message.contains("declined")); - assert_eq!( - detail.raw.as_ref().unwrap()["stop_details"]["category"], - "cyber" - ); - } - other => panic!("expected provider content-filter error, got {other:?}"), - } -} - -// --------------------------------------------------------------------------- -// Stream -// --------------------------------------------------------------------------- - -/// Shared setup for the happy-path text stream; the request and event halves -/// are pinned by separate tests. -async fn stream_text_happy_path_capture() -> (WireCapture, Vec) { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_stream_test","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":11,"cache_read_input_tokens":2,"cache_creation_input_tokens":1,"output_tokens":0}}}"#, - ), - ("ping", r#"{"type":"ping"}"#), - ( - "content_block_start", - r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"lo"}}"#, - ), - ( - "content_block_stop", - r#"{"type":"content_block_stop","index":0}"#, - ), - ( - "message_delta", - r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":5}}"#, - ), - ("message_stop", r#"{"type":"message_stop"}"#), - ]); - stream_capture(adapter(), &base_request(MODEL), &sse).await -} - -/// The captured request pins the stream flag on the wire. -#[tokio::test] -async fn stream_text_happy_path_request() { - let (capture, _) = stream_text_happy_path_capture().await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn stream_text_happy_path_events() { - let (_, events) = stream_text_happy_path_capture().await; - support::assert_stream_starts(&events); - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_tool_call_deltas() { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_stream_tool","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":20,"output_tokens":0}}}"#, - ), - ( - "content_block_start", - r#"{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01","name":"search","input":{}}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"qu"}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ery\":\"foo\"}"}}"#, - ), - ( - "content_block_stop", - r#"{"type":"content_block_stop","index":0}"#, - ), - ( - "message_delta", - r#"{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":9}}"#, - ), - ("message_stop", r#"{"type":"message_stop"}"#), - ]); - let (_capture, events) = stream_capture( - adapter(), - &corpus_tools(MODEL, Some(ToolChoice::Auto)), - &sse, - ) - .await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_thinking_with_signature_delta() { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_stream_think","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":15,"output_tokens":0}}}"#, - ), - ( - "content_block_start", - r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think"}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_stream_xyz"}}"#, - ), - ( - "content_block_stop", - r#"{"type":"content_block_stop","index":0}"#, - ), - ( - "content_block_start", - r#"{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"4."}}"#, - ), - ( - "content_block_stop", - r#"{"type":"content_block_stop","index":1}"#, - ), - ( - "message_delta", - r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}}"#, - ), - ("message_stop", r#"{"type":"message_stop"}"#), - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_error_event_mid_stream() { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_stream_err","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":9,"output_tokens":0}}}"#, - ), - ( - "error", - r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#, - ), - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_refusal_returns_error_without_final_response() { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_stream_refusal","type":"message","role":"assistant","model":"claude-fable-5","content":[],"usage":{"input_tokens":412,"output_tokens":0}}}"#, - ), - ( - "message_delta", - r#"{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":"cyber","explanation":"This request was declined."}},"usage":{"output_tokens":0}}"#, - ), - ("message_stop", r#"{"type":"message_stop"}"#), - ]); - let server = MockServer::start(); - let (mock, _slot) = mount_capture_sse(&server, "/messages", &sse); - let adapter = adapter().with_base_url(server.base_url()); - let mut stream = adapter - .stream(&base_request("claude-fable-5")) - .await - .expect("stream should start"); - - let mut saw_finish = false; - let mut refusal = None; - while let Some(item) = stream.next().await { - match item { - Ok(StreamEvent::Finish { .. }) => saw_finish = true, - Ok(_) => {} - Err(err) => { - refusal = Some(err); - break; - } - } - } - mock.assert(); - - assert!(!saw_finish, "refusal stream must not emit a final response"); - let err = refusal.expect("stream should yield a refusal error"); - assert!(err.failover_eligible()); - match &err { - Error::Provider { kind, detail } => { - assert_eq!(*kind, ProviderErrorKind::ContentFilter); - assert_eq!(detail.error_code.as_deref(), Some("refusal")); - assert!(detail.message.contains("claude-fable-5")); - assert_eq!( - detail.raw.as_ref().unwrap()["stop_details"]["category"], - "cyber" - ); - } - other => panic!("expected provider content-filter error, got {other:?}"), - } -} - -/// The Anthropic decoder never synthesizes a `Finish` on byte-stream end: -/// `message_stop` is the only finisher. A transcript that ends without it -/// must produce no `Finish` event. -#[tokio::test] -async fn stream_without_message_stop_emits_no_finish() { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_stream_cut","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":11,"output_tokens":0}}}"#, - ), - ( - "content_block_start", - r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#, - ), - ( - "content_block_stop", - r#"{"type":"content_block_stop","index":0}"#, - ), - ( - "message_delta", - r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":5}}"#, - ), - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -// --------------------------------------------------------------------------- -// Custom-named route (the Moonshot Kimi-over-anthropic shape) -// --------------------------------------------------------------------------- - -/// Shared setup for a custom-named Moonshot Kimi stream route using the -/// Anthropic dialect; separate tests pin the request and event halves. -async fn custom_named_stream_capture() -> (WireCapture, Vec) { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_kimi","type":"message","role":"assistant","model":"kimi-test","content":[],"usage":{"input_tokens":5,"output_tokens":0}}}"#, - ), - ( - "content_block_start", - r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#, - ), - ( - "content_block_stop", - r#"{"type":"content_block_stop","index":0}"#, - ), - ( - "message_delta", - r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}}"#, - ), - ("message_stop", r#"{"type":"message_stop"}"#), - ]); - stream_capture( - adapter().with_name("moonshot"), - &base_request("kimi-test"), - &sse, - ) - .await -} - -/// A custom-named Moonshot route authenticates with a bearer token and sends -/// no `anthropic-version` header. This pins that route shape on the wire. -#[tokio::test] -async fn custom_named_stream_route() { - let (capture, _) = custom_named_stream_capture().await; - fabro_test::fabro_json_snapshot!(capture); -} - -/// Since provider-identity normalization, the streamed `Response.provider` -/// carries the configured name. -#[tokio::test] -async fn custom_named_stream_identity() { - let (_, events) = custom_named_stream_capture().await; - fabro_test::fabro_json_snapshot!(events); -} - -/// Error events on a custom-named route carry the configured name in the -/// error detail (normalize-both decision). -#[tokio::test] -async fn custom_named_stream_error_identity() { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_kimi_err","type":"message","role":"assistant","model":"kimi-test","content":[],"usage":{"input_tokens":5,"output_tokens":0}}}"#, - ), - ( - "error", - r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#, - ), - ]); - let (_capture, events) = stream_capture( - adapter().with_name("moonshot"), - &base_request("kimi-test"), - &sse, - ) - .await; - fabro_test::fabro_json_snapshot!(events); -} diff --git a/lib/components/fabro-llm/tests/it/wire/gemini.rs b/lib/components/fabro-llm/tests/it/wire/gemini.rs deleted file mode 100644 index 2d1cc2e5e..000000000 --- a/lib/components/fabro-llm/tests/it/wire/gemini.rs +++ /dev/null @@ -1,536 +0,0 @@ -//! Wire snapshots for the Gemini `generateContent` dialect. The model is -//! part of the URL path, auth is the `x-goog-api-key` header, and the -//! decoder mints synthetic UUID tool-call ids (normalized to `[UUID]` in -//! these snapshots). - -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::GeminiAdapter; -use fabro_llm::types::{ - Message, Request, ResponseFormat, ResponseFormatType, ToolChoice, ToolDefinition, -}; -use httpmock::prelude::*; - -use crate::support::{ - self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments, - corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format, - corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools, - corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture, -}; - -const MODEL: &str = "gemini-test"; -const COMPLETE_PATH: &str = "/models/gemini-test:generateContent"; -const STREAM_PATH: &str = "/models/gemini-test:streamGenerateContent"; - -/// Minimal valid generateContent body for encode-side tests. -fn minimal_body() -> serde_json::Value { - serde_json::json!({ - "candidates": [{ - "content": {"role": "model", "parts": [{"text": "ok"}]}, - "finishReason": "STOP" - }], - "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1} - }) -} - -fn adapter() -> GeminiAdapter { - GeminiAdapter::new("test-key") -} - -/// Runs `complete()` against a capture mock and returns the captured wire -/// request. -async fn encode_capture(adapter: GeminiAdapter, request: &Request) -> WireCapture { - let server = MockServer::start(); - let (mock, slot) = mount_capture(&server, COMPLETE_PATH, minimal_body()); - let adapter = adapter.with_base_url(server.base_url()); - adapter - .complete(request) - .await - .expect("complete should succeed"); - mock.assert(); - take_capture(&slot) -} - -/// Runs `stream()` against an SSE transcript and returns the captured wire -/// request plus every emitted stream item as JSON (UUIDs normalized). -async fn stream_capture( - adapter: GeminiAdapter, - request: &Request, - sse_body: &str, -) -> (WireCapture, Vec) { - let server = MockServer::start(); - let (mock, slot) = mount_capture_sse(&server, STREAM_PATH, sse_body); - let adapter = adapter.with_base_url(server.base_url()); - let mut events = support::collect_stream_events(&adapter, request).await; - mock.assert(); - events.iter_mut().for_each(support::normalize_uuids); - (take_capture(&slot), events) -} - -// --------------------------------------------------------------------------- -// Round trip (encode + decode) -// --------------------------------------------------------------------------- - -/// Shared setup for the system+tools round trip. The decoded response is -/// returned as a UUID-normalized JSON value (gemini mints a synthetic UUID -/// for the response id); the encode and decode halves are pinned separately. -async fn system_and_tools_roundtrip() -> (WireCapture, serde_json::Value) { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - COMPLETE_PATH, - serde_json::json!({ - "candidates": [{ - "content": {"role": "model", "parts": [{"text": "Hello back"}]}, - "finishReason": "STOP" - }], - "usageMetadata": { - "promptTokenCount": 42, - "candidatesTokenCount": 7, - "cachedContentTokenCount": 10 - } - }), - ); - - let adapter = adapter().with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - tools: Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), - )]), - temperature: Some(0.5), - ..base_request(MODEL) - }; - - let response = adapter - .complete(&request) - .await - .expect("complete should succeed"); - mock.assert(); - let mut response_value = serde_json::to_value(&response).expect("response should serialize"); - support::normalize_uuids(&mut response_value); - (take_capture(&slot), response_value) -} - -#[tokio::test] -async fn system_and_tools_encode() { - let (capture, _) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn system_and_tools_decode() { - let (_, response) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Encode -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn encode_multi_turn() { - let capture = encode_capture(adapter(), &corpus_multi_turn(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_auto() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_required() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Required))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_named() { - let capture = encode_capture( - adapter(), - &corpus_tools(MODEL, Some(ToolChoice::named("search"))), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_none() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::None))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_round_trip() { - let capture = encode_capture(adapter(), &corpus_tool_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_thinking_round_trip() { - let capture = encode_capture(adapter(), &corpus_thinking_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_inline_attachments() { - let capture = encode_capture(adapter(), &corpus_inline_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_url_attachments() { - let capture = encode_capture(adapter(), &corpus_url_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_bad_file_path_attachments_dropped() { - let capture = encode_capture(adapter(), &corpus_bad_file_path_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// Gemini sends inline audio (the only dialect that does). -#[tokio::test] -async fn encode_audio_attachment() { - let capture = encode_capture(adapter(), &corpus_audio_attachment(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_object() { - let format = ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }; - let capture = encode_capture(adapter(), &corpus_response_format(MODEL, format)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_schema() { - let capture = encode_capture( - adapter(), - &corpus_response_format(MODEL, json_schema_format()), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_sampling_params() { - let capture = encode_capture(adapter(), &corpus_sampling_params(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// The "gemini"-namespaced provider_options merge — and the default -/// safety_settings injection it can override. -#[tokio::test] -async fn encode_provider_options_gemini_namespace() { - let capture = encode_capture( - adapter(), - &corpus_provider_options( - MODEL, - serde_json::json!({"gemini": {"cached_content": "cachedContents/abc"}}), - ), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_provider_options_can_override_safety_settings() { - let capture = encode_capture( - adapter(), - &corpus_provider_options( - MODEL, - serde_json::json!({"gemini": {"safety_settings": []}}), - ), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_reasoning_effort_with_levels_catalog() { - let catalog = support::catalog_from_toml( - r#" -[providers.gemini] -display_name = "Gemini" -adapter = "gemini" -agent_profile = "gemini" - -[models."gemini-test"] -provider = "gemini" -display_name = "Test Gemini" -family = "gemini" -default = true - -[models."gemini-test".limits] -context_window = 200000 -max_output = 4096 - -[models."gemini-test".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -"#, - ); - let request = Request { - reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High), - ..base_request(MODEL) - }; - let capture = encode_capture(adapter().with_catalog(catalog), &request).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn count_tokens_wire_shape() { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - "/models/gemini-test:countTokens", - serde_json::json!({"totalTokens": 123}), - ); - let adapter = adapter().with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - ..corpus_tools(MODEL, None) - }; - let count = adapter - .count_input_tokens(&request) - .await - .unwrap() - .expect("gemini should count tokens"); - - mock.assert(); - assert_eq!(count.input_tokens, 123); - fabro_test::fabro_json_snapshot!(take_capture(&slot)); -} - -// --------------------------------------------------------------------------- -// Decode -// --------------------------------------------------------------------------- - -/// Runs `complete()` against a canned body and returns the decoded response -/// as JSON with synthetic UUIDs normalized. -async fn decode_response(body: serde_json::Value) -> serde_json::Value { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, COMPLETE_PATH, body); - let adapter = adapter().with_base_url(server.base_url()); - let response = adapter - .complete(&base_request(MODEL)) - .await - .expect("complete should succeed"); - mock.assert(); - let mut value = serde_json::to_value(&response).expect("response should serialize"); - support::normalize_uuids(&mut value); - value -} - -/// functionCall parts get synthetic UUID ids, preserve `thoughtSignature`, -/// and force the finish reason to ToolCalls regardless of `finishReason`. -#[tokio::test] -async fn decode_function_call_with_thought_signature() { - let response = decode_response(serde_json::json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [ - {"text": "Let me search."}, - { - "functionCall": {"name": "search", "args": {"query": "foo"}}, - "thoughtSignature": "sig_gemini_xyz" - } - ] - }, - "finishReason": "STOP" - }], - "usageMetadata": {"promptTokenCount": 30, "candidatesTokenCount": 12} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -/// The Gemini usage arithmetic: input = (prompt - cached) + tool_use_prompt; -/// thoughts become reasoning tokens. -#[tokio::test] -async fn decode_usage_arithmetic() { - let response = decode_response(serde_json::json!({ - "candidates": [{ - "content": {"role": "model", "parts": [{"text": "ok"}]}, - "finishReason": "STOP" - }], - "usageMetadata": { - "promptTokenCount": 100, - "candidatesTokenCount": 50, - "thoughtsTokenCount": 8, - "cachedContentTokenCount": 30, - "toolUsePromptTokenCount": 5 - } - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -/// `thought: true` text parts decode as Thinking content. -#[tokio::test] -async fn decode_thought_parts() { - let response = decode_response(serde_json::json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [ - {"text": "Adding the numbers.", "thought": true}, - {"text": "4."} - ] - }, - "finishReason": "STOP" - }], - "usageMetadata": {"promptTokenCount": 25, "candidatesTokenCount": 40} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -#[tokio::test] -async fn decode_max_tokens_finish_reason() { - let length = decode_response(serde_json::json!({ - "candidates": [{ - "content": {"role": "model", "parts": [{"text": "Trunc"}]}, - "finishReason": "MAX_TOKENS" - }], - "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 128} - })) - .await; - fabro_test::fabro_json_snapshot!(length["finish_reason"]); -} - -#[tokio::test] -async fn decode_safety_finish_reason() { - let safety = decode_response(serde_json::json!({ - "candidates": [{ - "content": {"role": "model", "parts": [{"text": ""}]}, - "finishReason": "SAFETY" - }], - "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 0} - })) - .await; - fabro_test::fabro_json_snapshot!(safety["finish_reason"]); -} - -// --------------------------------------------------------------------------- -// Stream -// --------------------------------------------------------------------------- - -/// Shared setup for the happy-path text stream; the request and event halves -/// are pinned by separate tests. -async fn stream_text_happy_path_capture() -> (WireCapture, Vec) { - let sse = support::sse_data_transcript(&[ - r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Hel"}]}}]}"#, - r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"lo"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":5}}"#, - ]); - stream_capture(adapter(), &base_request(MODEL), &sse).await -} - -/// The captured request pins model-in-URL and `?alt=sse` on the wire. -#[tokio::test] -async fn stream_text_happy_path_request() { - let (capture, _) = stream_text_happy_path_capture().await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn stream_text_happy_path_events() { - let (_, events) = stream_text_happy_path_capture().await; - support::assert_stream_starts(&events); - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_function_call() { - let sse = support::sse_data_transcript(&[ - r#"{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"search","args":{"query":"foo"}},"thoughtSignature":"sig_stream_g"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":9}}"#, - ]); - let (_capture, events) = stream_capture( - adapter(), - &corpus_tools(MODEL, Some(ToolChoice::Auto)), - &sse, - ) - .await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_thought_parts() { - let sse = support::sse_data_transcript(&[ - r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Let me think","thought":true}]}}]}"#, - r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"4."}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":15,"candidatesTokenCount":12,"thoughtsTokenCount":6}}"#, - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -/// The Gemini decoder synthesizes a `Finish` on byte-stream end -/// unconditionally — even when no chunk carried a `finishReason`. -#[tokio::test] -async fn stream_end_synthesizes_finish_without_finish_reason() { - let sse = support::sse_data_transcript(&[ - r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Hello"}]}}]}"#, - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -// --------------------------------------------------------------------------- -// Custom-named route identity -// --------------------------------------------------------------------------- - -/// Streamed responses stamp the configured provider name (previously -/// hardcoded "gemini"). -#[tokio::test] -async fn custom_named_stream_identity() { - let sse = support::sse_data_transcript(&[ - r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Hi"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2}}"#, - ]); - let (_capture, events) = stream_capture( - adapter().with_name("gemini-proxy"), - &base_request(MODEL), - &sse, - ) - .await; - fabro_test::fabro_json_snapshot!(events); -} - -/// HTTP-level errors carry the configured name in the error detail -/// (normalize-both decision). -#[tokio::test] -async fn custom_named_http_error_identity() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST).path(COMPLETE_PATH); - then.status(500) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "error": {"message": "backend exploded", "status": "INTERNAL", "code": 500} - })); - }); - let adapter = adapter() - .with_name("gemini-proxy") - .with_base_url(server.base_url()); - let error = adapter - .complete(&base_request(MODEL)) - .await - .expect_err("complete should fail"); - mock.assert(); - fabro_test::fabro_json_snapshot!(serde_json::json!({ - "error": error.to_string(), - "retryable": error.retryable(), - "failover_eligible": error.failover_eligible(), - })); -} diff --git a/lib/components/fabro-llm/tests/it/wire/mod.rs b/lib/components/fabro-llm/tests/it/wire/mod.rs deleted file mode 100644 index 16ad18987..000000000 --- a/lib/components/fabro-llm/tests/it/wire/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Wire snapshot tests pinning per-dialect encode/decode behavior. -//! -//! Each test points a real adapter at a local httpmock server, side-channels -//! the full received request (method, path, headers, body) out of an -//! `is_true` matcher closure, responds with a canned provider body, and -//! snapshots both the captured wire request (encode) and the decoded -//! canonical `Response` (decode). The codec extraction PRs must keep these -//! snapshot values identical. -//! -//! The anthropic/gemini dialects have no twin coverage, so these snapshots -//! are the only behavior net for those extractions. -//! -//! Snapshots are stored externally under `snapshots/` (via -//! `fabro_test::fabro_json_snapshot!(value)` with no inline literal) to keep -//! these source files small. Review and accept with `cargo insta` -//! (`pending-snapshots` then `accept`), per CLAUDE.md. A few tests assert two -//! snapshots in one function; insta names the second `-2.snap`. - -mod anthropic; -mod gemini; -mod openai_compatible; -mod openai_responses; diff --git a/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs b/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs deleted file mode 100644 index 22615dcd6..000000000 --- a/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs +++ /dev/null @@ -1,953 +0,0 @@ -//! Wire snapshots for the OpenAI Chat Completions dialect served by -//! `OpenAiCompatibleAdapter` (kimi, zai, minimax, venice, inception, ollama, -//! litellm — all config-only routes over this adapter). - -use std::sync::Arc; - -use fabro_llm::generate::StreamAccumulator; -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::OpenAiCompatibleAdapter; -use fabro_llm::types::{ - Message, ReasoningEffort, Request, ResponseFormat, ResponseFormatType, ToolChoice, - ToolDefinition, -}; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{Catalog, ProviderId}; -use httpmock::prelude::*; - -use crate::support::{ - self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments, - corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format, - corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools, - corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture, -}; - -const MODEL: &str = "test-model"; - -/// Fixed `created` timestamp for canned bodies (named to satisfy clippy's -/// unreadable-literal lint without touching the JSON wire value). -const CREATED_TS: i64 = 1_700_000_000; - -/// Minimal valid Chat Completions body for encode-side tests. -fn minimal_body() -> serde_json::Value { - body_with_message(&serde_json::json!({"role": "assistant", "content": "ok"})) -} - -/// Wraps an assistant message in a complete Chat Completions body. -fn body_with_message(message: &serde_json::Value) -> serde_json::Value { - serde_json::json!({ - "id": "chatcmpl_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "choices": [{ - "index": 0, - "message": message, - "finish_reason": "stop" - }], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} - }) -} - -fn adapter(server: &MockServer) -> OpenAiCompatibleAdapter { - OpenAiCompatibleAdapter::new("test-key", server.base_url()) -} - -/// Runs `complete()` against a capture mock and returns the captured wire -/// request. -async fn encode_capture_with( - request: &Request, - configure: impl FnOnce(OpenAiCompatibleAdapter) -> OpenAiCompatibleAdapter, -) -> WireCapture { - let server = MockServer::start(); - let (mock, slot) = mount_capture(&server, "/chat/completions", minimal_body()); - let adapter = configure(adapter(&server)); - adapter - .complete(request) - .await - .expect("complete should succeed"); - mock.assert(); - take_capture(&slot) -} - -async fn encode_capture(request: &Request) -> WireCapture { - encode_capture_with(request, |adapter| adapter).await -} - -/// Runs `stream()` against an SSE transcript and returns the captured wire -/// request plus every emitted stream item as JSON. -async fn stream_capture( - request: &Request, - sse_body: &str, -) -> (WireCapture, Vec) { - let server = MockServer::start(); - let (mock, slot) = mount_capture_sse(&server, "/chat/completions", sse_body); - let adapter = adapter(&server); - let events = support::collect_stream_events(&adapter, request).await; - mock.assert(); - (take_capture(&slot), events) -} - -// --------------------------------------------------------------------------- -// Round trip (encode + decode) -// --------------------------------------------------------------------------- - -/// Shared setup for the system+tools round trip; the encode and decode halves -/// are pinned by separate tests. -async fn system_and_tools_roundtrip() -> (WireCapture, fabro_llm::types::Response) { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - "/chat/completions", - serde_json::json!({ - "id": "chatcmpl_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "choices": [{ - "index": 0, - "message": {"role": "assistant", "content": "Hello back"}, - "finish_reason": "stop" - }], - "usage": {"prompt_tokens": 42, "completion_tokens": 7, "total_tokens": 49} - }), - ); - - let adapter = adapter(&server); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - tools: Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), - )]), - temperature: Some(0.5), - ..base_request(MODEL) - }; - - let response = adapter - .complete(&request) - .await - .expect("complete should succeed"); - mock.assert(); - (take_capture(&slot), response) -} - -#[tokio::test] -async fn system_and_tools_encode() { - let (capture, _) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn system_and_tools_decode() { - let (_, response) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Encode -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn encode_multi_turn() { - let capture = encode_capture(&corpus_multi_turn(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_auto() { - let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::Auto))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_required() { - let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::Required))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_named() { - let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::named("search")))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_none() { - let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::None))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_round_trip() { - let capture = encode_capture(&corpus_tool_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// Assistant thinking parts echo back as `reasoning_content` (required by -/// Kimi and DeepSeek during tool-call continuations). -#[tokio::test] -async fn encode_thinking_round_trip_as_reasoning_content() { - let capture = encode_capture(&corpus_thinking_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// The compat encoder performs no attachment I/O: images are dropped -/// outright, documents become fallback text. -#[tokio::test] -async fn encode_inline_attachments() { - let capture = encode_capture(&corpus_inline_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_url_attachments() { - let capture = encode_capture(&corpus_url_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_bad_file_path_attachments() { - let capture = encode_capture(&corpus_bad_file_path_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_audio_attachment() { - let capture = encode_capture(&corpus_audio_attachment(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_object() { - let format = ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }; - let capture = encode_capture(&corpus_response_format(MODEL, format)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_schema() { - let capture = encode_capture(&corpus_response_format(MODEL, json_schema_format())).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_sampling_params() { - let capture = encode_capture(&corpus_sampling_params(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_kimi_k3_uses_catalog_reasoning_and_sampling_controls() { - let catalog = Arc::new(Catalog::from_builtin().expect("built-in catalog should build")); - let request = Request { - model: "kimi-k3".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - temperature: Some(0.7), - top_p: Some(0.9), - ..base_request(MODEL) - }; - let capture = encode_capture_with(&request, move |adapter| { - adapter.with_name("moonshot").with_catalog(catalog) - }) - .await; - - assert_eq!(capture.body["model"], "kimi-k3"); - assert_eq!(capture.body["reasoning_effort"], "high"); - assert!(capture.body.get("temperature").is_none()); - assert!(capture.body.get("top_p").is_none()); -} - -/// Counts JSON objects anywhere in `value` carrying a `cache_control` key. -fn count_cache_control_breakpoints(value: &serde_json::Value) -> usize { - match value { - serde_json::Value::Object(map) => { - usize::from(map.contains_key("cache_control")) - + map - .values() - .map(count_cache_control_breakpoints) - .sum::() - } - serde_json::Value::Array(items) => items.iter().map(count_cache_control_breakpoints).sum(), - _ => 0, - } -} - -/// Builtin catalog with the opt-in OpenRouter provider enabled. -fn openrouter_catalog() -> Arc { - let overrides: LlmCatalogSettings = toml::from_str("[providers.openrouter]\nenabled = true\n") - .expect("override TOML should parse"); - Arc::new( - Catalog::from_builtin_with_overrides(&overrides) - .expect("catalog with OpenRouter enabled should build"), - ) -} - -/// System + tools + two user turns against an OpenRouter model. -fn openrouter_multi_turn(model: &str) -> Request { - Request { - messages: vec![ - Message::system("You are a careful reviewer."), - Message::user("Review this."), - Message::assistant("Looking now."), - Message::user("Focus on the tests."), - ], - ..corpus_tools(model, None) - } -} - -/// OpenRouter serves Claude through this adapter, and Anthropic prompt -/// caching is opt-in per request: OpenRouter only forwards a cache write when -/// the body carries explicit ephemeral `cache_control` breakpoints (OpenAI -/// models cache implicitly; Anthropic models never do). The catalog row -/// declares `cache_control_breakpoints`, so the encoded request must mark the -/// cacheable prefix — otherwise every turn bills at the full uncached input -/// rate. -#[tokio::test] -async fn encode_openrouter_claude_marks_prompt_cache_breakpoints() { - let catalog = openrouter_catalog(); - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), "claude-fable-5") - .expect("OpenRouter Claude row should exist in the built-in catalog"); - assert!(model.features.prompt_cache); - assert!(model.features.cache_control_breakpoints); - - let request = openrouter_multi_turn("claude-fable-5"); - let capture = encode_capture_with(&request, move |adapter| { - adapter.with_name("openrouter").with_catalog(catalog) - }) - .await; - - assert_eq!(capture.body["model"], "anthropic/claude-fable-5"); - let messages = &capture.body["messages"]; - // The system prompt converts to parts form carrying a breakpoint; it - // covers the tool definitions too (tools precede system upstream). - assert_eq!(messages[0]["content"][0]["type"], "text"); - assert_eq!( - messages[0]["content"][0]["text"], - "You are a careful reviewer." - ); - assert_eq!( - messages[0]["content"][0]["cache_control"]["type"], - "ephemeral" - ); - // The second-to-last user turn carries the conversation breakpoint... - assert_eq!(messages[1]["content"][0]["text"], "Review this."); - assert_eq!( - messages[1]["content"][0]["cache_control"]["type"], - "ephemeral" - ); - // ...and the newest turn stays in plain-string form. - assert_eq!(messages[3]["content"], "Focus on the tests."); - assert_eq!(count_cache_control_breakpoints(&capture.body), 2); -} - -/// Models with implicit (server-side) caching must NOT get breakpoints even -/// though they support prompt caching — the annotation is an Anthropic-ism -/// the catalog row has to opt into. -#[tokio::test] -async fn encode_openrouter_implicit_cache_model_stays_plain() { - let catalog = openrouter_catalog(); - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), "gpt-5.6-luna") - .expect("OpenRouter GPT row should exist in the built-in catalog"); - assert!(model.features.prompt_cache); - assert!(!model.features.cache_control_breakpoints); - - let request = openrouter_multi_turn("gpt-5.6-luna"); - let capture = encode_capture_with(&request, move |adapter| { - adapter.with_name("openrouter").with_catalog(catalog) - }) - .await; - - assert_eq!(count_cache_control_breakpoints(&capture.body), 0); - assert_eq!( - capture.body["messages"][0]["content"], - "You are a careful reviewer." - ); -} - -/// `provider_options.openrouter.auto_cache = false` disables the breakpoints, -/// and the control key is consumed rather than merged into the body. -#[tokio::test] -async fn encode_openrouter_claude_auto_cache_opt_out() { - let request = Request { - provider_options: Some(serde_json::json!({"openrouter": {"auto_cache": false}})), - ..openrouter_multi_turn("claude-fable-5") - }; - let capture = encode_capture_with(&request, move |adapter| { - adapter - .with_name("openrouter") - .with_catalog(openrouter_catalog()) - }) - .await; - - assert_eq!(count_cache_control_breakpoints(&capture.body), 0); - assert!(capture.body.get("auto_cache").is_none()); -} - -/// The provider_options namespace key is the runtime adapter NAME, not a -/// static "openai_compatible" key (pinned in-module by -/// `provider_options_uses_adapter_name`; this pins it from outside). -#[tokio::test] -async fn encode_provider_options_keyed_by_adapter_name() { - let request = corpus_provider_options( - MODEL, - serde_json::json!({"moonshot": {"repetition_penalty": 1.2}}), - ); - let capture = encode_capture_with(&request, |adapter| adapter.with_name("moonshot")).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// Options under a key that does not match the adapter name must not merge. -#[tokio::test] -async fn encode_provider_options_other_namespace_ignored() { - let request = corpus_provider_options( - MODEL, - serde_json::json!({"openai": {"repetition_penalty": 1.2}}), - ); - let capture = encode_capture_with(&request, |adapter| adapter.with_name("moonshot")).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// The compat adapter has no count-tokens wire route. -#[tokio::test] -async fn count_input_tokens_unavailable() { - let server = MockServer::start(); - let adapter = adapter(&server); - let count = adapter - .count_input_tokens(&base_request(MODEL)) - .await - .unwrap(); - assert!(count.is_none()); -} - -// --------------------------------------------------------------------------- -// Decode -// --------------------------------------------------------------------------- - -async fn decode_response(body: serde_json::Value) -> fabro_llm::types::Response { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, "/chat/completions", body); - let adapter = adapter(&server); - let response = adapter - .complete(&base_request(MODEL)) - .await - .expect("complete should succeed"); - mock.assert(); - response -} - -/// Streams an SSE transcript and returns the final accumulated response. -async fn stream_final_response(sse_body: &str) -> fabro_llm::types::Response { - use futures::StreamExt; - - let server = MockServer::start(); - let (mock, _slot) = mount_capture_sse(&server, "/chat/completions", sse_body); - let adapter = adapter(&server); - let mut stream = adapter - .stream(&base_request(MODEL)) - .await - .expect("stream should start"); - let mut accumulator = StreamAccumulator::new(); - while let Some(item) = stream.next().await { - accumulator.process(&item.expect("stream event should decode")); - } - mock.assert(); - accumulator - .response() - .cloned() - .expect("stream should emit a finish event") -} - -#[tokio::test] -async fn decode_tool_calls_with_string_arguments() { - let response = decode_response(serde_json::json!({ - "id": "chatcmpl_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": null, - "tool_calls": [{ - "id": "call_abc", - "type": "function", - "function": {"name": "search", "arguments": "{\"query\":\"foo\"}"} - }] - }, - "finish_reason": "tool_calls" - }], - "usage": {"prompt_tokens": 30, "completion_tokens": 12, "total_tokens": 42} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -#[tokio::test] -async fn decode_reasoning_content_as_thinking() { - let response = decode_response(serde_json::json!({ - "id": "chatcmpl_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "4.", - "reasoning_content": "The user wants 2+2." - }, - "finish_reason": "stop" - }], - "usage": {"prompt_tokens": 25, "completion_tokens": 40, "total_tokens": 65} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Structured reasoning details -// --------------------------------------------------------------------------- - -/// The structured channel classifies summary and trace independently. -#[tokio::test] -async fn decode_reasoning_details_normalize_summary_and_trace() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning_details": [ - {"type": "reasoning.summary", "summary": "the user wants 2+2", "index": 0}, - {"type": "reasoning.text", "text": "2 plus 2 is 4", "index": 1}, - ] - }))) - .await; - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("the user wants 2+2")); - assert_eq!(reasoning.trace(), Some("2 plus 2 is 4")); -} - -/// Encrypted entries stay in the opaque provider part for future replay but -/// never reach the normalized output. -#[tokio::test] -async fn decode_reasoning_details_preserve_encrypted_entries_opaquely() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning_details": [ - {"type": "reasoning.encrypted", "data": "gAAAAAopaque", "index": 0}, - {"type": "reasoning.summary", "summary": "visible", "index": 1}, - ] - }))) - .await; - - let opaque = response - .message - .content - .iter() - .find_map(|part| match part { - fabro_llm::types::ContentPart::Other { kind, data } - if kind == fabro_llm::types::ContentPart::OPENAI_COMPAT_REASONING_DETAILS => - { - Some(data) - } - _ => None, - }) - .expect("opaque reasoning details preserved"); - assert_eq!(opaque[0]["data"], "gAAAAAopaque"); - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("visible")); - assert!(reasoning.trace().is_none()); -} - -/// Complete-response details are already assembled and must retain their -/// received block boundaries. -#[tokio::test] -async fn decode_reasoning_details_preserves_complete_entries_verbatim() { - let details = serde_json::json!([ - {"type": "reasoning.summary", "summary": "first"}, - {"type": "reasoning.summary", "summary": "second"}, - ]); - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning_details": details, - }))) - .await; - - let opaque = response - .message - .content - .iter() - .find_map(|part| match part { - fabro_llm::types::ContentPart::Other { kind, data } - if kind == fabro_llm::types::ContentPart::OPENAI_COMPAT_REASONING_DETAILS => - { - Some(data) - } - _ => None, - }) - .expect("opaque reasoning details preserved"); - assert_eq!(opaque, &details); - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("first\n\nsecond")); -} - -/// Unknown and malformed detail entries must not fail an otherwise valid -/// completion. -#[tokio::test] -async fn decode_tolerates_unknown_and_malformed_reasoning_details() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning_details": [ - {"type": "reasoning.future", "text": "new channel", "extra": {"nested": true}}, - {"type": "reasoning.summary", "summary": 7}, - "not-an-object", - 42, - ] - }))) - .await; - - assert_eq!(response.text(), "4."); - assert!(response.reasoning_output().is_none()); -} - -/// A scalar `reasoning_details` carries nothing replayable and is dropped -/// without disturbing the rest of the response. -#[tokio::test] -async fn decode_ignores_scalar_reasoning_details() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning_details": "unexpected" - }))) - .await; - - assert_eq!(response.text(), "4."); - assert!(response.reasoning_output().is_none()); -} - -/// OpenRouter returns both the structured channel and a flattened copy of -/// the same material; the summary must not appear twice. -#[tokio::test] -async fn decode_structured_details_suppress_the_duplicate_flattened_value() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning": "the user wants 2+2", - "reasoning_details": [ - {"type": "reasoning.summary", "summary": "the user wants 2+2", "index": 0}, - ] - }))) - .await; - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("the user wants 2+2")); - assert!(reasoning.trace().is_none()); -} - -/// A structured trace takes precedence over the flattened trace channel. -#[tokio::test] -async fn decode_structured_trace_takes_precedence_over_flattened_trace() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning": "flattened trace", - "reasoning_details": [{"type": "reasoning.text", "text": "verbatim trace", "index": 0}] - }))) - .await; - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert!(reasoning.summary().is_none()); - assert_eq!(reasoning.trace(), Some("verbatim trace")); -} - -/// A structured summary and distinct flattened trace are both retained. -#[tokio::test] -async fn decode_structured_summary_keeps_distinct_flattened_trace() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning": "full verbatim trace", - "reasoning_details": [ - {"type": "reasoning.summary", "summary": "short summary", "index": 0}, - ] - }))) - .await; - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("short summary")); - assert_eq!(reasoning.trace(), Some("full verbatim trace")); -} - -/// Streamed detail fragments coalesce back into the same normalized output -/// the non-streaming body produces. -#[tokio::test] -async fn stream_reasoning_details_normalize_like_the_non_streaming_body() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","reasoning_details":[{"type":"reasoning.summary","summary":"the user ","index":0},{"type":"reasoning.text","text":"2 plus ","index":1}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"reasoning_details":[{"type":"reasoning.summary","summary":"wants 2+2","index":0},{"type":"reasoning.text","text":"2 is 4","index":1}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"4."},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - "[DONE]", - ]); - let streamed = stream_final_response(&sse).await; - - let non_streamed = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning_details": [ - {"type": "reasoning.summary", "summary": "the user wants 2+2", "index": 0}, - {"type": "reasoning.text", "text": "2 plus 2 is 4", "index": 1}, - ] - }))) - .await; - - assert_eq!(streamed.reasoning_output(), non_streamed.reasoning_output()); - let reasoning = streamed.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("the user wants 2+2")); - assert_eq!(reasoning.trace(), Some("2 plus 2 is 4")); -} - -/// Providers may omit the optional index after the first fragment; the type -/// still identifies the logical detail being continued. -#[tokio::test] -async fn stream_reasoning_details_coalesce_when_a_later_fragment_omits_index() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","reasoning_details":[{"type":"reasoning.text","text":"first ","index":0}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"reasoning_details":[{"type":"reasoning.text","text":"second"}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"done"},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - "[DONE]", - ]); - - let response = stream_final_response(&sse).await; - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.trace(), Some("first second")); -} - -/// Cached and reasoning detail tokens are split into their own disjoint -/// buckets and subtracted out of input/output. -#[tokio::test] -async fn decode_usage_parses_token_details() { - let response = decode_response(serde_json::json!({ - "id": "chatcmpl_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "choices": [{ - "index": 0, - "message": {"role": "assistant", "content": "ok"}, - "finish_reason": "length" - }], - "usage": { - "prompt_tokens": 100, - "completion_tokens": 50, - "total_tokens": 150, - "prompt_tokens_details": {"cached_tokens": 80}, - "completion_tokens_details": {"reasoning_tokens": 20} - } - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -/// OpenRouter usage superset: in-band `cost` becomes an authoritative -/// `cost_usd`, and `cache_write_tokens` lands in its own disjoint bucket. -/// Unmodeled fields (`cost_details`, `audio_tokens`, top-level `provider`, -/// `native_finish_reason`) are tolerated and ignored. -#[tokio::test] -async fn decode_usage_openrouter_cost_and_cache_write() { - let response = decode_response(serde_json::json!({ - "id": "gen_or_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "provider": "Anthropic", - "choices": [{ - "index": 0, - "message": {"role": "assistant", "content": "ok"}, - "finish_reason": "stop", - "native_finish_reason": "end_turn" - }], - "usage": { - "prompt_tokens": 200, - "completion_tokens": 10, - "total_tokens": 210, - "cost": 0.0042, - "cost_details": {"upstream_inference_cost": null}, - "prompt_tokens_details": {"cached_tokens": 50, "cache_write_tokens": 100, "audio_tokens": 0}, - "completion_tokens_details": {"reasoning_tokens": 0} - } - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -/// Venice reports authoritative USD cost in a top-level object rather than -/// the OpenRouter `usage.cost` field. -#[tokio::test] -async fn decode_usage_venice_top_level_cost() { - let response = decode_response(serde_json::json!({ - "id": "chatcmpl_venice_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "choices": [{ - "index": 0, - "message": {"role": "assistant", "content": "ok"}, - "finish_reason": "stop" - }], - "cost": {"usd": 0.00042, "diem": 0.0}, - "usage": { - "prompt_tokens": 12, - "completion_tokens": 2, - "total_tokens": 14 - } - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Stream -// --------------------------------------------------------------------------- - -/// Shared setup for the happy-path text stream; the request and event halves -/// are pinned by separate tests. -async fn stream_text_happy_path_capture() -> (WireCapture, Vec) { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hel"},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"lo"},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":11,"completion_tokens":5,"total_tokens":16}}"#, - "[DONE]", - ]); - stream_capture(&base_request(MODEL), &sse).await -} - -/// The captured request pins the streaming request shape, including the usage -/// opt-in required for the trailing usage chunk. -#[tokio::test] -async fn stream_text_happy_path_request() { - let (capture, _) = stream_text_happy_path_capture().await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn stream_text_happy_path_events() { - let (_, events) = stream_text_happy_path_capture().await; - support::assert_stream_starts(&events); - fabro_test::fabro_json_snapshot!(events); -} - -/// OpenRouter streams report `cost` in the usage chunk; the Finish response -/// carries it as authoritative, with cached tokens in their own bucket. -#[tokio::test] -async fn stream_usage_openrouter_cost() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"gen_or_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},"finish_reason":null}]}"#, - r#"{"id":"gen_or_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - r#"{"id":"gen_or_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":2,"total_tokens":14,"cost":0.00031,"prompt_tokens_details":{"cached_tokens":4,"cache_write_tokens":0}}}"#, - "[DONE]", - ]); - let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -/// Venice streams authoritative USD cost in a top-level object on the usage -/// chunk. -#[tokio::test] -async fn stream_usage_venice_top_level_cost() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_venice_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_venice_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - r#"{"id":"chatcmpl_venice_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"cost":{"usd":0.00031,"diem":0.0},"usage":{"prompt_tokens":12,"completion_tokens":2,"total_tokens":14}}"#, - "[DONE]", - ]); - let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_tool_call_deltas() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"search","arguments":""}}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"qu"}}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ery\":\"foo\"}"}}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":20,"completion_tokens":9,"total_tokens":29}}"#, - "[DONE]", - ]); - let (_capture, events) = - stream_capture(&corpus_tools(MODEL, Some(ToolChoice::Auto)), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_reasoning_deltas() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","reasoning":"Let me "},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"reasoning_content":"think"},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"4."},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - "[DONE]", - ]); - let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -/// Minimax tolerance: a stream that ends without `[DONE]` still synthesizes -/// the finish — but only because content was started. -#[tokio::test] -async fn stream_without_done_synthesizes_finish_when_content_started() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - ]); - let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -/// The other half of the minimax contract: no content started and no -/// `[DONE]` — nothing is synthesized. `StreamStart` is not synthesis: the -/// provider did send a chunk, so the liveness edge is a fact about this -/// stream even though nothing usable followed. -#[tokio::test] -async fn stream_without_done_or_content_synthesizes_nothing() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}"#, - ]); - let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -// --------------------------------------------------------------------------- -// Custom-named route identity -// --------------------------------------------------------------------------- - -/// The compat adapter already stamped the configured name; pinned here to -/// complete the per-dialect identity matrix. -#[tokio::test] -async fn custom_named_complete_identity() { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, "/chat/completions", minimal_body()); - let adapter = adapter(&server).with_name("moonshot"); - let response = adapter - .complete(&base_request(MODEL)) - .await - .expect("complete should succeed"); - mock.assert(); - assert_eq!(response.provider, "moonshot"); -} diff --git a/lib/components/fabro-llm/tests/it/wire/openai_responses.rs b/lib/components/fabro-llm/tests/it/wire/openai_responses.rs deleted file mode 100644 index 079a05455..000000000 --- a/lib/components/fabro-llm/tests/it/wire/openai_responses.rs +++ /dev/null @@ -1,655 +0,0 @@ -//! Wire snapshots for the OpenAI Responses API dialect (`POST /responses`). - -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::OpenAiAdapter; -use fabro_llm::types::{ - ContentPart, Message, Request, ResponseFormat, ResponseFormatType, Role, ToolCall, ToolChoice, - ToolDefinition, -}; -use httpmock::prelude::*; - -use crate::support::{ - self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments, - corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format, - corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools, - corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture, -}; - -const MODEL: &str = "gpt-test"; - -/// Minimal valid Responses API body for encode-side tests. -fn minimal_body() -> serde_json::Value { - serde_json::json!({ - "id": "resp_test", - "object": "response", - "model": MODEL, - "status": "completed", - "output": [{ - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [{"type": "output_text", "text": "ok"}] - }], - "usage": {"input_tokens": 1, "output_tokens": 1} - }) -} - -fn adapter() -> OpenAiAdapter { - OpenAiAdapter::new("test-key") -} - -/// Runs `complete()` against a capture mock and returns the captured wire -/// request. -async fn encode_capture(adapter: OpenAiAdapter, request: &Request) -> WireCapture { - let server = MockServer::start(); - let (mock, slot) = mount_capture(&server, "/responses", minimal_body()); - let adapter = adapter.with_base_url(server.base_url()); - adapter - .complete(request) - .await - .expect("complete should succeed"); - mock.assert(); - take_capture(&slot) -} - -/// Runs `stream()` against an SSE transcript and returns the captured wire -/// request plus every emitted stream item as JSON. -async fn stream_capture( - adapter: OpenAiAdapter, - request: &Request, - sse_body: &str, -) -> (WireCapture, Vec) { - let server = MockServer::start(); - let (mock, slot) = mount_capture_sse(&server, "/responses", sse_body); - let adapter = adapter.with_base_url(server.base_url()); - let events = support::collect_stream_events(&adapter, request).await; - mock.assert(); - (take_capture(&slot), events) -} - -// --------------------------------------------------------------------------- -// Round trip (encode + decode) -// --------------------------------------------------------------------------- - -/// Shared setup for the system+tools round trip; the encode and decode halves -/// are pinned by separate tests. -async fn system_and_tools_roundtrip() -> (WireCapture, fabro_llm::types::Response) { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - "/responses", - serde_json::json!({ - "id": "resp_test", - "object": "response", - "model": MODEL, - "status": "completed", - "output": [{ - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [{"type": "output_text", "text": "Hello back"}] - }], - "usage": { - "input_tokens": 42, - "output_tokens": 7, - "input_tokens_details": {"cached_tokens": 10}, - "output_tokens_details": {"reasoning_tokens": 3} - } - }), - ); - - let adapter = adapter().with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - tools: Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), - )]), - temperature: Some(0.5), - ..base_request(MODEL) - }; - - let response = adapter - .complete(&request) - .await - .expect("complete should succeed"); - mock.assert(); - (take_capture(&slot), response) -} - -#[tokio::test] -async fn system_and_tools_encode() { - let (capture, _) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn system_and_tools_decode() { - let (_, response) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Encode -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn encode_multi_turn() { - let capture = encode_capture(adapter(), &corpus_multi_turn(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_auto() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_required() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Required))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_named() { - let capture = encode_capture( - adapter(), - &corpus_tools(MODEL, Some(ToolChoice::named("search"))), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_none() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::None))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_round_trip() { - let capture = encode_capture(adapter(), &corpus_tool_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// A tool call that decoded with an item-level id (`fc_…`) in -/// provider_metadata re-encodes with the dual ids split correctly. -#[tokio::test] -async fn encode_dual_id_tool_round_trip() { - let mut tool_call = ToolCall::new("call_abc", "search", serde_json::json!({"query": "foo"})); - tool_call.provider_metadata = Some(serde_json::json!({"id": "fc_123"})); - let mut request = corpus_tools(MODEL, None); - request.messages = vec![ - Message::user("Find foo"), - Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tool_call)], - name: None, - tool_call_id: None, - }, - Message::tool_result( - "call_abc", - serde_json::Value::String("2 matches".to_string()), - false, - ), - ]; - let capture = encode_capture(adapter(), &request).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// Opaque OpenAI items (reasoning / message) round-trip verbatim into the -/// input array. -#[tokio::test] -async fn encode_opaque_items_round_trip() { - let request = Request { - messages: vec![ - Message::user("Think about 2+2."), - Message { - role: Role::Assistant, - content: vec![ - ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: serde_json::json!({ - "type": "reasoning", - "id": "rs_1", - "summary": [{"type": "summary_text", "text": "Adding."}] - }), - }, - ContentPart::Other { - kind: ContentPart::OPENAI_MESSAGE.to_string(), - data: serde_json::json!({ - "type": "message", - "role": "assistant", - "id": "msg_1", - "content": [{"type": "output_text", "text": "4."}] - }), - }, - ], - name: None, - tool_call_id: None, - }, - Message::user("Now 3+3?"), - ], - ..base_request(MODEL) - }; - let capture = encode_capture(adapter(), &request).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// Canonical Thinking parts (anthropic-style) — distinct from the opaque -/// reasoning round-trip above. -#[tokio::test] -async fn encode_thinking_round_trip() { - let capture = encode_capture(adapter(), &corpus_thinking_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_inline_attachments() { - let capture = encode_capture(adapter(), &corpus_inline_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_url_attachments() { - let capture = encode_capture(adapter(), &corpus_url_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_bad_file_path_attachments_dropped() { - let capture = encode_capture(adapter(), &corpus_bad_file_path_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_audio_attachment() { - let capture = encode_capture(adapter(), &corpus_audio_attachment(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_object() { - let format = ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }; - let capture = encode_capture(adapter(), &corpus_response_format(MODEL, format)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_schema() { - let capture = encode_capture( - adapter(), - &corpus_response_format(MODEL, json_schema_format()), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_sampling_params() { - let capture = encode_capture(adapter(), &corpus_sampling_params(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_provider_options_openai_namespace() { - let capture = encode_capture( - adapter(), - &corpus_provider_options(MODEL, serde_json::json!({"openai": {"seed": 42}})), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_reasoning_effort_with_levels_catalog() { - let catalog = support::catalog_from_toml( - r#" -[providers.openai] -display_name = "OpenAI" -adapter = "openai" -agent_profile = "openai" - -[models."test-gpt"] -provider = "openai" -display_name = "Test GPT" -family = "gpt" -default = true - -[models."test-gpt".limits] -context_window = 200000 -max_output = 4096 - -[models."test-gpt".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -"#, - ); - let request = Request { - reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High), - ..base_request("test-gpt") - }; - let capture = encode_capture(adapter().with_catalog(catalog), &request).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// Codex mode forces streaming for `complete()` and omits sampling params -/// from the encoded body. -#[tokio::test] -async fn encode_codex_mode_forces_streaming_and_omits_params() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_codex","model":"gpt-test"}}"#, - r#"{"type":"response.output_text.delta","delta":"ok"}"#, - r#"{"type":"response.completed","response":{"id":"resp_codex","model":"gpt-test","status":"completed","output":[],"usage":{"input_tokens":5,"output_tokens":2}}}"#, - ]); - let server = MockServer::start(); - let (mock, slot) = mount_capture_sse(&server, "/responses", &sse); - let adapter = OpenAiAdapter::new("test-key") - .with_codex_mode() - .with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - temperature: Some(0.5), - top_p: Some(0.9), - ..base_request(MODEL) - }; - adapter.complete(&request).await.unwrap(); - mock.assert(); - fabro_test::fabro_json_snapshot!(take_capture(&slot)); -} - -#[tokio::test] -async fn count_tokens_wire_shape() { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - "/responses/input_tokens", - serde_json::json!({"input_tokens": 123, "object": "response.input_tokens"}), - ); - let adapter = adapter().with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - ..corpus_tools(MODEL, None) - }; - let count = adapter - .count_input_tokens(&request) - .await - .unwrap() - .expect("openai should count tokens"); - - mock.assert(); - assert_eq!(count.input_tokens, 123); - fabro_test::fabro_json_snapshot!(take_capture(&slot)); -} - -// --------------------------------------------------------------------------- -// Decode -// --------------------------------------------------------------------------- - -async fn decode_response(body: serde_json::Value) -> fabro_llm::types::Response { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, "/responses", body); - let adapter = adapter().with_base_url(server.base_url()); - let response = adapter - .complete(&base_request(MODEL)) - .await - .expect("complete should succeed"); - mock.assert(); - response -} - -/// The Responses usage arithmetic: cached tokens are subtracted from input, -/// reasoning tokens from output. -#[tokio::test] -async fn decode_usage_subtracts_cached_and_reasoning() { - let response = decode_response(serde_json::json!({ - "id": "resp_test", - "object": "response", - "model": MODEL, - "status": "completed", - "output": [{ - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [{"type": "output_text", "text": "ok"}] - }], - "usage": { - "input_tokens": 100, - "output_tokens": 50, - "input_tokens_details": {"cached_tokens": 80}, - "output_tokens_details": {"reasoning_tokens": 20} - } - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -/// Reasoning and function_call output items: reasoning becomes an opaque -/// round-trip part, function_call splits dual ids into id + metadata. -#[tokio::test] -async fn decode_reasoning_and_function_call_items() { - let response = decode_response(serde_json::json!({ - "id": "resp_test", - "object": "response", - "model": MODEL, - "status": "completed", - "output": [ - { - "type": "reasoning", - "id": "rs_1", - "summary": [{"type": "summary_text", "text": "Searching."}] - }, - { - "type": "function_call", - "id": "fc_123", - "call_id": "call_abc", - "name": "search", - "arguments": "{\"query\":\"foo\"}" - } - ], - "usage": {"input_tokens": 30, "output_tokens": 12} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -/// A reasoning item carrying both readable channels normalizes into both -/// fields while its encrypted payload stays opaque. -#[tokio::test] -async fn decode_reasoning_item_normalizes_summary_and_trace() { - let response = decode_response(serde_json::json!({ - "id": "resp_test", - "object": "response", - "model": MODEL, - "status": "completed", - "output": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "gAAAAAopaque", - "summary": [{"type": "summary_text", "text": "Adding two numbers."}], - "content": [{"type": "reasoning_text", "text": "2 plus 2 is 4."}] - }, - { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [{"type": "output_text", "text": "4."}] - } - ], - "usage": {"input_tokens": 30, "output_tokens": 12} - })) - .await; - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("Adding two numbers.")); - assert_eq!(reasoning.trace(), Some("2 plus 2 is 4.")); - - let normalized = serde_json::to_string(&reasoning).unwrap(); - assert!(!normalized.contains("gAAAAAopaque")); -} - -#[tokio::test] -async fn decode_incomplete_status_maps_to_length() { - let response = decode_response(serde_json::json!({ - "id": "resp_test", - "object": "response", - "model": MODEL, - "status": "incomplete", - "output": [{ - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [{"type": "output_text", "text": "Truncated"}] - }], - "usage": {"input_tokens": 10, "output_tokens": 128} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Stream -// --------------------------------------------------------------------------- - -/// Shared setup for the happy-path text stream; the request and event halves -/// are pinned by separate tests. -async fn stream_text_happy_path_capture() -> (WireCapture, Vec) { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, - r#"{"type":"response.output_text.delta","delta":"Hel"}"#, - r#"{"type":"response.output_text.delta","delta":"lo"}"#, - r#"{"type":"response.completed","response":{"id":"resp_stream","model":"gpt-test","status":"completed","output":[],"usage":{"input_tokens":11,"output_tokens":5,"input_tokens_details":{"cached_tokens":2},"output_tokens_details":{"reasoning_tokens":1}}}}"#, - ]); - stream_capture(adapter(), &base_request(MODEL), &sse).await -} - -/// The captured request pins the stream flag (and `include`) on the wire. -#[tokio::test] -async fn stream_text_happy_path_request() { - let (capture, _) = stream_text_happy_path_capture().await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn stream_text_happy_path_events() { - let (_, events) = stream_text_happy_path_capture().await; - support::assert_stream_starts(&events); - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_first_frame_error_still_opens_with_stream_start() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.failed","response":{"id":"resp_stream","error":{"code":"server_error","message":"boom"}}}"#, - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - - support::assert_stream_starts(&events); - assert!( - events - .get(1) - .and_then(|event| event.get("stream_item_error")) - .is_some(), - "the decoder error should follow stream_start: {events:?}" - ); -} - -#[tokio::test] -async fn stream_tool_call_deltas() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, - r#"{"type":"response.function_call_arguments.delta","item_id":"fc_123","call_id":"call_abc","name":"search","delta":"{\"qu"}"#, - r#"{"type":"response.function_call_arguments.delta","item_id":"fc_123","call_id":"call_abc","delta":"ery\":\"foo\"}"}"#, - r#"{"type":"response.output_item.done","item":{"type":"function_call","id":"fc_123","call_id":"call_abc","name":"search","arguments":"{\"query\":\"foo\"}"}}"#, - r#"{"type":"response.completed","response":{"id":"resp_stream","model":"gpt-test","status":"completed","output":[],"usage":{"input_tokens":20,"output_tokens":9}}}"#, - ]); - let (_capture, events) = stream_capture( - adapter(), - &corpus_tools(MODEL, Some(ToolChoice::Auto)), - &sse, - ) - .await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_reasoning_summary_deltas() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, - r#"{"type":"response.reasoning_summary_text.delta","delta":"Let me "}"#, - r#"{"type":"response.reasoning_summary_text.delta","delta":"think"}"#, - r#"{"type":"response.output_text.delta","delta":"4."}"#, - r#"{"type":"response.completed","response":{"id":"resp_stream","model":"gpt-test","status":"completed","output":[],"usage":{"input_tokens":15,"output_tokens":12,"output_tokens_details":{"reasoning_tokens":8}}}}"#, - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_failed_event_maps_to_error() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, - r#"{"type":"response.failed","response":{"id":"resp_stream","error":{"code":"server_error","message":"boom"}}}"#, - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -/// `response.incomplete` finishes the stream with `Length`. -#[tokio::test] -async fn stream_incomplete_maps_to_length() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, - r#"{"type":"response.output_text.delta","delta":"Trunc"}"#, - r#"{"type":"response.incomplete","response":{"id":"resp_stream","model":"gpt-test","status":"incomplete","output":[],"usage":{"input_tokens":10,"output_tokens":128}}}"#, - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -// --------------------------------------------------------------------------- -// Custom-named route identity -// --------------------------------------------------------------------------- - -/// Non-stream responses stamp the configured provider name (previously -/// hardcoded "openai"). -#[tokio::test] -async fn custom_named_complete_identity() { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, "/responses", minimal_body()); - let adapter = OpenAiAdapter::new("test-key") - .with_name("openai-proxy") - .with_base_url(server.base_url()); - let response = adapter - .complete(&base_request(MODEL)) - .await - .expect("complete should succeed"); - mock.assert(); - assert_eq!(response.provider, "openai-proxy"); -} - -/// Stream failure events carry the configured name in the error detail -/// (normalize-both decision). -#[tokio::test] -async fn custom_named_stream_failed_event_identity() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, - r#"{"type":"response.failed","response":{"id":"resp_stream","error":{"code":"server_error","message":"boom"}}}"#, - ]); - let (_capture, events) = stream_capture( - adapter().with_name("openai-proxy"), - &base_request(MODEL), - &sse, - ) - .await; - fabro_test::fabro_json_snapshot!(events); -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap deleted file mode 100644 index c3b630136..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap +++ /dev/null @@ -1,78 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/messages/count_tokens", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "anthropic-version", - "2023-06-01" - ], - [ - "content-length", - "412" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-api-key", - "test-key" - ] - ], - "body": { - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "system": "Be concise", - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "input_schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap deleted file mode 100644 index d9e90487b..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "stream_item_error": "Server error from moonshot: Overloaded", - "retryable": true, - "failover_eligible": true - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap deleted file mode 100644 index 83360e789..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap +++ /dev/null @@ -1,58 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": "block_0" - }, - { - "type": "text_delta", - "delta": "Hi", - "text_id": "block_0" - }, - { - "type": "text_end", - "text_id": "block_0" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 5, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "msg_kimi", - "model": "kimi-test", - "provider": "moonshot", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hi" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 5, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap deleted file mode 100644 index 4620d7897..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap +++ /dev/null @@ -1,47 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/messages", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "authorization", - "Bearer test-key" - ], - [ - "content-length", - "144" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ] - ], - "body": { - "model": "kimi-test", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "stream": true - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap deleted file mode 100644 index b10bff658..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap +++ /dev/null @@ -1,46 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "id": "msg_test", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Truncated answe" - } - ] - }, - "finish_reason": "length", - "usage": { - "input_tokens": 10, - "output_tokens": 128, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": "claude-sonnet-4-20250514", - "content": [ - { - "type": "text", - "text": "Truncated answe" - } - ], - "stop_reason": "max_tokens", - "stop_sequence": null, - "usage": { - "input_tokens": 10, - "output_tokens": 128 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap deleted file mode 100644 index bf89303f3..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap +++ /dev/null @@ -1,71 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "id": "msg_test", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "thinking", - "data": { - "text": "Step one.", - "signature": "sig_decode_abc", - "redacted": false - } - }, - { - "kind": "redacted_thinking", - "data": { - "text": "opaque-blob", - "signature": null, - "redacted": true - } - }, - { - "kind": "text", - "data": "Done." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 25, - "output_tokens": 40, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": "claude-sonnet-4-20250514", - "content": [ - { - "type": "thinking", - "thinking": "Step one.", - "signature": "sig_decode_abc" - }, - { - "type": "redacted_thinking", - "data": "opaque-blob" - }, - { - "type": "text", - "text": "Done." - } - ], - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { - "input_tokens": 25, - "output_tokens": 40 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap deleted file mode 100644 index 2c1ec7d19..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap +++ /dev/null @@ -1,66 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "id": "msg_test", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Let me search." - }, - { - "kind": "tool_call", - "data": { - "id": "toolu_01", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": null - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 30, - "output_tokens": 12, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": "claude-sonnet-4-20250514", - "content": [ - { - "type": "text", - "text": "Let me search." - }, - { - "type": "tool_use", - "id": "toolu_01", - "name": "search", - "input": { - "query": "foo" - } - } - ], - "stop_reason": "tool_use", - "stop_sequence": null, - "usage": { - "input_tokens": 30, - "output_tokens": 12 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap deleted file mode 100644 index 54793a2b6..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap +++ /dev/null @@ -1,24 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Transcribe this." - }, - { - "type": "text", - "text": "[Audio content not supported by this provider]" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap deleted file mode 100644 index c2c0acfb3..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Describe these attachments." - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap deleted file mode 100644 index 5fda9d667..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap +++ /dev/null @@ -1,36 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Describe these attachments." - }, - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": "ZmFrZS1wbmctYnl0ZXM=" - } - }, - { - "type": "document", - "source": { - "type": "base64", - "media_type": "application/pdf", - "data": "ZmFrZS1wZGYtYnl0ZXM=" - } - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap deleted file mode 100644 index 6042822b8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap +++ /dev/null @@ -1,69 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/messages", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "anthropic-version", - "2023-06-01" - ], - [ - "content-length", - "340" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-api-key", - "test-key" - ] - ], - "body": { - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "What is the capital of France?" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "Paris." - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "text", - "text": "And of Spain?" - } - ] - } - ], - "max_tokens": 128, - "system": "You are a terse assistant.", - "stop_sequences": [] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap deleted file mode 100644 index 5a6979bd6..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap +++ /dev/null @@ -1,95 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/messages", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "anthropic-beta", - "prompt-caching-2024-07-31" - ], - [ - "anthropic-version", - "2023-06-01" - ], - [ - "content-length", - "559" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-api-key", - "test-key" - ] - ], - "body": { - "model": "test-claude", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Review this." - } - ] - } - ], - "max_tokens": 128, - "system": [ - { - "type": "text", - "text": "You are a careful reviewer.", - "cache_control": { - "type": "ephemeral" - } - } - ], - "stop_sequences": [], - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "input_schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - }, - "cache_control": { - "type": "ephemeral" - } - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap deleted file mode 100644 index 5c5107443..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "top_k": 5 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap deleted file mode 100644 index 45d4779ed..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "test-claude", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "thinking": { - "type": "adaptive" - }, - "output_config": { - "effort": "high" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap deleted file mode 100644 index f505a20d9..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "system": "You must respond with valid JSON only, no other text.", - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap deleted file mode 100644 index b8c0aa6a1..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap +++ /dev/null @@ -1,41 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "tools": [ - { - "name": "json_output", - "description": "Output the requested structured data", - "input_schema": { - "type": "object", - "properties": { - "answer": { - "type": "string" - } - }, - "required": [ - "answer" - ] - } - } - ], - "tool_choice": { - "type": "tool", - "name": "json_output" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap deleted file mode 100644 index c4abd0eda..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "temperature": 0.7, - "top_p": 0.9, - "stop_sequences": [ - "END" - ], - "metadata": { - "trace_id": "trace-123" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap deleted file mode 100644 index 1619de37c..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap +++ /dev/null @@ -1,43 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Think step by step: what is 2+2?" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "The user wants 2+2, which is 4.", - "signature": "sig_test_abc123" - }, - { - "type": "text", - "text": "4." - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Now 3+3?" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap deleted file mode 100644 index 552c709ce..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap +++ /dev/null @@ -1,52 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "input_schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": { - "type": "auto" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap deleted file mode 100644 index 868f115d1..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap +++ /dev/null @@ -1,53 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "input_schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": { - "type": "tool", - "name": "search" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap deleted file mode 100644 index b9119a960..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap deleted file mode 100644 index 35eb0a281..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap +++ /dev/null @@ -1,52 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "input_schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": { - "type": "any" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap deleted file mode 100644 index 50cce34a4..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap +++ /dev/null @@ -1,91 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Find foo and read /tmp/x" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "Let me check." - }, - { - "type": "tool_use", - "id": "call_1", - "name": "search", - "input": { - "query": "foo" - } - }, - { - "type": "tool_use", - "id": "call_2", - "name": "read_file", - "input": { - "path": "/tmp/x" - } - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "call_1", - "content": "{\"matches\":2}", - "is_error": false - }, - { - "type": "tool_result", - "tool_use_id": "call_2", - "content": "file not found", - "is_error": true - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "input_schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap deleted file mode 100644 index 438fbc222..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap +++ /dev/null @@ -1,34 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Describe these attachments." - }, - { - "type": "image", - "source": { - "type": "url", - "url": "https://example.com/picture.png" - } - }, - { - "type": "document", - "source": { - "type": "url", - "url": "https://example.com/report.pdf" - } - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap deleted file mode 100644 index 6c8cc6f1a..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "stream_item_error": "Server error from anthropic: Overloaded", - "retryable": true, - "failover_eligible": true - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap deleted file mode 100644 index 04d15c0b8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": "block_0" - }, - { - "type": "text_delta", - "delta": "Hel", - "text_id": "block_0" - }, - { - "type": "text_delta", - "delta": "lo", - "text_id": "block_0" - }, - { - "type": "text_end", - "text_id": "block_0" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "reasoning_tokens": 0, - "cache_read_tokens": 2, - "cache_write_tokens": 1 - }, - "response": { - "id": "msg_stream_test", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "reasoning_tokens": 0, - "cache_read_tokens": 2, - "cache_write_tokens": 1 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap deleted file mode 100644 index 508c9c998..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "stream": true -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap deleted file mode 100644 index 68e599c83..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap +++ /dev/null @@ -1,76 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "reasoning_start" - }, - { - "type": "reasoning_delta", - "delta": "Let me think" - }, - { - "type": "reasoning_end" - }, - { - "type": "text_start", - "text_id": "block_1" - }, - { - "type": "text_delta", - "delta": "4.", - "text_id": "block_1" - }, - { - "type": "text_end", - "text_id": "block_1" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 15, - "output_tokens": 12, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "msg_stream_think", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "thinking", - "data": { - "text": "Let me think", - "signature": "sig_stream_xyz", - "redacted": false - } - }, - { - "kind": "text", - "data": "4." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 15, - "output_tokens": 12, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap deleted file mode 100644 index 18c46f075..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap +++ /dev/null @@ -1,95 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "tool_call_start", - "tool_call": { - "id": "toolu_01", - "name": "search", - "type": "function", - "arguments": {}, - "raw_arguments": null - } - }, - { - "type": "tool_call_delta", - "tool_call": { - "id": "toolu_01", - "name": "search", - "type": "function", - "arguments": "{\"qu", - "raw_arguments": null - } - }, - { - "type": "tool_call_delta", - "tool_call": { - "id": "toolu_01", - "name": "search", - "type": "function", - "arguments": "ery\":\"foo\"}", - "raw_arguments": null - } - }, - { - "type": "tool_call_end", - "tool_call": { - "id": "toolu_01", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}" - } - }, - { - "type": "finish", - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "msg_stream_tool", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "tool_call", - "data": { - "id": "toolu_01", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}" - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap deleted file mode 100644 index d2f4d0df0..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": "block_0" - }, - { - "type": "text_delta", - "delta": "Hello", - "text_id": "block_0" - }, - { - "type": "text_end", - "text_id": "block_0" - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap deleted file mode 100644 index f4afb357d..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap +++ /dev/null @@ -1,48 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "id": "msg_test", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello back" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 42, - "output_tokens": 7, - "reasoning_tokens": 0, - "cache_read_tokens": 10, - "cache_write_tokens": 3 - }, - "raw": { - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": "claude-sonnet-4-20250514", - "content": [ - { - "type": "text", - "text": "Hello back" - } - ], - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { - "input_tokens": 42, - "output_tokens": 7, - "cache_read_input_tokens": 10, - "cache_creation_input_tokens": 3 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap deleted file mode 100644 index 50f5a4167..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap +++ /dev/null @@ -1,66 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/messages", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "anthropic-version", - "2023-06-01" - ], - [ - "content-length", - "316" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-api-key", - "test-key" - ] - ], - "body": { - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "system": "Be concise", - "temperature": 0.5, - "stop_sequences": [], - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - } - } - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap deleted file mode 100644 index 2d75a657c..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap +++ /dev/null @@ -1,93 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/models/gemini-test:countTokens", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "content-length", - "583" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-goog-api-key", - "test-key" - ] - ], - "body": { - "generateContentRequest": { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "Be concise" - } - ] - }, - "generationConfig": { - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } - ], - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] - } - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap deleted file mode 100644 index 3f983d663..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "error": "Server error from gemini-proxy: backend exploded", - "retryable": true, - "failover_eligible": true -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap deleted file mode 100644 index 121057409..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap +++ /dev/null @@ -1,58 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": "[UUID]" - }, - { - "type": "text_delta", - "delta": "Hi", - "text_id": "[UUID]" - }, - { - "type": "text_end", - "text_id": "[UUID]" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 5, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini-proxy", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hi" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 5, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap deleted file mode 100644 index 751e5782b..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap +++ /dev/null @@ -1,71 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Let me search." - }, - { - "kind": "tool_call", - "data": { - "id": "[UUID]", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": null, - "provider_metadata": { - "thoughtSignature": "sig_gemini_xyz" - } - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 30, - "output_tokens": 12, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "Let me search." - }, - { - "functionCall": { - "name": "search", - "args": { - "query": "foo" - } - }, - "thoughtSignature": "sig_gemini_xyz" - } - ] - }, - "finishReason": "STOP" - } - ], - "usageMetadata": { - "promptTokenCount": 30, - "candidatesTokenCount": 12 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap deleted file mode 100644 index a8a887691..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -"length" diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap deleted file mode 100644 index 627e69083..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -"content_filter" diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap deleted file mode 100644 index b20aec438..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap +++ /dev/null @@ -1,59 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "thinking", - "data": { - "text": "Adding the numbers.", - "signature": null, - "redacted": false - } - }, - { - "kind": "text", - "data": "4." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 25, - "output_tokens": 40, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "Adding the numbers.", - "thought": true - }, - { - "text": "4." - } - ] - }, - "finishReason": "STOP" - } - ], - "usageMetadata": { - "promptTokenCount": 25, - "candidatesTokenCount": 40 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap deleted file mode 100644 index d32b27d72..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap +++ /dev/null @@ -1,50 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "ok" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 75, - "output_tokens": 50, - "reasoning_tokens": 8, - "cache_read_tokens": 30, - "cache_write_tokens": 0 - }, - "raw": { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "ok" - } - ] - }, - "finishReason": "STOP" - } - ], - "usageMetadata": { - "promptTokenCount": 100, - "candidatesTokenCount": 50, - "thoughtsTokenCount": 8, - "cachedContentTokenCount": 30, - "toolUsePromptTokenCount": 5 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap deleted file mode 100644 index 03246da8b..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap +++ /dev/null @@ -1,31 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Transcribe this." - }, - { - "inlineData": { - "mimeType": "audio/wav", - "data": "ZmFrZS13YXYtYnl0ZXM=" - } - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap deleted file mode 100644 index a27b54528..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Describe these attachments." - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap deleted file mode 100644 index 2db4f67e3..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap +++ /dev/null @@ -1,37 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Describe these attachments." - }, - { - "inlineData": { - "mimeType": "image/png", - "data": "ZmFrZS1wbmctYnl0ZXM=" - } - }, - { - "inlineData": { - "mimeType": "application/pdf", - "data": "ZmFrZS1wZGYtYnl0ZXM=" - } - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap deleted file mode 100644 index a8a547cd6..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap +++ /dev/null @@ -1,48 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "What is the capital of France?" - } - ] - }, - { - "role": "model", - "parts": [ - { - "text": "Paris." - } - ] - }, - { - "role": "user", - "parts": [ - { - "text": "And of Spain?" - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "You are a terse assistant." - } - ] - }, - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap deleted file mode 100644 index 6e2744c76..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap deleted file mode 100644 index 71baf9f7a..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "cached_content": "cachedContents/abc", - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap deleted file mode 100644 index 2e71f5623..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap deleted file mode 100644 index e04165dfd..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128, - "responseMimeType": "application/json" - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap deleted file mode 100644 index e8fc16720..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap +++ /dev/null @@ -1,37 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128, - "responseMimeType": "application/json", - "responseSchema": { - "type": "object", - "properties": { - "answer": { - "type": "string" - } - }, - "required": [ - "answer" - ] - } - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap deleted file mode 100644 index 87e6756df..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap +++ /dev/null @@ -1,30 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "temperature": 0.7, - "maxOutputTokens": 128, - "topP": 0.9, - "stopSequences": [ - "END" - ] - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap deleted file mode 100644 index 20f0d4724..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap +++ /dev/null @@ -1,41 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Think step by step: what is 2+2?" - } - ] - }, - { - "role": "model", - "parts": [ - { - "text": "4." - } - ] - }, - { - "role": "user", - "parts": [ - { - "text": "Now 3+3?" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap deleted file mode 100644 index 9682d4bd0..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } - ], - "toolConfig": { - "functionCallingConfig": { - "mode": "AUTO" - } - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap deleted file mode 100644 index ebab86778..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap +++ /dev/null @@ -1,66 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } - ], - "toolConfig": { - "functionCallingConfig": { - "mode": "ANY", - "allowedFunctionNames": [ - "search" - ] - } - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap deleted file mode 100644 index 9e7fe12ce..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } - ], - "toolConfig": { - "functionCallingConfig": { - "mode": "NONE" - } - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap deleted file mode 100644 index 0fee7b81d..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } - ], - "toolConfig": { - "functionCallingConfig": { - "mode": "ANY" - } - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap deleted file mode 100644 index 245c3cd85..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap +++ /dev/null @@ -1,108 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Find foo and read /tmp/x" - } - ] - }, - { - "role": "model", - "parts": [ - { - "text": "Let me check." - }, - { - "functionCall": { - "name": "search", - "args": { - "query": "foo" - } - } - }, - { - "functionCall": { - "name": "read_file", - "args": { - "path": "/tmp/x" - } - } - } - ] - }, - { - "role": "user", - "parts": [ - { - "functionResponse": { - "name": "search", - "response": { - "matches": 2 - } - } - } - ] - }, - { - "role": "user", - "parts": [ - { - "functionResponse": { - "name": "read_file", - "response": { - "result": "file not found" - } - } - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } - ], - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap deleted file mode 100644 index c3f9bdee7..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap +++ /dev/null @@ -1,37 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Describe these attachments." - }, - { - "fileData": { - "mimeType": "image/png", - "fileUri": "https://example.com/picture.png" - } - }, - { - "fileData": { - "mimeType": "application/pdf", - "fileUri": "https://example.com/report.pdf" - } - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap deleted file mode 100644 index 5ceb7e1d0..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap +++ /dev/null @@ -1,54 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": "[UUID]" - }, - { - "type": "text_delta", - "delta": "Hello", - "text_id": "[UUID]" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap deleted file mode 100644 index 22807bad4..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap +++ /dev/null @@ -1,86 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "tool_call_start", - "tool_call": { - "id": "[UUID]", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": null, - "provider_metadata": { - "thoughtSignature": "sig_stream_g" - } - } - }, - { - "type": "tool_call_end", - "tool_call": { - "id": "[UUID]", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": null, - "provider_metadata": { - "thoughtSignature": "sig_stream_g" - } - } - }, - { - "type": "finish", - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "tool_call", - "data": { - "id": "[UUID]", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": null, - "provider_metadata": { - "thoughtSignature": "sig_stream_g" - } - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap deleted file mode 100644 index f240b1c76..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": "[UUID]" - }, - { - "type": "text_delta", - "delta": "Hel", - "text_id": "[UUID]" - }, - { - "type": "text_delta", - "delta": "lo", - "text_id": "[UUID]" - }, - { - "type": "text_end", - "text_id": "[UUID]" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap deleted file mode 100644 index 90edf2a99..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap +++ /dev/null @@ -1,51 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/models/gemini-test:streamGenerateContent?alt=sse", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "content-length", - "197" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-goog-api-key", - "test-key" - ] - ], - "body": { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap deleted file mode 100644 index bf9e067fd..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap +++ /dev/null @@ -1,76 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "reasoning_start" - }, - { - "type": "reasoning_delta", - "delta": "Let me think" - }, - { - "type": "reasoning_end" - }, - { - "type": "text_start", - "text_id": "[UUID]" - }, - { - "type": "text_delta", - "delta": "4.", - "text_id": "[UUID]" - }, - { - "type": "text_end", - "text_id": "[UUID]" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 15, - "output_tokens": 12, - "reasoning_tokens": 6, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "thinking", - "data": { - "text": "Let me think", - "signature": null, - "redacted": false - } - }, - { - "kind": "text", - "data": "4." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 15, - "output_tokens": 12, - "reasoning_tokens": 6, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap deleted file mode 100644 index f5e0bd096..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap +++ /dev/null @@ -1,48 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello back" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 32, - "output_tokens": 7, - "reasoning_tokens": 0, - "cache_read_tokens": 10, - "cache_write_tokens": 0 - }, - "raw": { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "Hello back" - } - ] - }, - "finishReason": "STOP" - } - ], - "usageMetadata": { - "promptTokenCount": 42, - "candidatesTokenCount": 7, - "cachedContentTokenCount": 10 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap deleted file mode 100644 index 2a445751f..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap +++ /dev/null @@ -1,77 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/models/gemini-test:generateContent", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "content-length", - "425" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-goog-api-key", - "test-key" - ] - ], - "body": { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "Be concise" - } - ] - }, - "generationConfig": { - "temperature": 0.5, - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - } - } - } - ] - } - ], - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap deleted file mode 100644 index bd1327b8f..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap +++ /dev/null @@ -1,58 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "id": "chatcmpl_test", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "thinking", - "data": { - "text": "The user wants 2+2.", - "signature": null, - "redacted": false - } - }, - { - "kind": "text", - "data": "4." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 25, - "output_tokens": 40, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "chatcmpl_test", - "object": "chat.completion", - "created": 1700000000, - "model": "test-model", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "4.", - "reasoning_content": "The user wants 2+2." - }, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 25, - "completion_tokens": 40, - "total_tokens": 65 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap deleted file mode 100644 index 0d52c2d73..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap +++ /dev/null @@ -1,67 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "id": "chatcmpl_test", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "tool_call", - "data": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}" - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 30, - "output_tokens": 12, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "chatcmpl_test", - "object": "chat.completion", - "created": 1700000000, - "model": "test-model", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": null, - "tool_calls": [ - { - "id": "call_abc", - "type": "function", - "function": { - "name": "search", - "arguments": "{\"query\":\"foo\"}" - } - } - ] - }, - "finish_reason": "tool_calls" - } - ], - "usage": { - "prompt_tokens": 30, - "completion_tokens": 12, - "total_tokens": 42 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap deleted file mode 100644 index cff1eaf40..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap +++ /dev/null @@ -1,65 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "id": "gen_or_test", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "ok" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 50, - "output_tokens": 10, - "reasoning_tokens": 0, - "cache_read_tokens": 50, - "cache_write_tokens": 100 - }, - "raw": { - "id": "gen_or_test", - "object": "chat.completion", - "created": 1700000000, - "model": "test-model", - "provider": "Anthropic", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "ok" - }, - "finish_reason": "stop", - "native_finish_reason": "end_turn" - } - ], - "usage": { - "prompt_tokens": 200, - "completion_tokens": 10, - "total_tokens": 210, - "cost": 0.0042, - "cost_details": { - "upstream_inference_cost": null - }, - "prompt_tokens_details": { - "cached_tokens": 50, - "cache_write_tokens": 100, - "audio_tokens": 0 - }, - "completion_tokens_details": { - "reasoning_tokens": 0 - } - } - }, - "warnings": [], - "rate_limit": null, - "cost_usd": 0.0042, - "cost_source": "authoritative" -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap deleted file mode 100644 index 8e2239f87..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap +++ /dev/null @@ -1,55 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "id": "chatcmpl_test", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "ok" - } - ] - }, - "finish_reason": "length", - "usage": { - "input_tokens": 20, - "output_tokens": 30, - "reasoning_tokens": 20, - "cache_read_tokens": 80, - "cache_write_tokens": 0 - }, - "raw": { - "id": "chatcmpl_test", - "object": "chat.completion", - "created": 1700000000, - "model": "test-model", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "ok" - }, - "finish_reason": "length" - } - ], - "usage": { - "prompt_tokens": 100, - "completion_tokens": 50, - "total_tokens": 150, - "prompt_tokens_details": { - "cached_tokens": 80 - }, - "completion_tokens_details": { - "reasoning_tokens": 20 - } - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_venice_top_level_cost.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_venice_top_level_cost.snap deleted file mode 100644 index 7236fc7c8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_venice_top_level_cost.snap +++ /dev/null @@ -1,55 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "id": "chatcmpl_venice_test", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "ok" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 12, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "chatcmpl_venice_test", - "object": "chat.completion", - "created": 1700000000, - "model": "test-model", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "ok" - }, - "finish_reason": "stop" - } - ], - "cost": { - "usd": 0.00042, - "diem": 0.0 - }, - "usage": { - "prompt_tokens": 12, - "completion_tokens": 2, - "total_tokens": 14 - } - }, - "warnings": [], - "rate_limit": null, - "cost_usd": 0.00042, - "cost_source": "authoritative" -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap deleted file mode 100644 index 101179520..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Transcribe this.[Audio content not supported by this provider]" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap deleted file mode 100644 index 485ae8446..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Describe these attachments.[Document 'missing.pdf': content type not supported by this provider]" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap deleted file mode 100644 index 10995e1e8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Describe these attachments.[Document 'report.pdf': content type not supported by this provider]" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap deleted file mode 100644 index cbb42e14d..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "You are a terse assistant." - }, - { - "role": "user", - "content": "What is the capital of France?" - }, - { - "role": "assistant", - "content": "Paris." - }, - { - "role": "user", - "content": "And of Spain?" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap deleted file mode 100644 index 75a4a2634..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "repetition_penalty": 1.2 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap deleted file mode 100644 index f3e189bfe..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap deleted file mode 100644 index 24763c82f..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "response_format": { - "type": "json_object" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap deleted file mode 100644 index 7e35babc2..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "response", - "strict": true, - "schema": { - "type": "object", - "properties": { - "answer": { - "type": "string" - } - }, - "required": [ - "answer" - ] - } - } - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap deleted file mode 100644 index 74e26fcd1..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "temperature": 0.7, - "max_tokens": 128, - "top_p": 0.9, - "stop": [ - "END" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap deleted file mode 100644 index 86c83a213..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap +++ /dev/null @@ -1,23 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Think step by step: what is 2+2?" - }, - { - "role": "assistant", - "content": "4.", - "reasoning_content": "The user wants 2+2, which is 4." - }, - { - "role": "user", - "content": "Now 3+3?" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap deleted file mode 100644 index ea913ebfb..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap +++ /dev/null @@ -1,50 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "tools": [ - { - "type": "function", - "function": { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - } - }, - { - "type": "function", - "function": { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - } - ], - "tool_choice": "auto" -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap deleted file mode 100644 index 442e8a42d..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap +++ /dev/null @@ -1,55 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "tools": [ - { - "type": "function", - "function": { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - } - }, - { - "type": "function", - "function": { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - } - ], - "tool_choice": { - "type": "function", - "function": { - "name": "search" - } - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap deleted file mode 100644 index 919c82db5..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap +++ /dev/null @@ -1,50 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "tools": [ - { - "type": "function", - "function": { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - } - }, - { - "type": "function", - "function": { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - } - ], - "tool_choice": "none" -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap deleted file mode 100644 index 665fa3c5a..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap +++ /dev/null @@ -1,50 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "tools": [ - { - "type": "function", - "function": { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - } - }, - { - "type": "function", - "function": { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - } - ], - "tool_choice": "required" -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap deleted file mode 100644 index d601c24dc..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap +++ /dev/null @@ -1,81 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Find foo and read /tmp/x" - }, - { - "role": "assistant", - "content": "Let me check.", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "search", - "arguments": "{\"query\":\"foo\"}" - } - }, - { - "id": "call_2", - "type": "function", - "function": { - "name": "read_file", - "arguments": "{\"path\":\"/tmp/x\"}" - } - } - ] - }, - { - "role": "tool", - "content": "{\"matches\":2}", - "tool_call_id": "call_1" - }, - { - "role": "tool", - "content": "file not found", - "tool_call_id": "call_2" - } - ], - "max_tokens": 128, - "tools": [ - { - "type": "function", - "function": { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - } - }, - { - "type": "function", - "function": { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap deleted file mode 100644 index 10995e1e8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Describe these attachments.[Document 'report.pdf': content type not supported by this provider]" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap deleted file mode 100644 index 9aaca3762..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap +++ /dev/null @@ -1,66 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "4.", - "text_id": null - }, - { - "type": "text_end", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "chatcmpl_stream", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "thinking", - "data": { - "text": "Let me think", - "signature": null, - "redacted": false - } - }, - { - "kind": "text", - "data": "4." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap deleted file mode 100644 index 34dd07b3f..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "Hel", - "text_id": null - }, - { - "type": "text_delta", - "delta": "lo", - "text_id": null - }, - { - "type": "text_end", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "chatcmpl_stream", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap deleted file mode 100644 index 297b156fa..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "stream": true, - "stream_options": { - "include_usage": true - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap deleted file mode 100644 index e1bb0f279..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap +++ /dev/null @@ -1,95 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "tool_call_start", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": null, - "raw_arguments": null - } - }, - { - "type": "tool_call_delta", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": null, - "raw_arguments": null - } - }, - { - "type": "tool_call_delta", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": null, - "raw_arguments": null - } - }, - { - "type": "tool_call_end", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}" - } - }, - { - "type": "finish", - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "chatcmpl_stream", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "tool_call", - "data": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}" - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap deleted file mode 100644 index d00f07703..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap +++ /dev/null @@ -1,60 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "Hi", - "text_id": null - }, - { - "type": "text_end", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 8, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 4, - "cache_write_tokens": 0 - }, - "response": { - "id": "gen_or_stream", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hi" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 8, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 4, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null, - "cost_usd": 0.00031, - "cost_source": "authoritative" - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_venice_top_level_cost.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_venice_top_level_cost.snap deleted file mode 100644 index b2ac8b212..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_venice_top_level_cost.snap +++ /dev/null @@ -1,60 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "Hi", - "text_id": null - }, - { - "type": "text_end", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 12, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "chatcmpl_venice_stream", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hi" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 12, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null, - "cost_usd": 0.00031, - "cost_source": "authoritative" - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap deleted file mode 100644 index b54da4946..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap deleted file mode 100644 index 18f1feb19..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap +++ /dev/null @@ -1,58 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "Hello", - "text_id": null - }, - { - "type": "text_end", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "chatcmpl_stream", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap deleted file mode 100644 index eb24f04e8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap +++ /dev/null @@ -1,49 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "id": "chatcmpl_test", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello back" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 42, - "output_tokens": 7, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "chatcmpl_test", - "object": "chat.completion", - "created": 1700000000, - "model": "test-model", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Hello back" - }, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 42, - "completion_tokens": 7, - "total_tokens": 49 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap deleted file mode 100644 index 675c550b4..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap +++ /dev/null @@ -1,62 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/chat/completions", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "authorization", - "Bearer test-key" - ], - [ - "content-length", - "305" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ] - ], - "body": { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Be concise" - }, - { - "role": "user", - "content": "Hello" - } - ], - "temperature": 0.5, - "max_tokens": 128, - "tools": [ - { - "type": "function", - "function": { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - } - } - } - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap deleted file mode 100644 index 6491674c9..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap +++ /dev/null @@ -1,77 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/responses/input_tokens", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "authorization", - "Bearer test-key" - ], - [ - "content-length", - "454" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ] - ], - "body": { - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "instructions": "Be concise", - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap deleted file mode 100644 index 3fb9cbd0b..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "stream_item_error": "Server error from openai-proxy: boom", - "retryable": true, - "failover_eligible": true - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap deleted file mode 100644 index fa27da4b8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap +++ /dev/null @@ -1,65 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "id": "resp_test", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "openai_message", - "data": { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [ - { - "type": "output_text", - "text": "Truncated" - } - ] - } - }, - { - "kind": "text", - "data": "Truncated" - } - ] - }, - "finish_reason": "length", - "usage": { - "input_tokens": 10, - "output_tokens": 128, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_test", - "object": "response", - "model": "gpt-test", - "status": "incomplete", - "output": [ - { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [ - { - "type": "output_text", - "text": "Truncated" - } - ] - } - ], - "usage": { - "input_tokens": 10, - "output_tokens": 128 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap deleted file mode 100644 index e4597626a..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap +++ /dev/null @@ -1,81 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "id": "resp_test", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "openai_reasoning", - "data": { - "type": "reasoning", - "id": "rs_1", - "summary": [ - { - "type": "summary_text", - "text": "Searching." - } - ] - } - }, - { - "kind": "tool_call", - "data": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}", - "provider_metadata": { - "id": "fc_123" - } - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 30, - "output_tokens": 12, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_test", - "object": "response", - "model": "gpt-test", - "status": "completed", - "output": [ - { - "type": "reasoning", - "id": "rs_1", - "summary": [ - { - "type": "summary_text", - "text": "Searching." - } - ] - }, - { - "type": "function_call", - "id": "fc_123", - "call_id": "call_abc", - "name": "search", - "arguments": "{\"query\":\"foo\"}" - } - ], - "usage": { - "input_tokens": 30, - "output_tokens": 12 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap deleted file mode 100644 index 98813798b..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap +++ /dev/null @@ -1,71 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "id": "resp_test", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "openai_message", - "data": { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [ - { - "type": "output_text", - "text": "ok" - } - ] - } - }, - { - "kind": "text", - "data": "ok" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 20, - "output_tokens": 30, - "reasoning_tokens": 20, - "cache_read_tokens": 80, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_test", - "object": "response", - "model": "gpt-test", - "status": "completed", - "output": [ - { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [ - { - "type": "output_text", - "text": "ok" - } - ] - } - ], - "usage": { - "input_tokens": 100, - "output_tokens": 50, - "input_tokens_details": { - "cached_tokens": 80 - }, - "output_tokens_details": { - "reasoning_tokens": 20 - } - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap deleted file mode 100644 index 52d5331bf..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Transcribe this." - }, - { - "type": "input_text", - "text": "[Audio content not supported by this provider]" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap deleted file mode 100644 index 5fd2a64fd..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Describe these attachments." - }, - { - "type": "input_text", - "text": "[Document 'missing.pdf': content type not supported by this provider]" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap deleted file mode 100644 index f57e05f8b..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap +++ /dev/null @@ -1,51 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/responses", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "authorization", - "Bearer test-key" - ], - [ - "content-length", - "210" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ] - ], - "body": { - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "instructions": "Be concise", - "store": false, - "include": [ - "reasoning.encrypted_content" - ], - "stream": true - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap deleted file mode 100644 index 5c98c82d4..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap +++ /dev/null @@ -1,67 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Find foo" - } - ] - }, - { - "type": "function_call", - "id": "fc_123", - "call_id": "call_abc", - "name": "search", - "arguments": "{\"query\":\"foo\"}" - }, - { - "type": "function_call_output", - "call_id": "call_abc", - "output": "2 matches" - } - ], - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap deleted file mode 100644 index 172b57110..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Describe these attachments." - }, - { - "type": "input_image", - "image_url": "data:image/png;base64,ZmFrZS1wbmctYnl0ZXM=" - }, - { - "type": "input_text", - "text": "[Document 'report.pdf': content type not supported by this provider]" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap deleted file mode 100644 index 473ce2c5d..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap +++ /dev/null @@ -1,45 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "What is the capital of France?" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "Paris." - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "And of Spain?" - } - ] - } - ], - "instructions": "You are a terse assistant.", - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap deleted file mode 100644 index d672689b1..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap +++ /dev/null @@ -1,55 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Think about 2+2." - } - ] - }, - { - "type": "reasoning", - "id": "rs_1", - "summary": [ - { - "type": "summary_text", - "text": "Adding." - } - ] - }, - { - "type": "message", - "role": "assistant", - "id": "msg_1", - "content": [ - { - "type": "output_text", - "text": "4." - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Now 3+3?" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap deleted file mode 100644 index c2f03af2c..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ], - "seed": 42 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap deleted file mode 100644 index 57d01b2f9..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "test-gpt", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "reasoning": { - "effort": "high" - }, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap deleted file mode 100644 index 05ffc5a7d..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap +++ /dev/null @@ -1,29 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "text": { - "format": { - "type": "json_object" - } - }, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap deleted file mode 100644 index 1fe952e6c..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap +++ /dev/null @@ -1,42 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "text": { - "format": { - "type": "json_schema", - "name": "response", - "strict": true, - "schema": { - "type": "object", - "properties": { - "answer": { - "type": "string" - } - }, - "required": [ - "answer" - ] - } - } - }, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap deleted file mode 100644 index 783c7ab18..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "temperature": 0.7, - "max_output_tokens": 128, - "top_p": 0.9, - "stop": [ - "END" - ], - "metadata": { - "trace_id": "trace-123" - }, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap deleted file mode 100644 index a232e2e6a..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap +++ /dev/null @@ -1,44 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Think step by step: what is 2+2?" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "4." - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Now 3+3?" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap deleted file mode 100644 index b4b6aef6c..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap +++ /dev/null @@ -1,56 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": "auto", - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap deleted file mode 100644 index cb462f295..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap +++ /dev/null @@ -1,59 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": { - "type": "function", - "name": "search" - }, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap deleted file mode 100644 index 9c11e0609..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap +++ /dev/null @@ -1,56 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": "none", - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap deleted file mode 100644 index 3b9ad1745..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap +++ /dev/null @@ -1,56 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": "required", - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap deleted file mode 100644 index f542622fb..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap +++ /dev/null @@ -1,90 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Find foo and read /tmp/x" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "Let me check." - } - ] - }, - { - "type": "function_call", - "id": "call_1", - "call_id": "call_1", - "name": "search", - "arguments": "{\"query\":\"foo\"}" - }, - { - "type": "function_call", - "id": "call_2", - "call_id": "call_2", - "name": "read_file", - "arguments": "{\"path\":\"/tmp/x\"}" - }, - { - "type": "function_call_output", - "call_id": "call_1", - "output": "{\"matches\":2}" - }, - { - "type": "function_call_output", - "call_id": "call_2", - "output": "file not found", - "status": "incomplete" - } - ], - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap deleted file mode 100644 index b85c3da20..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Describe these attachments." - }, - { - "type": "input_image", - "image_url": "https://example.com/picture.png" - }, - { - "type": "input_text", - "text": "[Document 'report.pdf': content type not supported by this provider]" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap deleted file mode 100644 index 191a90c06..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "stream_item_error": "Server error from openai: boom", - "retryable": true, - "failover_eligible": true - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap deleted file mode 100644 index e274eb534..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "Trunc", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "length", - "usage": { - "input_tokens": 10, - "output_tokens": 128, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "resp_stream", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Trunc" - } - ] - }, - "finish_reason": "length", - "usage": { - "input_tokens": 10, - "output_tokens": 128, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_stream", - "model": "gpt-test", - "status": "incomplete", - "output": [], - "usage": { - "input_tokens": 10, - "output_tokens": 128 - } - }, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap deleted file mode 100644 index 47f067f13..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap +++ /dev/null @@ -1,77 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "reasoning_start" - }, - { - "type": "reasoning_delta", - "delta": "Let me " - }, - { - "type": "reasoning_delta", - "delta": "think" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "4.", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 15, - "output_tokens": 4, - "reasoning_tokens": 8, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "resp_stream", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "4." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 15, - "output_tokens": 4, - "reasoning_tokens": 8, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_stream", - "model": "gpt-test", - "status": "completed", - "output": [], - "usage": { - "input_tokens": 15, - "output_tokens": 12, - "output_tokens_details": { - "reasoning_tokens": 8 - } - } - }, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap deleted file mode 100644 index cde7ca175..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap +++ /dev/null @@ -1,74 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "Hel", - "text_id": null - }, - { - "type": "text_delta", - "delta": "lo", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 9, - "output_tokens": 4, - "reasoning_tokens": 1, - "cache_read_tokens": 2, - "cache_write_tokens": 0 - }, - "response": { - "id": "resp_stream", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 9, - "output_tokens": 4, - "reasoning_tokens": 1, - "cache_read_tokens": 2, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_stream", - "model": "gpt-test", - "status": "completed", - "output": [], - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "input_tokens_details": { - "cached_tokens": 2 - }, - "output_tokens_details": { - "reasoning_tokens": 1 - } - } - }, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap deleted file mode 100644 index 1dbf93c1a..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ], - "stream": true -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap deleted file mode 100644 index a4cc2e686..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap +++ /dev/null @@ -1,119 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "tool_call_start", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": {}, - "raw_arguments": "{\"qu", - "provider_metadata": { - "id": "fc_123" - } - } - }, - { - "type": "tool_call_delta", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": {}, - "raw_arguments": "{\"qu", - "provider_metadata": { - "id": "fc_123" - } - } - }, - { - "type": "tool_call_delta", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": {}, - "raw_arguments": "ery\":\"foo\"}", - "provider_metadata": { - "id": "fc_123" - } - } - }, - { - "type": "tool_call_end", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}", - "provider_metadata": { - "id": "fc_123" - } - } - }, - { - "type": "finish", - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "resp_stream", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "tool_call", - "data": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}", - "provider_metadata": { - "id": "fc_123" - } - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_stream", - "model": "gpt-test", - "status": "completed", - "output": [], - "usage": { - "input_tokens": 20, - "output_tokens": 9 - } - }, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap deleted file mode 100644 index 01d4e4fd4..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap +++ /dev/null @@ -1,71 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "id": "resp_test", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "openai_message", - "data": { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [ - { - "type": "output_text", - "text": "Hello back" - } - ] - } - }, - { - "kind": "text", - "data": "Hello back" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 32, - "output_tokens": 4, - "reasoning_tokens": 3, - "cache_read_tokens": 10, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_test", - "object": "response", - "model": "gpt-test", - "status": "completed", - "output": [ - { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [ - { - "type": "output_text", - "text": "Hello back" - } - ] - } - ], - "usage": { - "input_tokens": 42, - "output_tokens": 7, - "input_tokens_details": { - "cached_tokens": 10 - }, - "output_tokens_details": { - "reasoning_tokens": 3 - } - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap deleted file mode 100644 index 165c53cc3..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap +++ /dev/null @@ -1,67 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/responses", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "authorization", - "Bearer test-key" - ], - [ - "content-length", - "385" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ] - ], - "body": { - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "instructions": "Be concise", - "temperature": 0.5, - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - } - } - } - ], - "store": false, - "include": [ - "reasoning.encrypted_content" - ] - } -} diff --git a/lib/components/fabro-manifest/src/lib.rs b/lib/components/fabro-manifest/src/lib.rs index f1f4bea15..db317abb1 100644 --- a/lib/components/fabro-manifest/src/lib.rs +++ b/lib/components/fabro-manifest/src/lib.rs @@ -557,10 +557,6 @@ fn remotely_available_sha( /// Resolve a workflow reference and reject it when neither its config nor /// its graph exists on disk. /// A missing workflow surfaces as `fabro_config::Error::WorkflowNotFound`. -#[expect( - clippy::result_large_err, - reason = "callers match on the concrete config error to classify missing workflows" -)] fn resolve_existing_workflow_location( workflow: &Path, cwd: &Path, diff --git a/lib/components/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs index b9a9fdacf..b2df4d2af 100644 --- a/lib/components/fabro-store/src/run_state.rs +++ b/lib/components/fabro-store/src/run_state.rs @@ -1564,8 +1564,8 @@ fn conclusion_from_completed( props: &RunCompletedProps, timestamp: DateTime, ) -> Result { - let (stages, total_retries) = billing_rollup::billing_rollup_from_projection(projection, None) - .conclusion_stages(projection); + let (stages, total_retries) = + billing_rollup::billing_rollup_from_projection(projection).conclusion_stages(projection); Ok(Conclusion { timestamp, status: StageOutcome::from_str(&props.status) @@ -1588,8 +1588,8 @@ fn conclusion_from_failed( props: &RunFailedProps, timestamp: DateTime, ) -> Conclusion { - let (stages, total_retries) = billing_rollup::billing_rollup_from_projection(projection, None) - .conclusion_stages(projection); + let (stages, total_retries) = + billing_rollup::billing_rollup_from_projection(projection).conclusion_stages(projection); Conclusion { timestamp, status: StageOutcome::Failed { @@ -1759,8 +1759,8 @@ mod tests { AgentBackend, AgentControlState, AttrValue, AutomationRef, BilledModelUsage, BilledTokenCounts, BlobHash, BlockedReason, Checkpoint, CheckpointRecord, CommandTermination, EventBody, FailureCategory, FailureDetail, FailureReason, Graph, - McpServerStatus, Node, Outcome, ParallelBranchId, PendingReason, PermissionLevel, - PullRequestCreationStatus, PullRequestLink, QuestionType, ReasoningEffort, + McpServerStatus, ModelId, Node, Outcome, ParallelBranchId, PendingReason, PermissionLevel, + ProviderId, PullRequestCreationStatus, PullRequestLink, QuestionType, ReasoningEffort, RunApprovalState, RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunSize, RunSpec, RunStatus, Speed, StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness, @@ -1812,11 +1812,11 @@ mod tests { fn llm_started() -> EventBody { EventBody::AgentLlmStarted(AgentLlmStartedProps { - requested_model: ModelRef { - provider: "anthropic".parse().unwrap(), - model_id: "claude-fable-5".into(), - speed: Some(Speed::Fast), - }, + requested_model: ModelRef::new( + ProviderId::new("anthropic"), + ModelId::new("claude-fable-5"), + ) + .with_speed(Some(Speed::Fast)), visit: 1, }) } @@ -2324,18 +2324,10 @@ mod tests { fn test_usage(model_id: &str, input_tokens: i64, output_tokens: i64) -> BilledModelUsage { serde_json::from_value(json!({ - "input": { - "usage": { - "model": { - "provider": "openai", - "model_id": model_id - }, - "tokens": { - "input_tokens": input_tokens, - "output_tokens": output_tokens - } - }, - "facts": { "algorithm": "openai" } + "model": { "provider": "openai", "model_id": model_id }, + "tokens": { + "input": input_tokens, + "output": output_tokens }, "total_usd_micros": input_tokens + output_tokens })) @@ -5388,21 +5380,13 @@ mod tests { fn billed_usage() -> BilledModelUsage { serde_json::from_value(json!({ - "input": { - "usage": { - "model": { - "provider": "openai", - "model_id": "gpt-test" - }, - "tokens": { - "input_tokens": 10, - "output_tokens": 5, - "reasoning_tokens": 2, - "cache_read_tokens": 3, - "cache_write_tokens": 4 - } - }, - "facts": { "algorithm": "openai" } + "model": { "provider": "openai", "model_id": "gpt-test" }, + "tokens": { + "input": 10, + "output": 5, + "reasoning": 2, + "cache_read": 3, + "cache_write": 4 }, "total_usd_micros": 123 })) @@ -7622,11 +7606,11 @@ mod tests { fn started() -> EventBody { EventBody::AgentLlmStarted(AgentLlmStartedProps { - requested_model: ModelRef { - provider: "anthropic".parse().unwrap(), - model_id: "claude-fable-5".into(), - speed: Some(Speed::Fast), - }, + requested_model: ModelRef::new( + ProviderId::new("anthropic"), + ModelId::new("claude-fable-5"), + ) + .with_speed(Some(Speed::Fast)), visit: 1, }) } diff --git a/lib/components/fabro-store/tests/serializable_projection.rs b/lib/components/fabro-store/tests/serializable_projection.rs index d6fdcc682..d16a0e952 100644 --- a/lib/components/fabro-store/tests/serializable_projection.rs +++ b/lib/components/fabro-store/tests/serializable_projection.rs @@ -49,18 +49,10 @@ fn sample_checkpoint() -> Checkpoint { fn sample_usage() -> BilledModelUsage { serde_json::from_value(json!({ - "input": { - "usage": { - "model": { - "provider": "openai", - "model_id": "gpt-5.2" - }, - "tokens": { - "input_tokens": 123, - "output_tokens": 45 - } - }, - "facts": { "algorithm": "openai" } + "model": { "provider": "openai", "model_id": "gpt-5.2" }, + "tokens": { + "input": 123, + "output": 45 }, "total_usd_micros": 168 })) diff --git a/lib/components/fabro-validate/Cargo.toml b/lib/components/fabro-validate/Cargo.toml index 4287fd1f1..4ffd06a2a 100644 --- a/lib/components/fabro-validate/Cargo.toml +++ b/lib/components/fabro-validate/Cargo.toml @@ -15,10 +15,11 @@ workspace = true [dependencies] fabro-acp = { path = "../fabro-acp", default-features = false } fabro-graphviz = { path = "../fabro-graphviz" } -fabro-model = { path = "../../foundation/fabro-model" } +fabro-llm = { path = "../fabro-llm" } fabro-types = { path = "../../foundation/fabro-types" } serde = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +fabro-llm = { path = "../fabro-llm", features = ["test-support"] } toml = { workspace = true } diff --git a/lib/components/fabro-validate/src/lib.rs b/lib/components/fabro-validate/src/lib.rs index 3747b677c..1605dcc3a 100644 --- a/lib/components/fabro-validate/src/lib.rs +++ b/lib/components/fabro-validate/src/lib.rs @@ -1,7 +1,7 @@ pub mod rules; use fabro_graphviz::graph::Graph; -use fabro_model::Catalog; +use fabro_llm::lithos_catalog::Catalog; use serde::{Deserialize, Serialize}; /// Severity level for validation diagnostics. @@ -153,8 +153,8 @@ pub fn validate_with_catalog_or_raise( mod tests { use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_graphviz::parser; - use fabro_model::catalog::LlmCatalogSettings; - use fabro_model::{Catalog, ProviderId}; + use fabro_llm::lithos_catalog::Catalog; + use fabro_llm::test_support::test_catalog_with_overlay; use super::*; @@ -208,35 +208,29 @@ mod tests { g } + /// An operator-defined provider layered over the built-ins, the shape an + /// `[llm]` overlay produces. fn custom_catalog() -> Catalog { - let settings: LlmCatalogSettings = toml::from_str( + test_catalog_with_overlay( r#" -[providers.venice] -display_name = "Venice" -adapter = "openai_compatible" -agent_profile = "openai" +[providers.acme-venice] +display_name = "Acme Venice" +adapter = "openai-compatible" +codec = "openai-chat" base_url = "https://api.venice.ai/api/v1" +auth = { type = "bearer" } +default_model = "venice-large" -[providers.venice.auth] -credentials = ["env:VENICE_API_KEY"] +[providers.acme-venice.metadata.agent] +profile = "openai" -[models."venice-large"] -provider = "venice" +[providers.acme-venice.models.venice-large] display_name = "Venice Large" -family = "venice" -default = true - -[models."venice-large".limits] -context_window = 128000 - -[models."venice-large".features] -tools = true -vision = false -reasoning = false +api_model = "venice-large" +limits = { context_tokens = 128000, max_output_tokens = 8192 } +capabilities = { text = true, tools = true } "#, ) - .unwrap(); - Catalog::from_settings(&settings).unwrap() } #[test] @@ -377,7 +371,7 @@ reasoning = false #[test] fn validate_with_catalog_accepts_custom_catalog_entries() { - let g = graph_with_model_and_provider("venice-large", "venice"); + let g = graph_with_model_and_provider("venice-large", "acme-venice"); let catalog = custom_catalog(); let diagnostics = validate_with_catalog(&g, &catalog, &[]); @@ -406,7 +400,7 @@ reasoning = false assert!( diagnostics.iter().any(|d| d.rule == "node_model_known" && d.message.contains("missing-provider") - && d.message.contains(ProviderId::new("venice").as_str())), + && d.message.contains("acme-venice")), "missing provider diagnostic not found: {diagnostics:?}" ); } diff --git a/lib/components/fabro-validate/src/rules/mod.rs b/lib/components/fabro-validate/src/rules/mod.rs index c73657718..dc9270b87 100644 --- a/lib/components/fabro-validate/src/rules/mod.rs +++ b/lib/components/fabro-validate/src/rules/mod.rs @@ -1,3 +1,4 @@ +use fabro_llm::lithos_catalog::Catalog; mod all_conditional_edges; mod auto_status_deprecated; mod backend_valid; @@ -82,7 +83,7 @@ pub fn built_in_rules() -> Vec> { /// Returns lint rules that require the caller's resolved model catalog. #[must_use] -pub fn catalog_rules(catalog: &fabro_model::Catalog) -> Vec> { +pub fn catalog_rules(catalog: &Catalog) -> Vec> { vec![ stylesheet_model_known::rule(catalog), node_model_known::rule(catalog), diff --git a/lib/components/fabro-validate/src/rules/model_support.rs b/lib/components/fabro-validate/src/rules/model_support.rs index 7bab56a87..21735ef5a 100644 --- a/lib/components/fabro-validate/src/rules/model_support.rs +++ b/lib/components/fabro-validate/src/rules/model_support.rs @@ -1,13 +1,16 @@ +use fabro_llm::catalog; +use fabro_llm::lithos_catalog::Catalog; + use crate::{Diagnostic, Severity}; pub(super) fn check_model_known( rule_name: &str, - catalog: &fabro_model::Catalog, + catalog: &Catalog, model: &str, context: &str, node_id: Option, ) -> Option { - if catalog.is_model_selector(model) { + if catalog::is_model_selector(catalog, model) { return None; } Some(Diagnostic { @@ -26,21 +29,17 @@ pub(super) fn check_model_known( pub(super) fn check_provider_known( rule_name: &str, - catalog: &fabro_model::Catalog, + catalog: &Catalog, provider: &str, context: &str, node_id: Option, ) -> Option { - if catalog - .provider(&fabro_model::ProviderId::new(provider)) - .is_some() - { + if catalog::is_provider_selector(catalog, provider) { return None; } - let valid: Vec<&str> = catalog - .providers() + let valid: Vec = catalog::listed_providers(catalog) .iter() - .map(|provider| provider.id.as_str()) + .map(|provider| provider.id().to_string()) .collect(); let valid_str = valid.join(", "); Some(Diagnostic { diff --git a/lib/components/fabro-validate/src/rules/node_model_known.rs b/lib/components/fabro-validate/src/rules/node_model_known.rs index 64ba03eac..0cfb13841 100644 --- a/lib/components/fabro-validate/src/rules/node_model_known.rs +++ b/lib/components/fabro-validate/src/rules/node_model_known.rs @@ -1,5 +1,5 @@ use fabro_graphviz::graph::Graph; -use fabro_model::Catalog; +use fabro_llm::lithos_catalog::Catalog; use super::model_support::{check_model_known, check_provider_known}; use crate::{Diagnostic, LintRule}; @@ -48,7 +48,7 @@ impl LintRule for Rule<'_> { #[cfg(test)] mod tests { use fabro_graphviz::graph::{AttrValue, Node}; - use fabro_model::Catalog; + use fabro_llm::test_support::test_catalog; use super::Rule; use crate::rules::test_support::minimal_graph; @@ -60,12 +60,11 @@ mod tests { let mut node = Node::new("work"); node.attrs.insert( "model".to_string(), - AttrValue::String("claude-sonnet-4-5".to_string()), + AttrValue::String("claude-sonnet-4.5".to_string()), ); g.nodes.insert("work".to_string(), node); - let rule = Rule { - catalog: Catalog::builtin(), - }; + let catalog = test_catalog(); + let rule = Rule { catalog: &catalog }; let d = rule.apply(&g); assert!(d.is_empty()); } @@ -79,9 +78,8 @@ mod tests { AttrValue::String("nonexistent-model-xyz".to_string()), ); g.nodes.insert("work".to_string(), node); - let rule = Rule { - catalog: Catalog::builtin(), - }; + let catalog = test_catalog(); + let rule = Rule { catalog: &catalog }; let d = rule.apply(&g); assert_eq!(d.len(), 1); assert_eq!(d[0].severity, Severity::Warning); @@ -96,9 +94,8 @@ mod tests { node.attrs .insert("model".to_string(), AttrValue::String("opus".to_string())); g.nodes.insert("work".to_string(), node); - let rule = Rule { - catalog: Catalog::builtin(), - }; + let catalog = test_catalog(); + let rule = Rule { catalog: &catalog }; let d = rule.apply(&g); assert!(d.is_empty()); } @@ -109,25 +106,23 @@ mod tests { let mut node = Node::new("work"); node.attrs.insert( "provider".to_string(), - AttrValue::String("google".to_string()), + AttrValue::String("nonexistent-provider".to_string()), ); g.nodes.insert("work".to_string(), node); - let rule = Rule { - catalog: Catalog::builtin(), - }; + let catalog = test_catalog(); + let rule = Rule { catalog: &catalog }; let d = rule.apply(&g); assert_eq!(d.len(), 1); assert_eq!(d[0].severity, Severity::Warning); - assert!(d[0].message.contains("google")); + assert!(d[0].message.contains("nonexistent-provider")); assert_eq!(d[0].node_id.as_deref(), Some("work")); } #[test] fn node_model_known_rule_no_model_no_provider() { let g = minimal_graph(); - let rule = Rule { - catalog: Catalog::builtin(), - }; + let catalog = test_catalog(); + let rule = Rule { catalog: &catalog }; let d = rule.apply(&g); assert!(d.is_empty()); } diff --git a/lib/components/fabro-validate/src/rules/stylesheet_model_known.rs b/lib/components/fabro-validate/src/rules/stylesheet_model_known.rs index 655da0dd6..cafd16772 100644 --- a/lib/components/fabro-validate/src/rules/stylesheet_model_known.rs +++ b/lib/components/fabro-validate/src/rules/stylesheet_model_known.rs @@ -1,6 +1,6 @@ use fabro_graphviz::graph::Graph; use fabro_graphviz::stylesheet::{Selector, parse_stylesheet}; -use fabro_model::Catalog; +use fabro_llm::lithos_catalog::Catalog; use super::model_support::{check_model_known, check_provider_known}; use crate::{Diagnostic, LintRule}; @@ -77,7 +77,7 @@ impl LintRule for Rule<'_> { #[cfg(test)] mod tests { use fabro_graphviz::graph::AttrValue; - use fabro_model::Catalog; + use fabro_llm::test_support::test_catalog; use super::Rule; use crate::rules::test_support::minimal_graph; @@ -88,11 +88,10 @@ mod tests { let mut g = minimal_graph(); g.attrs.insert( "model_stylesheet".to_string(), - AttrValue::String("* { model: claude-sonnet-4-5; provider: anthropic; }".to_string()), + AttrValue::String("* { model: claude-sonnet-4.5; provider: anthropic; }".to_string()), ); - let rule = Rule { - catalog: Catalog::builtin(), - }; + let catalog = test_catalog(); + let rule = Rule { catalog: &catalog }; let d = rule.apply(&g); assert!(d.is_empty()); } @@ -104,9 +103,8 @@ mod tests { "model_stylesheet".to_string(), AttrValue::String("#opus { model: claude-opus-4-5; }".to_string()), ); - let rule = Rule { - catalog: Catalog::builtin(), - }; + let catalog = test_catalog(); + let rule = Rule { catalog: &catalog }; let d = rule.apply(&g); assert_eq!(d.len(), 1); assert_eq!(d[0].severity, Severity::Warning); @@ -119,15 +117,14 @@ mod tests { let mut g = minimal_graph(); g.attrs.insert( "model_stylesheet".to_string(), - AttrValue::String("* { provider: google; }".to_string()), + AttrValue::String("* { provider: nonexistent-provider; }".to_string()), ); - let rule = Rule { - catalog: Catalog::builtin(), - }; + let catalog = test_catalog(); + let rule = Rule { catalog: &catalog }; let d = rule.apply(&g); assert_eq!(d.len(), 1); assert_eq!(d[0].severity, Severity::Warning); - assert!(d[0].message.contains("google")); + assert!(d[0].message.contains("nonexistent-provider")); } #[test] @@ -137,9 +134,8 @@ mod tests { "model_stylesheet".to_string(), AttrValue::String("* { model: opus; }".to_string()), ); - let rule = Rule { - catalog: Catalog::builtin(), - }; + let catalog = test_catalog(); + let rule = Rule { catalog: &catalog }; let d = rule.apply(&g); assert!(d.is_empty()); } @@ -147,9 +143,8 @@ mod tests { #[test] fn stylesheet_model_known_rule_no_stylesheet() { let g = minimal_graph(); - let rule = Rule { - catalog: Catalog::builtin(), - }; + let catalog = test_catalog(); + let rule = Rule { catalog: &catalog }; let d = rule.apply(&g); assert!(d.is_empty()); } diff --git a/lib/components/fabro-workflow/Cargo.toml b/lib/components/fabro-workflow/Cargo.toml index b326fb139..388078b5b 100644 --- a/lib/components/fabro-workflow/Cargo.toml +++ b/lib/components/fabro-workflow/Cargo.toml @@ -39,7 +39,6 @@ fabro-util = { path = "../../foundation/fabro-util" } fabro-redact.workspace = true fabro-checkpoint = { path = "../fabro-checkpoint" } fabro-llm = { path = "../fabro-llm" } -fabro-model = { path = "../../foundation/fabro-model" } fabro-core = { path = "../../foundation/fabro-core" } fabro-store = { path = "../fabro-store" } fabro-static.workspace = true @@ -75,6 +74,7 @@ tempfile = "3" toml.workspace = true fabro-vault = { path = "../../foundation/fabro-vault" } [dev-dependencies] +fabro-llm = { path = "../fabro-llm", features = ["test-support"] } fabro-store = { path = "../fabro-store", features = ["test-support"] } fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] } fabro-github = { path = "../fabro-github", features = ["test-support"] } diff --git a/lib/components/fabro-workflow/src/billing_rollup.rs b/lib/components/fabro-workflow/src/billing_rollup.rs index 46056eace..9414a96a3 100644 --- a/lib/components/fabro-workflow/src/billing_rollup.rs +++ b/lib/components/fabro-workflow/src/billing_rollup.rs @@ -5,10 +5,9 @@ pub use fabro_types::billing_rollup::{ #[cfg(test)] mod tests { - use fabro_model::{Catalog, ModelRef, ProviderId}; use fabro_types::{ - AttrValue, BilledTokenCounts, Graph, Node, RunProjection, RunSpec, StageCompletion, - StageOutcome, first_event_seq, test_support, + AttrValue, BilledTokenCounts, Graph, ModelId, ModelRef, Node, RunProjection, RunSpec, + StageCompletion, StageOutcome, first_event_seq, provider_ids, test_support, }; use super::billing_rollup_from_projection; @@ -50,7 +49,7 @@ mod tests { timestamp: chrono::Utc::now(), }); - let rollup = billing_rollup_from_projection(&projection, None); + let rollup = billing_rollup_from_projection(&projection); assert_eq!(rollup.stages.len(), 1); assert_eq!(rollup.stages[0].node_id, "verify"); @@ -73,10 +72,10 @@ mod tests { assert_eq!(rollup.billed_visit_count, 2); assert_eq!(rollup.by_model.len(), 2); - assert_eq!(rollup.by_model[0].model.model_id, "gpt-new"); + assert_eq!(rollup.by_model[0].model.model_id.as_str(), "gpt-new"); assert_eq!(rollup.by_model[0].stages, 1); assert_eq!(rollup.by_model[0].billing.input_tokens, 200); - assert_eq!(rollup.by_model[1].model.model_id, "gpt-old"); + assert_eq!(rollup.by_model[1].model.model_id.as_str(), "gpt-old"); assert_eq!(rollup.by_model[1].stages, 1); assert_eq!(rollup.by_model[1].billing.input_tokens, 100); } @@ -93,7 +92,7 @@ mod tests { timestamp: chrono::Utc::now(), }); - let rollup = billing_rollup_from_projection(&projection, None); + let rollup = billing_rollup_from_projection(&projection); assert_eq!(rollup.stages.len(), 1); assert_eq!(rollup.stages[0].node_id, "build"); @@ -126,20 +125,16 @@ mod tests { timestamp: chrono::Utc::now(), }); - let rollup = billing_rollup_from_projection(&projection, None); + let rollup = billing_rollup_from_projection(&projection); assert_eq!(rollup.stages.len(), 0); assert_eq!(rollup.timing.wall_time_ms, 0); } #[test] - fn rollup_prices_in_flight_stage_usage_using_catalog() { + fn rollup_keeps_in_flight_stage_usage_unpriced() { let mut projection = test_projection(); - let model = ModelRef { - provider: ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: None, - }; + let model = ModelRef::new(provider_ids::openai(), ModelId::new("gpt-5.4")); let stage = projection.stage_entry("agent", 1, first_event_seq(1)); stage.started_at = Some(chrono::Utc::now()); stage.usage = BilledTokenCounts { @@ -150,22 +145,18 @@ mod tests { }; stage.model = Some(model.clone()); - let priced = billing_rollup_from_projection(&projection, Some(Catalog::builtin())); - let unpriced = billing_rollup_from_projection(&projection, None); + let rollup = billing_rollup_from_projection(&projection); - assert_eq!(priced.stages.len(), 1); - assert_eq!(priced.stages[0].node_id, "agent"); - let stage_cost = priced.stages[0].billing.total_usd_micros; - assert!( - stage_cost.is_some_and(|cost| cost > 0), - "expected priced stage cost, got {stage_cost:?}" - ); - assert_eq!(priced.totals.total_usd_micros, stage_cost); - assert_eq!(priced.by_model.len(), 1); - assert_eq!(priced.by_model[0].billing.total_usd_micros, stage_cost); - assert_eq!(unpriced.stages.len(), 1); - assert_eq!(unpriced.stages[0].billing.total_usd_micros, None); - assert_eq!(unpriced.totals.total_usd_micros, None); + // The rollup keeps the shape of what the events recorded. Costs come + // from the events themselves; an in-flight stage that has recorded no + // cost yet stays unpriced rather than being re-estimated here. + assert_eq!(rollup.stages.len(), 1); + assert_eq!(rollup.stages[0].node_id, "agent"); + assert_eq!(rollup.stages[0].billing.total_usd_micros, None); + assert_eq!(rollup.stages[0].billing.input_tokens, 500_000); + assert_eq!(rollup.totals.total_usd_micros, None); + assert_eq!(rollup.by_model.len(), 1); + assert_eq!(rollup.by_model[0].billing.input_tokens, 500_000); } fn run_spec_with_boundary_nodes() -> RunSpec { diff --git a/lib/components/fabro-workflow/src/error.rs b/lib/components/fabro-workflow/src/error.rs index 7bedbda70..d0b7eae10 100644 --- a/lib/components/fabro-workflow/src/error.rs +++ b/lib/components/fabro-workflow/src/error.rs @@ -2,8 +2,7 @@ use std::fmt; use std::sync::{Arc, LazyLock}; use fabro_graphviz::Error as GraphvizError; -use fabro_llm::{Error as LlmError, ProviderErrorKind}; -use fabro_model::ModelSelectionError; +use fabro_llm::{ErrorFacts, ErrorKind, LlmError, ModelSelectionError}; use fabro_template::TemplateError; pub use fabro_types::failure_signature::FailureSignature; pub use fabro_types::outcome::FailureCategory; @@ -19,30 +18,21 @@ use crate::outcome::{FailureDetail, Outcome, StageOutcome}; /// Classify an LLM error into a `FailureCategory` based on its structure. #[must_use] -pub fn classify_sdk_error(err: &LlmError) -> FailureCategory { - match err { - LlmError::Provider { kind, .. } => match kind { - ProviderErrorKind::RateLimit | ProviderErrorKind::Server => { - FailureCategory::TransientInfra - } - ProviderErrorKind::ContextLength | ProviderErrorKind::QuotaExceeded => { - FailureCategory::BudgetExhausted - } - ProviderErrorKind::Authentication - | ProviderErrorKind::AccessDenied - | ProviderErrorKind::NotFound - | ProviderErrorKind::InvalidRequest - | ProviderErrorKind::ContentFilter => FailureCategory::Deterministic, - }, - LlmError::RequestTimeout { .. } | LlmError::Network { .. } | LlmError::Stream { .. } => { - FailureCategory::TransientInfra - } - LlmError::Interrupt { .. } => FailureCategory::Canceled, - LlmError::InvalidToolCall { .. } - | LlmError::NoObjectGenerated { .. } - | LlmError::InvalidRequest { .. } - | LlmError::Configuration { .. } - | LlmError::UnsupportedToolChoice { .. } => FailureCategory::Deterministic, +pub fn classify_sdk_error(err: &E) -> FailureCategory { + match err.kind() { + ErrorKind::RateLimit + | ErrorKind::Server + | ErrorKind::Network + | ErrorKind::Timeout + | ErrorKind::StreamDecode => FailureCategory::TransientInfra, + ErrorKind::ContextLength | ErrorKind::QuotaExceeded => FailureCategory::BudgetExhausted, + ErrorKind::Cancelled => FailureCategory::Canceled, + // Configuration, model selection, auth, access, not-found, invalid + // request, content filter, provider, decode, resource limit, and + // middleware failures are deterministic. `ErrorKind` is + // non-exhaustive: a category added by a newer lithos never enables + // automatic retry either. + _ => FailureCategory::Deterministic, } } @@ -534,7 +524,7 @@ impl Error { Self::Stage { stage, .. } => { matches!(stage, ErrorStage::Handler | ErrorStage::Engine) } - Self::Llm(sdk_err) => sdk_err.retryable(), + Self::Llm(sdk_err) => sdk_err.is_retryable(), Self::Parse(_) | Self::Validation(_) | Self::ValidationFailed { .. } @@ -700,6 +690,12 @@ impl From for Error { } } +impl From for Error { + fn from(err: fabro_llm::Error) -> Self { + Self::Llm(LlmError::from(err)) + } +} + impl From for Error { fn from(e: GraphvizError) -> Self { match e { @@ -748,9 +744,25 @@ pub type Result = std::result::Result; #[cfg(test)] mod tests { use fabro_checkpoint::MetadataError; - use fabro_llm::{Error as SdkError, ProviderErrorDetail}; + use fabro_llm::RetryClassification; use super::*; + + /// A stored LLM error of `kind` from the `openai` provider. + fn sdk_error(kind: ErrorKind, message: &str) -> LlmError { + LlmError::from( + fabro_llm::Error::new(kind, message).with_provider(fabro_types::provider_ids::openai()), + ) + } + + /// A transient failure the provider may be asked to repeat. + fn transient_error(kind: ErrorKind, message: &str) -> LlmError { + LlmError::from( + fabro_llm::Error::new(kind, message) + .with_provider(fabro_types::provider_ids::openai()) + .with_retry(RetryClassification::Safe), + ) + } use crate::outcome::OutcomeExt; #[derive(Debug)] @@ -1144,38 +1156,23 @@ mod tests { #[test] fn llm_error_display() { - let sdk_err = SdkError::Network { - message: "connection refused".into(), - source: None, - }; + let sdk_err = transient_error(ErrorKind::Network, "connection refused"); let err = Error::Llm(sdk_err); - assert_eq!( - err.to_string(), - "LLM error: Network error: connection refused" - ); + assert_eq!(err.to_string(), "LLM error: connection refused"); } #[test] fn llm_error_retryable_delegates_to_sdk() { - let retryable = Error::Llm(SdkError::Network { - message: "timeout".into(), - source: None, - }); + let retryable = Error::Llm(transient_error(ErrorKind::Network, "timeout")); assert!(retryable.is_retryable()); - let non_retryable = Error::Llm(SdkError::Configuration { - message: "bad config".into(), - source: None, - }); + let non_retryable = Error::Llm(sdk_error(ErrorKind::Configuration, "bad config")); assert!(!non_retryable.is_retryable()); } #[test] fn llm_error_from_sdk_error() { - let sdk_err = SdkError::Stream { - message: "broken pipe".into(), - source: None, - }; + let sdk_err = transient_error(ErrorKind::StreamDecode, "broken pipe"); let err = Error::from(sdk_err); assert!(matches!(err, Error::Llm(_))); } @@ -1224,45 +1221,31 @@ mod tests { #[test] fn failure_class_llm_rate_limit() { - let err = Error::Llm(SdkError::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail::new("too fast", "openai")), - }); + let err = Error::Llm(transient_error(ErrorKind::RateLimit, "too fast")); assert_eq!(err.failure_category(), FailureCategory::TransientInfra); } #[test] fn failure_class_llm_context_length() { - let err = Error::Llm(SdkError::Provider { - kind: ProviderErrorKind::ContextLength, - detail: Box::new(ProviderErrorDetail::new("too long", "openai")), - }); + let err = Error::Llm(sdk_error(ErrorKind::ContextLength, "too long")); assert_eq!(err.failure_category(), FailureCategory::BudgetExhausted); } #[test] fn failure_class_llm_auth() { - let err = Error::Llm(SdkError::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail::new("bad key", "openai")), - }); + let err = Error::Llm(sdk_error(ErrorKind::Authentication, "bad key")); assert_eq!(err.failure_category(), FailureCategory::Deterministic); } #[test] fn failure_class_llm_abort() { - let err = Error::Llm(SdkError::Interrupt { - message: "user cancelled".into(), - }); + let err = Error::Llm(sdk_error(ErrorKind::Cancelled, "user cancelled")); assert_eq!(err.failure_category(), FailureCategory::Canceled); } #[test] fn failure_class_llm_timeout() { - let err = Error::Llm(SdkError::RequestTimeout { - message: "timed out".into(), - source: None, - }); + let err = Error::Llm(transient_error(ErrorKind::Timeout, "timed out")); assert_eq!(err.failure_category(), FailureCategory::TransientInfra); } @@ -1270,79 +1253,55 @@ mod tests { #[test] fn classify_sdk_rate_limit() { - let err = SdkError::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail::new("too fast", "openai")), - }; + let err = transient_error(ErrorKind::RateLimit, "too fast"); assert_eq!(classify_sdk_error(&err), FailureCategory::TransientInfra); } #[test] fn classify_sdk_server() { - let err = SdkError::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail::new("500", "openai")), - }; + let err = transient_error(ErrorKind::Server, "500"); assert_eq!(classify_sdk_error(&err), FailureCategory::TransientInfra); } #[test] fn classify_sdk_context_length() { - let err = SdkError::Provider { - kind: ProviderErrorKind::ContextLength, - detail: Box::new(ProviderErrorDetail::new("too long", "openai")), - }; + let err = sdk_error(ErrorKind::ContextLength, "too long"); assert_eq!(classify_sdk_error(&err), FailureCategory::BudgetExhausted); } #[test] fn classify_sdk_quota_exceeded() { - let err = SdkError::Provider { - kind: ProviderErrorKind::QuotaExceeded, - detail: Box::new(ProviderErrorDetail::new("out of quota", "openai")), - }; + let err = sdk_error(ErrorKind::QuotaExceeded, "out of quota"); assert_eq!(classify_sdk_error(&err), FailureCategory::BudgetExhausted); } #[test] fn classify_sdk_auth() { - let err = SdkError::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail::new("bad key", "openai")), - }; + let err = sdk_error(ErrorKind::Authentication, "bad key"); assert_eq!(classify_sdk_error(&err), FailureCategory::Deterministic); } #[test] fn classify_sdk_request_timeout() { - let err = SdkError::RequestTimeout { - message: "timed out".into(), - source: None, - }; + let err = transient_error(ErrorKind::Timeout, "timed out"); assert_eq!(classify_sdk_error(&err), FailureCategory::TransientInfra); } #[test] fn classify_sdk_abort() { - let err = SdkError::Interrupt { - message: "cancelled".into(), - }; + let err = sdk_error(ErrorKind::Cancelled, "cancelled"); assert_eq!(classify_sdk_error(&err), FailureCategory::Canceled); } #[test] fn classify_sdk_invalid_tool_call() { - let err = SdkError::InvalidToolCall { - message: "bad tool".into(), - }; + let err = sdk_error(ErrorKind::InvalidRequest, "bad tool"); assert_eq!(classify_sdk_error(&err), FailureCategory::Deterministic); } #[test] fn classify_sdk_invalid_request() { - let err = SdkError::InvalidRequest { - message: "unsupported reasoning effort".into(), - }; + let err = sdk_error(ErrorKind::InvalidRequest, "unsupported reasoning effort"); assert_eq!(classify_sdk_error(&err), FailureCategory::Deterministic); } @@ -1982,10 +1941,7 @@ mod tests { #[test] fn failure_signature_hint_llm_returns_some() { - let err = Error::Llm(SdkError::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail::new("bad key", "openai")), - }); + let err = Error::Llm(sdk_error(ErrorKind::Authentication, "bad key")); assert_eq!( err.failure_signature_hint(), Some(FailureSignature( @@ -2010,10 +1966,7 @@ mod tests { #[test] fn to_fail_outcome_llm_has_class_and_signature() { - let err = Error::Llm(SdkError::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail::new("bad key", "openai")), - }); + let err = Error::Llm(sdk_error(ErrorKind::Authentication, "bad key")); let outcome = err.to_fail_outcome(); assert_eq!(outcome.status, crate::outcome::StageOutcome::Failed { retry_requested: false, @@ -2040,10 +1993,7 @@ mod tests { #[test] fn to_fail_outcome_includes_error_message_as_reason() { - let err = Error::Llm(SdkError::Network { - message: "connection refused".into(), - source: None, - }); + let err = Error::Llm(transient_error(ErrorKind::Network, "connection refused")); let outcome = err.to_fail_outcome(); assert!( outcome @@ -2055,10 +2005,7 @@ mod tests { #[test] fn to_fail_outcome_no_context_updates() { - let err = Error::Llm(SdkError::Network { - message: "refused".into(), - source: None, - }); + let err = Error::Llm(transient_error(ErrorKind::Network, "refused")); let outcome = err.to_fail_outcome(); assert!(outcome.context_updates.is_empty()); } @@ -2110,10 +2057,7 @@ mod tests { Error::engine("engine err"), Error::publish("publish err"), Error::handler("handler err"), - Error::Llm(SdkError::Network { - message: "refused".into(), - source: None, - }), + Error::Llm(transient_error(ErrorKind::Network, "refused")), Error::Checkpoint("cp err".into()), Error::Stylesheet("style err".into()), Error::Io("io err".into()), @@ -2220,10 +2164,7 @@ mod tests { use crate::event::Event; // 1. Create SdkError → Error - let sdk_err = SdkError::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail::new("too fast", "openai")), - }; + let sdk_err = transient_error(ErrorKind::RateLimit, "too fast"); let arc_err = Error::Llm(sdk_err); assert_eq!(arc_err.failure_category(), FailureCategory::TransientInfra); @@ -2295,10 +2236,7 @@ mod tests { fn e2e_serde_stability_agent_error() { use fabro_agent::Error as AgentError; - let err = AgentError::Llm(SdkError::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail::new("too fast", "openai")), - }); + let err = AgentError::Llm(transient_error(ErrorKind::RateLimit, "too fast")); let json = serde_json::to_string(&err).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v["type"], "llm"); diff --git a/lib/components/fabro-workflow/src/event/convert.rs b/lib/components/fabro-workflow/src/event/convert.rs index f89d53bf6..da235b8ac 100644 --- a/lib/components/fabro-workflow/src/event/convert.rs +++ b/lib/components/fabro-workflow/src/event/convert.rs @@ -1,9 +1,8 @@ use ::fabro_types::{ - EventBody, RunControlAction, RunEvent, RunId, StageOutcome, run_event as fabro_types, + EventBody, RunControlAction, RunEvent, RunId, StageOutcome, UsdMicros, run_event as fabro_types, }; use chrono::Utc; use fabro_agent::{AgentEvent, SandboxEvent, SkillActivationSource}; -use fabro_model::UsdMicros; use uuid::Uuid; use super::Event; @@ -658,19 +657,18 @@ fn event_body_from_event(event: &Event) -> EventBody { text, model, usage, - cost_usd, - cost_source, + cost, tool_call_count, context_window, reasoning, } => { - let billing = billed_token_counts_from_llm(usage) - .with_reported_cost(cost_usd.map(UsdMicros::from_usd)); + let billing = billed_token_counts_from_llm(*usage) + .with_reported_cost(cost.as_ref().map(UsdMicros::from_cost)); EventBody::AgentMessage(fabro_types::AgentMessageProps { text: text.clone(), model: model.clone(), billing, - cost_source: *cost_source, + cost_source: cost.map(|cost| cost.source), tool_call_count: *tool_call_count, visit: *visit, message: None, @@ -1463,17 +1461,16 @@ mod tests { use std::collections::BTreeMap; use ::fabro_types::{ - AutomationRef, EventBody, FailureReason, ParallelBranchId, Principal, RunNoticeCode, - RunNoticeLevel, RunProvenance, StageId, SystemActorKind, fixtures, - run_event as fabro_types, test_support, + AutomationRef, EventBody, FailureReason, ModelId, ModelRef, ParallelBranchId, Principal, + ProviderId, RunNoticeCode, RunNoticeLevel, RunProvenance, StageId, SystemActorKind, + TokenCounts as LlmTokenCounts, fixtures, provider_ids, run_event as fabro_types, + test_support, }; use chrono::Utc; use fabro_agent::{ AgentEvent, McpToolSummary, MemoryFileSummary, SandboxEvent, SkillActivationSource, SkillSummary, }; - use fabro_llm::types::TokenCounts as LlmTokenCounts; - use fabro_model::{ModelRef, ProviderId}; use super::*; use crate::error::Error; @@ -2525,14 +2522,12 @@ mod tests { visit: 1, event: AgentEvent::AssistantMessage { text: "ok".to_string(), - model: ModelRef { - provider: ProviderId::anthropic(), - model_id: "claude-sonnet".into(), - speed: None, - }, + model: ModelRef::new( + provider_ids::anthropic(), + ModelId::new("claude-sonnet"), + ), usage: LlmTokenCounts::default(), - cost_usd: None, - cost_source: None, + cost: None, tool_call_count: 0, context_window: None, reasoning: None, @@ -2556,18 +2551,16 @@ mod tests { visit: 1, event: AgentEvent::AssistantMessage { text: "ok".to_string(), - model: ModelRef { - provider: ProviderId::new("custom_proxy"), - model_id: "proxy-model".into(), - speed: None, - }, + model: ModelRef::new( + ProviderId::new("custom_proxy"), + ModelId::new("proxy-model"), + ), usage: LlmTokenCounts { - input_tokens: 12, - output_tokens: 34, + input: 12, + output: 34, ..LlmTokenCounts::default() }, - cost_usd: None, - cost_source: None, + cost: None, tool_call_count: 0, context_window: None, reasoning: None, @@ -2581,7 +2574,7 @@ mod tests { panic!("expected agent message body"); }; assert_eq!(message.model.provider, ProviderId::new("custom_proxy")); - assert_eq!(message.model.model_id, "proxy-model"); + assert_eq!(message.model.model_id.as_str(), "proxy-model"); assert_eq!(message.billing.input_tokens, 12); assert_eq!(message.billing.output_tokens, 34); assert_eq!(message.billing.total_usd_micros, None); @@ -2594,18 +2587,20 @@ mod tests { visit: 1, event: AgentEvent::AssistantMessage { text: "ok".to_string(), - model: ModelRef { - provider: ProviderId::new("openrouter"), - model_id: "openai/gpt-5.4".into(), - speed: None, - }, + model: ModelRef::new( + ProviderId::new("openrouter"), + ModelId::new("openai/gpt-5.4"), + ), usage: LlmTokenCounts { - input_tokens: 12, - output_tokens: 34, + input: 12, + output: 34, ..LlmTokenCounts::default() }, - cost_usd: Some(0.125), - cost_source: Some(fabro_model::CostSource::Authoritative), + cost: Some(::fabro_types::Cost { + usd_micros: 125_000, + + source: ::fabro_types::CostSource::Provider, + }), tool_call_count: 0, context_window: None, reasoning: None, @@ -2621,7 +2616,7 @@ mod tests { assert_eq!(message.billing.total_usd_micros, Some(125_000)); assert_eq!( message.cost_source, - Some(fabro_model::CostSource::Authoritative) + Some(::fabro_types::CostSource::Provider) ); } @@ -2649,14 +2644,9 @@ mod tests { visit: 1, event: AgentEvent::AssistantMessage { text: "ok".to_string(), - model: ModelRef { - provider: ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: None, - }, + model: ModelRef::new(provider_ids::openai(), ModelId::new("gpt-5.4")), usage: LlmTokenCounts::default(), - cost_usd: None, - cost_source: None, + cost: None, tool_call_count: 0, context_window: Some(context_window), reasoning: None, @@ -2684,14 +2674,9 @@ mod tests { visit: 1, event: AgentEvent::AssistantMessage { text: String::new(), - model: ModelRef { - provider: ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: None, - }, + model: ModelRef::new(provider_ids::openai(), ModelId::new("gpt-5.4")), usage: LlmTokenCounts::default(), - cost_usd: None, - cost_source: None, + cost: None, tool_call_count: 1, context_window: None, reasoning: Some(::fabro_types::ReasoningOutput::new( diff --git a/lib/components/fabro-workflow/src/event/events.rs b/lib/components/fabro-workflow/src/event/events.rs index f23bc5c35..f8adcb0ca 100644 --- a/lib/components/fabro-workflow/src/event/events.rs +++ b/lib/components/fabro-workflow/src/event/events.rs @@ -4,13 +4,12 @@ use ::fabro_types::{ AutomationRef, BilledTokenCounts, BlobHash, BlockedReason, CommandTermination, DiffSummary, FailureReason, ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, PairTarget, ParallelBranchId, ParallelBranchResult, PendingReason, PermissionLevel, Principal, - PullRequestCreationId, PullRequestLink, ReviewTarget, RunFailure, RunId, RunNoticeLevel, - RunPairEndedReason, RunPairFailedReason, RunProvenance, RunRunnableSource, RunTarget, - RunTiming, SandboxProviderKind, StageId, StageOutcome, StageTiming, SuccessReason, - WorkflowVersionId, run_event as fabro_types, + PullRequestCreationId, PullRequestLink, ReasoningEffort, ReviewTarget, RunFailure, RunId, + RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunProvenance, RunRunnableSource, + RunTarget, RunTiming, SandboxProviderKind, Speed, StageId, StageOutcome, StageTiming, + SuccessReason, WorkflowVersionId, run_event as fabro_types, }; use fabro_agent::{AgentEvent, SandboxEvent}; -use fabro_model::{ReasoningEffort, Speed}; use serde::{Deserialize, Serialize}; use crate::error::{Error, run_failure_from_error}; diff --git a/lib/components/fabro-workflow/src/event/redaction.rs b/lib/components/fabro-workflow/src/event/redaction.rs index 339f1edd3..9cfe29ee7 100644 --- a/lib/components/fabro-workflow/src/event/redaction.rs +++ b/lib/components/fabro-workflow/src/event/redaction.rs @@ -30,10 +30,11 @@ pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result RegisteredTool { let name = definition.name.to_string(); RegisteredTool { - definition: LlmToolDefinition { - name: name.clone(), - description: definition.description.to_string(), - parameters: definition.parameters.clone(), - }, + definition: LlmToolDefinition::function( + name.clone(), + definition.description.to_string(), + definition.parameters.clone(), + ), executor: Arc::new(move |args, _context: ToolContext| { let name = name.clone(); let services = services.clone(); @@ -394,40 +392,37 @@ fn control_attr<'a>(node: &'a Node, key: &str) -> Option<&'a str> { } fn parse_reasoning_effort(node: &Node, value: &str) -> Result { - value.parse::().map_err(|source| { - Error::handler_with_source( - format!( - "Invalid reasoning_effort \"{value}\" for node \"{}\"; expected one of: {}", - node.id, - expected_values(ReasoningEffort::variants()), + value.parse().map_err(|_| { + Error::handler(format!( + "Invalid reasoning_effort \"{value}\" for node \"{}\"; expected one of: {}", + node.id, + expected_values( + ReasoningEffort::ALL + .into_iter() + .map(ReasoningEffort::as_str) ), - source, - ) + )) }) } fn parse_speed(node: &Node, value: &str) -> Result { - value.parse::().map_err(|source| { - Error::handler_with_source( - format!( - "Invalid speed \"{value}\" for node \"{}\"; expected one of: {}", - node.id, - expected_values(Speed::variants()), - ), - source, - ) + value.parse().map_err(|_| { + Error::handler(format!( + "Invalid speed \"{value}\" for node \"{}\"; expected one of: {}", + node.id, + expected_values(Speed::ALL.into_iter().map(Speed::as_str)), + )) }) } -fn expected_values(values: &[T]) -> String -where - T: ToString, -{ - values - .iter() - .map(ToString::to_string) - .collect::>() - .join(", ") +fn expected_values<'a>(values: impl Iterator) -> String { + values.collect::>().join(", ") +} + +/// Node-level `max_tokens`, as the client's `u32` output budget. +fn node_max_output_tokens(node: &Node) -> Option { + node.max_tokens() + .and_then(|tokens| u32::try_from(tokens).ok()) } /// Shared state for tracking file modifications from agent tool calls. @@ -760,7 +755,7 @@ impl LiveAgentInvocation { error: fabro_agent::Error, allow_failover: bool, emitter: &Arc, - ) -> Result { + ) -> Result { let disposition = classify_agent_error(error, allow_failover); self.abort_and_discard(emitter).await; match disposition { @@ -778,7 +773,7 @@ impl LiveAgentInvocation { async fn record_input_usage(&mut self) { self.event_forwarder.wait_for_processing_end().await; - self.total_usage += self.session.last_input_usage(); + billing::add_usage(&mut self.total_usage, self.session.last_input_usage()); UsdMicros::accumulate(&mut self.total_cost, self.session.last_input_cost()); } } @@ -792,7 +787,7 @@ impl AgentApiBackend { source: Arc, steering_hub: Arc, ) -> Self { - let catalog = Arc::new(Catalog::from_builtin().expect("default catalog should build")); + let catalog = Arc::new(fabro_llm::default_catalog()); Self::new_with_catalog( model, provider_id.into(), @@ -893,22 +888,33 @@ impl AgentApiBackend { let Some(requested_effort) = requested.reasoning_effort else { return FallbackControls::Usable(requested); }; - let Some(offering) = self - .catalog - .get_on_provider(&target.provider, target.model.as_str()) - else { + let Some(offering) = catalog::model_on_provider( + &self.catalog, + target.provider.as_str(), + target.model.as_str(), + ) else { // A catalog-unknown passthrough target has no advertised controls. // Preserve the request and let the provider validate it. return FallbackControls::Usable(requested); }; - let effective_effort = self.catalog.settings_for(offering).and_then(|settings| { - requested_effort.closest_supported(&settings.controls.reasoning_effort) + let capabilities = offering.model.capabilities(); + let effective_effort = controls::closest_supported_effort(requested_effort, |effort| { + capabilities.reasoning_effort(effort).is_supported() }); match effective_effort { Some(effort) => FallbackControls::Usable(EffectiveRequestControls { reasoning_effort: Some(effort), speed: requested.speed, }), + // No level is verified. Unless the requested one is verified + // unsupported, preserve it and let the provider validate, as for + // a passthrough target. + None if !capabilities + .reasoning_effort(requested_effort) + .is_unsupported() => + { + FallbackControls::Usable(requested) + } None => FallbackControls::NoNearbyReasoningLevel(requested_effort), } } @@ -919,7 +925,7 @@ impl AgentApiBackend { provider: &ProviderId, requested_controls: EffectiveRequestControls, ) -> (FallbackPlan, Vec) { - let primary_model = self.catalog.canonical_model_id(provider, model); + let primary_model = catalog::canonical_model_id(&self.catalog, provider, model); let original = LlmRoute { target: FallbackTarget::new(provider, &primary_model), controls: requested_controls, @@ -1021,7 +1027,7 @@ impl AgentApiBackend { route.controls, node, sandbox, - self.source.as_ref(), + Arc::clone(&self.source), Arc::clone(&self.catalog), self.tool_env.as_ref(), tool_hooks, @@ -1045,7 +1051,7 @@ impl AgentApiBackend { controls: EffectiveRequestControls, node: &Node, sandbox: &Arc, - source: &dyn CredentialSource, + source: Arc, catalog: Arc, tool_env: Option<&Arc>, tool_hooks: Option>, @@ -1053,9 +1059,7 @@ impl AgentApiBackend { tool_secrets: ToolSecrets, fabro_run_tools: Option, ) -> Result { - let client = Client::from_source(source, Arc::clone(&catalog)) - .await - .map_err(|e| Error::handler_with_source("Failed to create LLM client", e))?; + let client = build_llm_client(&catalog, source).await?; let profile_builder = AgentProfileBuilder::new( provider.profile_kind, @@ -1067,10 +1071,7 @@ impl AgentApiBackend { let profile_builder = if provider.profile_kind == AgentProfileKind::Claude5 { profile_builder.with_web_fetch_summarizer(Some(WebFetchSummarizer { client: client.clone(), - model_id: ModelHandle::ByName { - provider: provider.provider_id.clone(), - model: model.to_string(), - }, + model_id: ModelHandle::new(provider.provider_id.clone(), ModelId::new(model)), })) } else { profile_builder @@ -1078,7 +1079,7 @@ impl AgentApiBackend { let mut profile = profile_builder.build(); let config = SessionOptions { - max_tokens: node.max_tokens(), + max_tokens: node_max_output_tokens(node), reasoning_effort: controls.reasoning_effort, speed: controls.speed, tool_hooks, @@ -1196,7 +1197,7 @@ impl AgentApiBackend { async fn failover_agent_session( &self, fallback_plan: &mut FallbackPlan, - initial_error: fabro_llm::Error, + initial_error: LlmError, request: &CodergenRunRequest<'_>, input: &str, stage_scope: &StageScope, @@ -1236,7 +1237,7 @@ impl AgentApiBackend { route.controls, request.node, request.sandbox, - self.source.as_ref(), + Arc::clone(&self.source), Arc::clone(&self.catalog), self.tool_env.as_ref(), request.tool_hooks.clone(), @@ -1368,11 +1369,15 @@ impl AgentApiBackend { ); } - fn route_max_tokens(&self, node: &Node, route: &LlmRoute) -> Option { - node.max_tokens().or_else(|| { - self.catalog - .get_on_provider(&route.target.provider, route.target.model.as_str()) - .and_then(|model| model.limits.max_output) + fn route_max_tokens(&self, node: &Node, route: &LlmRoute) -> Option { + node_max_output_tokens(node).or_else(|| { + catalog::model_on_provider( + &self.catalog, + route.target.provider.as_str(), + route.target.model.as_str(), + ) + .and_then(|entry| entry.model.limits()) + .map(|limits| u32::try_from(limits.max_output_tokens).unwrap_or(u32::MAX)) }) } @@ -1383,23 +1388,27 @@ impl AgentApiBackend { route: &LlmRoute, messages: Vec, response_format: Option, - ) -> Request { - Request { - model: route.target.model.to_string(), - messages, - provider: Some(route.target.provider.to_string()), - tools: None, - tool_choice: None, - response_format, - temperature: None, - top_p: None, - max_tokens: self.route_max_tokens(node, route), - stop_sequences: None, - reasoning_effort: route.controls.reasoning_effort, - speed: route.controls.speed, - metadata: None, - provider_options: None, + ) -> Result { + let mut builder = + Request::builder().model(format!("{}/{}", route.target.provider, route.target.model)); + for message in messages { + builder = builder.message(message); } + if let Some(format) = response_format { + builder = builder.response_format(format); + } + if let Some(max_tokens) = self.route_max_tokens(node, route) { + builder = builder.max_output_tokens(max_tokens); + } + if let Some(effort) = route.controls.reasoning_effort { + builder = builder.reasoning_effort(effort); + } + if let Some(speed) = route.controls.speed { + builder = builder.speed(speed); + } + builder + .build() + .map_err(|err| Error::handler(format!("invalid LLM request: {err}"))) } async fn complete_one_shot_request( @@ -1412,35 +1421,46 @@ impl AgentApiBackend { plan: &mut FallbackPlan, ) -> Result { loop { - match client.complete(&request).await { + match client.complete(request.clone()).await { Ok(response) => { let route = plan.current(); return Ok(OneShotCompletion { response, - model: ModelRef { - provider: route.target.provider.clone(), - model_id: route.target.model.clone(), - speed: route.controls.speed, - }, + model: ModelRef::new( + route.target.provider.clone(), + route.target.model.clone(), + ) + .with_speed(route.controls.speed), }); } - Err(error) if error.failover_eligible() && plan.has_next() => { + Err(error) if failover_eligible(&error) && plan.has_next() => { let error_message = error.to_string(); plan.advance(); Self::emit_failover(node, emitter, stage_scope, plan, &error_message); request = self.route_request( node, plan.current(), - request.messages, - request.response_format, - ); + request.messages().to_vec(), + request.response_format().cloned(), + )?; } - Err(error) => return Err(Error::Llm(error)), + Err(error) => return Err(Error::Llm(LlmError::from(error))), } } } } +/// Build the LLM client a stage session dispatches through. +async fn build_llm_client( + catalog: &Arc, + source: Arc, +) -> Result { + fabro_llm::build_client(Catalog::clone(catalog), source, ClientOptions::standard()) + .await + .map(|built| built.client) + .map_err(|e| Error::handler_with_source("Failed to create LLM client", e)) +} + #[async_trait] impl CodergenBackend for AgentApiBackend { async fn shutdown(&self, emitter: &Arc) { @@ -1458,9 +1478,7 @@ impl CodergenBackend for AgentApiBackend { let emitter = request.emitter; let stage_scope = request.stage_scope; - let client = Client::from_source(self.source.as_ref(), Arc::clone(&self.catalog)) - .await - .map_err(|e| Error::handler_with_source("Failed to create LLM client", e))?; + let client = build_llm_client(&self.catalog, Arc::clone(&self.source)).await?; let model = node.model().unwrap_or(&self.model); let provider = self.resolve_provider_context(model, node.provider())?; @@ -1471,9 +1489,9 @@ impl CodergenBackend for AgentApiBackend { let mut messages = Vec::new(); if let Some(sys) = system_prompt { - messages.push(Message::system(sys)); + messages.push(Message::text(Role::System, sys)); } - messages.push(Message::user(prompt)); + messages.push(Message::text(Role::User, prompt)); let output_schema = structured_output::parse_node_output_schema(node)?; let response_format = output_schema @@ -1491,7 +1509,7 @@ impl CodergenBackend for AgentApiBackend { fallback_plan.current(), messages.clone(), response_format.clone(), - ); + )?; let inference_start = Instant::now(); let completion_result = self @@ -1506,10 +1524,10 @@ impl CodergenBackend for AgentApiBackend { .await; inference_duration = inference_duration.saturating_add(inference_start.elapsed()); let completion = completion_result?; - total_usage += completion.response.usage.clone(); + billing::add_usage(&mut total_usage, completion.response.usage); UsdMicros::accumulate( &mut total_cost, - completion.response.cost_usd.map(UsdMicros::from_usd), + completion.response.cost.as_ref().map(UsdMicros::from_cost), ); let response_text = completion.response.text(); @@ -1531,18 +1549,15 @@ impl CodergenBackend for AgentApiBackend { let repair_message = error.repair_message(schema, previous_validation_error.as_ref()); previous_validation_error = Some(error); - messages.push(Message::assistant(response_text)); - messages.push(Message::user(repair_message)); + messages.push(Message::text(Role::Assistant, response_text)); + messages.push(Message::text(Role::User, repair_message)); repair_attempts += 1; continue; } - let stage_usage = billed_model_usage_from_llm( - self.catalog.as_ref(), - &completion.model, - &total_usage, - )? - .with_reported_cost(total_cost); + let stage_usage = + billed_model_usage_from_llm(self.catalog.as_ref(), &completion.model, total_usage)? + .with_reported_cost(total_cost); return Ok(CodergenResult::Text { text: response_text, @@ -1570,8 +1585,8 @@ impl CodergenBackend for AgentApiBackend { }; // Take a cached session if reusing, otherwise create a new one. Cancel - // checks bracket `Client::from_source(...)` so cancellation arriving - // during credential refresh is not lost. + // checks bracket the client build so cancellation arriving during + // credential refresh is not lost. if request.cancel_token.is_cancelled() { return Err(Error::Cancelled); } @@ -1784,12 +1799,12 @@ impl CodergenBackend for AgentApiBackend { let stage_usage = billed_model_usage_from_llm( self.catalog.as_ref(), - &ModelRef { - provider: live.session.provider_id(), - model_id: live.session.model().into(), - speed: live.session.speed(), - }, - &live.total_usage, + &ModelRef::new( + live.session.provider_id(), + ModelId::new(live.session.model()), + ) + .with_speed(live.session.speed()), + live.total_usage, )? .with_reported_cost(live.total_cost); @@ -1883,13 +1898,15 @@ mod tests { use fabro_agent::{AgentProfile, LocalSandbox, ToolRegistry}; use fabro_api::types; use fabro_auth::{VaultCredentialSource, test_support as auth_test_support}; - use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; - use fabro_llm::{Error as LlmError, ProviderErrorDetail, ProviderErrorKind}; + use fabro_llm::adapter::{ProviderAdapter, ResolvedCall}; + use fabro_llm::lithos_catalog::AdapterId; + use fabro_llm::test_support::{client_with_adapters, test_catalog, test_catalog_with_overlay}; + use fabro_llm::{ErrorKind, ResponseStream, RetryClassification}; use fabro_tool::FabroToolBackend; use fabro_types::{ - EventEnvelope, FailureReason, Run, RunId, RunLifecycle, RunLinks, RunOrigin, + ContentPart, EventEnvelope, FailureReason, Run, RunId, RunLifecycle, RunLinks, RunOrigin, RunPairStatusResponse, RunProjection, RunStatus, RunTimestamps, SuccessReason, WorkflowRef, - test_support, + provider_ids, test_support, }; use fabro_vault::{SecretType, Vault}; use futures::stream; @@ -1920,7 +1937,7 @@ mod tests { } fn provider_id(&self) -> ProviderId { - ProviderId::openai() + provider_ids::openai() } fn model(&self) -> &str { @@ -1947,129 +1964,143 @@ mod tests { } } - struct ShutdownTestProvider; + struct ShutdownTestProvider { + id: AdapterId, + } - #[async_trait] - impl ProviderAdapter for ShutdownTestProvider { - fn name(&self) -> &str { - "openai" - } - - async fn complete( - &self, - _request: &Request, - ) -> Result { - unreachable!("shutdown test never calls LLM completion") - } - - async fn stream(&self, _request: &Request) -> Result { - Ok(Box::pin(stream::empty())) + impl ShutdownTestProvider { + fn new() -> Self { + Self { + id: AdapterId::new("mock"), + } } } - struct RefusalTestProvider; + #[async_trait] + impl ProviderAdapter for ShutdownTestProvider { + fn id(&self) -> &AdapterId { + &self.id + } + + async fn complete(&self, _call: &ResolvedCall) -> Result { + unreachable!("shutdown test never calls LLM completion") + } + + async fn stream(&self, _call: &ResolvedCall) -> Result { + Ok(ResponseStream::new(stream::empty())) + } + } + + struct RefusalTestProvider { + id: AdapterId, + } + + impl RefusalTestProvider { + fn new() -> Self { + Self { + id: AdapterId::new("mock"), + } + } + } #[async_trait] impl ProviderAdapter for RefusalTestProvider { - fn name(&self) -> &str { - "anthropic" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete( - &self, - _request: &Request, - ) -> Result { + async fn complete(&self, _call: &ResolvedCall) -> Result { Err(refusal_llm_error()) } - async fn stream(&self, _request: &Request) -> Result { - Ok(Box::pin(stream::empty())) + async fn stream(&self, _call: &ResolvedCall) -> Result { + Ok(ResponseStream::new(stream::empty())) } } struct TextTestProvider { - provider: &'static str, - text: &'static str, + id: AdapterId, + text: &'static str, + } + + impl TextTestProvider { + fn new(text: &'static str) -> Self { + Self { + id: AdapterId::new("mock"), + text, + } + } } #[async_trait] impl ProviderAdapter for TextTestProvider { - fn name(&self) -> &str { - self.provider + fn id(&self) -> &AdapterId { + &self.id } - async fn complete( - &self, - request: &Request, - ) -> Result { - Ok(fabro_llm::types::Response { - id: "msg_fallback".to_string(), - model: request.model.clone(), - provider: self.provider.to_string(), - message: Message::assistant(self.text), - finish_reason: fabro_llm::types::FinishReason::Stop, - usage: TokenCounts { - input_tokens: 3, - output_tokens: 2, - ..TokenCounts::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) + async fn complete(&self, call: &ResolvedCall) -> Result { + let handle = call.route().handle(); + let mut response = + Response::new(handle.provider().clone(), handle.model().clone(), vec![ + ContentPart::Text { + text: self.text.to_string(), + }, + ]); + response.id = Some("msg_fallback".to_string()); + response.usage = TokenCounts { + input: 3, + output: 2, + ..TokenCounts::default() + }; + Ok(response) } - async fn stream(&self, _request: &Request) -> Result { - Ok(Box::pin(stream::empty())) + async fn stream(&self, _call: &ResolvedCall) -> Result { + Ok(ResponseStream::new(stream::empty())) } } + /// An OpenAI-compatible mock provider served by `server`, with one model. + /// Its API key is the name lithos derives from the provider id, such as + /// `MOCK_API_KEY`. + fn mock_provider_overlay(provider: &str, model: &str, base_url: &str) -> String { + format!( + r#" +[providers.{provider}] +display_name = "{provider}" +adapter = "openai-compatible" +codec = "openai-chat" +base_url = {base_url} +auth = {{ type = "bearer" }} +default_model = "{model}" + +[providers.{provider}.metadata.agent] +profile = "openai" + +[providers.{provider}.models.{model}] +display_name = "{model}" +api_model = "{model}" +limits = {{ context_tokens = 8192, max_output_tokens = 1024 }} +capabilities = {{ text = true, tools = true, response_format = {{ json_object = true, json_schema = true }} }} +"#, + base_url = toml::Value::String(base_url.to_string()), + ) + } + fn mock_llm_catalog(server: &MockServer) -> Arc { - let settings: LlmCatalogSettings = toml::from_str(&format!( - r#" -[providers.mock] -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "{}" - -[providers.mock.auth] -credentials = ["env:MOCK_API_KEY"] - -[models.mock-model] -provider = "mock" -display_name = "Mock Model" -family = "mock" -default = true - -[models.mock-model.limits] -context_window = 8192 -max_output = 1024 - -[models.mock-model.features] -tools = true -vision = false -reasoning = false -"#, - server.base_url() - )) - .unwrap(); - Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()) + Arc::new(test_catalog_with_overlay(&mock_provider_overlay( + "mock", + "mock-model", + &server.base_url(), + ))) } + /// Modal and OpenRouter ship disabled; enable them the way an operator + /// would so their models become fallback targets. fn enabled_fallback_catalog() -> Arc { - let settings: LlmCatalogSettings = toml::from_str( - r" -[providers.modal] -enabled = true - -[providers.openrouter] -enabled = true -", - ) - .expect("fallback catalog overrides should parse"); - Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()) + Arc::new(test_catalog_with_overlay( + "[providers.modal]\nenabled = true\n\n[providers.openrouter]\nenabled = true\n", + )) } fn mock_api_backend(server: &MockServer) -> AgentApiBackend { @@ -2082,7 +2113,7 @@ enabled = true }); AgentApiBackend::new_with_catalog( "mock-model".to_string(), - ProviderId::from("mock"), + ProviderId::new("mock"), ModelFallbackPolicy::default(), source, SteeringHub::for_tests(), @@ -2091,59 +2122,20 @@ enabled = true } fn fallback_api_backend(server: &MockServer) -> AgentApiBackend { - let settings: LlmCatalogSettings = toml::from_str(&format!( - r#" -[providers.primary] -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "{}/primary" - -[providers.primary.auth] -credentials = ["env:PRIMARY_API_KEY"] - -[providers.primary.models.test-model] -display_name = "Primary Test Model" -family = "test" -default = true - -[providers.primary.models.test-model.limits] -context_window = 8192 -max_output = 1024 - -[providers.primary.models.test-model.features] -tools = true -vision = false -reasoning = false - -[providers.fallback] -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "{}/fallback" - -[providers.fallback.auth] -credentials = ["env:FALLBACK_API_KEY"] - -[providers.fallback.models.test-model] -display_name = "Fallback Test Model" -family = "test" -default = true - -[providers.fallback.models.test-model.limits] -context_window = 8192 -max_output = 1024 - -[providers.fallback.models.test-model.features] -tools = true -vision = false -reasoning = false -"#, - server.base_url(), - server.base_url(), - )) - .expect("fallback catalog should parse"); - let catalog = Arc::new( - Catalog::from_builtin_with_overrides(&settings).expect("catalog should build"), + let overlay = format!( + "{}\n{}", + mock_provider_overlay( + "primary", + "test-model", + &format!("{}/primary", server.base_url()), + ), + mock_provider_overlay( + "fallback", + "test-model", + &format!("{}/fallback", server.base_url()), + ), ); + let catalog = Arc::new(test_catalog_with_overlay(&overlay)); let source = auth_test_support::env_credential_source(|name| match name { "PRIMARY_API_KEY" | "FALLBACK_API_KEY" => Some("sk-test".to_string()), _ => None, @@ -2192,7 +2184,7 @@ reasoning = false "delta": { "content": text }, - "finish_reason": null + "finish_reason": "stop" }] }); let usage_chunk = serde_json::json!({ @@ -2263,20 +2255,20 @@ reasoning = false fn agent_backend_stores_config() { let backend = AgentApiBackend::new( "claude-opus-4-6".to_string(), - ProviderId::openai(), + provider_ids::openai(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), ); assert_eq!(backend.model, "claude-opus-4-6"); - assert_eq!(backend.provider_id, ProviderId::openai()); + assert_eq!(backend.provider_id, provider_ids::openai()); } #[test] fn agent_backend_initializes_empty_sessions() { let backend = AgentApiBackend::new( "claude-opus-4-6".to_string(), - ProviderId::anthropic(), + provider_ids::anthropic(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -2311,7 +2303,10 @@ reasoning = false .get(definition.name) .expect("shared Fabro run tool should be registered"); assert_eq!(registered.definition.description, definition.description); - assert_eq!(registered.definition.parameters, definition.parameters); + assert_eq!( + fabro_agent::tool_registry::ToolDefinitionExt::parameters(®istered.definition), + &definition.parameters + ); } } @@ -2962,9 +2957,9 @@ reasoning = false fn build_profile_can_register_subagent_tools() { let mut profile = AgentProfileBuilder::new( AgentProfileKind::Anthropic, - ProviderId::anthropic(), + provider_ids::anthropic(), "claude-opus-4-6", - Arc::new(Catalog::from_builtin().unwrap()), + Arc::new(test_catalog()), ) .build(); let supervisor = SubAgentSupervisor::new(1); @@ -3012,7 +3007,7 @@ reasoning = false // Parent turn 1: spawn a subagent. let parent_spawn = server.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes("PARENT_PROMPT_MARKER") .body_excludes(r#""role":"tool""#); then.status(200) @@ -3027,7 +3022,7 @@ reasoning = false // while the child keeps running in the background. let parent_final = server.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes("call_spawn_helper"); then.status(200) .header("content-type", "text/event-stream") @@ -3036,7 +3031,7 @@ reasoning = false // Child turn 1: the child session uses a tool. let child_read = server.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes("CHILD_TASK_MARKER") .body_excludes("PARENT_PROMPT_MARKER") .body_excludes(r#""role":"tool""#); @@ -3051,7 +3046,7 @@ reasoning = false // Child turn 2: the tool result is back; the child completes. let child_final = server.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes("call_child_read"); then.status(200) .header("content-type", "text/event-stream") @@ -3111,79 +3106,44 @@ reasoning = false #[test] fn api_backend_provider_pin_wins_over_priority_selection() { - let settings: LlmCatalogSettings = toml::from_str( - r" -[providers.openrouter] -enabled = true -", - ) - .unwrap(); let backend = AgentApiBackend::new_with_catalog( "gpt-5.4".to_string(), - ProviderId::from("openrouter"), + ProviderId::new("openrouter"), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), - Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()), + Arc::new(test_catalog_with_overlay(OPENROUTER_ENABLED)), ); let provider = backend.resolve_provider_context("gpt-5.4", None).unwrap(); - assert_eq!(provider.provider_id, ProviderId::from("openrouter")); + assert_eq!(provider.provider_id, ProviderId::new("openrouter")); } #[test] fn api_backend_node_provider_attr_overrides_backend_pin() { let backend = AgentApiBackend::new_with_catalog( "gpt-5.4".to_string(), - ProviderId::from("openrouter"), + ProviderId::new("openrouter"), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), - Arc::new(Catalog::from_builtin().unwrap()), + Arc::new(test_catalog()), ); let provider = backend .resolve_provider_context("gpt-5.4", Some("openai")) .unwrap(); - assert_eq!(provider.provider_id, ProviderId::openai()); + assert_eq!(provider.provider_id, provider_ids::openai()); } #[test] fn api_backend_resolves_custom_catalog_provider_profile() { - let settings: LlmCatalogSettings = toml::from_str( - r#" -[providers.acme] -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" - -[providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[models.acme-llama] -provider = "acme" -display_name = "Acme Llama" -family = "llama" -training = "2026-01" -default = true - -[models.acme-llama.limits] -context_window = 131072 -max_output = 8192 - -[models.acme-llama.features] -tools = true -vision = false -reasoning = false -"#, - ) - .unwrap(); - let catalog = Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()); + let catalog = Arc::new(test_catalog_with_overlay(ACME_LLAMA_OVERLAY)); let backend = AgentApiBackend::new_with_catalog( "acme-llama".to_string(), - ProviderId::from("acme"), + ProviderId::new("acme"), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3194,43 +3154,16 @@ reasoning = false .resolve_provider_context("acme-llama", None) .unwrap(); - assert_eq!(provider.provider_id, ProviderId::from("acme")); + assert_eq!(provider.provider_id, ProviderId::new("acme")); assert_eq!(provider.profile_kind, AgentProfileKind::OpenAi); } #[test] fn api_backend_resolves_model_agent_profile_override() { - let settings: LlmCatalogSettings = toml::from_str( - r#" -[providers.acme] -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" - -[models.acme-claude] -provider = "acme" -display_name = "Acme Claude" -family = "claude" -training = "2026-01" -default = true -agent_profile = "anthropic" -aliases = ["ac"] - -[models.acme-claude.limits] -context_window = 131072 -max_output = 8192 - -[models.acme-claude.features] -tools = true -vision = false -reasoning = false -"#, - ) - .unwrap(); - let catalog = Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()); + let catalog = Arc::new(test_catalog_with_overlay(ACME_CLAUDE_OVERLAY)); let backend = AgentApiBackend::new_with_catalog( "acme-claude".to_string(), - ProviderId::from("acme"), + ProviderId::new("acme"), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3239,7 +3172,7 @@ reasoning = false let provider = backend.resolve_provider_context("ac", None).unwrap(); - assert_eq!(provider.provider_id, ProviderId::from("acme")); + assert_eq!(provider.provider_id, ProviderId::new("acme")); assert_eq!(provider.profile_kind, AgentProfileKind::Anthropic); } @@ -3247,34 +3180,27 @@ reasoning = false fn api_backend_selects_claude5_profile_for_sonnet5() { let backend = AgentApiBackend::new_with_catalog( "claude-sonnet-5".to_string(), - ProviderId::anthropic(), + provider_ids::anthropic(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), - Arc::new(Catalog::from_builtin().unwrap()), + Arc::new(test_catalog()), ); let provider = backend .resolve_provider_context("claude-sonnet-5", None) .unwrap(); - assert_eq!(provider.provider_id, ProviderId::anthropic()); + assert_eq!(provider.provider_id, provider_ids::anthropic()); assert_eq!(provider.profile_kind, AgentProfileKind::Claude5); } #[test] fn api_backend_preserves_default_provider_for_legacy_model_identifier() { - let settings: LlmCatalogSettings = toml::from_str( - r" -[providers.openrouter] -enabled = true -", - ) - .unwrap(); - let catalog = Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()); + let catalog = Arc::new(test_catalog_with_overlay(OPENROUTER_ENABLED)); let backend = AgentApiBackend::new_with_catalog( "openai/gpt-5.4".to_string(), - ProviderId::from("openrouter"), + ProviderId::new("openrouter"), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3285,7 +3211,7 @@ enabled = true .resolve_provider_context("openai/gpt-5.4", None) .unwrap(); - assert_eq!(provider.provider_id, ProviderId::from("openrouter")); + assert_eq!(provider.provider_id, ProviderId::new("openrouter")); assert_eq!(provider.profile_kind, AgentProfileKind::OpenAi); } @@ -3293,7 +3219,7 @@ enabled = true fn run_model_controls_apply_when_node_omits_controls() { let backend = AgentApiBackend::new( "gpt-5.4".to_string(), - ProviderId::openai(), + provider_ids::openai(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3314,7 +3240,7 @@ enabled = true fn node_controls_override_run_model_controls() { let backend = AgentApiBackend::new( "gpt-5.4".to_string(), - ProviderId::openai(), + provider_ids::openai(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3330,20 +3256,20 @@ enabled = true ); node.attrs.insert( "speed".to_string(), - fabro_graphviz::graph::AttrValue::String("standard".to_string()), + fabro_graphviz::graph::AttrValue::String("balanced".to_string()), ); let controls = backend.resolve_effective_request_controls(&node).unwrap(); assert_eq!(controls.reasoning_effort, Some(ReasoningEffort::High)); - assert_eq!(controls.speed, Some(Speed::Standard)); + assert_eq!(controls.speed, Some(Speed::Balanced)); } #[test] fn omitted_reasoning_effort_stays_unset() { let backend = AgentApiBackend::new( "gpt-5.4".to_string(), - ProviderId::openai(), + provider_ids::openai(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3411,7 +3337,7 @@ enabled = true ])); let backend = AgentApiBackend::new_with_catalog( "claude-fable-5".to_string(), - ProviderId::anthropic(), + provider_ids::anthropic(), policy, auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3419,7 +3345,7 @@ enabled = true ); let (mut plan, notices) = backend.fallback_plan( "claude-fable-5", - &ProviderId::anthropic(), + &provider_ids::anthropic(), EffectiveRequestControls::default(), ); @@ -3454,7 +3380,7 @@ enabled = true .unwrap(); let backend = AgentApiBackend::new( "claude-opus-4-6".to_string(), - ProviderId::anthropic(), + provider_ids::anthropic(), ModelFallbackPolicy::default(), Arc::new(VaultCredentialSource::with_env_lookup( Arc::new(AsyncRwLock::new(vault)), @@ -3463,11 +3389,14 @@ enabled = true SteeringHub::for_tests(), ); - let client = Client::from_source(backend.source.as_ref(), Arc::clone(&backend.catalog)) + let client = build_llm_client(&backend.catalog, Arc::clone(&backend.source)) .await .unwrap(); - assert_eq!(client.provider_names(), vec!["anthropic"]); + assert_eq!( + client.available_providers().iter().collect::>(), + vec![&provider_ids::anthropic()] + ); } #[tokio::test] @@ -3479,24 +3408,24 @@ enabled = true )])); let backend = AgentApiBackend::new( "claude-fable-5".to_string(), - ProviderId::anthropic(), + provider_ids::anthropic(), fallback_policy, auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), ); - let mut providers = HashMap::new(); - providers.insert( - "anthropic".to_string(), - Arc::new(RefusalTestProvider) as Arc, + let client = client_with_adapters( + vec![ + ( + "anthropic", + Arc::new(RefusalTestProvider::new()) as Arc, + ), + ( + "openai", + Arc::new(TextTestProvider::new("fallback ok")) as Arc, + ), + ], + ClientOptions::default(), ); - providers.insert( - "openai".to_string(), - Arc::new(TextTestProvider { - provider: "openai", - text: "fallback ok", - }) as Arc, - ); - let client = Client::new(providers, Some("anthropic".to_string()), Vec::new()); let node = Node::new("ask"); let context = Context::new(); let stage_scope = StageScope::for_handler(&context, &node.id); @@ -3508,25 +3437,15 @@ enabled = true *emitted_failover_for_listener.lock().unwrap() = Some(props.clone()); } }); - let request = Request { - model: "claude-fable-5".to_string(), - messages: vec![Message::user("Hello")], - provider: Some("anthropic".to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: Some(128), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - }; + let request = Request::builder() + .model("anthropic/claude-fable-5") + .user("Hello") + .max_output_tokens(128) + .build() + .unwrap(); let (mut fallback_plan, notices) = backend.fallback_plan( "claude-fable-5", - &ProviderId::anthropic(), + &provider_ids::anthropic(), EffectiveRequestControls::default(), ); assert!(notices.is_empty()); @@ -3544,8 +3463,8 @@ enabled = true .unwrap(); assert_eq!(completion.response.text(), "fallback ok"); - assert_eq!(completion.model.provider, ProviderId::openai()); - assert_eq!(completion.model.model_id, "gpt-5.5"); + assert_eq!(completion.model.provider, provider_ids::openai()); + assert_eq!(completion.model.model_id.as_str(), "gpt-5.5"); let failover = emitted_failover .lock() .unwrap() @@ -3567,7 +3486,7 @@ enabled = true async fn explicit_provider_one_shot_stays_on_fallback_during_output_repair() { let server = MockServer::start(); let primary_failure = server.mock(|when, then| { - when.method(POST).path("/primary/chat/completions"); + when.method(POST).path("/primary/v1/chat/completions"); then.status(401) .header("content-type", "application/json") .json_body(serde_json::json!({ @@ -3579,7 +3498,7 @@ enabled = true }); let fallback_response = server.mock(|when, then| { when.method(POST) - .path("/fallback/chat/completions") + .path("/fallback/v1/chat/completions") .body_excludes(r#""role":"assistant""#); then.status(200) .header("content-type", "application/json") @@ -3587,7 +3506,7 @@ enabled = true }); let fallback_repair = server.mock(|when, then| { when.method(POST) - .path("/fallback/chat/completions") + .path("/fallback/v1/chat/completions") .body_includes(r#""role":"assistant""#) .body_includes("not json"); then.status(200) @@ -3638,7 +3557,7 @@ enabled = true let server = MockServer::start(); let first = server.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes(r#""type":"json_schema""#) .body_excludes(r#""role":"assistant""#); then.status(200) @@ -3651,7 +3570,7 @@ enabled = true }); let repair = server.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes(r#""type":"json_schema""#) .body_includes(r#""role":"assistant""#) .body_includes("not json") @@ -3697,8 +3616,8 @@ enabled = true }; assert_eq!(text, r#"{"passed":true}"#); let usage = usage.expect("usage should be aggregated"); - assert_eq!(usage.tokens().input_tokens, 21); - assert_eq!(usage.tokens().output_tokens, 3); + assert_eq!(usage.tokens().input, 21); + assert_eq!(usage.tokens().output, 3); assert_eq!(usage.total_usd_micros, Some(100_000)); } @@ -3707,7 +3626,7 @@ enabled = true let server = MockServer::start(); let first = server.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes(r#""stream":true"#) .body_excludes(r#""role":"assistant""#); then.status(200) @@ -3716,7 +3635,7 @@ enabled = true }); let repair = server.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes(r#""stream":true"#) .body_includes(r#""role":"assistant""#) .body_includes("not json") @@ -3760,8 +3679,8 @@ enabled = true }; assert_eq!(text, r#"{"passed":true}"#); let usage = usage.expect("usage should be aggregated"); - assert_eq!(usage.tokens().input_tokens, 41); - assert_eq!(usage.tokens().output_tokens, 7); + assert_eq!(usage.tokens().input, 41); + assert_eq!(usage.tokens().output, 7); } #[tokio::test] @@ -3769,7 +3688,7 @@ enabled = true let server = MockServer::start(); let first = server.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes(r#""stream":true"#) .body_excludes(r#""role":"assistant""#); then.status(200) @@ -3778,7 +3697,7 @@ enabled = true }); let first_repair = server.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes("JSON Pointer `/findings/0/rationale`") .body_excludes("unchanged from your previous repair"); then.status(200) @@ -3787,7 +3706,7 @@ enabled = true }); let second_repair = server.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes("JSON Pointer `/findings/0/rationale`") .body_includes("unchanged from your previous repair"); then.status(200) @@ -3839,7 +3758,7 @@ enabled = true let server = MockServer::start(); let primary_response = server.mock(|when, then| { when.method(POST) - .path("/primary/chat/completions") + .path("/primary/v1/chat/completions") .body_includes(r#""stream":true"#) .body_excludes(r#""role":"assistant""#); then.status(200) @@ -3848,7 +3767,7 @@ enabled = true }); let failed_repair = server.mock(|when, then| { when.method(POST) - .path("/primary/chat/completions") + .path("/primary/v1/chat/completions") .body_includes(r#""role":"assistant""#) .body_includes("not json"); then.status(401) @@ -3862,7 +3781,7 @@ enabled = true }); let fallback_response = server.mock(|when, then| { when.method(POST) - .path("/fallback/chat/completions") + .path("/fallback/v1/chat/completions") .body_includes(r#""stream":true"#) .body_excludes(r#""role":"assistant""#); then.status(200) @@ -3910,7 +3829,7 @@ enabled = true let server = MockServer::start(); let tool_call = server.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes(r#""stream":true"#) .body_excludes(r#""role":"tool""#); then.status(200) @@ -3923,7 +3842,7 @@ enabled = true }); let completion = server.mock(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes("call_web_search"); then.status(200) .header("content-type", "text/event-stream") @@ -3993,7 +3912,7 @@ enabled = true async fn api_backend_shutdown_closes_cached_sessions_once() { let backend = AgentApiBackend::new( "gpt-5.4".to_string(), - ProviderId::openai(), + provider_ids::openai(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -4008,12 +3927,13 @@ enabled = true .push(event.event_name().to_string()); }); - let mut providers = HashMap::new(); - providers.insert( - "openai".to_string(), - Arc::new(ShutdownTestProvider) as Arc, + let client = client_with_adapters( + vec![( + "openai", + Arc::new(ShutdownTestProvider::new()) as Arc, + )], + ClientOptions::default(), ); - let client = Client::new(providers, Some("openai".to_string()), Vec::new()); let session = Session::new( client, Arc::new(ShutdownTestProfile::new()), @@ -4025,7 +3945,7 @@ enabled = true ); let (fallback_plan, notices) = backend.fallback_plan( "gpt-5.4", - &ProviderId::openai(), + &provider_ids::openai(), EffectiveRequestControls::default(), ); assert!(notices.is_empty()); @@ -4051,12 +3971,13 @@ enabled = true #[tokio::test] async fn session_end_barrier_preserves_child_close_ordering() { - let mut providers = HashMap::new(); - providers.insert( - "openai".to_string(), - Arc::new(ShutdownTestProvider) as Arc, + let client = client_with_adapters( + vec![( + "openai", + Arc::new(ShutdownTestProvider::new()) as Arc, + )], + ClientOptions::default(), ); - let client = Client::new(providers, Some("openai".to_string()), Vec::new()); let mut session = Session::new( client, Arc::new(ShutdownTestProfile::new()), @@ -4114,47 +4035,90 @@ enabled = true // --- Bridge guard tests --- fn failover_eligible_llm_error() -> LlmError { - LlmError::Network { - message: "boom".into(), - source: None, - } + LlmError::from( + fabro_llm::Error::new(ErrorKind::Network, "boom") + .with_provider(provider_ids::openai()) + .with_retry(RetryClassification::Safe), + ) } fn non_failover_llm_error() -> LlmError { - LlmError::Provider { - kind: ProviderErrorKind::InvalidRequest, - detail: Box::new(ProviderErrorDetail { - message: "bad key".into(), - provider: "openai".into(), - status_code: Some(401), - error_code: None, - retry_after: None, - raw: None, - }), - } + LlmError::from( + fabro_llm::Error::new(ErrorKind::InvalidRequest, "bad key") + .with_provider(provider_ids::openai()) + .with_status(401), + ) } - fn refusal_llm_error() -> LlmError { - LlmError::Provider { - kind: ProviderErrorKind::ContentFilter, - detail: Box::new(ProviderErrorDetail { - message: "claude-fable-5 refused the request".into(), - provider: "anthropic".into(), - status_code: None, - error_code: Some("refusal".into()), - retry_after: None, - raw: Some(serde_json::json!({ - "stop_reason": "refusal", - "stop_details": { - "type": "refusal", - "category": "cyber", - "explanation": "This request was declined." - } - })), - }), - } + fn refusal_llm_error() -> fabro_llm::Error { + fabro_llm::Error::new( + ErrorKind::ContentFilter, + "claude-fable-5 refused the request", + ) + .with_provider(provider_ids::anthropic()) + .with_provider_code("refusal") + .with_raw_data(serde_json::json!({ + "stop_reason": "refusal", + "stop_details": { + "type": "refusal", + "category": "cyber", + "explanation": "This request was declined." + } + })) } + const OPENROUTER_ENABLED: &str = "[providers.openrouter]\nenabled = true\n"; + + /// An operator-defined OpenAI-compatible provider whose models take the + /// provider's `openai` agent profile. + const ACME_LLAMA_OVERLAY: &str = r#" +[providers.acme] +display_name = "Acme" +adapter = "openai-compatible" +codec = "openai-chat" +base_url = "https://api.acme.test/v1" +auth = { type = "bearer" } +default_model = "acme-llama" + +[providers.acme.metadata.agent] +profile = "openai" + +[providers.acme.models.acme-llama] +display_name = "Acme Llama" +api_model = "acme-llama" +limits = { context_tokens = 131072, max_output_tokens = 8192 } +capabilities = { text = true, tools = true } +family = "llama" +training_cutoff = "2026-01" + +"#; + + /// The same provider serving a Claude model that overrides the profile. + const ACME_CLAUDE_OVERLAY: &str = r#" +[providers.acme] +display_name = "Acme" +adapter = "openai-compatible" +codec = "openai-chat" +base_url = "https://api.acme.test/v1" +auth = { type = "bearer" } +default_model = "acme-claude" + +[providers.acme.metadata.agent] +profile = "openai" + +[providers.acme.models.acme-claude] +display_name = "Acme Claude" +aliases = ["ac"] +api_model = "acme-claude" +limits = { context_tokens = 131072, max_output_tokens = 8192 } +capabilities = { text = true, tools = true } +family = "claude" +training_cutoff = "2026-01" + +[providers.acme.models.acme-claude.metadata.agent] +profile = "anthropic" +"#; + #[tokio::test] async fn spawn_bridge_task_sets_cancelled_and_cancels_session_token() { let run_token = CancellationToken::new(); @@ -4335,7 +4299,7 @@ enabled = true #[test] fn classify_refusal_llm_returns_failover_when_allowed() { - let err = fabro_agent::Error::Llm(refusal_llm_error()); + let err = fabro_agent::Error::Llm(LlmError::from(refusal_llm_error())); assert!(matches!( classify_agent_error(err, true), AgentApiErrorDisposition::FailoverEligible(_) @@ -4344,7 +4308,7 @@ enabled = true #[test] fn classify_refusal_llm_returns_terminal_when_not_allowed() { - let err = fabro_agent::Error::Llm(refusal_llm_error()); + let err = fabro_agent::Error::Llm(LlmError::from(refusal_llm_error())); match classify_agent_error(err, false) { AgentApiErrorDisposition::Terminal(Error::Llm(llm_err)) => { assert!(llm_err.to_string().contains("claude-fable-5 refused")); diff --git a/lib/components/fabro-workflow/src/handler/llm/preamble.rs b/lib/components/fabro-workflow/src/handler/llm/preamble.rs index dc01e65ab..5f812e533 100644 --- a/lib/components/fabro-workflow/src/handler/llm/preamble.rs +++ b/lib/components/fabro-workflow/src/handler/llm/preamble.rs @@ -589,23 +589,18 @@ fn build_summary_preamble( #[cfg(test)] mod tests { use fabro_graphviz::graph::AttrValue; - use fabro_llm::types::TokenCounts; - use fabro_model::{Catalog, ModelRef, ProviderId}; + use fabro_types::{ModelId, ModelRef, TokenCounts, provider_ids}; use super::*; use crate::outcome::{BilledModelUsage, billed_model_usage_from_llm}; - fn stage_usage(model: &str, input_tokens: i64, output_tokens: i64) -> BilledModelUsage { + fn stage_usage(model: &str, input: u64, output: u64) -> BilledModelUsage { billed_model_usage_from_llm( - Catalog::builtin(), - &ModelRef { - provider: ProviderId::anthropic(), - model_id: model.into(), - speed: None, - }, - &TokenCounts { - input_tokens, - output_tokens, + &fabro_llm::test_support::test_catalog(), + &ModelRef::new(provider_ids::anthropic(), ModelId::new(model)), + TokenCounts { + input, + output, ..TokenCounts::default() }, ) diff --git a/lib/components/fabro-workflow/src/handler/llm/router.rs b/lib/components/fabro-workflow/src/handler/llm/router.rs index 24dc97e0e..3e0580016 100644 --- a/lib/components/fabro-workflow/src/handler/llm/router.rs +++ b/lib/components/fabro-workflow/src/handler/llm/router.rs @@ -81,7 +81,7 @@ mod tests { use async_trait::async_trait; use fabro_agent::{LocalSandbox, Sandbox}; use fabro_graphviz::graph::{AttrValue, Node}; - use fabro_model::{ReasoningEffort, Speed}; + use fabro_types::{ReasoningEffort, Speed}; use tokio_util::sync::CancellationToken; use super::*; diff --git a/lib/components/fabro-workflow/src/handler/llm/routing.rs b/lib/components/fabro-workflow/src/handler/llm/routing.rs index 3ce9618db..c3db8ae5d 100644 --- a/lib/components/fabro-workflow/src/handler/llm/routing.rs +++ b/lib/components/fabro-workflow/src/handler/llm/routing.rs @@ -1,6 +1,7 @@ use fabro_graphviz::graph::{self, Node}; -use fabro_model::{AgentProfileKind, Catalog, ProviderId}; -use fabro_types::AgentBackend; +use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::{ModelSelectionError, catalog, selection}; +use fabro_types::{AgentBackend, AgentProfileKind, ProviderId}; use crate::error::Error; @@ -50,38 +51,33 @@ pub(crate) fn resolve_provider_context( provider_attr: Option<&str>, ) -> Result { let provider_id = if let Some(provider) = provider_attr { - let requested = ProviderId::from(provider); - catalog - .provider(&requested) - .ok_or_else(|| { - Error::Precondition(format!("Provider \"{provider}\" is not configured")) - })? - .id - .clone() - } else if catalog - .get_on_provider(default_provider_id, model) - .is_some() - { + catalog::canonical_provider_id(catalog, provider).ok_or_else(|| { + Error::Precondition(format!("Provider \"{provider}\" is not configured")) + })? + } else if catalog::model_on_provider(catalog, default_provider_id.as_str(), model).is_some() { // The run's selected provider is a pin whenever it offers the model. default_provider_id.clone() } else { - match catalog.select(model, None, &catalog.all_provider_ids()) { - Ok(model) => model.provider.clone(), - Err(fabro_model::ModelSelectionError::UnknownSelector { .. }) => { - default_provider_id.clone() - } + match selection::select( + catalog, + model, + None, + &catalog::enabled_provider_ids(catalog), + ) { + Ok(entry) => entry.provider.id().clone(), + Err(ModelSelectionError::UnknownSelector { .. }) => default_provider_id.clone(), Err(error) => return Err(error.into()), } }; - let provider = catalog.provider(&provider_id).ok_or_else(|| { - Error::Precondition(format!("Provider \"{provider_id}\" is not configured")) - })?; - let profile_kind = catalog - .effective_agent_profile(&provider.id, Some(model)) + let provider_id = + catalog::canonical_provider_id(catalog, provider_id.as_str()).ok_or_else(|| { + Error::Precondition(format!("Provider \"{provider_id}\" is not configured")) + })?; + let profile_kind = catalog::agent_profile(catalog, provider_id.as_str(), Some(model)) .expect("validated provider should resolve an agent profile"); Ok(ProviderContext { - provider_id: provider.id.clone(), + provider_id, profile_kind, }) } diff --git a/lib/components/fabro-workflow/src/handler/prompt.rs b/lib/components/fabro-workflow/src/handler/prompt.rs index 5e2058c3c..5f0778fca 100644 --- a/lib/components/fabro-workflow/src/handler/prompt.rs +++ b/lib/components/fabro-workflow/src/handler/prompt.rs @@ -220,9 +220,8 @@ mod tests { use std::time::Duration; use fabro_graphviz::graph::AttrValue; - use fabro_model::{ReasoningEffort, Speed}; use fabro_store::{Database, RunDatabase, StageId}; - use fabro_types::{fixtures, test_support}; + use fabro_types::{ReasoningEffort, Speed, fixtures, test_support}; use object_store::memory::InMemory; use tempfile::TempDir; @@ -690,33 +689,31 @@ mod tests { tokio::fs::write(workspace.path().join("CLAUDE.md"), "anthropic memory") .await .unwrap(); - let overrides: fabro_model::catalog::LlmCatalogSettings = toml::from_str( + let catalog = Arc::new(fabro_llm::test_support::test_catalog_with_overlay( r#" -[providers.acme] -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" - -[models.acme-claude] -provider = "acme" -display_name = "Acme Claude" -family = "claude" -default = true -agent_profile = "anthropic" -aliases = ["ac"] - -[models.acme-claude.limits] -context_window = 1000 - -[models.acme-claude.features] -tools = true -vision = false -reasoning = false -"#, - ) - .unwrap(); - let catalog = - Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&overrides).unwrap()); + [providers.acme] + display_name = "Acme" + adapter = "openai-compatible" + codec = "openai-chat" + base_url = "https://api.acme.test/v1" + auth = { type = "bearer" } + default_model = "acme-claude" + + [providers.acme.metadata.agent] + profile = "openai" + + [providers.acme.models.acme-claude] + display_name = "Acme Claude" + aliases = ["ac"] + api_model = "acme-claude" + limits = { context_tokens = 1000, max_output_tokens = 500 } + capabilities = { text = true, tools = true } + family = "claude" + + [providers.acme.models.acme-claude.metadata.agent] + profile = "anthropic" + "#, + )); let mut services = make_services(); services.run = services .run @@ -725,7 +722,7 @@ reasoning = false ))) .with_catalog_context( Arc::clone(&catalog), - fabro_model::ProviderId::new("acme"), + fabro_types::ProviderId::new("acme"), "acme-claude".to_string(), ); @@ -766,32 +763,31 @@ reasoning = false tokio::fs::write(workspace.path().join("CLAUDE.md"), "anthropic memory") .await .unwrap(); - let overrides: fabro_model::catalog::LlmCatalogSettings = toml::from_str( + let catalog = Arc::new(fabro_llm::test_support::test_catalog_with_overlay( r#" -[providers.acme] -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" - -[models.acme-claude] -provider = "acme" -display_name = "Acme Claude" -family = "claude" -default = true -agent_profile = "anthropic" - -[models.acme-claude.limits] -context_window = 1000 - -[models.acme-claude.features] -tools = true -vision = false -reasoning = false -"#, - ) - .unwrap(); - let catalog = - Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&overrides).unwrap()); + [providers.acme] + display_name = "Acme" + adapter = "openai-compatible" + codec = "openai-chat" + base_url = "https://api.acme.test/v1" + auth = { type = "bearer" } + default_model = "acme-claude" + + [providers.acme.metadata.agent] + profile = "openai" + + [providers.acme.models.acme-claude] + display_name = "Acme Claude" + aliases = ["ac"] + api_model = "acme-claude" + limits = { context_tokens = 1000, max_output_tokens = 500 } + capabilities = { text = true, tools = true } + family = "claude" + + [providers.acme.models.acme-claude.metadata.agent] + profile = "anthropic" + "#, + )); let mut services = make_services(); services.run = services .run @@ -800,7 +796,7 @@ reasoning = false ))) .with_catalog_context( Arc::clone(&catalog), - fabro_model::ProviderId::new("acme"), + fabro_types::ProviderId::new("acme"), "acme-claude".to_string(), ); diff --git a/lib/components/fabro-workflow/src/handler/structured_output.rs b/lib/components/fabro-workflow/src/handler/structured_output.rs index 3403d87c4..eba502034 100644 --- a/lib/components/fabro-workflow/src/handler/structured_output.rs +++ b/lib/components/fabro-workflow/src/handler/structured_output.rs @@ -2,7 +2,7 @@ use std::fmt::Write as _; use std::sync::{Arc, LazyLock}; use fabro_graphviz::graph::Node; -use fabro_llm::types::{ResponseFormat, ResponseFormatType}; +use fabro_llm::types::ResponseFormat; use jsonschema::error::ValidationErrorKind; use jsonschema::paths::Location; use jsonschema::{ValidationError, Validator}; @@ -392,17 +392,16 @@ pub(crate) fn parse_node_output_schema(node: &Node) -> Result ResponseFormat { match schema { - OutputSchemaKind::Routing => ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }, - OutputSchemaKind::JsonSchema { schema, .. } => ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(schema.clone()), - strict: true, + OutputSchemaKind::Routing => ResponseFormat::JsonObject, + OutputSchemaKind::JsonSchema { schema, .. } => ResponseFormat::JsonSchema { + name: "output_schema".to_string(), + schema: schema.clone(), }, } } @@ -1119,12 +1118,10 @@ mod tests { let format = prompt_response_format(&schema); - assert_eq!(format.kind, ResponseFormatType::JsonSchema); - assert_eq!( - format.json_schema, - Some(serde_json::json!({"type": "object"})) - ); - assert!(format.strict); + assert_eq!(format, ResponseFormat::JsonSchema { + name: "output_schema".to_string(), + schema: serde_json::json!({"type": "object"}), + }); } #[test] diff --git a/lib/components/fabro-workflow/src/lifecycle/git.rs b/lib/components/fabro-workflow/src/lifecycle/git.rs index 4f1b30602..509f1a397 100644 --- a/lib/components/fabro-workflow/src/lifecycle/git.rs +++ b/lib/components/fabro-workflow/src/lifecycle/git.rs @@ -645,7 +645,6 @@ mod tests { use fabro_core::lifecycle::RunLifecycle; use fabro_core::state::ExecutionState; use fabro_graphviz::graph::types::{AttrValue, Edge, Graph, Node}; - use fabro_model::Catalog; use fabro_store::{EventEnvelope, RunDatabase, RunProjection}; use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase}; use fabro_types::{BlobHash, EventBody, RunEvent, WorkflowSettings, fixtures, test_support}; @@ -1307,10 +1306,10 @@ mod tests { None, finalize_locations, tokio_util::sync::CancellationToken::new(), - fabro_model::ProviderId::anthropic(), + fabro_types::provider_ids::anthropic(), "claude-sonnet-4-6".to_string(), auth_test_support::vault_only_credential_source(), - Arc::new(Catalog::from_builtin().expect("default catalog should build")), + Arc::new(fabro_llm::test_support::test_catalog()), Arc::new(SandboxGitRuntime::new()), Arc::clone(&lifecycle.metadata_runtime), lifecycle.metadata_writer.clone(), diff --git a/lib/components/fabro-workflow/src/model_fallback.rs b/lib/components/fabro-workflow/src/model_fallback.rs index f6de2fcd4..4a9d3c73f 100644 --- a/lib/components/fabro-workflow/src/model_fallback.rs +++ b/lib/components/fabro-workflow/src/model_fallback.rs @@ -1,10 +1,10 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use fabro_model::{ - Catalog, FallbackTarget, Model, ModelSelectionError, ProviderId, ReasoningEffort, -}; +use fabro_llm::catalog::ModelEntry; +use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::{FallbackTarget, ModelSelectionError, catalog, selection}; use fabro_types::settings::{ModelRef, ResolvedModelRef}; -use fabro_types::{RunNoticeCode, RunNoticeLevel}; +use fabro_types::{ProviderId, ReasoningEffort, RunNoticeCode, RunNoticeLevel}; use crate::Error; @@ -31,7 +31,7 @@ impl ModelFallbackPolicy { provider: &ProviderId, model: &str, ) -> Option<&'a [FallbackTarget]> { - self.chain_for_canonical(&catalog.canonical_model_id(provider, model)) + self.chain_for_canonical(&catalog::canonical_model_id(catalog, provider, model)) } /// Look up a chain by an already-canonicalized requested model ID. @@ -179,9 +179,11 @@ impl ModelFallbackNotice { requested_model, target, requested_effort, - } => format!( - "Model fallback `{target}` for requested model `{requested_model}` was skipped because it has no reasoning level near `{requested_effort}`." - ), + } => { + format!( + "Model fallback `{target}` for requested model `{requested_model}` was skipped because it has no reasoning level near `{requested_effort}`." + ) + } Self::ChainEmpty { requested_model } => format!( "No usable model fallbacks remain for requested model `{requested_model}` after filtering its configured candidates." ), @@ -205,8 +207,12 @@ pub fn resolve_model_fallbacks( for (raw_key, references) in configured { require_bare_model_key(catalog, raw_key)?; - let selected = - catalog.resolve_selection_with_catalog_fallback(Some(raw_key), None, &eligible)?; + let selected = selection::resolve_selection_with_catalog_fallback( + catalog, + Some(raw_key), + None, + &eligible, + )?; let requested_model = selected.model; if let Some(previous) = @@ -218,7 +224,8 @@ pub fn resolve_model_fallbacks( } let primary = FallbackTarget::new(&selected.provider, &requested_model); - let primary_model = catalog.get_on_provider(&selected.provider, &requested_model); + let primary_model = + catalog::model_on_provider(catalog, selected.provider.as_str(), &requested_model); let mut targets = Vec::new(); for model_ref in references { @@ -226,7 +233,7 @@ pub fn resolve_model_fallbacks( catalog, &requested_model, &primary, - primary_model, + primary_model.as_ref(), &eligible, model_ref, )? { @@ -292,7 +299,7 @@ fn resolve_fallback_candidate( catalog: &Catalog, requested_model: &str, primary: &FallbackTarget, - primary_model: Option<&Model>, + primary_model: Option<&ModelEntry<'_>>, eligible: &HashSet, model_ref: &ModelRef, ) -> Result { @@ -300,7 +307,7 @@ fn resolve_fallback_candidate( Ok(match model_ref.resolve(catalog)? { ResolvedModelRef::Provider(provider_name) => { - let provider = catalog.provider_id(&provider_name)?; + let provider = selection::require_provider(catalog, &provider_name)?; if !eligible.contains(&provider) { return Ok(FallbackCandidate::Skipped( ModelFallbackNotice::ProviderUnconfigured { @@ -319,8 +326,10 @@ fn resolve_fallback_candidate( }, )); }; - match catalog.closest(&provider, primary_model) { - Some(model) => FallbackCandidate::Target(FallbackTarget::new(provider, &model.id)), + match catalog::closest_model(catalog, provider.as_str(), primary_model.model) { + Some(entry) => { + FallbackCandidate::Target(FallbackTarget::new(provider, entry.model.id())) + } None => FallbackCandidate::Skipped(ModelFallbackNotice::NoCompatibleModel { requested_model: requested_model.to_string(), reference, @@ -332,7 +341,7 @@ fn resolve_fallback_candidate( provider: Some(provider_name), selector, } => { - let provider = catalog.provider_id(&provider_name)?; + let provider = selection::require_provider(catalog, &provider_name)?; if !eligible.contains(&provider) { return Ok(FallbackCandidate::Skipped( ModelFallbackNotice::ProviderUnconfigured { @@ -342,10 +351,11 @@ fn resolve_fallback_candidate( }, )); } - match catalog.resolve_on_provider(&provider, &selector) { - Ok(info) => { - FallbackCandidate::Target(FallbackTarget::new(&info.provider, &info.id)) - } + match selection::resolve_on_provider(catalog, &provider, &selector) { + Ok(entry) => FallbackCandidate::Target(FallbackTarget::new( + entry.provider.id(), + entry.model.id(), + )), Err(ModelSelectionError::UnknownSelectorOnProvider { .. }) => { FallbackCandidate::Target(FallbackTarget::new(provider, selector)) } @@ -355,8 +365,11 @@ fn resolve_fallback_candidate( ResolvedModelRef::Model { provider: None, selector, - } => match catalog.select(&selector, None, eligible) { - Ok(info) => FallbackCandidate::Target(FallbackTarget::new(&info.provider, &info.id)), + } => match selection::select(catalog, &selector, None, eligible) { + Ok(entry) => FallbackCandidate::Target(FallbackTarget::new( + entry.provider.id(), + entry.model.id(), + )), Err(ModelSelectionError::NoEligibleOffering { providers, .. }) => { FallbackCandidate::Skipped(ModelFallbackNotice::NoConfiguredOffering { requested_model: requested_model.to_string(), @@ -376,7 +389,10 @@ fn resolve_fallback_candidate( mod tests { use std::collections::BTreeMap; - use fabro_model::{Catalog, FallbackTarget, ProviderId}; + use fabro_llm::FallbackTarget; + use fabro_llm::lithos_catalog::Catalog; + use fabro_llm::test_support::test_catalog_with_overlay; + use fabro_types::ProviderId; use super::{ModelFallbackNotice, resolve_model_fallbacks}; @@ -388,14 +404,7 @@ mod tests { } fn openrouter_catalog() -> Catalog { - let overrides = toml::from_str( - r" -[providers.openrouter] -enabled = true -", - ) - .expect("catalog override should parse"); - Catalog::from_builtin_with_overrides(&overrides).expect("catalog should build") + test_catalog_with_overlay("[providers.openrouter]\nenabled = true\n") } #[test] @@ -497,19 +506,9 @@ enabled = true #[test] fn resolves_the_requested_production_policy_as_independent_chains() { - let catalog = { - let overrides = toml::from_str( - r" -[providers.modal] -enabled = true - -[providers.openrouter] -enabled = true -", - ) - .expect("catalog override should parse"); - Catalog::from_builtin_with_overrides(&overrides).expect("catalog should build") - }; + let catalog = test_catalog_with_overlay( + "[providers.modal]\nenabled = true\n\n[providers.openrouter]\nenabled = true\n", + ); let eligible = [ ProviderId::new("modal"), ProviderId::new("moonshot"), diff --git a/lib/components/fabro-workflow/src/operations/create.rs b/lib/components/fabro-workflow/src/operations/create.rs index bcb3087d5..c0cbe93bf 100644 --- a/lib/components/fabro-workflow/src/operations/create.rs +++ b/lib/components/fabro-workflow/src/operations/create.rs @@ -12,12 +12,12 @@ use std::sync::Arc; use fabro_config::Storage; use fabro_graphviz::graph::{AttrValue, Graph}; -use fabro_model::{Catalog, ProviderId}; +use fabro_llm::lithos_catalog::Catalog; use fabro_store::{BlobStore, Database}; use fabro_template::TemplateContext; use fabro_types::{ - AutomationRef, BlobHash, ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, - RunTarget, WorkflowSettings, WorkflowVersionId, + AutomationRef, BlobHash, ForkSourceRef, GitContext, ManifestPath, ProviderId, RunId, + RunProvenance, RunTarget, WorkflowSettings, WorkflowVersionId, }; use fabro_util::json::normalize_json_value; use tokio::task::spawn_blocking; @@ -688,7 +688,7 @@ mod tests { use fabro_store::Database; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunMode; - use fabro_types::{EventBody, WorkflowSettings, fixtures, test_support}; + use fabro_types::{EventBody, WorkflowSettings, fixtures, provider_ids, test_support}; use fabro_util::error::collect_chain; use fabro_validate::Severity; use object_store::local::LocalFileSystem; @@ -731,60 +731,31 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin().unwrap()) + Arc::new(fabro_llm::test_support::test_catalog()) } + /// OpenAI and OpenRouter both offering GPT-5.6 Sol as their default, so a + /// portable selector resolves to whichever provider is ready. fn portable_model_catalog() -> Arc { - let settings: fabro_model::catalog::LlmCatalogSettings = toml::from_str( + Arc::new(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 - -[providers.openrouter] -display_name = "OpenRouter" -adapter = "openai_compatible" -agent_profile = "openai" -priority = 25 - -[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(); - Arc::new(Catalog::from_settings(&settings).unwrap()) + [providers.openai] + priority = 90 + default_model = "gpt-5.6-sol" + + [providers.openrouter] + priority = 25 + default_model = "gpt-5.6-sol" + enabled = true + + "#, + )) } fn test_provider_ids() -> Vec { - Catalog::builtin().all_provider_ids().into_iter().collect() + fabro_llm::catalog::enabled_provider_ids(&fabro_llm::test_support::test_catalog()) + .into_iter() + .collect() } fn compile_input(request: &CreateRunInput) -> CreateRunCompileInput { @@ -2056,25 +2027,25 @@ reasoning = false }"#; let catalog = portable_model_catalog(); let cases = [ - (vec![ProviderId::openai()], None, ProviderId::openai()), + (vec![provider_ids::openai()], None, provider_ids::openai()), ( vec![ProviderId::new("openrouter")], None, ProviderId::new("openrouter"), ), ( - vec![ProviderId::openai(), ProviderId::new("openrouter")], + vec![provider_ids::openai(), ProviderId::new("openrouter")], None, - ProviderId::openai(), + provider_ids::openai(), ), ( - vec![ProviderId::openai(), ProviderId::new("openrouter")], + vec![provider_ids::openai(), ProviderId::new("openrouter")], Some("openrouter"), ProviderId::new("openrouter"), ), ]; - for selector in ["gpt-56-sol", "openai/gpt-5.6-sol"] { + for selector in ["gpt-56-sol", "gpt-5.6"] { for (ready, explicit_provider, expected_provider) in &cases { let dir = tempfile::tempdir().unwrap(); let mut settings = test_default_settings(); diff --git a/lib/components/fabro-workflow/src/operations/start.rs b/lib/components/fabro-workflow/src/operations/start.rs index 520e09a18..f5a5c8ad0 100644 --- a/lib/components/fabro-workflow/src/operations/start.rs +++ b/lib/components/fabro-workflow/src/operations/start.rs @@ -6,9 +6,8 @@ use std::time::{Duration, Instant}; use fabro_auth::{CredentialSource, VaultCredentialSource}; use fabro_interview::{AutoApproveInterviewer, Interviewer}; -use fabro_llm::client::Client as LlmClient; +use fabro_llm::lithos_catalog::Catalog; use fabro_mcp::config::McpServerSettings; -use fabro_model::{Catalog, ProviderId}; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::from_environment::{ daytona_config_from_environment, docker_config_from_environment_with_secrets, @@ -24,7 +23,7 @@ use fabro_types::settings::run::{ RunPrepareSettings as ResolvedRunPrepareSettings, }; use fabro_types::{ - ManifestPath, RunId, RunRunnableSource, RunSpec, RunTarget, SandboxProviderKind, + ManifestPath, ProviderId, RunId, RunRunnableSource, RunSpec, RunTarget, SandboxProviderKind, TargetValidationError, }; use fabro_util::error::collect_chain; @@ -751,15 +750,7 @@ async fn configured_providers_for_start( Arc::clone(vault), process_env_var, )); - match LlmClient::from_source_report(source.as_ref(), catalog).await { - Ok(report) => report - .client - .provider_names() - .into_iter() - .map(ProviderId::new) - .collect(), - Err(_) => Vec::new(), - } + source.resolve_all(catalog.as_ref()).await.ready } fn git_checkpoint_options_from_start( @@ -1321,7 +1312,7 @@ mod tests { }; use fabro_types::{ BilledModelUsage, GitContext, ManifestPath, RunTarget, StageTiming, WorkflowSettings, - fixtures, test_support, + fixtures, provider_ids, test_support, }; use fabro_vault::SecretType; use object_store::memory::InMemory; @@ -1442,73 +1433,31 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin().expect("default catalog should build")) + Arc::new(fabro_llm::test_support::test_catalog()) } fn test_provider_ids() -> Vec { - Catalog::builtin().all_provider_ids().into_iter().collect() + fabro_llm::catalog::enabled_provider_ids(&fabro_llm::test_support::test_catalog()) + .into_iter() + .collect() } + /// OpenAI and OpenRouter both offering GPT-5.6 Sol as their default, so a + /// portable selector resolves to whichever provider is ready. fn portable_model_catalog() -> Catalog { - let settings: fabro_model::catalog::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 - -[providers.openai.models."gpt-5.4-mini"] -display_name = "GPT-5.4 Mini" -family = "gpt-5" -aliases = ["mini"] - -[providers.openai.models."gpt-5.4-mini".limits] -context_window = 1000 - -[providers.openai.models."gpt-5.4-mini".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter] -display_name = "OpenRouter" -adapter = "openai_compatible" -agent_profile = "openai" -priority = 25 - -[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 -"#, + [providers.openai] + priority = 90 + default_model = "gpt-5.6-sol" + + [providers.openrouter] + priority = 25 + default_model = "gpt-5.6-sol" + enabled = true + + "#, ) - .unwrap(); - Catalog::from_settings(&settings).unwrap() } #[test] @@ -1525,40 +1474,39 @@ reasoning = false assert!(matches!( error, - Error::ModelSelection(fabro_model::ModelSelectionError::ProviderUnavailable { + Error::ModelSelection(fabro_llm::ModelSelectionError::ProviderUnavailable { provider - }) if provider == ProviderId::openai() + }) if provider == provider_ids::openai() )); } #[test] fn resolve_start_llm_infers_provider_from_model_alias() { - let overrides: fabro_model::catalog::LlmCatalogSettings = toml::from_str( + let catalog = fabro_llm::test_support::test_catalog_with_overlay( r#" -[providers.acme] -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" - -[models.acme-claude] -provider = "acme" -display_name = "Acme Claude" -family = "claude" -default = true -agent_profile = "anthropic" -aliases = ["ac"] - -[models.acme-claude.limits] -context_window = 1000 - -[models.acme-claude.features] -tools = true -vision = false -reasoning = false -"#, - ) - .unwrap(); - let catalog = Catalog::from_builtin_with_overrides(&overrides).unwrap(); + [providers.acme] + display_name = "Acme" + adapter = "openai-compatible" + codec = "openai-chat" + base_url = "https://api.acme.test/v1" + auth = { type = "bearer" } + default_model = "acme-claude" + + [providers.acme.metadata.agent] + profile = "openai" + + [providers.acme.models.acme-claude] + display_name = "Acme Claude" + aliases = ["ac"] + api_model = "acme-claude" + limits = { context_tokens = 1000, max_output_tokens = 500 } + capabilities = { text = true, tools = true } + family = "claude" + + [providers.acme.models.acme-claude.metadata.agent] + profile = "anthropic" + "#, + ); let mut settings = ResolvedRunSettings::default(); settings.model.name = Some("ac".to_string()); diff --git a/lib/components/fabro-workflow/src/operations/validate.rs b/lib/components/fabro-workflow/src/operations/validate.rs index dfcb38777..3a8db0c5b 100644 --- a/lib/components/fabro-workflow/src/operations/validate.rs +++ b/lib/components/fabro-workflow/src/operations/validate.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use fabro_model::{Catalog, ProviderId}; -use fabro_types::WorkflowSettings; +use fabro_llm::lithos_catalog::Catalog; +use fabro_types::{ProviderId, WorkflowSettings}; use super::create::{configured_default_provider, preprocess_and_validate, template_context}; use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow}; diff --git a/lib/components/fabro-workflow/src/outcome.rs b/lib/components/fabro-workflow/src/outcome.rs index 330969de1..e41717ca5 100644 --- a/lib/components/fabro-workflow/src/outcome.rs +++ b/lib/components/fabro-workflow/src/outcome.rs @@ -1,56 +1,37 @@ pub use fabro_core::outcome::{ FailureCategory, FailureDetail, OutcomeMeta, StageOutcome, StageState, }; -use fabro_llm::types::TokenCounts as LlmTokenCounts; -use fabro_model::{ - BilledTokenCounts, Catalog, ModelBillingInput, ModelRef, ModelUsage, TokenCounts, -}; +use fabro_llm::catalog; +use fabro_llm::lithos_catalog::Catalog; pub use fabro_types::BilledModelUsage; +use fabro_types::{BilledTokenCounts, ModelRef, TokenCounts}; use crate::error::{Error, FailureSignature, classify_failure_reason}; pub type Outcome = fabro_core::Outcome>; +/// Bills `usage` on `model` from catalog pricing. +/// +/// The provider must be one the catalog knows; a passthrough model on a known +/// provider is billed with no cost, since the catalog has no rates for it. pub fn billed_model_usage_from_llm( catalog: &Catalog, model: &ModelRef, - usage: &LlmTokenCounts, + usage: TokenCounts, ) -> Result { - let tokens = token_counts_from_llm_usage(usage); - let facts = catalog.billing_facts_for(model, &tokens).ok_or_else(|| { - Error::Precondition(format!("Provider \"{}\" is not configured", model.provider)) - })?; - let input = ModelBillingInput { - usage: ModelUsage { - model: model.clone(), - tokens, - }, - facts, - }; - - let total_usd_micros = catalog - .pricing_for(model) - .and_then(|pricing| pricing.bill(&input)) - .map(|amount| amount.0); - - Ok(BilledModelUsage { - input, - total_usd_micros, - }) + if catalog::provider(catalog, model.provider.as_str()).is_none() { + return Err(Error::Precondition(format!( + "Provider \"{}\" is not configured", + model.provider + ))); + } + let cost = catalog::estimate_cost(catalog, model, usage); + Ok(BilledModelUsage::new(model.clone(), usage, cost)) } #[must_use] -pub fn billed_token_counts_from_llm(usage: &LlmTokenCounts) -> BilledTokenCounts { - let tokens = token_counts_from_llm_usage(usage); - BilledTokenCounts { - input_tokens: tokens.input_tokens, - output_tokens: tokens.output_tokens, - total_tokens: tokens.total_tokens(), - reasoning_tokens: tokens.reasoning_tokens, - cache_read_tokens: tokens.cache_read_tokens, - cache_write_tokens: tokens.cache_write_tokens, - total_usd_micros: None, - } +pub fn billed_token_counts_from_llm(usage: TokenCounts) -> BilledTokenCounts { + BilledTokenCounts::from_token_counts(usage, None) } pub trait OutcomeExt: Sized { @@ -141,58 +122,56 @@ pub fn format_cost(cost: f64) -> String { format!("${cost:.2}") } -fn token_counts_from_llm_usage(usage: &LlmTokenCounts) -> TokenCounts { - usage.clone() -} - #[cfg(test)] mod tests { - use fabro_llm::types::TokenCounts; - use fabro_model::catalog::LlmCatalogSettings; - use fabro_model::{Catalog, ModelRef, ProviderId, Speed, UsdMicros}; + use fabro_llm::lithos_catalog::Catalog; + use fabro_llm::test_support::{test_catalog, test_catalog_with_overlay}; + use fabro_types::{ModelId, ModelRef, ProviderId, Speed, TokenCounts, UsdMicros, provider_ids}; use super::{OutcomeExt, billed_model_usage_from_llm}; fn model_ref(provider: ProviderId, model_id: &str, speed: Option) -> ModelRef { - ModelRef { - provider, - model_id: model_id.into(), - speed, - } + ModelRef::new(provider, ModelId::new(model_id)).with_speed(speed) + } + + fn catalog() -> Catalog { + test_catalog() } #[test] fn billed_model_usage_from_llm_bills_openai_cached_input_and_reasoning_output() { + // Stay under the 272k long-context tier so the standard rates apply. let usage = TokenCounts { - input_tokens: 500_000, - output_tokens: 125_000, - reasoning_tokens: 25_000, - cache_read_tokens: 250_000, + input: 100_000, + output: 25_000, + reasoning: 5_000, + cache_read: 50_000, ..TokenCounts::default() }; let billed = billed_model_usage_from_llm( - Catalog::builtin(), - &model_ref(ProviderId::openai(), "gpt-5.4", None), - &usage, + &catalog(), + &model_ref(provider_ids::openai(), "gpt-5.4", None), + usage, ) .unwrap(); - assert_eq!(billed.total_usd_micros, Some(3_562_500)); - assert_eq!(billed.tokens().output_tokens, 125_000); - assert_eq!(billed.tokens().reasoning_tokens, 25_000); + // 100k input at $2.50/M + 50k cached at $0.25/M + 30k output at $15/M. + assert_eq!(billed.total_usd_micros, Some(712_500)); + assert_eq!(billed.tokens().output, 25_000); + assert_eq!(billed.tokens().reasoning, 5_000); } #[test] fn response_cost_overrides_catalog_estimate() { let usage = TokenCounts { - input_tokens: 11, - output_tokens: 7, + input: 11, + output: 7, ..TokenCounts::default() }; let billed = billed_model_usage_from_llm( - Catalog::builtin(), - &model_ref(ProviderId::openai(), "gpt-5.4", None), - &usage, + &catalog(), + &model_ref(provider_ids::openai(), "gpt-5.4", None), + usage, ) .unwrap() .with_reported_cost(Some(UsdMicros(125_000))); @@ -213,142 +192,88 @@ mod tests { #[test] fn billed_model_usage_from_llm_bills_anthropic_fast_mode_cache_write_pricing() { let usage = TokenCounts { - input_tokens: 100_000, - output_tokens: 10_000, - reasoning_tokens: 5_000, - cache_read_tokens: 20_000, - cache_write_tokens: 30_000, + input: 100_000, + output: 10_000, + reasoning: 5_000, + cache_read: 20_000, + cache_write: 30_000, }; let billed = billed_model_usage_from_llm( - Catalog::builtin(), + &catalog(), &model_ref( - ProviderId::anthropic(), - "claude-opus-4-6", + provider_ids::anthropic(), + "claude-opus-5", Some(Speed::Fast), ), - &usage, + usage, ) .unwrap(); - assert_eq!(billed.total_usd_micros, Some(6_435_000)); + // Fast rates: $10/M input, $50/M output (incl. reasoning), $1/M cache + // read, $12.50/M cache write. + assert_eq!(billed.total_usd_micros, Some(2_145_000)); } #[test] fn billed_model_usage_from_llm_uses_injected_custom_catalog() { - let settings: LlmCatalogSettings = toml::from_str( + let catalog = test_catalog_with_overlay( r#" [providers.proxy] display_name = "Proxy" -adapter = "openai_compatible" -agent_profile = "openai" -billing_policy = "openai" +adapter = "openai-compatible" +codec = "openai-chat" base_url = "https://proxy.example/v1" +auth = { type = "bearer" } +default_model = "canonical-model" -[models.canonical-model] -provider = "proxy" -api_id = "wire-model" +[providers.proxy.models.canonical-model] display_name = "Canonical Model" -family = "proxy" -default = true - -[models.canonical-model.limits] -context_window = 1000 - -[models.canonical-model.features] -tools = true -vision = false -reasoning = false - -[models.canonical-model.costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 2.0 +api_model = "wire-model" +limits = { context_tokens = 1000, max_output_tokens = 500 } +capabilities = { text = true, tools = true } +pricing = { input_usd_micros_per_million = 1000000, output_usd_micros_per_million = 2000000 } "#, - ) - .unwrap(); - let catalog = Catalog::from_settings(&settings).unwrap(); + ); let usage = TokenCounts { - input_tokens: 500_000, - output_tokens: 250_000, + input: 1_000_000, + output: 500_000, ..TokenCounts::default() }; - let billed = billed_model_usage_from_llm( &catalog, &model_ref(ProviderId::new("proxy"), "canonical-model", None), - &usage, + usage, ) .unwrap(); - assert_eq!(&billed.model().provider, &ProviderId::new("proxy")); + assert_eq!(billed.total_usd_micros, Some(2_000_000)); assert_eq!(billed.model_id(), "canonical-model"); - assert_eq!(billed.total_usd_micros, Some(1_000_000)); } #[test] - fn billed_model_usage_from_llm_does_not_bill_provider_api_id() { - let settings: LlmCatalogSettings = toml::from_str( - r#" -[providers.proxy] -display_name = "Proxy" -adapter = "openai_compatible" -agent_profile = "openai" -billing_policy = "openai" -base_url = "https://proxy.example/v1" - -[models.canonical-model] -provider = "proxy" -api_id = "wire-model" -display_name = "Canonical Model" -family = "proxy" -default = true - -[models.canonical-model.limits] -context_window = 1000 - -[models.canonical-model.features] -tools = true -vision = false -reasoning = false - -[models.canonical-model.costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 2.0 -"#, - ) - .unwrap(); - let catalog = Catalog::from_settings(&settings).unwrap(); - + fn passthrough_model_on_known_provider_has_no_cost() { let billed = billed_model_usage_from_llm( - &catalog, - &model_ref(ProviderId::new("proxy"), "wire-model", None), - &TokenCounts { - input_tokens: 500_000, - output_tokens: 250_000, + &catalog(), + &model_ref(provider_ids::openai(), "brand-new-model", None), + TokenCounts { + input: 10, + output: 5, ..TokenCounts::default() }, ) .unwrap(); - - assert_eq!(billed.model_id(), "wire-model"); assert_eq!(billed.total_usd_micros, None); + assert_eq!(billed.tokens().input, 10); } #[test] - fn billed_model_usage_round_trips_dense_token_counts() { - let usage = TokenCounts { - input_tokens: 100, - output_tokens: 40, - reasoning_tokens: 5, - cache_read_tokens: 20, - cache_write_tokens: 10, - }; - let billed = billed_model_usage_from_llm( - Catalog::builtin(), - &model_ref(ProviderId::anthropic(), "claude-opus-4-6", None), - &usage, + fn unknown_provider_is_a_precondition_failure() { + let error = billed_model_usage_from_llm( + &catalog(), + &model_ref(ProviderId::new("nowhere"), "model", None), + TokenCounts::default(), ) - .unwrap(); - - assert_eq!(billed.tokens().clone(), usage); + .unwrap_err(); + assert!(error.to_string().contains("not configured"), "{error}"); } } diff --git a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs index 7e8647f6c..ec254b3d2 100644 --- a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs @@ -87,13 +87,8 @@ fn test_run_id(label: &str) -> RunId { } } -fn test_catalog() -> Arc { - Arc::new( - fabro_model::Catalog::from_builtin_with_overrides( - &fabro_model::catalog::LlmCatalogSettings::default(), - ) - .expect("default catalog should build"), - ) +fn test_catalog() -> Arc { + Arc::new(fabro_llm::test_support::test_catalog()) } fn test_emitter(label: &str) -> Emitter { @@ -270,7 +265,7 @@ async fn execute_test_run_with_options( }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_model::ProviderId::anthropic(), + provider_id: fabro_types::provider_ids::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -330,7 +325,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() { }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_model::ProviderId::anthropic(), + provider_id: fabro_types::provider_ids::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -471,7 +466,7 @@ async fn resumed_in_flight_node_starts_a_new_stage_execution() { }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_model::ProviderId::anthropic(), + provider_id: fabro_types::provider_ids::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -585,7 +580,7 @@ async fn run_with_lifecycle( }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_model::ProviderId::anthropic(), + provider_id: fabro_types::provider_ids::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), diff --git a/lib/components/fabro-workflow/src/pipeline/finalize.rs b/lib/components/fabro-workflow/src/pipeline/finalize.rs index a91ebbd6e..bc1ec1860 100644 --- a/lib/components/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/components/fabro-workflow/src/pipeline/finalize.rs @@ -81,7 +81,7 @@ fn build_conclusion_from_projection( final_git_commit_sha: Option, ) -> Conclusion { let billing = projection - .map(|projection| billing_rollup::billing_rollup_from_projection(projection, None)) + .map(billing_rollup::billing_rollup_from_projection) .unwrap_or_default(); let (stages, total_retries) = projection .map(|projection| billing.conclusion_stages(projection)) @@ -343,7 +343,7 @@ async fn compute_final_patch( #[cfg(any(test, feature = "test-support"))] pub(crate) fn billing_from_projection(projection: &RunProjection) -> Option { - billing_rollup::billing_rollup_from_projection(projection, None).billing_if_present() + billing_rollup::billing_rollup_from_projection(projection).billing_if_present() } pub(crate) fn build_terminal_event( @@ -580,7 +580,6 @@ mod tests { use bytes::Bytes; use fabro_auth::test_support as auth_test_support; use fabro_graphviz::graph::Graph; - use fabro_model::Catalog; use fabro_sandbox::test_support::MockSandbox; use fabro_store::{Database, EventEnvelope, RunDatabase, RunProjection}; use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase}; @@ -997,10 +996,10 @@ mod tests { None, locations, tokio_util::sync::CancellationToken::new(), - fabro_model::ProviderId::anthropic(), + fabro_types::provider_ids::anthropic(), "claude-sonnet-4-6".to_string(), auth_test_support::vault_only_credential_source(), - Arc::new(Catalog::from_builtin().expect("default catalog should build")), + Arc::new(fabro_llm::test_support::test_catalog()), Arc::new(SandboxGitRuntime::new()), metadata_runtime, metadata_writer, @@ -1030,10 +1029,10 @@ mod tests { None, locations, tokio_util::sync::CancellationToken::new(), - fabro_model::ProviderId::anthropic(), + fabro_types::provider_ids::anthropic(), "claude-sonnet-4-6".to_string(), auth_test_support::vault_only_credential_source(), - Arc::new(Catalog::from_builtin().expect("default catalog should build")), + Arc::new(fabro_llm::test_support::test_catalog()), Arc::new(SandboxGitRuntime::new()), Arc::new(RunMetadataRuntime::new()), None, diff --git a/lib/components/fabro-workflow/src/pipeline/initialize.rs b/lib/components/fabro-workflow/src/pipeline/initialize.rs index d05d9acd9..38fc31e51 100644 --- a/lib/components/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/components/fabro-workflow/src/pipeline/initialize.rs @@ -4,13 +4,11 @@ use std::sync::Arc; use std::time::Instant; use fabro_agent::{Sandbox, ToolSecrets}; -use fabro_auth::{ - CredentialSource, ExtraHeadersCredentialSource, VaultCredentialSource, auth_issue_message, -}; +use fabro_auth::{CredentialSource, ExtraHeadersCredentialSource, VaultCredentialSource}; use fabro_github::token_source::InstallationTokenSource; use fabro_graphviz::graph; use fabro_hooks::{HookContext, HookDecision, HookEvent, HookExecutionContext, HookRunner}; -use fabro_model::Catalog; +use fabro_llm::lithos_catalog::Catalog; use fabro_sandbox::{ GitSetupIntent, SandboxEventCallback, SandboxSpec, reconnect_for_run_with_callback, shell_quote, }; @@ -278,37 +276,22 @@ async fn build_registry( return Ok((build_llm_registry(), false)); } - match llm_source.resolve(catalog.as_ref()).await { - Ok(result) if result.credentials.is_empty() => { - if graph_needs_llm { - let detail = (!result.auth_issues.is_empty()).then(|| { - result - .auth_issues - .iter() - .map(|(provider, issue)| auth_issue_message(provider, issue)) - .collect::>() - .join("; ") - }); - let prefix = detail.map_or_else( - || "No LLM providers configured".to_string(), - |detail| format!("No usable LLM providers configured: {detail}"), - ); - return Err(Error::Precondition(format!( - "{prefix}. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or pass --dry-run to simulate." - ))); - } - Ok((build_no_backend(), false)) - } - Ok(_result) => Ok((build_llm_registry(), false)), - Err(e) => { - if graph_needs_llm { - return Err(Error::Precondition(format!( - "Failed to initialize LLM client: {e}. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or pass --dry-run to simulate.", - ))); - } - Ok((build_no_backend(), false)) + let result = llm_source.resolve_all(catalog.as_ref()).await; + if result.ready.is_empty() { + if graph_needs_llm { + let detail = + (!result.auth_issues.is_empty()).then(|| result.issue_messages().join("; ")); + let prefix = detail.map_or_else( + || "No LLM providers configured".to_string(), + |detail| format!("No usable LLM providers configured: {detail}"), + ); + return Err(Error::Precondition(format!( + "{prefix}. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or pass --dry-run to simulate." + ))); } + return Ok((build_no_backend(), false)); } + Ok((build_llm_registry(), false)) } async fn tool_secrets_from_configured_sources(vault: &Arc>) -> ToolSecrets { @@ -781,7 +764,7 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin().expect("default catalog should build")) + Arc::new(fabro_llm::test_support::test_catalog()) } fn memory_store() -> Arc { @@ -911,7 +894,7 @@ mod tests { sandbox: SandboxSpec::Local { working_directory }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_model::ProviderId::anthropic(), + provider_id: fabro_types::provider_ids::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -1101,17 +1084,16 @@ mod tests { assert_eq!(initialized.model, "test-model"); assert_eq!( initialized.engine.run.provider_id, - fabro_model::ProviderId::anthropic() + fabro_types::provider_ids::anthropic() ); assert!( initialized .engine .run .llm_source - .resolve(&initialized.engine.run.catalog) + .resolve_all(&initialized.engine.run.catalog) .await - .unwrap() - .credentials + .ready .is_empty() ); } @@ -1229,7 +1211,7 @@ mod tests { let (_registry, effective_dry_run) = build_registry( &LlmSpec { model: "claude-opus-4-6".to_string(), - provider_id: fabro_model::ProviderId::anthropic(), + provider_id: fabro_types::provider_ids::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -1261,17 +1243,22 @@ mod tests { let expected_session_id = run_id.to_string(); let source = build_llm_source(vault, run_id); - let resolved = source.resolve(test_catalog().as_ref()).await.unwrap(); + let catalog = test_catalog(); + let resolved = source.resolve_all(catalog.as_ref()).await; - assert!(!resolved.credentials.is_empty()); - for credential in &resolved.credentials { - assert_eq!( - credential - .extra_headers - .get(SESSION_ID_HEADER) - .map(String::as_str), - Some(expected_session_id.as_str()) - ); + assert!(!resolved.ready.is_empty()); + for provider in &resolved.ready { + let provider = catalog.provider(provider.as_str()).unwrap(); + let credentials = source.credentials(provider).await.unwrap(); + let fabro_llm::credentials::Credentials::Http(http) = credentials else { + panic!("vault credentials should be HTTP credentials"); + }; + let session_header = http + .extra_headers + .iter() + .find(|header| header.name == SESSION_ID_HEADER) + .map(|header| header.value.expose_secret()); + assert_eq!(session_header, Some(expected_session_id.as_str())); } } @@ -1351,7 +1338,7 @@ mod tests { }, llm: LlmSpec { model: "fake-acp".to_string(), - provider_id: fabro_model::ProviderId::openai(), + provider_id: fabro_types::provider_ids::openai(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -1454,7 +1441,7 @@ mod tests { }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_model::ProviderId::anthropic(), + provider_id: fabro_types::provider_ids::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -1596,7 +1583,7 @@ mod tests { }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_model::ProviderId::anthropic(), + provider_id: fabro_types::provider_ids::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), diff --git a/lib/components/fabro-workflow/src/pipeline/publish.rs b/lib/components/fabro-workflow/src/pipeline/publish.rs index 42010e133..6455ec3f1 100644 --- a/lib/components/fabro-workflow/src/pipeline/publish.rs +++ b/lib/components/fabro-workflow/src/pipeline/publish.rs @@ -179,7 +179,7 @@ impl Concluded { merge_strategy: pr_config.merge_strategy, }), run_store: &self.services.run_store, - llm_source: self.services.llm_source.as_ref(), + llm_source: Arc::clone(&self.services.llm_source), catalog: Arc::clone(&self.services.catalog), conclusion: Some(&self.conclusion), run_state: None, diff --git a/lib/components/fabro-workflow/src/pipeline/pull_request.rs b/lib/components/fabro-workflow/src/pipeline/pull_request.rs index 10f324dd3..f6fb74de5 100644 --- a/lib/components/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/components/fabro-workflow/src/pipeline/pull_request.rs @@ -5,12 +5,11 @@ use std::time::Duration; use fabro_auth::CredentialSource; use fabro_github::{self as github_app, ssh_url_to_https}; use fabro_graphviz::parser; -use fabro_llm::client::Client; -use fabro_llm::generate::{GenerateParams, generate_object}; -use fabro_model::{Catalog, ProviderId}; +use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::{Client, ClientOptions, Request, selection, structured}; use fabro_store::RunProjection; -use fabro_types::PullRequestLink; use fabro_types::settings::run::MergeStrategy; +use fabro_types::{ProviderId, PullRequestLink, Role}; use fabro_util::text::strip_goal_decoration; use tokio::time::sleep; use tracing::{debug, info, warn}; @@ -72,10 +71,10 @@ fn truncation_caps( eligible: &HashSet, catalog: &Catalog, ) -> TruncationCaps { - let ctx = catalog - .select(model, None, eligible) + let ctx = selection::select(catalog, model, None, eligible) .ok() - .and_then(|m| usize::try_from(m.context_window()).ok()) + .and_then(|entry| entry.model.limits()) + .and_then(|limits| usize::try_from(limits.context_tokens).ok()) .unwrap_or(UNKNOWN_MODEL_CTX); truncation_caps_for_context_window(ctx) @@ -334,14 +333,19 @@ pub async fn build_pr_content( goal: &str, model: &str, run_store: &RunStoreHandle, - llm_source: &dyn CredentialSource, + llm_source: Arc, catalog: Arc, conclusion: Option<&Conclusion>, run_state: Option<&RunProjection>, ) -> Result { - let client = Client::from_source(llm_source, Arc::clone(&catalog)) - .await - .map_err(|e| format!("Failed to create LLM client: {e}"))?; + let client = fabro_llm::build_client( + Catalog::clone(&catalog), + llm_source, + ClientOptions::standard(), + ) + .await + .map_err(|e| format!("Failed to create LLM client: {e}"))? + .client; build_pr_content_with_client( diff, @@ -385,7 +389,7 @@ async fn build_pr_content_with_client( let run_spec = run_state.map(|state| state.spec.clone()); let dot_source = run_state.and_then(|state| state.spec.graph_source.clone()); - let eligible = client.provider_ids(); + let eligible = client.available_providers().iter().cloned().collect(); let caps = truncation_caps(model, &eligible, catalog); let truncated_diff = truncate_chars(diff, caps.diff); @@ -398,18 +402,18 @@ async fn build_pr_content_with_client( format!("Goal: {goal}\n\nDiff:\n```\n{truncated_diff}\n```") }; - let params = GenerateParams::new(model, client) + let request = Request::builder() + .model(model) .system(PR_BODY_SYSTEM_PROMPT) - .prompt(prompt); + .message(fabro_types::Message::text(Role::User, prompt)) + .build() + .map_err(|e| format!("invalid PR content request: {e}"))?; + let completion = + structured::complete_object(&client, request, "pr_content", PR_CONTENT_SCHEMA.clone()) + .await + .map_err(|e| format!("LLM generation failed: {e}"))?; - let result = generate_object(params, PR_CONTENT_SCHEMA.clone()) - .await - .map_err(|e| format!("LLM generation failed: {e}"))?; - - let output = result - .output - .ok_or_else(|| "LLM generation returned no structured output".to_string())?; - let generated: PrContent = serde_json::from_value(output) + let generated: PrContent = serde_json::from_value(completion.object) .map_err(|e| format!("Failed to deserialize PR content: {e}"))?; let title = if generated.title.trim().is_empty() { @@ -458,7 +462,7 @@ pub struct OpenPullRequestRequest<'a> { pub draft: bool, pub auto_merge: Option, pub run_store: &'a RunStoreHandle, - pub llm_source: &'a dyn CredentialSource, + pub llm_source: Arc, pub catalog: Arc, pub conclusion: Option<&'a Conclusion>, pub run_state: Option<&'a RunProjection>, @@ -612,7 +616,7 @@ pub async fn open_pull_request( req.goal, req.model, req.run_store, - req.llm_source, + Arc::clone(&req.llm_source), Arc::clone(&req.catalog), req.conclusion, req.run_state, @@ -684,18 +688,15 @@ mod tests { use chrono::Utc; use fabro_auth::{CredentialSource, VaultCredentialSource}; use fabro_graphviz::graph::Graph; - use fabro_llm::Error as LlmError; - use fabro_llm::client::Client; - use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; - use fabro_llm::types::{FinishReason, Message, Request, Response, StreamEvent, TokenCounts}; - use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings}; + use fabro_llm::adapter::{ProviderAdapter, ResolvedCall}; + use fabro_llm::lithos_catalog::AdapterId; + use fabro_llm::{Response, ResponseStream}; use fabro_store::Database; use fabro_types::{ - BilledTokenCounts, RunProjection, RunSpec, SuccessReason, WorkflowSettings, - first_event_seq, fixtures, test_support, + BilledTokenCounts, ContentPart, RunProjection, RunSpec, SuccessReason, TokenCounts, + WorkflowSettings, first_event_seq, fixtures, test_support, }; use fabro_vault::{SecretType, Vault}; - use futures::stream; use httpmock::Method::{GET, POST}; use httpmock::MockServer; use object_store::memory::InMemory; @@ -705,77 +706,53 @@ mod tests { use crate::event::{Event, append_event}; use crate::records::StageSummary; + /// Answers every completion with one fixed text, attributed to the route + /// that was asked. struct MockProvider { - name: String, + id: AdapterId, response_text: String, } impl MockProvider { - fn new(name: &str, text: &str) -> Self { + fn new(text: &str) -> Self { Self { - name: name.to_string(), + id: AdapterId::new("mock"), response_text: text.to_string(), } } + + fn response(&self, call: &ResolvedCall) -> Response { + let handle = call.route().handle(); + let mut response = + Response::new(handle.provider().clone(), handle.model().clone(), vec![ + ContentPart::Text { + text: self.response_text.clone(), + }, + ]); + response.id = Some("resp_1".to_string()); + response.usage = TokenCounts { + input: 10, + output: 20, + ..TokenCounts::default() + }; + response + } } #[async_trait::async_trait] impl ProviderAdapter for MockProvider { - fn name(&self) -> &str { - &self.name + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(&self.response_text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) + async fn complete(&self, call: &ResolvedCall) -> Result { + Ok(self.response(call)) } - async fn stream(&self, _request: &Request) -> Result { - let text = self.response_text.clone(); - let events = vec![ - Ok(StreamEvent::text_delta(&text, Some("t1".into()))), - Ok(StreamEvent::finish( - FinishReason::Stop, - TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(&text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }, - )), - ]; - Ok(Box::pin(stream::iter(events))) + async fn stream(&self, call: &ResolvedCall) -> Result { + Ok(fabro_llm::test_support::response_to_stream( + self.response(call), + )) } } @@ -789,30 +766,47 @@ mod tests { } fn test_catalog_with_provider_base_url(provider: &str, base_url: &str) -> Arc { - let mut settings = LlmCatalogSettings::default(); - settings - .providers - .insert(provider.to_string(), ProviderCatalogSettings { - base_url: Some(base_url.to_string()), - ..ProviderCatalogSettings::default() - }); - Arc::new( - Catalog::from_builtin_with_overrides(&settings) - .expect("catalog with custom base_url should build"), + Arc::new(fabro_llm::test_support::test_catalog_with_provider_base_url(provider, base_url)) + } + + /// The catalog every mock-backed test resolves against: the built-ins plus + /// a `mock` provider that passes any model name through. + fn mock_catalog() -> Catalog { + fabro_llm::test_support::test_catalog_with_overlay( + r#" +[providers.mock] +display_name = "Mock" +adapter = "openai-compatible" +codec = "openai-chat" +base_url = "http://mock.invalid/v1" +auth = { type = "bearer" } +allow_passthrough = true + +[providers.mock.metadata.agent] +profile = "openai" + +[providers.mock.models.mock-model] +display_name = "Mock Model" +api_model = "mock-model" +limits = { context_tokens = 8192, max_output_tokens = 1024 } +capabilities = { text = true, tools = true, response_format = { json_object = true, json_schema = true } } +"#, ) } + /// A client over [`mock_catalog`] whose `provider_name` answers with + /// `text`. fn explicit_client(provider_name: &str, text: &str) -> Arc { - let mut providers: HashMap> = HashMap::new(); - providers.insert( - provider_name.to_string(), - Arc::new(MockProvider::new(provider_name, text)), - ); - Arc::new(Client::new( - providers, - Some(provider_name.to_string()), - vec![], - )) + let adapter: Arc = Arc::new(MockProvider::new(text)); + let mut options = fabro_llm::ClientOptions::default(); + options + .adapters + .push((fabro_types::ProviderId::new(provider_name), adapter)); + Arc::new( + fabro_llm::build_offline_client(mock_catalog(), options) + .expect("mock client should build") + .client, + ) } fn test_projection() -> RunProjection { @@ -1074,7 +1068,7 @@ mod tests { "Implement feature", "mock-model", &run_store.clone().into(), - Catalog::builtin(), + &mock_catalog(), Some(&make_test_conclusion()), None, explicit_client( @@ -1148,7 +1142,7 @@ mod tests { "Implement feature", "mock-model", &run_store.clone().into(), - Catalog::builtin(), + &mock_catalog(), Some(&make_test_conclusion()), None, explicit_client( @@ -1246,7 +1240,7 @@ mod tests { "Implement feature", "mock-model", &run_store.clone().into(), - Catalog::builtin(), + &mock_catalog(), Some(&make_test_conclusion()), None, explicit_client( @@ -1271,7 +1265,7 @@ mod tests { "Implement feature", "gpt-5.4", &run_store.clone().into(), - Catalog::builtin(), + &mock_catalog(), Some(&make_test_conclusion()), None, explicit_client( @@ -1329,7 +1323,7 @@ mod tests { "Implement feature", "gpt-5.4", &run_store_handle, - llm_source.as_ref(), + llm_source, catalog, Some(&make_test_conclusion()), None, @@ -1490,8 +1484,8 @@ mod tests { assert_eq!( truncation_caps( "unknown-model", - &Catalog::builtin().all_provider_ids(), - Catalog::builtin(), + &fabro_llm::catalog::enabled_provider_ids(&mock_catalog()), + &mock_catalog(), ), TruncationCaps { diff: 80_000, @@ -1517,7 +1511,7 @@ mod tests { draft: false, auto_merge: None, run_store: &harness.run_store, - llm_source: harness.llm_source.as_ref(), + llm_source: Arc::clone(&harness.llm_source), catalog: harness.catalog.clone(), conclusion: None, run_state: None, @@ -1556,7 +1550,7 @@ mod tests { "Implement feature", "mock-model", &run_store.clone().into(), - Catalog::builtin(), + &mock_catalog(), Some(&make_test_conclusion()), None, explicit_client("mock", &payload), @@ -1579,7 +1573,7 @@ mod tests { "## Plan:", "mock-model", &run_store.clone().into(), - Catalog::builtin(), + &mock_catalog(), Some(&make_test_conclusion()), None, explicit_client("mock", &payload), @@ -1669,7 +1663,7 @@ mod tests { "Implement feature", "mock-model", &run_store.clone().into(), - Catalog::builtin(), + &mock_catalog(), Some(&make_test_conclusion()), None, explicit_client("mock", &payload), @@ -1945,7 +1939,7 @@ mod tests { draft: false, auto_merge: None, run_store: &harness.run_store, - llm_source: harness.llm_source.as_ref(), + llm_source: Arc::clone(&harness.llm_source), catalog: harness.catalog.clone(), conclusion: None, run_state: None, @@ -1993,7 +1987,7 @@ mod tests { draft: false, auto_merge: None, run_store: &harness.run_store, - llm_source: harness.llm_source.as_ref(), + llm_source: Arc::clone(&harness.llm_source), catalog: harness.catalog.clone(), conclusion: None, run_state: None, @@ -2030,7 +2024,7 @@ mod tests { draft: false, auto_merge: None, run_store: &harness.run_store, - llm_source: harness.llm_source.as_ref(), + llm_source: Arc::clone(&harness.llm_source), catalog: harness.catalog.clone(), conclusion: None, run_state: None, diff --git a/lib/components/fabro-workflow/src/pipeline/transform.rs b/lib/components/fabro-workflow/src/pipeline/transform.rs index 542d52c43..2768c2496 100644 --- a/lib/components/fabro-workflow/src/pipeline/transform.rs +++ b/lib/components/fabro-workflow/src/pipeline/transform.rs @@ -113,7 +113,7 @@ mod tests { use std::sync::Arc; use fabro_graphviz::graph::AttrValue; - use fabro_model::Catalog; + use fabro_llm::lithos_catalog::Catalog; use super::*; use crate::file_resolver::FilesystemFileResolver; @@ -129,7 +129,7 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin().unwrap()) + Arc::new(fabro_llm::test_support::test_catalog()) } fn transform_options() -> TransformOptions { diff --git a/lib/components/fabro-workflow/src/pipeline/types.rs b/lib/components/fabro-workflow/src/pipeline/types.rs index c65f1fdf6..669db75e3 100644 --- a/lib/components/fabro-workflow/src/pipeline/types.rs +++ b/lib/components/fabro-workflow/src/pipeline/types.rs @@ -4,14 +4,14 @@ use std::sync::Arc; use fabro_graphviz::graph::Graph; use fabro_interview::Interviewer; +use fabro_llm::lithos_catalog::Catalog; use fabro_mcp::config::McpServerSettings; -use fabro_model::{Catalog, ProviderId}; use fabro_sandbox::SandboxSpec; use fabro_template::TemplateContext; use fabro_types::settings::run::{ PullRequestSettings, ResolvedGithubIntegration, RunModelControls, }; -use fabro_types::{ManifestPath, RunId, RunProjection}; +use fabro_types::{ManifestPath, ProviderId, RunId, RunProjection}; use fabro_validate::{Diagnostic, Severity}; use fabro_vault::Vault; use tokio::sync::RwLock as AsyncRwLock; diff --git a/lib/components/fabro-workflow/src/pipeline/validate.rs b/lib/components/fabro-workflow/src/pipeline/validate.rs index 3c550f5df..200a66de8 100644 --- a/lib/components/fabro-workflow/src/pipeline/validate.rs +++ b/lib/components/fabro-workflow/src/pipeline/validate.rs @@ -1,4 +1,4 @@ -use fabro_model::Catalog; +use fabro_llm::lithos_catalog::Catalog; use fabro_validate::LintRule; use super::types::{Transformed, Validated}; diff --git a/lib/components/fabro-workflow/src/run_materialization.rs b/lib/components/fabro-workflow/src/run_materialization.rs index d1118b076..714e184e1 100644 --- a/lib/components/fabro-workflow/src/run_materialization.rs +++ b/lib/components/fabro-workflow/src/run_materialization.rs @@ -1,10 +1,11 @@ use std::collections::HashSet; use fabro_graphviz::graph::Graph; -use fabro_model::{Catalog, ModelSelectionError, ProviderId}; -use fabro_types::WorkflowSettings; +use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::{ModelSelectionError, selection}; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunGoal; +use fabro_types::{ProviderId, WorkflowSettings}; use crate::error::Error; @@ -61,7 +62,7 @@ fn materialize_run_with_eligible_providers( )?; settings.run.model.name = Some(resolved_model); - settings.run.model.provider = Some(resolved_provider.into_inner()); + settings.run.model.provider = Some(resolved_provider.into_string()); let goal = graph.goal().to_string(); settings.run.goal = if goal.is_empty() { @@ -93,9 +94,14 @@ pub(crate) fn resolve_run_model( .filter(|provider| !provider.is_empty()) .map(ProviderId::new); let selected = if catalog_fallback { - catalog.resolve_selection_with_catalog_fallback(model, provider.as_ref(), eligible)? + selection::resolve_selection_with_catalog_fallback( + catalog, + model, + provider.as_ref(), + eligible, + )? } else { - catalog.resolve_selection(model, provider.as_ref(), eligible)? + selection::resolve_selection(catalog, model, provider.as_ref(), eligible)? }; Ok((selected.model, selected.provider)) } diff --git a/lib/components/fabro-workflow/src/services.rs b/lib/components/fabro-workflow/src/services.rs index 9fa4c21e1..b1662de01 100644 --- a/lib/components/fabro-workflow/src/services.rs +++ b/lib/components/fabro-workflow/src/services.rs @@ -6,13 +6,11 @@ use std::time::Duration; use fabro_agent::{Sandbox, ToolEnvProvider}; use fabro_auth::CredentialSource; -#[cfg(test)] -use fabro_auth::ResolvedCredentials; use fabro_github::token_source::InstallationTokenSource; use fabro_hooks::{HookContext, HookDecision, HookExecutionContext, HookRunner}; use fabro_interview::Interviewer; -use fabro_model::{Catalog, ProviderId}; -use fabro_types::{ManifestPath, RunId}; +use fabro_llm::lithos_catalog::Catalog; +use fabro_types::{ManifestPath, ProviderId, RunId}; use tokio_util::sync::CancellationToken; use crate::event::Emitter; @@ -271,12 +269,13 @@ impl EngineServices { #[async_trait::async_trait] impl CredentialSource for StubCredentialSource { - async fn resolve(&self, catalog: &Catalog) -> anyhow::Result { - let _ = catalog; - Ok(ResolvedCredentials { - credentials: Vec::new(), - auth_issues: Vec::new(), - }) + async fn credentials( + &self, + provider: &fabro_llm::lithos_catalog::CatalogProvider, + ) -> Result { + Err(fabro_auth::ResolveError::NotConfigured( + provider.id().clone(), + )) } async fn configured_providers(&self, catalog: &Catalog) -> Vec { @@ -318,10 +317,10 @@ impl EngineServices { None, locations, CancellationToken::new(), - ProviderId::anthropic(), - "claude-sonnet-4-6".to_string(), + fabro_types::provider_ids::anthropic(), + "claude-sonnet-4.6".to_string(), Arc::new(StubCredentialSource), - Arc::new(Catalog::from_builtin().expect("default catalog should build")), + Arc::new(fabro_llm::default_catalog()), Arc::new(SandboxGitRuntime::new()), Arc::new(RunMetadataRuntime::new()), None, diff --git a/lib/components/fabro-workflow/src/test_support.rs b/lib/components/fabro-workflow/src/test_support.rs index 039cb91cf..9efbeae84 100644 --- a/lib/components/fabro-workflow/src/test_support.rs +++ b/lib/components/fabro-workflow/src/test_support.rs @@ -8,10 +8,13 @@ use fabro_agent::Sandbox; use fabro_auth::{CredentialSource, test_support as auth_test_support}; use fabro_graphviz::graph::Graph as GvGraph; use fabro_interview::AutoApproveInterviewer; -use fabro_model::Catalog; -#[cfg(feature = "test-support")] -use fabro_model::ProviderId; +use fabro_llm::catalog; +use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::test_support::test_catalog; use fabro_store::{ArtifactStore, RunProjection, test_support as store_test_support}; +#[cfg(feature = "test-support")] +use fabro_types::ProviderId; +use fabro_types::{ModelId, ModelRef, provider_ids}; use object_store::local::LocalFileSystem; use crate::artifact_upload::ArtifactSink; @@ -36,7 +39,7 @@ pub(crate) fn test_configured_provider_ids( assume_ready: bool, ) -> Vec { if assume_ready { - catalog.all_provider_ids().into_iter().collect() + catalog::enabled_provider_ids(catalog).into_iter().collect() } else { configured_provider_ids } @@ -82,26 +85,20 @@ async fn execute_and_emit_terminal(initialized: InitializedState) -> Executed { #[must_use] pub fn test_usage( model_id: &str, - input_tokens: i64, - output_tokens: i64, + input_tokens: u64, + output_tokens: u64, ) -> fabro_types::BilledModelUsage { - serde_json::from_value(serde_json::json!({ - "input": { - "usage": { - "model": { - "provider": "openai", - "model_id": model_id - }, - "tokens": { - "input_tokens": input_tokens, - "output_tokens": output_tokens - } - }, - "facts": { "algorithm": "openai" } + let mut usage = fabro_types::BilledModelUsage::new( + ModelRef::new(provider_ids::openai(), ModelId::new(model_id)), + fabro_types::TokenCounts { + input: input_tokens, + output: output_tokens, + ..fabro_types::TokenCounts::default() }, - "total_usd_micros": input_tokens + output_tokens - })) - .expect("test_usage JSON must deserialise") + None, + ); + usage.total_usd_micros = Some(i64::try_from(input_tokens + output_tokens).unwrap_or(i64::MAX)); + usage } /// Append the `RunStartRequested → RunRunnable → RunStarting → RunRunning` @@ -272,12 +269,12 @@ async fn initialized( options.hook_runner, locations, run_options.cancel_token.clone(), - fabro_model::ProviderId::anthropic(), + provider_ids::anthropic(), "claude-sonnet-4-6".to_string(), options .llm_source .unwrap_or_else(auth_test_support::vault_only_credential_source), - Arc::new(Catalog::from_builtin().expect("default catalog should build")), + Arc::new(test_catalog()), Arc::new(SandboxGitRuntime::new()), Arc::new(RunMetadataRuntime::new()), None, diff --git a/lib/components/fabro-workflow/src/transforms/model_resolution.rs b/lib/components/fabro-workflow/src/transforms/model_resolution.rs index 7fb61b9d9..22f1cf33c 100644 --- a/lib/components/fabro-workflow/src/transforms/model_resolution.rs +++ b/lib/components/fabro-workflow/src/transforms/model_resolution.rs @@ -2,7 +2,9 @@ use std::collections::HashSet; use std::sync::Arc; use fabro_graphviz::graph::{AttrValue, Graph}; -use fabro_model::{Catalog, ProviderId}; +use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::{catalog, selection}; +use fabro_types::ProviderId; use super::Transform; use crate::error::Error; @@ -19,7 +21,7 @@ pub struct ModelResolutionTransform { impl ModelResolutionTransform { #[must_use] pub fn new(catalog: Arc) -> Self { - let eligible_providers = catalog.all_provider_ids(); + let eligible_providers = catalog::enabled_provider_ids(&catalog); Self { catalog, default_provider: None, @@ -65,14 +67,19 @@ impl ModelResolutionTransform { explicit_provider: Option<&ProviderId>, ) -> Result<(String, ProviderId), Error> { let selected = if self.catalog_fallback { - self.catalog.resolve_selection_with_catalog_fallback( + selection::resolve_selection_with_catalog_fallback( + &self.catalog, Some(model), explicit_provider, &self.eligible_providers, ) } else { - self.catalog - .resolve_selection(Some(model), explicit_provider, &self.eligible_providers) + selection::resolve_selection( + &self.catalog, + Some(model), + explicit_provider, + &self.eligible_providers, + ) }?; Ok((selected.model, selected.provider)) } @@ -148,45 +155,40 @@ mod tests { use std::sync::Arc; use fabro_graphviz::graph::{AttrValue, Graph, Node}; - use fabro_model::catalog::LlmCatalogSettings; + use fabro_llm::test_support::{test_catalog, test_catalog_with_overlay}; use super::*; + /// An operator-defined provider with one aliased model, the shape an + /// `[llm]` overlay produces. It joins the built-ins rather than replacing + /// them: lithos catalogs are layered, never standalone. fn custom_catalog() -> Arc { - let settings: LlmCatalogSettings = toml::from_str( + Arc::new(test_catalog_with_overlay( r#" -[providers.venice] -display_name = "Venice" -adapter = "openai_compatible" -agent_profile = "openai" +[providers.acme-venice] +display_name = "Acme Venice" +adapter = "openai-compatible" +codec = "openai-chat" base_url = "https://api.venice.ai/api/v1" +auth = { type = "bearer" } +priority = 200 +default_model = "venice-large" -[providers.venice.auth] -credentials = ["env:VENICE_API_KEY"] +[providers.acme-venice.metadata.agent] +profile = "openai" -[models."venice-large"] -provider = "venice" +[providers.acme-venice.models.venice-large] display_name = "Venice Large" -family = "venice" -default = true aliases = ["vl"] - -[models."venice-large".limits] -context_window = 128000 - -[models."venice-large".features] -tools = true -vision = false -reasoning = false +api_model = "venice-large" +limits = { context_tokens = 128000, max_output_tokens = 8192 } +capabilities = { text = true, tools = true } "#, - ) - .unwrap(); - Arc::new(Catalog::from_settings(&settings).unwrap()) + )) } fn builtin_transform() -> ModelResolutionTransform { - let catalog = Catalog::from_builtin().unwrap(); - ModelResolutionTransform::new(Arc::new(catalog)) + ModelResolutionTransform::new(Arc::new(test_catalog())) } #[test] @@ -195,7 +197,7 @@ reasoning = false let mut node = Node::new("a"); node.attrs.insert( "model".to_string(), - AttrValue::String("claude-sonnet-4-5".to_string()), + AttrValue::String("claude-sonnet-4.5".to_string()), ); graph.nodes.insert("a".to_string(), node); @@ -216,7 +218,7 @@ reasoning = false let mut node = Node::new("a"); node.attrs.insert( "model".to_string(), - AttrValue::String("claude-sonnet-4-5".to_string()), + AttrValue::String("claude-sonnet-4.5".to_string()), ); node.attrs.insert( "provider".to_string(), @@ -238,7 +240,7 @@ reasoning = false .attrs .get("model") .and_then(AttrValue::as_str), - Some("claude-sonnet-4-5") + Some("claude-sonnet-4.5") ); } @@ -345,20 +347,15 @@ reasoning = false .attrs .get("provider") .and_then(AttrValue::as_str), - Some("venice") + Some("acme-venice") ); } #[test] fn fallback_resolution_keeps_ready_preference_for_unpinned_nodes() { - let overrides: LlmCatalogSettings = toml::from_str( - r" -[providers.openrouter] -enabled = true -", - ) - .unwrap(); - let catalog = Arc::new(Catalog::from_builtin_with_overrides(&overrides).unwrap()); + let catalog = Arc::new(test_catalog_with_overlay( + "[providers.openrouter]\nenabled = true\n", + )); let mut graph = Graph::new("test"); let mut portable = Node::new("portable"); portable.attrs.insert( @@ -414,7 +411,7 @@ enabled = true .attrs .get("default_provider") .and_then(AttrValue::as_str), - Some("venice") + Some("acme-venice") ); } } diff --git a/lib/components/fabro-workflow/tests/it/integration.rs b/lib/components/fabro-workflow/tests/it/integration.rs index 399f07f37..ee407786e 100644 --- a/lib/components/fabro-workflow/tests/it/integration.rs +++ b/lib/components/fabro-workflow/tests/it/integration.rs @@ -30,10 +30,11 @@ use fabro_interview::{ Answer, AnswerValue, AutoApproveInterviewer, CallbackInterviewer, Interviewer, QueueInterviewer, RecordingInterviewer, }; -use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings}; -use fabro_model::{Catalog, ProviderId}; +use fabro_llm::lithos_catalog::Catalog; use fabro_store::{ArtifactKey, ArtifactStore}; -use fabro_types::{EventBody, RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref}; +use fabro_types::{ + EventBody, ProviderId, RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref, +}; use fabro_validate::{Severity, validate, validate_or_raise}; use fabro_workflow::artifact; use fabro_workflow::context::Context; @@ -66,21 +67,11 @@ use tokio_util::sync::CancellationToken; use ulid::Ulid; fn default_catalog() -> Arc { - Arc::new(Catalog::from_builtin().expect("default catalog should build")) + Arc::new(fabro_llm::test_support::test_catalog()) } fn catalog_with_provider_base_url(provider: &str, base_url: &str) -> Arc { - let mut settings = LlmCatalogSettings::default(); - settings - .providers - .insert(provider.to_string(), ProviderCatalogSettings { - base_url: Some(base_url.to_string()), - ..ProviderCatalogSettings::default() - }); - Arc::new( - Catalog::from_builtin_with_overrides(&settings) - .expect("catalog with custom base_url should build"), - ) + Arc::new(fabro_llm::test_support::test_catalog_with_provider_base_url(provider, base_url)) } fn local_env() -> Arc { @@ -2583,7 +2574,7 @@ async fn shared_thread_compaction_before_routing_audit_succeeds() { "model": "compact-model", "choices": [{ "delta": {"content": text}, - "finish_reason": null + "finish_reason": "stop" }] }); let usage_chunk = serde_json::json!({ @@ -2629,7 +2620,7 @@ async fn shared_thread_compaction_before_routing_audit_succeeds() { server .mock_async(move |when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes(r#""stream":true"#) .body_includes(prompt) .body_excludes(next_prompt); @@ -2648,7 +2639,7 @@ async fn shared_thread_compaction_before_routing_audit_succeeds() { let audit_mock = server .mock_async(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes(r#""stream":true"#) .body_includes("Audit shared-thread work"); then.status(200) @@ -2660,7 +2651,7 @@ async fn shared_thread_compaction_before_routing_audit_succeeds() { let compaction_mock = server .mock_async(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_excludes(r#""stream":true"#); then.status(200) .header("content-type", "application/json") @@ -2670,41 +2661,35 @@ async fn shared_thread_compaction_before_routing_audit_succeeds() { }) .await; - let settings: LlmCatalogSettings = toml::from_str(&format!( - r#" + let catalog = Arc::new(fabro_llm::test_support::test_catalog_with_overlay( + &format!( + r#" [providers.compact] -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "{}" +display_name = "Compact" +adapter = "openai-compatible" +codec = "openai-chat" +base_url = {base_url} +auth = {{ type = "bearer" }} +default_model = "compact-model" -[providers.compact.auth] -credentials = ["env:COMPACT_API_KEY"] +[providers.compact.metadata.agent] +profile = "openai" -[models.compact-model] -provider = "compact" +[providers.compact.models.compact-model] display_name = "Compact Model" -family = "mock" -default = true - -[models.compact-model.limits] -context_window = 100000 -max_output = 1024 - -[models.compact-model.features] -tools = true -vision = false -reasoning = false +api_model = "compact-model" +limits = {{ context_tokens = 100000, max_output_tokens = 1024 }} +capabilities = {{ text = true, tools = true, response_format = {{ json_object = true, json_schema = true }} }} "#, - server.base_url() - )) - .expect("test catalog should parse"); - let catalog = Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()); + base_url = toml::Value::String(server.base_url()), + ), + )); let source = auth_test_support::env_credential_source(|name| { (name == "COMPACT_API_KEY").then(|| "sk-test".to_string()) }); let backend = AgentApiBackend::new_with_catalog( "compact-model".to_string(), - ProviderId::from("compact"), + ProviderId::new("compact"), ModelFallbackPolicy::default(), source, Arc::new(SteeringHub::new(Arc::new(Emitter::default()))), @@ -2817,7 +2802,7 @@ async fn workflow_persists_authoritative_openrouter_cost_for_agent_stage() { "model": "openai/gpt-5.4", "choices": [{ "delta": {"content": "done"}, - "finish_reason": null + "finish_reason": "stop" }] }); let usage_chunk = serde_json::json!({ @@ -2835,7 +2820,7 @@ async fn workflow_persists_authoritative_openrouter_cost_for_agent_stage() { let completion_mock = server .mock_async(|when, then| { when.method(POST) - .path("/chat/completions") + .path("/v1/chat/completions") .body_includes(r#""stream":true"#) .body_includes("Report completion"); then.status(200) @@ -2844,22 +2829,21 @@ async fn workflow_persists_authoritative_openrouter_cost_for_agent_stage() { }) .await; - let settings: LlmCatalogSettings = toml::from_str(&format!( - r#" -[providers.openrouter] + let catalog = Arc::new(fabro_llm::test_support::test_catalog_with_overlay( + &format!( + "[providers.openrouter] +base_url = {} enabled = true -base_url = "{}" -"#, - server.base_url() - )) - .expect("test catalog should parse"); - let catalog = Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()); +", + toml::Value::String(server.base_url()), + ), + )); let source = auth_test_support::env_credential_source(|name| { (name == "OPENROUTER_API_KEY").then(|| "sk-test".to_string()) }); let backend = AgentApiBackend::new_with_catalog( "openai/gpt-5.4".to_string(), - ProviderId::from("openrouter"), + ProviderId::new("openrouter"), ModelFallbackPolicy::default(), source, Arc::new(SteeringHub::new(Arc::new(Emitter::default()))), @@ -5263,12 +5247,7 @@ async fn import_e2e_through_engine() { use fabro_workflow::transforms::ModelResolutionTransform; let dir = tempfile::tempdir().unwrap(); - let catalog = std::sync::Arc::new( - fabro_model::Catalog::from_builtin_with_overrides( - &fabro_model::catalog::LlmCatalogSettings::default(), - ) - .unwrap(), - ); + let catalog = std::sync::Arc::new(fabro_llm::test_support::test_catalog()); std::fs::write( dir.path().join("val.fabro"), r#"digraph validate { @@ -7321,11 +7300,9 @@ mod real_llm { use std::sync::Arc; use async_trait::async_trait; - use fabro_auth::EnvCredentialSource; + use fabro_auth::VaultCredentialSource; use fabro_graphviz::graph::Node; - use fabro_llm::client::Client; - use fabro_llm::providers::OpenAiAdapter; - use fabro_llm::types::{Message, Request}; + use fabro_llm::{Client, ClientOptions, Request}; use fabro_types::WorkflowSettings; use fabro_workflow::error::Error; use fabro_workflow::handler::agent::{ @@ -7352,25 +7329,16 @@ mod real_llm { impl LlmCodergenBackend { async fn complete(&self, prompt: &str) -> Result { - let request = Request { - model: self.model.clone(), - messages: vec![Message::user(prompt)], - provider: Some(self.provider.clone()), - tools: None, - tool_choice: None, - response_format: None, - temperature: Some(0.0), - top_p: None, - max_tokens: Some(200), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - }; + let request = Request::builder() + .model(format!("{}/{}", self.provider, self.model)) + .user(prompt) + .temperature(0.0) + .max_output_tokens(200) + .build() + .map_err(|e| Error::handler(e.to_string()))?; let response = self .client - .complete(&request) + .complete(request) .await .map_err(|e| Error::handler(e.to_string()))?; Ok(CodergenResult::Text { @@ -7399,27 +7367,45 @@ mod real_llm { } } + /// A client whose `openai` provider is the twin at `base_url`, + /// authenticated with `api_key`. + async fn twin_openai_client(base_url: String, api_key: String) -> Arc { + let catalog = fabro_llm::build_catalog(&fabro_config::LlmLayer::default(), &move |name| { + (name == fabro_static::EnvVars::OPENAI_BASE_URL).then(|| base_url.clone()) + }) + .expect("twin catalog should build"); + Arc::new( + fabro_llm::test_support::client_from_env( + catalog, + move |name| { + (name == fabro_static::EnvVars::OPENAI_API_KEY).then(|| api_key.clone()) + }, + ClientOptions::standard(), + ) + .await, + ) + } + async fn make_llm_client() -> Option> { + use fabro_llm::lithos_catalog::Catalog; + if fabro_test::TestMode::from_env().is_twin() { let (base_url, api_key) = fabro_test::e2e_openai!(); - let adapter: Arc = - Arc::new(OpenAiAdapter::new(api_key).with_base_url(base_url)); - let mut providers: HashMap> = - HashMap::new(); - providers.insert("openai".to_string(), adapter); - return Some(Arc::new(Client::new( - providers, - Some("openai".to_string()), - Vec::new(), - ))); + return Some(twin_openai_client(base_url, api_key).await); } fabro_test::require_env("ANTHROPIC_API_KEY")?; - let source = EnvCredentialSource::new(); + let source: Arc = + Arc::new(VaultCredentialSource::environment_only()); Some(Arc::new( - Client::from_source(&source, super::default_catalog()) - .await - .expect("unified-llm client should initialize from env source"), + fabro_llm::build_client( + Catalog::clone(&super::default_catalog()), + source, + ClientOptions::standard(), + ) + .await + .expect("LLM client should initialize from env source") + .client, )) } @@ -7583,14 +7569,7 @@ mod real_llm { .load(twin) .await; - let adapter: Arc = - Arc::new(OpenAiAdapter::new(namespace.clone()).with_base_url(twin.base_url.clone())); - let providers = HashMap::from([("openai".to_string(), adapter)]); - let client = Arc::new(Client::new( - providers, - Some("openai".to_string()), - Vec::new(), - )); + let client = twin_openai_client(twin.base_url.clone(), namespace.clone()).await; let mut graph = Graph::new("ForEachSecurityReview"); graph.attrs.insert( @@ -8207,8 +8186,8 @@ async fn workflow_run_with_vault_only_openai_codex_builds_pr_body() { "Implement feature", "gpt-5.4", &run_store_handle, - llm_source.as_ref(), - catalog, + Arc::clone(&llm_source), + Arc::clone(&catalog), Some(&Conclusion { timestamp: Utc::now(), status: StageOutcome::Succeeded, diff --git a/lib/components/fabro-workflow/tests/materialize_run.rs b/lib/components/fabro-workflow/tests/materialize_run.rs index 830a73a3b..be8a28522 100644 --- a/lib/components/fabro-workflow/tests/materialize_run.rs +++ b/lib/components/fabro-workflow/tests/materialize_run.rs @@ -1,9 +1,9 @@ use fabro_graphviz::graph::Graph; use fabro_graphviz::parser; -use fabro_model::{Catalog, ProviderId}; -use fabro_types::WorkflowSettings; +use fabro_llm::test_support::test_catalog; use fabro_types::settings::InterpString; use fabro_types::settings::run::{PullRequestSettings, RunGoal, RunModelSettings, RunNamespace}; +use fabro_types::{WorkflowSettings, provider_ids}; use fabro_workflow::run_materialization::materialize_run; fn graph(source: &str) -> Graph { @@ -34,8 +34,8 @@ fn materialize_run_applies_graph_and_catalog_defaults() { ..WorkflowSettings::default() }; - let materialized = materialize_run(settings, &graph(source), Catalog::builtin(), &[ - ProviderId::anthropic(), + let materialized = materialize_run(settings, &graph(source), &test_catalog(), &[ + provider_ids::anthropic(), ]) .unwrap(); let resolved = &materialized.run; @@ -61,8 +61,8 @@ fn materialize_run_uses_configured_provider_defaults() { let materialized = materialize_run( WorkflowSettings::default(), &graph(source), - Catalog::builtin(), - &[ProviderId::openai()], + &test_catalog(), + &[provider_ids::openai()], ) .unwrap(); let resolved = &materialized.run; diff --git a/lib/foundation/fabro-api/Cargo.toml b/lib/foundation/fabro-api/Cargo.toml index bc28670af..1930ef92f 100644 --- a/lib/foundation/fabro-api/Cargo.toml +++ b/lib/foundation/fabro-api/Cargo.toml @@ -18,7 +18,6 @@ chrono = { workspace = true, features = ["serde"] } fabro-automation = { path = "../../components/fabro-automation" } fabro-config = { path = "../fabro-config" } fabro-environment.workspace = true -fabro-model = { path = "../fabro-model" } fabro-types = { path = "../fabro-types" } progenitor-client = "0.13" regress = "0.10" diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index b5b090aa2..6f4c965f1 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -504,27 +504,23 @@ fn main() { "fabro_types::PendingInterviewRecord", &[], ), - ("CompletionUsage", "fabro_model::TokenCounts", &[]), + ("CompletionUsage", "fabro_types::TokenCounts", &[]), ("BilledTokenCounts", "fabro_types::BilledTokenCounts", &[]), - ("BillingModelRef", "fabro_model::ModelRef", &[]), - ("BillingSpeed", "fabro_model::Speed", &[]), + ("BillingModelRef", "fabro_types::ModelRef", &[]), + ("BillingSpeed", "fabro_types::Speed", &[]), ("ExecOutputTail", "fabro_types::ExecOutputTail", &[]), ("StageTiming", "fabro_types::StageTiming", &[]), ("RunTiming", "fabro_types::RunTiming", &[]), - ("ProviderId", "fabro_model::ProviderId", &[]), - ("Model", "fabro_model::Model", &[]), - ("Provider", "fabro_model::Provider", &[]), - ("ModelLimits", "fabro_model::ModelLimits", &[]), - ( - "ReasoningEffortFeature", - "fabro_model::ReasoningEffortFeature", - &[], - ), - ("ReasoningEffort", "fabro_model::ReasoningEffort", &[]), - ("ModelFeatures", "fabro_model::ModelFeatures", &[]), - ("ModelControls", "fabro_model::ModelControls", &[]), - ("ModelCosts", "fabro_model::ModelCosts", &[]), - ("ModelTestMode", "fabro_model::ModelTestMode", &[]), + ("ProviderId", "fabro_types::ProviderId", &[]), + ("ModelHandle", "fabro_types::ModelHandle", &[]), + ("Model", "fabro_types::Model", &[]), + ("Provider", "fabro_types::Provider", &[]), + ("ModelLimits", "fabro_types::ModelLimits", &[]), + ("ReasoningEffort", "fabro_types::ReasoningEffort", &[]), + ("ModelFeatures", "fabro_types::ModelFeatures", &[]), + ("ModelControls", "fabro_types::ModelControls", &[]), + ("ModelCosts", "fabro_types::ModelCosts", &[]), + ("ModelTestMode", "fabro_types::ModelTestMode", &[]), ("RunProjection", "fabro_types::RunProjection", &[]), ("RunEvent", "fabro_types::RunEvent", &[]), ("PairId", "fabro_types::PairId", &[]), @@ -738,6 +734,23 @@ fn main() { ("CompletionMessage", "fabro_types::Message", &[]), ("CompletionMessageRole", "fabro_types::Role", &[]), ("CompletionContentPart", "fabro_types::ContentPart", &[]), + ( + "CompletionToolDefinition", + "fabro_types::ToolDefinition", + &[], + ), + ( + "CompletionToolDefinitionKind", + "fabro_types::ToolDefinitionKind", + &[], + ), + ("CompletionToolChoice", "fabro_types::ToolChoice", &[]), + ( + "CompletionResponseFormat", + "fabro_types::ResponseFormat", + &[], + ), + ("CompletionCost", "fabro_types::Cost", &[]), ("WorkflowVersion", "fabro_types::WorkflowVersion", &[]), ("RunIntent", "fabro_types::RunIntent", &[]), ("RunIntentArgs", "fabro_types::RunIntentArgs", &[]), @@ -746,7 +759,7 @@ fn main() { ("WorkflowPath", "fabro_types::WorkflowPath", &[]), ("WorkflowVersionId", "fabro_types::WorkflowVersionId", &[]), ("BlobHash", "fabro_types::BlobHash", &[]), - ("CostSource", "fabro_model::CostSource", &[]), + ("CostSource", "fabro_types::CostSource", &[]), ]; for (name, path, impls) in replacements { settings.with_replacement(*name, *path, impls.iter().copied()); diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index 9a012aebd..58a98e6b0 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -19,11 +19,6 @@ pub mod types { AutomationReplace as ReplaceAutomationRequest, AutomationTrigger, }; pub use fabro_environment::Environment; - pub use fabro_model::{ - CostSource, Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, - ModelRef as BillingModelRef, ModelTestMode, Provider, ReasoningEffort, - ReasoningEffortFeature, Speed as BillingSpeed, TokenCounts as CompletionUsage, - }; pub use fabro_types::run_event::AgentSessionActivatedProps; pub use fabro_types::settings::run::{ McpHttpProtocol, RunIntegrationsGithubSettings, RunIntegrationsSettings, RunModelControls, @@ -46,37 +41,43 @@ pub mod types { ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary, AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash, - CommandTermination, Conclusion, ContentPart, CreateVariableRequest, DiffStats, DiffSummary, - DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail, - FailureSignature, GitContext, GitRunTarget, GitRunTarget as AutomationGitWorkflowSource, - IdpIdentity, IntegrationConnectionKind, IntegrationConnectionState, - IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, InterviewOption, - InterviewQuestionRecord, LlmOutputKind, McpServerDraft as CreateMcpServerRequest, - McpServerProjection, McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, - McpServerView as McpServer, McpTransportView, Message, PairId, PairMessageId, + CommandTermination, Conclusion, ContentPart, Cost as CompletionCost, CostSource, + CreateVariableRequest, DiffStats, DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, + FailureCategory, FailureDetail, FailureSignature, GitContext, GitRunTarget, + GitRunTarget as AutomationGitWorkflowSource, IdpIdentity, IntegrationConnectionKind, + IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider, + IntegrationStatus, InterviewOption, InterviewQuestionRecord, LlmOutputKind, + McpServerDraft as CreateMcpServerRequest, McpServerProjection, + McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, McpServerView as McpServer, + McpTransportView, Message, Model, ModelControls, ModelCosts, ModelFeatures, ModelHandle, + ModelLimits, ModelRef as BillingModelRef, ModelTestMode, PairId, PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry, PairTranscriptResponse, ParallelBranchId, - ParallelBranchResult, PendingInterviewRecord, PermissionLevel, Principal, PullRequest, - PullRequestCreation, PullRequestCreationId, PullRequestCreationStatus, PullRequestDetails, - PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink, - PullRequestMeta, PullRequestResponse, QuestionType, ReasoningOutput, RepositoryRef, - ReviewTarget, ReviewTargetKind, Role, Run, RunApproval, RunApprovalState, - RunClientProvenance, RunEvent, RunEventDetailContentKind, RunEventDetailResponse, - RunFailure, RunIntent, RunIntentArgs, RunPairStatusResponse, RunProjection, RunProvenance, - RunRunnableSource, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, - RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, RunSize, RunTarget, SandboxDetails, - SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy, - SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError, - SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState, - SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, - SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn, - SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowBreakdownItem, - StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection, - StageContextWindowStaleness, StageContextWindowUnavailableReason, - StageContextWindowWarning, StageHandler, StageId, StageInferenceProjection, - StageModelUsage, StageOutcome, StageProjection, StageState, StageToolBatchProjection, - SubAgentProjection, SubAgentStatus, SystemActorKind, SystemIntegrationStatus, - SystemIntegrationsResponse, TodoListProjection, TurnId, UpdateVariableRequest, + ParallelBranchResult, PendingInterviewRecord, PermissionLevel, Principal, Provider, + ProviderId, PullRequest, PullRequestCreation, PullRequestCreationId, + PullRequestCreationStatus, PullRequestDetails, PullRequestDetailsStatus, + PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse, + QuestionType, ReasoningEffort, ReasoningOutput, RepositoryRef, + ResponseFormat as CompletionResponseFormat, ReviewTarget, ReviewTargetKind, Role, Run, + RunApproval, RunApprovalState, RunClientProvenance, RunEvent, RunEventDetailContentKind, + RunEventDetailResponse, RunFailure, RunIntent, RunIntentArgs, RunPairStatusResponse, + RunProjection, RunProvenance, RunRunnableSource, RunSandbox, RunSandboxFailure, + RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, + RunSize, RunTarget, SandboxDetails, SandboxInfo, SandboxListMeta, SandboxListResponse, + SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProviderKind, + SandboxProviderLookupError, SandboxResources, SandboxService, SandboxServiceListResponse, + SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, + SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn, + SkillsProjection, Speed as BillingSpeed, StageCompletion, StageContextWindow, + StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, + StageContextWindowProjection, StageContextWindowStaleness, + StageContextWindowUnavailableReason, StageContextWindowWarning, StageHandler, StageId, + StageInferenceProjection, StageModelUsage, StageOutcome, StageProjection, StageState, + StageToolBatchProjection, SubAgentProjection, SubAgentStatus, SystemActorKind, + SystemIntegrationStatus, SystemIntegrationsResponse, TodoListProjection, + TokenCounts as CompletionUsage, ToolChoice as CompletionToolChoice, + ToolDefinition as CompletionToolDefinition, + ToolDefinitionKind as CompletionToolDefinitionKind, TurnId, UpdateVariableRequest, UserPrincipal, Variable, VariableListResponse, WorkflowPath, WorkflowSettings, WorkflowVersion, WorkflowVersionId, }; diff --git a/lib/foundation/fabro-api/tests/completion_message_round_trip.rs b/lib/foundation/fabro-api/tests/completion_message_round_trip.rs index 8e73a41e5..3b4fc1419 100644 --- a/lib/foundation/fabro-api/tests/completion_message_round_trip.rs +++ b/lib/foundation/fabro-api/tests/completion_message_round_trip.rs @@ -1,8 +1,8 @@ //! Proves the `CompletionMessage` / `CompletionMessageRole` / -//! `CompletionContentPart` OpenAPI schemas are served by the canonical -//! `fabro_types::{Message, Role, ContentPart}` via build.rs -//! `with_replacement`, and that the canonical serde output matches the -//! wire shape the spec describes. +//! `CompletionContentPart` OpenAPI schemas are served by the lithos +//! `Message`, `Role`, and `ContentPart` types re-exported from `fabro_types` +//! via build.rs `with_replacement`, and that the lithos serde output matches +//! the wire shape the spec describes. use std::any::{TypeId, type_name}; @@ -21,10 +21,10 @@ fn completion_message_reuses_domain_types() { fn role_json_matches_openapi_enum() { for (role, wire) in [ (Role::System, "system"), + (Role::Developer, "developer"), (Role::User, "user"), (Role::Assistant, "assistant"), (Role::Tool, "tool"), - (Role::Developer, "developer"), ] { assert_eq!(serde_json::to_value(role).unwrap(), json!(wire)); assert_eq!( @@ -39,27 +39,32 @@ fn role_json_matches_openapi_enum() { fn message_json_matches_openapi_shape() { // Optional fields are omitted, not serialized as null. assert_eq!( - serde_json::to_value(Message::user("hello")).unwrap(), + serde_json::to_value(Message::text(Role::User, "hello")).unwrap(), json!({ "role": "user", - "content": [{"kind": "text", "data": "hello"}] + "content": [{"type": "text", "text": "hello"}] }) ); - // Populated optionals appear under the spec's property names. - let mut message = Message::tool_result("call_1", json!("ok"), false); - message.name = Some("checker".to_string()); + let message = Message::new(Role::Tool, vec![ContentPart::ToolResult(ToolResult { + tool_call_id: "call_1".to_string(), + name: None, + content: vec![ContentPart::Text { + text: "ok".to_string(), + }], + is_error: false, + })]) + .with_name("checker") + .with_tool_call_id("call_1"); assert_eq!( serde_json::to_value(message).unwrap(), json!({ "role": "tool", "content": [{ - "kind": "tool_result", - "data": { - "tool_call_id": "call_1", - "content": "ok", - "is_error": false - } + "type": "tool_result", + "tool_call_id": "call_1", + "content": [{"type": "text", "text": "ok"}], + "is_error": false }], "name": "checker", "tool_call_id": "call_1" @@ -68,77 +73,27 @@ fn message_json_matches_openapi_shape() { } #[test] -fn message_accepts_explicit_nulls_for_optionals() { - // The previously generated API type serialized absent optionals as - // explicit nulls; inbound payloads in that older shape must keep - // parsing. - let message: Message = serde_json::from_value(json!({ - "role": "assistant", - "content": [{"kind": "text", "data": "hi"}], - "name": null, - "tool_call_id": null - })) - .unwrap(); - assert_eq!(message.role, Role::Assistant); - assert_eq!(message.name, None); - assert_eq!(message.tool_call_id, None); +fn tool_call_part_json_matches_lithos_shape() { + let part = ContentPart::ToolCall(ToolCall::function( + "call_1", + "write_workflow_file", + json!({"file_name": "workflow.fabro"}), + )); + let json = serde_json::to_value(&part).unwrap(); + assert_eq!(json["type"], "tool_call"); + assert_eq!(json["id"], "call_1"); + assert_eq!(json["name"], "write_workflow_file"); + let round_trip: ContentPart = serde_json::from_value(json).unwrap(); + assert_eq!(round_trip, part); } #[test] -fn content_part_json_matches_openapi_envelope() { - // The spec describes a `{kind, data}` envelope; every variant must - // serialize into it. - assert_eq!( - serde_json::to_value(ContentPart::text("hi")).unwrap(), - json!({"kind": "text", "data": "hi"}) - ); - - assert_eq!( - serde_json::to_value(ContentPart::ToolCall(ToolCall::new( - "call_1", - "write_workflow_file", - json!({"file_name": "workflow.fabro"}), - ))) - .unwrap(), - json!({ - "kind": "tool_call", - "data": { - "id": "call_1", - "name": "write_workflow_file", - "type": "function", - "arguments": {"file_name": "workflow.fabro"}, - "raw_arguments": null - } - }) - ); - - assert_eq!( - serde_json::to_value(ContentPart::ToolResult(ToolResult::success( - "call_1", - json!("done"), - ))) - .unwrap(), - json!({ - "kind": "tool_result", - "data": { - "tool_call_id": "call_1", - "content": "done", - "is_error": false - } - }) - ); -} - -#[test] -fn content_part_preserves_unknown_kinds() { - // The spec leaves `kind` open-ended; unknown kinds must round-trip - // (previously the handler conversion silently dropped them). - let wire = json!({"kind": "mystery", "data": {"x": 1}}); +fn content_part_preserves_unknown_types() { + // The spec leaves `type` open-ended; unknown types must round-trip so a + // newer writer's parts survive an older reader. + let wire = json!({"type": "mystery", "x": 1}); let part: ContentPart = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!(part, ContentPart::Other { - kind: "mystery".to_string(), - data: json!({"x": 1}), - }); + assert!(matches!(part, ContentPart::Unknown(_))); assert_eq!(serde_json::to_value(part).unwrap(), wire); } diff --git a/lib/foundation/fabro-api/tests/completion_usage_round_trip.rs b/lib/foundation/fabro-api/tests/completion_usage_round_trip.rs index 58da0288b..5c38c4e74 100644 --- a/lib/foundation/fabro-api/tests/completion_usage_round_trip.rs +++ b/lib/foundation/fabro-api/tests/completion_usage_round_trip.rs @@ -1,7 +1,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::CompletionUsage as ApiCompletionUsage; -use fabro_model::TokenCounts; +use fabro_types::TokenCounts; use serde_json::json; #[test] @@ -12,40 +12,36 @@ fn completion_usage_reuses_canonical_type() { #[test] fn completion_usage_json_matches_openapi_shape() { let usage = TokenCounts { - input_tokens: 10, - output_tokens: 20, - reasoning_tokens: 3, - cache_read_tokens: 4, - cache_write_tokens: 5, + input: 10, + output: 20, + reasoning: 3, + cache_read: 4, + cache_write: 5, }; - let json = serde_json::to_value(&usage).unwrap(); - assert_eq!(json["input_tokens"], 10); - assert_eq!(json["output_tokens"], 20); - assert_eq!(json["reasoning_tokens"], 3); - assert_eq!(json["cache_read_tokens"], 4); - assert_eq!(json["cache_write_tokens"], 5); + let json = serde_json::to_value(usage).unwrap(); + assert_eq!( + json, + json!({ + "input": 10, + "output": 20, + "reasoning": 3, + "cache_read": 4, + "cache_write": 5 + }) + ); let round_trip: ApiCompletionUsage = serde_json::from_value(json).unwrap(); assert_eq!(round_trip, usage); } #[test] -fn completion_usage_keeps_zero_counts_present() { - let json = serde_json::to_value(TokenCounts::default()).unwrap(); - assert_eq!( - json, - json!({ - "input_tokens": 0, - "output_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }) - ); - - let round_trip: ApiCompletionUsage = serde_json::from_value(json).unwrap(); - assert_eq!(round_trip, TokenCounts::default()); +fn completion_usage_missing_buckets_default_to_zero() { + let round_trip: ApiCompletionUsage = serde_json::from_value(json!({"input": 7})).unwrap(); + assert_eq!(round_trip, TokenCounts { + input: 7, + ..TokenCounts::default() + }); } fn assert_same_type() { diff --git a/lib/foundation/fabro-api/tests/cost_source_round_trip.rs b/lib/foundation/fabro-api/tests/cost_source_round_trip.rs index c97d0280f..4b69183d6 100644 --- a/lib/foundation/fabro-api/tests/cost_source_round_trip.rs +++ b/lib/foundation/fabro-api/tests/cost_source_round_trip.rs @@ -1,29 +1,39 @@ use std::any::{TypeId, type_name}; -use fabro_api::types::CostSource as ApiCostSource; -use fabro_model::CostSource; +use fabro_api::types::{CompletionCost as ApiCost, CostSource as ApiCostSource}; +use fabro_types::{Cost, CostSource}; use serde_json::json; #[test] -fn cost_source_reuses_canonical_type() { +fn cost_types_reuse_lithos_types() { assert_same_type::(); + assert_same_type::(); } #[test] fn cost_source_json_matches_openapi_shape() { - assert_eq!( - serde_json::to_value(CostSource::Authoritative).unwrap(), - json!("authoritative") - ); - assert_eq!( - serde_json::to_value(CostSource::Estimated).unwrap(), - json!("estimated") - ); + for (source, wire) in [ + (CostSource::Catalog, "catalog"), + (CostSource::Provider, "provider"), + (CostSource::Application, "application"), + ] { + assert_eq!(serde_json::to_value(source).unwrap(), json!(wire)); + assert_eq!( + serde_json::from_value::(json!(wire)).unwrap(), + source + ); + } +} - assert_eq!( - serde_json::from_value::(json!("estimated")).unwrap(), - CostSource::Estimated - ); +#[test] +fn cost_json_matches_openapi_shape() { + let cost = Cost { + usd_micros: 125_000, + source: CostSource::Provider, + }; + let json = serde_json::to_value(cost).unwrap(); + assert_eq!(json, json!({"usd_micros": 125000, "source": "provider"})); + assert_eq!(serde_json::from_value::(json).unwrap(), cost); } fn assert_same_type() { diff --git a/lib/foundation/fabro-api/tests/create_completion_request_round_trip.rs b/lib/foundation/fabro-api/tests/create_completion_request_round_trip.rs index 084827149..9abcfa7b2 100644 --- a/lib/foundation/fabro-api/tests/create_completion_request_round_trip.rs +++ b/lib/foundation/fabro-api/tests/create_completion_request_round_trip.rs @@ -1,14 +1,37 @@ use fabro_api::types::CreateCompletionRequest; -use fabro_model::ReasoningEffort; +use fabro_types::{ReasoningEffort, ResponseFormat, Speed, ToolChoice, ToolDefinitionKind}; use serde_json::json; #[test] -fn create_completion_request_reuses_canonical_reasoning_effort() { +fn create_completion_request_reuses_lithos_vocabulary() { let request: CreateCompletionRequest = serde_json::from_value(json!({ - "messages": [], - "reasoning_effort": "high" + "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}], + "model": "openai/gpt-5.4", + "reasoning_effort": "high", + "speed": "fast", + "tools": [{ + "name": "lookup", + "description": "Look something up", + "kind": {"type": "function", "input_schema": {"type": "object"}} + }], + "tool_choice": {"type": "tool", "name": "lookup"}, + "response_format": {"type": "json_object"} })) .unwrap(); assert_eq!(request.reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!(request.speed, Some(Speed::Fast)); + assert_eq!(request.tools.len(), 1); + assert!(matches!( + request.tools[0].kind, + ToolDefinitionKind::Function { .. } + )); + assert_eq!( + request.tool_choice, + Some(ToolChoice::Tool { + name: "lookup".to_string(), + }) + ); + assert_eq!(request.response_format, Some(ResponseFormat::JsonObject)); + assert_eq!(request.messages[0].content().len(), 1); } diff --git a/lib/foundation/fabro-api/tests/model_costs_round_trip.rs b/lib/foundation/fabro-api/tests/model_costs_round_trip.rs index c9a318957..e41fcee9a 100644 --- a/lib/foundation/fabro-api/tests/model_costs_round_trip.rs +++ b/lib/foundation/fabro-api/tests/model_costs_round_trip.rs @@ -1,7 +1,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::ModelCosts as ApiModelCosts; -use fabro_model::ModelCosts; +use fabro_types::ModelCosts; #[test] fn model_costs_reuses_canonical_type() { @@ -16,7 +16,7 @@ fn model_costs_json_matches_openapi_shape() { cache_input_cost_per_mtok: Some(0.5), }; - let json = serde_json::to_value(&costs).unwrap(); + let json = serde_json::to_value(costs).unwrap(); assert_eq!(json["input_cost_per_mtok"], 5.0); assert_eq!(json["output_cost_per_mtok"], 25.0); assert_eq!(json["cache_input_cost_per_mtok"], 0.5); diff --git a/lib/foundation/fabro-api/tests/model_features_round_trip.rs b/lib/foundation/fabro-api/tests/model_features_round_trip.rs deleted file mode 100644 index 42f596665..000000000 --- a/lib/foundation/fabro-api/tests/model_features_round_trip.rs +++ /dev/null @@ -1,44 +0,0 @@ -use std::any::{TypeId, type_name}; - -use fabro_api::types::ModelFeatures as ApiModelFeatures; -use fabro_model::{ModelFeatures, ReasoningEffortFeature}; - -#[test] -fn model_features_reuses_canonical_type() { - assert_same_type::(); -} - -#[test] -fn model_features_json_matches_openapi_shape() { - let features = ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: false, - cache_control_breakpoints: false, - sampling_params: true, - }; - - let json = serde_json::to_value(&features).unwrap(); - assert_eq!(json["tools"], true); - assert_eq!(json["vision"], true); - assert_eq!(json["reasoning"], true); - assert_eq!(json["reasoning_effort"], "levels"); - assert_eq!(json["prompt_cache"], false); - assert_eq!(json["cache_control_breakpoints"], false); - assert_eq!(json["sampling_params"], true); - - let round_trip: ApiModelFeatures = serde_json::from_value(json).unwrap(); - assert_eq!(round_trip, features); -} - -fn assert_same_type() { - assert_eq!( - TypeId::of::(), - TypeId::of::(), - "{} should be the same type as {}", - type_name::(), - type_name::() - ); -} diff --git a/lib/foundation/fabro-api/tests/model_limits_round_trip.rs b/lib/foundation/fabro-api/tests/model_limits_round_trip.rs index 0f2ed609f..c2a1d41e2 100644 --- a/lib/foundation/fabro-api/tests/model_limits_round_trip.rs +++ b/lib/foundation/fabro-api/tests/model_limits_round_trip.rs @@ -1,7 +1,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::ModelLimits as ApiModelLimits; -use fabro_model::ModelLimits; +use fabro_types::ModelLimits; #[test] fn model_limits_reuses_canonical_type() { @@ -15,7 +15,7 @@ fn model_limits_json_matches_openapi_shape() { max_output: Some(128_000), }; - let json = serde_json::to_value(&limits).unwrap(); + let json = serde_json::to_value(limits).unwrap(); assert_eq!(json["context_window"], 1_000_000); assert_eq!(json["max_output"], 128_000); diff --git a/lib/foundation/fabro-api/tests/model_round_trip.rs b/lib/foundation/fabro-api/tests/model_round_trip.rs index 0decc177f..0a18443f1 100644 --- a/lib/foundation/fabro-api/tests/model_round_trip.rs +++ b/lib/foundation/fabro-api/tests/model_round_trip.rs @@ -1,9 +1,8 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{Model as ApiModel, ModelControls as ApiModelControls}; -use fabro_model::{ - Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ProviderId, ReasoningEffort, - ReasoningEffortFeature, +use fabro_types::{ + Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffort, provider_ids, }; #[test] @@ -15,8 +14,8 @@ fn model_reuses_canonical_type() { #[test] fn model_json_matches_openapi_shape() { let model = Model { - id: "claude-opus-4-7".into(), - provider: ProviderId::anthropic(), + id: "claude-opus-4.7".into(), + provider: provider_ids::anthropic(), family: "claude-4".to_string(), display_name: "Claude Opus 4.7".to_string(), limits: ModelLimits { @@ -26,13 +25,11 @@ fn model_json_matches_openapi_shape() { training: Some("2025-08-01".to_string()), knowledge_cutoff: Some("May 2025".to_string()), features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, + tools: true, + vision: true, + reasoning: true, + prompt_cache: true, + sampling: true, }, controls: ModelControls { reasoning_effort: vec![ @@ -54,11 +51,11 @@ fn model_json_matches_openapi_shape() { }; let json = serde_json::to_value(&model).unwrap(); - assert_eq!(json["id"], "claude-opus-4-7"); + assert_eq!(json["id"], "claude-opus-4.7"); assert_eq!(json["provider"], "anthropic"); assert_eq!(json["knowledge_cutoff"], "May 2025"); - assert_eq!(json["features"]["reasoning_effort"], "levels"); assert_eq!(json["features"]["prompt_cache"], true); + assert_eq!(json["features"]["sampling"], true); assert_eq!( json["controls"]["reasoning_effort"], serde_json::json!(["low", "high", "max"]) diff --git a/lib/foundation/fabro-api/tests/model_test_mode_round_trip.rs b/lib/foundation/fabro-api/tests/model_test_mode_round_trip.rs index 157e4e5f2..6fef217a2 100644 --- a/lib/foundation/fabro-api/tests/model_test_mode_round_trip.rs +++ b/lib/foundation/fabro-api/tests/model_test_mode_round_trip.rs @@ -1,7 +1,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::ModelTestMode as ApiModelTestMode; -use fabro_model::ModelTestMode; +use fabro_types::ModelTestMode; use serde_json::json; #[test] diff --git a/lib/foundation/fabro-api/tests/provider_id_round_trip.rs b/lib/foundation/fabro-api/tests/provider_id_round_trip.rs index 2c834ac3f..877cc39ef 100644 --- a/lib/foundation/fabro-api/tests/provider_id_round_trip.rs +++ b/lib/foundation/fabro-api/tests/provider_id_round_trip.rs @@ -1,65 +1,34 @@ use std::any::{TypeId, type_name}; -use fabro_api::types::Model as ApiModel; -use fabro_model::{ - Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ProviderId, - ReasoningEffortFeature, -}; +use fabro_api::types::{ModelHandle as ApiModelHandle, ProviderId as ApiProviderId}; +use fabro_types::{ModelHandle, ModelId, ProviderId, provider_ids}; use serde_json::json; #[test] -fn provider_id_reuses_canonical_model_field_type() { - assert_same_type::(); +fn provider_id_and_model_handle_reuse_lithos_types() { + assert_same_type::(); + assert_same_type::(); } #[test] -fn provider_id_json_matches_openapi_shape_through_model() { +fn provider_id_json_is_a_bare_string() { assert_eq!( - serde_json::to_value(ProviderId::anthropic()).unwrap(), + serde_json::to_value(provider_ids::anthropic()).unwrap(), json!("anthropic") ); assert_eq!( - serde_json::to_value(ProviderId::openai()).unwrap(), - json!("openai") + serde_json::from_value::(json!("venice")).unwrap(), + ProviderId::new("venice") ); +} - let model = Model { - id: "venice-custom".into(), - provider: ProviderId::new("venice"), - family: "venice".to_string(), - display_name: "Venice Custom".to_string(), - limits: ModelLimits { - context_window: 128_000, - max_output: None, - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: false, - vision: false, - reasoning: false, - reasoning_effort: ReasoningEffortFeature::None, - prompt_cache: false, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls::default(), - costs: ModelCosts { - input_cost_per_mtok: None, - output_cost_per_mtok: None, - cache_input_cost_per_mtok: None, - }, - estimated_output_tps: None, - aliases: Vec::new(), - default: false, - small_default: false, - configured: true, - }; - - let json = serde_json::to_value(&model).unwrap(); - assert_eq!(json["provider"], "venice"); - let round_trip: ApiModel = serde_json::from_value(json).unwrap(); - assert_eq!(round_trip.provider, ProviderId::new("venice")); +#[test] +fn model_handle_json_matches_openapi_shape() { + let handle = ModelHandle::new(provider_ids::openai(), ModelId::new("gpt-5.4")); + let json = serde_json::to_value(&handle).unwrap(); + assert_eq!(json, json!({"provider": "openai", "model": "gpt-5.4"})); + let round_trip: ApiModelHandle = serde_json::from_value(json).unwrap(); + assert_eq!(round_trip, handle); } fn assert_same_type() { diff --git a/lib/foundation/fabro-api/tests/provider_round_trip.rs b/lib/foundation/fabro-api/tests/provider_round_trip.rs index 44537896a..e15492123 100644 --- a/lib/foundation/fabro-api/tests/provider_round_trip.rs +++ b/lib/foundation/fabro-api/tests/provider_round_trip.rs @@ -1,8 +1,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::Provider as ApiProvider; -use fabro_model::adapter::AdapterKind; -use fabro_model::{Provider, ProviderId}; +use fabro_types::{Provider, ProviderId, provider_ids}; #[test] fn provider_reuses_canonical_type() { @@ -12,15 +11,15 @@ fn provider_reuses_canonical_type() { #[test] fn provider_json_matches_openapi_shape() { let provider = Provider { - id: ProviderId::anthropic(), + id: provider_ids::anthropic(), display_name: "Anthropic".to_string(), - adapter: AdapterKind::Anthropic, - base_url: Some("https://api.anthropic.test/v1".to_string()), + adapter: "anthropic".to_string(), + base_url: "https://api.anthropic.test".to_string(), api_key_url: Some("https://console.anthropic.com/settings/keys".to_string()), priority: 100, aliases: vec!["claude".to_string()], model_count: 7, - default_model: Some("claude-opus-4-7".to_string()), + default_model: Some("claude-opus-4.7".to_string()), configured: true, expected_secret_name: Some("ANTHROPIC_API_KEY".to_string()), }; @@ -29,7 +28,7 @@ fn provider_json_matches_openapi_shape() { assert_eq!(json["id"], "anthropic"); assert_eq!(json["display_name"], "Anthropic"); assert_eq!(json["adapter"], "anthropic"); - assert_eq!(json["base_url"], "https://api.anthropic.test/v1"); + assert_eq!(json["base_url"], "https://api.anthropic.test"); assert_eq!( json["api_key_url"], "https://console.anthropic.com/settings/keys" @@ -37,7 +36,7 @@ fn provider_json_matches_openapi_shape() { assert_eq!(json["priority"], 100); assert_eq!(json["aliases"], serde_json::json!(["claude"])); assert_eq!(json["model_count"], 7); - assert_eq!(json["default_model"], "claude-opus-4-7"); + assert_eq!(json["default_model"], "claude-opus-4.7"); assert_eq!(json["configured"], true); assert_eq!(json["expected_secret_name"], "ANTHROPIC_API_KEY"); @@ -48,13 +47,13 @@ fn provider_json_matches_openapi_shape() { #[test] fn provider_omits_optional_fields_when_absent() { // Proves the required/optional split the OpenAPI `Provider` schema - // declares: the five `skip_serializing_if` fields drop out entirely, while - // the six required fields always serialize. + // declares: the four `skip_serializing_if` fields drop out entirely, while + // the required fields always serialize. let provider = Provider { id: ProviderId::new("custom"), display_name: "Custom".to_string(), - adapter: AdapterKind::OpenAiCompatible, - base_url: None, + adapter: "openai-compatible".to_string(), + base_url: "https://custom.test/v1".to_string(), api_key_url: None, priority: 0, aliases: Vec::new(), @@ -66,17 +65,21 @@ fn provider_omits_optional_fields_when_absent() { let json = serde_json::to_value(&provider).unwrap(); let object = json.as_object().unwrap(); - assert!(!object.contains_key("base_url")); assert!(!object.contains_key("api_key_url")); assert!(!object.contains_key("aliases")); assert!(!object.contains_key("default_model")); assert!(!object.contains_key("expected_secret_name")); - assert!(object.contains_key("id")); - assert!(object.contains_key("display_name")); - assert!(object.contains_key("adapter")); - assert!(object.contains_key("priority")); - assert!(object.contains_key("model_count")); - assert!(object.contains_key("configured")); + for key in [ + "id", + "display_name", + "adapter", + "base_url", + "priority", + "model_count", + "configured", + ] { + assert!(object.contains_key(key), "{key} should serialize"); + } let round_trip: ApiProvider = serde_json::from_value(json).unwrap(); assert_eq!(round_trip, provider); diff --git a/lib/foundation/fabro-api/tests/run_billing_stage_round_trip.rs b/lib/foundation/fabro-api/tests/run_billing_stage_round_trip.rs index be8d85747..4dec969dd 100644 --- a/lib/foundation/fabro-api/tests/run_billing_stage_round_trip.rs +++ b/lib/foundation/fabro-api/tests/run_billing_stage_round_trip.rs @@ -1,8 +1,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{BillingByModel, BillingModelRef, BillingSpeed, RunBillingStage}; -use fabro_model::{ModelRef, Speed}; -use fabro_types::StageState; +use fabro_types::{ModelRef, Speed, StageState}; use serde_json::json; #[test] diff --git a/lib/foundation/fabro-api/tests/session_contract_round_trip.rs b/lib/foundation/fabro-api/tests/session_contract_round_trip.rs index f987f73f8..0afb3c683 100644 --- a/lib/foundation/fabro-api/tests/session_contract_round_trip.rs +++ b/lib/foundation/fabro-api/tests/session_contract_round_trip.rs @@ -5,7 +5,6 @@ use fabro_api::types::{ SessionDetail as ApiSessionDetail, SessionRecord as ApiSessionRecord, SessionSummary as ApiSessionSummary, SessionTurn as ApiSessionTurn, SubmitTurnRequest, }; -use fabro_model::ProviderId; use fabro_types::{ SessionDetail, SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn, TurnId, fixtures, @@ -34,7 +33,7 @@ fn session_detail_round_trips_messages_active_turn_and_last_seq() { title: Some("Ask Fabro".to_string()), status: SessionStatus::Running, model: Some("gpt-5.4".to_string()), - provider: Some(ProviderId::openai()), + provider: Some(fabro_types::provider_ids::openai()), active_turn: Some(SessionTurn { id: turn_id, started_at: turn_started_at, diff --git a/lib/foundation/fabro-api/tests/stage_model_usage_round_trip.rs b/lib/foundation/fabro-api/tests/stage_model_usage_round_trip.rs index 1d541cdd7..0b57cf53a 100644 --- a/lib/foundation/fabro-api/tests/stage_model_usage_round_trip.rs +++ b/lib/foundation/fabro-api/tests/stage_model_usage_round_trip.rs @@ -3,8 +3,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{ ReasoningEffort as ApiReasoningEffort, StageModelUsage as ApiStageModelUsage, }; -use fabro_model::{ReasoningEffort, Speed}; -use fabro_types::StageModelUsage; +use fabro_types::{ReasoningEffort, Speed, StageModelUsage}; use serde_json::json; #[test] @@ -15,10 +14,11 @@ fn reasoning_effort_reuses_canonical_type() { #[test] fn reasoning_effort_round_trips_openapi_values() { for (value, effort) in [ + ("minimal", ReasoningEffort::Minimal), ("low", ReasoningEffort::Low), ("medium", ReasoningEffort::Medium), ("high", ReasoningEffort::High), - ("xhigh", ReasoningEffort::XHigh), + ("xhigh", ReasoningEffort::Xhigh), ("max", ReasoningEffort::Max), ] { assert_eq!( diff --git a/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs b/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs index c371ba368..baa71e871 100644 --- a/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs +++ b/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs @@ -22,16 +22,16 @@ use fabro_api::types::{ SubAgentProjection as ApiSubAgentProjection, SubAgentStatus as ApiSubAgentStatus, TodoListProjection as ApiTodoListProjection, }; -use fabro_model::{ModelId, ModelRef, ProviderId, Speed}; use fabro_types::{ ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary, - AgentToolsAvailableProps, LlmOutputKind, McpServerProjection, McpServerStatus, - ParallelBranchId, ParallelBranchResult, PermissionLevel, SkillsProjection, StageContextWindow, - StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, - StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowUnavailableReason, - StageContextWindowWarning, StageId, StageInferenceProjection, StageProjection, - StageToolBatchProjection, SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection, + AgentToolsAvailableProps, LlmOutputKind, McpServerProjection, McpServerStatus, ModelId, + ModelRef, ParallelBranchId, ParallelBranchResult, PermissionLevel, ProviderId, + SkillsProjection, Speed, StageContextWindow, StageContextWindowBreakdownItem, + StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection, + StageContextWindowStaleness, StageContextWindowUnavailableReason, StageContextWindowWarning, + StageId, StageInferenceProjection, StageProjection, StageToolBatchProjection, + SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection, }; use serde_json::json; diff --git a/lib/foundation/fabro-auth/Cargo.toml b/lib/foundation/fabro-auth/Cargo.toml index 8fcfa13b0..81ffce750 100644 --- a/lib/foundation/fabro-auth/Cargo.toml +++ b/lib/foundation/fabro-auth/Cargo.toml @@ -18,12 +18,12 @@ async-trait.workspace = true base64.workspace = true chrono = { workspace = true, features = ["serde"] } fabro-http.workspace = true -fabro-model = { path = "../fabro-model" } fabro-oauth = { path = "../fabro-oauth" } fabro-redact.workspace = true fabro-static.workspace = true fabro-types = { path = "../fabro-types" } fabro-vault = { path = "../fabro-vault" } +lithos-llm = { workspace = true, features = ["runtime", "environment-credentials", "bedrock-aws"] } serde.workspace = true serde_json.workspace = true thiserror.workspace = true @@ -32,6 +32,7 @@ tracing.workspace = true [dev-dependencies] httpmock = "0.8" +lithos-llm = { workspace = true, features = ["runtime", "builtin-catalog"] } tempfile = "3" tokio = { workspace = true, features = ["macros", "test-util"] } toml.workspace = true diff --git a/lib/foundation/fabro-auth/src/api_key_source.rs b/lib/foundation/fabro-auth/src/api_key_source.rs new file mode 100644 index 000000000..17b706498 --- /dev/null +++ b/lib/foundation/fabro-auth/src/api_key_source.rs @@ -0,0 +1,150 @@ +//! A credential source holding one operator-supplied API key. +//! +//! Used to validate a pasted key before it is stored: the key stands in for +//! the first secret the provider conventionally reads, so lithos shapes it +//! into the provider's auth scheme exactly as a stored secret would be. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use fabro_vault::Vault; +use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId}; +use lithos_llm::credentials::{ConventionalCredentials, CredentialProvider, Credentials}; +use tokio::sync::RwLock as AsyncRwLock; + +use crate::credential_source::CredentialSource; +use crate::error::ResolveError; +use crate::secrets::expected_secret_name; +use crate::vault_source::{auth_scheme_name, interpolated_headers, resolve_error}; + +pub struct ApiKeyCredentialSource { + provider: ProviderId, + key: String, + vault: Arc>, +} + +impl ApiKeyCredentialSource { + /// A source for `provider` with no vault behind it, so header secrets the + /// provider interpolates from the vault fail to resolve. + #[must_use] + pub fn new(provider: ProviderId, key: String) -> Self { + Self::with_vault( + provider, + key, + Arc::new(AsyncRwLock::new(Vault::from_entries(HashMap::new()))), + ) + } + + /// A source for `provider` whose interpolated headers resolve against + /// `vault`. + #[must_use] + pub fn with_vault(provider: ProviderId, key: String, vault: Arc>) -> Self { + Self { + provider, + key, + vault, + } + } +} + +impl std::fmt::Debug for ApiKeyCredentialSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ApiKeyCredentialSource") + .field("provider", &self.provider) + .finish_non_exhaustive() + } +} + +/// Shapes a caller-supplied API key into the provider's credentials. +pub(crate) async fn credentials_for_api_key( + provider: &CatalogProvider, + key: String, + vault: &Vault, +) -> Result { + let Some(name) = expected_secret_name(provider) else { + return Err(ResolveError::SchemeMismatch { + provider: provider.id().clone(), + scheme: auth_scheme_name(provider.auth()).to_string(), + }); + }; + let interpolated = interpolated_headers(vault, provider)?; + let mut credentials = ConventionalCredentials::new() + .with_lookup(move |candidate| (candidate == name).then(|| key.clone())) + .credentials(provider) + .await + .map_err(|err| resolve_error(provider, &err))?; + if let Credentials::Http(http) = &mut credentials { + http.extra_headers.extend(interpolated); + } + Ok(credentials) +} + +#[async_trait] +impl CredentialSource for ApiKeyCredentialSource { + async fn credentials(&self, provider: &CatalogProvider) -> Result { + if provider.id() != &self.provider { + return Err(ResolveError::NotConfigured(provider.id().clone())); + } + let vault = self.vault.read().await.clone(); + credentials_for_api_key(provider, self.key.clone(), &vault).await + } + + async fn configured_providers(&self, catalog: &Catalog) -> Vec { + catalog + .provider(self.provider.as_str()) + .ok() + .map(|provider| vec![provider.id().clone()]) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use lithos_llm::credentials::{HttpAuthentication, HttpCredentials}; + + use super::*; + use crate::secrets::accepts_api_key; + use crate::test_support::test_catalog; + + #[tokio::test] + async fn api_key_credentials_follow_the_provider_scheme() { + let catalog = test_catalog(); + let vault = Vault::from_entries(HashMap::new()); + let openai = credentials_for_api_key( + catalog.provider("openai").unwrap(), + "sk-test".to_string(), + &vault, + ) + .await + .unwrap(); + assert!(matches!( + openai, + Credentials::Http(HttpCredentials { + auth: HttpAuthentication::Bearer(secret), + .. + }) if secret.expose_secret() == "sk-test" + )); + let bedrock = credentials_for_api_key( + catalog.provider("bedrock").unwrap(), + "sk-test".to_string(), + &vault, + ) + .await + .unwrap(); + assert!(matches!(bedrock, Credentials::BedrockBearer(_))); + let modal = credentials_for_api_key( + catalog.provider("modal").unwrap(), + "sk-test".to_string(), + &vault, + ) + .await; + assert!(modal.is_err(), "modal has no single-key scheme"); + assert!(!accepts_api_key(catalog.provider("modal").unwrap())); + assert!(accepts_api_key(catalog.provider("openai").unwrap())); + assert!(accepts_api_key(catalog.provider("bedrock").unwrap())); + assert!(!accepts_api_key(catalog.provider("ollama").unwrap())); + } +} diff --git a/lib/foundation/fabro-auth/src/context.rs b/lib/foundation/fabro-auth/src/context.rs index 70c3b94eb..f8401b99e 100644 --- a/lib/foundation/fabro-auth/src/context.rs +++ b/lib/foundation/fabro-auth/src/context.rs @@ -1,4 +1,4 @@ -use fabro_model::ProviderId; +use lithos_llm::catalog::ProviderId; #[derive(Debug, Clone, PartialEq, Eq)] pub enum AuthContextRequest { diff --git a/lib/foundation/fabro-auth/src/credential.rs b/lib/foundation/fabro-auth/src/credential.rs index 95b195d61..0b185521f 100644 --- a/lib/foundation/fabro-auth/src/credential.rs +++ b/lib/foundation/fabro-auth/src/credential.rs @@ -1,5 +1,4 @@ use chrono::{DateTime, Duration, Utc}; -use fabro_redact::redact_string; pub use fabro_types::{OAuthConfig, OAuthCredential, OAuthTokens}; pub(crate) fn expires_at_from_now(expires_in: Option) -> DateTime { @@ -7,44 +6,6 @@ pub(crate) fn expires_at_from_now(expires_in: Option) -> DateTime { Utc::now() + Duration::seconds(seconds) } -#[derive(Clone, PartialEq, Eq)] -pub enum ApiKeyHeader { - Bearer(String), - Custom { - name: String, - value: String, - }, - /// No static header: the request is authenticated by AWS SigV4 signing, - /// with credentials resolved from the AWS default chain at request time. - AwsSigv4, -} - -fn redact_for_debug(value: &str) -> String { - let redacted = redact_string(value); - if redacted == value && !value.is_empty() { - "REDACTED".to_string() - } else { - redacted - } -} - -impl std::fmt::Debug for ApiKeyHeader { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Bearer(value) => f - .debug_tuple("Bearer") - .field(&redact_for_debug(value)) - .finish(), - Self::Custom { name, value } => f - .debug_struct("Custom") - .field("name", name) - .field("value", &redact_for_debug(value)) - .finish(), - Self::AwsSigv4 => f.write_str("AwsSigv4"), - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -81,12 +42,4 @@ mod tests { assert!(fixture(Utc::now() + Duration::minutes(4)).needs_refresh()); assert!(!fixture(Utc::now() + Duration::minutes(6)).needs_refresh()); } - - #[test] - fn api_key_header_debug_redacts_secret_values() { - let header = ApiKeyHeader::Bearer("sk-test".to_string()); - let debug = format!("{header:?}"); - assert!(!debug.contains("sk-test")); - assert!(debug.contains("REDACTED")); - } } diff --git a/lib/foundation/fabro-auth/src/credential_source.rs b/lib/foundation/fabro-auth/src/credential_source.rs index f611e629b..69b677385 100644 --- a/lib/foundation/fabro-auth/src/credential_source.rs +++ b/lib/foundation/fabro-auth/src/credential_source.rs @@ -1,17 +1,98 @@ +//! Per-attempt credential lookup for LLM providers. +//! +//! [`CredentialSource`] is Fabro's storage-aware credential seam: the vault, +//! the SQL secret store, and the process environment each implement it. +//! [`lithos_credentials`] adapts a source into the lithos +//! [`CredentialProvider`] the client calls before every provider attempt, so a +//! refreshed OAuth token is picked up by the next retry. + +use std::sync::Arc; + use async_trait::async_trait; -use fabro_model::{Catalog, ProviderId}; +use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId}; +use lithos_llm::credentials::{CredentialError, CredentialProvider, Credentials}; -use crate::{ApiCredential, ResolveError}; +use crate::{ResolveError, auth_issue_message}; -#[derive(Debug)] +/// Which providers a source can serve right now, and why the rest cannot. +#[derive(Debug, Default)] pub struct ResolvedCredentials { - pub credentials: Vec, + /// Enabled providers whose credentials resolved. + pub ready: Vec, + /// Enabled providers with credential material that failed to resolve, + /// such as an expired OAuth token that could not be refreshed. Providers + /// with no material at all are not issues; they are simply absent. pub auth_issues: Vec<(ProviderId, ResolveError)>, } +impl ResolvedCredentials { + /// A human-readable line per auth issue. + #[must_use] + pub fn issue_messages(&self) -> Vec { + self.auth_issues + .iter() + .map(|(provider, issue)| auth_issue_message(provider, issue)) + .collect() + } +} + #[async_trait] pub trait CredentialSource: Send + Sync { - async fn resolve(&self, catalog: &Catalog) -> anyhow::Result; + /// Resolves `provider`'s credentials for one request attempt. + async fn credentials(&self, provider: &CatalogProvider) -> Result; + /// Providers with credential material present. Does not refresh or + /// validate anything, so it is cheap enough for listings. async fn configured_providers(&self, catalog: &Catalog) -> Vec; + + /// Resolves every enabled provider once, separating the ready set from + /// the providers that have material but cannot use it. + async fn resolve_all(&self, catalog: &Catalog) -> ResolvedCredentials { + let mut resolved = ResolvedCredentials::default(); + for provider in catalog.providers().filter(|provider| provider.is_enabled()) { + match self.credentials(provider).await { + Ok(_) => resolved.ready.push(provider.id().clone()), + Err(ResolveError::NotConfigured(_)) => {} + Err(err) => resolved.auth_issues.push((provider.id().clone(), err)), + } + } + resolved + } +} + +/// Adapts a [`CredentialSource`] into the lithos credential provider. +#[must_use] +pub fn lithos_credentials(source: Arc) -> Arc { + Arc::new(SourceCredentialProvider { source }) +} + +struct SourceCredentialProvider { + source: Arc, +} + +#[async_trait] +impl CredentialProvider for SourceCredentialProvider { + async fn credentials( + &self, + provider: &CatalogProvider, + ) -> Result { + self.source.credentials(provider).await.map_err(|err| { + tracing::warn!( + provider = %provider.id(), + error = %err, + "LLM credentials could not be resolved for this attempt" + ); + match err { + ResolveError::NotConfigured(provider) => { + CredentialError::NotConfigured { provider } + } + ResolveError::SchemeMismatch { provider, .. } => { + CredentialError::SchemeMismatch { provider } + } + other => CredentialError::NotConfigured { + provider: other.provider().clone(), + }, + } + }) + } } diff --git a/lib/foundation/fabro-auth/src/env_source.rs b/lib/foundation/fabro-auth/src/env_source.rs deleted file mode 100644 index afe73f3a5..000000000 --- a/lib/foundation/fabro-auth/src/env_source.rs +++ /dev/null @@ -1,211 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use async_trait::async_trait; -use fabro_model::{Catalog, ProviderId}; -use fabro_static::EnvVars; -use fabro_vault::Vault; -use tokio::sync::RwLock as AsyncRwLock; - -use crate::resolve::apply_openai_codex_api_context; -use crate::{CredentialSource, EnvLookup, ResolvedCredentials, VaultCredentialSource}; - -/// A credential source for provider credentials declared as `env:`. -/// -/// This public SDK facade does not resolve `{{ env.NAME }}` settings -/// interpolation. Provider extra headers can use literals, but secret -/// interpolation requires a vault-backed source. -#[derive(Clone)] -pub struct EnvCredentialSource { - inner: VaultCredentialSource, - env_lookup: EnvLookup, -} - -impl EnvCredentialSource { - #[must_use] - #[expect( - clippy::disallowed_methods, - reason = "EnvCredentialSource is the provider credential process-env facade." - )] - pub fn new() -> Self { - Self::with_env_lookup(Arc::new(|name| std::env::var(name).ok())) - } - - #[must_use] - pub fn with_env_lookup(env_lookup: EnvLookup) -> Self { - let vault = Arc::new(AsyncRwLock::new(Vault::from_entries(HashMap::new()))); - let inner_lookup = Arc::clone(&env_lookup); - let inner = VaultCredentialSource::with_env_lookup(vault, move |name| inner_lookup(name)); - Self { inner, env_lookup } - } - - fn lookup(&self, name: &str) -> Option { - (self.env_lookup)(name) - } -} - -impl std::fmt::Debug for EnvCredentialSource { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EnvCredentialSource") - .finish_non_exhaustive() - } -} - -impl Default for EnvCredentialSource { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl CredentialSource for EnvCredentialSource { - async fn resolve(&self, catalog: &Catalog) -> anyhow::Result { - let mut resolved = self.inner.resolve(catalog).await?; - if let (Some(account_id), Some(credential)) = ( - self.lookup(EnvVars::CHATGPT_ACCOUNT_ID), - resolved - .credentials - .iter_mut() - .find(|credential| credential.provider == ProviderId::openai()), - ) { - apply_openai_codex_api_context(credential, Some(&account_id), self.env_lookup.as_ref()); - } - Ok(resolved) - } - - async fn configured_providers(&self, catalog: &Catalog) -> Vec { - self.inner.configured_providers(catalog).await - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::sync::Arc; - - use fabro_model::catalog::LlmCatalogSettings; - use fabro_model::{Catalog, ProviderId}; - use fabro_types::settings::interp::Namespace; - - use super::EnvCredentialSource; - use crate::CredentialSource; - - fn test_source(entries: &[(&str, &str)]) -> EnvCredentialSource { - let entries: HashMap = entries - .iter() - .map(|(key, value)| ((*key).to_string(), (*value).to_string())) - .collect(); - EnvCredentialSource::with_env_lookup(Arc::new(move |name| entries.get(name).cloned())) - } - - #[tokio::test] - async fn configured_providers_reads_injected_provider_env() { - let source = test_source(&[("ANTHROPIC_API_KEY", "anthropic-key")]); - let catalog = Catalog::from_builtin().unwrap(); - - assert_eq!(source.configured_providers(&catalog).await, vec![ - ProviderId::anthropic() - ]); - } - - #[tokio::test] - async fn resolve_builds_openai_codex_env_credential() { - let source = test_source(&[ - ("OPENAI_API_KEY", "openai-key"), - ("CHATGPT_ACCOUNT_ID", "acct_123"), - ("OPENAI_PROJECT_ID", "project_123"), - ]); - let catalog = Catalog::from_builtin().unwrap(); - - let resolved = source.resolve(&catalog).await.unwrap(); - let credential = resolved.credentials.first().unwrap(); - - assert_eq!(credential.provider, ProviderId::openai()); - assert!(credential.codex_mode); - assert_eq!( - credential.base_url.as_deref(), - Some("https://chatgpt.com/backend-api/codex") - ); - assert_eq!( - credential.extra_headers.get("ChatGPT-Account-Id"), - Some(&"acct_123".to_string()) - ); - assert_eq!(credential.project_id.as_deref(), Some("project_123")); - } - - #[tokio::test] - async fn env_settings_interpolation_remains_unsupported() { - let settings: LlmCatalogSettings = toml::from_str( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" - -[providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[providers.acme.extra_headers] -x-account = "{{ env.ACME_ACCOUNT }}" -"#, - ) - .unwrap(); - let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap(); - let source = test_source(&[("ACME_API_KEY", "acme-key"), ("ACME_ACCOUNT", "account-id")]); - - let resolved = source.resolve(&catalog).await.unwrap(); - - assert!( - resolved - .credentials - .iter() - .all(|credential| credential.provider != ProviderId::new("acme")) - ); - assert!(resolved.auth_issues.iter().any(|(provider, issue)| { - provider == &ProviderId::new("acme") - && matches!( - issue, - crate::ResolveError::Interpolation { source, .. } - if source.namespace == Namespace::Env - ) - })); - } - - #[tokio::test] - async fn modal_env_vars_do_not_replace_vault_secrets() { - let settings: LlmCatalogSettings = toml::from_str( - r#" -[providers.modal] -enabled = true -base_url = "https://example--kimi-k3.modal.run/v1" -"#, - ) - .unwrap(); - let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap(); - let source = test_source(&[ - ("MODAL_TOKEN_ID", "wk-test"), - ("MODAL_TOKEN_SECRET", "ws-test"), - ]); - let modal = ProviderId::new("modal"); - - assert!(!source.configured_providers(&catalog).await.contains(&modal)); - - let resolved = source.resolve(&catalog).await.unwrap(); - - assert!( - resolved - .credentials - .iter() - .all(|credential| credential.provider != modal) - ); - assert!(resolved.auth_issues.iter().any(|(provider, issue)| { - provider == &modal - && matches!( - issue, - crate::ResolveError::Interpolation { source, .. } - if source.namespace == Namespace::Secrets - ) - })); - } -} diff --git a/lib/foundation/fabro-auth/src/error.rs b/lib/foundation/fabro-auth/src/error.rs new file mode 100644 index 000000000..7986637da --- /dev/null +++ b/lib/foundation/fabro-auth/src/error.rs @@ -0,0 +1,83 @@ +//! Why a provider's credentials could not be resolved. + +use fabro_types::settings::ResolveError as InterpResolveError; +use fabro_vault::SecretType; +use lithos_llm::catalog::ProviderId; + +#[derive(Debug, thiserror::Error)] +pub enum ResolveError { + #[error("{0} is not configured")] + NotConfigured(ProviderId), + #[error("{provider} header interpolation failed: {source}")] + Interpolation { + provider: ProviderId, + #[source] + source: InterpResolveError, + }, + #[error("{provider} vault credential '{name}' is not valid Oauth JSON: {source}")] + VaultDecodeFailed { + provider: ProviderId, + name: String, + #[source] + source: serde_json::Error, + }, + #[error("{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth")] + VaultSchemaMismatch { + provider: ProviderId, + name: String, + actual: SecretType, + }, + #[error("{provider} requires re-authentication: {source}")] + RefreshFailed { + provider: ProviderId, + #[source] + source: anyhow::Error, + }, + #[error("{0} requires re-authentication: missing refresh token")] + RefreshTokenMissing(ProviderId), + #[error("{provider} resolved a secret its `{scheme}` auth scheme cannot use")] + SchemeMismatch { + provider: ProviderId, + scheme: String, + }, +} + +impl ResolveError { + #[must_use] + pub fn provider(&self) -> &ProviderId { + match self { + Self::NotConfigured(provider) + | Self::RefreshTokenMissing(provider) + | Self::Interpolation { provider, .. } + | Self::VaultDecodeFailed { provider, .. } + | Self::VaultSchemaMismatch { provider, .. } + | Self::RefreshFailed { provider, .. } + | Self::SchemeMismatch { provider, .. } => provider, + } + } +} + +#[must_use] +pub fn auth_issue_message(provider: &ProviderId, err: &ResolveError) -> String { + match err { + ResolveError::NotConfigured(_) => format!("{provider} is not configured"), + ResolveError::Interpolation { source, .. } => { + format!("{provider} header interpolation failed: {source}") + } + ResolveError::VaultDecodeFailed { name, source, .. } => { + format!("{provider} vault credential '{name}' is not valid OAuth JSON: {source}") + } + ResolveError::VaultSchemaMismatch { name, actual, .. } => format!( + "{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth" + ), + ResolveError::RefreshFailed { source, .. } => { + format!("{provider} requires re-authentication: {source}") + } + ResolveError::RefreshTokenMissing(_) => { + format!("{provider} requires re-authentication: refresh token missing") + } + ResolveError::SchemeMismatch { scheme, .. } => { + format!("{provider} resolved a secret its `{scheme}` auth scheme cannot use") + } + } +} diff --git a/lib/foundation/fabro-auth/src/extra_headers_source.rs b/lib/foundation/fabro-auth/src/extra_headers_source.rs index 29ad806cd..0226e1fee 100644 --- a/lib/foundation/fabro-auth/src/extra_headers_source.rs +++ b/lib/foundation/fabro-auth/src/extra_headers_source.rs @@ -2,15 +2,18 @@ use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; -use fabro_model::{Catalog, ProviderId}; +use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId}; +use lithos_llm::credentials::{CredentialHeader, Credentials, SecretValue}; -use crate::credential_source::{CredentialSource, ResolvedCredentials}; +use crate::ResolveError; +use crate::credential_source::CredentialSource; /// Decorates another [`CredentialSource`] by appending fixed extra headers to -/// every credential it resolves. +/// every HTTP credential it resolves. /// /// Headers already present on a credential (for example from explicit -/// provider configuration) are left untouched. +/// provider configuration) are left untouched. AWS-signed credentials carry +/// no header list and pass through unchanged. pub struct ExtraHeadersCredentialSource { inner: Arc, headers: HashMap, @@ -25,21 +28,24 @@ impl ExtraHeadersCredentialSource { #[async_trait] impl CredentialSource for ExtraHeadersCredentialSource { - async fn resolve(&self, catalog: &Catalog) -> anyhow::Result { - let mut resolved = self.inner.resolve(catalog).await?; - for credential in &mut resolved.credentials { + async fn credentials(&self, provider: &CatalogProvider) -> Result { + let mut credentials = self.inner.credentials(provider).await?; + if let Credentials::Http(http) = &mut credentials { for (name, value) in &self.headers { - if credential + if http .extra_headers - .keys() - .any(|existing| existing.eq_ignore_ascii_case(name)) + .iter() + .any(|existing| existing.name.eq_ignore_ascii_case(name)) { continue; } - credential.extra_headers.insert(name.clone(), value.clone()); + http.extra_headers.push(CredentialHeader::new( + name.clone(), + SecretValue::new(value.clone()), + )); } } - Ok(resolved) + Ok(credentials) } async fn configured_providers(&self, catalog: &Catalog) -> Vec { @@ -49,31 +55,35 @@ impl CredentialSource for ExtraHeadersCredentialSource { #[cfg(test)] mod tests { + use lithos_llm::credentials::HttpAuthentication; + use super::*; - use crate::{ApiCredential, ResolveError}; + use crate::test_support::test_catalog; struct StubSource { - credentials: Vec, - auth_issue_provider: Option, configured_providers: Vec, + existing_header: Option<(String, String)>, } #[async_trait] impl CredentialSource for StubSource { - async fn resolve(&self, _catalog: &Catalog) -> anyhow::Result { - Ok(ResolvedCredentials { - credentials: self.credentials.clone(), - auth_issues: self - .auth_issue_provider - .iter() - .map(|provider| { - ( - provider.clone(), - ResolveError::RefreshTokenMissing(provider.clone()), - ) - }) - .collect(), - }) + async fn credentials( + &self, + provider: &CatalogProvider, + ) -> Result { + if provider.id().as_str() == "bedrock" { + return Ok(Credentials::AwsDefaultChain { region: None }); + } + let mut credentials = Credentials::bearer(SecretValue::new("key")); + if let (Credentials::Http(http), Some((name, value))) = + (&mut credentials, &self.existing_header) + { + http.extra_headers.push(CredentialHeader::new( + name.clone(), + SecretValue::new(value.clone()), + )); + } + Ok(credentials) } async fn configured_providers(&self, _catalog: &Catalog) -> Vec { @@ -81,95 +91,60 @@ mod tests { } } - fn credential(provider: ProviderId, extra_headers: HashMap) -> ApiCredential { - ApiCredential { - provider, - auth_header: None, - extra_headers, - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - } - } - - #[tokio::test] - async fn appends_headers_to_every_resolved_credential() { - let source = ExtraHeadersCredentialSource::new( - Arc::new(StubSource { - credentials: vec![ - credential(ProviderId::anthropic(), HashMap::new()), - credential(ProviderId::openai(), HashMap::new()), - ], - auth_issue_provider: None, - configured_providers: Vec::new(), - }), - HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), - ); - - let resolved = source.resolve(Catalog::builtin()).await.unwrap(); - - assert_eq!(resolved.credentials.len(), 2); - for credential in &resolved.credentials { - assert_eq!( - credential - .extra_headers - .get("x-session-id") - .map(String::as_str), - Some("run-123") - ); - } - } - - #[tokio::test] - async fn preserves_case_insensitive_headers_already_set_on_a_credential() { - let source = ExtraHeadersCredentialSource::new( - Arc::new(StubSource { - credentials: vec![credential( - ProviderId::new("openrouter"), - HashMap::from([("X-Session-Id".to_string(), "configured".to_string())]), - )], - auth_issue_provider: None, - configured_providers: Vec::new(), - }), - HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), - ); - - let resolved = source.resolve(Catalog::builtin()).await.unwrap(); - - assert_eq!( - resolved.credentials[0] + fn header<'a>(credentials: &'a Credentials, name: &str) -> Option<&'a str> { + match credentials { + Credentials::Http(http) => http .extra_headers - .get("X-Session-Id") - .map(String::as_str), - Some("configured") - ); - assert_eq!(resolved.credentials[0].extra_headers.len(), 1); + .iter() + .find(|header| header.name.eq_ignore_ascii_case(name)) + .map(|header| header.value.expose_secret()), + _ => None, + } } #[tokio::test] - async fn passes_through_auth_issues_and_configured_providers() { - let auth_issue_provider = ProviderId::anthropic(); - let configured_provider = ProviderId::gemini(); + async fn appends_headers_to_http_credentials_only() { + let catalog = test_catalog(); let source = ExtraHeadersCredentialSource::new( Arc::new(StubSource { - credentials: vec![credential(ProviderId::openai(), HashMap::new())], - auth_issue_provider: Some(auth_issue_provider.clone()), - configured_providers: vec![configured_provider.clone()], + configured_providers: Vec::new(), + existing_header: None, }), HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), ); + let openai = source + .credentials(catalog.provider("openai").unwrap()) + .await + .unwrap(); + assert!(matches!( + &openai, + Credentials::Http(http) if matches!(http.auth, HttpAuthentication::Bearer(_)) + )); + assert_eq!(header(&openai, "x-session-id"), Some("run-123")); + let bedrock = source + .credentials(catalog.provider("bedrock").unwrap()) + .await + .unwrap(); + assert!(matches!(bedrock, Credentials::AwsDefaultChain { .. })); + } - let resolved = source.resolve(Catalog::builtin()).await.unwrap(); - let [(reported_provider, ResolveError::RefreshTokenMissing(error_provider))] = - resolved.auth_issues.as_slice() - else { - panic!("expected the inner source's refresh-token issue"); - }; - assert_eq!(reported_provider, &auth_issue_provider); - assert_eq!(error_provider, &auth_issue_provider); - - let providers = source.configured_providers(Catalog::builtin()).await; - assert_eq!(providers, vec![configured_provider]); + #[tokio::test] + async fn preserves_case_insensitive_headers_already_set() { + let catalog = test_catalog(); + let source = ExtraHeadersCredentialSource::new( + Arc::new(StubSource { + configured_providers: vec![ProviderId::new("openai")], + existing_header: Some(("X-Session-Id".to_string(), "configured".to_string())), + }), + HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), + ); + let credentials = source + .credentials(catalog.provider("openai").unwrap()) + .await + .unwrap(); + assert_eq!(header(&credentials, "x-session-id"), Some("configured")); + assert_eq!(source.configured_providers(&catalog).await, vec![ + ProviderId::new("openai") + ]); } } diff --git a/lib/foundation/fabro-auth/src/lib.rs b/lib/foundation/fabro-auth/src/lib.rs index e54266f86..34cd2590e 100644 --- a/lib/foundation/fabro-auth/src/lib.rs +++ b/lib/foundation/fabro-auth/src/lib.rs @@ -1,10 +1,11 @@ +mod api_key_source; mod context; mod credential; mod credential_source; -mod env_source; +mod error; mod extra_headers_source; mod refresh; -mod resolve; +mod secrets; mod sql_vault_source; mod strategy; #[cfg(any(test, feature = "test-support"))] @@ -14,16 +15,14 @@ mod vault_source; pub mod strategies; +pub use api_key_source::ApiKeyCredentialSource; pub use context::{AuthContextRequest, AuthContextResponse}; -pub use credential::{ApiKeyHeader, OAuthConfig, OAuthCredential, OAuthTokens}; -pub use credential_source::{CredentialSource, ResolvedCredentials}; -pub use env_source::EnvCredentialSource; +pub use credential::{OAuthConfig, OAuthCredential, OAuthTokens}; +pub use credential_source::{CredentialSource, ResolvedCredentials, lithos_credentials}; +pub use error::{ResolveError, auth_issue_message}; pub use extra_headers_source::ExtraHeadersCredentialSource; pub use refresh::refresh_oauth_credential; -pub use resolve::{ - ApiCredential, CredentialResolver, CredentialUsage, EnvLookup, ResolveError, - ResolvedCredential, auth_issue_message, build_api_key_header, -}; +pub use secrets::{accepts_api_key, expected_secret_name, secret_names}; pub use sql_vault_source::SqlVaultCredentialSource; pub use strategy::{ AuthMethod, AuthStrategy, CODEX_AUTH_URL, CODEX_CLIENT_ID, CODEX_TOKEN_URL, LoginResult, @@ -32,6 +31,8 @@ pub use strategy::{ pub use vault_ext::{ VaultLookupError, vault_get_oauth, vault_get_token, vault_set_oauth, vault_set_token, }; -pub use vault_source::VaultCredentialSource; +pub use vault_source::{EnvLookup, VaultCredentialSource}; +/// The vault entry holding the Codex OAuth credential that serves the +/// `openai-codex` provider. pub const OPENAI_CODEX_VAULT_SECRET_NAME: &str = "OPENAI_CODEX"; diff --git a/lib/foundation/fabro-auth/src/resolve.rs b/lib/foundation/fabro-auth/src/resolve.rs deleted file mode 100644 index dd12d1a1e..000000000 --- a/lib/foundation/fabro-auth/src/resolve.rs +++ /dev/null @@ -1,1274 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use fabro_model::catalog::CatalogProvider; -use fabro_model::{ApiKeyHeaderPolicy, Catalog, CredentialRef, ProviderId}; -use fabro_static::EnvVars; -use fabro_types::settings::{InterpString, ResolveCtx, ResolveError as InterpResolveError}; -use fabro_vault::{SecretType, Vault}; -use tokio::sync::RwLock as AsyncRwLock; -use tokio::task::spawn_blocking; - -use crate::credential::{ApiKeyHeader, OAuthCredential}; -use crate::refresh::refresh_oauth_credential; -use crate::vault_ext::{ - VaultLookupError, vault_get_oauth, vault_get_token, vault_set_oauth, vault_token_lookup, -}; - -pub type EnvLookup = Arc Option + Send + Sync>; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CredentialUsage { - ApiRequest, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ResolvedSecret { - ApiKey(String), - OAuth { - credential: Box, - vault_name: String, - }, - /// Opaque AWS SigV4 source: no static secret; the adapter signs requests - /// using the AWS default credential chain. - AwsSigv4, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ApiCredential { - pub provider: ProviderId, - pub auth_header: Option, - pub extra_headers: HashMap, - pub base_url: Option, - pub codex_mode: bool, - pub org_id: Option, - pub project_id: Option, -} - -impl ApiCredential { - /// Build an `ApiCredential` from an API key using the supplied catalog for - /// auth header policy and provider base URL. - pub fn from_api_key( - provider: impl Into, - key: String, - catalog: &Catalog, - ) -> Result { - let provider_id = provider.into(); - let provider = catalog - .provider(&provider_id) - .ok_or_else(|| ResolveError::NotConfigured(provider_id.clone()))?; - let auth_header = auth_header_for_catalog_provider(provider, key)?; - Ok(Self { - provider: provider_id, - auth_header: Some(auth_header), - extra_headers: HashMap::new(), - base_url: provider.base_url.clone(), - codex_mode: false, - org_id: None, - project_id: None, - }) - } - - /// Build an `ApiCredential` for a provider that authenticates with request - /// headers instead of an API key, such as Modal's proxy-token pair. - #[must_use] - pub fn with_extra_headers( - provider: impl Into, - extra_headers: HashMap, - ) -> Self { - Self { - provider: provider.into(), - auth_header: None, - extra_headers, - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - } - } -} - -const OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; -const CHATGPT_ACCOUNT_ID_HEADER: &str = "ChatGPT-Account-Id"; -const ORIGINATOR_HEADER: &str = "originator"; -const FABRO_ORIGINATOR: &str = "fabro"; - -pub(crate) fn apply_openai_api_env_context( - credential: &mut ApiCredential, - env_lookup: &(dyn Fn(&str) -> Option + Send + Sync), -) { - credential.org_id = env_lookup(EnvVars::OPENAI_ORG_ID); - credential.project_id = env_lookup(EnvVars::OPENAI_PROJECT_ID); -} - -pub(crate) fn apply_openai_codex_api_context( - credential: &mut ApiCredential, - account_id: Option<&str>, - env_lookup: &(dyn Fn(&str) -> Option + Send + Sync), -) { - apply_openai_api_env_context(credential, env_lookup); - if let Some(account_id) = account_id { - credential.extra_headers.insert( - CHATGPT_ACCOUNT_ID_HEADER.to_string(), - account_id.to_string(), - ); - } - credential - .extra_headers - .insert(ORIGINATOR_HEADER.to_string(), FABRO_ORIGINATOR.to_string()); - credential.base_url = Some(OPENAI_CODEX_BASE_URL.to_string()); - credential.codex_mode = true; -} - -#[must_use] -pub fn build_api_key_header(policy: ApiKeyHeaderPolicy, key: String) -> ApiKeyHeader { - match policy { - ApiKeyHeaderPolicy::Bearer => ApiKeyHeader::Bearer(key), - ApiKeyHeaderPolicy::Custom { name } => ApiKeyHeader::Custom { name, value: key }, - } -} - -fn auth_header_for_catalog_provider( - provider: &CatalogProvider, - key: String, -) -> Result { - let Some(auth) = &provider.auth else { - return Err(ResolveError::NotConfigured(provider.id.clone())); - }; - Ok(build_api_key_header(auth.header.clone(), key)) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ResolvedCredential { - Api(ApiCredential), -} - -#[derive(Debug, thiserror::Error)] -pub enum ResolveError { - #[error("{0} is not configured")] - NotConfigured(ProviderId), - #[error("{provider} header interpolation failed: {source}")] - Interpolation { - provider: ProviderId, - #[source] - source: InterpResolveError, - }, - #[error("{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth")] - VaultSchemaMismatch { - provider: ProviderId, - name: String, - actual: SecretType, - }, - #[error("{provider} vault credential '{name}' is not valid Oauth JSON: {source}")] - VaultDecodeFailed { - provider: ProviderId, - name: String, - #[source] - source: serde_json::Error, - }, - #[error("{provider} requires re-authentication: {source}")] - RefreshFailed { - provider: ProviderId, - #[source] - source: anyhow::Error, - }, - #[error("{0} requires re-authentication: missing refresh token")] - RefreshTokenMissing(ProviderId), -} - -#[must_use] -pub fn auth_issue_message(provider: &ProviderId, err: &ResolveError) -> String { - let provider_name = provider.display_name(); - match err { - ResolveError::NotConfigured(_) => { - format!("{provider_name} is not configured") - } - ResolveError::Interpolation { source, .. } => { - format!("{provider_name} header interpolation failed: {source}") - } - ResolveError::VaultSchemaMismatch { name, actual, .. } => { - format!( - "{provider_name} vault credential '{name}' has schema {actual:?}, expected Token or Oauth" - ) - } - ResolveError::VaultDecodeFailed { name, source, .. } => { - format!("{provider_name} vault credential '{name}' is not valid OAuth JSON: {source}") - } - ResolveError::RefreshFailed { source, .. } => { - format!("{provider_name} requires re-authentication: {source}") - } - ResolveError::RefreshTokenMissing(_) => { - format!("{provider_name} requires re-authentication: refresh token missing") - } - } -} - -#[derive(Clone)] -pub struct CredentialResolver { - vault: Arc>, - env_lookup: EnvLookup, -} - -impl CredentialResolver { - #[must_use] - #[expect( - clippy::disallowed_methods, - reason = "CredentialResolver owns the process-env fallback used after vault lookup." - )] - pub fn new(vault: Arc>) -> Self { - Self::with_env_lookup(vault, Arc::new(|name| std::env::var(name).ok())) - } - - #[must_use] - pub fn with_env_lookup(vault: Arc>, env_lookup: EnvLookup) -> Self { - Self { vault, env_lookup } - } - - pub async fn resolve( - &self, - provider: impl Into, - _usage: CredentialUsage, - catalog: &Catalog, - ) -> Result { - let provider_id = provider.into(); - let Some(catalog_provider) = catalog.provider(&provider_id) else { - return Err(ResolveError::NotConfigured(provider_id)); - }; - if catalog_provider.auth.is_none() { - let vault = self.vault.read().await; - return Self::api_credential_from_provider_auth(&vault, catalog_provider, catalog) - .map(ResolvedCredential::Api); - } - let initial_secret = { - let vault = self.vault.read().await; - self.find_credential(&vault, catalog_provider)? - }; - - let secret = if let ResolvedSecret::OAuth { - credential, - vault_name, - } = &initial_secret - { - if !credential.needs_refresh() { - initial_secret - } else if credential.tokens.refresh_token.is_none() { - return Err(ResolveError::RefreshTokenMissing(provider_id.clone())); - } else { - let refreshed = refresh_oauth_credential(credential) - .await - .map_err(|source| ResolveError::RefreshFailed { - provider: provider_id.clone(), - source, - })?; - let refreshed_for_store = refreshed.clone(); - let vault_name_for_store = vault_name.clone(); - let vault = Arc::clone(&self.vault); - spawn_blocking(move || { - let mut vault = vault.blocking_write(); - vault_set_oauth(&mut vault, &vault_name_for_store, &refreshed_for_store) - .map(|_| ()) - .map_err(anyhow::Error::from) - }) - .await - .map_err(|join_err| ResolveError::RefreshFailed { - provider: provider_id.clone(), - source: anyhow::Error::from(join_err), - })? - .map_err(|source| ResolveError::RefreshFailed { - provider: provider_id.clone(), - source, - })?; - ResolvedSecret::OAuth { - credential: Box::new(refreshed), - vault_name: vault_name.clone(), - } - } - } else { - initial_secret - }; - - let vault = self.vault.read().await; - self.to_api_credential(&vault, &provider_id, &secret, catalog) - .map(ResolvedCredential::Api) - } - - #[must_use] - pub fn configured_providers(&self, vault: &Vault, catalog: &Catalog) -> Vec { - catalog - .providers() - .iter() - .filter(|provider| self.has_credential_material(vault, provider, catalog)) - .map(|provider| provider.id.clone()) - .collect() - } - - fn find_credential( - &self, - vault: &Vault, - provider: &CatalogProvider, - ) -> Result { - let Some(auth) = &provider.auth else { - return Err(ResolveError::NotConfigured(provider.id.clone())); - }; - - for credential_ref in &auth.credentials { - if let Some(credential) = - self.credential_from_ref(vault, &provider.id, credential_ref)? - { - return Ok(credential); - } - } - - Err(ResolveError::NotConfigured(provider.id.clone())) - } - - fn has_credential_material( - &self, - vault: &Vault, - provider: &CatalogProvider, - catalog: &Catalog, - ) -> bool { - let Some(auth) = &provider.auth else { - return Self::resolved_extra_headers_for_catalog(vault, &provider.id, catalog).is_ok(); - }; - auth.credentials.iter().any(|credential_ref| { - self.credential_from_ref(vault, &provider.id, credential_ref) - .is_ok_and(|credential| credential.is_some()) - }) - } - - fn credential_from_ref( - &self, - vault: &Vault, - provider: &ProviderId, - credential_ref: &CredentialRef, - ) -> Result, ResolveError> { - match credential_ref { - CredentialRef::Vault(name) => match vault_get_token(vault, name) { - Ok(Some(token)) => Ok(Some(ResolvedSecret::ApiKey(token))), - Ok(None) => Ok(None), - Err(VaultLookupError::SchemaMismatch { - actual: SecretType::Oauth, - .. - }) => vault_get_oauth(vault, name) - .map(|credential| { - credential.map(|credential| ResolvedSecret::OAuth { - credential: Box::new(credential), - vault_name: name.clone(), - }) - }) - .map_err(|err| vault_lookup_error(provider, name, err)), - Err(err) => Err(vault_lookup_error(provider, name, err)), - }, - CredentialRef::Env(name) => Ok((self.env_lookup)(name).map(ResolvedSecret::ApiKey)), - // AWS SigV4 is an opaque source: it always "resolves" (the adapter - // signs at request time from the AWS chain), no vault/env lookup. - CredentialRef::AwsSigv4 => Ok(Some(ResolvedSecret::AwsSigv4)), - } - } - - fn provider_base_url_for_catalog(provider: &ProviderId, catalog: &Catalog) -> Option { - catalog - .provider(provider) - .and_then(|provider| provider.base_url.clone()) - } - - fn resolved_extra_headers_for_catalog( - vault: &Vault, - provider: &ProviderId, - catalog: &Catalog, - ) -> Result, ResolveError> { - let Some(catalog_provider) = catalog.provider(provider) else { - return Ok(HashMap::new()); - }; - let mut ctx = - ResolveCtx::new().with_secrets(|secret_name| vault_token_lookup(vault, secret_name)); - resolve_extra_headers(provider, &catalog_provider.extra_headers, &mut ctx) - } - - fn to_api_credential( - &self, - vault: &Vault, - provider_id: &ProviderId, - secret: &ResolvedSecret, - catalog: &Catalog, - ) -> Result { - let base_url = Self::provider_base_url_for_catalog(provider_id, catalog); - match secret { - // Opaque AWS SigV4 source: carry the marker so the adapter signs - // with the AWS chain; no static secret resolved here. - ResolvedSecret::AwsSigv4 => Ok(ApiCredential { - provider: provider_id.clone(), - auth_header: Some(ApiKeyHeader::AwsSigv4), - extra_headers: Self::resolved_extra_headers_for_catalog( - vault, - provider_id, - catalog, - )?, - base_url, - codex_mode: false, - org_id: None, - project_id: None, - }), - ResolvedSecret::ApiKey(key) => { - let provider = catalog - .provider(provider_id) - .ok_or_else(|| ResolveError::NotConfigured(provider_id.clone()))?; - let auth_header = auth_header_for_catalog_provider(provider, key.clone())?; - let mut cred = ApiCredential { - provider: provider_id.clone(), - auth_header: Some(auth_header), - extra_headers: Self::resolved_extra_headers_for_catalog( - vault, - provider_id, - catalog, - )?, - base_url, - codex_mode: false, - org_id: None, - project_id: None, - }; - if provider_id == &ProviderId::openai() { - apply_openai_api_env_context(&mut cred, &*self.env_lookup); - } - Ok(cred) - } - ResolvedSecret::OAuth { credential, .. } => { - let mut api_credential = ApiCredential { - provider: provider_id.clone(), - auth_header: Some(ApiKeyHeader::Bearer(credential.tokens.access_token.clone())), - extra_headers: Self::resolved_extra_headers_for_catalog( - vault, - provider_id, - catalog, - )?, - base_url, - codex_mode: false, - org_id: None, - project_id: None, - }; - if provider_id == &ProviderId::openai() { - apply_openai_codex_api_context( - &mut api_credential, - credential.account_id.as_deref(), - &*self.env_lookup, - ); - } - Ok(api_credential) - } - } - } - - fn api_credential_from_provider_auth( - vault: &Vault, - provider: &CatalogProvider, - catalog: &Catalog, - ) -> Result { - if provider.auth.is_some() { - return Err(ResolveError::NotConfigured(provider.id.clone())); - } - let extra_headers = Self::resolved_extra_headers_for_catalog(vault, &provider.id, catalog)?; - Ok(ApiCredential { - provider: provider.id.clone(), - auth_header: None, - extra_headers, - base_url: Self::provider_base_url_for_catalog(&provider.id, catalog), - codex_mode: false, - org_id: None, - project_id: None, - }) - } -} - -/// Resolve a provider's `extra_headers` interpolation sources with `ctx`. -/// -/// Resolved header values may contain secrets; keep this path free of value -/// logging. Content-based redaction covers credential-shaped values on output -/// surfaces, but no mechanism redacts these exact values, so a low-entropy -/// header value that does not look like a credential is not caught. -pub(crate) fn resolve_extra_headers( - provider: &ProviderId, - headers: &HashMap, - ctx: &mut ResolveCtx<'_>, -) -> Result, ResolveError> { - headers - .iter() - .map(|(name, source)| { - let value = InterpString::parse(source) - .resolve_with(ctx) - .map_err(|source| ResolveError::Interpolation { - provider: provider.clone(), - source, - })?; - Ok((name.clone(), value)) - }) - .collect() -} - -fn vault_lookup_error(provider: &ProviderId, name: &str, err: VaultLookupError) -> ResolveError { - match err { - VaultLookupError::SchemaMismatch { actual, .. } => ResolveError::VaultSchemaMismatch { - provider: provider.clone(), - name: name.to_string(), - actual, - }, - VaultLookupError::DecodeFailed { source, .. } => ResolveError::VaultDecodeFailed { - provider: provider.clone(), - name: name.to_string(), - source, - }, - } -} - -#[cfg(test)] -mod tests { - use std::error::Error as _; - - use chrono::{Duration, Utc}; - use fabro_model::catalog::LlmCatalogSettings; - use httpmock::Method::POST; - use httpmock::MockServer; - - use super::*; - use crate::credential::{OAuthConfig, OAuthCredential, OAuthTokens}; - use crate::vault_ext::{vault_get_oauth, vault_set_oauth, vault_set_token}; - - fn oauth_credential(token_url: String, expires_at: chrono::DateTime) -> OAuthCredential { - OAuthCredential { - tokens: OAuthTokens { - access_token: "expired-access".to_string(), - refresh_token: Some("refresh-token".to_string()), - expires_at, - }, - config: OAuthConfig { - auth_url: "https://auth.openai.com".to_string(), - token_url, - client_id: "test-client".to_string(), - scopes: vec!["openid".to_string()], - redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()), - use_pkce: true, - }, - account_id: Some("acct_123".to_string()), - } - } - - fn test_resolver(vault: Vault, env_lookup: EnvLookup) -> CredentialResolver { - CredentialResolver::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), env_lookup) - } - - fn catalog_with(overrides: &str) -> Catalog { - let settings: LlmCatalogSettings = toml::from_str(overrides).unwrap(); - Catalog::from_builtin_with_overrides(&settings).unwrap() - } - - fn default_catalog() -> Catalog { - catalog_with("") - } - - /// A no-auth portkey provider whose only variation is its `extra_headers` - /// TOML lines. - fn portkey_catalog(extra_headers: &str) -> Catalog { - catalog_with(&format!( - r#" -[providers.portkey] -display_name = "Portkey Bedrock" -adapter = "anthropic" -agent_profile = "anthropic" -base_url = "https://api.portkey.ai/v1" - -[providers.portkey.extra_headers] -{extra_headers} - -[models."portkey-claude"] -provider = "portkey" -display_name = "Portkey Claude" -family = "claude" -default = true - -[models."portkey-claude".limits] -context_window = 200000 - -[models."portkey-claude".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -"# - )) - } - - fn modal_catalog() -> Catalog { - catalog_with( - r#" -[providers.modal] -enabled = true -base_url = "https://example--kimi-k3.modal.run/v1" -"#, - ) - } - - #[tokio::test] - async fn resolve_openai_api_request_prefers_env_when_listed_first() { - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap(); - let resolver = test_resolver( - vault, - Arc::new(|name| (name == "OPENAI_API_KEY").then(|| "env-key".to_string())), - ); - let catalog = default_catalog(); - - let resolved = resolver - .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) - .await - .unwrap(); - - let ResolvedCredential::Api(api) = resolved; - assert_eq!( - api.auth_header, - Some(ApiKeyHeader::Bearer("env-key".to_string())) - ); - } - - #[tokio::test] - async fn resolve_moonshot_api_request_prefers_moonshot_env_key() { - let dir = tempfile::tempdir().unwrap(); - let vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - let resolver = test_resolver( - vault, - Arc::new(|name| match name { - EnvVars::MOONSHOT_API_KEY => Some("moonshot-key".to_string()), - EnvVars::KIMI_API_KEY => Some("kimi-key".to_string()), - _ => None, - }), - ); - - let resolved = resolver - .resolve( - ProviderId::new("moonshot"), - CredentialUsage::ApiRequest, - &default_catalog(), - ) - .await - .unwrap(); - - let ResolvedCredential::Api(api) = resolved; - assert_eq!( - api.auth_header, - Some(ApiKeyHeader::Bearer("moonshot-key".to_string())) - ); - } - - #[tokio::test] - async fn resolve_moonshot_api_request_falls_back_to_kimi_env_key() { - let dir = tempfile::tempdir().unwrap(); - let vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - let resolver = test_resolver( - vault, - Arc::new(|name| (name == EnvVars::KIMI_API_KEY).then(|| "kimi-key".to_string())), - ); - - let resolved = resolver - .resolve( - ProviderId::new("moonshot"), - CredentialUsage::ApiRequest, - &default_catalog(), - ) - .await - .unwrap(); - - let ResolvedCredential::Api(api) = resolved; - assert_eq!( - api.auth_header, - Some(ApiKeyHeader::Bearer("kimi-key".to_string())) - ); - } - - #[tokio::test] - async fn resolve_openai_api_request_falls_back_to_codex_oauth_credential() { - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_oauth( - &mut vault, - crate::OPENAI_CODEX_VAULT_SECRET_NAME, - &oauth_credential( - "https://auth.openai.com/oauth/token".to_string(), - Utc::now() + Duration::hours(1), - ), - ) - .unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - let catalog = default_catalog(); - - let resolved = resolver - .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) - .await - .unwrap(); - - let ResolvedCredential::Api(api) = resolved; - assert_eq!( - api.auth_header, - Some(ApiKeyHeader::Bearer("expired-access".to_string())) - ); - assert!(api.codex_mode); - assert_eq!( - api.base_url.as_deref(), - Some("https://chatgpt.com/backend-api/codex") - ); - } - - #[tokio::test] - async fn sigv4_provider_resolves_to_aws_sigv4_credential() { - let dir = tempfile::tempdir().unwrap(); - let vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - // No env credentials configured: SigV4 must still resolve. - let resolver = test_resolver(vault, Arc::new(|_| None)); - let catalog = catalog_with( - r#" -[providers.bedrock] -adapter = "bedrock" -enabled = true -base_url = "https://bedrock-runtime.eu-west-1.amazonaws.com" - -[providers.bedrock.auth] -credentials = ["aws_sigv4"] -"#, - ); - - let resolved = resolver - .resolve( - ProviderId::from("bedrock"), - CredentialUsage::ApiRequest, - &catalog, - ) - .await - .unwrap(); - - let ResolvedCredential::Api(api) = resolved; - assert_eq!(api.provider, ProviderId::from("bedrock")); - assert_eq!(api.auth_header, Some(ApiKeyHeader::AwsSigv4)); - } - - #[tokio::test] - async fn resolve_returns_not_configured_for_missing_provider() { - let dir = tempfile::tempdir().unwrap(); - let vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - let catalog = default_catalog(); - - let err = resolver - .resolve( - ProviderId::anthropic(), - CredentialUsage::ApiRequest, - &catalog, - ) - .await - .unwrap_err(); - - assert!(matches!( - err, - ResolveError::NotConfigured(provider) if provider == ProviderId::anthropic() - )); - } - - #[tokio::test] - async fn anthropic_api_credentials_use_x_api_key_header() { - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - let catalog = default_catalog(); - - let resolved = resolver - .resolve( - ProviderId::anthropic(), - CredentialUsage::ApiRequest, - &catalog, - ) - .await - .unwrap(); - let ResolvedCredential::Api(api) = resolved; - - assert_eq!( - api.auth_header, - Some(ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "anthropic-key".to_string(), - }) - ); - } - - #[tokio::test] - async fn custom_openai_compatible_resolves_with_catalog_base_url_from_vault() { - let catalog = catalog_with( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://default.example.com/v1" - -[providers.acme.auth] -credentials = ["vault:acme"] - -[models."compat-model"] -provider = "acme" -display_name = "Compat Model" -family = "openai" -default = true - -[models."compat-model".limits] -context_window = 128000 - -[models."compat-model".features] -tools = true -vision = false -reasoning = false -"#, - ); - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "acme", "compat-key").unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - let resolved = resolver - .resolve( - ProviderId::new("acme"), - CredentialUsage::ApiRequest, - &catalog, - ) - .await - .unwrap(); - - let ResolvedCredential::Api(api) = resolved; - assert_eq!( - api.auth_header, - Some(ApiKeyHeader::Bearer("compat-key".to_string())) - ); - assert_eq!( - api.base_url.as_deref(), - Some("https://default.example.com/v1") - ); - } - - #[tokio::test] - async fn with_env_lookup_overrides_vault_settings() { - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap(); - vault - .set( - "OPENAI_ORG_ID", - "vault-org", - fabro_vault::SecretType::Token, - None, - ) - .unwrap(); - let resolver = test_resolver( - vault, - Arc::new(|name| match name { - "OPENAI_API_KEY" => Some("env-key".to_string()), - "OPENAI_ORG_ID" => Some("env-org".to_string()), - _ => None, - }), - ); - let catalog = default_catalog(); - - let resolved = resolver - .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) - .await - .unwrap(); - let ResolvedCredential::Api(api) = resolved; - - assert_eq!(api.org_id.as_deref(), Some("env-org")); - } - - #[tokio::test] - async fn configured_providers_returns_vault_backed_provider() { - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - let vault = resolver.vault.read().await; - let catalog = default_catalog(); - - assert_eq!(resolver.configured_providers(&vault, &catalog), vec![ - ProviderId::openai() - ]); - } - - #[tokio::test] - async fn resolve_uses_custom_vault_backed_provider() { - let catalog = catalog_with( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" - -[providers.acme.auth] -credentials = ["vault:acme"] - -[models."acme-large"] -provider = "acme" -display_name = "Acme Large" -family = "acme" -default = true - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - ); - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "acme", "acme-key").unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - - let resolved = resolver - .resolve( - ProviderId::new("acme"), - CredentialUsage::ApiRequest, - &catalog, - ) - .await - .unwrap(); - - let ResolvedCredential::Api(api) = resolved; - assert_eq!(api.provider, ProviderId::new("acme")); - assert_eq!( - api.auth_header, - Some(ApiKeyHeader::Bearer("acme-key".to_string())) - ); - assert_eq!(api.base_url.as_deref(), Some("https://api.acme.test/v1")); - } - - #[tokio::test] - async fn configured_providers_returns_env_backed_provider() { - let dir = tempfile::tempdir().unwrap(); - let vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - let resolver = test_resolver( - vault, - Arc::new(|name| (name == "OPENAI_API_KEY").then(|| "env-key".to_string())), - ); - let vault = resolver.vault.read().await; - let catalog = default_catalog(); - - assert_eq!(resolver.configured_providers(&vault, &catalog), vec![ - ProviderId::openai() - ]); - } - - #[tokio::test] - async fn vault_source_resolves_secret_header_token() { - let catalog = portkey_catalog(r#"x-team-secret = "{{ secrets.gateway_team_secret }}""#); - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "gateway_team_secret", "s3cr3t").unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - - let resolved = resolver - .resolve( - ProviderId::new("portkey"), - CredentialUsage::ApiRequest, - &catalog, - ) - .await - .unwrap(); - - let ResolvedCredential::Api(api) = resolved; - assert_eq!( - api.extra_headers.get("x-team-secret"), - Some(&"s3cr3t".to_string()) - ); - } - - #[tokio::test] - async fn modal_resolves_both_vault_proxy_headers_without_authorization() { - let catalog = modal_catalog(); - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap(); - vault_set_token(&mut vault, "MODAL_TOKEN_SECRET", "ws-test").unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - let modal = ProviderId::new("modal"); - - { - let vault = resolver.vault.read().await; - assert!( - resolver - .configured_providers(&vault, &catalog) - .contains(&modal) - ); - } - - let resolved = resolver - .resolve(modal.clone(), CredentialUsage::ApiRequest, &catalog) - .await - .unwrap(); - let ResolvedCredential::Api(api) = resolved; - - assert!(api.auth_header.is_none()); - assert_eq!( - api.extra_headers, - HashMap::from([ - ("Modal-Key".to_string(), "wk-test".to_string()), - ("Modal-Secret".to_string(), "ws-test".to_string()), - ]) - ); - assert_eq!( - api.base_url.as_deref(), - Some("https://example--kimi-k3.modal.run/v1") - ); - } - - #[tokio::test] - async fn modal_is_not_configured_with_only_one_vault_proxy_token() { - let catalog = modal_catalog(); - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-present").unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - let modal = ProviderId::new("modal"); - - { - let vault = resolver.vault.read().await; - assert!( - !resolver - .configured_providers(&vault, &catalog) - .contains(&modal) - ); - } - - let err = resolver - .resolve(modal.clone(), CredentialUsage::ApiRequest, &catalog) - .await - .unwrap_err(); - - assert!(matches!( - err, - ResolveError::Interpolation { ref provider, .. } if provider == &modal - )); - let message = err.to_string(); - assert!(message.contains("MODAL_TOKEN_SECRET")); - assert!(!message.contains("wk-present")); - } - - #[tokio::test] - async fn resolve_multi_segment_header_token() { - let catalog = portkey_catalog(r#"authorization = "Bearer {{ secrets.TOKEN }}""#); - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "TOKEN", "gateway-token").unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - - let resolved = resolver - .resolve( - ProviderId::new("portkey"), - CredentialUsage::ApiRequest, - &catalog, - ) - .await - .unwrap(); - - let ResolvedCredential::Api(api) = resolved; - assert_eq!( - api.extra_headers.get("authorization"), - Some(&"Bearer gateway-token".to_string()) - ); - } - - #[tokio::test] - async fn missing_secret_header_fails_without_echoing_value() { - let catalog = portkey_catalog(r#"x-team-secret = "{{ secrets.MISSING }}""#); - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "OTHER_SECRET", "should-not-leak").unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - - let err = resolver - .resolve( - ProviderId::new("portkey"), - CredentialUsage::ApiRequest, - &catalog, - ) - .await - .unwrap_err(); - - assert!(matches!( - err, - ResolveError::Interpolation { ref provider, .. } - if provider == &ProviderId::new("portkey") - )); - let source = err - .source() - .expect("interpolation errors should preserve the source error"); - assert!(source.to_string().contains("MISSING")); - let message = err.to_string(); - assert!(message.contains("MISSING")); - assert!(!message.contains("should-not-leak")); - } - - #[tokio::test] - async fn header_with_file_or_oauth_vault_entry_fails_closed() { - let catalog = portkey_catalog(r#"x-team-secret = "{{ secrets.gateway_team_secret }}""#); - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_oauth( - &mut vault, - "gateway_team_secret", - &oauth_credential( - "https://auth.openai.com/oauth/token".to_string(), - Utc::now() + Duration::hours(1), - ), - ) - .unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - - let err = resolver - .resolve( - ProviderId::new("portkey"), - CredentialUsage::ApiRequest, - &catalog, - ) - .await - .unwrap_err(); - - assert!(matches!( - err, - ResolveError::Interpolation { ref provider, .. } - if provider == &ProviderId::new("portkey") - )); - let message = err.to_string(); - assert!(message.contains("gateway_team_secret")); - assert!(!message.contains("expired-access")); - assert!(!message.contains("refresh-token")); - } - - #[tokio::test] - async fn resolve_refreshes_expired_oauth_credentials_and_persists_them() { - let server = MockServer::start_async().await; - let refresh_mock = server - .mock_async(|when, then| { - when.method(POST) - .path("/oauth/token") - .header("content-type", "application/x-www-form-urlencoded") - .form_urlencoded_tuple("grant_type", "refresh_token") - .form_urlencoded_tuple("client_id", "test-client") - .form_urlencoded_tuple("refresh_token", "refresh-token"); - then.status(200) - .header("content-type", "application/json") - .body( - serde_json::json!({ - "access_token": "new-access", - "refresh_token": "new-refresh", - "expires_in": 3600 - }) - .to_string(), - ); - }) - .await; - - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_oauth( - &mut vault, - crate::OPENAI_CODEX_VAULT_SECRET_NAME, - &oauth_credential( - server.url("/oauth/token"), - Utc::now() - Duration::minutes(1), - ), - ) - .unwrap(); - let vault = Arc::new(AsyncRwLock::new(vault)); - let resolver = CredentialResolver::with_env_lookup(Arc::clone(&vault), Arc::new(|_| None)); - let catalog = default_catalog(); - - let resolved = resolver - .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) - .await - .unwrap(); - let ResolvedCredential::Api(api) = resolved; - - assert_eq!( - api.auth_header, - Some(ApiKeyHeader::Bearer("new-access".to_string())) - ); - assert!(api.codex_mode); - - let stored = { - let vault = vault.read().await; - vault_get_oauth(&vault, crate::OPENAI_CODEX_VAULT_SECRET_NAME) - .unwrap() - .unwrap() - }; - assert_eq!(stored.tokens.access_token, "new-access"); - assert_eq!(stored.tokens.refresh_token.as_deref(), Some("new-refresh")); - assert_eq!(stored.account_id.as_deref(), Some("acct_123")); - refresh_mock.assert_async().await; - } - - #[tokio::test] - async fn resolve_returns_refresh_token_missing_when_expired_oauth_has_no_refresh_token() { - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - let mut credential = oauth_credential( - "https://auth.openai.com/oauth/token".to_string(), - Utc::now() - Duration::minutes(1), - ); - credential.tokens.refresh_token = None; - vault_set_oauth( - &mut vault, - crate::OPENAI_CODEX_VAULT_SECRET_NAME, - &credential, - ) - .unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - let catalog = default_catalog(); - - let err = resolver - .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) - .await - .unwrap_err(); - - assert!(matches!( - err, - ResolveError::RefreshTokenMissing(provider) if provider == ProviderId::openai() - )); - } - - #[test] - fn auth_issue_message_formats_refresh_token_missing() { - let message = auth_issue_message( - &ProviderId::openai(), - &ResolveError::RefreshTokenMissing(ProviderId::openai()), - ); - - assert_eq!( - message, - "openai requires re-authentication: refresh token missing" - ); - } - - #[test] - fn api_credential_debug_redacts_secret_material() { - let credential = ApiCredential { - provider: ProviderId::openai(), - auth_header: Some(ApiKeyHeader::Bearer("sk-test".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }; - - let debug = format!("{credential:?}"); - - assert!(!debug.contains("sk-test")); - assert!(debug.contains("REDACTED")); - } -} diff --git a/lib/foundation/fabro-auth/src/secrets.rs b/lib/foundation/fabro-auth/src/secrets.rs new file mode 100644 index 000000000..3661f660f --- /dev/null +++ b/lib/foundation/fabro-auth/src/secrets.rs @@ -0,0 +1,47 @@ +//! Which secrets a provider reads. +//! +//! lithos-llm owns the convention: `openai` reads `OPENAI_API_KEY`, `gemini` +//! reads `GEMINI_API_KEY` then `GOOGLE_API_KEY`, and an operator-defined +//! provider reads a name derived from its id. Fabro stores secrets in its +//! vault under those same names, so the vault entry an operator creates and +//! the environment variable a shell exports are spelled alike. + +use fabro_types::provider_ids; +use lithos_llm::catalog::{AuthScheme, CatalogProvider, ProviderId}; +use lithos_llm::credentials::ConventionalCredentials; + +use crate::OPENAI_CODEX_VAULT_SECRET_NAME; + +/// The secret names `provider` reads, preferred name first. +#[must_use] +pub fn secret_names(provider: &CatalogProvider) -> Vec { + ConventionalCredentials::new().secret_names(provider) +} + +/// The secret an operator creates to configure `provider`, when the provider +/// reads one. +#[must_use] +pub fn expected_secret_name(provider: &CatalogProvider) -> Option { + secret_names(provider).into_iter().next() +} + +/// Whether the provider takes a single API key an operator can paste in. +/// +/// Providers that read several secrets (Modal's two proxy-token headers) or +/// none at all (Ollama) do not. +#[must_use] +pub fn accepts_api_key(provider: &CatalogProvider) -> bool { + matches!( + provider.auth(), + AuthScheme::Bearer { .. } + | AuthScheme::Header { .. } + | AuthScheme::BedrockBearer + | AuthScheme::Aws { .. } + ) && !secret_names(provider).is_empty() +} + +/// The vault entry holding `provider`'s OAuth credential, for the providers +/// Fabro can log into with a browser flow. +pub(crate) fn oauth_secret_name(provider: &ProviderId) -> Option<&'static str> { + (provider.as_str() == provider_ids::OPENAI_CODEX).then_some(OPENAI_CODEX_VAULT_SECRET_NAME) +} diff --git a/lib/foundation/fabro-auth/src/sql_vault_source.rs b/lib/foundation/fabro-auth/src/sql_vault_source.rs index ca581d5b6..6f0c0803b 100644 --- a/lib/foundation/fabro-auth/src/sql_vault_source.rs +++ b/lib/foundation/fabro-auth/src/sql_vault_source.rs @@ -1,15 +1,21 @@ use std::sync::Arc; use async_trait::async_trait; -use fabro_model::{Catalog, ProviderId}; use fabro_types::SecretType; use fabro_vault::{SecretSnapshot, SecretStore, SecretStoreError, Vault}; +use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId}; +use lithos_llm::credentials::Credentials; use tokio::sync::RwLock; use tracing::error; -use crate::credential_source::{CredentialSource, ResolvedCredentials}; -use crate::{EnvLookup, VaultCredentialSource}; +use crate::credential_source::CredentialSource; +use crate::{EnvLookup, ResolveError, VaultCredentialSource}; +/// Credentials backed by the SQL secret store. +/// +/// Every lookup snapshots the store, resolves against the snapshot, and +/// writes refreshed OAuth tokens back with a revision check so two concurrent +/// refreshes cannot clobber each other. #[derive(Clone)] pub struct SqlVaultCredentialSource { store: Arc, @@ -83,6 +89,13 @@ impl SqlVaultCredentialSource { } Ok(true) } + + fn store_error(provider: &ProviderId, err: SecretStoreError) -> ResolveError { + ResolveError::RefreshFailed { + provider: provider.clone(), + source: anyhow::Error::new(err), + } + } } impl std::fmt::Debug for SqlVaultCredentialSource { @@ -94,9 +107,13 @@ impl std::fmt::Debug for SqlVaultCredentialSource { #[async_trait] impl CredentialSource for SqlVaultCredentialSource { - async fn resolve(&self, catalog: &Catalog) -> anyhow::Result { + async fn credentials(&self, provider: &CatalogProvider) -> Result { for _ in 0..2 { - let before = self.store.snapshot().await?; + let before = self + .store + .snapshot() + .await + .map_err(|err| Self::store_error(provider.id(), err))?; let has_oauth = before .entries() .values() @@ -104,16 +121,23 @@ impl CredentialSource for SqlVaultCredentialSource { if !has_oauth { // Only OAuth resolution can write back (token refresh); with no // OAuth secrets, skip the snapshot clones and CAS machinery. - return self.source_for_snapshot(before).resolve(catalog).await; + return self.source_for_snapshot(before).credentials(provider).await; } let source = self.source_for_snapshot(before.clone()); - let resolved = source.resolve(catalog).await?; + let credentials = source.credentials(provider).await?; let after = source.snapshot().await; - if self.persist_oauth_refreshes(&before, &after).await? { - return Ok(resolved); + if self + .persist_oauth_refreshes(&before, &after) + .await + .map_err(|err| Self::store_error(provider.id(), err))? + { + return Ok(credentials); } } - anyhow::bail!("OAuth credential changed concurrently during refresh") + Err(ResolveError::RefreshFailed { + provider: provider.id().clone(), + source: anyhow::anyhow!("OAuth credential changed concurrently during refresh"), + }) } async fn configured_providers(&self, catalog: &Catalog) -> Vec { diff --git a/lib/foundation/fabro-auth/src/strategies/api_key.rs b/lib/foundation/fabro-auth/src/strategies/api_key.rs index 0d594110a..5029943f7 100644 --- a/lib/foundation/fabro-auth/src/strategies/api_key.rs +++ b/lib/foundation/fabro-auth/src/strategies/api_key.rs @@ -1,6 +1,5 @@ use async_trait::async_trait; -use fabro_model::catalog::CatalogProvider; -use fabro_model::{CredentialRef, ProviderId}; +use lithos_llm::catalog::{CatalogProvider, ProviderId}; use crate::context::{AuthContextRequest, AuthContextResponse}; use crate::strategy::{AuthStrategy, LoginResult}; @@ -15,24 +14,11 @@ pub struct ApiKeyStrategy { impl ApiKeyStrategy { #[must_use] pub fn new(provider: &CatalogProvider) -> Self { - let env_var_names = provider - .auth - .as_ref() - .map(|auth| { - auth.credentials - .iter() - .filter_map(|credential_ref| match credential_ref { - CredentialRef::Env(name) => Some(name.clone()), - CredentialRef::Vault(_) | CredentialRef::AwsSigv4 => None, - }) - .collect() - }) - .unwrap_or_default(); Self { - provider_id: provider.id.clone(), - display_name: provider.display_name.clone(), - env_var_names, - api_key_url: provider.api_key_url.clone(), + provider_id: provider.id().clone(), + display_name: provider.display_name().to_string(), + env_var_names: crate::secret_names(provider), + api_key_url: provider.api_key_url().map(str::to_string), } } } diff --git a/lib/foundation/fabro-auth/src/strategies/codex_device.rs b/lib/foundation/fabro-auth/src/strategies/codex_device.rs index 94a3399c8..47681b2e1 100644 --- a/lib/foundation/fabro-auth/src/strategies/codex_device.rs +++ b/lib/foundation/fabro-auth/src/strategies/codex_device.rs @@ -5,6 +5,7 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::{DateTime, Utc}; use fabro_http::HttpClient; +use fabro_types::provider_ids; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::time::sleep; @@ -298,7 +299,7 @@ impl AuthStrategy for CodexDeviceStrategy { .map_err(anyhow::Error::msg)?; Ok(LoginResult::OAuth { - provider: fabro_model::ProviderId::openai(), + provider: provider_ids::openai(), credential: OAuthCredential { tokens: OAuthTokens { access_token: token_response.access_token, diff --git a/lib/foundation/fabro-auth/src/strategy.rs b/lib/foundation/fabro-auth/src/strategy.rs index 3b0603248..4c91b2486 100644 --- a/lib/foundation/fabro-auth/src/strategy.rs +++ b/lib/foundation/fabro-auth/src/strategy.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; -use fabro_model::{Catalog, ProviderId}; +use fabro_types::provider_ids; +use lithos_llm::catalog::{Catalog, ProviderId}; use crate::context::{AuthContextRequest, AuthContextResponse}; use crate::credential::{OAuthConfig, OAuthCredential}; @@ -60,7 +61,7 @@ pub fn strategy_for( match method { AuthMethod::ApiKey => { let provider = catalog - .provider(provider_id) + .provider(provider_id.as_str()) .expect("API key auth requires a catalog provider"); Box::new(ApiKeyStrategy::new(provider)) } @@ -73,7 +74,7 @@ pub fn strategy_for( // forgets the constraint. assert_eq!( provider_id.as_str(), - ProviderId::OPENAI, + provider_ids::OPENAI, "CodexDevice auth is only constructed by CLI code for the \ OpenAI provider; all existing call sites enforce this pairing: \ got provider_id={provider_id}" @@ -87,6 +88,7 @@ pub fn strategy_for( mod tests { use super::*; use crate::context::AuthContextRequest; + use crate::test_support::test_catalog; #[test] fn codex_oauth_config_has_expected_defaults() { @@ -99,13 +101,13 @@ mod tests { } #[tokio::test] - async fn api_key_strategy_uses_provider_env_names() { - let catalog = Catalog::builtin(); - let provider = catalog.provider(&ProviderId::anthropic()).unwrap(); + async fn api_key_strategy_uses_provider_secret_names() { + let catalog = test_catalog(); + let provider = catalog.provider("anthropic").unwrap(); let mut strategy = ApiKeyStrategy::new(provider); let request = strategy.init().await.unwrap(); assert_eq!(request, AuthContextRequest::ApiKey { - provider_id: ProviderId::anthropic(), + provider_id: ProviderId::new("anthropic"), display_name: "Anthropic".to_string(), env_var_names: vec!["ANTHROPIC_API_KEY".to_string()], api_key_url: Some("https://console.anthropic.com/settings/keys".to_string()), diff --git a/lib/foundation/fabro-auth/src/test_support.rs b/lib/foundation/fabro-auth/src/test_support.rs index d3bb14cde..0b12e04d7 100644 --- a/lib/foundation/fabro-auth/src/test_support.rs +++ b/lib/foundation/fabro-auth/src/test_support.rs @@ -1,4 +1,4 @@ -//! Test-only credential sources. +//! Test-only credential sources and catalogs. //! //! Feature-gated so they never link into production builds. Production code //! resolves credentials through [`VaultCredentialSource`] over a real vault; @@ -8,11 +8,32 @@ use std::collections::HashMap; use std::sync::Arc; use fabro_vault::Vault; +use lithos_llm::catalog::Catalog; use tokio::sync::RwLock as AsyncRwLock; use crate::credential_source::CredentialSource; use crate::vault_source::VaultCredentialSource; +/// The lithos built-in catalog. +#[must_use] +pub fn test_catalog() -> Catalog { + Catalog::builder() + .with_builtin() + .build() + .expect("built-in catalog should build") +} + +/// The built-in catalog with an operator overlay applied. +#[must_use] +pub fn test_catalog_with_overlay(overlay: &str) -> Catalog { + Catalog::builder() + .with_builtin() + .overlay_toml(&format!("schema_version = 1\n{overlay}")) + .expect("overlay should parse") + .build() + .expect("built-in catalog with overlay should build") +} + /// A detached in-memory vault holding no secrets. #[must_use] pub fn empty_vault() -> Arc> { diff --git a/lib/foundation/fabro-auth/src/vault_source.rs b/lib/foundation/fabro-auth/src/vault_source.rs index aeba384a4..f99654d91 100644 --- a/lib/foundation/fabro-auth/src/vault_source.rs +++ b/lib/foundation/fabro-auth/src/vault_source.rs @@ -1,24 +1,63 @@ +//! Credentials from Fabro's vault, with the process environment as a second +//! store when the caller allows it. +//! +//! lithos-llm knows which named secrets each provider reads and how they shape +//! into the provider's authentication scheme. This source supplies the store: +//! a name is looked up in the environment first, then in the vault, under the +//! same conventional spelling (`OPENAI_API_KEY`, `MODAL_TOKEN_ID`). On top of +//! that lithos table, Fabro adds what only it knows about: +//! +//! - an OAuth credential in the vault (the Codex login), refreshed when it +//! expires and written back; +//! - `{{ secrets.NAME }}` tokens in a provider's `default_headers`, resolved +//! against the vault and re-sent as credential headers so the literal token +//! never reaches the wire; +//! - OpenAI organization and project headers from the environment. + use std::sync::Arc; use async_trait::async_trait; -use fabro_model::{Catalog, ProviderId}; -use fabro_vault::Vault; +use fabro_static::EnvVars; +use fabro_types::provider_ids; +use fabro_types::settings::{InterpString, ResolveCtx}; +use fabro_vault::{SecretType, Vault}; +use lithos_llm::catalog::{AuthScheme, Catalog, CatalogProvider, ProviderId}; +use lithos_llm::credentials::{ + ConventionalCredentials, CredentialError, CredentialHeader, CredentialProvider, Credentials, + HttpAuthentication, HttpCredentials, SecretValue, +}; use tokio::sync::RwLock as AsyncRwLock; +use tokio::task::spawn_blocking; -use crate::credential_source::{CredentialSource, ResolvedCredentials}; -use crate::{CredentialResolver, CredentialUsage, EnvLookup, ResolveError, ResolvedCredential}; +use crate::credential::OAuthCredential; +use crate::credential_source::CredentialSource; +use crate::error::ResolveError; +use crate::refresh::refresh_oauth_credential; +use crate::secrets::oauth_secret_name; +use crate::vault_ext::{VaultLookupError, vault_get_oauth, vault_set_oauth, vault_token_lookup}; +pub type EnvLookup = Arc Option + Send + Sync>; + +const CHATGPT_ACCOUNT_ID_HEADER: &str = "ChatGPT-Account-Id"; +const OPENAI_ORGANIZATION_HEADER: &str = "OpenAI-Organization"; +const OPENAI_PROJECT_HEADER: &str = "OpenAI-Project"; + +/// Credentials backed by an in-memory [`Vault`] plus an environment lookup. #[derive(Clone)] pub struct VaultCredentialSource { - vault: Arc>, - resolver: CredentialResolver, + vault: Arc>, + env_lookup: EnvLookup, } impl VaultCredentialSource { + /// A source over `vault` that falls back to the process environment. #[must_use] + #[expect( + clippy::disallowed_methods, + reason = "VaultCredentialSource::new owns the process-env fallback used after vault lookup." + )] pub fn new(vault: Arc>) -> Self { - let resolver = CredentialResolver::new(Arc::clone(&vault)); - Self { vault, resolver } + Self::with_env_lookup(vault, |name| std::env::var(name).ok()) } #[must_use] @@ -26,19 +65,135 @@ impl VaultCredentialSource { where F: Fn(&str) -> Option + Send + Sync + 'static, { - let env_lookup: EnvLookup = Arc::new(env_lookup); - let resolver = CredentialResolver::with_env_lookup(Arc::clone(&vault), env_lookup); - Self { vault, resolver } + Self { + vault, + env_lookup: Arc::new(env_lookup), + } } + /// A source that reads the vault and nothing else. #[must_use] pub fn vault_only(vault: Arc>) -> Self { Self::with_env_lookup(vault, |_| None) } + /// A source over an empty vault, so every secret comes from the process + /// environment. For SDK callers and tools that have no Fabro vault. + #[must_use] + pub fn environment_only() -> Self { + Self::new(Arc::new(AsyncRwLock::new(Vault::from_entries( + std::collections::HashMap::new(), + )))) + } + pub(crate) async fn snapshot(&self) -> Vault { self.vault.read().await.clone() } + + /// The lithos conventional table reading from the environment, then the + /// vault. + fn conventional(&self, vault: &Vault) -> ConventionalCredentials { + let vault = vault.clone(); + let env_lookup = Arc::clone(&self.env_lookup); + ConventionalCredentials::new() + .with_lookup(move |name| env_lookup(name).or_else(|| vault_token_lookup(&vault, name))) + } + + /// The vault's OAuth credential for `provider`, refreshed and persisted + /// when it has expired. `None` when the provider has no OAuth path or the + /// vault holds nothing under its name. + async fn oauth_credentials( + &self, + provider: &CatalogProvider, + vault: &Vault, + ) -> Result, ResolveError> { + let Some(name) = oauth_secret_name(provider.id()) else { + return Ok(None); + }; + let Some(entry) = vault.get_entry(name) else { + return Ok(None); + }; + if entry.secret_type == SecretType::Token { + // A pasted API key stored under the OAuth name still works. + return Ok(Some(Credentials::bearer(SecretValue::new( + entry.value.clone(), + )))); + } + let credential = vault_get_oauth(vault, name) + .map_err(|err| vault_lookup_error(provider.id(), name, err))? + .expect("entry is present"); + let credential = if credential.needs_refresh() { + if credential.tokens.refresh_token.is_none() { + return Err(ResolveError::RefreshTokenMissing(provider.id().clone())); + } + let refreshed = refresh_oauth_credential(&credential) + .await + .map_err(|source| ResolveError::RefreshFailed { + provider: provider.id().clone(), + source, + })?; + self.persist_oauth(provider.id(), name, &refreshed).await?; + refreshed + } else { + credential + }; + Ok(Some(oauth_bearer(&credential))) + } + + async fn persist_oauth( + &self, + provider: &ProviderId, + name: &str, + refreshed: &OAuthCredential, + ) -> Result<(), ResolveError> { + let refreshed = refreshed.clone(); + let name = name.to_string(); + let vault = Arc::clone(&self.vault); + let failed = |source| ResolveError::RefreshFailed { + provider: provider.clone(), + source, + }; + spawn_blocking(move || { + let mut vault = vault.blocking_write(); + vault_set_oauth(&mut vault, &name, &refreshed) + .map(|_| ()) + .map_err(anyhow::Error::from) + }) + .await + .map_err(|join_err| failed(anyhow::Error::from(join_err)))? + .map_err(failed) + } + + /// Headers Fabro adds on top of what lithos shaped: interpolated + /// `default_headers` and, for OpenAI, the organization and project ids + /// from the environment. + fn decorate( + &self, + provider: &CatalogProvider, + mut credentials: Credentials, + interpolated: Vec, + ) -> Credentials { + if let Credentials::Http(http) = &mut credentials { + http.extra_headers.extend(interpolated); + if provider.id().as_str() == provider_ids::OPENAI { + for (variable, header) in [ + (EnvVars::OPENAI_ORG_ID, OPENAI_ORGANIZATION_HEADER), + (EnvVars::OPENAI_PROJECT_ID, OPENAI_PROJECT_HEADER), + ] { + if let Some(value) = (self.env_lookup)(variable) { + http.extra_headers + .push(CredentialHeader::new(header, SecretValue::new(value))); + } + } + } + } + credentials + } + + async fn has_credential_material(&self, vault: &Vault, provider: &CatalogProvider) -> bool { + oauth_secret_name(provider.id()).is_some_and(|name| vault.get_entry(name).is_some()) + || self.conventional(vault).credentials(provider).await.is_ok() + } } impl std::fmt::Debug for VaultCredentialSource { @@ -50,142 +205,527 @@ impl std::fmt::Debug for VaultCredentialSource { #[async_trait] impl CredentialSource for VaultCredentialSource { - async fn resolve(&self, catalog: &Catalog) -> anyhow::Result { - let mut credentials = Vec::new(); - let mut auth_issues = Vec::new(); - - for provider in catalog.providers() { - match self - .resolver - .resolve(provider.id.clone(), CredentialUsage::ApiRequest, catalog) - .await - { - Ok(ResolvedCredential::Api(credential)) => credentials.push(credential), - Err(ResolveError::NotConfigured(_)) if provider.auth.is_some() => {} - Err(err) => auth_issues.push((provider.id.clone(), err)), - } + async fn credentials(&self, provider: &CatalogProvider) -> Result { + let vault = self.snapshot().await; + let interpolated = interpolated_headers(&vault, provider)?; + if let Some(oauth) = self.oauth_credentials(provider, &vault).await? { + return Ok(self.decorate(provider, oauth, interpolated)); } - - Ok(ResolvedCredentials { - credentials, - auth_issues, - }) + let credentials = self + .conventional(&vault) + .credentials(provider) + .await + .map_err(|err| resolve_error(provider, &err))?; + Ok(self.decorate(provider, credentials, interpolated)) } async fn configured_providers(&self, catalog: &Catalog) -> Vec { - let vault = self.vault.read().await; - self.resolver.configured_providers(&vault, catalog) + let vault = self.snapshot().await; + let mut configured = Vec::new(); + for provider in catalog.providers().filter(|provider| provider.is_enabled()) { + if self.has_credential_material(&vault, provider).await { + configured.push(provider.id().clone()); + } + } + configured + } +} + +/// Shapes a Codex OAuth credential into the bearer the deployment expects. +fn oauth_bearer(credential: &OAuthCredential) -> Credentials { + let mut http = HttpCredentials::new(HttpAuthentication::Bearer(SecretValue::new( + credential.tokens.access_token.clone(), + ))); + if let Some(account_id) = &credential.account_id { + http.extra_headers.push(CredentialHeader::new( + CHATGPT_ACCOUNT_ID_HEADER, + SecretValue::new(account_id.clone()), + )); + } + Credentials::Http(http) +} + +/// The provider's `default_headers` whose values reference `{{ secrets.* }}`, +/// resolved against the vault. +/// +/// lithos sends `default_headers` verbatim and lets credential headers of the +/// same name win, so only the interpolated ones are re-sent here. Resolved +/// values may contain secrets; keep this path free of value logging. +pub(crate) fn interpolated_headers( + vault: &Vault, + provider: &CatalogProvider, +) -> Result, ResolveError> { + let mut ctx = + ResolveCtx::new().with_secrets(|secret_name| vault_token_lookup(vault, secret_name)); + provider + .default_headers() + .iter() + .map(|(name, source)| (name, InterpString::parse(source))) + .filter(|(_, template)| !template.is_literal()) + .map(|(name, template)| { + let value = + template + .resolve_with(&mut ctx) + .map_err(|source| ResolveError::Interpolation { + provider: provider.id().clone(), + source, + })?; + Ok(CredentialHeader::new(name.clone(), SecretValue::new(value))) + }) + .collect() +} + +pub(crate) fn auth_scheme_name(scheme: &AuthScheme) -> &'static str { + match scheme { + AuthScheme::None => "none", + AuthScheme::Bearer { .. } => "bearer", + AuthScheme::Header { .. } => "header", + AuthScheme::Headers => "headers", + AuthScheme::Aws { .. } => "aws", + AuthScheme::BedrockBearer => "bedrock_bearer", + _ => "unknown", + } +} + +/// Maps a lithos lookup failure onto Fabro's vocabulary. A missing secret is +/// not an issue to report; the provider is simply not configured. +pub(crate) fn resolve_error(provider: &CatalogProvider, err: &CredentialError) -> ResolveError { + match err { + CredentialError::SchemeMismatch { .. } => ResolveError::SchemeMismatch { + provider: provider.id().clone(), + scheme: auth_scheme_name(provider.auth()).to_string(), + }, + _ => ResolveError::NotConfigured(provider.id().clone()), + } +} + +fn vault_lookup_error(provider: &ProviderId, name: &str, err: VaultLookupError) -> ResolveError { + match err { + VaultLookupError::SchemaMismatch { actual, .. } => ResolveError::VaultSchemaMismatch { + provider: provider.clone(), + name: name.to_string(), + actual, + }, + VaultLookupError::DecodeFailed { source, .. } => ResolveError::VaultDecodeFailed { + provider: provider.clone(), + name: name.to_string(), + source, + }, } } #[cfg(test)] mod tests { - use std::sync::Arc; + use std::collections::HashMap; use chrono::{Duration, Utc}; - use fabro_model::{Catalog, ProviderId}; - use fabro_vault::Vault; - use tokio::sync::RwLock as AsyncRwLock; + use httpmock::Method::POST; + use httpmock::MockServer; + use lithos_llm::catalog::Catalog; - use super::VaultCredentialSource; - use crate::credential::{OAuthConfig, OAuthCredential, OAuthTokens}; - use crate::vault_ext::{vault_set_oauth, vault_set_token}; - use crate::{CredentialSource, ResolveError}; + use super::*; + use crate::credential::{OAuthConfig, OAuthTokens}; + use crate::test_support::{test_catalog, test_catalog_with_overlay}; + use crate::vault_ext::vault_set_token; + use crate::{OPENAI_CODEX_VAULT_SECRET_NAME, auth_issue_message}; - fn expired_openai_credential() -> OAuthCredential { + fn oauth_credential(token_url: String, expires_at: chrono::DateTime) -> OAuthCredential { OAuthCredential { tokens: OAuthTokens { - access_token: "expired-access".to_string(), + access_token: "expired-access".to_string(), refresh_token: Some("refresh-token".to_string()), - expires_at: Utc::now() - Duration::hours(1), + expires_at, }, config: OAuthConfig { - auth_url: "https://auth.openai.com".to_string(), - token_url: "http://127.0.0.1:9/oauth/token".to_string(), - client_id: "client".to_string(), - scopes: vec!["openid".to_string()], - redirect_uri: Some("https://example.com/callback".to_string()), - use_pkce: true, + auth_url: "https://auth.openai.com".to_string(), + token_url, + client_id: "test-client".to_string(), + scopes: vec!["openid".to_string()], + redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()), + use_pkce: true, }, account_id: Some("acct_123".to_string()), } } - fn default_catalog() -> Catalog { - Catalog::from_builtin().unwrap() + fn empty_vault() -> Vault { + Vault::from_entries(HashMap::new()) + } + + fn source_with( + vault: Vault, + env: impl Fn(&str) -> Option + Send + Sync + 'static, + ) -> VaultCredentialSource { + VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), env) + } + + fn bearer_secret(credentials: &Credentials) -> &str { + match credentials { + Credentials::Http(HttpCredentials { + auth: HttpAuthentication::Bearer(secret), + .. + }) => secret.expose_secret(), + _ => panic!("expected bearer credentials"), + } + } + + /// A header the credentials carry, whether as the primary `auth` header + /// or as an extra header. + fn header_value<'a>(credentials: &'a Credentials, name: &str) -> Option<&'a str> { + let Credentials::Http(http) = credentials else { + return None; + }; + let primary = match &http.auth { + HttpAuthentication::Header(header) => Some(header), + _ => None, + }; + primary + .into_iter() + .chain(http.extra_headers.iter()) + .find(|header| header.name.eq_ignore_ascii_case(name)) + .map(|header| header.value.expose_secret()) + } + + /// An operator-defined gateway whose header secret lives in the vault. + fn gateway_catalog() -> Catalog { + test_catalog_with_overlay( + r#" +[providers.gateway] +display_name = "Gateway" +adapter = "openai-compatible" +codec = "openai-chat" +base_url = "https://gateway.test/v1" +auth = { type = "bearer" } +default_headers = { "x-portkey-api-key" = "{{ secrets.PORTKEY_API_KEY }}", "x-portkey-config" = "@prod" } + +[providers.gateway.models.large] +display_name = "Large" +api_model = "large" +"#, + ) } #[tokio::test] - async fn resolve_returns_credentials_and_auth_issues() { - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); + async fn environment_wins_over_the_vault() { + let mut vault = empty_vault(); + vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap(); + let source = source_with(vault, |name| { + (name == "OPENAI_API_KEY").then(|| "env-key".to_string()) + }); + let catalog = test_catalog(); + let credentials = source + .credentials(catalog.provider("openai").unwrap()) + .await + .unwrap(); + assert_eq!(bearer_secret(&credentials), "env-key"); + } + + #[tokio::test] + async fn conventional_fallback_names_apply_to_the_vault() { + let mut vault = empty_vault(); + vault_set_token(&mut vault, EnvVars::KIMI_API_KEY, "kimi-key").unwrap(); + let source = source_with(vault, |_| None); + let catalog = test_catalog(); + let credentials = source + .credentials(catalog.provider("moonshot").unwrap()) + .await + .unwrap(); + assert_eq!(bearer_secret(&credentials), "kimi-key"); + } + + #[tokio::test] + async fn anthropic_uses_its_header_scheme() { + let mut vault = empty_vault(); + vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap(); + let source = source_with(vault, |_| None); + let catalog = test_catalog(); + let credentials = source + .credentials(catalog.provider("anthropic").unwrap()) + .await + .unwrap(); + match credentials { + Credentials::Http(HttpCredentials { + auth: HttpAuthentication::Header(header), + .. + }) => { + assert_eq!(header.name, "x-api-key"); + assert_eq!(header.value.expose_secret(), "anthropic-key"); + } + _ => panic!("expected header credentials"), + } + } + + #[tokio::test] + async fn an_unlisted_provider_reads_its_derived_secret_name() { + let mut vault = empty_vault(); + vault_set_token(&mut vault, "GATEWAY_API_KEY", "gw-key").unwrap(); + vault_set_token(&mut vault, "PORTKEY_API_KEY", "pk-key").unwrap(); + let source = source_with(vault, |_| None); + let catalog = gateway_catalog(); + let credentials = source + .credentials(catalog.provider("gateway").unwrap()) + .await + .unwrap(); + assert_eq!(bearer_secret(&credentials), "gw-key"); + assert_eq!( + header_value(&credentials, "x-portkey-api-key"), + Some("pk-key") + ); + assert_eq!( + header_value(&credentials, "x-portkey-config"), + None, + "literal default headers are lithos's to send" + ); + } + + #[tokio::test] + async fn a_missing_header_secret_is_an_interpolation_issue() { + let mut vault = empty_vault(); + vault_set_token(&mut vault, "GATEWAY_API_KEY", "gw-key").unwrap(); + let source = source_with(vault, |_| None); + let catalog = gateway_catalog(); + let err = source + .credentials(catalog.provider("gateway").unwrap()) + .await + .unwrap_err(); + assert!(matches!(err, ResolveError::Interpolation { .. }), "{err}"); + assert!(!err.to_string().contains("gw-key")); + } + + #[tokio::test] + async fn codex_oauth_becomes_a_bearer_with_account_header() { + let mut vault = empty_vault(); vault_set_oauth( &mut vault, - crate::OPENAI_CODEX_VAULT_SECRET_NAME, - &expired_openai_credential(), + OPENAI_CODEX_VAULT_SECRET_NAME, + &oauth_credential( + "https://auth.openai.com/oauth/token".to_string(), + Utc::now() + Duration::hours(1), + ), ) .unwrap(); - vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap(); + let source = source_with(vault, |_| None); + let catalog = test_catalog(); + let credentials = source + .credentials(catalog.provider("openai-codex").unwrap()) + .await + .unwrap(); + assert_eq!(bearer_secret(&credentials), "expired-access"); + assert_eq!( + header_value(&credentials, CHATGPT_ACCOUNT_ID_HEADER), + Some("acct_123") + ); + } - let source = - VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |_| None); - let catalog = default_catalog(); + #[tokio::test] + async fn openai_api_key_attaches_org_and_project_from_env() { + let source = source_with(empty_vault(), |name| match name { + "OPENAI_API_KEY" => Some("key".to_string()), + "OPENAI_ORG_ID" => Some("org".to_string()), + "OPENAI_PROJECT_ID" => Some("proj".to_string()), + _ => None, + }); + let catalog = test_catalog(); + let credentials = source + .credentials(catalog.provider("openai").unwrap()) + .await + .unwrap(); + assert_eq!( + header_value(&credentials, "OpenAI-Organization"), + Some("org") + ); + assert_eq!(header_value(&credentials, "OpenAI-Project"), Some("proj")); + } - let resolved = source.resolve(&catalog).await.unwrap(); + #[tokio::test] + async fn bedrock_takes_a_bearer_and_falls_back_to_the_aws_default_chain() { + let catalog = test_catalog(); + let source = source_with(empty_vault(), |_| None); + let credentials = source + .credentials(catalog.provider("bedrock").unwrap()) + .await + .unwrap(); + assert!(matches!(credentials, Credentials::AwsDefaultChain { .. })); - assert_eq!(resolved.credentials.len(), 1); - assert_eq!(resolved.credentials[0].provider, ProviderId::anthropic()); - assert_eq!(resolved.auth_issues.len(), 1); + let source = source_with(empty_vault(), |name| { + (name == "BEDROCK_API_KEY").then(|| "bearer".to_string()) + }); + let credentials = source + .credentials(catalog.provider("bedrock").unwrap()) + .await + .unwrap(); + assert!(matches!(credentials, Credentials::BedrockBearer(_))); + } + + #[tokio::test] + async fn missing_provider_material_is_not_configured() { + let source = source_with(empty_vault(), |_| None); + let catalog = test_catalog(); + let err = source + .credentials(catalog.provider("anthropic").unwrap()) + .await + .unwrap_err(); assert!(matches!( - &resolved.auth_issues[0].1, - ResolveError::RefreshFailed { - provider, - .. - } if provider == &ProviderId::openai() + err, + ResolveError::NotConfigured(provider) if provider.as_str() == "anthropic" )); } #[tokio::test] - async fn configured_providers_reads_from_vault_without_refreshing() { - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "OPENAI_API_KEY", "openai-key").unwrap(); - vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap(); - let source = - VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |_| None); - let catalog = default_catalog(); + async fn modal_needs_both_proxy_tokens() { + let mut vault = empty_vault(); + vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap(); + let source = source_with(vault, |_| None); + let catalog = test_catalog(); + let modal = catalog.provider("modal").unwrap(); + assert!(matches!( + source.credentials(modal).await.unwrap_err(), + ResolveError::NotConfigured(_) + )); - assert_eq!(source.configured_providers(&catalog).await, vec![ - ProviderId::anthropic(), - ProviderId::openai() - ]); + let mut vault = empty_vault(); + vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap(); + vault_set_token(&mut vault, "MODAL_TOKEN_SECRET", "ws-test").unwrap(); + let source = VaultCredentialSource::vault_only(Arc::new(AsyncRwLock::new(vault))); + let credentials = source.credentials(modal).await.unwrap(); + assert_eq!(header_value(&credentials, "Modal-Key"), Some("wk-test")); + assert_eq!(header_value(&credentials, "Modal-Secret"), Some("ws-test")); } #[tokio::test] - async fn vault_only_ignores_env_lookup_values() { - let env_dir = tempfile::tempdir().unwrap(); - let vault_only_dir = tempfile::tempdir().unwrap(); - let catalog = default_catalog(); - let env_backed = VaultCredentialSource::with_env_lookup( - Arc::new(AsyncRwLock::new( - Vault::load(env_dir.path().join("secrets.json")).unwrap(), - )), - |name| (name == "OPENAI_API_KEY").then(|| "env-openai-key".to_string()), - ); - assert_eq!(env_backed.configured_providers(&catalog).await, vec![ - ProviderId::openai() - ]); + async fn configured_providers_reads_vault_and_env_without_refreshing() { + let mut vault = empty_vault(); + vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap(); + vault_set_oauth( + &mut vault, + OPENAI_CODEX_VAULT_SECRET_NAME, + &oauth_credential( + "http://127.0.0.1:9/oauth/token".to_string(), + Utc::now() - Duration::hours(1), + ), + ) + .unwrap(); + let source = source_with(vault, |name| { + (name == "ANTHROPIC_API_KEY").then(|| "env".to_string()) + }); + let catalog = test_catalog(); + let configured = source.configured_providers(&catalog).await; + assert!(configured.contains(&ProviderId::new("openai"))); + assert!(configured.contains(&ProviderId::new("anthropic"))); + assert!(configured.contains(&ProviderId::new("openai-codex"))); + // Bedrock always resolves through the AWS chain but ships disabled. + assert!(!configured.contains(&ProviderId::new("bedrock"))); + assert!(!configured.contains(&ProviderId::new("gemini"))); + } - let vault_only = VaultCredentialSource::vault_only(Arc::new(AsyncRwLock::new( - Vault::load(vault_only_dir.path().join("secrets.json")).unwrap(), - ))); + #[tokio::test] + async fn resolve_all_separates_ready_providers_from_auth_issues() { + let mut vault = empty_vault(); + vault_set_oauth( + &mut vault, + OPENAI_CODEX_VAULT_SECRET_NAME, + &oauth_credential( + "http://127.0.0.1:9/oauth/token".to_string(), + Utc::now() - Duration::hours(1), + ), + ) + .unwrap(); + vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap(); + let source = source_with(vault, |_| None); + let resolved = source.resolve_all(&test_catalog()).await; + assert_eq!(resolved.ready, vec![ProviderId::new("anthropic")]); + assert_eq!(resolved.auth_issues.len(), 1); + assert!(matches!( + &resolved.auth_issues[0].1, + ResolveError::RefreshFailed { provider, .. } if provider.as_str() == "openai-codex" + )); + } - assert!( - vault_only.configured_providers(&catalog).await.is_empty(), - "vault_only must not resolve env-backed provider keys" - ); - let resolved = vault_only.resolve(&catalog).await.unwrap(); - assert!(resolved.credentials.is_empty()); + #[tokio::test] + async fn vault_only_ignores_the_environment() { + let catalog = test_catalog(); + let vault_only = + VaultCredentialSource::vault_only(Arc::new(AsyncRwLock::new(empty_vault()))); + assert!(vault_only.configured_providers(&catalog).await.is_empty()); + let resolved = vault_only.resolve_all(&catalog).await; + assert!(resolved.ready.is_empty()); assert!(resolved.auth_issues.is_empty()); } + + #[tokio::test] + async fn refreshes_expired_oauth_credentials_and_persists_them() { + let server = MockServer::start_async().await; + let refresh_mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/oauth/token") + .form_urlencoded_tuple("grant_type", "refresh_token") + .form_urlencoded_tuple("client_id", "test-client") + .form_urlencoded_tuple("refresh_token", "refresh-token"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "access_token": "new-access", + "refresh_token": "new-refresh", + "expires_in": 3600 + }) + .to_string(), + ); + }) + .await; + + let mut vault = empty_vault(); + vault_set_oauth( + &mut vault, + OPENAI_CODEX_VAULT_SECRET_NAME, + &oauth_credential( + server.url("/oauth/token"), + Utc::now() - Duration::minutes(1), + ), + ) + .unwrap(); + let vault = Arc::new(AsyncRwLock::new(vault)); + let source = VaultCredentialSource::vault_only(Arc::clone(&vault)); + let catalog = test_catalog(); + + let credentials = source + .credentials(catalog.provider("openai-codex").unwrap()) + .await + .unwrap(); + assert_eq!(bearer_secret(&credentials), "new-access"); + + let stored = { + let vault = vault.read().await; + vault_get_oauth(&vault, OPENAI_CODEX_VAULT_SECRET_NAME) + .unwrap() + .unwrap() + }; + assert_eq!(stored.tokens.access_token, "new-access"); + assert_eq!(stored.tokens.refresh_token.as_deref(), Some("new-refresh")); + assert_eq!(stored.account_id.as_deref(), Some("acct_123")); + refresh_mock.assert_async().await; + } + + #[tokio::test] + async fn expired_oauth_without_refresh_token_requires_reauthentication() { + let mut vault = empty_vault(); + let mut credential = oauth_credential( + "https://auth.openai.com/oauth/token".to_string(), + Utc::now() - Duration::minutes(1), + ); + credential.tokens.refresh_token = None; + vault_set_oauth(&mut vault, OPENAI_CODEX_VAULT_SECRET_NAME, &credential).unwrap(); + let source = source_with(vault, |_| None); + let catalog = test_catalog(); + let err = source + .credentials(catalog.provider("openai-codex").unwrap()) + .await + .unwrap_err(); + assert!(matches!(err, ResolveError::RefreshTokenMissing(_))); + assert_eq!( + auth_issue_message(&ProviderId::new("openai-codex"), &err), + "openai-codex requires re-authentication: refresh token missing" + ); + } } diff --git a/lib/foundation/fabro-client/Cargo.toml b/lib/foundation/fabro-client/Cargo.toml index e2601ad8b..c12175924 100644 --- a/lib/foundation/fabro-client/Cargo.toml +++ b/lib/foundation/fabro-client/Cargo.toml @@ -18,7 +18,6 @@ bytes.workspace = true chrono = { workspace = true, features = ["serde"] } fabro-api = { path = "../fabro-api" } fabro-http.workspace = true -fabro-model = { path = "../fabro-model" } fabro-static.workspace = true fabro-types = { path = "../fabro-types" } fabro-util = { path = "../fabro-util" } diff --git a/lib/foundation/fabro-client/src/client.rs b/lib/foundation/fabro-client/src/client.rs index add17b464..f8536bbcf 100644 --- a/lib/foundation/fabro-client/src/client.rs +++ b/lib/foundation/fabro-client/src/client.rs @@ -10,13 +10,12 @@ use bytes::Bytes; use fabro_api::types; use fabro_http::header::{ACCEPT, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE}; use fabro_http::multipart::{Form, Part}; -use fabro_model::{Model, ModelTestMode, ProviderId, ReasoningEffort}; use fabro_types::settings::run::MergeStrategy; use fabro_types::{ - ArtifactUpload, BlobHash, EventEnvelope, PairId, PairMessageRecord, PairMessageRequest, - PairRecord, PairStartRequest, PairTranscriptResponse, Run, RunEvent, RunEventDetailResponse, - RunId, RunPairStatusResponse, RunProjection, SessionId, SessionRecord, StageId, - WorkflowVersion, WorkflowVersionId, + ArtifactUpload, BlobHash, EventEnvelope, Model, ModelTestMode, PairId, PairMessageRecord, + PairMessageRequest, PairRecord, PairStartRequest, PairTranscriptResponse, ProviderId, + ReasoningEffort, Run, RunEvent, RunEventDetailResponse, RunId, RunPairStatusResponse, + RunProjection, SessionId, SessionRecord, StageId, WorkflowVersion, WorkflowVersionId, }; use fabro_util::exit::{ErrorExt, ExitClass}; use futures::future::BoxFuture; diff --git a/lib/foundation/fabro-config/Cargo.toml b/lib/foundation/fabro-config/Cargo.toml index 8c3df637e..059ed184b 100644 --- a/lib/foundation/fabro-config/Cargo.toml +++ b/lib/foundation/fabro-config/Cargo.toml @@ -21,7 +21,6 @@ anyhow.workspace = true clap = { workspace = true, optional = true } chrono.workspace = true fabro-macros = { path = "../fabro-macros" } -fabro-model = { path = "../fabro-model" } fabro-options-metadata.workspace = true fabro-proc = { path = "../fabro-proc" } fabro-static.workspace = true diff --git a/lib/foundation/fabro-config/src/builders.rs b/lib/foundation/fabro-config/src/builders.rs index 08eb4c70b..db07ae3a3 100644 --- a/lib/foundation/fabro-config/src/builders.rs +++ b/lib/foundation/fabro-config/src/builders.rs @@ -1,8 +1,7 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::HashMap; use std::fmt; use std::path::Path; -use fabro_model::catalog as model_catalog; use fabro_types::settings::run::McpServerSettings; use fabro_types::settings::{RunNamespace, WorkflowNamespace}; use fabro_types::{ServerSettings, UserSettings, WorkflowSettings}; @@ -16,9 +15,8 @@ use crate::resolve::{ }; use crate::user::load_settings_config; use crate::{ - CliLayer, Combine, CostRates, EnvironmentLayer, Error, LlmLayer, LlmModelFeatures, - LlmModelLimits, MergeMap, ModelControls, ModelCostTable, ModelSettings, ProviderSettings, - Result, RunLayer, ServerLayer, SettingsLayer, run, + CliLayer, Combine, EnvironmentLayer, Error, LlmLayer, MergeMap, Result, RunLayer, ServerLayer, + SettingsLayer, run, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -231,7 +229,8 @@ pub struct ServerRuntimeSettings { pub manifest_run_defaults: RunLayer, pub manifest_environment_defaults: crate::MergeMap, pub manifest_run_settings: std::result::Result, - pub llm_catalog_settings: model_catalog::LlmCatalogSettings, + /// Operator catalog overlay, applied above the built-in and policy layers. + pub llm_overlay: LlmLayer, } pub fn load_server_runtime_settings( @@ -246,12 +245,12 @@ pub fn load_server_runtime_settings( resolve_server_runtime_settings(layer, run_overrides, server_overrides) } -pub fn load_llm_catalog_settings(path: Option<&Path>) -> Result { +pub fn load_llm_overlay(path: Option<&Path>) -> Result { let layer = match path { Some(path) => load_settings_path(path, SettingsSource::ActiveSettings)?, None => load_settings_config(None)?, }; - Ok(llm_catalog_settings_from_layer(&layer)) + Ok(llm_overlay_from_layer(&layer)) } #[cfg(test)] @@ -286,7 +285,7 @@ fn resolve_server_runtime_settings( let manifest_run_defaults = layer.run.clone().unwrap_or_default(); let manifest_environment_defaults = layer.environments.clone(); - let llm_catalog_settings = llm_catalog_settings_from_layer(&layer); + let llm_overlay = llm_overlay_from_layer(&layer); Ok(ServerRuntimeSettings { server_settings: ServerSettingsBuilder::from_layer(&layer)?, manifest_run_settings: RunSettingsBuilder::from_layer(&SettingsLayer { @@ -297,162 +296,13 @@ fn resolve_server_runtime_settings( .map_err(|err| SharedError::new(anyhow::Error::new(err))), manifest_run_defaults, manifest_environment_defaults, - llm_catalog_settings, + llm_overlay, }) } -fn llm_catalog_settings_from_layer(layer: &SettingsLayer) -> model_catalog::LlmCatalogSettings { +fn llm_overlay_from_layer(layer: &SettingsLayer) -> LlmLayer { let layer = layer.clone().combine(DEFAULTS_LAYER.clone()); - layer - .llm - .map(llm_layer_to_catalog_settings) - .unwrap_or_default() -} - -fn llm_layer_to_catalog_settings(llm: LlmLayer) -> model_catalog::LlmCatalogSettings { - model_catalog::LlmCatalogSettings { - providers: llm - .providers - .into_inner() - .into_iter() - .map(|(id, settings)| (id, provider_settings_to_catalog(settings))) - .collect(), - models: llm - .models - .into_inner() - .into_iter() - .map(|(id, settings)| (id, model_settings_to_catalog(settings))) - .collect(), - } -} - -fn provider_settings_to_catalog( - settings: ProviderSettings, -) -> model_catalog::ProviderCatalogSettings { - #[expect( - clippy::disallowed_methods, - reason = "collapse the authoring InterpString header values to their catalog source \ - strings; they are re-parsed and resolved at the credential boundary" - )] - let extra_headers = settings.extra_headers.map(|headers| { - headers - .into_iter() - .map(|(name, value)| (name, value.as_source())) - .collect() - }); - let models = settings - .models - .into_inner() - .into_iter() - .map(|(id, settings)| (id, model_settings_to_catalog(settings))) - .collect(); - model_catalog::ProviderCatalogSettings { - display_name: settings.display_name, - adapter: settings.adapter, - codec: settings.codec, - agent_profile: settings.agent_profile, - auth: settings.auth, - billing_policy: settings.billing_policy, - api_key_url: settings.api_key_url, - base_url: settings.base_url, - extra_headers, - priority: settings.priority, - enabled: settings.enabled, - aliases: settings.aliases, - models, - } -} - -fn model_settings_to_catalog(settings: ModelSettings) -> model_catalog::ModelCatalogSettings { - let ModelSettings { - provider, - api_id, - codec, - billing_policy, - agent_profile, - display_name, - family, - training, - knowledge_cutoff, - default, - small_default, - probe, - enabled, - aliases, - estimated_output_tps, - limits, - features, - controls, - costs, - } = settings; - model_catalog::ModelCatalogSettings { - provider, - api_id, - codec, - billing_policy, - agent_profile, - display_name, - family, - training, - knowledge_cutoff, - default, - small_default, - probe, - enabled, - aliases, - estimated_output_tps, - limits: limits.as_ref().map(model_limits_to_catalog), - features: features.as_ref().map(model_features_to_catalog), - controls: controls.map(model_controls_to_catalog), - costs: costs.as_ref().map(model_cost_table_to_catalog), - } -} - -fn model_limits_to_catalog(limits: &LlmModelLimits) -> model_catalog::SettingsModelLimits { - model_catalog::SettingsModelLimits { - context_window: limits.context_window, - max_output: limits.max_output, - } -} - -fn model_features_to_catalog(features: &LlmModelFeatures) -> model_catalog::SettingsModelFeatures { - model_catalog::SettingsModelFeatures { - tools: features.tools, - vision: features.vision, - reasoning: features.reasoning, - reasoning_by_default: features.reasoning_by_default, - reasoning_effort: features.reasoning_effort, - prompt_cache: features.prompt_cache, - cache_control_breakpoints: features.cache_control_breakpoints, - sampling_params: features.sampling_params, - } -} - -fn model_controls_to_catalog(controls: ModelControls) -> model_catalog::SettingsModelControls { - model_catalog::SettingsModelControls { - reasoning_effort: controls.reasoning_effort, - speed: controls.speed, - } -} - -fn model_cost_table_to_catalog(costs: &ModelCostTable) -> model_catalog::SettingsModelCostTable { - model_catalog::SettingsModelCostTable { - base: cost_rates_to_catalog(&costs.base), - speed: costs.speed.as_ref().map(|speed| { - speed - .iter() - .map(|(key, rates)| (key.clone(), cost_rates_to_catalog(rates))) - .collect::>() - }), - } -} - -fn cost_rates_to_catalog(rates: &CostRates) -> model_catalog::CostRates { - model_catalog::CostRates { - input_cost_per_mtok: rates.input_cost_per_mtok, - output_cost_per_mtok: rates.output_cost_per_mtok, - cache_input_cost_per_mtok: rates.cache_input_cost_per_mtok, - } + layer.llm.unwrap_or_default() } fn parse_settings_toml(source: &str, kind: SettingsSource) -> Result { @@ -829,7 +679,7 @@ provider = "docker" } #[test] - fn server_runtime_settings_preserves_llm_catalog_overrides() { + fn server_runtime_settings_preserves_llm_overlay() { let settings = server_runtime_settings_from_toml( r#" _version = 1 @@ -839,92 +689,28 @@ methods = ["dev-token"] [llm.providers.acme] display_name = "Acme" -adapter = "openai_compatible" +adapter = "openai-compatible" +codec = "openai-chat" base_url = "https://api.acme.test/v1" -agent_profile = "anthropic" +auth = { type = "bearer" } +enabled = true -[llm.providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[llm.models."acme-large"] -provider = "acme" +[llm.providers.acme.models."acme-large"] display_name = "Acme Large" -family = "acme" -default = true -agent_profile = "gemini" - -[llm.models."acme-large".limits] -context_window = 128000 - -[llm.models."acme-large".features] -tools = true -vision = false -reasoning = false +api_model = "acme-large" "#, None, None, ) .expect("server runtime settings should resolve"); - let catalog = - fabro_model::Catalog::from_builtin_with_overrides(&settings.llm_catalog_settings) - .expect("catalog overrides should build"); - + let overlay = settings.llm_overlay.0; + let acme = &overlay["providers"]["acme"]; + assert_eq!(acme["display_name"].as_str(), Some("Acme")); + assert_eq!(acme["enabled"].as_bool(), Some(true)); assert_eq!( - catalog - .get_on_provider(&fabro_model::ProviderId::new("acme"), "acme-large") - .map(|model| model.provider.clone()), - Some(fabro_model::ProviderId::new("acme")) - ); - assert_eq!( - catalog - .effective_agent_profile(&fabro_model::ProviderId::new("acme"), Some("acme-large")), - Some(fabro_model::AgentProfileKind::Gemini) - ); - } - - #[test] - fn server_runtime_settings_preserves_extra_header_sources() { - let settings = server_runtime_settings_from_toml( - r#" -_version = 1 - -[server.auth] -methods = ["dev-token"] - -[llm.providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -base_url = "https://api.acme.test/v1" - -[llm.providers.acme.extra_headers] -x-title = "My App" -x-api-key = "{{ env.ACME_GATEWAY_API_KEY }}" -x-team-secret = "Bearer {{ secrets.ACME_GATEWAY_TOKEN }}" -"#, - None, - None, - ) - .expect("server runtime settings should resolve"); - - let provider = settings - .llm_catalog_settings - .providers - .get("acme") - .expect("provider settings should be present"); - let headers = provider - .extra_headers - .as_ref() - .expect("extra header settings should be present"); - - assert_eq!(headers.get("x-title").map(String::as_str), Some("My App")); - assert_eq!( - headers.get("x-api-key").map(String::as_str), - Some("{{ env.ACME_GATEWAY_API_KEY }}") - ); - assert_eq!( - headers.get("x-team-secret").map(String::as_str), - Some("Bearer {{ secrets.ACME_GATEWAY_TOKEN }}") + acme["models"]["acme-large"]["api_model"].as_str(), + Some("acme-large") ); } } diff --git a/lib/foundation/fabro-config/src/layers/combine.rs b/lib/foundation/fabro-config/src/layers/combine.rs index aec393777..d058c2977 100644 --- a/lib/foundation/fabro-config/src/layers/combine.rs +++ b/lib/foundation/fabro-config/src/layers/combine.rs @@ -1,6 +1,5 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::HashMap; -use fabro_model::{AgentProfileKind, BillingPolicy, CodecKind, ProviderAuthConfig}; use fabro_types::PermissionLevel; use fabro_types::settings::cli::{CliAuthStrategy, OutputFormat, OutputVerbosity}; use fabro_types::settings::run::{ @@ -15,7 +14,6 @@ use fabro_types::settings::{Duration, InterpString, Size}; use super::LogFilter; use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer}; use super::environment::EnvironmentDockerfileLayer; -use super::llm::{CostRates, CredentialRef, ReasoningEffortFeature}; use super::run::{ HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer, RunCheckpointLayer, RunGoalLayer, @@ -85,11 +83,6 @@ impl_combine_or_option!( ServerAuthMethod, WebhookStrategy, LogFilter, - AgentProfileKind, - BillingPolicy, - CodecKind, - ProviderAuthConfig, - ReasoningEffortFeature, ); impl Combine for Option> { @@ -98,24 +91,12 @@ impl Combine for Option> { } } -impl Combine for Option> { - fn combine(self, other: Self) -> Self { - self.or(other) - } -} - impl Combine for Option> { fn combine(self, other: Self) -> Self { self.or(other) } } -impl Combine for Option> { - fn combine(self, other: Self) -> Self { - self.or(other) - } -} - impl Combine for Option> { fn combine(self, other: Self) -> Self { self.or(other) diff --git a/lib/foundation/fabro-config/src/layers/llm.rs b/lib/foundation/fabro-config/src/layers/llm.rs index 298968a62..8daa204c8 100644 --- a/lib/foundation/fabro-config/src/layers/llm.rs +++ b/lib/foundation/fabro-config/src/layers/llm.rs @@ -1,982 +1,126 @@ //! `[llm]` settings layer. //! -//! Holds the trusted, mergeable LLM provider/model catalog data: +//! Operator model catalog overrides. The table uses the lithos-llm catalog +//! schema verbatim, minus `schema_version`, and is applied as an overlay layer +//! on top of the lithos built-in catalog: //! //! ```toml -//! [llm.providers.moonshot] -//! display_name = "Moonshot AI" -//! adapter = "openai_compatible" -//! base_url = "https://api.moonshot.ai/v1" +//! [llm.providers.openrouter] //! priority = 60 //! enabled = true -//! aliases = ["moonshot-ai"] //! -//! [llm.providers.moonshot.auth] -//! credentials = [ -//! "env:MOONSHOT_API_KEY", -//! "env:KIMI_API_KEY", -//! "vault:MOONSHOT_API_KEY", -//! "vault:KIMI_API_KEY", -//! ] -//! -//! [llm.providers.moonshot.models."kimi-k2.5"] -//! ... +//! [llm.providers.openrouter.models."kimi-k2.5"] +//! small_default = true //! ``` //! -//! Per-provider and per-model entries field-merge across layers (default → -//! user → server → project → workflow/run). Inner arrays such as -//! `auth.credentials`, `aliases`, `controls.reasoning_effort`, and -//! `controls.speed` replace as whole arrays. -//! -//! Adapter keys (`adapter = "..."`) are parsed as plain strings here. -//! Resolution against the static adapter registry happens in `fabro-model` -//! when the resolved [`Catalog`](fabro_model::Catalog) is built. +//! Layers merge the same way lithos merges overlays: tables merge key by key +//! and every other value replaces. Fabro never interprets the table; lithos +//! validates it when the catalog is built. -use std::collections::{BTreeMap, HashMap}; - -use fabro_model::catalog::deserialize_knowledge_cutoff; -use fabro_model::{ - AgentProfileKind, BillingPolicy, CodecKind, ModelId, ProviderAuthConfig, ProviderId, catalog, -}; -pub use fabro_model::{CredentialRef, CredentialRefParseError, ReasoningEffortFeature}; -use fabro_types::settings::InterpString; use serde::{Deserialize, Serialize}; +use toml::Table; -use super::maps::MergeMap; +use super::combine::Combine; -/// Top-level `[llm]` settings layer. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct LlmLayer { - /// Provider definitions keyed by provider ID. - #[serde(default, skip_serializing_if = "MergeMap::is_empty")] - pub providers: MergeMap, - /// Legacy top-level model definitions. New settings put models below - /// their provider; parsing normalizes this map before layers combine. - #[serde(default, skip_serializing_if = "MergeMap::is_empty")] - pub models: MergeMap, -} - -/// One entry in `[llm.providers.]`. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct ProviderSettings { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub display_name: Option, - /// Adapter registry key (e.g. `"openai_compatible"`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub adapter: Option, - /// Wire dialect for this provider's routes (e.g. `"anthropic_messages"`). - /// Defaults to the adapter's codec; only the default pairing is accepted - /// today — validated at catalog build. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub codec: Option, - /// Agent profile used for routing/profile-specific behavior. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_profile: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub billing_policy: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api_key_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_url: Option, - /// Extra HTTP headers attached to every outgoing provider request after - /// credential resolution. Values are literal text or - /// `{{ secrets.NAME }}` interpolation strings. Put credentials in a secret - /// and reference them with a token, not a bare literal. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub extra_headers: Option>, - /// Higher wins; missing → `0`; ties broken by canonical provider ID. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub priority: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub aliases: Option>, - /// Model offerings served by this provider, keyed by canonical model ID. - #[serde(default, skip_serializing_if = "MergeMap::is_empty")] - pub models: MergeMap, -} - -/// One entry in `[llm.providers..models.]`. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct ModelSettings { - /// Compatibility-only provider for legacy `[llm.models.]` rows. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - /// Identifier sent to the provider API. Defaults to the catalog model ID - /// when omitted. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api_id: Option, - /// Wire dialect for this model's route, overriding the provider's codec. - /// Only the adapter's default pairing is accepted today — validated at - /// catalog build. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub codec: Option, - /// Billing family for this model, overriding the provider's policy - /// (e.g. Anthropic cache billing for a Claude model served through an - /// aggregator). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub billing_policy: Option, - /// Agent profile used for routing/profile-specific behavior. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_profile: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub display_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub family: Option, - /// Training data cutoff label. Built-ins keep the exact public string - /// already exposed by the model API. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub training: Option, - /// Public knowledge cutoff label. Built-ins keep values such as - /// `"May 2025"` exactly; bare TOML dates are normalized to `YYYY-MM-DD`. - #[serde( - default, - deserialize_with = "deserialize_knowledge_cutoff", - skip_serializing_if = "Option::is_none" - )] - pub knowledge_cutoff: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default: Option, - /// Whether this model should be preferred for small/cheap utility tasks. - /// Missing or false falls back to the provider default model. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub small_default: Option, - /// Whether this model should be preferred for provider connectivity - /// probes. Missing or false falls back to the provider default model. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub probe: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub aliases: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub estimated_output_tps: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub limits: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub features: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub controls: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub costs: Option, -} +/// Top-level `[llm]` settings layer: a raw lithos catalog overlay. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct LlmLayer(pub Table); impl LlmLayer { - /// Normalize the temporary legacy model table before this source is - /// combined with any other settings source. Resolution runs against this - /// layer's own providers plus the built-in catalog, because a single - /// source may reference built-in offerings that merge in later. - pub(crate) fn normalize_legacy_models(&mut self) -> Result<(), catalog::LegacyModelError> { - for (provider, settings) in self.providers.iter() { - for (model, settings) in settings.models.iter() { - if settings.provider.is_some() { - return Err(catalog::LegacyModelError::ScopedModelDeclaresProvider { - provider: ProviderId::new(provider.clone()), - model: ModelId::new(model.clone()), - }); - } - } - } + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } - let legacy_models = std::mem::take(&mut self.models.0); - if legacy_models.is_empty() { - return Ok(()); - } - let mut legacy_models = legacy_models.into_iter().collect::>(); - legacy_models.sort_by(|(left, _), (right, _)| left.cmp(right)); - - let mut index = catalog::LegacyModelIndex::default(); - let mut provider_ids = self.providers.keys().cloned().collect::>(); - provider_ids.sort_unstable(); - for provider_id in &provider_ids { - let settings = self - .providers - .get(provider_id) - .expect("provider ID came from provider map keys"); - let mut model_ids = settings.models.keys().cloned().collect::>(); - model_ids.sort_unstable(); - index.add_provider( - ProviderId::new(provider_id.clone()), - settings.aliases.clone().unwrap_or_default(), - model_ids.into_iter().map(|model_id| { - let model = settings - .models - .get(&model_id) - .expect("model ID came from model map keys"); - let aliases = model.aliases.clone().unwrap_or_default(); - (ModelId::new(model_id), aliases) - }), - ); - } - let index = index.with_builtin()?; - - for (legacy_id, mut settings) in legacy_models { - let explicit_provider = settings.provider.take(); - let (provider, model) = index.resolve(&legacy_id, explicit_provider.as_deref())?; - - let provider_settings = self.providers.entry(provider.to_string()).or_default(); - if provider_settings.models.contains_key(model.as_str()) { - return Err(catalog::LegacyModelError::DuplicateModel { provider, model }); - } - provider_settings - .models - .insert(model.into_inner(), settings); - } - Ok(()) + /// Render this layer as a lithos catalog overlay document. + #[must_use] + pub fn to_overlay_toml(&self) -> String { + toml::to_string(&self.0).expect("a TOML table always serializes") } } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct ModelLimits { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub context_window: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_output: Option, +impl Combine for LlmLayer { + fn combine(self, other: Self) -> Self { + let mut base = toml::Value::Table(other.0); + merge(&mut base, toml::Value::Table(self.0)); + match base { + toml::Value::Table(table) => Self(table), + _ => unreachable!("merging two tables yields a table"), + } + } } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct ModelFeatures { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub vision: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_by_default: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_effort: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prompt_cache: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_control_breakpoints: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sampling_params: Option, -} - -/// User-facing allow-list for native control values Fabro accepts on this -/// model. Whole-array replacement on merge. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct ModelControls { - /// Allowed reasoning-effort values. Strings (e.g. `"low"`, `"high"`, - /// `"xhigh"`) — validated as `ReasoningEffort` at catalog build. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_effort: Option>, - /// Additional speeds beyond `Speed::Standard`. Strings — validated as - /// `Speed` at catalog build. `Speed::Standard` is implicit and must not - /// appear here. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub speed: Option>, -} - -/// Pricing table. Base [`CostRates`] always apply; per-speed overrides -/// substitute when the request specifies a non-standard speed. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct ModelCostTable { - #[serde(flatten)] - pub base: CostRates, - /// Per-speed cost overrides (e.g. `costs.speed.fast = { ... }`). Keys - /// must reference a speed declared in `controls.speed`. `standard` is - /// not a valid override key — base rates serve standard speed. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub speed: Option>, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct CostRates { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input_cost_per_mtok: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub output_cost_per_mtok: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_input_cost_per_mtok: Option, +fn merge(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(existing, value); + } else { + base.insert(key, value); + } + } + } + (base, overlay) => *base = overlay, + } } #[cfg(test)] mod tests { - use std::str::FromStr; - - use fabro_model::ApiKeyHeaderPolicy; - use super::*; - use crate::layers::Combine; - // ---- CredentialRef ---------------------------------------------------- - - #[test] - fn credential_ref_parses_vault_form() { - let r = CredentialRef::from_str("vault:OPENAI_CODEX").unwrap(); - assert_eq!(r, CredentialRef::Vault("OPENAI_CODEX".to_string())); + fn layer(source: &str) -> LlmLayer { + LlmLayer(toml::from_str(source).unwrap()) } #[test] - fn credential_ref_parses_env_form() { - let r = CredentialRef::from_str("env:KIMI_API_KEY").unwrap(); - assert_eq!(r, CredentialRef::Env("KIMI_API_KEY".to_string())); - } - - #[test] - fn credential_ref_rejects_literal_secret() { - // A literal API key contains no `vault:` or `env:` prefix. - let err = CredentialRef::from_str("sk-ant-1234").unwrap_err(); - assert!(err.to_string().contains("must be")); - assert!( - !err.to_string().contains("sk-ant-1234"), - "error must not echo the literal secret string back to the user", + fn higher_layer_wins_scalars_and_merges_tables() { + let higher = layer( + r" +[providers.acme] +priority = 10 +enabled = false +", ); - } - - #[test] - fn credential_ref_rejects_empty_vault_name() { - let err = CredentialRef::from_str("vault:").unwrap_err(); - assert!(err.to_string().contains("missing")); - } - - #[test] - fn credential_ref_rejects_empty_env_name() { - let err = CredentialRef::from_str("env:").unwrap_err(); - assert!(err.to_string().contains("missing")); - } - - #[test] - fn credential_ref_round_trips_through_string() { - let r = CredentialRef::Vault("kimi".to_string()); - assert_eq!(r.to_string(), "vault:kimi"); - let back: CredentialRef = r.to_string().parse().unwrap(); - assert_eq!(back, r); - } - - #[test] - fn credential_ref_serializes_as_string_in_toml() { - let r = CredentialRef::Env("KIMI_API_KEY".to_string()); - let s = toml::Value::try_from(&r).unwrap(); - assert_eq!(s.as_str(), Some("env:KIMI_API_KEY")); - } - - #[test] - fn credential_ref_deserializes_from_toml_string() { - let parsed: CredentialRef = toml::from_str(r#"v = "vault:foo""#) - .map(|v: toml::Value| { - v.as_table() - .unwrap() - .get("v") - .unwrap() - .clone() - .try_into() - .unwrap() - }) - .unwrap(); - assert_eq!(parsed, CredentialRef::Vault("foo".to_string())); - } - - #[test] - fn credential_ref_in_array_rejects_literal_secret() { - // serde rejects literal secrets when parsed inside an array of - // CredentialRef. The error bubbles up as a TOML deserialization - // failure. - #[derive(Deserialize)] - #[expect( - dead_code, - reason = "field exists only to drive the deserializer; we assert on the parse error" - )] - struct Wrap { - v: Vec, - } - let err: Result = toml::from_str(r#"v = ["sk-literal-secret"]"#); - assert!(err.is_err(), "literal secret strings must fail to parse"); - } - - #[test] - fn provider_agent_profile_parses_from_toml() { - let parsed: LlmLayer = toml::from_str( + let lower = layer( r#" [providers.acme] -adapter = "openai_compatible" -agent_profile = "anthropic" -"#, - ) - .unwrap(); - - assert_eq!( - parsed.providers.get("acme").unwrap().agent_profile, - Some(fabro_model::AgentProfileKind::Anthropic) - ); - } - - #[test] - fn provider_codec_parses_from_toml() { - let parsed: LlmLayer = toml::from_str( - r#" -[providers.acme] -adapter = "openai_compatible" -codec = "openai_compatible" -"#, - ) - .unwrap(); - - assert_eq!( - parsed.providers.get("acme").unwrap().codec, - Some(fabro_model::CodecKind::OpenAiCompatible) - ); - } - - #[test] - fn model_codec_parses_from_toml() { - let parsed: LlmLayer = toml::from_str( - r#" -[models.acme_large] -provider = "acme" -codec = "anthropic_messages" -"#, - ) - .unwrap(); - - assert_eq!( - parsed.models.get("acme_large").unwrap().codec, - Some(fabro_model::CodecKind::AnthropicMessages) - ); - } - - #[test] - fn model_billing_policy_parses_from_toml() { - let parsed: LlmLayer = toml::from_str( - r#" -[models.acme_claude] -provider = "acme" -billing_policy = "anthropic" -"#, - ) - .unwrap(); - - assert_eq!( - parsed.models.get("acme_claude").unwrap().billing_policy, - Some(fabro_model::BillingPolicy::Anthropic) - ); - } - - #[test] - fn model_agent_profile_parses_from_toml() { - let parsed: LlmLayer = toml::from_str( - r#" -[models.acme_large] -provider = "acme" -agent_profile = "gemini" -"#, - ) - .unwrap(); - - assert_eq!( - parsed.models.get("acme_large").unwrap().agent_profile, - Some(fabro_model::AgentProfileKind::Gemini) - ); - } - - // ---- Provider extra headers ------------------------------------------ - - #[expect( - clippy::disallowed_methods, - reason = "tests assert unresolved interpolation header source round-trips" - )] - fn interp_source(value: &InterpString) -> String { - value.as_source() - } - - // ---- LlmLayer parsing ------------------------------------------------- - - #[test] - fn parses_minimal_provider_entry() { - let toml = r#" -[providers.moonshot] -display_name = "Moonshot AI" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.moonshot.ai/v1" -priority = 60 +priority = 5 +base_url = "https://acme.test" enabled = true -aliases = ["moonshot-ai"] - -[providers.moonshot.auth] -credentials = [ - "env:MOONSHOT_API_KEY", - "env:KIMI_API_KEY", - "vault:MOONSHOT_API_KEY", - "vault:KIMI_API_KEY", -] -"#; - let layer: LlmLayer = toml::from_str(toml).unwrap(); - let moonshot = layer.providers.get("moonshot").unwrap(); - assert_eq!(moonshot.display_name.as_deref(), Some("Moonshot AI")); - assert_eq!(moonshot.adapter.as_deref(), Some("openai_compatible")); - assert_eq!(moonshot.agent_profile, Some(AgentProfileKind::OpenAi)); - let auth = moonshot.auth.as_ref().expect("expected api_key auth"); - assert_eq!(auth.header, ApiKeyHeaderPolicy::Bearer); - assert_eq!(auth.credentials, vec![ - CredentialRef::Env("MOONSHOT_API_KEY".to_string()), - CredentialRef::Env("KIMI_API_KEY".to_string()), - CredentialRef::Vault("MOONSHOT_API_KEY".to_string()), - CredentialRef::Vault("KIMI_API_KEY".to_string()), - ]); - assert_eq!( - moonshot.base_url.as_deref(), - Some("https://api.moonshot.ai/v1") +[providers.acme.metadata.agent] +profile = "openai" +"#, ); - assert_eq!(moonshot.priority, Some(60)); - assert_eq!(moonshot.enabled, Some(true)); + let merged = higher.combine(lower).0; + let acme = &merged["providers"]["acme"]; + assert_eq!(acme["priority"].as_integer(), Some(10)); + assert_eq!(acme["base_url"].as_str(), Some("https://acme.test")); + assert_eq!(acme["enabled"].as_bool(), Some(false)); assert_eq!( - moonshot.aliases.as_deref(), - Some(&["moonshot-ai".to_string()][..]) + acme["metadata"]["agent"]["profile"].as_str(), + Some("openai") ); } #[test] - fn provider_extra_headers_parse_interp_tokens() { - let toml = r#" -[providers.portkey] -display_name = "Portkey Bedrock" -adapter = "anthropic" -base_url = "https://api.portkey.ai/v1" - -[providers.portkey.extra_headers] -x-title = "My App" -x-portkey-api-key = "{{ env.PORTKEY_API_KEY }}" -x-team-secret = "{{ secrets.gateway_team_secret }}" -"#; - - let layer: LlmLayer = toml::from_str(toml).unwrap(); - let portkey = layer.providers.get("portkey").unwrap(); - - assert!(portkey.auth.is_none()); - let headers = portkey.extra_headers.as_ref().unwrap(); + fn arrays_replace_whole() { + let higher = layer("[providers.acme]\naliases = [\"a\"]\n"); + let lower = layer("[providers.acme]\naliases = [\"b\", \"c\"]\n"); + let merged = higher.combine(lower).0; assert_eq!( - interp_source(headers.get("x-title").expect("x-title header should parse")), - "My App", - ); - assert_eq!( - interp_source( - headers - .get("x-portkey-api-key") - .expect("x-portkey-api-key header should parse") - ), - "{{ env.PORTKEY_API_KEY }}", - ); - assert_eq!( - interp_source( - headers - .get("x-team-secret") - .expect("x-team-secret header should parse") - ), - "{{ secrets.gateway_team_secret }}", + merged["providers"]["acme"]["aliases"] + .as_array() + .map(Vec::len), + Some(1) ); } #[test] - fn provider_extra_headers_accepts_bare_string_literal() { - let toml = r#" -[providers.portkey.extra_headers] -x-portkey-api-key = "sk-portkey-literal" -"#; - - let layer: LlmLayer = toml::from_str(toml).unwrap(); - let headers = layer - .providers - .get("portkey") - .unwrap() - .extra_headers - .as_ref() - .unwrap(); - let header = headers.get("x-portkey-api-key").unwrap(); - - assert!(header.is_literal()); - assert_eq!(interp_source(header), "sk-portkey-literal"); - } - - #[test] - fn parses_full_model_entry() { - let toml = r#" -[models."kimi-k2.5"] -provider = "moonshot" -api_id = "kimi-k2.5" -display_name = "Kimi K2.5" -family = "kimi" -training = "2025-01-01" -knowledge_cutoff = 2025-01-01 -default = true -enabled = true -aliases = ["kimi"] -estimated_output_tps = 50 - -[models."kimi-k2.5".limits] -context_window = 262144 -max_output = 32768 - -[models."kimi-k2.5".features] -tools = true -vision = false -reasoning = true - -[models."kimi-k2.5".costs] -input_cost_per_mtok = 0.60 -output_cost_per_mtok = 2.50 -cache_input_cost_per_mtok = 0.15 -"#; - let layer: LlmLayer = toml::from_str(toml).unwrap(); - let m = layer.models.get("kimi-k2.5").unwrap(); - assert_eq!(m.provider.as_deref(), Some("moonshot")); - assert_eq!(m.api_id.as_deref(), Some("kimi-k2.5")); - assert_eq!(m.display_name.as_deref(), Some("Kimi K2.5")); - assert_eq!(m.family.as_deref(), Some("kimi")); - assert_eq!(m.training.as_deref(), Some("2025-01-01")); - assert_eq!(m.knowledge_cutoff.as_deref(), Some("2025-01-01")); - assert_eq!(m.default, Some(true)); - assert_eq!(m.enabled, Some(true)); - assert_eq!(m.aliases.as_deref(), Some(&["kimi".to_string()][..])); - assert_eq!(m.estimated_output_tps, Some(50.0)); - - let limits = m.limits.as_ref().unwrap(); - assert_eq!(limits.context_window, Some(262_144)); - assert_eq!(limits.max_output, Some(32_768)); - - let features = m.features.as_ref().unwrap(); - assert_eq!(features.tools, Some(true)); - assert_eq!(features.vision, Some(false)); - assert_eq!(features.reasoning, Some(true)); - - let costs = m.costs.as_ref().unwrap(); - assert_eq!(costs.base.input_cost_per_mtok, Some(0.60)); - assert_eq!(costs.base.output_cost_per_mtok, Some(2.50)); - assert_eq!(costs.base.cache_input_cost_per_mtok, Some(0.15)); - assert!(costs.speed.is_none()); - } - - #[test] - fn parses_model_reasoning_effort_and_prompt_cache_features() { - let toml = r#" -[models."claude-bedrock"] -provider = "bedrock" - -[models."claude-bedrock".features] -tools = true -vision = true -reasoning = true -reasoning_by_default = false -reasoning_effort = "levels" -prompt_cache = false -"#; - let layer: LlmLayer = toml::from_str(toml).unwrap(); - let features = layer - .models - .get("claude-bedrock") - .unwrap() - .features - .as_ref() - .unwrap(); - - assert_eq!( - features.reasoning_effort, - Some(fabro_model::ReasoningEffortFeature::Levels) - ); - assert_eq!(features.reasoning_by_default, Some(false)); - assert_eq!(features.prompt_cache, Some(false)); - } - - #[test] - fn parses_knowledge_cutoff_display_label() { - let toml = r#" -[models."claude-opus-4-7"] -provider = "anthropic" -knowledge_cutoff = "May 2025" -"#; - let layer: LlmLayer = toml::from_str(toml).unwrap(); - let m = layer.models.get("claude-opus-4-7").unwrap(); - - assert_eq!(m.knowledge_cutoff.as_deref(), Some("May 2025")); - } - - #[test] - fn parses_controls_and_per_speed_costs() { - let toml = r#" -[models."claude-opus-4-6".controls] -reasoning_effort = ["low", "medium", "high"] -speed = ["fast"] - -[models."claude-opus-4-6".costs.speed.fast] -input_cost_per_mtok = 90.0 -output_cost_per_mtok = 450.0 -cache_input_cost_per_mtok = 9.0 -"#; - let layer: LlmLayer = toml::from_str(toml).unwrap(); - let m = layer.models.get("claude-opus-4-6").unwrap(); - - let controls = m.controls.as_ref().unwrap(); - assert_eq!( - controls.reasoning_effort.as_deref(), - Some(&["low".to_string(), "medium".to_string(), "high".to_string()][..]) - ); - assert_eq!(controls.speed.as_deref(), Some(&["fast".to_string()][..])); - - let costs = m.costs.as_ref().unwrap(); - let fast = costs.speed.as_ref().unwrap().get("fast").unwrap(); - assert_eq!(fast.input_cost_per_mtok, Some(90.0)); - assert_eq!(fast.output_cost_per_mtok, Some(450.0)); - assert_eq!(fast.cache_input_cost_per_mtok, Some(9.0)); - } - - #[test] - fn rejects_unknown_provider_field() { - let toml = r#" -[providers.moonshot] -adapter = "openai_compatible" -unknown_field = true -"#; - let err = toml::from_str::(toml).unwrap_err(); - assert!(err.to_string().contains("unknown_field")); - } - - #[test] - fn rejects_removed_provider_base_url_env_field() { - let toml = r#" -[providers.moonshot] -adapter = "openai_compatible" -base_url_env = "KIMI_BASE_URL" -"#; - let err = toml::from_str::(toml).unwrap_err(); - assert!(err.to_string().contains("base_url_env")); - } - - #[test] - fn rejects_unknown_model_field() { - let toml = r#" -[models.foo] -provider = "x" -mystery = 1 -"#; - let err = toml::from_str::(toml).unwrap_err(); - assert!(err.to_string().contains("mystery")); - } - - // ---- Combine / merge -------------------------------------------------- - - #[test] - fn provider_field_merge_keeps_self_values_and_fills_holes() { - let high = ProviderSettings { - adapter: Some("openai_compatible".to_string()), - base_url: Some("https://override.example".to_string()), - agent_profile: Some(fabro_model::AgentProfileKind::Anthropic), - ..ProviderSettings::default() - }; - let low = ProviderSettings { - adapter: Some("anthropic".to_string()), - base_url: Some("https://defaults.example".to_string()), - display_name: Some("Default".to_string()), - priority: Some(10), - agent_profile: Some(fabro_model::AgentProfileKind::OpenAi), - ..ProviderSettings::default() - }; - let merged = high.combine(low); - assert_eq!(merged.adapter.as_deref(), Some("openai_compatible")); - assert_eq!(merged.base_url.as_deref(), Some("https://override.example")); - assert_eq!(merged.display_name.as_deref(), Some("Default")); - assert_eq!(merged.priority, Some(10)); - assert_eq!( - merged.agent_profile, - Some(fabro_model::AgentProfileKind::Anthropic) - ); - } - - #[test] - fn provider_auth_replaces_wholesale() { - // Higher layer redeclares auth, so the low layer's auth table is - // dropped entirely (whole-value replacement). - let high = ProviderSettings { - auth: Some(ProviderAuthConfig { - credentials: vec![CredentialRef::Env("FOO".to_string())], - header: ApiKeyHeaderPolicy::Bearer, - }), - ..ProviderSettings::default() - }; - let low = ProviderSettings { - auth: Some(ProviderAuthConfig { - credentials: vec![ - CredentialRef::Vault("bar".to_string()), - CredentialRef::Env("BAZ".to_string()), - ], - header: ApiKeyHeaderPolicy::Custom { - name: "x-api-key".to_string(), - }, - }), - ..ProviderSettings::default() - }; - let merged = high.combine(low); - assert_eq!( - merged.auth, - Some(ProviderAuthConfig { - credentials: vec![CredentialRef::Env("FOO".to_string())], - header: ApiKeyHeaderPolicy::Bearer, - }) - ); - } - - #[test] - fn provider_auth_inherits_when_unset_in_higher_layer() { - let high = ProviderSettings::default(); - let low = ProviderSettings { - auth: Some(ProviderAuthConfig { - credentials: vec![CredentialRef::Env("FOO".to_string())], - header: ApiKeyHeaderPolicy::Bearer, - }), - ..ProviderSettings::default() - }; - let merged = high.combine(low); - assert_eq!( - merged.auth, - Some(ProviderAuthConfig { - credentials: vec![CredentialRef::Env("FOO".to_string())], - header: ApiKeyHeaderPolicy::Bearer, - }) - ); - } - - #[test] - fn provider_extra_headers_map_replaces_wholesale() { - let high = ProviderSettings { - extra_headers: Some(HashMap::from([( - "x-portkey-provider".to_string(), - InterpString::from("@bedrock-prod"), - )])), - ..ProviderSettings::default() - }; - let low = ProviderSettings { - extra_headers: Some(HashMap::from([ - ( - "x-portkey-api-key".to_string(), - InterpString::from("{{ env.PORTKEY_API_KEY }}"), - ), - ( - "x-portkey-provider".to_string(), - InterpString::from("@bedrock-default"), - ), - ])), - ..ProviderSettings::default() - }; - - let merged = high.combine(low); - - let headers = merged.extra_headers.unwrap(); - assert_eq!(headers.len(), 1); - assert_eq!( - headers.get("x-portkey-provider"), - Some(&InterpString::from("@bedrock-prod")), - ); - assert!(!headers.contains_key("x-portkey-api-key")); - } - - #[test] - fn provider_extra_headers_inherit_when_unset() { - let high = ProviderSettings::default(); - let low = ProviderSettings { - extra_headers: Some(HashMap::from([( - "x-portkey-api-key".to_string(), - InterpString::from("{{ env.PORTKEY_API_KEY }}"), - )])), - ..ProviderSettings::default() - }; - - let merged = high.combine(low); - - assert_eq!( - merged.extra_headers.unwrap().get("x-portkey-api-key"), - Some(&InterpString::from("{{ env.PORTKEY_API_KEY }}")), - ); - } - - #[test] - fn provider_extra_headers_empty_map_clears_lower_layer() { - let high = ProviderSettings { - extra_headers: Some(HashMap::new()), - ..ProviderSettings::default() - }; - let low = ProviderSettings { - extra_headers: Some(HashMap::from([( - "x-portkey-api-key".to_string(), - InterpString::from("{{ env.PORTKEY_API_KEY }}"), - )])), - ..ProviderSettings::default() - }; - - let merged = high.combine(low); - - assert!(merged.extra_headers.unwrap().is_empty()); - } - - #[test] - fn merge_map_field_merges_per_provider_id() { - let mut high_map: std::collections::HashMap = - std::collections::HashMap::new(); - high_map.insert("moonshot".to_string(), ProviderSettings { - base_url: Some("https://override".to_string()), - ..ProviderSettings::default() - }); - let high: MergeMap = MergeMap::from(high_map); - - let mut low_map: std::collections::HashMap = - std::collections::HashMap::new(); - low_map.insert("moonshot".to_string(), ProviderSettings { - adapter: Some("openai_compatible".to_string()), - base_url: Some("https://defaults".to_string()), - ..ProviderSettings::default() - }); - let low: MergeMap = MergeMap::from(low_map); - - let merged = high.combine(low); - let moonshot = merged.get("moonshot").unwrap(); - assert_eq!(moonshot.adapter.as_deref(), Some("openai_compatible")); - assert_eq!(moonshot.base_url.as_deref(), Some("https://override")); - } - - #[test] - fn model_controls_replace_wholesale() { - // Whole-array replacement: high layer's `reasoning_effort` shadows - // the low layer's list completely. - let high = ModelControls { - reasoning_effort: Some(vec!["high".to_string()]), - ..ModelControls::default() - }; - let low = ModelControls { - reasoning_effort: Some(vec!["low".to_string(), "high".to_string()]), - speed: Some(vec!["fast".to_string()]), - }; - let merged = high.combine(low); - assert_eq!( - merged.reasoning_effort.as_deref(), - Some(&["high".to_string()][..]) - ); - assert_eq!(merged.speed.as_deref(), Some(&["fast".to_string()][..])); - } - - #[test] - fn model_agent_profile_merges_as_scalar() { - let high = ModelSettings { - agent_profile: Some(fabro_model::AgentProfileKind::Gemini), - ..ModelSettings::default() - }; - let low = ModelSettings { - agent_profile: Some(fabro_model::AgentProfileKind::Anthropic), - ..ModelSettings::default() - }; - - assert_eq!( - high.combine(low).agent_profile, - Some(fabro_model::AgentProfileKind::Gemini) - ); + fn overlay_toml_round_trips() { + let source = layer("[providers.acme]\npriority = 3\n"); + let rendered = source.to_overlay_toml(); + assert_eq!(layer(&rendered), source); } } diff --git a/lib/foundation/fabro-config/src/layers/mod.rs b/lib/foundation/fabro-config/src/layers/mod.rs index c3fa1632c..f8b625c1d 100644 --- a/lib/foundation/fabro-config/src/layers/mod.rs +++ b/lib/foundation/fabro-config/src/layers/mod.rs @@ -20,11 +20,7 @@ pub use environment::{ EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer, RunEnvironmentLayer, }; -pub use llm::{ - CostRates, CredentialRef, CredentialRefParseError, LlmLayer, ModelControls, ModelCostTable, - ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits, ModelSettings, - ProviderSettings, ReasoningEffortFeature, -}; +pub use llm::LlmLayer; pub use log_filter::LogFilter; pub use maps::{MergeMap, ReplaceMap, StickyMap}; pub use project::ProjectLayer; diff --git a/lib/foundation/fabro-config/src/lib.rs b/lib/foundation/fabro-config/src/lib.rs index f6097b9be..33eeed889 100644 --- a/lib/foundation/fabro-config/src/lib.rs +++ b/lib/foundation/fabro-config/src/lib.rs @@ -32,8 +32,7 @@ use std::path::Path; pub use builders::{ ResolveErrors, RunSettingsBuilder, ServerRuntimeSettings, ServerSettingsBuilder, - UserSettingsBuilder, WorkflowSettingsBuilder, load_llm_catalog_settings, - load_server_runtime_settings, + UserSettingsBuilder, WorkflowSettingsBuilder, load_llm_overlay, load_server_runtime_settings, }; pub use error::{Error, Result}; pub use fabro_util::path::expand_tilde; @@ -42,22 +41,21 @@ pub use input_overrides::{InputOverrideParseError, parse_input_overrides, parse_ pub(crate) use layers::Combine; pub use layers::{ CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer, - CliOutputLayer, CliTargetLayer, CliUpdatesLayer, CostRates, CredentialRef, - CredentialRefParseError, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, - EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer, GitAuthorLayer, - GithubIntegrationLayer, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer, - InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter, - McpEntryLayer, MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings, - NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, - PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer, - RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer, - RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, - RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, - RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, - ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, - ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer, - ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, - ServerWebLayer, SettingsLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer, + CliOutputLayer, CliTargetLayer, CliUpdatesLayer, EnvironmentDockerfileLayer, + EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer, EnvironmentNetworkLayer, + EnvironmentResourcesLayer, GitAuthorLayer, GithubIntegrationLayer, HookAgentMarker, HookEntry, + HookTlsMode, IntegrationWebhooksLayer, InterviewProviderLayer, InterviewsLayer, LlmLayer, + LogFilter, McpEntryLayer, MergeMap, ModelRefOrSplice, NotificationProviderLayer, + NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, PrepareStep, ProjectLayer, + ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, + RunEnvironmentLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, + RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, + RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, + ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer, + ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer, + ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer, + ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer, + SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer, }; pub use logging::{resolve_log_destination, resolve_log_destination_with_env}; pub use parse::ParseError; diff --git a/lib/foundation/fabro-config/src/parse.rs b/lib/foundation/fabro-config/src/parse.rs index 8ed2273bd..e8fd37082 100644 --- a/lib/foundation/fabro-config/src/parse.rs +++ b/lib/foundation/fabro-config/src/parse.rs @@ -1,7 +1,5 @@ use std::fmt; -use fabro_model::catalog::LegacyModelError; - use crate::SettingsLayer; const CURRENT_VERSION: u32 = 1; @@ -32,7 +30,6 @@ const LEGACY_LLM_KEYS: &[&str] = &[ #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParseError { Toml(String), - LlmCatalog(LegacyModelError), Version(VersionError), UnknownTopLevelKey { key: String, @@ -48,7 +45,6 @@ impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Toml(msg) => write!(f, "settings file is not valid TOML: {msg}"), - Self::LlmCatalog(err) => fmt::Display::fmt(err, f), Self::Version(err) => fmt::Display::fmt(err, f), Self::UnknownTopLevelKey { key, hint } => { if let Some(hint) = hint { @@ -122,14 +118,8 @@ pub(crate) fn parse_settings(input: &str) -> Result { } } - let mut layer = raw - .try_into::() - .map_err(|e| ParseError::Toml(e.to_string()))?; - if let Some(llm) = layer.llm.as_mut() { - llm.normalize_legacy_models() - .map_err(ParseError::LlmCatalog)?; - } - Ok(layer) + raw.try_into::() + .map_err(|e| ParseError::Toml(e.to_string())) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -291,227 +281,35 @@ mod tests { } #[test] - fn accepts_new_llm_providers_subtree() { - let parsed = "[llm.providers.moonshot]\nadapter = \"openai_compatible\"\n" + fn accepts_llm_overlay_subtree() { + let parsed = "[llm.providers.moonshot]\npriority = 60\n" .parse::() .unwrap(); - assert!(parsed.llm.unwrap().providers.contains_key("moonshot")); - } - - #[test] - fn accepts_new_llm_models_subtree() { - let parsed = "[llm.providers.moonshot.models.\"foo\"]\n" - .parse::() - .unwrap(); - assert!( - parsed - .llm - .unwrap() - .providers - .get("moonshot") - .unwrap() - .models - .contains_key("foo") + let llm = parsed.llm.unwrap(); + assert_eq!( + llm.0["providers"]["moonshot"]["priority"].as_integer(), + Some(60) ); } #[test] - fn provider_scoped_model_rejects_redundant_provider_field() { - let error = r#" -[llm.providers.openai.models."gpt-5.4"] -provider = "openai" -"# - .parse::() - .unwrap_err(); - - assert!(matches!( - error, - ParseError::LlmCatalog(LegacyModelError::ScopedModelDeclaresProvider { - provider, - model, - }) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4" - )); - } - - #[test] - fn legacy_model_row_with_provider_normalizes_before_merge() { - use crate::layers::Combine as _; - + fn llm_overlay_merges_across_layers() { let higher = r#" -[llm.models."gpt-5.4"] -provider = "openai" -display_name = "Configured display name" -"# - .parse::() - .unwrap(); - let fallback = r#" [llm.providers.openai.models."gpt-5.4"] -family = "gpt-5" +small_default = true "# .parse::() .unwrap(); - - let merged = higher.combine(fallback); - let llm = merged.llm.unwrap(); - assert!(llm.models.is_empty()); - let model = llm - .providers - .get("openai") - .unwrap() - .models - .get("gpt-5.4") - .unwrap(); - assert_eq!( - model.display_name.as_deref(), - Some("Configured display name") - ); - assert_eq!(model.family.as_deref(), Some("gpt-5")); - } - - #[test] - fn provider_less_legacy_row_adopts_unique_builtin_offering() { - let parsed = r#" -[llm.models.mercury] -display_name = "Configured Mercury" -"# - .parse::() - .unwrap(); - let llm = parsed.llm.unwrap(); - - assert!(llm.models.is_empty()); - assert!( - llm.providers - .get("inception") - .unwrap() - .models - .contains_key("mercury-2") - ); - } - - #[test] - fn provider_less_legacy_row_rejects_ambiguous_builtin_offering() { - let error = r#" -[llm.models."gpt-5.6-sol"] -display_name = "Ambiguous" -"# - .parse::() - .unwrap_err(); - - assert!(matches!( - error, - ParseError::LlmCatalog(LegacyModelError::AmbiguousModel { - model, - candidates, - }) if model == "gpt-5.6-sol" && candidates.len() >= 2 - )); - } - - #[test] - fn same_source_legacy_and_provider_scoped_rows_conflict() { - let error = r#" + let lower = r#" [llm.providers.openai.models."gpt-5.4"] -display_name = "Canonical" - -[llm.models."gpt-5.4"] -provider = "openai" -display_name = "Legacy" -"# - .parse::() - .unwrap_err(); - - assert!(matches!( - error, - ParseError::LlmCatalog(LegacyModelError::DuplicateModel { - provider, - model, - }) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4" - )); - } - - #[test] - fn legacy_builtin_model_id_normalizes_with_explicit_provider() { - let parsed = r#" -[llm.models."openai/gpt-5.6-sol"] -provider = "openrouter" -display_name = "Configured Sol" +probe = true "# .parse::() .unwrap(); - let llm = parsed.llm.unwrap(); - - assert!(llm.models.is_empty()); - let model = llm - .providers - .get("openrouter") - .unwrap() - .models - .get("gpt-5.6-sol") - .unwrap(); - assert_eq!(model.display_name.as_deref(), Some("Configured Sol")); - assert!(model.provider.is_none()); - } - - #[test] - fn legacy_builtin_model_id_without_provider_uses_historical_catalog_provider() { - let parsed = r#" -[llm.models."anthropic/claude-fable-5"] -display_name = "Configured Fable" -"# - .parse::() - .unwrap(); - let llm = parsed.llm.unwrap(); - - assert!(llm.models.is_empty()); - assert!( - llm.providers - .get("openrouter") - .unwrap() - .models - .contains_key("claude-fable-5") - ); - } - - #[test] - fn same_model_slug_on_different_providers_merges_independently() { - use crate::layers::Combine as _; - - let direct = r#" -[llm.providers.openai.models.shared] -display_name = "Direct" -"# - .parse::() - .unwrap(); - let aggregator = r#" -[llm.providers.openrouter.models.shared] -display_name = "Aggregator" -"# - .parse::() - .unwrap(); - - let merged = direct.combine(aggregator); - let providers = merged.llm.unwrap().providers; - assert_eq!( - providers - .get("openai") - .unwrap() - .models - .get("shared") - .unwrap() - .display_name - .as_deref(), - Some("Direct") - ); - assert_eq!( - providers - .get("openrouter") - .unwrap() - .models - .get("shared") - .unwrap() - .display_name - .as_deref(), - Some("Aggregator") - ); + let merged = crate::Combine::combine(higher, lower); + let model = &merged.llm.unwrap().0["providers"]["openai"]["models"]["gpt-5.4"]; + assert_eq!(model["small_default"].as_bool(), Some(true)); + assert_eq!(model["probe"].as_bool(), Some(true)); } #[test] diff --git a/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs b/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs index 406ec0b49..b4909121d 100644 --- a/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs +++ b/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs @@ -214,146 +214,113 @@ url = "https://fabro.example.com/api/v1" fn render_manual_llm_catalog(output: &mut String) { output.push_str( - r#"## `[llm.providers.]` + r#"## `[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 +`_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.]` + +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 | required when `auth` present | Ordered credential refs. Accepted forms are `vault:`, `env:`, 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 | `[]` | 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..models.]` +## `[llm.providers..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..models.]` 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 | `[]` | 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 | `[]` | 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..models..limits]` +## `[llm.providers..models..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..models..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..models..controls]` - -| Key | Type / values | Default | Description | -|---|---|---|---| -| `reasoning_effort` | array | 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 | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. | - -## `[llm.providers..models..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..models..costs.speed.]` - -Per-speed cost overrides use the same keys as -`[llm.providers..models..costs]`. Each `` key -must be declared in -`[llm.providers..models..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. "#, ); diff --git a/lib/foundation/fabro-dev/tests/it/policy.rs b/lib/foundation/fabro-dev/tests/it/policy.rs index 2f2da24b6..6f1693b86 100644 --- a/lib/foundation/fabro-dev/tests/it/policy.rs +++ b/lib/foundation/fabro-dev/tests/it/policy.rs @@ -8,48 +8,6 @@ use walkdir::WalkDir; use crate::workspace_root; -/// `fabro_model::bootstrap_catalog` (and its module) is the install/API-key -/// validation hatch from the settings-driven LLM catalog plan. It must -/// **not** appear in request-serving paths — server handlers, workflow -/// operations, agent runtime, hooks, or completion handlers — because those -/// must use the resolved `Arc` threaded through their state. -/// -/// The allowed-callers list below is the policy boundary. Adding a new -/// caller is intentional and requires updating this list. -/// -/// The walker only descends into `lib/`, so non-`lib/` paths (docs, top-level -/// markdown) are not part of the allowlist. -const BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS: &[&str] = &[ - // The bootstrap module itself. - "lib/foundation/fabro-model/src/bootstrap_catalog", - // Public module declaration for the bootstrap hatch. - "lib/foundation/fabro-model/src/lib.rs", - // Install / first-run / API-key validation flows that legitimately need - // a built-in catalog before any project settings have been loaded. - "lib/components/fabro-install/", - "lib/apps/fabro-cli/src/commands/install/", - "lib/apps/fabro-cli/src/shared/install_", - "lib/apps/fabro-cli/src/shared/api_key_validation", - // Test support modules. - "tests/", - "test_support", - "/tests/it/", - "/tests/policy.rs", -]; - -/// Production runtime code should build catalogs from resolved settings and -/// thread the resulting `Arc` through state. Direct use of -/// `Catalog::builtin()` is reserved for `fabro-model` internals and tests. -const CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS: &[&str] = &[ - // The catalog owner may define and test the built-in/default catalog. - "lib/foundation/fabro-model/", - // Tests and test support may use built-ins as fixtures. - "/tests/", - "/tests/it/", - "test_support", - "/tests/policy.rs", -]; - const TEMPLATE_RENDER_ALLOWED_PATH_FRAGMENTS: &[&str] = &[ // The template crate owns the rendering API and its tests. "lib/foundation/fabro-template/src/lib.rs", @@ -70,32 +28,6 @@ const TEMPLATE_RENDER_FORBIDDEN_PATTERNS: &[&str] = &[ "fabro_template::{", ]; -#[test] -fn bootstrap_catalog_references_stay_in_allowlist() { - let violations = source_symbol_violations( - "bootstrap_catalog", - BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS, - ); - - assert!( - violations.is_empty(), - "bootstrap_catalog (install-only) referenced from non-allowlisted source files:\n{}\n\nIf this is intentional, add the path fragment to BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS in lib/foundation/fabro-dev/tests/it/policy.rs.", - format_violations(violations), - ); -} - -#[test] -fn catalog_builtin_references_stay_in_allowlist() { - let violations = - source_symbol_violations("Catalog::builtin()", CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS); - - assert!( - violations.is_empty(), - "Catalog::builtin() referenced from non-allowlisted production source files:\n{}\n\nRuntime code should use a resolved settings catalog via `Catalog::from_builtin_with_overrides(...)` or an injected `Arc`. If this is intentional test/bootstrap code, add the path fragment to CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS in lib/foundation/fabro-dev/tests/it/policy.rs.", - format_violations(violations), - ); -} - #[test] fn workflow_template_rendering_call_sites_stay_in_allowlist() { let mut violations = Vec::new(); diff --git a/lib/foundation/fabro-http/src/lib.rs b/lib/foundation/fabro-http/src/lib.rs index 27ef1414d..6d89de497 100644 --- a/lib/foundation/fabro-http/src/lib.rs +++ b/lib/foundation/fabro-http/src/lib.rs @@ -15,6 +15,7 @@ pub use reqwest::{ }; pub type HttpClient = reqwest::Client; +pub type HttpError = reqwest::Error; pub type BlockingHttpClient = reqwest::blocking::Client; pub type BlockingRequestBuilder = reqwest::blocking::RequestBuilder; pub type BlockingResponse = reqwest::blocking::Response; diff --git a/lib/foundation/fabro-model/Cargo.toml b/lib/foundation/fabro-model/Cargo.toml deleted file mode 100644 index 9c586d27e..000000000 --- a/lib/foundation/fabro-model/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "fabro-model" -edition.workspace = true -version.workspace = true -publish = false -license.workspace = true -description = "LLM model catalog: provider identity, model metadata, and resolution" - -[lib] -doctest = false - -[lints] -workspace = true - -[features] -clap = ["dep:clap"] - -[dependencies] -clap = { workspace = true, optional = true } -fabro-static.workspace = true -http = "1" -rust-embed.workspace = true -serde.workspace = true -serde_json.workspace = true -strum.workspace = true -thiserror.workspace = true -toml.workspace = true -tracing.workspace = true - -[dev-dependencies] -insta.workspace = true diff --git a/lib/foundation/fabro-model/src/billing.rs b/lib/foundation/fabro-model/src/billing.rs deleted file mode 100644 index f1c5867fa..000000000 --- a/lib/foundation/fabro-model/src/billing.rs +++ /dev/null @@ -1,1446 +0,0 @@ -use serde::{Deserialize, Serialize}; -use strum::{Display, EnumString, IntoStaticStr}; - -use crate::catalog::{BillingPolicy, Catalog, CatalogModelSettings}; -use crate::{Model, ModelCosts, ModelId, ProviderId}; - -const TOKENS_PER_MTOK: i128 = 1_000_000; -const ANTHROPIC_CACHE_WRITE_5M_NUMERATOR: i64 = 5; -const ANTHROPIC_CACHE_WRITE_5M_DENOMINATOR: i64 = 4; -const ANTHROPIC_CACHE_WRITE_1H_NUMERATOR: i64 = 2; -const ANTHROPIC_CACHE_WRITE_1H_DENOMINATOR: i64 = 1; -const USD_MICROS_PER_USD_F64: f64 = 1_000_000.0; - -fn saturating_i128_to_i64(value: i128) -> i64 { - i64::try_from(value).unwrap_or_else(|_| { - if value.is_negative() { - i64::MIN - } else { - i64::MAX - } - }) -} - -#[allow( - clippy::cast_possible_truncation, - clippy::cast_precision_loss, - reason = "Billing rounds bounded finite floats into i64 counters by design." -)] -fn saturating_rounded_f64_to_i64(value: f64) -> i64 { - if !value.is_finite() { - return if value.is_sign_negative() { - i64::MIN - } else { - i64::MAX - }; - } - - if value <= i64::MIN as f64 { - i64::MIN - } else if value >= i64::MAX as f64 { - i64::MAX - } else { - value as i64 - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] -pub struct UsdMicros(pub i64); - -impl UsdMicros { - #[must_use] - pub fn from_usd(usd: f64) -> Self { - Self(saturating_rounded_f64_to_i64( - (usd * USD_MICROS_PER_USD_F64).round(), - )) - } - - /// Folds a cost into a running total that stays `None` until a cost is - /// observed (`None` means "no provider data", not $0). - pub fn accumulate(total: &mut Option, cost: Option) { - if let Some(cost) = cost { - *total.get_or_insert_default() += cost; - } - } -} - -impl std::ops::Add for UsdMicros { - type Output = Self; - - fn add(self, rhs: Self) -> Self::Output { - Self(self.0.saturating_add(rhs.0)) - } -} - -impl std::ops::AddAssign for UsdMicros { - fn add_assign(&mut self, rhs: Self) { - *self = *self + rhs; - } -} - -impl std::iter::Sum for UsdMicros { - fn sum>(iter: I) -> Self { - iter.fold(Self::default(), |acc, value| acc + value) - } -} - -fn accumulate_optional_usd_micros(total: &mut Option, cost: Option) { - let mut typed_total = (*total).map(UsdMicros); - UsdMicros::accumulate(&mut typed_total, cost.map(UsdMicros)); - *total = typed_total.map(|value| value.0); -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct PricePerMTok { - pub usd_micros: i64, -} - -impl PricePerMTok { - #[must_use] - pub fn from_usd(usd: f64) -> Self { - Self { - usd_micros: UsdMicros::from_usd(usd).0, - } - } - - #[must_use] - pub fn multiply_ratio(self, numerator: i64, denominator: i64) -> Self { - Self { - usd_micros: self.usd_micros.saturating_mul(numerator) / denominator, - } - } - - #[must_use] - pub fn bill(self, tokens: i64) -> UsdMicros { - let total = i128::from(tokens) * i128::from(self.usd_micros); - UsdMicros(saturating_i128_to_i64(total / TOKENS_PER_MTOK)) - } -} - -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - Display, - EnumString, - IntoStaticStr, - strum::VariantArray, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum Speed { - Standard, - Fast, -} - -impl Speed { - #[must_use] - pub fn variants() -> &'static [Self] { - ::VARIANTS - } -} - -/// Source of a USD cost value attached to a completion response. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString, IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum CostSource { - /// The provider returned billing data in-band with the response. - Authoritative, - /// Computed from catalog prices and token usage. - Estimated, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ModelRef { - pub provider: ProviderId, - pub model_id: ModelId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub speed: Option, -} - -/// Token counts for one LLM call. -/// -/// All five fields are disjoint: each token is counted in exactly one bucket, -/// and `total_tokens()` is their sum. Provider mappings normalize their wire -/// formats into this shape. For example, OpenAI's nested cached tokens are -/// subtracted out of `input_tokens`, while Anthropic thinking tokens remain in -/// `output_tokens` because Anthropic does not expose a separate billed count. -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct TokenCounts { - pub input_tokens: i64, - pub output_tokens: i64, - #[serde(default)] - pub reasoning_tokens: i64, - #[serde(default)] - pub cache_read_tokens: i64, - #[serde(default)] - pub cache_write_tokens: i64, -} - -impl TokenCounts { - #[must_use] - pub fn billable_output_tokens(&self) -> i64 { - self.output_tokens + self.reasoning_tokens - } - - #[must_use] - pub fn total_tokens(&self) -> i64 { - self.input_tokens - + self.billable_output_tokens() - + self.cache_read_tokens - + self.cache_write_tokens - } -} - -impl std::ops::Add for TokenCounts { - type Output = Self; - - fn add(self, rhs: Self) -> Self::Output { - Self { - input_tokens: self.input_tokens + rhs.input_tokens, - output_tokens: self.output_tokens + rhs.output_tokens, - reasoning_tokens: self.reasoning_tokens + rhs.reasoning_tokens, - cache_read_tokens: self.cache_read_tokens + rhs.cache_read_tokens, - cache_write_tokens: self.cache_write_tokens + rhs.cache_write_tokens, - } - } -} - -impl std::ops::AddAssign for TokenCounts { - fn add_assign(&mut self, rhs: Self) { - self.input_tokens += rhs.input_tokens; - self.output_tokens += rhs.output_tokens; - self.reasoning_tokens += rhs.reasoning_tokens; - self.cache_read_tokens += rhs.cache_read_tokens; - self.cache_write_tokens += rhs.cache_write_tokens; - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ModelUsage { - pub model: ModelRef, - pub tokens: TokenCounts, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct OpenAiModelPricing { - pub input: PricePerMTok, - pub cached_input: Option, - pub output: PricePerMTok, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AnthropicModelPricing { - pub input: PricePerMTok, - pub cache_read: Option, - pub cache_write_5m: Option, - pub cache_write_1h: Option, - pub output: PricePerMTok, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GeminiStorageSegment { - pub cached_tokens: i64, - pub ttl_seconds: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GeminiStoragePricing { - pub usd_micros_per_mtok_second: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GeminiModelPricing { - pub input: PricePerMTok, - pub output: PricePerMTok, - pub cached_input: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub storage: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "algorithm", rename_all = "snake_case")] -pub enum ModelPricingPolicy { - #[serde(rename = "openai")] - OpenAi(OpenAiModelPricing), - Anthropic(AnthropicModelPricing), - Gemini(GeminiModelPricing), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ModelPricing { - pub model: ModelRef, - pub policy: ModelPricingPolicy, -} - -#[allow( - clippy::empty_structs_with_brackets, - reason = "This type must serialize as {} rather than null." -)] -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct OpenAiBillingFacts {} - -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct AnthropicBillingFacts { - #[serde(default)] - pub cache_write_5m_tokens: i64, - #[serde(default)] - pub cache_write_1h_tokens: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct GeminiBillingFacts { - #[serde(default)] - pub storage_segments: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "algorithm", rename_all = "snake_case")] -pub enum ModelBillingFacts { - #[serde(rename = "openai")] - OpenAi(OpenAiBillingFacts), - Anthropic(AnthropicBillingFacts), - Gemini(GeminiBillingFacts), -} - -impl ModelBillingFacts { - #[must_use] - pub fn for_policy(policy: BillingPolicy, tokens: &TokenCounts) -> Option { - match policy { - BillingPolicy::OpenAi => Some(Self::OpenAi(OpenAiBillingFacts::default())), - BillingPolicy::Anthropic => Some(Self::Anthropic(anthropic_billing_facts(tokens))), - BillingPolicy::Gemini => Some(Self::Gemini(GeminiBillingFacts::default())), - BillingPolicy::None => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ModelBillingInput { - pub usage: ModelUsage, - pub facts: ModelBillingFacts, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct BilledModelUsage { - pub input: ModelBillingInput, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub total_usd_micros: Option, -} - -impl BilledModelUsage { - #[must_use] - pub fn model(&self) -> &ModelRef { - &self.input.usage.model - } - - #[must_use] - pub fn model_id(&self) -> &str { - self.input.usage.model.model_id.as_str() - } - - #[must_use] - pub fn tokens(&self) -> &TokenCounts { - &self.input.usage.tokens - } - - /// Overrides the billed total with a provider-reported cost; `None` leaves - /// the catalog estimate in place. - #[must_use] - pub fn with_reported_cost(mut self, cost: Option) -> Self { - if let Some(cost) = cost { - self.total_usd_micros = Some(cost.0); - } - self - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct BilledTokenCounts { - pub input_tokens: i64, - pub output_tokens: i64, - pub total_tokens: i64, - #[serde(default)] - pub reasoning_tokens: i64, - #[serde(default)] - pub cache_read_tokens: i64, - #[serde(default)] - pub cache_write_tokens: i64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub total_usd_micros: Option, -} - -impl BilledTokenCounts { - #[must_use] - pub fn from_billed_usage(billed: &[BilledModelUsage]) -> Self { - let mut tokens = TokenCounts::default(); - let mut total_usd_micros = None; - - for entry in billed { - tokens += entry.input.usage.tokens.clone(); - accumulate_optional_usd_micros(&mut total_usd_micros, entry.total_usd_micros); - } - - Self { - input_tokens: tokens.input_tokens, - output_tokens: tokens.output_tokens, - total_tokens: tokens.total_tokens(), - reasoning_tokens: tokens.reasoning_tokens, - cache_read_tokens: tokens.cache_read_tokens, - cache_write_tokens: tokens.cache_write_tokens, - total_usd_micros, - } - } - - /// Returns the five disjoint per-call token buckets, dropping the derived - /// `total_tokens` sum and the optional `total_usd_micros` cost. - #[must_use] - pub fn token_counts(&self) -> TokenCounts { - TokenCounts { - input_tokens: self.input_tokens, - output_tokens: self.output_tokens, - reasoning_tokens: self.reasoning_tokens, - cache_read_tokens: self.cache_read_tokens, - cache_write_tokens: self.cache_write_tokens, - } - } - - pub fn add_counts(&mut self, source: &Self) { - self.input_tokens += source.input_tokens; - self.output_tokens += source.output_tokens; - self.total_tokens += source.total_tokens; - self.reasoning_tokens += source.reasoning_tokens; - self.cache_read_tokens += source.cache_read_tokens; - self.cache_write_tokens += source.cache_write_tokens; - accumulate_optional_usd_micros(&mut self.total_usd_micros, source.total_usd_micros); - } - - pub fn add_billed_usage(&mut self, usage: &BilledModelUsage) { - let tokens = usage.tokens(); - self.input_tokens += tokens.input_tokens; - self.output_tokens += tokens.output_tokens; - self.reasoning_tokens += tokens.reasoning_tokens; - self.cache_read_tokens += tokens.cache_read_tokens; - self.cache_write_tokens += tokens.cache_write_tokens; - self.total_tokens += tokens.total_tokens(); - accumulate_optional_usd_micros(&mut self.total_usd_micros, usage.total_usd_micros); - } - - pub fn replace_with_billed_usage(&mut self, usage: &BilledModelUsage) { - *self = Self::from_billed_usage(std::slice::from_ref(usage)); - } - - /// Overrides the billed total with a provider-reported cost; `None` leaves - /// any existing estimate in place. - #[must_use] - pub fn with_reported_cost(mut self, cost: Option) -> Self { - if let Some(cost) = cost { - self.total_usd_micros = Some(cost.0); - } - self - } - - #[must_use] - pub fn is_zero(&self) -> bool { - self.input_tokens == 0 - && self.output_tokens == 0 - && self.total_tokens == 0 - && self.reasoning_tokens == 0 - && self.cache_read_tokens == 0 - && self.cache_write_tokens == 0 - && self.total_usd_micros.unwrap_or(0) == 0 - } -} - -fn anthropic_billing_facts(tokens: &TokenCounts) -> AnthropicBillingFacts { - AnthropicBillingFacts { - cache_write_5m_tokens: tokens.cache_write_tokens, - cache_write_1h_tokens: 0, - } -} - -impl Catalog { - #[must_use] - pub fn pricing_for(&self, model_ref: &ModelRef) -> Option { - let model = self.offering(&model_ref.provider, &model_ref.model_id)?; - let provider = self.provider(&model_ref.provider)?; - let settings = self.settings_for(model)?; - let costs = costs_for_speed(model, settings, model_ref.speed)?; - pricing_for_model_costs( - model, - provider.id.clone(), - settings.billing_policy, - model_ref.speed, - &costs, - ) - } - - #[must_use] - pub fn billing_facts_for( - &self, - model_ref: &ModelRef, - tokens: &TokenCounts, - ) -> Option { - let policy = - self.effective_billing_policy(&model_ref.provider, Some(model_ref.model_id.as_str()))?; - ModelBillingFacts::for_policy(policy, tokens) - } - - /// Price a partial token sample for `model` using catalog pricing. - /// - /// Returns `None` when the provider has no billing policy, the model is - /// unknown, or the pricing algorithm cannot produce a result for the given - /// tokens. Used by read-side rollups so in-flight stages can show an - /// exact cost for the tokens consumed so far. - #[must_use] - pub fn price_tokens(&self, model: &ModelRef, tokens: &TokenCounts) -> Option { - let facts = self.billing_facts_for(model, tokens)?; - let input = ModelBillingInput { - usage: ModelUsage { - model: model.clone(), - tokens: tokens.clone(), - }, - facts, - }; - self.pricing_for(model) - .and_then(|pricing| pricing.bill(&input)) - .map(|amount| amount.0) - } -} - -fn costs_for_speed( - model: &Model, - settings: &CatalogModelSettings, - speed: Option, -) -> Option { - match speed { - None | Some(Speed::Standard) => Some(model.costs.clone()), - Some(speed) => { - if !settings.controls.speed.contains(&speed) { - return None; - } - let Some(speed_costs) = settings.speed_costs.get(&speed) else { - return Some(model.costs.clone()); - }; - Some(merge_cost_override(&model.costs, speed_costs)) - } - } -} - -fn merge_cost_override(base: &ModelCosts, override_costs: &ModelCosts) -> ModelCosts { - ModelCosts { - input_cost_per_mtok: override_costs - .input_cost_per_mtok - .or(base.input_cost_per_mtok), - output_cost_per_mtok: override_costs - .output_cost_per_mtok - .or(base.output_cost_per_mtok), - cache_input_cost_per_mtok: override_costs - .cache_input_cost_per_mtok - .or(base.cache_input_cost_per_mtok), - } -} - -impl Model { - #[must_use] - pub fn billing_model_ref(&self, speed: Option) -> ModelRef { - ModelRef { - provider: self.provider.clone(), - model_id: self.id.clone(), - speed, - } - } -} - -fn pricing_for_model_costs( - model: &Model, - provider_id: ProviderId, - billing_policy: BillingPolicy, - speed: Option, - costs: &ModelCosts, -) -> Option { - let input = costs.input_cost_per_mtok.map(PricePerMTok::from_usd)?; - let output = costs.output_cost_per_mtok.map(PricePerMTok::from_usd)?; - let cached_input = costs.cache_input_cost_per_mtok.map(PricePerMTok::from_usd); - - let policy = pricing_policy_for_billing_policy(billing_policy, input, output, cached_input)?; - Some(ModelPricing { - model: ModelRef { - provider: provider_id, - model_id: model.id.clone(), - speed, - }, - policy, - }) -} - -fn pricing_policy_for_billing_policy( - billing_policy: BillingPolicy, - input: PricePerMTok, - output: PricePerMTok, - cached_input: Option, -) -> Option { - match billing_policy { - BillingPolicy::Anthropic => Some(anthropic_pricing_policy(input, output, cached_input)), - BillingPolicy::Gemini => Some(ModelPricingPolicy::Gemini(GeminiModelPricing { - input, - output, - cached_input, - storage: None, - })), - BillingPolicy::OpenAi => Some(ModelPricingPolicy::OpenAi(OpenAiModelPricing { - input, - cached_input, - output, - })), - BillingPolicy::None => None, - } -} - -fn anthropic_pricing_policy( - input: PricePerMTok, - output: PricePerMTok, - cached_input: Option, -) -> ModelPricingPolicy { - ModelPricingPolicy::Anthropic(AnthropicModelPricing { - input, - cache_read: cached_input, - cache_write_5m: Some(input.multiply_ratio( - ANTHROPIC_CACHE_WRITE_5M_NUMERATOR, - ANTHROPIC_CACHE_WRITE_5M_DENOMINATOR, - )), - cache_write_1h: Some(input.multiply_ratio( - ANTHROPIC_CACHE_WRITE_1H_NUMERATOR, - ANTHROPIC_CACHE_WRITE_1H_DENOMINATOR, - )), - output, - }) -} - -impl ModelPricing { - #[must_use] - pub fn bill(&self, input: &ModelBillingInput) -> Option { - if input.usage.model != self.model { - return None; - } - - let bill = match (&self.policy, &input.facts) { - (ModelPricingPolicy::OpenAi(pricing), ModelBillingFacts::OpenAi(_)) => { - Some(bill_openai_like(pricing, &input.usage.tokens)) - } - (ModelPricingPolicy::Anthropic(pricing), ModelBillingFacts::Anthropic(facts)) => { - Some(bill_anthropic(pricing, &input.usage.tokens, facts)) - } - (ModelPricingPolicy::Gemini(pricing), ModelBillingFacts::Gemini(facts)) => { - bill_gemini(pricing, &input.usage.tokens, facts) - } - _ => None, - }?; - - Some(bill) - } - - #[must_use] - pub fn bill_usage(&self, input: ModelBillingInput) -> BilledModelUsage { - let total_usd_micros = self.bill(&input).map(|amount| amount.0); - BilledModelUsage { - input, - total_usd_micros, - } - } -} - -fn bill_openai_like(pricing: &OpenAiModelPricing, tokens: &TokenCounts) -> UsdMicros { - let mut total = pricing.input.bill(tokens.input_tokens); - total += pricing.output.bill(tokens.billable_output_tokens()); - if let Some(cached_input) = pricing.cached_input { - total += cached_input.bill(tokens.cache_read_tokens); - } - total -} - -fn bill_anthropic( - pricing: &AnthropicModelPricing, - tokens: &TokenCounts, - facts: &AnthropicBillingFacts, -) -> UsdMicros { - let mut total = pricing.input.bill(tokens.input_tokens); - total += pricing.output.bill(tokens.billable_output_tokens()); - if let Some(cache_read) = pricing.cache_read { - total += cache_read.bill(tokens.cache_read_tokens); - } - if let Some(cache_write_5m) = pricing.cache_write_5m { - total += cache_write_5m.bill(facts.cache_write_5m_tokens); - } - if let Some(cache_write_1h) = pricing.cache_write_1h { - total += cache_write_1h.bill(facts.cache_write_1h_tokens); - } - total -} - -fn bill_gemini( - pricing: &GeminiModelPricing, - tokens: &TokenCounts, - facts: &GeminiBillingFacts, -) -> Option { - if tokens.cache_read_tokens > 0 && pricing.cached_input.is_none() { - return None; - } - if !facts.storage_segments.is_empty() && pricing.storage.is_none() { - return None; - } - - let mut total = pricing.input.bill(tokens.input_tokens); - total += pricing.output.bill(tokens.billable_output_tokens()); - if let Some(cached_input) = pricing.cached_input { - total += cached_input.bill(tokens.cache_read_tokens); - } - if let Some(storage) = pricing.storage.as_ref() { - let storage_cost = facts - .storage_segments - .iter() - .map(|segment| { - let token_seconds = - i128::from(segment.cached_tokens) * i128::from(segment.ttl_seconds); - UsdMicros(saturating_i128_to_i64( - token_seconds * i128::from(storage.usd_micros_per_mtok_second) - / TOKENS_PER_MTOK, - )) - }) - .sum::(); - total += storage_cost; - } - - Some(total) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::catalog::LlmCatalogSettings; - use crate::{Catalog, ProviderId}; - - fn catalog_from_toml(source: &str) -> Catalog { - let settings: LlmCatalogSettings = - toml::from_str(source).expect("catalog fixture should parse"); - Catalog::from_settings(&settings).expect("catalog fixture should build") - } - - fn billed_usage( - input_tokens: i64, - output_tokens: i64, - total_usd_micros: Option, - ) -> BilledModelUsage { - BilledModelUsage { - input: ModelBillingInput { - usage: ModelUsage { - model: ModelRef { - provider: ProviderId::openai(), - model_id: ModelId::new("gpt-5.4"), - speed: None, - }, - tokens: TokenCounts { - input_tokens, - output_tokens, - reasoning_tokens: 3, - cache_read_tokens: 5, - cache_write_tokens: 7, - }, - }, - facts: ModelBillingFacts::OpenAi(OpenAiBillingFacts::default()), - }, - total_usd_micros, - } - } - - #[test] - fn usd_micros_accumulate_keeps_none_until_a_cost_is_observed() { - let mut total = None; - UsdMicros::accumulate(&mut total, None); - assert_eq!(total, None); - - UsdMicros::accumulate(&mut total, Some(UsdMicros(40_000))); - UsdMicros::accumulate(&mut total, None); - UsdMicros::accumulate(&mut total, Some(UsdMicros(60_000))); - assert_eq!(total, Some(UsdMicros(100_000))); - } - - #[test] - fn usd_micros_arithmetic_saturates_at_i64_bounds() { - assert_eq!(UsdMicros(i64::MAX) + UsdMicros(1), UsdMicros(i64::MAX)); - - let mut minimum = UsdMicros(i64::MIN); - minimum += UsdMicros(-1); - assert_eq!(minimum, UsdMicros(i64::MIN)); - - assert_eq!( - [UsdMicros(i64::MAX), UsdMicros(1)] - .into_iter() - .sum::(), - UsdMicros(i64::MAX) - ); - } - - #[test] - fn usd_micros_accumulate_saturates_at_i64_bounds() { - let mut maximum = Some(UsdMicros(i64::MAX)); - UsdMicros::accumulate(&mut maximum, Some(UsdMicros(1))); - assert_eq!(maximum, Some(UsdMicros(i64::MAX))); - - let mut minimum = Some(UsdMicros(i64::MIN)); - UsdMicros::accumulate(&mut minimum, Some(UsdMicros(-1))); - assert_eq!(minimum, Some(UsdMicros(i64::MIN))); - } - - #[test] - fn model_billing_policy_override_changes_the_billing_algorithm() { - let catalog = catalog_from_toml( - r#" -[providers.aggregator] -display_name = "Aggregator" -adapter = "openai_compatible" -base_url = "https://aggregator.test/v1" - -[models."claude-via-aggregator"] -provider = "aggregator" -billing_policy = "anthropic" -display_name = "Claude (via Aggregator)" -family = "claude" -default = true - -[models."claude-via-aggregator".limits] -context_window = 200000 - -[models."claude-via-aggregator".features] -tools = true -vision = false -reasoning = false - -[models."claude-via-aggregator".costs] -input_cost_per_mtok = 3.0 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.3 - -[models."plain-model"] -provider = "aggregator" -display_name = "Plain" -family = "plain" - -[models."plain-model".limits] -context_window = 100000 - -[models."plain-model".features] -tools = false -vision = false -reasoning = false - -[models."plain-model".costs] -input_cost_per_mtok = 3.0 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.3 -"#, - ); - - let tokens = TokenCounts { - cache_write_tokens: 1_000_000, - ..TokenCounts::default() - }; - let claude = ModelRef { - provider: ProviderId::new("aggregator"), - model_id: ModelId::new("claude-via-aggregator"), - speed: None, - }; - let plain = ModelRef { - provider: ProviderId::new("aggregator"), - model_id: ModelId::new("plain-model"), - speed: None, - }; - - // The override bills Anthropic-style: cache writes at 1.25x input - // ($3/MTok -> $3.75/MTok -> $3.75 for 1M write tokens). - assert_eq!(catalog.price_tokens(&claude, &tokens), Some(3_750_000)); - // The provider's default OpenAI policy has no cache-write charge. - assert_eq!(catalog.price_tokens(&plain, &tokens), Some(0)); - } - - #[test] - fn billed_token_counts_add_counts_accumulates_cost_when_known() { - let mut counts = BilledTokenCounts { - input_tokens: 1, - output_tokens: 2, - total_tokens: 3, - reasoning_tokens: 4, - cache_read_tokens: 5, - cache_write_tokens: 6, - total_usd_micros: None, - }; - counts.add_counts(&BilledTokenCounts { - input_tokens: 10, - output_tokens: 20, - total_tokens: 30, - reasoning_tokens: 40, - cache_read_tokens: 50, - cache_write_tokens: 60, - total_usd_micros: Some(70), - }); - - assert_eq!(counts, BilledTokenCounts { - input_tokens: 11, - output_tokens: 22, - total_tokens: 33, - reasoning_tokens: 44, - cache_read_tokens: 55, - cache_write_tokens: 66, - total_usd_micros: Some(70), - }); - } - - #[test] - fn billed_token_counts_add_billed_usage_preserves_unknown_cost() { - let mut counts = BilledTokenCounts::default(); - - counts.add_billed_usage(&billed_usage(10, 20, None)); - - assert_eq!(counts, BilledTokenCounts { - input_tokens: 10, - output_tokens: 20, - total_tokens: 45, - reasoning_tokens: 3, - cache_read_tokens: 5, - cache_write_tokens: 7, - total_usd_micros: None, - }); - } - - #[test] - fn billed_token_counts_add_billed_usage_accumulates_known_cost() { - let mut counts = BilledTokenCounts::default(); - - counts.add_billed_usage(&billed_usage(10, 20, Some(100))); - counts.add_billed_usage(&billed_usage(1, 2, Some(50))); - - assert_eq!(counts.input_tokens, 11); - assert_eq!(counts.output_tokens, 22); - assert_eq!(counts.total_tokens, 63); - assert_eq!(counts.total_usd_micros, Some(150)); - } - - #[test] - fn billed_token_counts_cost_rollups_saturate() { - let billed = [ - billed_usage(0, 0, Some(i64::MAX)), - billed_usage(0, 0, Some(1)), - ]; - assert_eq!( - BilledTokenCounts::from_billed_usage(&billed).total_usd_micros, - Some(i64::MAX) - ); - - let mut counts = BilledTokenCounts { - total_usd_micros: Some(i64::MAX), - ..BilledTokenCounts::default() - }; - counts.add_counts(&BilledTokenCounts { - total_usd_micros: Some(1), - ..BilledTokenCounts::default() - }); - assert_eq!(counts.total_usd_micros, Some(i64::MAX)); - - counts.add_billed_usage(&billed_usage(0, 0, Some(1))); - assert_eq!(counts.total_usd_micros, Some(i64::MAX)); - } - - #[test] - fn billed_token_counts_replace_with_billed_usage_discards_previous_values() { - let mut counts = BilledTokenCounts { - input_tokens: 100, - output_tokens: 200, - total_tokens: 300, - reasoning_tokens: 400, - cache_read_tokens: 500, - cache_write_tokens: 600, - total_usd_micros: Some(700), - }; - - counts.replace_with_billed_usage(&billed_usage(1, 2, None)); - - assert_eq!(counts, BilledTokenCounts { - input_tokens: 1, - output_tokens: 2, - total_tokens: 18, - reasoning_tokens: 3, - cache_read_tokens: 5, - cache_write_tokens: 7, - total_usd_micros: None, - }); - } - - #[test] - fn billed_token_counts_is_zero_treats_missing_and_zero_cost_as_zero() { - assert!(BilledTokenCounts::default().is_zero()); - assert!( - BilledTokenCounts { - total_usd_micros: Some(0), - ..BilledTokenCounts::default() - } - .is_zero() - ); - assert!( - !BilledTokenCounts { - input_tokens: 1, - ..BilledTokenCounts::default() - } - .is_zero() - ); - assert!( - !BilledTokenCounts { - total_usd_micros: Some(1), - ..BilledTokenCounts::default() - } - .is_zero() - ); - } - - #[test] - fn openai_pricing_bills_cached_input_and_reasoning_output() { - let pricing = ModelPricing { - model: ModelRef { - provider: ProviderId::openai(), - model_id: ModelId::new("gpt-5.4"), - speed: None, - }, - policy: ModelPricingPolicy::OpenAi(OpenAiModelPricing { - input: PricePerMTok { - usd_micros: 1_250_000, - }, - cached_input: Some(PricePerMTok { - usd_micros: 125_000, - }), - output: PricePerMTok { - usd_micros: 10_000_000, - }, - }), - }; - let input = ModelBillingInput { - usage: ModelUsage { - model: pricing.model.clone(), - tokens: TokenCounts { - input_tokens: 500_000, - output_tokens: 125_000, - reasoning_tokens: 25_000, - cache_read_tokens: 250_000, - cache_write_tokens: 0, - }, - }, - facts: ModelBillingFacts::OpenAi(OpenAiBillingFacts::default()), - }; - - assert_eq!(pricing.bill(&input), Some(UsdMicros(2_156_250))); - } - - #[test] - fn catalog_pricing_uses_speed_cost_overrides() { - let pricing = Catalog::builtin() - .pricing_for(&ModelRef { - provider: ProviderId::anthropic(), - model_id: ModelId::new("claude-opus-4-6"), - speed: Some(Speed::Fast), - }) - .unwrap(); - - let ModelPricingPolicy::Anthropic(anthropic) = pricing.policy else { - panic!("expected anthropic pricing"); - }; - - assert_eq!(pricing.model.provider, ProviderId::anthropic()); - assert_eq!(pricing.model.model_id, "claude-opus-4-6"); - assert_eq!(pricing.model.speed, Some(Speed::Fast)); - assert_eq!(anthropic.input.usd_micros, 30_000_000); - assert_eq!(anthropic.output.usd_micros, 150_000_000); - assert_eq!(anthropic.cache_read.unwrap().usd_micros, 3_000_000); - assert_eq!(anthropic.cache_write_5m.unwrap().usd_micros, 37_500_000); - assert_eq!(anthropic.cache_write_1h.unwrap().usd_micros, 60_000_000); - } - - #[test] - fn catalog_pricing_standard_speed_uses_base_costs() { - let pricing = Catalog::builtin() - .pricing_for(&ModelRef { - provider: ProviderId::anthropic(), - model_id: ModelId::new("claude-opus-4-6"), - speed: Some(Speed::Standard), - }) - .unwrap(); - - let ModelPricingPolicy::Anthropic(anthropic) = pricing.policy else { - panic!("expected anthropic pricing"); - }; - - assert_eq!(anthropic.input.usd_micros, 5_000_000); - assert_eq!(anthropic.output.usd_micros, 25_000_000); - assert_eq!(anthropic.cache_read.unwrap().usd_micros, 500_000); - assert_eq!(anthropic.cache_write_5m.unwrap().usd_micros, 6_250_000); - assert_eq!(anthropic.cache_write_1h.unwrap().usd_micros, 10_000_000); - } - - #[test] - fn catalog_pricing_supported_fast_without_override_uses_base_costs() { - let catalog = catalog_from_toml( - r#" -[providers.test_anthropic] -display_name = "Test Anthropic" -adapter = "anthropic" -agent_profile = "anthropic" -billing_policy = "anthropic" - -[models.test-opus] -provider = "test_anthropic" -display_name = "Test Opus" -family = "test" -default = true - -[models.test-opus.limits] -context_window = 1000 - -[models.test-opus.features] -tools = true -vision = false -reasoning = false - -[models.test-opus.controls] -speed = ["fast"] - -[models.test-opus.costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 4.0 -cache_input_cost_per_mtok = 0.25 -"#, - ); - - let pricing = catalog - .pricing_for(&ModelRef { - provider: ProviderId::new("test_anthropic"), - model_id: ModelId::new("test-opus"), - speed: Some(Speed::Fast), - }) - .unwrap(); - - let ModelPricingPolicy::Anthropic(anthropic) = pricing.policy else { - panic!("expected anthropic adapter pricing"); - }; - assert_eq!(anthropic.input.usd_micros, 1_000_000); - assert_eq!(anthropic.output.usd_micros, 4_000_000); - assert_eq!(anthropic.cache_read.unwrap().usd_micros, 250_000); - } - - #[test] - fn catalog_pricing_supports_custom_openai_compatible_provider_costs() { - let catalog = catalog_from_toml( - r#" -[providers.proxy] -display_name = "Proxy" -adapter = "openai_compatible" -agent_profile = "openai" -billing_policy = "openai" -base_url = "https://proxy.example/v1" - -[models.proxy-model] -provider = "proxy" -display_name = "Proxy Model" -family = "proxy" -default = true - -[models.proxy-model.limits] -context_window = 1000 - -[models.proxy-model.features] -tools = true -vision = false -reasoning = false - -[models.proxy-model.costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 2.0 -cache_input_cost_per_mtok = 0.1 -"#, - ); - - let pricing = catalog - .pricing_for(&ModelRef { - provider: ProviderId::new("proxy"), - model_id: ModelId::new("proxy-model"), - speed: None, - }) - .unwrap(); - - let ModelPricingPolicy::OpenAi(openai_like) = pricing.policy else { - panic!("expected OpenAI billing algorithm for OpenAI-compatible adapter"); - }; - assert_eq!(pricing.model.provider, ProviderId::new("proxy")); - assert_eq!(openai_like.input.usd_micros, 1_000_000); - assert_eq!(openai_like.output.usd_micros, 2_000_000); - assert_eq!(openai_like.cached_input.unwrap().usd_micros, 100_000); - } - - #[test] - fn catalog_pricing_uses_canonical_model_id_not_api_id() { - let catalog = catalog_from_toml( - r#" -[providers.proxy] -display_name = "Proxy" -adapter = "openai_compatible" -agent_profile = "openai" -billing_policy = "openai" -base_url = "https://proxy.example/v1" - -[models.canonical-model] -provider = "proxy" -api_id = "wire-model" -display_name = "Canonical Model" -family = "proxy" -default = true - -[models.canonical-model.limits] -context_window = 1000 - -[models.canonical-model.features] -tools = true -vision = false -reasoning = false - -[models.canonical-model.costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 2.0 -"#, - ); - - assert!( - catalog - .pricing_for(&ModelRef { - provider: ProviderId::new("proxy"), - model_id: ModelId::new("canonical-model"), - speed: None, - }) - .is_some() - ); - assert!( - catalog - .pricing_for(&ModelRef { - provider: ProviderId::new("proxy"), - model_id: ModelId::new("wire-model"), - speed: None, - }) - .is_none() - ); - } - - #[test] - fn catalog_pricing_unknown_provider_model_or_speed_has_no_estimate() { - assert!( - Catalog::builtin() - .pricing_for(&ModelRef { - provider: ProviderId::new("unknown"), - model_id: ModelId::new("claude-opus-4-6"), - speed: None, - }) - .is_none() - ); - assert!( - Catalog::builtin() - .pricing_for(&ModelRef { - provider: ProviderId::anthropic(), - model_id: ModelId::new("unknown"), - speed: None, - }) - .is_none() - ); - assert!( - Catalog::builtin() - .pricing_for(&ModelRef { - provider: ProviderId::openai(), - model_id: ModelId::new("gpt-5.4"), - speed: Some(Speed::Fast), - }) - .is_none() - ); - } - - #[test] - fn anthropic_billing_supports_distinct_cache_write_buckets() { - let pricing = ModelPricing { - model: ModelRef { - provider: ProviderId::anthropic(), - model_id: ModelId::new("claude-opus-4-6"), - speed: Some(Speed::Fast), - }, - policy: ModelPricingPolicy::Anthropic(AnthropicModelPricing { - input: PricePerMTok { - usd_micros: 30_000_000, - }, - cache_read: Some(PricePerMTok { - usd_micros: 3_000_000, - }), - cache_write_5m: Some(PricePerMTok { - usd_micros: 37_500_000, - }), - cache_write_1h: Some(PricePerMTok { - usd_micros: 60_000_000, - }), - output: PricePerMTok { - usd_micros: 150_000_000, - }, - }), - }; - let input = ModelBillingInput { - usage: ModelUsage { - model: pricing.model.clone(), - tokens: TokenCounts { - input_tokens: 100_000, - output_tokens: 10_000, - reasoning_tokens: 5_000, - cache_read_tokens: 20_000, - cache_write_tokens: 0, - }, - }, - facts: ModelBillingFacts::Anthropic(AnthropicBillingFacts { - cache_write_5m_tokens: 30_000, - cache_write_1h_tokens: 40_000, - }), - }; - - assert_eq!(pricing.bill(&input), Some(UsdMicros(8_835_000))); - } - - #[test] - fn gemini_billing_requires_storage_pricing_when_storage_facts_exist() { - let pricing = ModelPricing { - model: ModelRef { - provider: ProviderId::gemini(), - model_id: ModelId::new("gemini-3.1-pro-preview"), - speed: None, - }, - policy: ModelPricingPolicy::Gemini(GeminiModelPricing { - input: PricePerMTok { - usd_micros: 1_250_000, - }, - output: PricePerMTok { - usd_micros: 10_000_000, - }, - cached_input: None, - storage: None, - }), - }; - let input = ModelBillingInput { - usage: ModelUsage { - model: pricing.model.clone(), - tokens: TokenCounts { - input_tokens: 100_000, - output_tokens: 10_000, - reasoning_tokens: 0, - cache_read_tokens: 0, - cache_write_tokens: 0, - }, - }, - facts: ModelBillingFacts::Gemini(GeminiBillingFacts { - storage_segments: vec![GeminiStorageSegment { - cached_tokens: 100_000, - ttl_seconds: 60, - }], - }), - }; - - assert_eq!(pricing.bill(&input), None); - } - - #[test] - fn price_per_mtok_bill_saturates_large_totals() { - let price = PricePerMTok { - usd_micros: i64::MAX, - }; - - assert_eq!(price.bill(i64::MAX), UsdMicros(i64::MAX)); - } - - #[test] - fn price_per_mtok_from_usd_saturates_large_inputs() { - let price = PricePerMTok::from_usd(f64::MAX); - - assert_eq!(price.usd_micros, i64::MAX); - } - - #[test] - fn openai_billing_facts_serialize_as_empty_object() { - assert_eq!( - serde_json::to_value(OpenAiBillingFacts::default()).unwrap(), - serde_json::json!({}) - ); - } - - #[test] - fn pricing_policy_serializes_with_algorithm_tag() { - let policy = ModelPricingPolicy::OpenAi(OpenAiModelPricing { - input: PricePerMTok { usd_micros: 1 }, - cached_input: None, - output: PricePerMTok { usd_micros: 2 }, - }); - - assert_eq!( - serde_json::to_value(policy).unwrap(), - serde_json::json!({ - "algorithm": "openai", - "input": { "usd_micros": 1 }, - "cached_input": null, - "output": { "usd_micros": 2 } - }) - ); - } - - #[test] - fn old_provider_tagged_billing_facts_are_rejected() { - let error = serde_json::from_value::(serde_json::json!({ - "provider": "openai" - })) - .unwrap_err(); - assert!(error.to_string().contains("algorithm")); - } - - #[test] - fn old_provider_tagged_pricing_policy_is_rejected() { - let error = serde_json::from_value::(openai_pricing_json( - "provider", "moonshot", - )) - .unwrap_err(); - assert!(error.to_string().contains("algorithm")); - } - - #[test] - fn openai_billing_policy_uses_openai_billing_algorithm() { - let facts = - ModelBillingFacts::for_policy(BillingPolicy::OpenAi, &TokenCounts::default()).unwrap(); - assert_eq!( - facts, - ModelBillingFacts::OpenAi(OpenAiBillingFacts::default()) - ); - } - - fn openai_pricing_json(tag: &str, tag_value: &str) -> serde_json::Value { - let mut value = serde_json::json!({ - "input": { "usd_micros": 1 }, - "cached_input": null, - "output": { "usd_micros": 2 } - }); - value - .as_object_mut() - .unwrap() - .insert(tag.to_string(), tag_value.into()); - value - } -} diff --git a/lib/foundation/fabro-model/src/bootstrap_catalog.rs b/lib/foundation/fabro-model/src/bootstrap_catalog.rs deleted file mode 100644 index 4b150e0eb..000000000 --- a/lib/foundation/fabro-model/src/bootstrap_catalog.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Install/API-key validation access to the built-in catalog. -//! -//! Runtime request-serving paths should use a resolved catalog threaded -//! through their state. This module is the explicit hatch for setup flows that -//! need built-in provider/model metadata before project settings are loaded. - -use crate::Catalog; - -#[must_use] -pub fn catalog() -> &'static Catalog { - Catalog::builtin() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bootstrap_catalog_is_the_builtin_catalog() { - assert!(std::ptr::eq(catalog(), Catalog::builtin())); - } -} diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs deleted file mode 100644 index 56c0f5b0c..000000000 --- a/lib/foundation/fabro-model/src/catalog.rs +++ /dev/null @@ -1,7529 +0,0 @@ -use std::borrow::Cow; -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use std::str::FromStr; -use std::sync::LazyLock; - -use rust_embed::RustEmbed; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use strum::VariantArray; -use toml::de::Error as TomlDeError; -use tracing::warn; - -use crate::Speed; -use crate::adapter::{AdapterKind, AgentProfileKind}; -use crate::codec::CodecKind; -use crate::ids::{ModelId, ProviderId}; -use crate::provider::Provider; -use crate::reasoning::ReasoningEffort; -use crate::types::{ - Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature, -}; - -#[derive(RustEmbed)] -#[folder = "src/catalog/providers"] -struct BuiltinCatalogToml; - -/// TOML shape used by the model catalog builder. -/// -/// This deliberately lives in `fabro-model` instead of reusing -/// `fabro-config::LlmLayer`: `fabro-config` depends on `fabro-types`, and -/// `fabro-types` depends on `fabro-model`, so the catalog cannot depend on -/// `fabro-config` without creating a crate cycle. -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct LlmCatalogSettings { - #[serde(default)] - pub providers: HashMap, - /// Legacy `[models.""]` input. Canonical settings place model rows - /// under their provider; this map is normalized before layers merge. - #[serde(default)] - pub models: HashMap, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProviderCatalogSettings { - #[serde(default)] - pub display_name: Option, - #[serde(default)] - pub adapter: Option, - /// Wire dialect for this provider's routes. Defaults to the adapter's - /// codec; only the default pairing is accepted today. - #[serde(default)] - pub codec: Option, - #[serde(default)] - pub agent_profile: Option, - #[serde(default)] - pub auth: Option, - #[serde(default)] - pub billing_policy: Option, - #[serde(default)] - pub api_key_url: Option, - #[serde(default)] - pub base_url: Option, - /// Unresolved interpolation source strings (literal text or - /// `{{ secrets.NAME }}` tokens), resolved at the credential boundary in - /// `fabro-auth`. - #[serde(default)] - pub extra_headers: Option>, - #[serde(default)] - pub priority: Option, - #[serde(default)] - pub enabled: Option, - #[serde(default)] - pub aliases: Option>, - /// Model declarations keyed by Fabro's canonical model slug. - #[serde(default)] - pub models: HashMap, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ModelCatalogSettings { - /// Provider used only by the temporary legacy top-level `[models]` - /// compatibility shape. Canonical provider-scoped rows leave this unset. - #[serde(default)] - pub provider: Option, - #[serde(default)] - pub api_id: Option, - /// Wire dialect for this model's route, overriding the provider's codec - /// (the multiplexer case). Only the adapter's default pairing is - /// accepted today. - #[serde(default)] - pub codec: Option, - /// Billing family for this model, overriding the provider's policy - /// (e.g. Anthropic cache billing for a Claude model served through an - /// aggregator whose other models bill OpenAI-style). - #[serde(default)] - pub billing_policy: Option, - #[serde(default)] - pub agent_profile: Option, - #[serde(default)] - pub display_name: Option, - #[serde(default)] - pub family: Option, - #[serde(default)] - pub training: Option, - #[serde(default, deserialize_with = "deserialize_knowledge_cutoff")] - pub knowledge_cutoff: Option, - #[serde(default)] - pub default: Option, - #[serde(default)] - pub small_default: Option, - #[serde(default)] - pub probe: Option, - #[serde(default)] - pub enabled: Option, - #[serde(default)] - pub aliases: Option>, - #[serde(default)] - pub estimated_output_tps: Option, - #[serde(default)] - pub limits: Option, - #[serde(default)] - pub features: Option, - #[serde(default)] - pub controls: Option, - #[serde(default)] - pub costs: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SettingsModelLimits { - #[serde(default)] - pub context_window: Option, - #[serde(default)] - pub max_output: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SettingsModelFeatures { - #[serde(default)] - pub tools: Option, - #[serde(default)] - pub vision: Option, - #[serde(default)] - pub reasoning: Option, - /// Whether requests reason when no effort control is supplied. When - /// omitted, effort-capable models default to `true` and other models to - /// `false`. - #[serde(default)] - pub reasoning_by_default: Option, - #[serde(default)] - pub reasoning_effort: Option, - #[serde(default)] - pub prompt_cache: Option, - #[serde(default)] - pub cache_control_breakpoints: Option, - #[serde(default)] - pub sampling_params: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SettingsModelControls { - #[serde(default)] - pub reasoning_effort: Option>, - #[serde(default)] - pub speed: Option>, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SettingsModelCostTable { - #[serde(flatten)] - pub base: CostRates, - #[serde(default)] - pub speed: Option>, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CostRates { - #[serde(default)] - pub input_cost_per_mtok: Option, - #[serde(default)] - pub output_cost_per_mtok: Option, - #[serde(default)] - pub cache_input_cost_per_mtok: Option, -} - -/// Where a provider's credential comes from. -/// -/// `Vault`/`Env` reference a stored secret resolved to an auth header. -/// `AwsSigv4` is an opaque source: the credential comes from the AWS default -/// credential chain and the request is SigV4-signed rather than carrying a -/// static secret. It is only valid on Bedrock providers, which catalog -/// validation enforces before adapter construction. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(into = "String", try_from = "String")] -pub enum CredentialRef { - Vault(String), - Env(String), - AwsSigv4, -} - -impl std::fmt::Display for CredentialRef { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Vault(name) => write!(f, "vault:{name}"), - Self::Env(name) => write!(f, "env:{name}"), - Self::AwsSigv4 => write!(f, "aws_sigv4"), - } - } -} - -impl From for String { - fn from(value: CredentialRef) -> Self { - value.to_string() - } -} - -impl FromStr for CredentialRef { - type Err = CredentialRefParseError; - - fn from_str(value: &str) -> Result { - if let Some(name) = value.strip_prefix("vault:") { - if name.is_empty() { - return Err(CredentialRefParseError::EmptyVault); - } - return Ok(Self::Vault(name.to_string())); - } - if let Some(name) = value.strip_prefix("env:") { - if name.is_empty() { - return Err(CredentialRefParseError::EmptyEnv); - } - return Ok(Self::Env(name.to_string())); - } - if value == "aws_sigv4" { - return Ok(Self::AwsSigv4); - } - Err(CredentialRefParseError::Invalid) - } -} - -impl TryFrom for CredentialRef { - type Error = CredentialRefParseError; - - fn try_from(value: String) -> Result { - value.parse() - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -pub enum CredentialRefParseError { - #[error("credential reference must be `vault:`, `env:`, or `aws_sigv4`")] - Invalid, - #[error("credential reference is missing a name after `vault:`")] - EmptyVault, - #[error("credential reference is missing a name after `env:`")] - EmptyEnv, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProviderAuthConfig { - /// Ordered credential sources; the first that resolves wins. Static secrets - /// use `env:` / `vault:`; AWS SigV4 (Bedrock) uses `aws_sigv4`, - /// which resolves opaquely from the AWS credential chain. - pub credentials: Vec, - #[serde(default)] - pub header: ApiKeyHeaderPolicy, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub enum ApiKeyHeaderPolicy { - #[default] - Bearer, - Custom { - name: String, - }, -} - -impl Serialize for ApiKeyHeaderPolicy { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - match self { - Self::Bearer => serializer.serialize_str("bearer"), - Self::Custom { name } => { - use serde::ser::SerializeMap; - - let mut map = serializer.serialize_map(Some(1))?; - map.serialize_entry("custom", name)?; - map.end() - } - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum ApiKeyHeaderPolicyInput { - String(String), - Table(ApiKeyHeaderPolicyTable), -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ApiKeyHeaderPolicyTable { - custom: String, -} - -impl<'de> Deserialize<'de> for ApiKeyHeaderPolicy { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - use serde::de::Error as _; - - match ApiKeyHeaderPolicyInput::deserialize(deserializer)? { - ApiKeyHeaderPolicyInput::String(value) if value == "bearer" => Ok(Self::Bearer), - ApiKeyHeaderPolicyInput::String(value) => Err(D::Error::custom(format!( - "API key header must be `bearer`, got `{value}`" - ))), - ApiKeyHeaderPolicyInput::Table(table) => { - validate_header_name(&table.custom).map_err(D::Error::custom)?; - Ok(Self::Custom { name: table.custom }) - } - } - } -} - -fn validate_header_name(name: &str) -> Result<(), &'static str> { - http::HeaderName::from_bytes(name.as_bytes()) - .map(|_| ()) - .map_err(|_| "custom header name must be a valid HTTP header name") -} - -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - strum::Display, - strum::EnumString, - strum::IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum BillingPolicy { - #[serde(rename = "openai")] - #[strum(to_string = "openai")] - OpenAi, - Anthropic, - Gemini, - None, -} - -pub fn deserialize_knowledge_cutoff<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - use serde::de::Error as _; - use toml::value::Datetime; - - #[derive(Deserialize)] - #[serde(untagged)] - enum Either { - Toml(Datetime), - Str(String), - } - - let value = Option::::deserialize(deserializer)?; - match value { - None => Ok(None), - Some(Either::Str(value)) => Ok(Some(value)), - Some(Either::Toml(value)) => { - let date = value - .date - .ok_or_else(|| D::Error::custom("knowledge_cutoff requires a date component"))?; - Ok(Some(format!( - "{:04}-{:02}-{:02}", - date.year, date.month, date.day - ))) - } - } -} - -/// Global singleton catalog parsed from embedded provider TOML files. -static GLOBAL_CATALOG: LazyLock = LazyLock::new(|| { - Catalog::from_builtin_toml().expect("embedded provider TOML files must build a valid catalog") -}); - -/// A resolved fallback target: provider name + model ID. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FallbackTarget { - pub provider: ProviderId, - pub model: ModelId, -} - -impl FallbackTarget { - /// Build a target from anything that renders as a provider name and model - /// ID, so callers holding [`ProviderId`]/[`ModelId`] or bare passthrough - /// selectors all use one constructor. - pub fn new(provider: impl std::fmt::Display, model: impl std::fmt::Display) -> Self { - Self { - provider: ProviderId::new(provider.to_string()), - model: ModelId::new(model.to_string()), - } - } -} - -impl std::fmt::Display for FallbackTarget { - /// Renders as `provider:model`, matching the qualified form accepted by - /// model references. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}:{}", self.provider, self.model) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct CatalogProvider { - pub id: ProviderId, - pub display_name: String, - pub adapter: AdapterKind, - /// Wire dialect driven by this provider's routes; models may override it - /// via [`CatalogModelSettings::codec`]. - pub codec: CodecKind, - pub agent_profile: AgentProfileKind, - pub auth: Option, - pub billing_policy: BillingPolicy, - pub api_key_url: Option, - pub base_url: Option, - /// Unresolved interpolation source strings (literal text or - /// `{{ secrets.NAME }}` tokens), resolved at the credential boundary in - /// `fabro-auth`. - pub extra_headers: HashMap, - pub priority: i32, - pub aliases: Vec, -} - -impl CatalogProvider { - #[must_use] - pub fn vault_secret_name(&self) -> Option<&str> { - self.auth - .as_ref()? - .credentials - .iter() - .find_map(|credential_ref| match credential_ref { - CredentialRef::Vault(name) => Some(name.as_str()), - CredentialRef::Env(_) | CredentialRef::AwsSigv4 => None, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CatalogModelControls { - pub reasoning_effort: Vec, - pub speed: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct CatalogModelSettings { - pub api_id: String, - /// Wire dialect for this model's route (the provider codec unless the - /// model row overrides it). - pub codec: CodecKind, - /// Billing family for this model (the provider policy unless the model - /// row overrides it). - pub billing_policy: BillingPolicy, - pub agent_profile: AgentProfileKind, - /// Whether the provider route reasons when a request omits an effort - /// control. - pub reasoning_by_default: bool, - pub controls: CatalogModelControls, - pub speed_costs: HashMap, - probe: bool, -} - -#[derive(Debug, thiserror::Error)] -pub enum CatalogBuildError { - #[error("embedded built-in catalog contains no provider TOML files")] - NoBuiltinProviderFiles, - #[error("failed to read embedded provider TOML path '{path}' as UTF-8")] - InvalidBuiltinUtf8 { - path: String, - #[source] - source: std::str::Utf8Error, - }, - #[error("failed to parse embedded provider TOML '{path}'")] - InvalidBuiltinToml { - path: String, - #[source] - source: TomlDeError, - }, - #[error("embedded provider TOML '{path}' must define exactly one provider row")] - InvalidBuiltinProviderCount { path: String }, - #[error("embedded provider TOML '{path}' must define provider '{expected}', found '{actual}'")] - BuiltinProviderIdMismatch { - path: String, - expected: String, - actual: String, - }, - #[error( - "embedded provider TOML '{path}' contains model '{model}' for provider '{actual}', expected '{expected}'" - )] - BuiltinModelProviderMismatch { - path: String, - model: String, - expected: String, - actual: String, - }, - #[error("provider '{provider}' is missing required field '{field}'")] - MissingProviderField { - provider: ProviderId, - field: &'static str, - }, - #[error("provider '{provider}' uses unknown adapter '{adapter}'")] - UnknownAdapter { - provider: ProviderId, - adapter: String, - }, - #[error( - "provider '{provider}' configures codec '{codec}', but adapter '{adapter}' only supports '{expected}'" - )] - UnsupportedProviderCodec { - provider: ProviderId, - adapter: AdapterKind, - codec: CodecKind, - expected: CodecKind, - }, - #[error( - "model '{model}' configures codec '{codec}', but adapter '{adapter}' only supports '{expected}'" - )] - UnsupportedModelCodec { - model: String, - adapter: AdapterKind, - codec: CodecKind, - expected: CodecKind, - }, - #[error("provider '{provider}' API-key auth must declare at least one credential")] - EmptyApiKeyCredentials { provider: ProviderId }, - #[error( - "provider '{provider}' uses aws_sigv4 credentials, but adapter '{adapter}' does not support SigV4" - )] - UnsupportedAwsSigv4Credential { - provider: ProviderId, - adapter: AdapterKind, - }, - #[error("provider identifier '{identifier}' is declared by both '{first}' and '{second}'")] - DuplicateProviderIdentifier { - identifier: String, - first: ProviderId, - second: ProviderId, - }, - #[error("model '{model}' is missing required field '{field}'")] - MissingModelField { model: String, field: &'static str }, - #[error("model '{model}' references unknown provider '{provider}'")] - UnknownModelProvider { - model: String, - provider: ProviderId, - }, - #[error( - "provider '{provider}' model selector '{selector}' is declared by both '{first}' and '{second}'" - )] - DuplicateProviderModelSelector { - provider: ProviderId, - selector: String, - first: ModelId, - second: ModelId, - }, - #[error(transparent)] - LegacyModel(#[from] LegacyModelError), - #[error("provider '{provider}' model '{model}' has an empty api_id")] - EmptyModelApiId { - provider: ProviderId, - model: ModelId, - }, - #[error("provider '{provider}' has multiple default models: {models:?}")] - MultipleProviderDefaults { - provider: ProviderId, - models: Vec, - }, - #[error("provider '{provider}' has multiple small default models: {models:?}")] - MultipleProviderSmallDefaults { - provider: ProviderId, - models: Vec, - }, - #[error("catalog must contain at least one enabled default model")] - NoDefaultModel, - #[error("model '{model}' has invalid reasoning_effort '{value}'")] - InvalidReasoningEffort { - model: String, - value: String, - #[source] - source: strum::ParseError, - }, - #[error("model '{model}' declares reasoning_effort controls but features.reasoning is false")] - ReasoningEffortControlsWithoutReasoning { model: String }, - #[error("model '{model}' declares reasoning_effort feature but features.reasoning is false")] - ReasoningEffortWithoutReasoning { model: String }, - #[error("model '{model}' sets reasoning_by_default but features.reasoning is false")] - DefaultReasoningWithoutReasoning { model: String }, - #[error( - "model '{model}' declares cache_control_breakpoints but features.prompt_cache is false" - )] - CacheControlBreakpointsWithoutPromptCache { model: String }, - #[error( - "model '{model}' must declare at least one reasoning_effort when features.reasoning_effort is levels or always_adaptive" - )] - EmptyReasoningEffortControls { model: String }, - #[error("model '{model}' has invalid speed '{value}'")] - InvalidSpeed { - model: String, - value: String, - #[source] - source: strum::ParseError, - }, - #[error("model '{model}' must not declare standard in controls.speed")] - StandardSpeedControl { model: String }, - #[error("model '{model}' has costs.speed.{speed} without declaring controls.speed")] - UndeclaredSpeedCost { model: String, speed: Speed }, -} - -/// Failure to select one concrete provider/model offering. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum ModelSelectionError { - #[error("unknown model provider '{provider}'")] - UnknownProvider { provider: ProviderId }, - #[error("model provider '{provider}' is unavailable")] - ProviderUnavailable { provider: ProviderId }, - #[error("unknown model selector '{selector}'")] - UnknownSelector { selector: String }, - #[error("model selector '{selector}' is unknown on provider '{provider}'")] - UnknownSelectorOnProvider { - selector: String, - provider: ProviderId, - }, - #[error( - "model selector '{selector}' is known but has no offering on an eligible provider; available providers: {providers:?}" - )] - NoEligibleOffering { - selector: String, - providers: Vec, - }, - #[error( - "no default model is available on an eligible provider; providers with defaults: {providers:?}" - )] - NoDefaultModel { providers: Vec }, -} - -/// One provider/model pair chosen by [`Catalog::resolve_selection`]. The -/// model is the canonical catalog ID when the selector matched an offering, -/// or the caller's selector passed through verbatim when it did not. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SelectedModel { - pub provider: ProviderId, - pub model: String, -} - -/// Typed model catalog backed by a `Vec`. -/// -/// Use [`Catalog::builtin()`] for the embedded settings-backed catalog. -#[derive(Debug)] -pub struct Catalog { - models: Vec, - providers: Vec, - model_settings: HashMap<(ProviderId, ModelId), CatalogModelSettings>, - offering_index: HashMap<(ProviderId, ModelId), usize>, - provider_selector_index: HashMap<(ProviderId, String), usize>, - canonical_candidates: HashMap>, - alias_candidates: HashMap>, - provider_aliases: HashMap, - provider_index: HashMap, -} - -impl Catalog { - /// Returns a reference to the global built-in catalog (loaded once from - /// embedded provider TOML files). - #[must_use] - pub fn builtin() -> &'static Self { - &GLOBAL_CATALOG - } - - pub fn from_settings(settings: &LlmCatalogSettings) -> Result { - let settings = normalize_catalog_settings(settings.clone(), None)?; - let mut providers = build_providers(&settings)?; - providers.sort_by(provider_order); - - let mut provider_index = HashMap::new(); - for (idx, provider) in providers.iter().enumerate() { - provider_index.insert(provider.id.clone(), idx); - } - - let provider_aliases = build_provider_aliases(&providers)?; - let known_providers: HashSet<&str> = - settings.providers.keys().map(String::as_str).collect(); - let enabled_providers: HashSet<&str> = providers - .iter() - .map(|provider| provider.id.as_str()) - .collect(); - let provider_by_id: HashMap<&str, &CatalogProvider> = providers - .iter() - .map(|provider| (provider.id.as_str(), provider)) - .collect(); - - let mut models_with_settings = Vec::new(); - let mut model_identifiers = HashMap::>::new(); - let mut defaults_by_provider = HashMap::>::new(); - let mut small_defaults_by_provider = HashMap::>::new(); - - let mut provider_ids = settings.providers.keys().cloned().collect::>(); - provider_ids.sort_unstable(); - for provider_id in provider_ids { - if !known_providers.contains(provider_id.as_str()) - || !enabled_providers.contains(provider_id.as_str()) - { - continue; - } - let provider = provider_by_id - .get(provider_id.as_str()) - .expect("enabled provider ID should have provider metadata"); - let provider_settings = settings - .providers - .get(&provider_id) - .expect("provider ID came from settings map keys"); - let identifiers = model_identifiers.entry(provider.id.clone()).or_default(); - let mut model_ids = provider_settings.models.keys().cloned().collect::>(); - model_ids.sort_unstable(); - for model_id in model_ids { - let model_settings = provider_settings - .models - .get(&model_id) - .expect("model ID came from provider model map keys"); - if model_settings.enabled == Some(false) { - continue; - } - - if let Some((_, canonical_model)) = legacy_builtin_model(&model_id) { - return Err(LegacyModelError::LegacyIdentifierAsModelId { - identifier: model_id, - provider: provider.id.clone(), - model: canonical_model, - } - .into()); - } - - let (model, resolved_settings) = build_model(&model_id, model_settings, provider)?; - register_model_identifier( - identifiers, - model.id.as_str().to_string(), - model.id.clone(), - &model.provider, - )?; - for alias in &model.aliases { - register_model_identifier( - identifiers, - alias.clone(), - model.id.clone(), - &model.provider, - )?; - } - register_model_identifier( - identifiers, - resolved_settings.api_id.clone(), - model.id.clone(), - &model.provider, - )?; - - if model.default { - defaults_by_provider - .entry(model.provider.clone()) - .or_default() - .push(model.id.clone()); - } - if model.small_default { - small_defaults_by_provider - .entry(model.provider.clone()) - .or_default() - .push(model.id.clone()); - } - models_with_settings.push((model, resolved_settings)); - } - } - - for (provider, defaults) in defaults_by_provider { - if defaults.len() > 1 { - return Err(CatalogBuildError::MultipleProviderDefaults { - provider, - models: defaults.into_iter().map(ModelId::into_inner).collect(), - }); - } - } - for (provider, small_defaults) in small_defaults_by_provider { - if small_defaults.len() > 1 { - return Err(CatalogBuildError::MultipleProviderSmallDefaults { - provider, - models: small_defaults - .into_iter() - .map(ModelId::into_inner) - .collect(), - }); - } - } - if !models_with_settings.iter().any(|(model, _)| model.default) { - return Err(CatalogBuildError::NoDefaultModel); - } - - models_with_settings.sort_by(|(left, _), (right, _)| { - provider_index[&left.provider] - .cmp(&provider_index[&right.provider]) - .then_with(|| left.id.cmp(&right.id)) - }); - warn_multiple_probe_models(&models_with_settings); - let (offering_index, provider_selector_index, canonical_candidates, alias_candidates) = - build_model_indexes(&models_with_settings); - let mut model_settings_by_offering = HashMap::new(); - let mut models = Vec::new(); - for (model, settings) in models_with_settings { - model_settings_by_offering.insert((model.provider.clone(), model.id.clone()), settings); - models.push(model); - } - - Ok(Self { - models, - providers, - model_settings: model_settings_by_offering, - offering_index, - provider_selector_index, - canonical_candidates, - alias_candidates, - provider_aliases, - provider_index, - }) - } - - pub fn from_builtin_with_overrides( - overrides: &LlmCatalogSettings, - ) -> Result { - let builtins = normalize_catalog_settings(Self::builtin_settings()?, None)?; - let overrides = normalize_catalog_settings(overrides.clone(), Some(&builtins))?; - let settings = merge_catalog_settings(overrides, builtins); - Self::from_settings(&settings) - } - - /// Builds a fresh catalog from embedded provider TOML without user - /// overrides. - pub fn from_builtin() -> Result { - Self::from_builtin_toml() - } - - fn builtin_settings() -> Result { - let mut layer = LlmCatalogSettings::default(); - let mut paths = BuiltinCatalogToml::iter() - .filter(|path| path.ends_with(".toml")) - .map(Cow::into_owned) - .collect::>(); - paths.sort_unstable(); - if paths.is_empty() { - return Err(CatalogBuildError::NoBuiltinProviderFiles); - } - - for path in paths { - let file = BuiltinCatalogToml::get(&path) - .expect("path came from embedded built-in catalog iterator"); - let source = std::str::from_utf8(file.data.as_ref()).map_err(|source| { - CatalogBuildError::InvalidBuiltinUtf8 { - path: path.clone(), - source, - } - })?; - let fragment: LlmCatalogSettings = - toml::from_str(source).map_err(|source| CatalogBuildError::InvalidBuiltinToml { - path: path.clone(), - source, - })?; - validate_builtin_fragment(&path, &fragment)?; - layer.providers.extend(fragment.providers); - layer.models.extend(fragment.models); - } - - normalize_catalog_settings(layer, None) - } - - fn from_builtin_toml() -> Result { - Self::from_settings(&Self::builtin_settings()?) - } - - /// Test-only shorthand for selecting from every enabled catalog provider. - /// - /// Production callers must supply an explicit ready-provider snapshot to - /// [`Catalog::select`] or use a provider-scoped lookup. - #[cfg(test)] - #[must_use] - pub(crate) fn get(&self, selector: &str) -> Option<&Model> { - self.candidate_indices(selector) - .and_then(|indices| indices.first()) - .and_then(|idx| self.models.get(*idx)) - } - - /// Look up a canonical ID, alias, or API ID on exactly one provider, - /// without considering provider availability. Exact provider-scoped - /// identifiers win before historical built-in API identifiers normalize - /// to their canonical model slug. - #[must_use] - pub fn get_on_provider(&self, provider: &ProviderId, selector: &str) -> Option<&Model> { - let provider = self.provider(provider)?; - let lookup = |selector: &str| { - self.provider_selector_index - .get(&(provider.id.clone(), selector.to_string())) - }; - let index = - lookup(selector).or_else(|| lookup(&normalize_legacy_builtin_selector(selector)))?; - self.models.get(*index) - } - - /// Look up a canonical offering by its composite identity. - #[must_use] - pub fn offering(&self, provider: &ProviderId, model: &ModelId) -> Option<&Model> { - let provider = self.provider(provider)?; - self.offering_index - .get(&(provider.id.clone(), model.clone())) - .and_then(|idx| self.models.get(*idx)) - } - - /// Look up a provider by ID or alias, failing when the catalog has no such - /// provider. - pub fn require_provider( - &self, - provider: &ProviderId, - ) -> Result<&CatalogProvider, ModelSelectionError> { - self.provider(provider) - .ok_or_else(|| ModelSelectionError::UnknownProvider { - provider: provider.clone(), - }) - } - - /// Canonicalize a provider name or alias to its catalog ID. - pub fn provider_id(&self, name: &str) -> Result { - Ok(self.require_provider(&ProviderId::from(name))?.id.clone()) - } - - /// Resolve a canonical ID, alias, or API ID on exactly one provider. - pub fn resolve_on_provider( - &self, - provider: &ProviderId, - selector: &str, - ) -> Result<&Model, ModelSelectionError> { - let provider = self.require_provider(provider)?; - if let Some(model) = self.get_on_provider(&provider.id, selector) { - return Ok(model); - } - Err(ModelSelectionError::UnknownSelectorOnProvider { - selector: selector.to_string(), - provider: provider.id.clone(), - }) - } - - /// Select one concrete offering for a selector and ready-provider - /// snapshot. - /// - /// Historical built-in API identifiers normalize to their canonical model - /// slug before selection. - /// - /// An explicit provider is a pin and also permits that provider's API IDs. - /// Unqualified selection checks canonical IDs before aliases and uses the - /// catalog's provider priority ordering. - pub fn select<'a>( - &'a self, - selector: &str, - explicit_provider: Option<&ProviderId>, - eligible_providers: &HashSet, - ) -> Result<&'a Model, ModelSelectionError> { - let eligible = eligible_providers - .iter() - .filter_map(|provider| self.provider(provider).map(|provider| provider.id.clone())) - .collect::>(); - - if let Some(explicit_provider) = explicit_provider { - let provider = self.require_provider(explicit_provider)?; - if !eligible.contains(&provider.id) { - return Err(ModelSelectionError::ProviderUnavailable { - provider: provider.id.clone(), - }); - } - return self.resolve_on_provider(&provider.id, selector); - } - - let normalized_selector = normalize_legacy_builtin_selector(selector); - let canonical = self - .canonical_candidates - .get(&ModelId::new(normalized_selector.as_ref())); - if let Some(indices) = canonical { - if let Some(model) = indices - .iter() - .filter_map(|idx| self.models.get(*idx)) - .find(|model| eligible.contains(&model.provider)) - { - return Ok(model); - } - } - - let aliases = self.alias_candidates.get(normalized_selector.as_ref()); - if let Some(indices) = aliases { - if let Some(model) = indices - .iter() - .filter_map(|idx| self.models.get(*idx)) - .find(|model| eligible.contains(&model.provider)) - { - return Ok(model); - } - } - - let mut providers = Vec::new(); - for index in canonical - .into_iter() - .flatten() - .chain(aliases.into_iter().flatten()) - { - let Some(model) = self.models.get(*index) else { - continue; - }; - if !providers.contains(&model.provider) { - providers.push(model.provider.clone()); - } - } - if !providers.is_empty() { - return Err(ModelSelectionError::NoEligibleOffering { - selector: selector.to_string(), - providers, - }); - } - - Err(ModelSelectionError::UnknownSelector { - selector: selector.to_string(), - }) - } - - #[must_use] - pub fn all_provider_ids(&self) -> HashSet { - self.providers - .iter() - .map(|provider| provider.id.clone()) - .collect() - } - - /// Canonicalize a model selector to its catalog model ID, preferring the - /// given provider's offering. Unknown selectors pass through verbatim. - /// - /// Model-keyed fallback chains are written and read through this one - /// function so a configured chain key and a dispatch-time lookup cannot - /// silently disagree. - #[must_use] - pub fn canonical_model_id(&self, provider: &ProviderId, selector: &str) -> String { - self.get_on_provider(provider, selector).map_or_else( - || { - self.select(selector, None, &self.all_provider_ids()) - .map_or_else(|_| selector.to_string(), |offering| offering.id.to_string()) - }, - |offering| offering.id.to_string(), - ) - } - - /// Select the highest-priority default model on an eligible provider. - pub fn select_default( - &self, - eligible_providers: &HashSet, - ) -> Result<&Model, ModelSelectionError> { - let eligible = eligible_providers - .iter() - .filter_map(|provider| self.provider(provider).map(|provider| provider.id.clone())) - .collect::>(); - if let Some(model) = self - .models - .iter() - .find(|model| model.default && eligible.contains(&model.provider)) - { - return Ok(model); - } - let mut providers = self - .models - .iter() - .filter(|model| model.default) - .map(|model| model.provider.clone()) - .collect::>(); - providers.sort(); - providers.dedup(); - Err(ModelSelectionError::NoDefaultModel { providers }) - } - - /// Canonicalize a provider ID or alias and require it to be in the - /// eligible snapshot. - pub fn ready_provider( - &self, - provider: &ProviderId, - eligible_providers: &HashSet, - ) -> Result { - let provider = self.require_provider(provider)?; - let ready = eligible_providers.iter().any(|eligible| { - self.provider(eligible) - .is_some_and(|eligible| eligible.id == provider.id) - }); - if !ready { - return Err(ModelSelectionError::ProviderUnavailable { - provider: provider.id.clone(), - }); - } - Ok(provider.id.clone()) - } - - /// Resolve an optional selector to one provider/model pair, applying the - /// passthrough policy shared by every dispatch boundary: - /// - /// - A selector known to the catalog resolves to its canonical offering. - /// - An unknown selector pinned to a provider passes through verbatim on - /// that provider. - /// - An unqualified unknown selector passes through on the default - /// provider. - /// - No selector picks the default offering (of the pinned provider, when - /// one is given). - pub fn resolve_selection( - &self, - selector: Option<&str>, - explicit_provider: Option<&ProviderId>, - eligible_providers: &HashSet, - ) -> Result { - let Some(selector) = selector else { - let eligible = match explicit_provider { - Some(provider) => { - HashSet::from([self.ready_provider(provider, eligible_providers)?]) - } - None => eligible_providers.clone(), - }; - let offering = self.select_default(&eligible)?; - return Ok(SelectedModel { - provider: offering.provider.clone(), - model: offering.id.to_string(), - }); - }; - match self.select(selector, explicit_provider, eligible_providers) { - Ok(offering) => Ok(SelectedModel { - provider: offering.provider.clone(), - model: offering.id.to_string(), - }), - Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => { - Ok(SelectedModel { - provider, - model: selector.to_string(), - }) - } - Err(ModelSelectionError::UnknownSelector { .. }) => { - let default = self.select_default(eligible_providers)?; - Ok(SelectedModel { - provider: default.provider.clone(), - model: selector.to_string(), - }) - } - Err(error) => Err(error), - } - } - - /// Resolve a selection against a preferred provider snapshot, falling back - /// to every provider in the catalog only when the preferred set cannot - /// supply the requested provider or model. - /// - /// This is useful for readiness checks: ready providers remain preferred, - /// while a catalog-only offering can still be selected so the caller can - /// report why its provider is unavailable. Semantic failures such as an - /// unknown provider do not fall back. - pub fn resolve_selection_with_catalog_fallback( - &self, - selector: Option<&str>, - explicit_provider: Option<&ProviderId>, - preferred_providers: &HashSet, - ) -> Result { - match self.resolve_selection(selector, explicit_provider, preferred_providers) { - Ok(selected) => Ok(selected), - Err( - ModelSelectionError::ProviderUnavailable { .. } - | ModelSelectionError::NoEligibleOffering { .. } - | ModelSelectionError::NoDefaultModel { .. }, - ) => self.resolve_selection(selector, explicit_provider, &self.all_provider_ids()), - Err(error) => Err(error), - } - } - - #[must_use] - pub fn is_model_selector(&self, selector: &str) -> bool { - self.candidate_indices(selector).is_some() - } - - fn candidate_indices(&self, selector: &str) -> Option<&Vec> { - let selector = normalize_legacy_builtin_selector(selector); - self.canonical_candidates - .get(&ModelId::new(selector.as_ref())) - .or_else(|| self.alias_candidates.get(selector.as_ref())) - } - - #[must_use] - pub fn providers(&self) -> &[CatalogProvider] { - &self.providers - } - - #[must_use] - pub fn provider_summaries(&self, configured: &HashSet) -> Vec { - #[derive(Default)] - struct Stats { - model_count: u32, - default_model: Option, - } - - let mut stats_by_provider = HashMap::::new(); - for model in &self.models { - let stats = stats_by_provider.entry(model.provider.clone()).or_default(); - stats.model_count = stats.model_count.saturating_add(1); - if model.default { - stats.default_model = Some(model.id.to_string()); - } - } - - self.providers - .iter() - .map(|provider| { - let stats = stats_by_provider.remove(&provider.id).unwrap_or_default(); - Provider::from_catalog( - provider, - stats.model_count, - stats.default_model, - configured.contains(&provider.id), - ) - }) - .collect() - } - - #[must_use] - pub fn provider(&self, id: &ProviderId) -> Option<&CatalogProvider> { - let canonical = self.provider_aliases.get(id.as_str()).unwrap_or(id); - self.provider_index - .get(canonical) - .and_then(|idx| self.providers.get(*idx)) - } - - #[must_use] - pub fn provider_vault_secret_name(&self, id: &ProviderId) -> Option<&str> { - self.provider(id)?.vault_secret_name() - } - - #[must_use] - pub fn settings_for(&self, model: &Model) -> Option<&CatalogModelSettings> { - self.model_settings - .get(&(model.provider.clone(), model.id.clone())) - } - - /// Test-only shorthand for settings on the highest-priority enabled - /// offering. Production callers must retain the resolved offering and use - /// [`Catalog::settings_for`]. - #[cfg(test)] - #[must_use] - pub(crate) fn model_settings( - &self, - selector: impl AsRef, - ) -> Option<&CatalogModelSettings> { - self.get(selector.as_ref()) - .and_then(|model| self.settings_for(model)) - } - - #[must_use] - pub fn model_settings_on_provider( - &self, - provider: &ProviderId, - selector: &str, - ) -> Option<&CatalogModelSettings> { - let model = self.get_on_provider(provider, selector)?; - self.settings_for(model) - } - - #[must_use] - pub fn effective_agent_profile( - &self, - provider_id: &ProviderId, - model_id_or_alias: Option<&str>, - ) -> Option { - let provider = self.provider(provider_id)?; - let model_profile = model_id_or_alias - .and_then(|model_id| self.get_on_provider(&provider.id, model_id)) - .and_then(|model| self.settings_for(model)) - .map(|settings| settings.agent_profile); - Some(model_profile.unwrap_or(provider.agent_profile)) - } - - /// The codec a request for `model_id_or_alias` on `provider_id` speaks: - /// the model row's codec when one is configured, otherwise the - /// provider's. - #[must_use] - pub fn effective_codec( - &self, - provider_id: &ProviderId, - model_id_or_alias: Option<&str>, - ) -> Option { - let provider = self.provider(provider_id)?; - let model_codec = model_id_or_alias - .and_then(|model_id| self.get_on_provider(&provider.id, model_id)) - .and_then(|model| self.settings_for(model)) - .map(|settings| settings.codec); - Some(model_codec.unwrap_or(provider.codec)) - } - - /// The billing family for `model_id_or_alias` on `provider_id`: the model - /// row's policy when one is configured, otherwise the provider's (unknown - /// passthrough model ids keep the provider policy). - #[must_use] - pub fn effective_billing_policy( - &self, - provider_id: &ProviderId, - model_id_or_alias: Option<&str>, - ) -> Option { - let provider = self.provider(provider_id)?; - let model_policy = model_id_or_alias - .and_then(|model_id| self.get_on_provider(&provider.id, model_id)) - .and_then(|model| self.settings_for(model)) - .map(|settings| settings.billing_policy); - Some(model_policy.unwrap_or(provider.billing_policy)) - } - - /// List all models, optionally filtered by provider. - #[must_use] - pub fn list(&self, provider: Option<&ProviderId>) -> Vec<&Model> { - match provider { - None => self.models.iter().collect(), - Some(p) => { - let provider_id = self.provider(p).map_or(p, |provider| &provider.id); - self.models - .iter() - .filter(|m| &m.provider == provider_id) - .collect() - } - } - } - - /// The overall default model (first model marked `default` in catalog). - /// - /// # Panics - /// Panics if the catalog contains no default model. - #[must_use] - pub fn default_model(&self) -> &Model { - self.providers - .iter() - .find_map(|provider| self.default_for_provider(&provider.id)) - .or_else(|| self.models.iter().find(|m| m.default)) - .expect("catalog must contain at least one default model") - } - - /// The default model for a specific provider. - #[must_use] - pub fn default_for_provider(&self, p: &ProviderId) -> Option<&Model> { - let provider_id = self - .provider(p) - .map_or_else(|| p.clone(), |provider| provider.id.clone()); - self.models - .iter() - .find(|m| m.provider == provider_id && m.default) - } - - /// Small default model for a provider — the small/cheap utility model used - /// for metadata enrichment. `None` when the provider marks no small - /// default. Deliberately does not substitute the provider's normal - /// default, which is typically a large reasoning model: callers asking for - /// a small model give it a small token budget and a short timeout, and a - /// flagship model silently exceeds both. - #[must_use] - pub fn small_default_for_provider(&self, p: &ProviderId) -> Option<&Model> { - let provider_id = self.provider(p).map_or(p, |provider| &provider.id); - self.models - .iter() - .find(|m| &m.provider == provider_id && m.small_default) - } - - /// Default model for the best-available provider (based on API keys), - /// falling back to the global catalog default. - #[must_use] - #[expect( - clippy::disallowed_methods, - reason = "Catalog default selection intentionally checks provider API-key env refs." - )] - pub fn default_from_env(&self) -> &Model { - let configured = self - .providers - .iter() - .filter(|provider| { - provider.auth.as_ref().is_some_and(|auth| { - auth.credentials.iter().any(|credential| { - matches!(credential, CredentialRef::Env(name) if std::env::var(name).is_ok()) - }) - }) - }) - .map(|provider| provider.id.clone()) - .collect::>(); - self.default_for_configured_ids(&configured) - } - - /// Default model for the best-available built-in provider IDs, falling - /// back to the global catalog default. - #[must_use] - pub fn default_for_configured_ids(&self, configured: &[ProviderId]) -> &Model { - if configured.is_empty() { - return self.default_model(); - } - let configured = self.canonical_provider_ids(configured); - self.providers - .iter() - .filter(|provider| configured.contains(&provider.id)) - .find_map(|provider| self.default_for_provider(&provider.id)) - .unwrap_or_else(|| self.default_model()) - } - - /// Small default model for the best-available built-in provider IDs. - /// - /// Configured providers that mark no small default are skipped in favour - /// of a lower-priority provider that has one. Only when none of them does - /// is the ordinary default used. - #[must_use] - pub fn small_default_for_configured_ids(&self, configured: &[ProviderId]) -> &Model { - if configured.is_empty() { - return self.default_model(); - } - let configured = self.canonical_provider_ids(configured); - let mut fallback = None; - for model in self - .models - .iter() - .filter(|model| configured.contains(&model.provider)) - { - if model.small_default { - return model; - } - if model.default && fallback.is_none() { - fallback = Some(model); - } - } - fallback.unwrap_or_else(|| self.default_model()) - } - - fn canonical_provider_ids(&self, provider_ids: &[ProviderId]) -> HashSet { - provider_ids - .iter() - .filter_map(|id| self.provider(id).map(|provider| provider.id.clone())) - .collect() - } - - /// Probe model for a provider — the cheapest model suitable for - /// connectivity checks. Falls back to the provider's default when no - /// explicit override is configured. - #[must_use] - pub fn probe_for_provider(&self, p: &ProviderId) -> Option<&Model> { - let provider_id = self.provider(p).map_or(p, |provider| &provider.id); - if let Some(model) = self.models.iter().find(|model| { - &model.provider == provider_id - && self - .settings_for(model) - .is_some_and(|settings| settings.probe) - }) { - return Some(model); - } - self.default_for_provider(provider_id) - } - - /// Find the closest model on a target provider matching the reference's - /// capabilities. - /// - /// Hard-filters on `features.tools`, `features.vision`, and - /// `features.reasoning`. Among matches, picks the closest by - /// `costs.input_cost_per_mtok` (absolute diff). - #[must_use] - pub fn closest(&self, target: &ProviderId, reference: &Model) -> Option<&Model> { - let target = self - .provider(target) - .map_or(target, |provider| &provider.id); - self.models - .iter() - .filter(|m| { - &m.provider == target - && m.features.tools == reference.features.tools - && m.features.vision == reference.features.vision - && m.features.reasoning == reference.features.reasoning - }) - .min_by(|a, b| { - let ref_cost = reference.costs.input_cost_per_mtok.unwrap_or(0.0); - let cost_a = (a.costs.input_cost_per_mtok.unwrap_or(0.0) - ref_cost).abs(); - let cost_b = (b.costs.input_cost_per_mtok.unwrap_or(0.0) - ref_cost).abs(); - cost_a - .partial_cmp(&cost_b) - .unwrap_or(std::cmp::Ordering::Equal) - }) - } -} - -type ModelIndexes = ( - HashMap<(ProviderId, ModelId), usize>, - HashMap<(ProviderId, String), usize>, - HashMap>, - HashMap>, -); - -fn build_model_indexes(models: &[(Model, CatalogModelSettings)]) -> ModelIndexes { - let mut offering_index = HashMap::new(); - let mut provider_selector_index = HashMap::new(); - let mut canonical_candidates = HashMap::>::new(); - let mut alias_candidates = HashMap::>::new(); - for (idx, (model, settings)) in models.iter().enumerate() { - offering_index.insert((model.provider.clone(), model.id.clone()), idx); - provider_selector_index - .insert((model.provider.clone(), model.id.as_str().to_string()), idx); - canonical_candidates - .entry(model.id.clone()) - .or_default() - .push(idx); - for alias in &model.aliases { - provider_selector_index.insert((model.provider.clone(), alias.clone()), idx); - alias_candidates.entry(alias.clone()).or_default().push(idx); - } - provider_selector_index.insert((model.provider.clone(), settings.api_id.clone()), idx); - } - ( - offering_index, - provider_selector_index, - canonical_candidates, - alias_candidates, - ) -} - -fn normalize_catalog_settings( - mut settings: LlmCatalogSettings, - known: Option<&LlmCatalogSettings>, -) -> Result { - reject_scoped_provider_fields(&settings)?; - - let legacy_models = std::mem::take(&mut settings.models); - if legacy_models.is_empty() { - return Ok(settings); - } - let mut legacy_models = legacy_models.into_iter().collect::>(); - legacy_models.sort_by(|(left, _), (right, _)| left.cmp(right)); - - let mut index = LegacyModelIndex::default(); - index.add_settings(&settings); - if let Some(known) = known { - index.add_settings(known); - } - - for (legacy_id, mut model_settings) in legacy_models { - let explicit_provider = model_settings.provider.take(); - let (provider, model_id) = index.resolve(&legacy_id, explicit_provider.as_deref())?; - - if !settings.providers.contains_key(provider.as_str()) - && !known.is_some_and(|known| known.providers.contains_key(provider.as_str())) - { - return Err(CatalogBuildError::UnknownModelProvider { - model: legacy_id, - provider, - }); - } - - let provider_settings = settings.providers.entry(provider.to_string()).or_default(); - if provider_settings.models.contains_key(model_id.as_str()) { - return Err(LegacyModelError::DuplicateModel { - provider, - model: model_id, - } - .into()); - } - provider_settings - .models - .insert(model_id.into_inner(), model_settings); - } - Ok(settings) -} - -fn reject_scoped_provider_fields(settings: &LlmCatalogSettings) -> Result<(), LegacyModelError> { - for (provider, settings) in &settings.providers { - for (model, settings) in &settings.models { - if settings.provider.is_some() { - return Err(LegacyModelError::ScopedModelDeclaresProvider { - provider: ProviderId::new(provider.clone()), - model: ModelId::new(model.clone()), - }); - } - } - } - Ok(()) -} - -/// Failure to resolve a legacy top-level `[models.]` row onto its -/// provider. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum LegacyModelError { - #[error("failed to inspect the built-in model catalog: {message}")] - BuiltinCatalog { message: String }, - #[error( - "legacy built-in model identifier '{identifier}' cannot be used as a canonical model ID under provider '{provider}'; use '{model}'" - )] - LegacyIdentifierAsModelId { - identifier: String, - provider: ProviderId, - model: ModelId, - }, - #[error("legacy model row '{model}' omits provider and does not match a unique known offering")] - UnknownModel { model: String }, - #[error( - "legacy model row '{model}' omits provider and matches multiple offerings: {candidates:?}" - )] - AmbiguousModel { - model: String, - candidates: Vec<(ProviderId, ModelId)>, - }, - #[error("legacy model selector '{selector}' is ambiguous on provider '{provider}': {models:?}")] - AmbiguousAlias { - provider: ProviderId, - selector: String, - models: Vec, - }, - #[error("provider-scoped model '{provider}/{model}' must not declare a provider field")] - ScopedModelDeclaresProvider { - provider: ProviderId, - model: ModelId, - }, - #[error( - "provider '{provider}' model '{model}' is defined through both provider-scoped and legacy top-level syntax" - )] - DuplicateModel { - provider: ProviderId, - model: ModelId, - }, -} - -/// Identifier/alias view used to resolve legacy top-level `[models.]` -/// rows onto their provider before provider-scoped settings merge. -/// -/// Both the settings-layer normalization in `fabro-config` and catalog-build -/// normalization here feed this index: local entries first, lower-precedence -/// known entries (e.g. the built-in catalog) after. Canonical IDs always win -/// over aliases; alias ties resolve to the first entry added. -#[derive(Debug, Default)] -pub struct LegacyModelIndex { - providers: Vec, -} - -#[derive(Debug)] -struct LegacyProviderEntry { - id: ProviderId, - aliases: Vec, - models: Vec, -} - -#[derive(Debug)] -struct LegacyModelEntry { - id: ModelId, - aliases: Vec, -} - -impl LegacyModelIndex { - pub fn add_provider( - &mut self, - id: ProviderId, - aliases: Vec, - models: impl IntoIterator)>, - ) { - self.providers.push(LegacyProviderEntry { - id, - aliases, - models: models - .into_iter() - .map(|(id, aliases)| LegacyModelEntry { id, aliases }) - .collect(), - }); - } - - fn add_settings(&mut self, settings: &LlmCatalogSettings) { - let mut provider_ids = settings.providers.keys().collect::>(); - provider_ids.sort_unstable(); - for provider_id in provider_ids { - let provider = &settings.providers[provider_id]; - let mut model_ids = provider.models.keys().collect::>(); - model_ids.sort_unstable(); - self.add_provider( - ProviderId::new(provider_id.clone()), - provider.aliases.clone().unwrap_or_default(), - model_ids.into_iter().map(|model_id| { - let model = &provider.models[model_id]; - ( - ModelId::new(model_id.clone()), - model.aliases.clone().unwrap_or_default(), - ) - }), - ); - } - } - - /// Append the built-in catalog as the lowest-precedence tier. Includes - /// disabled providers because config compatibility normalization happens - /// before runtime availability is known. - pub fn with_builtin(mut self) -> Result { - let builtin = - Catalog::builtin_settings().map_err(|error| LegacyModelError::BuiltinCatalog { - message: error.to_string(), - })?; - self.add_settings(&builtin); - Ok(self) - } - - /// Resolve one legacy row to its provider-scoped address. Historical - /// built-in identifiers normalize to their canonical slug and use their - /// historical provider when no explicit provider is present. Other - /// unknown explicit providers or model selectors pass through verbatim; - /// rows without an explicit provider must match exactly one known - /// offering. - pub fn resolve( - &self, - legacy_id: &str, - explicit_provider: Option<&str>, - ) -> Result<(ProviderId, ModelId), LegacyModelError> { - if let Some((historical_provider, model)) = legacy_builtin_model(legacy_id) { - let provider = explicit_provider.map_or(historical_provider, |explicit| { - self.canonical_provider(explicit) - .unwrap_or_else(|| ProviderId::new(explicit)) - }); - return Ok((provider, model)); - } - if let Some(explicit) = explicit_provider { - let provider = self - .canonical_provider(explicit) - .unwrap_or_else(|| ProviderId::new(explicit)); - let model = self - .canonical_model_on(&provider, legacy_id)? - .unwrap_or_else(|| ModelId::new(legacy_id)); - return Ok((provider, model)); - } - let candidates = self.candidates(legacy_id); - match candidates.as_slice() { - [(provider, model)] => Ok((provider.clone(), model.clone())), - [] => Err(LegacyModelError::UnknownModel { - model: legacy_id.to_string(), - }), - _ => Err(LegacyModelError::AmbiguousModel { - model: legacy_id.to_string(), - candidates, - }), - } - } - - fn canonical_provider(&self, selector: &str) -> Option { - self.providers - .iter() - .find(|provider| provider.id.as_str() == selector) - .or_else(|| { - self.providers - .iter() - .find(|provider| provider.aliases.iter().any(|alias| alias == selector)) - }) - .map(|provider| provider.id.clone()) - } - - fn canonical_model_on( - &self, - provider: &ProviderId, - selector: &str, - ) -> Result, LegacyModelError> { - let models = || { - self.providers - .iter() - .filter(|entry| entry.id == *provider) - .flat_map(|entry| entry.models.iter()) - }; - if models().any(|model| model.id.as_str() == selector) { - return Ok(Some(ModelId::new(selector))); - } - let matches = models() - .filter(|model| model.aliases.iter().any(|alias| alias == selector)) - .map(|model| model.id.clone()) - .collect::>(); - match matches.len() { - 0 => Ok(None), - 1 => Ok(matches.into_iter().next()), - _ => Err(LegacyModelError::AmbiguousAlias { - provider: provider.clone(), - selector: selector.to_string(), - models: matches.into_iter().collect(), - }), - } - } - - fn candidates(&self, selector: &str) -> Vec<(ProviderId, ModelId)> { - let canonical = self - .providers - .iter() - .filter(|entry| { - entry - .models - .iter() - .any(|model| model.id.as_str() == selector) - }) - .map(|entry| (entry.id.clone(), ModelId::new(selector))) - .collect::>(); - if !canonical.is_empty() { - return canonical.into_iter().collect(); - } - self.providers - .iter() - .flat_map(|entry| { - entry - .models - .iter() - .filter(|model| model.aliases.iter().any(|alias| alias == selector)) - .map(|model| (entry.id.clone(), model.id.clone())) - }) - .collect::>() - .into_iter() - .collect() - } -} - -/// Historical built-in catalog keys from before Fabro separated canonical -/// model slugs from provider API identifiers. The provider records the key's -/// original offering for legacy catalog-row normalization; runtime selectors -/// normalize to the model slug and use normal provider-aware selection. -const LEGACY_BUILTIN_MODEL_IDENTIFIERS: &[(&str, &str, &str)] = &[ - ("openai.gpt-5.5", "bedrock-openai", "gpt-5.5"), - ("openai.gpt-5.4", "bedrock-openai", "gpt-5.4"), - ( - "us.anthropic.claude-sonnet-4-6", - "bedrock", - "claude-sonnet-4-6", - ), - ("us.anthropic.claude-opus-4-8", "bedrock", "claude-opus-4-8"), - ( - "us.anthropic.claude-haiku-4-5", - "bedrock", - "claude-haiku-4-5", - ), - ("openai.gpt-oss-120b", "bedrock", "gpt-oss-120b"), - ("openai.gpt-oss-20b", "bedrock", "gpt-oss-20b"), - ("amazon.nova-2-lite", "bedrock", "nova-2-lite"), - ("meta.llama4-maverick", "bedrock", "llama-4-maverick"), - ("mistral.mistral-large-3", "bedrock", "mistral-large-3"), - ("mistral.devstral-2", "bedrock", "devstral-2"), - ("deepseek.v3-2", "bedrock", "deepseek-v3.2"), - ("moonshotai.kimi-k2.5", "bedrock", "kimi-k2.5"), - ("zai.glm-5", "bedrock", "glm-5"), - ("minimax.minimax-m2.5", "bedrock", "minimax-m2.5"), - ("nvidia.nemotron-3-super", "bedrock", "nemotron-3-super"), - ("us.anthropic.claude-fable-5", "bedrock", "claude-fable-5"), - ("anthropic/claude-fable-5", "openrouter", "claude-fable-5"), - ("anthropic/claude-opus-4-8", "openrouter", "claude-opus-4-8"), - ("anthropic/claude-opus-4-7", "openrouter", "claude-opus-4-7"), - ( - "anthropic/claude-sonnet-4-6", - "openrouter", - "claude-sonnet-4-6", - ), - ( - "anthropic/claude-haiku-4-5", - "openrouter", - "claude-haiku-4-5", - ), - ("openai/gpt-5.6-sol", "openrouter", "gpt-5.6-sol"), - ("openai/gpt-5.6-terra", "openrouter", "gpt-5.6-terra"), - ("openai/gpt-5.6-luna", "openrouter", "gpt-5.6-luna"), - ("openai/gpt-5.4", "openrouter", "gpt-5.4"), - ("openai/gpt-5.5", "openrouter", "gpt-5.5"), - ( - "google/gemini-3.1-pro-preview", - "openrouter", - "gemini-3.1-pro-preview", - ), - ("google/gemini-3.5-flash", "openrouter", "gemini-3.5-flash"), - ("xiaomi/mimo-v2.5-pro", "openrouter", "mimo-v2.5-pro"), - ("minimax/minimax-m2.7", "openrouter", "minimax-m2.7"), - ("deepseek/deepseek-v4-pro", "openrouter", "deepseek-v4-pro"), - ( - "deepseek/deepseek-v4-flash", - "openrouter", - "deepseek-v4-flash", - ), - ("moonshotai/kimi-k2.6", "openrouter", "kimi-k2.6"), - ("moonshotai/kimi-k3", "openrouter", "kimi-k3"), - ("poolside/laguna-s-2.1", "openrouter", "laguna-s-2.1"), - ("poolside/laguna-xs-2.1", "openrouter", "laguna-xs-2.1"), - ("qwen/qwen3-coder", "openrouter", "qwen3-coder"), - ("qwen/qwen3.6-flash", "openrouter", "qwen3.6-flash"), - ("z-ai/glm-5.2", "openrouter", "glm-5.2"), - ("z-ai/glm-4.6", "openrouter", "glm-4.6"), - ( - "nvidia/nemotron-3-super-120b-a12b", - "openrouter", - "nemotron-3-super-120b-a12b", - ), - ("mistralai/devstral-2512", "openrouter", "devstral-2512"), -]; - -/// Return the historical provider and canonical model slug for a legacy -/// built-in catalog key. -#[must_use] -pub fn legacy_builtin_model(identifier: &str) -> Option<(ProviderId, ModelId)> { - LEGACY_BUILTIN_MODEL_IDENTIFIERS - .iter() - .find(|(legacy, _, _)| *legacy == identifier) - .map(|(_, provider, model)| (ProviderId::new(*provider), ModelId::new(*model))) -} - -fn normalize_legacy_builtin_selector(selector: &str) -> Cow<'_, str> { - legacy_builtin_model(selector).map_or_else( - || Cow::Borrowed(selector), - |(_, model)| Cow::Owned(model.into_inner()), - ) -} - -fn merge_catalog_settings( - higher: LlmCatalogSettings, - mut fallback: LlmCatalogSettings, -) -> LlmCatalogSettings { - for (id, provider) in higher.providers { - let provider = match fallback.providers.remove(&id) { - Some(fallback_provider) => merge_provider_settings(provider, fallback_provider), - None => provider, - }; - fallback.providers.insert(id, provider); - } - - fallback -} - -fn merge_provider_settings( - mut higher: ProviderCatalogSettings, - mut fallback: ProviderCatalogSettings, -) -> ProviderCatalogSettings { - for (id, model) in higher.models.drain() { - let model = match fallback.models.remove(&id) { - Some(fallback_model) => merge_model_settings(model, fallback_model), - None => model, - }; - fallback.models.insert(id, model); - } - ProviderCatalogSettings { - display_name: higher.display_name.or(fallback.display_name), - adapter: higher.adapter.or(fallback.adapter), - codec: higher.codec.or(fallback.codec), - agent_profile: higher.agent_profile.or(fallback.agent_profile), - auth: higher.auth.or(fallback.auth), - billing_policy: higher.billing_policy.or(fallback.billing_policy), - api_key_url: higher.api_key_url.or(fallback.api_key_url), - base_url: higher.base_url.or(fallback.base_url), - extra_headers: higher.extra_headers.or(fallback.extra_headers), - priority: higher.priority.or(fallback.priority), - enabled: higher.enabled.or(fallback.enabled), - aliases: higher.aliases.or(fallback.aliases), - models: fallback.models, - } -} - -fn merge_model_settings( - higher: ModelCatalogSettings, - fallback: ModelCatalogSettings, -) -> ModelCatalogSettings { - ModelCatalogSettings { - provider: higher.provider.or(fallback.provider), - api_id: higher.api_id.or(fallback.api_id), - codec: higher.codec.or(fallback.codec), - billing_policy: higher.billing_policy.or(fallback.billing_policy), - agent_profile: higher.agent_profile.or(fallback.agent_profile), - display_name: higher.display_name.or(fallback.display_name), - family: higher.family.or(fallback.family), - training: higher.training.or(fallback.training), - knowledge_cutoff: higher.knowledge_cutoff.or(fallback.knowledge_cutoff), - default: higher.default.or(fallback.default), - small_default: higher.small_default.or(fallback.small_default), - probe: higher.probe.or(fallback.probe), - enabled: higher.enabled.or(fallback.enabled), - aliases: higher.aliases.or(fallback.aliases), - estimated_output_tps: higher - .estimated_output_tps - .or(fallback.estimated_output_tps), - limits: merge_optional( - higher.limits, - fallback.limits, - merge_model_limits_settings, - ), - features: merge_optional( - higher.features, - fallback.features, - merge_model_features_settings, - ), - controls: merge_optional( - higher.controls, - fallback.controls, - merge_model_controls_settings, - ), - costs: merge_optional(higher.costs, fallback.costs, merge_model_cost_table), - } -} - -fn merge_optional(higher: Option, fallback: Option, merge: fn(&T, &T) -> T) -> Option { - match (higher, fallback) { - (Some(higher), Some(fallback)) => Some(merge(&higher, &fallback)), - (Some(higher), None) => Some(higher), - (None, fallback) => fallback, - } -} - -fn merge_model_limits_settings( - higher: &SettingsModelLimits, - fallback: &SettingsModelLimits, -) -> SettingsModelLimits { - SettingsModelLimits { - context_window: higher.context_window.or(fallback.context_window), - max_output: higher.max_output.or(fallback.max_output), - } -} - -fn merge_model_features_settings( - higher: &SettingsModelFeatures, - fallback: &SettingsModelFeatures, -) -> SettingsModelFeatures { - SettingsModelFeatures { - tools: higher.tools.or(fallback.tools), - vision: higher.vision.or(fallback.vision), - reasoning: higher.reasoning.or(fallback.reasoning), - reasoning_by_default: higher - .reasoning_by_default - .or(fallback.reasoning_by_default), - reasoning_effort: higher.reasoning_effort.or(fallback.reasoning_effort), - prompt_cache: higher.prompt_cache.or(fallback.prompt_cache), - cache_control_breakpoints: higher - .cache_control_breakpoints - .or(fallback.cache_control_breakpoints), - sampling_params: higher.sampling_params.or(fallback.sampling_params), - } -} - -fn merge_model_controls_settings( - higher: &SettingsModelControls, - fallback: &SettingsModelControls, -) -> SettingsModelControls { - SettingsModelControls { - reasoning_effort: higher - .reasoning_effort - .clone() - .or_else(|| fallback.reasoning_effort.clone()), - speed: higher.speed.clone().or_else(|| fallback.speed.clone()), - } -} - -fn merge_model_cost_table( - higher: &SettingsModelCostTable, - fallback: &SettingsModelCostTable, -) -> SettingsModelCostTable { - SettingsModelCostTable { - base: merge_cost_rates(&higher.base, &fallback.base), - speed: higher.speed.clone().or_else(|| fallback.speed.clone()), - } -} - -fn merge_cost_rates(higher: &CostRates, fallback: &CostRates) -> CostRates { - CostRates { - input_cost_per_mtok: higher.input_cost_per_mtok.or(fallback.input_cost_per_mtok), - output_cost_per_mtok: higher - .output_cost_per_mtok - .or(fallback.output_cost_per_mtok), - cache_input_cost_per_mtok: higher - .cache_input_cost_per_mtok - .or(fallback.cache_input_cost_per_mtok), - } -} - -fn build_providers( - settings: &LlmCatalogSettings, -) -> Result, CatalogBuildError> { - let mut providers = Vec::new(); - let mut ids = settings.providers.keys().cloned().collect::>(); - ids.sort_unstable(); - for id in ids { - let provider_id = ProviderId::from(id.clone()); - let settings = settings - .providers - .get(&id) - .expect("provider ID came from settings map keys"); - if settings.enabled == Some(false) { - continue; - } - - let adapter_name = - required_provider_string(&provider_id, settings.adapter.as_ref(), "adapter")?; - let adapter = AdapterKind::from_str(&adapter_name).map_err(|_| { - CatalogBuildError::UnknownAdapter { - provider: provider_id.clone(), - adapter: adapter_name, - } - })?; - let defaults = adapter_defaults(adapter); - let codec = resolve_provider_codec(&provider_id, adapter, settings.codec)?; - let agent_profile = settings.agent_profile.unwrap_or(defaults.agent_profile); - let auth = settings.auth.clone(); - validate_provider_auth(&provider_id, adapter, auth.as_ref())?; - - providers.push(CatalogProvider { - id: provider_id, - display_name: settings.display_name.clone().unwrap_or_else(|| id.clone()), - adapter, - codec, - agent_profile, - auth, - billing_policy: settings.billing_policy.unwrap_or(defaults.billing_policy), - api_key_url: settings.api_key_url.clone(), - base_url: settings.base_url.clone(), - extra_headers: settings.extra_headers.clone().unwrap_or_default(), - priority: settings.priority.unwrap_or_default(), - aliases: settings.aliases.clone().unwrap_or_default(), - }); - } - Ok(providers) -} - -#[derive(Debug, Clone, Copy)] -struct AdapterDefaults { - agent_profile: AgentProfileKind, - billing_policy: BillingPolicy, -} - -fn adapter_defaults(adapter: AdapterKind) -> AdapterDefaults { - match adapter { - // Bedrock hosts Anthropic-family models, so it shares the Anthropic - // agent profile and billing policy by default. - AdapterKind::Anthropic | AdapterKind::Bedrock => AdapterDefaults { - agent_profile: AgentProfileKind::Anthropic, - billing_policy: BillingPolicy::Anthropic, - }, - AdapterKind::OpenAi | AdapterKind::OpenAiCompatible => AdapterDefaults { - agent_profile: AgentProfileKind::OpenAi, - billing_policy: BillingPolicy::OpenAi, - }, - AdapterKind::Gemini => AdapterDefaults { - agent_profile: AgentProfileKind::Gemini, - billing_policy: BillingPolicy::Gemini, - }, - } -} - -/// Resolve a provider row's codec, rejecting pairings outside the adapter's -/// default so no new route combination is silently enabled by configuration. -fn resolve_provider_codec( - provider: &ProviderId, - adapter: AdapterKind, - configured: Option, -) -> Result { - let expected = CodecKind::default_for(adapter); - match configured { - Some(codec) if codec != expected => Err(CatalogBuildError::UnsupportedProviderCodec { - provider: provider.clone(), - adapter, - codec, - expected, - }), - _ => Ok(expected), - } -} - -/// Resolve a model row's codec against its provider, with the same -/// only-the-default-pairing rule as [`resolve_provider_codec`]. -fn resolve_model_codec( - model_id: &str, - provider: &CatalogProvider, - configured: Option, -) -> Result { - let expected = CodecKind::default_for(provider.adapter); - match configured { - Some(codec) if codec != expected => Err(CatalogBuildError::UnsupportedModelCodec { - model: model_id.to_string(), - adapter: provider.adapter, - codec, - expected, - }), - Some(codec) => Ok(codec), - None => Ok(provider.codec), - } -} - -fn validate_provider_auth( - provider: &ProviderId, - adapter: AdapterKind, - auth: Option<&ProviderAuthConfig>, -) -> Result<(), CatalogBuildError> { - match auth { - Some(auth) if auth.credentials.is_empty() => { - Err(CatalogBuildError::EmptyApiKeyCredentials { - provider: provider.clone(), - }) - } - Some(auth) - if adapter != AdapterKind::Bedrock - && auth - .credentials - .iter() - .any(|credential| matches!(credential, CredentialRef::AwsSigv4)) => - { - Err(CatalogBuildError::UnsupportedAwsSigv4Credential { - provider: provider.clone(), - adapter, - }) - } - _ => Ok(()), - } -} - -fn build_provider_aliases( - providers: &[CatalogProvider], -) -> Result, CatalogBuildError> { - let mut identifiers = BTreeMap::::new(); - for provider in providers { - register_provider_identifier( - &mut identifiers, - provider.id.as_str().to_string(), - provider.id.clone(), - )?; - for alias in &provider.aliases { - register_provider_identifier(&mut identifiers, alias.clone(), provider.id.clone())?; - } - } - Ok(identifiers.into_iter().collect()) -} - -fn build_model( - model_id: &str, - settings: &ModelCatalogSettings, - provider: &CatalogProvider, -) -> Result<(Model, CatalogModelSettings), CatalogBuildError> { - let family = required_model_string(model_id, settings.family.as_ref(), "family")?; - let display_name = - required_model_string(model_id, settings.display_name.as_ref(), "display_name")?; - let limits = settings - .limits - .as_ref() - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model_id.to_string(), - field: "limits", - })?; - let context_window = - limits - .context_window - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model_id.to_string(), - field: "limits.context_window", - })?; - let features = - settings - .features - .as_ref() - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model_id.to_string(), - field: "features", - })?; - let model_features = build_model_features(model_id, features)?; - let reasoning_by_default = features - .reasoning_by_default - .unwrap_or_else(|| model_features.supports_reasoning_effort()); - if reasoning_by_default && !model_features.reasoning { - return Err(CatalogBuildError::DefaultReasoningWithoutReasoning { - model: model_id.to_string(), - }); - } - let controls = build_model_controls(model_id, &model_features, settings)?; - let costs = build_model_costs(settings.costs.as_ref()); - let speed_costs = build_speed_costs(model_id, settings.costs.as_ref(), &controls)?; - - let model = Model { - id: ModelId::new(model_id), - provider: provider.id.clone(), - family, - display_name, - limits: ModelLimits { - context_window, - max_output: limits.max_output, - }, - training: settings.training.clone(), - knowledge_cutoff: settings.knowledge_cutoff.clone(), - features: model_features, - controls: ModelControls { - reasoning_effort: controls.reasoning_effort.clone(), - }, - costs, - estimated_output_tps: settings.estimated_output_tps, - aliases: settings.aliases.clone().unwrap_or_default(), - default: settings.default.unwrap_or_default(), - small_default: settings.small_default.unwrap_or_default(), - configured: false, - }; - let api_id = match settings.api_id.as_ref() { - Some(api_id) if api_id.is_empty() => { - return Err(CatalogBuildError::EmptyModelApiId { - provider: provider.id.clone(), - model: ModelId::new(model_id), - }); - } - Some(api_id) => api_id.clone(), - None => model_id.to_string(), - }; - let catalog_settings = CatalogModelSettings { - api_id, - codec: resolve_model_codec(model_id, provider, settings.codec)?, - billing_policy: settings.billing_policy.unwrap_or(provider.billing_policy), - agent_profile: settings.agent_profile.unwrap_or(provider.agent_profile), - reasoning_by_default, - controls, - speed_costs, - probe: settings.probe.unwrap_or_default(), - }; - Ok((model, catalog_settings)) -} - -fn warn_multiple_probe_models(models_with_settings: &[(Model, CatalogModelSettings)]) { - let mut probes_by_provider = BTreeMap::>::new(); - for (model, settings) in models_with_settings { - if settings.probe { - probes_by_provider - .entry(model.provider.clone()) - .or_default() - .push(model.id.to_string()); - } - } - - for (provider, models) in probes_by_provider { - if models.len() > 1 { - warn!( - provider = %provider, - models = ?models, - "Multiple probe models configured for provider" - ); - } - } -} - -fn build_model_features( - model_id: &str, - features: &SettingsModelFeatures, -) -> Result { - let reasoning = features - .reasoning - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model_id.to_string(), - field: "features.reasoning", - })?; - let reasoning_effort = features.reasoning_effort.unwrap_or_default(); - if !reasoning && reasoning_effort != ReasoningEffortFeature::None { - return Err(CatalogBuildError::ReasoningEffortWithoutReasoning { - model: model_id.to_string(), - }); - } - let prompt_cache = features.prompt_cache.unwrap_or_default(); - let cache_control_breakpoints = features.cache_control_breakpoints.unwrap_or_default(); - if cache_control_breakpoints && !prompt_cache { - return Err( - CatalogBuildError::CacheControlBreakpointsWithoutPromptCache { - model: model_id.to_string(), - }, - ); - } - - Ok(ModelFeatures { - tools: features - .tools - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model_id.to_string(), - field: "features.tools", - })?, - vision: features - .vision - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model_id.to_string(), - field: "features.vision", - })?, - reasoning, - reasoning_effort, - prompt_cache, - cache_control_breakpoints, - sampling_params: features.sampling_params.unwrap_or(true), - }) -} - -fn build_model_costs(costs: Option<&SettingsModelCostTable>) -> ModelCosts { - let base = costs.map(|costs| &costs.base); - ModelCosts { - input_cost_per_mtok: base.and_then(|base| base.input_cost_per_mtok), - output_cost_per_mtok: base.and_then(|base| base.output_cost_per_mtok), - cache_input_cost_per_mtok: base.and_then(|base| base.cache_input_cost_per_mtok), - } -} - -fn build_speed_costs( - model_id: &str, - costs: Option<&SettingsModelCostTable>, - controls: &CatalogModelControls, -) -> Result, CatalogBuildError> { - let mut speed_costs = HashMap::new(); - let Some(costs) = costs.and_then(|costs| costs.speed.as_ref()) else { - return Ok(speed_costs); - }; - for (speed, rates) in costs { - let speed = parse_speed(model_id, speed)?; - if !controls.speed.contains(&speed) { - return Err(CatalogBuildError::UndeclaredSpeedCost { - model: model_id.to_string(), - speed, - }); - } - speed_costs.insert(speed, cost_rates_to_model_costs(rates)); - } - Ok(speed_costs) -} - -fn cost_rates_to_model_costs(rates: &CostRates) -> ModelCosts { - ModelCosts { - input_cost_per_mtok: rates.input_cost_per_mtok, - output_cost_per_mtok: rates.output_cost_per_mtok, - cache_input_cost_per_mtok: rates.cache_input_cost_per_mtok, - } -} - -fn build_model_controls( - model_id: &str, - features: &ModelFeatures, - settings: &ModelCatalogSettings, -) -> Result { - let supports_native_reasoning_effort = features.supports_reasoning_effort(); - let reasoning_effort = match settings - .controls - .as_ref() - .and_then(|controls| controls.reasoning_effort.as_ref()) - { - Some(values) if !features.reasoning && !values.is_empty() => { - return Err(CatalogBuildError::ReasoningEffortControlsWithoutReasoning { - model: model_id.to_string(), - }); - } - Some(values) if values.is_empty() && supports_native_reasoning_effort => { - return Err(CatalogBuildError::EmptyReasoningEffortControls { - model: model_id.to_string(), - }); - } - Some(values) => values - .iter() - .map(|value| parse_reasoning_effort(model_id, value)) - .collect::, _>>()?, - None if supports_native_reasoning_effort => ReasoningEffort::VARIANTS.to_vec(), - None => Vec::new(), - }; - - let speed = settings - .controls - .as_ref() - .and_then(|controls| controls.speed.as_ref()) - .map(|values| { - values - .iter() - .map(|value| parse_speed_control(model_id, value)) - .collect::, _>>() - }) - .transpose()? - .unwrap_or_default(); - - Ok(CatalogModelControls { - reasoning_effort, - speed, - }) -} - -fn parse_reasoning_effort( - model_id: &str, - value: &str, -) -> Result { - ReasoningEffort::from_str(value).map_err(|source| CatalogBuildError::InvalidReasoningEffort { - model: model_id.to_string(), - value: value.to_string(), - source, - }) -} - -fn parse_speed(model_id: &str, value: &str) -> Result { - Speed::from_str(value).map_err(|source| CatalogBuildError::InvalidSpeed { - model: model_id.to_string(), - value: value.to_string(), - source, - }) -} - -fn parse_speed_control(model_id: &str, value: &str) -> Result { - let speed = parse_speed(model_id, value)?; - if speed == Speed::Standard { - return Err(CatalogBuildError::StandardSpeedControl { - model: model_id.to_string(), - }); - } - Ok(speed) -} - -fn required_provider_string( - provider: &ProviderId, - value: Option<&String>, - field: &'static str, -) -> Result { - value - .filter(|value| !value.is_empty()) - .cloned() - .ok_or_else(|| CatalogBuildError::MissingProviderField { - provider: provider.clone(), - field, - }) -} - -fn required_model_string( - model: &str, - value: Option<&String>, - field: &'static str, -) -> Result { - value - .filter(|value| !value.is_empty()) - .cloned() - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model.to_string(), - field, - }) -} - -fn register_provider_identifier( - identifiers: &mut BTreeMap, - identifier: String, - owner: ProviderId, -) -> Result<(), CatalogBuildError> { - match identifiers.get(&identifier) { - Some(existing) if existing != &owner => { - Err(CatalogBuildError::DuplicateProviderIdentifier { - identifier, - first: existing.clone(), - second: owner, - }) - } - _ => { - identifiers.insert(identifier, owner); - Ok(()) - } - } -} - -fn register_model_identifier( - identifiers: &mut BTreeMap, - identifier: String, - owner: ModelId, - provider: &ProviderId, -) -> Result<(), CatalogBuildError> { - match identifiers.get(&identifier) { - Some(existing) if existing != &owner => { - Err(CatalogBuildError::DuplicateProviderModelSelector { - provider: provider.clone(), - selector: identifier, - first: existing.clone(), - second: owner, - }) - } - _ => { - identifiers.insert(identifier, owner); - Ok(()) - } - } -} - -fn validate_builtin_fragment( - path: &str, - fragment: &LlmCatalogSettings, -) -> Result<(), CatalogBuildError> { - if fragment.providers.len() != 1 { - return Err(CatalogBuildError::InvalidBuiltinProviderCount { - path: path.to_string(), - }); - } - let expected = path - .strip_suffix(".toml") - .unwrap_or(path) - .rsplit('/') - .next() - .unwrap_or(path); - let actual = fragment - .providers - .keys() - .next() - .expect("provider count was checked"); - if actual != expected { - return Err(CatalogBuildError::BuiltinProviderIdMismatch { - path: path.to_string(), - expected: expected.to_string(), - actual: actual.clone(), - }); - } - - for (model, settings) in &fragment.models { - let Some(provider) = settings.provider.as_ref() else { - continue; - }; - if provider != expected { - return Err(CatalogBuildError::BuiltinModelProviderMismatch { - path: path.to_string(), - model: model.clone(), - expected: expected.to_string(), - actual: provider.clone(), - }); - } - } - Ok(()) -} - -fn provider_order(left: &CatalogProvider, right: &CatalogProvider) -> std::cmp::Ordering { - right - .priority - .cmp(&left.priority) - .then_with(|| left.id.cmp(&right.id)) -} - -#[cfg(test)] -mod tests { - use strum::VariantArray; - - use super::*; - use crate::adapter::AdapterKind; - use crate::reasoning::ReasoningEffort; - use crate::{AgentProfileKind, ProviderId, Speed}; - - fn minimal_settings(source: &str) -> LlmCatalogSettings { - toml::from_str(source).expect("fixture should parse as an LLM settings layer") - } - - fn portable_model_catalog() -> Catalog { - Catalog::from_settings(&minimal_settings( - 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", "portable"] -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 = true - -[providers.openrouter] -display_name = "OpenRouter" -adapter = "openai_compatible" -agent_profile = "openai" -priority = 25 - -[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", "portable"] -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 = true -"#, - )) - .expect("portable model fixture should build") - } - - const BEDROCK_SIGV4_LAYER: &str = r#" -[providers.bedrock] -adapter = "bedrock" -base_url = "https://bedrock-runtime.eu-west-1.amazonaws.com" - -[providers.bedrock.auth] -credentials = ["aws_sigv4"] - -[models."bedrock-sonnet"] -provider = "bedrock" -api_id = "anthropic.claude-sonnet-4-6" -display_name = "Bedrock Sonnet" -family = "claude-4" -default = true - -[models."bedrock-sonnet".limits] -context_window = 200000 -max_output = 64000 - -[models."bedrock-sonnet".features] -tools = true -vision = true -reasoning = true -"#; - - #[test] - fn provider_parses_bedrock_base_url_and_sigv4_credential() { - let catalog = Catalog::from_settings(&minimal_settings(BEDROCK_SIGV4_LAYER)).unwrap(); - let provider = catalog.provider(&ProviderId::from("bedrock")).unwrap(); - assert_eq!( - provider.base_url.as_deref(), - Some("https://bedrock-runtime.eu-west-1.amazonaws.com") - ); - assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ - CredentialRef::AwsSigv4 - ]); - // Bedrock inherits the Anthropic agent profile and billing by default. - assert_eq!(provider.agent_profile, AgentProfileKind::Anthropic); - assert_eq!(provider.billing_policy, BillingPolicy::Anthropic); - } - - #[test] - fn aws_sigv4_credential_round_trips() { - assert_eq!( - "aws_sigv4".parse::().unwrap(), - CredentialRef::AwsSigv4 - ); - assert_eq!(CredentialRef::AwsSigv4.to_string(), "aws_sigv4"); - } - - // ---- Catalog struct tests ---- - - #[test] - fn from_builtin_matches_builtin_catalog() { - let catalog = Catalog::from_builtin().expect("built-in catalog should build"); - - assert_eq!( - catalog.get("sonnet").map(|model| model.id.as_str()), - Catalog::builtin() - .get("sonnet") - .map(|model| model.id.as_str()) - ); - assert_eq!( - catalog.default_model().id, - Catalog::builtin().default_model().id - ); - } - - #[test] - fn builtin_overrides_sparse_provider_fields() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.anthropic] -enabled = false -", - )) - .expect("sparse built-in provider override should build"); - - assert!(catalog.provider(&ProviderId::anthropic()).is_none()); - assert!(catalog.get("claude-sonnet-4-5").is_none()); - assert!( - catalog - .providers() - .iter() - .any(|provider| provider.id == ProviderId::openai()) - ); - } - - #[test] - fn builtin_overrides_add_custom_openai_compatible_provider_and_model() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" -priority = 120 -aliases = ["acme-ai"] - -[providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[models."acme-large"] -provider = "acme" -display_name = "Acme Large" -family = "acme" -default = true -aliases = ["al"] - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - )) - .expect("custom provider overlay should build"); - - let provider = catalog - .provider(&ProviderId::new("acme-ai")) - .expect("provider alias should resolve"); - assert_eq!(provider.id, ProviderId::new("acme")); - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - - let model = catalog.get("al").expect("model alias should resolve"); - assert_eq!(model.id, "acme-large"); - assert_eq!(model.provider, ProviderId::new("acme")); - } - - #[test] - fn builtin_bedrock_provider_is_opt_in() { - let bedrock = ProviderId::new("bedrock"); - let builtin = Catalog::builtin(); - - assert!(builtin.provider(&bedrock).is_none()); - assert!(builtin.list(Some(&bedrock)).is_empty()); - - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.bedrock] -enabled = true -", - )) - .expect("enabled Bedrock override should build from the built-in provider settings"); - - let provider = catalog - .provider(&bedrock) - .expect("enabled Bedrock provider should be present"); - assert_eq!(provider.adapter, AdapterKind::Bedrock); - assert_eq!(provider.codec, CodecKind::BedrockConverse); - assert_eq!( - provider.base_url.as_deref(), - Some("https://bedrock-runtime.us-east-1.amazonaws.com") - ); - // Bearer key first (env then vault, like every other provider), under - // either the AWS-canonical name or Fabro's `_API_KEY` - // convention; SigV4 chain as the fallback. - assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ - CredentialRef::Env("AWS_BEARER_TOKEN_BEDROCK".to_string()), - CredentialRef::Env("BEDROCK_API_KEY".to_string()), - CredentialRef::Vault("AWS_BEARER_TOKEN_BEDROCK".to_string()), - CredentialRef::Vault("BEDROCK_API_KEY".to_string()), - CredentialRef::AwsSigv4, - ]); - - // Claude rows bill Anthropic-style; open-weights rows override the - // provider's Anthropic defaults the other way. - assert_eq!( - catalog - .model_settings_on_provider(&bedrock, "claude-sonnet-4-6") - .unwrap() - .billing_policy, - BillingPolicy::Anthropic - ); - assert_eq!( - catalog - .model_settings_on_provider(&bedrock, "glm-5") - .unwrap() - .billing_policy, - BillingPolicy::OpenAi - ); - assert_eq!( - catalog - .model_settings_on_provider(&bedrock, "claude-haiku-4-5") - .unwrap() - .api_id, - "us.anthropic.claude-haiku-4-5-20251001-v1:0" - ); - assert_eq!( - catalog - .default_for_provider(&bedrock) - .map(|model| model.id.as_str()), - Some("claude-sonnet-5") - ); - // Fable 5 ships with sampling params pinned off (the Converse - // encoder drops temperature/top_p for it). - let fable = catalog - .get_on_provider(&bedrock, "claude-fable-5") - .expect("fable row should be present"); - assert!(!fable.features.sampling_params); - let fable_settings = catalog - .settings_for(fable) - .expect("fable settings should be present"); - assert!(fable_settings.reasoning_by_default); - assert_eq!(fable_settings.agent_profile, AgentProfileKind::Claude5); - assert_eq!( - catalog - .model_settings_on_provider(&bedrock, "claude-fable-5") - .unwrap() - .billing_policy, - BillingPolicy::Anthropic - ); - let sonnet = catalog - .get_on_provider(&bedrock, "claude-sonnet-5") - .expect("Sonnet 5 row should be present"); - assert_eq!(sonnet.limits.context_window, 1_000_000); - assert_eq!(sonnet.limits.max_output, Some(128_000)); - assert!(!sonnet.features.sampling_params); - assert_eq!( - catalog.settings_for(sonnet).unwrap().agent_profile, - AgentProfileKind::Claude5 - ); - } - - #[test] - fn builtin_bedrock_openai_provider_is_opt_in() { - let provider_id = ProviderId::new("bedrock-openai"); - let builtin = Catalog::builtin(); - - assert!(builtin.provider(&provider_id).is_none()); - - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.bedrock-openai] -enabled = true -", - )) - .expect("enabled bedrock-openai override should build"); - - let provider = catalog - .provider(&provider_id) - .expect("enabled bedrock-openai provider should be present"); - // OpenAI frontier on Bedrock rides the existing openai_responses - // dialect against the bedrock-mantle endpoint — pure configuration. - assert_eq!(provider.adapter, AdapterKind::OpenAi); - assert_eq!(provider.codec, CodecKind::OpenAiResponses); - assert_eq!( - provider.base_url.as_deref(), - Some("https://bedrock-mantle.us-east-1.api.aws/openai/v1") - ); - assert_eq!( - catalog - .default_for_provider(&provider_id) - .map(|model| model.id.as_str()), - Some("gpt-5.5") - ); - } - - #[test] - fn builtin_poolside_provider_routes_current_laguna_models() { - let poolside = ProviderId::new("poolside"); - let catalog = Catalog::builtin(); - let provider = catalog - .provider(&poolside) - .expect("Poolside provider should be active"); - - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - assert_eq!(provider.codec, CodecKind::OpenAiCompatible); - assert_eq!(provider.billing_policy, BillingPolicy::OpenAi); - assert_eq!( - provider.base_url.as_deref(), - Some("https://inference.poolside.ai/v1") - ); - assert_eq!(provider.priority, 65); - assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ - CredentialRef::Env("POOLSIDE_API_KEY".to_string()), - CredentialRef::Vault("POOLSIDE_API_KEY".to_string()), - ]); - - assert_eq!( - catalog - .default_for_provider(&poolside) - .map(|model| model.id.as_str()), - Some("laguna-s-2.1") - ); - assert_eq!( - catalog - .small_default_for_provider(&poolside) - .map(|model| model.id.as_str()), - Some("laguna-xs-2.1") - ); - assert_eq!( - catalog - .probe_for_provider(&poolside) - .map(|model| model.id.as_str()), - Some("laguna-xs-2.1") - ); - - let s = catalog.get("laguna").expect("Laguna alias should resolve"); - assert_eq!(s.id, "laguna-s-2.1"); - assert_eq!(s.limits.context_window, 1_048_576); - assert_eq!(s.limits.max_output, Some(131_072)); - assert!(s.features.tools); - assert!(s.features.reasoning); - assert!(s.features.prompt_cache); - assert!(s.features.sampling_params); - assert!(!s.features.vision); - assert!(!s.supports_reasoning_effort()); - assert_eq!(s.costs.input_cost_per_mtok, Some(0.10)); - assert_eq!(s.costs.output_cost_per_mtok, Some(0.20)); - assert_eq!(s.costs.cache_input_cost_per_mtok, Some(0.01)); - assert_eq!( - catalog.model_settings(&s.id).unwrap().api_id, - "poolside/laguna-s-2.1" - ); - - let xs = catalog - .get("laguna-xs") - .expect("Laguna XS alias should resolve"); - assert_eq!(xs.id, "laguna-xs-2.1"); - assert_eq!(xs.limits.context_window, 262_144); - assert_eq!(xs.limits.max_output, Some(32_768)); - assert!(xs.features.tools); - assert!(xs.features.reasoning); - assert!(xs.features.prompt_cache); - assert!(xs.features.sampling_params); - assert!(!xs.features.vision); - assert!(!xs.supports_reasoning_effort()); - assert_eq!(xs.costs.input_cost_per_mtok, Some(0.10)); - assert_eq!(xs.costs.output_cost_per_mtok, Some(0.20)); - assert_eq!(xs.costs.cache_input_cost_per_mtok, Some(0.05)); - assert_eq!( - catalog.model_settings(&xs.id).unwrap().api_id, - "poolside/laguna-xs-2.1" - ); - } - - #[test] - fn builtin_deepseek_provider_routes_v4_models() { - let deepseek = ProviderId::new("deepseek"); - let catalog = Catalog::builtin(); - let provider = catalog - .provider(&deepseek) - .expect("DeepSeek provider should be active"); - - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - assert_eq!(provider.codec, CodecKind::OpenAiCompatible); - assert_eq!(provider.billing_policy, BillingPolicy::OpenAi); - assert_eq!( - provider.base_url.as_deref(), - Some("https://api.deepseek.com") - ); - assert_eq!(provider.priority, 75); - assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ - CredentialRef::Env("DEEPSEEK_API_KEY".to_string()), - CredentialRef::Vault("DEEPSEEK_API_KEY".to_string()), - ]); - assert_eq!( - catalog - .default_for_provider(&deepseek) - .map(|model| model.id.as_str()), - Some("deepseek-v4-flash") - ); - assert_eq!( - catalog - .small_default_for_provider(&deepseek) - .map(|model| model.id.as_str()), - Some("deepseek-v4-flash") - ); - assert_eq!( - catalog - .probe_for_provider(&deepseek) - .map(|model| model.id.as_str()), - Some("deepseek-v4-flash") - ); - - let expected = [ - ("deepseek-v4-flash", 0.14, 0.28, 0.0028), - ("deepseek-v4-pro", 0.435, 0.87, 0.003_625), - ]; - for (id, input, output, cache_read) in expected { - let model = catalog - .get_on_provider(&deepseek, id) - .unwrap_or_else(|| panic!("DeepSeek model '{id}' should be present")); - assert_eq!(model.family, "deepseek-v4", "{id}"); - assert_eq!(model.limits.context_window, 1_048_576, "{id}"); - assert_eq!(model.limits.max_output, Some(384_000), "{id}"); - assert!(model.features.tools, "{id}"); - assert!(!model.features.vision, "{id}"); - assert!(model.features.reasoning, "{id}"); - assert!(model.features.prompt_cache, "{id}"); - assert!(!model.features.sampling_params, "{id}"); - assert_eq!(model.costs.input_cost_per_mtok, Some(input), "{id}"); - assert_eq!(model.costs.output_cost_per_mtok, Some(output), "{id}"); - assert_eq!( - model.costs.cache_input_cost_per_mtok, - Some(cache_read), - "{id}" - ); - - let settings = catalog - .model_settings_on_provider(&deepseek, id) - .unwrap_or_else(|| panic!("DeepSeek settings for '{id}' should be present")); - assert_eq!(settings.api_id, id, "{id}"); - assert!(settings.reasoning_by_default, "{id}"); - } - } - - #[test] - fn builtin_deepseek_reasoning_controls_match_provider_dialects() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.fireworks] -enabled = true - -[providers.openrouter] -enabled = true -", - )) - .expect("DeepSeek gateway providers should build when enabled"); - - let expected = [ - (ProviderId::new("deepseek"), "deepseek-v4-flash", vec![ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - (ProviderId::new("deepseek"), "deepseek-v4-pro", vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - (ProviderId::new("fireworks"), "deepseek-v4-flash", vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - (ProviderId::new("fireworks"), "deepseek-v4-pro", vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - (ProviderId::new("openrouter"), "deepseek-v4-flash", vec![ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - (ProviderId::new("openrouter"), "deepseek-v4-pro", vec![ - ReasoningEffort::High, - ReasoningEffort::XHigh, - ]), - ]; - - for (provider, id, efforts) in expected { - let model = catalog - .get_on_provider(&provider, id) - .unwrap_or_else(|| panic!("{provider}/{id} should be present")); - assert!(model.features.reasoning, "{provider}/{id}"); - assert_eq!( - model.features.reasoning_effort, - ReasoningEffortFeature::Levels, - "{provider}/{id}" - ); - assert_eq!(model.controls.reasoning_effort, efforts, "{provider}/{id}"); - assert!(!model.features.sampling_params, "{provider}/{id}"); - - let settings = catalog - .model_settings_on_provider(&provider, id) - .unwrap_or_else(|| panic!("{provider}/{id} settings should be present")); - assert_eq!( - settings.agent_profile, - AgentProfileKind::OpenAi, - "{provider}/{id}" - ); - assert!(settings.reasoning_by_default, "{provider}/{id}"); - assert_eq!( - settings.controls.reasoning_effort, efforts, - "{provider}/{id}" - ); - } - } - - #[test] - fn builtin_openrouter_provider_is_opt_in() { - let openrouter = ProviderId::new("openrouter"); - let builtin = Catalog::builtin(); - - assert!(builtin.provider(&openrouter).is_none()); - assert!(builtin.list(Some(&openrouter)).is_empty()); - - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - let provider = catalog - .provider(&openrouter) - .expect("enabled OpenRouter provider should be present"); - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - assert_eq!(provider.codec, CodecKind::OpenAiCompatible); - assert_eq!( - provider.base_url.as_deref(), - Some("https://openrouter.ai/api/v1") - ); - assert_eq!(provider.billing_policy, BillingPolicy::OpenAi); - - // Claude rows override the provider's OpenAI billing default; - // open-weights rows inherit it. - assert_eq!( - catalog - .model_settings_on_provider(&openrouter, "claude-sonnet-5") - .unwrap() - .billing_policy, - BillingPolicy::Anthropic - ); - assert_eq!( - catalog - .model_settings_on_provider(&openrouter, "deepseek-v4-flash") - .unwrap() - .billing_policy, - BillingPolicy::OpenAi - ); - let deepseek = catalog - .get_on_provider(&openrouter, "deepseek-v4-flash") - .expect("DeepSeek V4 Flash should be present on OpenRouter"); - assert_eq!(deepseek.limits.max_output, Some(384_000)); - assert!(deepseek.features.prompt_cache); - assert_eq!(deepseek.costs.input_cost_per_mtok, Some(0.14)); - assert_eq!(deepseek.costs.output_cost_per_mtok, Some(0.28)); - assert_eq!(deepseek.costs.cache_input_cost_per_mtok, Some(0.0028)); - assert_eq!( - catalog.settings_for(deepseek).unwrap().api_id, - "deepseek/deepseek-v4-flash-0731" - ); - let deepseek_pro = catalog - .get_on_provider(&openrouter, "deepseek-v4-pro") - .expect("DeepSeek V4 Pro should be present on OpenRouter"); - assert_eq!(deepseek_pro.limits.max_output, Some(384_000)); - assert!(deepseek_pro.features.prompt_cache); - assert_eq!( - deepseek_pro.costs.cache_input_cost_per_mtok, - Some(0.003_625) - ); - assert_eq!( - catalog - .default_for_provider(&openrouter) - .map(|model| model.id.as_str()), - Some("claude-sonnet-5") - ); - } - - #[test] - fn builtin_openrouter_includes_gpt_5_6_and_current_claude_models_when_enabled() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - let expected = [ - ( - "gpt-5.6-sol", - "openai/gpt-5.6-sol", - "gpt-5", - 1_050_000, - 5.0, - 30.0, - 0.5, - ReasoningEffortFeature::Levels, - false, - false, - BillingPolicy::OpenAi, - ), - ( - "gpt-5.6-terra", - "openai/gpt-5.6-terra", - "gpt-5", - 1_050_000, - 2.5, - 15.0, - 0.25, - ReasoningEffortFeature::Levels, - false, - false, - BillingPolicy::OpenAi, - ), - ( - "gpt-5.6-luna", - "openai/gpt-5.6-luna", - "gpt-5", - 1_050_000, - 1.0, - 6.0, - 0.1, - ReasoningEffortFeature::Levels, - false, - false, - BillingPolicy::OpenAi, - ), - ( - "claude-opus-5", - "anthropic/claude-opus-5", - "claude-5", - 1_000_000, - 5.0, - 25.0, - 0.5, - ReasoningEffortFeature::Levels, - false, - true, - BillingPolicy::Anthropic, - ), - ( - "claude-opus-4-8", - "anthropic/claude-opus-4.8", - "claude-4", - 1_000_000, - 5.0, - 25.0, - 0.5, - ReasoningEffortFeature::Levels, - false, - true, - BillingPolicy::Anthropic, - ), - ( - "claude-fable-5", - "anthropic/claude-fable-5", - "claude-5", - 1_000_000, - 10.0, - 50.0, - 1.0, - ReasoningEffortFeature::AlwaysAdaptive, - false, - true, - BillingPolicy::Anthropic, - ), - ( - "claude-sonnet-5", - "anthropic/claude-sonnet-5", - "claude-5", - 1_000_000, - 2.0, - 10.0, - 0.2, - ReasoningEffortFeature::Levels, - false, - true, - BillingPolicy::Anthropic, - ), - ]; - - for ( - id, - api_id, - family, - context_window, - input_cost, - output_cost, - cache_input_cost, - reasoning_effort, - sampling_params, - cache_control_breakpoints, - billing_policy, - ) in expected - { - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), id) - .unwrap_or_else(|| panic!("OpenRouter model '{id}' should be present")); - assert_eq!(model.provider, ProviderId::new("openrouter"), "{id}"); - assert_eq!(model.family, family, "{id}"); - assert_eq!(model.limits.context_window, context_window, "{id}"); - assert_eq!(model.limits.max_output, Some(128_000), "{id}"); - assert!(model.features.tools, "{id}"); - assert!(model.features.vision, "{id}"); - assert!(model.features.reasoning, "{id}"); - assert!(model.features.prompt_cache, "{id}"); - assert_eq!(model.features.reasoning_effort, reasoning_effort, "{id}"); - assert_eq!(model.features.sampling_params, sampling_params, "{id}"); - assert_eq!( - model.features.cache_control_breakpoints, cache_control_breakpoints, - "{id}" - ); - assert_eq!(model.costs.input_cost_per_mtok, Some(input_cost), "{id}"); - assert_eq!(model.costs.output_cost_per_mtok, Some(output_cost), "{id}"); - assert_eq!( - model.costs.cache_input_cost_per_mtok, - Some(cache_input_cost), - "{id}" - ); - - let settings = catalog - .model_settings_on_provider(&ProviderId::new("openrouter"), id) - .unwrap_or_else(|| panic!("OpenRouter settings for '{id}' should be present")); - assert_eq!(settings.api_id, api_id, "{id}"); - assert_eq!(settings.billing_policy, billing_policy, "{id}"); - assert_eq!( - settings.controls.reasoning_effort, - ReasoningEffort::VARIANTS, - "{id}" - ); - if family == "claude-5" { - assert_eq!(settings.agent_profile, AgentProfileKind::Claude5, "{id}"); - } - } - - for (alias, expected) in [ - ("opus", "claude-opus-5"), - ("claude-opus", "claude-opus-5"), - ("sonnet", "claude-sonnet-5"), - ("claude-sonnet", "claude-sonnet-5"), - ] { - let model = catalog - .resolve_on_provider(&ProviderId::new("openrouter"), alias) - .unwrap_or_else(|error| panic!("{alias} should resolve on OpenRouter: {error}")); - assert_eq!(model.id, expected, "{alias}"); - } - } - - #[test] - fn builtin_gpt_5_6_short_aliases_are_portable() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - for provider in [ProviderId::openai(), ProviderId::new("openrouter")] { - for (alias, canonical_id) in [ - ("sol", "gpt-5.6-sol"), - ("gpt-sol", "gpt-5.6-sol"), - ("terra", "gpt-5.6-terra"), - ("gpt-terra", "gpt-5.6-terra"), - ("luna", "gpt-5.6-luna"), - ("gpt-luna", "gpt-5.6-luna"), - ] { - let model = catalog - .resolve_on_provider(&provider, alias) - .unwrap_or_else(|error| { - panic!("{alias} should resolve on {provider}: {error}") - }); - assert_eq!(model.provider, provider, "{alias}"); - assert_eq!(model.id, canonical_id, "{alias}"); - } - } - } - - #[test] - fn builtin_glm_5_2_aliases_are_portable() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - for provider in [ProviderId::new("zai"), ProviderId::new("openrouter")] { - for alias in ["glm", "glm5", "glm52", "glm5.2"] { - let model = catalog - .resolve_on_provider(&provider, alias) - .unwrap_or_else(|error| { - panic!("{alias} should resolve on {provider}: {error}") - }); - assert_eq!(model.provider, provider, "{alias}"); - assert_eq!(model.id, "glm-5.2", "{alias}"); - } - } - } - - #[test] - fn builtin_deepseek_v4_selectors_resolve_on_openrouter() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - let openrouter = ProviderId::new("openrouter"); - - for (selector, canonical_id) in [ - ("deepseek-v4-pro", "deepseek-v4-pro"), - ("deepseek-v4", "deepseek-v4-flash"), - ("deepseek", "deepseek-v4-flash"), - ("deepseek-v4-flash", "deepseek-v4-flash"), - ("deepseek-flash", "deepseek-v4-flash"), - ] { - let model = catalog - .resolve_on_provider(&openrouter, selector) - .unwrap_or_else(|error| { - panic!("{selector} should resolve on {openrouter}: {error}") - }); - assert_eq!(model.provider, openrouter, "{selector}"); - assert_eq!(model.id, canonical_id, "{selector}"); - } - } - - #[test] - fn builtin_legacy_vendor_ids_normalize_for_pinned_and_unpinned_selection() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - let openrouter = ProviderId::new("openrouter"); - - for (selector, canonical_id) in [ - ("anthropic/claude-fable-5", "claude-fable-5"), - ("openai/gpt-5.6-sol", "gpt-5.6-sol"), - ] { - let model = catalog - .resolve_on_provider(&openrouter, selector) - .unwrap_or_else(|error| panic!("{selector} should resolve on OpenRouter: {error}")); - assert_eq!(model.provider, openrouter, "{selector}"); - assert_eq!(model.id, canonical_id, "{selector}"); - } - - let anthropic = ProviderId::anthropic(); - let selector = "anthropic/claude-fable-5"; - let selected = catalog - .resolve_selection( - Some(selector), - None, - &HashSet::from([anthropic.clone(), openrouter.clone()]), - ) - .unwrap(); - assert_eq!(selected.provider, anthropic); - assert_eq!(selected.model, "claude-fable-5"); - - let selected = catalog - .resolve_selection(Some(selector), None, &HashSet::from([openrouter.clone()])) - .unwrap(); - assert_eq!(selected.provider, openrouter); - assert_eq!(selected.model, "claude-fable-5"); - } - - #[test] - fn every_legacy_builtin_identifier_targets_an_existing_offering() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.bedrock] -enabled = true - -[providers.bedrock-openai] -enabled = true - -[providers.openrouter] -enabled = true -", - )) - .expect("all providers referenced by the legacy table should build"); - - for (legacy_id, provider_id, canonical_id) in LEGACY_BUILTIN_MODEL_IDENTIFIERS { - let provider = ProviderId::new(*provider_id); - let model = catalog - .resolve_on_provider(&provider, legacy_id) - .unwrap_or_else(|error| { - panic!( - "legacy identifier '{legacy_id}' should resolve on '{provider}': {error}" - ) - }); - - assert_eq!(model.provider, provider, "{legacy_id}"); - assert_eq!(model.id, *canonical_id, "{legacy_id}"); - assert_eq!( - legacy_builtin_model(legacy_id), - Some((provider, ModelId::new(*canonical_id))), - "{legacy_id}" - ); - } - } - - #[test] - fn builtin_openrouter_includes_glm_5_2_when_enabled() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), "glm-5.2") - .expect("OpenRouter GLM 5.2 should be present"); - insta::assert_debug_snapshot!(model, @r#" - Model { - id: "glm-5.2", - provider: openrouter, - family: "glm-5", - display_name: "GLM 5.2 (via OpenRouter)", - limits: ModelLimits { - context_window: 1048576, - max_output: Some( - 131072, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: false, - reasoning: true, - reasoning_effort: Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls { - reasoning_effort: [ - High, - XHigh, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 0.784, - ), - output_cost_per_mtok: Some( - 2.464, - ), - cache_input_cost_per_mtok: Some( - 0.1456, - ), - }, - estimated_output_tps: None, - aliases: [ - "glm", - "glm5", - "glm52", - "glm5.2", - ], - default: false, - small_default: false, - configured: false, - } - "#); - - let settings = catalog - .model_settings_on_provider(&ProviderId::new("openrouter"), "glm-5.2") - .expect("OpenRouter GLM 5.2 settings should be present"); - assert_eq!(settings.api_id, "z-ai/glm-5.2"); - assert_eq!(settings.controls.reasoning_effort, vec![ - ReasoningEffort::High, - ReasoningEffort::XHigh - ]); - } - - #[test] - fn builtin_openrouter_includes_kimi_k3_when_enabled() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), "kimi-k3") - .expect("OpenRouter Kimi K3 should be present"); - insta::assert_debug_snapshot!(model, @r#" - Model { - id: "kimi-k3", - provider: openrouter, - family: "kimi-k3", - display_name: "Kimi K3 (via OpenRouter)", - limits: ModelLimits { - context_window: 1048576, - max_output: Some( - 131072, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: AlwaysAdaptive, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: false, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - High, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 3.0, - ), - output_cost_per_mtok: Some( - 15.0, - ), - cache_input_cost_per_mtok: Some( - 0.3, - ), - }, - estimated_output_tps: None, - aliases: [], - default: false, - small_default: false, - configured: false, - } - "#); - - let settings = catalog - .model_settings_on_provider(&ProviderId::new("openrouter"), "kimi-k3") - .expect("OpenRouter Kimi K3 settings should be present"); - assert_eq!(settings.api_id, "moonshotai/kimi-k3"); - assert_eq!(settings.controls.reasoning_effort, vec![ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ]); - } - - #[test] - fn builtin_openrouter_includes_qwen3_8_max_when_enabled() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), "qwen3.8-max") - .expect("OpenRouter Qwen3.8 Max should be present"); - insta::assert_debug_snapshot!(model, @r#" - Model { - id: "qwen3.8-max", - provider: openrouter, - family: "qwen3", - display_name: "Qwen3.8 Max", - limits: ModelLimits { - context_window: 1000000, - max_output: Some( - 131072, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - Medium, - High, - XHigh, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 2.0, - ), - output_cost_per_mtok: Some( - 6.0, - ), - cache_input_cost_per_mtok: Some( - 0.25, - ), - }, - estimated_output_tps: None, - aliases: [], - default: false, - small_default: false, - configured: false, - } - "#); - - let settings = catalog - .model_settings_on_provider(&ProviderId::new("openrouter"), "qwen3.8-max") - .expect("OpenRouter Qwen3.8 Max settings should be present"); - assert_eq!(settings.api_id, "qwen/qwen3.8-max"); - assert!(settings.reasoning_by_default); - assert_eq!(settings.controls.reasoning_effort, vec![ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, - ReasoningEffort::XHigh, - ]); - } - - #[test] - fn builtin_modal_provider_is_opt_in() { - let modal = ProviderId::new("modal"); - let builtin = Catalog::builtin(); - - assert!(builtin.provider(&modal).is_none()); - assert!(builtin.list(Some(&modal)).is_empty()); - - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.modal] -enabled = true -", - )) - .expect("enabled Modal override should build from the built-in provider settings"); - - let provider = catalog - .provider(&modal) - .expect("enabled Modal provider should be present"); - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - assert_eq!(provider.codec, CodecKind::OpenAiCompatible); - assert_eq!(provider.agent_profile, AgentProfileKind::Kimi); - assert_eq!(provider.billing_policy, BillingPolicy::OpenAi); - assert_eq!(provider.priority, 75); - assert!(provider.auth.is_none()); - assert_eq!( - provider.extra_headers, - HashMap::from([ - ( - "Modal-Key".to_string(), - "{{ secrets.MODAL_TOKEN_ID }}".to_string(), - ), - ( - "Modal-Secret".to_string(), - "{{ secrets.MODAL_TOKEN_SECRET }}".to_string(), - ), - ]) - ); - - // Modal assigns the endpoint URL per deployment, so the built-in entry - // ships without one and the operator supplies it through settings. - assert!(provider.base_url.is_none()); - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[providers.modal] -enabled = true -base_url = "https://example--kimi-k3.modal.run/v1" -"#, - )) - .expect("Modal base URL override should build"); - assert_eq!( - catalog - .provider(&modal) - .and_then(|provider| provider.base_url.as_deref()), - Some("https://example--kimi-k3.modal.run/v1") - ); - } - - #[test] - fn builtin_modal_includes_kimi_k3_when_enabled() { - let modal = ProviderId::new("modal"); - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.modal] -enabled = true -", - )) - .expect("enabled Modal override should build from the built-in provider settings"); - - assert_eq!(catalog.list(Some(&modal)).len(), 1); - let model = catalog - .get_on_provider(&modal, "kimi-k3") - .expect("Modal Kimi K3 should be present"); - insta::assert_debug_snapshot!(model, @r#" - Model { - id: "kimi-k3", - provider: modal, - family: "kimi-k3", - display_name: "Kimi K3 (via Modal)", - limits: ModelLimits { - context_window: 1048576, - max_output: Some( - 131072, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: AlwaysAdaptive, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: false, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - High, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 3.0, - ), - output_cost_per_mtok: Some( - 15.0, - ), - cache_input_cost_per_mtok: Some( - 0.3, - ), - }, - estimated_output_tps: Some( - 460.0, - ), - aliases: [], - default: true, - small_default: false, - configured: false, - } - "#); - - let settings = catalog - .model_settings_on_provider(&modal, "kimi-k3") - .expect("Modal Kimi K3 settings should be present"); - assert_eq!(settings.api_id, "moonshotai/Kimi-K3"); - assert_eq!(settings.agent_profile, AgentProfileKind::Kimi); - assert_eq!(settings.billing_policy, BillingPolicy::OpenAi); - assert_eq!(settings.controls.reasoning_effort, vec![ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ]); - } - - #[test] - fn builtin_moonshot_provider_prefers_moonshot_api_key() { - let moonshot = ProviderId::new("moonshot"); - let catalog = Catalog::builtin(); - let provider = catalog - .provider(&moonshot) - .expect("Moonshot provider should be present"); - - assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ - CredentialRef::Env("MOONSHOT_API_KEY".to_string()), - CredentialRef::Env("KIMI_API_KEY".to_string()), - CredentialRef::Vault("MOONSHOT_API_KEY".to_string()), - CredentialRef::Vault("KIMI_API_KEY".to_string()), - ]); - assert_eq!( - catalog.provider_vault_secret_name(&moonshot), - Some("MOONSHOT_API_KEY") - ); - } - - #[test] - fn builtin_kimi_k3_selection_follows_provider_priority() { - let moonshot = ProviderId::new("moonshot"); - let modal = ProviderId::new("modal"); - let fireworks = ProviderId::new("fireworks"); - let openrouter = ProviderId::new("openrouter"); - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.modal] -enabled = true - -[providers.fireworks] -enabled = true - -[providers.openrouter] -enabled = true -", - )) - .expect("enabled Kimi K3 provider overrides should build"); - - let selected = catalog - .select( - "kimi-k3", - None, - &HashSet::from([ - moonshot.clone(), - modal.clone(), - fireworks.clone(), - openrouter.clone(), - ]), - ) - .expect("Modal should win portable Kimi K3 selection"); - assert_eq!(selected.provider, modal); - - let selected = catalog - .select( - "kimi-k3", - None, - &HashSet::from([moonshot.clone(), fireworks.clone(), openrouter.clone()]), - ) - .expect("Moonshot should win when Modal is unavailable"); - assert_eq!(selected.provider, moonshot); - - let selected = catalog - .select( - "kimi-k3", - None, - &HashSet::from([fireworks.clone(), openrouter]), - ) - .expect("Fireworks should win when only gateway routes are available"); - assert_eq!(selected.provider, fireworks); - } - - #[test] - fn builtin_openrouter_includes_poolside_laguna_when_enabled() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - let expected = [ - ("laguna-s-2.1", 1_048_576, 131_072, 0.10, 0.20, 0.01), - ("laguna-xs-2.1", 262_144, 32_768, 0.06, 0.12, 0.03), - ]; - - for (id, context, max_output, input, output, cache_read) in expected { - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), id) - .unwrap_or_else(|| panic!("OpenRouter model '{id}' should be present")); - assert_eq!(model.provider, ProviderId::new("openrouter"), "{id}"); - assert_eq!(model.family, "laguna-2", "{id}"); - assert_eq!(model.limits.context_window, context, "{id}"); - assert_eq!(model.limits.max_output, Some(max_output), "{id}"); - assert!(model.features.tools, "{id}"); - assert!(model.features.reasoning, "{id}"); - assert!(model.features.prompt_cache, "{id}"); - assert!(model.features.sampling_params, "{id}"); - assert!(!model.features.vision, "{id}"); - assert!(!model.supports_reasoning_effort(), "{id}"); - assert_eq!(model.costs.input_cost_per_mtok, Some(input), "{id}"); - assert_eq!(model.costs.output_cost_per_mtok, Some(output), "{id}"); - assert_eq!( - model.costs.cache_input_cost_per_mtok, - Some(cache_read), - "{id}" - ); - - let settings = catalog - .model_settings_on_provider(&ProviderId::new("openrouter"), id) - .unwrap_or_else(|| panic!("OpenRouter settings for '{id}' should be present")); - assert_eq!(settings.api_id, format!("poolside/{id}"), "{id}"); - assert!(settings.controls.reasoning_effort.is_empty(), "{id}"); - } - } - - #[test] - fn builtin_fireworks_provider_is_opt_in() { - let fireworks = ProviderId::new("fireworks"); - let builtin = Catalog::builtin(); - - assert!(builtin.provider(&fireworks).is_none()); - assert!(builtin.list(Some(&fireworks)).is_empty()); - - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.fireworks] -enabled = true -", - )) - .expect("enabled Fireworks override should build from the built-in provider settings"); - - let provider = catalog - .provider(&fireworks) - .expect("enabled Fireworks provider should be present"); - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - assert_eq!(provider.codec, CodecKind::OpenAiCompatible); - assert_eq!( - provider.base_url.as_deref(), - Some("https://api.fireworks.ai/inference/v1") - ); - assert_eq!(provider.billing_policy, BillingPolicy::OpenAi); - assert_eq!(provider.priority, 30); - assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ - CredentialRef::Env("FIREWORKS_API_KEY".to_string()), - CredentialRef::Vault("FIREWORKS_API_KEY".to_string()), - ]); - - assert_eq!( - catalog - .default_for_provider(&fireworks) - .map(|model| model.id.as_str()), - Some("kimi-k2.7-code") - ); - assert_eq!( - catalog - .small_default_for_provider(&fireworks) - .map(|model| model.id.as_str()), - Some("gpt-oss-20b") - ); - assert_eq!( - catalog - .probe_for_provider(&fireworks) - .map(|model| model.id.as_str()), - Some("gpt-oss-20b") - ); - } - - #[test] - fn builtin_fireworks_models_when_enabled() { - let fireworks = ProviderId::new("fireworks"); - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.fireworks] -enabled = true -", - )) - .expect("enabled Fireworks override should build from the built-in provider settings"); - - // (id, api_id, family, context_window, max_output, vision, reasoning, - // input, output, cache_read) - let expected = [ - ( - "kimi-k3", - "accounts/fireworks/models/kimi-k3", - "kimi-k3", - 1_048_576, - 131_072, - true, - true, - 3.0, - 15.0, - 0.3, - ), - ( - "kimi-k3-fast", - "accounts/fireworks/routers/kimi-k3-fast", - "kimi-k3", - 1_048_576, - 131_072, - true, - true, - 4.5, - 22.5, - 0.45, - ), - ( - "kimi-k2.7-code", - "accounts/fireworks/models/kimi-k2p7-code", - "kimi-k2", - 262_144, - 32_768, - true, - true, - 0.95, - 4.0, - 0.19, - ), - ( - "kimi-k2.6", - "accounts/fireworks/models/kimi-k2p6", - "kimi-k2", - 262_144, - 16_384, - false, - false, - 0.95, - 4.0, - 0.16, - ), - ( - "deepseek-v4-pro", - "accounts/fireworks/models/deepseek-v4-pro", - "deepseek-v4", - 1_048_576, - 16_384, - false, - true, - 1.74, - 3.48, - 0.145, - ), - ( - "deepseek-v4-flash", - "accounts/fireworks/models/deepseek-v4-flash", - "deepseek-v4", - 1_048_576, - 384_000, - false, - true, - 0.14, - 0.28, - 0.028, - ), - ( - "glm-5.2", - "accounts/fireworks/models/glm-5p2", - "glm-5", - 1_048_576, - 131_072, - false, - true, - 1.4, - 4.4, - 0.14, - ), - ( - "minimax-m2.7", - "accounts/fireworks/models/minimax-m2p7", - "minimax-m2", - 196_608, - 16_384, - false, - false, - 0.3, - 1.2, - 0.059, - ), - ( - "qwen3.7-plus", - "accounts/fireworks/models/qwen3p7-plus", - "qwen3", - 262_144, - 16_384, - true, - false, - 0.4, - 1.6, - 0.08, - ), - ( - "gpt-oss-120b", - "accounts/fireworks/models/gpt-oss-120b", - "gpt-oss", - 131_072, - 32_768, - false, - true, - 0.15, - 0.6, - 0.015, - ), - ( - "gpt-oss-20b", - "accounts/fireworks/models/gpt-oss-20b", - "gpt-oss", - 131_072, - 32_768, - false, - true, - 0.07, - 0.3, - 0.035, - ), - ]; - - let mut model_ids: Vec<&str> = catalog - .list(Some(&fireworks)) - .iter() - .map(|model| model.id.as_str()) - .collect(); - model_ids.sort_unstable(); - let mut expected_ids: Vec<&str> = expected.iter().map(|row| row.0).collect(); - expected_ids.sort_unstable(); - assert_eq!( - model_ids, expected_ids, - "expected rows must cover every Fireworks model" - ); - - for ( - id, - api_id, - family, - context, - max_output, - vision, - reasoning, - input, - output, - cache_read, - ) in expected - { - let model = catalog - .get_on_provider(&fireworks, id) - .unwrap_or_else(|| panic!("Fireworks model '{id}' should be present")); - assert_eq!(model.family, family, "{id}"); - assert_eq!(model.limits.context_window, context, "{id}"); - assert_eq!(model.limits.max_output, Some(max_output), "{id}"); - assert!(model.features.tools, "{id}"); - assert_eq!(model.features.vision, vision, "{id}"); - assert_eq!(model.features.reasoning, reasoning, "{id}"); - assert!(model.features.prompt_cache, "{id}"); - assert_eq!(model.costs.input_cost_per_mtok, Some(input), "{id}"); - assert_eq!(model.costs.output_cost_per_mtok, Some(output), "{id}"); - assert_eq!( - model.costs.cache_input_cost_per_mtok, - Some(cache_read), - "{id}" - ); - - let settings = catalog - .model_settings_on_provider(&fireworks, id) - .unwrap_or_else(|| panic!("Fireworks settings for '{id}' should be present")); - assert_eq!(settings.api_id, api_id, "{id}"); - assert_eq!(settings.billing_policy, BillingPolicy::OpenAi, "{id}"); - } - - for id in ["kimi-k3", "kimi-k3-fast"] { - let model = catalog - .get_on_provider(&fireworks, id) - .unwrap_or_else(|| panic!("Fireworks model '{id}' should be present")); - assert_eq!( - model.features.reasoning_effort, - ReasoningEffortFeature::AlwaysAdaptive, - "{id}" - ); - assert!(!model.features.sampling_params, "{id}"); - - let settings = catalog - .model_settings_on_provider(&fireworks, id) - .unwrap_or_else(|| panic!("Fireworks settings for '{id}' should be present")); - assert_eq!(settings.agent_profile, AgentProfileKind::Kimi, "{id}"); - assert_eq!( - settings.controls.reasoning_effort, - [ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, - ], - "{id}" - ); - } - } - - #[test] - fn builtin_deepseek_shared_slugs_are_portable_across_providers() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.fireworks] -enabled = true - -[providers.openrouter] -enabled = true -", - )) - .expect("enabled Fireworks and OpenRouter overrides should build"); - - for provider in [ProviderId::new("fireworks"), ProviderId::new("openrouter")] { - for id in [ - "kimi-k3", - "kimi-k2.6", - "deepseek-v4-pro", - "deepseek-v4-flash", - "glm-5.2", - "minimax-m2.7", - ] { - let model = catalog - .get_on_provider(&provider, id) - .unwrap_or_else(|| panic!("'{id}' should resolve on provider '{provider}'")); - assert_eq!(model.id, id, "{provider}/{id}"); - assert_eq!(model.provider, provider, "{provider}/{id}"); - } - } - - for provider in [ - ProviderId::new("deepseek"), - ProviderId::new("fireworks"), - ProviderId::new("openrouter"), - ] { - for id in ["deepseek-v4-pro", "deepseek-v4-flash"] { - let model = catalog - .get_on_provider(&provider, id) - .unwrap_or_else(|| panic!("'{id}' should resolve on provider '{provider}'")); - assert_eq!(model.id, id, "{provider}/{id}"); - assert_eq!(model.provider, provider, "{provider}/{id}"); - assert_eq!(model.limits.context_window, 1_048_576, "{provider}/{id}"); - } - for alias in ["deepseek", "deepseek-v4", "deepseek-flash"] { - let model = catalog - .resolve_on_provider(&provider, alias) - .unwrap_or_else(|error| { - panic!("'{alias}' should resolve on provider '{provider}': {error}") - }); - assert_eq!(model.id, "deepseek-v4-flash", "{provider}/{alias}"); - assert_eq!(model.provider, provider, "{provider}/{alias}"); - } - } - - let selected = catalog - .select( - "deepseek", - None, - &HashSet::from([ - ProviderId::new("deepseek"), - ProviderId::new("fireworks"), - ProviderId::new("openrouter"), - ]), - ) - .expect("direct DeepSeek should win portable DeepSeek selection"); - assert_eq!(selected.provider, ProviderId::new("deepseek")); - } - - #[test] - fn builtin_ollama_provider_is_opt_in() { - let ollama = ProviderId::new("ollama"); - let builtin = Catalog::builtin(); - - assert!(builtin.provider(&ollama).is_none()); - assert!(builtin.list(Some(&ollama)).is_empty()); - - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.ollama] -enabled = true -", - )) - .expect("enabled Ollama override should build from the built-in provider settings"); - - let provider = catalog - .provider(&ollama) - .expect("enabled Ollama provider should be present"); - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - assert_eq!( - provider.base_url.as_deref(), - Some("http://localhost:11434/v1") - ); - assert_eq!(provider.billing_policy, BillingPolicy::None); - - assert!(catalog.list(Some(&ollama)).is_empty()); - assert!(catalog.default_for_provider(&ollama).is_none()); - } - - #[test] - fn builtin_get_by_id() { - let m = Catalog::builtin().get("claude-opus-4-6").unwrap(); - assert_eq!(m.id, "claude-opus-4-6"); - } - - #[test] - fn builtin_get_unknown() { - assert!(Catalog::builtin().get("nonexistent").is_none()); - } - - #[test] - fn builtin_list_all() { - let all = Catalog::builtin().list(None); - assert!(!all.is_empty()); - } - - #[test] - fn builtin_list_by_provider() { - let anthropic = Catalog::builtin().list(Some(&ProviderId::anthropic())); - assert!(!anthropic.is_empty()); - assert!( - anthropic - .iter() - .all(|m| m.provider == ProviderId::anthropic()) - ); - } - - #[test] - fn builtin_list_unknown_provider_empty() { - let models = Catalog::builtin().list(Some(&ProviderId::new("missing-provider"))); - assert!(models.is_empty()); - } - - #[test] - fn builtin_default_model() { - let m = Catalog::builtin().default_model(); - assert!(m.default); - } - - #[test] - fn builtin_default_for_provider() { - let m = Catalog::builtin() - .default_for_provider(&ProviderId::anthropic()) - .unwrap(); - assert_eq!(m.id, "claude-sonnet-5"); - assert!(m.default); - - let m = Catalog::builtin() - .default_for_provider(&ProviderId::openai()) - .unwrap(); - assert_eq!(m.provider, ProviderId::openai()); - assert!(m.default); - - let m = Catalog::builtin() - .default_for_provider(&ProviderId::gemini()) - .unwrap(); - assert_eq!(m.id, "gemini-3.5-flash"); - } - - #[test] - fn builtin_probe_openai_returns_override() { - let m = Catalog::builtin() - .probe_for_provider(&ProviderId::openai()) - .unwrap(); - assert_eq!(m.id, "gpt-5.4-mini"); - } - - #[test] - fn builtin_probe_anthropic_returns_override() { - let m = Catalog::builtin() - .probe_for_provider(&ProviderId::anthropic()) - .unwrap(); - assert_eq!(m.id, "claude-haiku-4-5"); - } - - #[test] - fn builtin_probe_gemini_returns_default() { - let m = Catalog::builtin() - .probe_for_provider(&ProviderId::gemini()) - .unwrap(); - assert_eq!(m.id, "gemini-3.5-flash"); - } - - #[test] - fn builtin_small_defaults_are_marked_per_provider() { - let catalog = Catalog::builtin(); - - let small_defaults = catalog - .list(None) - .into_iter() - .filter(|model| model.small_default) - .collect::>(); - - assert!( - !small_defaults.is_empty(), - "built-in catalog should mark at least one small default model" - ); - - for model in small_defaults { - assert_eq!( - catalog - .small_default_for_provider(&model.provider) - .unwrap() - .id, - model.id - ); - } - } - - #[test] - fn builtin_closest_opus_to_gemini() { - let opus = Catalog::builtin().get("claude-opus-4-6").unwrap(); - let result = Catalog::builtin() - .closest(&ProviderId::gemini(), opus) - .unwrap(); - assert_eq!(result.id, "gemini-3.1-pro-preview"); - } - - #[test] - fn builtin_closest_no_match() { - let haiku = Catalog::builtin().get("claude-haiku-4-5").unwrap(); - assert!( - Catalog::builtin() - .closest(&ProviderId::openai(), haiku) - .is_none() - ); - } - - #[test] - fn builtin_catalog_is_loaded_from_provider_toml_settings() { - let catalog = Catalog::builtin(); - - assert_eq!( - catalog.provider(&ProviderId::openai()).unwrap().adapter, - AdapterKind::OpenAi - ); - assert_eq!( - catalog - .provider(&ProviderId::openai()) - .unwrap() - .api_key_url - .as_deref(), - Some("https://platform.openai.com/api-keys") - ); - assert_eq!( - catalog - .provider(&ProviderId::new("moonshot")) - .unwrap() - .base_url - .as_deref(), - Some("https://api.moonshot.ai/v1") - ); - assert_eq!(catalog.model_settings("gpt-5.4").unwrap().api_id, "gpt-5.4"); - assert_eq!( - catalog.get("claude-opus-4-7").unwrap().knowledge_cutoff(), - Some("May 2025") - ); - assert_eq!( - catalog - .model_settings("gpt-5.4") - .unwrap() - .controls - .reasoning_effort, - ReasoningEffort::VARIANTS - ); - assert_eq!( - catalog - .model_settings("claude-sonnet-4-5") - .unwrap() - .controls - .reasoning_effort, - ReasoningEffort::VARIANTS - ); - } - - #[test] - fn catalog_from_settings_rejects_unknown_adapter() { - let layer = minimal_settings( - r#" -[providers.test-provider] -display_name = "Test Provider" -adapter = "not_real" -enabled = true -"#, - ); - - let err = Catalog::from_settings(&layer).unwrap_err(); - - assert!(matches!( - err, - CatalogBuildError::UnknownAdapter { provider, adapter } - if provider == ProviderId::new("test-provider") && adapter == "not_real" - )); - } - - // ---- Codec on the route ---- - - #[test] - fn provider_codec_defaults_from_adapter() { - let catalog = Catalog::builtin(); - - for (provider, expected) in [ - ("anthropic", CodecKind::AnthropicMessages), - ("openai", CodecKind::OpenAiResponses), - ("gemini", CodecKind::GeminiGenerate), - ("moonshot", CodecKind::OpenAiCompatible), - ] { - let provider_id = ProviderId::new(provider); - assert_eq!(catalog.provider(&provider_id).unwrap().codec, expected); - assert_eq!(catalog.effective_codec(&provider_id, None), Some(expected)); - } - } - - #[test] - fn model_codec_inherits_provider_codec() { - let catalog = Catalog::builtin(); - - assert_eq!( - catalog.model_settings("claude-sonnet-4-5").unwrap().codec, - CodecKind::AnthropicMessages - ); - assert_eq!( - catalog.model_settings("gpt-5.4").unwrap().codec, - CodecKind::OpenAiResponses - ); - assert_eq!( - catalog.effective_codec(&ProviderId::anthropic(), Some("claude-sonnet-4-5")), - Some(CodecKind::AnthropicMessages) - ); - } - - #[test] - fn explicit_codec_matching_the_adapter_default_is_accepted() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -codec = "openai_compatible" -base_url = "https://api.acme.test/v1" - -[models."acme-large"] -provider = "acme" -codec = "openai_compatible" -display_name = "Acme Large" -family = "acme" - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - )) - .expect("default codec pairing should build"); - - assert_eq!( - catalog.provider(&ProviderId::new("acme")).unwrap().codec, - CodecKind::OpenAiCompatible - ); - assert_eq!( - catalog.model_settings("acme-large").unwrap().codec, - CodecKind::OpenAiCompatible - ); - assert_eq!( - catalog.effective_codec(&ProviderId::new("acme"), Some("acme-large")), - Some(CodecKind::OpenAiCompatible) - ); - } - - #[test] - fn provider_codec_outside_the_adapter_default_is_rejected() { - let layer = minimal_settings( - r#" -[providers.test-provider] -display_name = "Test Provider" -adapter = "openai" -codec = "anthropic_messages" -"#, - ); - - let err = Catalog::from_settings(&layer).unwrap_err(); - - assert!(matches!( - err, - CatalogBuildError::UnsupportedProviderCodec { - provider, - adapter: AdapterKind::OpenAi, - codec: CodecKind::AnthropicMessages, - expected: CodecKind::OpenAiResponses, - } if provider == ProviderId::new("test-provider") - )); - } - - #[test] - fn model_codec_outside_the_adapter_default_is_rejected() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -enabled = true - -[models.one] -provider = "test" -codec = "gemini_generate" -display_name = "One" -family = "test" -default = true - -[models.one.limits] -context_window = 1000 - -[models.one.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let err = Catalog::from_settings(&layer).unwrap_err(); - - assert!(matches!( - err, - CatalogBuildError::UnsupportedModelCodec { - model, - adapter: AdapterKind::OpenAi, - codec: CodecKind::GeminiGenerate, - expected: CodecKind::OpenAiResponses, - } if model == "one" - )); - } - - #[test] - fn builtin_override_can_pin_the_default_codec() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[providers.anthropic] -codec = "anthropic_messages" -"#, - )) - .expect("override pinning the default codec should build"); - - assert_eq!( - catalog.provider(&ProviderId::anthropic()).unwrap().codec, - CodecKind::AnthropicMessages - ); - } - - #[test] - /// Canonical IDs, aliases, and API IDs share one identifier namespace per - /// provider, so a collision in any of them is rejected the same way. - fn catalog_from_settings_rejects_duplicate_provider_model_selectors() { - for (declaration, expected) in [ - (r#"aliases = ["shared"]"#, "shared"), - (r#"api_id = "vendor/shared""#, "vendor/shared"), - ] { - let layer = minimal_settings(&format!( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" -enabled = true - -[providers.test.models.one] -display_name = "One" -family = "test" -{declaration} - -[providers.test.models.one.limits] -context_window = 1000 - -[providers.test.models.one.features] -tools = false -vision = false -reasoning = false - -[providers.test.models.two] -display_name = "Two" -family = "test" -{declaration} - -[providers.test.models.two.limits] -context_window = 1000 - -[providers.test.models.two.features] -tools = false -vision = false -reasoning = false -"# - )); - - let err = Catalog::from_settings(&layer).unwrap_err(); - - assert!( - matches!( - &err, - CatalogBuildError::DuplicateProviderModelSelector { - provider, - selector, - first, - second, - } if provider == &ProviderId::new("test") - && selector == expected - && first == "one" - && second == "two" - ), - "{declaration}: {err:?}" - ); - } - } - - #[test] - fn provider_scoped_lookup_accepts_canonical_alias_and_api_id_selectors() { - let catalog = Catalog::from_settings(&minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" -aliases = ["test-alias"] - -[providers.test.models.one] -api_id = "vendor/models/one:latest" -display_name = "One" -family = "test" -aliases = ["one-alias"] -default = true - -[providers.test.models.one.limits] -context_window = 1000 - -[providers.test.models.one.features] -tools = false -vision = false -reasoning = false -"#, - )) - .expect("provider-scoped selector fixture should build"); - - for selector in ["one", "one-alias", "vendor/models/one:latest"] { - let model = catalog - .resolve_on_provider(&ProviderId::new("test-alias"), selector) - .unwrap_or_else(|error| { - panic!("selector '{selector}' should resolve on provider alias: {error}") - }); - assert_eq!(model.provider, ProviderId::new("test"), "{selector}"); - assert_eq!(model.id, "one", "{selector}"); - } - - assert!(matches!( - catalog.select( - "vendor/models/one:latest", - None, - &HashSet::from([ProviderId::new("test")]), - ), - Err(ModelSelectionError::UnknownSelector { selector }) - if selector == "vendor/models/one:latest" - )); - } - - #[test] - fn provider_scoped_model_rejects_redundant_provider_field() { - let error = Catalog::from_settings(&minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" - -[providers.test.models.one] -provider = "test" -"#, - )) - .unwrap_err(); - - assert!(matches!( - error, - CatalogBuildError::LegacyModel(LegacyModelError::ScopedModelDeclaresProvider { - provider, - model, - }) if provider == ProviderId::new("test") && model == "one" - )); - } - - #[test] - fn provider_scoped_model_rejects_legacy_builtin_id_as_canonical_id() { - let error = Catalog::from_settings(&minimal_settings( - r#" -[providers.openrouter] -display_name = "OpenRouter" -adapter = "openai_compatible" - -[providers.openrouter.models."openai/gpt-5.6-sol"] -"#, - )) - .unwrap_err(); - - assert!(matches!( - error, - CatalogBuildError::LegacyModel( - LegacyModelError::LegacyIdentifierAsModelId { - identifier, - provider, - model, - } - ) if identifier == "openai/gpt-5.6-sol" - && provider == ProviderId::new("openrouter") - && model == "gpt-5.6-sol" - )); - } - - #[test] - fn provider_aware_selection_uses_readiness_priority_and_api_ids() { - let catalog = portable_model_catalog(); - let openai = ProviderId::openai(); - let openrouter = ProviderId::new("openrouter"); - - let offerings = catalog - .list(None) - .into_iter() - .filter(|model| model.id.as_str() == "gpt-5.6-sol") - .collect::>(); - assert_eq!(offerings.len(), 2); - - let direct = catalog - .select("gpt-56-sol", None, &HashSet::from([openai.clone()])) - .unwrap(); - assert_eq!(direct.provider, openai); - assert_eq!(direct.id, "gpt-5.6-sol"); - assert_eq!(catalog.settings_for(direct).unwrap().api_id, "gpt-5.6-sol"); - - let aggregator = catalog - .select("gpt-56-sol", None, &HashSet::from([openrouter.clone()])) - .unwrap(); - assert_eq!(aggregator.provider, openrouter); - assert_eq!(aggregator.id, "gpt-5.6-sol"); - assert_eq!( - catalog.settings_for(aggregator).unwrap().api_id, - "openai/gpt-5.6-sol" - ); - - let both = HashSet::from([ProviderId::openai(), ProviderId::new("openrouter")]); - assert_eq!( - catalog.select("portable", None, &both).unwrap().provider, - ProviderId::openai() - ); - assert_eq!( - catalog - .select("portable", Some(&ProviderId::new("openrouter")), &both,) - .unwrap() - .provider, - ProviderId::new("openrouter") - ); - assert!(matches!( - catalog.select( - "portable", - Some(&ProviderId::new("openrouter")), - &HashSet::from([ProviderId::openai()]), - ), - Err(ModelSelectionError::ProviderUnavailable { provider }) - if provider == ProviderId::new("openrouter") - )); - } - - #[test] - fn selection_fallback_preserves_ready_preference_per_request() { - let catalog = portable_model_catalog(); - let openai = ProviderId::openai(); - let openrouter = ProviderId::new("openrouter"); - let ready = HashSet::from([openrouter.clone()]); - - let shared = catalog - .resolve_selection_with_catalog_fallback(Some("portable"), None, &ready) - .unwrap(); - assert_eq!(shared.provider, openrouter); - - let pinned = catalog - .resolve_selection_with_catalog_fallback(Some("portable"), Some(&openai), &ready) - .unwrap(); - assert_eq!(pinned.provider, openai); - - let unknown = catalog - .resolve_selection_with_catalog_fallback(Some("provider-private-preview"), None, &ready) - .unwrap(); - assert_eq!(unknown.provider, ProviderId::new("openrouter")); - assert_eq!(unknown.model, "provider-private-preview"); - } - - #[test] - fn legacy_builtin_selector_uses_readiness_priority_and_explicit_pins() { - let catalog = portable_model_catalog(); - let openai = ProviderId::openai(); - let openrouter = ProviderId::new("openrouter"); - let selector = "openai/gpt-5.6-sol"; - - for (eligible, expected_provider) in [ - (HashSet::from([openai.clone()]), openai.clone()), - (HashSet::from([openrouter.clone()]), openrouter.clone()), - ( - HashSet::from([openai.clone(), openrouter.clone()]), - openai.clone(), - ), - ] { - let selected = catalog - .resolve_selection(Some(selector), None, &eligible) - .unwrap(); - assert_eq!(selected.provider, expected_provider); - assert_eq!(selected.model, "gpt-5.6-sol"); - } - - let both = HashSet::from([openai, openrouter.clone()]); - let selected = catalog - .resolve_selection(Some(selector), Some(&openrouter), &both) - .unwrap(); - assert_eq!(selected.provider, openrouter); - assert_eq!(selected.model, "gpt-5.6-sol"); - } - - #[test] - fn equal_provider_priorities_use_canonical_provider_id_as_tie_breaker() { - let catalog = Catalog::from_settings(&minimal_settings( - r#" -[providers.zeta] -display_name = "Zeta" -adapter = "openai" -agent_profile = "openai" -priority = 10 - -[providers.zeta.models.zeta] -display_name = "Zeta" -family = "test" -aliases = ["shared"] -default = true - -[providers.zeta.models.zeta.limits] -context_window = 1000 - -[providers.zeta.models.zeta.features] -tools = false -vision = false -reasoning = false - -[providers.alpha] -display_name = "Alpha" -adapter = "openai" -agent_profile = "openai" -priority = 10 - -[providers.alpha.models.alpha] -display_name = "Alpha" -family = "test" -aliases = ["shared"] -default = true - -[providers.alpha.models.alpha.limits] -context_window = 1000 - -[providers.alpha.models.alpha.features] -tools = false -vision = false -reasoning = false -"#, - )) - .unwrap(); - - let eligible = HashSet::from([ProviderId::new("zeta"), ProviderId::new("alpha")]); - assert_eq!( - catalog.select("shared", None, &eligible).unwrap().provider, - ProviderId::new("alpha") - ); - } - - #[test] - fn canonical_id_wins_over_cross_provider_alias() { - let catalog = Catalog::from_settings(&minimal_settings( - r#" -[providers.direct] -display_name = "Direct" -adapter = "openai" -agent_profile = "openai" -priority = 1 - -[providers.direct.models.shared] -display_name = "Canonical Shared" -family = "test" -default = true - -[providers.direct.models.shared.limits] -context_window = 1000 - -[providers.direct.models.shared.features] -tools = false -vision = false -reasoning = false - -[providers.aggregator] -display_name = "Aggregator" -adapter = "openai" -agent_profile = "openai" -priority = 100 - -[providers.aggregator.models.other] -display_name = "Alias Shared" -family = "test" -aliases = ["shared"] -default = true - -[providers.aggregator.models.other.limits] -context_window = 1000 - -[providers.aggregator.models.other.features] -tools = false -vision = false -reasoning = false -"#, - )) - .unwrap(); - let eligible = HashSet::from([ProviderId::new("direct"), ProviderId::new("aggregator")]); - - let unqualified = catalog.select("shared", None, &eligible).unwrap(); - assert_eq!(unqualified.provider, ProviderId::new("direct")); - assert_eq!(unqualified.id, "shared"); - - let qualified = catalog - .resolve_on_provider(&ProviderId::new("aggregator"), "shared") - .unwrap(); - assert_eq!(qualified.id, "other"); - - let aggregator_only = HashSet::from([ProviderId::new("aggregator")]); - let portable_alias = catalog.select("shared", None, &aggregator_only).unwrap(); - assert_eq!(portable_alias.provider, ProviderId::new("aggregator")); - assert_eq!(portable_alias.id, "other"); - } - - #[test] - fn empty_api_id_is_rejected() { - let error = Catalog::from_settings(&minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[providers.test.models.model] -api_id = "" -display_name = "Model" -family = "test" -default = true - -[providers.test.models.model.limits] -context_window = 1000 - -[providers.test.models.model.features] -tools = false -vision = false -reasoning = false -"#, - )) - .unwrap_err(); - - assert!(matches!( - error, - CatalogBuildError::EmptyModelApiId { provider, model } - if provider == ProviderId::new("test") && model == "model" - )); - } - - #[test] - fn catalog_from_settings_filters_disabled_providers_and_models() { - let layer = minimal_settings( - r#" -[providers.enabled] -display_name = "Enabled" -adapter = "openai" -agent_profile = "openai" -enabled = true - -[providers.disabled] -enabled = false - -[models.enabled_model] -provider = "enabled" -display_name = "Enabled Model" -family = "test" -aliases = ["enabled-alias"] -default = true - -[models.enabled_model.limits] -context_window = 1000 - -[models.enabled_model.features] -tools = false -vision = false -reasoning = false - -[models.disabled_model] -provider = "enabled" -display_name = "Disabled Model" -family = "test" -aliases = ["disabled-alias"] -enabled = false - -[models.disabled_model.limits] -context_window = 1000 - -[models.disabled_model.features] -tools = false -vision = false -reasoning = false - -[models.model_on_disabled_provider] -provider = "disabled" -display_name = "Hidden" -family = "test" - -[models.model_on_disabled_provider.limits] -context_window = 1000 - -[models.model_on_disabled_provider.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert!(catalog.get("enabled_model").is_some()); - assert!(catalog.get("enabled-alias").is_some()); - assert!(catalog.get("disabled_model").is_none()); - assert!(catalog.get("disabled-alias").is_none()); - assert!(catalog.get("model_on_disabled_provider").is_none()); - assert!(catalog.provider(&ProviderId::new("disabled")).is_none()); - } - - #[test] - fn provider_priority_drives_configured_default_ordering() { - let layer = minimal_settings( - r#" -[providers.low] -display_name = "Low" -adapter = "openai" -agent_profile = "openai" -priority = 10 - -[providers.high] -display_name = "High" -adapter = "openai" -agent_profile = "openai" -priority = 20 - -[models.low_default] -provider = "low" -display_name = "Low Default" -family = "test" -default = true - -[models.low_default.limits] -context_window = 1000 - -[models.low_default.features] -tools = false -vision = false -reasoning = false - -[models.high_default] -provider = "high" -display_name = "High Default" -family = "test" -default = true - -[models.high_default.limits] -context_window = 1000 - -[models.high_default.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!(catalog.default_model().id, "high_default"); - assert_eq!( - catalog - .default_for_configured_ids(&[ProviderId::new("low"), ProviderId::new("high")]) - .id, - "high_default" - ); - assert_eq!( - catalog - .default_for_configured_ids(&[ProviderId::new("low")]) - .id, - "low_default" - ); - } - - #[test] - fn catalog_lists_models_by_provider_priority_then_model_id() { - let layer = minimal_settings( - r#" -[providers.zeta] -display_name = "Zeta" -adapter = "openai" -agent_profile = "openai" -priority = 20 - -[providers.alpha] -display_name = "Alpha" -adapter = "openai" -agent_profile = "openai" -priority = 10 - -[models.zeta_two] -provider = "zeta" -display_name = "Zeta Two" -family = "test" -default = true - -[models.zeta_two.limits] -context_window = 1000 - -[models.zeta_two.features] -tools = false -vision = false -reasoning = false - -[models.alpha_one] -provider = "alpha" -display_name = "Alpha One" -family = "test" -default = true - -[models.alpha_one.limits] -context_window = 1000 - -[models.alpha_one.features] -tools = false -vision = false -reasoning = false - -[models.zeta_one] -provider = "zeta" -display_name = "Zeta One" -family = "test" - -[models.zeta_one.limits] -context_window = 1000 - -[models.zeta_one.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - let ids = catalog - .list(None) - .into_iter() - .map(|model| model.id.as_str()) - .collect::>(); - - assert_eq!(ids, ["zeta_one", "zeta_two", "alpha_one"]); - assert_eq!(catalog.default_model().id, "zeta_two"); - } - - #[test] - fn provider_aliases_resolve_provider_scoped_catalog_methods() { - let layer = minimal_settings( - r#" -[providers.canonical] -display_name = "Canonical" -adapter = "openai" -agent_profile = "openai" -aliases = ["alias"] - -[models.default_model] -provider = "canonical" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - let alias = ProviderId::new("alias"); - let reference = catalog.get("default_model").unwrap(); - - assert_eq!( - catalog.provider(&alias).unwrap().id, - ProviderId::new("canonical") - ); - assert_eq!( - catalog.default_for_provider(&alias).unwrap().id, - "default_model" - ); - assert_eq!( - catalog - .default_for_configured_ids(std::slice::from_ref(&alias)) - .id, - "default_model" - ); - assert_eq!(catalog.list(Some(&alias))[0].id, "default_model"); - assert_eq!( - catalog.closest(&alias, reference).unwrap().id, - "default_model" - ); - } - - #[test] - fn probe_for_provider_prefers_enabled_probe_model_over_provider_default() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.probe_model] -provider = "test" -display_name = "Probe Model" -family = "test" -probe = true - -[models.probe_model.limits] -context_window = 1000 - -[models.probe_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .probe_for_provider(&ProviderId::new("test")) - .unwrap() - .id, - "probe_model" - ); - } - - #[test] - fn probe_for_provider_falls_back_to_provider_default_when_no_probe_marked() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.other_model] -provider = "test" -display_name = "Other Model" -family = "test" - -[models.other_model.limits] -context_window = 1000 - -[models.other_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .probe_for_provider(&ProviderId::new("test")) - .unwrap() - .id, - "default_model" - ); - } - - #[test] - fn probe_false_override_clears_inherited_builtin_probe_marker() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[models."gpt-5.4-mini"] -probe = false -"#, - )) - .expect("sparse built-in model override should build"); - - let openai = ProviderId::openai(); - assert_eq!( - catalog.probe_for_provider(&openai).unwrap().id, - catalog.default_for_provider(&openai).unwrap().id - ); - } - - #[test] - fn probe_for_provider_resolves_provider_alias() { - let layer = minimal_settings( - r#" -[providers.canonical] -display_name = "Canonical" -adapter = "openai" -agent_profile = "openai" -aliases = ["alias"] - -[models.default_model] -provider = "canonical" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.probe_model] -provider = "canonical" -display_name = "Probe Model" -family = "test" -probe = true - -[models.probe_model.limits] -context_window = 1000 - -[models.probe_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .probe_for_provider(&ProviderId::new("alias")) - .unwrap() - .id, - "probe_model" - ); - } - - #[test] - fn small_default_for_provider_prefers_enabled_small_default_model_over_provider_default() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.small_model] -provider = "test" -display_name = "Small Model" -family = "test" -small_default = true - -[models.small_model.limits] -context_window = 1000 - -[models.small_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .small_default_for_provider(&ProviderId::new("test")) - .unwrap() - .id, - "small_model" - ); - } - - #[test] - fn small_default_for_provider_returns_none_when_no_small_default_marked() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.other_model] -provider = "test" -display_name = "Other Model" -family = "test" - -[models.other_model.limits] -context_window = 1000 - -[models.other_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert!( - catalog - .small_default_for_provider(&ProviderId::new("test")) - .is_none() - ); - } - - #[test] - fn small_default_for_provider_resolves_provider_alias() { - let layer = minimal_settings( - r#" -[providers.canonical] -display_name = "Canonical" -adapter = "openai" -agent_profile = "openai" -aliases = ["alias"] - -[models.default_model] -provider = "canonical" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.small_model] -provider = "canonical" -display_name = "Small Model" -family = "test" -small_default = true - -[models.small_model.limits] -context_window = 1000 - -[models.small_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .small_default_for_provider(&ProviderId::new("alias")) - .unwrap() - .id, - "small_model" - ); - } - - #[test] - fn small_default_for_configured_ids_uses_highest_priority_configured_provider() { - let layer = minimal_settings( - r#" -[providers.low] -display_name = "Low" -adapter = "openai" -agent_profile = "openai" -priority = 10 - -[providers.high] -display_name = "High" -adapter = "openai" -agent_profile = "openai" -priority = 20 - -[models.low_default] -provider = "low" -display_name = "Low Default" -family = "test" -default = true - -[models.low_default.limits] -context_window = 1000 - -[models.low_default.features] -tools = false -vision = false -reasoning = false - -[models.low_small] -provider = "low" -display_name = "Low Small" -family = "test" -small_default = true - -[models.low_small.limits] -context_window = 1000 - -[models.low_small.features] -tools = false -vision = false -reasoning = false - -[models.high_default] -provider = "high" -display_name = "High Default" -family = "test" -default = true - -[models.high_default.limits] -context_window = 1000 - -[models.high_default.features] -tools = false -vision = false -reasoning = false - -[models.high_small] -provider = "high" -display_name = "High Small" -family = "test" -small_default = true - -[models.high_small.limits] -context_window = 1000 - -[models.high_small.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .small_default_for_configured_ids(&[ - ProviderId::new("low"), - ProviderId::new("high") - ]) - .id, - "high_small" - ); - assert_eq!( - catalog - .small_default_for_configured_ids(&[ProviderId::new("low")]) - .id, - "low_small" - ); - assert_eq!( - catalog.small_default_for_configured_ids(&[]).id, - catalog.default_model().id - ); - } - - #[test] - fn small_default_for_configured_ids_skips_provider_without_a_small_model() { - let layer = minimal_settings( - r#" -[providers.low] -display_name = "Low" -adapter = "openai" -agent_profile = "openai" -priority = 10 - -[providers.high] -display_name = "High" -adapter = "openai" -agent_profile = "openai" -priority = 20 - -[models.low_small] -provider = "low" -display_name = "Low Small" -family = "test" -small_default = true - -[models.low_small.limits] -context_window = 1000 - -[models.low_small.features] -tools = false -vision = false -reasoning = false - -[models.high_default] -provider = "high" -display_name = "High Default" -family = "test" -default = true - -[models.high_default.limits] -context_window = 1000 - -[models.high_default.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - // `high` outranks `low` but marks no small default, so selection moves - // on rather than substituting `high_default`. - assert_eq!( - catalog - .small_default_for_configured_ids(&[ - ProviderId::new("low"), - ProviderId::new("high") - ]) - .id, - "low_small" - ); - } - - #[test] - fn small_default_for_configured_ids_falls_back_to_provider_default() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .small_default_for_configured_ids(&[ProviderId::new("test")]) - .id, - "default_model" - ); - } - - #[test] - fn multiple_small_default_models_for_provider_fail_catalog_build() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.first_small] -provider = "test" -display_name = "First Small" -family = "test" -small_default = true - -[models.first_small.limits] -context_window = 1000 - -[models.first_small.features] -tools = false -vision = false -reasoning = false - -[models.second_small] -provider = "test" -display_name = "Second Small" -family = "test" -small_default = true - -[models.second_small.limits] -context_window = 1000 - -[models.second_small.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let err = Catalog::from_settings(&layer).unwrap_err(); - - assert!(matches!( - err, - CatalogBuildError::MultipleProviderSmallDefaults { provider, models } - if provider == ProviderId::new("test") - && models == vec!["first_small".to_string(), "second_small".to_string()] - )); - } - - #[test] - fn small_default_false_override_clears_inherited_builtin_small_default_marker() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[models."gpt-5.4-mini"] -small_default = false -"#, - )) - .expect("sparse built-in model override should build"); - - let openai = ProviderId::openai(); - assert!(catalog.small_default_for_provider(&openai).is_none()); - } - - #[test] - fn multiple_probe_models_are_non_fatal_and_select_a_probe_model() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.first_probe] -provider = "test" -display_name = "First Probe" -family = "test" -probe = true - -[models.first_probe.limits] -context_window = 1000 - -[models.first_probe.features] -tools = false -vision = false -reasoning = false - -[models.second_probe] -provider = "test" -display_name = "Second Probe" -family = "test" -probe = true - -[models.second_probe.limits] -context_window = 1000 - -[models.second_probe.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - let selected = catalog - .probe_for_provider(&ProviderId::new("test")) - .unwrap() - .id - .as_str(); - - assert!(["first_probe", "second_probe"].contains(&selected)); - assert_ne!(selected, "default_model"); - } - - #[test] - fn provider_agent_profile_overrides_adapter_default() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai_compatible" -base_url = "https://api.test/v1" -agent_profile = "anthropic" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .provider(&ProviderId::new("test")) - .unwrap() - .agent_profile, - AgentProfileKind::Anthropic - ); - assert_eq!( - catalog.effective_agent_profile(&ProviderId::new("test"), Some("default_model")), - Some(AgentProfileKind::Anthropic) - ); - } - - #[test] - fn adapter_defaults_provider_agent_profile_and_billing_policy() { - let settings = minimal_settings( - r#" -[providers.anthropic] -display_name = "Anthropic" -adapter = "anthropic" - -[providers.openai] -display_name = "OpenAI" -adapter = "openai" - -[providers.gemini] -display_name = "Gemini" -adapter = "gemini" - -[providers.compat] -display_name = "Compatible" -adapter = "openai_compatible" -"#, - ); - - let providers = build_providers(&settings).unwrap(); - let provider = |id: &str| { - providers - .iter() - .find(|provider| provider.id.as_str() == id) - .unwrap() - }; - - assert_eq!( - provider("anthropic").agent_profile, - AgentProfileKind::Anthropic - ); - assert_eq!( - provider("anthropic").billing_policy, - BillingPolicy::Anthropic - ); - assert_eq!(provider("openai").agent_profile, AgentProfileKind::OpenAi); - assert_eq!(provider("openai").billing_policy, BillingPolicy::OpenAi); - assert_eq!(provider("gemini").agent_profile, AgentProfileKind::Gemini); - assert_eq!(provider("gemini").billing_policy, BillingPolicy::Gemini); - assert_eq!(provider("compat").agent_profile, AgentProfileKind::OpenAi); - assert_eq!(provider("compat").billing_policy, BillingPolicy::OpenAi); - } - - #[test] - fn model_agent_profile_overrides_provider_profile_for_same_provider() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "anthropic" -aliases = ["alias"] - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true -agent_profile = "gemini" -aliases = ["default-alias"] - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .model_settings("default-alias") - .unwrap() - .agent_profile, - AgentProfileKind::Gemini - ); - assert_eq!( - catalog.effective_agent_profile(&ProviderId::new("alias"), Some("default-alias")), - Some(AgentProfileKind::Gemini) - ); - } - - #[test] - fn effective_agent_profile_does_not_leak_unrelated_model_override() { - let layer = minimal_settings( - r#" -[providers.one] -display_name = "One" -adapter = "openai" -agent_profile = "openai" - -[providers.two] -display_name = "Two" -adapter = "openai" -agent_profile = "anthropic" - -[models.one_model] -provider = "one" -display_name = "One Model" -family = "test" -default = true - -[models.one_model.limits] -context_window = 1000 - -[models.one_model.features] -tools = false -vision = false -reasoning = false - -[models.two_model] -provider = "two" -display_name = "Two Model" -family = "test" -default = true -agent_profile = "gemini" - -[models.two_model.limits] -context_window = 1000 - -[models.two_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog.effective_agent_profile(&ProviderId::new("one"), Some("two_model")), - Some(AgentProfileKind::OpenAi) - ); - } - - #[test] - fn effective_agent_profile_is_scoped_by_provider_for_shared_model_id() { - let layer = minimal_settings( - r#" -[providers.one] -display_name = "One" -adapter = "openai" -agent_profile = "openai" - -[providers.one.models.shared] -display_name = "Shared on One" -family = "test" -default = true - -[providers.one.models.shared.limits] -context_window = 1000 - -[providers.one.models.shared.features] -tools = false -vision = false -reasoning = false - -[providers.two] -display_name = "Two" -adapter = "openai" -agent_profile = "anthropic" - -[providers.two.models.shared] -display_name = "Shared on Two" -family = "test" -default = true -agent_profile = "gemini" - -[providers.two.models.shared.limits] -context_window = 1000 - -[providers.two.models.shared.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog.effective_agent_profile(&ProviderId::new("one"), Some("shared")), - Some(AgentProfileKind::OpenAi) - ); - assert_eq!( - catalog.effective_agent_profile(&ProviderId::new("two"), Some("shared")), - Some(AgentProfileKind::Gemini) - ); - } - - #[test] - fn omitted_agent_profile_uses_adapter_default() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "gemini" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .provider(&ProviderId::new("test")) - .unwrap() - .agent_profile, - AgentProfileKind::Gemini - ); - assert_eq!( - catalog.effective_agent_profile(&ProviderId::new("test"), Some("default_model")), - Some(AgentProfileKind::Gemini) - ); - } - - #[test] - fn provider_auth_modes_and_billing_policy_are_catalog_owned() { - let settings = minimal_settings( - r#" -[providers.bearer] -display_name = "Bearer" -adapter = "openai" - -[providers.bearer.auth] -credentials = ["env:BEARER_API_KEY", "vault:BEARER_API_KEY"] - -[providers.custom] -display_name = "Custom" -adapter = "gemini" - -[providers.custom.auth] -credentials = ["env:CUSTOM_API_KEY"] -header = { custom = "x-api-key" } - -[providers.none] -display_name = "No Auth" -adapter = "openai_compatible" -billing_policy = "none" -"#, - ); - - let providers = build_providers(&settings).unwrap(); - let provider = |id: &str| { - providers - .iter() - .find(|provider| provider.id.as_str() == id) - .unwrap() - }; - - let bearer = provider("bearer"); - assert_eq!(bearer.billing_policy, BillingPolicy::OpenAi); - assert_eq!( - bearer.auth, - Some(ProviderAuthConfig { - credentials: vec![ - CredentialRef::Env("BEARER_API_KEY".to_string()), - CredentialRef::Vault("BEARER_API_KEY".to_string()), - ], - header: ApiKeyHeaderPolicy::Bearer, - }) - ); - - let custom = provider("custom"); - assert_eq!(custom.billing_policy, BillingPolicy::Gemini); - assert_eq!( - custom.auth, - Some(ProviderAuthConfig { - credentials: vec![CredentialRef::Env("CUSTOM_API_KEY".to_string())], - header: ApiKeyHeaderPolicy::Custom { - name: "x-api-key".to_string(), - }, - }) - ); - - let no_auth = provider("none"); - assert_eq!(no_auth.billing_policy, BillingPolicy::None); - assert!(no_auth.auth.is_none()); - } - - #[test] - fn provider_auth_header_defaults_to_bearer_when_omitted() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" - -[providers.test.auth] -credentials = ["env:TEST_API_KEY"] -"#, - ); - let providers = build_providers(&settings).unwrap(); - let test = providers - .iter() - .find(|provider| provider.id.as_str() == "test") - .unwrap(); - assert_eq!( - test.auth.as_ref().unwrap().header, - ApiKeyHeaderPolicy::Bearer - ); - } - - #[test] - fn catalog_from_settings_rejects_invalid_provider_auth_configs() { - let empty_api_key_credentials = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[providers.test.auth] -credentials = [] -"#, - ); - assert!(matches!( - Catalog::from_settings(&empty_api_key_credentials).unwrap_err(), - CatalogBuildError::EmptyApiKeyCredentials { provider } - if provider == ProviderId::new("test") - )); - - let sigv4_on_openai = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[providers.test.auth] -credentials = ["aws_sigv4"] -"#, - ); - assert!(matches!( - Catalog::from_settings(&sigv4_on_openai).unwrap_err(), - CatalogBuildError::UnsupportedAwsSigv4Credential { provider, adapter } - if provider == ProviderId::new("test") && adapter == AdapterKind::OpenAi - )); - } - - #[test] - fn provider_auth_deserialization_rejects_invalid_auth_shape() { - let invalid_header = toml::from_str::( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[providers.test.auth] -credentials = ["env:TEST_API_KEY"] -header = { custom = "bad header" } -"#, - ) - .unwrap_err(); - assert!( - invalid_header - .to_string() - .contains("custom header name must be a valid HTTP header name") - ); - - let legacy_type_tag = toml::from_str::( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[providers.test.auth] -type = "api_key" -credentials = ["env:TEST_API_KEY"] -"#, - ) - .unwrap_err(); - assert!( - legacy_type_tag.to_string().contains("unknown field `type`"), - "expected unknown-field error for legacy `type` key, got: {legacy_type_tag}" - ); - } - - #[test] - fn catalog_from_settings_validates_model_controls_and_speed_costs() { - let invalid_effort = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" -default = true - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = false -vision = false -reasoning = true -reasoning_effort = "levels" - -[models.model.controls] -reasoning_effort = ["turbo"] -"#, - ); - assert!(matches!( - Catalog::from_settings(&invalid_effort).unwrap_err(), - CatalogBuildError::InvalidReasoningEffort { model, value, .. } - if model == "model" && value == "turbo" - )); - - let undeclared_speed_cost = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "anthropic" -agent_profile = "anthropic" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" -default = true - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = false -vision = false -reasoning = false - -[models.model.costs.speed.fast] -input_cost_per_mtok = 1.0 -"#, - ); - assert!(matches!( - Catalog::from_settings(&undeclared_speed_cost).unwrap_err(), - CatalogBuildError::UndeclaredSpeedCost { model, speed } - if model == "model" && speed == Speed::Fast - )); - } - - #[test] - fn catalog_from_settings_accepts_reasoning_effort_feature_levels() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" -default = true - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = true -reasoning_by_default = false -reasoning_effort = "levels" -prompt_cache = true - -[models.model.controls] -reasoning_effort = ["low", "medium"] -"#, - ); - - let catalog = Catalog::from_settings(&settings).unwrap(); - let model = catalog.get("model").unwrap(); - assert_eq!( - model.features.reasoning_effort, - crate::ReasoningEffortFeature::Levels - ); - assert!(model.features.prompt_cache); - assert!( - !catalog - .model_settings("model") - .unwrap() - .reasoning_by_default - ); - assert_eq!( - catalog - .model_settings("model") - .unwrap() - .controls - .reasoning_effort, - vec![ReasoningEffort::Low, ReasoningEffort::Medium] - ); - } - - #[test] - fn catalog_from_settings_accepts_reasoning_effort_feature_always_adaptive() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" -default = true - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -"#, - ); - - let catalog = Catalog::from_settings(&settings).unwrap(); - let model = catalog.get("model").unwrap(); - assert_eq!( - model.features.reasoning_effort, - crate::ReasoningEffortFeature::AlwaysAdaptive - ); - assert!(model.supports_reasoning_effort()); - assert!( - catalog - .model_settings("model") - .unwrap() - .reasoning_by_default - ); - // Always-adaptive models get the full default effort controls, same as - // Levels. - assert_eq!( - catalog - .model_settings("model") - .unwrap() - .controls - .reasoning_effort, - ReasoningEffort::VARIANTS.to_vec() - ); - } - - #[test] - fn catalog_from_settings_accepts_reasoning_effort_controls_without_native_effort_feature() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" -default = true - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = true -reasoning_by_default = true -reasoning_effort = "none" - -[models.model.controls] -reasoning_effort = ["low"] -"#, - ); - - let catalog = Catalog::from_settings(&settings).unwrap(); - let model = catalog.get("model").unwrap(); - assert_eq!( - model.features.reasoning_effort, - crate::ReasoningEffortFeature::None - ); - assert!( - catalog - .model_settings("model") - .unwrap() - .reasoning_by_default - ); - assert_eq!( - catalog - .model_settings("model") - .unwrap() - .controls - .reasoning_effort, - vec![ReasoningEffort::Low] - ); - } - - #[test] - fn catalog_from_settings_rejects_reasoning_effort_controls_without_reasoning() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = false -reasoning_effort = "none" - -[models.model.controls] -reasoning_effort = ["low"] -"#, - ); - - assert!(matches!( - Catalog::from_settings(&settings).unwrap_err(), - CatalogBuildError::ReasoningEffortControlsWithoutReasoning { model } - if model == "model" - )); - } - - #[test] - fn catalog_from_settings_rejects_reasoning_effort_feature_without_reasoning() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = false -reasoning_effort = "levels" -"#, - ); - - assert!(matches!( - Catalog::from_settings(&settings).unwrap_err(), - CatalogBuildError::ReasoningEffortWithoutReasoning { model } - if model == "model" - )); - } - - #[test] - fn catalog_from_settings_rejects_default_reasoning_without_reasoning() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = false -reasoning_by_default = true -"#, - ); - - assert!(matches!( - Catalog::from_settings(&settings).unwrap_err(), - CatalogBuildError::DefaultReasoningWithoutReasoning { model } - if model == "model" - )); - } - - #[test] - fn catalog_from_settings_rejects_cache_control_breakpoints_without_prompt_cache() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://example.test/v1" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = false -cache_control_breakpoints = true -"#, - ); - - assert!(matches!( - Catalog::from_settings(&settings).unwrap_err(), - CatalogBuildError::CacheControlBreakpointsWithoutPromptCache { model } - if model == "model" - )); - } - - #[test] - fn catalog_from_settings_rejects_always_adaptive_effort_without_reasoning() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" -default = true - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = false -reasoning_effort = "always_adaptive" -"#, - ); - - assert!(matches!( - Catalog::from_settings(&settings).unwrap_err(), - CatalogBuildError::ReasoningEffortWithoutReasoning { model } - if model == "model" - )); - } - - #[test] - fn catalog_from_settings_sampling_params_defaults_true_and_accepts_false() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.with-sampling] -provider = "test" -display_name = "With" -family = "test" -default = true - -[models.with-sampling.limits] -context_window = 1000 - -[models.with-sampling.features] -tools = true -vision = false -reasoning = false - -[models.no-sampling] -provider = "test" -display_name = "Without" -family = "test" - -[models.no-sampling.limits] -context_window = 1000 - -[models.no-sampling.features] -tools = true -vision = false -reasoning = false -sampling_params = false -"#, - ); - - let catalog = Catalog::from_settings(&settings).unwrap(); - assert!( - catalog - .get("with-sampling") - .unwrap() - .features - .sampling_params - ); - assert!(!catalog.get("no-sampling").unwrap().features.sampling_params); - } - - // ---- Provider / catalog data integrity tests ---- - - #[test] - fn every_provider_has_catalog_models() { - let catalog = Catalog::builtin(); - for provider in catalog.providers() { - let models = catalog.list(Some(&provider.id)); - assert!( - !models.is_empty(), - "Provider {:?} has no models in catalog", - provider.id, - ); - } - } - - #[test] - fn every_provider_has_exactly_one_default_model() { - let catalog = Catalog::builtin(); - for provider in catalog.providers() { - let defaults: Vec<_> = catalog - .list(Some(&provider.id)) - .into_iter() - .filter(|m| m.default) - .collect(); - assert_eq!( - defaults.len(), - 1, - "Provider {:?} should have exactly one default model, found {}: {:?}", - provider.id, - defaults.len(), - defaults.iter().map(|m| &m.id).collect::>() - ); - } - } - - #[test] - fn every_catalog_model_provider_has_catalog_provider() { - let catalog = Catalog::builtin(); - for model in catalog.list(None) { - assert!( - catalog.provider(&model.provider).is_some(), - "catalog model '{}' provider {:?} has no provider metadata", - model.id, - model.provider, - ); - } - } - - // ---- Model info snapshot tests ---- - - #[test] - fn get_model_info_by_id() { - let info = Catalog::builtin().get("claude-opus-4-6").unwrap(); - insta::assert_debug_snapshot!(info, @r#" - Model { - id: "claude-opus-4-6", - provider: anthropic, - family: "claude-4", - display_name: "Claude Opus 4.6", - limits: ModelLimits { - context_window: 1000000, - max_output: Some( - 128000, - ), - }, - training: Some( - "2025-08-01", - ), - knowledge_cutoff: Some( - "May 2025", - ), - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - Medium, - High, - XHigh, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 5.0, - ), - output_cost_per_mtok: Some( - 25.0, - ), - cache_input_cost_per_mtok: Some( - 0.5, - ), - }, - estimated_output_tps: Some( - 25.0, - ), - aliases: [], - default: false, - small_default: false, - configured: false, - } - "#); - } - - #[test] - fn get_model_info_returns_none_for_unknown() { - assert!(Catalog::builtin().get("nonexistent-model").is_none()); - } - - #[test] - fn kimi_k2_5_in_catalog() { - let m = Catalog::builtin().get("kimi-k2.5").unwrap(); - insta::assert_debug_snapshot!(m, @r#" - Model { - id: "kimi-k2.5", - provider: moonshot, - family: "kimi-k2", - display_name: "Kimi K2.5", - limits: ModelLimits { - context_window: 262144, - max_output: Some( - 32768, - ), - }, - training: Some( - "2025-10-01", - ), - knowledge_cutoff: Some( - "October 2025", - ), - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: None, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: false, - }, - controls: ModelControls { - reasoning_effort: [], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 0.6, - ), - output_cost_per_mtok: Some( - 3.0, - ), - cache_input_cost_per_mtok: Some( - 0.1, - ), - }, - estimated_output_tps: Some( - 50.0, - ), - aliases: [], - default: false, - small_default: false, - configured: false, - } - "#); - } - - #[test] - fn kimi_k3_in_catalog() { - let catalog = Catalog::builtin(); - let m = catalog.get("kimi-k3").unwrap(); - insta::assert_debug_snapshot!(m, @r#" - Model { - id: "kimi-k3", - provider: moonshot, - family: "kimi-k3", - display_name: "Kimi K3", - limits: ModelLimits { - context_window: 1048576, - max_output: Some( - 131072, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: AlwaysAdaptive, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: false, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - High, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 3.0, - ), - output_cost_per_mtok: Some( - 15.0, - ), - cache_input_cost_per_mtok: Some( - 0.3, - ), - }, - estimated_output_tps: None, - aliases: [ - "kimi", - ], - default: true, - small_default: false, - configured: false, - } - "#); - assert_eq!( - catalog - .model_settings("kimi-k3") - .unwrap() - .controls - .reasoning_effort, - vec![ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ] - ); - } - - #[test] - fn kimi_alias() { - assert_eq!(Catalog::builtin().get("kimi").unwrap().id, "kimi-k3"); - } - - #[test] - fn glm_4_7_in_catalog() { - let m = Catalog::builtin().get("glm-4.7").unwrap(); - assert_eq!(m.provider, ProviderId::new("zai")); - assert_eq!(Catalog::builtin().get("glm4").unwrap().id, "glm-4.7"); - } - - #[test] - fn glm_5_2_in_catalog() { - let catalog = Catalog::builtin(); - let model = catalog.get("glm-5.2").expect("GLM 5.2 should be present"); - insta::assert_debug_snapshot!(model, @r#" - Model { - id: "glm-5.2", - provider: zai, - family: "glm-5", - display_name: "GLM 5.2", - limits: ModelLimits { - context_window: 1048576, - max_output: Some( - 131072, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: false, - reasoning: true, - reasoning_effort: Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls { - reasoning_effort: [ - High, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 1.4, - ), - output_cost_per_mtok: Some( - 4.4, - ), - cache_input_cost_per_mtok: Some( - 0.26, - ), - }, - estimated_output_tps: None, - aliases: [ - "glm", - "glm5", - "glm52", - "glm5.2", - ], - default: true, - small_default: false, - configured: false, - } - "#); - - let settings = catalog - .model_settings("glm-5.2") - .expect("GLM 5.2 settings should be present"); - assert_eq!(settings.api_id, "glm-5.2"); - assert_eq!(settings.controls.reasoning_effort, vec![ - ReasoningEffort::High, - ReasoningEffort::Max - ]); - assert_eq!(catalog.get("glm").unwrap().id, "glm-5.2"); - assert_eq!(catalog.get("glm5").unwrap().id, "glm-5.2"); - assert_eq!(catalog.get("glm52").unwrap().id, "glm-5.2"); - assert_eq!(catalog.get("glm5.2").unwrap().id, "glm-5.2"); - } - - #[test] - fn minimax_m2_5_in_catalog() { - let m = Catalog::builtin().get("minimax-m2.5").unwrap(); - assert_eq!(m.provider, ProviderId::new("minimax")); - } - - #[test] - fn mercury_2_in_catalog() { - let m = Catalog::builtin().get("mercury-2").unwrap(); - insta::assert_debug_snapshot!(m, @r#" - Model { - id: "mercury-2", - provider: inception, - family: "mercury", - display_name: "Mercury 2", - limits: ModelLimits { - context_window: 131072, - max_output: Some( - 50000, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: false, - reasoning: true, - reasoning_effort: Levels, - prompt_cache: false, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - Medium, - High, - XHigh, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 0.25, - ), - output_cost_per_mtok: Some( - 0.75, - ), - cache_input_cost_per_mtok: None, - }, - estimated_output_tps: Some( - 1000.0, - ), - aliases: [ - "mercury", - ], - default: true, - small_default: false, - configured: false, - } - "#); - } - - #[test] - fn mercury_alias_resolves_to_mercury_2() { - assert_eq!(Catalog::builtin().get("mercury").unwrap().id, "mercury-2"); - } - - #[test] - fn gpt_5_4_pro_in_catalog() { - let m = Catalog::builtin().get("gpt-5.4-pro").unwrap(); - insta::assert_debug_snapshot!(m, @r#" - Model { - id: "gpt-5.4-pro", - provider: openai, - family: "gpt-5", - display_name: "GPT-5.4 Pro", - limits: ModelLimits { - context_window: 1047576, - max_output: Some( - 128000, - ), - }, - training: Some( - "2025-08-31", - ), - knowledge_cutoff: Some( - "April 2025", - ), - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: Levels, - prompt_cache: false, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - Medium, - High, - XHigh, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 30.0, - ), - output_cost_per_mtok: Some( - 180.0, - ), - cache_input_cost_per_mtok: Some( - 3.0, - ), - }, - estimated_output_tps: Some( - 20.0, - ), - aliases: [ - "gpt54-pro", - "gpt-54-pro", - ], - default: false, - small_default: false, - configured: false, - } - "#); - } - - #[test] - fn gpt54_alias() { - assert_eq!(Catalog::builtin().get("gpt54").unwrap().id, "gpt-5.4"); - } - - #[test] - fn gpt_54_hyphenated_alias() { - assert_eq!(Catalog::builtin().get("gpt-54").unwrap().id, "gpt-5.4"); - } - - #[test] - fn gpt_54_pro_hyphenated_alias() { - assert_eq!( - Catalog::builtin().get("gpt-54-pro").unwrap().id, - "gpt-5.4-pro" - ); - } - - #[test] - fn gpt_54_mini_hyphenated_alias() { - assert_eq!( - Catalog::builtin().get("gpt-54-mini").unwrap().id, - "gpt-5.4-mini" - ); - } - - #[test] - fn openai_codex_default_context_windows_match_codex_catalog() { - let catalog = Catalog::builtin(); - - for model in [ - "gpt-5.2", - "gpt-5.3-codex", - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.5", - "gpt-5.6-luna", - "gpt-5.6-sol", - "gpt-5.6-terra", - ] { - assert_eq!( - catalog.get(model).unwrap().context_window(), - 272_000, - "{model} should use the Codex-safe default context window" - ); - } - } - - #[test] - fn openai_context_window_can_be_overridden_for_direct_api_usage() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[providers.openai.models."gpt-5.5".limits] -context_window = 1050000 -"#, - )) - .expect("sparse built-in model limit override should build"); - - let model = catalog.get("gpt-5.5").unwrap(); - assert_eq!(model.context_window(), 1_050_000); - assert_eq!(model.max_output(), Some(128_000)); - } - - // ---- Closest model tests ---- - - #[test] - fn closest_model_sonnet_to_gemini() { - let sonnet = Catalog::builtin().get("claude-sonnet-4-5").unwrap(); - let result = Catalog::builtin() - .closest(&ProviderId::gemini(), sonnet) - .unwrap(); - assert_eq!(result.id, "gemini-3.1-pro-preview"); - } - - #[test] - fn closest_model_haiku_to_moonshot() { - let haiku = Catalog::builtin().get("claude-haiku-4-5").unwrap(); - assert!( - Catalog::builtin() - .closest(&ProviderId::new("moonshot"), haiku) - .is_none() - ); - } - - #[test] - fn closest_model_no_capability_match() { - let glm = Catalog::builtin().get("glm-4.7").unwrap(); - assert!( - Catalog::builtin() - .closest(&ProviderId::gemini(), glm) - .is_none() - ); - } - - // ---- Cost tests ---- - - #[test] - fn model_info_costs() { - let claude = Catalog::builtin().get("claude-opus-4-6").unwrap(); - assert_eq!(claude.costs.input_cost_per_mtok, Some(5.0)); - assert_eq!(claude.costs.output_cost_per_mtok, Some(25.0)); - - let sonnet = Catalog::builtin().get("claude-sonnet-4-5").unwrap(); - assert_eq!(sonnet.costs.input_cost_per_mtok, Some(3.0)); - } -} diff --git a/lib/foundation/fabro-model/src/catalog/providers/anthropic.toml b/lib/foundation/fabro-model/src/catalog/providers/anthropic.toml deleted file mode 100644 index 6d0adb16c..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/anthropic.toml +++ /dev/null @@ -1,261 +0,0 @@ -[providers.anthropic] -display_name = "Anthropic" -adapter = "anthropic" -api_key_url = "https://console.anthropic.com/settings/keys" -base_url = "https://api.anthropic.com/v1" -priority = 100 - -[providers.anthropic.auth] -credentials = ["env:ANTHROPIC_API_KEY", "vault:ANTHROPIC_API_KEY"] -header = { custom = "x-api-key" } - -[providers.anthropic.models."claude-fable-5"] -display_name = "Claude Fable 5" -family = "claude-5" -aliases = ["fable", "claude-fable"] -agent_profile = "claude-5" - -[providers.anthropic.models."claude-fable-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.anthropic.models."claude-fable-5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -sampling_params = false - -[providers.anthropic.models."claude-fable-5".costs] -input_cost_per_mtok = 10.0 -output_cost_per_mtok = 50.0 -cache_input_cost_per_mtok = 1.0 - -[providers.anthropic.models."claude-opus-5"] -display_name = "Claude Opus 5" -family = "claude-5" -training = "2026-05-01" -knowledge_cutoff = "May 2026" -aliases = ["opus", "claude-opus"] -agent_profile = "claude-5" - -[providers.anthropic.models."claude-opus-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.anthropic.models."claude-opus-5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -[providers.anthropic.models."claude-opus-5".controls] -speed = ["fast"] - -[providers.anthropic.models."claude-opus-5".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 25.0 -cache_input_cost_per_mtok = 0.5 - -[providers.anthropic.models."claude-opus-5".costs.speed.fast] -input_cost_per_mtok = 10.0 -output_cost_per_mtok = 50.0 -cache_input_cost_per_mtok = 1.0 - -[providers.anthropic.models."claude-sonnet-5"] -display_name = "Claude Sonnet 5" -family = "claude-5" -training = "2026-01-01" -knowledge_cutoff = "Jan 2026" -default = true -aliases = ["sonnet", "claude-sonnet"] -agent_profile = "claude-5" - -[providers.anthropic.models."claude-sonnet-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.anthropic.models."claude-sonnet-5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -# Introductory pricing through August 31, 2026. -[providers.anthropic.models."claude-sonnet-5".costs] -input_cost_per_mtok = 2.0 -output_cost_per_mtok = 10.0 -cache_input_cost_per_mtok = 0.2 - -[providers.anthropic.models."claude-opus-4-8"] -display_name = "Claude Opus 4.8" -family = "claude-4" -training = "2026-01-01" -knowledge_cutoff = "Jan 2026" -estimated_output_tps = 25 - -[providers.anthropic.models."claude-opus-4-8".limits] -context_window = 1000000 -max_output = 128000 - -[providers.anthropic.models."claude-opus-4-8".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -[providers.anthropic.models."claude-opus-4-8".controls] -speed = ["fast"] - -[providers.anthropic.models."claude-opus-4-8".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 25.0 -cache_input_cost_per_mtok = 0.5 - -[providers.anthropic.models."claude-opus-4-8".costs.speed.fast] -input_cost_per_mtok = 10.0 -output_cost_per_mtok = 50.0 -cache_input_cost_per_mtok = 1.0 - -[providers.anthropic.models."claude-opus-4-7"] -display_name = "Claude Opus 4.7" -family = "claude-4" -training = "2025-08-01" -knowledge_cutoff = "May 2025" -estimated_output_tps = 25 - -[providers.anthropic.models."claude-opus-4-7".limits] -context_window = 1000000 -max_output = 128000 - -[providers.anthropic.models."claude-opus-4-7".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -[providers.anthropic.models."claude-opus-4-7".controls] -speed = ["fast"] - -[providers.anthropic.models."claude-opus-4-7".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 25.0 -cache_input_cost_per_mtok = 0.5 - -[providers.anthropic.models."claude-opus-4-7".costs.speed.fast] -input_cost_per_mtok = 30.0 -output_cost_per_mtok = 150.0 -cache_input_cost_per_mtok = 3.0 - -[providers.anthropic.models."claude-opus-4-6"] -display_name = "Claude Opus 4.6" -family = "claude-4" -training = "2025-08-01" -knowledge_cutoff = "May 2025" -estimated_output_tps = 25 - -[providers.anthropic.models."claude-opus-4-6".limits] -context_window = 1000000 -max_output = 128000 - -[providers.anthropic.models."claude-opus-4-6".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[providers.anthropic.models."claude-opus-4-6".controls] -speed = ["fast"] - -[providers.anthropic.models."claude-opus-4-6".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 25.0 -cache_input_cost_per_mtok = 0.5 - -[providers.anthropic.models."claude-opus-4-6".costs.speed.fast] -input_cost_per_mtok = 30.0 -output_cost_per_mtok = 150.0 -cache_input_cost_per_mtok = 3.0 - -[providers.anthropic.models."claude-sonnet-4-5"] -display_name = "Claude Sonnet 4.5" -family = "claude-4" -training = "2025-08-01" -knowledge_cutoff = "May 2025" -estimated_output_tps = 50 - -[providers.anthropic.models."claude-sonnet-4-5".limits] -context_window = 200000 -max_output = 64000 - -[providers.anthropic.models."claude-sonnet-4-5".features] -tools = true -vision = true -reasoning = true -prompt_cache = true - -[providers.anthropic.models."claude-sonnet-4-5".controls] -reasoning_effort = ["low", "medium", "high", "xhigh", "max"] - -[providers.anthropic.models."claude-sonnet-4-5".costs] -input_cost_per_mtok = 3.0 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.3 - -[providers.anthropic.models."claude-sonnet-4-6"] -display_name = "Claude Sonnet 4.6" -family = "claude-4" -training = "2025-08-01" -knowledge_cutoff = "May 2025" -estimated_output_tps = 50 - -[providers.anthropic.models."claude-sonnet-4-6".limits] -context_window = 200000 -max_output = 64000 - -[providers.anthropic.models."claude-sonnet-4-6".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[providers.anthropic.models."claude-sonnet-4-6".costs] -input_cost_per_mtok = 3.0 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.3 - -[providers.anthropic.models."claude-haiku-4-5"] -display_name = "Claude Haiku 4.5" -family = "claude-4" -training = "2025-08-01" -knowledge_cutoff = "May 2025" -estimated_output_tps = 100 -aliases = ["haiku", "claude-haiku"] -probe = true -small_default = true - -[providers.anthropic.models."claude-haiku-4-5".limits] -context_window = 200000 -max_output = 8192 - -[providers.anthropic.models."claude-haiku-4-5".features] -tools = true -vision = true -reasoning = false -prompt_cache = true - -[providers.anthropic.models."claude-haiku-4-5".costs] -input_cost_per_mtok = 0.8 -output_cost_per_mtok = 4.0 -cache_input_cost_per_mtok = 0.08 diff --git a/lib/foundation/fabro-model/src/catalog/providers/bedrock-openai.toml b/lib/foundation/fabro-model/src/catalog/providers/bedrock-openai.toml deleted file mode 100644 index 21384d9b2..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/bedrock-openai.toml +++ /dev/null @@ -1,75 +0,0 @@ -[providers.bedrock-openai] -display_name = "Amazon Bedrock (OpenAI frontier)" -adapter = "openai" -api_key_url = "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html" -base_url = "https://bedrock-mantle.us-east-1.api.aws/openai/v1" -priority = 19 -enabled = false - -[providers.bedrock-openai.auth] -credentials = [ - "env:AWS_BEARER_TOKEN_BEDROCK", - "env:BEDROCK_API_KEY", - "vault:AWS_BEARER_TOKEN_BEDROCK", - "vault:BEDROCK_API_KEY", -] - -# OpenAI's frontier models on Bedrock (GPT-5.5/5.4) are served ONLY by the -# bedrock-mantle endpoint's OpenAI Responses API — they are not reachable -# through Converse or InvokeModel on bedrock-runtime. That surface speaks -# the openai_responses dialect with a Bedrock API key as the bearer token, -# so this companion provider row is pure configuration over the existing -# openai adapter: same AWS account and key as the `bedrock` provider, a -# different endpoint and wire dialect. -# -# Notes: -# - Auth is Bedrock-API-key only on this row (SigV4 on mantle uses the -# `bedrock-mantle` signing name, which the openai adapter does not do). -# - bedrock-mantle is regional (13 regions); change base_url to -# `https://bedrock-mantle..api.aws/openai/v1` as needed. -# - Responses state: Fabro always sends `store: false`, so nothing is -# retained under mantle's default 30-day Project retention. -# -# To enable, add to ~/.fabro/settings.toml: -# -# [llm.providers.bedrock-openai] -# enabled = true - -[providers.bedrock-openai.models."gpt-5.5"] -api_id = "openai.gpt-5.5" -display_name = "GPT-5.5 (Bedrock)" -family = "gpt-5" -default = true - -[providers.bedrock-openai.models."gpt-5.5".limits] -context_window = 272000 -max_output = 128000 - -[providers.bedrock-openai.models."gpt-5.5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.bedrock-openai.models."gpt-5.5".costs] -input_cost_per_mtok = 5.5 -output_cost_per_mtok = 33.0 - -[providers.bedrock-openai.models."gpt-5.4"] -api_id = "openai.gpt-5.4" -display_name = "GPT-5.4 (Bedrock)" -family = "gpt-5" - -[providers.bedrock-openai.models."gpt-5.4".limits] -context_window = 272000 -max_output = 128000 - -[providers.bedrock-openai.models."gpt-5.4".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.bedrock-openai.models."gpt-5.4".costs] -input_cost_per_mtok = 2.75 -output_cost_per_mtok = 16.5 diff --git a/lib/foundation/fabro-model/src/catalog/providers/bedrock.toml b/lib/foundation/fabro-model/src/catalog/providers/bedrock.toml deleted file mode 100644 index 401037c42..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/bedrock.toml +++ /dev/null @@ -1,409 +0,0 @@ -[providers.bedrock] -display_name = "Amazon Bedrock" -adapter = "bedrock" -api_key_url = "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html" -base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" -priority = 20 -enabled = false - -[providers.bedrock.auth] -# An explicit Bedrock API key wins (from the process env, or the server -# vault via `fabro secret set `, matching every other provider's -# env-then-vault order); SigV4 (the AWS default credential chain, resolved -# at request time) is the fallback. `aws_sigv4` always resolves, which is -# why this provider ships disabled: enabling it is the operator's statement -# that AWS credentials are expected to work. The key is read from either -# `AWS_BEARER_TOKEN_BEDROCK` (the AWS-canonical name, also honored by the -# AWS SDKs/CLI) or `BEDROCK_API_KEY` (Fabro's `_API_KEY` -# convention); the env names are checked before the vault. -credentials = [ - "env:AWS_BEARER_TOKEN_BEDROCK", - "env:BEDROCK_API_KEY", - "vault:AWS_BEARER_TOKEN_BEDROCK", - "vault:BEDROCK_API_KEY", - "aws_sigv4", -] - -# To enable Bedrock, add the following to ~/.fabro/settings.toml: -# -# [llm.providers.bedrock] -# enabled = true -# base_url = "https://bedrock-runtime..amazonaws.com" -# -# The signing region is derived from the base_url. Authenticate with -# either a Bedrock API key (AWS_BEARER_TOKEN_BEDROCK or BEDROCK_API_KEY) or -# any AWS default credential chain source (env keys, profile, IMDS, IRSA, SSO). -# -# Model ids use cross-region inference profiles (`us.` / `global.` -# prefixes) where on-demand access requires them. Pricing rows are -# best-effort estimates from June 2026 list prices. - -# ---------- Anthropic Claude ---------- -# -# Claude bills Anthropic-style cache reads/writes, so these rows override -# the provider's billing default. Claude 5 models appear at the end of this -# file because their Bedrock deployments pin sampling parameters and require -# extra endpoint-specific handling. - -[providers.bedrock.models."claude-sonnet-4-6"] -api_id = "us.anthropic.claude-sonnet-4-6" -display_name = "Claude Sonnet 4.6 (Bedrock)" -family = "claude-4" -billing_policy = "anthropic" - -[providers.bedrock.models."claude-sonnet-4-6".limits] -context_window = 1000000 -max_output = 64000 - -[providers.bedrock.models."claude-sonnet-4-6".features] -tools = true -vision = true -reasoning = true -prompt_cache = true - -[providers.bedrock.models."claude-sonnet-4-6".costs] -input_cost_per_mtok = 3.0 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.3 - -[providers.bedrock.models."claude-opus-4-8"] -api_id = "us.anthropic.claude-opus-4-8" -display_name = "Claude Opus 4.8 (Bedrock)" -family = "claude-4" -billing_policy = "anthropic" - -[providers.bedrock.models."claude-opus-4-8".limits] -context_window = 1000000 -max_output = 128000 - -[providers.bedrock.models."claude-opus-4-8".features] -tools = true -vision = true -reasoning = true -prompt_cache = true - -[providers.bedrock.models."claude-opus-4-8".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 25.0 -cache_input_cost_per_mtok = 0.5 - -[providers.bedrock.models."claude-haiku-4-5"] -api_id = "us.anthropic.claude-haiku-4-5-20251001-v1:0" -display_name = "Claude Haiku 4.5 (Bedrock)" -family = "claude-4" -billing_policy = "anthropic" -small_default = true - -[providers.bedrock.models."claude-haiku-4-5".limits] -context_window = 200000 -max_output = 64000 - -[providers.bedrock.models."claude-haiku-4-5".features] -tools = true -vision = true -reasoning = false -prompt_cache = true - -[providers.bedrock.models."claude-haiku-4-5".costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 5.0 -cache_input_cost_per_mtok = 0.1 - -# ---------- OpenAI open-weights ---------- -# -# GPT-5.5/5.4 are NOT here: on Bedrock they are Responses-API-only on the -# bedrock-mantle endpoint (no Converse), a named follow-up route. - -[providers.bedrock.models."gpt-oss-120b"] -api_id = "openai.gpt-oss-120b-1:0" -display_name = "GPT-OSS 120B (Bedrock)" -family = "gpt-oss" -billing_policy = "openai" -agent_profile = "openai" - -[providers.bedrock.models."gpt-oss-120b".limits] -context_window = 128000 -max_output = 16384 - -[providers.bedrock.models."gpt-oss-120b".features] -tools = true -vision = false -reasoning = true - -[providers.bedrock.models."gpt-oss-120b".costs] -input_cost_per_mtok = 0.15 -output_cost_per_mtok = 0.60 - -[providers.bedrock.models."gpt-oss-20b"] -api_id = "openai.gpt-oss-20b-1:0" -display_name = "GPT-OSS 20B (Bedrock)" -family = "gpt-oss" -billing_policy = "openai" -agent_profile = "openai" - -[providers.bedrock.models."gpt-oss-20b".limits] -context_window = 128000 -max_output = 16384 - -[providers.bedrock.models."gpt-oss-20b".features] -tools = true -vision = false -reasoning = true - -[providers.bedrock.models."gpt-oss-20b".costs] -input_cost_per_mtok = 0.07 -output_cost_per_mtok = 0.30 - -# ---------- Amazon Nova ---------- - -[providers.bedrock.models."nova-2-lite"] -api_id = "global.amazon.nova-2-lite-v1:0" -display_name = "Nova 2 Lite (Bedrock)" -family = "nova-2" -billing_policy = "openai" -agent_profile = "openai" - -[providers.bedrock.models."nova-2-lite".limits] -context_window = 1000000 -# Bedrock caps Nova output at 65535 (2^16 - 1); 65536 trips -# "maximum tokens exceeds the model limit of 65535" since the prompt handler -# defaults max_tokens to max_output. -max_output = 65535 - -[providers.bedrock.models."nova-2-lite".features] -tools = true -vision = true -reasoning = false - -[providers.bedrock.models."nova-2-lite".costs] -input_cost_per_mtok = 0.30 -output_cost_per_mtok = 2.50 - -# ---------- Open-weights ---------- - -[providers.bedrock.models."llama-4-maverick"] -api_id = "us.meta.llama4-maverick-17b-instruct-v1:0" -display_name = "Llama 4 Maverick (Bedrock)" -family = "llama-4" -billing_policy = "openai" -agent_profile = "openai" - -[providers.bedrock.models."llama-4-maverick".limits] -context_window = 1000000 -max_output = 8192 - -[providers.bedrock.models."llama-4-maverick".features] -tools = true -vision = true -reasoning = false - -[providers.bedrock.models."mistral-large-3"] -api_id = "mistral.mistral-large-3-675b-instruct" -display_name = "Mistral Large 3 (Bedrock)" -family = "mistral-large" -billing_policy = "openai" -agent_profile = "openai" - -[providers.bedrock.models."mistral-large-3".limits] -context_window = 256000 -max_output = 32768 - -[providers.bedrock.models."mistral-large-3".features] -tools = true -vision = true -reasoning = false - -[providers.bedrock.models."mistral-large-3".costs] -input_cost_per_mtok = 0.50 -output_cost_per_mtok = 1.50 - -[providers.bedrock.models."devstral-2"] -api_id = "mistral.devstral-2-123b" -display_name = "Devstral 2 (Bedrock)" -family = "devstral" -billing_policy = "openai" -agent_profile = "openai" - -[providers.bedrock.models."devstral-2".limits] -context_window = 256000 -max_output = 32768 - -[providers.bedrock.models."devstral-2".features] -tools = true -vision = false -reasoning = false - -[providers.bedrock.models."deepseek-v3.2"] -api_id = "deepseek.v3.2" -display_name = "DeepSeek V3.2 (Bedrock)" -family = "deepseek-v3" -billing_policy = "openai" -agent_profile = "openai" - -[providers.bedrock.models."deepseek-v3.2".limits] -context_window = 164000 -max_output = 8192 - -[providers.bedrock.models."deepseek-v3.2".features] -tools = true -vision = false -reasoning = true - -[providers.bedrock.models."deepseek-v3.2".costs] -input_cost_per_mtok = 0.62 -output_cost_per_mtok = 1.85 - -# Qwen3 Coder Next: omitted pending a verified Bedrock model/inference-profile -# id. The fabro id is not itself a valid Bedrock identifier (Converse returns -# "The provided model identifier is invalid"), so this row needs an explicit -# `api_id` confirmed against `aws bedrock list-inference-profiles` before it -# ships. Re-add with: -# [providers.bedrock.models."qwen3-coder-next"] -# api_id = "" -# display_name = "Qwen3 Coder Next (Bedrock)" -# family = "qwen3" -# billing_policy = "openai" -# agent_profile = "openai" -# [providers.bedrock.models."qwen3-coder-next".limits] -# context_window = 256000 -# max_output = 16384 -# [providers.bedrock.models."qwen3-coder-next".features] -# tools = true - -[providers.bedrock.models."kimi-k2.5"] -api_id = "moonshotai.kimi-k2.5" -display_name = "Kimi K2.5 (Bedrock)" -family = "kimi-k2" -billing_policy = "openai" -agent_profile = "openai" - -[providers.bedrock.models."kimi-k2.5".limits] -context_window = 262144 -max_output = 16384 - -[providers.bedrock.models."kimi-k2.5".features] -tools = true -vision = true -reasoning = false - -[providers.bedrock.models."kimi-k2.5".costs] -input_cost_per_mtok = 0.60 -output_cost_per_mtok = 3.00 - -[providers.bedrock.models."glm-5"] -api_id = "zai.glm-5" -display_name = "GLM 5 (Bedrock)" -family = "glm" -billing_policy = "openai" -agent_profile = "openai" - -[providers.bedrock.models."glm-5".limits] -context_window = 200000 -max_output = 128000 - -[providers.bedrock.models."glm-5".features] -tools = true -vision = false -reasoning = false - -[providers.bedrock.models."glm-5".costs] -input_cost_per_mtok = 1.00 -output_cost_per_mtok = 3.20 - -[providers.bedrock.models."minimax-m2.5"] -api_id = "minimax.minimax-m2.5" -display_name = "MiniMax M2.5 (Bedrock)" -family = "minimax-m2" -billing_policy = "openai" -agent_profile = "openai" - -[providers.bedrock.models."minimax-m2.5".limits] -context_window = 196000 -max_output = 8192 - -[providers.bedrock.models."minimax-m2.5".features] -tools = true -vision = false -reasoning = false - -[providers.bedrock.models."minimax-m2.5".costs] -input_cost_per_mtok = 0.30 -output_cost_per_mtok = 1.20 - -[providers.bedrock.models."nemotron-3-super"] -api_id = "nvidia.nemotron-super-3-120b" -display_name = "Nemotron 3 Super (Bedrock)" -family = "nemotron-3" -billing_policy = "openai" -agent_profile = "openai" - -[providers.bedrock.models."nemotron-3-super".limits] -context_window = 256000 -max_output = 32768 - -[providers.bedrock.models."nemotron-3-super".features] -tools = true -vision = false -reasoning = false - -# Claude Fable 5: adaptive thinking is always on server-side; the row pins -# sampling_params = false so the Converse encoder drops temperature/top_p -# (Bedrock rejects them for this model). Requires the account-level -# provider_data_share opt-in in the Bedrock console. Effort-level mapping -# through additionalModelRequestFields is a named follow-up, so -# reasoning_effort stays undeclared here (requests carrying one are -# rejected up front rather than silently dropped). - -[providers.bedrock.models."claude-fable-5"] -api_id = "us.anthropic.claude-fable-5" -display_name = "Claude Fable 5 (Bedrock)" -family = "claude-5" -billing_policy = "anthropic" -agent_profile = "claude-5" - -[providers.bedrock.models."claude-fable-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.bedrock.models."claude-fable-5".features] -tools = true -vision = true -reasoning = true -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.bedrock.models."claude-fable-5".costs] -input_cost_per_mtok = 10.0 -output_cost_per_mtok = 50.0 -cache_input_cost_per_mtok = 1.0 - -# Claude Sonnet 5 uses adaptive thinking by default and rejects non-default -# sampling parameters. Effort-level mapping through -# additionalModelRequestFields is a named follow-up, as for Fable 5. - -[providers.bedrock.models."claude-sonnet-5"] -api_id = "us.anthropic.claude-sonnet-5" -display_name = "Claude Sonnet 5 (Bedrock)" -family = "claude-5" -billing_policy = "anthropic" -default = true -agent_profile = "claude-5" - -[providers.bedrock.models."claude-sonnet-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.bedrock.models."claude-sonnet-5".features] -tools = true -vision = true -reasoning = true -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -# Introductory pricing through August 31, 2026. -[providers.bedrock.models."claude-sonnet-5".costs] -input_cost_per_mtok = 2.0 -output_cost_per_mtok = 10.0 -cache_input_cost_per_mtok = 0.2 diff --git a/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml b/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml deleted file mode 100644 index e97981178..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml +++ /dev/null @@ -1,74 +0,0 @@ -[providers.deepseek] -display_name = "DeepSeek" -adapter = "openai_compatible" -api_key_url = "https://platform.deepseek.com/api_keys" -base_url = "https://api.deepseek.com" -priority = 75 - -[providers.deepseek.auth] -credentials = ["env:DEEPSEEK_API_KEY", "vault:DEEPSEEK_API_KEY"] - -# DeepSeek V4 uses thinking mode by default. The API accepts sampling -# parameters in that mode but ignores them, so Fabro omits those parameters. -# Prompt caching is automatic and usage reports prompt_cache_hit_tokens. -# Prices are from api-docs.deepseek.com/quick_start/pricing, verified -# 2026-07-31. - -[providers.deepseek.models."deepseek-v4-flash"] -display_name = "DeepSeek V4 Flash" -family = "deepseek-v4" -aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] -# DeepSeek uses a general coding prompt and standard JSON function tools. -agent_profile = "openai" -default = true -small_default = true -probe = true - -[providers.deepseek.models."deepseek-v4-flash".limits] -context_window = 1048576 -max_output = 384000 - -[providers.deepseek.models."deepseek-v4-flash".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.deepseek.models."deepseek-v4-flash".controls] -reasoning_effort = ["low", "high", "max"] - -[providers.deepseek.models."deepseek-v4-flash".costs] -input_cost_per_mtok = 0.14 -output_cost_per_mtok = 0.28 -cache_input_cost_per_mtok = 0.0028 - -[providers.deepseek.models."deepseek-v4-pro"] -display_name = "DeepSeek V4 Pro" -family = "deepseek-v4" -agent_profile = "openai" - -[providers.deepseek.models."deepseek-v4-pro".limits] -context_window = 1048576 -max_output = 384000 - -[providers.deepseek.models."deepseek-v4-pro".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.deepseek.models."deepseek-v4-pro".controls] -# V4 Pro currently maps low to high. Keep only its distinct effort levels; -# DeepSeek says it plans to change Pro's mapping in early August 2026. -reasoning_effort = ["high", "max"] - -[providers.deepseek.models."deepseek-v4-pro".costs] -input_cost_per_mtok = 0.435 -output_cost_per_mtok = 0.87 -cache_input_cost_per_mtok = 0.003625 diff --git a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml b/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml deleted file mode 100644 index 48024f3ea..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml +++ /dev/null @@ -1,281 +0,0 @@ -[providers.fireworks] -display_name = "Fireworks AI" -adapter = "openai_compatible" -api_key_url = "https://app.fireworks.ai/settings/users/api-keys" -base_url = "https://api.fireworks.ai/inference/v1" -priority = 30 -enabled = false - -[providers.fireworks.auth] -credentials = ["env:FIREWORKS_API_KEY", "vault:FIREWORKS_API_KEY"] - -# To enable Fireworks, add the following to ~/.fabro/settings.toml: -# -# [llm.providers.fireworks] -# enabled = true -# -# Then run `fabro provider login --provider fireworks` to store the API key, -# or set the FIREWORKS_API_KEY environment variable. -# -# api_id values use Fireworks account-scoped paths; dots in upstream model -# names become "p" (glm-5.2 -> glm-5p2). `GET /v1/models` only returns a -# featured subset of serverless models, so validate api_ids against -# /chat/completions, not the models list. -# -# Prompt caching is automatic prefix caching (no cache_control breakpoints); -# serverless responses report prompt_tokens_details.cached_tokens in the -# usage body. Costs below are from docs.fireworks.ai/serverless/pricing, -# verified 2026-08-04. Rows use standard-tier prices unless noted otherwise. - -[providers.fireworks.models."kimi-k3"] -api_id = "accounts/fireworks/models/kimi-k3" -display_name = "Kimi K3" -family = "kimi-k3" -agent_profile = "kimi" - -[providers.fireworks.models."kimi-k3".limits] -context_window = 1048576 -max_output = 131072 - -[providers.fireworks.models."kimi-k3".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -sampling_params = false - -[providers.fireworks.models."kimi-k3".controls] -reasoning_effort = ["low", "medium", "high"] - -[providers.fireworks.models."kimi-k3".costs] -input_cost_per_mtok = 3.0 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.3 - -# Fireworks exposes the Fast tier through a separate router model ID. Its -# published prices are 50% above the standard Kimi K3 rates. -[providers.fireworks.models."kimi-k3-fast"] -api_id = "accounts/fireworks/routers/kimi-k3-fast" -display_name = "Kimi K3 Fast" -family = "kimi-k3" -agent_profile = "kimi" - -[providers.fireworks.models."kimi-k3-fast".limits] -context_window = 1048576 -max_output = 131072 - -[providers.fireworks.models."kimi-k3-fast".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -sampling_params = false - -[providers.fireworks.models."kimi-k3-fast".controls] -reasoning_effort = ["low", "medium", "high"] - -[providers.fireworks.models."kimi-k3-fast".costs] -input_cost_per_mtok = 4.5 -output_cost_per_mtok = 22.5 -cache_input_cost_per_mtok = 0.45 - -[providers.fireworks.models."kimi-k2.7-code"] -api_id = "accounts/fireworks/models/kimi-k2p7-code" -display_name = "Kimi K2.7 Code" -family = "kimi-k2" -default = true - -[providers.fireworks.models."kimi-k2.7-code".limits] -context_window = 262144 -max_output = 32768 - -[providers.fireworks.models."kimi-k2.7-code".features] -tools = true -vision = true -reasoning = true -prompt_cache = true - -[providers.fireworks.models."kimi-k2.7-code".costs] -input_cost_per_mtok = 0.95 -output_cost_per_mtok = 4.0 -cache_input_cost_per_mtok = 0.19 - -[providers.fireworks.models."kimi-k2.6"] -api_id = "accounts/fireworks/models/kimi-k2p6" -display_name = "Kimi K2.6" -family = "kimi-k2" - -[providers.fireworks.models."kimi-k2.6".limits] -context_window = 262144 -max_output = 16384 - -[providers.fireworks.models."kimi-k2.6".features] -tools = true -vision = false -reasoning = false -prompt_cache = true - -[providers.fireworks.models."kimi-k2.6".costs] -input_cost_per_mtok = 0.95 -output_cost_per_mtok = 4.0 -cache_input_cost_per_mtok = 0.16 - -[providers.fireworks.models."deepseek-v4-pro"] -api_id = "accounts/fireworks/models/deepseek-v4-pro" -display_name = "DeepSeek V4 Pro" -family = "deepseek-v4" -agent_profile = "openai" - -[providers.fireworks.models."deepseek-v4-pro".limits] -context_window = 1048576 -max_output = 16384 - -[providers.fireworks.models."deepseek-v4-pro".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.fireworks.models."deepseek-v4-pro".controls] -# Fireworks promotes low/medium to high and xhigh to max for DeepSeek V4. -reasoning_effort = ["high", "max"] - -[providers.fireworks.models."deepseek-v4-pro".costs] -input_cost_per_mtok = 1.74 -output_cost_per_mtok = 3.48 -cache_input_cost_per_mtok = 0.145 - -[providers.fireworks.models."deepseek-v4-flash"] -api_id = "accounts/fireworks/models/deepseek-v4-flash" -display_name = "DeepSeek V4 Flash" -family = "deepseek-v4" -aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] -agent_profile = "openai" - -[providers.fireworks.models."deepseek-v4-flash".limits] -context_window = 1048576 -max_output = 384000 - -[providers.fireworks.models."deepseek-v4-flash".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.fireworks.models."deepseek-v4-flash".controls] -reasoning_effort = ["high", "max"] - -[providers.fireworks.models."deepseek-v4-flash".costs] -input_cost_per_mtok = 0.14 -output_cost_per_mtok = 0.28 -cache_input_cost_per_mtok = 0.028 - -[providers.fireworks.models."glm-5.2"] -api_id = "accounts/fireworks/models/glm-5p2" -display_name = "GLM 5.2 (via Fireworks)" -family = "glm-5" - -[providers.fireworks.models."glm-5.2".limits] -context_window = 1048576 -max_output = 131072 - -[providers.fireworks.models."glm-5.2".features] -tools = true -vision = false -reasoning = true -prompt_cache = true - -[providers.fireworks.models."glm-5.2".costs] -input_cost_per_mtok = 1.4 -output_cost_per_mtok = 4.4 -cache_input_cost_per_mtok = 0.14 - -[providers.fireworks.models."minimax-m2.7"] -api_id = "accounts/fireworks/models/minimax-m2p7" -display_name = "MiniMax M2.7" -family = "minimax-m2" - -[providers.fireworks.models."minimax-m2.7".limits] -context_window = 196608 -max_output = 16384 - -[providers.fireworks.models."minimax-m2.7".features] -tools = true -vision = false -reasoning = false -prompt_cache = true - -[providers.fireworks.models."minimax-m2.7".costs] -input_cost_per_mtok = 0.3 -output_cost_per_mtok = 1.2 -cache_input_cost_per_mtok = 0.059 - -[providers.fireworks.models."qwen3.7-plus"] -api_id = "accounts/fireworks/models/qwen3p7-plus" -display_name = "Qwen3.7 Plus" -family = "qwen3" - -[providers.fireworks.models."qwen3.7-plus".limits] -context_window = 262144 -max_output = 16384 - -[providers.fireworks.models."qwen3.7-plus".features] -tools = true -vision = true -reasoning = false -prompt_cache = true - -[providers.fireworks.models."qwen3.7-plus".costs] -input_cost_per_mtok = 0.4 -output_cost_per_mtok = 1.6 -cache_input_cost_per_mtok = 0.08 - -[providers.fireworks.models."gpt-oss-120b"] -api_id = "accounts/fireworks/models/gpt-oss-120b" -display_name = "GPT-OSS 120B" -family = "gpt-oss" - -[providers.fireworks.models."gpt-oss-120b".limits] -context_window = 131072 -max_output = 32768 - -[providers.fireworks.models."gpt-oss-120b".features] -tools = true -vision = false -reasoning = true -prompt_cache = true - -[providers.fireworks.models."gpt-oss-120b".costs] -input_cost_per_mtok = 0.15 -output_cost_per_mtok = 0.6 -cache_input_cost_per_mtok = 0.015 - -[providers.fireworks.models."gpt-oss-20b"] -api_id = "accounts/fireworks/models/gpt-oss-20b" -display_name = "GPT-OSS 20B" -family = "gpt-oss" -small_default = true -probe = true - -[providers.fireworks.models."gpt-oss-20b".limits] -context_window = 131072 -max_output = 32768 - -[providers.fireworks.models."gpt-oss-20b".features] -tools = true -vision = false -reasoning = true -prompt_cache = true - -[providers.fireworks.models."gpt-oss-20b".costs] -input_cost_per_mtok = 0.07 -output_cost_per_mtok = 0.3 -cache_input_cost_per_mtok = 0.035 diff --git a/lib/foundation/fabro-model/src/catalog/providers/gemini.toml b/lib/foundation/fabro-model/src/catalog/providers/gemini.toml deleted file mode 100644 index a03c2a249..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/gemini.toml +++ /dev/null @@ -1,127 +0,0 @@ -[providers.gemini] -display_name = "Gemini" -adapter = "gemini" -api_key_url = "https://aistudio.google.com/apikey" -base_url = "https://generativelanguage.googleapis.com/v1beta" -priority = 80 - -[providers.gemini.auth] -credentials = ["env:GEMINI_API_KEY", "env:GOOGLE_API_KEY", "vault:GEMINI_API_KEY"] -header = { custom = "x-goog-api-key" } - -[providers.gemini.models."gemini-3.1-pro-preview"] -display_name = "Gemini 3.1 Pro (Preview)" -family = "gemini-3" -training = "2025-01-01" -knowledge_cutoff = "January 2025" -estimated_output_tps = 85 -aliases = ["gemini-pro"] - -[providers.gemini.models."gemini-3.1-pro-preview".limits] -context_window = 1048576 -max_output = 65536 - -[providers.gemini.models."gemini-3.1-pro-preview".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.gemini.models."gemini-3.1-pro-preview".costs] -input_cost_per_mtok = 2.0 -output_cost_per_mtok = 12.0 -cache_input_cost_per_mtok = 0.5 - -[providers.gemini.models."gemini-3.1-pro-preview-customtools"] -display_name = "Gemini 3.1 Pro Custom Tools (Preview)" -family = "gemini-3" -training = "2025-01-01" -knowledge_cutoff = "January 2025" -estimated_output_tps = 85 -aliases = ["gemini-customtools"] - -[providers.gemini.models."gemini-3.1-pro-preview-customtools".limits] -context_window = 1048576 -max_output = 65536 - -[providers.gemini.models."gemini-3.1-pro-preview-customtools".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.gemini.models."gemini-3.1-pro-preview-customtools".costs] -input_cost_per_mtok = 2.0 -output_cost_per_mtok = 12.0 -cache_input_cost_per_mtok = 0.5 - -[providers.gemini.models."gemini-3.5-flash"] -display_name = "Gemini 3.5 Flash" -family = "gemini-3" -training = "2025-01-01" -knowledge_cutoff = "January 2025" -default = true -estimated_output_tps = 150 -aliases = ["gemini-35-flash"] - -[providers.gemini.models."gemini-3.5-flash".limits] -context_window = 1048576 -max_output = 65536 - -[providers.gemini.models."gemini-3.5-flash".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.gemini.models."gemini-3.5-flash".costs] -input_cost_per_mtok = 1.5 -output_cost_per_mtok = 9.0 -cache_input_cost_per_mtok = 0.15 - -[providers.gemini.models."gemini-3-flash-preview"] -display_name = "Gemini 3 Flash (Preview)" -family = "gemini-3" -training = "2025-01-01" -knowledge_cutoff = "January 2025" -estimated_output_tps = 150 -aliases = ["gemini-flash"] - -[providers.gemini.models."gemini-3-flash-preview".limits] -context_window = 1048576 -max_output = 65536 - -[providers.gemini.models."gemini-3-flash-preview".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.gemini.models."gemini-3-flash-preview".costs] -input_cost_per_mtok = 0.5 -output_cost_per_mtok = 3.0 -cache_input_cost_per_mtok = 0.125 - -[providers.gemini.models."gemini-3.1-flash-lite"] -display_name = "Gemini 3.1 Flash Lite" -family = "gemini-3" -training = "2025-01-01" -knowledge_cutoff = "January 2025" -estimated_output_tps = 200 -aliases = ["gemini-flash-lite", "gemini-3.1-flash-lite-preview"] -small_default = true - -[providers.gemini.models."gemini-3.1-flash-lite".limits] -context_window = 1048576 -max_output = 65536 - -[providers.gemini.models."gemini-3.1-flash-lite".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.gemini.models."gemini-3.1-flash-lite".costs] -input_cost_per_mtok = 0.25 -output_cost_per_mtok = 1.5 -cache_input_cost_per_mtok = 0.025 diff --git a/lib/foundation/fabro-model/src/catalog/providers/inception.toml b/lib/foundation/fabro-model/src/catalog/providers/inception.toml deleted file mode 100644 index 965120f27..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/inception.toml +++ /dev/null @@ -1,30 +0,0 @@ -[providers.inception] -display_name = "Inception" -adapter = "openai_compatible" -api_key_url = "https://console.inceptionlabs.ai/api-keys" -base_url = "https://api.inceptionlabs.ai/v1" -priority = 40 - -[providers.inception.auth] -credentials = ["env:INCEPTION_API_KEY", "vault:INCEPTION_API_KEY"] - -[providers.inception.models."mercury-2"] -display_name = "Mercury 2" -family = "mercury" -default = true -estimated_output_tps = 1000 -aliases = ["mercury"] - -[providers.inception.models."mercury-2".limits] -context_window = 131072 -max_output = 50000 - -[providers.inception.models."mercury-2".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" - -[providers.inception.models."mercury-2".costs] -input_cost_per_mtok = 0.25 -output_cost_per_mtok = 0.75 diff --git a/lib/foundation/fabro-model/src/catalog/providers/litellm.toml b/lib/foundation/fabro-model/src/catalog/providers/litellm.toml deleted file mode 100644 index 1307378c1..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/litellm.toml +++ /dev/null @@ -1,30 +0,0 @@ -[providers.litellm] -display_name = "LiteLLM" -adapter = "openai_compatible" -base_url = "http://localhost:4000/v1" -priority = 50 -enabled = false - -[providers.litellm.auth] -credentials = ["env:LITELLM_API_KEY", "vault:LITELLM_API_KEY"] - -# To enable LiteLLM, add entries like these to settings.toml: -# -# [llm.providers.litellm] -# enabled = true -# base_url = "http://localhost:4000/v1" -# -# [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 diff --git a/lib/foundation/fabro-model/src/catalog/providers/minimax.toml b/lib/foundation/fabro-model/src/catalog/providers/minimax.toml deleted file mode 100644 index e68dfc290..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/minimax.toml +++ /dev/null @@ -1,29 +0,0 @@ -[providers.minimax] -display_name = "MiniMax" -adapter = "openai_compatible" -api_key_url = "https://platform.minimaxi.com/user-center/basic-information/interface-key" -base_url = "https://api.minimax.io/v1" -priority = 50 - -[providers.minimax.auth] -credentials = ["env:MINIMAX_API_KEY", "vault:MINIMAX_API_KEY"] - -[providers.minimax.models."minimax-m2.5"] -display_name = "Minimax M2.5" -family = "minimax-m2" -default = true -estimated_output_tps = 45 -aliases = ["minimax"] - -[providers.minimax.models."minimax-m2.5".limits] -context_window = 196608 -max_output = 16384 - -[providers.minimax.models."minimax-m2.5".features] -tools = true -vision = false -reasoning = false - -[providers.minimax.models."minimax-m2.5".costs] -input_cost_per_mtok = 0.3 -output_cost_per_mtok = 1.2 diff --git a/lib/foundation/fabro-model/src/catalog/providers/modal.toml b/lib/foundation/fabro-model/src/catalog/providers/modal.toml deleted file mode 100644 index d00e40bd1..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/modal.toml +++ /dev/null @@ -1,53 +0,0 @@ -[providers.modal] -display_name = "Modal" -adapter = "openai_compatible" -agent_profile = "kimi" -api_key_url = "https://modal.com/docs/guide/endpoints#proxy-tokens" -priority = 75 -enabled = false - -[providers.modal.extra_headers] -"Modal-Key" = "{{ secrets.MODAL_TOKEN_ID }}" -"Modal-Secret" = "{{ secrets.MODAL_TOKEN_SECRET }}" - -# Modal assigns an endpoint URL when the Shared API or an Auto Endpoint is -# created. To enable Modal, add the endpoint URL to ~/.fabro/settings.toml: -# -# [llm.providers.modal] -# enabled = true -# base_url = "https://.modal.run/v1" -# -# Then store both proxy-token values in the Fabro server vault: -# -# fabro secret set MODAL_TOKEN_ID wk-... -# fabro secret set MODAL_TOKEN_SECRET ws-... - -# Modal serves the Hugging Face repository id, so `api_id` keeps that -# capitalization. OpenRouter routes the same model under its own lowercase -# slug (`moonshotai/kimi-k3`). -[providers.modal.models."kimi-k3"] -api_id = "moonshotai/Kimi-K3" -display_name = "Kimi K3 (via Modal)" -family = "kimi-k3" -default = true -estimated_output_tps = 460 - -[providers.modal.models."kimi-k3".limits] -context_window = 1048576 -max_output = 131072 - -[providers.modal.models."kimi-k3".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -sampling_params = false - -[providers.modal.models."kimi-k3".controls] -reasoning_effort = ["low", "high", "max"] - -[providers.modal.models."kimi-k3".costs] -input_cost_per_mtok = 3.0 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.3 diff --git a/lib/foundation/fabro-model/src/catalog/providers/moonshot.toml b/lib/foundation/fabro-model/src/catalog/providers/moonshot.toml deleted file mode 100644 index aaa4d7ec4..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/moonshot.toml +++ /dev/null @@ -1,67 +0,0 @@ -[providers.moonshot] -display_name = "Moonshot AI" -adapter = "openai_compatible" -agent_profile = "kimi" -api_key_url = "https://platform.kimi.ai/console/api-keys" -base_url = "https://api.moonshot.ai/v1" -priority = 70 - -[providers.moonshot.auth] -credentials = [ - "env:MOONSHOT_API_KEY", - "env:KIMI_API_KEY", - "vault:MOONSHOT_API_KEY", - "vault:KIMI_API_KEY", -] - -[providers.moonshot.models."kimi-k2.5"] -display_name = "Kimi K2.5" -family = "kimi-k2" -training = "2025-10-01" -knowledge_cutoff = "October 2025" -estimated_output_tps = 50 - -[providers.moonshot.models."kimi-k2.5".limits] -context_window = 262144 -max_output = 32768 - -[providers.moonshot.models."kimi-k2.5".features] -tools = true -vision = true -reasoning = true -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.moonshot.models."kimi-k2.5".costs] -input_cost_per_mtok = 0.6 -output_cost_per_mtok = 3.0 -cache_input_cost_per_mtok = 0.1 - -[providers.moonshot.models."kimi-k3"] -display_name = "Kimi K3" -family = "kimi-k3" -default = true -aliases = ["kimi"] - -[providers.moonshot.models."kimi-k3".limits] -context_window = 1048576 -# K3 accepts explicit completion budgets up to 1048576, but Fabro also uses -# max_output as the default request budget. Match Kimi's 131072-token default. -max_output = 131072 - -[providers.moonshot.models."kimi-k3".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -sampling_params = false - -[providers.moonshot.models."kimi-k3".controls] -reasoning_effort = ["low", "high", "max"] - -[providers.moonshot.models."kimi-k3".costs] -input_cost_per_mtok = 3.0 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.3 diff --git a/lib/foundation/fabro-model/src/catalog/providers/ollama.toml b/lib/foundation/fabro-model/src/catalog/providers/ollama.toml deleted file mode 100644 index 78dc5db69..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/ollama.toml +++ /dev/null @@ -1,25 +0,0 @@ -[providers.ollama] -display_name = "Ollama" -adapter = "openai_compatible" -billing_policy = "none" -base_url = "http://localhost:11434/v1" -priority = 30 -enabled = false - -# Example model. Uncomment after `ollama pull qwen3.5` (and `enabled = true` -# above) to expose it through the OpenAI-compatible adapter. -# -# [providers.ollama.models."qwen3.5"] -# api_id = "qwen3.5:latest" -# display_name = "Qwen3.5" -# family = "qwen3.5" -# default = true -# aliases = ["ollama-qwen3.5"] -# -# [providers.ollama.models."qwen3.5".limits] -# context_window = 32768 -# -# [providers.ollama.models."qwen3.5".features] -# tools = true -# vision = false -# reasoning = false diff --git a/lib/foundation/fabro-model/src/catalog/providers/openai.toml b/lib/foundation/fabro-model/src/catalog/providers/openai.toml deleted file mode 100644 index a95919357..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/openai.toml +++ /dev/null @@ -1,201 +0,0 @@ -[providers.openai] -display_name = "OpenAI" -adapter = "openai" -api_key_url = "https://platform.openai.com/api-keys" -base_url = "https://api.openai.com/v1" -priority = 90 - -[providers.openai.auth] -credentials = ["env:OPENAI_API_KEY", "vault:OPENAI_API_KEY", "vault:OPENAI_CODEX"] - -[providers.openai.models."gpt-5.6-sol"] -display_name = "GPT-5.6 Sol" -family = "gpt-5" -training = "2026-02-16" -knowledge_cutoff = "February 16, 2026" -default = true -aliases = ["sol", "gpt-sol", "gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"] -# Codex drives the 5.6 models with a much narrower tool set than the other -# OpenAI models. Set per model so the rest of this provider keeps "openai". -agent_profile = "gpt56" - -[providers.openai.models."gpt-5.6-sol".limits] -context_window = 272000 -max_output = 128000 - -[providers.openai.models."gpt-5.6-sol".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[providers.openai.models."gpt-5.6-sol".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 30.0 -cache_input_cost_per_mtok = 0.5 - -[providers.openai.models."gpt-5.6-terra"] -display_name = "GPT-5.6 Terra" -family = "gpt-5" -training = "2026-02-16" -knowledge_cutoff = "February 16, 2026" -aliases = ["terra", "gpt-terra", "gpt56-terra", "gpt-56-terra"] -agent_profile = "gpt56" - -[providers.openai.models."gpt-5.6-terra".limits] -context_window = 272000 -max_output = 128000 - -[providers.openai.models."gpt-5.6-terra".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[providers.openai.models."gpt-5.6-terra".costs] -input_cost_per_mtok = 2.5 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.25 - -[providers.openai.models."gpt-5.6-luna"] -display_name = "GPT-5.6 Luna" -family = "gpt-5" -training = "2026-02-16" -knowledge_cutoff = "February 16, 2026" -aliases = ["luna", "gpt-luna", "gpt56-luna", "gpt-56-luna"] -agent_profile = "gpt56" - -[providers.openai.models."gpt-5.6-luna".limits] -context_window = 272000 -max_output = 128000 - -[providers.openai.models."gpt-5.6-luna".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[providers.openai.models."gpt-5.6-luna".costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 6.0 -cache_input_cost_per_mtok = 0.1 - -[providers.openai.models."gpt-5.4"] -display_name = "GPT-5.4" -family = "gpt-5" -training = "2025-08-31" -knowledge_cutoff = "April 2025" -estimated_output_tps = 70 -aliases = ["gpt54", "gpt-54", "gpt-5.2", "gpt5", "gpt-5.3-codex", "codex"] - -[providers.openai.models."gpt-5.4".limits] -context_window = 272000 -max_output = 128000 - -[providers.openai.models."gpt-5.4".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.openai.models."gpt-5.4".costs] -input_cost_per_mtok = 2.5 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.25 - -[providers.openai.models."gpt-5.5"] -display_name = "GPT-5.5" -family = "gpt-5" -training = "2025-12-01" -knowledge_cutoff = "December 2025" -estimated_output_tps = 70 -aliases = ["gpt55", "gpt-55"] - -[providers.openai.models."gpt-5.5".limits] -context_window = 272000 -max_output = 128000 - -[providers.openai.models."gpt-5.5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.openai.models."gpt-5.5".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 30.0 -cache_input_cost_per_mtok = 0.5 - -[providers.openai.models."gpt-5.5-pro"] -display_name = "GPT-5.5 Pro" -family = "gpt-5" -training = "2025-12-01" -knowledge_cutoff = "December 2025" -estimated_output_tps = 20 -aliases = ["gpt55-pro", "gpt-55-pro"] - -[providers.openai.models."gpt-5.5-pro".limits] -context_window = 1050000 -max_output = 128000 - -[providers.openai.models."gpt-5.5-pro".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.openai.models."gpt-5.5-pro".costs] -input_cost_per_mtok = 30.0 -output_cost_per_mtok = 180.0 -cache_input_cost_per_mtok = 3.0 - -[providers.openai.models."gpt-5.4-pro"] -display_name = "GPT-5.4 Pro" -family = "gpt-5" -training = "2025-08-31" -knowledge_cutoff = "April 2025" -estimated_output_tps = 20 -aliases = ["gpt54-pro", "gpt-54-pro"] - -[providers.openai.models."gpt-5.4-pro".limits] -context_window = 1047576 -max_output = 128000 - -[providers.openai.models."gpt-5.4-pro".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.openai.models."gpt-5.4-pro".costs] -input_cost_per_mtok = 30.0 -output_cost_per_mtok = 180.0 -cache_input_cost_per_mtok = 3.0 - -[providers.openai.models."gpt-5.4-mini"] -display_name = "GPT-5.4 Mini" -family = "gpt-5" -training = "2025-08-31" -knowledge_cutoff = "April 2025" -estimated_output_tps = 140 -aliases = ["gpt54-mini", "gpt-54-mini", "gpt-5.3-codex-spark", "codex-spark"] -probe = true -small_default = true - -[providers.openai.models."gpt-5.4-mini".limits] -context_window = 272000 -max_output = 128000 - -[providers.openai.models."gpt-5.4-mini".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.openai.models."gpt-5.4-mini".costs] -input_cost_per_mtok = 0.75 -output_cost_per_mtok = 4.5 -cache_input_cost_per_mtok = 0.075 diff --git a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml deleted file mode 100644 index 427d178b4..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml +++ /dev/null @@ -1,699 +0,0 @@ -[providers.openrouter] -display_name = "OpenRouter" -adapter = "openai_compatible" -api_key_url = "https://openrouter.ai/keys" -base_url = "https://openrouter.ai/api/v1" -priority = 25 -enabled = false - -[providers.openrouter.auth] -credentials = ["env:OPENROUTER_API_KEY", "vault:OPENROUTER_API_KEY"] - -# Attribution headers (HTTP-Referer, X-Title) are NOT sent by default. -# Self-hosted Fabro installations stay anonymous on OpenRouter's public -# leaderboard unless the operator opts in. To advertise, add to -# settings.toml: -# -# [llm.providers.openrouter.extra_headers] -# "HTTP-Referer" = "https://your-site.example" -# "X-Title" = "Your App" -# -# To enable OpenRouter, add the following to ~/.fabro/settings.toml: -# -# [llm.providers.openrouter] -# enabled = true -# -# Then run `fabro provider login openrouter` to store the API key, -# or set the OPENROUTER_API_KEY environment variable. - -# ---------- Anthropic via OpenRouter ---------- -# -# Claude models bill Anthropic-style (cache read/write pricing), so these -# rows override the provider's OpenAI-default billing_policy. Costs are -# best-effort estimates; OpenRouter returns the authoritative usage.cost -# in-band on every response. - -[providers.openrouter.models."claude-fable-5"] -api_id = "anthropic/claude-fable-5" -display_name = "Claude Fable 5 (via OpenRouter)" -family = "claude-5" -billing_policy = "anthropic" -aliases = ["fable", "claude-fable"] -agent_profile = "claude-5" - -[providers.openrouter.models."claude-fable-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.openrouter.models."claude-fable-5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -cache_control_breakpoints = true -sampling_params = false - -[providers.openrouter.models."claude-fable-5".costs] -input_cost_per_mtok = 10.0 -output_cost_per_mtok = 50.0 -cache_input_cost_per_mtok = 1.0 - -[providers.openrouter.models."claude-opus-5"] -api_id = "anthropic/claude-opus-5" -display_name = "Claude Opus 5 (via OpenRouter)" -family = "claude-5" -billing_policy = "anthropic" -training = "2026-05-01" -knowledge_cutoff = "May 2026" -aliases = ["opus", "claude-opus"] -agent_profile = "claude-5" - -[providers.openrouter.models."claude-opus-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.openrouter.models."claude-opus-5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -cache_control_breakpoints = true -sampling_params = false - -[providers.openrouter.models."claude-opus-5".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 25.0 -cache_input_cost_per_mtok = 0.5 - -[providers.openrouter.models."claude-sonnet-5"] -api_id = "anthropic/claude-sonnet-5" -display_name = "Claude Sonnet 5 (via OpenRouter)" -family = "claude-5" -billing_policy = "anthropic" -training = "2026-01-01" -knowledge_cutoff = "Jan 2026" -default = true -aliases = ["sonnet", "claude-sonnet"] -agent_profile = "claude-5" - -[providers.openrouter.models."claude-sonnet-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.openrouter.models."claude-sonnet-5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -cache_control_breakpoints = true -sampling_params = false - -# Current introductory rate. OpenRouter's authoritative in-band usage.cost -# supersedes this estimate on completed responses. -[providers.openrouter.models."claude-sonnet-5".costs] -input_cost_per_mtok = 2.0 -output_cost_per_mtok = 10.0 -cache_input_cost_per_mtok = 0.2 - -[providers.openrouter.models."claude-opus-4-8"] -api_id = "anthropic/claude-opus-4.8" -display_name = "Claude Opus 4.8 (via OpenRouter)" -family = "claude-4" -billing_policy = "anthropic" -training = "2026-01-01" -knowledge_cutoff = "Jan 2026" - -[providers.openrouter.models."claude-opus-4-8".limits] -context_window = 1000000 -max_output = 128000 - -[providers.openrouter.models."claude-opus-4-8".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -cache_control_breakpoints = true -sampling_params = false - -[providers.openrouter.models."claude-opus-4-8".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 25.0 -cache_input_cost_per_mtok = 0.5 - -[providers.openrouter.models."claude-opus-4-7"] -api_id = "anthropic/claude-opus-4.7" -display_name = "Claude Opus 4.7 (via OpenRouter)" -family = "claude-4" -billing_policy = "anthropic" - -[providers.openrouter.models."claude-opus-4-7".limits] -context_window = 1000000 -max_output = 128000 - -[providers.openrouter.models."claude-opus-4-7".features] -tools = true -vision = true -reasoning = true -prompt_cache = true -cache_control_breakpoints = true - -[providers.openrouter.models."claude-opus-4-7".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 25.0 -cache_input_cost_per_mtok = 0.5 - -[providers.openrouter.models."claude-sonnet-4-6"] -api_id = "anthropic/claude-sonnet-4.6" -display_name = "Claude Sonnet 4.6 (via OpenRouter)" -family = "claude-4" -billing_policy = "anthropic" - -[providers.openrouter.models."claude-sonnet-4-6".limits] -context_window = 1000000 -max_output = 64000 - -[providers.openrouter.models."claude-sonnet-4-6".features] -tools = true -vision = true -reasoning = true -prompt_cache = true -cache_control_breakpoints = true - -[providers.openrouter.models."claude-sonnet-4-6".costs] -input_cost_per_mtok = 3.0 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.3 - -[providers.openrouter.models."claude-haiku-4-5"] -api_id = "anthropic/claude-haiku-4.5" -display_name = "Claude Haiku 4.5 (via OpenRouter)" -family = "claude-4" -billing_policy = "anthropic" -small_default = true -aliases = ["haiku", "claude-haiku"] - -[providers.openrouter.models."claude-haiku-4-5".limits] -context_window = 200000 -max_output = 8192 - -[providers.openrouter.models."claude-haiku-4-5".features] -tools = true -vision = true -reasoning = false -prompt_cache = true -cache_control_breakpoints = true - -[providers.openrouter.models."claude-haiku-4-5".costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 5.0 -cache_input_cost_per_mtok = 0.1 - -# ---------- OpenAI via OpenRouter ---------- - -# GPT-5.6 entries use OpenRouter's base rates below the 272k-token -# long-context threshold. Authoritative in-band usage.cost covers -# long-context and cache-write pricing. - -[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" -training = "2026-02-16" -knowledge_cutoff = "February 16, 2026" -aliases = ["sol", "gpt-sol", "gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"] -# Same narrow Codex tool set as on the openai provider; the gateway does not -# change what the model was trained against. -agent_profile = "gpt56" - -[providers.openrouter.models."gpt-5.6-sol".limits] -context_window = 1050000 -max_output = 128000 - -[providers.openrouter.models."gpt-5.6-sol".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -[providers.openrouter.models."gpt-5.6-sol".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 30.0 -cache_input_cost_per_mtok = 0.5 - -[providers.openrouter.models."gpt-5.6-terra"] -api_id = "openai/gpt-5.6-terra" -display_name = "GPT-5.6 Terra (via OpenRouter)" -family = "gpt-5" -training = "2026-02-16" -knowledge_cutoff = "February 16, 2026" -aliases = ["terra", "gpt-terra", "gpt56-terra", "gpt-56-terra"] -agent_profile = "gpt56" - -[providers.openrouter.models."gpt-5.6-terra".limits] -context_window = 1050000 -max_output = 128000 - -[providers.openrouter.models."gpt-5.6-terra".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -[providers.openrouter.models."gpt-5.6-terra".costs] -input_cost_per_mtok = 2.5 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.25 - -[providers.openrouter.models."gpt-5.6-luna"] -api_id = "openai/gpt-5.6-luna" -display_name = "GPT-5.6 Luna (via OpenRouter)" -family = "gpt-5" -training = "2026-02-16" -knowledge_cutoff = "February 16, 2026" -aliases = ["luna", "gpt-luna", "gpt56-luna", "gpt-56-luna"] -agent_profile = "gpt56" - -[providers.openrouter.models."gpt-5.6-luna".limits] -context_window = 1050000 -max_output = 128000 - -[providers.openrouter.models."gpt-5.6-luna".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -[providers.openrouter.models."gpt-5.6-luna".costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 6.0 -cache_input_cost_per_mtok = 0.1 - -[providers.openrouter.models."gpt-5.4"] -api_id = "openai/gpt-5.4" -display_name = "GPT-5.4 (via OpenRouter)" -family = "gpt-5" -aliases = ["gpt54", "gpt-54", "gpt-5.2", "gpt5", "gpt-5.3-codex", "codex"] - -[providers.openrouter.models."gpt-5.4".limits] -context_window = 1050000 -max_output = 32768 - -[providers.openrouter.models."gpt-5.4".features] -tools = true -vision = true -reasoning = true - -[providers.openrouter.models."gpt-5.4".costs] -input_cost_per_mtok = 2.5 -output_cost_per_mtok = 15.0 - -[providers.openrouter.models."gpt-5.5"] -api_id = "openai/gpt-5.5" -display_name = "GPT-5.5 (via OpenRouter)" -family = "gpt-5" -aliases = ["gpt55", "gpt-55"] - -[providers.openrouter.models."gpt-5.5".limits] -context_window = 1050000 -max_output = 32768 - -[providers.openrouter.models."gpt-5.5".features] -tools = true -vision = true -reasoning = true - -[providers.openrouter.models."gpt-5.5".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 30.0 - -# ---------- Google Gemini via OpenRouter ---------- - -[providers.openrouter.models."gemini-3.1-pro-preview"] -api_id = "google/gemini-3.1-pro-preview" -display_name = "Gemini 3.1 Pro Preview (via OpenRouter)" -family = "gemini-3" - -[providers.openrouter.models."gemini-3.1-pro-preview".limits] -context_window = 1048576 -max_output = 65536 - -[providers.openrouter.models."gemini-3.1-pro-preview".features] -tools = true -vision = true -reasoning = true - -[providers.openrouter.models."gemini-3.1-pro-preview".costs] -input_cost_per_mtok = 2.0 -output_cost_per_mtok = 12.0 - -[providers.openrouter.models."gemini-3.5-flash"] -api_id = "google/gemini-3.5-flash" -display_name = "Gemini 3.5 Flash (via OpenRouter)" -family = "gemini-3" - -[providers.openrouter.models."gemini-3.5-flash".limits] -context_window = 1048576 -max_output = 65536 - -[providers.openrouter.models."gemini-3.5-flash".features] -tools = true -vision = true -reasoning = false - -[providers.openrouter.models."gemini-3.5-flash".costs] -input_cost_per_mtok = 1.5 -output_cost_per_mtok = 9.0 - -# ---------- Open-weights models ---------- - -[providers.openrouter.models."mimo-v2.5-pro"] -api_id = "xiaomi/mimo-v2.5-pro" -display_name = "Xiaomi MiMo v2.5 Pro" -family = "mimo-v2" - -[providers.openrouter.models."mimo-v2.5-pro".limits] -context_window = 1050000 -max_output = 16384 - -[providers.openrouter.models."mimo-v2.5-pro".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."mimo-v2.5-pro".costs] -input_cost_per_mtok = 0.435 -output_cost_per_mtok = 0.87 - -[providers.openrouter.models."minimax-m2.7"] -api_id = "minimax/minimax-m2.7" -display_name = "MiniMax M2.7" -family = "minimax-m2" - -[providers.openrouter.models."minimax-m2.7".limits] -context_window = 200000 -max_output = 16384 - -[providers.openrouter.models."minimax-m2.7".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."minimax-m2.7".costs] -input_cost_per_mtok = 0.28 -output_cost_per_mtok = 1.20 - -[providers.openrouter.models."deepseek-v4-pro"] -api_id = "deepseek/deepseek-v4-pro" -display_name = "DeepSeek V4 Pro" -family = "deepseek-v4" -agent_profile = "openai" - -[providers.openrouter.models."deepseek-v4-pro".limits] -context_window = 1048576 -max_output = 384000 - -[providers.openrouter.models."deepseek-v4-pro".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.openrouter.models."deepseek-v4-pro".controls] -# OpenRouter names DeepSeek's max tier xhigh on this route. -reasoning_effort = ["high", "xhigh"] - -[providers.openrouter.models."deepseek-v4-pro".costs] -input_cost_per_mtok = 0.435 -output_cost_per_mtok = 0.87 -cache_input_cost_per_mtok = 0.003625 - -[providers.openrouter.models."deepseek-v4-flash"] -api_id = "deepseek/deepseek-v4-flash-0731" -display_name = "DeepSeek V4 Flash" -family = "deepseek-v4" -aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] -agent_profile = "openai" - -[providers.openrouter.models."deepseek-v4-flash".limits] -context_window = 1048576 -max_output = 384000 - -[providers.openrouter.models."deepseek-v4-flash".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.openrouter.models."deepseek-v4-flash".controls] -reasoning_effort = ["low", "high", "max"] - -[providers.openrouter.models."deepseek-v4-flash".costs] -input_cost_per_mtok = 0.14 -output_cost_per_mtok = 0.28 -cache_input_cost_per_mtok = 0.0028 - -[providers.openrouter.models."kimi-k2.6"] -api_id = "moonshotai/kimi-k2.6" -display_name = "Kimi K2.6" -family = "kimi-k2" -# Kimi models get the Kimi agent profile wherever they are served from, so a -# gateway route behaves like the direct Moonshot one. -agent_profile = "kimi" - -[providers.openrouter.models."kimi-k2.6".limits] -context_window = 262144 -max_output = 16384 - -[providers.openrouter.models."kimi-k2.6".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."kimi-k2.6".costs] -input_cost_per_mtok = 0.73 -output_cost_per_mtok = 3.49 - -[providers.openrouter.models."kimi-k3"] -api_id = "moonshotai/kimi-k3" -display_name = "Kimi K3 (via OpenRouter)" -family = "kimi-k3" -agent_profile = "kimi" - -[providers.openrouter.models."kimi-k3".limits] -context_window = 1048576 -max_output = 131072 - -[providers.openrouter.models."kimi-k3".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -sampling_params = false - -[providers.openrouter.models."kimi-k3".controls] -reasoning_effort = ["low", "high", "max"] - -[providers.openrouter.models."kimi-k3".costs] -input_cost_per_mtok = 3.0 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.3 - -[providers.openrouter.models."laguna-s-2.1"] -api_id = "poolside/laguna-s-2.1" -display_name = "Laguna S 2.1 (via OpenRouter)" -family = "laguna-2" - -[providers.openrouter.models."laguna-s-2.1".limits] -context_window = 1048576 -max_output = 131072 - -[providers.openrouter.models."laguna-s-2.1".features] -tools = true -vision = false -reasoning = true -prompt_cache = true -sampling_params = true - -[providers.openrouter.models."laguna-s-2.1".costs] -input_cost_per_mtok = 0.10 -output_cost_per_mtok = 0.20 -cache_input_cost_per_mtok = 0.01 - -[providers.openrouter.models."laguna-xs-2.1"] -api_id = "poolside/laguna-xs-2.1" -display_name = "Laguna XS 2.1 (via OpenRouter)" -family = "laguna-2" - -[providers.openrouter.models."laguna-xs-2.1".limits] -context_window = 262144 -max_output = 32768 - -[providers.openrouter.models."laguna-xs-2.1".features] -tools = true -vision = false -reasoning = true -prompt_cache = true -sampling_params = true - -# Current promotional rate. OpenRouter's authoritative in-band usage.cost -# supersedes this estimate on completed responses. -[providers.openrouter.models."laguna-xs-2.1".costs] -input_cost_per_mtok = 0.06 -output_cost_per_mtok = 0.12 -cache_input_cost_per_mtok = 0.03 - -[providers.openrouter.models."qwen3-coder"] -api_id = "qwen/qwen3-coder" -display_name = "Qwen3 Coder" -family = "qwen3" - -[providers.openrouter.models."qwen3-coder".limits] -context_window = 1050000 -max_output = 16384 - -[providers.openrouter.models."qwen3-coder".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."qwen3-coder".costs] -input_cost_per_mtok = 0.22 -output_cost_per_mtok = 1.80 - -[providers.openrouter.models."qwen3.6-flash"] -api_id = "qwen/qwen3.6-flash" -display_name = "Qwen3.6 Flash" -family = "qwen3" - -[providers.openrouter.models."qwen3.6-flash".limits] -context_window = 1000000 -max_output = 16384 - -[providers.openrouter.models."qwen3.6-flash".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."qwen3.6-flash".costs] -input_cost_per_mtok = 0.1875 -output_cost_per_mtok = 1.125 - -[providers.openrouter.models."qwen3.8-max"] -api_id = "qwen/qwen3.8-max" -display_name = "Qwen3.8 Max" -family = "qwen3" - -[providers.openrouter.models."qwen3.8-max".limits] -context_window = 1000000 -max_output = 131072 - -[providers.openrouter.models."qwen3.8-max".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[providers.openrouter.models."qwen3.8-max".controls] -reasoning_effort = ["low", "medium", "high", "xhigh"] - -[providers.openrouter.models."qwen3.8-max".costs] -input_cost_per_mtok = 2.0 -output_cost_per_mtok = 6.0 -cache_input_cost_per_mtok = 0.25 - -[providers.openrouter.models."glm-5.2"] -api_id = "z-ai/glm-5.2" -display_name = "GLM 5.2 (via OpenRouter)" -family = "glm-5" -aliases = ["glm", "glm5", "glm52", "glm5.2"] - -[providers.openrouter.models."glm-5.2".limits] -context_window = 1048576 -max_output = 131072 - -[providers.openrouter.models."glm-5.2".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[providers.openrouter.models."glm-5.2".controls] -reasoning_effort = ["high", "xhigh"] - -[providers.openrouter.models."glm-5.2".costs] -input_cost_per_mtok = 0.784 -output_cost_per_mtok = 2.464 -cache_input_cost_per_mtok = 0.1456 - -[providers.openrouter.models."glm-4.6"] -api_id = "z-ai/glm-4.6" -display_name = "GLM 4.6" -family = "glm-4" - -[providers.openrouter.models."glm-4.6".limits] -context_window = 203000 -max_output = 16384 - -[providers.openrouter.models."glm-4.6".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."glm-4.6".costs] -input_cost_per_mtok = 0.43 -output_cost_per_mtok = 1.74 - -[providers.openrouter.models."nemotron-3-super-120b-a12b"] -api_id = "nvidia/nemotron-3-super-120b-a12b" -display_name = "NVIDIA Nemotron 3 Super 120B" -family = "nemotron-3" - -[providers.openrouter.models."nemotron-3-super-120b-a12b".limits] -context_window = 1000000 -max_output = 16384 - -[providers.openrouter.models."nemotron-3-super-120b-a12b".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."nemotron-3-super-120b-a12b".costs] -input_cost_per_mtok = 0.09 -output_cost_per_mtok = 0.45 - -[providers.openrouter.models."devstral-2512"] -api_id = "mistralai/devstral-2512" -display_name = "Devstral 2512" -family = "devstral" - -[providers.openrouter.models."devstral-2512".limits] -context_window = 262144 -max_output = 16384 - -[providers.openrouter.models."devstral-2512".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."devstral-2512".costs] -input_cost_per_mtok = 0.40 -output_cost_per_mtok = 2.00 diff --git a/lib/foundation/fabro-model/src/catalog/providers/poolside.toml b/lib/foundation/fabro-model/src/catalog/providers/poolside.toml deleted file mode 100644 index 65ce87246..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/poolside.toml +++ /dev/null @@ -1,61 +0,0 @@ -[providers.poolside] -display_name = "Poolside" -adapter = "openai_compatible" -api_key_url = "https://platform.poolside.ai" -base_url = "https://inference.poolside.ai/v1" -priority = 65 - -[providers.poolside.auth] -credentials = ["env:POOLSIDE_API_KEY", "vault:POOLSIDE_API_KEY"] - -[providers.poolside.models."laguna-s-2.1"] -api_id = "poolside/laguna-s-2.1" -display_name = "Laguna S 2.1" -family = "laguna-2" -default = true -aliases = ["laguna", "laguna-s"] - -[providers.poolside.models."laguna-s-2.1".limits] -context_window = 1048576 -max_output = 131072 - -[providers.poolside.models."laguna-s-2.1".features] -tools = true -vision = false -reasoning = true -prompt_cache = true -sampling_params = true - -# Poolside Platform is free for a limited preview period. Keep the published -# paid hosted rate as Fabro's durable estimate for paid access and post-preview -# usage. -[providers.poolside.models."laguna-s-2.1".costs] -input_cost_per_mtok = 0.10 -output_cost_per_mtok = 0.20 -cache_input_cost_per_mtok = 0.01 - -[providers.poolside.models."laguna-xs-2.1"] -api_id = "poolside/laguna-xs-2.1" -display_name = "Laguna XS 2.1" -family = "laguna-2" -small_default = true -probe = true -aliases = ["laguna-xs"] - -[providers.poolside.models."laguna-xs-2.1".limits] -context_window = 262144 -max_output = 32768 - -[providers.poolside.models."laguna-xs-2.1".features] -tools = true -vision = false -reasoning = true -prompt_cache = true -sampling_params = true - -# Poolside Platform is free for a limited preview period. These are Poolside's -# published paid endpoint rates. -[providers.poolside.models."laguna-xs-2.1".costs] -input_cost_per_mtok = 0.10 -output_cost_per_mtok = 0.20 -cache_input_cost_per_mtok = 0.05 diff --git a/lib/foundation/fabro-model/src/catalog/providers/venice.toml b/lib/foundation/fabro-model/src/catalog/providers/venice.toml deleted file mode 100644 index 9791591e0..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/venice.toml +++ /dev/null @@ -1,212 +0,0 @@ -# Model IDs, capabilities, contexts, and prices are from Venice's published -# model catalog, verified 2026-08-21: -# https://github.com/veniceai/api-docs/blob/59a300b1d036c0c0acc0e5f75c0ab0dd07c40c1c/data/static-models.json - -[providers.venice] -display_name = "Venice" -adapter = "openai_compatible" -base_url = "https://api.venice.ai/api/v1" -priority = 35 -aliases = ["venice-ai"] -billing_policy = "openai" - -[providers.venice.auth] -credentials = ["env:VENICE_API_KEY", "vault:VENICE_API_KEY"] - -[providers.venice.models."kimi-k3"] -display_name = "Kimi K3" -family = "kimi-k3" -agent_profile = "kimi" -aliases = ["kimi"] - -[providers.venice.models."kimi-k3".limits] -context_window = 1000000 -max_output = 131072 - -[providers.venice.models."kimi-k3".features] -tools = true -vision = true -reasoning = true -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.venice.models."kimi-k3".costs] -input_cost_per_mtok = 3.75 -output_cost_per_mtok = 18.75 -cache_input_cost_per_mtok = 0.375 - -[providers.venice.models."kimi-k3-fast"] -api_id = "kimi-k3-fast-api" -display_name = "Kimi K3 Fast" -family = "kimi-k3" -agent_profile = "kimi" -aliases = ["kimi-fast"] - -[providers.venice.models."kimi-k3-fast".limits] -context_window = 1000000 -max_output = 131072 - -[providers.venice.models."kimi-k3-fast".features] -tools = true -vision = true -reasoning = true -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.venice.models."kimi-k3-fast".costs] -input_cost_per_mtok = 4.5 -output_cost_per_mtok = 22.5 -cache_input_cost_per_mtok = 0.45 - -[providers.venice.models."grok-4.6"] -api_id = "grok-4-6" -display_name = "Grok 4.6" -family = "grok-4" -aliases = ["grok", "grok46", "grok-46"] - -[providers.venice.models."grok-4.6".limits] -context_window = 500000 -max_output = 32000 - -[providers.venice.models."grok-4.6".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true - -[providers.venice.models."grok-4.6".controls] -reasoning_effort = ["low", "medium", "high", "xhigh"] - -[providers.venice.models."grok-4.6".costs] -input_cost_per_mtok = 2.27 -output_cost_per_mtok = 6.8 -cache_input_cost_per_mtok = 0.57 - -[providers.venice.models."glm-5.3"] -api_id = "z-ai-glm-5-3" -display_name = "GLM 5.3" -family = "glm-5" -aliases = ["glm", "glm5", "glm53", "glm5.3", "glm-5-3"] - -[providers.venice.models."glm-5.3".limits] -context_window = 1000000 -max_output = 131072 - -[providers.venice.models."glm-5.3".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true - -[providers.venice.models."glm-5.3".controls] -reasoning_effort = ["low", "high", "max"] - -[providers.venice.models."glm-5.3".costs] -input_cost_per_mtok = 1.75 -output_cost_per_mtok = 5.5 -cache_input_cost_per_mtok = 0.325 - -[providers.venice.models."deepseek-v4-flash"] -api_id = "deepseek-v4-flash-0731" -display_name = "DeepSeek V4 Flash" -family = "deepseek-v4" -agent_profile = "openai" -default = true -aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] - -[providers.venice.models."deepseek-v4-flash".limits] -context_window = 1000000 -max_output = 32768 - -[providers.venice.models."deepseek-v4-flash".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.venice.models."deepseek-v4-flash".controls] -reasoning_effort = ["low", "high", "max"] - -[providers.venice.models."deepseek-v4-flash".costs] -input_cost_per_mtok = 0.175 -output_cost_per_mtok = 0.35 -cache_input_cost_per_mtok = 0.035 - -[providers.venice.models."deepseek-v4-pro"] -api_id = "deepseek-v4-pro-0813" -display_name = "DeepSeek V4 Pro" -family = "deepseek-v4" -agent_profile = "openai" -aliases = ["deepseek-pro"] - -[providers.venice.models."deepseek-v4-pro".limits] -context_window = 1000000 -max_output = 32768 - -[providers.venice.models."deepseek-v4-pro".features] -tools = true -vision = false -reasoning = true -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.venice.models."deepseek-v4-pro".costs] -input_cost_per_mtok = 1.65 -output_cost_per_mtok = 4.95 -cache_input_cost_per_mtok = 0.165 - -[providers.venice.models."qwen3.8-max"] -api_id = "qwen-3-8-max" -display_name = "Qwen 3.8 Max" -family = "qwen3" -aliases = ["qwen", "qwen-max", "qwen3.8", "qwen-3.8", "qwen38", "qwen-3.8-max", "qwen38-max"] - -[providers.venice.models."qwen3.8-max".limits] -context_window = 1000000 -max_output = 131072 - -[providers.venice.models."qwen3.8-max".features] -tools = true -vision = true -reasoning = true -reasoning_by_default = true -prompt_cache = true - -[providers.venice.models."qwen3.8-max".costs] -input_cost_per_mtok = 2.5 -output_cost_per_mtok = 7.5 -cache_input_cost_per_mtok = 0.3125 - -[providers.venice.models."qwen3.8-27b"] -api_id = "qwen-3-8-27b" -display_name = "Qwen 3.8 27B" -family = "qwen3.8" -aliases = ["qwen-27b", "qwen-3.8-27b", "qwen38-27b"] - -[providers.venice.models."qwen3.8-27b".limits] -context_window = 262144 -max_output = 131072 - -[providers.venice.models."qwen3.8-27b".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true - -[providers.venice.models."qwen3.8-27b".controls] -reasoning_effort = ["low", "medium", "xhigh"] - -[providers.venice.models."qwen3.8-27b".costs] -input_cost_per_mtok = 0.45 -output_cost_per_mtok = 3.2 diff --git a/lib/foundation/fabro-model/src/catalog/providers/zai.toml b/lib/foundation/fabro-model/src/catalog/providers/zai.toml deleted file mode 100644 index 58fb8c38a..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/zai.toml +++ /dev/null @@ -1,53 +0,0 @@ -[providers.zai] -display_name = "Z.ai" -adapter = "openai_compatible" -api_key_url = "https://open.bigmodel.cn/usercenter/apikeys" -base_url = "https://api.z.ai/api/coding/paas/v4" -priority = 60 - -[providers.zai.auth] -credentials = ["env:ZAI_API_KEY", "vault:ZAI_API_KEY"] - -[providers.zai.models."glm-5.2"] -display_name = "GLM 5.2" -family = "glm-5" -default = true -aliases = ["glm", "glm5", "glm52", "glm5.2"] - -[providers.zai.models."glm-5.2".limits] -context_window = 1048576 -max_output = 131072 - -[providers.zai.models."glm-5.2".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[providers.zai.models."glm-5.2".controls] -reasoning_effort = ["high", "max"] - -[providers.zai.models."glm-5.2".costs] -input_cost_per_mtok = 1.4 -output_cost_per_mtok = 4.4 -cache_input_cost_per_mtok = 0.26 - -[providers.zai.models."glm-4.7"] -display_name = "GLM 4.7" -family = "glm-4" -estimated_output_tps = 100 -aliases = ["glm4"] - -[providers.zai.models."glm-4.7".limits] -context_window = 202752 -max_output = 16384 - -[providers.zai.models."glm-4.7".features] -tools = true -vision = false -reasoning = false - -[providers.zai.models."glm-4.7".costs] -input_cost_per_mtok = 0.6 -output_cost_per_mtok = 2.2 diff --git a/lib/foundation/fabro-model/src/codec.rs b/lib/foundation/fabro-model/src/codec.rs deleted file mode 100644 index b9667d286..000000000 --- a/lib/foundation/fabro-model/src/codec.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! Wire-dialect identity shared by the model catalog and LLM route assembly. -//! -//! A codec names *what the bytes say* — the wire dialect a route speaks — -//! independently of the transport/auth scheme named by -//! [`AdapterKind`](crate::AdapterKind). Catalog rows may select a codec -//! explicitly; rows that omit it inherit the adapter's default, which -//! reproduces the historical adapter→dialect fusion exactly. - -use serde::{Deserialize, Serialize}; -use strum::{Display, EnumString, IntoStaticStr, VariantArray}; - -use crate::adapter::AdapterKind; - -/// Stable wire-dialect identity for a route. -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - Display, - EnumString, - IntoStaticStr, - VariantArray, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum CodecKind { - AnthropicMessages, - #[serde(rename = "openai_responses")] - #[strum(to_string = "openai_responses")] - OpenAiResponses, - /// The conservative Chat Completions dialect. The name matches today's - /// `openai_compatible` adapter string; `openai_chat` stays reserved for a - /// possible future full-proprietary Chat Completions dialect. - #[serde(rename = "openai_compatible")] - #[strum(to_string = "openai_compatible")] - OpenAiCompatible, - GeminiGenerate, - /// Amazon Bedrock's unified Converse/ConverseStream dialect: one - /// model-agnostic envelope AWS translates to each hosted family's - /// native format server-side. - BedrockConverse, -} - -impl CodecKind { - /// The codec each adapter kind drives when a catalog row does not - /// configure `codec` explicitly. These defaults reproduce the historical - /// behavior where the adapter implied the wire dialect. - #[must_use] - pub fn default_for(adapter: AdapterKind) -> Self { - match adapter { - AdapterKind::Anthropic => Self::AnthropicMessages, - AdapterKind::OpenAi => Self::OpenAiResponses, - AdapterKind::Gemini => Self::GeminiGenerate, - AdapterKind::OpenAiCompatible => Self::OpenAiCompatible, - AdapterKind::Bedrock => Self::BedrockConverse, - } - } - - #[must_use] - pub fn as_str(self) -> &'static str { - self.into() - } -} - -impl AsRef for CodecKind { - fn as_ref(&self) -> &str { - (*self).as_str() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn codec_kind_round_trips_as_snake_case() { - for kind in CodecKind::VARIANTS { - let json = serde_json::to_string(kind).unwrap(); - assert_eq!(json, format!("\"{}\"", kind.as_str())); - let parsed: CodecKind = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, *kind); - assert_eq!(kind.as_str().parse::().unwrap(), *kind); - } - } - - #[test] - fn codec_kind_strings_match_route_vocabulary() { - for (kind, expected) in [ - (CodecKind::AnthropicMessages, "anthropic_messages"), - (CodecKind::OpenAiResponses, "openai_responses"), - (CodecKind::OpenAiCompatible, "openai_compatible"), - (CodecKind::GeminiGenerate, "gemini_generate"), - (CodecKind::BedrockConverse, "bedrock_converse"), - ] { - assert_eq!(kind.as_str(), expected); - assert_eq!(kind.to_string(), expected); - } - } - - #[test] - fn adapter_defaults_reproduce_the_historical_fusion() { - for (adapter, expected) in [ - (AdapterKind::Anthropic, CodecKind::AnthropicMessages), - (AdapterKind::OpenAi, CodecKind::OpenAiResponses), - (AdapterKind::Gemini, CodecKind::GeminiGenerate), - (AdapterKind::OpenAiCompatible, CodecKind::OpenAiCompatible), - (AdapterKind::Bedrock, CodecKind::BedrockConverse), - ] { - assert_eq!(CodecKind::default_for(adapter), expected); - } - } -} diff --git a/lib/foundation/fabro-model/src/ids.rs b/lib/foundation/fabro-model/src/ids.rs deleted file mode 100644 index b46d89cfe..000000000 --- a/lib/foundation/fabro-model/src/ids.rs +++ /dev/null @@ -1,209 +0,0 @@ -//! String-backed provider and model identifiers. -//! -//! Provider and model identity are catalog data, not closed enums. These -//! newtypes give catalog/auth/server seams a single, type-safe wrapper while -//! keeping wire format compatible with plain strings. - -use std::fmt; -use std::str::FromStr; - -use serde::{Deserialize, Serialize}; - -/// Stable provider identifier referenced from settings, vault, and request -/// routing. -/// -/// Wraps a `String` because the set of providers is open-ended and supplied -/// by `[llm.providers]` settings rather than compiled into a Rust enum. -#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct ProviderId(String); - -impl ProviderId { - pub const ANTHROPIC: &'static str = "anthropic"; - pub const OPENAI: &'static str = "openai"; - pub const GEMINI: &'static str = "gemini"; - - /// Construct a provider ID from any string-like value without validation. - /// Catalog construction is responsible for canonicalisation; consumers - /// only need a wrapper for type clarity. - pub fn new(id: impl Into) -> Self { - Self(id.into()) - } - - /// Borrow the inner string. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Consume the wrapper and return the inner `String`. - #[must_use] - pub fn into_inner(self) -> String { - self.0 - } - - #[must_use] - pub fn anthropic() -> Self { - Self::new(Self::ANTHROPIC) - } - - #[must_use] - pub fn openai() -> Self { - Self::new(Self::OPENAI) - } - - #[must_use] - pub fn gemini() -> Self { - Self::new(Self::GEMINI) - } - - #[must_use] - pub fn display_name(&self) -> String { - self.0.clone() - } -} - -impl fmt::Display for ProviderId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl fmt::Debug for ProviderId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl From<&str> for ProviderId { - fn from(s: &str) -> Self { - Self(s.to_string()) - } -} - -impl From for ProviderId { - fn from(s: String) -> Self { - Self(s) - } -} - -impl FromStr for ProviderId { - type Err = std::convert::Infallible; - - fn from_str(s: &str) -> Result { - Ok(Self::from(s)) - } -} - -impl AsRef for ProviderId { - fn as_ref(&self) -> &str { - &self.0 - } -} - -/// Stable canonical, human-facing model identifier. -/// -/// Aliases are alternate selectors for a model offering; they are not model -/// IDs. The same `ModelId` may be offered by more than one provider, so a -/// concrete catalog offering is identified by `(ProviderId, ModelId)`. -#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct ModelId(String); - -impl ModelId { - pub fn new(id: impl Into) -> Self { - Self(id.into()) - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - #[must_use] - pub fn into_inner(self) -> String { - self.0 - } -} - -impl fmt::Display for ModelId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl fmt::Debug for ModelId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - -impl From<&str> for ModelId { - fn from(s: &str) -> Self { - Self(s.to_string()) - } -} - -impl From for ModelId { - fn from(s: String) -> Self { - Self(s) - } -} - -impl AsRef for ModelId { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl PartialEq for ModelId { - fn eq(&self, other: &str) -> bool { - self.0 == other - } -} - -impl PartialEq<&str> for ModelId { - fn eq(&self, other: &&str) -> bool { - self.0 == *other - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn provider_id_is_transparent_string_in_json() { - let id = ProviderId::new("moonshot"); - let json = serde_json::to_string(&id).unwrap(); - assert_eq!(json, "\"moonshot\""); - let back: ProviderId = serde_json::from_str(&json).unwrap(); - assert_eq!(back, id); - } - - #[test] - fn model_id_is_transparent_string_in_json() { - let id = ModelId::new("kimi-k2.5"); - let json = serde_json::to_string(&id).unwrap(); - assert_eq!(json, "\"kimi-k2.5\""); - let back: ModelId = serde_json::from_str(&json).unwrap(); - assert_eq!(back, id); - } - - #[test] - fn display_writes_inner_string() { - assert_eq!(ProviderId::new("anthropic").to_string(), "anthropic"); - assert_eq!( - ModelId::new("claude-opus-4-7").to_string(), - "claude-opus-4-7" - ); - } - - #[test] - fn ord_is_lexicographic() { - let mut v = [ProviderId::new("zai"), ProviderId::new("anthropic")]; - v.sort(); - assert_eq!(v[0].as_str(), "anthropic"); - assert_eq!(v[1].as_str(), "zai"); - } -} diff --git a/lib/foundation/fabro-model/src/lib.rs b/lib/foundation/fabro-model/src/lib.rs deleted file mode 100644 index fc43c95a7..000000000 --- a/lib/foundation/fabro-model/src/lib.rs +++ /dev/null @@ -1,32 +0,0 @@ -pub mod adapter; -pub mod billing; -pub mod bootstrap_catalog; -pub mod catalog; -pub mod codec; -pub mod ids; -pub mod model_ref; -pub mod model_test; -pub mod provider; -pub mod reasoning; -pub mod types; - -pub use adapter::{AdapterKind, AgentProfileKind}; -pub use billing::{ - AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts, CostSource, - GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment, - ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage, - OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros, -}; -pub use catalog::{ - ApiKeyHeaderPolicy, BillingPolicy, Catalog, CredentialRef, CredentialRefParseError, - FallbackTarget, ModelSelectionError, ProviderAuthConfig, SelectedModel, -}; -pub use codec::CodecKind; -pub use ids::{ModelId, ProviderId}; -pub use model_ref::ModelHandle; -pub use model_test::ModelTestMode; -pub use provider::Provider; -pub use reasoning::ReasoningEffort; -pub use types::{ - Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature, -}; diff --git a/lib/foundation/fabro-model/src/model_ref.rs b/lib/foundation/fabro-model/src/model_ref.rs deleted file mode 100644 index f8d1f0a8f..000000000 --- a/lib/foundation/fabro-model/src/model_ref.rs +++ /dev/null @@ -1,109 +0,0 @@ -use std::fmt; -use std::sync::Arc; - -use crate::ids::ProviderId; -use crate::types::Model; - -/// A reference to a model — either a fully resolved `Model` or a -/// provider + model-name pair that hasn't been looked up yet. -#[derive(Clone)] -pub enum ModelHandle { - /// A model whose metadata has been resolved from the catalog. - Resolved(Arc), - /// An unresolved provider:model pair (e.g. from CLI input or config). - ByName { - provider: ProviderId, - model: String, - }, -} - -impl ModelHandle { - /// The model identifier string (e.g. `"claude-opus-4-6"`). - #[must_use] - pub fn model_id(&self) -> &str { - match self { - Self::Resolved(m) => m.id.as_str(), - Self::ByName { model, .. } => model, - } - } - - /// The provider for this model. - #[must_use] - pub fn provider(&self) -> &ProviderId { - match self { - Self::Resolved(m) => &m.provider, - Self::ByName { provider, .. } => provider, - } - } -} - -impl fmt::Display for ModelHandle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}:{}", self.provider(), self.model_id()) - } -} - -impl fmt::Debug for ModelHandle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Resolved(m) => write!(f, "ModelRef::Resolved({:?})", m.id), - Self::ByName { provider, model } => f - .debug_struct("ModelRef::ByName") - .field("provider", provider) - .field("model", model) - .finish(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ProviderId; - use crate::catalog::Catalog; - - #[test] - fn by_name_display() { - let r = ModelHandle::ByName { - provider: ProviderId::anthropic(), - model: "claude-opus-4-6".to_string(), - }; - assert_eq!(r.to_string(), "anthropic:claude-opus-4-6"); - } - - #[test] - fn by_name_accessors() { - let r = ModelHandle::ByName { - provider: ProviderId::openai(), - model: "gpt-5.4".to_string(), - }; - assert_eq!(r.model_id(), "gpt-5.4"); - assert_eq!(r.provider(), &ProviderId::openai()); - } - - #[test] - fn resolved_display() { - let info = Catalog::builtin().get("claude-opus-4-6").unwrap().clone(); - let r = ModelHandle::Resolved(Arc::new(info)); - assert_eq!(r.to_string(), "anthropic:claude-opus-4-6"); - } - - #[test] - fn resolved_accessors() { - let info = Catalog::builtin().get("gpt-5.4").unwrap().clone(); - let r = ModelHandle::Resolved(Arc::new(info)); - assert_eq!(r.model_id(), "gpt-5.4"); - assert_eq!(r.provider(), &ProviderId::openai()); - } - - #[test] - fn debug_format() { - let r = ModelHandle::ByName { - provider: ProviderId::gemini(), - model: "gemini-3.1-pro-preview".to_string(), - }; - let debug = format!("{r:?}"); - assert!(debug.contains("ByName")); - assert!(debug.contains("gemini")); - } -} diff --git a/lib/foundation/fabro-model/src/provider.rs b/lib/foundation/fabro-model/src/provider.rs deleted file mode 100644 index 5d3e227be..000000000 --- a/lib/foundation/fabro-model/src/provider.rs +++ /dev/null @@ -1,95 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::adapter::AdapterKind; -use crate::catalog::CatalogProvider; -use crate::ids::ProviderId; - -/// A user-facing LLM provider from the catalog. -/// -/// The public projection of [`CatalogProvider`]. It deliberately omits -/// internal-only fields (`auth`, `extra_headers`, `billing_policy`, -/// `agent_profile`) so credential material never reaches the wire. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Provider { - pub id: ProviderId, - pub display_name: String, - pub adapter: AdapterKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api_key_url: Option, - pub priority: i32, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub aliases: Vec, - /// Number of catalog models for this provider. Stamped by the handler. - pub model_count: u32, - /// Catalog default model ID for this provider, if any. Stamped by the - /// handler. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_model: Option, - /// True if the server has credential material configured for this provider - /// when the response is produced. Always `false` in static catalog data; - /// stamped by `GET /providers` per request. - #[serde(default)] - pub configured: bool, - /// Suggested vault secret name for configuring this provider, derived - /// from the first vault credential in the catalog. `None` when the - /// provider has no vault credential (e.g. Ollama, env-only providers). - /// Used by the web UI to prefill the create-secret form. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expected_secret_name: Option, -} - -impl Provider { - #[must_use] - pub fn from_catalog( - provider: &CatalogProvider, - model_count: u32, - default_model: Option, - configured: bool, - ) -> Self { - Self { - id: provider.id.clone(), - display_name: provider.display_name.clone(), - adapter: provider.adapter, - base_url: provider.base_url.clone(), - api_key_url: provider.api_key_url.clone(), - priority: provider.priority, - aliases: provider.aliases.clone(), - model_count, - default_model, - configured, - expected_secret_name: provider.vault_secret_name().map(str::to_owned), - } - } -} - -#[cfg(test)] -mod tests { - use super::Provider; - use crate::catalog::Catalog; - use crate::ids::ProviderId; - - #[test] - fn from_catalog_provider_copies_static_fields_and_supplied_runtime_fields() { - let catalog = Catalog::builtin(); - let anthropic = catalog - .provider(&ProviderId::anthropic()) - .expect("builtin catalog must define anthropic"); - - let provider = - Provider::from_catalog(anthropic, 7, Some("claude-opus-4-7".to_string()), true); - - assert_eq!(provider.id, ProviderId::anthropic()); - assert_eq!(provider.display_name, anthropic.display_name); - assert_eq!(provider.adapter, anthropic.adapter); - assert_eq!(provider.priority, anthropic.priority); - assert_eq!(provider.model_count, 7); - assert_eq!(provider.default_model.as_deref(), Some("claude-opus-4-7")); - assert!(provider.configured); - assert_eq!( - provider.expected_secret_name.as_deref(), - Some("ANTHROPIC_API_KEY"), - ); - } -} diff --git a/lib/foundation/fabro-model/src/reasoning.rs b/lib/foundation/fabro-model/src/reasoning.rs deleted file mode 100644 index 56e07ad26..000000000 --- a/lib/foundation/fabro-model/src/reasoning.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Shared reasoning-effort enum. -//! -//! `ReasoningEffort` is a Rust-owned vocabulary type. Catalog data, request -//! validation, OpenAPI replacement types, and the LLM client all share one -//! enum so that adding a new effort value remains a Rust change. - -use std::cmp::Reverse; - -use serde::{Deserialize, Serialize}; - -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - PartialOrd, - Ord, - Serialize, - Deserialize, - strum::Display, - strum::EnumString, - strum::IntoStaticStr, - strum::VariantArray, -)] -#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] -#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum ReasoningEffort { - Low, - Medium, - High, - XHigh, - Max, -} - -impl ReasoningEffort { - #[must_use] - pub fn variants() -> &'static [Self] { - ::VARIANTS - } - - /// Select the supported effort nearest to this value. - /// - /// The enum declaration defines the ordered progression. When two values - /// are equally distant, the higher effort wins. - #[must_use] - pub fn closest_supported(self, supported: &[Self]) -> Option { - supported - .iter() - .copied() - .min_by_key(|effort| ((self as u8).abs_diff(*effort as u8), Reverse(*effort))) - } -} - -#[cfg(test)] -mod tests { - use std::str::FromStr; - - use strum::VariantArray; - - use super::*; - - #[test] - fn parses_canonical_lowercase_strings() { - assert_eq!( - ReasoningEffort::from_str("low").unwrap(), - ReasoningEffort::Low - ); - assert_eq!( - ReasoningEffort::from_str("medium").unwrap(), - ReasoningEffort::Medium - ); - assert_eq!( - ReasoningEffort::from_str("high").unwrap(), - ReasoningEffort::High - ); - assert_eq!( - ReasoningEffort::from_str("xhigh").unwrap(), - ReasoningEffort::XHigh - ); - assert_eq!( - ReasoningEffort::from_str("max").unwrap(), - ReasoningEffort::Max - ); - } - - #[test] - fn rejects_unknown_strings() { - assert!(ReasoningEffort::from_str("none").is_err()); - assert!(ReasoningEffort::from_str("").is_err()); - assert!(ReasoningEffort::from_str("HIGH").is_err()); - } - - #[test] - fn display_matches_serde_lowercase() { - assert_eq!(ReasoningEffort::XHigh.to_string(), "xhigh"); - assert_eq!(<&'static str>::from(ReasoningEffort::Max), "max"); - } - - #[test] - fn variants_in_ordered_progression() { - let v = ReasoningEffort::VARIANTS; - assert_eq!(v[0], ReasoningEffort::Low); - assert_eq!(v[v.len() - 1], ReasoningEffort::Max); - } - - #[test] - fn closest_supported_uses_exact_match() { - assert_eq!( - ReasoningEffort::High.closest_supported(&[ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - Some(ReasoningEffort::High) - ); - } - - #[test] - fn closest_supported_rounds_equal_distance_up() { - let kimi = [ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ]; - assert_eq!( - ReasoningEffort::Medium.closest_supported(&kimi), - Some(ReasoningEffort::High) - ); - assert_eq!( - ReasoningEffort::XHigh.closest_supported(&kimi), - Some(ReasoningEffort::Max) - ); - } - - #[test] - fn closest_supported_uses_nearest_lower_value_when_needed() { - assert_eq!( - ReasoningEffort::Max - .closest_supported(&[ReasoningEffort::High, ReasoningEffort::XHigh]), - Some(ReasoningEffort::XHigh) - ); - } - - #[test] - fn closest_supported_returns_none_for_unsupported_control() { - assert_eq!(ReasoningEffort::High.closest_supported(&[]), None); - } - - #[test] - fn round_trip_through_json() { - let json = serde_json::to_string(&ReasoningEffort::High).unwrap(); - assert_eq!(json, "\"high\""); - let parsed: ReasoningEffort = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, ReasoningEffort::High); - } -} diff --git a/lib/foundation/fabro-model/src/types.rs b/lib/foundation/fabro-model/src/types.rs deleted file mode 100644 index 50ae020dc..000000000 --- a/lib/foundation/fabro-model/src/types.rs +++ /dev/null @@ -1,287 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::ids::{ModelId, ProviderId}; -use crate::reasoning::ReasoningEffort; - -// --- 2.9 Model --- - -#[derive( - Debug, - Clone, - Copy, - Default, - PartialEq, - Eq, - Serialize, - Deserialize, - strum::Display, - strum::EnumString, - strum::IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum ReasoningEffortFeature { - Levels, - /// Effort levels are supported, and thinking is natively always-on - /// adaptive at the endpoint; a manual thinking on/off toggle is not - /// accepted. - AlwaysAdaptive, - #[default] - None, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ModelLimits { - pub context_window: i64, - pub max_output: Option, -} - -fn default_true() -> bool { - true -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ModelFeatures { - pub tools: bool, - pub vision: bool, - pub reasoning: bool, - /// Whether this model endpoint supports a native reasoning-effort - /// parameter. User-facing allowed effort values live in catalog controls. - #[serde(default)] - pub reasoning_effort: ReasoningEffortFeature, - /// Whether this model endpoint supports prompt caching annotations. - #[serde(default)] - pub prompt_cache: bool, - /// Whether the endpoint only caches when the request marks the cacheable - /// prefix with Anthropic-style `cache_control` breakpoints. Set on - /// OpenAI-compatible routes fronting Anthropic models (e.g. Claude via - /// OpenRouter); dialects whose caching mechanism is implied (native - /// Anthropic, Bedrock) ignore it. - #[serde(default)] - pub cache_control_breakpoints: bool, - /// Whether the model endpoint accepts classic sampling parameters - /// (`temperature`, `top_p`). Models with always-on adaptive behavior - /// reject them. - #[serde(default = "default_true")] - pub sampling_params: bool, -} - -impl ModelFeatures { - /// Whether the model endpoint accepts a native reasoning-effort level. - #[must_use] - pub fn supports_reasoning_effort(&self) -> bool { - matches!( - self.reasoning_effort, - ReasoningEffortFeature::Levels | ReasoningEffortFeature::AlwaysAdaptive - ) - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ModelCosts { - pub input_cost_per_mtok: Option, - pub output_cost_per_mtok: Option, - pub cache_input_cost_per_mtok: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct ModelControls { - /// Exact reasoning-effort values accepted by this provider/model offering. - /// An empty list means the request control is unsupported. - #[serde(default)] - pub reasoning_effort: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Model { - pub id: ModelId, - pub provider: ProviderId, - pub family: String, - pub display_name: String, - pub limits: ModelLimits, - pub training: Option, - pub knowledge_cutoff: Option, - pub features: ModelFeatures, - /// Required in API responses; defaulted on deserialization so newer - /// clients tolerate older servers that predate this field. - #[serde(default)] - pub controls: ModelControls, - pub costs: ModelCosts, - pub estimated_output_tps: Option, - pub aliases: Vec, - #[serde(default)] - pub default: bool, - #[serde(default)] - pub small_default: bool, - /// Whether the server has any credential configured for this model's - /// provider at the time of the response. Always `false` in static catalog - /// data; populated by `GET /models` per request. - #[serde(default)] - pub configured: bool, -} - -impl Model { - pub fn id(&self) -> &str { - self.id.as_str() - } - - pub fn provider(&self) -> &ProviderId { - &self.provider - } - - pub fn family(&self) -> &str { - &self.family - } - - pub fn display_name(&self) -> &str { - &self.display_name - } - - pub fn context_window(&self) -> i64 { - self.limits.context_window - } - - pub fn max_output(&self) -> Option { - self.limits.max_output - } - - pub fn supports_tools(&self) -> bool { - self.features.tools - } - - pub fn supports_vision(&self) -> bool { - self.features.vision - } - - pub fn supports_reasoning(&self) -> bool { - self.features.reasoning - } - - pub fn supports_reasoning_effort(&self) -> bool { - self.features.supports_reasoning_effort() - } - - pub fn supports_prompt_cache(&self) -> bool { - self.features.prompt_cache - } - - pub fn supports_sampling_params(&self) -> bool { - self.features.sampling_params - } - - pub fn training(&self) -> Option<&str> { - self.training.as_deref() - } - - pub fn knowledge_cutoff(&self) -> Option<&str> { - self.knowledge_cutoff.as_deref() - } - - pub fn input_cost_per_mtok(&self) -> Option { - self.costs.input_cost_per_mtok - } - - pub fn output_cost_per_mtok(&self) -> Option { - self.costs.output_cost_per_mtok - } - - pub fn cache_input_cost_per_mtok(&self) -> Option { - self.costs.cache_input_cost_per_mtok - } - - pub fn estimated_output_tps(&self) -> Option { - self.estimated_output_tps - } - - pub fn aliases(&self) -> &[String] { - &self.aliases - } - - pub fn is_default(&self) -> bool { - self.default - } - - pub fn is_small_default(&self) -> bool { - self.small_default - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ids::ProviderId; - - #[test] - fn reasoning_effort_feature_always_adaptive_round_trips() { - let parsed: ReasoningEffortFeature = - serde_json::from_value(serde_json::json!("always_adaptive")).unwrap(); - assert_eq!(parsed, ReasoningEffortFeature::AlwaysAdaptive); - assert_eq!( - serde_json::to_value(parsed).unwrap(), - serde_json::json!("always_adaptive") - ); - assert_eq!(parsed.to_string(), "always_adaptive"); - assert_eq!( - "always_adaptive".parse::().unwrap(), - parsed - ); - } - - #[test] - fn inherent_methods_return_correct_values() { - let info = Model { - id: ModelId::new("model-id"), - provider: ProviderId::new("provider-id"), - family: "family".to_string(), - display_name: "Display Name".to_string(), - limits: ModelLimits { - context_window: 123_456, - max_output: Some(7_890), - }, - training: Some("training".to_string()), - knowledge_cutoff: Some("knowledge-cutoff".to_string()), - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls::default(), - costs: ModelCosts { - input_cost_per_mtok: Some(1.0), - output_cost_per_mtok: Some(2.0), - cache_input_cost_per_mtok: Some(0.1), - }, - estimated_output_tps: Some(42.0), - aliases: vec!["alias".to_string()], - default: true, - small_default: true, - configured: false, - }; - - assert_eq!(info.id(), "model-id"); - assert_eq!(info.provider(), &ProviderId::new("provider-id")); - assert_eq!(info.family(), "family"); - assert_eq!(info.display_name(), "Display Name"); - assert_eq!(info.context_window(), 123_456); - assert_eq!(info.max_output(), Some(7_890)); - assert!(info.supports_tools()); - assert!(info.supports_vision()); - assert!(info.supports_reasoning()); - assert!(info.supports_reasoning_effort()); - assert!(info.supports_prompt_cache()); - assert!(info.supports_sampling_params()); - assert_eq!(info.training(), Some("training")); - assert_eq!(info.knowledge_cutoff(), Some("knowledge-cutoff")); - assert_eq!(info.input_cost_per_mtok(), Some(1.0)); - assert_eq!(info.output_cost_per_mtok(), Some(2.0)); - assert_eq!(info.cache_input_cost_per_mtok(), Some(0.1)); - assert_eq!(info.estimated_output_tps(), Some(42.0)); - assert_eq!(info.aliases(), &["alias".to_string()]); - assert!(info.is_default()); - assert!(info.is_small_default()); - } -} diff --git a/lib/foundation/fabro-test/src/lib.rs b/lib/foundation/fabro-test/src/lib.rs index 5d442b411..62dd1a89c 100644 --- a/lib/foundation/fabro-test/src/lib.rs +++ b/lib/foundation/fabro-test/src/lib.rs @@ -2446,11 +2446,13 @@ pub async fn twin_openai() -> &'static TwinOpenAi { let base_url = format!("http://127.0.0.1:{}/v1", addr.port()); let config = TwinConfig { - bind_addr: addr, + bind_addr: addr, require_auth: true, enable_admin: true, + ..TwinConfig::from_lookup(&|_| None).expect("twin-openai defaults should load") }; - let app = twin_openai::build_app_with_config(config); + let app = + twin_openai::build_app_with_config(config).expect("twin-openai app should build"); tokio::spawn(async move { axum::serve(listener, app).await.expect("twin-openai serve"); diff --git a/lib/foundation/fabro-types/Cargo.toml b/lib/foundation/fabro-types/Cargo.toml index 273309c73..4cd59e7bb 100644 --- a/lib/foundation/fabro-types/Cargo.toml +++ b/lib/foundation/fabro-types/Cargo.toml @@ -21,9 +21,9 @@ workspace = true chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, optional = true } dirs.workspace = true -fabro-model = { path = "../fabro-model" } fabro-util = { path = "../fabro-util" } hex.workspace = true +lithos-llm = { workspace = true, features = ["runtime"] } serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/lib/foundation/fabro-model/src/adapter.rs b/lib/foundation/fabro-types/src/agent_profile.rs similarity index 55% rename from lib/foundation/fabro-model/src/adapter.rs rename to lib/foundation/fabro-types/src/agent_profile.rs index c9eb25b97..991751ecf 100644 --- a/lib/foundation/fabro-model/src/adapter.rs +++ b/lib/foundation/fabro-types/src/agent_profile.rs @@ -1,54 +1,12 @@ -//! Adapter registry keys shared by the model catalog and LLM factories. +//! Agent profile vocabulary shared by the catalog and the agent. //! -//! Provider/model catalog rows parse adapter strings into [`AdapterKind`]. -//! Runtime code should carry the typed kind instead of re-matching on strings. +//! The catalog records which profile a model should run under in its +//! `metadata.agent.profile` entry, a namespace lithos-llm ships and Pebble +//! reads too. This enum is the Rust spelling of that value. use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, IntoStaticStr, VariantArray}; -/// Stable adapter identity for protocol/client behavior. -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - Display, - EnumString, - IntoStaticStr, - VariantArray, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum AdapterKind { - Anthropic, - #[serde(rename = "openai")] - #[strum(to_string = "openai")] - OpenAi, - Gemini, - #[serde(rename = "openai_compatible")] - #[strum(to_string = "openai_compatible")] - OpenAiCompatible, - Bedrock, -} - -impl AdapterKind { - #[must_use] - pub fn as_str(self) -> &'static str { - self.into() - } -} - -impl AsRef for AdapterKind { - fn as_ref(&self) -> &str { - (*self).as_str() - } -} - -/// Internal dispatch key that `fabro-agent` maps to a concrete agent profile. #[derive( Debug, Clone, @@ -89,33 +47,30 @@ pub enum AgentProfileKind { /// per provider, so other models on the `openai` provider keep /// [`Self::OpenAi`]. Gpt56, + /// GPT-6 models (Astra), which Codex drives with the same narrow tool + /// contract as GPT-5.6. Fabro runs them on the GPT-5.6 harness. + Gpt6, +} + +impl AgentProfileKind { + #[must_use] + pub fn as_str(self) -> &'static str { + self.into() + } + + /// Whether the profile runs Codex's narrow core tool set (a shell, a file + /// editor, and `update_plan`) instead of Fabro's dedicated read, + /// discovery, and fetch tools. + #[must_use] + pub fn uses_codex_core_tools(self) -> bool { + matches!(self, Self::Gpt56 | Self::Gpt6) + } } #[cfg(test)] mod tests { use super::*; - #[test] - fn adapter_kind_round_trips_as_snake_case() { - for kind in AdapterKind::VARIANTS { - let json = serde_json::to_string(kind).unwrap(); - assert_eq!(json, format!("\"{}\"", kind.as_str())); - let parsed: AdapterKind = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, *kind); - assert_eq!(kind.as_str().parse::().unwrap(), *kind); - } - } - - #[test] - fn bedrock_adapter_kind_roundtrips() { - assert_eq!(AdapterKind::Bedrock.as_str(), "bedrock"); - assert_eq!( - "bedrock".parse::().unwrap(), - AdapterKind::Bedrock - ); - assert!(AdapterKind::VARIANTS.contains(&AdapterKind::Bedrock)); - } - #[test] fn agent_profile_kind_round_trips_as_settings_strings() { for kind in AgentProfileKind::VARIANTS { @@ -127,4 +82,14 @@ mod tests { assert_eq!(expected.parse::().unwrap(), *kind); } } + + #[test] + fn claude5_and_gpt56_use_their_catalog_spellings() { + assert_eq!(AgentProfileKind::Claude5.as_str(), "claude-5"); + assert_eq!(AgentProfileKind::Gpt56.as_str(), "gpt56"); + assert_eq!(AgentProfileKind::Gpt6.as_str(), "gpt6"); + assert!(AgentProfileKind::Gpt6.uses_codex_core_tools()); + assert!(!AgentProfileKind::OpenAi.uses_codex_core_tools()); + assert_eq!(AgentProfileKind::OpenAi.as_str(), "openai"); + } } diff --git a/lib/foundation/fabro-types/src/billing.rs b/lib/foundation/fabro-types/src/billing.rs index d994df7ea..1fe4c77ae 100644 --- a/lib/foundation/fabro-types/src/billing.rs +++ b/lib/foundation/fabro-types/src/billing.rs @@ -1,6 +1,464 @@ -pub use fabro_model::{ - AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts, - GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment, - ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage, - OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros, -}; +//! Billing rollup vocabulary. +//! +//! Per-response token usage and cost come from lithos: [`TokenCounts`] holds +//! the five disjoint buckets and [`CostSource`] says where a cost came from. +//! Fabro sums that usage across responses, stages, and runs. The types here +//! are those sums, plus [`ModelRef`], the identity a billed response is +//! grouped under. + +use lithos_llm::catalog::{ModelHandle, ModelId, ProviderId}; +pub use lithos_llm::types::{Cost, CostSource, Speed, TokenCounts}; +use serde::{Deserialize, Serialize}; + +const USD_MICROS_PER_USD_F64: f64 = 1_000_000.0; + +#[allow( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + reason = "Billing rounds bounded finite floats into i64 counters by design." +)] +fn saturating_rounded_f64_to_i64(value: f64) -> i64 { + if !value.is_finite() { + return if value.is_sign_negative() { + i64::MIN + } else { + i64::MAX + }; + } + + if value <= i64::MIN as f64 { + i64::MIN + } else if value >= i64::MAX as f64 { + i64::MAX + } else { + value as i64 + } +} + +fn saturating_u64_to_i64(value: u64) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} + +fn saturating_i64_to_u64(value: i64) -> u64 { + u64::try_from(value).unwrap_or_default() +} + +/// A USD amount in micros (one millionth of a dollar). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] +pub struct UsdMicros(pub i64); + +impl UsdMicros { + #[must_use] + pub fn from_usd(usd: f64) -> Self { + Self(saturating_rounded_f64_to_i64( + (usd * USD_MICROS_PER_USD_F64).round(), + )) + } + + /// Converts a lithos cost into Fabro's signed micros. + #[must_use] + pub fn from_cost(cost: &Cost) -> Self { + Self(saturating_u64_to_i64(cost.usd_micros)) + } + + /// Folds a cost into a running total that stays `None` until a cost is + /// observed (`None` means "no provider data", not $0). + pub fn accumulate(total: &mut Option, cost: Option) { + if let Some(cost) = cost { + *total.get_or_insert_default() += cost; + } + } +} + +impl std::ops::Add for UsdMicros { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self(self.0.saturating_add(rhs.0)) + } +} + +impl std::ops::AddAssign for UsdMicros { + fn add_assign(&mut self, rhs: Self) { + *self = *self + rhs; + } +} + +impl std::iter::Sum for UsdMicros { + fn sum>(iter: I) -> Self { + iter.fold(Self::default(), |acc, value| acc + value) + } +} + +/// Adds `rhs` into `total` bucket by bucket with saturation. +pub fn add_usage(total: &mut TokenCounts, rhs: TokenCounts) { + total.input = total.input.saturating_add(rhs.input); + total.output = total.output.saturating_add(rhs.output); + total.reasoning = total.reasoning.saturating_add(rhs.reasoning); + total.cache_read = total.cache_read.saturating_add(rhs.cache_read); + total.cache_write = total.cache_write.saturating_add(rhs.cache_write); +} + +fn accumulate_optional_usd_micros(total: &mut Option, cost: Option) { + let mut typed_total = (*total).map(UsdMicros); + UsdMicros::accumulate(&mut typed_total, cost.map(UsdMicros)); + *total = typed_total.map(|value| value.0); +} + +/// Provider-qualified model identity a billed response is grouped under. +/// +/// Carries the requested speed tier because providers price tiers +/// differently, so two responses from the same model at different speeds are +/// separate billing rows. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelRef { + pub provider: ProviderId, + pub model_id: ModelId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option, +} + +impl ModelRef { + #[must_use] + pub fn new(provider: ProviderId, model_id: ModelId) -> Self { + Self { + provider, + model_id, + speed: None, + } + } + + #[must_use] + pub fn from_handle(handle: &ModelHandle, speed: Option) -> Self { + Self { + provider: handle.provider().clone(), + model_id: handle.model().clone(), + speed, + } + } + + #[must_use] + pub fn with_speed(mut self, speed: Option) -> Self { + self.speed = speed; + self + } + + #[must_use] + pub fn handle(&self) -> ModelHandle { + ModelHandle::new(self.provider.clone(), self.model_id.clone()) + } + + /// Stable ordering key: provider, then model, then speed label. + #[must_use] + pub fn sort_key(&self) -> (&str, &str, &'static str) { + ( + self.provider.as_str(), + self.model_id.as_str(), + self.speed.map_or("", Speed::as_str), + ) + } +} + +impl std::hash::Hash for ModelRef { + fn hash(&self, state: &mut H) { + self.provider.hash(state); + self.model_id.hash(state); + self.speed.map(Speed::as_str).hash(state); + } +} + +impl std::fmt::Display for ModelRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.provider, self.model_id)?; + if let Some(speed) = self.speed { + write!(f, " ({speed})")?; + } + Ok(()) + } +} + +/// Usage and cost of one billed model response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BilledModelUsage { + pub model: ModelRef, + pub tokens: TokenCounts, + /// Cost for `tokens`, when the provider reported one or the catalog could + /// price them. `None` means no cost data, not zero. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_usd_micros: Option, +} + +impl BilledModelUsage { + #[must_use] + pub fn new(model: ModelRef, tokens: TokenCounts, cost: Option) -> Self { + Self { + model, + tokens, + total_usd_micros: cost.map(|cost| UsdMicros::from_cost(&cost).0), + } + } + + #[must_use] + pub fn model(&self) -> &ModelRef { + &self.model + } + + #[must_use] + pub fn model_id(&self) -> &str { + self.model.model_id.as_str() + } + + #[must_use] + pub fn tokens(&self) -> TokenCounts { + self.tokens + } + + /// Overrides the billed total with a reported cost; `None` leaves the + /// existing value in place. + #[must_use] + pub fn with_reported_cost(mut self, cost: Option) -> Self { + if let Some(cost) = cost { + self.total_usd_micros = Some(cost.0); + } + self + } +} + +/// Token counts summed across one or more responses, with the summed cost. +/// +/// `total_tokens` is the sum of the five buckets. `total_usd_micros` stays +/// `None` until at least one summed response carried a cost. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct BilledTokenCounts { + pub input_tokens: i64, + pub output_tokens: i64, + pub total_tokens: i64, + #[serde(default)] + pub reasoning_tokens: i64, + #[serde(default)] + pub cache_read_tokens: i64, + #[serde(default)] + pub cache_write_tokens: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_usd_micros: Option, +} + +impl BilledTokenCounts { + #[must_use] + pub fn from_token_counts(tokens: TokenCounts, total_usd_micros: Option) -> Self { + Self { + input_tokens: saturating_u64_to_i64(tokens.input), + output_tokens: saturating_u64_to_i64(tokens.output), + total_tokens: saturating_u64_to_i64(tokens.total()), + reasoning_tokens: saturating_u64_to_i64(tokens.reasoning), + cache_read_tokens: saturating_u64_to_i64(tokens.cache_read), + cache_write_tokens: saturating_u64_to_i64(tokens.cache_write), + total_usd_micros, + } + } + + #[must_use] + pub fn from_billed_usage(billed: &[BilledModelUsage]) -> Self { + let mut counts = Self::default(); + for entry in billed { + counts.add_billed_usage(entry); + } + counts + } + + /// Returns the five disjoint per-call token buckets, dropping the derived + /// `total_tokens` sum and the optional `total_usd_micros` cost. + #[must_use] + pub fn token_counts(&self) -> TokenCounts { + TokenCounts { + input: saturating_i64_to_u64(self.input_tokens), + output: saturating_i64_to_u64(self.output_tokens), + reasoning: saturating_i64_to_u64(self.reasoning_tokens), + cache_read: saturating_i64_to_u64(self.cache_read_tokens), + cache_write: saturating_i64_to_u64(self.cache_write_tokens), + } + } + + pub fn add_counts(&mut self, source: &Self) { + self.input_tokens = self.input_tokens.saturating_add(source.input_tokens); + self.output_tokens = self.output_tokens.saturating_add(source.output_tokens); + self.total_tokens = self.total_tokens.saturating_add(source.total_tokens); + self.reasoning_tokens = self + .reasoning_tokens + .saturating_add(source.reasoning_tokens); + self.cache_read_tokens = self + .cache_read_tokens + .saturating_add(source.cache_read_tokens); + self.cache_write_tokens = self + .cache_write_tokens + .saturating_add(source.cache_write_tokens); + accumulate_optional_usd_micros(&mut self.total_usd_micros, source.total_usd_micros); + } + + pub fn add_billed_usage(&mut self, usage: &BilledModelUsage) { + self.add_counts(&Self::from_token_counts( + usage.tokens, + usage.total_usd_micros, + )); + } + + pub fn replace_with_billed_usage(&mut self, usage: &BilledModelUsage) { + *self = Self::from_billed_usage(std::slice::from_ref(usage)); + } + + /// Overrides the billed total with a reported cost; `None` leaves any + /// existing value in place. + #[must_use] + pub fn with_reported_cost(mut self, cost: Option) -> Self { + if let Some(cost) = cost { + self.total_usd_micros = Some(cost.0); + } + self + } + + #[must_use] + pub fn is_zero(&self) -> bool { + self.input_tokens == 0 + && self.output_tokens == 0 + && self.total_tokens == 0 + && self.reasoning_tokens == 0 + && self.cache_read_tokens == 0 + && self.cache_write_tokens == 0 + && self.total_usd_micros.unwrap_or(0) == 0 + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn tokens() -> TokenCounts { + TokenCounts { + input: 100, + output: 20, + reasoning: 5, + cache_read: 7, + cache_write: 3, + } + } + + fn model() -> ModelRef { + ModelRef::new( + ProviderId::new("anthropic"), + ModelId::new("claude-sonnet-5"), + ) + } + + #[test] + fn usd_micros_from_usd_rounds_to_nearest_micro() { + assert_eq!(UsdMicros::from_usd(0.012_345), UsdMicros(12_345)); + assert_eq!(UsdMicros::from_usd(1.0), UsdMicros(1_000_000)); + assert_eq!(UsdMicros::from_usd(f64::INFINITY), UsdMicros(i64::MAX)); + } + + #[test] + fn usd_micros_from_cost_saturates() { + let cost = Cost { + usd_micros: u64::MAX, + source: CostSource::Provider, + }; + assert_eq!(UsdMicros::from_cost(&cost), UsdMicros(i64::MAX)); + } + + #[test] + fn accumulate_stays_none_without_costs() { + let mut total = None; + UsdMicros::accumulate(&mut total, None); + assert_eq!(total, None); + UsdMicros::accumulate(&mut total, Some(UsdMicros(5))); + UsdMicros::accumulate(&mut total, None); + UsdMicros::accumulate(&mut total, Some(UsdMicros(7))); + assert_eq!(total, Some(UsdMicros(12))); + } + + #[test] + fn billed_token_counts_from_token_counts_sums_total() { + let counts = BilledTokenCounts::from_token_counts(tokens(), Some(42)); + assert_eq!(counts.input_tokens, 100); + assert_eq!(counts.output_tokens, 20); + assert_eq!(counts.reasoning_tokens, 5); + assert_eq!(counts.cache_read_tokens, 7); + assert_eq!(counts.cache_write_tokens, 3); + assert_eq!(counts.total_tokens, 135); + assert_eq!(counts.total_usd_micros, Some(42)); + assert_eq!(counts.token_counts(), tokens()); + } + + #[test] + fn billed_token_counts_sum_billed_usage_and_costs() { + let priced = BilledModelUsage::new( + model(), + tokens(), + Some(Cost { + usd_micros: 10, + source: CostSource::Catalog, + }), + ); + let unpriced = BilledModelUsage::new(model(), tokens(), None); + let counts = BilledTokenCounts::from_billed_usage(&[priced, unpriced]); + assert_eq!(counts.input_tokens, 200); + assert_eq!(counts.total_tokens, 270); + assert_eq!(counts.total_usd_micros, Some(10)); + } + + #[test] + fn billed_token_counts_without_costs_report_none() { + let counts = + BilledTokenCounts::from_billed_usage(&[BilledModelUsage::new(model(), tokens(), None)]); + assert_eq!(counts.total_usd_micros, None); + assert!(!counts.is_zero()); + assert!(BilledTokenCounts::default().is_zero()); + } + + #[test] + fn billed_model_usage_serializes_lithos_token_buckets() { + let usage = BilledModelUsage::new(model().with_speed(Some(Speed::Fast)), tokens(), None); + let value = serde_json::to_value(&usage).unwrap(); + assert_eq!( + value, + json!({ + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-5", + "speed": "fast", + }, + "tokens": { + "input": 100, + "output": 20, + "reasoning": 5, + "cache_read": 7, + "cache_write": 3, + }, + }) + ); + let back: BilledModelUsage = serde_json::from_value(value).unwrap(); + assert_eq!(back, usage); + } + + #[test] + fn model_ref_hash_distinguishes_speed_tiers() { + use std::collections::HashSet; + + let mut set = HashSet::new(); + set.insert(model()); + set.insert(model().with_speed(Some(Speed::Fast))); + set.insert(model().with_speed(Some(Speed::Fast))); + assert_eq!(set.len(), 2); + } + + #[test] + fn model_ref_display_names_the_route_and_speed() { + assert_eq!(model().to_string(), "anthropic/claude-sonnet-5"); + assert_eq!( + model().with_speed(Some(Speed::Fast)).to_string(), + "anthropic/claude-sonnet-5 (fast)" + ); + } +} diff --git a/lib/foundation/fabro-types/src/billing_rollup.rs b/lib/foundation/fabro-types/src/billing_rollup.rs index acdefa407..9ec5aee23 100644 --- a/lib/foundation/fabro-types/src/billing_rollup.rs +++ b/lib/foundation/fabro-types/src/billing_rollup.rs @@ -1,7 +1,5 @@ use std::collections::HashMap; -use fabro_model::Catalog; - use crate::{BilledTokenCounts, ModelRef, RunProjection, RunTiming, StageSummary, StageTiming}; #[derive(Debug, Clone, PartialEq)] @@ -119,10 +117,7 @@ impl ProjectionBillingRollup { } #[must_use] -pub fn billing_rollup_from_projection( - projection: &RunProjection, - catalog: Option<&Catalog>, -) -> ProjectionBillingRollup { +pub fn billing_rollup_from_projection(projection: &RunProjection) -> ProjectionBillingRollup { let mut stage_indices = HashMap::::new(); let mut stages = Vec::::new(); let mut by_model = HashMap::::new(); @@ -134,8 +129,7 @@ pub fn billing_rollup_from_projection( if projection.is_boundary_stage(stage_id.node_id()) { continue; } - let usage = stage.billed_usage(catalog); - let usage = usage.as_ref(); + let usage = &stage.usage; if stage.completion.is_none() && stage.timing.is_none() && usage.is_zero() { continue; } @@ -180,19 +174,7 @@ pub fn billing_rollup_from_projection( } let mut by_model = by_model.into_values().collect::>(); - by_model.sort_by(|left, right| { - let left_provider = left.model.provider.to_string(); - let right_provider = right.model.provider.to_string(); - left_provider - .cmp(&right_provider) - .then_with(|| left.model.model_id.cmp(&right.model.model_id)) - .then_with(|| { - left.model - .speed - .map(<&'static str>::from) - .cmp(&right.model.speed.map(<&'static str>::from)) - }) - }); + by_model.sort_by(|left, right| left.model.sort_key().cmp(&right.model.sort_key())); ProjectionBillingRollup { stages, diff --git a/lib/foundation/fabro-types/src/catalog_api.rs b/lib/foundation/fabro-types/src/catalog_api.rs new file mode 100644 index 000000000..f304dc0e7 --- /dev/null +++ b/lib/foundation/fabro-types/src/catalog_api.rs @@ -0,0 +1,95 @@ +//! API projections of the model catalog. +//! +//! `GET /models` and `GET /providers` return these. They are views over the +//! lithos catalog plus Fabro policy, stamped per request with whether the +//! server holds credentials for each provider. + +use serde::{Deserialize, Serialize}; + +use crate::{ModelId, ProviderId, ReasoningEffort}; + +/// Token limits for a model. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelLimits { + pub context_window: i64, + pub max_output: Option, +} + +/// Capability flags for a model. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelFeatures { + pub tools: bool, + pub vision: bool, + pub reasoning: bool, + pub prompt_cache: bool, + /// Whether the model accepts classic sampling parameters + /// (`temperature`, `top_p`). + pub sampling: bool, +} + +/// Request-control values a model accepts. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelControls { + /// Reasoning-effort values accepted by this offering. Empty means the + /// control is unsupported. + #[serde(default)] + pub reasoning_effort: Vec, +} + +/// Pricing per million tokens in USD. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct ModelCosts { + pub input_cost_per_mtok: Option, + pub output_cost_per_mtok: Option, + pub cache_input_cost_per_mtok: Option, +} + +/// One provider's offering of a model. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Model { + pub id: ModelId, + pub provider: ProviderId, + pub family: String, + pub display_name: String, + pub limits: ModelLimits, + pub training: Option, + pub knowledge_cutoff: Option, + pub features: ModelFeatures, + #[serde(default)] + pub controls: ModelControls, + pub costs: ModelCosts, + pub estimated_output_tps: Option, + pub aliases: Vec, + #[serde(default)] + pub default: bool, + #[serde(default)] + pub small_default: bool, + /// Whether the server holds credential material for this model's + /// provider. Stamped per request; never implies the credential works. + #[serde(default)] + pub configured: bool, +} + +/// An LLM provider with effective configuration and configured status. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Provider { + pub id: ProviderId, + pub display_name: String, + /// lithos adapter id, such as `openai` or `openai-compatible`. + pub adapter: String, + pub base_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key_url: Option, + pub priority: i32, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub aliases: Vec, + pub model_count: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, + #[serde(default)] + pub configured: bool, + /// Vault secret an operator creates to configure this provider, when the + /// provider reads one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_secret_name: Option, +} diff --git a/lib/foundation/fabro-types/src/controls.rs b/lib/foundation/fabro-types/src/controls.rs new file mode 100644 index 000000000..0017f6bc7 --- /dev/null +++ b/lib/foundation/fabro-types/src/controls.rs @@ -0,0 +1,76 @@ +//! Helpers over the lithos request-control enums. +//! +//! lithos owns [`ReasoningEffort`] and [`Speed`], their spellings, and their +//! parsing (`ALL`, `as_str`, `Display`, `FromStr`). What stays here is +//! Fabro's own rule for substituting a reasoning level a model lacks. + +pub use lithos_llm::types::{ReasoningEffort, Speed}; + +/// Position of an effort in the least-to-most ordering. +fn effort_rank(effort: ReasoningEffort) -> usize { + ReasoningEffort::ALL + .iter() + .position(|candidate| *candidate == effort) + .unwrap_or(ReasoningEffort::ALL.len()) +} + +/// Selects the supported effort nearest to `requested`. +/// +/// When two supported values are equally distant, the higher effort wins. +/// Returns `None` when nothing is supported. +#[must_use] +pub fn closest_supported_effort( + requested: ReasoningEffort, + supported: impl Fn(ReasoningEffort) -> bool, +) -> Option { + let target = effort_rank(requested); + ReasoningEffort::ALL + .into_iter() + .filter(|effort| supported(*effort)) + .min_by_key(|effort| { + let rank = effort_rank(*effort); + (rank.abs_diff(target), std::cmp::Reverse(rank)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lithos_spellings_match_serde() { + for effort in ReasoningEffort::ALL { + let json = serde_json::to_string(&effort).unwrap(); + assert_eq!(json, format!("\"{effort}\"")); + assert_eq!(effort.as_str().parse::().unwrap(), effort); + } + for speed in Speed::ALL { + let json = serde_json::to_string(&speed).unwrap(); + assert_eq!(json, format!("\"{speed}\"")); + assert_eq!(speed.as_str().parse::().unwrap(), speed); + } + assert!("standard".parse::().is_err()); + assert!("standard".parse::().is_err()); + } + + #[test] + fn closest_supported_prefers_the_higher_neighbor_on_ties() { + let supported = |effort| matches!(effort, ReasoningEffort::Low | ReasoningEffort::High); + assert_eq!( + closest_supported_effort(ReasoningEffort::Medium, supported), + Some(ReasoningEffort::High) + ); + assert_eq!( + closest_supported_effort(ReasoningEffort::Max, supported), + Some(ReasoningEffort::High) + ); + assert_eq!( + closest_supported_effort(ReasoningEffort::Minimal, supported), + Some(ReasoningEffort::Low) + ); + assert_eq!( + closest_supported_effort(ReasoningEffort::Medium, |_| false), + None + ); + } +} diff --git a/lib/foundation/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs index e5639e4ee..24a51c0a0 100644 --- a/lib/foundation/fabro-types/src/lib.rs +++ b/lib/foundation/fabro-types/src/lib.rs @@ -1,14 +1,17 @@ extern crate self as fabro_types; +pub mod agent_profile; pub mod artifact; pub mod auth; pub mod billing; pub mod billing_rollup; pub mod blob_hash; pub mod blob_ref; +pub mod catalog_api; pub mod checkpoint; pub mod command_output; pub mod conclusion; +pub mod controls; pub mod dense; pub mod diff; pub mod event_envelope; @@ -20,10 +23,12 @@ pub mod interview; pub mod llm_backend; pub mod manifest_path; pub mod mcp_store; +pub mod model_test; pub mod outcome; pub mod pair; pub mod parallel; pub mod principal; +pub mod provider_ids; pub mod pull_request; pub mod reasoning; pub mod repository; @@ -60,23 +65,22 @@ pub mod workflow_path; pub mod workflow_version; pub mod workflow_version_id; +pub use agent_profile::AgentProfileKind; pub use artifact::ArtifactUpload; pub use auth::{IdpIdentity, IdpIdentityError}; pub use billing::{ - AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts, - GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment, - ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage, - OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros, + BilledModelUsage, BilledTokenCounts, Cost, CostSource, ModelRef, Speed, TokenCounts, UsdMicros, }; pub use blob_hash::BlobHash; pub use blob_ref::{format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref}; +pub use catalog_api::{Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, Provider}; pub use checkpoint::Checkpoint; pub use command_output::{CommandOutputStream, CommandTermination}; pub use conclusion::{Conclusion, StageSummary}; +pub use controls::ReasoningEffort; pub use dense::{ServerSettings, UserSettings, WorkflowSettings}; pub use diff::{DiffStats, DiffSummary, RunDiff}; pub use event_envelope::EventEnvelope; -pub use fabro_model::ReasoningEffort; pub use failure_signature::FailureSignature; pub use graph::{ AttrValue, AttributeScope, ContextKeyAttr, Edge, Graph, KNOWN_HANDLER_TYPES, Node, OnFailure, @@ -89,6 +93,10 @@ pub use input_scalar::{ pub use interview::{ InterviewQuestionRecord, QuestionType, ReviewTarget, ReviewTargetError, ReviewTargetKind, }; +pub use lithos_llm::catalog::{ModelHandle, ModelId, ProviderId}; +pub use lithos_llm::types::{ + FinishReason, Request, RequestBuildError, RequestBuilder, Response, ResponseFormat, StreamEvent, +}; pub use llm_backend::AgentBackend; pub use manifest_path::{ManifestPath, ManifestPathParseError}; pub use mcp_store::{ @@ -96,6 +104,7 @@ pub use mcp_store::{ McpServerRevisionParseError, McpServerValidationError, McpServerView, McpTransportView, validate_mcp_server_fields, }; +pub use model_test::ModelTestMode; pub use outcome::{ FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageOutcome, StageState, }; @@ -192,8 +201,10 @@ pub use system_integrations::{ pub use timing::{RunTiming, StageTiming}; pub use todo::{TodoListKind, TodoListProjection, TodoPatch, TodoProjection, TodoStatus}; pub use transcript::{ - AudioData, ContentPart, DocumentData, ImageData, Message, MessageId, MessageKind, - MessageSource, PairMessageRef, Role, ThinkingData, ToolCall, ToolResult, TranscriptMessage, + AudioContent, ContentPart, DocumentContent, ImageContent, MediaSource, Message, MessageId, + MessageKind, MessageSource, PairMessageRef, ReasoningContent, Role, ToolCall, ToolCallKind, + ToolChoice, ToolDefinition, ToolDefinitionKind, ToolInput, ToolResult, TranscriptMessage, + text_of, tool_call_arguments, tool_result_from_json, tool_result_to_json, }; pub use variable::{ CreateVariableRequest, UpdateVariableRequest, Variable, VariableListResponse, is_env_style_name, diff --git a/lib/foundation/fabro-model/src/model_test.rs b/lib/foundation/fabro-types/src/model_test.rs similarity index 90% rename from lib/foundation/fabro-model/src/model_test.rs rename to lib/foundation/fabro-types/src/model_test.rs index f099bf4c2..610d51b91 100644 --- a/lib/foundation/fabro-model/src/model_test.rs +++ b/lib/foundation/fabro-types/src/model_test.rs @@ -1,3 +1,5 @@ +//! Model probe modes exposed by `POST /models/{id}/test`. + use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, IntoStaticStr}; diff --git a/lib/foundation/fabro-types/src/provider_ids.rs b/lib/foundation/fabro-types/src/provider_ids.rs new file mode 100644 index 000000000..e3ab92142 --- /dev/null +++ b/lib/foundation/fabro-types/src/provider_ids.rs @@ -0,0 +1,34 @@ +//! Well-known provider identifiers. +//! +//! Provider identity is open-ended catalog data, so [`ProviderId`] is a plain +//! string newtype. The first-party providers are named here because code +//! paths such as Codex login and the install flow refer to them directly. + +use lithos_llm::catalog::ProviderId; + +pub const ANTHROPIC: &str = "anthropic"; +pub const OPENAI: &str = "openai"; +/// The ChatGPT-subscription deployment that stands in for [`OPENAI`] when a +/// Codex OAuth credential is present. +pub const OPENAI_CODEX: &str = "openai-codex"; +pub const GEMINI: &str = "gemini"; + +#[must_use] +pub fn anthropic() -> ProviderId { + ProviderId::new(ANTHROPIC) +} + +#[must_use] +pub fn openai() -> ProviderId { + ProviderId::new(OPENAI) +} + +#[must_use] +pub fn openai_codex() -> ProviderId { + ProviderId::new(OPENAI_CODEX) +} + +#[must_use] +pub fn gemini() -> ProviderId { + ProviderId::new(GEMINI) +} diff --git a/lib/foundation/fabro-types/src/run_event/agent.rs b/lib/foundation/fabro-types/src/run_event/agent.rs index 171e69aa3..b06f21e08 100644 --- a/lib/foundation/fabro-types/src/run_event/agent.rs +++ b/lib/foundation/fabro-types/src/run_event/agent.rs @@ -1,4 +1,3 @@ -use fabro_model::{CostSource, ReasoningEffort, Speed}; use serde::{Deserialize, Serialize}; use serde_json::Value; use strum::{Display, EnumString, IntoStaticStr}; @@ -6,8 +5,9 @@ use strum::{Display, EnumString, IntoStaticStr}; use super::{BilledTokenCounts, ExecOutputTail}; use crate::transcript::{ToolCall, ToolResult, TranscriptMessage}; use crate::{ - CommandTermination, MessageId, ModelRef, PairId, PairMessageId, PairSystemMessageKind, - PermissionLevel, ReasoningOutput, StageContextWindowProjection, TurnId, + CommandTermination, CostSource, MessageId, ModelRef, PairId, PairMessageId, + PairSystemMessageKind, PermissionLevel, ReasoningEffort, ReasoningOutput, Speed, + StageContextWindowProjection, TurnId, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -149,7 +149,7 @@ pub struct AgentToolStartedProps { pub tool_call_id: String, pub arguments: Value, pub visit: u32, - /// Canonical tool call payload. Carries `tool_type`, `raw_arguments`, and + /// Canonical tool call payload. Carries the typed input and /// `provider_metadata` (e.g. Gemini `thought_signature`) so tool actions /// can be replayed against the originating provider. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -526,14 +526,13 @@ mod tests { use serde_json::json; use super::*; - use crate::transcript::{ContentPart, MessageKind, MessageSource, TranscriptMessage}; + use crate::provider_ids; + use crate::transcript::{ + ContentPart, MessageKind, MessageSource, TranscriptMessage, tool_result_from_json, + }; fn sample_model_ref() -> ModelRef { - ModelRef { - provider: fabro_model::ProviderId::openai(), - model_id: "gpt-5".into(), - speed: None, - } + ModelRef::new(provider_ids::openai(), "gpt-5".into()) } #[test] @@ -587,7 +586,9 @@ mod tests { #[test] fn agent_message_props_carries_canonical_transcript_message() { let msg = TranscriptMessage::new(MessageKind::Agent, MessageSource::ProviderAnswer, vec![ - ContentPart::text("ok"), + ContentPart::Text { + text: "ok".to_string(), + }, ]); let props = AgentMessageProps { text: "ok".to_string(), @@ -624,8 +625,9 @@ mod tests { #[test] fn agent_tool_started_props_carries_canonical_tool_call_and_linkage() { - let mut tc = ToolCall::new("call_1", "Bash", json!({"cmd": "ls"})); - tc.provider_metadata = Some(json!({"thought_signature": "sig"})); + let mut tc = ToolCall::function("call_1", "Bash", json!({"cmd": "ls"})); + tc.provider_metadata + .insert("gemini".to_string(), json!({"thought_signature": "sig"})); let parent = MessageId::new(); let turn = TurnId::new(); let props = AgentToolStartedProps { @@ -639,7 +641,7 @@ mod tests { }; let v = serde_json::to_value(&props).unwrap(); assert_eq!( - v["tool_call"]["provider_metadata"]["thought_signature"], + v["tool_call"]["provider_metadata"]["gemini"]["thought_signature"], "sig" ); assert_eq!(v["turn_id"], turn.to_string()); @@ -667,7 +669,7 @@ mod tests { #[test] fn agent_tool_completed_props_carries_canonical_tool_result() { - let tr = ToolResult::success("call_1", json!({"stdout": "ok"})); + let tr = tool_result_from_json("call_1", json!({"stdout": "ok"}), false); let turn = TurnId::new(); let props = AgentToolCompletedProps { tool_name: "Bash".to_string(), @@ -682,7 +684,7 @@ mod tests { turn_id: Some(turn), }; let v = serde_json::to_value(&props).unwrap(); - assert_eq!(v["tool_result"]["content"]["stdout"], "ok"); + assert_eq!(v["tool_result"]["content"][0]["value"]["stdout"], "ok"); assert_eq!(v["output_bytes_observed"], 120); assert_eq!(v["output_bytes_retained"], 100); assert_eq!(v["output_bytes_omitted"], 20); diff --git a/lib/foundation/fabro-types/src/run_event/misc.rs b/lib/foundation/fabro-types/src/run_event/misc.rs index 9e5600287..b9206ac33 100644 --- a/lib/foundation/fabro-types/src/run_event/misc.rs +++ b/lib/foundation/fabro-types/src/run_event/misc.rs @@ -1,10 +1,9 @@ -use fabro_model::ReasoningEffort; use serde::{Deserialize, Serialize}; use super::ExecOutputTail; use crate::{ - CommandTermination, ParallelBranchResult, PullRequestCreationId, PullRequestLink, ReviewTarget, - StageId, StageOutcome, + CommandTermination, ParallelBranchResult, PullRequestCreationId, PullRequestLink, + ReasoningEffort, ReviewTarget, StageId, StageOutcome, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] diff --git a/lib/foundation/fabro-types/src/run_event/mod.rs b/lib/foundation/fabro-types/src/run_event/mod.rs index a0450dea6..fc2e6c9c5 100644 --- a/lib/foundation/fabro-types/src/run_event/mod.rs +++ b/lib/foundation/fabro-types/src/run_event/mod.rs @@ -8,7 +8,6 @@ pub mod todo; pub use agent::*; use chrono::{DateTime, Utc}; -pub use fabro_model::BilledTokenCounts; pub use infra::*; pub use misc::*; pub use run::*; @@ -20,7 +19,7 @@ pub use session::*; pub use stage::*; pub use todo::*; -use crate::{ParallelBranchId, Principal, RunId, StageId, UsdMicros}; +use crate::{BilledTokenCounts, ParallelBranchId, Principal, RunId, StageId}; /// Maximum accepted body size for `POST /runs/{id}/events`. /// @@ -920,8 +919,10 @@ impl RunEvent { } } -/// Upgrades historical wire shapes only in the value being decoded. Legacy -/// importers still retain and compare the original stored JSON. +/// Upgrades historical envelope shapes only in the value being decoded. +/// +/// Event bodies carry no compatibility rewrites: Fabro is greenfield, so a +/// stored body either matches the current schema or fails to decode. fn normalize_legacy_event(value: &mut Value) { let Some(event) = value .get("event") @@ -941,81 +942,17 @@ fn normalize_legacy_event_properties(event: &str, properties: &mut Value) { return; }; match event { - "agent.message" => normalize_legacy_agent_message(object), "run.completed" => normalize_legacy_timing(object, false), "run.failed" => { normalize_legacy_run_failure(object); normalize_legacy_timing(object, false); } - "stage.completed" => { - normalize_legacy_usage_field(object); - normalize_legacy_billing_field(object, "billing"); - normalize_legacy_timing(object, true); - } - "stage.failed" => normalize_legacy_billing_field(object, "billing"), - "prompt.completed" => { - normalize_legacy_usage_field(object); - normalize_legacy_billing_field(object, "billing"); - } - "checkpoint.completed" => normalize_legacy_checkpoint_billing(object), + "stage.completed" => normalize_legacy_timing(object, true), "sandbox.initialized" => normalize_legacy_sandbox_id(object), _ => {} } } -fn normalize_legacy_agent_message(properties: &mut Map) { - let speed = properties - .get("usage") - .and_then(Value::as_object) - .and_then(|usage| usage.get("speed")) - .and_then(Value::as_str) - .map(str::to_owned); - if let Some(model_id) = properties.get("model").and_then(Value::as_str) { - let mut model = Map::from_iter([ - ( - "provider".to_owned(), - Value::String(legacy_provider_for_model(model_id).to_owned()), - ), - ("model_id".to_owned(), Value::String(model_id.to_owned())), - ]); - if let Some(speed @ ("standard" | "fast")) = speed.as_deref() { - model.insert("speed".to_owned(), Value::String(speed.to_owned())); - } - properties.insert("model".to_owned(), Value::Object(model)); - } - if !properties.contains_key("billing") { - if let Some(usage) = properties.remove("usage") { - properties.insert("billing".to_owned(), usage); - } - } -} - -fn normalize_legacy_usage_field(properties: &mut Map) { - if !properties.contains_key("billing") { - if let Some(usage) = properties.remove("usage") { - properties.insert("billing".to_owned(), usage); - } - } -} - -fn normalize_legacy_billing_field(properties: &mut Map, field: &str) { - if let Some(billing) = properties.get_mut(field) { - normalize_legacy_billing_values(billing); - } -} - -fn normalize_legacy_checkpoint_billing(properties: &mut Map) { - let Some(outcomes) = properties - .get_mut("node_outcomes") - .and_then(Value::as_object_mut) - else { - return; - }; - for outcome in outcomes.values_mut().filter_map(Value::as_object_mut) { - normalize_legacy_billing_field(outcome, "usage"); - } -} - fn normalize_legacy_timing(properties: &mut Map, stage: bool) { if properties.contains_key("timing") { return; @@ -1076,135 +1013,6 @@ fn normalize_legacy_sandbox_id(properties: &mut Map) { properties.insert("id".to_owned(), Value::String(id.to_owned())); } -fn normalize_legacy_billing_values(value: &mut Value) { - if legacy_stage_usage(value) { - let legacy = std::mem::take(value); - *value = normalized_legacy_stage_usage(&legacy); - return; - } - match value { - Value::Array(values) => { - for value in values { - normalize_legacy_billing_values(value); - } - } - Value::Object(object) => { - if let Some(facts) = object.get_mut("facts").and_then(Value::as_object_mut) { - if !facts.contains_key("algorithm") { - let provider = facts - .get("provider") - .and_then(Value::as_str) - .map(str::to_owned); - if let Some(provider) = provider { - facts.remove("provider"); - facts.insert( - "algorithm".to_owned(), - Value::String(legacy_billing_algorithm(&provider).to_owned()), - ); - } - } - } - for value in object.values_mut() { - normalize_legacy_billing_values(value); - } - } - _ => {} - } -} - -fn legacy_stage_usage(value: &Value) -> bool { - let Some(object) = value.as_object() else { - return false; - }; - object.get("model").is_some_and(Value::is_string) - && object.get("input_tokens").is_some_and(Value::is_number) - && object.get("output_tokens").is_some_and(Value::is_number) -} - -fn normalized_legacy_stage_usage(legacy: &Value) -> Value { - let object = legacy - .as_object() - .expect("legacy stage usage was validated as an object"); - let model_id = object - .get("model") - .and_then(Value::as_str) - .expect("legacy stage usage was validated with a string model"); - let provider = legacy_provider_for_model(model_id); - let mut model = json!({ - "provider": provider, - "model_id": model_id, - }); - if let Some(speed @ ("standard" | "fast")) = object.get("speed").and_then(Value::as_str) { - model["speed"] = Value::String(speed.to_owned()); - } - let input_tokens = object - .get("input_tokens") - .and_then(Value::as_i64) - .unwrap_or(0); - let output_tokens = object - .get("output_tokens") - .and_then(Value::as_i64) - .unwrap_or(0); - let reasoning_tokens = object - .get("reasoning_tokens") - .and_then(Value::as_i64) - .unwrap_or(0); - let cache_read_tokens = object - .get("cache_read_tokens") - .and_then(Value::as_i64) - .unwrap_or(0); - let cache_write_tokens = object - .get("cache_write_tokens") - .and_then(Value::as_i64) - .unwrap_or(0); - let mut normalized = json!({ - "input": { - "usage": { - "model": model, - "tokens": { - "input_tokens": input_tokens, - "output_tokens": output_tokens, - "reasoning_tokens": reasoning_tokens, - "cache_read_tokens": cache_read_tokens, - "cache_write_tokens": cache_write_tokens, - } - }, - "facts": { - "algorithm": legacy_billing_algorithm(provider), - } - } - }); - if let Some(cost) = object.get("cost").and_then(Value::as_f64) { - normalized["total_usd_micros"] = Value::from(UsdMicros::from_usd(cost).0); - } - normalized -} - -fn legacy_provider_for_model(model_id: &str) -> &'static str { - if model_id.starts_with("claude-") { - "anthropic" - } else if model_id.starts_with("gemini-") { - "gemini" - } else if model_id.starts_with("gpt-") - || model_id.starts_with("chatgpt-") - || model_id.starts_with("o1") - || model_id.starts_with("o3") - || model_id.starts_with("o4") - { - "openai" - } else { - "legacy" - } -} - -fn legacy_billing_algorithm(provider: &str) -> &'static str { - match provider { - "anthropic" => "anthropic", - "gemini" => "gemini", - _ => "openai", - } -} - impl Serialize for RunEvent { fn serialize(&self, serializer: S) -> Result where @@ -1232,8 +1040,8 @@ mod tests { use super::*; use crate::{ - AuthMethod, BlobHash, CommandTermination, Edge, Graph, IdpIdentity, Node, PendingReason, - WorkflowSettings, fixtures, test_support, + AuthMethod, BlobHash, CommandTermination, Edge, Graph, IdpIdentity, ModelRef, Node, + PendingReason, WorkflowSettings, fixtures, provider_ids, test_support, }; fn user_principal(login: &str) -> Principal { @@ -1377,138 +1185,6 @@ mod tests { assert_eq!(props.settings.run, WorkflowSettings::default().run); } - #[test] - fn historical_agent_message_accepts_string_model() { - let line = stored_event( - "agent.message", - &json!({ - "text": "done", - "model": "gemini-3.1-pro-preview", - "billing": { - "input_tokens": 10, - "output_tokens": 5, - "total_tokens": 15 - }, - "tool_call_count": 0, - "visit": 1 - }), - ); - - let parsed = RunEvent::from_value(line).unwrap(); - let normalized = parsed.to_value().unwrap(); - - assert_eq!(normalized["properties"]["model"]["provider"], "gemini"); - assert_eq!( - normalized["properties"]["model"]["model_id"], - "gemini-3.1-pro-preview" - ); - } - - #[test] - fn historical_stage_usage_and_duration_are_upgraded() { - let line = stored_event( - "stage.completed", - &json!({ - "index": 0, - "duration_ms": 42, - "status": "succeeded", - "usage": { - "model": "claude-sonnet-4-6", - "input_tokens": 100, - "output_tokens": 20, - "cache_read_tokens": 7, - "cache_write_tokens": 3, - "reasoning_tokens": 2, - "speed": "fast", - "cost": 0.012_345 - }, - "attempt": 1, - "max_attempts": 1 - }), - ); - - let parsed = RunEvent::from_value(line).unwrap(); - let normalized = parsed.to_value().unwrap(); - let properties = &normalized["properties"]; - - assert_eq!(properties["timing"]["wall_time_ms"], 42); - assert_eq!( - properties["billing"]["input"]["facts"]["algorithm"], - "anthropic" - ); - assert_eq!( - properties["billing"]["input"]["usage"]["model"]["speed"], - "fast" - ); - assert_eq!(properties["billing"]["total_usd_micros"], 12_345); - assert!(properties.get("duration_ms").is_none()); - assert!(properties.get("usage").is_none()); - } - - #[test] - fn historical_billing_provider_tags_are_upgraded() { - let legacy_billing = json!({ - "input": { - "usage": { - "model": { - "provider": "anthropic", - "model_id": "claude-sonnet-4-6" - }, - "tokens": { - "input_tokens": 100, - "output_tokens": 20, - "reasoning_tokens": 0, - "cache_read_tokens": 7, - "cache_write_tokens": 3 - } - }, - "facts": { - "provider": "anthropic", - "cache_write_5m_tokens": 3, - "cache_write_1h_tokens": 0 - } - }, - "total_usd_micros": 123 - }); - let prompt = stored_event( - "prompt.completed", - &json!({ - "response": "done", - "model": "claude-sonnet-4-6", - "provider": "anthropic", - "billing": legacy_billing.clone() - }), - ); - let checkpoint = stored_event( - "checkpoint.completed", - &json!({ - "status": "succeeded", - "current_node": "build", - "node_outcomes": { - "build": { - "status": "succeeded", - "usage": legacy_billing - } - } - }), - ); - - let prompt = RunEvent::from_value(prompt).unwrap().to_value().unwrap(); - let checkpoint = RunEvent::from_value(checkpoint) - .unwrap() - .to_value() - .unwrap(); - - assert_eq!( - prompt["properties"]["billing"]["input"]["facts"]["algorithm"], - "anthropic" - ); - assert_eq!( - checkpoint["properties"]["node_outcomes"]["build"]["usage"]["input"]["facts"]["algorithm"], - "anthropic" - ); - } - #[test] fn historical_terminal_and_sandbox_events_are_upgraded() { let completed = stored_event( @@ -2688,11 +2364,7 @@ mod tests { fn agent_message_omits_context_window_when_absent() { let body = EventBody::AgentMessage(AgentMessageProps { text: "ok".to_string(), - model: crate::ModelRef { - provider: fabro_model::ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: None, - }, + model: ModelRef::new(provider_ids::openai(), "gpt-5.4".into()), billing: BilledTokenCounts::default(), cost_source: None, tool_call_count: 0, @@ -2719,11 +2391,7 @@ mod tests { fn agent_message_omits_reasoning_when_absent() { let body = EventBody::AgentMessage(AgentMessageProps { text: "ok".to_string(), - model: crate::ModelRef { - provider: fabro_model::ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: None, - }, + model: ModelRef::new(provider_ids::openai(), "gpt-5.4".into()), billing: BilledTokenCounts::default(), cost_source: None, tool_call_count: 0, @@ -2747,11 +2415,7 @@ mod tests { fn agent_message_carries_reasoning_through_canonical_json() { let body = EventBody::AgentMessage(AgentMessageProps { text: String::new(), - model: crate::ModelRef { - provider: fabro_model::ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: None, - }, + model: ModelRef::new(provider_ids::openai(), "gpt-5.4".into()), billing: BilledTokenCounts::default(), cost_source: None, tool_call_count: 1, @@ -2804,11 +2468,7 @@ mod tests { }; let body = EventBody::AgentMessage(AgentMessageProps { text: "ok".to_string(), - model: crate::ModelRef { - provider: fabro_model::ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: None, - }, + model: ModelRef::new(provider_ids::openai(), "gpt-5.4".into()), billing: BilledTokenCounts::default(), cost_source: None, tool_call_count: 0, diff --git a/lib/foundation/fabro-types/src/run_event/session.rs b/lib/foundation/fabro-types/src/run_event/session.rs index 02dd98ea1..6f50a88f2 100644 --- a/lib/foundation/fabro-types/src/run_event/session.rs +++ b/lib/foundation/fabro-types/src/run_event/session.rs @@ -1,8 +1,7 @@ -use fabro_model::ProviderId; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::TurnId; +use crate::{ProviderId, TurnId}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunSessionCreatedProps { diff --git a/lib/foundation/fabro-types/src/run_event/stage.rs b/lib/foundation/fabro-types/src/run_event/stage.rs index 18a5319b0..cc6aa69ab 100644 --- a/lib/foundation/fabro-types/src/run_event/stage.rs +++ b/lib/foundation/fabro-types/src/run_event/stage.rs @@ -1,12 +1,12 @@ use std::collections::BTreeMap; -use fabro_model::{ReasoningEffort, Speed}; use serde::{Deserialize, Serialize}; use serde_json::Value; use super::ExecOutputTail; use crate::{ - BilledModelUsage, DiffSummary, FailureDetail, Outcome, StageId, StageOutcome, StageTiming, + BilledModelUsage, DiffSummary, FailureDetail, Outcome, ReasoningEffort, Speed, StageId, + StageOutcome, StageTiming, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/lib/foundation/fabro-types/src/run_projection.rs b/lib/foundation/fabro-types/src/run_projection.rs index d41258298..b05d249e3 100644 --- a/lib/foundation/fabro-types/src/run_projection.rs +++ b/lib/foundation/fabro-types/src/run_projection.rs @@ -3,7 +3,6 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::num::NonZeroU32; use chrono::{DateTime, Utc}; -use fabro_model::{Catalog, ReasoningEffort, Speed}; use strum::{Display, EnumString, IntoStaticStr}; use crate::run_event::{AgentSessionActivatedProps, StagePromptProps}; @@ -11,9 +10,9 @@ use crate::{ AgentBackend, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary, AgentToolSummary, BilledTokenCounts, Checkpoint, Conclusion, InterviewQuestionRecord, InvalidTransition, LlmOutputKind, ModelRef, ParallelBranchId, PermissionLevel, - PullRequestCreation, PullRequestLink, RunApproval, RunControlAction, RunDiff, RunId, - RunSandbox, RunSpec, RunStatus, RunTiming, StageCompletion, StageHandler, StageId, StageState, - StageTiming, StartRecord, TodoListProjection, timing, + PullRequestCreation, PullRequestLink, ReasoningEffort, RunApproval, RunControlAction, RunDiff, + RunId, RunSandbox, RunSpec, RunStatus, RunTiming, Speed, StageCompletion, StageHandler, + StageId, StageState, StageTiming, StartRecord, TodoListProjection, timing, }; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -605,29 +604,6 @@ impl StageProjection { self.state } - /// This stage's token counts with a cost attached. - /// - /// A provider-reported cost always wins. Otherwise the catalog prices the - /// recorded tokens for the stage's model. The stored counts pass through - /// untouched when there is no catalog, no model, or no price for that - /// model. Empty usage also passes through untouched. These cases leave - /// `total_usd_micros` as `None` rather than zero. - #[must_use] - pub fn billed_usage(&self, catalog: Option<&Catalog>) -> Cow<'_, BilledTokenCounts> { - if self.usage.total_usd_micros.is_some() || self.usage.is_zero() { - return Cow::Borrowed(&self.usage); - } - let (Some(catalog), Some(model)) = (catalog, self.model.as_ref()) else { - return Cow::Borrowed(&self.usage); - }; - let Some(total_usd_micros) = catalog.price_tokens(model, &self.usage.token_counts()) else { - return Cow::Borrowed(&self.usage); - }; - let mut usage = self.usage.clone(); - usage.total_usd_micros = Some(total_usd_micros); - Cow::Owned(usage) - } - /// Live wall-clock time in milliseconds. /// /// While the stage is non-terminal (`Pending`, `Running`, or `Retrying`), @@ -1119,11 +1095,10 @@ mod iter_stages_tests { use std::num::NonZeroU32; use chrono::Utc; - use fabro_model::{Catalog, ModelRef, ProviderId}; use serde_json::json; use super::RunProjection; - use crate::{AgentControlState, BilledTokenCounts, StageProjection, test_support}; + use crate::{AgentControlState, StageProjection, test_support}; fn seq(n: u32) -> NonZeroU32 { NonZeroU32::new(n).unwrap() @@ -1230,77 +1205,6 @@ mod iter_stages_tests { assert_eq!(order, vec!["build@1", "verify@1", "verify@2"]); } } - - fn priced_stage(total_usd_micros: Option) -> StageProjection { - let mut stage = StageProjection::new(seq(1)); - stage.usage = BilledTokenCounts { - input_tokens: 500_000, - output_tokens: 125_000, - total_tokens: 625_000, - total_usd_micros, - ..BilledTokenCounts::default() - }; - stage.model = Some(ModelRef { - provider: ProviderId::openai(), - model_id: "gpt-5.4".into(), - speed: None, - }); - stage - } - - #[test] - fn billed_usage_prices_uncosted_tokens_from_the_catalog() { - let stage = priced_stage(None); - - assert_eq!(stage.billed_usage(None).total_usd_micros, None); - let priced = stage.billed_usage(Some(Catalog::builtin())); - assert!( - priced.total_usd_micros.is_some_and(|cost| cost > 0), - "expected a catalog price, got {:?}", - priced.total_usd_micros - ); - // Pricing only fills in the cost; the token buckets pass through. - assert_eq!(priced.input_tokens, 500_000); - assert_eq!(priced.output_tokens, 125_000); - } - - #[test] - fn billed_usage_keeps_a_provider_reported_cost_over_the_catalog_estimate() { - let stage = priced_stage(Some(42)); - - assert_eq!( - stage - .billed_usage(Some(Catalog::builtin())) - .total_usd_micros, - Some(42) - ); - } - - #[test] - fn billed_usage_leaves_a_modelless_stage_uncosted() { - let mut stage = priced_stage(None); - stage.model = None; - - assert_eq!( - stage - .billed_usage(Some(Catalog::builtin())) - .total_usd_micros, - None - ); - } - - #[test] - fn billed_usage_leaves_zero_tokens_uncosted() { - let mut stage = priced_stage(None); - stage.usage = BilledTokenCounts::default(); - - assert_eq!( - stage - .billed_usage(Some(Catalog::builtin())) - .total_usd_micros, - None - ); - } } #[cfg(test)] @@ -1334,11 +1238,7 @@ mod live_timing_tests { StageInferenceProjection { session_id: "session-1".to_string(), started_at, - requested_model: ModelRef { - provider: "anthropic".parse().unwrap(), - model_id: "claude-sonnet-5".into(), - speed: None, - }, + requested_model: ModelRef::new("anthropic".into(), "claude-sonnet-5".into()), first_output_at: None, first_output_kind: None, retries: 0, diff --git a/lib/foundation/fabro-types/src/session.rs b/lib/foundation/fabro-types/src/session.rs index b825338d9..5387a673a 100644 --- a/lib/foundation/fabro-types/src/session.rs +++ b/lib/foundation/fabro-types/src/session.rs @@ -1,5 +1,5 @@ use chrono::{DateTime, Utc}; -use fabro_model::ProviderId; +use lithos_llm::catalog::ProviderId; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, IntoStaticStr}; diff --git a/lib/foundation/fabro-types/src/settings/model_ref.rs b/lib/foundation/fabro-types/src/settings/model_ref.rs index b551684b7..941fda119 100644 --- a/lib/foundation/fabro-types/src/settings/model_ref.rs +++ b/lib/foundation/fabro-types/src/settings/model_ref.rs @@ -24,6 +24,7 @@ use std::fmt; use std::str::FromStr; +use lithos_llm::catalog::Catalog; use serde::de::{self, Visitor}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -228,14 +229,14 @@ impl ModelRef { } } -impl ModelRegistry for fabro_model::Catalog { +impl ModelRegistry for Catalog { fn is_provider(&self, token: &str) -> bool { - self.provider(&fabro_model::ProviderId::from(token)) - .is_some() + self.provider(token).is_ok() } fn is_model(&self, token: &str) -> bool { - self.is_model_selector(token) + self.providers() + .any(|provider| provider.model(token).is_some()) } } diff --git a/lib/foundation/fabro-types/src/transcript.rs b/lib/foundation/fabro-types/src/transcript.rs index fce181dba..32efda94f 100644 --- a/lib/foundation/fabro-types/src/transcript.rs +++ b/lib/foundation/fabro-types/src/transcript.rs @@ -1,16 +1,21 @@ -//! Canonical provider-neutral transcript primitives. +//! Canonical transcript primitives. //! -//! These types are the durable replay shapes for agent sessions. They were -//! promoted from `fabro-llm` so the Fabro event stream, API responses, and -//! runtime history can share one canonical Rust model rather than ferrying -//! parallel DTOs between layers. `fabro-llm::types` re-exports these so -//! existing imports keep working. +//! The message vocabulary (`Message`, `ContentPart`, `ToolCall`, `ToolResult`, +//! and friends) is lithos's, re-exported here so the event stream, API +//! responses, and runtime history share one Rust model. [`TranscriptMessage`] +//! is Fabro's durable replay record: identity, provenance, and usage wrapped +//! around lithos content parts. use chrono::{DateTime, Utc}; -use fabro_model::{ModelRef, TokenCounts}; -use serde::{Deserialize, Serialize, de}; +pub use lithos_llm::types::{ + AudioContent, ContentPart, DocumentContent, ImageContent, MediaSource, Message, + ReasoningContent, Role, TokenCounts, ToolArgumentError, ToolArguments, ToolCall, ToolCallKind, + ToolChoice, ToolDefinition, ToolDefinitionKind, ToolInput, ToolResult, UnknownContent, +}; +use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, IntoStaticStr}; +use crate::billing::ModelRef; use crate::id::ulid_id; use crate::pair::{PairId, PairMessageId}; use crate::principal::Principal; @@ -18,336 +23,68 @@ use crate::session::TurnId; ulid_id!(MessageId); -// --- Content data structures ------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ImageData { - pub url: Option, - pub data: Option>, - pub media_type: Option, - pub detail: Option, +/// Concatenates the text parts of a message or response. +#[must_use] +pub fn text_of(parts: &[ContentPart]) -> String { + parts + .iter() + .filter_map(|part| match part { + ContentPart::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect() } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AudioData { - pub url: Option, - pub data: Option>, - pub media_type: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct DocumentData { - pub url: Option, - pub data: Option>, - pub media_type: Option, - pub file_name: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ThinkingData { - pub text: String, - pub signature: Option, - pub redacted: bool, -} - -// --- Tool call / tool result ------------------------------------------------- - -fn default_tool_type() -> String { - "function".to_string() -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ToolCall { - pub id: String, - pub name: String, - #[serde(rename = "type", default = "default_tool_type")] - pub tool_type: String, - pub arguments: serde_json::Value, - pub raw_arguments: Option, - /// Opaque provider-specific metadata (e.g. Gemini `thought_signature`). - /// Preserved across round-trips so the provider can include it when - /// sending conversation history back to the API. - #[serde(skip_serializing_if = "Option::is_none")] - pub provider_metadata: Option, -} - -impl ToolCall { - pub fn new( - id: impl Into, - name: impl Into, - arguments: serde_json::Value, - ) -> Self { - Self { - id: id.into(), - name: name.into(), - tool_type: "function".to_string(), - arguments, - raw_arguments: None, - provider_metadata: None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ToolResult { - pub tool_call_id: String, - pub content: serde_json::Value, - pub is_error: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub image_data: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub image_media_type: Option, -} - -impl ToolResult { - pub fn success(id: impl Into, content: serde_json::Value) -> Self { - Self { - tool_call_id: id.into(), - content, - is_error: false, - image_data: None, - image_media_type: None, - } - } - - pub fn error(id: impl Into, message: impl Into) -> Self { - Self { - tool_call_id: id.into(), - content: serde_json::Value::String(message.into()), - is_error: true, - image_data: None, - image_media_type: None, - } - } -} - -// --- ContentPart ------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ContentPart { - Text(String), - Image(ImageData), - Audio(AudioData), - Document(DocumentData), - ToolCall(ToolCall), - ToolResult(ToolResult), - Thinking(ThinkingData), - Other { - kind: String, - data: serde_json::Value, - }, -} - -impl Serialize for ContentPart { - fn serialize(&self, serializer: S) -> Result { - use serde::ser::SerializeMap; - let mut map = serializer.serialize_map(Some(2))?; - match self { - Self::Text(v) => { - map.serialize_entry("kind", "text")?; - map.serialize_entry("data", v)?; - } - Self::Image(v) => { - map.serialize_entry("kind", "image")?; - map.serialize_entry("data", v)?; - } - Self::Audio(v) => { - map.serialize_entry("kind", "audio")?; - map.serialize_entry("data", v)?; - } - Self::Document(v) => { - map.serialize_entry("kind", "document")?; - map.serialize_entry("data", v)?; - } - Self::ToolCall(v) => { - map.serialize_entry("kind", "tool_call")?; - map.serialize_entry("data", v)?; - } - Self::ToolResult(v) => { - map.serialize_entry("kind", "tool_result")?; - map.serialize_entry("data", v)?; - } - Self::Thinking(v) => { - let kind = if v.redacted { - "redacted_thinking" - } else { - "thinking" - }; - map.serialize_entry("kind", kind)?; - map.serialize_entry("data", v)?; - } - Self::Other { kind, data } => { - map.serialize_entry("kind", kind)?; - map.serialize_entry("data", data)?; - } - } - map.end() - } -} - -impl<'de> Deserialize<'de> for ContentPart { - fn deserialize>(deserializer: D) -> Result { - let value = serde_json::Value::deserialize(deserializer)?; - let kind = value - .get("kind") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| de::Error::missing_field("kind"))?; - let data = value - .get("data") - .cloned() - .unwrap_or(serde_json::Value::Null); - match kind { - "text" => serde_json::from_value(data) - .map(Self::Text) - .map_err(de::Error::custom), - "image" => serde_json::from_value(data) - .map(Self::Image) - .map_err(de::Error::custom), - "audio" => serde_json::from_value(data) - .map(Self::Audio) - .map_err(de::Error::custom), - "document" => serde_json::from_value(data) - .map(Self::Document) - .map_err(de::Error::custom), - "tool_call" => serde_json::from_value(data) - .map(Self::ToolCall) - .map_err(de::Error::custom), - "tool_result" => serde_json::from_value(data) - .map(Self::ToolResult) - .map_err(de::Error::custom), - "thinking" => serde_json::from_value(data) - .map(Self::Thinking) - .map_err(de::Error::custom), - "redacted_thinking" => serde_json::from_value::(data) - .map(|mut td| { - td.redacted = true; - Self::Thinking(td) - }) - .map_err(de::Error::custom), - other => Ok(Self::Other { - kind: other.to_string(), - data, - }), - } - } -} - -impl ContentPart { - /// Kind string for opaque OpenAI reasoning output items. - pub const OPENAI_REASONING: &str = "openai_reasoning"; - /// Kind string for opaque OpenAI message output items. - pub const OPENAI_MESSAGE: &str = "openai_message"; - /// Kind string for opaque OpenAI-compatible `reasoning_details` entries. - /// The data is the received array of detail objects, preserved verbatim - /// so encrypted entries survive for future provider-aware replay. Only - /// known readable members are ever normalized out of it. - pub const OPENAI_COMPAT_REASONING_DETAILS: &str = "openai_compat_reasoning_details"; - - pub fn text(text: impl Into) -> Self { - Self::Text(text.into()) - } - - /// Returns `true` if this is an opaque OpenAI item (reasoning or message) - /// that should be round-tripped verbatim through the API. - pub fn is_opaque_openai(&self) -> bool { - matches!( - self, - Self::Other { kind, .. } - if kind == Self::OPENAI_REASONING || kind == Self::OPENAI_MESSAGE - ) - } -} - -// --- Role / Message -// ----------------------------------------------------------- - -/// Author role of a chat [`Message`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Role { - System, - User, - Assistant, - Tool, - Developer, -} - -/// Provider-neutral chat message exchanged with an LLM. +/// Builds a tool result whose content is one JSON value. /// -/// This is the request/response message shape shared by `fabro-llm` -/// requests and the completions API wire contract. The durable -/// session-transcript record is [`TranscriptMessage`], which carries -/// identity, provenance, and usage on top of the same [`ContentPart`] -/// vocabulary. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Message { - pub role: Role, - pub content: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option, +/// Plain strings become a text part so providers render them as text; every +/// other value is carried as structured JSON. +#[must_use] +pub fn tool_result_from_json( + tool_call_id: impl Into, + content: serde_json::Value, + is_error: bool, +) -> ToolResult { + let part = match content { + serde_json::Value::String(text) => ContentPart::Text { text }, + value => ContentPart::Json { value }, + }; + ToolResult { + tool_call_id: tool_call_id.into(), + name: None, + content: vec![part], + is_error, + } } -impl Message { - pub fn system(text: impl Into) -> Self { - Self { - role: Role::System, - content: vec![ContentPart::text(text)], - name: None, - tool_call_id: None, - } +/// Projects a tool result back to one JSON value, the inverse of +/// [`tool_result_from_json`]. +/// +/// A lone text part becomes a string and a lone JSON part its value. Any +/// other shape is carried as the array of serialized parts. +#[must_use] +pub fn tool_result_to_json(result: &ToolResult) -> serde_json::Value { + match result.content.as_slice() { + [ContentPart::Text { text }] => serde_json::Value::String(text.clone()), + [ContentPart::Json { value }] => value.clone(), + parts => serde_json::Value::Array( + parts + .iter() + .map(|part| serde_json::to_value(part).unwrap_or(serde_json::Value::Null)) + .collect(), + ), } +} - pub fn user(text: impl Into) -> Self { - Self { - role: Role::User, - content: vec![ContentPart::text(text)], - name: None, - tool_call_id: None, - } - } - - pub fn assistant(text: impl Into) -> Self { - Self { - role: Role::Assistant, - content: vec![ContentPart::text(text)], - name: None, - tool_call_id: None, - } - } - - pub fn tool_result( - tool_call_id: impl Into, - content: serde_json::Value, - is_error: bool, - ) -> Self { - let id = tool_call_id.into(); - Self { - role: Role::Tool, - content: vec![ContentPart::ToolResult(ToolResult { - tool_call_id: id.clone(), - content, - is_error, - image_data: None, - image_media_type: None, - })], - name: None, - tool_call_id: Some(id), - } - } - - /// Concatenates text from all text content parts. - #[must_use] - pub fn text(&self) -> String { - self.content - .iter() - .filter_map(|part| match part { - ContentPart::Text(text) => Some(text.as_str()), - _ => None, - }) - .collect() - } +/// The arguments of a tool call as one JSON value. +/// +/// Function arguments are the parsed JSON object; malformed arguments and +/// custom free-form input are carried as their raw text. +#[must_use] +pub fn tool_call_arguments(call: &ToolCall) -> serde_json::Value { + call.input + .to_value() + .unwrap_or_else(|_| serde_json::Value::String(call.input.raw().to_string())) } // --- TranscriptMessage ------------------------------------------------------ @@ -423,7 +160,7 @@ pub struct PairMessageRef { /// Canonical durable transcript message. /// /// Named `TranscriptMessage` rather than `Message` to avoid import ambiguity -/// with `fabro_agent::Message` and `fabro_llm::types::Message`. +/// with `fabro_agent::Message` and the lithos request [`Message`]. /// /// `kind` captures provider/model-role semantics for replay; `source` /// captures audit/UI provenance. Both are required to faithfully reconstruct @@ -480,59 +217,30 @@ mod tests { use super::*; #[test] - fn content_part_text_roundtrips() { - let part = ContentPart::text("hello"); - let v = serde_json::to_value(&part).unwrap(); - assert_eq!(v, json!({"kind": "text", "data": "hello"})); - let back: ContentPart = serde_json::from_value(v).unwrap(); - assert_eq!(back, part); + fn text_of_concatenates_text_parts_only() { + let parts = vec![ + ContentPart::Text { + text: "hello ".to_string(), + }, + ContentPart::Json { value: json!(1) }, + ContentPart::Text { + text: "world".to_string(), + }, + ]; + assert_eq!(text_of(&parts), "hello world"); } #[test] - fn content_part_thinking_preserves_signature_and_redaction() { - let part = ContentPart::Thinking(ThinkingData { - text: "private thought".to_string(), - signature: Some("sig_abc".to_string()), - redacted: true, - }); - let v = serde_json::to_value(&part).unwrap(); - assert_eq!(v["kind"], "redacted_thinking"); - assert_eq!(v["data"]["signature"], "sig_abc"); - let back: ContentPart = serde_json::from_value(v).unwrap(); - assert_eq!(back, part); - } - - #[test] - fn content_part_other_preserves_provider_kind() { - let part = ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: json!({"item_id": "rs_1", "encrypted": "x"}), - }; - assert!(part.is_opaque_openai()); - let v = serde_json::to_value(&part).unwrap(); - let back: ContentPart = serde_json::from_value(v).unwrap(); - assert_eq!(back, part); - } - - #[test] - fn tool_call_preserves_provider_metadata() { - let mut tc = ToolCall::new("call_1", "Bash", json!({"cmd": "ls"})); - tc.provider_metadata = Some(json!({"thought_signature": "sig"})); - tc.raw_arguments = Some("{\"cmd\":\"ls\"}".to_string()); - let v = serde_json::to_value(&tc).unwrap(); - assert_eq!(v["provider_metadata"]["thought_signature"], "sig"); - let back: ToolCall = serde_json::from_value(v).unwrap(); - assert_eq!(back, tc); - } - - #[test] - fn tool_result_round_trips_with_default_image_fields() { - let tr = ToolResult::success("call_1", json!({"ok": true})); - let v = serde_json::to_value(&tr).unwrap(); - // Optional image fields are omitted on serialize. - assert!(v.get("image_data").is_none()); - let back: ToolResult = serde_json::from_value(v).unwrap(); - assert_eq!(back, tr); + fn tool_result_from_json_keeps_strings_as_text() { + let result = tool_result_from_json("call_1", json!("ok"), false); + assert_eq!(result.content, vec![ContentPart::Text { + text: "ok".to_string(), + }]); + let result = tool_result_from_json("call_1", json!({"ok": true}), true); + assert!(result.is_error); + assert_eq!(result.content, vec![ContentPart::Json { + value: json!({"ok": true}), + }]); } #[test] @@ -544,7 +252,9 @@ mod tests { source: MessageSource::Steer, actor: None, pair: None, - content: vec![ContentPart::text("please continue")], + content: vec![ContentPart::Text { + text: "please continue".to_string(), + }], model: None, response_id: None, usage: None, @@ -553,6 +263,10 @@ mod tests { let v = serde_json::to_value(&msg).unwrap(); assert_eq!(v["kind"], "user"); assert_eq!(v["source"], "steer"); + assert_eq!( + v["content"][0], + json!({"type": "text", "text": "please continue"}) + ); let back: TranscriptMessage = serde_json::from_value(v).unwrap(); assert_eq!(back, msg); } @@ -560,7 +274,9 @@ mod tests { #[test] fn transcript_message_drops_optional_fields_on_serialize() { let msg = TranscriptMessage::new(MessageKind::Agent, MessageSource::ProviderAnswer, vec![ - ContentPart::text("done"), + ContentPart::Text { + text: "done".to_string(), + }, ]); let v = serde_json::to_value(&msg).unwrap(); let obj = v.as_object().unwrap(); @@ -574,6 +290,22 @@ mod tests { assert!(!obj.contains_key("created_at")); } + #[test] + fn transcript_message_usage_uses_lithos_buckets() { + let mut msg = + TranscriptMessage::new(MessageKind::Agent, MessageSource::ProviderAnswer, vec![]); + msg.usage = Some(TokenCounts { + input: 10, + output: 2, + ..TokenCounts::default() + }); + let v = serde_json::to_value(&msg).unwrap(); + assert_eq!( + v["usage"], + json!({"input": 10, "output": 2, "reasoning": 0, "cache_read": 0, "cache_write": 0}) + ); + } + #[test] fn pair_message_ref_skips_empty_client_id() { let r = PairMessageRef { diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 3940c2505..51e597a78 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -89,9 +89,13 @@ models/code-location.ts models/command-log-response.ts models/command-termination.ts models/completion-content-part.ts +models/completion-cost.ts models/completion-message.ts +models/completion-response-format.ts +models/completion-response-warnings-inner.ts models/completion-response.ts models/completion-tool-choice.ts +models/completion-tool-definition-kind.ts models/completion-tool-definition.ts models/completion-usage.ts models/conclusion.ts @@ -232,6 +236,7 @@ models/merge-run-pull-request-response.ts models/model-controls.ts models/model-costs.ts models/model-features.ts +models/model-handle.ts models/model-limits.ts models/model-reference.ts models/model-test-mode.ts @@ -319,7 +324,6 @@ models/pull-request-settings.ts models/pull-request-user.ts models/pull-request.ts models/question-type.ts -models/reasoning-effort-feature.ts models/reasoning-effort.ts models/reasoning-output-trace-only.ts models/reasoning-output-with-summary.ts diff --git a/lib/packages/fabro-api-client/src/api/completions-api.ts b/lib/packages/fabro-api-client/src/api/completions-api.ts index e4cb58fd4..0f791e35d 100644 --- a/lib/packages/fabro-api-client/src/api/completions-api.ts +++ b/lib/packages/fabro-api-client/src/api/completions-api.ts @@ -33,7 +33,7 @@ import type { ErrorResponse } from '../models'; export const CompletionsApiAxiosParamCreator = function (configuration?: Configuration) { return { /** - * 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. + * Generate a text completion. Set `stream: true` for SSE streaming. 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. * @summary Create Completion * @param {CreateCompletionRequest} createCompletionRequest * @param {*} [options] Override http request option. @@ -83,7 +83,7 @@ export const CompletionsApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = CompletionsApiAxiosParamCreator(configuration) return { /** - * 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. + * Generate a text completion. Set `stream: true` for SSE streaming. 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. * @summary Create Completion * @param {CreateCompletionRequest} createCompletionRequest * @param {*} [options] Override http request option. @@ -105,7 +105,7 @@ export const CompletionsApiFactory = function (configuration?: Configuration, ba const localVarFp = CompletionsApiFp(configuration) return { /** - * 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. + * Generate a text completion. Set `stream: true` for SSE streaming. 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. * @summary Create Completion * @param {CreateCompletionRequest} createCompletionRequest * @param {*} [options] Override http request option. @@ -122,7 +122,7 @@ export const CompletionsApiFactory = function (configuration?: Configuration, ba */ export class CompletionsApi extends BaseAPI { /** - * 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. + * Generate a text completion. Set `stream: true` for SSE streaming. 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. * @summary Create Completion * @param {CreateCompletionRequest} createCompletionRequest * @param {*} [options] Override http request option. diff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts index d1afa2112..8703a61cf 100644 --- a/lib/packages/fabro-api-client/src/api/runs-api.ts +++ b/lib/packages/fabro-api-client/src/api/runs-api.ts @@ -372,7 +372,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) }; }, /** - * Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`). + * Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `pull_request_environment_unsupported`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`). * @summary Create Run * @param {CreateRunRequest} createRunRequest * @param {*} [options] Override http request option. @@ -1677,7 +1677,7 @@ export const RunsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`). + * Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `pull_request_environment_unsupported`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`). * @summary Create Run * @param {CreateRunRequest} createRunRequest * @param {*} [options] Override http request option. @@ -2137,7 +2137,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? return localVarFp.closeRunPullRequest(id, options).then((request) => request(axios, basePath)); }, /** - * Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`). + * Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `pull_request_environment_unsupported`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`). * @summary Create Run * @param {CreateRunRequest} createRunRequest * @param {*} [options] Override http request option. @@ -2518,7 +2518,7 @@ export class RunsApi extends BaseAPI { } /** - * Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`). + * Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `pull_request_environment_unsupported`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`). * @summary Create Run * @param {CreateRunRequest} createRunRequest * @param {*} [options] Override http request option. diff --git a/lib/packages/fabro-api-client/src/models/billing-speed.ts b/lib/packages/fabro-api-client/src/models/billing-speed.ts index 80e070d52..6f3289403 100644 --- a/lib/packages/fabro-api-client/src/models/billing-speed.ts +++ b/lib/packages/fabro-api-client/src/models/billing-speed.ts @@ -15,12 +15,13 @@ /** - * Optional provider-specific model speed tier used for cost estimates. + * lithos `Speed`: the requested latency or cost tier. */ export const BillingSpeed = { - STANDARD: 'standard', - FAST: 'fast' + FAST: 'fast', + BALANCED: 'balanced', + ECONOMICAL: 'economical' } as const; export type BillingSpeed = typeof BillingSpeed[keyof typeof BillingSpeed]; diff --git a/lib/packages/fabro-api-client/src/models/completion-content-part.ts b/lib/packages/fabro-api-client/src/models/completion-content-part.ts index 0f0d2d7e0..a3cb87021 100644 --- a/lib/packages/fabro-api-client/src/models/completion-content-part.ts +++ b/lib/packages/fabro-api-client/src/models/completion-content-part.ts @@ -15,12 +15,13 @@ /** - * A content part within a message, discriminated by `kind`. + * 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}). */ export interface CompletionContentPart { + [key: string]: any; + /** - * Content part type: text, image, tool_call, tool_result, thinking, etc. + * Content part type. */ - 'kind': string; - 'data'?: any; + 'type': string; } diff --git a/lib/packages/fabro-api-client/src/models/completion-cost.ts b/lib/packages/fabro-api-client/src/models/completion-cost.ts new file mode 100644 index 000000000..33775d578 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/completion-cost.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { CostSource } from './cost-source'; + +/** + * lithos `Cost`: a USD amount in micros and where it came from. + */ +export interface CompletionCost { + 'usd_micros': number; + 'source': CostSource; +} diff --git a/lib/packages/fabro-api-client/src/models/completion-message.ts b/lib/packages/fabro-api-client/src/models/completion-message.ts index 60f264761..fc1e8cc43 100644 --- a/lib/packages/fabro-api-client/src/models/completion-message.ts +++ b/lib/packages/fabro-api-client/src/models/completion-message.ts @@ -18,7 +18,7 @@ import type { CompletionContentPart } from './completion-content-part'; /** - * A message in the conversation. + * A lithos `Message`. `content` parts are discriminated by `type`. */ export interface CompletionMessage { /** @@ -41,10 +41,10 @@ export interface CompletionMessage { export const CompletionMessageRoleEnum = { SYSTEM: 'system', + DEVELOPER: 'developer', USER: 'user', ASSISTANT: 'assistant', - TOOL: 'tool', - DEVELOPER: 'developer' + TOOL: 'tool' } as const; export type CompletionMessageRoleEnum = typeof CompletionMessageRoleEnum[keyof typeof CompletionMessageRoleEnum]; diff --git a/lib/packages/fabro-api-client/src/models/completion-response-format.ts b/lib/packages/fabro-api-client/src/models/completion-response-format.ts new file mode 100644 index 000000000..2413c9dca --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/completion-response-format.ts @@ -0,0 +1,32 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * lithos `ResponseFormat`, discriminated by `type`: `text`, `json_object`, or `json_schema` ({name, schema}). + */ +export interface CompletionResponseFormat { + [key: string]: any; + + 'type': CompletionResponseFormatTypeEnum; +} + +export const CompletionResponseFormatTypeEnum = { + TEXT: 'text', + JSON_OBJECT: 'json_object', + JSON_SCHEMA: 'json_schema' +} as const; + +export type CompletionResponseFormatTypeEnum = typeof CompletionResponseFormatTypeEnum[keyof typeof CompletionResponseFormatTypeEnum]; diff --git a/lib/packages/fabro-api-client/src/models/completion-response-warnings-inner.ts b/lib/packages/fabro-api-client/src/models/completion-response-warnings-inner.ts new file mode 100644 index 000000000..537982c01 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/completion-response-warnings-inner.ts @@ -0,0 +1,20 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface CompletionResponseWarningsInner { + 'code': string; + 'message': string; +} diff --git a/lib/packages/fabro-api-client/src/models/completion-response.ts b/lib/packages/fabro-api-client/src/models/completion-response.ts index 555907f9c..e36d9434d 100644 --- a/lib/packages/fabro-api-client/src/models/completion-response.ts +++ b/lib/packages/fabro-api-client/src/models/completion-response.ts @@ -15,34 +15,39 @@ // May contain unused imports in some cases // @ts-ignore -import type { CompletionMessage } from './completion-message'; +import type { CompletionContentPart } from './completion-content-part'; +// May contain unused imports in some cases +// @ts-ignore +import type { CompletionCost } from './completion-cost'; +// May contain unused imports in some cases +// @ts-ignore +import type { CompletionResponseWarningsInner } from './completion-response-warnings-inner'; // May contain unused imports in some cases // @ts-ignore import type { CompletionUsage } from './completion-usage'; // May contain unused imports in some cases // @ts-ignore -import type { CostSource } from './cost-source'; +import type { ModelHandle } from './model-handle'; +/** + * 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. + */ export interface CompletionResponse { - 'id': string; - /** - * Canonical model ID selected for the request. - */ - 'model': string; - /** - * LLM provider identifier. - */ - 'provider': string; - 'message': CompletionMessage; - /** - * Why generation stopped (end_turn, max_tokens, tool_calls). - */ - 'stop_reason': string; - 'usage': CompletionUsage; 'output'?: any; + 'id'?: string | null; + 'model': ModelHandle; + 'content': Array; /** - * USD cost of the completion when known: estimated from catalog prices unless the provider returned authoritative billing data. + * Tool calls withheld because the turn ended early. */ - 'cost_usd'?: number; - 'cost_source'?: CostSource; + 'suppressed_tool_calls'?: Array<{ [key: string]: any; }>; + /** + * Why generation stopped: stop, length, tool_call, content_filter, error, incomplete, or a provider-specific reason. + */ + 'finish_reason': string; + 'usage': CompletionUsage; + 'cost'?: CompletionCost; + 'rate_limits'?: { [key: string]: any; }; + 'warnings'?: Array; + 'raw'?: any; } diff --git a/lib/packages/fabro-api-client/src/models/completion-tool-choice.ts b/lib/packages/fabro-api-client/src/models/completion-tool-choice.ts index 87b4ffdb2..4f483732d 100644 --- a/lib/packages/fabro-api-client/src/models/completion-tool-choice.ts +++ b/lib/packages/fabro-api-client/src/models/completion-tool-choice.ts @@ -15,24 +15,24 @@ /** - * Controls how the model selects tools. + * A lithos `ToolChoice`, discriminated by `type`. */ export interface CompletionToolChoice { /** * Tool selection mode. */ - 'mode': CompletionToolChoiceModeEnum; + 'type': CompletionToolChoiceTypeEnum; /** - * Required when mode is \"named\". + * Required when type is `tool`. */ - 'tool_name'?: string; + 'name'?: string; } -export const CompletionToolChoiceModeEnum = { +export const CompletionToolChoiceTypeEnum = { AUTO: 'auto', NONE: 'none', REQUIRED: 'required', - NAMED: 'named' + TOOL: 'tool' } as const; -export type CompletionToolChoiceModeEnum = typeof CompletionToolChoiceModeEnum[keyof typeof CompletionToolChoiceModeEnum]; +export type CompletionToolChoiceTypeEnum = typeof CompletionToolChoiceTypeEnum[keyof typeof CompletionToolChoiceTypeEnum]; diff --git a/lib/packages/fabro-api-client/src/models/completion-tool-definition-kind.ts b/lib/packages/fabro-api-client/src/models/completion-tool-definition-kind.ts new file mode 100644 index 000000000..f23d43e6f --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/completion-tool-definition-kind.ts @@ -0,0 +1,31 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * lithos `ToolDefinitionKind`: `{type: function, input_schema}` for JSON-argument tools or `{type: custom, format}` for free-form input. + */ +export interface CompletionToolDefinitionKind { + [key: string]: any; + + 'type': CompletionToolDefinitionKindTypeEnum; +} + +export const CompletionToolDefinitionKindTypeEnum = { + FUNCTION: 'function', + CUSTOM: 'custom' +} as const; + +export type CompletionToolDefinitionKindTypeEnum = typeof CompletionToolDefinitionKindTypeEnum[keyof typeof CompletionToolDefinitionKindTypeEnum]; diff --git a/lib/packages/fabro-api-client/src/models/completion-tool-definition.ts b/lib/packages/fabro-api-client/src/models/completion-tool-definition.ts index 2cdfa60e7..07cf2d98c 100644 --- a/lib/packages/fabro-api-client/src/models/completion-tool-definition.ts +++ b/lib/packages/fabro-api-client/src/models/completion-tool-definition.ts @@ -13,9 +13,12 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { CompletionToolDefinitionKind } from './completion-tool-definition-kind'; /** - * A tool available for the model to call. + * A lithos `ToolDefinition`. `kind` is `{type: function, input_schema}` for JSON-argument tools or `{type: custom, format}` for free-form input. */ export interface CompletionToolDefinition { /** @@ -26,5 +29,5 @@ export interface CompletionToolDefinition { * Human-readable tool description. */ 'description': string; - 'parameters': any; + 'kind': CompletionToolDefinitionKind; } diff --git a/lib/packages/fabro-api-client/src/models/completion-usage.ts b/lib/packages/fabro-api-client/src/models/completion-usage.ts index 6d2e8892d..11ef8c627 100644 --- a/lib/packages/fabro-api-client/src/models/completion-usage.ts +++ b/lib/packages/fabro-api-client/src/models/completion-usage.ts @@ -15,27 +15,27 @@ /** - * 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. */ export interface CompletionUsage { /** - * Number of uncached input tokens consumed. + * Uncached prompt tokens. */ - 'input_tokens': number; + 'input'?: number; /** - * Number of non-reasoning output tokens generated. + * Non-reasoning completion tokens. */ - 'output_tokens': number; + 'output'?: number; /** - * Number of separately reported reasoning tokens. + * Separately reported reasoning tokens. */ - 'reasoning_tokens': number; + 'reasoning'?: number; /** - * Number of input tokens served from a provider cache. + * Prompt tokens served from a provider cache. */ - 'cache_read_tokens': number; + 'cache_read'?: number; /** - * Number of input tokens written to a provider cache. + * Prompt tokens written to a provider cache. */ - 'cache_write_tokens': number; + 'cache_write'?: number; } diff --git a/lib/packages/fabro-api-client/src/models/cost-source.ts b/lib/packages/fabro-api-client/src/models/cost-source.ts index 9e91b3d6f..0230e950a 100644 --- a/lib/packages/fabro-api-client/src/models/cost-source.ts +++ b/lib/packages/fabro-api-client/src/models/cost-source.ts @@ -15,12 +15,13 @@ /** - * Whether `cost_usd` came from provider billing data (authoritative) or catalog price estimation (estimated). + * Where a cost came from: `catalog` (estimated from catalog prices), `provider` (the provider\'s own billing data), or `application`. */ export const CostSource = { - AUTHORITATIVE: 'authoritative', - ESTIMATED: 'estimated' + CATALOG: 'catalog', + PROVIDER: 'provider', + APPLICATION: 'application' } as const; export type CostSource = typeof CostSource[keyof typeof CostSource]; diff --git a/lib/packages/fabro-api-client/src/models/create-completion-request.ts b/lib/packages/fabro-api-client/src/models/create-completion-request.ts index c59f7869d..db03e9352 100644 --- a/lib/packages/fabro-api-client/src/models/create-completion-request.ts +++ b/lib/packages/fabro-api-client/src/models/create-completion-request.ts @@ -13,11 +13,17 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { BillingSpeed } from './billing-speed'; // May contain unused imports in some cases // @ts-ignore import type { CompletionMessage } from './completion-message'; // May contain unused imports in some cases // @ts-ignore +import type { CompletionResponseFormat } from './completion-response-format'; +// May contain unused imports in some cases +// @ts-ignore import type { CompletionToolChoice } from './completion-tool-choice'; // May contain unused imports in some cases // @ts-ignore @@ -26,15 +32,22 @@ import type { CompletionToolDefinition } from './completion-tool-definition'; // @ts-ignore import type { ReasoningEffort } from './reasoning-effort'; +/** + * A lithos `Request` plus `stream`. Field names match the lithos wire form so a serialized lithos request can be posted as-is. + */ export interface CreateCompletionRequest { /** * The conversation messages. */ 'messages': Array; /** - * Model ID or alias. Server picks a ready-provider default if omitted. + * Model selector: `provider/model`, a model id or alias, or a provider id. The server picks a ready-provider default when omitted. */ 'model'?: string; + /** + * Optional provider pin for a bare model selector. + */ + 'provider'?: string; /** * System prompt (convenience; prepended as a system message). */ @@ -48,9 +61,10 @@ export interface CreateCompletionRequest { */ 'tools'?: Array; 'tool_choice'?: CompletionToolChoice; + 'response_format'?: CompletionResponseFormat; 'schema'?: any; + 'max_output_tokens'?: number; 'temperature'?: number; - 'max_tokens'?: number; 'top_p'?: number; /** * Stop sequences. @@ -61,8 +75,15 @@ export interface CreateCompletionRequest { */ 'reasoning_effort'?: ReasoningEffort; /** - * Optional provider pin. + * Requested speed tier. */ - 'provider'?: string; - 'provider_options'?: any; + 'speed'?: BillingSpeed; + /** + * Request tags forwarded to providers that accept them. + */ + 'metadata'?: { [key: string]: string; }; + /** + * Raw provider options keyed by provider id. + */ + 'provider_options'?: { [key: string]: any; }; } diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index f70535003..0a1bbef32 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -60,10 +60,14 @@ export * from './code-location'; export * from './command-log-response'; export * from './command-termination'; export * from './completion-content-part'; +export * from './completion-cost'; export * from './completion-message'; export * from './completion-response'; +export * from './completion-response-format'; +export * from './completion-response-warnings-inner'; export * from './completion-tool-choice'; export * from './completion-tool-definition'; +export * from './completion-tool-definition-kind'; export * from './completion-usage'; export * from './conclusion'; export * from './cost-source'; @@ -203,6 +207,7 @@ export * from './model'; export * from './model-controls'; export * from './model-costs'; export * from './model-features'; +export * from './model-handle'; export * from './model-limits'; export * from './model-reference'; export * from './model-test-mode'; @@ -290,7 +295,6 @@ export * from './pull-request-settings'; export * from './pull-request-user'; export * from './question-type'; export * from './reasoning-effort'; -export * from './reasoning-effort-feature'; export * from './reasoning-output'; export * from './reasoning-output-trace-only'; export * from './reasoning-output-with-summary'; diff --git a/lib/packages/fabro-api-client/src/models/model-features.ts b/lib/packages/fabro-api-client/src/models/model-features.ts index 42fcae9d6..4029cf401 100644 --- a/lib/packages/fabro-api-client/src/models/model-features.ts +++ b/lib/packages/fabro-api-client/src/models/model-features.ts @@ -13,12 +13,9 @@ */ -// May contain unused imports in some cases -// @ts-ignore -import type { ReasoningEffortFeature } from './reasoning-effort-feature'; /** - * Capability flags for a model. + * Capability flags for a model, from the lithos catalog. */ export interface ModelFeatures { /** @@ -26,24 +23,19 @@ export interface ModelFeatures { */ 'tools': boolean; /** - * Whether the model supports vision/image inputs. + * Whether the model supports image inputs. */ 'vision': boolean; /** * Whether the model supports extended reasoning. */ 'reasoning': boolean; - 'reasoning_effort': ReasoningEffortFeature; /** * Whether the model endpoint supports prompt caching. */ 'prompt_cache': boolean; - /** - * Whether the endpoint only caches when the request marks the cacheable prefix with Anthropic-style cache_control breakpoints (e.g. Claude via OpenRouter). - */ - 'cache_control_breakpoints': boolean; /** * Whether the model accepts classic sampling parameters (temperature, top_p). */ - 'sampling_params': boolean; + 'sampling': boolean; } diff --git a/lib/packages/fabro-api-client/src/models/model-handle.ts b/lib/packages/fabro-api-client/src/models/model-handle.ts new file mode 100644 index 000000000..47be9df22 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/model-handle.ts @@ -0,0 +1,29 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * A resolved provider and model identity. + */ +export interface ModelHandle { + /** + * LLM provider identifier. + */ + 'provider': string; + /** + * Canonical model id within the provider. + */ + 'model': string; +} diff --git a/lib/packages/fabro-api-client/src/models/provider.ts b/lib/packages/fabro-api-client/src/models/provider.ts index ae5be76c3..9edd7e550 100644 --- a/lib/packages/fabro-api-client/src/models/provider.ts +++ b/lib/packages/fabro-api-client/src/models/provider.ts @@ -27,13 +27,13 @@ export interface Provider { */ 'display_name': string; /** - * Protocol adapter the provider speaks. + * lithos adapter id the provider speaks, such as `anthropic`, `openai`, `gemini`, or `openai-compatible`. */ - 'adapter': ProviderAdapterEnum; + 'adapter': string; /** - * Operator-set base URL override, if any. + * Effective API base URL, including any operator override. */ - 'base_url'?: string | null; + 'base_url': string; /** * URL where an operator can obtain an API key for this provider. */ @@ -63,12 +63,3 @@ export interface Provider { */ 'expected_secret_name'?: string | null; } - -export const ProviderAdapterEnum = { - ANTHROPIC: 'anthropic', - OPENAI: 'openai', - GEMINI: 'gemini', - OPENAI_COMPATIBLE: 'openai_compatible' -} as const; - -export type ProviderAdapterEnum = typeof ProviderAdapterEnum[keyof typeof ProviderAdapterEnum]; diff --git a/lib/packages/fabro-api-client/src/models/reasoning-effort-feature.ts b/lib/packages/fabro-api-client/src/models/reasoning-effort-feature.ts deleted file mode 100644 index f7de9a438..000000000 --- a/lib/packages/fabro-api-client/src/models/reasoning-effort-feature.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * 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. - */ - -export const ReasoningEffortFeature = { - LEVELS: 'levels', - ALWAYS_ADAPTIVE: 'always_adaptive', - NONE: 'none' -} as const; - -export type ReasoningEffortFeature = typeof ReasoningEffortFeature[keyof typeof ReasoningEffortFeature]; diff --git a/lib/packages/fabro-api-client/src/models/reasoning-effort.ts b/lib/packages/fabro-api-client/src/models/reasoning-effort.ts index adb8c1398..21137ab61 100644 --- a/lib/packages/fabro-api-client/src/models/reasoning-effort.ts +++ b/lib/packages/fabro-api-client/src/models/reasoning-effort.ts @@ -19,6 +19,7 @@ */ export const ReasoningEffort = { + MINIMAL: 'minimal', LOW: 'low', MEDIUM: 'medium', HIGH: 'high', diff --git a/test/twin/openai/.gitignore b/test/twin/openai/.gitignore deleted file mode 100644 index 434ee8ad1..000000000 --- a/test/twin/openai/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.worktrees diff --git a/test/twin/openai/Cargo.toml b/test/twin/openai/Cargo.toml deleted file mode 100644 index 4641a47ed..000000000 --- a/test/twin/openai/Cargo.toml +++ /dev/null @@ -1,30 +0,0 @@ -[package] -name = "twin-openai" -edition.workspace = true -version.workspace = true -publish = false -license.workspace = true -description = "Fake OpenAI-compatible server for local black-box testing" - -[lib] -doctest = false - -[lints] -workspace = true - -[dependencies] -anyhow.workspace = true -async-stream = "0.3" -axum = { workspace = true, features = ["macros"] } -fabro-http.workspace = true -fabro-static.workspace = true -futures-util.workspace = true -http = "1" -serde.workspace = true -serde_json.workspace = true -tokio.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true - -[dev-dependencies] -tower = "0.5" diff --git a/test/twin/openai/README.md b/test/twin/openai/README.md deleted file mode 100644 index 01ec2da38..000000000 --- a/test/twin/openai/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# twin-openai - -Async Rust fake OpenAI-compatible server for local black-box testing. - -## Endpoints - -- `GET /healthz` -- `POST /v1/responses` -- `POST /v1/chat/completions` -- `POST /__admin/scenarios` -- `POST /__admin/reset` -- `GET /__admin/requests` - -`/v1/*` routes require a non-empty bearer token. Scenarios, request logs, and deterministic response IDs are scoped by bearer token so concurrent test clients can share one server safely. - -`/__admin/*` routes are unauthenticated by default, but an optional bearer token selects the same namespace as `/v1/*`. Admin requests with a malformed or empty `Authorization` header are rejected. - -## Run locally - -```bash -cargo run -``` - -The server binds to `127.0.0.1:3000` by default. - -## Admin scripting - -Load deterministic one-shot scenarios: - -```bash -curl -X POST http://127.0.0.1:3000/__admin/scenarios \ - -H 'Authorization: Bearer suite-a' \ - -H 'content-type: application/json' \ - -d '{ - "scenarios": [ - { - "matcher": { "endpoint": "responses", "model": "gpt-test", "stream": false }, - "script": { "kind": "success", "response_text": "scripted reply" } - } - ] - }' -``` - -Inspect normalized request logs: - -```bash -curl http://127.0.0.1:3000/__admin/requests \ - -H 'Authorization: Bearer suite-a' -``` - -Reset scenarios, logs, and deterministic counters: - -```bash -curl -X POST http://127.0.0.1:3000/__admin/reset \ - -H 'Authorization: Bearer suite-a' -``` - -## Behavior summary - -- Non-stream and stream success paths are driven from the same canonical response plan. -- `/v1/responses` and `/v1/chat/completions` share the same deterministic fallback behavior. -- Structured output supports `json_object` and a documented `json_schema` subset. -- Scripted failures support OpenAI-shaped application errors, delays, hangs, partial streams, and malformed SSE. - -## Optional Live OpenAI Smoke Suite - -Run the ignored live drift detector only when you explicitly want to compare `twin-openai` against the real OpenAI API: - -```bash -OPENAI_API_KEY=... cargo test --test live_openai_contract -- --ignored --nocapture -``` - -Optional environment variables: - -- `TWIN_OPENAI_LIVE_MODEL` defaults to `gpt-5-nano-2025-08-07` -- `TWIN_OPENAI_LIVE_BASE_URL` defaults to `https://api.openai.com` -- `OPENAI_ORGANIZATION` and `OPENAI_PROJECT` are forwarded when present - -This suite is not part of normal CI. It is intentionally a drift detector for request/response shape and SSE sequencing, so opt-in failures can represent real compatibility gaps rather than a broken local test harness. - -If the supplied OpenAI credentials lack required endpoint scopes or quota, the ignored test will skip the blocked live surface instead of reporting protocol drift. - -Current live coverage includes `responses` and `chat.completions` text, streaming, structured output, function tools, `tool_choice: "none"` behavior, image-input acceptance, and both non-stream and streamed `responses` continuation turns. - -See [docs/compatibility-matrix.md](/Users/bhelmkamp/p/brynary/twin-openai/docs/compatibility-matrix.md) for the supported field matrix and explicit exclusions. diff --git a/test/twin/openai/docs/compatibility-matrix.md b/test/twin/openai/docs/compatibility-matrix.md deleted file mode 100644 index bf6bcebb3..000000000 --- a/test/twin/openai/docs/compatibility-matrix.md +++ /dev/null @@ -1,64 +0,0 @@ -# Compatibility Matrix - -Supported endpoints: - -- `GET /healthz` -- `POST /v1/responses` -- `POST /v1/chat/completions` -- `POST /__admin/scenarios` -- `POST /__admin/reset` -- `GET /__admin/requests` - -State isolation: - -- `/v1/*` request state is scoped by bearer token -- admin routes may include the same bearer token to target that namespace -- admin routes without auth operate on the global namespace - -Supported `/v1/responses` fields: - -- bearer auth -- `stream` -- `metadata` -- `stop` -- `previous_response_id` -- `reasoning` -- `text.format.type = text | json_object | json_schema` -- image inputs in `input[*].content[*].type = input_image` -- scripted tool calls and continuation input items - -Supported `/v1/chat/completions` fields: - -- bearer auth -- `max_tokens` -- `stream` -- `tools` -- `tool_choice` -- `response_format.type = text | json_object | json_schema` -- `stop` -- reasoning-bearing assistant content - -Unknown top-level fields are accepted and ignored. The twin does not simulate the behavior of -fields that are not listed above. - -Structured output subset: - -- object roots -- primitive property types: `string`, `integer`, `number`, `boolean` -- nested object properties - -Unsupported schema constructs fail explicitly: - -- arrays -- `anyOf` -- `oneOf` - -Failure scripting: - -- application errors with explicit status and OpenAI-shaped body -- optional `Retry-After` -- delay before headers -- hang before first byte -- inter-event stream delay -- close stream after N chunks -- malformed/truncated SSE ending diff --git a/test/twin/openai/docs/plans/2026-03-27-async-rust-fake-openai-test-plan.md b/test/twin/openai/docs/plans/2026-03-27-async-rust-fake-openai-test-plan.md deleted file mode 100644 index 00c127687..000000000 --- a/test/twin/openai/docs/plans/2026-03-27-async-rust-fake-openai-test-plan.md +++ /dev/null @@ -1,204 +0,0 @@ -# Async Rust Fake OpenAI Test Plan - -The approved testing strategy still holds after reconciling it with the implementation plan. The plan narrows the generic strategy into a concrete async Rust action surface: `GET /healthz`, `POST /v1/responses`, `POST /v1/chat/completions`, and the unauthenticated admin control routes `POST /__admin/scenarios`, `POST /__admin/reset`, and `GET /__admin/requests`. No strategy change requiring user approval was identified. - -## Harness requirements - -1. **Ephemeral server harness** - - What it does: boots the real `tokio` + `axum` server on an ephemeral localhost port with test config, then drives it through real HTTP. - - What it exposes: base URL, authenticated and unauthenticated HTTP clients, SSE reader utilities, timeout helpers, and optional raw socket/body readers for truncated-stream cases. - - Estimated complexity: medium. - - Tests depending on it: 1 through 14. - -2. **Admin scenario harness** - - What it does: scripts deterministic server behavior through `POST /__admin/scenarios`, clears state with `POST /__admin/reset`, and fetches normalized request logs from `GET /__admin/requests`. - - What it exposes: helpers to enqueue FIFO scenarios, seed failure scripts, reset state between tests, and fetch request-log artifacts for assertions. - - Estimated complexity: medium. - - Tests depending on it: 4 through 13. - -3. **Canonical output comparison helpers** - - What it does: normalizes non-stream JSON and streamed SSE transcripts into comparable observable artifacts so the same canonical plan can be validated across transport modes and endpoints. - - What it exposes: parsed text transcript, tool-call transcript, reasoning transcript, completion marker presence, and required-field assertions. - - Estimated complexity: low to medium. - - Tests depending on it: 3, 6, 8, 9. - -4. **Optional live OpenAI differential harness** - - What it does: when explicit credentials are present outside normal CI, sends normalized requests to both `twin-openai` and the real OpenAI API and compares protocol shape rather than literal generated text. - - What it exposes: paired request runner, field-by-field comparator for required JSON fields and SSE event ordering, and opt-in skip behavior when credentials are absent. - - Estimated complexity: medium. - - Tests depending on it: 12. - -## Test plan - -1. **Name**: health and auth endpoints enforce the public service boundary - - **Type**: integration - - **Disposition**: new - - **Harness**: Ephemeral server harness - - **Preconditions**: server is running with default local-test config and no scenarios loaded. - - **Actions**: `GET /healthz`; `POST /v1/responses` without `Authorization`; `POST /v1/chat/completions` with an empty bearer token; `POST /v1/responses` with a non-empty bearer token and minimal valid JSON. - - **Expected outcome**: `GET /healthz` returns `200`; `/v1/*` rejects missing or empty bearer auth with stable OpenAI-shaped error JSON; a syntactically valid authenticated request reaches endpoint handling rather than failing auth. Source of truth: approved strategy, implementation plan scope and product contract. - - **Interactions**: router wiring, auth middleware, request parsing, error serialization. - -2. **Name**: non-stream responses create returns deterministic OpenAI-shaped JSON - - **Type**: integration - - **Disposition**: new - - **Harness**: Ephemeral server harness - - **Preconditions**: server is running, no scenario matches the request. - - **Actions**: `POST /v1/responses` with bearer auth, `stream=false`, text input, and optional inert `OpenAI-Organization` and `OpenAI-Project` headers. - - **Expected outcome**: `200` with valid `/v1/responses` JSON containing deterministic ID format, timestamp, output items, and usage object; response text is the documented deterministic fallback derived from user text rather than a real model output. Source of truth: implementation plan user-visible behavior, required invariants, OpenAI Responses API shape. - - **Interactions**: request models, deterministic default engine, JSON serialization, inert compatibility header handling. - -3. **Name**: streaming responses emit valid SSE and the same content as non-stream responses - - **Type**: invariant - - **Disposition**: new - - **Harness**: Ephemeral server harness plus Canonical output comparison helpers - - **Preconditions**: server is running, no scenario matches the request, same logical request body is available in both stream and non-stream forms. - - **Actions**: send one `POST /v1/responses` with `stream=false` and one with `stream=true`; collect the full JSON body and full SSE transcript. - - **Expected outcome**: streamed events are valid SSE, ordered, flushed incrementally, and terminate with the supported completion semantics; the user-visible text, reasoning content, and tool-call transcript reconstructed from the stream match the non-stream response derived from the same canonical plan. Source of truth: approved strategy, implementation plan invariants, OpenAI streaming docs. - - **Interactions**: canonical response-plan projection, SSE encoder, chunk flushing, completion signaling. - -4. **Name**: admin-loaded scenarios are consumed once and in FIFO order - - **Type**: scenario - - **Disposition**: new - - **Harness**: Ephemeral server harness plus Admin scenario harness - - **Preconditions**: server is running; two matching scenarios are loaded for the same endpoint and matcher. - - **Actions**: `POST /__admin/scenarios` with two matching scripts; call the matching OpenAI endpoint twice; call it a third time after the queue is exhausted; fetch `GET /__admin/requests`; reset via `POST /__admin/reset`. - - **Expected outcome**: first OpenAI request consumes the first script, second consumes the second, third falls back to deterministic default behavior; request log is append-only until reset and empty after reset. Source of truth: implementation plan scenario scripting model, user-visible behavior, required invariants. - - **Interactions**: admin API, scenario matcher, FIFO consumption, fallback engine, request logging, reset behavior. - -5. **Name**: responses accept the declared compatibility fields and reject unsupported combinations clearly - - **Type**: boundary - - **Disposition**: new - - **Harness**: Ephemeral server harness - - **Preconditions**: server is running with no scenarios required. - - **Actions**: `POST /v1/responses` requests covering `metadata`, `stop`, `previous_response_id`, reasoning options, image inputs, `text` response format, `json_object`, supported `json_schema`, and one unsupported field or unsupported schema construct. - - **Expected outcome**: supported fields are accepted without changing the documented deterministic semantics; unsupported combinations fail with stable OpenAI-shaped error JSON rather than silent success. Source of truth: implementation plan endpoint compatibility contract, approved strategy, OpenAI docs for supported fields. - - **Interactions**: request validation, schema subset validation, image-input parsing, explicit unsupported-case handling. - -6. **Name**: scripted tool-call and continuation flows work through responses in stream and non-stream modes - - **Type**: scenario - - **Disposition**: new - - **Harness**: Ephemeral server harness plus Admin scenario harness plus Canonical output comparison helpers - - **Preconditions**: server is running; an admin scenario is queued that emits a tool call on the first request and a final answer on a continuation request that includes `function_call_output`. - - **Actions**: load the scripted scenario; call `POST /v1/responses` once non-stream and once stream to observe tool-call emission; call `POST /v1/responses` again with continuation input containing the tool output and optional `previous_response_id`. - - **Expected outcome**: first turn returns or streams a valid tool-call item with deterministic IDs and supported reasoning content; continuation request is accepted and returns the scripted final answer; stream and non-stream transcripts stay equivalent for the same planned turn. Source of truth: implementation plan tool-call and continuation contract, OpenAI function-calling and Responses docs. - - **Interactions**: scenario engine, canonical plan, responses renderer, continuation input parsing, SSE event sequencing. - -7. **Name**: structured output support is explicit, deterministic, and bounded to the documented schema subset - - **Type**: boundary - - **Disposition**: new - - **Harness**: Ephemeral server harness plus Admin scenario harness where needed - - **Preconditions**: server is running; supported and unsupported response format payloads are available. - - **Actions**: call `POST /v1/responses` with `json_object`; call with a supported `json_schema` subset; call with an unsupported schema feature such as an out-of-scope construct defined by the compatibility matrix. - - **Expected outcome**: `json_object` and supported `json_schema` requests return deterministic JSON matching the declared format; unsupported schema constructs fail explicitly with stable error JSON. Source of truth: implementation plan deterministic structured-output behavior and compatibility-matrix requirement, OpenAI structured outputs docs. - - **Interactions**: response-format validation, deterministic JSON generation, error-body rendering. - -8. **Name**: chat completions non-stream and stream use the same canonical plan as responses when behavior is equivalent - - **Type**: invariant - - **Disposition**: new - - **Harness**: Ephemeral server harness plus Admin scenario harness plus Canonical output comparison helpers - - **Preconditions**: server is running; one default request pair and one scripted scenario pair exist that are semantically representable on both endpoints. - - **Actions**: send equivalent requests to `POST /v1/responses` and `POST /v1/chat/completions` in both stream and non-stream forms. - - **Expected outcome**: both endpoints produce their respective OpenAI-shaped envelopes, but the user-visible text, reasoning transcript, and tool-call intent are equivalent because both render the same canonical plan; chat streaming emits valid delta events and terminal markers. Source of truth: implementation plan required invariants and endpoint compatibility contract, OpenAI Responses and Chat Completions docs. - - **Interactions**: cross-endpoint adapters, canonical plan projection, chat delta SSE rendering, shared validation helpers. - -9. **Name**: chat completions accept supported fields and reject unsupported combinations explicitly - - **Type**: boundary - - **Disposition**: new - - **Harness**: Ephemeral server harness - - **Preconditions**: server is running with no scenario required. - - **Actions**: `POST /v1/chat/completions` with supported `tools`, `tool_choice`, `response_format`, `stop`, and reasoning-bearing assistant content; send one request with an unsupported combination declared outside the compatibility matrix. - - **Expected outcome**: supported requests succeed in stream and non-stream modes; unsupported combinations fail with stable OpenAI-shaped error JSON rather than being ignored. Source of truth: implementation plan endpoint compatibility contract, OpenAI Chat Completions docs. - - **Interactions**: chat request parsing, response-format handling, tool-choice validation, error serialization. - -10. **Name**: scripted application errors preserve status, error shape, and retry metadata - - **Type**: integration - - **Disposition**: new - - **Harness**: Ephemeral server harness plus Admin scenario harness - - **Preconditions**: server is running; scenarios are loaded for each scripted application error variant. - - **Actions**: queue and invoke scripted failures for `400`, `401`, `403`, `404`, `408`, `413`, `429`, `500`, `502`, `503`, and `504`; include distinct quota-style and content-filter-style error bodies and a `429` carrying `Retry-After`. - - **Expected outcome**: each request returns the scripted HTTP status, OpenAI-shaped error JSON, and any scripted `Retry-After` header without transport corruption; quota-style and content-filter-style bodies remain observably distinct from generic invalid-request errors. Source of truth: approved strategy failure matrix, implementation plan task 7. - - **Interactions**: admin scripting, error renderer, header propagation, status mapping. - -11. **Name**: delayed first byte and hung requests are observable over real sockets and bounded by client timeouts - - **Type**: scenario - - **Disposition**: new - - **Harness**: Ephemeral server harness plus Admin scenario harness - - **Preconditions**: server is running; one scenario delays before headers and one hangs forever. - - **Actions**: invoke the delayed-first-byte scenario with a timeout larger than the scripted delay; invoke the hang scenario with a short explicit client timeout. - - **Expected outcome**: delayed-first-byte requests eventually succeed after the scripted pause; hung requests never produce completion and are terminated by the test client timeout rather than silently succeeding or closing early. Source of truth: approved strategy performance and failure-injection requirements, implementation plan transport-failure mechanics. - - **Interactions**: async timing, response-body start behavior, timeout guards, scenario engine. - -12. **Name**: partial stream close and malformed SSE are distinguishable transport failures - - **Type**: regression - - **Disposition**: new - - **Harness**: Ephemeral server harness plus Admin scenario harness with raw socket/body reader support - - **Preconditions**: server is running; one scenario is configured to close after N chunks and another to emit malformed or truncated SSE. - - **Actions**: invoke both scenarios against streaming `/v1/responses` and streaming `/v1/chat/completions`; capture raw stream bytes and parsed client behavior. - - **Expected outcome**: the partial-close case yields a valid prefix of the transcript followed by observable premature termination; the malformed/truncated case yields an invalid SSE/body artifact rather than a well-formed completion. Both failures are visible at the HTTP/SSE surface, not only through internal state. Source of truth: approved strategy failure matrix, implementation plan task 7 and execution notes. - - **Interactions**: streaming body writer, SSE encoder, abrupt connection teardown, lower-level HTTP client behavior. - -13. **Name**: optional live OpenAI differential checks preserve required protocol shape for the supported matrix - - **Type**: differential - - **Disposition**: new - - **Harness**: Optional live OpenAI differential harness - - **Preconditions**: explicit real OpenAI credentials are available outside required CI; normalized request fixtures exist only for the documented supported matrix. - - **Actions**: send paired non-stream and stream requests for supported `responses` and `chat.completions` cases to `twin-openai` and the real OpenAI API; compare status classes, required fields, SSE event ordering, and header behavior while ignoring literal text. - - **Expected outcome**: for the documented supported matrix, `twin-openai` matches the real API on protocol shape closely enough that generic OpenAI clients can interact with it; any intentional deviations are recorded in the compatibility matrix rather than hidden. Source of truth: approved strategy reference-comparison harness, official OpenAI API docs, real API as reference implementation. - - **Interactions**: external OpenAI service, local compatibility matrix, normalization/comparison utilities. - -14. **Name**: localhost success paths stay fast enough to catch accidental blocking or deadlock - - **Type**: invariant - - **Disposition**: new - - **Harness**: Ephemeral server harness - - **Preconditions**: server is running locally with default deterministic behavior and no injected delays. - - **Actions**: time a representative non-stream `/v1/responses` request, a representative streaming `/v1/responses` request until first event and completion, and a representative `/v1/chat/completions` request. - - **Expected outcome**: success-path requests complete comfortably under the generous local thresholds from the approved strategy, and first stream event arrives quickly enough to catch catastrophic async blocking rather than normal variance. Source of truth: approved strategy performance section. - - **Interactions**: full request path, async scheduling, streaming flush behavior, serialization. - -## Coverage summary - -Covered action space: -- `GET /healthz` -- `POST /v1/responses` with and without auth -- `POST /v1/responses` non-stream success -- `POST /v1/responses` stream success -- `POST /v1/responses` with `metadata` -- `POST /v1/responses` with `stop` -- `POST /v1/responses` with `previous_response_id` -- `POST /v1/responses` with reasoning options -- `POST /v1/responses` with image inputs -- `POST /v1/responses` with `json_object` -- `POST /v1/responses` with supported `json_schema` -- `POST /v1/responses` with unsupported schema or unsupported field combinations -- `POST /v1/responses` scripted tool-call turn -- `POST /v1/responses` continuation turn with `function_call_output` -- `POST /v1/chat/completions` with and without auth -- `POST /v1/chat/completions` non-stream success -- `POST /v1/chat/completions` stream success -- `POST /v1/chat/completions` with `tools` -- `POST /v1/chat/completions` with `tool_choice` -- `POST /v1/chat/completions` with `response_format` -- `POST /v1/chat/completions` with `stop` -- `POST /v1/chat/completions` with reasoning-bearing assistant content -- `POST /v1/chat/completions` with unsupported field combinations -- `POST /__admin/scenarios` -- `POST /__admin/reset` -- `GET /__admin/requests` -- Scripted status-error variants -- Scripted quota/content-filter variants -- Scripted `Retry-After` -- Scripted delayed first byte -- Scripted hang forever -- Scripted partial stream then close -- Scripted malformed or truncated SSE - -Explicit exclusions per the agreed strategy: -- Any downstream-consumer black-box tests, fixtures, scripts, CI jobs, or docs. -- Any OpenAI endpoint outside the documented supported phase-one matrix. -- Undocumented compatibility quirks not captured by the official docs or the optional live differential suite. -- Production-scale performance benchmarking; only generous local guardrail timing assertions are included. - -Risk carried by exclusions: -- Generic client compatibility outside the documented matrix may still drift until exercised by the optional differential suite. -- Consumers relying on undocumented OpenAI edge behavior may discover gaps that this repository intentionally does not claim to support. diff --git a/test/twin/openai/docs/plans/2026-03-27-async-rust-fake-openai.md b/test/twin/openai/docs/plans/2026-03-27-async-rust-fake-openai.md deleted file mode 100644 index 3de019316..000000000 --- a/test/twin/openai/docs/plans/2026-03-27-async-rust-fake-openai.md +++ /dev/null @@ -1,579 +0,0 @@ -# Async Rust Fake OpenAI Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a standalone async Rust HTTP service that is OpenAI-compatible for the supported endpoints, deterministic without using a real LLM, and able to script protocol and transport failures for black-box end-to-end testing. - -**Architecture:** Implement a single `tokio` + `axum` server with one canonical internal turn/stream model that feeds both `/v1/responses` and `/v1/chat/completions`. Drive behavior through a neutral in-memory scenario engine plus admin control endpoints so success and failure paths are deterministic, consumer-agnostic, and identical across streaming and non-streaming transports. - -**Tech Stack:** Rust, Tokio, Axum, Hyper, Serde, Reqwest, futures-util, uuid, tracing, anyhow/thiserror. - ---- - -## Scope and Product Contract - -This repository stays consumer-agnostic. It must not mention, import, script against, or test against any downstream application. The product is the fake OpenAI server itself. - -The supported steady-state surface should be: - -- `POST /v1/responses` -- `POST /v1/chat/completions` -- `GET /healthz` -- `POST /__admin/scenarios` -- `POST /__admin/reset` -- `GET /__admin/requests` - -The service should require a bearer token for `/v1/*` routes and ignore its value beyond presence, so generic clients can use any non-empty API key. The admin routes should be intentionally local-test-focused and unauthenticated by default. - -Within those two OpenAI endpoints, phase-one compatibility must explicitly cover the request features this fake is expected to handle in practice: bearer auth, optional `OpenAI-Organization` and `OpenAI-Project` headers, `stream`, tools, `tool_choice`, `stop`, `metadata`, `previous_response_id`, reasoning requests, text response formats (`text`, `json_object`, `json_schema` subset), and image inputs. Anything outside that matrix must fail clearly and be documented as unsupported. - -### User-visible behavior - -- Non-streaming endpoints return valid OpenAI-shaped JSON with deterministic IDs, timestamps, usage objects, and output items. -- Streaming endpoints emit valid SSE with flush-per-event behavior and proper terminal completion semantics for the supported endpoint. -- If a scenario is preloaded through the admin API, the next matching OpenAI request consumes that scenario exactly once and responds according to its script. -- If no scripted scenario matches, the server falls back to a documented deterministic default behavior so the fake remains usable without setup. -- Tool-call and continuation flows are supported through the canonical internal turn model rather than endpoint-specific hacks. -- Failure injection can simulate application errors and transport failures including hangs, delayed first byte, partial stream then close, and malformed/truncated stream bodies. - -### Required invariants - -- The same internal response plan drives both stream and non-stream rendering for a given request. -- The same internal response plan drives both `/v1/responses` and `/v1/chat/completions` when the underlying behavior is equivalent. -- Scenario matching and consumption are deterministic and thread-safe. -- Request logs are append-only during a test run and resettable through admin control. -- Unsupported request shapes fail explicitly with stable error JSON; they must not silently degrade into a misleading success. -- No code or docs in this repository may reference downstream projects or embed downstream-specific fixtures. - -## Strategy Gate - -The clean path is to treat this as a server product, not a pile of endpoint stubs. The central design decision is to introduce a canonical internal "response plan" that represents output text, tool calls, usage, and failure timing once, then render it into each OpenAI surface. That avoids the most likely long-term bug: streaming and non-streaming behavior drifting apart, or `/v1/responses` and `/v1/chat/completions` diverging because they were implemented separately. - -The other key decision is to build a neutral admin control plane instead of encoding behavior in magic prompts or downstream-specific conventions. A scripted scenario queue is more explicit, easier to test, and robust enough to drive hangs, truncation, and delayed chunks without polluting the OpenAI-compatible surface. - -## File Structure - -Create and own the code with these boundaries: - -- `Cargo.toml`: crate metadata and dependencies. -- `src/lib.rs`: public server bootstrap API for tests and the binary. -- `src/main.rs`: runtime entrypoint and environment-based config loading. -- `src/config.rs`: bind address, auth mode, and admin-route configuration. -- `src/app.rs`: router construction and shared state wiring. -- `src/state.rs`: top-level application state and synchronization primitives. -- `src/openai/mod.rs`: route registration and shared endpoint helpers. -- `src/openai/auth.rs`: bearer-token enforcement for `/v1/*`. -- `src/openai/models.rs`: serde request/response models and shared validation helpers. -- `src/openai/responses.rs`: `/v1/responses` handler and renderer adapter. -- `src/openai/chat_completions.rs`: `/v1/chat/completions` handler and renderer adapter. -- `src/engine/mod.rs`: orchestration entrypoint from HTTP requests into deterministic execution. -- `src/engine/scenario.rs`: scenario definition, matchers, and one-shot consumption rules. -- `src/engine/defaults.rs`: deterministic fallback behavior when no scenario matches. -- `src/engine/plan.rs`: canonical internal response plan and stream event plan. -- `src/engine/failures.rs`: modeled transport/application failure behaviors and timing. -- `src/admin.rs`: admin routes for scenario load/reset/request-log retrieval. -- `src/logs.rs`: request-log structures and admin serialization. -- `src/sse.rs`: SSE encoding, chunk flushing, and stream completion helpers. -- `tests/common/mod.rs`: ephemeral server harness, admin helpers, and HTTP/SSE client helpers. -- `tests/health_and_auth.rs`: healthcheck plus auth and malformed-request coverage. -- `tests/responses_contract.rs`: `/v1/responses` success and validation cases. -- `tests/chat_completions_contract.rs`: `/v1/chat/completions` success and validation cases. -- `tests/tool_and_schema_contract.rs`: tool calls, continuation, and structured output cases. -- `tests/failure_modes.rs`: status-code errors, retry headers, hangs, partial streams, truncation, and malformed SSE. -- `README.md`: generic usage, supported surface, and local run instructions. -- `docs/compatibility-matrix.md`: explicit supported request fields, unsupported fields, and failure-model capabilities. - -## Contracts and Boundaries To Lock Down Before Coding - -1. Canonical plan model - - Represent one request result as a `ResponsePlan`. - - Include output text segments, tool calls, optional structured JSON payload, usage data, delays, and terminal status. - - Make stream rendering a pure projection of `ResponsePlan`, never a separate business path. - -2. Scenario scripting model - - A scenario should contain a matcher and a response script. - - Matchers should support endpoint, model name, stream flag, and optional metadata tags or request-substring checks. - - Scripts should support: success payload, OpenAI-style error response, delay before headers, delay between chunks, hang forever, close after N chunks, and malformed/truncated final body. - - Consumption should be FIFO among matching scenarios so tests can script multi-call flows deterministically. - -3. Deterministic default behavior - - The fallback path should not require admin setup. - - Default text behavior: extract user text inputs/messages, normalize whitespace, and return a stable synthetic summary/echo form. - - Default tool behavior: do not infer tool calls from arbitrary prompts; only emit tool calls when a scripted scenario requests them. - - Default structured-output behavior: if a supported `json_schema` response format is requested, return deterministic JSON matching the schema only for the supported primitive/object subset; reject unsupported schema constructs explicitly. - -4. Endpoint compatibility - - `/v1/responses` must support both stream and non-stream create flows, including continuation input items such as tool outputs when present. - - `/v1/chat/completions` must support both stream and non-stream chat flows using the same canonical plan. - - `/v1/responses` must accept optional `previous_response_id`, `metadata`, `stop`, reasoning requests, and image inputs without requiring downstream-specific behavior. - - `/v1/chat/completions` must accept `tools`, `tool_choice`, `response_format`, `stop`, and assistant reasoning content in both stream and non-stream modes. - - Request validation should reject unsupported combinations with stable OpenAI-shaped error JSON rather than silently ignoring them. - -5. Transport-failure mechanics - - Use real async streaming bodies so timing and truncation are observable over actual sockets. - - Guard hang tests with explicit timeouts in the test harness. - - Keep low-level transport failure code isolated in `engine/failures.rs` and `sse.rs`; handlers should describe failures, not hand-roll socket behavior. - -## Task 1: Bootstrap the async Rust service skeleton - -**Files:** -- Create: `Cargo.toml` -- Create: `src/lib.rs` -- Create: `src/main.rs` -- Create: `src/config.rs` -- Create: `src/app.rs` -- Create: `src/state.rs` -- Create: `src/admin.rs` -- Create: `src/openai/mod.rs` -- Create: `src/openai/auth.rs` -- Create: `tests/common/mod.rs` -- Create: `tests/health_and_auth.rs` -- Create: `README.md` - -- [ ] **Step 1: Identify or write the failing test** - -Write integration tests that prove the server boots, `GET /healthz` returns `200`, `/v1/*` rejects missing bearer auth, and a syntactically valid authenticated request currently fails because the endpoint handlers are not implemented yet. - -```rust -#[tokio::test] -async fn healthz_is_available() { /* ... */ } - -#[tokio::test] -async fn responses_requires_bearer_auth() { /* ... */ } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test healthz_is_available -- --exact` -Expected: FAIL because the crate and test harness do not exist yet. - -- [ ] **Step 3: Write minimal implementation** - -Create the crate, wire `tokio` + `axum`, add application state, implement `GET /healthz`, add auth middleware for `/v1/*`, expose an app-construction function from `src/lib.rs`, and add a minimal `README.md` that describes the service generically. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cargo test healthz_is_available responses_requires_bearer_auth -- --exact` -Expected: PASS - -- [ ] **Step 5: Refactor and verify** - -Tighten config loading and router composition, then run the targeted tests and the current full suite. - -Run: `cargo test` -Run: `cargo fmt --check` -Run: `cargo clippy --all-targets -- -D warnings` -Expected: all PASS - -- [ ] **Step 6: Commit** - -```bash -git add Cargo.toml src tests README.md -git commit -m "feat: bootstrap async rust fake openai server" -``` - -## Task 2: Define request models and the canonical response-plan engine - -**Files:** -- Create: `src/openai/models.rs` -- Create: `src/engine/mod.rs` -- Create: `src/engine/plan.rs` -- Create: `src/engine/defaults.rs` -- Modify: `src/openai/mod.rs` -- Modify: `src/state.rs` -- Create: `tests/responses_contract.rs` - -- [ ] **Step 1: Identify or write the failing test** - -Write `/v1/responses` contract tests for authenticated non-streaming requests that should return deterministic text, stable `response.id` formatting, output items, usage fields, and acceptance of the request fields the service must support generically: `metadata`, `stop`, `previous_response_id`, optional org/project headers, and image inputs. - -```rust -#[tokio::test] -async fn responses_create_returns_deterministic_non_stream_payload() { /* ... */ } - -#[tokio::test] -async fn responses_accepts_supported_openai_request_fields() { /* ... */ } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test responses_create_returns_deterministic_non_stream_payload -- --exact` -Expected: FAIL with `404` or unimplemented handler. - -- [ ] **Step 3: Write minimal implementation** - -Add serde request/response models for the supported `/v1/responses` subset, including `metadata`, `stop`, `previous_response_id`, reasoning request fields, and image inputs. Implement `ResponsePlan`, deterministic fallback extraction of user text input, stable IDs/timestamps per response, and a valid non-stream JSON payload from a real handler. Accept org/project headers as inert compatibility inputs rather than rejecting them. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cargo test responses_create_returns_deterministic_non_stream_payload -- --exact` -Expected: PASS - -- [ ] **Step 5: Refactor and verify** - -Move validation and fallback rendering into focused engine modules so handlers stay thin, then run targeted checks plus the broader suite. - -Run: `cargo test responses_create_returns_deterministic_non_stream_payload -- --exact` -Run: `cargo test` -Run: `cargo fmt --check` -Run: `cargo clippy --all-targets -- -D warnings` -Expected: all PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/openai/models.rs src/engine src/openai/mod.rs src/state.rs tests/responses_contract.rs -git commit -m "feat: add canonical response plan and responses api baseline" -``` - -## Task 3: Add streaming for `/v1/responses` from the canonical plan - -**Files:** -- Create: `src/sse.rs` -- Modify: `src/openai/responses.rs` -- Modify: `src/engine/plan.rs` -- Modify: `src/engine/defaults.rs` -- Modify: `tests/common/mod.rs` -- Modify: `tests/responses_contract.rs` - -- [ ] **Step 1: Identify or write the failing test** - -Extend the `/v1/responses` tests to assert valid SSE framing, ordered events, flush behavior, and terminal completion for `stream=true`, including reasoning deltas and tool-call-related event sequences when the canonical plan contains them. - -```rust -#[tokio::test] -async fn responses_stream_emits_expected_sse_sequence() { /* ... */ } - -#[tokio::test] -async fn responses_stream_emits_reasoning_and_completion_events() { /* ... */ } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test responses_stream_emits_expected_sse_sequence -- --exact` -Expected: FAIL because streaming is not implemented or event order is wrong. - -- [ ] **Step 3: Write minimal implementation** - -Add a streaming renderer that projects `ResponsePlan` into SSE events, including terminal completion, reasoning events, and tool-call-related events required by the supported `/v1/responses` subset. Keep the non-stream and stream paths backed by the same `ResponsePlan` instance so content does not drift. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cargo test responses_stream_emits_expected_sse_sequence -- --exact` -Expected: PASS - -- [ ] **Step 5: Refactor and verify** - -Extract reusable SSE helpers and add assertions that the streamed text and non-stream text are semantically identical for the same request. - -Run: `cargo test responses_stream_emits_expected_sse_sequence -- --exact` -Run: `cargo test responses_create_returns_deterministic_non_stream_payload -- --exact` -Run: `cargo test` -Run: `cargo fmt --check` -Run: `cargo clippy --all-targets -- -D warnings` -Expected: all PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/sse.rs src/openai/responses.rs src/engine tests/common/mod.rs tests/responses_contract.rs -git commit -m "feat: stream responses api events from canonical plans" -``` - -## Task 4: Add scenario scripting and request logging through the admin API - -**Files:** -- Create: `src/engine/scenario.rs` -- Create: `src/logs.rs` -- Modify: `src/admin.rs` -- Modify: `src/state.rs` -- Modify: `src/engine/mod.rs` -- Modify: `src/engine/defaults.rs` -- Create: `tests/tool_and_schema_contract.rs` - -- [ ] **Step 1: Identify or write the failing test** - -Write admin tests that preload one-shot scenarios, verify that matching OpenAI requests consume them in FIFO order, and verify that `GET /__admin/requests` returns normalized request logs. - -```rust -#[tokio::test] -async fn admin_loaded_scenarios_are_consumed_fifo() { /* ... */ } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test admin_loaded_scenarios_are_consumed_fifo -- --exact` -Expected: FAIL because admin scripting and logs do not exist yet. - -- [ ] **Step 3: Write minimal implementation** - -Implement scenario definitions, matcher evaluation, atomic one-shot consumption, request logging, `/__admin/scenarios`, `/__admin/reset`, and `/__admin/requests`. Make matching generic: endpoint, model, stream flag, metadata tags, and text-substring checks are enough for phase one. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cargo test admin_loaded_scenarios_are_consumed_fifo -- --exact` -Expected: PASS - -- [ ] **Step 5: Refactor and verify** - -Ensure the admin wire format is stable and documented, then re-run the targeted checks and full suite. - -Run: `cargo test admin_loaded_scenarios_are_consumed_fifo -- --exact` -Run: `cargo test` -Run: `cargo fmt --check` -Run: `cargo clippy --all-targets -- -D warnings` -Expected: all PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/engine/scenario.rs src/logs.rs src/admin.rs src/state.rs src/engine tests/tool_and_schema_contract.rs -git commit -m "feat: add scenario scripting and request log admin api" -``` - -## Task 5: Implement tool calls, continuation, and structured outputs - -**Files:** -- Modify: `src/openai/models.rs` -- Modify: `src/openai/responses.rs` -- Modify: `src/engine/plan.rs` -- Modify: `src/engine/scenario.rs` -- Modify: `src/engine/defaults.rs` -- Modify: `tests/tool_and_schema_contract.rs` -- Create: `docs/compatibility-matrix.md` - -- [ ] **Step 1: Identify or write the failing test** - -Add tests for: - -- scripted tool-call output on `/v1/responses` -- continuation input that includes tool output items -- reasoning-bearing assistant turns and `previous_response_id` continuation acceptance -- deterministic `json_schema` structured output for the supported subset -- deterministic `json_object` output for the simpler structured-output mode -- explicit rejection of unsupported schema constructs - -```rust -#[tokio::test] -async fn responses_supports_scripted_tool_call_and_continuation() { /* ... */ } - -#[tokio::test] -async fn responses_structured_output_support_is_explicit() { /* ... */ } - -#[tokio::test] -async fn responses_reasoning_and_continuation_fields_round_trip() { /* ... */ } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test responses_supports_scripted_tool_call_and_continuation -- --exact` -Run: `cargo test responses_structured_output_support_is_explicit -- --exact` -Expected: FAIL because tool and schema flows are incomplete. - -- [ ] **Step 3: Write minimal implementation** - -Extend the canonical plan model to carry tool calls, reasoning segments, and structured JSON outputs. Support scripted tool-call emissions and continuation inputs on `/v1/responses`, including `function_call_output` items and optional `previous_response_id`. Implement explicit `json_object` support plus `json_schema` support for a documented subset only, and reject everything else with stable OpenAI-shaped errors. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cargo test responses_supports_scripted_tool_call_and_continuation -- --exact` -Run: `cargo test responses_structured_output_support_is_explicit -- --exact` -Expected: PASS - -- [ ] **Step 5: Refactor and verify** - -Update the compatibility matrix so supported and unsupported fields are unambiguous. Then re-run all response and tool tests plus the full suite. - -Run: `cargo test responses_supports_scripted_tool_call_and_continuation -- --exact` -Run: `cargo test responses_structured_output_support_is_explicit -- --exact` -Run: `cargo test` -Run: `cargo fmt --check` -Run: `cargo clippy --all-targets -- -D warnings` -Expected: all PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/openai/models.rs src/openai/responses.rs src/engine docs/compatibility-matrix.md tests/tool_and_schema_contract.rs -git commit -m "feat: support scripted tool calls and structured outputs" -``` - -## Task 6: Add `/v1/chat/completions` on the same engine - -**Files:** -- Create: `src/openai/chat_completions.rs` -- Modify: `src/openai/mod.rs` -- Modify: `src/openai/models.rs` -- Modify: `src/engine/plan.rs` -- Create: `tests/chat_completions_contract.rs` - -- [ ] **Step 1: Identify or write the failing test** - -Write non-stream and stream contract tests for `/v1/chat/completions` proving that the same scenario/default behavior can be rendered into chat-completion JSON and delta SSE events while accepting the supported request features for this endpoint: tools, `tool_choice`, `response_format`, `stop`, and reasoning content. - -```rust -#[tokio::test] -async fn chat_completions_non_stream_uses_same_canonical_plan() { /* ... */ } - -#[tokio::test] -async fn chat_completions_stream_uses_same_canonical_plan() { /* ... */ } - -#[tokio::test] -async fn chat_completions_accepts_supported_openai_compatible_fields() { /* ... */ } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test chat_completions_non_stream_uses_same_canonical_plan -- --exact` -Run: `cargo test chat_completions_stream_uses_same_canonical_plan -- --exact` -Expected: FAIL because the endpoint is not implemented yet. - -- [ ] **Step 3: Write minimal implementation** - -Add request/response models for the supported chat-completions subset and render chat success and stream deltas from the existing canonical plan instead of adding a second behavior engine. Ensure tool calls, `tool_choice`, `response_format`, `stop`, and assistant reasoning content are accepted and rendered consistently between stream and non-stream paths. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cargo test chat_completions_non_stream_uses_same_canonical_plan -- --exact` -Run: `cargo test chat_completions_stream_uses_same_canonical_plan -- --exact` -Expected: PASS - -- [ ] **Step 5: Refactor and verify** - -Eliminate duplication between the endpoint adapters and verify that cross-endpoint equivalence holds where expected. - -Run: `cargo test chat_completions_non_stream_uses_same_canonical_plan -- --exact` -Run: `cargo test chat_completions_stream_uses_same_canonical_plan -- --exact` -Run: `cargo test` -Run: `cargo fmt --check` -Run: `cargo clippy --all-targets -- -D warnings` -Expected: all PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/openai/chat_completions.rs src/openai/mod.rs src/openai/models.rs src/engine/plan.rs tests/chat_completions_contract.rs -git commit -m "feat: add chat completions compatibility surface" -``` - -## Task 7: Implement application and transport failure injection - -**Files:** -- Create: `src/engine/failures.rs` -- Modify: `src/engine/scenario.rs` -- Modify: `src/admin.rs` -- Modify: `src/sse.rs` -- Modify: `tests/common/mod.rs` -- Create: `tests/failure_modes.rs` -- Modify: `docs/compatibility-matrix.md` -- Modify: `README.md` - -- [ ] **Step 1: Identify or write the failing test** - -Add real-socket tests for: - -- OpenAI-shaped JSON errors with status `400`, `401`, `403`, `404`, `408`, `413`, `429`, `500`, `502`, `503`, `504` -- quota-exceeded and content-filter error bodies that classify differently from generic invalid requests -- `Retry-After` propagation on scripted rate limits -- delayed first byte -- hang forever guarded by timeout -- partial SSE then close -- malformed/truncated SSE body - -```rust -#[tokio::test] -async fn scripted_hang_times_out_client_side() { /* ... */ } - -#[tokio::test] -async fn scripted_partial_stream_then_close_is_observable() { /* ... */ } - -#[tokio::test] -async fn scripted_budget_and_content_filter_errors_are_distinct() { /* ... */ } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test scripted_hang_times_out_client_side -- --exact` -Run: `cargo test scripted_partial_stream_then_close_is_observable -- --exact` -Expected: FAIL because failure injection is not implemented yet. - -- [ ] **Step 3: Write minimal implementation** - -Implement failure scripts for status errors, quota/content-filter error variants, header delays, inter-event delays, hangs, close-after-N-chunks, and malformed/truncated stream endings. Support explicit `Retry-After` control for rate-limit scenarios. Keep these paths data-driven through scenarios rather than hard-coded test hooks. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cargo test scripted_hang_times_out_client_side -- --exact` -Run: `cargo test scripted_partial_stream_then_close_is_observable -- --exact` -Expected: PASS - -- [ ] **Step 5: Refactor and verify** - -Make failure behavior documentation explicit and re-run the full suite to ensure the low-level transport code has not broken happy paths. - -Run: `cargo test` -Run: `cargo fmt --check` -Run: `cargo clippy --all-targets -- -D warnings` -Expected: all PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/engine/failures.rs src/engine/scenario.rs src/admin.rs src/sse.rs tests/common/mod.rs tests/failure_modes.rs docs/compatibility-matrix.md README.md -git commit -m "feat: add scripted application and transport failures" -``` - -## Task 8: Final polish, docs, and whole-project verification - -**Files:** -- Modify: `README.md` -- Modify: `docs/compatibility-matrix.md` -- Modify: any touched source or tests needed for final cleanup - -- [ ] **Step 1: Identify or write the failing test** - -Identify any remaining gaps from the final full-suite pass. If no behavioral gap remains, treat the failing check as documentation incompleteness: make sure the README explains local startup, admin scripting, and the supported compatibility matrix clearly enough that a new engineer can run it without guesswork. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test` -Run: `cargo fmt --check` -Run: `cargo clippy --all-targets -- -D warnings` -Expected: any remaining issues are real and concrete; if all pass, proceed directly to Step 3 as a docs-and-cleanup pass. - -- [ ] **Step 3: Write minimal implementation** - -Resolve any final defects, tighten docs, confirm unsupported behaviors are explicitly documented, and remove dead code or duplicated helpers introduced during the build-out. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cargo test` -Run: `cargo fmt --check` -Run: `cargo clippy --all-targets -- -D warnings` -Expected: all PASS - -- [ ] **Step 5: Refactor and verify** - -Run one final end-to-end verification pass on the complete repository. - -Run: `cargo test` -Run: `cargo fmt --check` -Run: `cargo clippy --all-targets -- -D warnings` -Expected: all PASS - -- [ ] **Step 6: Commit** - -```bash -git add README.md docs/compatibility-matrix.md src tests -git commit -m "docs: finalize fake openai compatibility and usage guidance" -``` - -## Execution Notes - -- Prefer small focused modules over large endpoint files; the canonical plan is the seam that keeps the codebase understandable. -- Do not add persistence, databases, or external queues. In-memory state is the correct steady-state for deterministic local black-box testing. -- Do not implement undocumented endpoint variants "just in case." Add only the explicitly documented supported surface and fail clearly elsewhere. -- Keep the compatibility matrix concrete. The implementation is only done when the docs and tests name the exact supported request fields and failure scripts for both `/v1/responses` and `/v1/chat/completions`. -- For malformed/truncated stream tests, use lower-level response reading when `reqwest` normalizes away the exact transport symptom. -- Keep timestamps and IDs deterministic enough for assertions without freezing the entire clock globally; injecting a clock/ID generator through state is the clean path. -- Never weaken a valid test to get green. If a transport failure test flakes, fix the timing/control mechanism rather than loosening the assertion into uselessness. diff --git a/test/twin/openai/docs/plans/2026-03-28-debug-web-ui-test-plan.md b/test/twin/openai/docs/plans/2026-03-28-debug-web-ui-test-plan.md deleted file mode 100644 index a8f46f236..000000000 --- a/test/twin/openai/docs/plans/2026-03-28-debug-web-ui-test-plan.md +++ /dev/null @@ -1,154 +0,0 @@ -# Debug Web UI Test Plan - -The agreed testing strategy holds after reconciling it with the implementation plan. The plan adds two new routes (`GET /__debug` and `GET /__debug/state.json`) gated behind the existing `enable_admin` config flag, plus new snapshot types in `src/state.rs` and a `script_kind()` accessor on `ScenarioScript`. No strategy change requiring user approval was identified. - -One clarification: the strategy calls for "3-4 HTTP integration tests against the debug endpoint." After reviewing the implementation plan, the highest-value integration tests are: (1) empty-state HTML page serves correctly, (2) JSON state endpoint returns correct snapshot after scenarios are loaded, (3) HTML page reflects loaded scenarios and request logs, and (4) debug routes are hidden when `enable_admin` is false. That is exactly 4 tests. The headless Chrome screenshot and HTML safety check round out the plan at 6 new tests, plus the 50-test regression gate. - -## Harness requirements - -1. **Ephemeral server harness** (existing: `tests/common/mod.rs`) - - What it does: boots the real `tokio` + `axum` server on an ephemeral localhost port with `enable_admin: true` and `require_auth: true`, exposing `base_url`, authenticated client, and unauthenticated client. - - Tests depending on it: 1 through 6. - -2. **Unauthenticated HTTP client** (existing on `TestServer`) - - What it does: the `TestServer.client` field is an unauthenticated `reqwest::Client` used to hit routes that do not require bearer auth (like `/__debug`). No new helper needed. - - Tests depending on it: 1, 2, 3, 4. - -3. **Admin scenario harness** (existing on `TestServer`) - - What it does: scripts server state via `enqueue_scenarios()`, drives OpenAI requests via `post_responses()`, and fetches request logs via `request_logs()`. - - Tests depending on it: 2, 3. - -4. **Headless Chrome harness** (new, lightweight) - - What it does: launches headless Chrome/Chromium via `std::process::Command` to capture a screenshot of the debug page. Falls back to skip if no Chrome binary is found. - - What it exposes: a PNG file written to a temp path; assertion that the file is non-empty and is a valid PNG (starts with the PNG magic bytes). - - Estimated complexity: low. No new crate; uses `chromium --headless --screenshot` CLI. - - Tests depending on it: 5. - -## Test plan - -### 1. Name: debug HTML page serves valid HTML with correct content-type on empty state - -- **Type**: integration -- **Disposition**: new -- **Harness**: Ephemeral server harness -- **Preconditions**: server is running with `enable_admin: true`; no scenarios loaded; no requests made. -- **Actions**: - - `GET /__debug` using the unauthenticated client. -- **Expected outcome**: - - HTTP status is `200`. - - `content-type` header contains `text/html`. - - Response body contains `` (well-formed HTML document). - - Response body contains the page title text `twin-openai` and `debug`. - - Response body contains the empty-state marker text `no active namespaces` (since no scenarios have been loaded and no requests have been made). - - Source of truth: implementation plan (route `GET /__debug`, HTML rendering with empty state indicator). -- **Interactions**: axum routing, `enable_admin` gate, `debug_snapshot()`, `render_html()`. - -### 2. Name: debug JSON endpoint returns correct state snapshot after scenarios are loaded and requests are made - -- **Type**: integration -- **Disposition**: new -- **Harness**: Ephemeral server harness, Admin scenario harness -- **Preconditions**: server is running with `enable_admin: true`. -- **Actions**: - 1. Load two scenarios via `POST /__admin/scenarios` with bearer auth: one `success` script matching `responses` endpoint with model `gpt-test`, and one `error` script matching `responses` endpoint with model `gpt-error`. - 2. Make one `POST /v1/responses` request with bearer auth, model `gpt-test`, input `"hello debug"`, `stream: false` -- this consumes the first scenario and logs a request. - 3. `GET /__debug/state.json` using the unauthenticated client. -- **Expected outcome**: - - HTTP status is `200`. - - `content-type` header contains `application/json`. - - Response body parses as JSON with a top-level `namespaces` array. - - The namespace array contains exactly one entry whose `key` field starts with `"Bearer:"`. - - That namespace's `scenarios` array has exactly 1 remaining scenario (the `error` script; the `success` script was consumed). - - The remaining scenario has `endpoint: "responses"`, `model: "gpt-error"`, `script_kind: "error"`. - - That namespace's `request_logs` array has exactly 1 entry with `endpoint: "responses"`, `model: "gpt-test"`, `input_text` containing `"hello debug"`. - - Source of truth: implementation plan (JSON API shape, `DebugSnapshot` / `NamespaceSnapshot` / `ScenarioSnapshot` structs, FIFO consumption model). -- **Interactions**: admin scenario loading, OpenAI responses endpoint, `debug_snapshot()`, JSON serialization. - -### 3. Name: debug HTML page reflects loaded scenarios and request logs in rendered output - -- **Type**: integration -- **Disposition**: new -- **Harness**: Ephemeral server harness, Admin scenario harness -- **Preconditions**: server is running with `enable_admin: true`. -- **Actions**: - 1. Load one `success` scenario via `POST /__admin/scenarios` with bearer auth, matching `responses` endpoint, model `gpt-html-test`. - 2. Make one `POST /v1/responses` request with bearer auth, model `gpt-other`, input `"check the page"`, `stream: false` (default behavior, does not consume the scenario because model does not match). - 3. `GET /__debug` using the unauthenticated client. -- **Expected outcome**: - - HTTP status is `200`. - - Response body contains the scenario's model name `gpt-html-test` in the rendered HTML (proving scenarios appear). - - Response body contains the text `success` (the `script_kind` of the loaded scenario). - - Response body contains the text `gpt-other` (the model from the request log). - - Response body contains `check the page` (the input text from the request log). - - Response body does NOT contain `no active namespaces` (because there is at least one namespace). - - Source of truth: implementation plan (HTML page structure showing scenarios table and request log table per namespace). -- **Interactions**: admin scenario loading, OpenAI responses endpoint, `debug_snapshot()`, `render_html()`, HTML escaping. - -### 4. Name: debug routes are not accessible when enable_admin is false - -- **Type**: integration -- **Disposition**: new -- **Harness**: Custom server setup (not the default `spawn_server`, which uses `enable_admin: true`) -- **Preconditions**: server is started with `enable_admin: false` (construct `Config` directly and call `build_app_with_config`). -- **Actions**: - - `GET /__debug` using an unauthenticated client. - - `GET /__debug/state.json` using an unauthenticated client. -- **Expected outcome**: - - Both requests return HTTP `404` (the routes are not registered when admin is disabled). - - Source of truth: implementation plan (`debug_ui::router()` is only merged when `enable_admin` is true, same as `admin::router()`). -- **Interactions**: `app::router()` conditional routing, config flag. - -### 5. Name: debug page renders visually in headless Chrome and produces a non-empty screenshot - -- **Type**: scenario -- **Disposition**: new -- **Harness**: Ephemeral server harness, Headless Chrome harness -- **Preconditions**: server is running with `enable_admin: true`; headless Chrome/Chromium is available on PATH. Test is skipped if Chrome is not found. -- **Actions**: - 1. Load one scenario and make one request (to populate state for a non-trivial render). - 2. Run headless Chrome: `chromium --headless --disable-gpu --screenshot=/tmp/.png --window-size=1280,900 /__debug` (or `google-chrome` / `chromium-browser` depending on platform). - 3. Read the output PNG file. -- **Expected outcome**: - - The Chrome process exits with code 0. - - The screenshot file exists and is at least 10 KB (a non-trivial rendered page, not a blank screen). - - The first 8 bytes of the file match the PNG magic number (`\x89PNG\r\n\x1a\n`). - - Source of truth: agreed testing strategy (headless Chrome screenshot capture, 1 test case). -- **Interactions**: full server stack, HTML rendering, CSS rendering in a real browser engine. - -### 6. Name: HTML output escapes user-controlled values to prevent injection - -- **Type**: unit -- **Disposition**: new -- **Harness**: Ephemeral server harness, Admin scenario harness -- **Preconditions**: server is running with `enable_admin: true`. -- **Actions**: - 1. Load a scenario via `POST /__admin/scenarios` where the model field contains an HTML injection attempt: ``. - 2. `GET /__debug` using the unauthenticated client. -- **Expected outcome**: - - HTTP status is `200`. - - Response body contains the escaped form `<script>` (proving the `escape_html` function is applied). - - Response body does NOT contain the literal unescaped string `\n\n"); - - html -} - -fn render_content(html: &mut String, namespaces: &[NamespaceSnapshot]) { - if namespaces.is_empty() { - html.push_str("

(no active namespaces)

\n"); - return; - } - - for ns in namespaces { - let _ = write!( - html, - "
\n

{}

\n", - escape_html(&ns.key) - ); - - // Queued scenarios - html.push_str("

queued scenarios

\n"); - render_scenarios_table(html, &ns.scenarios); - - // Request log - html.push_str("

request log

\n"); - render_requests_table(html, &ns.request_logs); - - html.push_str("
\n"); - } -} - -fn render_scenarios_table(html: &mut String, scenarios: &[ScenarioSnapshot]) { - if scenarios.is_empty() { - html.push_str("

(no queued scenarios)

\n"); - return; - } - - html.push_str( - " \n \n \n \n \n", - ); - - for (i, s) in scenarios.iter().enumerate() { - let model = s - .model - .as_deref() - .map_or_else(|| "--".to_owned(), escape_html); - let stream = match s.stream { - Some(v) => format!("{v}"), - None => "--".to_owned(), - }; - let input_contains = s - .input_contains - .as_deref() - .map_or_else(|| "--".to_owned(), escape_html); - - let _ = writeln!( - html, - " ", - i + 1, - escape_html(&s.endpoint), - model, - stream, - input_contains, - escape_html(&s.script_kind), - ); - } - - html.push_str(" \n
#endpointmodelstreaminput_containsscript
{}{}{}{}{}{}
\n"); -} - -fn render_requests_table(html: &mut String, logs: &[RequestLog]) { - if logs.is_empty() { - html.push_str("

(no requests logged)

\n"); - return; - } - - html.push_str( - " \n \n \n \n \n", - ); - - for (i, r) in logs.iter().enumerate() { - let meta = serde_json::to_string(&r.metadata).unwrap_or_else(|_| "{}".to_owned()); - let meta_display = if meta == "{}" { - "--".to_owned() - } else { - escape_html(&truncate(&meta, 80)) - }; - - let _ = writeln!( - html, - " ", - i + 1, - escape_html(&r.endpoint), - escape_html(&r.model), - r.stream, - escape_html(&truncate(&r.input_text, 120)), - meta_display, - ); - } - - html.push_str(" \n
#endpointmodelstreaminput textmetadata
{}{}{}{}{}{}
\n"); -} - -const CSS: &str = r#" -:root { - --bg: #0a0a0a; - --bg-row: #111; - --bg-row-alt: #0d0d0d; - --bg-hover: #1a1a0a; - --text: #ccc; - --text-bright: #00ff41; - --text-heading: #ffb000; - --text-dim: #666; - --text-error: #ff4444; - --border: #333; - --border-bright: #555; -} - -body { - background: var(--bg); - color: var(--text); - font-family: "IBM Plex Mono", "Fira Code", "Cascadia Code", monospace; - margin: 0; - padding: 24px; -} - -header { - display: flex; - align-items: center; - justify-content: space-between; -} - -h1 { - color: var(--text-heading); - text-transform: uppercase; - letter-spacing: 0.2em; - font-size: 1.1rem; - margin: 0; -} - -h1 .dim { - color: var(--text-dim); -} - -h2.namespace-header { - color: var(--text-heading); - border-top: 2px solid var(--border-bright); - padding-top: 16px; - margin-top: 32px; -} - -h3 { - color: var(--text-dim); - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 0.15em; -} - -table { - width: 100%; - border-collapse: collapse; - border: 2px solid var(--border); -} - -th { - color: var(--text-heading); - text-transform: uppercase; - font-size: 0.7rem; - letter-spacing: 0.1em; - padding: 6px 10px; - text-align: left; - border-bottom: 2px solid var(--border); -} - -td { - color: var(--text-bright); - padding: 5px 10px; - font-size: 0.85rem; - border-bottom: 1px solid var(--border); -} - -tr:nth-child(even) { - background: var(--bg-row-alt); -} - -tr:hover td { - background: var(--bg-hover); - transition: background 0.15s; -} - -.empty { - color: var(--text-error); - font-style: italic; - padding: 8px 0; -} - -.summary { - color: var(--text-dim); - margin: 12px 0 24px; - font-size: 0.85rem; -} - -.sep { - margin: 0 8px; - color: var(--border-bright); -} - -footer { - color: var(--text-dim); - font-size: 0.75rem; - margin-top: 40px; - border-top: 1px solid var(--border); - padding-top: 12px; -} - -.status { - display: flex; - align-items: center; -} - -.live-label { - color: var(--text-bright); - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 0.1em; -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } -} - -.live-dot { - display: inline-block; - width: 8px; - height: 8px; - background: var(--text-bright); - border-radius: 50%; - animation: pulse 2s ease-in-out infinite; - margin-right: 6px; - vertical-align: middle; -} - -button { - background: transparent; - color: var(--text-dim); - border: 1px solid var(--border); - padding: 2px 10px; - font-family: inherit; - font-size: 0.75rem; - cursor: pointer; - text-transform: uppercase; - letter-spacing: 0.1em; - margin-left: 12px; -} - -button:hover { - color: var(--text-bright); - border-color: var(--text-bright); -} -"#; - -const JS: &str = r#" -async function refresh() { - try { - const res = await fetch('/__debug/state.json'); - const data = await res.json(); - document.getElementById('content').innerHTML = renderState(data); - updateSummary(data); - } catch(e) { /* silent -- next interval will retry */ } -} - -if (new URLSearchParams(window.location.search).get('refresh') !== '0') { - setInterval(refresh, 2000); -} - -function renderState(data) { - if (data.namespaces.length === 0) { - return '

(no active namespaces)

'; - } - return data.namespaces.map(function(ns) { - return '
' - + '

' + esc(ns.key) + '

' - + '

queued scenarios

' - + renderScenariosTable(ns.scenarios) - + '

request log

' - + renderRequestsTable(ns.request_logs) - + '
'; - }).join(''); -} - -function renderScenariosTable(scenarios) { - if (scenarios.length === 0) return '

(no queued scenarios)

'; - var rows = scenarios.map(function(s, i) { - return '' - + '' + (i+1) + '' - + '' + esc(s.endpoint) + '' - + '' + esc(s.model || '--') + '' - + '' + (s.stream === null ? '--' : s.stream) + '' - + '' + esc(s.input_contains || '--') + '' - + '' + esc(s.script_kind) + '' - + ''; - }).join(''); - return '' - + '' - + '' + rows + '
#endpointmodelstreaminput_containsscript
'; -} - -function renderRequestsTable(logs) { - if (logs.length === 0) return '

(no requests logged)

'; - var rows = logs.map(function(r, i) { - var meta = JSON.stringify(r.metadata); - if (meta === '{}') meta = '--'; - return '' - + '' + (i+1) + '' - + '' + esc(r.endpoint) + '' - + '' + esc(r.model) + '' - + '' + r.stream + '' - + '' + esc(trunc(r.input_text, 120)) + '' - + '' + esc(trunc(meta, 80)) + '' - + ''; - }).join(''); - return '' - + '' - + '' + rows + '
#endpointmodelstreaminput textmetadata
'; -} - -function updateSummary(data) { - var sc = 0, rq = 0; - data.namespaces.forEach(function(ns) { - sc += ns.scenarios.length; - rq += ns.request_logs.length; - }); - document.getElementById('ns-count').textContent = data.namespaces.length + ' namespaces'; - document.getElementById('sc-count').textContent = sc + ' queued scenarios'; - document.getElementById('rq-count').textContent = rq + ' logged requests'; -} - -function esc(s) { - return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); -} - -function trunc(s, n) { - return s.length > n ? s.slice(0, n) + '...' : s; -} -"#; diff --git a/test/twin/openai/src/engine/defaults.rs b/test/twin/openai/src/engine/defaults.rs deleted file mode 100644 index 169caf4da..000000000 --- a/test/twin/openai/src/engine/defaults.rs +++ /dev/null @@ -1,113 +0,0 @@ -use serde_json::{Value, json}; - -use super::plan::{ResponsePlan, TokenUsage}; -use crate::openai::models::{ResponseFormat, ResponsesRequest, normalize_whitespace}; - -pub fn build_default_response_plan( - response_number: u64, - request: &ResponsesRequest, -) -> ResponsePlan { - let normalized_text = request.extract_user_text(); - let response_text = format!("deterministic: {normalized_text}"); - let input_tokens = normalized_text.split_whitespace().count() as u64; - - let structured_output = request.response_format().and_then(|format| match format { - ResponseFormat::Text => None, - ResponseFormat::JsonObject => Some(json!({ - "message": response_text, - "model": request.model, - })), - ResponseFormat::JsonSchema(schema) => { - Some(generate_json_from_schema(&schema, &response_text)) - } - }); - let reasoning = if request.reasoning.is_some() { - vec![format!("reasoning: {normalized_text}")] - } else { - Vec::new() - }; - - ResponsePlan { - id: format!("resp_{response_number:06}"), - created: response_number, - model: request.model.clone(), - response_text, - structured_output, - reasoning, - tool_calls: Vec::new(), - usage: TokenUsage::new(input_tokens, 5), - } -} - -pub fn build_default_chat_plan( - response_number: u64, - model: String, - input_text: &str, - response_format: Option, - reasoning_requested: bool, -) -> ResponsePlan { - let normalized_text = normalize_whitespace(input_text); - let response_text = format!("deterministic: {normalized_text}"); - let structured_output = response_format.and_then(|format| match format { - ResponseFormat::Text => None, - ResponseFormat::JsonObject => Some(json!({ - "message": response_text, - "model": model, - })), - ResponseFormat::JsonSchema(schema) => { - Some(generate_json_from_schema(&schema, &response_text)) - } - }); - let reasoning = if reasoning_requested { - vec![format!("reasoning: {normalized_text}")] - } else { - Vec::new() - }; - let input_tokens = normalized_text.split_whitespace().count() as u64; - - ResponsePlan { - id: format!("resp_{response_number:06}"), - created: response_number, - model, - response_text, - structured_output, - reasoning, - tool_calls: Vec::new(), - usage: TokenUsage::new(input_tokens, 5), - } -} - -fn generate_json_from_schema(schema: &Value, response_text: &str) -> Value { - let schema = schema.get("schema").unwrap_or(schema); - - match schema.get("type").and_then(Value::as_str) { - Some("object") => { - let properties = schema - .get("properties") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - - let mut object = serde_json::Map::new(); - for (name, property_schema) in properties { - object.insert( - name, - primitive_value_for_schema(&property_schema, response_text), - ); - } - Value::Object(object) - } - _ => json!({ "message": response_text }), - } -} - -fn primitive_value_for_schema(schema: &Value, response_text: &str) -> Value { - match schema.get("type").and_then(Value::as_str) { - Some("string") => Value::String(response_text.to_owned()), - Some("integer") => json!(1), - Some("number") => json!(1.0), - Some("boolean") => json!(true), - Some("object") => generate_json_from_schema(schema, response_text), - _ => Value::Null, - } -} diff --git a/test/twin/openai/src/engine/failures.rs b/test/twin/openai/src/engine/failures.rs deleted file mode 100644 index 8ad78f60f..000000000 --- a/test/twin/openai/src/engine/failures.rs +++ /dev/null @@ -1,58 +0,0 @@ -use axum::http::StatusCode; - -use super::plan::ResponsePlan; -use crate::openai::models::{ErrorBody, ErrorEnvelope}; - -#[derive(Clone, Copy, Debug, Default)] -pub struct TransportOptions { - pub delay_before_headers_ms: u64, - pub inter_event_delay_ms: u64, - pub close_after_chunks: Option, - pub malformed_sse: bool, -} - -#[derive(Clone, Debug)] -pub struct SuccessOutcome { - pub plan: ResponsePlan, - pub transport: TransportOptions, -} - -#[derive(Clone, Debug)] -pub struct ErrorOutcome { - pub status: StatusCode, - pub body: ErrorEnvelope, - pub retry_after: Option, - pub delay_before_headers_ms: u64, -} - -#[derive(Clone, Debug)] -pub enum ExecutionOutcome { - Success(SuccessOutcome), - Error(ErrorOutcome), - Hang { delay_before_headers_ms: u64 }, -} - -impl ErrorOutcome { - pub fn new( - status: StatusCode, - message: String, - error_type: String, - code: String, - retry_after: Option, - delay_before_headers_ms: u64, - ) -> Self { - Self { - status, - body: ErrorEnvelope { - error: ErrorBody { - message, - error_type, - param: serde_json::Value::Null, - code, - }, - }, - retry_after, - delay_before_headers_ms, - } - } -} diff --git a/test/twin/openai/src/engine/mod.rs b/test/twin/openai/src/engine/mod.rs deleted file mode 100644 index 867735baa..000000000 --- a/test/twin/openai/src/engine/mod.rs +++ /dev/null @@ -1,126 +0,0 @@ -pub mod defaults; -pub mod failures; -pub mod plan; -pub mod scenario; - -use self::defaults::{build_default_chat_plan, build_default_response_plan}; -use self::failures::{ExecutionOutcome, SuccessOutcome, TransportOptions}; -use self::plan::ResponsePlan; -use self::scenario::RequestContext; -use crate::openai::models::{ - ChatCompletionsRequest, OpenAiError, ResponsesRequest, ToolChoiceMode, -}; -use crate::state::{AppState, NamespaceKey}; - -pub fn execute_responses_request( - state: &AppState, - namespace: &NamespaceKey, - request: &ResponsesRequest, -) -> Result { - request.validate()?; - let context = RequestContext { - endpoint: "responses".to_owned(), - model: request.model.clone(), - stream: request.stream, - metadata: request.metadata.clone(), - input_text: request.extract_user_text(), - instructions_text: request.extract_instruction_text(), - }; - state.log_request(namespace, context.clone()); - - if let Some(scenario) = state.take_matching_scenario(namespace, &context) { - return match scenario.execute_for_responses(state.next_response_id(namespace), request) { - ExecutionOutcome::Success(success) => Ok(ExecutionOutcome::Success( - enforce_tool_choice(request.tool_choice_mode(), success)?, - )), - outcome => Ok(outcome), - }; - } - - Ok(ExecutionOutcome::Success(enforce_tool_choice( - request.tool_choice_mode(), - SuccessOutcome { - plan: build_default_response_plan(state.next_response_id(namespace), request), - transport: TransportOptions::default(), - }, - )?)) -} - -pub fn execute_chat_request( - state: &AppState, - namespace: &NamespaceKey, - request: &ChatCompletionsRequest, -) -> Result { - request.validate()?; - let context = RequestContext { - endpoint: "chat.completions".to_owned(), - model: request.model.clone(), - stream: request.stream, - metadata: serde_json::Map::new(), - input_text: request.extract_user_text(), - instructions_text: request.extract_instruction_text(), - }; - state.log_request(namespace, context.clone()); - - if let Some(scenario) = state.take_matching_scenario(namespace, &context) { - return match scenario.execute_for_chat(state.next_response_id(namespace), request) { - ExecutionOutcome::Success(success) => Ok(ExecutionOutcome::Success( - enforce_tool_choice(request.tool_choice_mode(), success)?, - )), - outcome => Ok(outcome), - }; - } - - Ok(ExecutionOutcome::Success(enforce_tool_choice( - request.tool_choice_mode(), - SuccessOutcome { - plan: build_default_chat_plan( - state.next_response_id(namespace), - request.model.clone(), - &request.extract_user_text(), - request.response_format(), - request.reasoning_requested(), - ), - transport: TransportOptions::default(), - }, - )?)) -} - -fn enforce_tool_choice( - tool_choice: Option, - success: SuccessOutcome, -) -> Result { - validate_tool_choice_against_plan(tool_choice, &success.plan)?; - Ok(success) -} - -fn validate_tool_choice_against_plan( - tool_choice: Option, - plan: &ResponsePlan, -) -> Result<(), OpenAiError> { - match tool_choice { - None | Some(ToolChoiceMode::Auto) => Ok(()), - Some(ToolChoiceMode::NoTool) if plan.tool_calls.is_empty() => Ok(()), - Some(ToolChoiceMode::NoTool) => Err(OpenAiError::invalid_request( - "tool_choice", - "tool_choice forbids tool calls for this request", - )), - Some(ToolChoiceMode::Required) if !plan.tool_calls.is_empty() => Ok(()), - Some(ToolChoiceMode::Required) => Err(OpenAiError::invalid_request( - "tool_choice", - "tool_choice required a tool call but none was planned", - )), - Some(ToolChoiceMode::Function(name)) - if plan - .tool_calls - .iter() - .any(|tool_call| tool_call.name == name) => - { - Ok(()) - } - Some(ToolChoiceMode::Function(name)) => Err(OpenAiError::invalid_request( - "tool_choice", - &format!("tool_choice requested function `{name}` but it was not planned"), - )), - } -} diff --git a/test/twin/openai/src/engine/plan.rs b/test/twin/openai/src/engine/plan.rs deleted file mode 100644 index f750bb39b..000000000 --- a/test/twin/openai/src/engine/plan.rs +++ /dev/null @@ -1,163 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct TokenUsage { - pub input_tokens: u64, - pub output_tokens: u64, -} - -impl TokenUsage { - #[must_use] - pub const fn new(input_tokens: u64, output_tokens: u64) -> Self { - Self { - input_tokens, - output_tokens, - } - } - - #[must_use] - pub const fn total_tokens(self) -> u64 { - self.input_tokens + self.output_tokens - } - - #[must_use] - pub fn responses_json(self) -> Value { - json!({ - "input_tokens": self.input_tokens, - "output_tokens": self.output_tokens, - "total_tokens": self.total_tokens(), - }) - } - - #[must_use] - pub fn chat_completions_json(self) -> Value { - json!({ - "prompt_tokens": self.input_tokens, - "completion_tokens": self.output_tokens, - "total_tokens": self.total_tokens(), - }) - } -} - -impl Default for TokenUsage { - fn default() -> Self { - Self::new(1, 5) - } -} - -#[derive(Clone, Debug)] -pub struct ResponsePlan { - pub id: String, - pub created: u64, - pub model: String, - pub response_text: String, - pub structured_output: Option, - pub reasoning: Vec, - pub tool_calls: Vec, - pub usage: TokenUsage, -} - -#[derive(Clone, Debug)] -pub struct ToolCallPlan { - pub id: String, - pub name: String, - pub arguments: Value, - pub raw_arguments: Option, -} - -impl ResponsePlan { - pub fn tool_call_arguments_text(tool_call: &ToolCallPlan) -> String { - tool_call - .raw_arguments - .clone() - .unwrap_or_else(|| tool_call.arguments.to_string()) - } - - fn responses_tool_call_item(tool_call: &ToolCallPlan) -> Value { - json!({ - "id": format!("fc_{}", tool_call.id), - "type": "function_call", - "call_id": tool_call.id, - "name": tool_call.name, - "arguments": Self::tool_call_arguments_text(tool_call), - }) - } - - pub fn chat_content(&self) -> String { - self.structured_output - .as_ref() - .map_or_else(|| self.response_text.clone(), ToString::to_string) - } - - pub fn responses_json(&self) -> Value { - let mut content_items = Vec::new(); - - if !self.response_text.is_empty() { - content_items.push(json!({ - "type": "output_text", - "text": self.response_text, - })); - } - - if let Some(structured_output) = &self.structured_output { - content_items.push(json!({ - "type": "output_json", - "json": structured_output, - })); - } - - let mut output = Vec::new(); - - if !content_items.is_empty() { - output.push(json!({ - "id": format!("msg_{}", self.id), - "type": "message", - "role": "assistant", - "content": content_items, - })); - } - - for tool_call in &self.tool_calls { - output.push(Self::responses_tool_call_item(tool_call)); - } - - json!({ - "id": self.id, - "object": "response", - "created": self.created, - "model": self.model, - "status": "completed", - "reasoning": self.reasoning, - "output": output, - "usage": self.usage.responses_json() - }) - } - - pub fn chat_completions_json(&self) -> Value { - json!({ - "id": format!("chatcmpl_{}", self.id), - "object": "chat.completion", - "created": self.created, - "model": self.model, - "choices": [{ - "index": 0, - "finish_reason": if self.tool_calls.is_empty() { "stop" } else { "tool_calls" }, - "message": { - "role": "assistant", - "content": self.chat_content(), - "reasoning": self.reasoning, - "tool_calls": self.tool_calls.iter().map(|tool_call| json!({ - "id": tool_call.id, - "type": "function", - "function": { - "name": tool_call.name, - "arguments": Self::tool_call_arguments_text(tool_call), - } - })).collect::>(), - } - }], - "usage": self.usage.chat_completions_json() - }) - } -} diff --git a/test/twin/openai/src/engine/scenario.rs b/test/twin/openai/src/engine/scenario.rs deleted file mode 100644 index b30be7eb0..000000000 --- a/test/twin/openai/src/engine/scenario.rs +++ /dev/null @@ -1,271 +0,0 @@ -use axum::http::StatusCode; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; - -use super::failures::{ErrorOutcome, ExecutionOutcome, SuccessOutcome, TransportOptions}; -use super::plan::{ResponsePlan, TokenUsage, ToolCallPlan}; -use crate::openai::models::{ChatCompletionsRequest, ResponsesRequest}; - -#[derive(Clone, Debug, Deserialize)] -pub struct ScenarioEnvelope { - pub scenarios: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct Scenario { - pub matcher: ScenarioMatcher, - pub script: ScenarioScript, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct ScenarioMatcher { - pub endpoint: String, - pub model: Option, - pub stream: Option, - #[serde(default)] - pub metadata: Map, - pub input_contains: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum ScenarioScript { - Success { - response_text: Option, - reasoning: Option>, - structured_output: Option, - tool_calls: Option>, - usage: Option, - delay_before_headers_ms: Option, - inter_event_delay_ms: Option, - close_after_chunks: Option, - malformed_sse: Option, - }, - Error { - status: u16, - message: String, - error_type: String, - code: String, - retry_after: Option, - delay_before_headers_ms: Option, - }, - Hang { - delay_before_headers_ms: Option, - }, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct ToolCallTemplate { - pub id: Option, - pub name: String, - pub arguments: Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub raw_arguments: Option, -} - -#[derive(Clone, Debug)] -pub struct RequestContext { - pub endpoint: String, - pub model: String, - pub stream: bool, - pub metadata: Map, - pub input_text: String, - pub instructions_text: String, -} - -impl ScenarioScript { - pub fn script_kind(&self) -> &str { - match self { - Self::Success { .. } => "success", - Self::Error { .. } => "error", - Self::Hang { .. } => "hang", - } - } -} - -impl Scenario { - pub fn matches(&self, request: &RequestContext) -> bool { - if self.matcher.endpoint != request.endpoint { - return false; - } - - if let Some(model) = &self.matcher.model { - if model != &request.model { - return false; - } - } - - if let Some(stream) = self.matcher.stream { - if stream != request.stream { - return false; - } - } - - if let Some(needle) = &self.matcher.input_contains { - if !request.input_text.contains(needle) { - return false; - } - } - - self.matcher.metadata.iter().all(|(key, value)| { - request - .metadata - .get(key) - .is_some_and(|candidate| candidate == value) - }) - } - - pub fn execute_for_responses( - &self, - response_number: u64, - request: &ResponsesRequest, - ) -> ExecutionOutcome { - match &self.script { - ScenarioScript::Success { - response_text, - reasoning, - structured_output, - tool_calls, - usage, - delay_before_headers_ms, - inter_event_delay_ms, - close_after_chunks, - malformed_sse, - } => ExecutionOutcome::Success(SuccessOutcome { - plan: build_plan_from_script( - response_number, - request.model.clone(), - &request.extract_user_text(), - response_text.clone(), - reasoning.clone().unwrap_or_default(), - structured_output.clone(), - tool_calls.clone().unwrap_or_default(), - *usage, - ), - transport: TransportOptions { - delay_before_headers_ms: delay_before_headers_ms.unwrap_or_default(), - inter_event_delay_ms: inter_event_delay_ms.unwrap_or_default(), - close_after_chunks: *close_after_chunks, - malformed_sse: malformed_sse.unwrap_or(false), - }, - }), - ScenarioScript::Error { - status, - message, - error_type, - code, - retry_after, - delay_before_headers_ms, - } => ExecutionOutcome::Error(ErrorOutcome::new( - StatusCode::from_u16(*status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - message.clone(), - error_type.clone(), - code.clone(), - retry_after.clone(), - delay_before_headers_ms.unwrap_or_default(), - )), - ScenarioScript::Hang { - delay_before_headers_ms, - } => ExecutionOutcome::Hang { - delay_before_headers_ms: delay_before_headers_ms.unwrap_or_default(), - }, - } - } - - pub fn execute_for_chat( - &self, - response_number: u64, - request: &ChatCompletionsRequest, - ) -> ExecutionOutcome { - match &self.script { - ScenarioScript::Success { - response_text, - reasoning, - structured_output, - tool_calls, - usage, - delay_before_headers_ms, - inter_event_delay_ms, - close_after_chunks, - malformed_sse, - } => ExecutionOutcome::Success(SuccessOutcome { - plan: build_plan_from_script( - response_number, - request.model.clone(), - &request.extract_user_text(), - response_text.clone(), - reasoning.clone().unwrap_or_default(), - structured_output.clone(), - tool_calls.clone().unwrap_or_default(), - *usage, - ), - transport: TransportOptions { - delay_before_headers_ms: delay_before_headers_ms.unwrap_or_default(), - inter_event_delay_ms: inter_event_delay_ms.unwrap_or_default(), - close_after_chunks: *close_after_chunks, - malformed_sse: malformed_sse.unwrap_or(false), - }, - }), - ScenarioScript::Error { - status, - message, - error_type, - code, - retry_after, - delay_before_headers_ms, - } => ExecutionOutcome::Error(ErrorOutcome::new( - StatusCode::from_u16(*status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - message.clone(), - error_type.clone(), - code.clone(), - retry_after.clone(), - delay_before_headers_ms.unwrap_or_default(), - )), - ScenarioScript::Hang { - delay_before_headers_ms, - } => ExecutionOutcome::Hang { - delay_before_headers_ms: delay_before_headers_ms.unwrap_or_default(), - }, - } - } -} - -fn build_plan_from_script( - response_number: u64, - model: String, - default_input: &str, - response_text: Option, - reasoning: Vec, - structured_output: Option, - tool_calls: Vec, - usage: Option, -) -> ResponsePlan { - let output_text = match response_text { - Some(response_text) => response_text, - None if tool_calls.is_empty() && structured_output.is_none() => { - format!("deterministic: {default_input}") - } - None => String::new(), - }; - ResponsePlan { - id: format!("resp_{response_number:06}"), - created: response_number, - model, - response_text: output_text, - structured_output, - reasoning, - tool_calls: tool_calls - .into_iter() - .enumerate() - .map(|(index, tool_call)| ToolCallPlan { - id: tool_call - .id - .unwrap_or_else(|| format!("call_{response_number}_{index}")), - name: tool_call.name, - arguments: tool_call.arguments, - raw_arguments: tool_call.raw_arguments, - }) - .collect(), - usage: usage.unwrap_or_default(), - } -} diff --git a/test/twin/openai/src/lib.rs b/test/twin/openai/src/lib.rs deleted file mode 100644 index 1776c54d4..000000000 --- a/test/twin/openai/src/lib.rs +++ /dev/null @@ -1,26 +0,0 @@ -#![allow( - clippy::result_large_err, - reason = "Twin HTTP handlers return full axum::Response errors directly." -)] - -pub mod admin; -pub mod app; -pub mod config; -pub mod debug_ui; -pub mod engine; -pub mod logs; -pub mod openai; -pub mod sse; -pub mod state; - -use axum::Router; -use config::Config; -use state::AppState; - -pub fn build_app() -> Router { - build_app_with_config(Config::from_env().unwrap_or_default()) -} - -pub fn build_app_with_config(config: Config) -> Router { - app::router(AppState::new(config)) -} diff --git a/test/twin/openai/src/logs.rs b/test/twin/openai/src/logs.rs deleted file mode 100644 index 0fce6eafd..000000000 --- a/test/twin/openai/src/logs.rs +++ /dev/null @@ -1,12 +0,0 @@ -use serde::Serialize; -use serde_json::{Map, Value}; - -#[derive(Clone, Debug, Serialize)] -pub struct RequestLog { - pub endpoint: String, - pub model: String, - pub stream: bool, - pub input_text: String, - pub instructions_text: String, - pub metadata: Map, -} diff --git a/test/twin/openai/src/main.rs b/test/twin/openai/src/main.rs deleted file mode 100644 index b4941c9e1..000000000 --- a/test/twin/openai/src/main.rs +++ /dev/null @@ -1,21 +0,0 @@ -use anyhow::Result; -use tokio::net::TcpListener; -use tracing_subscriber::fmt; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; -use twin_openai::app; -use twin_openai::config::Config; -use twin_openai::state::AppState; - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::registry() - .with(tracing_subscriber::EnvFilter::from_default_env()) - .with(fmt::layer()) - .init(); - - let config = Config::from_env()?; - let listener = TcpListener::bind(config.bind_addr).await?; - axum::serve(listener, app::router(AppState::new(config))).await?; - Ok(()) -} diff --git a/test/twin/openai/src/openai/auth.rs b/test/twin/openai/src/openai/auth.rs deleted file mode 100644 index cd3e72d05..000000000 --- a/test/twin/openai/src/openai/auth.rs +++ /dev/null @@ -1,67 +0,0 @@ -use axum::Json; -use axum::extract::Request; -use axum::http::header::AUTHORIZATION; -use axum::http::{HeaderMap, HeaderValue, StatusCode}; -use axum::middleware::Next; -use axum::response::{IntoResponse, Response}; -use serde_json::json; - -use crate::state::NamespaceKey; - -pub async fn require_bearer_auth(request: Request, next: Next) -> Response { - match bearer_token_from_headers(request.headers()) { - Ok(Some(_)) => next.run(request).await, - Ok(None) | Err(()) => missing_bearer_token_response(), - } -} - -pub fn openai_request_namespace( - headers: &HeaderMap, - require_auth: bool, -) -> Result { - match bearer_token_from_headers(headers) { - Ok(Some(token)) => Ok(NamespaceKey::Bearer(token)), - Ok(None) if !require_auth => Ok(NamespaceKey::Global), - Ok(None) | Err(()) => Err(missing_bearer_token_response()), - } -} - -pub fn admin_request_namespace(headers: &HeaderMap) -> Result { - match bearer_token_from_headers(headers) { - Ok(Some(token)) => Ok(NamespaceKey::Bearer(token)), - Ok(None) => Ok(NamespaceKey::Global), - Err(()) => Err(missing_bearer_token_response()), - } -} - -fn bearer_token_from_headers(headers: &HeaderMap) -> Result, ()> { - match headers.get(AUTHORIZATION) { - Some(value) => parse_bearer_token(value).map(Some).ok_or(()), - None => Ok(None), - } -} - -fn parse_bearer_token(value: &HeaderValue) -> Option { - let Ok(value) = value.to_str() else { - return None; - }; - - let token = value.strip_prefix("Bearer ").map(str::trim)?; - - (!token.is_empty()).then(|| token.to_owned()) -} - -fn missing_bearer_token_response() -> Response { - ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": { - "message": "missing or empty bearer token", - "type": "invalid_request_error", - "param": "Authorization", - "code": "missing_bearer_token" - } - })), - ) - .into_response() -} diff --git a/test/twin/openai/src/openai/chat_completions.rs b/test/twin/openai/src/openai/chat_completions.rs deleted file mode 100644 index da2d46bf3..000000000 --- a/test/twin/openai/src/openai/chat_completions.rs +++ /dev/null @@ -1,82 +0,0 @@ -use axum::Json; -use axum::extract::State; -use axum::extract::rejection::JsonRejection; -use axum::http::HeaderMap; -use axum::http::header::RETRY_AFTER; -use axum::response::IntoResponse; -use futures_util::future; -use tokio::time::{Duration, sleep}; - -use super::models::ChatCompletionsRequest; -use crate::engine::execute_chat_request; -use crate::engine::failures::ExecutionOutcome; -use crate::openai::auth; -use crate::sse::chat_sse_response; -use crate::state::AppState; - -pub async fn create_chat_completion( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> impl IntoResponse { - let namespace = match auth::openai_request_namespace(&headers, state.config.require_auth) { - Ok(namespace) => namespace, - Err(response) => return response, - }; - - let request = match payload { - Ok(Json(request)) => request, - Err(rejection) => { - return super::models::OpenAiError::from_json_rejection(&rejection) - .into_response() - .into_response(); - } - }; - - match execute_chat_request(&state, &namespace, &request) { - Ok(ExecutionOutcome::Success(success)) => { - if success.transport.delay_before_headers_ms > 0 { - sleep(Duration::from_millis( - success.transport.delay_before_headers_ms, - )) - .await; - } - - if request.stream { - chat_sse_response( - &success.plan, - request.include_stream_usage(), - success.transport, - ) - .into_response() - } else { - Json(success.plan.chat_completions_json()).into_response() - } - } - Ok(ExecutionOutcome::Error(error)) => { - if error.delay_before_headers_ms > 0 { - sleep(Duration::from_millis(error.delay_before_headers_ms)).await; - } - - let mut response = Json(error.body).into_response(); - *response.status_mut() = error.status; - if let Some(retry_after) = error.retry_after { - response.headers_mut().insert( - RETRY_AFTER, - retry_after.parse().expect("valid Retry-After header"), - ); - } - response - } - Ok(ExecutionOutcome::Hang { - delay_before_headers_ms, - }) => { - if delay_before_headers_ms > 0 { - sleep(Duration::from_millis(delay_before_headers_ms)).await; - } - future::pending::<()>().await; - unreachable!() - } - Err(error) => error.into_response().into_response(), - } -} diff --git a/test/twin/openai/src/openai/mod.rs b/test/twin/openai/src/openai/mod.rs deleted file mode 100644 index 582da1cff..000000000 --- a/test/twin/openai/src/openai/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -pub mod auth; -pub mod chat_completions; -pub mod models; -pub mod responses; - -use axum::routing::post; -use axum::{Router, middleware}; - -use crate::state::AppState; - -pub fn router(require_auth: bool) -> Router { - let router = Router::new() - .route("/responses", post(responses::create_response)) - .route( - "/chat/completions", - post(chat_completions::create_chat_completion), - ); - - if require_auth { - router.layer(middleware::from_fn(auth::require_bearer_auth)) - } else { - router - } -} diff --git a/test/twin/openai/src/openai/models.rs b/test/twin/openai/src/openai/models.rs deleted file mode 100644 index be41db099..000000000 --- a/test/twin/openai/src/openai/models.rs +++ /dev/null @@ -1,1104 +0,0 @@ -use std::collections::HashSet; - -use axum::Json; -use axum::extract::rejection::JsonRejection; -use axum::http::StatusCode; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; - -/// Accepts all known OpenAI Responses API fields. Unknown fields are ignored -/// via `#[serde(flatten)]` so the twin stays compatible as the API evolves. -#[derive(Clone, Debug, Deserialize)] -pub struct ResponsesRequest { - pub model: String, - #[serde(default)] - pub input: ResponseInput, - pub instructions: Option, - #[serde(default)] - pub stream: bool, - #[serde(default)] - pub metadata: Map, - pub stop: Option, - pub previous_response_id: Option, - pub reasoning: Option, - pub text: Option, - pub tools: Option>, - pub tool_choice: Option, - /// Catch-all for fields the twin doesn't use (temperature, top_p, etc.) - #[allow( - dead_code, - reason = "Serde captures unknown request fields for forward compatibility." - )] - #[serde(flatten)] - extra: Map, -} - -impl ResponsesRequest { - pub fn extract_user_text(&self) -> String { - let text = self.input.extract_text(); - if text.is_empty() { - "empty input".to_owned() - } else { - text - } - } - - pub fn extract_instruction_text(&self) -> String { - self.instructions - .as_deref() - .map(normalize_whitespace) - .unwrap_or_default() - } - - pub fn response_format(&self) -> Option { - let format = self.text.as_ref()?.format.as_ref()?; - response_format_from_kind( - "text.format.type", - &format.kind, - format.json_schema_payload(), - ) - .ok() - } - - pub fn tool_choice_mode(&self) -> Option { - tool_choice_mode(self.tool_choice.as_ref(), ToolSurface::Responses) - } - - pub fn validate(&self) -> Result<(), OpenAiError> { - if self.model.trim().is_empty() { - return Err(OpenAiError::invalid_request( - "model", - "model must not be empty", - )); - } - - if let Some(text) = &self.text { - let Some(format) = &text.format else { - return Err(OpenAiError::invalid_request( - "text.format", - "text.format must be present when text is provided", - )); - }; - - if let ResponseFormat::JsonSchema(schema) = response_format_from_kind( - "text.format.type", - &format.kind, - format.json_schema_payload(), - )? { - validate_json_schema_subset(&schema)?; - } - } - - if let Some(ResponseFormat::JsonSchema(schema)) = self.response_format() { - validate_json_schema_subset(&schema)?; - } - - validate_tools(self.tools.as_ref(), "tools", ToolSurface::Responses)?; - validate_tool_choice( - self.tool_choice.as_ref(), - "tool_choice", - ToolSurface::Responses, - )?; - validate_tool_choice_requires_tools(self.tool_choice.as_ref(), self.tools.as_ref())?; - validate_stop(self.stop.as_ref(), "stop")?; - validate_response_input(&self.input, self.previous_response_id.as_deref())?; - - Ok(()) - } -} - -#[derive(Clone, Debug)] -pub enum ResponseFormat { - Text, - JsonObject, - JsonSchema(Value), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ToolSurface { - Responses, - ChatCompletions, -} - -#[derive(Clone, Debug, Deserialize, Default)] -#[serde(untagged)] -pub enum ResponseInput { - Text(String), - Items(Vec), - #[default] - Empty, -} - -impl ResponseInput { - fn extract_text(&self) -> String { - match self { - Self::Text(text) => normalize_whitespace(text), - Self::Items(items) => { - let pieces: Vec = items - .iter() - .flat_map(InputItem::extract_texts_for_fallback) - .collect(); - normalize_whitespace(&pieces.join(" ")) - } - Self::Empty => String::new(), - } - } -} - -#[derive(Clone, Debug, Deserialize)] -pub struct InputItem { - #[serde(default)] - pub role: Option, - #[serde(default)] - pub content: InputContent, - #[serde(default)] - #[serde(rename = "type")] - pub item_type: Option, - #[serde(default)] - pub output: Option, - #[serde(default)] - pub call_id: Option, -} - -impl InputItem { - fn extract_texts_for_fallback(&self) -> Vec { - if self.kind().is_tool_output() { - return self - .output - .as_ref() - .map(|output| vec![normalize_whitespace(output)]) - .unwrap_or_default(); - } - - if self.role.as_deref() == Some("user") { - return self.content.extract_texts(); - } - - Vec::new() - } - - fn kind(&self) -> InputItemKind { - InputItemKind::from_wire(self.item_type.as_deref()) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum InputItemKind { - Message, - FunctionCall, - CustomToolCall, - FunctionCallOutput, - CustomToolCallOutput, - Other, -} - -impl InputItemKind { - fn from_wire(item_type: Option<&str>) -> Self { - match item_type { - None | Some("message") => Self::Message, - Some("function_call") => Self::FunctionCall, - Some("custom_tool_call") => Self::CustomToolCall, - Some("function_call_output") => Self::FunctionCallOutput, - Some("custom_tool_call_output") => Self::CustomToolCallOutput, - Some(_) => Self::Other, - } - } - - fn is_tool_call(self) -> bool { - matches!(self, Self::FunctionCall | Self::CustomToolCall) - } - - fn is_tool_output(self) -> bool { - matches!(self, Self::FunctionCallOutput | Self::CustomToolCallOutput) - } -} - -#[derive(Clone, Debug, Deserialize, Default)] -#[serde(untagged)] -pub enum InputContent { - Text(String), - Parts(Vec), - #[default] - Empty, -} - -impl InputContent { - fn extract_texts(&self) -> Vec { - match self { - Self::Text(text) => vec![normalize_whitespace(text)], - Self::Parts(parts) => parts.iter().filter_map(ContentPart::text_value).collect(), - Self::Empty => Vec::new(), - } - } - - fn is_empty(&self) -> bool { - match self { - Self::Text(text) => text.is_empty(), - Self::Parts(parts) => parts.is_empty(), - Self::Empty => true, - } - } -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ContentPart { - #[serde(rename = "type")] - pub kind: String, - #[serde(default)] - pub text: Option, - #[serde(default)] - pub image_url: Option, -} - -impl ContentPart { - fn text_value(&self) -> Option { - match self.kind.as_str() { - "input_text" | "text" => self.text.as_ref().map(|text| normalize_whitespace(text)), - _ => None, - } - } -} - -fn validate_response_input( - input: &ResponseInput, - previous_response_id: Option<&str>, -) -> Result<(), OpenAiError> { - let ResponseInput::Items(items) = input else { - return Ok(()); - }; - - let mut function_call_ids = HashSet::new(); - for item in items { - validate_input_item(item)?; - let kind = item.kind(); - if kind.is_tool_call() { - if let Some(call_id) = item.call_id.as_deref().filter(|id| !id.is_empty()) { - function_call_ids.insert(call_id); - } - } else if kind.is_tool_output() && previous_response_id.is_none() { - let call_id = item.call_id.as_deref().unwrap_or_default(); - if !function_call_ids.contains(call_id) { - return Err(OpenAiError::invalid_request( - "input", - &format!("No tool call found for tool call output with call_id {call_id}."), - )); - } - } - } - - Ok(()) -} - -fn validate_input_item(item: &InputItem) -> Result<(), OpenAiError> { - match item.kind() { - InputItemKind::FunctionCallOutput | InputItemKind::CustomToolCallOutput => { - validate_function_call_output_item(item) - } - InputItemKind::Message => validate_message_input_item(item), - // Accept any other item type — the twin extracts user text for fallback - // responses and ignores items it doesn't understand. - InputItemKind::FunctionCall | InputItemKind::CustomToolCall | InputItemKind::Other => { - Ok(()) - } - } -} - -fn validate_function_call_output_item(item: &InputItem) -> Result<(), OpenAiError> { - if item.role.is_some() || !item.content.is_empty() { - return Err(OpenAiError::invalid_request( - "input", - "function_call_output items may not include role or content", - )); - } - - if item.call_id.as_deref().is_none_or(str::is_empty) { - return Err(OpenAiError::invalid_request( - "input", - "function_call_output items require call_id", - )); - } - - if item.output.as_deref().is_none_or(str::is_empty) { - return Err(OpenAiError::invalid_request( - "input", - "function_call_output items require output", - )); - } - - Ok(()) -} - -fn validate_message_input_item(item: &InputItem) -> Result<(), OpenAiError> { - if item.role.as_deref().is_none_or(str::is_empty) { - return Err(OpenAiError::invalid_request( - "input", - "message input items require role", - )); - } - - if item.output.is_some() || item.call_id.is_some() { - return Err(OpenAiError::invalid_request( - "input", - "message input items may not include function_call_output fields", - )); - } - - let role = item.role.as_deref().unwrap_or_default(); - validate_input_content(role, &item.content) -} - -fn validate_input_content(role: &str, content: &InputContent) -> Result<(), OpenAiError> { - match content { - InputContent::Text(_) => Ok(()), - InputContent::Parts(parts) if !parts.is_empty() => { - for part in parts { - validate_input_content_part(role, part)?; - } - Ok(()) - } - _ => Err(OpenAiError::invalid_request( - "input", - "message input items require supported content", - )), - } -} - -fn validate_input_content_part(role: &str, part: &ContentPart) -> Result<(), OpenAiError> { - match part.kind.as_str() { - "input_text" | "text" if part.text.as_deref().is_some() => Ok(()), - // Assistant history items are replayed with their original output - // parts; the real API accepts output_text on assistant messages. - "output_text" if role == "assistant" && part.text.as_deref().is_some() => Ok(()), - "input_image" - if part - .image_url - .as_deref() - .is_some_and(is_supported_image_reference) => - { - Ok(()) - } - "input_text" | "text" => Err(OpenAiError::invalid_request( - "input", - "text input parts require text", - )), - "output_text" if role == "assistant" => Err(OpenAiError::invalid_request( - "input", - "text input parts require text", - )), - "output_text" => Err(OpenAiError::invalid_request( - "input", - "output_text parts are only valid on assistant messages", - )), - "input_image" => Err(OpenAiError::invalid_request( - "input", - "image input parts require a supported image_url", - )), - _ => Err(OpenAiError::invalid_request( - "input", - "unsupported input content part type", - )), - } -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct TextOptions { - pub format: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct TextFormat { - #[serde(rename = "type")] - pub kind: String, - #[serde(default)] - pub json_schema: Option, - #[serde(default)] - pub name: Option, - #[serde(default)] - pub schema: Option, - #[serde(default)] - pub description: Option, - #[serde(default)] - pub strict: Option, -} - -impl TextFormat { - fn json_schema_payload(&self) -> Option { - self.json_schema.clone().or_else(|| { - json_schema_config_value( - self.name.as_ref(), - self.schema.as_ref(), - self.description.as_ref(), - self.strict, - ) - }) - } -} - -#[derive(Clone, Debug, Serialize)] -pub struct ErrorEnvelope { - pub error: ErrorBody, -} - -#[derive(Clone, Debug, Serialize)] -pub struct ErrorBody { - pub message: String, - #[serde(rename = "type")] - pub error_type: String, - pub param: Value, - pub code: String, -} - -#[derive(Clone, Debug)] -pub struct OpenAiError { - pub status: StatusCode, - pub body: ErrorEnvelope, -} - -impl OpenAiError { - pub fn invalid_request(param: &str, message: &str) -> Self { - Self { - status: StatusCode::BAD_REQUEST, - body: ErrorEnvelope { - error: ErrorBody { - message: message.to_owned(), - error_type: "invalid_request_error".to_owned(), - param: Value::String(param.to_owned()), - code: "invalid_request".to_owned(), - }, - }, - } - } - - pub fn into_response(self) -> (StatusCode, Json) { - (self.status, Json(self.body)) - } - - pub fn from_json_rejection(rejection: &JsonRejection) -> Self { - Self::invalid_request("body", &rejection.body_text()) - } -} - -fn validate_json_schema_subset(schema: &Value) -> Result<(), OpenAiError> { - let schema = schema.get("schema").unwrap_or(schema); - match schema.get("type").and_then(Value::as_str) { - Some("object") => { - let Some(properties) = schema.get("properties").and_then(Value::as_object) else { - return Err(OpenAiError::invalid_request( - "text.format.json_schema", - "json_schema object types must define properties", - )); - }; - - for property in properties.values() { - validate_schema_node(property)?; - } - Ok(()) - } - _ => Err(OpenAiError::invalid_request( - "text.format.json_schema", - "unsupported json_schema root type", - )), - } -} - -fn validate_schema_node(node: &Value) -> Result<(), OpenAiError> { - if node.get("items").is_some() || node.get("anyOf").is_some() || node.get("oneOf").is_some() { - return Err(OpenAiError::invalid_request( - "text.format.json_schema", - "unsupported json_schema construct", - )); - } - - match node.get("type").and_then(Value::as_str) { - Some("string" | "number" | "integer" | "boolean") => Ok(()), - Some("object") => validate_json_schema_subset(node), - _ => Err(OpenAiError::invalid_request( - "text.format.json_schema", - "unsupported json_schema property type", - )), - } -} - -pub fn normalize_whitespace(input: &str) -> String { - input.split_whitespace().collect::>().join(" ") -} - -/// Accepts all known OpenAI Chat Completions API fields. Unknown top-level -/// fields are ignored via `#[serde(flatten)]` so the twin stays compatible as -/// clients add request options. -#[derive(Clone, Debug, Deserialize)] -pub struct ChatCompletionsRequest { - pub model: String, - pub messages: Vec, - pub max_tokens: Option, - #[serde(default)] - pub stream: bool, - stream_options: Option, - pub tools: Option>, - pub tool_choice: Option, - pub response_format: Option, - pub stop: Option, - /// Catch-all for fields the twin doesn't use (temperature, top_p, etc.) - #[allow( - dead_code, - reason = "Serde captures unknown request fields for forward compatibility." - )] - #[serde(flatten)] - extra: Map, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ChatStreamOptions { - #[serde(default)] - include_usage: bool, -} - -impl ChatCompletionsRequest { - pub fn include_stream_usage(&self) -> bool { - self.stream_options - .as_ref() - .is_some_and(|options| options.include_usage) - } - - pub fn extract_user_text(&self) -> String { - let pieces: Vec = self - .messages - .iter() - .filter(|message| message.role == "user") - .flat_map(ChatMessage::extract_texts) - .collect(); - let text = normalize_whitespace(&pieces.join(" ")); - if text.is_empty() { - "empty input".to_owned() - } else { - text - } - } - - pub fn extract_instruction_text(&self) -> String { - let pieces: Vec = self - .messages - .iter() - .filter(|message| message.role == "system" || message.role == "developer") - .flat_map(ChatMessage::extract_texts) - .collect(); - normalize_whitespace(&pieces.join(" ")) - } - - pub fn response_format(&self) -> Option { - let format = self.response_format.as_ref()?; - response_format_from_kind( - "response_format.type", - &format.kind, - format.json_schema_payload(), - ) - .ok() - } - - pub fn reasoning_requested(&self) -> bool { - self.messages - .iter() - .any(ChatMessage::contains_reasoning_content) - } - - pub fn tool_choice_mode(&self) -> Option { - tool_choice_mode(self.tool_choice.as_ref(), ToolSurface::ChatCompletions) - } - - pub fn validate(&self) -> Result<(), OpenAiError> { - if self.model.trim().is_empty() { - return Err(OpenAiError::invalid_request( - "model", - "model must not be empty", - )); - } - - if let Some(format) = &self.response_format { - if let ResponseFormat::JsonSchema(schema) = response_format_from_kind( - "response_format.type", - &format.kind, - format.json_schema_payload(), - )? { - validate_json_schema_subset(&schema)?; - } - } - - if let Some(ResponseFormat::JsonSchema(schema)) = self.response_format() { - validate_json_schema_subset(&schema)?; - } - - validate_tools(self.tools.as_ref(), "tools", ToolSurface::ChatCompletions)?; - validate_tool_choice( - self.tool_choice.as_ref(), - "tool_choice", - ToolSurface::ChatCompletions, - )?; - validate_tool_choice_requires_tools(self.tool_choice.as_ref(), self.tools.as_ref())?; - validate_stop(self.stop.as_ref(), "stop")?; - validate_chat_messages(&self.messages)?; - - Ok(()) - } -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ChatMessage { - pub role: String, - pub content: Option, - pub reasoning_content: Option, - pub tool_call_id: Option, - pub tool_calls: Option>, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ChatMessageToolCall { - pub id: String, - #[serde(rename = "type")] - pub kind: String, - pub function: ChatMessageToolCallFunction, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ChatMessageToolCallFunction { - pub name: String, - pub arguments: String, -} - -impl ChatMessage { - fn extract_texts(&self) -> Vec { - match self.content.as_ref() { - Some(Value::String(text)) => vec![normalize_whitespace(text)], - Some(Value::Array(parts)) => parts - .iter() - .filter_map(|part| { - part.get("text") - .and_then(Value::as_str) - .map(normalize_whitespace) - }) - .collect(), - _ => Vec::new(), - } - } - - fn contains_reasoning_content(&self) -> bool { - self.role == "assistant" - && (self - .reasoning_content - .as_deref() - .is_some_and(|reasoning| !reasoning.trim().is_empty()) - || self - .content - .as_ref() - .and_then(Value::as_array) - .is_some_and(|parts| { - parts.iter().any(|part| { - part.get("type") - .and_then(Value::as_str) - .is_some_and(|kind| kind == "reasoning") - }) - })) - } -} - -fn validate_chat_messages(messages: &[ChatMessage]) -> Result<(), OpenAiError> { - if messages.is_empty() { - return Err(OpenAiError::invalid_request( - "messages", - "messages must not be empty", - )); - } - - for message in messages { - validate_chat_message(message)?; - } - - Ok(()) -} - -fn validate_chat_message(message: &ChatMessage) -> Result<(), OpenAiError> { - if message.role.trim().is_empty() { - return Err(OpenAiError::invalid_request( - "messages", - "message role must not be empty", - )); - } - - validate_chat_tool_fields(message)?; - - match message.content.as_ref() { - Some(Value::String(_)) => Ok(()), - Some(Value::Array(parts)) if !parts.is_empty() => { - for part in parts { - validate_chat_message_part(part, &message.role)?; - } - Ok(()) - } - None if message.role == "assistant" - && message - .tool_calls - .as_ref() - .is_some_and(|tool_calls| !tool_calls.is_empty()) => - { - Ok(()) - } - _ => Err(OpenAiError::invalid_request( - "messages", - "unsupported message content shape", - )), - } -} - -fn validate_chat_tool_fields(message: &ChatMessage) -> Result<(), OpenAiError> { - if message.role == "tool" { - if message - .tool_call_id - .as_deref() - .is_none_or(|tool_call_id| tool_call_id.trim().is_empty()) - { - return Err(OpenAiError::invalid_request( - "messages", - "tool messages require a tool_call_id", - )); - } - } else if message.tool_call_id.is_some() { - return Err(OpenAiError::invalid_request( - "messages", - "tool_call_id is only supported on tool messages", - )); - } - - let Some(tool_calls) = &message.tool_calls else { - return Ok(()); - }; - if message.role != "assistant" { - return Err(OpenAiError::invalid_request( - "messages", - "tool_calls are only supported on assistant messages", - )); - } - if tool_calls.is_empty() { - return Err(OpenAiError::invalid_request( - "messages", - "tool_calls must not be empty", - )); - } - for tool_call in tool_calls { - if tool_call.id.trim().is_empty() - || tool_call.kind != "function" - || tool_call.function.name.trim().is_empty() - || tool_call.function.arguments.trim().is_empty() - { - return Err(OpenAiError::invalid_request( - "messages", - "invalid assistant tool call", - )); - } - } - - Ok(()) -} - -fn validate_chat_message_part(part: &Value, role: &str) -> Result<(), OpenAiError> { - let Some(object) = part.as_object() else { - return Err(OpenAiError::invalid_request( - "messages", - "message content parts must be objects", - )); - }; - - let Some(kind) = object.get("type").and_then(Value::as_str) else { - return Err(OpenAiError::invalid_request( - "messages", - "message content part type is required", - )); - }; - - match kind { - "text" => { - if object.get("text").and_then(Value::as_str).is_none() { - return Err(OpenAiError::invalid_request( - "messages", - "text-bearing message content parts require text", - )); - } - Ok(()) - } - "reasoning" => { - if role != "assistant" { - return Err(OpenAiError::invalid_request( - "messages", - "reasoning content parts are only supported on assistant messages", - )); - } - if object.get("text").and_then(Value::as_str).is_none() { - return Err(OpenAiError::invalid_request( - "messages", - "text-bearing message content parts require text", - )); - } - Ok(()) - } - "image_url" => { - if !object - .get("image_url") - .is_some_and(is_valid_chat_image_reference) - { - return Err(OpenAiError::invalid_request( - "messages", - "image_url parts require a supported image_url object", - )); - } - Ok(()) - } - _ => Err(OpenAiError::invalid_request( - "messages", - "unsupported message content part type", - )), - } -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ChatResponseFormat { - #[serde(rename = "type")] - pub kind: String, - #[serde(default)] - pub schema: Option, - #[serde(default)] - pub json_schema: Option, -} - -impl ChatResponseFormat { - fn json_schema_payload(&self) -> Option { - self.json_schema.clone().or_else(|| self.schema.clone()) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ToolChoiceMode { - Auto, - NoTool, - Required, - Function(String), -} - -fn response_format_from_kind( - param: &str, - kind: &str, - schema: Option, -) -> Result { - match kind { - "text" => Ok(ResponseFormat::Text), - "json_object" => Ok(ResponseFormat::JsonObject), - "json_schema" => schema - .map(ResponseFormat::JsonSchema) - .ok_or_else(|| OpenAiError::invalid_request(param, "json_schema requires schema")), - _ => Err(OpenAiError::invalid_request( - param, - "unsupported response format type", - )), - } -} - -fn validate_tools( - tools: Option<&Vec>, - param: &str, - surface: ToolSurface, -) -> Result<(), OpenAiError> { - let Some(tools) = tools else { - return Ok(()); - }; - - for tool in tools { - let Some(tool_type) = tool.get("type").and_then(Value::as_str) else { - return Err(OpenAiError::invalid_request(param, "tool type is required")); - }; - - match tool_type { - "function" => { - if function_tool_name(tool, surface).is_none() { - return Err(OpenAiError::invalid_request( - param, - "function tool name is required", - )); - } - } - "custom" if surface == ToolSurface::Responses => { - if function_tool_name(tool, surface).is_none() { - return Err(OpenAiError::invalid_request( - param, - "custom tool name is required", - )); - } - } - _ => { - return Err(OpenAiError::invalid_request(param, "unsupported tool type")); - } - } - } - - Ok(()) -} - -fn validate_tool_choice( - tool_choice: Option<&Value>, - param: &str, - surface: ToolSurface, -) -> Result<(), OpenAiError> { - let Some(tool_choice) = tool_choice else { - return Ok(()); - }; - - match tool_choice { - Value::String(value) if matches!(value.as_str(), "auto" | "none" | "required") => Ok(()), - Value::Object(object) - if object - .get("type") - .and_then(Value::as_str) - .is_some_and(|value| value == "function") - && function_tool_choice_name(tool_choice, surface).is_some() => - { - Ok(()) - } - _ => Err(OpenAiError::invalid_request( - param, - "unsupported tool_choice shape", - )), - } -} - -fn validate_tool_choice_requires_tools( - tool_choice: Option<&Value>, - tools: Option<&Vec>, -) -> Result<(), OpenAiError> { - let Some(tool_choice) = tool_choice else { - return Ok(()); - }; - - let requires_tools = match tool_choice { - Value::String(value) => value == "required", - Value::Object(_) => true, - _ => false, - }; - - if requires_tools && tools.is_none_or(Vec::is_empty) { - return Err(OpenAiError::invalid_request( - "tool_choice", - "tool_choice requires tools to be provided", - )); - } - - Ok(()) -} - -fn validate_stop(stop: Option<&Value>, param: &str) -> Result<(), OpenAiError> { - let Some(stop) = stop else { - return Ok(()); - }; - - match stop { - Value::String(_) => Ok(()), - Value::Array(values) if values.iter().all(Value::is_string) => Ok(()), - _ => Err(OpenAiError::invalid_request( - param, - "stop must be a string or array of strings", - )), - } -} - -fn tool_choice_mode(tool_choice: Option<&Value>, surface: ToolSurface) -> Option { - match tool_choice? { - Value::String(value) => match value.as_str() { - "auto" => Some(ToolChoiceMode::Auto), - "none" => Some(ToolChoiceMode::NoTool), - "required" => Some(ToolChoiceMode::Required), - _ => None, - }, - Value::Object(_) => function_tool_choice_name(tool_choice?, surface) - .map(|name| ToolChoiceMode::Function(name.to_owned())), - _ => None, - } -} - -fn function_tool_name(tool: &Value, surface: ToolSurface) -> Option<&str> { - if surface == ToolSurface::Responses { - if let Some(name) = tool.get("name").and_then(Value::as_str) { - return Some(name); - } - } - - tool.get("function") - .and_then(Value::as_object) - .and_then(|function| function.get("name")) - .and_then(Value::as_str) -} - -fn function_tool_choice_name(tool_choice: &Value, surface: ToolSurface) -> Option<&str> { - if surface == ToolSurface::Responses { - if let Some(name) = tool_choice.get("name").and_then(Value::as_str) { - return Some(name); - } - } - - tool_choice - .get("function") - .and_then(Value::as_object) - .and_then(|function| function.get("name")) - .and_then(Value::as_str) -} - -fn json_schema_config_value( - name: Option<&String>, - schema: Option<&Value>, - description: Option<&String>, - strict: Option, -) -> Option { - if name.is_none() && schema.is_none() && description.is_none() && strict.is_none() { - return None; - } - - let mut object = Map::new(); - if let Some(name) = name { - object.insert("name".to_owned(), Value::String(name.clone())); - } - if let Some(schema) = schema { - object.insert("schema".to_owned(), schema.clone()); - } - if let Some(description) = description { - object.insert("description".to_owned(), Value::String(description.clone())); - } - if let Some(strict) = strict { - object.insert("strict".to_owned(), Value::Bool(strict)); - } - Some(Value::Object(object)) -} - -fn is_supported_image_reference(image_url: &str) -> bool { - !image_url.trim().is_empty() - && (image_url.starts_with("http://") - || image_url.starts_with("https://") - || image_url.starts_with("data:")) -} - -fn is_valid_chat_image_reference(image_url: &Value) -> bool { - let Some(object) = image_url.as_object() else { - return false; - }; - - let Some(url) = object.get("url").and_then(Value::as_str) else { - return false; - }; - - if !is_supported_image_reference(url) { - return false; - } - - object.get("detail").is_none_or(Value::is_string) -} diff --git a/test/twin/openai/src/openai/responses.rs b/test/twin/openai/src/openai/responses.rs deleted file mode 100644 index 0031759c1..000000000 --- a/test/twin/openai/src/openai/responses.rs +++ /dev/null @@ -1,77 +0,0 @@ -use axum::Json; -use axum::extract::State; -use axum::extract::rejection::JsonRejection; -use axum::http::HeaderMap; -use axum::http::header::RETRY_AFTER; -use axum::response::IntoResponse; -use futures_util::future; -use tokio::time::{Duration, sleep}; - -use super::models::ResponsesRequest; -use crate::engine::execute_responses_request; -use crate::engine::failures::ExecutionOutcome; -use crate::openai::auth; -use crate::sse::responses_sse_response; -use crate::state::AppState; - -pub async fn create_response( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> impl IntoResponse { - let namespace = match auth::openai_request_namespace(&headers, state.config.require_auth) { - Ok(namespace) => namespace, - Err(response) => return response, - }; - - let request = match payload { - Ok(Json(request)) => request, - Err(rejection) => { - return super::models::OpenAiError::from_json_rejection(&rejection) - .into_response() - .into_response(); - } - }; - - match execute_responses_request(&state, &namespace, &request) { - Ok(ExecutionOutcome::Success(success)) => { - if success.transport.delay_before_headers_ms > 0 { - sleep(Duration::from_millis( - success.transport.delay_before_headers_ms, - )) - .await; - } - - if request.stream { - responses_sse_response(&success.plan, success.transport).into_response() - } else { - Json(success.plan.responses_json()).into_response() - } - } - Ok(ExecutionOutcome::Error(error)) => { - if error.delay_before_headers_ms > 0 { - sleep(Duration::from_millis(error.delay_before_headers_ms)).await; - } - - let mut response = Json(error.body).into_response(); - *response.status_mut() = error.status; - if let Some(retry_after) = error.retry_after { - response.headers_mut().insert( - RETRY_AFTER, - retry_after.parse().expect("valid Retry-After header"), - ); - } - response - } - Ok(ExecutionOutcome::Hang { - delay_before_headers_ms, - }) => { - if delay_before_headers_ms > 0 { - sleep(Duration::from_millis(delay_before_headers_ms)).await; - } - future::pending::<()>().await; - unreachable!() - } - Err(error) => error.into_response().into_response(), - } -} diff --git a/test/twin/openai/src/sse.rs b/test/twin/openai/src/sse.rs deleted file mode 100644 index 2d30a0f88..000000000 --- a/test/twin/openai/src/sse.rs +++ /dev/null @@ -1,385 +0,0 @@ -use async_stream::stream; -use axum::body::Body; -use axum::http::{HeaderValue, Response, StatusCode, header}; -use serde_json::{Value, json}; -use tokio::time::{Duration, sleep}; - -use crate::engine::failures::TransportOptions; -use crate::engine::plan::ResponsePlan; - -pub fn responses_sse_response(plan: &ResponsePlan, transport: TransportOptions) -> Response { - let mut events = Vec::new(); - let reasoning_item_id = format!("rs_{}", plan.id); - let message_item_id = format!("msg_{}", plan.id); - let mut next_output_index = 0; - let streamed_text = plan.structured_output.as_ref().map(Value::to_string); - - events.push(sse_event( - "response.created", - &json!({ - "type": "response.created", - "response": { - "id": plan.id, - "object": "response", - "created": plan.created, - "model": plan.model, - "status": "in_progress", - "output": [], - }, - }), - )); - events.push(sse_event( - "response.in_progress", - &json!({ - "type": "response.in_progress", - "response": { - "id": plan.id, - "object": "response", - "created": plan.created, - "model": plan.model, - "status": "in_progress", - "output": [], - }, - }), - )); - - events.push(sse_event( - "response.output_item.added", - &json!({ - "type": "response.output_item.added", - "item": { - "id": reasoning_item_id, - "type": "reasoning", - "summary": [], - }, - "output_index": next_output_index, - }), - )); - for reasoning in &plan.reasoning { - events.push(sse_event( - "response.reasoning.delta", - &json!({ - "type": "response.reasoning.delta", - "delta": reasoning, - "item_id": reasoning_item_id, - "output_index": next_output_index, - }), - )); - } - events.push(sse_event( - "response.output_item.done", - &json!({ - "type": "response.output_item.done", - "item": { - "id": reasoning_item_id, - "type": "reasoning", - "summary": [], - }, - "output_index": next_output_index, - }), - )); - next_output_index += 1; - - if !plan.response_text.is_empty() || streamed_text.is_some() { - events.push(sse_event( - "response.output_item.added", - &json!({ - "type": "response.output_item.added", - "item": { - "id": message_item_id, - "type": "message", - "status": "in_progress", - "content": [], - "role": "assistant", - }, - "output_index": next_output_index, - }), - )); - - let message_text = streamed_text - .as_deref() - .unwrap_or(plan.response_text.as_str()); - - if !message_text.is_empty() { - events.push(sse_event( - "response.content_part.added", - &json!({ - "type": "response.content_part.added", - "content_index": 0, - "item_id": message_item_id, - "output_index": next_output_index, - "part": { - "type": "output_text", - "text": "", - }, - }), - )); - events.push(sse_event( - "response.output_text.delta", - &json!({ - "type": "response.output_text.delta", - "content_index": 0, - "item_id": message_item_id, - "output_index": next_output_index, - "delta": message_text, - }), - )); - events.push(sse_event( - "response.output_text.done", - &json!({ - "type": "response.output_text.done", - "content_index": 0, - "item_id": message_item_id, - "output_index": next_output_index, - "text": message_text, - }), - )); - events.push(sse_event( - "response.content_part.done", - &json!({ - "type": "response.content_part.done", - "content_index": 0, - "item_id": message_item_id, - "output_index": next_output_index, - "part": { - "type": "output_text", - "text": message_text, - }, - }), - )); - } - - // The completed item carries its full content, like the real API. - // Adapters round-trip this item verbatim into the next request's - // input, so omitting content here produces an invalid replay. - events.push(sse_event( - "response.output_item.done", - &json!({ - "type": "response.output_item.done", - "item": { - "id": message_item_id, - "type": "message", - "status": "completed", - "role": "assistant", - "content": [{ - "type": "output_text", - "text": message_text, - }], - }, - "output_index": next_output_index, - }), - )); - next_output_index += 1; - } - - for tool_call in &plan.tool_calls { - let item_id = format!("fc_{}", tool_call.id); - events.push(sse_event( - "response.output_item.added", - &json!({ - "type": "response.output_item.added", - "item": { - "id": item_id, - "type": "function_call", - "call_id": tool_call.id, - "name": tool_call.name, - "arguments": "", - }, - "output_index": next_output_index, - }), - )); - events.push(sse_event( - "response.function_call_arguments.delta", - &json!({ - "type": "response.function_call_arguments.delta", - "item_id": item_id, - "delta": ResponsePlan::tool_call_arguments_text(tool_call), - "output_index": next_output_index, - }), - )); - events.push(sse_event( - "response.function_call_arguments.done", - &json!({ - "type": "response.function_call_arguments.done", - "item_id": item_id, - "arguments": ResponsePlan::tool_call_arguments_text(tool_call), - "output_index": next_output_index, - }), - )); - events.push(sse_event( - "response.output_item.done", - &json!({ - "type": "response.output_item.done", - "item": { - "id": item_id, - "type": "function_call", - "call_id": tool_call.id, - "name": tool_call.name, - "arguments": ResponsePlan::tool_call_arguments_text(tool_call), - }, - "output_index": next_output_index, - }), - )); - next_output_index += 1; - } - - if !transport.malformed_sse { - events.push(sse_event( - "response.completed", - &json!({ - "type": "response.completed", - "response": plan.responses_json(), - }), - )); - } - - stream_response(events, transport) -} - -pub fn chat_sse_response( - plan: &ResponsePlan, - include_usage: bool, - transport: TransportOptions, -) -> Response { - let mut events = Vec::new(); - let content = plan.chat_content(); - events.push(chat_chunk(&json!({ - "id": format!("chatcmpl_{}", plan.id), - "object": "chat.completion.chunk", - "created": plan.created, - "model": plan.model, - "choices": [{ - "index": 0, - "delta": { - "role": "assistant" - }, - "finish_reason": Value::Null, - }] - }))); - - if !content.is_empty() { - events.push(chat_chunk(&json!({ - "id": format!("chatcmpl_{}", plan.id), - "object": "chat.completion.chunk", - "created": plan.created, - "model": plan.model, - "choices": [{ - "index": 0, - "delta": { - "content": content - }, - "finish_reason": Value::Null, - }] - }))); - } - - for reasoning in &plan.reasoning { - events.push(chat_chunk(&json!({ - "id": format!("chatcmpl_{}", plan.id), - "object": "chat.completion.chunk", - "created": plan.created, - "model": plan.model, - "choices": [{ - "index": 0, - "delta": { - "reasoning": reasoning - }, - "finish_reason": Value::Null, - }] - }))); - } - - if !plan.tool_calls.is_empty() { - events.push(chat_chunk(&json!({ - "id": format!("chatcmpl_{}", plan.id), - "object": "chat.completion.chunk", - "created": plan.created, - "model": plan.model, - "choices": [{ - "index": 0, - "delta": { - "tool_calls": plan.tool_calls.iter().enumerate().map(|(index, tool_call)| json!({ - "index": index, - "id": tool_call.id, - "type": "function", - "function": { - "name": tool_call.name, - "arguments": ResponsePlan::tool_call_arguments_text(tool_call), - } - })).collect::>() - }, - "finish_reason": Value::Null, - }] - }))); - } - - if !transport.malformed_sse { - events.push(chat_chunk(&json!({ - "id": format!("chatcmpl_{}", plan.id), - "object": "chat.completion.chunk", - "created": plan.created, - "model": plan.model, - "choices": [{ - "index": 0, - "delta": {}, - "finish_reason": if plan.tool_calls.is_empty() { "stop" } else { "tool_calls" }, - }] - }))); - if include_usage { - events.push(chat_chunk(&json!({ - "id": format!("chatcmpl_{}", plan.id), - "object": "chat.completion.chunk", - "created": plan.created, - "model": plan.model, - "choices": [], - "usage": plan.usage.chat_completions_json(), - }))); - } - events.push("data: [DONE]\n\n".to_owned()); - } - - stream_response(events, transport) -} - -fn stream_response(events: Vec, transport: TransportOptions) -> Response { - let limit = transport.close_after_chunks.unwrap_or(events.len()); - let malformed_sse = transport.malformed_sse; - let inter_event_delay_ms = transport.inter_event_delay_ms; - - let body = Body::from_stream(stream! { - for (index, event) in events.into_iter().enumerate() { - if index >= limit { - break; - } - - if inter_event_delay_ms > 0 { - sleep(Duration::from_millis(inter_event_delay_ms)).await; - } - - yield Ok::<_, std::convert::Infallible>(event.into_bytes()); - } - - if malformed_sse { - yield Ok::<_, std::convert::Infallible>(b"event: malformed\ndata: {".to_vec()); - } - }); - - let mut response = Response::new(body); - *response.status_mut() = StatusCode::OK; - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/event-stream"), - ); - response - .headers_mut() - .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); - response -} - -fn sse_event(event: &str, data: &Value) -> String { - format!("event: {event}\ndata: {data}\n\n") -} - -fn chat_chunk(data: &Value) -> String { - format!("data: {data}\n\n") -} diff --git a/test/twin/openai/src/state.rs b/test/twin/openai/src/state.rs deleted file mode 100644 index 29cece646..000000000 --- a/test/twin/openai/src/state.rs +++ /dev/null @@ -1,178 +0,0 @@ -use std::collections::HashMap; -use std::fmt; -use std::sync::{Arc, Mutex}; - -use serde::Serialize; -use serde_json::Value; - -use crate::config::Config; -use crate::engine::scenario::{RequestContext, Scenario}; -use crate::logs::RequestLog; - -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub enum NamespaceKey { - Global, - Bearer(String), -} - -impl fmt::Display for NamespaceKey { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Global => write!(f, "Global"), - Self::Bearer(token) => write!(f, "Bearer: {token}"), - } - } -} - -#[derive(Clone, Debug, Serialize)] -pub struct DebugSnapshot { - pub namespaces: Vec, -} - -#[derive(Clone, Debug, Serialize)] -pub struct NamespaceSnapshot { - pub key: String, - pub scenarios: Vec, - pub request_logs: Vec, -} - -#[derive(Clone, Debug, Serialize)] -pub struct ScenarioSnapshot { - pub endpoint: String, - pub model: Option, - pub stream: Option, - pub input_contains: Option, - pub metadata: serde_json::Map, - pub script_kind: String, -} - -#[derive(Clone, Debug)] -pub struct AppState { - pub config: Config, - inner: Arc, -} - -#[derive(Debug)] -struct AppStateInner { - namespaces: Mutex>, -} - -#[derive(Debug)] -struct NamespaceState { - next_response_number: u64, - scenarios: Vec, - request_logs: Vec, -} - -impl Default for NamespaceState { - fn default() -> Self { - Self { - next_response_number: 1, - scenarios: Vec::new(), - request_logs: Vec::new(), - } - } -} - -impl AppState { - pub fn new(config: Config) -> Self { - Self { - config, - inner: Arc::new(AppStateInner { - namespaces: Mutex::new(HashMap::new()), - }), - } - } - - pub fn next_response_id(&self, namespace: &NamespaceKey) -> u64 { - let mut namespaces = self.inner.namespaces.lock().expect("namespaces lock"); - let namespace_state = namespaces.entry(namespace.clone()).or_default(); - let response_id = namespace_state.next_response_number; - namespace_state.next_response_number += 1; - response_id - } - - pub fn enqueue_scenarios(&self, namespace: &NamespaceKey, mut scenarios: Vec) { - self.inner - .namespaces - .lock() - .expect("namespaces lock") - .entry(namespace.clone()) - .or_default() - .scenarios - .append(&mut scenarios); - } - - pub fn take_matching_scenario( - &self, - namespace: &NamespaceKey, - request: &RequestContext, - ) -> Option { - let mut namespaces = self.inner.namespaces.lock().expect("namespaces lock"); - let scenarios = &mut namespaces.entry(namespace.clone()).or_default().scenarios; - let position = scenarios - .iter() - .position(|scenario| scenario.matches(request))?; - Some(scenarios.remove(position)) - } - - pub fn log_request(&self, namespace: &NamespaceKey, request: RequestContext) { - self.inner - .namespaces - .lock() - .expect("namespaces lock") - .entry(namespace.clone()) - .or_default() - .request_logs - .push(RequestLog { - endpoint: request.endpoint, - model: request.model, - stream: request.stream, - input_text: request.input_text, - instructions_text: request.instructions_text, - metadata: request.metadata, - }); - } - - pub fn request_logs(&self, namespace: &NamespaceKey) -> Vec { - self.inner - .namespaces - .lock() - .expect("namespaces lock") - .get(namespace) - .map(|namespace_state| namespace_state.request_logs.clone()) - .unwrap_or_default() - } - - pub fn reset(&self, namespace: &NamespaceKey) { - self.inner - .namespaces - .lock() - .expect("namespaces lock") - .remove(namespace); - } - - pub fn debug_snapshot(&self) -> DebugSnapshot { - let namespaces = self.inner.namespaces.lock().expect("namespaces lock"); - let mut result = Vec::new(); - for (key, ns) in namespaces.iter() { - result.push(NamespaceSnapshot { - key: key.to_string(), - scenarios: ns - .scenarios - .iter() - .map(|s| ScenarioSnapshot { - endpoint: s.matcher.endpoint.clone(), - model: s.matcher.model.clone(), - stream: s.matcher.stream, - input_contains: s.matcher.input_contains.clone(), - metadata: s.matcher.metadata.clone(), - script_kind: s.script.script_kind().to_owned(), - }) - .collect(), - request_logs: ns.request_logs.clone(), - }); - } - DebugSnapshot { namespaces: result } - } -} diff --git a/test/twin/openai/tests/chat_completions_contract.rs b/test/twin/openai/tests/chat_completions_contract.rs deleted file mode 100644 index 7837dfe5b..000000000 --- a/test/twin/openai/tests/chat_completions_contract.rs +++ /dev/null @@ -1,535 +0,0 @@ -mod common; - -use serde_json::json; - -#[tokio::test] -async fn chat_completions_non_stream_uses_same_canonical_plan() { - let server = common::spawn_server().await.expect("server should start"); - - let response = server - .post_responses(json!({ - "model": "gpt-test", - "input": "same plan", - "stream": false - })) - .await - .json::() - .await - .expect("json"); - - let chat = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": "same plan" }], - "stream": false - })) - .await - .json::() - .await - .expect("json"); - - assert_eq!( - response["output"][0]["content"][0]["text"], - chat["choices"][0]["message"]["content"] - ); -} - -#[tokio::test] -async fn chat_completions_stream_uses_same_canonical_plan() { - let server = common::spawn_server().await.expect("server should start"); - - let (status, chunks) = server - .post_chat_stream(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": "stream same plan" }], - "stream": true - })) - .await; - - let joined = chunks.join(""); - - assert_eq!(status, 200); - assert!(joined.contains("\"content\":\"deterministic: stream same plan\"")); - assert!(!joined.contains("\"usage\"")); - assert!(joined.contains("data: [DONE]")); -} - -#[tokio::test] -async fn chat_completions_stream_includes_usage_when_requested() { - let server = common::spawn_server().await.expect("server should start"); - - let (status, chunks) = server - .post_chat_stream(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": "stream with usage" }], - "stream": true, - "stream_options": { "include_usage": true } - })) - .await; - - assert_eq!(status, 200); - let transcript = - common::parse_sse_transcript(chunks.join("").as_bytes()).expect("valid SSE transcript"); - let usage_chunk = transcript - .events - .iter() - .filter(|event| event.data != "[DONE]") - .map(|event| { - serde_json::from_str::(&event.data).expect("valid JSON chunk") - }) - .find(|chunk| chunk.get("usage").is_some()) - .expect("trailing usage chunk"); - - assert_eq!(usage_chunk["choices"], json!([])); - assert_eq!( - usage_chunk["usage"], - json!({ - "prompt_tokens": 3, - "completion_tokens": 5, - "total_tokens": 8 - }) - ); - assert!(transcript.done); -} - -#[tokio::test] -async fn chat_completions_accepts_supported_openai_compatible_fields() { - let server = common::spawn_server().await.expect("server should start"); - - let response = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [ - { - "role": "assistant", - "content": [ - { "type": "reasoning", "text": "reasoning trace" } - ] - }, - { "role": "user", "content": "structured chat" } - ], - "max_tokens": 128, - "stream": false, - "tools": [{ "type": "function", "function": { "name": "lookup" } }], - "tool_choice": "auto", - "stop": ["END"], - "response_format": { "type": "json_object" } - })) - .await; - - assert_eq!(response.status(), 200); - let body = response.json::().await.expect("json"); - assert_eq!( - body["choices"][0]["message"]["content"], - "{\"message\":\"deterministic: structured chat\",\"model\":\"gpt-test\"}" - ); - assert_eq!( - body["choices"][0]["message"]["reasoning"][0], - "reasoning: structured chat" - ); -} - -#[tokio::test] -async fn chat_completions_accepts_tool_call_history() { - let server = common::spawn_server().await.expect("server should start"); - - let response = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [ - { "role": "user", "content": "replace old with new" }, - { - "role": "assistant", - "tool_calls": [{ - "id": "call_edit", - "type": "function", - "function": { - "name": "edit_file", - "arguments": "{\"old\":\"old\",\"new\":\"new\"}" - } - }] - }, - { - "role": "tool", - "content": "Updated data.txt", - "tool_call_id": "call_edit" - } - ], - "stream": false - })) - .await; - - assert_eq!(response.status(), 200); - let body = response.json::().await.expect("json"); - assert_eq!( - body["choices"][0]["message"]["content"], - "deterministic: replace old with new" - ); -} - -#[tokio::test] -async fn chat_completions_supports_scripted_tool_call_and_json_schema() { - let server = common::spawn_server().await.expect("server should start"); - server - .enqueue_scenarios(json!({ - "scenarios": [ - { - "matcher": { "endpoint": "chat.completions", "model": "gpt-test", "stream": false, "input_contains": "tool please" }, - "script": { - "kind": "success", - "tool_calls": [ - { - "id": "call_weather", - "name": "lookup_weather", - "arguments": { "city": "Boston" } - } - ] - } - }, - { - "matcher": { "endpoint": "chat.completions", "model": "gpt-test", "stream": true, "input_contains": "tool please" }, - "script": { - "kind": "success", - "tool_calls": [ - { - "id": "call_weather", - "name": "lookup_weather", - "arguments": { "city": "Boston" } - } - ] - } - } - ] - })) - .await; - - let non_stream = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": "tool please" }], - "tools": [{ "type": "function", "function": { "name": "lookup_weather" } }], - "tool_choice": { - "type": "function", - "function": { "name": "lookup_weather" } - }, - "stream": false - })) - .await - .json::() - .await - .expect("json"); - - assert_eq!(non_stream["choices"][0]["finish_reason"], "tool_calls"); - assert_eq!( - non_stream["choices"][0]["message"]["tool_calls"][0]["function"]["name"], - "lookup_weather" - ); - assert_eq!(non_stream["choices"][0]["message"]["content"], ""); - - let (status, chunks) = server - .post_chat_stream(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": "tool please" }], - "tools": [{ "type": "function", "function": { "name": "lookup_weather" } }], - "tool_choice": { - "type": "function", - "function": { "name": "lookup_weather" } - }, - "stream": true - })) - .await; - let joined = chunks.join(""); - assert_eq!(status, 200); - assert!(joined.contains("\"tool_calls\"")); - assert!(!joined.contains("\"content\":\"deterministic:")); - - let structured = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": "schema chat" }], - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "chat_schema", - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" }, - "ok": { "type": "boolean" } - } - } - , - "strict": true - } - }, - "stream": false - })) - .await - .json::() - .await - .expect("json"); - - assert_eq!( - structured["choices"][0]["message"]["content"], - "{\"message\":\"deterministic: schema chat\",\"ok\":true}" - ); -} - -#[tokio::test] -async fn chat_completions_stream_preserves_reasoning_transcript() { - let server = common::spawn_server().await.expect("server should start"); - - let (status, chunks) = server - .post_chat_stream(json!({ - "model": "gpt-test", - "messages": [ - { - "role": "assistant", - "content": [ - { "type": "reasoning", "text": "reasoning trace" } - ] - }, - { "role": "user", "content": "stream same plan" } - ], - "stream": true - })) - .await; - - let joined = chunks.join(""); - - assert_eq!(status, 200); - assert!(joined.contains("\"reasoning\":\"reasoning: stream same plan\"")); - assert!(joined.contains("\"content\":\"deterministic: stream same plan\"")); - assert!(joined.contains("data: [DONE]")); -} - -#[tokio::test] -async fn chat_completions_do_not_infer_reasoning_from_user_text() { - let server = common::spawn_server().await.expect("server should start"); - - let response = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": "Please explain your reasoning plainly" }], - "stream": false - })) - .await - .json::() - .await - .expect("json"); - - assert_eq!(response["choices"][0]["message"]["reasoning"], json!([])); - assert_eq!( - response["choices"][0]["message"]["content"], - "deterministic: Please explain your reasoning plainly" - ); -} - -#[tokio::test] -async fn chat_completions_reject_reasoning_parts_on_non_assistant_messages() { - let server = common::spawn_server().await.expect("server should start"); - - let user_reasoning = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [{ - "role": "user", - "content": [{ "type": "reasoning", "text": "not allowed here" }] - }], - "stream": false - })) - .await; - - assert_eq!(user_reasoning.status(), 400); - let body = user_reasoning - .json::() - .await - .expect("json"); - assert_eq!(body["error"]["type"], "invalid_request_error"); - assert_eq!(body["error"]["param"], "messages"); - - let system_reasoning = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [{ - "role": "system", - "content": [{ "type": "reasoning", "text": "not allowed here either" }] - }], - "stream": false - })) - .await; - - assert_eq!(system_reasoning.status(), 400); - let body = system_reasoning - .json::() - .await - .expect("json"); - assert_eq!(body["error"]["type"], "invalid_request_error"); - assert_eq!(body["error"]["param"], "messages"); -} - -#[tokio::test] -async fn chat_completions_accept_unknown_top_level_fields() { - let server = common::spawn_server().await.expect("server should start"); - - let (status, chunks) = server - .post_chat_stream(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": "hello" }], - "stream": true, - "temperature": 0.7, - "top_p": 0.9, - "prompt_cache_key": "conversation-123" - })) - .await; - - assert_eq!(status, 200); - let transcript = - common::parse_sse_transcript(chunks.join("").as_bytes()).expect("valid SSE transcript"); - assert!(transcript.done); -} - -#[tokio::test] -async fn chat_completions_reject_unsupported_tool_choice_shape() { - let server = common::spawn_server().await.expect("server should start"); - - let response = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": "hello" }], - "tools": [{ "type": "function", "function": { "name": "lookup_weather" } }], - "tool_choice": { "type": "required" } - })) - .await; - - assert_eq!(response.status(), 400); - let body = response.json::().await.expect("json"); - assert_eq!(body["error"]["type"], "invalid_request_error"); -} - -#[tokio::test] -async fn chat_completions_reject_required_tool_choice_without_tools() { - let server = common::spawn_server().await.expect("server should start"); - - let response = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": "hello" }], - "tool_choice": "required" - })) - .await; - - assert_eq!(response.status(), 400); - let body = response.json::().await.expect("json"); - assert_eq!(body["error"]["type"], "invalid_request_error"); -} - -#[tokio::test] -async fn chat_completions_reject_unfulfilled_tool_choice_requirements() { - let server = common::spawn_server().await.expect("server should start"); - - let required = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": "plain text please" }], - "tools": [{ "type": "function", "function": { "name": "lookup_weather" } }], - "tool_choice": "required" - })) - .await; - - assert_eq!(required.status(), 400); - let body = required.json::().await.expect("json"); - assert_eq!(body["error"]["type"], "invalid_request_error"); - assert_eq!(body["error"]["param"], "tool_choice"); - - let named = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": "plain text please" }], - "tools": [{ "type": "function", "function": { "name": "lookup_weather" } }], - "tool_choice": { - "type": "function", - "function": { "name": "lookup_weather" } - } - })) - .await; - - assert_eq!(named.status(), 400); - let body = named.json::().await.expect("json"); - assert_eq!(body["error"]["type"], "invalid_request_error"); - assert_eq!(body["error"]["param"], "tool_choice"); -} - -#[tokio::test] -async fn chat_completions_rejects_unsupported_response_format() { - let server = common::spawn_server().await.expect("server should start"); - - let response = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": "bad format" }], - "response_format": { "type": "xml" } - })) - .await; - - assert_eq!(response.status(), 400); - let body = response.json::().await.expect("json"); - assert_eq!(body["error"]["type"], "invalid_request_error"); -} - -#[tokio::test] -async fn chat_completions_reject_empty_messages() { - let server = common::spawn_server().await.expect("server should start"); - - let response = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [] - })) - .await; - - assert_eq!(response.status(), 400); - let body = response.json::().await.expect("json"); - assert_eq!(body["error"]["type"], "invalid_request_error"); - assert_eq!(body["error"]["param"], "messages"); -} - -#[tokio::test] -async fn chat_completions_reject_null_message_content() { - let server = common::spawn_server().await.expect("server should start"); - - let response = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [{ "role": "user", "content": null }] - })) - .await; - - assert_eq!(response.status(), 400); - let body = response.json::().await.expect("json"); - assert_eq!(body["error"]["type"], "invalid_request_error"); - assert_eq!(body["error"]["param"], "messages"); -} - -#[tokio::test] -async fn chat_completions_reject_malformed_image_input() { - let server = common::spawn_server().await.expect("server should start"); - - let response = server - .post_chat(json!({ - "model": "gpt-test", - "messages": [{ - "role": "user", - "content": [{ - "type": "image_url", - "image_url": {} - }] - }] - })) - .await; - - assert_eq!(response.status(), 400); - let body = response.json::().await.expect("json"); - assert_eq!(body["error"]["type"], "invalid_request_error"); - assert_eq!(body["error"]["param"], "messages"); -} diff --git a/test/twin/openai/tests/common/mod.rs b/test/twin/openai/tests/common/mod.rs deleted file mode 100644 index 86625972a..000000000 --- a/test/twin/openai/tests/common/mod.rs +++ /dev/null @@ -1,587 +0,0 @@ -#![allow( - dead_code, - unreachable_pub, - reason = "Shared test helpers stay public within the test crate and not every helper is used everywhere." -)] - -use std::collections::HashMap; -use std::net::SocketAddr; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, Instant}; - -use anyhow::Result; -use fabro_http::header::AUTHORIZATION; -use fabro_http::{HttpClient as Client, HttpClientBuilder}; -use futures_util::StreamExt; -use serde_json::Value; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; -use twin_openai::config::Config; - -pub struct TestServer { - pub base_url: String, - pub client: Client, - pub auth_client: Client, - pub bearer_token: String, -} - -#[derive(Clone)] -pub struct ApiClient { - pub base_url: String, - client: Client, - bearer_token: Option, - organization: Option, - project: Option, -} - -pub struct RecordedResponse { - pub status: fabro_http::StatusCode, - pub headers: HashMap, - pub body: Vec, -} - -pub struct RawStreamResponse { - pub status: u16, - pub headers: HashMap, - pub body: Vec, -} - -pub struct TimedStreamResponse { - pub status: fabro_http::StatusCode, - pub first_event_elapsed: Duration, - pub chunks: Vec, -} - -#[derive(Debug)] -pub struct ParsedSseTranscript { - pub blocks: Vec, - pub events: Vec, - pub done: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ParsedSseEvent { - pub event: Option, - pub data: String, -} - -static NEXT_BEARER_TOKEN: AtomicU64 = AtomicU64::new(1); - -pub fn test_http_client() -> Result { - fabro_http::test_http_client().map_err(Into::into) -} - -pub async fn spawn_server() -> Result { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr: SocketAddr = listener.local_addr()?; - let app = twin_openai::build_app_with_config(Config { - bind_addr: "127.0.0.1:0".parse().expect("valid addr"), - require_auth: true, - enable_admin: true, - }); - - tokio::spawn(async move { - axum::serve(listener, app).await.expect("server should run"); - }); - - TestServer::new(format!("http://{addr}"), next_bearer_token()) -} - -fn next_bearer_token() -> String { - format!( - "test-key-{}", - NEXT_BEARER_TOKEN.fetch_add(1, Ordering::SeqCst) - ) -} - -fn authorization_header_value(bearer_token: &str) -> String { - format!("Bearer {bearer_token}") -} - -fn build_authenticated_client(bearer_token: &str) -> Result { - HttpClientBuilder::new() - .proxy_policy(fabro_http::ProxyPolicy::Disabled) - .default_headers( - [( - AUTHORIZATION, - authorization_header_value(bearer_token) - .parse() - .expect("valid header"), - )] - .into_iter() - .collect(), - ) - .build() - .map_err(Into::into) -} - -impl ApiClient { - pub fn new( - base_url: impl Into, - bearer_token: Option, - organization: Option, - project: Option, - ) -> Result { - Ok(Self { - base_url: base_url.into(), - client: HttpClientBuilder::new() - .proxy_policy(fabro_http::ProxyPolicy::Disabled) - .timeout(Duration::from_secs(30)) - .build()?, - bearer_token, - organization, - project, - }) - } - - pub fn with_client( - base_url: impl Into, - client: Client, - bearer_token: Option, - organization: Option, - project: Option, - ) -> Self { - Self { - base_url: base_url.into(), - client, - bearer_token, - organization, - project, - } - } - - pub async fn post_json(&self, path: &str, body: &Value) -> fabro_http::Response { - self.post(path) - .json(body) - .send() - .await - .expect("request should complete") - } - - pub async fn post_json_recorded(&self, path: &str, body: &Value) -> RecordedResponse { - record_response(self.post_json(path, body).await).await - } - - pub async fn get_recorded(&self, path: &str) -> RecordedResponse { - record_response( - self.get(path) - .send() - .await - .expect("request should complete"), - ) - .await - } - - pub fn post(&self, path: &str) -> fabro_http::RequestBuilder { - self.request(self.client.post(format!("{}{}", self.base_url, path))) - } - - pub fn get(&self, path: &str) -> fabro_http::RequestBuilder { - self.request(self.client.get(format!("{}{}", self.base_url, path))) - } - - fn request(&self, mut request: fabro_http::RequestBuilder) -> fabro_http::RequestBuilder { - if let Some(token) = &self.bearer_token { - request = request.bearer_auth(token); - } - if let Some(org) = &self.organization { - request = request.header("OpenAI-Organization", org); - } - if let Some(project) = &self.project { - request = request.header("OpenAI-Project", project); - } - - request - } -} - -impl TestServer { - fn new(base_url: String, bearer_token: String) -> Result { - let client = test_http_client()?; - let auth_client = build_authenticated_client(&bearer_token)?; - - Ok(Self { - base_url, - client, - auth_client, - bearer_token, - }) - } - - pub fn authorization_header_value(&self) -> String { - authorization_header_value(&self.bearer_token) - } - - pub fn api_client(&self) -> ApiClient { - ApiClient::with_client( - self.base_url.clone(), - self.client.clone(), - Some(self.bearer_token.clone()), - None, - None, - ) - } - - pub fn fork_namespace(&self) -> Result { - Self::new(self.base_url.clone(), next_bearer_token()) - } -} - -impl TestServer { - pub async fn post_responses(&self, body: Value) -> fabro_http::Response { - self.auth_client - .post(format!("{}/v1/responses", self.base_url)) - .json(&body) - .send() - .await - .expect("request should complete") - } - - pub async fn post_responses_with_headers( - &self, - body: Value, - org: Option<&str>, - project: Option<&str>, - ) -> fabro_http::Response { - let mut request = self - .auth_client - .post(format!("{}/v1/responses", self.base_url)); - - if let Some(org) = org { - request = request.header("OpenAI-Organization", org); - } - - if let Some(project) = project { - request = request.header("OpenAI-Project", project); - } - - request - .json(&body) - .send() - .await - .expect("request should complete") - } - - pub async fn post_responses_stream( - &self, - body: Value, - ) -> (fabro_http::StatusCode, Vec) { - let response = self - .auth_client - .post(format!("{}/v1/responses", self.base_url)) - .json(&body) - .send() - .await - .expect("request should complete"); - - let status = response.status(); - let mut stream = response.bytes_stream(); - let mut chunks = Vec::new(); - - while let Some(chunk) = stream.next().await { - chunks.push( - String::from_utf8(chunk.expect("stream chunk").to_vec()).expect("utf8 stream"), - ); - } - - (status, chunks) - } - - pub async fn post_chat(&self, body: Value) -> fabro_http::Response { - self.auth_client - .post(format!("{}/v1/chat/completions", self.base_url)) - .json(&body) - .send() - .await - .expect("request should complete") - } - - pub async fn post_chat_stream(&self, body: Value) -> (fabro_http::StatusCode, Vec) { - let response = self.post_chat(body).await; - let status = response.status(); - let mut stream = response.bytes_stream(); - let mut chunks = Vec::new(); - - while let Some(chunk) = stream.next().await { - chunks.push( - String::from_utf8(chunk.expect("stream chunk").to_vec()).expect("utf8 stream"), - ); - } - - (status, chunks) - } - - pub async fn post_chat_with_auth_header( - &self, - body: Value, - authorization: Option<&str>, - ) -> fabro_http::Response { - let mut request = self - .client - .post(format!("{}/v1/chat/completions", self.base_url)); - - if let Some(value) = authorization { - request = request.header(AUTHORIZATION, value); - } - - request - .json(&body) - .send() - .await - .expect("request should complete") - } - - pub async fn post_responses_stream_timed(&self, body: Value) -> TimedStreamResponse { - let started = Instant::now(); - let response = self - .auth_client - .post(format!("{}/v1/responses", self.base_url)) - .json(&body) - .send() - .await - .expect("request should complete"); - let status = response.status(); - let mut stream = response.bytes_stream(); - let mut chunks = Vec::new(); - let mut first_event_elapsed = Duration::ZERO; - - if let Some(chunk) = stream.next().await { - chunks.push( - String::from_utf8(chunk.expect("stream chunk").to_vec()).expect("utf8 stream"), - ); - first_event_elapsed = started.elapsed(); - } - - while let Some(chunk) = stream.next().await { - chunks.push( - String::from_utf8(chunk.expect("stream chunk").to_vec()).expect("utf8 stream"), - ); - } - - TimedStreamResponse { - status, - first_event_elapsed, - chunks, - } - } - - pub async fn post_responses_stream_raw(&self, body: Value) -> RawStreamResponse { - self.raw_stream_request("/v1/responses", &body).await - } - - pub async fn post_chat_stream_raw(&self, body: Value) -> RawStreamResponse { - self.raw_stream_request("/v1/chat/completions", &body).await - } - - pub async fn enqueue_scenarios(&self, scenarios: Value) { - let response = self - .auth_client - .post(format!("{}/__admin/scenarios", self.base_url)) - .json(&scenarios) - .send() - .await - .expect("admin request should complete"); - - assert_eq!(response.status(), 200); - } - - pub async fn reset(&self) { - let response = self - .auth_client - .post(format!("{}/__admin/reset", self.base_url)) - .send() - .await - .expect("admin reset should complete"); - - assert_eq!(response.status(), 200); - } - - pub async fn request_logs(&self) -> Value { - self.auth_client - .get(format!("{}/__admin/requests", self.base_url)) - .send() - .await - .expect("admin logs should complete") - .json() - .await - .expect("logs json should parse") - } - - async fn raw_stream_request(&self, path: &str, body: &Value) -> RawStreamResponse { - let authority = self - .base_url - .strip_prefix("http://") - .expect("http base url"); - let mut stream = TcpStream::connect(authority) - .await - .expect("socket should connect"); - let body = serde_json::to_vec(body).expect("json body"); - let authorization = self.authorization_header_value(); - let request = format!( - "POST {path} HTTP/1.1\r\nHost: {authority}\r\nAuthorization: {authorization}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); - - stream - .write_all(request.as_bytes()) - .await - .expect("request headers should write"); - stream - .write_all(&body) - .await - .expect("request body should write"); - stream.flush().await.expect("request should flush"); - - let mut response = Vec::new(); - stream - .read_to_end(&mut response) - .await - .expect("response should read"); - - decode_http_response(&response) - } -} - -pub fn parse_sse_transcript(body: &[u8]) -> Result { - let text = std::str::from_utf8(body).map_err(|_| "sse body was not valid utf-8".to_owned())?; - let mut blocks = Vec::new(); - let mut events = Vec::new(); - let mut done = false; - let mut remainder = text; - - while let Some((block, rest)) = remainder.split_once("\n\n") { - if !block.is_empty() { - let event = parse_sse_block(block)?; - if event.data == "[DONE]" { - done = true; - } - blocks.push(block.to_owned()); - events.push(event); - } - remainder = rest; - } - - if !remainder.is_empty() { - return Err("sse stream ended with an incomplete event".to_owned()); - } - - Ok(ParsedSseTranscript { - blocks, - events, - done, - }) -} - -fn parse_sse_block(block: &str) -> Result { - let mut event = None; - let mut data_lines = Vec::new(); - - for line in block.lines() { - if let Some(value) = line.strip_prefix("event: ") { - event = Some(value.to_owned()); - continue; - } - if let Some(value) = line.strip_prefix("data: ") { - data_lines.push(value.to_owned()); - continue; - } - if line.starts_with("id: ") || line.starts_with(':') { - continue; - } - - return Err(format!("unsupported sse line: {line}")); - } - - Ok(ParsedSseEvent { - event, - data: data_lines.join("\n"), - }) -} - -pub async fn record_response(response: fabro_http::Response) -> RecordedResponse { - let status = response.status(); - let headers = response - .headers() - .iter() - .map(|(name, value)| { - ( - name.as_str().to_ascii_lowercase(), - value.to_str().unwrap_or_default().to_owned(), - ) - }) - .collect(); - let body = response - .bytes() - .await - .expect("response body should read") - .to_vec(); - - RecordedResponse { - status, - headers, - body, - } -} - -fn decode_http_response(response: &[u8]) -> RawStreamResponse { - let header_end = response - .windows(4) - .position(|window| window == b"\r\n\r\n") - .expect("http header terminator") - + 4; - let head = std::str::from_utf8(&response[..header_end]).expect("header utf8"); - let mut lines = head.split("\r\n"); - let status_line = lines.next().expect("status line"); - let status = status_line - .split_whitespace() - .nth(1) - .expect("status code") - .parse::() - .expect("numeric status"); - let mut headers = HashMap::new(); - - for line in lines.filter(|line| !line.is_empty()) { - let (name, value) = line.split_once(':').expect("header format"); - headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_owned()); - } - - let body = if headers - .get("transfer-encoding") - .is_some_and(|value| value.eq_ignore_ascii_case("chunked")) - { - decode_chunked_body(&response[header_end..]) - } else { - response[header_end..].to_vec() - }; - - RawStreamResponse { - status, - headers, - body, - } -} - -fn decode_chunked_body(encoded: &[u8]) -> Vec { - let mut cursor = 0; - let mut decoded = Vec::new(); - - loop { - let size_end = encoded[cursor..] - .windows(2) - .position(|window| window == b"\r\n") - .expect("chunk size terminator") - + cursor; - let size = std::str::from_utf8(&encoded[cursor..size_end]).expect("chunk size utf8"); - let size = usize::from_str_radix(size.trim(), 16).expect("hex chunk size"); - cursor = size_end + 2; - - if size == 0 { - break; - } - - decoded.extend_from_slice(&encoded[cursor..cursor + size]); - cursor += size + 2; - } - - decoded -} diff --git a/test/twin/openai/tests/config_contract.rs b/test/twin/openai/tests/config_contract.rs deleted file mode 100644 index 283c14538..000000000 --- a/test/twin/openai/tests/config_contract.rs +++ /dev/null @@ -1,15 +0,0 @@ -use twin_openai::config::Config; - -#[test] -fn config_loads_from_environment() { - let config = Config::from_lookup(&|name| match name { - "TWIN_OPENAI_BIND_ADDR" => Some("127.0.0.1:4100".to_string()), - "TWIN_OPENAI_REQUIRE_AUTH" | "TWIN_OPENAI_ENABLE_ADMIN" => Some("false".to_string()), - _ => None, - }) - .expect("config should load"); - - assert_eq!(config.bind_addr.to_string(), "127.0.0.1:4100"); - assert!(!config.require_auth); - assert!(!config.enable_admin); -} diff --git a/test/twin/openai/tests/debug_ui.rs b/test/twin/openai/tests/debug_ui.rs deleted file mode 100644 index 73d856ad3..000000000 --- a/test/twin/openai/tests/debug_ui.rs +++ /dev/null @@ -1,403 +0,0 @@ -#![expect( - clippy::disallowed_methods, - reason = "These browser-debug integration tests synchronously probe for Chrome binaries before launching external tooling." -)] - -mod common; - -use std::process::Stdio; - -use serde_json::json; -use tokio::net::TcpListener; -use tokio::process::Command as TokioCommand; -use tokio::time::{Duration, timeout}; -use twin_openai::config::Config; - -#[tokio::test] -async fn debug_html_page_serves_valid_html_on_empty_state() { - let server = common::spawn_server().await.expect("server should start"); - - let response = server - .client - .get(format!("{}/__debug", server.base_url)) - .send() - .await - .expect("debug page request should complete"); - - assert_eq!(response.status(), 200); - let content_type = response - .headers() - .get("content-type") - .expect("content-type header should be present") - .to_str() - .expect("content-type should be valid string"); - assert!( - content_type.contains("text/html"), - "content-type should contain text/html, got: {content_type}" - ); - - let body = response.text().await.expect("body should read"); - assert!( - body.contains(""), - "response should contain DOCTYPE" - ); - assert!( - body.contains("twin-openai"), - "response should contain project name" - ); - assert!(body.contains("debug"), "response should contain 'debug'"); - assert!( - body.contains("no active namespaces"), - "empty state should show 'no active namespaces'" - ); -} - -#[tokio::test] -async fn debug_json_endpoint_returns_correct_state_snapshot() { - let server = common::spawn_server().await.expect("server should start"); - - // Load two scenarios: one success, one error - server - .enqueue_scenarios(json!({ - "scenarios": [ - { - "matcher": { "endpoint": "responses", "model": "gpt-test" }, - "script": { "kind": "success" } - }, - { - "matcher": { "endpoint": "responses", "model": "gpt-error" }, - "script": { - "kind": "error", - "status": 500, - "message": "test error", - "error_type": "server_error", - "code": "server_error" - } - } - ] - })) - .await; - - // Make one request that consumes the first (success) scenario - let response = server - .post_responses(json!({ - "model": "gpt-test", - "input": "hello debug", - "stream": false - })) - .await; - assert_eq!(response.status(), 200); - - // GET the debug JSON endpoint (unauthenticated) - let response = server - .client - .get(format!("{}/__debug/state.json", server.base_url)) - .send() - .await - .expect("debug json request should complete"); - - assert_eq!(response.status(), 200); - let content_type = response - .headers() - .get("content-type") - .expect("content-type header should be present") - .to_str() - .expect("content-type should be valid string"); - assert!( - content_type.contains("application/json"), - "content-type should contain application/json, got: {content_type}" - ); - - let body: serde_json::Value = response.json().await.expect("json should parse"); - - // Should have a top-level namespaces array - let namespaces = body["namespaces"] - .as_array() - .expect("namespaces should be an array"); - assert_eq!(namespaces.len(), 1, "should have exactly one namespace"); - - let ns = &namespaces[0]; - assert!( - ns["key"].as_str().unwrap().starts_with("Bearer:"), - "namespace key should start with 'Bearer:', got: {}", - ns["key"] - ); - - // Should have 1 remaining scenario (the error one; the success was consumed) - let scenarios = ns["scenarios"] - .as_array() - .expect("scenarios should be an array"); - assert_eq!(scenarios.len(), 1, "should have 1 remaining scenario"); - assert_eq!(scenarios[0]["endpoint"], "responses"); - assert_eq!(scenarios[0]["model"], "gpt-error"); - assert_eq!(scenarios[0]["script_kind"], "error"); - - // Should have 1 request log - let request_logs = ns["request_logs"] - .as_array() - .expect("request_logs should be an array"); - assert_eq!(request_logs.len(), 1, "should have 1 request log"); - assert_eq!(request_logs[0]["endpoint"], "responses"); - assert_eq!(request_logs[0]["model"], "gpt-test"); - assert!( - request_logs[0]["input_text"] - .as_str() - .unwrap() - .contains("hello debug"), - "request log should contain input text 'hello debug'" - ); -} - -#[tokio::test] -async fn debug_html_page_reflects_loaded_scenarios_and_request_logs() { - let server = common::spawn_server().await.expect("server should start"); - - // Load one success scenario - server - .enqueue_scenarios(json!({ - "scenarios": [ - { - "matcher": { "endpoint": "responses", "model": "gpt-html-test" }, - "script": { "kind": "success" } - } - ] - })) - .await; - - // Make one request with a different model (won't match, but still gets logged - // via the default behavior) - let response = server - .post_responses(json!({ - "model": "gpt-other", - "input": "check the page", - "stream": false - })) - .await; - // The request gets a deterministic response (no matching scenario consumed - // since model doesn't match). Status should be 200 (default behavior). - assert_eq!(response.status(), 200); - - // GET the debug HTML page - let response = server - .client - .get(format!("{}/__debug", server.base_url)) - .send() - .await - .expect("debug page request should complete"); - - assert_eq!(response.status(), 200); - let body = response.text().await.expect("body should read"); - - // Should contain the scenario's model name - assert!( - body.contains("gpt-html-test"), - "HTML should contain scenario model 'gpt-html-test'" - ); - // Should contain the script kind - assert!( - body.contains("success"), - "HTML should contain script kind 'success'" - ); - // Should contain the request log model - assert!( - body.contains("gpt-other"), - "HTML should contain request log model 'gpt-other'" - ); - // Should contain the request log input text - assert!( - body.contains("check the page"), - "HTML should contain request log input text 'check the page'" - ); - // Verify the server-rendered content section does not show empty state. - // The JS source always includes the "no active namespaces" string as a - // template, so we check that the server-rendered content div contains - // namespace sections rather than the empty-state paragraph. - assert!( - body.contains("namespace-header"), - "HTML should contain a namespace-header element (proving non-empty rendering)" - ); -} - -#[tokio::test] -async fn debug_routes_not_accessible_when_admin_disabled() { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind should succeed"); - let addr = listener.local_addr().expect("should have addr"); - let app = twin_openai::build_app_with_config(Config { - bind_addr: "127.0.0.1:0".parse().expect("valid addr"), - require_auth: false, - enable_admin: false, - }); - - tokio::spawn(async move { - axum::serve(listener, app).await.expect("server should run"); - }); - - let base_url = format!("http://{addr}"); - let client = common::test_http_client().expect("test client"); - - let html_response = client - .get(format!("{base_url}/__debug")) - .send() - .await - .expect("request should complete"); - assert_eq!( - html_response.status(), - 404, - "debug HTML should be 404 when admin disabled" - ); - - let json_response = client - .get(format!("{base_url}/__debug/state.json")) - .send() - .await - .expect("request should complete"); - assert_eq!( - json_response.status(), - 404, - "debug JSON should be 404 when admin disabled" - ); -} - -#[tokio::test] -async fn debug_page_renders_in_headless_chrome() { - // Find Chrome binary - let chrome_binary = ["chromium", "google-chrome", "chromium-browser"] - .iter() - .find(|name| { - std::process::Command::new("which") - .arg(name) - .output() - .is_ok_and(|o| o.status.success()) - }); - - let Some(chrome_binary) = chrome_binary.copied() else { - return; - }; - - let server = common::spawn_server().await.expect("server should start"); - - // Load a scenario and make a request so the page has content - server - .enqueue_scenarios(json!({ - "scenarios": [ - { - "matcher": { "endpoint": "responses", "model": "gpt-screenshot" }, - "script": { "kind": "success" } - } - ] - })) - .await; - - let response = server - .post_responses(json!({ - "model": "gpt-screenshot", - "input": "screenshot test", - "stream": false - })) - .await; - assert_eq!(response.status(), 200); - - let screenshot_path = format!( - "/tmp/twin-openai-debug-screenshot-{}.png", - std::process::id() - ); - let mut command = TokioCommand::new(chrome_binary); - command.args([ - "--headless", - "--disable-gpu", - &format!("--screenshot={screenshot_path}"), - "--window-size=1280,900", - ]); - if cfg!(target_os = "linux") { - // Ubuntu 24.04 GitHub runners block Chrome's default sandbox unless it - // is launched with a compatible user namespace or disabled explicitly. - command.arg("--no-sandbox"); - } - let mut child = command - .stdout(Stdio::null()) - .stderr(Stdio::null()) - // Static mode keeps the page visually identical for the screenshot - // while avoiding a live refresh loop that can stall headless Chrome - // on Linux CI. - .arg(format!("{}/__debug?refresh=0", server.base_url)) - .spawn() - .expect("Chrome should start"); - - // Chrome with --screenshot exits once the file is written, so waiting on - // the process is the deterministic completion signal. - let wait_result = timeout(Duration::from_mins(2), child.wait()).await; - let status = match wait_result { - Ok(Ok(status)) => status, - Ok(Err(err)) => panic!("Chrome wait failed: {err}"), - Err(_) => { - let _ = child.start_kill(); - let _ = child.wait().await; - panic!("Chrome did not exit within 120s while taking screenshot"); - } - }; - assert!( - status.success(), - "Chrome exited with non-success status: {status}" - ); - - let screenshot_data = std::fs::read(&screenshot_path) - .expect("Chrome exited successfully but screenshot file is missing"); - - assert!( - screenshot_data.len() >= 10_000, - "screenshot should be at least 10KB, got {} bytes", - screenshot_data.len() - ); - // Check PNG magic bytes - assert_eq!( - &screenshot_data[..8], - b"\x89PNG\r\n\x1a\n", - "screenshot should be a valid PNG" - ); - - // Clean up - let _ = std::fs::remove_file(&screenshot_path); -} - -#[tokio::test] -async fn debug_html_escapes_user_controlled_values() { - let server = common::spawn_server().await.expect("server should start"); - - // Load a scenario with an XSS attempt in the model name - server - .enqueue_scenarios(json!({ - "scenarios": [ - { - "matcher": { - "endpoint": "responses", - "model": "" - }, - "script": { "kind": "success" } - } - ] - })) - .await; - - let response = server - .client - .get(format!("{}/__debug", server.base_url)) - .send() - .await - .expect("debug page request should complete"); - - assert_eq!(response.status(), 200); - let body = response.text().await.expect("body should read"); - - // Should contain the escaped form - assert!( - body.contains("<script>"), - "HTML should contain escaped '