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/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/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..8e78809f0 100644 --- a/lib/apps/fabro-cli/src/args.rs +++ b/lib/apps/fabro-cli/src/args.rs @@ -5,11 +5,11 @@ 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::settings::cli::{OutputFormat, OutputVerbosity}; use fabro_types::settings::run::MergeStrategy; +use fabro_types::{ReasoningEffort, controls}; use fabro_util::printer::Printer; pub(crate) const LONG_VERSION: &str = concat!( @@ -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,17 @@ pub(crate) struct CompletionArgs { /// Shell to generate completions for pub shell: clap_complete::Shell, } + +fn parse_reasoning_effort_arg(value: &str) -> Result { + controls::parse_reasoning_effort(value).ok_or_else(|| { + format!( + "unknown reasoning effort '{value}'; expected one of: {}", + controls::REASONING_EFFORTS + .iter() + .copied() + .map(controls::reasoning_effort_name) + .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..1e459711e 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() + catalog::listed_providers(catalog) .iter() - .filter(|provider| supports_install_api_key(provider)) - .map(|provider| provider.id.clone()) + .filter(|entry| supports_install_api_key(entry.provider)) + .map(|entry| entry.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(|entry| fabro_auth::env_var_names(entry.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(|entry| fabro_auth::expected_vault_secret_name(entry.provider)) + .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..793f92875 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(), + |entry| entry.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 entry = 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(entry.provider), "provider '{}' does not define an API-key credential path", - catalog_provider.id + entry.provider.id() ); - Ok(catalog_provider) + Ok(entry.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 } @@ -377,33 +368,36 @@ async fn await_user_response_from_source( #[cfg(test)] mod tests { + use fabro_types::catalog_policy; + use super::*; #[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 policy = catalog_policy::provider_policy(provider); + let url = policy.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()); } } #[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 +411,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..c1b6be4a2 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.fabro]\nagent_profile = \"openai\"\ncredentials = [\"env:ACME_AWS_API_KEY\"]\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.fabro]\nagent_profile = \"openai\"\ncredentials = [\"env:ACME_AWS_API_KEY\"]\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..cb44626ed 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 entry = 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() { - Ok(catalog_provider) + if fabro_auth::accepts_api_key(entry.provider) { + Ok(entry.provider) } else { Err(format!( "provider '{}' does not define an API-key credential path", - catalog_provider.id + entry.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_vault_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..104a6f4cf 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.metadata.fabro]\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,19 @@ 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}" + +[providers.openrouter.metadata.fabro] 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 +1902,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 +2016,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 +2908,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 +3010,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 +3018,29 @@ 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] +[providers.acme.metadata.fabro] +agent_profile = "openai" credentials = ["env:ACME_API_KEY"] [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 +3060,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..bdc23a652 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::controls; 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 controls::parse_reasoning_effort(value) { + Some(effort) => Some(effort), + None => { + 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,62 @@ 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 = controls::REASONING_EFFORTS + .iter() + .copied() + .filter(|candidate| capabilities.reasoning_effort(*candidate).is_supported()) + .map(controls::reasoning_effort_name) + .collect::>() + .join(", "); + return ApiError::bad_request(format!( + "model '{model_id}' does not support reasoning_effort '{}'; allowed values: {allowed}", + controls::reasoning_effort_name(effort) + )) + .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..7056e511e 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) + .iter() + .filter(|entry| eligible.contains(entry.provider.id())) + .find_map(|entry| { + catalog::provider_models(entry.provider) + .into_iter() + .find(|model| model.model.api_model() == api_model) + .map(|model| (entry.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,28 @@ 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" -[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.metadata.fabro] +enabled = true "#, ) - .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 +1611,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 +1631,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 +1656,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 +1668,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 +1706,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 +1720,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 +1994,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 +2020,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 +2034,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..4ee760198 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`). +fn acme_overlay(base_url: &str, credential: &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.fabro] +agent_profile = "openai" +credentials = [{credential}] + +[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 }} + +[providers.acme.models."acme-large".metadata.fabro] +probe = true +"#, + base_url = toml::Value::String(base_url.to_string()), + credential = toml::Value::String(credential.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,11 @@ 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", + "env:ACME_API_KEY", + )) .build(); let app = crate::test_support::build_test_router(state); let dot = r#"digraph Test { @@ -6139,14 +6132,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 +7209,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 +7797,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 +8394,7 @@ fn create_github_token_app_state_with_env_lookup( token, github_api_base_url, env_lookup, - LlmCatalogSettings::default(), + LlmLayer::default(), ) } @@ -8417,7 +8402,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 +8430,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 +8777,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 +8797,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 +8815,53 @@ 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] +[providers.direct.metadata.fabro] +agent_profile = "openai" credentials = ["vault:DIRECT_API_KEY"] [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] +[providers.aggregator.metadata.fabro] +agent_profile = "openai" credentials = ["vault:AGGREGATOR_API_KEY"] [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 +8982,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 +9000,32 @@ 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] +[providers.acme.metadata.fabro] +agent_profile = "openai" credentials = ["vault:ACME_API_KEY"] [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 +9050,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 +9063,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 +9080,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(), "vault:ACME_API_KEY"); 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 +9200,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", "env:ACME_API_KEY"); 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 +9270,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", "env:ACME_API_KEY"); 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 +9336,23 @@ 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") + .provider, + ) + .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 +9537,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 +9555,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", "vault:ACME_API_KEY").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 +9592,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 +9628,56 @@ 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] +[providers.zeta.metadata.fabro] 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 }} + +[providers.zeta.models.zeta-probe.metadata.fabro] +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] +[providers.alpha.metadata.fabro] 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 }} + +[providers.alpha.models.alpha-probe.metadata.fabro] 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 +11311,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 +11408,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 +16791,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 +16809,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 +16870,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 +16914,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 +16930,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 +16957,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 +17128,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 +18323,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 +18536,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 +18580,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 +18591,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 +18604,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 +18645,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 +18657,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 +18672,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(), "vault:ACME_API_KEY"); 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 +18695,7 @@ reasoning = false "messages": [ { "role": "user", - "content": [{"kind": "text", "data": "hi"}] + "content": [{"type": "text", "text": "hi"}] } ] }) @@ -18889,7 +18712,7 @@ reasoning = false .contains("expected test failure"), "unexpected error body: {body:?}" ); - completion.assert(); + assert!(completion.calls() >= 1); } #[tokio::test] @@ -18897,7 +18720,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 +18767,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/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-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/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',