mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Merge remote-tracking branch 'origin/main' into fabro/run/01KY7Y01REECZ24XXTMBZ3PPV9
This commit is contained in:
commit
b3e085dc45
147 changed files with 5667 additions and 1575 deletions
|
|
@ -12,7 +12,7 @@ digraph ImplementPlan {
|
|||
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="openai/gpt-5.6-sol", provider="openrouter", reasoning_effort="xhigh"]
|
||||
simplify_fable [label="Simplify (Claude Fable 5)", prompt="@prompts/simplify.md", model="anthropic/claude-fable-5", provider="openrouter", reasoning_effort="xhigh"]
|
||||
simplify_sol [label="Simplify (GPT-5.6 Sol)", prompt="@prompts/simplify.md", model="openai/gpt-5.6-sol", provider="openrouter", reasoning_effort="max"]
|
||||
verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
|
||||
verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", timeout="20m", goal_gate=true, retry_target="fixup"]
|
||||
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", model="anthropic/claude-fable-5", provider="openrouter", reasoning_effort="xhigh", max_visits=3]
|
||||
|
||||
start -> toolchain
|
||||
|
|
|
|||
103
Cargo.lock
generated
103
Cargo.lock
generated
|
|
@ -2239,7 +2239,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-acp"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent-client-protocol-tokio",
|
||||
|
|
@ -2258,7 +2258,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-agent"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2300,7 +2300,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-api"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-automation",
|
||||
|
|
@ -2323,7 +2323,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2348,7 +2348,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-automation"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2367,11 +2367,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-build-support"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-checkpoint"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -2387,7 +2387,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-cli"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2489,7 +2489,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-client"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2518,7 +2518,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-config"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2547,7 +2547,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-core"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-types",
|
||||
|
|
@ -2562,7 +2562,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-db"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2574,7 +2574,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dev"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2593,7 +2593,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dump"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2607,7 +2607,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-environment"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2629,7 +2629,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-github"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2651,7 +2651,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-graphviz"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -2666,7 +2666,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-hooks"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-agent",
|
||||
|
|
@ -2689,7 +2689,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-http"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
|
|
@ -2699,7 +2699,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-install"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2718,7 +2718,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-interview"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dialoguer",
|
||||
|
|
@ -2733,7 +2733,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-llm"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2774,7 +2774,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-macros"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-options-metadata",
|
||||
|
|
@ -2785,7 +2785,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-manifest"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-api",
|
||||
|
|
@ -2803,7 +2803,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2823,7 +2823,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-server"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2850,7 +2850,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-store"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-db",
|
||||
|
|
@ -2868,7 +2868,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-model"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
|
|
@ -2884,7 +2884,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-oauth"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2906,7 +2906,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-options-metadata"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2914,7 +2914,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
@ -2923,7 +2923,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-redact"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"ref-cast",
|
||||
|
|
@ -2939,7 +2939,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-sandbox"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2984,7 +2984,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3076,7 +3076,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-slack"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-interview",
|
||||
|
|
@ -3098,18 +3098,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-spa"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"rust-embed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-static"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-store"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
|
@ -3139,7 +3139,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-telemetry"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -3165,7 +3165,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-template"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -3179,7 +3179,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-test"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3204,7 +3204,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tool"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3225,7 +3225,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tracker"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3239,7 +3239,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-types"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -3261,7 +3261,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-util"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console 0.15.11",
|
||||
|
|
@ -3282,7 +3282,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-validate"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"fabro-acp",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -3295,7 +3295,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-variable"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3312,7 +3312,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-vault"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3331,7 +3331,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3369,6 +3369,7 @@ dependencies = [
|
|||
"fabro-util",
|
||||
"fabro-validate",
|
||||
"fabro-vault",
|
||||
"fabro-workflow",
|
||||
"futures",
|
||||
"git2",
|
||||
"hex",
|
||||
|
|
@ -8494,7 +8495,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-github"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -8513,7 +8514,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-openai"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ resolver = "2"
|
|||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.303.0-nightly.2"
|
||||
version = "0.303.0-nightly.4"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
|
|||
32
apps/fabro-web/app/lib/model-offerings.test.ts
Normal file
32
apps/fabro-web/app/lib/model-offerings.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
modelOfferingKey,
|
||||
modelOfferingTestArgs,
|
||||
} from "./model-offerings";
|
||||
|
||||
describe("model offering identity", () => {
|
||||
const openai = { id: "portable-model", provider: "openai" };
|
||||
const openrouter = { id: "portable-model", provider: "openrouter" };
|
||||
|
||||
test("keeps duplicate model IDs in independent row state", () => {
|
||||
expect(modelOfferingKey(openai)).not.toBe(modelOfferingKey(openrouter));
|
||||
|
||||
const state = new Map([
|
||||
[modelOfferingKey(openai), "ok"],
|
||||
[modelOfferingKey(openrouter), "error"],
|
||||
]);
|
||||
expect(state.get(modelOfferingKey(openai))).toBe("ok");
|
||||
expect(state.get(modelOfferingKey(openrouter))).toBe("error");
|
||||
});
|
||||
|
||||
test("includes the row provider in model-test request arguments", () => {
|
||||
expect(modelOfferingTestArgs(openai)).toEqual([
|
||||
"portable-model",
|
||||
"openai",
|
||||
]);
|
||||
expect(modelOfferingTestArgs(openrouter)).toEqual([
|
||||
"portable-model",
|
||||
"openrouter",
|
||||
]);
|
||||
});
|
||||
});
|
||||
13
apps/fabro-web/app/lib/model-offerings.ts
Normal file
13
apps/fabro-web/app/lib/model-offerings.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { Model } from "@qltysh/fabro-api-client";
|
||||
|
||||
type ModelOfferingIdentity = Pick<Model, "provider" | "id">;
|
||||
|
||||
export function modelOfferingKey(model: ModelOfferingIdentity): string {
|
||||
return `${model.provider}\u0000${model.id}`;
|
||||
}
|
||||
|
||||
export function modelOfferingTestArgs(
|
||||
model: ModelOfferingIdentity,
|
||||
): [id: string, provider: string] {
|
||||
return [model.id, model.provider];
|
||||
}
|
||||
|
|
@ -28,6 +28,10 @@ import {
|
|||
} from "../components/runs-list/sort-header";
|
||||
import { Tooltip } from "../components/ui";
|
||||
import { formatContextWindow, formatTokensPerSecond } from "../lib/format";
|
||||
import {
|
||||
modelOfferingKey,
|
||||
modelOfferingTestArgs,
|
||||
} from "../lib/model-offerings";
|
||||
import { useDebouncedValue } from "../hooks/effects";
|
||||
|
||||
export function meta() {
|
||||
|
|
@ -277,30 +281,46 @@ function ModelsSection({ providers }: { providers: Provider[] }) {
|
|||
|
||||
const runSweep = useCallback(async () => {
|
||||
if (running) return;
|
||||
const ids = rows.map((r) => r.id);
|
||||
if (ids.length === 0) return;
|
||||
const offerings = rows.map((model) => ({
|
||||
id: model.id,
|
||||
provider: model.provider,
|
||||
key: modelOfferingKey(model),
|
||||
}));
|
||||
if (offerings.length === 0) return;
|
||||
|
||||
const seed = new Map<string, RowState>();
|
||||
for (const id of ids) seed.set(id, { phase: "queued" });
|
||||
for (const offering of offerings) {
|
||||
seed.set(offering.key, { phase: "queued" });
|
||||
}
|
||||
setResults(seed);
|
||||
setSweep({ done: 0, total: ids.length, ok: 0, failed: 0 });
|
||||
setSweep({ done: 0, total: offerings.length, ok: 0, failed: 0 });
|
||||
|
||||
let cursor = 0;
|
||||
const worker = async () => {
|
||||
while (cursor < ids.length) {
|
||||
while (cursor < offerings.length) {
|
||||
const i = cursor;
|
||||
cursor += 1;
|
||||
const id = ids[i];
|
||||
const offering = offerings[i];
|
||||
setResults((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, { phase: "running" });
|
||||
next.set(offering.key, { phase: "running" });
|
||||
return next;
|
||||
});
|
||||
|
||||
let outcome: RowState;
|
||||
try {
|
||||
const result = await apiData(() => modelsApi.testModel(id));
|
||||
if (result.status === "ok") {
|
||||
const result = await apiData(() =>
|
||||
modelsApi.testModel(...modelOfferingTestArgs(offering)),
|
||||
);
|
||||
if (
|
||||
result.provider !== offering.provider ||
|
||||
result.model_id !== offering.id
|
||||
) {
|
||||
outcome = {
|
||||
phase: "error",
|
||||
message: `Server tested unexpected offering ${result.provider}/${result.model_id}`,
|
||||
};
|
||||
} else if (result.status === "ok") {
|
||||
outcome = { phase: "ok" };
|
||||
} else if (result.status === "error") {
|
||||
outcome = {
|
||||
|
|
@ -319,7 +339,7 @@ function ModelsSection({ providers }: { providers: Provider[] }) {
|
|||
|
||||
setResults((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, outcome);
|
||||
next.set(offering.key, outcome);
|
||||
return next;
|
||||
});
|
||||
setSweep((prev) =>
|
||||
|
|
@ -336,7 +356,7 @@ function ModelsSection({ providers }: { providers: Provider[] }) {
|
|||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(TEST_CONCURRENCY, ids.length) }, () =>
|
||||
Array.from({ length: Math.min(TEST_CONCURRENCY, offerings.length) }, () =>
|
||||
worker(),
|
||||
),
|
||||
);
|
||||
|
|
@ -435,12 +455,12 @@ function ModelsSection({ providers }: { providers: Provider[] }) {
|
|||
<tbody>
|
||||
{rows.map((model) => (
|
||||
<ModelTableRow
|
||||
key={model.id}
|
||||
key={modelOfferingKey(model)}
|
||||
model={model}
|
||||
providerLabel={
|
||||
providerNameById.get(model.provider) ?? model.provider
|
||||
}
|
||||
state={results.get(model.id)}
|
||||
state={results.get(modelOfferingKey(model))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -468,7 +488,7 @@ function ModelTableRow({
|
|||
state: RowState | undefined;
|
||||
}) {
|
||||
return (
|
||||
<tr className="border-b border-line transition-colors last:border-b-0 hover:bg-overlay/40">
|
||||
<tr className="border-b border-line last:border-b-0">
|
||||
<td className="whitespace-nowrap px-3 py-2.5 text-fg-3">
|
||||
{providerLabel}
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -5452,7 +5452,9 @@ paths:
|
|||
operationId: listModels
|
||||
tags: [Models]
|
||||
summary: List Models
|
||||
description: Returns a paginated list of available LLM models from the built-in catalog.
|
||||
description: |
|
||||
Returns one row per provider/model offering from the catalog. Model IDs
|
||||
are unique within a provider; `(provider, id)` is the resource identity.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ModelProviderFilter"
|
||||
- $ref: "#/components/parameters/ModelQueryFilter"
|
||||
|
|
@ -5487,7 +5489,8 @@ paths:
|
|||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The model identifier.
|
||||
description: The canonical model ID or an alias.
|
||||
- $ref: "#/components/parameters/ModelTestProviderParam"
|
||||
- $ref: "#/components/parameters/ModelTestModeParam"
|
||||
responses:
|
||||
"200":
|
||||
|
|
@ -5996,6 +5999,17 @@ components:
|
|||
$ref: "#/components/schemas/ModelTestMode"
|
||||
example: basic
|
||||
|
||||
ModelTestProviderParam:
|
||||
name: provider
|
||||
in: query
|
||||
required: false
|
||||
description: |
|
||||
Pin the test to this provider's offering. When omitted, the server
|
||||
selects among ready providers by catalog priority.
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProviderId"
|
||||
example: openrouter
|
||||
|
||||
headers:
|
||||
XRequestId:
|
||||
description: >
|
||||
|
|
@ -7766,6 +7780,12 @@ components:
|
|||
$ref: "#/components/schemas/SessionStatus"
|
||||
model:
|
||||
type: ["string", "null"]
|
||||
description: Canonical model ID selected when the session was created.
|
||||
provider:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ProviderId"
|
||||
- type: "null"
|
||||
description: Provider selected when the session was created.
|
||||
active_turn:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SessionTurn"
|
||||
|
|
@ -7798,6 +7818,12 @@ components:
|
|||
$ref: "#/components/schemas/SessionStatus"
|
||||
model:
|
||||
type: ["string", "null"]
|
||||
description: Canonical model ID selected when the session was created.
|
||||
provider:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ProviderId"
|
||||
- type: "null"
|
||||
description: Provider selected when the session was created.
|
||||
active_turn:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SessionTurn"
|
||||
|
|
@ -7832,6 +7858,12 @@ components:
|
|||
$ref: "#/components/schemas/SessionStatus"
|
||||
model:
|
||||
type: ["string", "null"]
|
||||
description: Canonical model ID selected when the session was created.
|
||||
provider:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ProviderId"
|
||||
- type: "null"
|
||||
description: Provider selected when the session was created.
|
||||
active_turn:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SessionTurn"
|
||||
|
|
@ -7857,7 +7889,12 @@ components:
|
|||
type: string
|
||||
model:
|
||||
type: string
|
||||
description: Catalog model ID or alias, or provider-qualified provider/model reference. Stored as the canonical catalog model ID.
|
||||
description: |
|
||||
Catalog model ID or alias. The server selects among ready
|
||||
providers and stores the canonical model ID.
|
||||
provider:
|
||||
$ref: "#/components/schemas/ProviderId"
|
||||
description: Optional provider pin. Provider-qualified model references remain accepted for compatibility.
|
||||
|
||||
SubmitTurnRequest:
|
||||
type: object
|
||||
|
|
@ -8235,7 +8272,9 @@ components:
|
|||
example: 1.50
|
||||
|
||||
Model:
|
||||
description: An available LLM model from the built-in catalog.
|
||||
description: |
|
||||
One provider's offering of an LLM model. The `id` is unique within
|
||||
`provider`; `(provider, id)` is the stable resource identity.
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
|
|
@ -8255,7 +8294,7 @@ components:
|
|||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Unique model identifier.
|
||||
description: Canonical human-facing model ID, unique within the provider.
|
||||
example: "claude-opus-4-6"
|
||||
provider:
|
||||
$ref: "#/components/schemas/ProviderId"
|
||||
|
|
@ -8310,12 +8349,15 @@ components:
|
|||
type: object
|
||||
required:
|
||||
- model_id
|
||||
- provider
|
||||
- status
|
||||
properties:
|
||||
model_id:
|
||||
type: string
|
||||
description: The model identifier that was tested.
|
||||
description: The canonical model ID that was tested.
|
||||
example: "claude-opus-4-6"
|
||||
provider:
|
||||
$ref: "#/components/schemas/ProviderId"
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
|
|
@ -8406,7 +8448,7 @@ components:
|
|||
$ref: "#/components/schemas/CompletionMessage"
|
||||
model:
|
||||
type: string
|
||||
description: Model ID or alias. Server picks default if omitted.
|
||||
description: Model ID or alias. Server picks a ready-provider default if omitted.
|
||||
system:
|
||||
type: string
|
||||
description: System prompt (convenience; prepended as a system message).
|
||||
|
|
@ -8442,7 +8484,7 @@ components:
|
|||
description: Reasoning effort level.
|
||||
provider:
|
||||
type: string
|
||||
description: Provider to route to.
|
||||
description: Optional provider pin.
|
||||
provider_options:
|
||||
description: Provider-specific options.
|
||||
|
||||
|
|
@ -8459,12 +8501,15 @@ components:
|
|||
|
||||
CompletionResponse:
|
||||
type: object
|
||||
required: [id, model, message, stop_reason, usage]
|
||||
required: [id, model, provider, message, stop_reason, usage]
|
||||
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:
|
||||
|
|
@ -8518,7 +8563,10 @@ components:
|
|||
same format the model writes back via `write_workflow_file`.
|
||||
model:
|
||||
type: string
|
||||
description: Model id or alias. Server picks the default if omitted.
|
||||
description: Model ID or alias. Server picks a ready-provider default if omitted.
|
||||
provider:
|
||||
$ref: "#/components/schemas/ProviderId"
|
||||
description: Optional provider pin.
|
||||
|
||||
PaginatedSavedQueryList:
|
||||
description: Paginated list of saved queries.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ This is useful for workflows that prepare their own workspace, run against alrea
|
|||
|
||||
<Accordion title="Workflows">
|
||||
- Added `[run.clone]`, `[run.run_branch]`, and `[run.meta_branch]` settings for clone, branch, and metadata branch behavior
|
||||
- Added `[llm.providers]` and `[llm.models]` settings foundations for configurable provider and model catalog data
|
||||
- Added provider-scoped `[llm.providers.<id>.models.<slug>]` settings foundations for configurable provider and model catalog data
|
||||
- Added typed provider `extra_headers` settings for gateway-backed LLM providers
|
||||
</Accordion>
|
||||
|
||||
|
|
|
|||
|
|
@ -28,8 +28,7 @@ credentials = ["env:ACME_GATEWAY_API_KEY"]
|
|||
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
|
||||
x-portkey-config = { literal = "@bedrock-prod" }
|
||||
|
||||
[llm.models."team-code-large"]
|
||||
provider = "proxy"
|
||||
[llm.providers.proxy.models."team-code-large"]
|
||||
api_id = "provider-wire-model-name"
|
||||
default = true
|
||||
```
|
||||
|
|
|
|||
|
|
@ -11,6 +11,29 @@ No single model is best at everything. Fabro lets you assign the right model to
|
|||
|
||||
## Model catalog
|
||||
|
||||
Fabro keeps five related concepts separate:
|
||||
|
||||
- A **provider** serves requests, such as `openai` or `openrouter`.
|
||||
- A **model slug** is Fabro's canonical, human-facing model ID, such as `gpt-5.6-sol`.
|
||||
- An **alias** is another user-facing selector, such as `gpt-56-sol`.
|
||||
- A **family** is display and matching metadata. It does not affect routing identity.
|
||||
- An **API ID** is the opaque model string sent on the provider wire. Workflows should never reference it.
|
||||
|
||||
One provider's route to one model slug is an **offering**, identified by `(provider, model slug)`. The same slug and alias may appear on several providers. Within one provider, however, every slug or alias must identify exactly one offering.
|
||||
|
||||
For an unqualified selector, Fabro checks canonical slugs before aliases, filters the candidates to providers whose adapters are ready, and then chooses the highest provider priority. Equal priorities use canonical provider ID order. An explicit provider restricts selection to that provider and is a pin: if it is unavailable, Fabro reports the error instead of silently switching.
|
||||
|
||||
For example, the shared `gpt-56-sol` alias can be portable across direct OpenAI and OpenRouter offerings:
|
||||
|
||||
| Ready providers | Selector | Selected offering |
|
||||
|---|---|---|
|
||||
| OpenAI only | `gpt-56-sol` | `openai/gpt-5.6-sol` |
|
||||
| OpenRouter only | `gpt-56-sol` | `openrouter/gpt-5.6-sol` |
|
||||
| OpenAI and OpenRouter | `gpt-56-sol` | OpenAI, because it has higher priority |
|
||||
| Both, with `provider = "openrouter"` | `gpt-56-sol` | OpenRouter, because the provider is pinned |
|
||||
|
||||
Fabro performs this selection once when creating a run and persists the chosen provider and canonical slug in the run settings and graph. Resuming that run does not reconsider provider priority when credentials change. Runtime [model fallbacks](/execution/failures#model-fallbacks) are the separate mechanism for handling a later provider failure.
|
||||
|
||||
| Model | Provider | Aliases | Context | Cost (in/out per Mtok) | Speed |
|
||||
|---|---|---|---|---|---|
|
||||
| `claude-fable-5` | anthropic | `fable`, `claude-fable` | 1M | $10.00 / $50.00 | n/a |
|
||||
|
|
@ -46,7 +69,7 @@ Claude Fable 5 is available as an explicit model but is not the default Anthropi
|
|||
|
||||
## Configuring providers and models
|
||||
|
||||
Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Provider and model IDs are strings, so a server or project can add an OpenAI-compatible provider without a Fabro release.
|
||||
Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Models are nested under their provider, so two providers can expose the same model slug without overwriting each other.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[llm.providers.proxy]
|
||||
|
|
@ -62,8 +85,7 @@ credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
|
|||
x-portkey-api-key = "{{ env.PORTKEY_API_KEY }}"
|
||||
x-portkey-config = "@bedrock-prod"
|
||||
|
||||
[llm.models."team-code-large"]
|
||||
provider = "proxy"
|
||||
[llm.providers.proxy.models."team-code-large"]
|
||||
api_id = "provider-wire-model-name"
|
||||
agent_profile = "anthropic"
|
||||
display_name = "Team Code Large"
|
||||
|
|
@ -73,27 +95,26 @@ small_default = true
|
|||
aliases = ["team-code"]
|
||||
estimated_output_tps = 80
|
||||
|
||||
[llm.models."team-code-large".limits]
|
||||
[llm.providers.proxy.models."team-code-large".limits]
|
||||
context_window = 200000
|
||||
max_output = 32000
|
||||
|
||||
[llm.models."team-code-large".features]
|
||||
[llm.providers.proxy.models."team-code-large".features]
|
||||
tools = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
prompt_cache = true
|
||||
effort = true
|
||||
|
||||
[llm.models."team-code-large".controls]
|
||||
[llm.providers.proxy.models."team-code-large".controls]
|
||||
reasoning_effort = ["low", "medium", "high"]
|
||||
speed = ["fast"]
|
||||
|
||||
[llm.models."team-code-large".costs]
|
||||
[llm.providers.proxy.models."team-code-large".costs]
|
||||
input_cost_per_mtok = 1.50
|
||||
output_cost_per_mtok = 8.00
|
||||
cache_input_cost_per_mtok = 0.30
|
||||
|
||||
[llm.models."team-code-large".costs.speed.fast]
|
||||
[llm.providers.proxy.models."team-code-large".costs.speed.fast]
|
||||
input_cost_per_mtok = 3.00
|
||||
output_cost_per_mtok = 16.00
|
||||
cache_input_cost_per_mtok = 0.60
|
||||
|
|
@ -106,24 +127,27 @@ For [LiteLLM](/integrations/litellm), Fabro ships a disabled provider entry. Ena
|
|||
enabled = true
|
||||
base_url = "http://localhost:4000/v1"
|
||||
|
||||
[llm.models."litellm-gpt-5"]
|
||||
provider = "litellm"
|
||||
[llm.providers.litellm.models."litellm-gpt-5"]
|
||||
api_id = "gpt-5"
|
||||
display_name = "LiteLLM GPT-5"
|
||||
family = "litellm"
|
||||
default = true
|
||||
|
||||
[llm.models."litellm-gpt-5".limits]
|
||||
[llm.providers.litellm.models."litellm-gpt-5".limits]
|
||||
context_window = 128000
|
||||
max_output = 8192
|
||||
|
||||
[llm.models."litellm-gpt-5".features]
|
||||
[llm.providers.litellm.models."litellm-gpt-5".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
```
|
||||
|
||||
`api_id` is the model name sent to the provider API. Omit it when the Fabro model ID and provider model ID are the same.
|
||||
`api_id` is the opaque model name sent to that provider's API. It defaults to the exact model slug, so omit it when the two strings match. Fabro does not infer vendor prefixes or rewrite the value.
|
||||
|
||||
<Note>
|
||||
Historical built-in catalog keys that exposed provider API IDs remain accepted as compatibility selectors. Fabro normalizes a primary or node selector such as `openai/gpt-5.6-sol` to the canonical `gpt-5.6-sol` slug before normal provider-aware selection. With no provider pin, the highest-priority ready offering wins; a separate `provider = "openrouter"` pin selects the OpenRouter offering. Fabro also normalizes these keys in legacy top-level `[llm.models]` rows without rewriting the settings file.
|
||||
</Note>
|
||||
|
||||
Model roles are separate: `default = true` controls normal model selection for workflow execution, while `small_default = true` marks the provider's small/cheap utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's normal default.
|
||||
|
||||
|
|
@ -139,7 +163,7 @@ Provider fields in configuration, APIs, and model routing are provider ID string
|
|||
|
||||
### Poolside
|
||||
|
||||
Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same models are also available through the opt-in OpenRouter provider under vendor-namespaced IDs.
|
||||
Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same model slugs are also available through the opt-in OpenRouter provider; its vendor-namespaced strings remain provider-only `api_id` values.
|
||||
|
||||
### OpenRouter
|
||||
|
||||
|
|
@ -169,11 +193,11 @@ Fabro ships an Ollama provider definition that is disabled by default. Enable it
|
|||
enabled = true
|
||||
```
|
||||
|
||||
Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.models.<id>]` blocks for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`.
|
||||
Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.providers.ollama.models."<model-slug>"]` blocks for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`.
|
||||
|
||||
## Default models
|
||||
|
||||
When no model or provider is specified, Fabro checks configured provider credentials and chooses the first configured provider by catalog priority. If no provider credentials are configured, it uses the catalog's global default model. Each provider has a default model:
|
||||
When no model or provider is specified, Fabro chooses the default offering on the highest-priority ready provider. If no provider adapter is ready, run creation reports that no eligible offering is available. Each provider has its own default model:
|
||||
|
||||
| Provider | Default model |
|
||||
|---|---|
|
||||
|
|
@ -184,7 +208,7 @@ When no model or provider is specified, Fabro checks configured provider credent
|
|||
| `poolside` | `laguna-s-2.1` |
|
||||
| `zai` | `glm-4.7` |
|
||||
| `minimax` | `minimax-m2.5` |
|
||||
| `inception` | `mercury` |
|
||||
| `inception` | `mercury-2` |
|
||||
|
||||
## Using models in workflows
|
||||
|
||||
|
|
@ -221,7 +245,7 @@ fabro run docs/internal/demo/01-hello.fabro --model claude-opus-4-6
|
|||
fabro run docs/internal/demo/04-pipeline.fabro --model gemini-3.1-pro-preview
|
||||
```
|
||||
|
||||
These flags set the default model for all nodes that don't have an explicit model assigned via a stylesheet. The provider is automatically inferred from the model catalog — you only need `--provider` for models not in the catalog or to force a specific provider.
|
||||
These flags set the default model for all nodes that don't have an explicit model assigned via a stylesheet. Without `--provider`, Fabro selects among ready offerings by priority. Add `--provider` to pin an exact provider, including for an uncatalogued provider model string.
|
||||
|
||||
### Run config TOML
|
||||
|
||||
|
|
@ -247,7 +271,7 @@ Then launch with:
|
|||
fabro run run.toml
|
||||
```
|
||||
|
||||
The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Fabro tries them in order when the primary provider is unavailable.
|
||||
The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Fabro tries them in order when the primary provider is unavailable. In this field, qualified references keep their established provider-pin meaning: `"openai/gpt-5.6-sol"` selects the direct OpenAI offering.
|
||||
|
||||
<Note>
|
||||
The precedence order is: node-level stylesheet > run config TOML > CLI flags > server defaults. More specific settings always win.
|
||||
|
|
|
|||
|
|
@ -121,7 +121,15 @@ provider = "anthropic"
|
|||
fallbacks = ["gemini", "openai"]
|
||||
```
|
||||
|
||||
When Anthropic is unavailable, Fabro tries Gemini first, then OpenAI. Each fallback entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. For each fallback provider, Fabro selects the closest model by matching required capabilities (tool use, vision, reasoning) and minimizing cost difference.
|
||||
When Anthropic fails, Fabro tries Gemini first, then OpenAI. Fallback resolution is provider-aware:
|
||||
|
||||
- A bare provider token such as `"gemini"` selects that provider's closest compatible model.
|
||||
- A qualified selector such as `"openrouter/gpt-56-sol"` resolves only within that provider.
|
||||
- A bare model slug or alias considers ready providers and uses provider priority.
|
||||
|
||||
Qualified fallback references always remain provider pins, including strings that were historical built-in API IDs. For example, `"openai/gpt-5.6-sol"` pins the direct OpenAI offering.
|
||||
|
||||
The primary provider and model were already resolved and persisted when the run was created; resuming does not re-run primary selection. Fallbacks are only considered after an eligible runtime failure.
|
||||
|
||||
### What triggers failover
|
||||
|
||||
|
|
|
|||
|
|
@ -138,12 +138,16 @@ name = "claude-sonnet-4-5"
|
|||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `name` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). |
|
||||
| `provider` | Provider name (optional — auto-inferred from the model catalog). Only needed for models not in the catalog or to force a specific provider. |
|
||||
| `name` | Canonical model slug or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). |
|
||||
| `provider` | Optional provider pin. When omitted, Fabro selects among ready offerings by provider priority. When present, an unavailable provider is an error rather than permission to switch. |
|
||||
| `fallbacks` | Ordered list of model references to try when the primary is unavailable. Entries can be bare provider tokens (`"openai"`), bare model aliases, or qualified `"provider/model"` references. |
|
||||
|
||||
Provider values are catalog provider ID strings. Built-in IDs like `anthropic` and `openai` work, and settings-defined IDs like `proxy` work after they are added under `[llm.providers.<id>]`.
|
||||
|
||||
At run creation, Fabro resolves the primary selector and every node selector against the ready-provider snapshot. It persists the selected canonical model slug and provider, so resuming the run does not choose a different provider just because credentials or priorities changed. The configured fallback chain remains available for failures that occur while the materialized run is executing.
|
||||
|
||||
Historical built-in provider API IDs are accepted for compatibility and normalize before this selection. For example, `name = "openai/gpt-5.6-sol"` is treated as the canonical `gpt-5.6-sol` selector; omit `provider` to use readiness and priority, or set `provider` separately to pin an offering.
|
||||
|
||||
#### `[run.model.controls]`
|
||||
|
||||
Set default model controls for all nodes that do not override them in the workflow stylesheet:
|
||||
|
|
|
|||
|
|
@ -157,6 +157,6 @@ Bedrock-specific request fields pass through verbatim via `provider_options.bedr
|
|||
How Fabro routes model IDs, providers, and fallbacks.
|
||||
</Card>
|
||||
<Card title="Settings Configuration" icon="gear" href="/reference/user-configuration">
|
||||
Full reference for `[llm.providers.<id>]` and `[llm.models.<id>]`.
|
||||
Full reference for provider settings and provider-scoped model offerings.
|
||||
</Card>
|
||||
</Columns>
|
||||
|
|
|
|||
|
|
@ -24,18 +24,17 @@ _version = 1
|
|||
enabled = true
|
||||
base_url = "http://localhost:4000/v1"
|
||||
|
||||
[llm.models."litellm-gpt-5"]
|
||||
provider = "litellm"
|
||||
[llm.providers.litellm.models."litellm-gpt-5"]
|
||||
api_id = "gpt-5"
|
||||
display_name = "LiteLLM GPT-5"
|
||||
family = "litellm"
|
||||
default = true
|
||||
|
||||
[llm.models."litellm-gpt-5".limits]
|
||||
[llm.providers.litellm.models."litellm-gpt-5".limits]
|
||||
context_window = 128000
|
||||
max_output = 8192
|
||||
|
||||
[llm.models."litellm-gpt-5".features]
|
||||
[llm.providers.litellm.models."litellm-gpt-5".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
|
@ -94,18 +93,17 @@ digraph Example {
|
|||
Declare each LiteLLM-routed model explicitly so Fabro knows its provider, context window, tool support, and routing defaults:
|
||||
|
||||
```toml title="settings.toml"
|
||||
[llm.models."litellm-fast"]
|
||||
provider = "litellm"
|
||||
[llm.providers.litellm.models."litellm-fast"]
|
||||
api_id = "fast-model"
|
||||
display_name = "LiteLLM Fast"
|
||||
family = "litellm"
|
||||
aliases = ["fast"]
|
||||
|
||||
[llm.models."litellm-fast".limits]
|
||||
[llm.providers.litellm.models."litellm-fast".limits]
|
||||
context_window = 64000
|
||||
max_output = 4096
|
||||
|
||||
[llm.models."litellm-fast".features]
|
||||
[llm.providers.litellm.models."litellm-fast".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
|
@ -128,6 +126,6 @@ Only one model for a provider should set `default = true`. You may also mark one
|
|||
How Fabro routes model IDs, providers, and fallbacks.
|
||||
</Card>
|
||||
<Card title="Settings Configuration" icon="gear" href="/reference/user-configuration">
|
||||
Full reference for `[llm.providers.<id>]` and `[llm.models.<id>]`.
|
||||
Full reference for provider settings and provider-scoped model offerings.
|
||||
</Card>
|
||||
</Columns>
|
||||
|
|
|
|||
|
|
@ -44,34 +44,33 @@ export OPENROUTER_API_KEY=sk-or-v1-...
|
|||
|
||||
## Included models
|
||||
|
||||
The built-in catalog curates frontier and open-weights models under vendor-namespaced IDs:
|
||||
The built-in catalog gives OpenRouter offerings the same human-facing model slugs used by direct providers. Vendor-namespaced OpenRouter IDs remain opaque `api_id` values:
|
||||
|
||||
| Fabro model ID | Notes |
|
||||
| Fabro model slug | OpenRouter API ID / notes |
|
||||
| --- | --- |
|
||||
| `anthropic/claude-opus-4-7` | Claude via OpenRouter, Anthropic-style cache billing |
|
||||
| `anthropic/claude-sonnet-4-6` | Provider default |
|
||||
| `anthropic/claude-haiku-4-5` | Provider small default |
|
||||
| `openai/gpt-5.4`, `openai/gpt-5.5` | |
|
||||
| `google/gemini-3.1-pro-preview`, `google/gemini-3.5-flash` | |
|
||||
| `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | |
|
||||
| `moonshotai/kimi-k2.6`, `qwen/qwen3-coder`, `qwen/qwen3.6-flash` | |
|
||||
| `poolside/laguna-s-2.1`, `poolside/laguna-xs-2.1` | Poolside Laguna coding models with native reasoning and tool use |
|
||||
| `z-ai/glm-4.6`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro` | |
|
||||
| `nvidia/nemotron-3-super-120b-a12b`, `mistralai/devstral-2512` | |
|
||||
| `claude-opus-4-7` | `anthropic/claude-opus-4.7`; Anthropic-style cache billing |
|
||||
| `claude-sonnet-4-6` | `anthropic/claude-sonnet-4.6`; provider default |
|
||||
| `claude-haiku-4-5` | `anthropic/claude-haiku-4.5`; provider small default |
|
||||
| `gpt-5.4`, `gpt-5.5` | `openai/gpt-5.4`, `openai/gpt-5.5` |
|
||||
| `gemini-3.1-pro-preview`, `gemini-3.5-flash` | `google/...` API IDs |
|
||||
| `deepseek-v4-pro`, `deepseek-v4-flash` | `deepseek/...` API IDs |
|
||||
| `kimi-k2.6`, `qwen3-coder`, `qwen3.6-flash` | Vendor-prefixed API IDs |
|
||||
| `laguna-s-2.1`, `laguna-xs-2.1` | `poolside/...`; native reasoning and tool use |
|
||||
| `glm-4.6`, `minimax-m2.7`, `mimo-v2.5-pro` | Vendor-prefixed API IDs |
|
||||
| `nemotron-3-super-120b-a12b`, `devstral-2512` | Vendor-prefixed API IDs |
|
||||
|
||||
Any other OpenRouter model can be added as a settings model entry with `provider = "openrouter"` and the OpenRouter slug as `api_id`:
|
||||
Any other OpenRouter model can be added under the provider. Choose a stable Fabro model slug as the table key and put OpenRouter's exact vendor/model string in `api_id`:
|
||||
|
||||
```toml title="settings.toml"
|
||||
[llm.models."meta-llama/llama-4-maverick"]
|
||||
provider = "openrouter"
|
||||
[llm.providers.openrouter.models."llama-4-maverick"]
|
||||
api_id = "meta-llama/llama-4-maverick"
|
||||
display_name = "Llama 4 Maverick"
|
||||
family = "llama-4"
|
||||
|
||||
[llm.models."meta-llama/llama-4-maverick".limits]
|
||||
[llm.providers.openrouter.models."llama-4-maverick".limits]
|
||||
context_window = 1000000
|
||||
|
||||
[llm.models."meta-llama/llama-4-maverick".features]
|
||||
[llm.providers.openrouter.models."llama-4-maverick".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
|
@ -81,8 +80,8 @@ reasoning = false
|
|||
|
||||
```bash
|
||||
fabro model list --provider openrouter
|
||||
fabro model test --model anthropic/claude-sonnet-4-6
|
||||
fabro run workflow.fabro --model deepseek/deepseek-v4-flash
|
||||
fabro model test --provider openrouter --model claude-sonnet-4-6
|
||||
fabro run workflow.fabro --provider openrouter --model deepseek-v4-flash
|
||||
```
|
||||
|
||||
When targeting a non-default remote server, pass the same `--server` value to verification commands:
|
||||
|
|
@ -159,6 +158,6 @@ Fabro does not send OpenRouter's optional attribution headers (`HTTP-Referer`, `
|
|||
How Fabro routes model IDs, providers, and fallbacks.
|
||||
</Card>
|
||||
<Card title="Settings Configuration" icon="gear" href="/reference/user-configuration">
|
||||
Full reference for `[llm.providers.<id>]` and `[llm.models.<id>]`.
|
||||
Full reference for provider settings and provider-scoped model offerings.
|
||||
</Card>
|
||||
</Columns>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver
|
|||
|---|---|
|
||||
| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` |
|
||||
| Shared run defaults | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github.permissions]`, `[run.hooks]`, `[run.agent.mcps]` |
|
||||
| Shared LLM catalog | `[llm.providers.<id>]`, `[llm.models.<id>]`, model limits, features, controls, and costs |
|
||||
| Shared LLM catalog | `[llm.providers.<id>]`, provider-scoped `[llm.providers.<id>.models.<slug>]` offerings, limits, features, controls, and costs |
|
||||
| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
|
||||
|
||||
`[cli.*]` and `[server.*]` stanzas are owner-specific: they are only consumed from `~/.fabro/settings.toml` (plus process-local flags and env overrides). The same stanzas in `.fabro/project.toml` or `workflow.toml` remain schema-valid but runtime-inert.
|
||||
|
|
@ -97,23 +97,22 @@ credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
|
|||
x-portkey-api-key = "{{ env.PORTKEY_API_KEY }}"
|
||||
x-portkey-config = "@bedrock-prod"
|
||||
|
||||
[llm.models."team-code-large"]
|
||||
provider = "proxy"
|
||||
[llm.providers.proxy.models."team-code-large"]
|
||||
api_id = "provider-wire-model-name"
|
||||
agent_profile = "anthropic"
|
||||
display_name = "Team Code Large"
|
||||
default = true
|
||||
aliases = ["team-code"]
|
||||
|
||||
[llm.models."team-code-large".controls]
|
||||
[llm.providers.proxy.models."team-code-large".controls]
|
||||
reasoning_effort = ["low", "medium", "high"]
|
||||
speed = ["fast"]
|
||||
|
||||
[llm.models."team-code-large".costs]
|
||||
[llm.providers.proxy.models."team-code-large".costs]
|
||||
input_cost_per_mtok = 1.50
|
||||
output_cost_per_mtok = 8.00
|
||||
|
||||
[llm.models."team-code-large".costs.speed.fast]
|
||||
[llm.providers.proxy.models."team-code-large".costs.speed.fast]
|
||||
input_cost_per_mtok = 3.00
|
||||
output_cost_per_mtok = 16.00
|
||||
|
||||
|
|
@ -201,19 +200,20 @@ x-team-secret = "{{ secrets.gateway_team_secret }}"
|
|||
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>`, `env:<NAME>`, and `aws_sigv4` (sign requests from the AWS default credential chain — Bedrock). Literal secret strings are rejected. |
|
||||
| `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. |
|
||||
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values are interpolation strings: literal text, an `{{ env.NAME }}` token, or a `{{ secrets.NAME }}` token. Put credentials in a secret and reference them with a `{{ secrets.NAME }}` token, not a bare literal. |
|
||||
| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |
|
||||
| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection; ties use canonical provider ID. |
|
||||
| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. |
|
||||
| `aliases` | array<string> | `[]` | Additional provider names accepted by model routing and fallback config. |
|
||||
|
||||
## `[llm.models.<id>]`
|
||||
## `[llm.providers.<provider>.models.<model-slug>]`
|
||||
|
||||
Define or override a model in the catalog. The table key is the canonical
|
||||
model ID Fabro users reference; `api_id` is the model string sent to the
|
||||
provider API.
|
||||
Define or override one provider's offering of a model. The table key is the
|
||||
canonical model slug Fabro users reference. An offering's identity is the
|
||||
pair `(provider, model slug)`, so different providers may use the same slug
|
||||
and aliases. `api_id` is the opaque model string sent to this provider's API
|
||||
and defaults to the exact model slug.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[llm.models."team-code-large"]
|
||||
provider = "proxy"
|
||||
[llm.providers.proxy.models."team-code-large"]
|
||||
api_id = "provider-wire-model-name"
|
||||
agent_profile = "anthropic"
|
||||
display_name = "Team Code Large"
|
||||
|
|
@ -224,27 +224,27 @@ enabled = true
|
|||
aliases = ["team-code"]
|
||||
estimated_output_tps = 80
|
||||
|
||||
[llm.models."team-code-large".limits]
|
||||
[llm.providers.proxy.models."team-code-large".limits]
|
||||
context_window = 200000
|
||||
max_output = 32000
|
||||
|
||||
[llm.models."team-code-large".features]
|
||||
[llm.providers.proxy.models."team-code-large".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
prompt_cache = true
|
||||
|
||||
[llm.models."team-code-large".controls]
|
||||
[llm.providers.proxy.models."team-code-large".controls]
|
||||
reasoning_effort = ["low", "medium", "high"]
|
||||
speed = ["fast"]
|
||||
|
||||
[llm.models."team-code-large".costs]
|
||||
[llm.providers.proxy.models."team-code-large".costs]
|
||||
input_cost_per_mtok = 1.50
|
||||
output_cost_per_mtok = 8.00
|
||||
cache_input_cost_per_mtok = 0.30
|
||||
|
||||
[llm.models."team-code-large".costs.speed.fast]
|
||||
[llm.providers.proxy.models."team-code-large".costs.speed.fast]
|
||||
input_cost_per_mtok = 3.00
|
||||
output_cost_per_mtok = 16.00
|
||||
cache_input_cost_per_mtok = 0.60
|
||||
|
|
@ -252,8 +252,7 @@ cache_input_cost_per_mtok = 0.60
|
|||
|
||||
| Key | Type / values | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `provider` | string | None | Provider ID this model belongs to. |
|
||||
| `api_id` | string | model ID | Identifier sent to the provider API. |
|
||||
| `api_id` | string | model slug | Opaque identifier sent to this provider's API. An explicitly empty value is invalid. |
|
||||
| `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` | provider profile | Agent profile override for this model. Model overrides take precedence over provider overrides. |
|
||||
| `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | provider policy | Billing algorithm override for this model — for models whose billing family differs from their provider's (e.g. Claude served through OpenRouter bills Anthropic-style cache reads/writes). |
|
||||
| `display_name` | string | model ID | Human-readable model name. |
|
||||
|
|
@ -263,17 +262,17 @@ cache_input_cost_per_mtok = 0.60
|
|||
| `default` | boolean | `false` | Whether this is the provider default model. |
|
||||
| `probe` | boolean | `false` | Whether this model should be preferred for provider connectivity probes. Set `false` in a higher-precedence layer to clear an inherited probe marker. |
|
||||
| `enabled` | boolean | `true` | Set `false` to disable a model after lower-precedence layers define it. |
|
||||
| `aliases` | array<string> | `[]` | Additional model names accepted by routing and fallback config. |
|
||||
| `aliases` | array<string> | `[]` | Additional model selectors accepted by routing and fallback config. Aliases may repeat across providers, but one selector cannot identify two models within the same provider. |
|
||||
| `estimated_output_tps` | number | None | Estimated output tokens per second for catalog display and planning. |
|
||||
|
||||
## `[llm.models.<id>.limits]`
|
||||
## `[llm.providers.<provider>.models.<model-slug>.limits]`
|
||||
|
||||
| Key | Type / values | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `context_window` | integer | None | Maximum context window size in tokens. |
|
||||
| `max_output` | integer | None | Maximum output tokens, if known. |
|
||||
|
||||
## `[llm.models.<id>.features]`
|
||||
## `[llm.providers.<provider>.models.<model-slug>.features]`
|
||||
|
||||
| Key | Type / values | Default | Description |
|
||||
|---|---|---|---|
|
||||
|
|
@ -284,14 +283,14 @@ cache_input_cost_per_mtok = 0.60
|
|||
| `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. |
|
||||
| `sampling_params` | boolean | `true` | Whether the model accepts classic sampling parameters (`temperature`, `top_p`). |
|
||||
|
||||
## `[llm.models.<id>.controls]`
|
||||
## `[llm.providers.<provider>.models.<model-slug>.controls]`
|
||||
|
||||
| Key | Type / values | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `reasoning_effort` | array<string> | all standard levels when feature is `"levels"` or `"always_adaptive"` | User-facing reasoning effort values Fabro may send for this model. Can be set explicitly for reasoning models whose provider adapter maps effort to a non-native API shape. |
|
||||
| `speed` | array<string> | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. |
|
||||
|
||||
## `[llm.models.<id>.costs]`
|
||||
## `[llm.providers.<provider>.models.<model-slug>.costs]`
|
||||
|
||||
| Key | Type / values | Default | Description |
|
||||
|---|---|---|---|
|
||||
|
|
@ -299,10 +298,12 @@ cache_input_cost_per_mtok = 0.60
|
|||
| `output_cost_per_mtok` | number | None | Output cost in USD per million tokens. |
|
||||
| `cache_input_cost_per_mtok` | number | None | Cached input/read cost in USD per million tokens. |
|
||||
|
||||
## `[llm.models.<id>.costs.speed.<speed>]`
|
||||
## `[llm.providers.<provider>.models.<model-slug>.costs.speed.<speed>]`
|
||||
|
||||
Per-speed cost overrides use the same keys as `[llm.models.<id>.costs]`.
|
||||
Each `<speed>` key must be declared in `[llm.models.<id>.controls].speed`.
|
||||
Per-speed cost overrides use the same keys as
|
||||
`[llm.providers.<provider>.models.<model-slug>.costs]`. Each `<speed>` key
|
||||
must be declared in
|
||||
`[llm.providers.<provider>.models.<model-slug>.controls].speed`.
|
||||
The `standard` speed is implicit and always uses the base cost table.
|
||||
|
||||
## `[cli.updates]`
|
||||
|
|
|
|||
|
|
@ -36,13 +36,13 @@ pub trait AgentProfile: Send + Sync {
|
|||
}
|
||||
|
||||
fn knowledge_cutoff(&self) -> Option<String> {
|
||||
self.catalog()
|
||||
.and_then(|catalog| catalog.get(self.model()))
|
||||
self.catalog_model()
|
||||
.and_then(|m| m.knowledge_cutoff().map(str::to_string))
|
||||
}
|
||||
|
||||
fn catalog_model(&self) -> Option<&Model> {
|
||||
self.catalog().and_then(|catalog| catalog.get(self.model()))
|
||||
let catalog = self.catalog()?;
|
||||
catalog.get_on_provider(&self.provider_id(), self.model())
|
||||
}
|
||||
|
||||
fn context_window_size(&self) -> usize {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ use fabro_llm::types::{Request, Response};
|
|||
use fabro_mcp::config::McpServerSettings;
|
||||
#[cfg(test)]
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_model::{AgentProfileKind, Catalog, ModelHandle, ProviderId};
|
||||
use fabro_model::{AgentProfileKind, Catalog, ModelHandle, ModelSelectionError, ProviderId};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_vault::SecretStore;
|
||||
|
|
@ -255,7 +255,11 @@ fn parse_provider(args: &AgentArgs) -> anyhow::Result<ProviderId> {
|
|||
Ok(provider_str.parse()?)
|
||||
}
|
||||
|
||||
fn resolve_provider_id(catalog: &Catalog, args: &AgentArgs) -> anyhow::Result<ProviderId> {
|
||||
fn resolve_provider_id(
|
||||
catalog: &Catalog,
|
||||
args: &AgentArgs,
|
||||
eligible_providers: &std::collections::HashSet<ProviderId>,
|
||||
) -> anyhow::Result<ProviderId> {
|
||||
if args.provider.is_some() {
|
||||
let requested = parse_provider(args)?;
|
||||
return Ok(catalog
|
||||
|
|
@ -263,8 +267,10 @@ fn resolve_provider_id(catalog: &Catalog, args: &AgentArgs) -> anyhow::Result<Pr
|
|||
.map_or(requested, |provider| provider.id.clone()));
|
||||
}
|
||||
if let Some(model_id) = args.model.as_deref() {
|
||||
if let Some(model) = catalog.get(model_id) {
|
||||
return Ok(model.provider.clone());
|
||||
match catalog.select(model_id, None, eligible_providers) {
|
||||
Ok(model) => return Ok(model.provider.clone()),
|
||||
Err(ModelSelectionError::UnknownSelector { .. }) => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
let requested = parse_provider(args)?;
|
||||
|
|
@ -530,7 +536,7 @@ pub async fn run_with_args_and_client_and_catalog(
|
|||
// threads
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
|
||||
let provider_id = resolve_provider_id(&catalog, &args)?;
|
||||
let provider_id = resolve_provider_id(&catalog, &args, &client.provider_ids())?;
|
||||
ensure_provider_registered(&client, &provider_id)?;
|
||||
|
||||
if args.verbose {
|
||||
|
|
@ -550,6 +556,7 @@ pub async fn run_with_args_and_client_and_catalog(
|
|||
"provider '{provider_id}' has no default model in the catalog; pass --model explicitly"
|
||||
)
|
||||
})?
|
||||
.to_string()
|
||||
};
|
||||
let profile_kind = profile_kind_for_provider(&catalog, &provider_id, Some(&model))?;
|
||||
eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}")));
|
||||
|
|
@ -1089,7 +1096,7 @@ mod tests {
|
|||
};
|
||||
|
||||
assert_eq!(
|
||||
resolve_provider_id(&catalog, &args).unwrap(),
|
||||
resolve_provider_id(&catalog, &args, &catalog.all_provider_ids()).unwrap(),
|
||||
ProviderId::new("acme-aws")
|
||||
);
|
||||
}
|
||||
|
|
@ -1121,7 +1128,7 @@ mod tests {
|
|||
};
|
||||
|
||||
assert_eq!(
|
||||
resolve_provider_id(&catalog, &args).unwrap(),
|
||||
resolve_provider_id(&catalog, &args, &catalog.all_provider_ids()).unwrap(),
|
||||
ProviderId::new("acme-aws")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1742,9 +1742,9 @@ impl Session {
|
|||
let model = ModelRef {
|
||||
provider: self.provider_profile.provider_id(),
|
||||
model_id: if response.model.is_empty() {
|
||||
self.provider_profile.model().to_string()
|
||||
self.provider_profile.model().into()
|
||||
} else {
|
||||
response.model.clone()
|
||||
response.model.clone().into()
|
||||
},
|
||||
speed: self.config.speed,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,18 +17,18 @@ fn profile_context_window_matches_catalog_for_default_models() {
|
|||
|
||||
let profile: Box<dyn AgentProfile> = match provider.agent_profile {
|
||||
fabro_model::AgentProfileKind::OpenAi if provider.id == ProviderId::openai() => {
|
||||
Box::new(OpenAiProfile::new(model).with_catalog(Arc::clone(&catalog)))
|
||||
Box::new(OpenAiProfile::new(model.as_str()).with_catalog(Arc::clone(&catalog)))
|
||||
}
|
||||
fabro_model::AgentProfileKind::OpenAi => Box::new(
|
||||
OpenAiProfile::new(model)
|
||||
OpenAiProfile::new(model.as_str())
|
||||
.with_provider_id(provider.id.clone())
|
||||
.with_catalog(Arc::clone(&catalog)),
|
||||
),
|
||||
fabro_model::AgentProfileKind::Gemini => {
|
||||
Box::new(GeminiProfile::new(model).with_catalog(Arc::clone(&catalog)))
|
||||
Box::new(GeminiProfile::new(model.as_str()).with_catalog(Arc::clone(&catalog)))
|
||||
}
|
||||
fabro_model::AgentProfileKind::Anthropic => {
|
||||
Box::new(AnthropicProfile::new(model).with_catalog(Arc::clone(&catalog)))
|
||||
Box::new(AnthropicProfile::new(model.as_str()).with_catalog(Arc::clone(&catalog)))
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ fn model_reuses_canonical_type() {
|
|||
#[test]
|
||||
fn model_json_matches_openapi_shape() {
|
||||
let model = Model {
|
||||
id: "claude-opus-4-7".to_string(),
|
||||
id: "claude-opus-4-7".into(),
|
||||
provider: ProviderId::anthropic(),
|
||||
family: "claude-4".to_string(),
|
||||
display_name: "Claude Opus 4.7".to_string(),
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ fn provider_id_json_matches_openapi_shape_through_model() {
|
|||
);
|
||||
|
||||
let model = Model {
|
||||
id: "venice-custom".to_string(),
|
||||
id: "venice-custom".into(),
|
||||
provider: ProviderId::new("venice"),
|
||||
family: "venice".to_string(),
|
||||
display_name: "Venice Custom".to_string(),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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,
|
||||
|
|
@ -33,6 +34,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()),
|
||||
active_turn: Some(SessionTurn {
|
||||
id: turn_id,
|
||||
started_at: turn_started_at,
|
||||
|
|
|
|||
175
lib/crates/fabro-auth/src/extra_headers_source.rs
Normal file
175
lib/crates/fabro-auth/src/extra_headers_source.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
|
||||
use crate::credential_source::{CredentialSource, ResolvedCredentials};
|
||||
|
||||
/// Decorates another [`CredentialSource`] by appending fixed extra headers to
|
||||
/// every credential it resolves.
|
||||
///
|
||||
/// Headers already present on a credential (for example from explicit
|
||||
/// provider configuration) are left untouched.
|
||||
pub struct ExtraHeadersCredentialSource {
|
||||
inner: Arc<dyn CredentialSource>,
|
||||
headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ExtraHeadersCredentialSource {
|
||||
#[must_use]
|
||||
pub fn new(inner: Arc<dyn CredentialSource>, headers: HashMap<String, String>) -> Self {
|
||||
Self { inner, headers }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CredentialSource for ExtraHeadersCredentialSource {
|
||||
async fn resolve(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
|
||||
let mut resolved = self.inner.resolve(catalog).await?;
|
||||
for credential in &mut resolved.credentials {
|
||||
for (name, value) in &self.headers {
|
||||
if credential
|
||||
.extra_headers
|
||||
.keys()
|
||||
.any(|existing| existing.eq_ignore_ascii_case(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
credential.extra_headers.insert(name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
|
||||
self.inner.configured_providers(catalog).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ApiCredential, ResolveError};
|
||||
|
||||
struct StubSource {
|
||||
credentials: Vec<ApiCredential>,
|
||||
auth_issue_provider: Option<ProviderId>,
|
||||
configured_providers: Vec<ProviderId>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CredentialSource for StubSource {
|
||||
async fn resolve(&self, _catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
|
||||
Ok(ResolvedCredentials {
|
||||
credentials: self.credentials.clone(),
|
||||
auth_issues: self
|
||||
.auth_issue_provider
|
||||
.iter()
|
||||
.map(|provider| {
|
||||
(
|
||||
provider.clone(),
|
||||
ResolveError::RefreshTokenMissing(provider.clone()),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn configured_providers(&self, _catalog: &Catalog) -> Vec<ProviderId> {
|
||||
self.configured_providers.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn credential(provider: ProviderId, extra_headers: HashMap<String, String>) -> ApiCredential {
|
||||
ApiCredential {
|
||||
provider,
|
||||
auth_header: None,
|
||||
extra_headers,
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn appends_headers_to_every_resolved_credential() {
|
||||
let source = ExtraHeadersCredentialSource::new(
|
||||
Arc::new(StubSource {
|
||||
credentials: vec![
|
||||
credential(ProviderId::anthropic(), HashMap::new()),
|
||||
credential(ProviderId::openai(), HashMap::new()),
|
||||
],
|
||||
auth_issue_provider: None,
|
||||
configured_providers: Vec::new(),
|
||||
}),
|
||||
HashMap::from([("x-session-id".to_string(), "run-123".to_string())]),
|
||||
);
|
||||
|
||||
let resolved = source.resolve(Catalog::builtin()).await.unwrap();
|
||||
|
||||
assert_eq!(resolved.credentials.len(), 2);
|
||||
for credential in &resolved.credentials {
|
||||
assert_eq!(
|
||||
credential
|
||||
.extra_headers
|
||||
.get("x-session-id")
|
||||
.map(String::as_str),
|
||||
Some("run-123")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preserves_case_insensitive_headers_already_set_on_a_credential() {
|
||||
let source = ExtraHeadersCredentialSource::new(
|
||||
Arc::new(StubSource {
|
||||
credentials: vec![credential(
|
||||
ProviderId::new("openrouter"),
|
||||
HashMap::from([("X-Session-Id".to_string(), "configured".to_string())]),
|
||||
)],
|
||||
auth_issue_provider: None,
|
||||
configured_providers: Vec::new(),
|
||||
}),
|
||||
HashMap::from([("x-session-id".to_string(), "run-123".to_string())]),
|
||||
);
|
||||
|
||||
let resolved = source.resolve(Catalog::builtin()).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.credentials[0]
|
||||
.extra_headers
|
||||
.get("X-Session-Id")
|
||||
.map(String::as_str),
|
||||
Some("configured")
|
||||
);
|
||||
assert_eq!(resolved.credentials[0].extra_headers.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passes_through_auth_issues_and_configured_providers() {
|
||||
let auth_issue_provider = ProviderId::anthropic();
|
||||
let configured_provider = ProviderId::gemini();
|
||||
let source = ExtraHeadersCredentialSource::new(
|
||||
Arc::new(StubSource {
|
||||
credentials: vec![credential(ProviderId::openai(), HashMap::new())],
|
||||
auth_issue_provider: Some(auth_issue_provider.clone()),
|
||||
configured_providers: vec![configured_provider.clone()],
|
||||
}),
|
||||
HashMap::from([("x-session-id".to_string(), "run-123".to_string())]),
|
||||
);
|
||||
|
||||
let resolved = source.resolve(Catalog::builtin()).await.unwrap();
|
||||
let [(reported_provider, ResolveError::RefreshTokenMissing(error_provider))] =
|
||||
resolved.auth_issues.as_slice()
|
||||
else {
|
||||
panic!("expected the inner source's refresh-token issue");
|
||||
};
|
||||
assert_eq!(reported_provider, &auth_issue_provider);
|
||||
assert_eq!(error_provider, &auth_issue_provider);
|
||||
|
||||
let providers = source.configured_providers(Catalog::builtin()).await;
|
||||
assert_eq!(providers, vec![configured_provider]);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ mod context;
|
|||
mod credential;
|
||||
mod credential_source;
|
||||
mod env_source;
|
||||
mod extra_headers_source;
|
||||
mod refresh;
|
||||
mod resolve;
|
||||
mod sql_vault_source;
|
||||
|
|
@ -15,6 +16,7 @@ pub use context::{AuthContextRequest, AuthContextResponse};
|
|||
pub use credential::{ApiKeyHeader, OAuthConfig, OAuthCredential, OAuthTokens};
|
||||
pub use credential_source::{CredentialSource, ResolvedCredentials};
|
||||
pub use env_source::EnvCredentialSource;
|
||||
pub use extra_headers_source::ExtraHeadersCredentialSource;
|
||||
pub use refresh::refresh_oauth_credential;
|
||||
pub use resolve::{
|
||||
ApiCredential, CredentialResolver, CredentialUsage, EnvLookup, ResolveError,
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ assert_cmd = "2"
|
|||
fabro-acp = { path = "../fabro-acp", features = ["test-support"] }
|
||||
fabro-build-support = { path = "../build-support" }
|
||||
fabro-server = { path = "../fabro-server", features = ["test-support"] }
|
||||
fabro-workflow = { path = "../fabro-workflow", features = ["test-support"] }
|
||||
fabro-types = { path = "../fabro-types", features = ["clap", "test-support"] }
|
||||
insta = { workspace = true, features = ["filters"] }
|
||||
paste = "1"
|
||||
|
|
|
|||
|
|
@ -45,10 +45,21 @@ struct CompletedModelTest {
|
|||
status: String,
|
||||
}
|
||||
|
||||
fn find_model_by_id_or_alias(models: &[Model], id: &str) -> Option<Model> {
|
||||
fn model_matches_selector(model: &Model, selector: &str) -> bool {
|
||||
model.id == selector || model.aliases.iter().any(|alias| alias == selector)
|
||||
}
|
||||
|
||||
fn find_model_by_id_or_alias(
|
||||
models: &[Model],
|
||||
id: &str,
|
||||
provider: Option<&ProviderId>,
|
||||
) -> Option<Model> {
|
||||
models
|
||||
.iter()
|
||||
.find(|model| model.id == id || model.aliases.iter().any(|alias| alias == id))
|
||||
.find(|model| {
|
||||
provider.is_none_or(|provider| &model.provider == provider)
|
||||
&& model_matches_selector(model, id)
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +127,7 @@ fn model_row(model: &Model, use_color: bool) -> Vec<CellStruct> {
|
|||
format_cost(model.costs.output_cost_per_mtok),
|
||||
);
|
||||
vec![
|
||||
model.id.clone().cell().bold(use_color),
|
||||
model.id.as_str().cell().bold(use_color),
|
||||
model
|
||||
.provider
|
||||
.as_str()
|
||||
|
|
@ -172,9 +183,20 @@ fn print_models_table(models: &[Model], styles: &Styles) {
|
|||
}
|
||||
|
||||
fn configured_model_test_status(
|
||||
expected: &Model,
|
||||
result: Result<api_types::ModelTestResult>,
|
||||
) -> (Color, String, bool) {
|
||||
match result {
|
||||
Ok(resp) if resp.provider != expected.provider || resp.model_id != expected.id.as_str() => {
|
||||
(
|
||||
Color::Red,
|
||||
format!(
|
||||
"error: server tested unexpected offering {}/{}",
|
||||
resp.provider, resp.model_id
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
Ok(resp) if resp.status == api_types::ModelTestResultStatus::Ok => {
|
||||
(Color::Green, "ok".to_string(), false)
|
||||
}
|
||||
|
|
@ -197,21 +219,21 @@ fn model_test_row_from_status(model: &Model, status: &str, result_color: Color)
|
|||
let trimmed = status.trim();
|
||||
match result_color {
|
||||
Color::Green => ModelTestRow {
|
||||
model: model.id.clone(),
|
||||
model: model.id.to_string(),
|
||||
provider: model.provider.clone(),
|
||||
result: ModelTestResultKind::Pass,
|
||||
detail: None,
|
||||
error: None,
|
||||
},
|
||||
Color::Yellow => ModelTestRow {
|
||||
model: model.id.clone(),
|
||||
model: model.id.to_string(),
|
||||
provider: model.provider.clone(),
|
||||
result: ModelTestResultKind::Skip,
|
||||
detail: Some(trimmed.to_string()),
|
||||
error: None,
|
||||
},
|
||||
_ => ModelTestRow {
|
||||
model: model.id.clone(),
|
||||
model: model.id.to_string(),
|
||||
provider: model.provider.clone(),
|
||||
result: ModelTestResultKind::Fail,
|
||||
detail: None,
|
||||
|
|
@ -251,21 +273,45 @@ async fn test_models_via_server(
|
|||
let mut skipped = 0u32;
|
||||
let mut skipped_providers: Vec<String> = Vec::new();
|
||||
if let Some(model_id) = model {
|
||||
let listed_models = client.list_models(None, Some(model_id)).await?;
|
||||
let listed_info = find_model_by_id_or_alias(&listed_models, model_id);
|
||||
let requested_provider = provider.map(ProviderId::new);
|
||||
let listed_models = client.list_models(provider, Some(model_id)).await?;
|
||||
let listed_info = find_model_by_id_or_alias(&listed_models, model_id, None);
|
||||
if !json_output {
|
||||
eprint!("Testing {model_id}...");
|
||||
}
|
||||
let result = client.test_model(model_id, request_mode).await;
|
||||
let has_configured_match = listed_models
|
||||
.iter()
|
||||
.any(|model| model.configured && model_matches_selector(model, model_id));
|
||||
let result =
|
||||
if requested_provider.is_none() && listed_info.is_some() && !has_configured_match {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
client
|
||||
.test_model(model_id, requested_provider.as_ref(), request_mode)
|
||||
.await,
|
||||
)
|
||||
};
|
||||
if !json_output {
|
||||
eprintln!(" done");
|
||||
}
|
||||
|
||||
let (info, result_color, status) = match result {
|
||||
Ok(resp) => {
|
||||
let info = find_model_by_id_or_alias(&listed_models, &resp.model_id).with_context(
|
||||
|| format!("Unknown model returned by server: {}", resp.model_id),
|
||||
)?;
|
||||
None => {
|
||||
let info = listed_info.with_context(|| format!("Unknown model: {model_id}"))?;
|
||||
failures += 1;
|
||||
skipped += 1;
|
||||
(info, Color::Yellow, "not configured".to_string())
|
||||
}
|
||||
Some(Ok(resp)) => {
|
||||
let info =
|
||||
find_model_by_id_or_alias(&listed_models, &resp.model_id, Some(&resp.provider))
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Unknown model returned by server: {}/{}",
|
||||
resp.provider, resp.model_id
|
||||
)
|
||||
})?;
|
||||
if resp.status == api_types::ModelTestResultStatus::Ok {
|
||||
(info, Color::Green, "ok".to_string())
|
||||
} else if resp.status == api_types::ModelTestResultStatus::Skip {
|
||||
|
|
@ -280,10 +326,10 @@ async fn test_models_via_server(
|
|||
(info, Color::Red, format!("error: {message}"))
|
||||
}
|
||||
}
|
||||
Err(err) if err.to_string().contains("Model not found") => {
|
||||
Some(Err(err)) if err.to_string().contains("Model not found") => {
|
||||
bail!("Unknown model: {model_id}");
|
||||
}
|
||||
Err(err) => {
|
||||
Some(Err(err)) => {
|
||||
let info = listed_info.with_context(|| format!("Unknown model: {model_id}"))?;
|
||||
failures += 1;
|
||||
(info, Color::Red, format!("error: {err}"))
|
||||
|
|
@ -328,11 +374,14 @@ async fn test_models_via_server(
|
|||
.map(|(index, info)| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
let result = client.test_model(&info.id, request_mode).await;
|
||||
let result = client
|
||||
.test_model(info.id.as_str(), Some(&info.provider), request_mode)
|
||||
.await;
|
||||
if !json_output {
|
||||
eprintln!("Testing {}... done", info.id);
|
||||
}
|
||||
let (result_color, status, failed) = configured_model_test_status(result);
|
||||
let (result_color, status, failed) =
|
||||
configured_model_test_status(&info, result);
|
||||
(
|
||||
CompletedModelTest {
|
||||
index,
|
||||
|
|
@ -478,7 +527,7 @@ mod tests {
|
|||
|
||||
fn test_model_json(id: &str, provider: ProviderId) -> serde_json::Value {
|
||||
serde_json::to_value(Model {
|
||||
id: id.to_string(),
|
||||
id: id.into(),
|
||||
provider,
|
||||
family: "test".to_string(),
|
||||
display_name: format!("{id} display"),
|
||||
|
|
@ -512,7 +561,7 @@ mod tests {
|
|||
|
||||
fn custom_model_json(id: &str, provider: &str) -> serde_json::Value {
|
||||
serde_json::to_value(Model {
|
||||
id: id.to_string(),
|
||||
id: id.into(),
|
||||
provider: ProviderId::new(provider),
|
||||
family: "test".to_string(),
|
||||
display_name: format!("{id} display"),
|
||||
|
|
@ -610,6 +659,7 @@ mod tests {
|
|||
.body(
|
||||
serde_json::json!({
|
||||
"model_id": "test-model",
|
||||
"provider": "anthropic",
|
||||
"status": "ok"
|
||||
})
|
||||
.to_string(),
|
||||
|
|
@ -618,7 +668,7 @@ mod tests {
|
|||
.await;
|
||||
|
||||
let client = test_client(&server.url(""));
|
||||
let response = client.test_model("test-model", None).await.unwrap();
|
||||
let response = client.test_model("test-model", None, None).await.unwrap();
|
||||
|
||||
assert_eq!(response.status, api_types::ModelTestResultStatus::Ok);
|
||||
assert!(response.error_message.is_none());
|
||||
|
|
@ -637,6 +687,7 @@ mod tests {
|
|||
.body(
|
||||
serde_json::json!({
|
||||
"model_id": "test-model",
|
||||
"provider": "anthropic",
|
||||
"status": "error",
|
||||
"error_message": "timeout"
|
||||
})
|
||||
|
|
@ -647,7 +698,7 @@ mod tests {
|
|||
|
||||
let client = test_client(&server.url(""));
|
||||
let response = client
|
||||
.test_model("test-model", Some(ModelTestMode::Deep))
|
||||
.test_model("test-model", None, Some(ModelTestMode::Deep))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -666,6 +717,7 @@ mod tests {
|
|||
.body(
|
||||
serde_json::json!({
|
||||
"model_id": "kimi-k2.5",
|
||||
"provider": "kimi",
|
||||
"status": "skip"
|
||||
})
|
||||
.to_string(),
|
||||
|
|
@ -674,7 +726,7 @@ mod tests {
|
|||
.await;
|
||||
|
||||
let client = test_client(&server.url(""));
|
||||
let response = client.test_model("kimi-k2.5", None).await.unwrap();
|
||||
let response = client.test_model("kimi-k2.5", None, None).await.unwrap();
|
||||
|
||||
assert_eq!(response.status, api_types::ModelTestResultStatus::Skip);
|
||||
assert!(response.error_message.is_none());
|
||||
|
|
@ -698,7 +750,7 @@ mod tests {
|
|||
.await;
|
||||
|
||||
let client = test_client(&server.url(""));
|
||||
let result = client.test_model("bad-model", None).await;
|
||||
let result = client.test_model("bad-model", None, None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Model not found"));
|
||||
}
|
||||
|
|
@ -732,6 +784,7 @@ mod tests {
|
|||
.body(
|
||||
serde_json::json!({
|
||||
"model_id": "venice-large",
|
||||
"provider": "venice",
|
||||
"status": "ok"
|
||||
})
|
||||
.to_string(),
|
||||
|
|
@ -754,6 +807,80 @@ mod tests {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_model_test_keeps_duplicate_ids_scoped_by_provider() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
server
|
||||
.mock_async(|when, then| {
|
||||
when.method("GET")
|
||||
.path("/api/v1/models")
|
||||
.query_param("page[limit]", "100")
|
||||
.query_param("page[offset]", "0");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
custom_model_json("portable-model", "openai"),
|
||||
custom_model_json("portable-model", "openrouter")
|
||||
],
|
||||
"meta": { "has_more": false }
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
let openai = server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST")
|
||||
.path("/api/v1/models/portable-model/test")
|
||||
.query_param("provider", "openai");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"model_id": "portable-model",
|
||||
"provider": "openai",
|
||||
"status": "ok"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
let openrouter = server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST")
|
||||
.path("/api/v1/models/portable-model/test")
|
||||
.query_param("provider", "openrouter");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"model_id": "portable-model",
|
||||
"provider": "openrouter",
|
||||
"status": "ok"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
test_models_via_server(
|
||||
&test_client(&server.url("")),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
2,
|
||||
&Styles::new(false),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
openai.assert_async().await;
|
||||
openrouter.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_models_from_server_parses_response() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ pub(crate) async fn run(args: AskArgs, base_ctx: &CommandContext) -> Result<()>
|
|||
let run_id = client.resolve_run(&args.run).await?.id;
|
||||
let session = client
|
||||
.create_run_session(run_id, CreateRunSessionRequest {
|
||||
title: Some(session_title(&args.prompt)),
|
||||
model: args.model,
|
||||
title: Some(session_title(&args.prompt)),
|
||||
model: args.model,
|
||||
provider: None,
|
||||
})
|
||||
.await?;
|
||||
let mut stream = client
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
|
|||
EventBody::ParallelCompleted(_) => Some(ProgressEvent::ParallelCompleted),
|
||||
EventBody::AgentMessage(props) => Some(ProgressEvent::AssistantMessage {
|
||||
stage_node_id: node_id,
|
||||
model: props.model.model_id.clone(),
|
||||
model: props.model.model_id.to_string(),
|
||||
}),
|
||||
EventBody::AgentToolStarted(props) => Some(ProgressEvent::ToolCallStarted {
|
||||
stage_node_id: node_id,
|
||||
|
|
|
|||
|
|
@ -100,9 +100,10 @@ pub(crate) async fn validate_api_key(
|
|||
.await
|
||||
.context("failed to create LLM client")?;
|
||||
|
||||
let probe_model = catalog
|
||||
.probe_for_provider(provider)
|
||||
.map_or_else(|| format!("unknown-{provider}"), |model| model.id.clone());
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ fn model_test_does_not_announce_unconfigured() {
|
|||
.header("Content-Type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"model_id": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"status": "ok"
|
||||
}));
|
||||
});
|
||||
|
|
@ -251,6 +252,7 @@ fn model_test_skipped_footer_sources_from_listing() {
|
|||
.header("Content-Type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"model_id": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"status": "ok"
|
||||
}));
|
||||
});
|
||||
|
|
@ -282,6 +284,7 @@ fn model_test_post_list_race_is_a_failure() {
|
|||
.header("Content-Type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"model_id": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"status": "skip"
|
||||
}));
|
||||
});
|
||||
|
|
@ -323,6 +326,7 @@ fn model_test_json_partitions_skip_and_fail() {
|
|||
.header("Content-Type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"model_id": "claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"status": "skip"
|
||||
}));
|
||||
});
|
||||
|
|
@ -529,6 +533,7 @@ async fn concurrent_test_model(
|
|||
|
||||
Json(serde_json::json!({
|
||||
"model_id": id,
|
||||
"provider": "anthropic",
|
||||
"status": "ok"
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ use fabro_server::auth::GithubEndpoints;
|
|||
use fabro_server::jwt_auth::resolve_auth_mode_with_lookup;
|
||||
use fabro_server::server::{RouterOptions, build_router_with_options};
|
||||
use fabro_server::test_support::TestAppStateBuilder;
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_test::{GitHubAppState, TestContext, apply_test_isolation};
|
||||
use serde_json::Value;
|
||||
use tokio::net::TcpListener;
|
||||
|
|
@ -88,7 +89,10 @@ impl RealAuthHarness {
|
|||
.max_concurrent_runs(5)
|
||||
.env_lookup(|_| None)
|
||||
.server_secret_env(secrets)
|
||||
.vault_entries([("GITHUB_APP_CLIENT_SECRET", github_client_secret.as_str())])
|
||||
.vault_entries([
|
||||
("GITHUB_APP_CLIENT_SECRET", github_client_secret.as_str()),
|
||||
(EnvVars::OPENAI_API_KEY, "test-openai-api-key"),
|
||||
])
|
||||
.build();
|
||||
let github_base = github_base_url(&twin.base_url);
|
||||
let router = build_router_with_options(state, &auth_mode, RouterOptions {
|
||||
|
|
|
|||
|
|
@ -833,11 +833,15 @@ impl Client {
|
|||
pub async fn test_model(
|
||||
&self,
|
||||
id: &str,
|
||||
provider: Option<&ProviderId>,
|
||||
mode: Option<ModelTestMode>,
|
||||
) -> Result<types::ModelTestResult> {
|
||||
let response = self
|
||||
.send_api(|client| async move {
|
||||
let mut request = client.test_model().id(id.to_string());
|
||||
if let Some(provider) = provider {
|
||||
request = request.provider(provider.clone());
|
||||
}
|
||||
if let Some(mode) = mode {
|
||||
request = request.mode(mode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -340,6 +340,12 @@ fn provider_settings_to_catalog(
|
|||
.map(|(name, value)| (name, value.as_source()))
|
||||
.collect()
|
||||
});
|
||||
let models = settings
|
||||
.models
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(|(id, settings)| (id, model_settings_to_catalog(settings)))
|
||||
.collect();
|
||||
model_catalog::ProviderCatalogSettings {
|
||||
display_name: settings.display_name,
|
||||
adapter: settings.adapter,
|
||||
|
|
@ -353,6 +359,7 @@ fn provider_settings_to_catalog(
|
|||
priority: settings.priority,
|
||||
enabled: settings.enabled,
|
||||
aliases: settings.aliases,
|
||||
models,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -863,7 +870,7 @@ reasoning = false
|
|||
|
||||
assert_eq!(
|
||||
catalog
|
||||
.get("acme-large")
|
||||
.get_on_provider(&fabro_model::ProviderId::new("acme"), "acme-large")
|
||||
.map(|model| model.provider.clone()),
|
||||
Some(fabro_model::ProviderId::new("acme"))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@
|
|||
//! enabled = true
|
||||
//! aliases = ["moonshot"]
|
||||
//!
|
||||
//! [llm.models."kimi-k2.5"]
|
||||
//! provider = "kimi"
|
||||
//! [llm.providers.kimi.models."kimi-k2.5"]
|
||||
//! ...
|
||||
//! ```
|
||||
//!
|
||||
|
|
@ -29,7 +28,9 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use fabro_model::catalog::deserialize_knowledge_cutoff;
|
||||
use fabro_model::{AgentProfileKind, BillingPolicy, CodecKind, ProviderAuthConfig};
|
||||
use fabro_model::{
|
||||
AgentProfileKind, BillingPolicy, CodecKind, ModelId, ProviderAuthConfig, ProviderId, catalog,
|
||||
};
|
||||
pub use fabro_model::{CredentialRef, CredentialRefParseError, ReasoningEffortFeature};
|
||||
use fabro_types::settings::InterpString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -43,7 +44,8 @@ pub struct LlmLayer {
|
|||
/// Provider definitions keyed by provider ID.
|
||||
#[serde(default, skip_serializing_if = "MergeMap::is_empty")]
|
||||
pub providers: MergeMap<ProviderSettings>,
|
||||
/// Model definitions keyed by canonical model ID.
|
||||
/// Legacy top-level model definitions. New settings put models below
|
||||
/// their provider; parsing normalizes this map before layers combine.
|
||||
#[serde(default, skip_serializing_if = "MergeMap::is_empty")]
|
||||
pub models: MergeMap<ModelSettings>,
|
||||
}
|
||||
|
|
@ -87,13 +89,16 @@ pub struct ProviderSettings {
|
|||
pub enabled: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub aliases: Option<Vec<String>>,
|
||||
/// Model offerings served by this provider, keyed by canonical model ID.
|
||||
#[serde(default, skip_serializing_if = "MergeMap::is_empty")]
|
||||
pub models: MergeMap<ModelSettings>,
|
||||
}
|
||||
|
||||
/// One entry in `[llm.models.<id>]`.
|
||||
/// One entry in `[llm.providers.<provider>.models.<id>]`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ModelSettings {
|
||||
/// Provider ID this model belongs to.
|
||||
/// Compatibility-only provider for legacy `[llm.models.<id>]` rows.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider: Option<String>,
|
||||
/// Identifier sent to the provider API. Defaults to the catalog model ID
|
||||
|
|
@ -155,6 +160,71 @@ pub struct ModelSettings {
|
|||
pub costs: Option<ModelCostTable>,
|
||||
}
|
||||
|
||||
impl LlmLayer {
|
||||
/// Normalize the temporary legacy model table before this source is
|
||||
/// combined with any other settings source. Resolution runs against this
|
||||
/// layer's own providers plus the built-in catalog, because a single
|
||||
/// source may reference built-in offerings that merge in later.
|
||||
pub(crate) fn normalize_legacy_models(&mut self) -> Result<(), catalog::LegacyModelError> {
|
||||
for (provider, settings) in self.providers.iter() {
|
||||
for (model, settings) in settings.models.iter() {
|
||||
if settings.provider.is_some() {
|
||||
return Err(catalog::LegacyModelError::ScopedModelDeclaresProvider {
|
||||
provider: ProviderId::new(provider.clone()),
|
||||
model: ModelId::new(model.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let legacy_models = std::mem::take(&mut self.models.0);
|
||||
if legacy_models.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut legacy_models = legacy_models.into_iter().collect::<Vec<_>>();
|
||||
legacy_models.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||
|
||||
let mut index = catalog::LegacyModelIndex::default();
|
||||
let mut provider_ids = self.providers.keys().cloned().collect::<Vec<_>>();
|
||||
provider_ids.sort_unstable();
|
||||
for provider_id in &provider_ids {
|
||||
let settings = self
|
||||
.providers
|
||||
.get(provider_id)
|
||||
.expect("provider ID came from provider map keys");
|
||||
let mut model_ids = settings.models.keys().cloned().collect::<Vec<_>>();
|
||||
model_ids.sort_unstable();
|
||||
index.add_provider(
|
||||
ProviderId::new(provider_id.clone()),
|
||||
settings.aliases.clone().unwrap_or_default(),
|
||||
model_ids.into_iter().map(|model_id| {
|
||||
let model = settings
|
||||
.models
|
||||
.get(&model_id)
|
||||
.expect("model ID came from model map keys");
|
||||
let aliases = model.aliases.clone().unwrap_or_default();
|
||||
(ModelId::new(model_id), aliases)
|
||||
}),
|
||||
);
|
||||
}
|
||||
let index = index.with_builtin()?;
|
||||
|
||||
for (legacy_id, mut settings) in legacy_models {
|
||||
let explicit_provider = settings.provider.take();
|
||||
let (provider, model) = index.resolve(&legacy_id, explicit_provider.as_deref())?;
|
||||
|
||||
let provider_settings = self.providers.entry(provider.to_string()).or_default();
|
||||
if provider_settings.models.contains_key(model.as_str()) {
|
||||
return Err(catalog::LegacyModelError::DuplicateModel { provider, model });
|
||||
}
|
||||
provider_settings
|
||||
.models
|
||||
.insert(model.into_inner(), settings);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ModelLimits {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use std::fmt;
|
||||
|
||||
use fabro_model::catalog::LegacyModelError;
|
||||
|
||||
use crate::SettingsLayer;
|
||||
|
||||
const CURRENT_VERSION: u32 = 1;
|
||||
|
|
@ -30,6 +32,7 @@ const LEGACY_LLM_KEYS: &[&str] = &[
|
|||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ParseError {
|
||||
Toml(String),
|
||||
LlmCatalog(LegacyModelError),
|
||||
Version(VersionError),
|
||||
UnknownTopLevelKey {
|
||||
key: String,
|
||||
|
|
@ -45,6 +48,7 @@ impl fmt::Display for ParseError {
|
|||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Toml(msg) => write!(f, "settings file is not valid TOML: {msg}"),
|
||||
Self::LlmCatalog(err) => fmt::Display::fmt(err, f),
|
||||
Self::Version(err) => fmt::Display::fmt(err, f),
|
||||
Self::UnknownTopLevelKey { key, hint } => {
|
||||
if let Some(hint) = hint {
|
||||
|
|
@ -118,8 +122,14 @@ pub(crate) fn parse_settings(input: &str) -> Result<SettingsLayer, ParseError> {
|
|||
}
|
||||
}
|
||||
|
||||
raw.try_into::<SettingsLayer>()
|
||||
.map_err(|e| ParseError::Toml(e.to_string()))
|
||||
let mut layer = raw
|
||||
.try_into::<SettingsLayer>()
|
||||
.map_err(|e| ParseError::Toml(e.to_string()))?;
|
||||
if let Some(llm) = layer.llm.as_mut() {
|
||||
llm.normalize_legacy_models()
|
||||
.map_err(ParseError::LlmCatalog)?;
|
||||
}
|
||||
Ok(layer)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -290,10 +300,218 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn accepts_new_llm_models_subtree() {
|
||||
let parsed = "[llm.models.\"foo\"]\nprovider = \"kimi\"\n"
|
||||
let parsed = "[llm.providers.kimi.models.\"foo\"]\n"
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
assert!(parsed.llm.unwrap().models.contains_key("foo"));
|
||||
assert!(
|
||||
parsed
|
||||
.llm
|
||||
.unwrap()
|
||||
.providers
|
||||
.get("kimi")
|
||||
.unwrap()
|
||||
.models
|
||||
.contains_key("foo")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_scoped_model_rejects_redundant_provider_field() {
|
||||
let error = r#"
|
||||
[llm.providers.openai.models."gpt-5.4"]
|
||||
provider = "openai"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
ParseError::LlmCatalog(LegacyModelError::ScopedModelDeclaresProvider {
|
||||
provider,
|
||||
model,
|
||||
}) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_model_row_with_provider_normalizes_before_merge() {
|
||||
use crate::layers::Combine as _;
|
||||
|
||||
let higher = r#"
|
||||
[llm.models."gpt-5.4"]
|
||||
provider = "openai"
|
||||
display_name = "Configured display name"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
let fallback = r#"
|
||||
[llm.providers.openai.models."gpt-5.4"]
|
||||
family = "gpt-5"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
|
||||
let merged = higher.combine(fallback);
|
||||
let llm = merged.llm.unwrap();
|
||||
assert!(llm.models.is_empty());
|
||||
let model = llm
|
||||
.providers
|
||||
.get("openai")
|
||||
.unwrap()
|
||||
.models
|
||||
.get("gpt-5.4")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
model.display_name.as_deref(),
|
||||
Some("Configured display name")
|
||||
);
|
||||
assert_eq!(model.family.as_deref(), Some("gpt-5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_less_legacy_row_adopts_unique_builtin_offering() {
|
||||
let parsed = r#"
|
||||
[llm.models.mercury]
|
||||
display_name = "Configured Mercury"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
let llm = parsed.llm.unwrap();
|
||||
|
||||
assert!(llm.models.is_empty());
|
||||
assert!(
|
||||
llm.providers
|
||||
.get("inception")
|
||||
.unwrap()
|
||||
.models
|
||||
.contains_key("mercury-2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_less_legacy_row_rejects_ambiguous_builtin_offering() {
|
||||
let error = r#"
|
||||
[llm.models."gpt-5.6-sol"]
|
||||
display_name = "Ambiguous"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
ParseError::LlmCatalog(LegacyModelError::AmbiguousModel {
|
||||
model,
|
||||
candidates,
|
||||
}) if model == "gpt-5.6-sol" && candidates.len() >= 2
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_source_legacy_and_provider_scoped_rows_conflict() {
|
||||
let error = r#"
|
||||
[llm.providers.openai.models."gpt-5.4"]
|
||||
display_name = "Canonical"
|
||||
|
||||
[llm.models."gpt-5.4"]
|
||||
provider = "openai"
|
||||
display_name = "Legacy"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
ParseError::LlmCatalog(LegacyModelError::DuplicateModel {
|
||||
provider,
|
||||
model,
|
||||
}) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_builtin_model_id_normalizes_with_explicit_provider() {
|
||||
let parsed = r#"
|
||||
[llm.models."openai/gpt-5.6-sol"]
|
||||
provider = "openrouter"
|
||||
display_name = "Configured Sol"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
let llm = parsed.llm.unwrap();
|
||||
|
||||
assert!(llm.models.is_empty());
|
||||
let model = llm
|
||||
.providers
|
||||
.get("openrouter")
|
||||
.unwrap()
|
||||
.models
|
||||
.get("gpt-5.6-sol")
|
||||
.unwrap();
|
||||
assert_eq!(model.display_name.as_deref(), Some("Configured Sol"));
|
||||
assert!(model.provider.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_builtin_model_id_without_provider_uses_historical_catalog_provider() {
|
||||
let parsed = r#"
|
||||
[llm.models."anthropic/claude-fable-5"]
|
||||
display_name = "Configured Fable"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
let llm = parsed.llm.unwrap();
|
||||
|
||||
assert!(llm.models.is_empty());
|
||||
assert!(
|
||||
llm.providers
|
||||
.get("openrouter")
|
||||
.unwrap()
|
||||
.models
|
||||
.contains_key("claude-fable-5")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_model_slug_on_different_providers_merges_independently() {
|
||||
use crate::layers::Combine as _;
|
||||
|
||||
let direct = r#"
|
||||
[llm.providers.openai.models.shared]
|
||||
display_name = "Direct"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
let aggregator = r#"
|
||||
[llm.providers.openrouter.models.shared]
|
||||
display_name = "Aggregator"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
|
||||
let merged = direct.combine(aggregator);
|
||||
let providers = merged.llm.unwrap().providers;
|
||||
assert_eq!(
|
||||
providers
|
||||
.get("openai")
|
||||
.unwrap()
|
||||
.models
|
||||
.get("shared")
|
||||
.unwrap()
|
||||
.display_name
|
||||
.as_deref(),
|
||||
Some("Direct")
|
||||
);
|
||||
assert_eq!(
|
||||
providers
|
||||
.get("openrouter")
|
||||
.unwrap()
|
||||
.models
|
||||
.get("shared")
|
||||
.unwrap()
|
||||
.display_name
|
||||
.as_deref(),
|
||||
Some("Aggregator")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -247,19 +247,20 @@ x-team-secret = "{{ secrets.gateway_team_secret }}"
|
|||
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>`, `env:<NAME>`, and `aws_sigv4` (sign requests from the AWS default credential chain — Bedrock). Literal secret strings are rejected. |
|
||||
| `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. |
|
||||
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values are interpolation strings: literal text, an `{{ env.NAME }}` token, or a `{{ secrets.NAME }}` token. Put credentials in a secret and reference them with a `{{ secrets.NAME }}` token, not a bare literal. |
|
||||
| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |
|
||||
| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection; ties use canonical provider ID. |
|
||||
| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. |
|
||||
| `aliases` | array<string> | `[]` | Additional provider names accepted by model routing and fallback config. |
|
||||
|
||||
## `[llm.models.<id>]`
|
||||
## `[llm.providers.<provider>.models.<model-slug>]`
|
||||
|
||||
Define or override a model in the catalog. The table key is the canonical
|
||||
model ID Fabro users reference; `api_id` is the model string sent to the
|
||||
provider API.
|
||||
Define or override one provider's offering of a model. The table key is the
|
||||
canonical model slug Fabro users reference. An offering's identity is the
|
||||
pair `(provider, model slug)`, so different providers may use the same slug
|
||||
and aliases. `api_id` is the opaque model string sent to this provider's API
|
||||
and defaults to the exact model slug.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[llm.models."team-code-large"]
|
||||
provider = "proxy"
|
||||
[llm.providers.proxy.models."team-code-large"]
|
||||
api_id = "provider-wire-model-name"
|
||||
agent_profile = "anthropic"
|
||||
display_name = "Team Code Large"
|
||||
|
|
@ -270,27 +271,27 @@ enabled = true
|
|||
aliases = ["team-code"]
|
||||
estimated_output_tps = 80
|
||||
|
||||
[llm.models."team-code-large".limits]
|
||||
[llm.providers.proxy.models."team-code-large".limits]
|
||||
context_window = 200000
|
||||
max_output = 32000
|
||||
|
||||
[llm.models."team-code-large".features]
|
||||
[llm.providers.proxy.models."team-code-large".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
prompt_cache = true
|
||||
|
||||
[llm.models."team-code-large".controls]
|
||||
[llm.providers.proxy.models."team-code-large".controls]
|
||||
reasoning_effort = ["low", "medium", "high"]
|
||||
speed = ["fast"]
|
||||
|
||||
[llm.models."team-code-large".costs]
|
||||
[llm.providers.proxy.models."team-code-large".costs]
|
||||
input_cost_per_mtok = 1.50
|
||||
output_cost_per_mtok = 8.00
|
||||
cache_input_cost_per_mtok = 0.30
|
||||
|
||||
[llm.models."team-code-large".costs.speed.fast]
|
||||
[llm.providers.proxy.models."team-code-large".costs.speed.fast]
|
||||
input_cost_per_mtok = 3.00
|
||||
output_cost_per_mtok = 16.00
|
||||
cache_input_cost_per_mtok = 0.60
|
||||
|
|
@ -298,8 +299,7 @@ cache_input_cost_per_mtok = 0.60
|
|||
|
||||
| Key | Type / values | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `provider` | string | None | Provider ID this model belongs to. |
|
||||
| `api_id` | string | model ID | Identifier sent to the provider API. |
|
||||
| `api_id` | string | model slug | Opaque identifier sent to this provider's API. An explicitly empty value is invalid. |
|
||||
| `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` | provider profile | Agent profile override for this model. Model overrides take precedence over provider overrides. |
|
||||
| `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | provider policy | Billing algorithm override for this model — for models whose billing family differs from their provider's (e.g. Claude served through OpenRouter bills Anthropic-style cache reads/writes). |
|
||||
| `display_name` | string | model ID | Human-readable model name. |
|
||||
|
|
@ -309,17 +309,17 @@ cache_input_cost_per_mtok = 0.60
|
|||
| `default` | boolean | `false` | Whether this is the provider default model. |
|
||||
| `probe` | boolean | `false` | Whether this model should be preferred for provider connectivity probes. Set `false` in a higher-precedence layer to clear an inherited probe marker. |
|
||||
| `enabled` | boolean | `true` | Set `false` to disable a model after lower-precedence layers define it. |
|
||||
| `aliases` | array<string> | `[]` | Additional model names accepted by routing and fallback config. |
|
||||
| `aliases` | array<string> | `[]` | Additional model selectors accepted by routing and fallback config. Aliases may repeat across providers, but one selector cannot identify two models within the same provider. |
|
||||
| `estimated_output_tps` | number | None | Estimated output tokens per second for catalog display and planning. |
|
||||
|
||||
## `[llm.models.<id>.limits]`
|
||||
## `[llm.providers.<provider>.models.<model-slug>.limits]`
|
||||
|
||||
| Key | Type / values | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `context_window` | integer | None | Maximum context window size in tokens. |
|
||||
| `max_output` | integer | None | Maximum output tokens, if known. |
|
||||
|
||||
## `[llm.models.<id>.features]`
|
||||
## `[llm.providers.<provider>.models.<model-slug>.features]`
|
||||
|
||||
| Key | Type / values | Default | Description |
|
||||
|---|---|---|---|
|
||||
|
|
@ -330,14 +330,14 @@ cache_input_cost_per_mtok = 0.60
|
|||
| `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. |
|
||||
| `sampling_params` | boolean | `true` | Whether the model accepts classic sampling parameters (`temperature`, `top_p`). |
|
||||
|
||||
## `[llm.models.<id>.controls]`
|
||||
## `[llm.providers.<provider>.models.<model-slug>.controls]`
|
||||
|
||||
| Key | Type / values | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `reasoning_effort` | array<string> | all standard levels when feature is `"levels"` or `"always_adaptive"` | User-facing reasoning effort values Fabro may send for this model. Can be set explicitly for reasoning models whose provider adapter maps effort to a non-native API shape. |
|
||||
| `speed` | array<string> | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. |
|
||||
|
||||
## `[llm.models.<id>.costs]`
|
||||
## `[llm.providers.<provider>.models.<model-slug>.costs]`
|
||||
|
||||
| Key | Type / values | Default | Description |
|
||||
|---|---|---|---|
|
||||
|
|
@ -345,10 +345,12 @@ cache_input_cost_per_mtok = 0.60
|
|||
| `output_cost_per_mtok` | number | None | Output cost in USD per million tokens. |
|
||||
| `cache_input_cost_per_mtok` | number | None | Cached input/read cost in USD per million tokens. |
|
||||
|
||||
## `[llm.models.<id>.costs.speed.<speed>]`
|
||||
## `[llm.providers.<provider>.models.<model-slug>.costs.speed.<speed>]`
|
||||
|
||||
Per-speed cost overrides use the same keys as `[llm.models.<id>.costs]`.
|
||||
Each `<speed>` key must be declared in `[llm.models.<id>.controls].speed`.
|
||||
Per-speed cost overrides use the same keys as
|
||||
`[llm.providers.<provider>.models.<model-slug>.costs]`. Each `<speed>` key
|
||||
must be declared in
|
||||
`[llm.providers.<provider>.models.<model-slug>.controls].speed`.
|
||||
The `standard` speed is implicit and always uses the base cost table.
|
||||
|
||||
"#,
|
||||
|
|
|
|||
|
|
@ -323,11 +323,10 @@ impl HookExecutorImpl {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve a model alias (e.g. "haiku") to a concrete model ID.
|
||||
fn resolve_model(model: Option<&str>, catalog: &Catalog) -> String {
|
||||
let model_id = model.unwrap_or("haiku");
|
||||
let model_info = catalog.get(model_id);
|
||||
model_info.map_or(model_id, |m| m.id.as_str()).to_string()
|
||||
/// Keep the requested selector intact so the ready-provider-aware LLM
|
||||
/// client can resolve aliases at dispatch time.
|
||||
fn resolve_model(model: Option<&str>) -> String {
|
||||
model.unwrap_or("haiku").to_string()
|
||||
}
|
||||
|
||||
/// Build the user message for prompt/agent hooks.
|
||||
|
|
@ -377,7 +376,7 @@ impl HookExecutorImpl {
|
|||
}
|
||||
};
|
||||
|
||||
let resolved_model = Self::resolve_model(model.as_deref(), catalog.as_ref());
|
||||
let resolved_model = Self::resolve_model(model.as_deref());
|
||||
let user_msg = Self::build_hook_user_message(&prompt, context);
|
||||
|
||||
Self::execute_llm_with_timeout(definition.timeout(), "prompt", || async move {
|
||||
|
|
@ -446,7 +445,7 @@ impl HookExecutorImpl {
|
|||
}
|
||||
};
|
||||
|
||||
let resolved_model = Self::resolve_model(model.as_deref(), catalog.as_ref());
|
||||
let resolved_model = Self::resolve_model(model.as_deref());
|
||||
let user_msg = Self::build_hook_user_message(&prompt, context);
|
||||
|
||||
Self::execute_llm_with_timeout(definition.timeout(), "agent", || async move {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ use std::collections::HashMap;
|
|||
use std::sync::Arc;
|
||||
|
||||
use fabro_auth::ApiKeyHeader;
|
||||
use fabro_model::{AdapterKind, AgentProfileKind, BillingPolicy, Catalog, CodecKind, ProviderId};
|
||||
use fabro_model::{
|
||||
AdapterKind, AgentProfileKind, BillingPolicy, Catalog, CodecKind, Model, ProviderId,
|
||||
};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::provider::ProviderAdapter;
|
||||
|
|
@ -242,13 +244,11 @@ pub struct Route {
|
|||
pub agent_profile: AgentProfileKind,
|
||||
}
|
||||
|
||||
/// Resolve the route for `model_id_or_alias` from the catalog's provider and
|
||||
/// model rows. Returns `None` when the model or its provider is unknown.
|
||||
/// Resolve the route for one already-selected catalog offering.
|
||||
#[must_use]
|
||||
pub fn resolve_route(catalog: &Catalog, model_id_or_alias: &str) -> Option<Route> {
|
||||
let model = catalog.get(model_id_or_alias)?;
|
||||
pub fn resolve_route(catalog: &Catalog, model: &Model) -> Option<Route> {
|
||||
let provider = catalog.provider(&model.provider)?;
|
||||
let settings = catalog.model_settings(&model.id)?;
|
||||
let settings = catalog.settings_for(model)?;
|
||||
Some(Route {
|
||||
provider: provider.id.clone(),
|
||||
transport: provider.adapter,
|
||||
|
|
@ -263,6 +263,12 @@ pub fn resolve_route(catalog: &Catalog, model_id_or_alias: &str) -> Option<Route
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn select_from_all<'a>(catalog: &'a Catalog, selector: &str) -> &'a Model {
|
||||
catalog
|
||||
.select(selector, None, &catalog.all_provider_ids())
|
||||
.unwrap_or_else(|error| panic!("built-in model '{selector}' should resolve: {error}"))
|
||||
}
|
||||
|
||||
/// One row of the route-equivalence table: model id plus the
|
||||
/// `(deployment_id, transport, codec, billing_policy, agent_profile)`
|
||||
/// tuple it must resolve to.
|
||||
|
|
@ -336,7 +342,8 @@ mod tests {
|
|||
);
|
||||
|
||||
for (model_id, deployment_id, transport, codec, billing_policy, agent_profile) in expected {
|
||||
let route = resolve_route(catalog, model_id)
|
||||
let model = select_from_all(catalog, model_id);
|
||||
let route = resolve_route(catalog, model)
|
||||
.unwrap_or_else(|| panic!("built-in model '{model_id}' should resolve"));
|
||||
assert_eq!(route.deployment_id, *deployment_id, "{model_id}");
|
||||
assert_eq!(route.transport, *transport, "{model_id}");
|
||||
|
|
@ -350,16 +357,20 @@ mod tests {
|
|||
fn resolve_route_follows_model_aliases() {
|
||||
let catalog = Catalog::builtin();
|
||||
|
||||
let by_alias = resolve_route(catalog, "sonnet").expect("alias should resolve");
|
||||
let by_id = resolve_route(catalog, "claude-sonnet-4-6").expect("id should resolve");
|
||||
let by_alias = resolve_route(catalog, select_from_all(catalog, "sonnet"))
|
||||
.expect("alias should resolve");
|
||||
let by_id = resolve_route(catalog, select_from_all(catalog, "claude-sonnet-4-6"))
|
||||
.expect("id should resolve");
|
||||
|
||||
assert_eq!(by_alias, by_id);
|
||||
assert_eq!(by_alias.provider, ProviderId::anthropic());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_route_returns_none_for_unknown_models() {
|
||||
assert_eq!(resolve_route(Catalog::builtin(), "not-a-model"), None);
|
||||
fn resolve_route_resolves_by_id_for_model_from_another_catalog_instance() {
|
||||
let other = Catalog::from_builtin().unwrap();
|
||||
let model = select_from_all(&other, "gpt-5.4");
|
||||
assert!(resolve_route(Catalog::builtin(), model).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_auth::{ApiCredential, CredentialSource};
|
||||
use fabro_model::{AdapterKind, Catalog, ProviderId};
|
||||
use fabro_model::{AdapterKind, Catalog, ModelSelectionError, ProviderId};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::adapter_registry::{
|
||||
self, AdapterConfig, AdapterKindOptions, OpenAiAdapterOptions, factory_for,
|
||||
AdapterConfig, AdapterKindOptions, OpenAiAdapterOptions, factory_for,
|
||||
};
|
||||
use crate::cost;
|
||||
use crate::error::{Error, ProviderErrorKind};
|
||||
|
|
@ -44,6 +44,11 @@ enum RegistrationMode {
|
|||
CollectIssues,
|
||||
}
|
||||
|
||||
struct ResolvedRequest {
|
||||
provider: Arc<dyn ProviderAdapter>,
|
||||
request: Request,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Create a new Client with explicit configuration.
|
||||
#[must_use]
|
||||
|
|
@ -259,44 +264,149 @@ impl Client {
|
|||
)
|
||||
}
|
||||
|
||||
/// Resolve the provider for a request: an explicit `request.provider`
|
||||
/// wins, then the model's catalog route, then the default provider.
|
||||
fn resolve_provider(&self, request: &Request) -> Result<Arc<dyn ProviderAdapter>, Error> {
|
||||
let route = self
|
||||
.catalog
|
||||
.as_ref()
|
||||
.and_then(|catalog| adapter_registry::resolve_route(catalog, &request.model));
|
||||
|
||||
let provider_name = request
|
||||
.provider
|
||||
.as_deref()
|
||||
.or_else(|| route.as_ref().map(|route| route.provider.as_str()))
|
||||
.or(self.default_provider.as_deref())
|
||||
.ok_or_else(|| Error::Configuration {
|
||||
message: "No provider specified and no default provider set".into(),
|
||||
source: None,
|
||||
})?;
|
||||
let provider_name = self.canonical_provider_name(provider_name);
|
||||
|
||||
self.providers
|
||||
.get(&provider_name)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::Configuration {
|
||||
message: format!("Provider '{provider_name}' not registered"),
|
||||
source: None,
|
||||
fn provider_adapter(&self, provider_name: &str) -> Option<Arc<dyn ProviderAdapter>> {
|
||||
let canonical = self.canonical_provider_name(provider_name);
|
||||
self.providers.get(&canonical).cloned().or_else(|| {
|
||||
self.providers.iter().find_map(|(name, adapter)| {
|
||||
(self.canonical_provider_name(name) == canonical).then(|| Arc::clone(adapter))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn eligible_provider_ids(&self) -> HashSet<ProviderId> {
|
||||
self.providers
|
||||
.keys()
|
||||
.map(|provider| ProviderId::new(self.canonical_provider_name(provider)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve one concrete provider/model offering and canonicalize a cloned
|
||||
/// request. Explicit-provider unknown models remain passthrough values.
|
||||
fn resolve_request_with_adapter(&self, request: &Request) -> Result<ResolvedRequest, Error> {
|
||||
let mut resolved = request.clone();
|
||||
let Some(catalog) = &self.catalog else {
|
||||
let provider_name = request
|
||||
.provider
|
||||
.as_deref()
|
||||
.or(self.default_provider.as_deref())
|
||||
.ok_or_else(|| Error::Configuration {
|
||||
message: "No provider specified and no default provider set".into(),
|
||||
source: None,
|
||||
})?;
|
||||
let provider =
|
||||
self.provider_adapter(provider_name)
|
||||
.ok_or_else(|| Error::Configuration {
|
||||
message: format!("Provider '{provider_name}' not registered"),
|
||||
source: None,
|
||||
})?;
|
||||
resolved.provider = Some(provider.name().to_string());
|
||||
return Ok(ResolvedRequest {
|
||||
provider,
|
||||
request: resolved,
|
||||
});
|
||||
};
|
||||
|
||||
let eligible = self.eligible_provider_ids();
|
||||
if let Some(explicit) = request.provider.as_deref() {
|
||||
let explicit = ProviderId::new(explicit);
|
||||
if catalog.provider(&explicit).is_some() {
|
||||
let selected = catalog
|
||||
.resolve_selection(Some(&request.model), Some(&explicit), &eligible)
|
||||
.map_err(selection_error)?;
|
||||
let provider = self
|
||||
.provider_adapter(selected.provider.as_str())
|
||||
.ok_or_else(|| {
|
||||
selection_error(ModelSelectionError::ProviderUnavailable {
|
||||
provider: selected.provider.clone(),
|
||||
})
|
||||
})?;
|
||||
resolved.model = selected.model;
|
||||
resolved.provider = Some(selected.provider.into_inner());
|
||||
return Ok(ResolvedRequest {
|
||||
provider,
|
||||
request: resolved,
|
||||
});
|
||||
}
|
||||
|
||||
let provider =
|
||||
self.provider_adapter(explicit.as_str())
|
||||
.ok_or_else(|| Error::Configuration {
|
||||
message: format!("Provider '{explicit}' not registered"),
|
||||
source: None,
|
||||
})?;
|
||||
resolved.provider = Some(provider.name().to_string());
|
||||
return Ok(ResolvedRequest {
|
||||
provider,
|
||||
request: resolved,
|
||||
});
|
||||
}
|
||||
|
||||
match catalog.select(&request.model, None, &eligible) {
|
||||
Ok(model) => {
|
||||
let provider = self
|
||||
.provider_adapter(model.provider.as_str())
|
||||
.ok_or_else(|| {
|
||||
selection_error(ModelSelectionError::ProviderUnavailable {
|
||||
provider: model.provider.clone(),
|
||||
})
|
||||
})?;
|
||||
resolved.model = model.id.to_string();
|
||||
resolved.provider = Some(model.provider.to_string());
|
||||
Ok(ResolvedRequest {
|
||||
provider,
|
||||
request: resolved,
|
||||
})
|
||||
}
|
||||
Err(ModelSelectionError::UnknownSelector { .. }) => {
|
||||
let provider_name =
|
||||
self.default_provider
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error::Configuration {
|
||||
message: "No provider specified and no default provider set".into(),
|
||||
source: None,
|
||||
})?;
|
||||
let provider =
|
||||
self.provider_adapter(provider_name)
|
||||
.ok_or_else(|| Error::Configuration {
|
||||
message: format!("Provider '{provider_name}' not registered"),
|
||||
source: None,
|
||||
})?;
|
||||
resolved.provider = Some(self.canonical_provider_name(provider.name()));
|
||||
Ok(ResolvedRequest {
|
||||
provider,
|
||||
request: resolved,
|
||||
})
|
||||
}
|
||||
Err(error) => Err(selection_error(error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the concrete provider/model route and return a canonicalized
|
||||
/// clone of the request without dispatching it.
|
||||
///
|
||||
/// This is useful at persistence and API boundaries that must expose the
|
||||
/// selected provider alongside the canonical model ID. The caller-owned
|
||||
/// request is never modified.
|
||||
pub fn resolve_request(&self, request: &Request) -> Result<Request, Error> {
|
||||
self.resolve_request_with_adapter(request)
|
||||
.map(|resolved| resolved.request)
|
||||
}
|
||||
|
||||
fn validate_request_controls(&self, request: &Request) -> Result<(), Error> {
|
||||
let Some(catalog) = &self.catalog else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(settings) = catalog.model_settings(&request.model) else {
|
||||
let Some(provider) = request.provider.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let model_id = catalog
|
||||
.get(&request.model)
|
||||
.map_or(request.model.as_str(), |model| model.id.as_str());
|
||||
let Some(model) = catalog.get_on_provider(&ProviderId::new(provider), &request.model)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(settings) = catalog.settings_for(model) else {
|
||||
return Ok(());
|
||||
};
|
||||
let model_id = model.id.as_str();
|
||||
|
||||
if let Some(effort) = request.reasoning_effort {
|
||||
if !settings.controls.reasoning_effort.contains(&effort) {
|
||||
|
|
@ -333,11 +443,11 @@ impl Client {
|
|||
/// registered, or any provider/middleware error encountered during the
|
||||
/// request.
|
||||
pub async fn complete(&self, request: &Request) -> Result<Response, Error> {
|
||||
self.validate_request_controls(request)?;
|
||||
let provider = self.resolve_provider(request)?;
|
||||
let ResolvedRequest { provider, request } = self.resolve_request_with_adapter(request)?;
|
||||
self.validate_request_controls(&request)?;
|
||||
|
||||
if self.middleware.is_empty() {
|
||||
return complete_stamped(&provider, self.catalog.as_deref(), request).await;
|
||||
return complete_stamped(&provider, self.catalog.as_deref(), &request).await;
|
||||
}
|
||||
|
||||
// Build middleware chain. Cost is stamped at the base so middleware
|
||||
|
|
@ -358,7 +468,7 @@ impl Client {
|
|||
})
|
||||
});
|
||||
|
||||
chain(request.clone()).await
|
||||
chain(request).await
|
||||
}
|
||||
|
||||
/// Send a streaming request (Section 4.2).
|
||||
|
|
@ -369,11 +479,11 @@ impl Client {
|
|||
/// registered, or any provider/middleware error encountered during the
|
||||
/// request.
|
||||
pub async fn stream(&self, request: &Request) -> Result<StreamEventStream, Error> {
|
||||
self.validate_request_controls(request)?;
|
||||
let provider = self.resolve_provider(request)?;
|
||||
let ResolvedRequest { provider, request } = self.resolve_request_with_adapter(request)?;
|
||||
self.validate_request_controls(&request)?;
|
||||
|
||||
if self.middleware.is_empty() {
|
||||
return stream_stamped(&provider, self.catalog.clone(), request).await;
|
||||
return stream_stamped(&provider, self.catalog.clone(), &request).await;
|
||||
}
|
||||
|
||||
// Build streaming middleware chain. Cost is stamped at the base so
|
||||
|
|
@ -394,7 +504,7 @@ impl Client {
|
|||
})
|
||||
});
|
||||
|
||||
chain(request.clone()).await
|
||||
chain(request).await
|
||||
}
|
||||
|
||||
/// Count the model-visible input/context tokens for a request without
|
||||
|
|
@ -410,19 +520,19 @@ impl Client {
|
|||
request: &Request,
|
||||
preference: InputTokenCountPreference,
|
||||
) -> Result<InputTokenCount, Error> {
|
||||
self.validate_request_controls(request)?;
|
||||
let provider = self.resolve_provider(request)?;
|
||||
provider.validate_request(request)?;
|
||||
let ResolvedRequest { provider, request } = self.resolve_request_with_adapter(request)?;
|
||||
self.validate_request_controls(&request)?;
|
||||
provider.validate_request(&request)?;
|
||||
|
||||
if preference == InputTokenCountPreference::EstimateOnly {
|
||||
return Ok(estimate_input_tokens(request, provider.name()));
|
||||
return Ok(estimate_input_tokens(&request, provider.name()));
|
||||
}
|
||||
|
||||
match provider.count_input_tokens(request).await {
|
||||
match provider.count_input_tokens(&request).await {
|
||||
Ok(Some(count)) => Ok(count),
|
||||
Ok(None) if preference == InputTokenCountPreference::PreferProvider => {
|
||||
Ok(fallback_estimate(
|
||||
request,
|
||||
&request,
|
||||
provider.name(),
|
||||
"provider_token_count_unsupported",
|
||||
"provider does not support input token counting; returned local estimate",
|
||||
|
|
@ -440,7 +550,7 @@ impl Client {
|
|||
&& token_count_fallback_eligible(&error) =>
|
||||
{
|
||||
Ok(fallback_estimate(
|
||||
request,
|
||||
&request,
|
||||
provider.name(),
|
||||
"provider_token_count_failed",
|
||||
"provider input token counting failed; returned local estimate",
|
||||
|
|
@ -471,6 +581,12 @@ impl Client {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Canonical IDs for provider adapters that registered successfully.
|
||||
#[must_use]
|
||||
pub fn provider_ids(&self) -> HashSet<ProviderId> {
|
||||
self.eligible_provider_ids()
|
||||
}
|
||||
|
||||
/// Check whether a provider adapter is registered.
|
||||
#[must_use]
|
||||
pub fn has_provider(&self, name: &str) -> bool {
|
||||
|
|
@ -489,6 +605,10 @@ impl Client {
|
|||
}
|
||||
}
|
||||
|
||||
fn selection_error(error: ModelSelectionError) -> Error {
|
||||
Error::configuration_error(error.to_string(), error)
|
||||
}
|
||||
|
||||
/// Validate, run, and cost-stamp a blocking request. Shared by
|
||||
/// [`Client::complete`]'s direct path and its middleware-chain base so cost
|
||||
/// stamping stays single-sited.
|
||||
|
|
@ -499,7 +619,19 @@ async fn complete_stamped(
|
|||
) -> Result<Response, Error> {
|
||||
provider.validate_request(request)?;
|
||||
let mut response = provider.complete(request).await?;
|
||||
cost::apply_estimated_cost(catalog, &request.model, request.speed, &mut response);
|
||||
let selected_provider = request
|
||||
.provider
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| provider.name());
|
||||
response.model.clone_from(&request.model);
|
||||
response.provider = selected_provider.to_string();
|
||||
cost::apply_estimated_cost(
|
||||
catalog,
|
||||
selected_provider,
|
||||
&request.model,
|
||||
request.speed,
|
||||
&mut response,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
|
|
@ -515,6 +647,10 @@ async fn stream_stamped(
|
|||
let stream = provider.stream(request).await?;
|
||||
Ok(stamp_stream_costs(
|
||||
catalog,
|
||||
request
|
||||
.provider
|
||||
.clone()
|
||||
.unwrap_or_else(|| provider.name().to_string()),
|
||||
request.model.clone(),
|
||||
request.speed,
|
||||
stream,
|
||||
|
|
@ -526,6 +662,7 @@ async fn stream_stamped(
|
|||
/// [`Client::complete`] stamps on blocking responses.
|
||||
fn stamp_stream_costs(
|
||||
catalog: Option<Arc<Catalog>>,
|
||||
provider: String,
|
||||
model: String,
|
||||
speed: Option<Speed>,
|
||||
stream: StreamEventStream,
|
||||
|
|
@ -534,8 +671,12 @@ fn stamp_stream_costs(
|
|||
|
||||
Box::pin(stream.map(move |event| {
|
||||
event.map(|mut event| {
|
||||
if let StreamEvent::Finish { response, .. } = &mut event {
|
||||
cost::apply_estimated_cost(catalog.as_deref(), &model, speed, response);
|
||||
if let StreamEvent::Finish { response, .. } | StreamEvent::StepFinish { response, .. } =
|
||||
&mut event
|
||||
{
|
||||
response.model.clone_from(&model);
|
||||
response.provider.clone_from(&provider);
|
||||
cost::apply_estimated_cost(catalog.as_deref(), &provider, &model, speed, response);
|
||||
}
|
||||
event
|
||||
})
|
||||
|
|
@ -606,7 +747,9 @@ mod tests {
|
|||
use futures::stream;
|
||||
|
||||
use super::*;
|
||||
use crate::adapter_registry;
|
||||
use crate::error::ProviderErrorDetail;
|
||||
use crate::providers::openai_compatible;
|
||||
use crate::types::*;
|
||||
|
||||
/// A mock provider for testing.
|
||||
|
|
@ -863,6 +1006,169 @@ output_cost_per_mtok = 2.0
|
|||
Arc::new(Catalog::from_settings(&settings).unwrap())
|
||||
}
|
||||
|
||||
fn portable_model_catalog(openrouter_base_url: &str) -> Arc<Catalog> {
|
||||
let settings: LlmCatalogSettings = toml::from_str(&format!(
|
||||
r#"
|
||||
[providers.openai]
|
||||
display_name = "OpenAI"
|
||||
adapter = "openai_compatible"
|
||||
agent_profile = "openai"
|
||||
base_url = "https://openai.invalid/v1"
|
||||
priority = 90
|
||||
|
||||
[providers.openai.models."gpt-5.6-sol"]
|
||||
display_name = "GPT-5.6 Sol"
|
||||
family = "gpt-5"
|
||||
aliases = ["gpt-56-sol"]
|
||||
default = true
|
||||
|
||||
[providers.openai.models."gpt-5.6-sol".limits]
|
||||
context_window = 1000
|
||||
|
||||
[providers.openai.models."gpt-5.6-sol".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[providers.openai.models."gpt-5.6-sol".costs]
|
||||
input_cost_per_mtok = 1.0
|
||||
output_cost_per_mtok = 2.0
|
||||
|
||||
[providers.openrouter]
|
||||
display_name = "OpenRouter"
|
||||
adapter = "openai_compatible"
|
||||
agent_profile = "openai"
|
||||
base_url = "{openrouter_base_url}"
|
||||
priority = 25
|
||||
|
||||
[providers.openrouter.models."gpt-5.6-sol"]
|
||||
api_id = "openai/gpt-5.6-sol"
|
||||
display_name = "GPT-5.6 Sol (via OpenRouter)"
|
||||
family = "gpt-5"
|
||||
aliases = ["gpt-56-sol"]
|
||||
default = true
|
||||
|
||||
[providers.openrouter.models."gpt-5.6-sol".limits]
|
||||
context_window = 1000
|
||||
|
||||
[providers.openrouter.models."gpt-5.6-sol".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[providers.openrouter.models."gpt-5.6-sol".costs]
|
||||
input_cost_per_mtok = 10.0
|
||||
output_cost_per_mtok = 20.0
|
||||
"#,
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(Catalog::from_settings(&settings).unwrap())
|
||||
}
|
||||
|
||||
async fn portable_mock_client(catalog: &Arc<Catalog>, providers: &[&str]) -> Client {
|
||||
let mut client = Client::new(HashMap::new(), None, vec![]);
|
||||
for provider in providers {
|
||||
client
|
||||
.register_provider(Arc::new(MockProvider::new(provider, provider)))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
client.catalog = Some(Arc::clone(catalog));
|
||||
client
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_alias_selects_by_ready_providers_and_priority_without_mutating_request() {
|
||||
let catalog = portable_model_catalog("https://openrouter.invalid/v1");
|
||||
let mut original = test_request();
|
||||
original.model = "gpt-56-sol".to_string();
|
||||
|
||||
let direct = portable_mock_client(&catalog, &["openai"]).await;
|
||||
let direct_request = direct.resolve_request(&original).unwrap();
|
||||
assert_eq!(direct_request.model, "gpt-5.6-sol");
|
||||
assert_eq!(direct_request.provider.as_deref(), Some("openai"));
|
||||
|
||||
let aggregator = portable_mock_client(&catalog, &["openrouter"]).await;
|
||||
let aggregator_request = aggregator.resolve_request(&original).unwrap();
|
||||
assert_eq!(aggregator_request.model, "gpt-5.6-sol");
|
||||
assert_eq!(aggregator_request.provider.as_deref(), Some("openrouter"));
|
||||
|
||||
let both = portable_mock_client(&catalog, &["openrouter", "openai"]).await;
|
||||
let both_request = both.resolve_request(&original).unwrap();
|
||||
assert_eq!(both_request.provider.as_deref(), Some("openai"));
|
||||
|
||||
assert_eq!(original.model, "gpt-56-sol");
|
||||
assert_eq!(original.provider, None);
|
||||
|
||||
let response = aggregator.complete(&original).await.unwrap();
|
||||
assert_eq!(response.model, "gpt-5.6-sol");
|
||||
assert_eq!(response.provider, "openrouter");
|
||||
assert_eq!(response.cost_source, Some(CostSource::Estimated));
|
||||
assert_eq!(response.cost_usd, Some(0.0005));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_provider_pins_shared_alias_and_preserves_unknown_passthrough() {
|
||||
let catalog = portable_model_catalog("https://openrouter.invalid/v1");
|
||||
let client = portable_mock_client(&catalog, &["openai", "openrouter"]).await;
|
||||
|
||||
let mut aliased = test_request();
|
||||
aliased.model = "gpt-56-sol".to_string();
|
||||
aliased.provider = Some("openrouter".to_string());
|
||||
let resolved = client.resolve_request(&aliased).unwrap();
|
||||
assert_eq!(resolved.model, "gpt-5.6-sol");
|
||||
assert_eq!(resolved.provider.as_deref(), Some("openrouter"));
|
||||
|
||||
let mut unknown = test_request();
|
||||
unknown.model = "provider-private-preview".to_string();
|
||||
unknown.provider = Some("openrouter".to_string());
|
||||
let resolved = client.resolve_request(&unknown).unwrap();
|
||||
assert_eq!(resolved.model, "provider-private-preview");
|
||||
assert_eq!(resolved.provider.as_deref(), Some("openrouter"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selected_offering_api_id_reaches_openai_compatible_wire_request() {
|
||||
let upstream = httpmock::MockServer::start_async().await;
|
||||
let completion = upstream
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST)
|
||||
.path("/chat/completions")
|
||||
.json_body_includes(r#"{"model":"openai/gpt-5.6-sol"}"#);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"id": "chatcmpl-portable",
|
||||
"model": "openai/gpt-5.6-sol",
|
||||
"choices": [{
|
||||
"message": {"role": "assistant", "content": "OK"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 2
|
||||
}
|
||||
}));
|
||||
})
|
||||
.await;
|
||||
let catalog = portable_model_catalog(&upstream.base_url());
|
||||
let adapter = openai_compatible::Adapter::new("test-key", upstream.base_url())
|
||||
.with_name("openrouter")
|
||||
.with_catalog(Arc::clone(&catalog));
|
||||
let mut client = Client::new(HashMap::new(), None, vec![]);
|
||||
client.register_provider(Arc::new(adapter)).await.unwrap();
|
||||
client.catalog = Some(catalog);
|
||||
let mut request = test_request();
|
||||
request.model = "gpt-56-sol".to_string();
|
||||
|
||||
let response = client.complete(&request).await.unwrap();
|
||||
|
||||
assert_eq!(response.model, "gpt-5.6-sol");
|
||||
assert_eq!(response.provider, "openrouter");
|
||||
completion.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_stamps_estimated_cost_from_catalog() {
|
||||
let mut client = Client::new(HashMap::new(), None, vec![]);
|
||||
|
|
@ -1592,7 +1898,10 @@ reasoning = false
|
|||
let mut request = test_request();
|
||||
request.provider = Some("acme-ai".to_string());
|
||||
|
||||
let provider = client.resolve_provider(&request).unwrap();
|
||||
let provider = client
|
||||
.resolve_request_with_adapter(&request)
|
||||
.unwrap()
|
||||
.provider;
|
||||
|
||||
assert_eq!(provider.name(), "acme");
|
||||
}
|
||||
|
|
@ -1620,12 +1929,15 @@ reasoning = false
|
|||
let client = client_with_all_catalog_providers(&catalog).await;
|
||||
|
||||
for model in catalog.list(None) {
|
||||
let route = adapter_registry::resolve_route(&catalog, &model.id)
|
||||
let route = adapter_registry::resolve_route(&catalog, model)
|
||||
.expect("built-in model should resolve to a route");
|
||||
let mut request = test_request();
|
||||
request.model = model.id.clone();
|
||||
request.model = model.id.to_string();
|
||||
|
||||
let provider = client.resolve_provider(&request).unwrap();
|
||||
let provider = client
|
||||
.resolve_request_with_adapter(&request)
|
||||
.unwrap()
|
||||
.provider;
|
||||
|
||||
assert_eq!(provider.name(), route.provider.as_str(), "{}", model.id);
|
||||
}
|
||||
|
|
@ -1640,7 +1952,10 @@ reasoning = false
|
|||
request.model = "gpt-5.4-mini".to_string();
|
||||
request.provider = Some("anthropic".to_string());
|
||||
|
||||
let provider = client.resolve_provider(&request).unwrap();
|
||||
let provider = client
|
||||
.resolve_request_with_adapter(&request)
|
||||
.unwrap()
|
||||
.provider;
|
||||
|
||||
assert_eq!(provider.name(), "anthropic");
|
||||
}
|
||||
|
|
@ -1654,7 +1969,10 @@ reasoning = false
|
|||
let mut request = test_request();
|
||||
request.model = "model-not-in-any-catalog".to_string();
|
||||
|
||||
let provider = client.resolve_provider(&request).unwrap();
|
||||
let provider = client
|
||||
.resolve_request_with_adapter(&request)
|
||||
.unwrap()
|
||||
.provider;
|
||||
|
||||
assert_eq!(provider.name(), default);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -618,26 +618,26 @@ reasoning = true
|
|||
/// Encode `request` on the direct-Anthropic route, optionally with a
|
||||
/// catalog (for capability-driven behavior like prompt-cache/effort).
|
||||
fn encode_direct(request: &Request, catalog: Option<&Catalog>, stream: bool) -> EncodedRequest {
|
||||
let deployment_id = common::api_model_id(catalog, &request.model);
|
||||
let deployment_id = common::api_model_id(catalog, "anthropic", &request.model);
|
||||
let params = direct_params();
|
||||
let ctx = CodecCtx {
|
||||
request,
|
||||
provider_name: "anthropic",
|
||||
deployment_id: &deployment_id,
|
||||
model: common::catalog_model(catalog, &request.model),
|
||||
model: common::catalog_model(catalog, "anthropic", &request.model),
|
||||
params: ¶ms,
|
||||
};
|
||||
encode(&ctx, stream)
|
||||
}
|
||||
|
||||
fn encode_count_direct(request: &Request, catalog: Option<&Catalog>) -> EncodedRequest {
|
||||
let deployment_id = common::api_model_id(catalog, &request.model);
|
||||
let deployment_id = common::api_model_id(catalog, "anthropic", &request.model);
|
||||
let params = direct_params();
|
||||
let ctx = CodecCtx {
|
||||
request,
|
||||
provider_name: "anthropic",
|
||||
deployment_id: &deployment_id,
|
||||
model: common::catalog_model(catalog, &request.model),
|
||||
model: common::catalog_model(catalog, "anthropic", &request.model),
|
||||
params: ¶ms,
|
||||
};
|
||||
encode_count_tokens(&ctx)
|
||||
|
|
|
|||
|
|
@ -347,8 +347,8 @@ fn merge_provider_options(body: &mut Value, provider_options: Option<&Value>, pr
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_model::Catalog;
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
|
@ -617,7 +617,7 @@ sampling_params = false
|
|||
request: &request,
|
||||
provider_name: "bedrock",
|
||||
deployment_id: "pinned-model",
|
||||
model: catalog.get("pinned-model"),
|
||||
model: catalog.get_on_provider(&ProviderId::new("bedrock"), "pinned-model"),
|
||||
params: ¶ms,
|
||||
};
|
||||
let encoded = encode(&ctx, false).unwrap();
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ pub(super) fn merge_provider_options(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_model::Catalog;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
|
||||
use super::super::wire::ApiRequest;
|
||||
use super::*;
|
||||
|
|
@ -183,16 +183,18 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn encode_omits_sampling_params_for_models_that_reject_them() {
|
||||
let model = Catalog::builtin().get("kimi-k3").unwrap();
|
||||
let model = Catalog::builtin()
|
||||
.get_on_provider(&ProviderId::new("kimi"), "kimi-k3")
|
||||
.unwrap();
|
||||
let mut request = minimal_request();
|
||||
request.model = model.id.clone();
|
||||
request.model = model.id.to_string();
|
||||
request.temperature = Some(0.7);
|
||||
request.top_p = Some(0.9);
|
||||
let params = CodecParams::default();
|
||||
let ctx = CodecCtx {
|
||||
request: &request,
|
||||
provider_name: "kimi",
|
||||
deployment_id: &model.id,
|
||||
deployment_id: model.id.as_str(),
|
||||
model: Some(model),
|
||||
params: ¶ms,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ pub(crate) fn estimate_cost_usd(
|
|||
let catalog = catalog?;
|
||||
// The billing machinery compares ModelRefs against the catalog's
|
||||
// canonical identity, so resolve model aliases and provider names first.
|
||||
let model = catalog.get(model)?;
|
||||
let provider = catalog.provider(&ProviderId::new(provider))?;
|
||||
let model = catalog.get_on_provider(&provider.id, model)?;
|
||||
let model_ref = ModelRef {
|
||||
provider: provider.id.clone(),
|
||||
model_id: model.id.clone(),
|
||||
|
|
@ -47,6 +47,7 @@ pub(crate) fn estimate_cost_usd(
|
|||
/// aliases); the response's provider name selects the billing policy.
|
||||
pub(crate) fn apply_estimated_cost(
|
||||
catalog: Option<&Catalog>,
|
||||
provider: &str,
|
||||
model: &str,
|
||||
speed: Option<Speed>,
|
||||
response: &mut Response,
|
||||
|
|
@ -54,7 +55,7 @@ pub(crate) fn apply_estimated_cost(
|
|||
if response.cost_usd.is_some() {
|
||||
return;
|
||||
}
|
||||
let estimate = estimate_cost_usd(catalog, &response.provider, model, &response.usage, speed);
|
||||
let estimate = estimate_cost_usd(catalog, provider, model, &response.usage, speed);
|
||||
response.cost_usd = estimate;
|
||||
response.cost_source = estimate.map(|_| CostSource::Estimated);
|
||||
}
|
||||
|
|
@ -214,7 +215,7 @@ output_cost_per_mtok = {output_cost_per_mtok}
|
|||
..TokenCounts::default()
|
||||
});
|
||||
|
||||
apply_estimated_cost(Some(&catalog), "gpt-test", None, &mut response);
|
||||
apply_estimated_cost(Some(&catalog), "openai", "gpt-test", None, &mut response);
|
||||
|
||||
assert_eq!(response.cost_source, Some(CostSource::Estimated));
|
||||
assert!(response.cost_usd.is_some());
|
||||
|
|
@ -224,7 +225,7 @@ output_cost_per_mtok = {output_cost_per_mtok}
|
|||
fn apply_estimated_cost_leaves_source_unset_without_estimate() {
|
||||
let mut response = response_with_usage(TokenCounts::default());
|
||||
|
||||
apply_estimated_cost(None, "gpt-test", None, &mut response);
|
||||
apply_estimated_cost(None, "openai", "gpt-test", None, &mut response);
|
||||
|
||||
assert_eq!(response.cost_usd, None);
|
||||
assert_eq!(response.cost_source, None);
|
||||
|
|
@ -241,7 +242,7 @@ output_cost_per_mtok = {output_cost_per_mtok}
|
|||
response.cost_usd = Some(0.42);
|
||||
response.cost_source = Some(CostSource::Authoritative);
|
||||
|
||||
apply_estimated_cost(Some(&catalog), "gpt-test", None, &mut response);
|
||||
apply_estimated_cost(Some(&catalog), "openai", "gpt-test", None, &mut response);
|
||||
|
||||
assert_eq!(response.cost_usd, Some(0.42));
|
||||
assert_eq!(response.cost_source, Some(CostSource::Authoritative));
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ pub async fn run_model_test(
|
|||
}
|
||||
|
||||
async fn run_basic_test(info: &Model, client: Arc<Client>) -> ModelTestOutcome {
|
||||
run_basic_model_probe(&info.id, &info.provider, client).await
|
||||
run_basic_model_probe(info.id.as_str(), &info.provider, client).await
|
||||
}
|
||||
|
||||
/// Run the cheap single-prompt model availability probe without requiring a
|
||||
|
|
@ -132,7 +132,7 @@ fn build_deep_test_params(info: &Model, client: Arc<Client>) -> Option<GenerateP
|
|||
},
|
||||
);
|
||||
|
||||
let mut params = GenerateParams::new(&info.id, client)
|
||||
let mut params = GenerateParams::new(info.id.to_string(), client)
|
||||
.provider(info.provider.to_string())
|
||||
.prompt(
|
||||
"Use the add tool twice: first add 15 and 27, then add that result to 42. \
|
||||
|
|
@ -176,7 +176,7 @@ mod tests {
|
|||
|
||||
fn test_model_with(features: ModelFeatures) -> Model {
|
||||
Model {
|
||||
id: "test-model".to_string(),
|
||||
id: "test-model".into(),
|
||||
provider: ProviderId::anthropic(),
|
||||
family: "test".to_string(),
|
||||
display_name: "Test Model".to_string(),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use crate::codec::anthropic_messages::{AnthropicMessages, anthropic_option};
|
|||
use crate::codec::{AnthropicVersion, Codec, CodecCtx, CodecParams, EncodedRequest};
|
||||
use crate::error::Error;
|
||||
use crate::provider::{self, ProviderAdapter, StreamEventStream};
|
||||
use crate::providers::common::{self as common};
|
||||
use crate::providers::common::{self as common, CatalogRoute};
|
||||
use crate::token_count::{InputTokenCount, InputTokenCountMethod};
|
||||
use crate::transport::{self, HttpTransport, SseFraming};
|
||||
use crate::types::{AdapterTimeout, Request, Response, StreamEvent};
|
||||
|
|
@ -118,7 +118,7 @@ impl Adapter {
|
|||
request,
|
||||
provider_name: &self.provider_name,
|
||||
deployment_id,
|
||||
model: common::catalog_model(self.catalog.as_deref(), &request.model),
|
||||
model: self.catalog_model(&request.model),
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
|
@ -210,6 +210,16 @@ fn anthropic_thinking_type(provider_options: Option<&serde_json::Value>) -> Opti
|
|||
.and_then(serde_json::Value::as_str)
|
||||
}
|
||||
|
||||
impl common::CatalogRoute for Adapter {
|
||||
fn catalog(&self) -> Option<&Catalog> {
|
||||
self.catalog.as_deref()
|
||||
}
|
||||
|
||||
fn provider_name(&self) -> &str {
|
||||
&self.provider_name
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProviderAdapter for Adapter {
|
||||
fn name(&self) -> &str {
|
||||
|
|
@ -228,7 +238,7 @@ impl ProviderAdapter for Adapter {
|
|||
self.validate_request(request)?;
|
||||
let resolved = self.resolve_request(request).await;
|
||||
let codec = AnthropicMessages;
|
||||
let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model);
|
||||
let deployment_id = self.api_model_id(&resolved.model);
|
||||
let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params);
|
||||
|
||||
let Some(encoded) = codec.encode_count_tokens(&ctx).transpose()? else {
|
||||
|
|
@ -264,7 +274,7 @@ impl ProviderAdapter for Adapter {
|
|||
|
||||
let resolved = self.resolve_request(request).await;
|
||||
let codec = AnthropicMessages;
|
||||
let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model);
|
||||
let deployment_id = self.api_model_id(&resolved.model);
|
||||
let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params);
|
||||
|
||||
let encoded = codec.encode(&ctx, false)?;
|
||||
|
|
@ -281,7 +291,7 @@ impl ProviderAdapter for Adapter {
|
|||
let route = self.route_config();
|
||||
let resolved = self.resolve_request(request).await;
|
||||
let codec = AnthropicMessages;
|
||||
let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model);
|
||||
let deployment_id = self.api_model_id(&resolved.model);
|
||||
let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params);
|
||||
|
||||
let encoded = codec.encode(&ctx, true)?;
|
||||
|
|
@ -307,7 +317,7 @@ impl ProviderAdapter for Adapter {
|
|||
// Always-adaptive models reject manual enabled/disabled thinking
|
||||
// configs at the API, so fail them locally with a clear message
|
||||
// instead.
|
||||
let model_info = common::catalog_model(self.catalog.as_deref(), &request.model);
|
||||
let model_info = self.catalog_model(&request.model);
|
||||
if let Some(model) = model_info
|
||||
.filter(|m| m.features.reasoning_effort == ReasoningEffortFeature::AlwaysAdaptive)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ use crate::codec::bedrock_converse::BedrockConverse;
|
|||
use crate::codec::{Codec, CodecCtx, CodecParams, EncodedRequest, RawEvent, StreamDecoder};
|
||||
use crate::error::Error;
|
||||
use crate::provider::{self, ProviderAdapter, StreamEventStream};
|
||||
use crate::providers::common::{self as common};
|
||||
use crate::providers::common::{self as common, CatalogRoute};
|
||||
use crate::transport::{self, HttpTransport};
|
||||
use crate::types::{AdapterTimeout, Request, Response, StreamEvent};
|
||||
|
||||
|
|
@ -169,7 +169,7 @@ impl Adapter {
|
|||
request,
|
||||
provider_name: &self.provider_name,
|
||||
deployment_id,
|
||||
model: common::catalog_model(self.catalog.as_deref(), &request.model),
|
||||
model: self.catalog_model(&request.model),
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
|
@ -228,6 +228,16 @@ impl Adapter {
|
|||
}
|
||||
}
|
||||
|
||||
impl common::CatalogRoute for Adapter {
|
||||
fn catalog(&self) -> Option<&Catalog> {
|
||||
self.catalog.as_deref()
|
||||
}
|
||||
|
||||
fn provider_name(&self) -> &str {
|
||||
&self.provider_name
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProviderAdapter for Adapter {
|
||||
fn name(&self) -> &str {
|
||||
|
|
@ -239,7 +249,7 @@ impl ProviderAdapter for Adapter {
|
|||
|
||||
let resolved = self.resolve_request(request).await;
|
||||
let codec = BedrockConverse;
|
||||
let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model);
|
||||
let deployment_id = self.api_model_id(&resolved.model);
|
||||
let params = CodecParams::default();
|
||||
let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms);
|
||||
|
||||
|
|
@ -253,7 +263,7 @@ impl ProviderAdapter for Adapter {
|
|||
|
||||
let resolved = self.resolve_request(request).await;
|
||||
let codec = BedrockConverse;
|
||||
let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model);
|
||||
let deployment_id = self.api_model_id(&resolved.model);
|
||||
let params = CodecParams::default();
|
||||
let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,42 @@
|
|||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use fabro_model::{Catalog, Model};
|
||||
use fabro_model::{Catalog, Model, ProviderId};
|
||||
use fabro_static::EnvVars;
|
||||
use tokio::fs;
|
||||
|
||||
#[must_use]
|
||||
pub fn catalog_model<'a>(catalog: Option<&'a Catalog>, model: &str) -> Option<&'a Model> {
|
||||
catalog.and_then(|catalog| catalog.get(model))
|
||||
pub fn catalog_model<'a>(
|
||||
catalog: Option<&'a Catalog>,
|
||||
provider: &str,
|
||||
model: &str,
|
||||
) -> Option<&'a Model> {
|
||||
catalog.and_then(|catalog| catalog.get_on_provider(&ProviderId::new(provider), model))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn api_model_id(catalog: Option<&Catalog>, model: &str) -> String {
|
||||
pub fn api_model_id(catalog: Option<&Catalog>, provider: &str, model: &str) -> String {
|
||||
catalog
|
||||
.and_then(|catalog| catalog.model_settings(model))
|
||||
.and_then(|catalog| catalog.model_settings_on_provider(&ProviderId::new(provider), model))
|
||||
.map_or_else(|| model.to_string(), |settings| settings.api_id.clone())
|
||||
}
|
||||
|
||||
/// Adapters that route models through an optional catalog scoped to one
|
||||
/// provider name.
|
||||
pub trait CatalogRoute {
|
||||
fn catalog(&self) -> Option<&Catalog>;
|
||||
fn provider_name(&self) -> &str;
|
||||
|
||||
/// Catalog offering for a canonical ID or alias on this provider.
|
||||
fn catalog_model(&self, model: &str) -> Option<&Model> {
|
||||
catalog_model(self.catalog(), self.provider_name(), model)
|
||||
}
|
||||
|
||||
/// Identifier sent to the provider API for a model.
|
||||
fn api_model_id(&self, model: &str) -> String {
|
||||
api_model_id(self.catalog(), self.provider_name(), model)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a URL string looks like a local file path.
|
||||
#[must_use]
|
||||
pub fn is_file_path(url: &str) -> bool {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::error::Error;
|
|||
use crate::provider::{
|
||||
ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice,
|
||||
};
|
||||
use crate::providers::common::{self as common};
|
||||
use crate::providers::common::{self as common, CatalogRoute};
|
||||
use crate::token_count::{InputTokenCount, InputTokenCountMethod};
|
||||
use crate::transport::{self, HttpTransport, SseFraming};
|
||||
use crate::types::{AdapterTimeout, Request, Response};
|
||||
|
|
@ -102,7 +102,7 @@ impl Adapter {
|
|||
request,
|
||||
provider_name: &self.provider_name,
|
||||
deployment_id,
|
||||
model: common::catalog_model(self.catalog.as_deref(), &request.model),
|
||||
model: self.catalog_model(&request.model),
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
|
@ -125,6 +125,16 @@ impl Adapter {
|
|||
}
|
||||
}
|
||||
|
||||
impl common::CatalogRoute for Adapter {
|
||||
fn catalog(&self) -> Option<&Catalog> {
|
||||
self.catalog.as_deref()
|
||||
}
|
||||
|
||||
fn provider_name(&self) -> &str {
|
||||
&self.provider_name
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProviderAdapter for Adapter {
|
||||
fn name(&self) -> &str {
|
||||
|
|
@ -147,7 +157,7 @@ impl ProviderAdapter for Adapter {
|
|||
|
||||
let resolved = self.resolve_request(request).await;
|
||||
let codec = GeminiGenerate;
|
||||
let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model);
|
||||
let deployment_id = self.api_model_id(&resolved.model);
|
||||
let params = CodecParams::default();
|
||||
let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms);
|
||||
|
||||
|
|
@ -177,7 +187,7 @@ impl ProviderAdapter for Adapter {
|
|||
|
||||
let resolved = self.resolve_request(request).await;
|
||||
let codec = GeminiGenerate;
|
||||
let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model);
|
||||
let deployment_id = self.api_model_id(&resolved.model);
|
||||
let params = CodecParams::default();
|
||||
let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms);
|
||||
|
||||
|
|
@ -194,7 +204,7 @@ impl ProviderAdapter for Adapter {
|
|||
|
||||
let resolved = self.resolve_request(request).await;
|
||||
let codec = GeminiGenerate;
|
||||
let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model);
|
||||
let deployment_id = self.api_model_id(&resolved.model);
|
||||
let params = CodecParams::default();
|
||||
let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::error::Error;
|
|||
use crate::provider::{
|
||||
ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice,
|
||||
};
|
||||
use crate::providers::common::{self as common};
|
||||
use crate::providers::common::{self as common, CatalogRoute};
|
||||
use crate::token_count::{InputTokenCount, InputTokenCountMethod};
|
||||
use crate::transport::{self, HttpTransport, SseFraming};
|
||||
use crate::types::{AdapterTimeout, Request, Response, StreamEvent};
|
||||
|
|
@ -131,7 +131,7 @@ impl Adapter {
|
|||
request,
|
||||
provider_name: &self.provider_name,
|
||||
deployment_id,
|
||||
model: common::catalog_model(self.catalog.as_deref(), &request.model),
|
||||
model: self.catalog_model(&request.model),
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
|
@ -192,6 +192,16 @@ impl Adapter {
|
|||
}
|
||||
}
|
||||
|
||||
impl common::CatalogRoute for Adapter {
|
||||
fn catalog(&self) -> Option<&Catalog> {
|
||||
self.catalog.as_deref()
|
||||
}
|
||||
|
||||
fn provider_name(&self) -> &str {
|
||||
&self.provider_name
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProviderAdapter for Adapter {
|
||||
fn name(&self) -> &str {
|
||||
|
|
@ -214,7 +224,7 @@ impl ProviderAdapter for Adapter {
|
|||
|
||||
let resolved = self.resolve_request(request).await;
|
||||
let codec = OpenAiResponses;
|
||||
let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model);
|
||||
let deployment_id = self.api_model_id(&resolved.model);
|
||||
let params = self.codec_params();
|
||||
let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms);
|
||||
|
||||
|
|
@ -250,7 +260,7 @@ impl ProviderAdapter for Adapter {
|
|||
|
||||
let resolved = self.resolve_request(request).await;
|
||||
let codec = OpenAiResponses;
|
||||
let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model);
|
||||
let deployment_id = self.api_model_id(&resolved.model);
|
||||
let params = self.codec_params();
|
||||
let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms);
|
||||
|
||||
|
|
@ -267,7 +277,7 @@ impl ProviderAdapter for Adapter {
|
|||
|
||||
let resolved = self.resolve_request(request).await;
|
||||
let codec = OpenAiResponses;
|
||||
let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model);
|
||||
let deployment_id = self.api_model_id(&resolved.model);
|
||||
let params = self.codec_params();
|
||||
let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use crate::error::Error;
|
|||
use crate::provider::{
|
||||
ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice,
|
||||
};
|
||||
use crate::providers::common::{self as common};
|
||||
use crate::providers::common::{self as common, CatalogRoute};
|
||||
use crate::transport::{self, HttpTransport, SseFraming};
|
||||
use crate::types::{AdapterTimeout, Request, Response};
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ impl Adapter {
|
|||
/// Resolve the wire model id (catalog `api_id`, falling back to the
|
||||
/// requested model).
|
||||
fn deployment_id(&self, request: &Request) -> String {
|
||||
common::api_model_id(self.catalog.as_deref(), &request.model)
|
||||
self.api_model_id(&request.model)
|
||||
}
|
||||
|
||||
/// Build the borrowed codec context. `deployment_id` and `params` are
|
||||
|
|
@ -103,7 +103,7 @@ impl Adapter {
|
|||
request,
|
||||
provider_name: &self.provider_name,
|
||||
deployment_id,
|
||||
model: common::catalog_model(self.catalog.as_deref(), &request.model),
|
||||
model: self.catalog_model(&request.model),
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +127,16 @@ impl Adapter {
|
|||
}
|
||||
}
|
||||
|
||||
impl common::CatalogRoute for Adapter {
|
||||
fn catalog(&self) -> Option<&Catalog> {
|
||||
self.catalog.as_deref()
|
||||
}
|
||||
|
||||
fn provider_name(&self) -> &str {
|
||||
&self.provider_name
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProviderAdapter for Adapter {
|
||||
fn name(&self) -> &str {
|
||||
|
|
|
|||
|
|
@ -326,7 +326,7 @@ async fn poolside_laguna_xs_deep_tool_round_trip() {
|
|||
.expect("Poolside client should build from the catalog"),
|
||||
);
|
||||
let model = catalog
|
||||
.get("laguna-xs-2.1")
|
||||
.get_on_provider(&ProviderId::new("poolside"), "laguna-xs-2.1")
|
||||
.expect("direct Poolside Laguna XS should be present");
|
||||
|
||||
let outcome = run_model_test(model, ModelTestMode::Deep, client).await;
|
||||
|
|
@ -572,7 +572,7 @@ async fn openrouter_kimi_k3_deep_tool_round_trip() {
|
|||
.expect("OpenRouter client should build from the catalog"),
|
||||
);
|
||||
let model = catalog
|
||||
.get("moonshotai/kimi-k3")
|
||||
.get_on_provider(&ProviderId::new("openrouter"), "kimi-k3")
|
||||
.expect("OpenRouter Kimi K3 should be present");
|
||||
|
||||
let outcome = run_model_test(model, ModelTestMode::Deep, client).await;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||
use strum::{Display, EnumString, IntoStaticStr};
|
||||
|
||||
use crate::catalog::{BillingPolicy, Catalog, CatalogModelSettings};
|
||||
use crate::{Model, ModelCosts, ProviderId};
|
||||
use crate::{Model, ModelCosts, ModelId, ProviderId};
|
||||
|
||||
const TOKENS_PER_MTOK: i128 = 1_000_000;
|
||||
const ANTHROPIC_CACHE_WRITE_5M_NUMERATOR: i64 = 5;
|
||||
|
|
@ -161,7 +161,7 @@ pub enum CostSource {
|
|||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ModelRef {
|
||||
pub provider: ProviderId,
|
||||
pub model_id: String,
|
||||
pub model_id: ModelId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub speed: Option<Speed>,
|
||||
}
|
||||
|
|
@ -344,7 +344,7 @@ impl BilledModelUsage {
|
|||
|
||||
#[must_use]
|
||||
pub fn model_id(&self) -> &str {
|
||||
&self.input.usage.model.model_id
|
||||
self.input.usage.model.model_id.as_str()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
|
@ -470,13 +470,9 @@ fn anthropic_billing_facts(tokens: &TokenCounts) -> AnthropicBillingFacts {
|
|||
impl Catalog {
|
||||
#[must_use]
|
||||
pub fn pricing_for(&self, model_ref: &ModelRef) -> Option<ModelPricing> {
|
||||
let model = self.get(&model_ref.model_id)?;
|
||||
let model = self.offering(&model_ref.provider, &model_ref.model_id)?;
|
||||
let provider = self.provider(&model_ref.provider)?;
|
||||
if model.provider != provider.id {
|
||||
return None;
|
||||
}
|
||||
|
||||
let settings = self.model_settings(&model.id)?;
|
||||
let settings = self.settings_for(model)?;
|
||||
let costs = costs_for_speed(model, settings, model_ref.speed)?;
|
||||
pricing_for_model_costs(
|
||||
model,
|
||||
|
|
@ -494,7 +490,7 @@ impl Catalog {
|
|||
tokens: &TokenCounts,
|
||||
) -> Option<ModelBillingFacts> {
|
||||
let policy =
|
||||
self.effective_billing_policy(&model_ref.provider, Some(&model_ref.model_id))?;
|
||||
self.effective_billing_policy(&model_ref.provider, Some(model_ref.model_id.as_str()))?;
|
||||
ModelBillingFacts::for_policy(policy, tokens)
|
||||
}
|
||||
|
||||
|
|
@ -748,7 +744,7 @@ mod tests {
|
|||
usage: ModelUsage {
|
||||
model: ModelRef {
|
||||
provider: ProviderId::openai(),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: ModelId::new("gpt-5.4"),
|
||||
speed: None,
|
||||
},
|
||||
tokens: TokenCounts {
|
||||
|
|
@ -859,12 +855,12 @@ cache_input_cost_per_mtok = 0.3
|
|||
};
|
||||
let claude = ModelRef {
|
||||
provider: ProviderId::new("aggregator"),
|
||||
model_id: "claude-via-aggregator".to_string(),
|
||||
model_id: ModelId::new("claude-via-aggregator"),
|
||||
speed: None,
|
||||
};
|
||||
let plain = ModelRef {
|
||||
provider: ProviderId::new("aggregator"),
|
||||
model_id: "plain-model".to_string(),
|
||||
model_id: ModelId::new("plain-model"),
|
||||
speed: None,
|
||||
};
|
||||
|
||||
|
|
@ -1018,7 +1014,7 @@ cache_input_cost_per_mtok = 0.3
|
|||
let pricing = ModelPricing {
|
||||
model: ModelRef {
|
||||
provider: ProviderId::openai(),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: ModelId::new("gpt-5.4"),
|
||||
speed: None,
|
||||
},
|
||||
policy: ModelPricingPolicy::OpenAi(OpenAiModelPricing {
|
||||
|
|
@ -1055,7 +1051,7 @@ cache_input_cost_per_mtok = 0.3
|
|||
let pricing = Catalog::builtin()
|
||||
.pricing_for(&ModelRef {
|
||||
provider: ProviderId::anthropic(),
|
||||
model_id: "claude-opus-4-6".to_string(),
|
||||
model_id: ModelId::new("claude-opus-4-6"),
|
||||
speed: Some(Speed::Fast),
|
||||
})
|
||||
.unwrap();
|
||||
|
|
@ -1079,7 +1075,7 @@ cache_input_cost_per_mtok = 0.3
|
|||
let pricing = Catalog::builtin()
|
||||
.pricing_for(&ModelRef {
|
||||
provider: ProviderId::anthropic(),
|
||||
model_id: "claude-opus-4-6".to_string(),
|
||||
model_id: ModelId::new("claude-opus-4-6"),
|
||||
speed: Some(Speed::Standard),
|
||||
})
|
||||
.unwrap();
|
||||
|
|
@ -1132,7 +1128,7 @@ cache_input_cost_per_mtok = 0.25
|
|||
let pricing = catalog
|
||||
.pricing_for(&ModelRef {
|
||||
provider: ProviderId::new("test_anthropic"),
|
||||
model_id: "test-opus".to_string(),
|
||||
model_id: ModelId::new("test-opus"),
|
||||
speed: Some(Speed::Fast),
|
||||
})
|
||||
.unwrap();
|
||||
|
|
@ -1180,7 +1176,7 @@ cache_input_cost_per_mtok = 0.1
|
|||
let pricing = catalog
|
||||
.pricing_for(&ModelRef {
|
||||
provider: ProviderId::new("proxy"),
|
||||
model_id: "proxy-model".to_string(),
|
||||
model_id: ModelId::new("proxy-model"),
|
||||
speed: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
|
@ -1230,7 +1226,7 @@ output_cost_per_mtok = 2.0
|
|||
catalog
|
||||
.pricing_for(&ModelRef {
|
||||
provider: ProviderId::new("proxy"),
|
||||
model_id: "canonical-model".to_string(),
|
||||
model_id: ModelId::new("canonical-model"),
|
||||
speed: None,
|
||||
})
|
||||
.is_some()
|
||||
|
|
@ -1239,7 +1235,7 @@ output_cost_per_mtok = 2.0
|
|||
catalog
|
||||
.pricing_for(&ModelRef {
|
||||
provider: ProviderId::new("proxy"),
|
||||
model_id: "wire-model".to_string(),
|
||||
model_id: ModelId::new("wire-model"),
|
||||
speed: None,
|
||||
})
|
||||
.is_none()
|
||||
|
|
@ -1252,7 +1248,7 @@ output_cost_per_mtok = 2.0
|
|||
Catalog::builtin()
|
||||
.pricing_for(&ModelRef {
|
||||
provider: ProviderId::new("unknown"),
|
||||
model_id: "claude-opus-4-6".to_string(),
|
||||
model_id: ModelId::new("claude-opus-4-6"),
|
||||
speed: None,
|
||||
})
|
||||
.is_none()
|
||||
|
|
@ -1261,7 +1257,7 @@ output_cost_per_mtok = 2.0
|
|||
Catalog::builtin()
|
||||
.pricing_for(&ModelRef {
|
||||
provider: ProviderId::anthropic(),
|
||||
model_id: "unknown".to_string(),
|
||||
model_id: ModelId::new("unknown"),
|
||||
speed: None,
|
||||
})
|
||||
.is_none()
|
||||
|
|
@ -1270,7 +1266,7 @@ output_cost_per_mtok = 2.0
|
|||
Catalog::builtin()
|
||||
.pricing_for(&ModelRef {
|
||||
provider: ProviderId::openai(),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: ModelId::new("gpt-5.4"),
|
||||
speed: Some(Speed::Fast),
|
||||
})
|
||||
.is_none()
|
||||
|
|
@ -1282,7 +1278,7 @@ output_cost_per_mtok = 2.0
|
|||
let pricing = ModelPricing {
|
||||
model: ModelRef {
|
||||
provider: ProviderId::anthropic(),
|
||||
model_id: "claude-opus-4-6".to_string(),
|
||||
model_id: ModelId::new("claude-opus-4-6"),
|
||||
speed: Some(Speed::Fast),
|
||||
},
|
||||
policy: ModelPricingPolicy::Anthropic(AnthropicModelPricing {
|
||||
|
|
@ -1328,7 +1324,7 @@ output_cost_per_mtok = 2.0
|
|||
let pricing = ModelPricing {
|
||||
model: ModelRef {
|
||||
provider: ProviderId::gemini(),
|
||||
model_id: "gemini-3.1-pro-preview".to_string(),
|
||||
model_id: ModelId::new("gemini-3.1-pro-preview"),
|
||||
speed: None,
|
||||
},
|
||||
policy: ModelPricingPolicy::Gemini(GeminiModelPricing {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,18 +9,16 @@ priority = 100
|
|||
credentials = ["env:ANTHROPIC_API_KEY", "vault:ANTHROPIC_API_KEY"]
|
||||
header = { custom = "x-api-key" }
|
||||
|
||||
[models."claude-fable-5"]
|
||||
provider = "anthropic"
|
||||
api_id = "claude-fable-5"
|
||||
[providers.anthropic.models."claude-fable-5"]
|
||||
display_name = "Claude Fable 5"
|
||||
family = "claude-5"
|
||||
aliases = ["fable", "claude-fable"]
|
||||
|
||||
[models."claude-fable-5".limits]
|
||||
[providers.anthropic.models."claude-fable-5".limits]
|
||||
context_window = 1000000
|
||||
max_output = 128000
|
||||
|
||||
[models."claude-fable-5".features]
|
||||
[providers.anthropic.models."claude-fable-5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
|
|
@ -28,14 +26,12 @@ reasoning_effort = "always_adaptive"
|
|||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
[models."claude-fable-5".costs]
|
||||
[providers.anthropic.models."claude-fable-5".costs]
|
||||
input_cost_per_mtok = 10.0
|
||||
output_cost_per_mtok = 50.0
|
||||
cache_input_cost_per_mtok = 1.0
|
||||
|
||||
[models."claude-opus-4-8"]
|
||||
provider = "anthropic"
|
||||
api_id = "claude-opus-4-8"
|
||||
[providers.anthropic.models."claude-opus-4-8"]
|
||||
display_name = "Claude Opus 4.8"
|
||||
family = "claude-4"
|
||||
training = "2026-01-01"
|
||||
|
|
@ -43,11 +39,11 @@ knowledge_cutoff = "Jan 2026"
|
|||
estimated_output_tps = 25
|
||||
aliases = ["opus", "claude-opus"]
|
||||
|
||||
[models."claude-opus-4-8".limits]
|
||||
[providers.anthropic.models."claude-opus-4-8".limits]
|
||||
context_window = 1000000
|
||||
max_output = 128000
|
||||
|
||||
[models."claude-opus-4-8".features]
|
||||
[providers.anthropic.models."claude-opus-4-8".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
|
|
@ -55,33 +51,31 @@ reasoning_effort = "levels"
|
|||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
[models."claude-opus-4-8".controls]
|
||||
[providers.anthropic.models."claude-opus-4-8".controls]
|
||||
speed = ["fast"]
|
||||
|
||||
[models."claude-opus-4-8".costs]
|
||||
[providers.anthropic.models."claude-opus-4-8".costs]
|
||||
input_cost_per_mtok = 5.0
|
||||
output_cost_per_mtok = 25.0
|
||||
cache_input_cost_per_mtok = 0.5
|
||||
|
||||
[models."claude-opus-4-8".costs.speed.fast]
|
||||
[providers.anthropic.models."claude-opus-4-8".costs.speed.fast]
|
||||
input_cost_per_mtok = 10.0
|
||||
output_cost_per_mtok = 50.0
|
||||
cache_input_cost_per_mtok = 1.0
|
||||
|
||||
[models."claude-opus-4-7"]
|
||||
provider = "anthropic"
|
||||
api_id = "claude-opus-4-7"
|
||||
[providers.anthropic.models."claude-opus-4-7"]
|
||||
display_name = "Claude Opus 4.7"
|
||||
family = "claude-4"
|
||||
training = "2025-08-01"
|
||||
knowledge_cutoff = "May 2025"
|
||||
estimated_output_tps = 25
|
||||
|
||||
[models."claude-opus-4-7".limits]
|
||||
[providers.anthropic.models."claude-opus-4-7".limits]
|
||||
context_window = 1000000
|
||||
max_output = 128000
|
||||
|
||||
[models."claude-opus-4-7".features]
|
||||
[providers.anthropic.models."claude-opus-4-7".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
|
|
@ -89,82 +83,76 @@ reasoning_effort = "levels"
|
|||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
[models."claude-opus-4-7".controls]
|
||||
[providers.anthropic.models."claude-opus-4-7".controls]
|
||||
speed = ["fast"]
|
||||
|
||||
[models."claude-opus-4-7".costs]
|
||||
[providers.anthropic.models."claude-opus-4-7".costs]
|
||||
input_cost_per_mtok = 5.0
|
||||
output_cost_per_mtok = 25.0
|
||||
cache_input_cost_per_mtok = 0.5
|
||||
|
||||
[models."claude-opus-4-7".costs.speed.fast]
|
||||
[providers.anthropic.models."claude-opus-4-7".costs.speed.fast]
|
||||
input_cost_per_mtok = 30.0
|
||||
output_cost_per_mtok = 150.0
|
||||
cache_input_cost_per_mtok = 3.0
|
||||
|
||||
[models."claude-opus-4-6"]
|
||||
provider = "anthropic"
|
||||
api_id = "claude-opus-4-6"
|
||||
[providers.anthropic.models."claude-opus-4-6"]
|
||||
display_name = "Claude Opus 4.6"
|
||||
family = "claude-4"
|
||||
training = "2025-08-01"
|
||||
knowledge_cutoff = "May 2025"
|
||||
estimated_output_tps = 25
|
||||
|
||||
[models."claude-opus-4-6".limits]
|
||||
[providers.anthropic.models."claude-opus-4-6".limits]
|
||||
context_window = 1000000
|
||||
max_output = 128000
|
||||
|
||||
[models."claude-opus-4-6".features]
|
||||
[providers.anthropic.models."claude-opus-4-6".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
prompt_cache = true
|
||||
|
||||
[models."claude-opus-4-6".controls]
|
||||
[providers.anthropic.models."claude-opus-4-6".controls]
|
||||
speed = ["fast"]
|
||||
|
||||
[models."claude-opus-4-6".costs]
|
||||
[providers.anthropic.models."claude-opus-4-6".costs]
|
||||
input_cost_per_mtok = 5.0
|
||||
output_cost_per_mtok = 25.0
|
||||
cache_input_cost_per_mtok = 0.5
|
||||
|
||||
[models."claude-opus-4-6".costs.speed.fast]
|
||||
[providers.anthropic.models."claude-opus-4-6".costs.speed.fast]
|
||||
input_cost_per_mtok = 30.0
|
||||
output_cost_per_mtok = 150.0
|
||||
cache_input_cost_per_mtok = 3.0
|
||||
|
||||
[models."claude-sonnet-4-5"]
|
||||
provider = "anthropic"
|
||||
api_id = "claude-sonnet-4-5"
|
||||
[providers.anthropic.models."claude-sonnet-4-5"]
|
||||
display_name = "Claude Sonnet 4.5"
|
||||
family = "claude-4"
|
||||
training = "2025-08-01"
|
||||
knowledge_cutoff = "May 2025"
|
||||
estimated_output_tps = 50
|
||||
|
||||
[models."claude-sonnet-4-5".limits]
|
||||
[providers.anthropic.models."claude-sonnet-4-5".limits]
|
||||
context_window = 200000
|
||||
max_output = 64000
|
||||
|
||||
[models."claude-sonnet-4-5".features]
|
||||
[providers.anthropic.models."claude-sonnet-4-5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
prompt_cache = true
|
||||
|
||||
[models."claude-sonnet-4-5".controls]
|
||||
[providers.anthropic.models."claude-sonnet-4-5".controls]
|
||||
reasoning_effort = ["low", "medium", "high", "xhigh", "max"]
|
||||
|
||||
[models."claude-sonnet-4-5".costs]
|
||||
[providers.anthropic.models."claude-sonnet-4-5".costs]
|
||||
input_cost_per_mtok = 3.0
|
||||
output_cost_per_mtok = 15.0
|
||||
cache_input_cost_per_mtok = 0.3
|
||||
|
||||
[models."claude-sonnet-4-6"]
|
||||
provider = "anthropic"
|
||||
api_id = "claude-sonnet-4-6"
|
||||
[providers.anthropic.models."claude-sonnet-4-6"]
|
||||
display_name = "Claude Sonnet 4.6"
|
||||
family = "claude-4"
|
||||
training = "2025-08-01"
|
||||
|
|
@ -173,25 +161,23 @@ default = true
|
|||
estimated_output_tps = 50
|
||||
aliases = ["sonnet", "claude-sonnet"]
|
||||
|
||||
[models."claude-sonnet-4-6".limits]
|
||||
[providers.anthropic.models."claude-sonnet-4-6".limits]
|
||||
context_window = 200000
|
||||
max_output = 64000
|
||||
|
||||
[models."claude-sonnet-4-6".features]
|
||||
[providers.anthropic.models."claude-sonnet-4-6".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
prompt_cache = true
|
||||
|
||||
[models."claude-sonnet-4-6".costs]
|
||||
[providers.anthropic.models."claude-sonnet-4-6".costs]
|
||||
input_cost_per_mtok = 3.0
|
||||
output_cost_per_mtok = 15.0
|
||||
cache_input_cost_per_mtok = 0.3
|
||||
|
||||
[models."claude-haiku-4-5"]
|
||||
provider = "anthropic"
|
||||
api_id = "claude-haiku-4-5"
|
||||
[providers.anthropic.models."claude-haiku-4-5"]
|
||||
display_name = "Claude Haiku 4.5"
|
||||
family = "claude-4"
|
||||
training = "2025-08-01"
|
||||
|
|
@ -201,17 +187,17 @@ aliases = ["haiku", "claude-haiku"]
|
|||
probe = true
|
||||
small_default = true
|
||||
|
||||
[models."claude-haiku-4-5".limits]
|
||||
[providers.anthropic.models."claude-haiku-4-5".limits]
|
||||
context_window = 200000
|
||||
max_output = 8192
|
||||
|
||||
[models."claude-haiku-4-5".features]
|
||||
[providers.anthropic.models."claude-haiku-4-5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = false
|
||||
prompt_cache = true
|
||||
|
||||
[models."claude-haiku-4-5".costs]
|
||||
[providers.anthropic.models."claude-haiku-4-5".costs]
|
||||
input_cost_per_mtok = 0.8
|
||||
output_cost_per_mtok = 4.0
|
||||
cache_input_cost_per_mtok = 0.08
|
||||
|
|
|
|||
|
|
@ -35,41 +35,41 @@ credentials = [
|
|||
# [llm.providers.bedrock-openai]
|
||||
# enabled = true
|
||||
|
||||
[models."openai.gpt-5.5"]
|
||||
provider = "bedrock-openai"
|
||||
[providers.bedrock-openai.models."gpt-5.5"]
|
||||
api_id = "openai.gpt-5.5"
|
||||
display_name = "GPT-5.5 (Bedrock)"
|
||||
family = "gpt-5"
|
||||
default = true
|
||||
|
||||
[models."openai.gpt-5.5".limits]
|
||||
[providers.bedrock-openai.models."gpt-5.5".limits]
|
||||
context_window = 272000
|
||||
max_output = 128000
|
||||
|
||||
[models."openai.gpt-5.5".features]
|
||||
[providers.bedrock-openai.models."gpt-5.5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[models."openai.gpt-5.5".costs]
|
||||
[providers.bedrock-openai.models."gpt-5.5".costs]
|
||||
input_cost_per_mtok = 5.5
|
||||
output_cost_per_mtok = 33.0
|
||||
|
||||
[models."openai.gpt-5.4"]
|
||||
provider = "bedrock-openai"
|
||||
[providers.bedrock-openai.models."gpt-5.4"]
|
||||
api_id = "openai.gpt-5.4"
|
||||
display_name = "GPT-5.4 (Bedrock)"
|
||||
family = "gpt-5"
|
||||
|
||||
[models."openai.gpt-5.4".limits]
|
||||
[providers.bedrock-openai.models."gpt-5.4".limits]
|
||||
context_window = 272000
|
||||
max_output = 128000
|
||||
|
||||
[models."openai.gpt-5.4".features]
|
||||
[providers.bedrock-openai.models."gpt-5.4".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[models."openai.gpt-5.4".costs]
|
||||
[providers.bedrock-openai.models."gpt-5.4".costs]
|
||||
input_cost_per_mtok = 2.75
|
||||
output_cost_per_mtok = 16.5
|
||||
|
|
|
|||
|
|
@ -45,68 +45,67 @@ credentials = [
|
|||
# file because its Bedrock deployment pins sampling parameters and requires an
|
||||
# extra data-sharing opt-in.
|
||||
|
||||
[models."us.anthropic.claude-sonnet-4-6"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."claude-sonnet-4-6"]
|
||||
api_id = "us.anthropic.claude-sonnet-4-6"
|
||||
display_name = "Claude Sonnet 4.6 (Bedrock)"
|
||||
family = "claude-4"
|
||||
billing_policy = "anthropic"
|
||||
default = true
|
||||
|
||||
[models."us.anthropic.claude-sonnet-4-6".limits]
|
||||
[providers.bedrock.models."claude-sonnet-4-6".limits]
|
||||
context_window = 1000000
|
||||
max_output = 64000
|
||||
|
||||
[models."us.anthropic.claude-sonnet-4-6".features]
|
||||
[providers.bedrock.models."claude-sonnet-4-6".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
prompt_cache = true
|
||||
|
||||
[models."us.anthropic.claude-sonnet-4-6".costs]
|
||||
[providers.bedrock.models."claude-sonnet-4-6".costs]
|
||||
input_cost_per_mtok = 3.0
|
||||
output_cost_per_mtok = 15.0
|
||||
cache_input_cost_per_mtok = 0.3
|
||||
|
||||
[models."us.anthropic.claude-opus-4-8"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."claude-opus-4-8"]
|
||||
api_id = "us.anthropic.claude-opus-4-8"
|
||||
display_name = "Claude Opus 4.8 (Bedrock)"
|
||||
family = "claude-4"
|
||||
billing_policy = "anthropic"
|
||||
|
||||
[models."us.anthropic.claude-opus-4-8".limits]
|
||||
[providers.bedrock.models."claude-opus-4-8".limits]
|
||||
context_window = 1000000
|
||||
max_output = 128000
|
||||
|
||||
[models."us.anthropic.claude-opus-4-8".features]
|
||||
[providers.bedrock.models."claude-opus-4-8".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
prompt_cache = true
|
||||
|
||||
[models."us.anthropic.claude-opus-4-8".costs]
|
||||
[providers.bedrock.models."claude-opus-4-8".costs]
|
||||
input_cost_per_mtok = 5.0
|
||||
output_cost_per_mtok = 25.0
|
||||
cache_input_cost_per_mtok = 0.5
|
||||
|
||||
[models."us.anthropic.claude-haiku-4-5"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."claude-haiku-4-5"]
|
||||
api_id = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
display_name = "Claude Haiku 4.5 (Bedrock)"
|
||||
family = "claude-4"
|
||||
billing_policy = "anthropic"
|
||||
small_default = true
|
||||
|
||||
[models."us.anthropic.claude-haiku-4-5".limits]
|
||||
[providers.bedrock.models."claude-haiku-4-5".limits]
|
||||
context_window = 200000
|
||||
max_output = 64000
|
||||
|
||||
[models."us.anthropic.claude-haiku-4-5".features]
|
||||
[providers.bedrock.models."claude-haiku-4-5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = false
|
||||
prompt_cache = true
|
||||
|
||||
[models."us.anthropic.claude-haiku-4-5".costs]
|
||||
[providers.bedrock.models."claude-haiku-4-5".costs]
|
||||
input_cost_per_mtok = 1.0
|
||||
output_cost_per_mtok = 5.0
|
||||
cache_input_cost_per_mtok = 0.1
|
||||
|
|
@ -116,149 +115,142 @@ cache_input_cost_per_mtok = 0.1
|
|||
# GPT-5.5/5.4 are NOT here: on Bedrock they are Responses-API-only on the
|
||||
# bedrock-mantle endpoint (no Converse), a named follow-up route.
|
||||
|
||||
[models."openai.gpt-oss-120b"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."gpt-oss-120b"]
|
||||
api_id = "openai.gpt-oss-120b-1:0"
|
||||
display_name = "GPT-OSS 120B (Bedrock)"
|
||||
family = "gpt-oss"
|
||||
billing_policy = "openai"
|
||||
agent_profile = "openai"
|
||||
|
||||
[models."openai.gpt-oss-120b".limits]
|
||||
[providers.bedrock.models."gpt-oss-120b".limits]
|
||||
context_window = 128000
|
||||
max_output = 16384
|
||||
|
||||
[models."openai.gpt-oss-120b".features]
|
||||
[providers.bedrock.models."gpt-oss-120b".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
|
||||
[models."openai.gpt-oss-120b".costs]
|
||||
[providers.bedrock.models."gpt-oss-120b".costs]
|
||||
input_cost_per_mtok = 0.15
|
||||
output_cost_per_mtok = 0.60
|
||||
|
||||
[models."openai.gpt-oss-20b"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."gpt-oss-20b"]
|
||||
api_id = "openai.gpt-oss-20b-1:0"
|
||||
display_name = "GPT-OSS 20B (Bedrock)"
|
||||
family = "gpt-oss"
|
||||
billing_policy = "openai"
|
||||
agent_profile = "openai"
|
||||
|
||||
[models."openai.gpt-oss-20b".limits]
|
||||
[providers.bedrock.models."gpt-oss-20b".limits]
|
||||
context_window = 128000
|
||||
max_output = 16384
|
||||
|
||||
[models."openai.gpt-oss-20b".features]
|
||||
[providers.bedrock.models."gpt-oss-20b".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
|
||||
[models."openai.gpt-oss-20b".costs]
|
||||
[providers.bedrock.models."gpt-oss-20b".costs]
|
||||
input_cost_per_mtok = 0.07
|
||||
output_cost_per_mtok = 0.30
|
||||
|
||||
# ---------- Amazon Nova ----------
|
||||
|
||||
[models."amazon.nova-2-lite"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."nova-2-lite"]
|
||||
api_id = "global.amazon.nova-2-lite-v1:0"
|
||||
display_name = "Nova 2 Lite (Bedrock)"
|
||||
family = "nova-2"
|
||||
billing_policy = "openai"
|
||||
agent_profile = "openai"
|
||||
|
||||
[models."amazon.nova-2-lite".limits]
|
||||
[providers.bedrock.models."nova-2-lite".limits]
|
||||
context_window = 1000000
|
||||
# Bedrock caps Nova output at 65535 (2^16 - 1); 65536 trips
|
||||
# "maximum tokens exceeds the model limit of 65535" since the prompt handler
|
||||
# defaults max_tokens to max_output.
|
||||
max_output = 65535
|
||||
|
||||
[models."amazon.nova-2-lite".features]
|
||||
[providers.bedrock.models."nova-2-lite".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = false
|
||||
|
||||
[models."amazon.nova-2-lite".costs]
|
||||
[providers.bedrock.models."nova-2-lite".costs]
|
||||
input_cost_per_mtok = 0.30
|
||||
output_cost_per_mtok = 2.50
|
||||
|
||||
# ---------- Open-weights ----------
|
||||
|
||||
[models."meta.llama4-maverick"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."llama-4-maverick"]
|
||||
api_id = "us.meta.llama4-maverick-17b-instruct-v1:0"
|
||||
display_name = "Llama 4 Maverick (Bedrock)"
|
||||
family = "llama-4"
|
||||
billing_policy = "openai"
|
||||
agent_profile = "openai"
|
||||
|
||||
[models."meta.llama4-maverick".limits]
|
||||
[providers.bedrock.models."llama-4-maverick".limits]
|
||||
context_window = 1000000
|
||||
max_output = 8192
|
||||
|
||||
[models."meta.llama4-maverick".features]
|
||||
[providers.bedrock.models."llama-4-maverick".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = false
|
||||
|
||||
[models."mistral.mistral-large-3"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."mistral-large-3"]
|
||||
api_id = "mistral.mistral-large-3-675b-instruct"
|
||||
display_name = "Mistral Large 3 (Bedrock)"
|
||||
family = "mistral-large"
|
||||
billing_policy = "openai"
|
||||
agent_profile = "openai"
|
||||
|
||||
[models."mistral.mistral-large-3".limits]
|
||||
[providers.bedrock.models."mistral-large-3".limits]
|
||||
context_window = 256000
|
||||
max_output = 32768
|
||||
|
||||
[models."mistral.mistral-large-3".features]
|
||||
[providers.bedrock.models."mistral-large-3".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = false
|
||||
|
||||
[models."mistral.mistral-large-3".costs]
|
||||
[providers.bedrock.models."mistral-large-3".costs]
|
||||
input_cost_per_mtok = 0.50
|
||||
output_cost_per_mtok = 1.50
|
||||
|
||||
[models."mistral.devstral-2"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."devstral-2"]
|
||||
api_id = "mistral.devstral-2-123b"
|
||||
display_name = "Devstral 2 (Bedrock)"
|
||||
family = "devstral"
|
||||
billing_policy = "openai"
|
||||
agent_profile = "openai"
|
||||
|
||||
[models."mistral.devstral-2".limits]
|
||||
[providers.bedrock.models."devstral-2".limits]
|
||||
context_window = 256000
|
||||
max_output = 32768
|
||||
|
||||
[models."mistral.devstral-2".features]
|
||||
[providers.bedrock.models."devstral-2".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."deepseek.v3-2"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."deepseek-v3.2"]
|
||||
api_id = "deepseek.v3.2"
|
||||
display_name = "DeepSeek V3.2 (Bedrock)"
|
||||
family = "deepseek-v3"
|
||||
billing_policy = "openai"
|
||||
agent_profile = "openai"
|
||||
|
||||
[models."deepseek.v3-2".limits]
|
||||
[providers.bedrock.models."deepseek-v3.2".limits]
|
||||
context_window = 164000
|
||||
max_output = 8192
|
||||
|
||||
[models."deepseek.v3-2".features]
|
||||
[providers.bedrock.models."deepseek-v3.2".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
|
||||
[models."deepseek.v3-2".costs]
|
||||
[providers.bedrock.models."deepseek-v3.2".costs]
|
||||
input_cost_per_mtok = 0.62
|
||||
output_cost_per_mtok = 1.85
|
||||
|
||||
|
|
@ -267,92 +259,90 @@ output_cost_per_mtok = 1.85
|
|||
# "The provided model identifier is invalid"), so this row needs an explicit
|
||||
# `api_id` confirmed against `aws bedrock list-inference-profiles` before it
|
||||
# ships. Re-add with:
|
||||
# [models."qwen.qwen3-coder-next"]
|
||||
# provider = "bedrock"
|
||||
# [providers.bedrock.models."qwen3-coder-next"]
|
||||
# api_id = "<verified bedrock id>"
|
||||
# display_name = "Qwen3 Coder Next (Bedrock)"
|
||||
# family = "qwen3"
|
||||
# billing_policy = "openai"
|
||||
# agent_profile = "openai"
|
||||
# [models."qwen.qwen3-coder-next".limits]
|
||||
# [providers.bedrock.models."qwen3-coder-next".limits]
|
||||
# context_window = 256000
|
||||
# max_output = 16384
|
||||
# [models."qwen.qwen3-coder-next".features]
|
||||
# [providers.bedrock.models."qwen3-coder-next".features]
|
||||
# tools = true
|
||||
|
||||
[models."moonshotai.kimi-k2.5"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."kimi-k2.5"]
|
||||
api_id = "moonshotai.kimi-k2.5"
|
||||
display_name = "Kimi K2.5 (Bedrock)"
|
||||
family = "kimi-k2"
|
||||
billing_policy = "openai"
|
||||
agent_profile = "openai"
|
||||
|
||||
[models."moonshotai.kimi-k2.5".limits]
|
||||
[providers.bedrock.models."kimi-k2.5".limits]
|
||||
context_window = 262144
|
||||
max_output = 16384
|
||||
|
||||
[models."moonshotai.kimi-k2.5".features]
|
||||
[providers.bedrock.models."kimi-k2.5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = false
|
||||
|
||||
[models."moonshotai.kimi-k2.5".costs]
|
||||
[providers.bedrock.models."kimi-k2.5".costs]
|
||||
input_cost_per_mtok = 0.60
|
||||
output_cost_per_mtok = 3.00
|
||||
|
||||
[models."zai.glm-5"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."glm-5"]
|
||||
api_id = "zai.glm-5"
|
||||
display_name = "GLM 5 (Bedrock)"
|
||||
family = "glm"
|
||||
billing_policy = "openai"
|
||||
agent_profile = "openai"
|
||||
|
||||
[models."zai.glm-5".limits]
|
||||
[providers.bedrock.models."glm-5".limits]
|
||||
context_window = 200000
|
||||
max_output = 128000
|
||||
|
||||
[models."zai.glm-5".features]
|
||||
[providers.bedrock.models."glm-5".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."zai.glm-5".costs]
|
||||
[providers.bedrock.models."glm-5".costs]
|
||||
input_cost_per_mtok = 1.00
|
||||
output_cost_per_mtok = 3.20
|
||||
|
||||
[models."minimax.minimax-m2.5"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."minimax-m2.5"]
|
||||
api_id = "minimax.minimax-m2.5"
|
||||
display_name = "MiniMax M2.5 (Bedrock)"
|
||||
family = "minimax-m2"
|
||||
billing_policy = "openai"
|
||||
agent_profile = "openai"
|
||||
|
||||
[models."minimax.minimax-m2.5".limits]
|
||||
[providers.bedrock.models."minimax-m2.5".limits]
|
||||
context_window = 196000
|
||||
max_output = 8192
|
||||
|
||||
[models."minimax.minimax-m2.5".features]
|
||||
[providers.bedrock.models."minimax-m2.5".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."minimax.minimax-m2.5".costs]
|
||||
[providers.bedrock.models."minimax-m2.5".costs]
|
||||
input_cost_per_mtok = 0.30
|
||||
output_cost_per_mtok = 1.20
|
||||
|
||||
[models."nvidia.nemotron-3-super"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."nemotron-3-super"]
|
||||
api_id = "nvidia.nemotron-super-3-120b"
|
||||
display_name = "Nemotron 3 Super (Bedrock)"
|
||||
family = "nemotron-3"
|
||||
billing_policy = "openai"
|
||||
agent_profile = "openai"
|
||||
|
||||
[models."nvidia.nemotron-3-super".limits]
|
||||
[providers.bedrock.models."nemotron-3-super".limits]
|
||||
context_window = 256000
|
||||
max_output = 32768
|
||||
|
||||
[models."nvidia.nemotron-3-super".features]
|
||||
[providers.bedrock.models."nemotron-3-super".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
|
@ -365,24 +355,24 @@ reasoning = false
|
|||
# reasoning_effort stays undeclared here (requests carrying one are
|
||||
# rejected up front rather than silently dropped).
|
||||
|
||||
[models."us.anthropic.claude-fable-5"]
|
||||
provider = "bedrock"
|
||||
[providers.bedrock.models."claude-fable-5"]
|
||||
api_id = "us.anthropic.claude-fable-5"
|
||||
display_name = "Claude Fable 5 (Bedrock)"
|
||||
family = "claude-5"
|
||||
billing_policy = "anthropic"
|
||||
|
||||
[models."us.anthropic.claude-fable-5".limits]
|
||||
[providers.bedrock.models."claude-fable-5".limits]
|
||||
context_window = 1000000
|
||||
max_output = 128000
|
||||
|
||||
[models."us.anthropic.claude-fable-5".features]
|
||||
[providers.bedrock.models."claude-fable-5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
[models."us.anthropic.claude-fable-5".costs]
|
||||
[providers.bedrock.models."claude-fable-5".costs]
|
||||
input_cost_per_mtok = 10.0
|
||||
output_cost_per_mtok = 50.0
|
||||
cache_input_cost_per_mtok = 1.0
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ priority = 80
|
|||
credentials = ["env:GEMINI_API_KEY", "env:GOOGLE_API_KEY", "vault:GEMINI_API_KEY"]
|
||||
header = { custom = "x-goog-api-key" }
|
||||
|
||||
[models."gemini-3.1-pro-preview"]
|
||||
provider = "gemini"
|
||||
api_id = "gemini-3.1-pro-preview"
|
||||
[providers.gemini.models."gemini-3.1-pro-preview"]
|
||||
display_name = "Gemini 3.1 Pro (Preview)"
|
||||
family = "gemini-3"
|
||||
training = "2025-01-01"
|
||||
|
|
@ -19,24 +17,22 @@ knowledge_cutoff = "January 2025"
|
|||
estimated_output_tps = 85
|
||||
aliases = ["gemini-pro"]
|
||||
|
||||
[models."gemini-3.1-pro-preview".limits]
|
||||
[providers.gemini.models."gemini-3.1-pro-preview".limits]
|
||||
context_window = 1048576
|
||||
max_output = 65536
|
||||
|
||||
[models."gemini-3.1-pro-preview".features]
|
||||
[providers.gemini.models."gemini-3.1-pro-preview".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[models."gemini-3.1-pro-preview".costs]
|
||||
[providers.gemini.models."gemini-3.1-pro-preview".costs]
|
||||
input_cost_per_mtok = 2.0
|
||||
output_cost_per_mtok = 12.0
|
||||
cache_input_cost_per_mtok = 0.5
|
||||
|
||||
[models."gemini-3.1-pro-preview-customtools"]
|
||||
provider = "gemini"
|
||||
api_id = "gemini-3.1-pro-preview-customtools"
|
||||
[providers.gemini.models."gemini-3.1-pro-preview-customtools"]
|
||||
display_name = "Gemini 3.1 Pro Custom Tools (Preview)"
|
||||
family = "gemini-3"
|
||||
training = "2025-01-01"
|
||||
|
|
@ -44,24 +40,22 @@ knowledge_cutoff = "January 2025"
|
|||
estimated_output_tps = 85
|
||||
aliases = ["gemini-customtools"]
|
||||
|
||||
[models."gemini-3.1-pro-preview-customtools".limits]
|
||||
[providers.gemini.models."gemini-3.1-pro-preview-customtools".limits]
|
||||
context_window = 1048576
|
||||
max_output = 65536
|
||||
|
||||
[models."gemini-3.1-pro-preview-customtools".features]
|
||||
[providers.gemini.models."gemini-3.1-pro-preview-customtools".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[models."gemini-3.1-pro-preview-customtools".costs]
|
||||
[providers.gemini.models."gemini-3.1-pro-preview-customtools".costs]
|
||||
input_cost_per_mtok = 2.0
|
||||
output_cost_per_mtok = 12.0
|
||||
cache_input_cost_per_mtok = 0.5
|
||||
|
||||
[models."gemini-3.5-flash"]
|
||||
provider = "gemini"
|
||||
api_id = "gemini-3.5-flash"
|
||||
[providers.gemini.models."gemini-3.5-flash"]
|
||||
display_name = "Gemini 3.5 Flash"
|
||||
family = "gemini-3"
|
||||
training = "2025-01-01"
|
||||
|
|
@ -70,24 +64,22 @@ default = true
|
|||
estimated_output_tps = 150
|
||||
aliases = ["gemini-35-flash"]
|
||||
|
||||
[models."gemini-3.5-flash".limits]
|
||||
[providers.gemini.models."gemini-3.5-flash".limits]
|
||||
context_window = 1048576
|
||||
max_output = 65536
|
||||
|
||||
[models."gemini-3.5-flash".features]
|
||||
[providers.gemini.models."gemini-3.5-flash".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[models."gemini-3.5-flash".costs]
|
||||
[providers.gemini.models."gemini-3.5-flash".costs]
|
||||
input_cost_per_mtok = 1.5
|
||||
output_cost_per_mtok = 9.0
|
||||
cache_input_cost_per_mtok = 0.15
|
||||
|
||||
[models."gemini-3-flash-preview"]
|
||||
provider = "gemini"
|
||||
api_id = "gemini-3-flash-preview"
|
||||
[providers.gemini.models."gemini-3-flash-preview"]
|
||||
display_name = "Gemini 3 Flash (Preview)"
|
||||
family = "gemini-3"
|
||||
training = "2025-01-01"
|
||||
|
|
@ -95,24 +87,22 @@ knowledge_cutoff = "January 2025"
|
|||
estimated_output_tps = 150
|
||||
aliases = ["gemini-flash"]
|
||||
|
||||
[models."gemini-3-flash-preview".limits]
|
||||
[providers.gemini.models."gemini-3-flash-preview".limits]
|
||||
context_window = 1048576
|
||||
max_output = 65536
|
||||
|
||||
[models."gemini-3-flash-preview".features]
|
||||
[providers.gemini.models."gemini-3-flash-preview".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[models."gemini-3-flash-preview".costs]
|
||||
[providers.gemini.models."gemini-3-flash-preview".costs]
|
||||
input_cost_per_mtok = 0.5
|
||||
output_cost_per_mtok = 3.0
|
||||
cache_input_cost_per_mtok = 0.125
|
||||
|
||||
[models."gemini-3.1-flash-lite"]
|
||||
provider = "gemini"
|
||||
api_id = "gemini-3.1-flash-lite"
|
||||
[providers.gemini.models."gemini-3.1-flash-lite"]
|
||||
display_name = "Gemini 3.1 Flash Lite"
|
||||
family = "gemini-3"
|
||||
training = "2025-01-01"
|
||||
|
|
@ -121,17 +111,17 @@ estimated_output_tps = 200
|
|||
aliases = ["gemini-flash-lite", "gemini-3.1-flash-lite-preview"]
|
||||
small_default = true
|
||||
|
||||
[models."gemini-3.1-flash-lite".limits]
|
||||
[providers.gemini.models."gemini-3.1-flash-lite".limits]
|
||||
context_window = 1048576
|
||||
max_output = 65536
|
||||
|
||||
[models."gemini-3.1-flash-lite".features]
|
||||
[providers.gemini.models."gemini-3.1-flash-lite".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[models."gemini-3.1-flash-lite".costs]
|
||||
[providers.gemini.models."gemini-3.1-flash-lite".costs]
|
||||
input_cost_per_mtok = 0.25
|
||||
output_cost_per_mtok = 1.5
|
||||
cache_input_cost_per_mtok = 0.025
|
||||
|
|
|
|||
|
|
@ -8,25 +8,23 @@ priority = 40
|
|||
[providers.inception.auth]
|
||||
credentials = ["env:INCEPTION_API_KEY", "vault:INCEPTION_API_KEY"]
|
||||
|
||||
[models."mercury-2"]
|
||||
provider = "inception"
|
||||
api_id = "mercury-2"
|
||||
[providers.inception.models."mercury-2"]
|
||||
display_name = "Mercury 2"
|
||||
family = "mercury"
|
||||
default = true
|
||||
estimated_output_tps = 1000
|
||||
aliases = ["mercury"]
|
||||
|
||||
[models."mercury-2".limits]
|
||||
[providers.inception.models."mercury-2".limits]
|
||||
context_window = 131072
|
||||
max_output = 50000
|
||||
|
||||
[models."mercury-2".features]
|
||||
[providers.inception.models."mercury-2".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[models."mercury-2".costs]
|
||||
[providers.inception.models."mercury-2".costs]
|
||||
input_cost_per_mtok = 0.25
|
||||
output_cost_per_mtok = 0.75
|
||||
|
|
|
|||
|
|
@ -8,46 +8,42 @@ priority = 70
|
|||
[providers.kimi.auth]
|
||||
credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"]
|
||||
|
||||
[models."kimi-k2.5"]
|
||||
provider = "kimi"
|
||||
api_id = "kimi-k2.5"
|
||||
[providers.kimi.models."kimi-k2.5"]
|
||||
display_name = "Kimi K2.5"
|
||||
family = "kimi-k2"
|
||||
training = "2025-10-01"
|
||||
knowledge_cutoff = "October 2025"
|
||||
estimated_output_tps = 50
|
||||
|
||||
[models."kimi-k2.5".limits]
|
||||
[providers.kimi.models."kimi-k2.5".limits]
|
||||
context_window = 262144
|
||||
max_output = 32768
|
||||
|
||||
[models."kimi-k2.5".features]
|
||||
[providers.kimi.models."kimi-k2.5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
[models."kimi-k2.5".costs]
|
||||
[providers.kimi.models."kimi-k2.5".costs]
|
||||
input_cost_per_mtok = 0.6
|
||||
output_cost_per_mtok = 3.0
|
||||
cache_input_cost_per_mtok = 0.1
|
||||
|
||||
[models."kimi-k3"]
|
||||
provider = "kimi"
|
||||
api_id = "kimi-k3"
|
||||
[providers.kimi.models."kimi-k3"]
|
||||
display_name = "Kimi K3"
|
||||
family = "kimi-k3"
|
||||
default = true
|
||||
aliases = ["kimi"]
|
||||
|
||||
[models."kimi-k3".limits]
|
||||
[providers.kimi.models."kimi-k3".limits]
|
||||
context_window = 1048576
|
||||
# K3 accepts explicit completion budgets up to 1048576, but Fabro also uses
|
||||
# max_output as the default request budget. Match Kimi's 131072-token default.
|
||||
max_output = 131072
|
||||
|
||||
[models."kimi-k3".features]
|
||||
[providers.kimi.models."kimi-k3".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
|
|
@ -55,10 +51,10 @@ reasoning_effort = "always_adaptive"
|
|||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
[models."kimi-k3".controls]
|
||||
[providers.kimi.models."kimi-k3".controls]
|
||||
reasoning_effort = ["low", "high", "max"]
|
||||
|
||||
[models."kimi-k3".costs]
|
||||
[providers.kimi.models."kimi-k3".costs]
|
||||
input_cost_per_mtok = 3.0
|
||||
output_cost_per_mtok = 15.0
|
||||
cache_input_cost_per_mtok = 0.3
|
||||
|
|
|
|||
|
|
@ -14,18 +14,17 @@ credentials = ["env:LITELLM_API_KEY", "vault:LITELLM_API_KEY"]
|
|||
# enabled = true
|
||||
# base_url = "http://localhost:4000/v1"
|
||||
#
|
||||
# [llm.models."litellm-gpt-5"]
|
||||
# provider = "litellm"
|
||||
# [llm.providers.litellm.models."litellm-gpt-5"]
|
||||
# api_id = "gpt-5"
|
||||
# display_name = "LiteLLM GPT-5"
|
||||
# family = "litellm"
|
||||
# default = true
|
||||
#
|
||||
# [llm.models."litellm-gpt-5".limits]
|
||||
# [llm.providers.litellm.models."litellm-gpt-5".limits]
|
||||
# context_window = 128000
|
||||
# max_output = 8192
|
||||
#
|
||||
# [llm.models."litellm-gpt-5".features]
|
||||
# [llm.providers.litellm.models."litellm-gpt-5".features]
|
||||
# tools = true
|
||||
# vision = false
|
||||
# reasoning = false
|
||||
|
|
|
|||
|
|
@ -8,24 +8,22 @@ priority = 50
|
|||
[providers.minimax.auth]
|
||||
credentials = ["env:MINIMAX_API_KEY", "vault:MINIMAX_API_KEY"]
|
||||
|
||||
[models."minimax-m2.5"]
|
||||
provider = "minimax"
|
||||
api_id = "minimax-m2.5"
|
||||
[providers.minimax.models."minimax-m2.5"]
|
||||
display_name = "Minimax M2.5"
|
||||
family = "minimax-m2"
|
||||
default = true
|
||||
estimated_output_tps = 45
|
||||
aliases = ["minimax"]
|
||||
|
||||
[models."minimax-m2.5".limits]
|
||||
[providers.minimax.models."minimax-m2.5".limits]
|
||||
context_window = 196608
|
||||
max_output = 16384
|
||||
|
||||
[models."minimax-m2.5".features]
|
||||
[providers.minimax.models."minimax-m2.5".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."minimax-m2.5".costs]
|
||||
[providers.minimax.models."minimax-m2.5".costs]
|
||||
input_cost_per_mtok = 0.3
|
||||
output_cost_per_mtok = 1.2
|
||||
|
|
|
|||
|
|
@ -9,18 +9,17 @@ enabled = false
|
|||
# Example model. Uncomment after `ollama pull qwen3.5` (and `enabled = true`
|
||||
# above) to expose it through the OpenAI-compatible adapter.
|
||||
#
|
||||
# [models."qwen3.5"]
|
||||
# provider = "ollama"
|
||||
# [providers.ollama.models."qwen3.5"]
|
||||
# api_id = "qwen3.5:latest"
|
||||
# display_name = "Qwen3.5"
|
||||
# family = "qwen3.5"
|
||||
# default = true
|
||||
# aliases = ["ollama-qwen3.5"]
|
||||
#
|
||||
# [models."qwen3.5".limits]
|
||||
# [providers.ollama.models."qwen3.5".limits]
|
||||
# context_window = 32768
|
||||
#
|
||||
# [models."qwen3.5".features]
|
||||
# [providers.ollama.models."qwen3.5".features]
|
||||
# tools = true
|
||||
# vision = false
|
||||
# reasoning = false
|
||||
|
|
|
|||
|
|
@ -8,85 +8,77 @@ priority = 90
|
|||
[providers.openai.auth]
|
||||
credentials = ["env:OPENAI_API_KEY", "vault:OPENAI_API_KEY", "vault:OPENAI_CODEX"]
|
||||
|
||||
[models."gpt-5.6-sol"]
|
||||
provider = "openai"
|
||||
api_id = "gpt-5.6-sol"
|
||||
[providers.openai.models."gpt-5.6-sol"]
|
||||
display_name = "GPT-5.6 Sol"
|
||||
family = "gpt-5"
|
||||
training = "2026-02-16"
|
||||
knowledge_cutoff = "February 16, 2026"
|
||||
default = true
|
||||
aliases = ["gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"]
|
||||
aliases = ["sol", "gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"]
|
||||
|
||||
[models."gpt-5.6-sol".limits]
|
||||
[providers.openai.models."gpt-5.6-sol".limits]
|
||||
context_window = 272000
|
||||
max_output = 128000
|
||||
|
||||
[models."gpt-5.6-sol".features]
|
||||
[providers.openai.models."gpt-5.6-sol".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
prompt_cache = true
|
||||
|
||||
[models."gpt-5.6-sol".costs]
|
||||
[providers.openai.models."gpt-5.6-sol".costs]
|
||||
input_cost_per_mtok = 5.0
|
||||
output_cost_per_mtok = 30.0
|
||||
cache_input_cost_per_mtok = 0.5
|
||||
|
||||
[models."gpt-5.6-terra"]
|
||||
provider = "openai"
|
||||
api_id = "gpt-5.6-terra"
|
||||
[providers.openai.models."gpt-5.6-terra"]
|
||||
display_name = "GPT-5.6 Terra"
|
||||
family = "gpt-5"
|
||||
training = "2026-02-16"
|
||||
knowledge_cutoff = "February 16, 2026"
|
||||
aliases = ["gpt56-terra", "gpt-56-terra"]
|
||||
aliases = ["terra", "gpt56-terra", "gpt-56-terra"]
|
||||
|
||||
[models."gpt-5.6-terra".limits]
|
||||
[providers.openai.models."gpt-5.6-terra".limits]
|
||||
context_window = 272000
|
||||
max_output = 128000
|
||||
|
||||
[models."gpt-5.6-terra".features]
|
||||
[providers.openai.models."gpt-5.6-terra".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
prompt_cache = true
|
||||
|
||||
[models."gpt-5.6-terra".costs]
|
||||
[providers.openai.models."gpt-5.6-terra".costs]
|
||||
input_cost_per_mtok = 2.5
|
||||
output_cost_per_mtok = 15.0
|
||||
cache_input_cost_per_mtok = 0.25
|
||||
|
||||
[models."gpt-5.6-luna"]
|
||||
provider = "openai"
|
||||
api_id = "gpt-5.6-luna"
|
||||
[providers.openai.models."gpt-5.6-luna"]
|
||||
display_name = "GPT-5.6 Luna"
|
||||
family = "gpt-5"
|
||||
training = "2026-02-16"
|
||||
knowledge_cutoff = "February 16, 2026"
|
||||
aliases = ["gpt56-luna", "gpt-56-luna"]
|
||||
aliases = ["luna", "gpt56-luna", "gpt-56-luna"]
|
||||
|
||||
[models."gpt-5.6-luna".limits]
|
||||
[providers.openai.models."gpt-5.6-luna".limits]
|
||||
context_window = 272000
|
||||
max_output = 128000
|
||||
|
||||
[models."gpt-5.6-luna".features]
|
||||
[providers.openai.models."gpt-5.6-luna".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
prompt_cache = true
|
||||
|
||||
[models."gpt-5.6-luna".costs]
|
||||
[providers.openai.models."gpt-5.6-luna".costs]
|
||||
input_cost_per_mtok = 1.0
|
||||
output_cost_per_mtok = 6.0
|
||||
cache_input_cost_per_mtok = 0.1
|
||||
|
||||
[models."gpt-5.4"]
|
||||
provider = "openai"
|
||||
api_id = "gpt-5.4"
|
||||
[providers.openai.models."gpt-5.4"]
|
||||
display_name = "GPT-5.4"
|
||||
family = "gpt-5"
|
||||
training = "2025-08-31"
|
||||
|
|
@ -94,24 +86,22 @@ knowledge_cutoff = "April 2025"
|
|||
estimated_output_tps = 70
|
||||
aliases = ["gpt54", "gpt-54", "gpt-5.2", "gpt5", "gpt-5.3-codex", "codex"]
|
||||
|
||||
[models."gpt-5.4".limits]
|
||||
[providers.openai.models."gpt-5.4".limits]
|
||||
context_window = 272000
|
||||
max_output = 128000
|
||||
|
||||
[models."gpt-5.4".features]
|
||||
[providers.openai.models."gpt-5.4".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[models."gpt-5.4".costs]
|
||||
[providers.openai.models."gpt-5.4".costs]
|
||||
input_cost_per_mtok = 2.5
|
||||
output_cost_per_mtok = 15.0
|
||||
cache_input_cost_per_mtok = 0.25
|
||||
|
||||
[models."gpt-5.5"]
|
||||
provider = "openai"
|
||||
api_id = "gpt-5.5"
|
||||
[providers.openai.models."gpt-5.5"]
|
||||
display_name = "GPT-5.5"
|
||||
family = "gpt-5"
|
||||
training = "2025-12-01"
|
||||
|
|
@ -119,24 +109,22 @@ knowledge_cutoff = "December 2025"
|
|||
estimated_output_tps = 70
|
||||
aliases = ["gpt55", "gpt-55"]
|
||||
|
||||
[models."gpt-5.5".limits]
|
||||
[providers.openai.models."gpt-5.5".limits]
|
||||
context_window = 272000
|
||||
max_output = 128000
|
||||
|
||||
[models."gpt-5.5".features]
|
||||
[providers.openai.models."gpt-5.5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[models."gpt-5.5".costs]
|
||||
[providers.openai.models."gpt-5.5".costs]
|
||||
input_cost_per_mtok = 5.0
|
||||
output_cost_per_mtok = 30.0
|
||||
cache_input_cost_per_mtok = 0.5
|
||||
|
||||
[models."gpt-5.5-pro"]
|
||||
provider = "openai"
|
||||
api_id = "gpt-5.5-pro"
|
||||
[providers.openai.models."gpt-5.5-pro"]
|
||||
display_name = "GPT-5.5 Pro"
|
||||
family = "gpt-5"
|
||||
training = "2025-12-01"
|
||||
|
|
@ -144,24 +132,22 @@ knowledge_cutoff = "December 2025"
|
|||
estimated_output_tps = 20
|
||||
aliases = ["gpt55-pro", "gpt-55-pro"]
|
||||
|
||||
[models."gpt-5.5-pro".limits]
|
||||
[providers.openai.models."gpt-5.5-pro".limits]
|
||||
context_window = 1050000
|
||||
max_output = 128000
|
||||
|
||||
[models."gpt-5.5-pro".features]
|
||||
[providers.openai.models."gpt-5.5-pro".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[models."gpt-5.5-pro".costs]
|
||||
[providers.openai.models."gpt-5.5-pro".costs]
|
||||
input_cost_per_mtok = 30.0
|
||||
output_cost_per_mtok = 180.0
|
||||
cache_input_cost_per_mtok = 3.0
|
||||
|
||||
[models."gpt-5.4-pro"]
|
||||
provider = "openai"
|
||||
api_id = "gpt-5.4-pro"
|
||||
[providers.openai.models."gpt-5.4-pro"]
|
||||
display_name = "GPT-5.4 Pro"
|
||||
family = "gpt-5"
|
||||
training = "2025-08-31"
|
||||
|
|
@ -169,24 +155,22 @@ knowledge_cutoff = "April 2025"
|
|||
estimated_output_tps = 20
|
||||
aliases = ["gpt54-pro", "gpt-54-pro"]
|
||||
|
||||
[models."gpt-5.4-pro".limits]
|
||||
[providers.openai.models."gpt-5.4-pro".limits]
|
||||
context_window = 1047576
|
||||
max_output = 128000
|
||||
|
||||
[models."gpt-5.4-pro".features]
|
||||
[providers.openai.models."gpt-5.4-pro".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[models."gpt-5.4-pro".costs]
|
||||
[providers.openai.models."gpt-5.4-pro".costs]
|
||||
input_cost_per_mtok = 30.0
|
||||
output_cost_per_mtok = 180.0
|
||||
cache_input_cost_per_mtok = 3.0
|
||||
|
||||
[models."gpt-5.4-mini"]
|
||||
provider = "openai"
|
||||
api_id = "gpt-5.4-mini"
|
||||
[providers.openai.models."gpt-5.4-mini"]
|
||||
display_name = "GPT-5.4 Mini"
|
||||
family = "gpt-5"
|
||||
training = "2025-08-31"
|
||||
|
|
@ -196,17 +180,17 @@ aliases = ["gpt54-mini", "gpt-54-mini", "gpt-5.3-codex-spark", "codex-spark"]
|
|||
probe = true
|
||||
small_default = true
|
||||
|
||||
[models."gpt-5.4-mini".limits]
|
||||
[providers.openai.models."gpt-5.4-mini".limits]
|
||||
context_window = 272000
|
||||
max_output = 128000
|
||||
|
||||
[models."gpt-5.4-mini".features]
|
||||
[providers.openai.models."gpt-5.4-mini".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[models."gpt-5.4-mini".costs]
|
||||
[providers.openai.models."gpt-5.4-mini".costs]
|
||||
input_cost_per_mtok = 0.75
|
||||
output_cost_per_mtok = 4.5
|
||||
cache_input_cost_per_mtok = 0.075
|
||||
|
|
|
|||
|
|
@ -33,18 +33,18 @@ credentials = ["env:OPENROUTER_API_KEY", "vault:OPENROUTER_API_KEY"]
|
|||
# best-effort estimates; OpenRouter returns the authoritative usage.cost
|
||||
# in-band on every response.
|
||||
|
||||
[models."anthropic/claude-fable-5"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."claude-fable-5"]
|
||||
api_id = "anthropic/claude-fable-5"
|
||||
display_name = "Claude Fable 5 (via OpenRouter)"
|
||||
family = "claude-5"
|
||||
billing_policy = "anthropic"
|
||||
aliases = ["fable", "claude-fable"]
|
||||
|
||||
[models."anthropic/claude-fable-5".limits]
|
||||
[providers.openrouter.models."claude-fable-5".limits]
|
||||
context_window = 1000000
|
||||
max_output = 128000
|
||||
|
||||
[models."anthropic/claude-fable-5".features]
|
||||
[providers.openrouter.models."claude-fable-5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
|
|
@ -52,26 +52,25 @@ reasoning_effort = "always_adaptive"
|
|||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
[models."anthropic/claude-fable-5".costs]
|
||||
[providers.openrouter.models."claude-fable-5".costs]
|
||||
input_cost_per_mtok = 10.0
|
||||
output_cost_per_mtok = 50.0
|
||||
cache_input_cost_per_mtok = 1.0
|
||||
|
||||
[models."anthropic/claude-opus-4-8"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."claude-opus-4-8"]
|
||||
api_id = "anthropic/claude-opus-4.8"
|
||||
display_name = "Claude Opus 4.8 (via OpenRouter)"
|
||||
family = "claude-4"
|
||||
billing_policy = "anthropic"
|
||||
training = "2026-01-01"
|
||||
knowledge_cutoff = "Jan 2026"
|
||||
aliases = ["anthropic/claude-opus-4.8"]
|
||||
aliases = ["opus", "claude-opus"]
|
||||
|
||||
[models."anthropic/claude-opus-4-8".limits]
|
||||
[providers.openrouter.models."claude-opus-4-8".limits]
|
||||
context_window = 1000000
|
||||
max_output = 128000
|
||||
|
||||
[models."anthropic/claude-opus-4-8".features]
|
||||
[providers.openrouter.models."claude-opus-4-8".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
|
|
@ -79,75 +78,74 @@ reasoning_effort = "levels"
|
|||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
[models."anthropic/claude-opus-4-8".costs]
|
||||
[providers.openrouter.models."claude-opus-4-8".costs]
|
||||
input_cost_per_mtok = 5.0
|
||||
output_cost_per_mtok = 25.0
|
||||
cache_input_cost_per_mtok = 0.5
|
||||
|
||||
[models."anthropic/claude-opus-4-7"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."claude-opus-4-7"]
|
||||
api_id = "anthropic/claude-opus-4.7"
|
||||
display_name = "Claude Opus 4.7 (via OpenRouter)"
|
||||
family = "claude-4"
|
||||
billing_policy = "anthropic"
|
||||
|
||||
[models."anthropic/claude-opus-4-7".limits]
|
||||
[providers.openrouter.models."claude-opus-4-7".limits]
|
||||
context_window = 1000000
|
||||
max_output = 128000
|
||||
|
||||
[models."anthropic/claude-opus-4-7".features]
|
||||
[providers.openrouter.models."claude-opus-4-7".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
prompt_cache = true
|
||||
|
||||
[models."anthropic/claude-opus-4-7".costs]
|
||||
[providers.openrouter.models."claude-opus-4-7".costs]
|
||||
input_cost_per_mtok = 5.0
|
||||
output_cost_per_mtok = 25.0
|
||||
cache_input_cost_per_mtok = 0.5
|
||||
|
||||
[models."anthropic/claude-sonnet-4-6"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."claude-sonnet-4-6"]
|
||||
api_id = "anthropic/claude-sonnet-4.6"
|
||||
display_name = "Claude Sonnet 4.6 (via OpenRouter)"
|
||||
family = "claude-4"
|
||||
billing_policy = "anthropic"
|
||||
default = true
|
||||
aliases = ["sonnet", "claude-sonnet"]
|
||||
|
||||
[models."anthropic/claude-sonnet-4-6".limits]
|
||||
[providers.openrouter.models."claude-sonnet-4-6".limits]
|
||||
context_window = 1000000
|
||||
max_output = 64000
|
||||
|
||||
[models."anthropic/claude-sonnet-4-6".features]
|
||||
[providers.openrouter.models."claude-sonnet-4-6".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
prompt_cache = true
|
||||
|
||||
[models."anthropic/claude-sonnet-4-6".costs]
|
||||
[providers.openrouter.models."claude-sonnet-4-6".costs]
|
||||
input_cost_per_mtok = 3.0
|
||||
output_cost_per_mtok = 15.0
|
||||
cache_input_cost_per_mtok = 0.3
|
||||
|
||||
[models."anthropic/claude-haiku-4-5"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."claude-haiku-4-5"]
|
||||
api_id = "anthropic/claude-haiku-4.5"
|
||||
display_name = "Claude Haiku 4.5 (via OpenRouter)"
|
||||
family = "claude-4"
|
||||
billing_policy = "anthropic"
|
||||
small_default = true
|
||||
aliases = ["haiku", "claude-haiku"]
|
||||
|
||||
[models."anthropic/claude-haiku-4-5".limits]
|
||||
[providers.openrouter.models."claude-haiku-4-5".limits]
|
||||
context_window = 200000
|
||||
max_output = 8192
|
||||
|
||||
[models."anthropic/claude-haiku-4-5".features]
|
||||
[providers.openrouter.models."claude-haiku-4-5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = false
|
||||
prompt_cache = true
|
||||
|
||||
[models."anthropic/claude-haiku-4-5".costs]
|
||||
[providers.openrouter.models."claude-haiku-4-5".costs]
|
||||
input_cost_per_mtok = 1.0
|
||||
output_cost_per_mtok = 5.0
|
||||
cache_input_cost_per_mtok = 0.1
|
||||
|
|
@ -158,19 +156,19 @@ cache_input_cost_per_mtok = 0.1
|
|||
# long-context threshold. Authoritative in-band usage.cost covers
|
||||
# long-context and cache-write pricing.
|
||||
|
||||
[models."openai/gpt-5.6-sol"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."gpt-5.6-sol"]
|
||||
api_id = "openai/gpt-5.6-sol"
|
||||
display_name = "GPT-5.6 Sol (via OpenRouter)"
|
||||
family = "gpt-5"
|
||||
training = "2026-02-16"
|
||||
knowledge_cutoff = "February 16, 2026"
|
||||
aliases = ["sol", "gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"]
|
||||
|
||||
[models."openai/gpt-5.6-sol".limits]
|
||||
[providers.openrouter.models."gpt-5.6-sol".limits]
|
||||
context_window = 1050000
|
||||
max_output = 128000
|
||||
|
||||
[models."openai/gpt-5.6-sol".features]
|
||||
[providers.openrouter.models."gpt-5.6-sol".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
|
|
@ -178,24 +176,24 @@ reasoning_effort = "levels"
|
|||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
[models."openai/gpt-5.6-sol".costs]
|
||||
[providers.openrouter.models."gpt-5.6-sol".costs]
|
||||
input_cost_per_mtok = 5.0
|
||||
output_cost_per_mtok = 30.0
|
||||
cache_input_cost_per_mtok = 0.5
|
||||
|
||||
[models."openai/gpt-5.6-terra"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."gpt-5.6-terra"]
|
||||
api_id = "openai/gpt-5.6-terra"
|
||||
display_name = "GPT-5.6 Terra (via OpenRouter)"
|
||||
family = "gpt-5"
|
||||
training = "2026-02-16"
|
||||
knowledge_cutoff = "February 16, 2026"
|
||||
aliases = ["terra", "gpt56-terra", "gpt-56-terra"]
|
||||
|
||||
[models."openai/gpt-5.6-terra".limits]
|
||||
[providers.openrouter.models."gpt-5.6-terra".limits]
|
||||
context_window = 1050000
|
||||
max_output = 128000
|
||||
|
||||
[models."openai/gpt-5.6-terra".features]
|
||||
[providers.openrouter.models."gpt-5.6-terra".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
|
|
@ -203,24 +201,24 @@ reasoning_effort = "levels"
|
|||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
[models."openai/gpt-5.6-terra".costs]
|
||||
[providers.openrouter.models."gpt-5.6-terra".costs]
|
||||
input_cost_per_mtok = 2.5
|
||||
output_cost_per_mtok = 15.0
|
||||
cache_input_cost_per_mtok = 0.25
|
||||
|
||||
[models."openai/gpt-5.6-luna"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."gpt-5.6-luna"]
|
||||
api_id = "openai/gpt-5.6-luna"
|
||||
display_name = "GPT-5.6 Luna (via OpenRouter)"
|
||||
family = "gpt-5"
|
||||
training = "2026-02-16"
|
||||
knowledge_cutoff = "February 16, 2026"
|
||||
aliases = ["luna", "gpt56-luna", "gpt-56-luna"]
|
||||
|
||||
[models."openai/gpt-5.6-luna".limits]
|
||||
[providers.openrouter.models."gpt-5.6-luna".limits]
|
||||
context_window = 1050000
|
||||
max_output = 128000
|
||||
|
||||
[models."openai/gpt-5.6-luna".features]
|
||||
[providers.openrouter.models."gpt-5.6-luna".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
|
|
@ -228,197 +226,189 @@ reasoning_effort = "levels"
|
|||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
[models."openai/gpt-5.6-luna".costs]
|
||||
[providers.openrouter.models."gpt-5.6-luna".costs]
|
||||
input_cost_per_mtok = 1.0
|
||||
output_cost_per_mtok = 6.0
|
||||
cache_input_cost_per_mtok = 0.1
|
||||
|
||||
[models."openai/gpt-5.4"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."gpt-5.4"]
|
||||
api_id = "openai/gpt-5.4"
|
||||
display_name = "GPT-5.4 (via OpenRouter)"
|
||||
family = "gpt-5"
|
||||
aliases = ["gpt54", "gpt-54", "gpt-5.2", "gpt5", "gpt-5.3-codex", "codex"]
|
||||
|
||||
[models."openai/gpt-5.4".limits]
|
||||
[providers.openrouter.models."gpt-5.4".limits]
|
||||
context_window = 1050000
|
||||
max_output = 32768
|
||||
|
||||
[models."openai/gpt-5.4".features]
|
||||
[providers.openrouter.models."gpt-5.4".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
|
||||
[models."openai/gpt-5.4".costs]
|
||||
[providers.openrouter.models."gpt-5.4".costs]
|
||||
input_cost_per_mtok = 2.5
|
||||
output_cost_per_mtok = 15.0
|
||||
|
||||
[models."openai/gpt-5.5"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."gpt-5.5"]
|
||||
api_id = "openai/gpt-5.5"
|
||||
display_name = "GPT-5.5 (via OpenRouter)"
|
||||
family = "gpt-5"
|
||||
aliases = ["gpt55", "gpt-55"]
|
||||
|
||||
[models."openai/gpt-5.5".limits]
|
||||
[providers.openrouter.models."gpt-5.5".limits]
|
||||
context_window = 1050000
|
||||
max_output = 32768
|
||||
|
||||
[models."openai/gpt-5.5".features]
|
||||
[providers.openrouter.models."gpt-5.5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
|
||||
[models."openai/gpt-5.5".costs]
|
||||
[providers.openrouter.models."gpt-5.5".costs]
|
||||
input_cost_per_mtok = 5.0
|
||||
output_cost_per_mtok = 30.0
|
||||
|
||||
# ---------- Google Gemini via OpenRouter ----------
|
||||
|
||||
[models."google/gemini-3.1-pro-preview"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."gemini-3.1-pro-preview"]
|
||||
api_id = "google/gemini-3.1-pro-preview"
|
||||
display_name = "Gemini 3.1 Pro Preview (via OpenRouter)"
|
||||
family = "gemini-3"
|
||||
|
||||
[models."google/gemini-3.1-pro-preview".limits]
|
||||
[providers.openrouter.models."gemini-3.1-pro-preview".limits]
|
||||
context_window = 1048576
|
||||
max_output = 65536
|
||||
|
||||
[models."google/gemini-3.1-pro-preview".features]
|
||||
[providers.openrouter.models."gemini-3.1-pro-preview".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
|
||||
[models."google/gemini-3.1-pro-preview".costs]
|
||||
[providers.openrouter.models."gemini-3.1-pro-preview".costs]
|
||||
input_cost_per_mtok = 2.0
|
||||
output_cost_per_mtok = 12.0
|
||||
|
||||
[models."google/gemini-3.5-flash"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."gemini-3.5-flash"]
|
||||
api_id = "google/gemini-3.5-flash"
|
||||
display_name = "Gemini 3.5 Flash (via OpenRouter)"
|
||||
family = "gemini-3"
|
||||
|
||||
[models."google/gemini-3.5-flash".limits]
|
||||
[providers.openrouter.models."gemini-3.5-flash".limits]
|
||||
context_window = 1048576
|
||||
max_output = 65536
|
||||
|
||||
[models."google/gemini-3.5-flash".features]
|
||||
[providers.openrouter.models."gemini-3.5-flash".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = false
|
||||
|
||||
[models."google/gemini-3.5-flash".costs]
|
||||
[providers.openrouter.models."gemini-3.5-flash".costs]
|
||||
input_cost_per_mtok = 1.5
|
||||
output_cost_per_mtok = 9.0
|
||||
|
||||
# ---------- Open-weights models ----------
|
||||
|
||||
[models."xiaomi/mimo-v2.5-pro"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."mimo-v2.5-pro"]
|
||||
api_id = "xiaomi/mimo-v2.5-pro"
|
||||
display_name = "Xiaomi MiMo v2.5 Pro"
|
||||
family = "mimo-v2"
|
||||
|
||||
[models."xiaomi/mimo-v2.5-pro".limits]
|
||||
[providers.openrouter.models."mimo-v2.5-pro".limits]
|
||||
context_window = 1050000
|
||||
max_output = 16384
|
||||
|
||||
[models."xiaomi/mimo-v2.5-pro".features]
|
||||
[providers.openrouter.models."mimo-v2.5-pro".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."xiaomi/mimo-v2.5-pro".costs]
|
||||
[providers.openrouter.models."mimo-v2.5-pro".costs]
|
||||
input_cost_per_mtok = 0.435
|
||||
output_cost_per_mtok = 0.87
|
||||
|
||||
[models."minimax/minimax-m2.7"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."minimax-m2.7"]
|
||||
api_id = "minimax/minimax-m2.7"
|
||||
display_name = "MiniMax M2.7"
|
||||
family = "minimax-m2"
|
||||
|
||||
[models."minimax/minimax-m2.7".limits]
|
||||
[providers.openrouter.models."minimax-m2.7".limits]
|
||||
context_window = 200000
|
||||
max_output = 16384
|
||||
|
||||
[models."minimax/minimax-m2.7".features]
|
||||
[providers.openrouter.models."minimax-m2.7".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."minimax/minimax-m2.7".costs]
|
||||
[providers.openrouter.models."minimax-m2.7".costs]
|
||||
input_cost_per_mtok = 0.28
|
||||
output_cost_per_mtok = 1.20
|
||||
|
||||
[models."deepseek/deepseek-v4-pro"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."deepseek-v4-pro"]
|
||||
api_id = "deepseek/deepseek-v4-pro"
|
||||
display_name = "DeepSeek V4 Pro"
|
||||
family = "deepseek-v4"
|
||||
|
||||
[models."deepseek/deepseek-v4-pro".limits]
|
||||
[providers.openrouter.models."deepseek-v4-pro".limits]
|
||||
context_window = 1050000
|
||||
max_output = 16384
|
||||
|
||||
[models."deepseek/deepseek-v4-pro".features]
|
||||
[providers.openrouter.models."deepseek-v4-pro".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
|
||||
[models."deepseek/deepseek-v4-pro".costs]
|
||||
[providers.openrouter.models."deepseek-v4-pro".costs]
|
||||
input_cost_per_mtok = 0.435
|
||||
output_cost_per_mtok = 0.87
|
||||
|
||||
[models."deepseek/deepseek-v4-flash"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."deepseek-v4-flash"]
|
||||
api_id = "deepseek/deepseek-v4-flash"
|
||||
display_name = "DeepSeek V4 Flash"
|
||||
family = "deepseek-v4"
|
||||
|
||||
[models."deepseek/deepseek-v4-flash".limits]
|
||||
[providers.openrouter.models."deepseek-v4-flash".limits]
|
||||
context_window = 1050000
|
||||
max_output = 16384
|
||||
|
||||
[models."deepseek/deepseek-v4-flash".features]
|
||||
[providers.openrouter.models."deepseek-v4-flash".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."deepseek/deepseek-v4-flash".costs]
|
||||
[providers.openrouter.models."deepseek-v4-flash".costs]
|
||||
input_cost_per_mtok = 0.10
|
||||
output_cost_per_mtok = 0.20
|
||||
|
||||
[models."moonshotai/kimi-k2.6"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."kimi-k2.6"]
|
||||
api_id = "moonshotai/kimi-k2.6"
|
||||
display_name = "Kimi K2.6"
|
||||
family = "kimi-k2"
|
||||
|
||||
[models."moonshotai/kimi-k2.6".limits]
|
||||
[providers.openrouter.models."kimi-k2.6".limits]
|
||||
context_window = 262144
|
||||
max_output = 16384
|
||||
|
||||
[models."moonshotai/kimi-k2.6".features]
|
||||
[providers.openrouter.models."kimi-k2.6".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."moonshotai/kimi-k2.6".costs]
|
||||
[providers.openrouter.models."kimi-k2.6".costs]
|
||||
input_cost_per_mtok = 0.73
|
||||
output_cost_per_mtok = 3.49
|
||||
|
||||
[models."moonshotai/kimi-k3"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."kimi-k3"]
|
||||
api_id = "moonshotai/kimi-k3"
|
||||
display_name = "Kimi K3 (via OpenRouter)"
|
||||
family = "kimi-k3"
|
||||
|
||||
[models."moonshotai/kimi-k3".limits]
|
||||
[providers.openrouter.models."kimi-k3".limits]
|
||||
context_window = 1048576
|
||||
max_output = 131072
|
||||
|
||||
[models."moonshotai/kimi-k3".features]
|
||||
[providers.openrouter.models."kimi-k3".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
|
|
@ -426,47 +416,45 @@ reasoning_effort = "always_adaptive"
|
|||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
[models."moonshotai/kimi-k3".controls]
|
||||
[providers.openrouter.models."kimi-k3".controls]
|
||||
reasoning_effort = ["low", "high", "max"]
|
||||
|
||||
[models."moonshotai/kimi-k3".costs]
|
||||
[providers.openrouter.models."kimi-k3".costs]
|
||||
input_cost_per_mtok = 3.0
|
||||
output_cost_per_mtok = 15.0
|
||||
cache_input_cost_per_mtok = 0.3
|
||||
|
||||
[models."poolside/laguna-s-2.1"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."laguna-s-2.1"]
|
||||
api_id = "poolside/laguna-s-2.1"
|
||||
display_name = "Laguna S 2.1 (via OpenRouter)"
|
||||
family = "laguna-2"
|
||||
|
||||
[models."poolside/laguna-s-2.1".limits]
|
||||
[providers.openrouter.models."laguna-s-2.1".limits]
|
||||
context_window = 1048576
|
||||
max_output = 131072
|
||||
|
||||
[models."poolside/laguna-s-2.1".features]
|
||||
[providers.openrouter.models."laguna-s-2.1".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
prompt_cache = true
|
||||
sampling_params = true
|
||||
|
||||
[models."poolside/laguna-s-2.1".costs]
|
||||
[providers.openrouter.models."laguna-s-2.1".costs]
|
||||
input_cost_per_mtok = 0.10
|
||||
output_cost_per_mtok = 0.20
|
||||
cache_input_cost_per_mtok = 0.01
|
||||
|
||||
[models."poolside/laguna-xs-2.1"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."laguna-xs-2.1"]
|
||||
api_id = "poolside/laguna-xs-2.1"
|
||||
display_name = "Laguna XS 2.1 (via OpenRouter)"
|
||||
family = "laguna-2"
|
||||
|
||||
[models."poolside/laguna-xs-2.1".limits]
|
||||
[providers.openrouter.models."laguna-xs-2.1".limits]
|
||||
context_window = 262144
|
||||
max_output = 32768
|
||||
|
||||
[models."poolside/laguna-xs-2.1".features]
|
||||
[providers.openrouter.models."laguna-xs-2.1".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
|
|
@ -475,127 +463,121 @@ sampling_params = true
|
|||
|
||||
# Current promotional rate. OpenRouter's authoritative in-band usage.cost
|
||||
# supersedes this estimate on completed responses.
|
||||
[models."poolside/laguna-xs-2.1".costs]
|
||||
[providers.openrouter.models."laguna-xs-2.1".costs]
|
||||
input_cost_per_mtok = 0.06
|
||||
output_cost_per_mtok = 0.12
|
||||
cache_input_cost_per_mtok = 0.03
|
||||
|
||||
[models."qwen/qwen3-coder"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."qwen3-coder"]
|
||||
api_id = "qwen/qwen3-coder"
|
||||
display_name = "Qwen3 Coder"
|
||||
family = "qwen3"
|
||||
|
||||
[models."qwen/qwen3-coder".limits]
|
||||
[providers.openrouter.models."qwen3-coder".limits]
|
||||
context_window = 1050000
|
||||
max_output = 16384
|
||||
|
||||
[models."qwen/qwen3-coder".features]
|
||||
[providers.openrouter.models."qwen3-coder".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."qwen/qwen3-coder".costs]
|
||||
[providers.openrouter.models."qwen3-coder".costs]
|
||||
input_cost_per_mtok = 0.22
|
||||
output_cost_per_mtok = 1.80
|
||||
|
||||
[models."qwen/qwen3.6-flash"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."qwen3.6-flash"]
|
||||
api_id = "qwen/qwen3.6-flash"
|
||||
display_name = "Qwen3.6 Flash"
|
||||
family = "qwen3"
|
||||
|
||||
[models."qwen/qwen3.6-flash".limits]
|
||||
[providers.openrouter.models."qwen3.6-flash".limits]
|
||||
context_window = 1000000
|
||||
max_output = 16384
|
||||
|
||||
[models."qwen/qwen3.6-flash".features]
|
||||
[providers.openrouter.models."qwen3.6-flash".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."qwen/qwen3.6-flash".costs]
|
||||
[providers.openrouter.models."qwen3.6-flash".costs]
|
||||
input_cost_per_mtok = 0.1875
|
||||
output_cost_per_mtok = 1.125
|
||||
|
||||
[models."z-ai/glm-5.2"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."glm-5.2"]
|
||||
api_id = "z-ai/glm-5.2"
|
||||
display_name = "GLM 5.2 (via OpenRouter)"
|
||||
family = "glm-5"
|
||||
|
||||
[models."z-ai/glm-5.2".limits]
|
||||
[providers.openrouter.models."glm-5.2".limits]
|
||||
context_window = 1048576
|
||||
max_output = 131072
|
||||
|
||||
[models."z-ai/glm-5.2".features]
|
||||
[providers.openrouter.models."glm-5.2".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
prompt_cache = true
|
||||
|
||||
[models."z-ai/glm-5.2".controls]
|
||||
[providers.openrouter.models."glm-5.2".controls]
|
||||
reasoning_effort = ["high", "xhigh"]
|
||||
|
||||
[models."z-ai/glm-5.2".costs]
|
||||
[providers.openrouter.models."glm-5.2".costs]
|
||||
input_cost_per_mtok = 0.784
|
||||
output_cost_per_mtok = 2.464
|
||||
cache_input_cost_per_mtok = 0.1456
|
||||
|
||||
[models."z-ai/glm-4.6"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."glm-4.6"]
|
||||
api_id = "z-ai/glm-4.6"
|
||||
display_name = "GLM 4.6"
|
||||
family = "glm-4"
|
||||
|
||||
[models."z-ai/glm-4.6".limits]
|
||||
[providers.openrouter.models."glm-4.6".limits]
|
||||
context_window = 203000
|
||||
max_output = 16384
|
||||
|
||||
[models."z-ai/glm-4.6".features]
|
||||
[providers.openrouter.models."glm-4.6".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."z-ai/glm-4.6".costs]
|
||||
[providers.openrouter.models."glm-4.6".costs]
|
||||
input_cost_per_mtok = 0.43
|
||||
output_cost_per_mtok = 1.74
|
||||
|
||||
[models."nvidia/nemotron-3-super-120b-a12b"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."nemotron-3-super-120b-a12b"]
|
||||
api_id = "nvidia/nemotron-3-super-120b-a12b"
|
||||
display_name = "NVIDIA Nemotron 3 Super 120B"
|
||||
family = "nemotron-3"
|
||||
|
||||
[models."nvidia/nemotron-3-super-120b-a12b".limits]
|
||||
[providers.openrouter.models."nemotron-3-super-120b-a12b".limits]
|
||||
context_window = 1000000
|
||||
max_output = 16384
|
||||
|
||||
[models."nvidia/nemotron-3-super-120b-a12b".features]
|
||||
[providers.openrouter.models."nemotron-3-super-120b-a12b".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."nvidia/nemotron-3-super-120b-a12b".costs]
|
||||
[providers.openrouter.models."nemotron-3-super-120b-a12b".costs]
|
||||
input_cost_per_mtok = 0.09
|
||||
output_cost_per_mtok = 0.45
|
||||
|
||||
[models."mistralai/devstral-2512"]
|
||||
provider = "openrouter"
|
||||
[providers.openrouter.models."devstral-2512"]
|
||||
api_id = "mistralai/devstral-2512"
|
||||
display_name = "Devstral 2512"
|
||||
family = "devstral"
|
||||
|
||||
[models."mistralai/devstral-2512".limits]
|
||||
[providers.openrouter.models."devstral-2512".limits]
|
||||
context_window = 262144
|
||||
max_output = 16384
|
||||
|
||||
[models."mistralai/devstral-2512".features]
|
||||
[providers.openrouter.models."devstral-2512".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."mistralai/devstral-2512".costs]
|
||||
[providers.openrouter.models."devstral-2512".costs]
|
||||
input_cost_per_mtok = 0.40
|
||||
output_cost_per_mtok = 2.00
|
||||
|
|
|
|||
|
|
@ -8,19 +8,18 @@ priority = 65
|
|||
[providers.poolside.auth]
|
||||
credentials = ["env:POOLSIDE_API_KEY", "vault:POOLSIDE_API_KEY"]
|
||||
|
||||
[models."laguna-s-2.1"]
|
||||
provider = "poolside"
|
||||
[providers.poolside.models."laguna-s-2.1"]
|
||||
api_id = "poolside/laguna-s-2.1"
|
||||
display_name = "Laguna S 2.1"
|
||||
family = "laguna-2"
|
||||
default = true
|
||||
aliases = ["laguna", "laguna-s"]
|
||||
|
||||
[models."laguna-s-2.1".limits]
|
||||
[providers.poolside.models."laguna-s-2.1".limits]
|
||||
context_window = 1048576
|
||||
max_output = 131072
|
||||
|
||||
[models."laguna-s-2.1".features]
|
||||
[providers.poolside.models."laguna-s-2.1".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
|
|
@ -30,13 +29,12 @@ sampling_params = true
|
|||
# Poolside Platform is free for a limited preview period. Keep the published
|
||||
# paid hosted rate as Fabro's durable estimate for paid access and post-preview
|
||||
# usage.
|
||||
[models."laguna-s-2.1".costs]
|
||||
[providers.poolside.models."laguna-s-2.1".costs]
|
||||
input_cost_per_mtok = 0.10
|
||||
output_cost_per_mtok = 0.20
|
||||
cache_input_cost_per_mtok = 0.01
|
||||
|
||||
[models."laguna-xs-2.1"]
|
||||
provider = "poolside"
|
||||
[providers.poolside.models."laguna-xs-2.1"]
|
||||
api_id = "poolside/laguna-xs-2.1"
|
||||
display_name = "Laguna XS 2.1"
|
||||
family = "laguna-2"
|
||||
|
|
@ -44,11 +42,11 @@ small_default = true
|
|||
probe = true
|
||||
aliases = ["laguna-xs"]
|
||||
|
||||
[models."laguna-xs-2.1".limits]
|
||||
[providers.poolside.models."laguna-xs-2.1".limits]
|
||||
context_window = 262144
|
||||
max_output = 32768
|
||||
|
||||
[models."laguna-xs-2.1".features]
|
||||
[providers.poolside.models."laguna-xs-2.1".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
|
|
@ -57,7 +55,7 @@ sampling_params = true
|
|||
|
||||
# Poolside Platform is free for a limited preview period. These are Poolside's
|
||||
# published paid endpoint rates.
|
||||
[models."laguna-xs-2.1".costs]
|
||||
[providers.poolside.models."laguna-xs-2.1".costs]
|
||||
input_cost_per_mtok = 0.10
|
||||
output_cost_per_mtok = 0.20
|
||||
cache_input_cost_per_mtok = 0.05
|
||||
|
|
|
|||
|
|
@ -8,43 +8,39 @@ aliases = ["venice-ai"]
|
|||
[providers.venice.auth]
|
||||
credentials = ["env:VENICE_API_KEY", "vault:VENICE_API_KEY"]
|
||||
|
||||
[models."venice-uncensored-1-2"]
|
||||
provider = "venice"
|
||||
api_id = "venice-uncensored-1-2"
|
||||
[providers.venice.models."venice-uncensored-1-2"]
|
||||
display_name = "Venice Uncensored 1.2"
|
||||
family = "venice-uncensored"
|
||||
default = true
|
||||
aliases = ["venice-uncensored", "vu"]
|
||||
|
||||
[models."venice-uncensored-1-2".limits]
|
||||
[providers.venice.models."venice-uncensored-1-2".limits]
|
||||
context_window = 128000
|
||||
max_output = 8192
|
||||
|
||||
[models."venice-uncensored-1-2".features]
|
||||
[providers.venice.models."venice-uncensored-1-2".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = false
|
||||
|
||||
[models."venice-uncensored-1-2".costs]
|
||||
[providers.venice.models."venice-uncensored-1-2".costs]
|
||||
input_cost_per_mtok = 0.2
|
||||
output_cost_per_mtok = 0.9
|
||||
|
||||
[models."venice-uncensored-role-play"]
|
||||
provider = "venice"
|
||||
api_id = "venice-uncensored-role-play"
|
||||
[providers.venice.models."venice-uncensored-role-play"]
|
||||
display_name = "Venice Uncensored Role Play"
|
||||
family = "venice-uncensored"
|
||||
aliases = ["venice-roleplay", "vrp"]
|
||||
|
||||
[models."venice-uncensored-role-play".limits]
|
||||
[providers.venice.models."venice-uncensored-role-play".limits]
|
||||
context_window = 128000
|
||||
max_output = 4096
|
||||
|
||||
[models."venice-uncensored-role-play".features]
|
||||
[providers.venice.models."venice-uncensored-role-play".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = false
|
||||
|
||||
[models."venice-uncensored-role-play".costs]
|
||||
[providers.venice.models."venice-uncensored-role-play".costs]
|
||||
input_cost_per_mtok = 0.5
|
||||
output_cost_per_mtok = 2.0
|
||||
|
|
|
|||
|
|
@ -8,50 +8,46 @@ priority = 60
|
|||
[providers.zai.auth]
|
||||
credentials = ["env:ZAI_API_KEY", "vault:ZAI_API_KEY"]
|
||||
|
||||
[models."glm-5.2"]
|
||||
provider = "zai"
|
||||
api_id = "glm-5.2"
|
||||
[providers.zai.models."glm-5.2"]
|
||||
display_name = "GLM 5.2"
|
||||
family = "glm-5"
|
||||
default = true
|
||||
aliases = ["glm", "glm5"]
|
||||
|
||||
[models."glm-5.2".limits]
|
||||
[providers.zai.models."glm-5.2".limits]
|
||||
context_window = 1048576
|
||||
max_output = 131072
|
||||
|
||||
[models."glm-5.2".features]
|
||||
[providers.zai.models."glm-5.2".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
prompt_cache = true
|
||||
|
||||
[models."glm-5.2".controls]
|
||||
[providers.zai.models."glm-5.2".controls]
|
||||
reasoning_effort = ["high", "max"]
|
||||
|
||||
[models."glm-5.2".costs]
|
||||
[providers.zai.models."glm-5.2".costs]
|
||||
input_cost_per_mtok = 1.4
|
||||
output_cost_per_mtok = 4.4
|
||||
cache_input_cost_per_mtok = 0.26
|
||||
|
||||
[models."glm-4.7"]
|
||||
provider = "zai"
|
||||
api_id = "glm-4.7"
|
||||
[providers.zai.models."glm-4.7"]
|
||||
display_name = "GLM 4.7"
|
||||
family = "glm-4"
|
||||
estimated_output_tps = 100
|
||||
aliases = ["glm4"]
|
||||
|
||||
[models."glm-4.7".limits]
|
||||
[providers.zai.models."glm-4.7".limits]
|
||||
context_window = 202752
|
||||
max_output = 16384
|
||||
|
||||
[models."glm-4.7".features]
|
||||
[providers.zai.models."glm-4.7".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[models."glm-4.7".costs]
|
||||
[providers.zai.models."glm-4.7".costs]
|
||||
input_cost_per_mtok = 0.6
|
||||
output_cost_per_mtok = 2.2
|
||||
|
|
|
|||
|
|
@ -101,9 +101,12 @@ impl AsRef<str> for ProviderId {
|
|||
}
|
||||
}
|
||||
|
||||
/// Stable model identifier — either the canonical catalog ID or one of its
|
||||
/// declared aliases.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
/// Stable canonical, human-facing model identifier.
|
||||
///
|
||||
/// Aliases are alternate selectors for a model offering; they are not model
|
||||
/// IDs. The same `ModelId` may be offered by more than one provider, so a
|
||||
/// concrete catalog offering is identified by `(ProviderId, ModelId)`.
|
||||
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ModelId(String);
|
||||
|
||||
|
|
@ -129,6 +132,12 @@ impl fmt::Display for ModelId {
|
|||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ModelId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ModelId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_string())
|
||||
|
|
@ -147,6 +156,18 @@ impl AsRef<str> for ModelId {
|
|||
}
|
||||
}
|
||||
|
||||
impl PartialEq<str> for ModelId {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<&str> for ModelId {
|
||||
fn eq(&self, other: &&str) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ pub use billing::{
|
|||
};
|
||||
pub use catalog::{
|
||||
ApiKeyHeaderPolicy, BillingPolicy, Catalog, CredentialRef, CredentialRefParseError,
|
||||
FallbackTarget, ProviderAuthConfig,
|
||||
FallbackTarget, ModelSelectionError, ProviderAuthConfig, SelectedModel,
|
||||
};
|
||||
pub use codec::CodecKind;
|
||||
pub use ids::{ModelId, ProviderId};
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ impl ModelHandle {
|
|||
#[must_use]
|
||||
pub fn model_id(&self) -> &str {
|
||||
match self {
|
||||
Self::Resolved(m) => &m.id,
|
||||
Self::Resolved(m) => m.id.as_str(),
|
||||
Self::ByName { model, .. } => model,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::ProviderId;
|
||||
use crate::ids::{ModelId, ProviderId};
|
||||
|
||||
// --- 2.9 Model ---
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ pub struct ModelCosts {
|
|||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Model {
|
||||
pub id: String,
|
||||
pub id: ModelId,
|
||||
pub provider: ProviderId,
|
||||
pub family: String,
|
||||
pub display_name: String,
|
||||
|
|
@ -102,7 +102,7 @@ pub struct Model {
|
|||
|
||||
impl Model {
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
self.id.as_str()
|
||||
}
|
||||
|
||||
pub fn provider(&self) -> &ProviderId {
|
||||
|
|
@ -210,7 +210,7 @@ mod tests {
|
|||
#[test]
|
||||
fn inherent_methods_return_correct_values() {
|
||||
let info = Model {
|
||||
id: "model-id".to_string(),
|
||||
id: ModelId::new("model-id"),
|
||||
provider: ProviderId::new("provider-id"),
|
||||
family: "family".to_string(),
|
||||
display_name: "Display Name".to_string(),
|
||||
|
|
|
|||
|
|
@ -254,11 +254,11 @@ async fn probe_single_provider(
|
|||
};
|
||||
let model_id = model.id.clone();
|
||||
|
||||
let outcome = run_basic_model_probe(&model_id, &provider, client).await;
|
||||
let outcome = run_basic_model_probe(model_id.as_str(), &provider, client).await;
|
||||
match outcome.status {
|
||||
ModelTestStatus::Ok => ProviderProbeResult {
|
||||
provider,
|
||||
model_id: Some(model_id),
|
||||
model_id: Some(model_id.to_string()),
|
||||
status: ProviderProbeStatus::Ok,
|
||||
error_message: None,
|
||||
diagnostic_detail: None,
|
||||
|
|
@ -267,7 +267,12 @@ async fn probe_single_provider(
|
|||
let raw = outcome
|
||||
.error_message
|
||||
.unwrap_or_else(|| "provider probe failed".to_string());
|
||||
provider_probe_error(provider, Some(model_id), redact_string(&raw), None)
|
||||
provider_probe_error(
|
||||
provider,
|
||||
Some(model_id.to_string()),
|
||||
redact_string(&raw),
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2167,7 +2167,7 @@ async fn validate_llm_provider(
|
|||
})?
|
||||
.id
|
||||
.clone();
|
||||
let params = GenerateParams::new(probe_model, Arc::new(client))
|
||||
let params = GenerateParams::new(probe_model.to_string(), Arc::new(client))
|
||||
.provider(input.provider.to_string())
|
||||
.prompt("Say OK")
|
||||
.max_tokens(16);
|
||||
|
|
|
|||
|
|
@ -476,19 +476,20 @@ async fn build_preflight_report(
|
|||
|
||||
let catalog = state.catalog();
|
||||
let llm_result = state.resolve_llm_client().await;
|
||||
let configured_providers = match &llm_result {
|
||||
Ok(result) => result.provider_ids(),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to resolve LLM client while checking ready providers");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if let Err(err) = &llm_result {
|
||||
warn!(error = ?err, "Failed to resolve LLM client while checking ready providers");
|
||||
}
|
||||
// Preflight is credential-independent static validation. Materialize
|
||||
// against every enabled catalog provider so aliases and defaults can be
|
||||
// inspected even when the corresponding adapter is not currently ready;
|
||||
// `run_llm_check` below reports actual credential/registration readiness.
|
||||
let enabled_providers = catalog.all_provider_ids().into_iter().collect::<Vec<_>>();
|
||||
let materialized = materialize_run(
|
||||
prepared.settings.clone(),
|
||||
graph,
|
||||
catalog.as_ref(),
|
||||
&configured_providers,
|
||||
);
|
||||
&enabled_providers,
|
||||
)?;
|
||||
let resolved_run = materialized.run;
|
||||
let server_settings = state.server_settings();
|
||||
let github_integration = &server_settings.server.integrations.github;
|
||||
|
|
@ -1049,8 +1050,15 @@ async fn run_llm_check(
|
|||
has_llm_nodes = true;
|
||||
let node_model = node.model().unwrap_or(model);
|
||||
let node_provider = node.provider().unwrap_or(default_provider);
|
||||
let (resolved_model, resolved_provider) = if let Some(info) = catalog.get(node_model) {
|
||||
(info.id.clone(), info.provider.to_string())
|
||||
let resolved = if node.provider().is_some() {
|
||||
catalog.get_on_provider(&ProviderId::new(node_provider), node_model)
|
||||
} else {
|
||||
catalog
|
||||
.select(node_model, None, &catalog.all_provider_ids())
|
||||
.ok()
|
||||
};
|
||||
let (resolved_model, resolved_provider) = if let Some(info) = resolved {
|
||||
(info.id.to_string(), info.provider.to_string())
|
||||
} else {
|
||||
(node_model.to_string(), node_provider.to_string())
|
||||
};
|
||||
|
|
@ -1534,6 +1542,7 @@ enabled = {clone_enabled}
|
|||
Catalog::builtin(),
|
||||
&[ProviderId::anthropic()],
|
||||
)
|
||||
.unwrap()
|
||||
.run;
|
||||
|
||||
(prepared, resolved)
|
||||
|
|
@ -2381,9 +2390,8 @@ digraph Demo {
|
|||
assert!(response_mock.calls_async().await >= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preflight_unknown_llm_provider_reports_not_configured() {
|
||||
let state = crate::test_support::test_app_state();
|
||||
#[test]
|
||||
fn static_validation_rejects_unknown_llm_provider() {
|
||||
let mut manifest = minimal_manifest();
|
||||
manifest.workflows.get_mut("workflow.fabro").unwrap().source = r#"
|
||||
digraph Demo {
|
||||
|
|
@ -2399,29 +2407,16 @@ digraph Demo {
|
|||
&manifest,
|
||||
)
|
||||
.unwrap();
|
||||
let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap();
|
||||
let Err(error) = validate_prepared_manifest(&prepared, test_catalog()) else {
|
||||
panic!("unknown provider should fail static validation");
|
||||
};
|
||||
|
||||
let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!ok);
|
||||
let llm_check = response.checks.sections[0]
|
||||
.checks
|
||||
.iter()
|
||||
.find(|check| check.name == "LLM" && check.summary == "missing-model")
|
||||
.expect("preflight should include the requested custom LLM provider");
|
||||
assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Warning);
|
||||
assert_eq!(
|
||||
llm_check.remediation.as_deref(),
|
||||
Some("Provider \"missing-provider\" is not configured")
|
||||
);
|
||||
assert!(
|
||||
llm_check
|
||||
.details
|
||||
.iter()
|
||||
.any(|detail| detail.text == "Provider: missing-provider")
|
||||
);
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowError::ModelSelection(fabro_model::ModelSelectionError::UnknownProvider {
|
||||
provider
|
||||
}) if provider.as_str() == "missing-provider"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -2437,17 +2432,16 @@ base_url = "https://api.acme.test/v1"
|
|||
[providers.acme.auth]
|
||||
credentials = ["env:ACME_API_KEY"]
|
||||
|
||||
[models."acme-large"]
|
||||
provider = "acme"
|
||||
[providers.acme.models."acme-large"]
|
||||
display_name = "Acme Large"
|
||||
family = "acme"
|
||||
default = true
|
||||
aliases = ["vl"]
|
||||
|
||||
[models."acme-large".limits]
|
||||
[providers.acme.models."acme-large".limits]
|
||||
context_window = 128000
|
||||
|
||||
[models."acme-large".features]
|
||||
[providers.acme.models."acme-large".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
|
@ -2472,7 +2466,7 @@ digraph Demo {
|
|||
&manifest,
|
||||
)
|
||||
.unwrap();
|
||||
let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap();
|
||||
let validated = validate_prepared_manifest(&prepared, state.catalog()).unwrap();
|
||||
|
||||
let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -1453,7 +1453,7 @@ impl AppState {
|
|||
self.catalog()
|
||||
.default_for_configured_ids(&provider_ids)
|
||||
.id
|
||||
.clone(),
|
||||
.to_string(),
|
||||
)
|
||||
};
|
||||
AskFabroReadiness { default_model }
|
||||
|
|
|
|||
|
|
@ -336,6 +336,7 @@ fn run_due_schedules_once<'a>(
|
|||
mod tests {
|
||||
use fabro_api::types::RunManifest;
|
||||
use fabro_automation::{AutomationDraft, AutomationTarget, AutomationTrigger, ScheduleTrigger};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_store::ListRunsQuery;
|
||||
use fabro_types::RunStatus;
|
||||
use serde_json::json;
|
||||
|
|
@ -428,6 +429,7 @@ mod tests {
|
|||
fn test_state_with_materializer(materializer: TestAutomationRunMaterializer) -> Arc<AppState> {
|
||||
TestAppStateBuilder::new()
|
||||
.env_lookup(|_| None)
|
||||
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.automation_materializer(materializer)
|
||||
.build()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_model::{Catalog, ModelSelectionError};
|
||||
|
||||
use super::super::{
|
||||
ApiError, AppState, CompletionResponse, CompletionToolChoiceMode, CompletionUsage,
|
||||
CreateCompletionRequest, FinishReason, GenerateParams, IntoResponse, Json, LlmMessage,
|
||||
LlmRequest, RequiredUser, Response, Router, State, StatusCode, ToolChoice, ToolDefinition,
|
||||
Ulid, error, generate_object, info, post, warn,
|
||||
LlmRequest, ProviderId, RequiredUser, Response, Router, State, StatusCode, ToolChoice,
|
||||
ToolDefinition, Ulid, error, generate_object, info, post, warn,
|
||||
};
|
||||
use super::llm_sse;
|
||||
|
||||
|
|
@ -28,21 +31,34 @@ async fn create_completion(
|
|||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<CreateCompletionRequest>,
|
||||
) -> Response {
|
||||
// Resolve model
|
||||
let catalog = state.catalog();
|
||||
let model_id = req
|
||||
.model
|
||||
.unwrap_or_else(|| catalog.default_model().id.clone());
|
||||
|
||||
let catalog_info = catalog.get(&model_id);
|
||||
|
||||
// Resolve provider: explicit request > catalog > None
|
||||
let explicit_provider = req.provider;
|
||||
let provider_name = explicit_provider
|
||||
.clone()
|
||||
.or_else(|| catalog_info.map(|i| i.provider.to_string()));
|
||||
|
||||
info!(model = %model_id, provider = ?provider_name, "Completion request received");
|
||||
let llm_result = match state.resolve_llm_client().await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
error!(error = ?err, "Failed to create LLM client");
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to resolve LLM providers: {err}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
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");
|
||||
}
|
||||
let client = llm_result.client;
|
||||
let (model_id, selected_provider) = match resolve_request_model(
|
||||
catalog.as_ref(),
|
||||
&client.provider_ids(),
|
||||
req.model.as_deref(),
|
||||
req.provider,
|
||||
) {
|
||||
Ok(selection) => selection,
|
||||
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
|
||||
|
|
@ -81,7 +97,7 @@ async fn create_completion(
|
|||
let request = LlmRequest {
|
||||
model: model_id.clone(),
|
||||
messages,
|
||||
provider: provider_name.clone(),
|
||||
provider: Some(selected_provider.to_string()),
|
||||
tools,
|
||||
tool_choice,
|
||||
response_format: None,
|
||||
|
|
@ -98,35 +114,15 @@ async fn create_completion(
|
|||
metadata: None,
|
||||
provider_options: req.provider_options,
|
||||
};
|
||||
info!(
|
||||
model = %model_id,
|
||||
provider = %selected_provider,
|
||||
"Completion request received"
|
||||
);
|
||||
|
||||
// Force non-streaming for structured output
|
||||
let use_stream = req.stream && req.schema.is_none();
|
||||
|
||||
let llm_result = match state.resolve_llm_client().await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
error!(error = ?err, "Failed to create LLM client");
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create LLM client: {err}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
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");
|
||||
}
|
||||
let client = llm_result.client;
|
||||
if let Some(provider) = explicit_provider.as_deref() {
|
||||
if !client.has_provider(provider) {
|
||||
return ApiError::bad_request(format!("Provider \"{provider}\" is not configured"))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
if use_stream {
|
||||
// Streaming path: forward all StreamEvents as SSE
|
||||
let stream_result = match client.stream(&request).await {
|
||||
|
|
@ -170,6 +166,7 @@ async fn create_completion(
|
|||
Json(CompletionResponse {
|
||||
id: msg_id,
|
||||
model: model_id,
|
||||
provider: selected_provider,
|
||||
message: response.message,
|
||||
stop_reason,
|
||||
usage: CompletionUsage {
|
||||
|
|
@ -192,6 +189,7 @@ async fn create_completion(
|
|||
Json(CompletionResponse {
|
||||
id: response.id,
|
||||
model: response.model,
|
||||
provider: ProviderId::new(response.provider),
|
||||
message: response.message,
|
||||
stop_reason,
|
||||
usage: CompletionUsage {
|
||||
|
|
@ -210,3 +208,15 @@ async fn create_completion(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_request_model(
|
||||
catalog: &Catalog,
|
||||
eligible: &HashSet<ProviderId>,
|
||||
requested_model: Option<&str>,
|
||||
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)?;
|
||||
Ok((selected.model, selected.provider))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||
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;
|
||||
use fabro_redact::redact_string;
|
||||
|
||||
use super::super::{
|
||||
|
|
@ -40,7 +41,9 @@ struct ModelListParams {
|
|||
#[derive(serde::Deserialize)]
|
||||
struct ModelTestParams {
|
||||
#[serde(default)]
|
||||
mode: Option<String>,
|
||||
mode: Option<String>,
|
||||
#[serde(default)]
|
||||
provider: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_models(
|
||||
|
|
@ -62,7 +65,7 @@ async fn list_models(
|
|||
.into_iter()
|
||||
.filter(|model| match &query {
|
||||
Some(query) => {
|
||||
model.id.to_lowercase().contains(query)
|
||||
model.id.as_str().to_lowercase().contains(query)
|
||||
|| model.display_name.to_lowercase().contains(query)
|
||||
|| model
|
||||
.aliases
|
||||
|
|
@ -155,7 +158,7 @@ async fn test_provider_credentials(
|
|||
.into_response();
|
||||
};
|
||||
|
||||
let outcome = run_basic_model_probe(&model.id, &provider_id, client).await;
|
||||
let outcome = run_basic_model_probe(model.id.as_str(), &provider_id, client).await;
|
||||
match outcome.status {
|
||||
ModelTestStatus::Ok => (
|
||||
StatusCode::OK,
|
||||
|
|
@ -204,11 +207,6 @@ async fn test_model(
|
|||
},
|
||||
None => ModelTestMode::Basic,
|
||||
};
|
||||
let catalog = state.catalog();
|
||||
let Some(info) = catalog.get(&id) else {
|
||||
return ApiError::not_found(format!("Model not found: {id}")).into_response();
|
||||
};
|
||||
|
||||
let llm_result = match state.resolve_llm_client().await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
|
|
@ -220,6 +218,23 @@ async fn test_model(
|
|||
.into_response();
|
||||
}
|
||||
};
|
||||
let catalog = state.catalog();
|
||||
let eligible = llm_result
|
||||
.provider_ids()
|
||||
.into_iter()
|
||||
.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) {
|
||||
Ok(info) => info,
|
||||
Err(error) => return model_selection_response(&error),
|
||||
}
|
||||
} else {
|
||||
match catalog.select(&id, None, &eligible) {
|
||||
Ok(info) => info,
|
||||
Err(error) => return model_selection_response(&error),
|
||||
}
|
||||
};
|
||||
if let Some((_, issue)) = llm_result
|
||||
.auth_issues
|
||||
.iter()
|
||||
|
|
@ -231,6 +246,7 @@ async fn test_model(
|
|||
if !llm_result.client.has_provider(provider_name) {
|
||||
return Json(serde_json::json!({
|
||||
"model_id": info.id,
|
||||
"provider": info.provider,
|
||||
"status": "skip",
|
||||
}))
|
||||
.into_response();
|
||||
|
|
@ -240,8 +256,24 @@ async fn test_model(
|
|||
let outcome = run_model_test(info, mode, client).await;
|
||||
Json(serde_json::json!({
|
||||
"model_id": info.id,
|
||||
"provider": info.provider,
|
||||
"status": <&'static str>::from(outcome.status),
|
||||
"error_message": outcome.error_message,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn model_selection_response(error: &ModelSelectionError) -> Response {
|
||||
match error {
|
||||
ModelSelectionError::UnknownProvider { .. }
|
||||
| ModelSelectionError::UnknownSelector { .. }
|
||||
| ModelSelectionError::UnknownSelectorOnProvider { .. } => {
|
||||
ApiError::not_found(error.to_string()).into_response()
|
||||
}
|
||||
ModelSelectionError::ProviderUnavailable { .. }
|
||||
| ModelSelectionError::NoEligibleOffering { .. }
|
||||
| ModelSelectionError::NoDefaultModel { .. } => {
|
||||
ApiError::bad_request(error.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -883,7 +883,7 @@ mod tests {
|
|||
text: "I found the issue.".to_string(),
|
||||
model: ModelRef {
|
||||
provider: ProviderId::new("openai"),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: None,
|
||||
},
|
||||
billing: BilledTokenCounts::default(),
|
||||
|
|
@ -916,7 +916,7 @@ mod tests {
|
|||
text: "wrong stage".to_string(),
|
||||
model: ModelRef {
|
||||
provider: ProviderId::new("openai"),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: None,
|
||||
},
|
||||
billing: BilledTokenCounts::default(),
|
||||
|
|
|
|||
|
|
@ -109,11 +109,33 @@ async fn create_playground_chat(
|
|||
}
|
||||
|
||||
let catalog = state.catalog();
|
||||
let model_id = req
|
||||
.model
|
||||
.unwrap_or_else(|| catalog.default_model().id.clone());
|
||||
|
||||
info!(model = %model_id, "Playground chat turn");
|
||||
let llm_result = match state.resolve_llm_client().await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
error!(error = ?err, "playground: failed to create LLM client");
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to resolve LLM providers: {err}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
for (provider, issue) in &llm_result.auth_issues {
|
||||
warn!(provider = %provider, error = %issue, "playground: provider auth issue");
|
||||
}
|
||||
for issue in &llm_result.registration_issues {
|
||||
warn!(provider = %issue.provider, error = %issue.error, "playground: provider registration issue");
|
||||
}
|
||||
let client = llm_result.client;
|
||||
let (model_id, selected_provider) = match super::completions::resolve_request_model(
|
||||
catalog.as_ref(),
|
||||
&client.provider_ids(),
|
||||
req.model.as_deref(),
|
||||
req.provider.map(|provider| provider.to_string()),
|
||||
) {
|
||||
Ok(selection) => selection,
|
||||
Err(error) => return ApiError::bad_request(error.to_string()).into_response(),
|
||||
};
|
||||
|
||||
// Request messages are already the canonical `fabro_types::Message` —
|
||||
// the API schema reuses it via build.rs `with_replacement`.
|
||||
|
|
@ -124,7 +146,7 @@ async fn create_playground_chat(
|
|||
let request = LlmRequest {
|
||||
model: model_id,
|
||||
messages,
|
||||
provider: None,
|
||||
provider: Some(selected_provider.into_inner()),
|
||||
tools: Some(playground_tools()),
|
||||
tool_choice: Some(ToolChoice::Auto),
|
||||
response_format: None,
|
||||
|
|
@ -137,22 +159,11 @@ async fn create_playground_chat(
|
|||
metadata: None,
|
||||
provider_options: None,
|
||||
};
|
||||
|
||||
let llm_result = match state.resolve_llm_client().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
error!(error = ?err, "playground: failed to create LLM client");
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create LLM client: {err}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
for (provider, issue) in &llm_result.auth_issues {
|
||||
warn!(provider = %provider, error = %issue, "playground: provider auth issue");
|
||||
}
|
||||
let client = llm_result.client;
|
||||
info!(
|
||||
model = %request.model,
|
||||
provider = request.provider.as_deref().unwrap_or(""),
|
||||
"Playground chat turn"
|
||||
);
|
||||
|
||||
let stream_result = match client.stream(&request).await {
|
||||
Ok(s) => s,
|
||||
|
|
@ -238,6 +249,7 @@ mod tests {
|
|||
messages,
|
||||
workflow_fabro,
|
||||
model: None,
|
||||
provider: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -321,7 +321,10 @@ async fn create_run_pull_request(
|
|||
} else {
|
||||
let catalog = state.catalog();
|
||||
let configured = state.ready_llm_provider_ids().await;
|
||||
catalog.default_for_configured_ids(&configured).id.clone()
|
||||
catalog
|
||||
.default_for_configured_ids(&configured)
|
||||
.id
|
||||
.to_string()
|
||||
};
|
||||
let catalog = state.catalog();
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ use crate::run_manifest;
|
|||
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
|
||||
use crate::run_title_generation::{self, GenerateTitleInput, TitlePromptInput, WorkflowSummary};
|
||||
use crate::server_secrets::LlmClientResult;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use crate::test_support as server_test_support;
|
||||
|
||||
pub(super) fn manifest_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
|
|
@ -600,10 +602,23 @@ pub(crate) async fn create_run_from_manifest(
|
|||
.as_ref()
|
||||
.map(LlmClientResult::provider_ids)
|
||||
.unwrap_or_default();
|
||||
let run_materialization_provider_ids = {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
{
|
||||
server_test_support::test_run_materialization_provider_ids(
|
||||
catalog.as_ref(),
|
||||
&ready_provider_ids,
|
||||
)
|
||||
}
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
{
|
||||
ready_provider_ids.clone()
|
||||
}
|
||||
};
|
||||
let provenance = run_provenance(&headers, &actor);
|
||||
let mut create_input = run_manifest::create_run_input(
|
||||
prepared.clone(),
|
||||
ready_provider_ids.clone(),
|
||||
run_materialization_provider_ids,
|
||||
provenance,
|
||||
web_url.clone(),
|
||||
vars,
|
||||
|
|
@ -625,6 +640,9 @@ pub(crate) async fn create_run_from_manifest(
|
|||
Err(WorkflowError::ValidationFailed { .. } | WorkflowError::Parse(_)) => {
|
||||
return ApiError::bad_request("Validation failed").into_response();
|
||||
}
|
||||
Err(err @ (WorkflowError::ModelSelection(_) | WorkflowError::ModelReference(_))) => {
|
||||
return ApiError::bad_request(err.to_string()).into_response();
|
||||
}
|
||||
Err(err) => {
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
|
@ -681,7 +699,7 @@ pub(crate) async fn create_run_from_manifest(
|
|||
workflow,
|
||||
run_inputs,
|
||||
client: llm_result.client,
|
||||
model_id: title_model_id,
|
||||
model_id: title_model_id.to_string(),
|
||||
provider_id: title_provider_id,
|
||||
});
|
||||
}
|
||||
|
|
@ -725,23 +743,6 @@ fn spawn_generated_title_task(task: GeneratedTitleTask) {
|
|||
return;
|
||||
}
|
||||
|
||||
let current = match task
|
||||
.state
|
||||
.stores
|
||||
.runs
|
||||
.get_cached_summary(&task.run_id, Utc::now())
|
||||
.await
|
||||
{
|
||||
Ok(Some(summary)) => summary,
|
||||
Ok(None) => return,
|
||||
Err(err) => {
|
||||
tracing::debug!(run_id = %task.run_id, error = %err, "Failed to re-read run summary for title update");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if current.title != task.deterministic_title {
|
||||
return;
|
||||
}
|
||||
let run_store = match task.state.stores.runs.open_run(&task.run_id).await {
|
||||
Ok(store) => store,
|
||||
Err(err) => {
|
||||
|
|
@ -749,7 +750,8 @@ fn spawn_generated_title_task(task: GeneratedTitleTask) {
|
|||
return;
|
||||
}
|
||||
};
|
||||
if let Err(err) = workflow_event::append_event(
|
||||
let expected_title = task.deterministic_title;
|
||||
if let Err(err) = workflow_event::append_event_if(
|
||||
&run_store,
|
||||
&task.run_id,
|
||||
&workflow_event::Event::RunTitleUpdated {
|
||||
|
|
@ -758,6 +760,7 @@ fn spawn_generated_title_task(task: GeneratedTitleTask) {
|
|||
system_kind: SystemActorKind::Engine,
|
||||
}),
|
||||
},
|
||||
move |projection| projection.title().as_ref() == expected_title,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ use fabro_api::types::{
|
|||
};
|
||||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_llm::types::ToolDefinition;
|
||||
use fabro_model::{AgentProfileKind, Catalog, ModelHandle, ProviderId};
|
||||
use fabro_model::{
|
||||
AgentProfileKind, Catalog, ModelHandle, ModelSelectionError, ProviderId, catalog,
|
||||
};
|
||||
use fabro_sandbox::reconnect::reconnect_for_run;
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_store::{
|
||||
|
|
@ -35,7 +37,7 @@ use fabro_types::run_event::{
|
|||
RunSessionTurnFailedProps, RunSessionTurnInterruptedProps, RunSessionTurnStartedProps,
|
||||
RunSessionTurnSucceededProps, RunSessionUserMessageProps,
|
||||
};
|
||||
use fabro_types::settings::{ModelRef as SettingsModelRef, ModelRegistry, ResolvedModelRef};
|
||||
use fabro_types::settings::ModelRef as SettingsModelRef;
|
||||
use fabro_types::{EventBody, EventEnvelope, RunEvent, RunId, SessionDetail, SessionId, TurnId};
|
||||
use fabro_workflow::handler::llm::api::register_named_fabro_run_tools;
|
||||
use fabro_workflow::services::FabroRunToolServices;
|
||||
|
|
@ -160,8 +162,24 @@ async fn create_run_session(
|
|||
Ok(store) => store,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let model = match canonical_session_model(state.catalog().as_ref(), request.model.as_deref()) {
|
||||
Ok(model) => model,
|
||||
let llm_result = match state.resolve_llm_client().await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to resolve LLM providers: {err}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let eligible = llm_result.provider_ids().into_iter().collect();
|
||||
let (provider, model) = match canonical_session_model(
|
||||
state.catalog().as_ref(),
|
||||
&eligible,
|
||||
request.model.as_deref(),
|
||||
request.provider.as_ref(),
|
||||
) {
|
||||
Ok(selection) => selection,
|
||||
Err(err) => return err.into_response(),
|
||||
};
|
||||
|
||||
|
|
@ -180,8 +198,9 @@ async fn create_run_session(
|
|||
run_id,
|
||||
session_id,
|
||||
EventBody::RunSessionCreated(RunSessionCreatedProps {
|
||||
title: request.title,
|
||||
model,
|
||||
title: request.title,
|
||||
model: Some(model),
|
||||
provider: Some(provider),
|
||||
}),
|
||||
now,
|
||||
)
|
||||
|
|
@ -782,17 +801,26 @@ fn selected_session_model(
|
|||
llm_result: &LlmClientResult,
|
||||
session: &ProjectedRunSession,
|
||||
) -> Result<(ProviderId, String, AgentProfileKind), AskFabroBuildError> {
|
||||
let configured_provider_ids = llm_result.provider_ids();
|
||||
let selected = match session.record.model.as_deref() {
|
||||
Some(model_id) => catalog.get(model_id).ok_or_else(|| {
|
||||
AskFabroBuildError::ModelUnavailable(format!(
|
||||
"session model '{model_id}' is not in the catalog"
|
||||
))
|
||||
})?,
|
||||
None => catalog.default_for_configured_ids(&configured_provider_ids),
|
||||
};
|
||||
let provider_id = selected.provider.clone();
|
||||
let model = selected.id.clone();
|
||||
let eligible = llm_result
|
||||
.provider_ids()
|
||||
.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 (provider_id, model) = (selected.provider, selected.model);
|
||||
let profile_kind = catalog
|
||||
.effective_agent_profile(&provider_id, Some(&model))
|
||||
.ok_or_else(|| {
|
||||
|
|
@ -805,79 +833,88 @@ fn selected_session_model(
|
|||
|
||||
fn canonical_session_model(
|
||||
catalog: &Catalog,
|
||||
eligible: &std::collections::HashSet<ProviderId>,
|
||||
requested: Option<&str>,
|
||||
) -> Result<Option<String>, ApiError> {
|
||||
explicit_provider: Option<&ProviderId>,
|
||||
) -> 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(),
|
||||
})
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let Some(requested) = requested else {
|
||||
return Ok(None);
|
||||
let selected = catalog
|
||||
.resolve_selection(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));
|
||||
}
|
||||
let model_ref = requested
|
||||
.parse::<SettingsModelRef>()
|
||||
.map_err(|err| ApiError::bad_request(err.to_string()))?;
|
||||
let registry = CatalogModelRegistry { catalog };
|
||||
match model_ref
|
||||
.resolve(®istry)
|
||||
.map_err(|err| ApiError::bad_request(err.to_string()))?
|
||||
{
|
||||
ResolvedModelRef::Provider(provider) => Err(ApiError::bad_request(format!(
|
||||
"Session model reference '{provider}' names a provider; include a model ID."
|
||||
))),
|
||||
ResolvedModelRef::Model {
|
||||
provider: Some(provider),
|
||||
model,
|
||||
} => resolve_provider_qualified_session_model(catalog, &provider, &model).map(Some),
|
||||
ResolvedModelRef::Model {
|
||||
provider: None,
|
||||
model,
|
||||
} => catalog
|
||||
.get(&model)
|
||||
.map(|model| Some(model.id.clone()))
|
||||
.ok_or_else(|| ApiError::bad_request(format!("Unknown session model '{model}'."))),
|
||||
}
|
||||
let (qualified_provider, model) = match model_ref {
|
||||
SettingsModelRef::Qualified { provider, model } => {
|
||||
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,
|
||||
})
|
||||
})?;
|
||||
if let Some(explicit) = explicit_provider.as_ref() {
|
||||
if explicit != &provider {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"Session provider pin '{explicit}' conflicts with model reference provider \
|
||||
'{provider}'."
|
||||
)));
|
||||
}
|
||||
}
|
||||
(Some(provider), model)
|
||||
}
|
||||
SettingsModelRef::Bare(model) => {
|
||||
if explicit_provider.is_none() && catalog.provider(&ProviderId::new(&model)).is_some() {
|
||||
let detail = if catalog.is_model_selector(&model) {
|
||||
format!(
|
||||
"Session model reference '{model}' is ambiguous between a provider and a \
|
||||
model selector; supply `provider` or use `provider/model`."
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Session model reference '{model}' names a provider; include a model ID."
|
||||
)
|
||||
};
|
||||
return Err(ApiError::bad_request(detail));
|
||||
}
|
||||
(None, model)
|
||||
}
|
||||
};
|
||||
let provider = qualified_provider.as_ref().or(explicit_provider.as_ref());
|
||||
let selected = catalog
|
||||
.resolve_selection(Some(&model), provider, eligible)
|
||||
.map_err(|error| session_selection_error(&error))?;
|
||||
Ok((selected.provider, selected.model))
|
||||
}
|
||||
|
||||
fn resolve_provider_qualified_session_model(
|
||||
catalog: &Catalog,
|
||||
provider_ref: &str,
|
||||
model_ref: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
let provider_id = ProviderId::new(provider_ref);
|
||||
let provider = catalog.provider(&provider_id).ok_or_else(|| {
|
||||
ApiError::bad_request(format!("Unknown session model provider '{provider_ref}'."))
|
||||
})?;
|
||||
let model = catalog
|
||||
.get(model_ref)
|
||||
.ok_or_else(|| ApiError::bad_request(format!("Unknown session model '{model_ref}'.")))?;
|
||||
if model.provider != provider.id {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"Session model '{model_ref}' belongs to provider '{}', not '{}'.",
|
||||
model.provider, provider.id
|
||||
)));
|
||||
}
|
||||
Ok(model.id.clone())
|
||||
}
|
||||
|
||||
struct CatalogModelRegistry<'a> {
|
||||
catalog: &'a Catalog,
|
||||
}
|
||||
|
||||
impl ModelRegistry for CatalogModelRegistry<'_> {
|
||||
fn is_provider(&self, token: &str) -> bool {
|
||||
self.catalog.provider(&ProviderId::new(token)).is_some()
|
||||
}
|
||||
|
||||
fn is_model(&self, token: &str) -> bool {
|
||||
self.catalog.get(token).is_some()
|
||||
}
|
||||
|
||||
fn provider_of(&self, token: &str) -> Option<String> {
|
||||
self.catalog
|
||||
.get(token)
|
||||
.map(|model| model.provider.to_string())
|
||||
}
|
||||
fn session_selection_error(error: &ModelSelectionError) -> ApiError {
|
||||
ApiError::bad_request(error.to_string())
|
||||
}
|
||||
|
||||
fn build_profile(
|
||||
|
|
@ -1275,7 +1312,7 @@ fn agent_event_payload(event_turn_id: TurnId, event: AgentEvent) -> Option<Event
|
|||
RunSessionAssistantMessageProps {
|
||||
turn_id: event_turn_id,
|
||||
text,
|
||||
model: Some(model.model_id),
|
||||
model: Some(model.model_id.to_string()),
|
||||
usage: serde_json::to_value(usage).unwrap_or(Value::Null),
|
||||
},
|
||||
)),
|
||||
|
|
@ -1510,6 +1547,7 @@ 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 super::*;
|
||||
|
|
@ -1550,6 +1588,199 @@ mod tests {
|
|||
registry
|
||||
}
|
||||
|
||||
fn portable_session_catalog() -> Catalog {
|
||||
let settings: LlmCatalogSettings = toml::from_str(
|
||||
r#"
|
||||
[providers.openai]
|
||||
display_name = "OpenAI"
|
||||
adapter = "openai"
|
||||
agent_profile = "openai"
|
||||
priority = 90
|
||||
|
||||
[providers.openai.models."gpt-5.6-sol"]
|
||||
display_name = "GPT-5.6 Sol"
|
||||
family = "gpt-5"
|
||||
aliases = ["gpt-56-sol"]
|
||||
default = true
|
||||
|
||||
[providers.openai.models."gpt-5.6-sol".limits]
|
||||
context_window = 1000
|
||||
|
||||
[providers.openai.models."gpt-5.6-sol".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[providers.openrouter]
|
||||
display_name = "OpenRouter"
|
||||
adapter = "openai_compatible"
|
||||
agent_profile = "openai"
|
||||
priority = 25
|
||||
|
||||
[providers.openrouter.models."gpt-5.6-sol"]
|
||||
api_id = "openai/gpt-5.6-sol"
|
||||
display_name = "GPT-5.6 Sol (via OpenRouter)"
|
||||
family = "gpt-5"
|
||||
aliases = ["gpt-56-sol"]
|
||||
default = true
|
||||
|
||||
[providers.openrouter.models."gpt-5.6-sol".limits]
|
||||
context_window = 1000
|
||||
|
||||
[providers.openrouter.models."gpt-5.6-sol".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
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 openrouter = ProviderId::new("openrouter");
|
||||
|
||||
assert_eq!(
|
||||
canonical_session_model(
|
||||
&catalog,
|
||||
&std::collections::HashSet::from([openai.clone()]),
|
||||
Some("gpt-56-sol"),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
(openai.clone(), "gpt-5.6-sol".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_session_model(
|
||||
&catalog,
|
||||
&std::collections::HashSet::from([openrouter.clone()]),
|
||||
Some("gpt-56-sol"),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
(openrouter.clone(), "gpt-5.6-sol".to_string())
|
||||
);
|
||||
let both = std::collections::HashSet::from([openai.clone(), openrouter.clone()]);
|
||||
assert_eq!(
|
||||
canonical_session_model(&catalog, &both, Some("gpt-56-sol"), None).unwrap(),
|
||||
(openai, "gpt-5.6-sol".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_session_model(&catalog, &both, Some("gpt-56-sol"), Some(&openrouter),)
|
||||
.unwrap(),
|
||||
(openrouter.clone(), "gpt-5.6-sol".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_session_model(&catalog, &both, Some("openrouter/gpt-56-sol"), None,).unwrap(),
|
||||
(openrouter, "gpt-5.6-sol".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_session_model_preserves_unknown_passthrough_on_selected_provider() {
|
||||
let catalog = portable_session_catalog();
|
||||
let openai = ProviderId::openai();
|
||||
let openrouter = ProviderId::new("openrouter");
|
||||
let both = std::collections::HashSet::from([openai.clone(), openrouter.clone()]);
|
||||
|
||||
assert_eq!(
|
||||
canonical_session_model(&catalog, &both, Some("future-model"), Some(&openrouter),)
|
||||
.unwrap(),
|
||||
(openrouter, "future-model".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_session_model(&catalog, &both, Some("future-model"), None).unwrap(),
|
||||
(openai, "future-model".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_session_model_rejects_an_unavailable_explicit_provider() {
|
||||
let catalog = portable_session_catalog();
|
||||
let error = canonical_session_model(
|
||||
&catalog,
|
||||
&std::collections::HashSet::from([ProviderId::openai()]),
|
||||
Some("gpt-56-sol"),
|
||||
Some(&ProviderId::new("openrouter")),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_session_model_normalizes_legacy_builtin_selector_before_qualification() {
|
||||
let catalog = portable_session_catalog();
|
||||
let openai = ProviderId::openai();
|
||||
let openrouter = ProviderId::new("openrouter");
|
||||
let both = std::collections::HashSet::from([openai.clone(), openrouter.clone()]);
|
||||
|
||||
assert_eq!(
|
||||
canonical_session_model(&catalog, &both, Some("openai/gpt-5.6-sol"), None,).unwrap(),
|
||||
(openai, "gpt-5.6-sol".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_session_model(
|
||||
&catalog,
|
||||
&both,
|
||||
Some("openai/gpt-5.6-sol"),
|
||||
Some(&openrouter),
|
||||
)
|
||||
.unwrap(),
|
||||
(openrouter.clone(), "gpt-5.6-sol".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_session_model(
|
||||
&catalog,
|
||||
&std::collections::HashSet::from([openrouter.clone()]),
|
||||
Some("openai/gpt-5.6-sol"),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
(openrouter, "gpt-5.6-sol".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_session_model_still_treats_non_legacy_qualified_model_as_a_pin() {
|
||||
let catalog = portable_session_catalog();
|
||||
let openrouter = ProviderId::new("openrouter");
|
||||
|
||||
assert_eq!(
|
||||
canonical_session_model(
|
||||
&catalog,
|
||||
&catalog.all_provider_ids(),
|
||||
Some("openrouter/gpt-56-sol"),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
(openrouter, "gpt-5.6-sol".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_session_model_rejects_conflicting_non_legacy_provider_pins() {
|
||||
let catalog = portable_session_catalog();
|
||||
let error = canonical_session_model(
|
||||
&catalog,
|
||||
&catalog.all_provider_ids(),
|
||||
Some("openrouter/gpt-56-sol"),
|
||||
Some(&ProviderId::openai()),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.status(), StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
error
|
||||
.into_response_entry()
|
||||
.detail
|
||||
.contains("conflicts with model reference provider")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_event_payload_maps_text_delta_to_session_assistant_delta() {
|
||||
let turn_id = TurnId::new();
|
||||
|
|
|
|||
|
|
@ -1553,7 +1553,7 @@ async fn github_webhook_accepts_valid_signature_with_wrong_bearer_token() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn create_secret_stores_valid_oauth_entries() {
|
||||
let state = test_app_state();
|
||||
let state = TestAppStateBuilder::new().build();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
|
||||
let req = Request::builder()
|
||||
|
|
@ -1993,7 +1993,7 @@ async fn create_secret_rejects_invalid_oauth_name() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn delete_secret_by_name_removes_file_secret() {
|
||||
let state = test_app_state();
|
||||
let state = TestAppStateBuilder::new().build();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
|
||||
let create_req = Request::builder()
|
||||
|
|
@ -3394,25 +3394,30 @@ async fn create_run_with_explicit_title_skips_generated_title_work() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_run_without_ready_llm_provider_skips_generated_title_work() {
|
||||
async fn create_run_without_ready_llm_provider_rejects_implicit_model_selection() {
|
||||
let state = TestAppStateBuilder::new().env_lookup(|_| None).build();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
|
||||
let body = post_run_manifest(&app, minimal_manifest_json(MINIMAL_DOT)).await;
|
||||
let run_id: RunId = body["id"].as_str().unwrap().parse().unwrap();
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/runs"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(minimal_manifest_json(MINIMAL_DOT).to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = response_json!(response, StatusCode::BAD_REQUEST).await;
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.stores
|
||||
.runs
|
||||
.get_cached_summary(&run_id, Utc::now())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.title,
|
||||
"Test"
|
||||
assert!(
|
||||
body["errors"][0]["detail"]
|
||||
.as_str()
|
||||
.is_some_and(|detail| detail.contains("no default model is available")),
|
||||
"unexpected response: {body}"
|
||||
);
|
||||
assert_eq!(title_update_event_count(&state, run_id).await, 0);
|
||||
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -3490,7 +3495,7 @@ async fn generated_title_does_not_overwrite_user_title_edit() {
|
|||
response_json!(response, StatusCode::OK).await;
|
||||
|
||||
wait_for_mock_hits(&title_mock, 1).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
|
|
@ -3524,7 +3529,10 @@ async fn post_run_manifest(app: &Router, manifest: serde_json::Value) -> serde_j
|
|||
|
||||
#[tokio::test]
|
||||
async fn post_runs_create_regression_keeps_api_behavior_without_automation_metadata() {
|
||||
let state = TestAppStateBuilder::new().env_lookup(|_| None).build();
|
||||
let state = TestAppStateBuilder::new()
|
||||
.env_lookup(|_| None)
|
||||
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let mut manifest = minimal_manifest_json(MINIMAL_DOT);
|
||||
manifest["title"] = json!("API title");
|
||||
|
|
@ -3547,7 +3555,10 @@ async fn post_runs_create_regression_keeps_api_behavior_without_automation_metad
|
|||
|
||||
#[tokio::test]
|
||||
async fn create_run_from_manifest_helper_persists_without_automation_metadata() {
|
||||
let state = TestAppStateBuilder::new().env_lookup(|_| None).build();
|
||||
let state = TestAppStateBuilder::new()
|
||||
.env_lookup(|_| None)
|
||||
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build();
|
||||
let manifest: RunManifest = serde_json::from_value(minimal_manifest_json(MINIMAL_DOT)).unwrap();
|
||||
let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
let run_id = RunId::new();
|
||||
|
|
@ -3583,7 +3594,10 @@ async fn create_run_from_manifest_helper_persists_without_automation_metadata()
|
|||
|
||||
#[tokio::test]
|
||||
async fn create_run_from_manifest_helper_persists_automation_metadata() {
|
||||
let state = TestAppStateBuilder::new().env_lookup(|_| None).build();
|
||||
let state = TestAppStateBuilder::new()
|
||||
.env_lookup(|_| None)
|
||||
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build();
|
||||
let manifest: RunManifest = serde_json::from_value(minimal_manifest_json(MINIMAL_DOT)).unwrap();
|
||||
let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
let run_id = RunId::new();
|
||||
|
|
@ -4130,7 +4144,7 @@ fn context_window_event(
|
|||
text: "assistant response".to_string(),
|
||||
model: ModelRef {
|
||||
provider: ProviderId::openai(),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: None,
|
||||
},
|
||||
usage: TokenCounts::default(),
|
||||
|
|
@ -6094,6 +6108,15 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
|
|||
.set("GITHUB_TOKEN", token, SecretType::Token, None)
|
||||
.expect("test github token should be writable");
|
||||
}
|
||||
Vault::load(vault_path.clone())
|
||||
.expect("test vault should load")
|
||||
.set(
|
||||
EnvVars::OPENAI_API_KEY,
|
||||
"test-openai-api-key",
|
||||
SecretType::Token,
|
||||
None,
|
||||
)
|
||||
.expect("test OpenAI credential should be writable");
|
||||
let db_pool = test_db_pool_for_vault_path(&vault_path).expect("test db pool should build");
|
||||
let preloaded_vault = crate::test_support::test_secret_snapshot(db_pool.clone())
|
||||
.expect("test secret snapshot should build");
|
||||
|
|
@ -6392,7 +6415,7 @@ async fn test_model_unknown_returns_404() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_model_alias_returns_canonical_model_id() {
|
||||
async fn test_model_explicit_provider_alias_returns_canonical_model_id_when_unavailable() {
|
||||
let state = test_app_state_with_env_lookup(
|
||||
default_test_server_settings(),
|
||||
RunLayer::default(),
|
||||
|
|
@ -6403,7 +6426,7 @@ async fn test_model_alias_returns_canonical_model_id() {
|
|||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/models/sonnet/test"))
|
||||
.uri(api("/models/sonnet/test?provider=anthropic"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
|
@ -6411,9 +6434,208 @@ async fn test_model_alias_returns_canonical_model_id() {
|
|||
let response = app.oneshot(req).await.unwrap();
|
||||
let body = response_json!(response, StatusCode::OK).await;
|
||||
assert_eq!(body["model_id"], "claude-sonnet-4-6");
|
||||
assert_eq!(body["provider"], "anthropic");
|
||||
assert_eq!(body["status"], "skip");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_model_unqualified_known_alias_requires_a_ready_provider() {
|
||||
let state = test_app_state_with_env_lookup(
|
||||
default_test_server_settings(),
|
||||
RunLayer::default(),
|
||||
5,
|
||||
|_| None,
|
||||
);
|
||||
let app = crate::test_support::build_test_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/models/sonnet/test"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_status!(response, StatusCode::BAD_REQUEST).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_api_keeps_duplicate_ids_provider_scoped_and_selects_ready_priority() {
|
||||
let direct_upstream = MockServer::start();
|
||||
let aggregator_upstream = MockServer::start();
|
||||
let direct_probe = direct_upstream.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/chat/completions")
|
||||
.json_body_includes(r#"{"model":"portable-model"}"#);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(json!({
|
||||
"id": "chatcmpl-direct",
|
||||
"model": "portable-model",
|
||||
"choices": [{
|
||||
"message": {"role": "assistant", "content": "OK"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 2
|
||||
}
|
||||
}));
|
||||
});
|
||||
let aggregator_probe = aggregator_upstream.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/chat/completions")
|
||||
.json_body_includes(r#"{"model":"vendor/portable-model"}"#);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(json!({
|
||||
"id": "chatcmpl-aggregator",
|
||||
"model": "vendor/portable-model",
|
||||
"choices": [{
|
||||
"message": {"role": "assistant", "content": "OK"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 2
|
||||
}
|
||||
}));
|
||||
});
|
||||
let settings: LlmCatalogSettings = toml::from_str(&format!(
|
||||
r#"
|
||||
[providers.direct]
|
||||
display_name = "Direct"
|
||||
adapter = "openai_compatible"
|
||||
agent_profile = "openai"
|
||||
base_url = "{}"
|
||||
priority = 120
|
||||
|
||||
[providers.direct.auth]
|
||||
credentials = ["vault:DIRECT_API_KEY"]
|
||||
|
||||
[providers.direct.models.portable-model]
|
||||
display_name = "Portable (direct)"
|
||||
family = "portable"
|
||||
aliases = ["portable"]
|
||||
default = true
|
||||
|
||||
[providers.direct.models.portable-model.limits]
|
||||
context_window = 1000
|
||||
|
||||
[providers.direct.models.portable-model.features]
|
||||
tools = false
|
||||
vision = false
|
||||
reasoning = false
|
||||
|
||||
[providers.aggregator]
|
||||
display_name = "Aggregator"
|
||||
adapter = "openai_compatible"
|
||||
agent_profile = "openai"
|
||||
base_url = "{}"
|
||||
priority = 110
|
||||
|
||||
[providers.aggregator.auth]
|
||||
credentials = ["vault:AGGREGATOR_API_KEY"]
|
||||
|
||||
[providers.aggregator.models.portable-model]
|
||||
api_id = "vendor/portable-model"
|
||||
display_name = "Portable (aggregator)"
|
||||
family = "portable"
|
||||
aliases = ["portable"]
|
||||
default = true
|
||||
|
||||
[providers.aggregator.models.portable-model.limits]
|
||||
context_window = 1000
|
||||
|
||||
[providers.aggregator.models.portable-model.features]
|
||||
tools = false
|
||||
vision = false
|
||||
reasoning = false
|
||||
"#,
|
||||
direct_upstream.base_url(),
|
||||
aggregator_upstream.base_url(),
|
||||
))
|
||||
.unwrap();
|
||||
let state = TestAppStateBuilder::new()
|
||||
.llm_catalog_settings(settings)
|
||||
.vault_entries([
|
||||
("DIRECT_API_KEY", "direct-test-key"),
|
||||
("AGGREGATOR_API_KEY", "aggregator-test-key"),
|
||||
])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(state);
|
||||
|
||||
let list = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/models?query=portable-model"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let list = response_json!(list, StatusCode::OK).await;
|
||||
let rows = list["data"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
.map(|row| row["provider"].as_str().unwrap())
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
std::collections::BTreeSet::from(["aggregator", "direct"])
|
||||
);
|
||||
assert!(
|
||||
rows.iter()
|
||||
.all(|row| row["id"] == "portable-model" && row["configured"] == true)
|
||||
);
|
||||
|
||||
let filtered = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/models?provider=aggregator&query=portable-model"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let filtered = response_json!(filtered, StatusCode::OK).await;
|
||||
assert_eq!(filtered["data"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(filtered["data"][0]["provider"], "aggregator");
|
||||
|
||||
for (query, expected_provider) in [
|
||||
("?provider=direct", "direct"),
|
||||
("?provider=aggregator", "aggregator"),
|
||||
("", "direct"),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/models/portable/test{query}")))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = response_json!(response, StatusCode::OK).await;
|
||||
assert_eq!(body["model_id"], "portable-model");
|
||||
assert_eq!(body["provider"], expected_provider);
|
||||
assert_eq!(body["status"], "ok");
|
||||
}
|
||||
|
||||
direct_probe.assert_calls(2);
|
||||
aggregator_probe.assert_calls(1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_model_invalid_mode_returns_400() {
|
||||
let state = test_app_state_with_env_lookup(
|
||||
|
|
@ -13483,7 +13705,7 @@ async fn get_aggregate_billing_returns_provider_model_speed_identity() {
|
|||
agg.by_model.insert(
|
||||
ModelRef {
|
||||
provider: ProviderId::anthropic(),
|
||||
model_id: "claude-opus-4-6".to_string(),
|
||||
model_id: "claude-opus-4-6".into(),
|
||||
speed: None,
|
||||
},
|
||||
ModelBillingTotals {
|
||||
|
|
@ -13502,7 +13724,7 @@ async fn get_aggregate_billing_returns_provider_model_speed_identity() {
|
|||
agg.by_model.insert(
|
||||
ModelRef {
|
||||
provider: ProviderId::anthropic(),
|
||||
model_id: "claude-opus-4-6".to_string(),
|
||||
model_id: "claude-opus-4-6".into(),
|
||||
speed: Some(Speed::Fast),
|
||||
},
|
||||
ModelBillingTotals {
|
||||
|
|
@ -13563,7 +13785,7 @@ async fn get_aggregate_billing_saturates_total_cost_across_models() {
|
|||
agg.by_model.insert(
|
||||
ModelRef {
|
||||
provider: ProviderId::openai(),
|
||||
model_id: model_id.to_string(),
|
||||
model_id: model_id.into(),
|
||||
speed: None,
|
||||
},
|
||||
ModelBillingTotals {
|
||||
|
|
@ -13611,7 +13833,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() {
|
|||
fabro_workflow::ProjectionBillingByModel {
|
||||
model: ModelRef {
|
||||
provider: ProviderId::openai(),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: None,
|
||||
},
|
||||
stages: 1,
|
||||
|
|
@ -13628,7 +13850,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() {
|
|||
fabro_workflow::ProjectionBillingByModel {
|
||||
model: ModelRef {
|
||||
provider: ProviderId::openai(),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: Some(Speed::Fast),
|
||||
},
|
||||
stages: 1,
|
||||
|
|
@ -13655,7 +13877,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() {
|
|||
assert_eq!(
|
||||
accumulator.by_model[&ModelRef {
|
||||
provider: ProviderId::openai(),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: None,
|
||||
}]
|
||||
.stages,
|
||||
|
|
@ -13664,7 +13886,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() {
|
|||
assert_eq!(
|
||||
accumulator.by_model[&ModelRef {
|
||||
provider: ProviderId::openai(),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: None,
|
||||
}]
|
||||
.billing
|
||||
|
|
@ -13674,7 +13896,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() {
|
|||
assert_eq!(
|
||||
accumulator.by_model[&ModelRef {
|
||||
provider: ProviderId::openai(),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: Some(Speed::Fast),
|
||||
}]
|
||||
.stages,
|
||||
|
|
@ -13683,7 +13905,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() {
|
|||
assert_eq!(
|
||||
accumulator.by_model[&ModelRef {
|
||||
provider: ProviderId::openai(),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: Some(Speed::Fast),
|
||||
}]
|
||||
.billing
|
||||
|
|
@ -13771,6 +13993,17 @@ level = "debug"
|
|||
manifest_run_defaults_from_toml(source),
|
||||
5,
|
||||
);
|
||||
state
|
||||
.stores
|
||||
.vault
|
||||
.set(
|
||||
EnvVars::ANTHROPIC_API_KEY,
|
||||
"test-anthropic-api-key",
|
||||
SecretType::Token,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
|
||||
let req = Request::builder()
|
||||
|
|
@ -13936,6 +14169,7 @@ async fn cancel_run_overwrites_pending_pause_request() {
|
|||
async fn cancel_run_requests_worker_runtime_stop_when_control_unavailable() {
|
||||
let runtime = StdArc::new(RecordingWorkerRuntime::default());
|
||||
let state = TestAppStateBuilder::new()
|
||||
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.worker_runtime(runtime.clone())
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
|
|
@ -14815,42 +15049,52 @@ async fn create_completion_unknown_provider_returns_clear_error() {
|
|||
let body = response_json!(response, StatusCode::BAD_REQUEST).await;
|
||||
assert_eq!(
|
||||
body["errors"][0]["detail"],
|
||||
"Provider \"missing-provider\" is not configured"
|
||||
"unknown model provider 'missing-provider'"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_completion_default_model_uses_app_state_catalog() {
|
||||
let llm_catalog_settings: LlmCatalogSettings = toml::from_str(
|
||||
let upstream = MockServer::start();
|
||||
let completion = upstream.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/chat/completions")
|
||||
.json_body_includes(r#"{"model":"acme-large"}"#);
|
||||
then.status(500)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(json!({"error": {"message": "expected test failure"}}));
|
||||
});
|
||||
let llm_catalog_settings: LlmCatalogSettings = toml::from_str(&format!(
|
||||
r#"
|
||||
[providers.acme]
|
||||
display_name = "Acme"
|
||||
adapter = "openai_compatible"
|
||||
agent_profile = "openai"
|
||||
base_url = "https://api.acme.test/v1"
|
||||
base_url = "{}"
|
||||
priority = 120
|
||||
|
||||
[providers.acme.auth]
|
||||
credentials = ["env:ACME_API_KEY"]
|
||||
credentials = ["vault:ACME_API_KEY"]
|
||||
|
||||
[models."acme-large"]
|
||||
provider = "acme"
|
||||
[providers.acme.models."acme-large"]
|
||||
display_name = "Acme Large"
|
||||
family = "acme"
|
||||
default = true
|
||||
|
||||
[models."acme-large".limits]
|
||||
[providers.acme.models."acme-large".limits]
|
||||
context_window = 128000
|
||||
|
||||
[models."acme-large".features]
|
||||
[providers.acme.models."acme-large".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
"#,
|
||||
)
|
||||
upstream.base_url()
|
||||
))
|
||||
.expect("catalog fixture should parse");
|
||||
let state = TestAppStateBuilder::new()
|
||||
.llm_catalog_settings(llm_catalog_settings)
|
||||
.vault_entries([("ACME_API_KEY", "acme-test-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(state);
|
||||
|
||||
|
|
@ -14878,9 +15122,10 @@ reasoning = false
|
|||
body["errors"][0]["detail"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Provider 'acme' not registered"),
|
||||
.contains("expected test failure"),
|
||||
"unexpected error body: {body:?}"
|
||||
);
|
||||
completion.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[
|
|||
// reach the refresh-ahead loop in the ACP handler.
|
||||
EnvVars::FABRO_PUSH_CRED_REFRESH_AHEAD,
|
||||
EnvVars::FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS,
|
||||
#[cfg(feature = "test-support")]
|
||||
"FABRO_TEST_ASSUME_LLM_READY",
|
||||
EnvVars::TERM,
|
||||
EnvVars::NO_COLOR,
|
||||
EnvVars::CLICOLOR,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use fabro_config::{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_sandbox::SandboxProviderRegistry;
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_store::{ArtifactStore, Database};
|
||||
|
|
@ -49,6 +50,27 @@ pub const TEST_DEV_TOKEN: &str =
|
|||
"fabro_dev_abababababababababababababababababababababababababababababababab";
|
||||
pub const TEST_SESSION_SECRET: &str =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const TEST_OPENAI_API_KEY: &str = "test-openai-api-key";
|
||||
const FABRO_TEST_ASSUME_LLM_READY: &str = "FABRO_TEST_ASSUME_LLM_READY";
|
||||
|
||||
/// Supply enabled catalog providers to CLI fixture run materialization.
|
||||
///
|
||||
/// This is scoped to the `test-support` feature and an explicit child-process
|
||||
/// flag. The CLI suite shares a server whose credential state can change
|
||||
/// between tests, so the flag deliberately ignores that mutable state. It does
|
||||
/// not register adapters or make model execution available.
|
||||
pub(crate) fn test_run_materialization_provider_ids(
|
||||
catalog: &Catalog,
|
||||
ready_provider_ids: &[ProviderId],
|
||||
) -> Vec<ProviderId> {
|
||||
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()
|
||||
} else {
|
||||
ready_provider_ids.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_test_server_settings() -> ServerSettings {
|
||||
ServerSettingsBuilder::from_toml(
|
||||
|
|
@ -320,13 +342,13 @@ pub fn llm_catalog_settings_with_provider_base_url(
|
|||
}
|
||||
|
||||
pub fn test_app_state() -> Arc<AppState> {
|
||||
TestAppStateBuilder::new().build()
|
||||
ready_test_app_state_builder().build()
|
||||
}
|
||||
|
||||
pub fn test_app_state_with_registry_factory(
|
||||
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
||||
) -> Arc<AppState> {
|
||||
TestAppStateBuilder::new()
|
||||
ready_test_app_state_builder()
|
||||
.registry_factory(registry_factory_override)
|
||||
.build()
|
||||
}
|
||||
|
|
@ -336,7 +358,7 @@ pub fn test_app_state_with_settings_and_registry_factory(
|
|||
manifest_run_defaults: RunLayer,
|
||||
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
||||
) -> Arc<AppState> {
|
||||
TestAppStateBuilder::new()
|
||||
ready_test_app_state_builder()
|
||||
.runtime_settings(server_settings, manifest_run_defaults)
|
||||
.registry_factory(registry_factory_override)
|
||||
.build()
|
||||
|
|
@ -348,7 +370,7 @@ pub fn test_app_state_with_options_and_registry_factory(
|
|||
max_concurrent_runs: usize,
|
||||
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
||||
) -> Arc<AppState> {
|
||||
TestAppStateBuilder::new()
|
||||
ready_test_app_state_builder()
|
||||
.runtime_settings(server_settings, manifest_run_defaults)
|
||||
.max_concurrent_runs(max_concurrent_runs)
|
||||
.registry_factory(registry_factory_override)
|
||||
|
|
@ -360,12 +382,16 @@ pub fn test_app_state_with_options(
|
|||
manifest_run_defaults: RunLayer,
|
||||
max_concurrent_runs: usize,
|
||||
) -> Arc<AppState> {
|
||||
TestAppStateBuilder::new()
|
||||
ready_test_app_state_builder()
|
||||
.runtime_settings(server_settings, manifest_run_defaults)
|
||||
.max_concurrent_runs(max_concurrent_runs)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn ready_test_app_state_builder() -> TestAppStateBuilder {
|
||||
TestAppStateBuilder::new().vault_entries([(EnvVars::OPENAI_API_KEY, TEST_OPENAI_API_KEY)])
|
||||
}
|
||||
|
||||
pub(crate) fn resolved_runtime_settings_for_tests(
|
||||
server_settings: ServerSettings,
|
||||
manifest_run_defaults: RunLayer,
|
||||
|
|
@ -383,7 +409,7 @@ pub fn test_app_state_with_runtime_settings_and_registry_factory(
|
|||
manifest_run_defaults: RunLayer,
|
||||
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
||||
) -> Arc<AppState> {
|
||||
TestAppStateBuilder::new()
|
||||
ready_test_app_state_builder()
|
||||
.runtime_settings(server_settings, manifest_run_defaults)
|
||||
.registry_factory(registry_factory_override)
|
||||
.build()
|
||||
|
|
@ -395,7 +421,7 @@ pub fn test_app_state_with_runtime_settings_and_options_and_registry_factory(
|
|||
max_concurrent_runs: usize,
|
||||
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
||||
) -> Arc<AppState> {
|
||||
TestAppStateBuilder::new()
|
||||
ready_test_app_state_builder()
|
||||
.runtime_settings(server_settings, manifest_run_defaults)
|
||||
.max_concurrent_runs(max_concurrent_runs)
|
||||
.registry_factory(registry_factory_override)
|
||||
|
|
@ -407,7 +433,7 @@ pub fn test_app_state_with_runtime_settings_and_options(
|
|||
manifest_run_defaults: RunLayer,
|
||||
max_concurrent_runs: usize,
|
||||
) -> Arc<AppState> {
|
||||
TestAppStateBuilder::new()
|
||||
ready_test_app_state_builder()
|
||||
.runtime_settings(server_settings, manifest_run_defaults)
|
||||
.max_concurrent_runs(max_concurrent_runs)
|
||||
.build()
|
||||
|
|
@ -475,7 +501,7 @@ pub fn test_app_state_with_runtime_settings_and_session_key(
|
|||
)
|
||||
.expect("test server env should be writable");
|
||||
}
|
||||
TestAppStateBuilder::new()
|
||||
ready_test_app_state_builder()
|
||||
.runtime_settings(server_settings, manifest_run_defaults)
|
||||
.vault_path(vault_path)
|
||||
.server_env_path(server_env_path)
|
||||
|
|
@ -501,7 +527,7 @@ pub fn test_app_state_with_store(
|
|||
store: Arc<Database>,
|
||||
artifact_store: ArtifactStore,
|
||||
) -> Arc<AppState> {
|
||||
TestAppStateBuilder::new()
|
||||
ready_test_app_state_builder()
|
||||
.runtime_settings(server_settings, manifest_run_defaults)
|
||||
.max_concurrent_runs(max_concurrent_runs)
|
||||
.store_bundle(store, artifact_store)
|
||||
|
|
@ -612,7 +638,7 @@ pub fn test_app_state_with_store_and_runtime_settings(
|
|||
store: Arc<Database>,
|
||||
artifact_store: ArtifactStore,
|
||||
) -> Arc<AppState> {
|
||||
TestAppStateBuilder::new()
|
||||
ready_test_app_state_builder()
|
||||
.runtime_settings(server_settings, manifest_run_defaults)
|
||||
.max_concurrent_runs(max_concurrent_runs)
|
||||
.store_bundle(store, artifact_store)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use fabro_server::server::build_router;
|
|||
use fabro_server::test_support::{
|
||||
TestAppStateBuilder, TestAutomationRunMaterializer, build_test_router, test_auth_mode,
|
||||
};
|
||||
use fabro_static::EnvVars;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::Row as _;
|
||||
use tower::ServiceExt;
|
||||
|
|
@ -86,6 +87,7 @@ fn automation_app_with_fake_materializer() -> (axum::Router, tempfile::TempDir,
|
|||
let state = TestAppStateBuilder::new()
|
||||
.active_config_path(active_config_path)
|
||||
.vault_path(vault_path)
|
||||
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.automation_materializer(TestAutomationRunMaterializer::succeed(
|
||||
materialized_manifest,
|
||||
submitted_manifest_bytes,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::time::Duration;
|
|||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use fabro_static::EnvVars;
|
||||
use object_store::ObjectStore;
|
||||
use object_store::memory::InMemory;
|
||||
use tokio::sync::Barrier;
|
||||
|
|
@ -23,6 +24,7 @@ fn app_with_store(object_store: Arc<dyn ObjectStore>) -> axum::Router {
|
|||
let state = fabro_server::test_support::TestAppStateBuilder::new()
|
||||
.runtime_settings(settings.server_settings, settings.manifest_run_defaults)
|
||||
.env_lookup(|_| None)
|
||||
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.store_bundle(store, artifact_store)
|
||||
.build();
|
||||
fabro_server::test_support::build_test_router(state)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use fabro_static::EnvVars;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::helpers::{
|
||||
MINIMAL_DOT, api, minimal_manifest_json, response_json, response_status, test_app_state,
|
||||
test_app_with_no_providers,
|
||||
};
|
||||
|
||||
async fn create_run(app: &axum::Router) -> String {
|
||||
|
|
@ -196,6 +196,7 @@ async fn supplied_session_model_alias_is_canonicalized() {
|
|||
.find(|event| event["event"] == "run.session.created")
|
||||
.expect("session creation event should be recorded");
|
||||
assert_eq!(created_event["properties"]["model"], "gpt-5.4");
|
||||
assert_eq!(created_event["properties"]["provider"], "openai");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -203,9 +204,23 @@ async fn provider_qualified_session_model_is_canonicalized() {
|
|||
let app = fabro_server::test_support::build_test_router(test_app_state());
|
||||
let run_id = create_run(&app).await;
|
||||
|
||||
let created = create_session_with_model(&app, &run_id, "Ask Fabro", "openai/gpt-5.4").await;
|
||||
let created =
|
||||
create_session_with_model(&app, &run_id, "Ask Fabro", "openai/gpt-5.4-mini").await;
|
||||
|
||||
assert_eq!(created["model"], "gpt-5.4");
|
||||
assert_eq!(created["model"], "gpt-5.4-mini");
|
||||
assert_eq!(created["provider"], "openai");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_session_models_preserve_passthrough_on_the_selected_provider() {
|
||||
let app = fabro_server::test_support::build_test_router(test_app_state());
|
||||
let run_id = create_run(&app).await;
|
||||
|
||||
for model in ["not-a-real-model", "openai/not-a-real-model"] {
|
||||
let created = create_session_with_model(&app, &run_id, "Ask Fabro", model).await;
|
||||
assert_eq!(created["model"], "not-a-real-model");
|
||||
assert_eq!(created["provider"], "openai");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -213,7 +228,7 @@ async fn invalid_session_model_refs_are_rejected_at_creation() {
|
|||
let app = fabro_server::test_support::build_test_router(test_app_state());
|
||||
let run_id = create_run(&app).await;
|
||||
|
||||
for model in ["not-a-real-model", "openai", "openai/", "anthropic/gpt-5.4"] {
|
||||
for model in ["openai", "openai/", "anthropic/gpt-5.4"] {
|
||||
let response = create_session_response(
|
||||
&app,
|
||||
&run_id,
|
||||
|
|
@ -241,6 +256,7 @@ async fn ambiguous_session_model_refs_are_rejected_at_creation() {
|
|||
);
|
||||
let state = fabro_server::test_support::TestAppStateBuilder::new()
|
||||
.llm_catalog_settings(catalog_settings)
|
||||
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build();
|
||||
let app = fabro_server::test_support::build_test_router(state);
|
||||
let run_id = create_run(&app).await;
|
||||
|
|
@ -260,13 +276,14 @@ async fn ambiguous_session_model_refs_are_rejected_at_creation() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_turn_fails_when_selected_model_provider_is_unconfigured() {
|
||||
let app = test_app_with_no_providers();
|
||||
async fn session_turn_fails_when_selected_model_provider_becomes_unconfigured() {
|
||||
let app = fabro_server::test_support::build_test_router(test_app_state());
|
||||
let run_id = create_run(&app).await;
|
||||
let created = create_session_with_model(&app, &run_id, "Ask Fabro", "gpt54").await;
|
||||
let session_id = created["id"]
|
||||
.as_str()
|
||||
.expect("session response should include an id");
|
||||
delete_openai_credential(&app).await;
|
||||
|
||||
let request = Request::builder()
|
||||
.method("POST")
|
||||
|
|
@ -347,7 +364,7 @@ async fn unsupported_derived_turn_read_routes_are_removed() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn session_events_are_filtered_by_session_and_paginated_by_run_sequence() {
|
||||
let app = test_app_with_no_providers();
|
||||
let app = fabro_server::test_support::build_test_router(test_app_state());
|
||||
let run_id = create_run(&app).await;
|
||||
let first = create_session(&app, &run_id, "First").await;
|
||||
let second = create_session(&app, &run_id, "Second").await;
|
||||
|
|
@ -365,6 +382,7 @@ async fn session_events_are_filtered_by_session_and_paginated_by_run_sequence()
|
|||
)
|
||||
.await;
|
||||
let after_first_created_seq = first_detail["last_seq"].as_u64().unwrap() + 1;
|
||||
delete_openai_credential(&app).await;
|
||||
|
||||
let turn_id = fabro_types::TurnId::new();
|
||||
let submit = Request::builder()
|
||||
|
|
@ -443,13 +461,33 @@ async fn session_sse_events(response: axum::response::Response) -> Vec<serde_jso
|
|||
.collect()
|
||||
}
|
||||
|
||||
async fn delete_openai_credential(app: &axum::Router) {
|
||||
let request = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(api("/secrets"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"name": EnvVars::OPENAI_API_KEY,
|
||||
}))
|
||||
.expect("delete-secret request should serialize"),
|
||||
))
|
||||
.expect("delete-secret request should build");
|
||||
response_status(
|
||||
app.clone().oneshot(request).await.unwrap(),
|
||||
StatusCode::NO_CONTENT,
|
||||
"DELETE /api/v1/secrets",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn assert_session_metadata_only(value: &serde_json::Value) {
|
||||
let object = value
|
||||
.as_object()
|
||||
.expect("session response should be a JSON object");
|
||||
assert_eq!(value["provider"], "openai");
|
||||
for field in [
|
||||
"working_dir",
|
||||
"provider",
|
||||
"permissions",
|
||||
"deleted_at",
|
||||
"runtime_context",
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ async fn dry_run_serve_starts_and_runs_workflow() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_model_skip_when_no_providers() {
|
||||
async fn test_model_known_but_unavailable_returns_bad_request() {
|
||||
let app = test_app_with_no_providers();
|
||||
|
||||
let req = Request::builder()
|
||||
|
|
@ -68,12 +68,15 @@ async fn test_model_skip_when_no_providers() {
|
|||
let response = app.oneshot(req).await.unwrap();
|
||||
let body = response_json(
|
||||
response,
|
||||
StatusCode::OK,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"POST /api/v1/models/claude-opus-4-6/test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(body["model_id"], "claude-opus-4-6");
|
||||
assert_eq!(body["status"], "skip");
|
||||
assert!(
|
||||
body["errors"][0]["detail"]
|
||||
.as_str()
|
||||
.is_some_and(|detail| detail.contains("no offering on an eligible provider"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -114,26 +117,26 @@ async fn dry_run_serve_rejects_invalid_dot() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_no_provider_non_streaming_returns_502() {
|
||||
async fn completion_no_provider_non_streaming_returns_400() {
|
||||
let app = test_app_with_no_providers();
|
||||
|
||||
let response = app.oneshot(completion_request(false)).await.unwrap();
|
||||
response_status(
|
||||
response,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"POST /api/v1/completions",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_no_provider_streaming_returns_502() {
|
||||
async fn completion_no_provider_streaming_returns_400() {
|
||||
let app = test_app_with_no_providers();
|
||||
|
||||
let response = app.oneshot(completion_request(true)).await.unwrap();
|
||||
response_status(
|
||||
response,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"POST /api/v1/completions?stream=true",
|
||||
)
|
||||
.await;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use axum::body::Body;
|
|||
use axum::http::{Request, StatusCode};
|
||||
use fabro_auth::EnvCredentialSource;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_test::{TwinScenario, TwinScenarios, twin_openai};
|
||||
use fabro_types::RunId;
|
||||
use tokio::time::sleep;
|
||||
|
|
@ -41,7 +42,7 @@ fn test_app_with_openai_agent_backend(openai_base_url: String, api_key: String)
|
|||
.expect("test catalog should build"),
|
||||
);
|
||||
let source_api_key = api_key.clone();
|
||||
let env_api_key = api_key;
|
||||
let env_api_key = api_key.clone();
|
||||
let llm_source: Arc<dyn fabro_auth::CredentialSource> = Arc::new(
|
||||
EnvCredentialSource::with_env_lookup(Arc::new(move |name| match name {
|
||||
"OPENAI_API_KEY" => Some(source_api_key.clone()),
|
||||
|
|
@ -52,6 +53,7 @@ fn test_app_with_openai_agent_backend(openai_base_url: String, api_key: String)
|
|||
.runtime_settings(settings.server_settings, settings.manifest_run_defaults)
|
||||
.max_concurrent_runs(5)
|
||||
.llm_catalog_settings(llm_catalog_settings)
|
||||
.vault_entries([(EnvVars::OPENAI_API_KEY, api_key)])
|
||||
.registry_factory(move |interviewer| {
|
||||
let catalog = Arc::clone(&catalog);
|
||||
let llm_source = Arc::clone(&llm_source);
|
||||
|
|
|
|||
|
|
@ -60,16 +60,17 @@ async fn sse_stream_contains_expected_event_types() {
|
|||
|
||||
wait_for_run_status_not_in(&app, &run_id, &["runnable", "starting"]).await;
|
||||
|
||||
// Get SSE stream
|
||||
// Replay from the beginning so the assertion does not depend on whether
|
||||
// the run advances before the attach request is handled.
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api(&format!("/runs/{run_id}/attach")))
|
||||
.uri(api(&format!("/runs/{run_id}/attach?since_seq=1")))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = checked_response(
|
||||
app.clone().oneshot(req).await.unwrap(),
|
||||
StatusCode::OK,
|
||||
format!("GET /api/v1/runs/{run_id}/attach"),
|
||||
format!("GET /api/v1/runs/{run_id}/attach?since_seq=1"),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -103,18 +104,12 @@ async fn sse_stream_contains_expected_event_types() {
|
|||
}
|
||||
}
|
||||
|
||||
// Because we subscribe while the run is only guaranteed to be past
|
||||
// "runnable", a live stream should include at least one stage event.
|
||||
// If the run completes before we attach with no unread events, an empty
|
||||
// stream is still a valid 200 response.
|
||||
if !event_types.is_empty() {
|
||||
assert!(
|
||||
event_types
|
||||
.iter()
|
||||
.any(|t| t == "stage.started" || t == "stage.completed"),
|
||||
"should contain stage events, got: {event_types:?}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
event_types
|
||||
.iter()
|
||||
.any(|t| t == "stage.started" || t == "stage.completed"),
|
||||
"should contain stage events, got: {event_types:?}"
|
||||
);
|
||||
|
||||
// Pipeline is complete (SSE stream ended), verify checkpoint
|
||||
let cp_body = wait_for_checkpoint(&app, &run_id).await;
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ impl RunSessionProjection {
|
|||
let mut record = SessionRecord::new(session_id, run_id, envelope.event.ts);
|
||||
record.title.clone_from(&props.title);
|
||||
record.model.clone_from(&props.model);
|
||||
record.provider.clone_from(&props.provider);
|
||||
let projected = ProjectedRunSession {
|
||||
record,
|
||||
runtime_context: Vec::new(),
|
||||
|
|
@ -254,8 +255,9 @@ mod tests {
|
|||
1,
|
||||
session_id,
|
||||
EventBody::RunSessionCreated(RunSessionCreatedProps {
|
||||
title: Some("Ask".to_string()),
|
||||
model: Some("test-model".to_string()),
|
||||
title: Some("Ask".to_string()),
|
||||
model: Some("test-model".to_string()),
|
||||
provider: None,
|
||||
}),
|
||||
),
|
||||
event(
|
||||
|
|
@ -318,8 +320,9 @@ mod tests {
|
|||
1,
|
||||
session_id,
|
||||
EventBody::RunSessionCreated(RunSessionCreatedProps {
|
||||
title: None,
|
||||
model: None,
|
||||
title: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
}),
|
||||
),
|
||||
event(
|
||||
|
|
@ -387,8 +390,9 @@ mod tests {
|
|||
1,
|
||||
session_id,
|
||||
EventBody::RunSessionCreated(RunSessionCreatedProps {
|
||||
title: Some("Ask".to_string()),
|
||||
model: Some("test-model".to_string()),
|
||||
title: Some("Ask".to_string()),
|
||||
model: Some("test-model".to_string()),
|
||||
provider: None,
|
||||
}),
|
||||
),
|
||||
event(
|
||||
|
|
@ -408,7 +412,7 @@ mod tests {
|
|||
|
||||
assert!(value.get("runtime_context").is_none());
|
||||
assert!(value.get("working_dir").is_none());
|
||||
assert!(value.get("provider").is_none());
|
||||
assert!(value["provider"].is_null());
|
||||
assert!(value.get("permissions").is_none());
|
||||
assert!(value.get("deleted_at").is_none());
|
||||
}
|
||||
|
|
@ -423,8 +427,9 @@ mod tests {
|
|||
1,
|
||||
session_id,
|
||||
EventBody::RunSessionCreated(RunSessionCreatedProps {
|
||||
title: None,
|
||||
model: None,
|
||||
title: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
}),
|
||||
),
|
||||
event(
|
||||
|
|
@ -439,8 +444,9 @@ mod tests {
|
|||
3,
|
||||
other_session_id,
|
||||
EventBody::RunSessionCreated(RunSessionCreatedProps {
|
||||
title: Some("Other".to_string()),
|
||||
model: None,
|
||||
title: Some("Other".to_string()),
|
||||
model: None,
|
||||
provider: None,
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
|
@ -478,8 +484,9 @@ mod tests {
|
|||
1,
|
||||
session_id,
|
||||
EventBody::RunSessionCreated(RunSessionCreatedProps {
|
||||
title: None,
|
||||
model: None,
|
||||
title: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
}),
|
||||
),
|
||||
event(
|
||||
|
|
|
|||
|
|
@ -1068,7 +1068,7 @@ fn run_models(state: &RunProjection) -> Vec<RunModel> {
|
|||
.filter_map(|(_, stage)| stage.model.as_ref())
|
||||
.map(|model| RunModel {
|
||||
provider: Some(model.provider.to_string()),
|
||||
name: model.model_id.clone(),
|
||||
name: model.model_id.to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
models.sort_by(|left, right| {
|
||||
|
|
|
|||
|
|
@ -803,6 +803,40 @@ mod tests {
|
|||
assert!(matches!(err, Error::ReadOnly));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_event_if_evaluates_latest_projection_before_appending() {
|
||||
let (_object_store, store) = make_store();
|
||||
let run = store.create_run(&test_run_id("run-1")).await.unwrap();
|
||||
append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
|
||||
let initial_title = run.state().await.unwrap().title().into_owned();
|
||||
|
||||
run.append_event(&event_payload(
|
||||
"run-1",
|
||||
"2026-03-27T12:00:01Z",
|
||||
"run.title.updated",
|
||||
&serde_json::json!({ "title": "User title" }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let generated_update = event_payload(
|
||||
"run-1",
|
||||
"2026-03-27T12:00:02Z",
|
||||
"run.title.updated",
|
||||
&serde_json::json!({ "title": "Generated title" }),
|
||||
);
|
||||
let appended = run
|
||||
.append_event_if(&generated_update, |projection| {
|
||||
projection.title() == initial_title
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(appended, None);
|
||||
assert_eq!(run.state().await.unwrap().title(), "User title");
|
||||
assert_eq!(run.list_events().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_request_events_set_pending_control_without_overwriting_status() {
|
||||
let (_object_store, store) = make_store();
|
||||
|
|
|
|||
|
|
@ -169,6 +169,10 @@ impl RunDatabase {
|
|||
|
||||
async fn projected_state(&self) -> Result<RunProjection> {
|
||||
let _state_guard = self.inner.state_lock.lock().await;
|
||||
self.projected_state_locked().await
|
||||
}
|
||||
|
||||
async fn projected_state_locked(&self) -> Result<RunProjection> {
|
||||
let next_seq = {
|
||||
let cache = self.inner.projection_cache.lock().await;
|
||||
cache.last_seq.saturating_add(1)
|
||||
|
|
@ -254,12 +258,35 @@ impl RunDatabase {
|
|||
Ok(self.append_event_envelope(payload).await?.seq)
|
||||
}
|
||||
|
||||
/// Atomically appends `payload` when `predicate` matches the latest run
|
||||
/// projection.
|
||||
pub async fn append_event_if(
|
||||
&self,
|
||||
payload: &EventPayload,
|
||||
predicate: impl FnOnce(&RunProjection) -> bool,
|
||||
) -> Result<Option<u32>> {
|
||||
if self.read_only {
|
||||
return Err(Error::ReadOnly);
|
||||
}
|
||||
payload.validate(&self.inner.run_id)?;
|
||||
let _state_guard = self.inner.state_lock.lock().await;
|
||||
let projection = self.projected_state_locked().await?;
|
||||
if !predicate(&projection) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(self.append_event_envelope_locked(payload).await?.seq))
|
||||
}
|
||||
|
||||
pub async fn append_event_envelope(&self, payload: &EventPayload) -> Result<EventEnvelope> {
|
||||
if self.read_only {
|
||||
return Err(Error::ReadOnly);
|
||||
}
|
||||
payload.validate(&self.inner.run_id)?;
|
||||
let _state_guard = self.inner.state_lock.lock().await;
|
||||
self.append_event_envelope_locked(payload).await
|
||||
}
|
||||
|
||||
async fn append_event_envelope_locked(&self, payload: &EventPayload) -> Result<EventEnvelope> {
|
||||
let seq = self.inner.event_seq.fetch_add(1, Ordering::SeqCst);
|
||||
let event = EventEnvelope {
|
||||
seq,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ const TEST_SESSION_SECRET: &str =
|
|||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const TEST_DEV_TOKEN: &str =
|
||||
"fabro_dev_abababababababababababababababababababababababababababababababab";
|
||||
const FABRO_TEST_ASSUME_LLM_READY: &str = "FABRO_TEST_ASSUME_LLM_READY";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum TestMode {
|
||||
|
|
@ -187,6 +188,7 @@ pub fn isolated_env(home_dir: &Path) -> HashMap<String, String> {
|
|||
EnvVars::FABRO_TEST_IN_MEMORY_STORE.to_string(),
|
||||
"1".to_string(),
|
||||
);
|
||||
env.insert(FABRO_TEST_ASSUME_LLM_READY.to_string(), "1".to_string());
|
||||
env
|
||||
}
|
||||
|
||||
|
|
@ -210,6 +212,7 @@ fn apply_test_isolation_with_lookup(
|
|||
.env(EnvVars::FABRO_SUPPRESS_OPEN_BROWSER, "1");
|
||||
cmd.env(EnvVars::FABRO_SERVER_MAX_CONCURRENT_RUNS, "64");
|
||||
cmd.env(EnvVars::FABRO_TEST_IN_MEMORY_STORE, "1");
|
||||
cmd.env(FABRO_TEST_ASSUME_LLM_READY, "1");
|
||||
}
|
||||
|
||||
/// Create a fresh tempdir containing an empty `storage/` subdirectory, for
|
||||
|
|
|
|||
|
|
@ -385,7 +385,7 @@ mod tests {
|
|||
fn sample_model_ref() -> ModelRef {
|
||||
ModelRef {
|
||||
provider: fabro_model::ProviderId::openai(),
|
||||
model_id: "gpt-5".to_string(),
|
||||
model_id: "gpt-5".into(),
|
||||
speed: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2135,7 +2135,7 @@ mod tests {
|
|||
text: "ok".to_string(),
|
||||
model: crate::ModelRef {
|
||||
provider: fabro_model::ProviderId::openai(),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: None,
|
||||
},
|
||||
billing: BilledTokenCounts::default(),
|
||||
|
|
@ -2188,7 +2188,7 @@ mod tests {
|
|||
text: "ok".to_string(),
|
||||
model: crate::ModelRef {
|
||||
provider: fabro_model::ProviderId::openai(),
|
||||
model_id: "gpt-5.4".to_string(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: None,
|
||||
},
|
||||
billing: BilledTokenCounts::default(),
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue