mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-10 22:43:37 +00:00
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:
parent
82bcafcfca
commit
75e5f34c0d
95 changed files with 2122 additions and 2447 deletions
|
|
@ -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}');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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(", ")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
"#);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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!({})),
|
||||
®istry,
|
||||
Arc::clone(&sandbox),
|
||||
None,
|
||||
|
|
@ -2050,12 +2034,13 @@ reasoning = false
|
|||
.await;
|
||||
|
||||
assert!(result.is_error, "{tool_name} should be blocked");
|
||||
let output = fabro_types::tool_result_to_json(&result);
|
||||
assert!(
|
||||
result
|
||||
.content
|
||||
output
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("denied by tool access policy")
|
||||
.contains("denied by tool access policy"),
|
||||
"{output}"
|
||||
);
|
||||
}
|
||||
assert_eq!(executions.load(Ordering::SeqCst), 0);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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("")),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>() {
|
||||
|
|
|
|||
|
|
@ -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>() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
26
lib/packages/fabro-api-client/src/models/completion-cost.ts
generated
Normal file
26
lib/packages/fabro-api-client/src/models/completion-cost.ts
generated
Normal 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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
32
lib/packages/fabro-api-client/src/models/completion-response-format.ts
generated
Normal file
32
lib/packages/fabro-api-client/src/models/completion-response-format.ts
generated
Normal 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];
|
||||
20
lib/packages/fabro-api-client/src/models/completion-response-warnings-inner.ts
generated
Normal file
20
lib/packages/fabro-api-client/src/models/completion-response-warnings-inner.ts
generated
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
31
lib/packages/fabro-api-client/src/models/completion-tool-definition-kind.ts
generated
Normal file
31
lib/packages/fabro-api-client/src/models/completion-tool-definition-kind.ts
generated
Normal 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];
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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; };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
29
lib/packages/fabro-api-client/src/models/model-handle.ts
generated
Normal file
29
lib/packages/fabro-api-client/src/models/model-handle.ts
generated
Normal 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;
|
||||
}
|
||||
17
lib/packages/fabro-api-client/src/models/provider.ts
generated
17
lib/packages/fabro-api-client/src/models/provider.ts
generated
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
*/
|
||||
|
||||
export const ReasoningEffort = {
|
||||
MINIMAL: 'minimal',
|
||||
LOW: 'low',
|
||||
MEDIUM: 'medium',
|
||||
HIGH: 'high',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue