Expose lithos request and response shapes through the API, server, CLI, and web

The OpenAPI spec adopts the lithos request, response, content part,
tool, usage, and cost schemas. The completions endpoint returns the
lithos `Response` JSON verbatim and SSE carries lithos `StreamEvent`s
verbatim. The models and providers endpoints serve the fabro-types
catalog views, and the install and model-test flows probe providers
through fabro-llm.

The CLI builds its catalog from the operator overlay, drives `fabro exec`
through the server gateway adapter, and parses reasoning effort with the
shared controls. The web app reads content parts as lithos-tagged
objects. The TypeScript client is regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-09 17:26:57 -06:00
parent 82bcafcfca
commit 75e5f34c0d
No known key found for this signature in database
95 changed files with 2122 additions and 2447 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,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<ReasoningEffort>,
}
@ -1727,7 +1728,7 @@ pub(crate) struct InstallGithubArgs {
#[derive(Args, Debug, Clone, Default)]
pub(crate) struct InstallNonInteractiveArgs {
#[arg(long, hide = true)]
pub(crate) llm_provider: Option<fabro_model::ProviderId>,
pub(crate) llm_provider: Option<fabro_types::ProviderId>,
#[arg(long, hide = true)]
pub(crate) llm_api_key_stdin: bool,
@ -1854,3 +1855,17 @@ pub(crate) struct CompletionArgs {
/// Shell to generate completions for
pub shell: clap_complete::Shell,
}
fn parse_reasoning_effort_arg(value: &str) -> Result<ReasoningEffort, String> {
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::<Vec<_>>()
.join(", ")
)
})
}

View file

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

View file

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

View file

@ -34,13 +34,13 @@ use fabro_install::{
restore_optional_file, rollback_dev_token_write, seed_environments_in_storage,
write_github_app_settings, write_token_settings,
};
use fabro_model::catalog::CatalogProvider;
use fabro_model::{Catalog, CredentialRef, ProviderId};
use fabro_llm::catalog;
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
use fabro_server::serve;
use fabro_store::ArtifactStore;
use fabro_types::ServerSettings;
use fabro_types::settings::server::ServerAuthMethod;
use fabro_types::settings::validate_public_url_with_label;
use fabro_types::{ProviderId, ServerSettings, provider_ids};
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use fabro_util::version::FABRO_VERSION;
@ -75,46 +75,31 @@ const GITHUB_APP_PRIVATE_KEY_KEY: &str = fabro_static::EnvVars::GITHUB_APP_PRIVA
const GITHUB_APP_CLIENT_SECRET_KEY: &str = fabro_static::EnvVars::GITHUB_APP_CLIENT_SECRET;
const GITHUB_APP_WEBHOOK_SECRET_KEY: &str = fabro_static::EnvVars::GITHUB_APP_WEBHOOK_SECRET;
static INSTALL_CATALOG: LazyLock<Catalog> = LazyLock::new(|| {
Catalog::from_builtin().expect("embedded install model catalog should be valid")
});
static INSTALL_CATALOG: LazyLock<Catalog> = LazyLock::new(fabro_llm::default_catalog);
fn supports_install_api_key(provider: &CatalogProvider) -> bool {
provider.auth.is_some()
fabro_auth::accepts_api_key(provider)
}
fn install_llm_provider_ids(catalog: &Catalog) -> Vec<ProviderId> {
catalog
.providers()
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::<Vec<_>>()
.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()),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,17 +15,16 @@ use dialoguer::console::Term;
use dialoguer::theme::ColorfulTheme;
use dialoguer::{Confirm, Password};
use fabro_auth::{
ApiCredential, AuthContextRequest, AuthContextResponse, AuthMethod, LoginResult,
codex_oauth_config, strategy_for,
AuthContextRequest, AuthContextResponse, AuthMethod, LoginResult, codex_oauth_config,
strategy_for,
};
use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{GenerateParams, generate};
use fabro_model::catalog::CatalogProvider;
use fabro_model::{Catalog, ProviderId};
use fabro_llm::catalog;
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus};
use fabro_types::{ProviderId, provider_ids};
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use tokio::task::spawn_blocking;
use tokio::time::timeout;
// ---------------------------------------------------------------------------
// Interactive prompts
@ -55,16 +54,14 @@ pub(crate) enum ApiKeySource {
// API key validation
// ---------------------------------------------------------------------------
fn default_catalog_for_provider_auth() -> Result<Arc<Catalog>> {
Ok(Arc::new(
Catalog::from_builtin().context("failed to build provider auth catalog")?,
))
fn default_catalog_for_provider_auth() -> Arc<Catalog> {
Arc::new(fabro_llm::default_catalog())
}
pub(crate) fn provider_display_name(provider: &ProviderId, catalog: &Catalog) -> String {
catalog.provider(provider).map_or_else(
|| provider.display_name(),
|provider| provider.display_name.clone(),
catalog::provider(catalog, provider.as_str()).map_or_else(
|| provider.to_string(),
|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<Catalog>,
) -> Result<()> {
api_key_catalog_provider(provider, catalog.as_ref())?;
let client = LlmClient::from_credentials(
vec![ApiCredential::from_api_key(
provider.clone(),
api_key.to_string(),
catalog.as_ref(),
)?],
Arc::clone(&catalog),
let outcome = probe::probe_provider_with_api_key(
Catalog::clone(&catalog),
provider,
api_key.to_string(),
std::time::Duration::from_secs(30),
)
.await
.context("failed to create LLM client")?;
let probe_model = catalog.probe_for_provider(provider).map_or_else(
|| format!("unknown-{provider}"),
|model| model.id.to_string(),
);
let params = GenerateParams::new(probe_model, Arc::new(client))
.provider(provider.to_string())
.prompt("Say OK")
.max_tokens(16);
let response = timeout(std::time::Duration::from_secs(30), generate(params))
.await
.context("API key validation timed out")?;
response
.map(|_| ())
.context("API key validation request failed")
.map_err(|err| match err {
ApiKeyProbeError::Setup(err) => {
anyhow::Error::new(err).context("failed to create LLM client")
}
other => anyhow::Error::msg(other.to_string()),
})?;
match outcome.status {
ModelTestStatus::Ok => Ok(()),
ModelTestStatus::Error => Err(anyhow::anyhow!(
"API key validation request failed: {}",
outcome
.error_message
.unwrap_or_else(|| "unknown error".to_string())
)),
}
}
fn normalize_api_key_input(raw: &str) -> Result<String> {
@ -193,7 +184,7 @@ async fn read_and_validate_api_key(
}
pub(crate) async fn pick_auth_method(provider: &ProviderId) -> Result<AuthMethod> {
if provider != &ProviderId::openai() {
if provider != &provider_ids::openai() {
return Ok(AuthMethod::ApiKey);
}
@ -212,7 +203,7 @@ pub(crate) async fn authenticate_provider(
s: &Styles,
printer: Printer,
) -> Result<LoginResult> {
authenticate_provider_with_catalog(provider, s, printer, default_catalog_for_provider_auth()?)
authenticate_provider_with_catalog(provider, s, printer, default_catalog_for_provider_auth())
.await
}
@ -238,7 +229,7 @@ pub(crate) async fn authenticate_provider_with_api_key_source(
source,
s,
printer,
default_catalog_for_provider_auth()?,
default_catalog_for_provider_auth(),
)
.await
}
@ -269,7 +260,7 @@ pub(crate) async fn authenticate_provider_with_method(
method,
s,
printer,
default_catalog_for_provider_auth()?,
default_catalog_for_provider_auth(),
)
.await
}
@ -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");

View file

@ -333,7 +333,7 @@ fn exec_accepts_configured_custom_provider_from_settings() {
let context = test_context!();
context.write_home(
".fabro/settings.toml",
"_version = 1\n\n[llm.providers.acme-aws]\nadapter = \"openai_compatible\"\nagent_profile = \"openai\"\nbase_url = \"https://bedrock.example.invalid/v1\"\n\n[llm.providers.acme-aws.auth]\ncredentials = [\"env:ACME_AWS_API_KEY\"]\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n",
"_version = 1\n\n[llm.providers.acme-aws]\ndisplay_name = \"Acme AWS\"\nadapter = \"openai-compatible\"\ncodec = \"openai-chat\"\nbase_url = \"https://bedrock.example.invalid/v1\"\nauth = { type = \"bearer\" }\nallow_passthrough = true\n\n[llm.providers.acme-aws.metadata.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"
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,17 +24,16 @@ use fabro_install::{
write_github_app_settings, write_object_store_settings, write_sandbox_settings,
write_token_settings,
};
use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{GenerateParams, generate};
use fabro_model::catalog::CatalogProvider;
use fabro_model::{Catalog, ProviderId};
use fabro_llm::catalog as llm_catalog;
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus};
use fabro_sandbox::daytona;
use fabro_static::EnvVars;
use fabro_store::ArtifactStore;
use fabro_types::ServerSettings;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::settings::server::ObjectStoreSettings;
use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label};
use fabro_types::{ProviderId, ServerSettings};
use fabro_util::version::FABRO_VERSION;
use fabro_util::{Home, session_secret};
use fabro_vault::SecretType as VaultSecretType;
@ -97,9 +96,8 @@ const REDACTED_SECRET_VALUE: &str = "[REDACTED]";
const VALIDATION_TIMEOUT: Duration = Duration::from_secs(20);
const VALIDATION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
static INSTALL_CATALOG: LazyLock<Arc<Catalog>> = LazyLock::new(|| {
Arc::new(Catalog::from_builtin().expect("embedded install model catalog should be valid"))
});
static INSTALL_CATALOG: LazyLock<Arc<Catalog>> =
LazyLock::new(|| Arc::new(fabro_llm::default_catalog()));
impl InstallAppState {
#[must_use]
@ -848,24 +846,21 @@ async fn put_install_llm(
}
fn install_catalog_provider(provider: &ProviderId) -> Result<&'static CatalogProvider, String> {
let catalog_provider = INSTALL_CATALOG
.provider(provider)
let 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<String, String> {
install_catalog_provider(provider)?;
INSTALL_CATALOG
.provider_vault_secret_name(provider)
.map(str::to_string)
let catalog_provider = install_catalog_provider(provider)?;
fabro_auth::expected_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<String> {
) -> anyhow::Result<Catalog> {
let Some(base_url) = state.upstreams.provider_base_urls.get(provider.id()) else {
return Ok(Catalog::clone(&INSTALL_CATALOG));
};
let overlay = fabro_config::LlmLayer(
toml::from_str(&format!(
"[providers.{}]\nbase_url = {}\n",
toml_key(provider.id().as_str()),
toml::Value::String(base_url.clone())
))
.context("install provider base URL overlay should parse")?,
);
fabro_llm::build_catalog(&overlay, &|_| None)
.context("install catalog with provider base URL override should build")
}
fn toml_key(key: &str) -> String {
if key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
key.to_string()
} else {
format!("{key:?}")
}
}
#[cfg(test)]
fn provider_base_url_override(state: &InstallAppState, provider: &CatalogProvider) -> String {
state
.upstreams
.provider_base_urls
.get(&provider.id)
.get(provider.id())
.cloned()
.or_else(|| provider.base_url.clone())
.unwrap_or_else(|| provider.base_url().to_string())
}
async fn validate_github_token(state: &InstallAppState, token: &str) -> anyhow::Result<String> {
@ -2343,7 +2341,6 @@ mod tests {
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_config::{Storage, envfile};
use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV};
use fabro_model::{Catalog, ProviderId};
use fabro_static::EnvVars;
use fabro_vault::SecretType as VaultSecretType;
use object_store::Error as ObjectStoreError;
@ -2356,9 +2353,9 @@ mod tests {
InstallObjectStoreState, InstallSandboxProviderState, InstallSandboxState,
InstallTokenQuery, LlmProvidersInput, PendingInstall, ServerConfigInput, ServerSecrets,
build_github_app_manifest, classify_object_store_validation_error, detect_canonical_url,
install_object_store_lookup, lock_unpoisoned, post_install_finish,
provider_base_url_override, resolve_install_object_store_state, token_is_valid,
write_artifact_store_metadata,
install_catalog_provider, install_object_store_lookup, lock_unpoisoned,
post_install_finish, provider_base_url_override, resolve_install_object_store_state,
token_is_valid, write_artifact_store_metadata,
};
#[test]
@ -2570,25 +2567,25 @@ mod tests {
#[test]
fn install_provider_base_url_falls_back_to_catalog_base_url() {
let state = InstallAppState::for_test("expected");
let catalog = Catalog::builtin();
let provider = catalog.provider(&ProviderId::openai()).unwrap();
let provider = install_catalog_provider(&fabro_types::provider_ids::openai()).unwrap();
assert_eq!(
provider_base_url_override(&state, provider).as_deref(),
Some("https://api.openai.com/v1")
provider_base_url_override(&state, provider),
"https://api.openai.com"
);
}
#[test]
fn install_provider_base_url_prefers_state_override() {
let state = InstallAppState::for_test("expected")
.with_provider_base_url(ProviderId::openai(), "https://proxy.example.com/v1");
let catalog = Catalog::builtin();
let provider = catalog.provider(&ProviderId::openai()).unwrap();
let state = InstallAppState::for_test("expected").with_provider_base_url(
fabro_types::provider_ids::openai(),
"https://proxy.example.com/v1",
);
let provider = install_catalog_provider(&fabro_types::provider_ids::openai()).unwrap();
assert_eq!(
provider_base_url_override(&state, provider).as_deref(),
Some("https://proxy.example.com/v1")
provider_base_url_override(&state, provider),
"https://proxy.example.com/v1"
);
}

View file

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

View file

@ -15,8 +15,9 @@ use fabro_config::{
use fabro_github::token_source::{InstallationTokenSource, ResolvedToken, TokenSnapshot};
use fabro_graphviz::graph::{Graph, is_llm_handler_type};
use fabro_graphviz::render::apply_direction;
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe};
use fabro_model::{Catalog, ProviderId};
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::probe::{self, ModelTestStatus};
use fabro_llm::{FabroClient, catalog};
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::from_environment::{
daytona_config_from_environment, docker_config_from_environment,
@ -30,7 +31,8 @@ use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{EnvironmentProvider, McpServerSettings, RunGoal, RunNamespace};
use fabro_types::{
ManifestPath, RunId, RunNoticeLevel, SandboxProviderKind, ServerSettings, WorkflowSettings,
ManifestPath, ProviderId, RunId, RunNoticeLevel, SandboxProviderKind, ServerSettings,
WorkflowSettings,
};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
@ -48,7 +50,6 @@ use tokio::time;
use crate::run_compiler;
use crate::server::AppState;
use crate::server_secrets::LlmClientResult;
#[derive(Clone)]
pub(crate) struct PreparedManifest {
@ -227,7 +228,7 @@ pub(crate) async fn run_preflight(
state: &AppState,
prepared: &PreparedManifest,
validated: &Validated,
llm_result: Result<LlmClientResult>,
llm_result: Result<FabroClient>,
) -> Result<(types::PreflightResponse, bool)> {
let (report, checks_ok) =
build_preflight_report(state, prepared, validated, llm_result).await?;
@ -401,7 +402,7 @@ async fn build_preflight_report(
state: &AppState,
prepared: &PreparedManifest,
validated: &Validated,
llm_result: Result<LlmClientResult>,
llm_result: Result<FabroClient>,
) -> Result<(CheckReport, bool)> {
let graph = validated.graph();
let mut checks = base_preflight_checks(prepared, graph);
@ -421,7 +422,7 @@ async fn build_preflight_report(
let catalog = state.catalog();
let ready_providers = llm_result
.as_ref()
.map(LlmClientResult::provider_ids)
.map(FabroClient::provider_ids)
.unwrap_or_default();
let materialized = materialize_run_with_ready_providers(
prepared.settings.clone(),
@ -1071,7 +1072,7 @@ async fn run_llm_check(
model: &str,
default_provider: &str,
catalog: &Catalog,
llm_result: Result<LlmClientResult>,
llm_result: Result<FabroClient>,
) -> bool {
let mut model_providers = std::collections::BTreeSet::new();
let mut has_llm_nodes = false;
@ -1093,7 +1094,7 @@ async fn run_llm_check(
match llm_result {
Ok(result) => {
let auth_issues = result.auth_issues;
let registration_issues = result.registration_issues;
let registration_issues = result.build_issues;
let client = Arc::new(result.client);
let mut all_ok = true;
@ -1123,9 +1124,9 @@ async fn run_llm_check(
status: CheckStatus::Warning,
summary: model_id.clone(),
details: vec![CheckDetail::new(format!("Provider: {provider_name}"))],
remediation: Some(issue.error.to_string()),
remediation: Some(issue.cause.to_string()),
}));
} else if !client.has_provider(provider_name) {
} else if !client.available_providers().contains(&provider_id) {
all_ok = false;
completed_checks.push((index, CheckResult {
name: "LLM".into(),
@ -1149,9 +1150,12 @@ async fn run_llm_check(
.map(|probe| {
let client = Arc::clone(&client);
async move {
let outcome =
run_basic_model_probe(&probe.model_id, &probe.provider_name, client)
.await;
let outcome = probe::run_basic_probe(
&client,
&format!("{}/{}", probe.provider_name, probe.model_id),
Duration::from_secs(fabro_types::ModelTestMode::Basic.timeout_secs()),
)
.await;
let (status, remediation) = if outcome.status == ModelTestStatus::Ok {
(CheckStatus::Pass, None)
} else {
@ -1206,10 +1210,8 @@ async fn run_llm_check(
}
fn canonical_provider_id(catalog: &Catalog, provider_name: &str) -> ProviderId {
let provider_id = ProviderId::from(provider_name);
catalog
.provider(&provider_id)
.map_or(provider_id, |provider| provider.id.clone())
catalog::canonical_provider_id(catalog, provider_name)
.unwrap_or_else(|| ProviderId::new(provider_name))
}
async fn run_github_token_check(
@ -1664,8 +1666,7 @@ fn report_to_api(report: &CheckReport) -> types::PreflightCheckReport {
#[cfg(test)]
mod tests {
use fabro_model::ProviderId;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_types::ProviderId;
use fabro_workflow::run_materialization::materialize_run;
use super::*;
@ -1757,18 +1758,13 @@ mod tests {
}
fn test_catalog() -> Arc<Catalog> {
Arc::new(Catalog::from_builtin().unwrap())
Arc::new(fabro_llm::test_support::test_catalog())
}
fn openrouter_catalog() -> Catalog {
let overrides = toml::from_str(
r"
[providers.openrouter]
enabled = true
",
fabro_llm::test_support::test_catalog_with_overlay(
"[providers.openrouter.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<fabro_types::settings::ModelRef> {
@ -1879,20 +1875,19 @@ enabled = true
) -> Arc<crate::server::AppState> {
let moonshot_url = server.url("/moonshot/v1");
let openrouter_url = server.url("/openrouter/v1");
let llm_catalog_settings: LlmCatalogSettings = toml::from_str(&format!(
r#"
crate::test_support::TestAppStateBuilder::new()
.llm_overlay_toml(&format!(
r#"
[providers.moonshot]
base_url = "{moonshot_url}"
[providers.openrouter]
base_url = "{openrouter_url}"
[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(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,8 +20,8 @@ use fabro_agent::{
use fabro_api::types::{
CreateRunSessionRequest, PaginatedEventList, PaginationMeta, SubmitTurnRequest,
};
use fabro_llm::types::ToolDefinition;
use fabro_model::{AgentProfileKind, Catalog, ModelSelectionError, ProviderId, catalog};
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::{FabroClient, ModelSelectionError, catalog, selection};
use fabro_sandbox::reconnect::reconnect_for_run;
use fabro_static::EnvVars;
use fabro_store::{
@ -35,7 +35,10 @@ use fabro_types::run_event::{
RunSessionTurnSucceededProps, RunSessionUserMessageProps,
};
use fabro_types::settings::ModelRef as SettingsModelRef;
use fabro_types::{EventBody, EventEnvelope, RunEvent, RunId, SessionDetail, SessionId, TurnId};
use fabro_types::{
AgentProfileKind, EventBody, EventEnvelope, ProviderId, RunEvent, RunId, SessionDetail,
SessionId, ToolDefinition, TurnId,
};
use fabro_workflow::handler::llm::api::register_named_fabro_run_tools;
use fabro_workflow::services::FabroRunToolServices;
use serde_json::Value;
@ -52,7 +55,6 @@ use super::super::{
};
use crate::error::ApiError;
use crate::principal_middleware::RequiredUser;
use crate::server_secrets::LlmClientResult;
use crate::worker_token::issue_worker_token;
const SESSION_SSE_BUFFER_CAPACITY: usize = 1024;
@ -685,12 +687,12 @@ async fn build_agent_session(
for (provider, issue) in &llm_result.auth_issues {
warn!(provider = %provider, error = %issue, "LLM provider unavailable due to auth issue");
}
for issue in &llm_result.registration_issues {
warn!(provider = %issue.provider, error = %issue.error, "LLM provider unavailable due to registration issue");
for issue in &llm_result.build_issues {
warn!(provider = %issue.provider, error = %issue.cause, "LLM provider unavailable due to build issue");
}
let (provider_id, model, profile_kind) =
selected_session_model(&catalog, &llm_result, session)?;
if !llm_result.client.has_provider(provider_id.as_str()) {
if !llm_result.has_provider(&provider_id) {
let message = format!("LLM credentials not configured for provider '{provider_id}'");
return if session.record.model.is_some() {
Err(AskFabroBuildError::ModelUnavailable(message))
@ -784,7 +786,7 @@ async fn build_agent_session(
fn selected_session_model(
catalog: &Catalog,
llm_result: &LlmClientResult,
llm_result: &FabroClient,
session: &ProjectedRunSession,
) -> Result<(ProviderId, String, AgentProfileKind), AskFabroBuildError> {
let eligible = llm_result
@ -792,23 +794,25 @@ fn selected_session_model(
.into_iter()
.collect::<std::collections::HashSet<_>>();
let record = &session.record;
let selected = catalog
.resolve_selection(record.model.as_deref(), record.provider.as_ref(), &eligible)
.map_err(|error| {
// A missing default with no provider pin means no LLM is
// configured at all; every other failure is about the requested
// model/provider.
if record.provider.is_none()
&& matches!(error, ModelSelectionError::NoDefaultModel { .. })
{
AskFabroBuildError::LlmUnconfigured(error.to_string())
} else {
AskFabroBuildError::ModelUnavailable(error.to_string())
}
})?;
let selected = selection::resolve_selection(
catalog,
record.model.as_deref(),
record.provider.as_ref(),
&eligible,
)
.map_err(|error| {
// A missing default with no provider pin means no LLM is
// configured at all; every other failure is about the requested
// model/provider.
if record.provider.is_none() && matches!(error, ModelSelectionError::NoDefaultModel { .. })
{
AskFabroBuildError::LlmUnconfigured(error.to_string())
} else {
AskFabroBuildError::ModelUnavailable(error.to_string())
}
})?;
let (provider_id, model) = (selected.provider, selected.model);
let profile_kind = catalog
.effective_agent_profile(&provider_id, Some(&model))
let profile_kind = catalog::agent_profile(catalog, provider_id.as_str(), Some(&model))
.ok_or_else(|| {
AskFabroBuildError::ModelUnavailable(format!(
"provider '{provider_id}' is not configured"
@ -825,31 +829,29 @@ fn canonical_session_model(
) -> Result<(ProviderId, String), ApiError> {
let explicit_provider = explicit_provider
.map(|provider| {
catalog
.provider(provider)
.map(|provider| provider.id.clone())
.ok_or_else(|| {
session_selection_error(&ModelSelectionError::UnknownProvider {
provider: provider.clone(),
})
catalog::canonical_provider_id(catalog, provider.as_str()).ok_or_else(|| {
session_selection_error(&ModelSelectionError::UnknownProvider {
provider: provider.to_string(),
})
})
})
.transpose()?;
let Some(requested) = requested else {
let selected = catalog
.resolve_selection(None, explicit_provider.as_ref(), eligible)
.map_err(|error| session_selection_error(&error))?;
let selected =
selection::resolve_selection(catalog, None, explicit_provider.as_ref(), eligible)
.map_err(|error| session_selection_error(&error))?;
return Ok((selected.provider, selected.model));
};
let requested = requested.trim();
if requested.is_empty() {
return Err(ApiError::bad_request("Session model must not be empty."));
}
if catalog::legacy_builtin_model(requested).is_some() {
let selected = catalog
.resolve_selection(Some(requested), explicit_provider.as_ref(), eligible)
.map_err(|error| session_selection_error(&error))?;
return Ok((selected.provider, selected.model));
// An aggregator's wire id (`openai/gpt-5.6-sol` on OpenRouter) is matched
// whole on a pinned provider before its prefix is read as a provider.
if let Some(explicit) = explicit_provider.as_ref().filter(|p| eligible.contains(*p)) {
if let Some(entry) = catalog::model_on_provider(catalog, explicit.as_str(), requested) {
return Ok((explicit.clone(), entry.model.id().to_string()));
}
}
let model_ref = requested
.parse::<SettingsModelRef>()
@ -857,15 +859,16 @@ fn canonical_session_model(
.qualify(catalog);
let (qualified_provider, selector) = match model_ref {
SettingsModelRef::Qualified { provider, selector } => {
let requested_provider = ProviderId::new(provider);
let provider = catalog
.provider(&requested_provider)
.map(|provider| provider.id.clone())
.ok_or_else(|| {
session_selection_error(&ModelSelectionError::UnknownProvider {
provider: requested_provider,
})
})?;
let provider = catalog::canonical_provider_id(catalog, &provider).ok_or_else(|| {
session_selection_error(&ModelSelectionError::UnknownProvider { provider })
})?;
// When the prefixed provider is not ready, the whole string may
// still be an eligible aggregator's wire id for the same model.
if explicit_provider.is_none() && !eligible.contains(&provider) {
if let Some(found) = api_model_on_eligible(catalog, requested, eligible) {
return Ok(found);
}
}
if let Some(explicit) = explicit_provider.as_ref() {
if explicit != &provider {
return Err(ApiError::bad_request(format!(
@ -877,10 +880,8 @@ fn canonical_session_model(
(Some(provider), selector)
}
SettingsModelRef::Bare(selector) => {
if explicit_provider.is_none()
&& catalog.provider(&ProviderId::new(&selector)).is_some()
{
let detail = if catalog.is_model_selector(&selector) {
if explicit_provider.is_none() && catalog::is_provider_selector(catalog, &selector) {
let detail = if catalog::is_model_selector(catalog, &selector) {
format!(
"Session model reference '{selector}' is ambiguous between a provider and \
a model selector; supply `provider` or use `provider:model`."
@ -896,12 +897,28 @@ fn canonical_session_model(
}
};
let provider = qualified_provider.as_ref().or(explicit_provider.as_ref());
let selected = catalog
.resolve_selection(Some(&selector), provider, eligible)
let selected = selection::resolve_selection(catalog, Some(&selector), provider, eligible)
.map_err(|error| session_selection_error(&error))?;
Ok((selected.provider, selected.model))
}
/// The highest-priority eligible provider offering `api_model` as a wire id.
fn api_model_on_eligible(
catalog: &Catalog,
api_model: &str,
eligible: &std::collections::HashSet<ProviderId>,
) -> Option<(ProviderId, String)> {
catalog::enabled_providers(catalog)
.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<Catalog>> {
self.inner.catalog()
}
@ -1486,19 +1503,17 @@ mod tests {
use fabro_agent::config::ToolAccess;
use fabro_agent::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource};
use fabro_llm::types::{ToolCall, ToolDefinition};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_types::test_support;
use fabro_types::{ToolCall, ToolDefinition, test_support};
use super::*;
fn stub_tool(name: &str) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: name.to_string(),
description: format!("{name} test tool"),
parameters: serde_json::json!({"type": "object"}),
},
definition: ToolDefinition::function(
name.to_string(),
format!("{name} test tool"),
serde_json::json!({"type": "object"}),
),
executor: Arc::new(|_args, _ctx: ToolContext| {
Box::pin(async { Ok("ok".to_string()) })
}),
@ -1528,59 +1543,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!({})),
&registry,
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);

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"

View file

@ -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());

View file

@ -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,
};

View file

@ -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);
}

View file

@ -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<T: 'static, U: 'static>() {

View file

@ -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::<ApiCostSource, CostSource>();
assert_same_type::<ApiCost, Cost>();
}
#[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::<ApiCostSource>(json!(wire)).unwrap(),
source
);
}
}
assert_eq!(
serde_json::from_value::<ApiCostSource>(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::<ApiCost>(json).unwrap(), cost);
}
fn assert_same_type<T: 'static, U: 'static>() {

View file

@ -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);
}

View file

@ -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);

View file

@ -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::<ApiModelFeatures, ModelFeatures>();
}
#[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<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -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);

View file

@ -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"])

View file

@ -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]

View file

@ -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::<ApiModel, Model>();
fn provider_id_and_model_handle_reuse_lithos_types() {
assert_same_type::<ApiProviderId, ProviderId>();
assert_same_type::<ApiModelHandle, ModelHandle>();
}
#[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::<ProviderId>(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<T: 'static, U: 'static>() {

View file

@ -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);

View file

@ -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]

View file

@ -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,

View file

@ -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!(

View file

@ -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;

View file

@ -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" }

View file

@ -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;

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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];

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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];

View file

@ -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];

View file

@ -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;
}

View file

@ -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<CompletionContentPart>;
/**
* 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<CompletionResponseWarningsInner>;
'raw'?: any;
}

View file

@ -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];

View file

@ -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];

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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];

View file

@ -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<CompletionMessage>;
/**
* 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<CompletionToolDefinition>;
'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; };
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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];

View file

@ -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];

View file

@ -19,6 +19,7 @@
*/
export const ReasoningEffort = {
MINIMAL: 'minimal',
LOW: 'low',
MEDIUM: 'medium',
HIGH: 'high',