Rename fabro-api-types to fabro-api with progenitor client generation

Replace typify-only type generation with progenitor, which generates both
Rust types (in a `types` module) and a reqwest-based HTTP client from the
OpenAPI spec. Also upgrades reqwest 0.12→0.13 and rmcp 0.15→1.3 to align
dependency versions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-03 11:17:27 -07:00
parent 7ba75e4743
commit a02148aacd
No known key found for this signature in database
16 changed files with 617 additions and 267 deletions

View file

@ -40,7 +40,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
The OpenAPI spec at `docs/api-reference/fabro-api.yaml` is the source of truth for the fabro-api HTTP interface.
1. Edit `docs/api-reference/fabro-api.yaml`
2. `cargo build -p fabro-api-types` — build.rs regenerates Rust types via typify
2. `cargo build -p fabro-api` — build.rs regenerates Rust types and client via progenitor
3. Write/update handler in `lib/crates/fabro-server/src/server.rs`, add route to `build_router()`
4. `cargo nextest run -p fabro-server` — conformance test catches spec/router drift
5. `cd lib/packages/fabro-api-client && bun run generate` — regenerates TypeScript Axios client
@ -55,7 +55,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
- **fabro-agent** — AI coding agent with tool use (Bash, Read, Write, Edit, Glob, Grep, WebFetch). `Sandbox` trait abstracts execution environments
- **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header
- **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming
- **fabro-api-types** — Auto-generated Rust types from OpenAPI spec (build.rs + typify)
- **fabro-api** — Auto-generated Rust types and reqwest HTTP client from OpenAPI spec (build.rs + progenitor)
- **fabro-github** — GitHub App auth (JWT signing, installation tokens, PR creation)
- **fabro-db** — SQLite with WAL mode, schema migrations
- **fabro-mcp** — Model Context Protocol client/server
@ -72,7 +72,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
### Key design patterns
- **Sandbox trait** — Uniform interface for local, Docker, and Daytona execution environments
- **Graphviz graph workflows** — Stages and transitions defined as Graphviz graph attributes
- **OpenAPI-first**`fabro-api.yaml` drives both Rust type generation (typify) and TypeScript client generation (openapi-generator)
- **OpenAPI-first**`fabro-api.yaml` drives Rust type + client generation (progenitor) and TypeScript client generation (openapi-generator)
- **Checkpoint/resume** — Workflows can be paused, checkpointed, and resumed
## Strategy docs

386
Cargo.lock generated
View file

@ -650,6 +650,12 @@ dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfg-if"
version = "1.0.4"
@ -766,6 +772,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@ -1156,7 +1172,7 @@ name = "daytona-api-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=06033ca#06033caaf5d9e12918ae68396048c5c0a98822c6"
dependencies = [
"reqwest",
"reqwest 0.12.28",
"reqwest-middleware",
"serde",
"serde_json",
@ -1173,7 +1189,7 @@ dependencies = [
"daytona-api-client",
"daytona-toolbox-client",
"futures-util",
"reqwest",
"reqwest 0.12.28",
"reqwest-middleware",
"serde",
"serde_json",
@ -1189,7 +1205,7 @@ name = "daytona-toolbox-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=06033ca#06033caaf5d9e12918ae68396048c5c0a98822c6"
dependencies = [
"reqwest",
"reqwest 0.12.28",
"reqwest-middleware",
"serde",
"serde_json",
@ -1528,7 +1544,7 @@ dependencies = [
"jsonschema",
"libc",
"paste",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"shell-escape",
@ -1541,17 +1557,19 @@ dependencies = [
]
[[package]]
name = "fabro-api-types"
name = "fabro-api"
version = "0.176.2"
dependencies = [
"chrono",
"openapiv3",
"prettyplease",
"schemars 0.8.22",
"progenitor",
"progenitor-client",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_yaml",
"syn 2.0.117",
"typify",
"uuid",
]
@ -1624,7 +1642,7 @@ dependencies = [
"predicates",
"rand 0.8.5",
"regex",
"reqwest",
"reqwest 0.13.2",
"rustls",
"rustls-pemfile",
"scopeguard",
@ -1694,7 +1712,7 @@ version = "0.176.2"
dependencies = [
"fabro-util",
"insta",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_yaml",
@ -1713,7 +1731,7 @@ dependencies = [
"fabro-macros",
"fabro-test",
"jsonwebtoken",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"tokio",
@ -1745,7 +1763,7 @@ dependencies = [
"fabro-util",
"httpmock",
"regex",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"tokio",
@ -1789,7 +1807,7 @@ dependencies = [
"indicatif",
"insta",
"rand 0.8.5",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"thiserror 2.0.18",
@ -1817,7 +1835,7 @@ dependencies = [
"fabro-config",
"fabro-types",
"futures",
"reqwest",
"reqwest 0.13.2",
"rmcp",
"serde",
"serde_json",
@ -1844,7 +1862,7 @@ dependencies = [
"httpmock",
"open",
"rand 0.8.5",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"sha2",
@ -1924,7 +1942,7 @@ dependencies = [
"cookie",
"dirs",
"fabro-agent",
"fabro-api-types",
"fabro-api",
"fabro-config",
"fabro-db",
"fabro-github",
@ -1950,7 +1968,7 @@ dependencies = [
"object_store",
"openapiv3",
"rand 0.8.5",
"reqwest",
"reqwest 0.13.2",
"rust-embed",
"rustls",
"rustls-pemfile",
@ -1980,7 +1998,7 @@ dependencies = [
"fabro-interview",
"fabro-workflow",
"futures-util",
"reqwest",
"reqwest 0.13.2",
"rustls",
"serde",
"serde_json",
@ -2027,7 +2045,7 @@ dependencies = [
"mac_address",
"md5",
"regex",
"reqwest",
"reqwest 0.13.2",
"sentry",
"serde",
"serde_json",
@ -2044,7 +2062,7 @@ dependencies = [
"axum",
"insta",
"regex",
"reqwest",
"reqwest 0.13.2",
"serde_json",
"tempfile",
"tokio",
@ -2059,7 +2077,7 @@ dependencies = [
"async-trait",
"fabro-github",
"httpmock",
"reqwest",
"reqwest 0.13.2",
"serde_json",
"tokio",
"tracing",
@ -2148,7 +2166,7 @@ dependencies = [
"predicates",
"rand 0.8.5",
"regex",
"reqwest",
"reqwest 0.13.2",
"scopeguard",
"serde",
"serde_json",
@ -3285,6 +3303,50 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "jobserver"
version = "0.1.34"
@ -4127,7 +4189,7 @@ dependencies = [
"percent-encoding",
"quick-xml",
"rand 0.9.2",
"reqwest",
"reqwest 0.12.28",
"ring",
"serde",
"serde_json",
@ -4669,6 +4731,72 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "progenitor"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d36315275b213c64c68dff684477ea7118a0f630832f737b550796a368f9962c"
dependencies = [
"progenitor-client",
"progenitor-impl",
"progenitor-macro",
]
[[package]]
name = "progenitor-client"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3999c302f5f2a42b7ca1cc39ad9e612c74cf2910ef6e58f869e45f3068b9659f"
dependencies = [
"bytes",
"futures-core",
"percent-encoding",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_urlencoded",
]
[[package]]
name = "progenitor-impl"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de362a0477182f45accdbad4d43cd89a95a1db0a518a7c1ddf3e525e6896f0f0"
dependencies = [
"heck 0.5.0",
"http",
"indexmap 2.13.0",
"openapiv3",
"proc-macro2",
"quote",
"regex",
"schemars 0.8.22",
"serde",
"serde_json",
"syn 2.0.117",
"thiserror 2.0.18",
"typify",
"unicode-ident",
]
[[package]]
name = "progenitor-macro"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c98aeaaab266bf848a602c78e039e7d62c80ba36303ae4092ec65f17e7fd0eaa"
dependencies = [
"openapiv3",
"proc-macro2",
"progenitor-impl",
"quote",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_tokenstream",
"serde_yaml",
"syn 2.0.117",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@ -4705,6 +4833,7 @@ version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
@ -4974,11 +5103,53 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots 1.0.6",
]
[[package]]
name = "reqwest"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.5.0",
"web-sys",
]
[[package]]
name = "reqwest-middleware"
version = "0.4.2"
@ -4988,7 +5159,7 @@ dependencies = [
"anyhow",
"async-trait",
"http",
"reqwest",
"reqwest 0.12.28",
"serde",
"thiserror 1.0.69",
"tower-service",
@ -5010,9 +5181,9 @@ dependencies = [
[[package]]
name = "rmcp"
version = "0.15.0"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee"
checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419"
dependencies = [
"async-trait",
"chrono",
@ -5021,7 +5192,7 @@ dependencies = [
"pastey",
"pin-project-lite",
"process-wrap",
"reqwest",
"reqwest 0.13.2",
"rmcp-macros",
"schemars 1.2.1",
"serde",
@ -5036,9 +5207,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "0.15.0"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe"
checksum = "36ea0e100fadf81be85d7ff70f86cd805c7572601d4ab2946207f36540854b43"
dependencies = [
"darling 0.23.0",
"proc-macro2",
@ -5201,6 +5372,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.9"
@ -5249,10 +5447,12 @@ version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"chrono",
"dyn-clone",
"schemars_derive 0.8.22",
"serde",
"serde_json",
"uuid",
]
[[package]]
@ -5339,6 +5539,10 @@ name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "sentry"
@ -5347,7 +5551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "016958f51b96861dead7c1e02290f138411d05e94fad175c8636a835dee6e51e"
dependencies = [
"httpdate",
"reqwest",
"reqwest 0.12.28",
"rustls",
"sentry-backtrace",
"sentry-contexts",
@ -5530,6 +5734,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_tokenstream"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.117",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@ -6681,7 +6897,7 @@ dependencies = [
"base64",
"chrono",
"jsonwebtoken",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"tempfile",
@ -6700,7 +6916,7 @@ dependencies = [
"axum",
"futures-util",
"http",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"tokio",
@ -6731,6 +6947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b715573a376585888b742ead9be5f4826105e622169180662e2c81bed4a149c3"
dependencies = [
"typify-impl",
"typify-macro",
]
[[package]]
@ -6753,6 +6970,23 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "typify-macro"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd04bb1207cd4e250941cc1641f4c4815f7eaa2145f45c09dd49cb0a3691710a"
dependencies = [
"proc-macro2",
"quote",
"schemars 0.8.22",
"semver",
"serde",
"serde_json",
"serde_tokenstream",
"syn 2.0.117",
"typify-impl",
]
[[package]]
name = "ulid"
version = "1.2.1"
@ -7123,6 +7357,19 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
@ -7167,6 +7414,15 @@ dependencies = [
"string_cache_codegen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
@ -7418,6 +7674,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
@ -7463,6 +7728,21 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
@ -7529,6 +7809,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@ -7547,6 +7833,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@ -7565,6 +7857,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@ -7595,6 +7893,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@ -7613,6 +7917,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@ -7631,6 +7941,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@ -7649,6 +7965,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"

View file

@ -17,7 +17,7 @@ thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "query", "form"] }
ulid = "1"
uuid = { version = "1", features = ["v4", "v7"] }
rand = "0.8"
@ -41,7 +41,7 @@ git2 = { version = "0.20", default-features = false }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
tracing-appender = "0.2"
rmcp = { version = "0.15.0", default-features = false }
rmcp = { version = "1.3", default-features = false }
walkdir = "2"
regex = "1"
semver = "1"

View file

@ -34,31 +34,34 @@ console.log(data);
The generated client includes a typed API class for each endpoint group: `RunsApi`, `WorkflowsApi`, `SessionsApi`, `VerificationsApi`, `InsightsApi`, and others.
## Rust (Types Only)
## Rust (Types + Client)
The `fabro-api-types` crate generates Rust structs and enums from the OpenAPI component schemas at compile time using [typify](https://github.com/oxidecomputer/typify). This provides type-safe representations of all API models but does not include an HTTP client.
The `fabro-api` crate generates Rust structs, enums, and a `reqwest`-based HTTP client from the full OpenAPI spec at compile time using [progenitor](https://github.com/oxidecomputer/progenitor). This provides type-safe representations of all API models and a builder-style client for every endpoint.
### How It Works
A `build.rs` script reads `docs/api-reference/fabro-api.yaml`, extracts `components/schemas`, and feeds them to typify. The generated code is written to `OUT_DIR` and included via:
A `build.rs` script reads `docs/api-reference/fabro-api.yaml`, patches it from OpenAPI 3.1 to 3.0 for progenitor compatibility, and generates both types and a client. The generated code is written to `OUT_DIR` and included via:
```rust
// lib/crates/fabro-api-types/src/lib.rs
include!(concat!(env!("OUT_DIR"), "/openapi_types.rs"));
// lib/crates/fabro-api/src/lib.rs
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
```
### Regenerating
The types are regenerated automatically on every `cargo build` when the OpenAPI spec changes:
The types and client are regenerated automatically on every `cargo build` when the OpenAPI spec changes:
```bash
cargo build -p fabro-api-types
cargo build -p fabro-api
```
### Usage
```rust
use fabro_api_types::RunListItem;
use fabro_api::types::RunListItem;
use fabro_api::Client;
let client = Client::new("http://localhost:3000");
```
All generated types derive `serde::Deserialize` and `serde::Serialize`, so they work directly with any Rust HTTP client for request and response parsing.
All generated types derive `serde::Deserialize` and `serde::Serialize`. The client uses builder-style methods for each endpoint.

View file

@ -1,50 +0,0 @@
use std::{env, fs, path::Path};
use schemars::schema::Schema;
use typify::{TypeSpace, TypeSpaceSettings};
fn main() {
let spec_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("docs/api-reference/fabro-api.yaml");
println!("cargo::rerun-if-changed={}", spec_path.display());
let spec_text = fs::read_to_string(&spec_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", spec_path.display()));
let spec: serde_json::Value =
serde_yaml::from_str(&spec_text).unwrap_or_else(|e| panic!("failed to parse YAML: {e}"));
let schemas = spec["components"]["schemas"]
.as_object()
.expect("no components/schemas in spec");
let named_schemas: Vec<(String, Schema)> = schemas
.iter()
.map(|(name, value)| {
let schema: Schema = serde_json::from_value(value.clone())
.unwrap_or_else(|e| panic!("failed to parse schema {name}: {e}"));
(name.clone(), schema)
})
.collect();
let settings = TypeSpaceSettings::default();
let mut type_space = TypeSpace::new(&settings);
type_space
.add_ref_types(named_schemas)
.expect("failed to add schemas to type space");
let token_stream = type_space.to_stream();
let syntax_tree =
syn::parse2::<syn::File>(token_stream).expect("failed to parse generated tokens");
let formatted = prettyplease::unparse(&syntax_tree);
let out_dir = env::var("OUT_DIR").unwrap();
let out_path = Path::new(&out_dir).join("openapi_types.rs");
fs::write(&out_path, formatted).expect("failed to write generated types");
}

View file

@ -1,5 +0,0 @@
#[allow(clippy::absolute_paths, clippy::derivable_impls)]
mod generated {
include!(concat!(env!("OUT_DIR"), "/openapi_types.rs"));
}
pub use generated::*;

View file

@ -1,10 +1,10 @@
[package]
name = "fabro-api-types"
name = "fabro-api"
edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "Generated Rust types from the Fabro API OpenAPI spec"
description = "Generated Rust types and HTTP client from the Fabro API OpenAPI spec"
[lib]
doctest = false
@ -15,13 +15,15 @@ wildcard_imports = "warn"
[dependencies]
chrono = { workspace = true, features = ["serde"] }
progenitor-client = "0.13"
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
uuid = { workspace = true, features = ["serde"] }
[build-dependencies]
typify = { version = "0.6", default-features = false }
schemars = "0.8"
openapiv3 = "2"
progenitor = "0.13"
serde_json = "1"
serde_yaml = "0.9"
prettyplease = "0.2"

View file

@ -0,0 +1,105 @@
use std::{env, fs, path::Path};
use progenitor::{GenerationSettings, Generator, InterfaceStyle};
/// Recursively convert OpenAPI 3.1 `type: "null"` patterns to 3.0 `nullable: true`.
///
/// Handles two patterns:
/// - `oneOf: [{...}, {type: "null"}]` → the non-null schema with `nullable: true`
/// - `type: [T1, ..., "null"]` → the remaining types with `nullable: true`
fn patch_nullable(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
// Pattern: oneOf with a {type: "null"} variant
if let Some(one_of) = map.get_mut("oneOf") {
if let Some(variants) = one_of.as_array_mut() {
let null_idx = variants.iter().position(|v| {
v.get("type").and_then(serde_json::Value::as_str) == Some("null")
});
if let Some(idx) = null_idx {
variants.remove(idx);
if variants.len() == 1 {
// Collapse single-variant oneOf into the schema itself
let mut inner = variants.remove(0);
inner
.as_object_mut()
.unwrap()
.insert("nullable".to_string(), serde_json::Value::Bool(true));
patch_nullable(&mut inner);
*value = inner;
return;
}
map.insert("nullable".to_string(), serde_json::Value::Bool(true));
}
}
}
// Pattern: type array containing "null"
let needs_nullable_from_type = map
.get("type")
.and_then(|v| v.as_array())
.is_some_and(|arr| arr.iter().any(|v| v.as_str() == Some("null")));
if needs_nullable_from_type {
if let Some(type_val) = map.get_mut("type") {
if let Some(arr) = type_val.as_array_mut() {
arr.retain(|v| v.as_str() != Some("null"));
if arr.len() == 1 {
*type_val = arr.remove(0);
}
}
}
map.insert("nullable".to_string(), serde_json::Value::Bool(true));
}
for v in map.values_mut() {
patch_nullable(v);
}
}
serde_json::Value::Array(arr) => {
for v in arr {
patch_nullable(v);
}
}
_ => {}
}
}
fn main() {
let spec_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("docs/api-reference/fabro-api.yaml");
println!("cargo::rerun-if-changed={}", spec_path.display());
let spec_text = fs::read_to_string(&spec_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", spec_path.display()));
let mut spec_value: serde_json::Value =
serde_yaml::from_str(&spec_text).unwrap_or_else(|e| panic!("failed to parse YAML: {e}"));
// Progenitor only supports OpenAPI 3.0.x; our spec uses 3.1.0 but doesn't
// rely on any 3.1-only features that affect codegen.
spec_value["openapi"] = serde_json::Value::String("3.0.3".to_string());
patch_nullable(&mut spec_value);
let spec: openapiv3::OpenAPI =
serde_json::from_value(spec_value).expect("failed to deserialize OpenAPI spec");
let mut settings = GenerationSettings::default();
settings.with_interface(InterfaceStyle::Builder);
let mut generator = Generator::new(&settings);
let tokens = generator
.generate_tokens(&spec)
.expect("failed to generate tokens from OpenAPI spec");
let syntax_tree = syn::parse2::<syn::File>(tokens).expect("failed to parse generated tokens");
let formatted = prettyplease::unparse(&syntax_tree);
let out_dir = env::var("OUT_DIR").unwrap();
let out_path = Path::new(&out_dir).join("codegen.rs");
fs::write(&out_path, formatted).expect("failed to write generated code");
}

View file

@ -0,0 +1,13 @@
#[allow(
clippy::absolute_paths,
clippy::all,
clippy::derivable_impls,
clippy::needless_lifetimes,
unreachable_pub,
unused_imports
)]
mod generated {
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
}
pub use generated::Client;
pub use generated::types;

View file

@ -212,12 +212,10 @@ impl McpClient {
}
};
let params = CallToolRequestParams {
meta: None,
name: name.to_string().into(),
arguments: args,
task: None,
};
let mut params = CallToolRequestParams::new(name.to_string());
if let Some(arguments) = args {
params = params.with_arguments(arguments);
}
debug!(server = %self.server_name, tool = %name, "Calling MCP tool");

View file

@ -13,19 +13,11 @@ pub(crate) struct LoggingClientHandler;
impl ClientHandler for LoggingClientHandler {
fn get_info(&self) -> ClientInfo {
ClientInfo {
protocol_version: ProtocolVersion::V_2025_03_26,
capabilities: ClientCapabilities::default(),
client_info: Implementation {
name: "fabro-mcp".into(),
version: env!("CARGO_PKG_VERSION").into(),
title: None,
description: None,
icons: None,
website_url: None,
},
meta: None,
}
ClientInfo::new(
ClientCapabilities::default(),
Implementation::new("fabro-mcp", env!("CARGO_PKG_VERSION")),
)
.with_protocol_version(ProtocolVersion::V_2025_03_26)
}
async fn on_cancelled(

View file

@ -184,7 +184,7 @@ impl Default for McpConnectionManager {
#[cfg(test)]
mod tests {
use super::*;
use rmcp::model::{Content, RawTextContent};
use rmcp::model::Content;
#[test]
fn qualified_tool_name_basic() {
@ -242,21 +242,14 @@ mod tests {
}
fn make_text_content(text: &str) -> Content {
Content {
raw: RawContent::Text(RawTextContent {
text: text.to_string(),
meta: None,
}),
annotations: None,
}
Content::text(text)
}
fn make_call_result(content: Vec<Content>, is_error: Option<bool>) -> CallToolResult {
CallToolResult {
content,
structured_content: None,
is_error,
meta: None,
if is_error == Some(true) {
CallToolResult::error(content)
} else {
CallToolResult::success(content)
}
}
@ -292,20 +285,7 @@ mod tests {
#[test]
fn call_result_to_string_image_placeholder() {
use rmcp::model::RawImageContent;
let result = CallToolResult {
content: vec![Content {
raw: RawContent::Image(RawImageContent {
data: "base64data".to_string(),
mime_type: "image/png".to_string(),
meta: None,
}),
annotations: None,
}],
structured_content: None,
is_error: None,
meta: None,
};
let result = CallToolResult::success(vec![Content::image("base64data", "image/png")]);
assert_eq!(
call_result_to_string(&result),
Ok("[image content]".to_string())

View file

@ -27,7 +27,7 @@ fabro-retro = { path = "../fabro-retro" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
fabro-db = { path = "../fabro-db" }
fabro-api-types = { path = "../fabro-api-types" }
fabro-api = { path = "../fabro-api" }
fabro-store = { path = "../fabro-store" }
chrono.workspace = true
futures-util.workspace = true

View file

@ -8,6 +8,7 @@ use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use fabro_api::types::{RunStatus, RunStatusResponse, SessionTurn, SmoothnessRating};
use serde_json::json;
use crate::error::ApiError;
@ -21,7 +22,7 @@ pub(crate) struct RetroListParams {
#[serde(rename = "page[offset]", default)]
offset: u32,
workflow: Option<String>,
smoothness: Option<fabro_api_types::SmoothnessRating>,
smoothness: Option<SmoothnessRating>,
}
fn paginated_response<T: serde::Serialize>(
@ -141,9 +142,9 @@ pub(crate) async fn get_run_status(
match runs::list_items().into_iter().find(|r| r.id == id) {
Some(item) => (
StatusCode::OK,
Json(fabro_api_types::RunStatusResponse {
Json(RunStatusResponse {
id: id.clone(),
status: fabro_api_types::RunStatus::Running,
status: RunStatus::Running,
error: None,
queue_position: None,
created_at: item.created_at,
@ -470,13 +471,9 @@ pub(crate) async fn session_events_stub(
for turn in &session.turns {
let (event_type, data) = match turn {
fabro_api_types::SessionTurn::UserTurn(_) => continue,
fabro_api_types::SessionTurn::AssistantTurn(t) => {
("assistant_turn", serde_json::to_string(t).unwrap())
}
fabro_api_types::SessionTurn::ToolTurn(t) => {
("tool_turn", serde_json::to_string(t).unwrap())
}
SessionTurn::UserTurn(_) => continue,
SessionTurn::AssistantTurn(t) => ("assistant_turn", serde_json::to_string(t).unwrap()),
SessionTurn::ToolTurn(t) => ("tool_turn", serde_json::to_string(t).unwrap()),
};
if last_event_id.is_none() || seq > last_event_id.unwrap() {
@ -620,7 +617,7 @@ fn ts(s: &str) -> DateTime<Utc> {
mod runs {
use super::ts;
use fabro_api_types::*;
use fabro_api::types::*;
pub(super) fn list_items() -> Vec<RunListItem> {
vec![
@ -1241,7 +1238,7 @@ mod runs {
}
}
pub(super) fn verifications() -> Vec<fabro_api_types::RunVerification> {
pub(super) fn verifications() -> Vec<RunVerification> {
super::verifications::run_verifications()
}
@ -1340,7 +1337,7 @@ mod runs {
}
mod usage {
use fabro_api_types::*;
use fabro_api::types::*;
pub(super) fn aggregate() -> AggregateUsage {
AggregateUsage {
@ -1392,7 +1389,7 @@ mod usage {
mod workflows {
use super::ts;
use fabro_api_types::*;
use fabro_api::types::*;
pub(super) fn list_items() -> Vec<WorkflowListItem> {
vec![
@ -1764,7 +1761,7 @@ mod workflows {
mod verifications {
use super::ts;
use fabro_api_types::*;
use fabro_api::types::*;
// ── Category definitions (name, question, controls) ─────────────────
@ -2823,7 +2820,7 @@ mod verifications {
mod signoffs {
use super::ts;
use fabro_api_types::*;
use fabro_api::types::*;
struct SignoffDef {
id: &'static str,
@ -2938,7 +2935,7 @@ mod signoffs {
mod retros {
use super::ts;
use fabro_api_types::*;
use fabro_api::types::*;
#[allow(clippy::too_many_arguments)]
fn stage(
@ -3272,7 +3269,7 @@ mod retros {
mod sessions {
use super::ts;
use fabro_api_types::*;
use fabro_api::types::*;
use uuid::Uuid;
fn uid(n: u128) -> Uuid {
@ -3414,7 +3411,7 @@ mod sessions {
mod insights {
use super::ts;
use fabro_api_types::*;
use fabro_api::types::*;
pub(super) fn saved_queries() -> Vec<SavedQuery> {
vec![

View file

@ -52,10 +52,13 @@ use fabro_workflow::operations::{self, CreateRunInput, WorkflowInput};
use fabro_workflow::pipeline::Persisted;
use fabro_workflow::records::Checkpoint;
pub use fabro_api_types::{
ApiQuestion, ApiQuestionOption, PaginatedRunList, PaginationMeta,
QuestionType as ApiQuestionType, RunStatus, RunStatusResponse, StartRunRequest,
SubmitAnswerRequest,
use fabro_api::types::AggregateUsageTotals as ApiAggregateUsageTotals;
pub use fabro_api::types::{
AggregateUsage, ApiQuestion, ApiQuestionOption, CompletionContentPart, CompletionMessage,
CompletionMessageRole, CompletionResponse, CompletionToolChoiceMode, CompletionUsage,
CreateCompletionRequest, ModelReference, PaginatedRunList, PaginationMeta,
QuestionType as ApiQuestionType, RunError, RunStatus, RunStatusResponse, StartRunRequest,
SubmitAnswerRequest, TokenUsage, UsageByModel,
};
pub fn default_page_limit() -> u32 {
@ -377,21 +380,21 @@ async fn get_aggregate_usage(
.aggregate_usage
.lock()
.expect("aggregate_usage lock poisoned");
let by_model: Vec<fabro_api_types::UsageByModel> = agg
let by_model: Vec<UsageByModel> = agg
.by_model
.iter()
.map(|(model, totals)| fabro_api_types::UsageByModel {
model: fabro_api_types::ModelReference { id: model.clone() },
.map(|(model, totals)| UsageByModel {
model: ModelReference { id: model.clone() },
stages: totals.stages,
usage: fabro_api_types::TokenUsage {
usage: TokenUsage {
input_tokens: totals.input_tokens,
output_tokens: totals.output_tokens,
cost: totals.cost,
},
})
.collect();
let response = fabro_api_types::AggregateUsage {
totals: fabro_api_types::AggregateUsageTotals {
let response = AggregateUsage {
totals: ApiAggregateUsageTotals {
runs: agg.total_runs,
input_tokens: by_model.iter().map(|m| m.usage.input_tokens).sum(),
output_tokens: by_model.iter().map(|m| m.usage.output_tokens).sum(),
@ -489,12 +492,9 @@ async fn list_runs(
.map(|(id, managed_run)| RunStatusResponse {
id: id.to_string(),
status: managed_run.status,
error: managed_run
.error
.as_ref()
.map(|msg| fabro_api_types::RunError {
message: msg.clone(),
}),
error: managed_run.error.as_ref().map(|msg| RunError {
message: msg.clone(),
}),
queue_position: queue_positions.get(id).copied(),
created_at: managed_run.created_at,
})
@ -891,12 +891,9 @@ async fn get_run_status(
Json(RunStatusResponse {
id: id.to_string(),
status: managed_run.status,
error: managed_run
.error
.as_ref()
.map(|msg| fabro_api_types::RunError {
message: msg.clone(),
}),
error: managed_run.error.as_ref().map(|msg| RunError {
message: msg.clone(),
}),
created_at: managed_run.created_at,
queue_position,
}),
@ -1276,13 +1273,13 @@ fn finish_reason_to_api_stop_reason(reason: &FinishReason) -> String {
}
}
fn convert_api_message(msg: &fabro_api_types::CompletionMessage) -> LlmMessage {
fn convert_api_message(msg: &CompletionMessage) -> LlmMessage {
let role = match msg.role {
fabro_api_types::CompletionMessageRole::System => Role::System,
fabro_api_types::CompletionMessageRole::User => Role::User,
fabro_api_types::CompletionMessageRole::Assistant => Role::Assistant,
fabro_api_types::CompletionMessageRole::Tool => Role::Tool,
fabro_api_types::CompletionMessageRole::Developer => Role::Developer,
CompletionMessageRole::System => Role::System,
CompletionMessageRole::User => Role::User,
CompletionMessageRole::Assistant => Role::Assistant,
CompletionMessageRole::Tool => Role::Tool,
CompletionMessageRole::Developer => Role::Developer,
};
let content: Vec<ContentPart> = msg
.content
@ -1300,15 +1297,15 @@ fn convert_api_message(msg: &fabro_api_types::CompletionMessage) -> LlmMessage {
}
}
fn convert_llm_message(msg: &LlmMessage) -> fabro_api_types::CompletionMessage {
fn convert_llm_message(msg: &LlmMessage) -> CompletionMessage {
let role = match msg.role {
Role::System => fabro_api_types::CompletionMessageRole::System,
Role::User => fabro_api_types::CompletionMessageRole::User,
Role::Assistant => fabro_api_types::CompletionMessageRole::Assistant,
Role::Tool => fabro_api_types::CompletionMessageRole::Tool,
Role::Developer => fabro_api_types::CompletionMessageRole::Developer,
Role::System => CompletionMessageRole::System,
Role::User => CompletionMessageRole::User,
Role::Assistant => CompletionMessageRole::Assistant,
Role::Tool => CompletionMessageRole::Tool,
Role::Developer => CompletionMessageRole::Developer,
};
let content: Vec<fabro_api_types::CompletionContentPart> = msg
let content: Vec<CompletionContentPart> = msg
.content
.iter()
.filter_map(|part| {
@ -1316,7 +1313,7 @@ fn convert_llm_message(msg: &LlmMessage) -> fabro_api_types::CompletionMessage {
serde_json::from_value(json).ok()
})
.collect();
fabro_api_types::CompletionMessage {
CompletionMessage {
role,
content,
name: msg.name.clone(),
@ -1327,7 +1324,7 @@ fn convert_llm_message(msg: &LlmMessage) -> fabro_api_types::CompletionMessage {
async fn create_completion(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Json(req): Json<fabro_api_types::CreateCompletionRequest>,
Json(req): Json<CreateCompletionRequest>,
) -> Response {
// Resolve model
let model_id = req.model.unwrap_or_else(|| {
@ -1373,12 +1370,10 @@ async fn create_completion(
// Convert tool_choice
let tool_choice: Option<ToolChoice> = req.tool_choice.map(|tc| match tc.mode {
fabro_api_types::CompletionToolChoiceMode::Auto => ToolChoice::Auto,
fabro_api_types::CompletionToolChoiceMode::None => ToolChoice::None,
fabro_api_types::CompletionToolChoiceMode::Required => ToolChoice::Required,
fabro_api_types::CompletionToolChoiceMode::Named => {
ToolChoice::named(tc.tool_name.unwrap_or_default())
}
CompletionToolChoiceMode::Auto => ToolChoice::Auto,
CompletionToolChoiceMode::None => ToolChoice::None,
CompletionToolChoiceMode::Required => ToolChoice::Required,
CompletionToolChoiceMode::Named => ToolChoice::named(tc.tool_name.unwrap_or_default()),
});
// Build the LLM request
@ -1431,18 +1426,18 @@ async fn create_completion(
)]);
return Sse::new(sse_stream).into_response();
}
let empty_msg = fabro_api_types::CompletionMessage {
role: fabro_api_types::CompletionMessageRole::Assistant,
let empty_msg = CompletionMessage {
role: CompletionMessageRole::Assistant,
content: vec![],
name: None,
tool_call_id: None,
};
return Json(fabro_api_types::CompletionResponse {
return Json(CompletionResponse {
id: msg_id,
model: model_id,
message: empty_msg,
stop_reason: "end_turn".to_string(),
usage: fabro_api_types::CompletionUsage {
usage: CompletionUsage {
input_tokens: 0,
output_tokens: 0,
},
@ -1528,12 +1523,12 @@ async fn create_completion(
params = params.top_p(top_p);
}
match generate_object(params, schema).await {
Ok(result) => Json(fabro_api_types::CompletionResponse {
Ok(result) => Json(CompletionResponse {
id: msg_id,
model: model_id,
message: convert_llm_message(&result.response.message),
stop_reason: finish_reason_to_api_stop_reason(&result.finish_reason),
usage: fabro_api_types::CompletionUsage {
usage: CompletionUsage {
input_tokens: result.usage.input_tokens,
output_tokens: result.usage.output_tokens,
},
@ -1545,12 +1540,12 @@ async fn create_completion(
}
} else {
match client.complete(&request).await {
Ok(response) => Json(fabro_api_types::CompletionResponse {
Ok(response) => Json(CompletionResponse {
id: response.id,
model: response.model,
message: convert_llm_message(&response.message),
stop_reason: finish_reason_to_api_stop_reason(&response.finish_reason),
usage: fabro_api_types::CompletionUsage {
usage: CompletionUsage {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
},

View file

@ -14,6 +14,12 @@ use fabro_llm::types::{Message as LlmMessage, StreamEvent};
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;
use fabro_api::types::{
AssistantTurn, AssistantTurnKind, CreateSessionRequest, CreateSessionResponse, ModelReference,
PaginatedSessionList, PaginationMeta, SendMessageRequest, SendMessageResponse, SessionDetail,
SessionListItem, SessionTurn, UserTurn, UserTurnKind,
};
use crate::error::ApiError;
use crate::jwt_auth::AuthenticatedService;
use crate::server::{AppState, PaginationParams};
@ -30,7 +36,7 @@ pub struct SessionState {
pub model_id: String,
pub model_provider: Option<String>,
pub system_prompt: Option<String>,
pub turns: Vec<fabro_api_types::SessionTurn>,
pub turns: Vec<SessionTurn>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub event_tx: broadcast::Sender<SessionEvent>,
@ -77,15 +83,13 @@ fn resolve_model(model_arg: Option<String>) -> (String, Option<String>) {
}
}
fn turns_to_messages(turns: &[fabro_api_types::SessionTurn]) -> Vec<LlmMessage> {
fn turns_to_messages(turns: &[SessionTurn]) -> Vec<LlmMessage> {
turns
.iter()
.filter_map(|turn| match turn {
fabro_api_types::SessionTurn::UserTurn(t) => Some(LlmMessage::user(&t.content)),
fabro_api_types::SessionTurn::AssistantTurn(t) => {
Some(LlmMessage::assistant(&t.content))
}
fabro_api_types::SessionTurn::ToolTurn(_) => None,
SessionTurn::UserTurn(t) => Some(LlmMessage::user(&t.content)),
SessionTurn::AssistantTurn(t) => Some(LlmMessage::assistant(&t.content)),
SessionTurn::ToolTurn(_) => None,
})
.collect()
}
@ -125,13 +129,11 @@ fn spawn_generation(store: SessionStore, session_id: uuid::Uuid, dry_run: bool,
if let Some(session) = store.get_mut(&session_id) {
session
.turns
.push(fabro_api_types::SessionTurn::AssistantTurn(
fabro_api_types::AssistantTurn {
kind: fabro_api_types::AssistantTurnKind::Assistant,
content,
created_at: now,
},
));
.push(SessionTurn::AssistantTurn(AssistantTurn {
kind: AssistantTurnKind::Assistant,
content,
created_at: now,
}));
session.updated_at = now;
}
}
@ -193,13 +195,11 @@ fn spawn_generation(store: SessionStore, session_id: uuid::Uuid, dry_run: bool,
if let Some(session) = store.get_mut(&session_id) {
session
.turns
.push(fabro_api_types::SessionTurn::AssistantTurn(
fabro_api_types::AssistantTurn {
kind: fabro_api_types::AssistantTurnKind::Assistant,
content: full_text,
created_at: now,
},
));
.push(SessionTurn::AssistantTurn(AssistantTurn {
kind: AssistantTurnKind::Assistant,
content: full_text,
created_at: now,
}));
session.updated_at = now;
}
}
@ -210,7 +210,7 @@ fn spawn_generation(store: SessionStore, session_id: uuid::Uuid, dry_run: bool,
pub async fn create_session(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Json(req): Json<fabro_api_types::CreateSessionRequest>,
Json(req): Json<CreateSessionRequest>,
) -> Response {
let (model_id, model_provider) = resolve_model(req.model);
let now = chrono::Utc::now();
@ -220,8 +220,8 @@ pub async fn create_session(
let (event_tx, _) = broadcast::channel(256);
let generation_seq = Arc::new(AtomicU64::new(1));
let user_turn = fabro_api_types::SessionTurn::UserTurn(fabro_api_types::UserTurn {
kind: fabro_api_types::UserTurnKind::User,
let user_turn = SessionTurn::UserTurn(UserTurn {
kind: UserTurnKind::User,
content: req.content,
created_at: now,
});
@ -248,10 +248,10 @@ pub async fn create_session(
(
StatusCode::CREATED,
Json(fabro_api_types::CreateSessionResponse {
Json(CreateSessionResponse {
id: session_id,
title,
model: fabro_api_types::ModelReference { id: model_id },
model: ModelReference { id: model_id },
created_at: now,
updated_at: now,
}),
@ -268,10 +268,10 @@ pub async fn retrieve_session(
match store.get(&id) {
Some(session) => (
StatusCode::OK,
Json(fabro_api_types::SessionDetail {
Json(SessionDetail {
id: session.id,
title: session.title.clone(),
model: fabro_api_types::ModelReference {
model: ModelReference {
id: session.model_id.clone(),
},
created_at: session.created_at,
@ -288,20 +288,18 @@ pub async fn send_message(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<uuid::Uuid>,
Json(req): Json<fabro_api_types::SendMessageRequest>,
Json(req): Json<SendMessageRequest>,
) -> Response {
let seq = {
let mut store = state.sessions.write().expect("session store lock poisoned");
match store.get_mut(&id) {
Some(session) => {
let now = chrono::Utc::now();
session.turns.push(fabro_api_types::SessionTurn::UserTurn(
fabro_api_types::UserTurn {
kind: fabro_api_types::UserTurnKind::User,
content: req.content,
created_at: now,
},
));
session.turns.push(SessionTurn::UserTurn(UserTurn {
kind: UserTurnKind::User,
content: req.content,
created_at: now,
}));
session.updated_at = now;
session.generation_seq.fetch_add(1, Ordering::Relaxed) + 1
}
@ -313,7 +311,7 @@ pub async fn send_message(
(
StatusCode::ACCEPTED,
Json(fabro_api_types::SendMessageResponse { accepted: true }),
Json(SendMessageResponse { accepted: true }),
)
.into_response()
}
@ -377,16 +375,16 @@ pub async fn list_sessions(
let limit = pagination.limit.clamp(1, 100) as usize;
let offset = pagination.offset as usize;
let mut items: Vec<fabro_api_types::SessionListItem> = store
let mut items: Vec<SessionListItem> = store
.values()
.map(|session| {
let last_message_preview = session
.turns
.last()
.map(|t| match t {
fabro_api_types::SessionTurn::UserTurn(u) => u.content.clone(),
fabro_api_types::SessionTurn::AssistantTurn(a) => a.content.clone(),
fabro_api_types::SessionTurn::ToolTurn(_) => String::new(),
SessionTurn::UserTurn(u) => u.content.clone(),
SessionTurn::AssistantTurn(a) => a.content.clone(),
SessionTurn::ToolTurn(_) => String::new(),
})
.unwrap_or_default();
let preview = if last_message_preview.len() > 100 {
@ -394,10 +392,10 @@ pub async fn list_sessions(
} else {
last_message_preview
};
fabro_api_types::SessionListItem {
SessionListItem {
id: session.id,
title: session.title.clone(),
model: fabro_api_types::ModelReference {
model: ModelReference {
id: session.model_id.clone(),
},
last_message_preview: preview,
@ -416,9 +414,9 @@ pub async fn list_sessions(
(
StatusCode::OK,
Json(fabro_api_types::PaginatedSessionList {
Json(PaginatedSessionList {
data,
meta: fabro_api_types::PaginationMeta { has_more },
meta: PaginationMeta { has_more },
}),
)
.into_response()