diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 53a4c8e..2cc15e4 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -33,6 +33,7 @@ import ( "github.com/taciturnaxolotl/potluck/internal/money" "github.com/taciturnaxolotl/potluck/internal/pool" "github.com/taciturnaxolotl/potluck/internal/provider" + "github.com/taciturnaxolotl/potluck/internal/provider/registry" "github.com/taciturnaxolotl/potluck/internal/store" "github.com/taciturnaxolotl/potluck/internal/stream" @@ -107,6 +108,24 @@ func main() { log.Info("free provider configured", "base_url", cfg.FreeProvider.BaseURL) } + // Build multi-provider registry from DB. Falls back to a pioneer-only + // registry if the providers table hasn't been migrated yet. + reg, err := registry.LoadFromDB(context.Background(), q) + if err != nil { + log.Warn("failed to load providers from DB, using pioneer-only fallback", "err", err) + configs := []registry.ProviderConfig{ + {ID: "pioneer", Type: registry.TypeOpenAICompat, Name: "Pioneer", BaseURL: cfg.Pioneer.BaseURL}, + } + if cfg.FreeProvider.Enabled() { + freeBase := strings.TrimSuffix(strings.TrimRight(cfg.FreeProvider.BaseURL, "/"), "/v1") + configs = append(configs, registry.ProviderConfig{ + ID: "free", Type: registry.TypeFree, Name: "Free", BaseURL: freeBase, + }) + } + reg = registry.New(configs) + } + log.Info("provider registry loaded", "providers", len(reg.List())) + keyPool, err := pool.New(q, cfg.PoolKeySecret) if err != nil { log.Fatal("pool: init failed", "err", err) @@ -169,6 +188,7 @@ func main() { Pool: keyPool, Provider: pioneer, FreeProvider: freeProvider, + Registry: reg, } v1Srv := &v1.Server{ Q: q, @@ -177,6 +197,7 @@ func main() { Provider: pioneer, Pool: keyPool, FreeProvider: freeProvider, + Registry: reg, } // /api/* — cookie-authenticated, internal surface. diff --git a/server/db/migrations b/server/db/migrations new file mode 120000 index 0000000..e1e5443 --- /dev/null +++ b/server/db/migrations @@ -0,0 +1 @@ +../internal/migrations/files \ No newline at end of file diff --git a/server/db/migrations/00001_init.sql b/server/db/migrations/00001_init.sql deleted file mode 100644 index 6c3e1d4..0000000 --- a/server/db/migrations/00001_init.sql +++ /dev/null @@ -1,119 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- All money columns are int64 micros (1 USD = 1_000_000). Never floats. - -CREATE TABLE users ( - id TEXT PRIMARY KEY, -- uuid - email TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL, -- unix seconds - last_seen_at INTEGER -) STRICT; - -CREATE TABLE sessions ( - id TEXT PRIMARY KEY, -- opaque token (hashed) - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - last_used_at INTEGER NOT NULL -) STRICT; -CREATE INDEX sessions_by_user ON sessions(user_id); - --- Conversations and messages. -CREATE TABLE conversations ( - id TEXT PRIMARY KEY, -- uuid - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - archived_at INTEGER -) STRICT; -CREATE INDEX conversations_by_user ON conversations(user_id, updated_at DESC); - -CREATE TABLE messages ( - id TEXT PRIMARY KEY, -- server uuid - conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, - client_id TEXT, -- idempotency key from client (uuidv7) - role TEXT NOT NULL, -- 'user' | 'assistant' | 'system' | 'tool' - content TEXT NOT NULL DEFAULT '', - model TEXT, -- provider model id (assistant only) - created_at INTEGER NOT NULL, - UNIQUE(conversation_id, client_id) -) STRICT; -CREATE INDEX messages_by_conversation ON messages(conversation_id, created_at); - --- Streams: one per assistant generation. Chunks fan out from a stream. -CREATE TABLE streams ( - id TEXT PRIMARY KEY, -- uuid - conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - assistant_message_id TEXT REFERENCES messages(id), - idempotency_key TEXT NOT NULL, -- client supplies on POST /api/chat - model TEXT NOT NULL, - status TEXT NOT NULL, -- 'pending' | 'running' | 'done' | 'error' | 'canceled' - error_code TEXT, - error_message TEXT, - started_at INTEGER NOT NULL, - finished_at INTEGER, - UNIQUE(user_id, idempotency_key) -) STRICT; -CREATE INDEX streams_by_user ON streams(user_id, started_at DESC); - -CREATE TABLE stream_chunks ( - stream_id TEXT NOT NULL REFERENCES streams(id) ON DELETE CASCADE, - seq INTEGER NOT NULL, -- 1-based monotonic - event TEXT NOT NULL, -- 'delta' | 'usage' | 'error' | 'done' - data TEXT NOT NULL, -- JSON payload - created_at INTEGER NOT NULL, - PRIMARY KEY (stream_id, seq) -) STRICT, WITHOUT ROWID; - --- Ledger. --- contributions: positive amounts. spends: positive amounts charged against a stream. --- Balance = sum(contributions.amount) - sum(spends.amount). -CREATE TABLE contributions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - amount_micros INTEGER NOT NULL CHECK (amount_micros > 0), - note TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL -) STRICT; -CREATE INDEX contributions_by_user ON contributions(user_id, created_at DESC); - -CREATE TABLE spends ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - stream_id TEXT NOT NULL REFERENCES streams(id) ON DELETE CASCADE, - model TEXT NOT NULL, - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - amount_micros INTEGER NOT NULL CHECK (amount_micros >= 0), - is_estimated INTEGER NOT NULL DEFAULT 0, -- 1 if usage chunk missing; reconcile job fixes - created_at INTEGER NOT NULL, - UNIQUE(stream_id) -) STRICT; -CREATE INDEX spends_by_user ON spends(user_id, created_at DESC); - --- Provider model price book. amount_micros_per_1k_tokens, refreshed manually. -CREATE TABLE model_prices ( - model TEXT PRIMARY KEY, - input_micros_per_1k INTEGER NOT NULL, - output_micros_per_1k INTEGER NOT NULL, - updated_at INTEGER NOT NULL -) STRICT; - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP TABLE IF EXISTS model_prices; -DROP TABLE IF EXISTS spends; -DROP TABLE IF EXISTS contributions; -DROP TABLE IF EXISTS stream_chunks; -DROP TABLE IF EXISTS streams; -DROP TABLE IF EXISTS messages; -DROP TABLE IF EXISTS conversations; -DROP TABLE IF EXISTS sessions; -DROP TABLE IF EXISTS users; --- +goose StatementEnd diff --git a/server/db/migrations/00002_api_keys.sql b/server/db/migrations/00002_api_keys.sql deleted file mode 100644 index bc641ec..0000000 --- a/server/db/migrations/00002_api_keys.sql +++ /dev/null @@ -1,58 +0,0 @@ --- +goose Up --- +goose StatementBegin - -CREATE TABLE api_keys ( - id TEXT PRIMARY KEY, -- uuid - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - -- SHA-256 of the full plaintext key. UNIQUE — collision would be a bug. - key_hash TEXT NOT NULL UNIQUE, - -- The mnemonic word from the key, kept in plaintext for display - -- ("my cedar key"). Not a security input. - key_word TEXT NOT NULL, - -- Last 5 chars of the plaintext key (the checksum). Lets the UI mask - -- the entropy while still showing something stable per key. - key_last4 TEXT NOT NULL, - name TEXT NOT NULL DEFAULT '', - -- Optional per-key budget cap, in USD micros. NULL = no per-key cap; - -- the user's own ledger balance still applies on top. - max_budget_micros INTEGER, - -- Cached running total spent under THIS key. Updated alongside the - -- spends row so we can enforce max_budget without a JOIN. - spent_micros INTEGER NOT NULL DEFAULT 0, - last_used_at INTEGER, -- debounced, see AGENTS.md - created_at INTEGER NOT NULL, - revoked_at INTEGER -- soft delete -) STRICT; - -CREATE INDEX api_keys_by_user ON api_keys(user_id, revoked_at); - --- /v1/* idempotency — the body cached against a key for the dedup window. --- The body is the full JSON response (or the first chunk's metadata for --- streams; streams aren't replayed verbatim, just deduped at start). -CREATE TABLE idempotency_keys ( - key TEXT PRIMARY KEY, -- client's Idempotency-Key header - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - api_key_id TEXT REFERENCES api_keys(id) ON DELETE CASCADE, - request_hash TEXT NOT NULL, -- sha256 of canonical request body - status INTEGER NOT NULL, -- HTTP status of the cached response - response_body BLOB NOT NULL, - response_type TEXT NOT NULL, -- 'json' | 'stream-meta' - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL -) STRICT; - -CREATE INDEX idempotency_keys_by_user ON idempotency_keys(user_id, created_at); -CREATE INDEX idempotency_keys_expiry ON idempotency_keys(expires_at); - --- Add api_key_id to spends so /v1/* spend can be attributed back to a key. --- streams (the /api/* surface) don't carry this — they're cookie-auth. -ALTER TABLE spends ADD COLUMN api_key_id TEXT REFERENCES api_keys(id); - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -ALTER TABLE spends DROP COLUMN api_key_id; -DROP TABLE IF EXISTS idempotency_keys; -DROP TABLE IF EXISTS api_keys; --- +goose StatementEnd diff --git a/server/db/migrations/00003_hca.sql b/server/db/migrations/00003_hca.sql deleted file mode 100644 index c6a0ebb..0000000 --- a/server/db/migrations/00003_hca.sql +++ /dev/null @@ -1,28 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- Hack Club Auth integration. Each user can be linked to one HCA identity; --- the link is the source of truth for sign-in. Email is mirrored from HCA --- on each login but isn't the primary key (HCA emails can change). --- --- The unique index is intentionally non-partial: SQLite refuses to use --- partial indexes as the conflict target for `INSERT ... ON CONFLICT(hca_id) --- DO UPDATE`, and SQLite already treats multiple NULLs as distinct under --- a UNIQUE constraint, so pre-HCA rows with NULL `hca_id` coexist fine. -ALTER TABLE users ADD COLUMN hca_id TEXT; -CREATE UNIQUE INDEX users_hca_id ON users(hca_id); - --- Cache the avatar / display fields HCA hands us so the chat UI doesn't --- need a second round-trip on every page load. Refreshed on every login. -ALTER TABLE users ADD COLUMN slack_id TEXT; -ALTER TABLE users ADD COLUMN verification_status TEXT; - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP INDEX IF EXISTS users_hca_id; -ALTER TABLE users DROP COLUMN verification_status; -ALTER TABLE users DROP COLUMN slack_id; -ALTER TABLE users DROP COLUMN hca_id; --- +goose StatementEnd diff --git a/server/db/migrations/00004_session_meta.sql b/server/db/migrations/00004_session_meta.sql deleted file mode 100644 index 83e0a8e..0000000 --- a/server/db/migrations/00004_session_meta.sql +++ /dev/null @@ -1,15 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- Capture the remote IP and User-Agent at session creation so the --- settings page can show "Chrome on macOS · Cambridge, US" per session. -ALTER TABLE sessions ADD COLUMN ip TEXT; -ALTER TABLE sessions ADD COLUMN user_agent TEXT; - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -ALTER TABLE sessions DROP COLUMN user_agent; -ALTER TABLE sessions DROP COLUMN ip; --- +goose StatementEnd diff --git a/server/db/migrations/00005_pool_keys.sql b/server/db/migrations/00005_pool_keys.sql deleted file mode 100644 index 08ab208..0000000 --- a/server/db/migrations/00005_pool_keys.sql +++ /dev/null @@ -1,49 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- pool_keys holds pioneer.ai API keys contributed by users to the shared --- key pool. Any active key is eligible to serve requests. The picker selects --- the key with the least spend today that is still under its daily cap. --- --- daily_limit_micros defaults to $1000 (1_000_000_000 micros) per key. --- 1 USD = 1_000_000 micros, so $1000 = 1_000_000 * 1_000 = 1_000_000_000. --- --- The key itself is stored AES-256-GCM encrypted at rest (server holds the --- key via POTLUCK_POOL_KEY_SECRET). The ciphertext is base64url encoded. --- We store a truncated SHA-256 prefix for dedup checking without decrypting. -CREATE TABLE pool_keys ( - id TEXT PRIMARY KEY, -- uuid - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - -- Display name for this key. Usually something like "my pioneer key". - label TEXT NOT NULL DEFAULT '', - -- AES-256-GCM encrypted pioneer API key. base64url(nonce || ciphertext). - -- Decrypted only at request time inside the pool picker. - key_ciphertext TEXT NOT NULL, - -- First 16 hex chars of SHA-256(plaintext) — dedup without decrypting. - key_fingerprint TEXT NOT NULL UNIQUE, - -- Whether this key is currently active in the pool. - active INTEGER NOT NULL DEFAULT 1, -- bool - -- Daily spend limit in USD micros. Default $1000/day. - daily_limit_micros INTEGER NOT NULL DEFAULT 1000000000, - -- Running total for today (UTC). Reset logic is in the picker: if - -- today_date != current UTC date, reset to 0 and update today_date. - -- This is soft state; exact accounting still lives in spends. - today_date INTEGER NOT NULL DEFAULT 0, -- unix day (ts / 86400) - today_micros INTEGER NOT NULL DEFAULT 0, -- micros spent today - -- All-time cumulative spend under this key. - total_micros INTEGER NOT NULL DEFAULT 0, - -- Request count (not stream count — one stream = one request to pioneer). - request_count INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - last_used_at INTEGER -) STRICT; - -CREATE INDEX pool_keys_by_user ON pool_keys(user_id); -CREATE INDEX pool_keys_active ON pool_keys(active, today_micros); - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP TABLE IF EXISTS pool_keys; --- +goose StatementEnd diff --git a/server/db/migrations/00006_pool_keys_v2.sql b/server/db/migrations/00006_pool_keys_v2.sql deleted file mode 100644 index 34f9d35..0000000 --- a/server/db/migrations/00006_pool_keys_v2.sql +++ /dev/null @@ -1,140 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- Extend pool_keys with two-budget model, pioneer health tracking, and --- pending-validation support. All changes are additive; existing rows --- and queries keep working unchanged until the follow-up migration drops --- daily_limit_micros. --- --- Two-budget model: --- max_micros = owner's daily ceiling (replaces daily_limit_micros) --- shared_micros = portion donated to the shared pool (≤ max_micros) --- private = max_micros - shared_micros (reserved for owner only) --- --- Pioneer health: --- pioneer_health = 'unknown' | 'healthy' | 'unauthorized' --- A 401 from pioneer means exhausted OR revoked — we can't tell which. --- Mark unauthorized, retry daily. After 14 days consecutive, set revoked_at. --- A 503 from pioneer means their auth service is down — don't touch health. --- --- Only pro-plan keys are accepted (credit_limit = 100,000 credits = $1000/day). - -ALTER TABLE pool_keys ADD COLUMN max_micros INTEGER NOT NULL DEFAULT 1000000000; -ALTER TABLE pool_keys ADD COLUMN shared_micros INTEGER NOT NULL DEFAULT 1000000000; -ALTER TABLE pool_keys ADD COLUMN pioneer_team_id TEXT; -ALTER TABLE pool_keys ADD COLUMN pioneer_payment_plan TEXT; -ALTER TABLE pool_keys ADD COLUMN pioneer_credit_limit_micros INTEGER; -ALTER TABLE pool_keys ADD COLUMN pioneer_remaining_micros INTEGER; -ALTER TABLE pool_keys ADD COLUMN pioneer_health INTEGER NOT NULL DEFAULT 0; - -- 0=unknown 1=healthy 2=unauthorized -ALTER TABLE pool_keys ADD COLUMN pioneer_unhealthy_since INTEGER; -ALTER TABLE pool_keys ADD COLUMN pending_validation INTEGER NOT NULL DEFAULT 0; -ALTER TABLE pool_keys ADD COLUMN last_billing_sync_at INTEGER; -ALTER TABLE pool_keys ADD COLUMN revoked_at INTEGER; - --- Backfill: seed max/shared from the old daily_limit_micros. -UPDATE pool_keys SET - max_micros = daily_limit_micros, - shared_micros = daily_limit_micros; - --- potluck_requests: one row per chat completion we proxy, written at --- request start and updated at finish. The reconciler matches pioneer --- billing rows against this table to attribute cost to the right user. -CREATE TABLE potluck_requests ( - id TEXT PRIMARY KEY, -- our uuid - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - api_key_id TEXT REFERENCES api_keys(id) ON DELETE SET NULL, - pool_key_id TEXT REFERENCES pool_keys(id) ON DELETE SET NULL, - surface TEXT NOT NULL, -- 'web' | 'v1' - model TEXT NOT NULL, - started_at INTEGER NOT NULL, -- unix seconds - finished_at INTEGER, -- NULL while in flight - prompt_tokens INTEGER, - completion_tokens INTEGER, - total_tokens INTEGER, - status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'done' | 'error' | 'canceled' - error_code TEXT -) STRICT; -CREATE INDEX potluck_requests_by_user ON potluck_requests(user_id, started_at DESC); -CREATE INDEX potluck_requests_by_key ON potluck_requests(pool_key_id, finished_at); - --- pool_key_billing_rows: one row per pioneer billing log entry ingested. --- Idempotent on pioneer's id. Attribution tracks which potluck user --- caused the spend (NULL = key owner, for off-platform or judge fallback). -CREATE TABLE pool_key_billing_rows ( - id TEXT PRIMARY KEY, -- pioneer's billing row UUID - pool_key_id TEXT NOT NULL REFERENCES pool_keys(id) ON DELETE CASCADE, - pioneer_created_at INTEGER NOT NULL, -- unix seconds - credit_micros INTEGER NOT NULL, -- credit_usage * 10000 - cost_micros INTEGER NOT NULL, -- cost_usd * 1000000 - token_usage INTEGER NOT NULL, - model TEXT NOT NULL, - endpoint TEXT NOT NULL, - attributed_user_id TEXT REFERENCES users(id), -- NULL = key owner - -- attribution: 0=matched 1=judge_paired 2=owner_fallback 3=duplicate - attribution INTEGER NOT NULL, - is_duplicate INTEGER NOT NULL DEFAULT 0, -- 1 when attribution=3 - matched_request_id TEXT REFERENCES potluck_requests(id), - ingested_at INTEGER NOT NULL -) STRICT; -CREATE INDEX pool_key_billing_by_key ON pool_key_billing_rows(pool_key_id, pioneer_created_at DESC); -CREATE INDEX pool_key_billing_by_user ON pool_key_billing_rows(attributed_user_id, pioneer_created_at DESC); - --- user_daily_spend: per-user-per-UTC-day spend split into shared vs --- private. Updated by the reconciler in the same transaction as --- pool_key_billing_rows inserts. --- day = unix_seconds / 86400. -CREATE TABLE user_daily_spend ( - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - day INTEGER NOT NULL, - shared_spent_micros INTEGER NOT NULL DEFAULT 0, - private_spent_micros INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (user_id, day) -) STRICT, WITHOUT ROWID; - --- user_daily_allowances: how much of the shared pool each user may --- spend today. Set by the recompute button. Allowances only ever grow --- on a recompute (no claw-back). If no row exists for today, the gate --- falls back to a fair-share calculation on the fly. -CREATE TABLE user_daily_allowances ( - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - day INTEGER NOT NULL, - shared_allowance_micros INTEGER NOT NULL, - set_at INTEGER NOT NULL, - set_by_user_id TEXT NOT NULL REFERENCES users(id), - PRIMARY KEY (user_id, day) -) STRICT, WITHOUT ROWID; - --- models_catalog: cache of /v1/models + /base-models, refreshed hourly. --- Replaces the model_prices table for display purposes. --- input/output_price_per_million_micros are from /base-models — display --- only, not used for billing math. -CREATE TABLE models_catalog ( - id TEXT PRIMARY KEY, - label TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - context_window INTEGER, - max_output_tokens INTEGER, - is_chat INTEGER NOT NULL DEFAULT 1, - tier TEXT, - input_price_per_million_micros INTEGER, - output_price_per_million_micros INTEGER, - raw_json TEXT NOT NULL DEFAULT '{}', - refreshed_at INTEGER NOT NULL -) STRICT; - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP TABLE IF EXISTS models_catalog; -DROP TABLE IF EXISTS user_daily_allowances; -DROP TABLE IF EXISTS user_daily_spend; -DROP TABLE IF EXISTS pool_key_billing_rows; -DROP TABLE IF EXISTS potluck_requests; - --- SQLite doesn't support DROP COLUMN before 3.35; the columns added via --- ALTER TABLE are left in place on rollback. Rolling back to 00005 and --- forward again is the safe path if you need a clean slate. - --- +goose StatementEnd diff --git a/server/db/migrations/00007_custom_display_name.sql b/server/db/migrations/00007_custom_display_name.sql deleted file mode 100644 index 3549f7e..0000000 --- a/server/db/migrations/00007_custom_display_name.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- Tracks whether the user has manually set their display name. --- When true, syncCachetName skips the update so the custom name survives logins. -ALTER TABLE users ADD COLUMN custom_display_name INTEGER NOT NULL DEFAULT 0; - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -ALTER TABLE users DROP COLUMN custom_display_name; --- +goose StatementEnd diff --git a/server/db/migrations/00008_smart_allocations.sql b/server/db/migrations/00008_smart_allocations.sql deleted file mode 100644 index 2b25779..0000000 --- a/server/db/migrations/00008_smart_allocations.sql +++ /dev/null @@ -1,27 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- Smart allocation breakdown. --- shared_allowance_micros stays as the total (floor + bonus) for the gate --- middleware and for backward compat with existing callers. --- The new columns let the UI render WHY the allowance is what it is. --- --- floor_micros = guaranteed share, max(fair_share, spent_today) --- bonus_micros = historical-prediction bonus (redistributed surplus) --- predicted_total_micros = expected total spend today based on history --- history_days_used = how many of the last 30 days had any spend (0 = no signal) - -ALTER TABLE user_daily_allowances ADD COLUMN floor_micros INTEGER NOT NULL DEFAULT 0; -ALTER TABLE user_daily_allowances ADD COLUMN bonus_micros INTEGER NOT NULL DEFAULT 0; -ALTER TABLE user_daily_allowances ADD COLUMN predicted_total_micros INTEGER NOT NULL DEFAULT 0; -ALTER TABLE user_daily_allowances ADD COLUMN history_days_used INTEGER NOT NULL DEFAULT 0; - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -ALTER TABLE user_daily_allowances DROP COLUMN history_days_used; -ALTER TABLE user_daily_allowances DROP COLUMN predicted_total_micros; -ALTER TABLE user_daily_allowances DROP COLUMN bonus_micros; -ALTER TABLE user_daily_allowances DROP COLUMN floor_micros; --- +goose StatementEnd diff --git a/server/db/migrations/00009_admin_waitlist.sql b/server/db/migrations/00009_admin_waitlist.sql deleted file mode 100644 index dca1a43..0000000 --- a/server/db/migrations/00009_admin_waitlist.sql +++ /dev/null @@ -1,18 +0,0 @@ --- +goose Up --- +goose StatementBegin - -ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0; -ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; - --- Seed the first admin by display name (best-effort; no-op if not yet signed in). -UPDATE users SET is_admin = 1 WHERE display_name = 'Kieran Klukas'; - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin - -ALTER TABLE users DROP COLUMN status; -ALTER TABLE users DROP COLUMN is_admin; - --- +goose StatementEnd diff --git a/server/db/migrations/00010_user_memory.sql b/server/db/migrations/00010_user_memory.sql deleted file mode 100644 index 01eea61..0000000 --- a/server/db/migrations/00010_user_memory.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- Single-row per-user memory store. Keys are freeform strings; values are --- text. The model reads these at prompt time and writes to them via the --- set_memory tool. The UI also lets users edit directly. -CREATE TABLE user_memory ( - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - key TEXT NOT NULL, - value TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()), - PRIMARY KEY (user_id, key) -); - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP TABLE IF EXISTS user_memory; --- +goose StatementEnd diff --git a/server/db/queries/pool_keys_multi_provider.sql b/server/db/queries/pool_keys_multi_provider.sql new file mode 100644 index 0000000..a030e12 --- /dev/null +++ b/server/db/queries/pool_keys_multi_provider.sql @@ -0,0 +1,45 @@ +-- Multi-provider pool key queries. +-- These extend the v2 queries with provider_id filtering. + +-- name: PickPoolKeyForProvider :one +-- Best active healthy key for a specific provider. +SELECT * FROM pool_keys +WHERE active = 1 + AND revoked_at IS NULL + AND pending_validation = 0 + AND provider_id = ? + AND pioneer_health = 1 + AND (pioneer_remaining_micros IS NULL OR pioneer_remaining_micros > 10000000) + AND today_micros < max_micros +ORDER BY today_micros ASC, RANDOM() +LIMIT 1; + +-- name: PickOwnKeyForProvider :one +-- User's own key with private budget for a specific provider. +SELECT * FROM pool_keys +WHERE user_id = ? + AND active = 1 + AND revoked_at IS NULL + AND pending_validation = 0 + AND provider_id = ? + AND pioneer_health = 1 + AND (pioneer_remaining_micros IS NULL OR pioneer_remaining_micros > 10000000) + AND today_micros < max_micros + AND max_micros > shared_micros +ORDER BY (max_micros - max(today_micros, shared_micros)) DESC, RANDOM() +LIMIT 1; + +-- name: HasHealthyKeyForProvider :one +-- Check if there's at least one healthy key for a provider. +SELECT 1 FROM pool_keys +WHERE active = 1 + AND revoked_at IS NULL + AND pending_validation = 0 + AND provider_id = ? + AND pioneer_health = 1 + AND (pioneer_remaining_micros IS NULL OR pioneer_remaining_micros > 10000000) + AND today_micros < max_micros +LIMIT 1; + +-- name: ListKeysByProvider :many +SELECT * FROM pool_keys WHERE provider_id = ? ORDER BY created_at DESC; diff --git a/server/db/queries/providers.sql b/server/db/queries/providers.sql new file mode 100644 index 0000000..ca8e614 --- /dev/null +++ b/server/db/queries/providers.sql @@ -0,0 +1,23 @@ +-- Provider registry queries for multi-provider support. + +-- name: ListActiveProviders :many +SELECT * FROM providers WHERE active = 1 ORDER BY id; + +-- name: GetProvider :one +SELECT * FROM providers WHERE id = ?; + +-- name: CreateProvider :exec +INSERT INTO providers (id, type, name, base_url, config_json, active, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?); + +-- name: UpdateProvider :exec +UPDATE providers SET + type = ?, + name = ?, + base_url = ?, + config_json = ?, + active = ? +WHERE id = ?; + +-- name: DeleteProvider :exec +DELETE FROM providers WHERE id = ?; diff --git a/server/db/queries/streams.sql b/server/db/queries/streams.sql index 452f0d6..d8bee3c 100644 --- a/server/db/queries/streams.sql +++ b/server/db/queries/streams.sql @@ -31,3 +31,12 @@ ORDER BY seq ASC; -- name: MaxStreamChunkSeq :one SELECT COALESCE(MAX(seq), 0) FROM stream_chunks WHERE stream_id = ?; + +-- name: GetRunningStreamForConversation :one +-- Returns the most-recently-started stream for a conversation that is +-- currently running. Used by handleConversationEvents to bootstrap +-- observers who connect after the stream has already started. +SELECT * FROM streams +WHERE conversation_id = ? AND status = 'running' +ORDER BY started_at DESC +LIMIT 1; diff --git a/server/go.mod b/server/go.mod index 9779da9..e0b9d6a 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,8 +1,9 @@ module github.com/taciturnaxolotl/potluck -go 1.26.2 +go 1.26.4 require ( + charm.land/fantasy v0.31.0 charm.land/log/v2 v2.0.0 github.com/caarlos0/env/v11 v11.4.1 github.com/go-chi/chi/v5 v5.2.5 @@ -16,16 +17,56 @@ require ( require ( charm.land/lipgloss/v2 v2.0.1 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect + github.com/aws/smithy-go v1.26.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 // indirect + github.com/charmbracelet/x/exp/strings v0.1.0 // indirect + github.com/charmbracelet/x/json v0.2.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/kaptinlin/go-i18n v0.4.9 // indirect + github.com/kaptinlin/jsonpointer v0.4.25 // indirect + github.com/kaptinlin/jsonschema v0.7.15 // indirect + github.com/kaptinlin/messageformat-go v0.6.4 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect @@ -35,11 +76,29 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/api v0.282.0 // indirect + google.golang.org/genai v1.58.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/server/go.sum b/server/go.sum index 5379a78..edcd243 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,15 +1,67 @@ +charm.land/fantasy v0.31.0 h1:ioLVRi7A8lZXR8mrCIeseuCcq0KqAak46revmGumnpc= +charm.land/fantasy v0.31.0/go.mod h1:lAE2gO68SrB1S5TrW5g0TRoxz9V+qJcg0Elx/uPWsDI= charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94= +github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= +github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 h1:BW/sZtyd1JyYy0h5adMm3tzpNyL857LWjuTRET6OhpY= +github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E= github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= +github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= +github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= +github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ= +github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= @@ -20,22 +72,64 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA= +github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kaptinlin/go-i18n v0.4.9 h1:kxYNfExb1GG241BIPnQaD3YOvWkP7poHwfj8ctwIpkw= +github.com/kaptinlin/go-i18n v0.4.9/go.mod h1:15vHHYLhwo1stIdztJatujSttIY6sRFt+7v8K1ik1AA= +github.com/kaptinlin/jsonpointer v0.4.25 h1:iJ197e8n+WwqaqBsa53FqG3rPJCg5oijyFXEXNWWC3E= +github.com/kaptinlin/jsonpointer v0.4.25/go.mod h1:wVOBaXGGnP42YsMb6zev/3W5POTvspdNfh8DXzf8XS8= +github.com/kaptinlin/jsonschema v0.7.15 h1:5v9TBnGOm/DV4EEsC13dfR0UXid/3OcR7yAnpRlruE0= +github.com/kaptinlin/jsonschema v0.7.15/go.mod h1:5njr9isKoP9FnqZrFV1Iygx7QqjN3SBZ/AkgBl4Elug= +github.com/kaptinlin/messageformat-go v0.6.4 h1:6nC70fsqEn2xxg/Xoby2+Dk2r77kvxa3QNnYL/hsNcM= +github.com/kaptinlin/messageformat-go v0.6.4/go.mod h1:553UGZ1x5jmGtyH4pQKYwLGMyPm71deCoZICjq1DtR8= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= @@ -48,8 +142,12 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -60,24 +158,74 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I= +google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genai v1.58.0 h1:MNA3ZkRyr7MnRwZ9RNZ60p4+UMKV3yYRw6pyHq4pp0U= +google.golang.org/genai v1.58.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE= +google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/server/internal/api/v1/chat.go b/server/internal/api/v1/chat.go index a32cf23..d656b0d 100644 --- a/server/internal/api/v1/chat.go +++ b/server/internal/api/v1/chat.go @@ -1,7 +1,6 @@ package v1 import ( - "bytes" "context" "database/sql" "encoding/json" @@ -11,21 +10,20 @@ import ( "strings" "time" - charmlog "charm.land/log/v2" + "charm.land/fantasy" "github.com/google/uuid" apimw "github.com/taciturnaxolotl/potluck/internal/api/middleware" + fadapter "github.com/taciturnaxolotl/potluck/internal/provider/fantasy" "github.com/taciturnaxolotl/potluck/internal/pool" - "github.com/taciturnaxolotl/potluck/internal/provider" "github.com/taciturnaxolotl/potluck/internal/store" ) -// handleChatCompletions proxies POST /v1/chat/completions. +// handleChatCompletions proxies POST /v1/chat/completions through fantasy. // -// Models prefixed with "free/" are routed to the self-hosted free provider -// (when configured) and bypass the shared pool gate entirely — no pool key is -// consumed and no spend is recorded. All other models go through the existing -// pool-gated Pioneer path. +// Models prefixed with "provider_id/" are routed to that provider. +// Bare model names default to pioneer. Models prefixed "free/" bypass +// the pool gate entirely. func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 8<<20)) if err != nil { @@ -34,82 +32,110 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - var probe struct { - Stream bool `json:"stream"` - Model string `json:"model"` - } - if err := json.Unmarshal(body, &probe); err != nil { + var oaiReq oaiChatRequest + if err := json.Unmarshal(body, &oaiReq); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "request body is not valid JSON") return } - - // Route free/ models to the self-hosted provider, bypassing the pool gate. - if s.FreeProvider != nil && strings.HasPrefix(probe.Model, "free/") { - // Strip the "free/" prefix before forwarding — the upstream doesn't know it. - upstreamModel := strings.TrimPrefix(probe.Model, "free/") - body = rewriteModelInBody(body, upstreamModel) - if probe.Stream { - s.streamCompletionFree(w, r, body) - } else { - s.bufferedCompletionFree(w, r, body) - } + if oaiReq.Model == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "model is required") return } - // Paid path: enforce pool gate before picking a key. - u, _ := apimw.UserFromContext(r.Context()) - if gr := apimw.CheckPoolGate(r.Context(), s.Q, s.Pool.HasHealthyKey, u); gr != nil { - writeError(w, gr.Status, gr.Code, gr.Message) - return + // Resolve provider and upstream model name. + isFree := strings.HasPrefix(oaiReq.Model, "free/") + var providerID, upstreamModel string + if isFree { + providerID = "free" + upstreamModel = strings.TrimPrefix(oaiReq.Model, "free/") + } else { + providerID, upstreamModel = s.Registry.ResolveModel(oaiReq.Model) } - if probe.Stream { - s.streamCompletion(w, r, body, probe.Model) + // Paid path: enforce pool gate before picking a key. + if !isFree { + u, _ := apimw.UserFromContext(r.Context()) + if gr := apimw.CheckPoolGate(r.Context(), s.Q, s.Pool.HasHealthyKey, u); gr != nil { + writeError(w, gr.Status, gr.Code, gr.Message) + return + } + } else if _, ok := s.Registry.Get("free"); !ok { + writeError(w, http.StatusBadRequest, "invalid_request", "free provider not configured") return } - s.bufferedCompletion(w, r, body, probe.Model) -} -// rewriteModelInBody replaces the "model" field in a raw JSON chat-completion -// body. Returns the original body unchanged on any error. -func rewriteModelInBody(body []byte, model string) []byte { - var req map[string]json.RawMessage - if err := json.Unmarshal(body, &req); err != nil { - return body + // Pick pool key and construct fantasy provider. + var sel *pool.Selection + var apiKey string + var reqID string + u, _ := apimw.UserFromContext(r.Context()) + apiKeyObj, _ := apimw.APIKeyFromContext(r.Context()) + + if !isFree { + var userID string + if u != nil { + userID = u.ID + } + sel, err = s.Pool.PickForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "no_pool_keys", "no active pool keys available") + return + } + apiKey = sel.APIKey() + + // Write request log row. + reqID = uuid.NewString() + poolKeyID := sql.NullString{String: sel.KeyID(), Valid: sel.KeyID() != ""} + apiKeyID := sql.NullString{} + if apiKeyObj != nil { + apiKeyID = sql.NullString{String: apiKeyObj.ID, Valid: true} + } + if u != nil { + _, _ = s.Q.CreatePotluckRequest(r.Context(), store.CreatePotluckRequestParams{ + ID: reqID, + UserID: u.ID, + ApiKeyID: apiKeyID, + PoolKeyID: poolKeyID, + Surface: "v1", + Model: oaiReq.Model, + StartedAt: time.Now().Unix(), + }) + } } - modelJSON, err := json.Marshal(model) + + fp, err := s.Registry.ToFantasy(providerID, apiKey) if err != nil { - return body + writeError(w, http.StatusInternalServerError, "provider_config", err.Error()) + return } - req["model"] = modelJSON - out, err := json.Marshal(req) + lm, err := fp.LanguageModel(r.Context(), upstreamModel) if err != nil { - return body + writeError(w, http.StatusInternalServerError, "provider_config", err.Error()) + return } - return out -} -// streamCompletionFree forwards a streaming request to the free provider. -// No pool key is selected and no spend is recorded. -func (s *Server) streamCompletionFree(w http.ResponseWriter, r *http.Request, body []byte) { - var req map[string]json.RawMessage - if err := json.Unmarshal(body, &req); err != nil { + // Translate OpenAI request → fantasy.Call. + call, err := translateOAICall(oaiReq) + if err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } - req["stream"] = json.RawMessage(`true`) - if _, ok := req["stream_options"]; !ok { - req["stream_options"] = json.RawMessage(`{"include_usage":true}`) - } - patchedBody, err := json.Marshal(req) - if err != nil { - writeError(w, http.StatusInternalServerError, "server_error", err.Error()) - return + + if oaiReq.Stream { + s.streamCompletion(w, r, lm, call, upstreamModel, oaiReq.StreamOptions.IncludeUsage, sel, reqID, u) + } else { + s.bufferedCompletion(w, r, lm, call, sel, reqID, u) } +} - chunks, errs, err := s.FreeProvider.StreamChatRaw(r.Context(), patchedBody) +// streamCompletion handles streaming chat completions via fantasy. +func (s *Server) streamCompletion(w http.ResponseWriter, r *http.Request, lm fantasy.LanguageModel, call fantasy.Call, model string, includeUsage bool, sel *pool.Selection, reqID string, u *store.User) { + streamResp, err := lm.Stream(r.Context(), call) if err != nil { writeError(w, http.StatusBadGateway, "provider_down", err.Error()) + if u != nil && reqID != "" { + go finishRequest(s.Q, reqID, 0, 0, 0, "error") + } return } @@ -119,317 +145,101 @@ func (s *Server) streamCompletionFree(w http.ResponseWriter, r *http.Request, bo w.WriteHeader(http.StatusOK) flusher, _ := w.(http.Flusher) - for { - select { - case <-r.Context().Done(): - return - case ch, ok := <-chunks: - if !ok { - _, _ = w.Write([]byte("data: [DONE]\n\n")) - return - } - if ch.Done { - _, _ = w.Write([]byte("data: [DONE]\n\n")) - if flusher != nil { - flusher.Flush() - } - return + adapter := fadapter.NewV1Adapter(model, includeUsage) + var accUsage fadapter.AccumulatedUsage + + for part := range streamResp { + accUsage.Add(part) + + if part.Type == fantasy.StreamPartTypeError && part.Error != nil { + fadapter.WriteError(w, part.Error.Error(), "provider_error") + if u != nil && reqID != "" { + go finishRequest(s.Q, reqID, 0, 0, 0, "error") } - _, _ = w.Write([]byte("data: ")) - _, _ = w.Write(ch.Raw) - _, _ = w.Write([]byte("\n\n")) + return + } + + chunks := adapter.Adapt(part) + for _, chunk := range chunks { + _, _ = w.Write(chunk) if flusher != nil { flusher.Flush() } - case e := <-errs: - if e != nil { - errJSON, _ := json.Marshal(map[string]any{ - "error": map[string]any{ - "message": e.Error(), - "type": "server_error", - "code": "provider_error", - }, - }) - _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", errJSON) - if flusher != nil { - flusher.Flush() - } - return - } } } -} -// bufferedCompletionFree forwards a non-streaming request to the free provider. -// No pool key is selected and no spend is recorded. -func (s *Server) bufferedCompletionFree(w http.ResponseWriter, r *http.Request, body []byte) { - req, err := http.NewRequestWithContext(r.Context(), - http.MethodPost, s.FreeProvider.BaseURL+"/v1/chat/completions", bytes.NewReader(body)) - if err != nil { - writeError(w, http.StatusInternalServerError, "server_error", err.Error()) - return + // Send usage chunk if include_usage was requested. + if includeUsage { + if usageData := adapter.UsageChunk(accUsage); usageData != nil { + _, _ = w.Write(usageData) + if flusher != nil { + flusher.Flush() + } + } } - req.Header.Set("Content-Type", "application/json") - resp, err := s.FreeProvider.HTTP.Do(req) - if err != nil { - writeError(w, http.StatusBadGateway, "provider_down", err.Error()) - return + _, _ = w.Write([]byte("data: [DONE]\n\n")) + if flusher != nil { + flusher.Flush() } - defer resp.Body.Close() - respBody, _ := io.ReadAll(resp.Body) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(resp.StatusCode) - _, _ = w.Write(respBody) + go settle(s.Q, s.Pool, sel, reqID, accUsage, u) } -// bufferedCompletion handles a non-streaming chat completion. -func (s *Server) bufferedCompletion(w http.ResponseWriter, r *http.Request, body []byte, model string) { - u, _ := apimw.UserFromContext(r.Context()) - apiKey, _ := apimw.APIKeyFromContext(r.Context()) - - var userID string - if u != nil { - userID = u.ID - } - sel, err := s.Pool.PickForUser(r.Context(), userID) - if err != nil { - writeError(w, http.StatusServiceUnavailable, "no_pool_keys", "no active pool keys available") - return - } - - // Write request log row immediately. - reqID := uuid.NewString() - poolKeyID := sql.NullString{String: sel.KeyID(), Valid: sel.KeyID() != ""} - apiKeyID := sql.NullString{} - if apiKey != nil { - apiKeyID = sql.NullString{String: apiKey.ID, Valid: true} - } - startedAt := time.Now().Unix() - if u != nil { - _, _ = s.Q.CreatePotluckRequest(r.Context(), store.CreatePotluckRequestParams{ - ID: reqID, - UserID: u.ID, - ApiKeyID: apiKeyID, - PoolKeyID: poolKeyID, - Surface: "v1", - Model: model, - StartedAt: startedAt, - }) - } - - req, err := http.NewRequestWithContext(r.Context(), - http.MethodPost, s.Provider.BaseURL+"/v1/chat/completions", bytes.NewReader(body)) - if err != nil { - writeError(w, http.StatusInternalServerError, "server_error", err.Error()) - return - } - req.Header.Set("Authorization", "Bearer "+sel.APIKey()) - req.Header.Set("Content-Type", "application/json") - - resp, err := s.Provider.HTTP.Do(req) +// bufferedCompletion handles non-streaming chat completions via fantasy. +func (s *Server) bufferedCompletion(w http.ResponseWriter, r *http.Request, lm fantasy.LanguageModel, call fantasy.Call, sel *pool.Selection, reqID string, u *store.User) { + resp, err := lm.Generate(r.Context(), call) if err != nil { writeError(w, http.StatusBadGateway, "provider_down", err.Error()) - if u != nil { + if u != nil && reqID != "" { go finishRequest(s.Q, reqID, 0, 0, 0, "error") } return } - defer resp.Body.Close() - - // Buffer the response so we can parse usage before returning. - respBody, _ := io.ReadAll(resp.Body) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(resp.StatusCode) - _, _ = w.Write(respBody) - // Parse usage from the response body and settle asynchronously. - go func() { - var respJSON struct { - Usage *provider.Usage `json:"usage"` + // Build OpenAI-shaped response. + var content string + for _, part := range resp.Content { + if tp, ok := part.(fantasy.TextPart); ok { + content += tp.Text } - _ = json.Unmarshal(respBody, &respJSON) - var prompt, completion, total int64 - if respJSON.Usage != nil { - prompt = int64(respJSON.Usage.PromptTokens) - completion = int64(respJSON.Usage.CompletionTokens) - total = int64(respJSON.Usage.TotalTokens) - } - status := "done" - if resp.StatusCode/100 != 2 { - status = "error" - } - if u != nil { - finishRequest(s.Q, reqID, prompt, completion, total, status) - } - _ = s.Pool.RecordSpend(context.Background(), sel, 0) - }() -} - -// streamCompletion forwards an SSE chat completion straight through. -func (s *Server) streamCompletion(w http.ResponseWriter, r *http.Request, body []byte, model string) { - u, _ := apimw.UserFromContext(r.Context()) - apiKey, _ := apimw.APIKeyFromContext(r.Context()) - - var userID string - if u != nil { - userID = u.ID - } - sel, err := s.Pool.PickForUser(r.Context(), userID) - if err != nil { - writeError(w, http.StatusServiceUnavailable, "no_pool_keys", "no active pool keys available") - return } - // Write request log row immediately. - reqID := uuid.NewString() - poolKeyID := sql.NullString{String: sel.KeyID(), Valid: sel.KeyID() != ""} - apiKeyID := sql.NullString{} - if apiKey != nil { - apiKeyID = sql.NullString{String: apiKey.ID, Valid: true} - } - startedAt := time.Now().Unix() - if u != nil { - _, _ = s.Q.CreatePotluckRequest(r.Context(), store.CreatePotluckRequestParams{ - ID: reqID, - UserID: u.ID, - ApiKeyID: apiKeyID, - PoolKeyID: poolKeyID, - Surface: "v1", - Model: model, - StartedAt: startedAt, - }) + oaiResp := oaiChatResponse{ + ID: fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), + Object: "chat.completion", + Created: time.Now().Unix(), + Model: lm.Model(), + Choices: []oaiResponseChoice{{ + Index: 0, + Message: oaiResponseMessage{ + Role: "assistant", + Content: content, + }, + FinishReason: string(resp.FinishReason), + }}, + Usage: translateFantasyUsage(resp.Usage), } - // Inject stream_options.include_usage into the raw body so we get - // accurate usage for settlement. We forward the body as-is otherwise - // to preserve tool_calls, tool_call_id, and any other fields that - // our typed ChatMessage struct would strip. - var req map[string]json.RawMessage - if err := json.Unmarshal(body, &req); err != nil { - writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) - return - } - req["stream"] = json.RawMessage(`true`) - if _, ok := req["stream_options"]; !ok { - req["stream_options"] = json.RawMessage(`{"include_usage":true}`) - } - patchedBody, err := json.Marshal(req) - if err != nil { - writeError(w, http.StatusInternalServerError, "server_error", err.Error()) - return - } - - pc := &provider.Client{ - BaseURL: s.Provider.BaseURL, - APIKey: sel.APIKey(), - HTTP: s.Provider.HTTP, - } - chunks, errs, err := pc.StreamChatRaw(r.Context(), patchedBody) - if err != nil { - writeError(w, http.StatusBadGateway, "provider_down", err.Error()) - if u != nil { - go finishRequest(s.Q, reqID, 0, 0, 0, "error") - } - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - flusher, _ := w.(http.Flusher) + _ = json.NewEncoder(w).Encode(oaiResp) - var usage *provider.Usage - chunkCount := 0 - for { - select { - case <-r.Context().Done(): - if u != nil { - go finishRequest(s.Q, reqID, 0, 0, 0, "canceled") - } - return - case ch, ok := <-chunks: - if !ok { - // Chunks channel closed — should only happen after errs fires. - // If we get here without an error it means the goroutine exited - // cleanly (sent [DONE] or hit the no-DONE error path above). - _, _ = w.Write([]byte("data: [DONE]\n\n")) - go settle(s.Q, s.Pool, sel, reqID, usage, u) - return - } - if ch.Usage != nil { - usage = ch.Usage - } - if ch.Done { - _, _ = w.Write([]byte("data: [DONE]\n\n")) - if flusher != nil { - flusher.Flush() - } - go settle(s.Q, s.Pool, sel, reqID, usage, u) - return - } - chunkCount++ - _, _ = w.Write([]byte("data: ")) - _, _ = w.Write(ch.Raw) - _, _ = w.Write([]byte("\n\n")) - if flusher != nil { - flusher.Flush() - } - case e := <-errs: - if e != nil { - tokensReceived := 0 - if usage != nil { - tokensReceived = usage.TotalTokens - } - charmlog.Error("stream error from pioneer", - "user_id", func() string { - if u != nil { return u.ID } - return "" - }(), - "model", model, - "pool_key_id", sel.KeyID(), - "req_id", reqID, - "chunks_received", chunkCount, - "tokens_received", tokensReceived, - "err", e, - ) - // Send the error as an SSE event in OpenAI's envelope so the - // client (crush, etc.) can display it properly. We've already - // committed the 200 header so we can't change the status. - errJSON, _ := json.Marshal(map[string]any{ - "error": map[string]any{ - "message": e.Error(), - "type": "server_error", - "code": "provider_error", - }, - }) - _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", errJSON) - if flusher != nil { - flusher.Flush() - } - if u != nil { - go finishRequest(s.Q, reqID, 0, 0, 0, "error") - } - return - } - } - } + go settle(s.Q, s.Pool, sel, reqID, fadapter.AccumulatedUsage{ + InputTokens: resp.Usage.InputTokens, + OutputTokens: resp.Usage.OutputTokens, + TotalTokens: resp.Usage.TotalTokens, + }, u) } // settle fires off the post-stream DB writes. Runs in a goroutine. -func settle(q *store.Queries, poolMgr *pool.Manager, sel *pool.Selection, reqID string, usage *provider.Usage, u *store.User) { - var prompt, completion, total int64 - if usage != nil { - prompt = int64(usage.PromptTokens) - completion = int64(usage.CompletionTokens) - total = int64(usage.TotalTokens) - } +func settle(q *store.Queries, poolMgr *pool.Manager, sel *pool.Selection, reqID string, usage fadapter.AccumulatedUsage, u *store.User) { if u != nil { - finishRequest(q, reqID, prompt, completion, total, "done") + finishRequest(q, reqID, usage.InputTokens, usage.OutputTokens, usage.TotalTokens, "done") + } + if sel != nil { + _ = poolMgr.RecordSpend(context.Background(), sel, 0) } - _ = poolMgr.RecordSpend(context.Background(), sel, 0) } // finishRequest updates the potluck_requests row after the upstream call ends. @@ -444,11 +254,3 @@ func finishRequest(q *store.Queries, reqID string, prompt, completion, total int ID: reqID, }) } - -// asString safely extracts a string from a map[string]any without panicking. -func asString(v any) string { - s, _ := v.(string) - return s -} - - diff --git a/server/internal/api/v1/server.go b/server/internal/api/v1/server.go index 6d68e68..a68ef99 100644 --- a/server/internal/api/v1/server.go +++ b/server/internal/api/v1/server.go @@ -16,6 +16,7 @@ import ( "github.com/taciturnaxolotl/potluck/internal/ledger" "github.com/taciturnaxolotl/potluck/internal/pool" "github.com/taciturnaxolotl/potluck/internal/provider" + "github.com/taciturnaxolotl/potluck/internal/provider/registry" "github.com/taciturnaxolotl/potluck/internal/store" ) @@ -24,9 +25,10 @@ type Server struct { Q *store.Queries Auth *auth.Service Ledger *ledger.Service - Provider *provider.Client + Provider *provider.Client // legacy; being replaced by Registry Pool *pool.Manager FreeProvider *provider.Client // nil when free provider is not configured + Registry *registry.Registry // multi-provider registry (new) } // Mount installs the v1 routes onto r. The caller chains the bearer-auth diff --git a/server/internal/api/v1/translate.go b/server/internal/api/v1/translate.go new file mode 100644 index 0000000..7b342f8 --- /dev/null +++ b/server/internal/api/v1/translate.go @@ -0,0 +1,261 @@ +package v1 + +import ( + "encoding/json" + "fmt" + + "charm.land/fantasy" +) + +// OpenAI request/response types for the v1 API surface. + +type oaiChatRequest struct { + Model string `json:"model"` + Messages []oaiMessage `json:"messages"` + Stream bool `json:"stream"` + StreamOptions *oaiStreamOptions `json:"stream_options,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + MaxTokens *int64 `json:"max_tokens,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + Tools []oaiTool `json:"tools,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` +} + +type oaiStreamOptions struct { + IncludeUsage bool `json:"include_usage"` +} + +type oaiMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` // string or []content_part + Name string `json:"name,omitempty"` + ToolCalls []oaiToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type oaiTool struct { + Type string `json:"type"` + Function oaiFunction `json:"function"` +} + +type oaiFunction struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +type oaiToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function oaiToolFunction `json:"function"` +} + +type oaiToolFunction struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// Response types. + +type oaiChatResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []oaiResponseChoice `json:"choices"` + Usage *oaiUsage `json:"usage,omitempty"` +} + +type oaiResponseChoice struct { + Index int `json:"index"` + Message oaiResponseMessage `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type oaiResponseMessage struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []oaiToolCall `json:"tool_calls,omitempty"` +} + +type oaiUsage struct { + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + PromptTokensDetails *oaiPromptTokensDetails `json:"prompt_tokens_details,omitempty"` + CompletionTokensDetails *oaiCompletionDetails `json:"completion_tokens_details,omitempty"` +} + +type oaiPromptTokensDetails struct { + CachedTokens int64 `json:"cached_tokens"` +} + +type oaiCompletionDetails struct { + ReasoningTokens int64 `json:"reasoning_tokens"` +} + +// translateOAICall converts an OpenAI chat completion request into a fantasy.Call. +func translateOAICall(req oaiChatRequest) (fantasy.Call, error) { + prompt, err := translateMessages(req.Messages) + if err != nil { + return fantasy.Call{}, err + } + + call := fantasy.Call{ + Prompt: prompt, + Temperature: req.Temperature, + TopP: req.TopP, + MaxOutputTokens: req.MaxTokens, + FrequencyPenalty: req.FrequencyPenalty, + PresencePenalty: req.PresencePenalty, + } + + if len(req.Tools) > 0 { + tools := make([]fantasy.Tool, len(req.Tools)) + for i, t := range req.Tools { + tools[i] = fantasy.FunctionTool{ + Name: t.Function.Name, + Description: t.Function.Description, + InputSchema: t.Function.Parameters, + } + } + call.Tools = tools + } + + if len(req.ToolChoice) > 0 { + tc, err := translateToolChoice(req.ToolChoice) + if err != nil { + return fantasy.Call{}, err + } + call.ToolChoice = &tc + } + + return call, nil +} + +func translateMessages(msgs []oaiMessage) ([]fantasy.Message, error) { + out := make([]fantasy.Message, 0, len(msgs)) + for _, m := range msgs { + switch m.Role { + case "system", "developer": + text, err := extractTextContent(m.Content) + if err != nil { + return nil, fmt.Errorf("system message content: %w", err) + } + out = append(out, fantasy.NewSystemMessage(text)) + + case "user": + text, err := extractTextContent(m.Content) + if err != nil { + return nil, fmt.Errorf("user message content: %w", err) + } + out = append(out, fantasy.NewUserMessage(text)) + + case "assistant": + text, _ := extractTextContent(m.Content) + var parts []fantasy.MessagePart + if text != "" { + parts = append(parts, fantasy.TextPart{Text: text}) + } + for _, tc := range m.ToolCalls { + parts = append(parts, fantasy.ToolCallPart{ + ToolCallID: tc.ID, + ToolName: tc.Function.Name, + Input: tc.Function.Arguments, + }) + } + out = append(out, fantasy.Message{ + Role: fantasy.MessageRoleAssistant, + Content: parts, + }) + + case "tool": + text, _ := extractTextContent(m.Content) + out = append(out, fantasy.Message{ + Role: fantasy.MessageRoleTool, + Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ + ToolCallID: m.ToolCallID, + Output: fantasy.ToolResultOutputContentText{Text: text}, + }, + }, + }) + + default: + return nil, fmt.Errorf("unsupported message role: %s", m.Role) + } + } + return out, nil +} + +// extractTextContent handles both string and array content formats. +func extractTextContent(raw json.RawMessage) (string, error) { + if len(raw) == 0 { + return "", nil + } + // Try string first. + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s, nil + } + // Try array of content parts. + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &parts); err == nil { + var text string + for _, p := range parts { + if p.Type == "text" { + text += p.Text + } + } + return text, nil + } + return "", fmt.Errorf("unsupported content format") +} + +func translateToolChoice(raw json.RawMessage) (fantasy.ToolChoice, error) { + // Try string first ("auto", "none", "required"). + var s string + if err := json.Unmarshal(raw, &s); err == nil { + switch s { + case "auto": + return fantasy.ToolChoiceAuto, nil + case "none": + return fantasy.ToolChoiceNone, nil + case "required": + return fantasy.ToolChoiceRequired, nil + default: + return fantasy.ToolChoiceAuto, nil + } + } + // Try object {"type":"function","function":{"name":"..."}}. + var obj struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + } `json:"function"` + } + if err := json.Unmarshal(raw, &obj); err == nil && obj.Function.Name != "" { + return fantasy.SpecificToolChoice(obj.Function.Name), nil + } + return fantasy.ToolChoiceAuto, nil +} + +func translateFantasyUsage(u fantasy.Usage) *oaiUsage { + usage := &oaiUsage{ + PromptTokens: u.InputTokens, + CompletionTokens: u.OutputTokens, + TotalTokens: u.TotalTokens, + } + if u.CacheReadTokens > 0 { + usage.PromptTokensDetails = &oaiPromptTokensDetails{CachedTokens: u.CacheReadTokens} + } + if u.ReasoningTokens > 0 { + usage.CompletionTokensDetails = &oaiCompletionDetails{ReasoningTokens: u.ReasoningTokens} + } + return usage +} diff --git a/server/internal/api/web/chat.go b/server/internal/api/web/chat.go index 00199f7..8a487e9 100644 --- a/server/internal/api/web/chat.go +++ b/server/internal/api/web/chat.go @@ -9,11 +9,12 @@ import ( "strings" "time" + "charm.land/fantasy" "github.com/google/uuid" apimw "github.com/taciturnaxolotl/potluck/internal/api/middleware" + fadapter "github.com/taciturnaxolotl/potluck/internal/provider/fantasy" "github.com/taciturnaxolotl/potluck/internal/pool" - "github.com/taciturnaxolotl/potluck/internal/provider" "github.com/taciturnaxolotl/potluck/internal/store" "github.com/taciturnaxolotl/potluck/internal/stream" "github.com/taciturnaxolotl/potluck/internal/tools" @@ -86,8 +87,9 @@ type chatMsg struct { // When the model invokes tools, the server executes them and re-prompts in a // loop (up to 5 iterations) until finish_reason is "stop" or content-only. // -// Models prefixed "free/" bypass the pool gate; all others go through the -// shared pool and incur normal spend tracking. +// Models prefixed "provider_id/" are routed to that provider via the registry. +// Bare model names default to pioneer. Models prefixed "free/" bypass the pool +// gate and use the free provider. func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { u, _ := currentUser(r) @@ -101,14 +103,22 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { return } + // Resolve provider and upstream model name. isFree := strings.HasPrefix(req.Model, "free/") + var providerID, upstreamModel string + if isFree { + providerID = "free" + upstreamModel = strings.TrimPrefix(req.Model, "free/") + } else { + providerID, upstreamModel = s.Registry.ResolveModel(req.Model) + } if !isFree { if gr := apimw.CheckPoolGate(r.Context(), s.Q, s.Pool.HasHealthyKey, u); gr != nil { writeErr(w, gr.Status, gr.Code, gr.Message) return } - } else if s.FreeProvider == nil { + } else if _, ok := s.Registry.Get("free"); !ok { writeErr(w, 400, "invalid_request", "free provider not configured") return } @@ -199,15 +209,13 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { StartedAt: now, }) - // Pick provider and upstream model name. - upstreamModel := req.Model - var pc *provider.Client + // Pick pool key and construct fantasy provider. var sel *pool.Selection var reqID string + var apiKey string if isFree { - pc = s.FreeProvider - upstreamModel = strings.TrimPrefix(req.Model, "free/") + apiKey = "" // free provider needs no key } else { var err error sel, err = s.Pool.PickForUser(r.Context(), u.ID) @@ -215,11 +223,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { writeErr(w, 503, "no_pool_keys", "no active pool keys available") return } - pc = &provider.Client{ - BaseURL: s.Provider.BaseURL, - APIKey: sel.APIKey(), - HTTP: s.Provider.HTTP, - } + apiKey = sel.APIKey() reqID = uuid.NewString() _, _ = s.Q.CreatePotluckRequest(r.Context(), store.CreatePotluckRequestParams{ ID: reqID, @@ -232,16 +236,37 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { }) } + fp, err := s.Registry.ToFantasy(providerID, apiKey) + if err != nil { + writeErr(w, 500, "provider_config", err.Error()) + return + } + lm, err := fp.LanguageModel(r.Context(), upstreamModel) + if err != nil { + writeErr(w, 500, "provider_config", err.Error()) + return + } + memoryRows, err := s.Q.GetUserMemory(r.Context(), u.ID) if err != nil { writeErr(w, 500, "internal", err.Error()) return } - provMsgs := make([]provider.ChatMessage, 1, len(req.Messages)+1) - provMsgs[0] = provider.ChatMessage{Role: "system", Content: provider.StringContent(systemPrompt(memoryRows))} + // Build fantasy prompt messages. + prompt := []fantasy.Message{ + fantasy.NewSystemMessage(systemPrompt(memoryRows)), + } for _, m := range req.Messages { - provMsgs = append(provMsgs, provider.ChatMessage{Role: m.Role, Content: provider.StringContent(m.Content)}) + switch m.Role { + case "user": + prompt = append(prompt, fantasy.NewUserMessage(m.Content)) + case "assistant": + prompt = append(prompt, fantasy.Message{ + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: m.Content}}, + }) + } } w.Header().Set("Content-Type", "text/event-stream") @@ -251,7 +276,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { flusher, _ := w.(http.Flusher) var full strings.Builder - var usage *provider.Usage + var accUsage fadapter.AccumulatedUsage _ = s.Q.SetStreamStatus(r.Context(), store.SetStreamStatusParams{ Status: "running", @@ -306,11 +331,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { const maxIter = 5 for iter := 0; iter < maxIter; iter++ { - chunks, errs, err := pc.StreamChat(genCtx, provider.ChatRequest{ - Model: upstreamModel, - Messages: provMsgs, - Tools: tools.Definitions(), - }) + call := fantasy.Call{ + Prompt: prompt, + Tools: tools.FantasyDefinitions(), + } + + streamResp, err := lm.Stream(genCtx, call) if err != nil { writeErr(w, 502, "provider_down", err.Error()) if !isFree && reqID != "" { @@ -319,63 +345,63 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { return } - toolCalls := make(map[int]*provider.ToolCall) - var finishReason string + // Accumulate tool calls across the stream. + type accumulatedToolCall struct { + id string + name string + arguments strings.Builder + } + activeTools := make(map[string]*accumulatedToolCall) + var toolOrder []string // preserve insertion order + var finishReason fantasy.FinishReason - streamDone := false - for !streamDone { + for part := range streamResp { + // Check for client disconnect without stopping generation. select { case <-ctxDone: clientGone = true ctxDone = nil - continue - case ch, ok := <-chunks: - if ch.Usage != nil { - usage = ch.Usage - } - if !ok || ch.Done { - streamDone = true - continue - } - if ch.ReasoningDelta != "" { - emit("reasoning", map[string]any{"type": "reasoning", "content": ch.ReasoningDelta}) + default: + } + + accUsage.Add(part) + + switch part.Type { + case fantasy.StreamPartTypeTextDelta: + if part.Delta != "" { + full.WriteString(part.Delta) + emit("delta", map[string]any{"type": "delta", "content": part.Delta}) } - if ch.Delta != "" { - full.WriteString(ch.Delta) - emit("delta", map[string]any{"type": "delta", "content": ch.Delta}) + + case fantasy.StreamPartTypeReasoningDelta: + if part.Delta != "" { + emit("reasoning", map[string]any{"type": "reasoning", "content": part.Delta}) } - for _, tcd := range ch.ToolCalls { - if tcd.Index < 0 { - continue - } - tc, exists := toolCalls[tcd.Index] - if !exists { - tc = &provider.ToolCall{ - ID: tcd.ID, - Type: tcd.Type, - } - toolCalls[tcd.Index] = tc - } - if tcd.ID != "" { - tc.ID = tcd.ID - } - if tc.Type == "" && tcd.Type != "" { - tc.Type = tcd.Type - } - tc.Function.Name += tcd.Function.Name - tc.Function.Arguments += tcd.Function.Arguments + + case fantasy.StreamPartTypeToolInputStart: + tc := &accumulatedToolCall{ + id: part.ID, + name: part.ToolCallName, } - if ch.FinishReason != "" { - finishReason = ch.FinishReason + activeTools[part.ID] = tc + toolOrder = append(toolOrder, part.ID) + + case fantasy.StreamPartTypeToolInputDelta: + if tc, ok := activeTools[part.ID]; ok { + tc.arguments.WriteString(part.ToolCallInput) } - case e := <-errs: - if e != nil { - emit("error", map[string]any{"type": "error", "message": e.Error()}) + + case fantasy.StreamPartTypeFinish: + finishReason = part.FinishReason + + case fantasy.StreamPartTypeError: + if part.Error != nil { + emit("error", map[string]any{"type": "error", "message": part.Error.Error()}) _ = s.Q.SetStreamStatus(genCtx, store.SetStreamStatusParams{ Status: "error", FinishedAt: sql.NullInt64{Int64: time.Now().Unix(), Valid: true}, ErrorCode: sql.NullString{String: "provider_down", Valid: true}, - ErrorMessage: sql.NullString{String: e.Error(), Valid: true}, + ErrorMessage: sql.NullString{String: part.Error.Error(), Valid: true}, ID: streamID, }) if !isFree && reqID != "" { @@ -387,57 +413,69 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } // Tool invocation: execute tools, emit events, extend messages, re-stream. - if finishReason == "tool_calls" && len(toolCalls) > 0 { - for _, tc := range toolCalls { + if finishReason == fantasy.FinishReasonToolCalls && len(activeTools) > 0 { + // Build assistant message with tool calls. + var assistantParts []fantasy.MessagePart + if full.Len() > 0 { + assistantParts = append(assistantParts, fantasy.TextPart{Text: full.String()}) + } + for _, id := range toolOrder { + tc := activeTools[id] emit("tool_call", map[string]any{ "type": "tool_call", - "id": tc.ID, - "name": tc.Function.Name, - "arguments": tc.Function.Arguments, + "id": tc.id, + "name": tc.name, + "arguments": tc.arguments.String(), + }) + assistantParts = append(assistantParts, fantasy.ToolCallPart{ + ToolCallID: tc.id, + ToolName: tc.name, + Input: tc.arguments.String(), }) } - - // Append the assistant message with tool calls to the growing history. - assistantTCs := make([]provider.ToolCall, 0, len(toolCalls)) - for _, tc := range toolCalls { - assistantTCs = append(assistantTCs, *tc) - } - provMsgs = append(provMsgs, provider.ChatMessage{ - Role: "assistant", - Content: nil, - ToolCalls: assistantTCs, + prompt = append(prompt, fantasy.Message{ + Role: fantasy.MessageRoleAssistant, + Content: assistantParts, }) // Execute each tool and append a tool-role message with the result. - for _, tc := range toolCalls { - result, toolErr := tools.Execute(genCtx, s.Q, u.ID, tc.Function.Name, tc.Function.Arguments) + for _, id := range toolOrder { + tc := activeTools[id] + result, toolErr := tools.Execute(genCtx, s.Q, u.ID, tc.name, tc.arguments.String()) if toolErr != nil { result = fmt.Sprintf("error: %v", toolErr) } emit("tool_result", map[string]any{ "type": "tool_result", - "tool_call_id": tc.ID, + "tool_call_id": tc.id, "content": result, }) - provMsgs = append(provMsgs, provider.ChatMessage{ - Role: "tool", - ToolCallID: tc.ID, - Content: provider.StringContent(result), + prompt = append(prompt, fantasy.Message{ + Role: fantasy.MessageRoleTool, + Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ + ToolCallID: tc.id, + Output: fantasy.ToolResultOutputContentText{Text: result}, + }, + }, }) } + + // Reset for next iteration. + full.Reset() continue } // No tool call or final iteration — done. - payload := map[string]any{"type": "done"} - if usage != nil { - payload["usage"] = map[string]any{ - "prompt_tokens": usage.PromptTokens, - "completion_tokens": usage.CompletionTokens, - "total_tokens": usage.TotalTokens, + donePayload := map[string]any{"type": "done"} + if accUsage.HasUsage() { + donePayload["usage"] = map[string]any{ + "prompt_tokens": accUsage.InputTokens, + "completion_tokens": accUsage.OutputTokens, + "total_tokens": accUsage.TotalTokens, } } - emit("done", payload) + emit("done", donePayload) _ = s.Q.SetStreamStatus(genCtx, store.SetStreamStatusParams{ Status: "done", FinishedAt: sql.NullInt64{Int64: time.Now().Unix(), Valid: true}, @@ -445,20 +483,20 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { ErrorMessage: sql.NullString{}, ID: streamID, }) - go s.finalizeChatMsg(convID, assistantMsgID, full.String(), isFree, sel, reqID, usage, isNewConv, firstUserMsg(req.Messages), pc, upstreamModel) + go s.finalizeChatMsg(convID, assistantMsgID, full.String(), isFree, sel, reqID, accUsage, isNewConv, firstUserMsg(req.Messages), fp, upstreamModel) return } // Max iterations reached without content finish. - payload := map[string]any{"type": "done"} - if usage != nil { - payload["usage"] = map[string]any{ - "prompt_tokens": usage.PromptTokens, - "completion_tokens": usage.CompletionTokens, - "total_tokens": usage.TotalTokens, + donePayload := map[string]any{"type": "done"} + if accUsage.HasUsage() { + donePayload["usage"] = map[string]any{ + "prompt_tokens": accUsage.InputTokens, + "completion_tokens": accUsage.OutputTokens, + "total_tokens": accUsage.TotalTokens, } } - emit("done", payload) + emit("done", donePayload) _ = s.Q.SetStreamStatus(genCtx, store.SetStreamStatusParams{ Status: "done", FinishedAt: sql.NullInt64{Int64: time.Now().Unix(), Valid: true}, @@ -466,38 +504,49 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { ErrorMessage: sql.NullString{}, ID: streamID, }) - go s.finalizeChatMsg(convID, assistantMsgID, full.String(), isFree, sel, reqID, usage, isNewConv, firstUserMsg(req.Messages), pc, upstreamModel) + go s.finalizeChatMsg(convID, assistantMsgID, full.String(), isFree, sel, reqID, accUsage, isNewConv, firstUserMsg(req.Messages), fp, upstreamModel) } -func (s *Server) finalizeChatMsg(convID, aID, content string, isFree bool, sel *pool.Selection, reqID string, usage *provider.Usage, isNew bool, userMsg string, pc *provider.Client, model string) { +func (s *Server) finalizeChatMsg(convID, aID, content string, isFree bool, sel *pool.Selection, reqID string, usage fadapter.AccumulatedUsage, isNew bool, userMsg string, fp fantasy.Provider, model string) { ctx := context.Background() _ = s.Q.AppendAssistantContent(ctx, store.AppendAssistantContentParams{Content: content, ID: aID}) _ = s.Q.TouchConversation(ctx, store.TouchConversationParams{UpdatedAt: time.Now().Unix(), ID: convID}) if !isFree && sel != nil { - var p, c, t int64 - if usage != nil { - p, c, t = int64(usage.PromptTokens), int64(usage.CompletionTokens), int64(usage.TotalTokens) - } - finishWebReq(s.Q, reqID, p, c, t, "done") + finishWebReq(s.Q, reqID, usage.InputTokens, usage.OutputTokens, usage.TotalTokens, "done") _ = s.Pool.RecordSpend(ctx, sel, 0) } - if isNew && pc != nil && userMsg != "" { - go s.generateAndPushTitle(convID, userMsg, content, pc, model) + if isNew && userMsg != "" { + go s.generateAndPushTitle(convID, userMsg, content, fp, model) } } -func (s *Server) generateAndPushTitle(convID, userMsg, assistantMsg string, pc *provider.Client, model string) { +func (s *Server) generateAndPushTitle(convID, userMsg, assistantMsg string, fp fantasy.Provider, model string) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + lm, err := fp.LanguageModel(ctx, model) + if err != nil { + return + } + uSnip := truncateTitle(userMsg, 300) aSnip := truncateTitle(assistantMsg, 300) prompt := "Generate a short title (3-7 words, no quotes, no trailing punctuation) for this conversation:\n\nUser: " + uSnip + "\nAssistant: " + aSnip + "\n\nReply with ONLY the title." - title, err := pc.Complete(ctx, model, []provider.ChatMessage{ - {Role: "user", Content: provider.StringContent(prompt)}, + resp, err := lm.Generate(ctx, fantasy.Call{ + Prompt: []fantasy.Message{fantasy.NewUserMessage(prompt)}, }) - if err != nil || title == "" { + if err != nil || len(resp.Content) == 0 { + return + } + + var title string + for _, part := range resp.Content { + if tp, ok := part.(fantasy.TextPart); ok { + title += tp.Text + } + } + if title == "" { return } title = truncateTitle(strings.Trim(title, `"' `), 80) diff --git a/server/internal/api/web/web.go b/server/internal/api/web/web.go index 565bdbc..ed37046 100644 --- a/server/internal/api/web/web.go +++ b/server/internal/api/web/web.go @@ -23,6 +23,7 @@ import ( "github.com/taciturnaxolotl/potluck/internal/ledger" "github.com/taciturnaxolotl/potluck/internal/pool" "github.com/taciturnaxolotl/potluck/internal/provider" + "github.com/taciturnaxolotl/potluck/internal/provider/registry" "github.com/taciturnaxolotl/potluck/internal/store" "github.com/taciturnaxolotl/potluck/internal/stream" ) @@ -34,8 +35,9 @@ type Server struct { Ledger *ledger.Service Hub *stream.Hub Pool *pool.Manager - Provider *provider.Client // Pioneer upstream + Provider *provider.Client // Pioneer upstream (legacy; being replaced by Registry) FreeProvider *provider.Client // self-hosted free endpoint; nil if not configured + Registry *registry.Registry // multi-provider registry (new) } // Mount registers /api/* routes on r. The caller wraps with cookie-auth diff --git a/server/internal/migrations/files/00011_multi_provider.sql b/server/internal/migrations/files/00011_multi_provider.sql new file mode 100644 index 0000000..3c4828b --- /dev/null +++ b/server/internal/migrations/files/00011_multi_provider.sql @@ -0,0 +1,50 @@ +-- +goose Up +-- +goose StatementBegin + +-- Multi-provider support: registry of upstream LLM providers. +-- Each provider has a type (openai_compat, anthropic, google, openrouter, free) +-- that determines which fantasy provider constructor to use and which health +-- check / billing ingest strategy applies. +CREATE TABLE providers ( + id TEXT PRIMARY KEY, -- e.g. 'pioneer', 'openrouter', 'free' + type TEXT NOT NULL, -- 'openai_compat' | 'anthropic' | 'google' | 'openrouter' | 'free' + name TEXT NOT NULL, -- human-readable display name + base_url TEXT NOT NULL, -- upstream API base URL + config_json TEXT NOT NULL DEFAULT '{}', -- provider-specific config (auth header template, etc.) + active INTEGER NOT NULL DEFAULT 1, -- 0 = disabled, skip during key picking + created_at INTEGER NOT NULL -- unix seconds +) STRICT; + +-- Seed the pioneer provider (matches existing PIONEER_BASE_URL default). +INSERT INTO providers (id, type, name, base_url, config_json, active, created_at) +VALUES ('pioneer', 'openai_compat', 'Pioneer', 'https://api.pioneer.ai', '{}', 1, strftime('%s', 'now')); + +-- Add provider_id to pool_keys. SQLite doesn't allow REFERENCES with a +-- non-NULL default in ALTER TABLE, so we add without FK first, backfill, +-- then enforce via application logic (the registry validates provider IDs). +ALTER TABLE pool_keys ADD COLUMN provider_id TEXT NOT NULL DEFAULT 'pioneer'; + +-- Add provider_id to potluck_requests for tracking which provider served each request. +ALTER TABLE potluck_requests ADD COLUMN provider_id TEXT; + +-- Backfill potluck_requests.provider_id from the pool key's provider. +UPDATE potluck_requests SET provider_id = ( + SELECT pk.provider_id FROM pool_keys pk WHERE pk.id = potluck_requests.pool_key_id +) WHERE pool_key_id IS NOT NULL; + +-- Index for provider-scoped queries. +CREATE INDEX pool_keys_by_provider ON pool_keys(provider_id, active); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP INDEX IF EXISTS pool_keys_by_provider; + +-- SQLite doesn't support DROP COLUMN before 3.35; these columns remain on +-- rollback. The providers table is safe to drop since nothing references it +-- after column removal. +DROP TABLE IF EXISTS providers; + +-- +goose StatementEnd diff --git a/server/internal/provider/fantasy/adapter.go b/server/internal/provider/fantasy/adapter.go new file mode 100644 index 0000000..b0b1683 --- /dev/null +++ b/server/internal/provider/fantasy/adapter.go @@ -0,0 +1,33 @@ +// Package fantasy provides adapters between charm.land/fantasy's StreamPart +// type and potluck's two output formats: stream.Event (web UI) and OpenAI SSE +// chunks (v1 API). +package fantasy + +import ( + "charm.land/fantasy" +) + +// AccumulatedUsage tracks usage across a streaming response. +type AccumulatedUsage struct { + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + ReasoningTokens int64 + CacheCreationTokens int64 + CacheReadTokens int64 +} + +// Add accumulates usage from a StreamPart. +func (u *AccumulatedUsage) Add(part fantasy.StreamPart) { + u.InputTokens += part.Usage.InputTokens + u.OutputTokens += part.Usage.OutputTokens + u.TotalTokens += part.Usage.TotalTokens + u.ReasoningTokens += part.Usage.ReasoningTokens + u.CacheCreationTokens += part.Usage.CacheCreationTokens + u.CacheReadTokens += part.Usage.CacheReadTokens +} + +// HasUsage returns true if any usage has been accumulated. +func (u *AccumulatedUsage) HasUsage() bool { + return u.TotalTokens > 0 || u.InputTokens > 0 || u.OutputTokens > 0 +} diff --git a/server/internal/provider/fantasy/v1_adapter.go b/server/internal/provider/fantasy/v1_adapter.go new file mode 100644 index 0000000..de36319 --- /dev/null +++ b/server/internal/provider/fantasy/v1_adapter.go @@ -0,0 +1,250 @@ +package fantasy + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "charm.land/fantasy" +) + +// OpenAI SSE chunk types for the v1 API adapter. + +type oaiChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []oaiChoice `json:"choices"` + Usage *oaiUsage `json:"usage,omitempty"` +} + +type oaiChoice struct { + Index int `json:"index"` + Delta *oaiDelta `json:"delta,omitempty"` + FinishReason *string `json:"finish_reason"` +} + +type oaiDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []oaiToolCall `json:"tool_calls,omitempty"` +} + +type oaiToolCall struct { + Index int `json:"index"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function oaiToolFunction `json:"function"` +} + +type oaiToolFunction struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +type oaiUsage struct { + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + PromptTokensDetails *oaiPromptTokensDetails `json:"prompt_tokens_details,omitempty"` + CompletionTokensDetails *oaiCompletionDetails `json:"completion_tokens_details,omitempty"` +} + +type oaiPromptTokensDetails struct { + CachedTokens int64 `json:"cached_tokens"` +} + +type oaiCompletionDetails struct { + ReasoningTokens int64 `json:"reasoning_tokens"` +} + +// V1Adapter converts fantasy StreamParts into OpenAI-compatible SSE chunks. +type V1Adapter struct { + chunkID string + model string + created int64 + roleSent bool + toolIdx int + includeUsage bool +} + +// NewV1Adapter creates an adapter for converting StreamParts to OpenAI SSE format. +func NewV1Adapter(model string, includeUsage bool) *V1Adapter { + return &V1Adapter{ + chunkID: fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), + model: model, + created: time.Now().Unix(), + includeUsage: includeUsage, + } +} + +// Adapt converts a single StreamPart into zero or more JSON-encoded SSE data lines. +// Each returned byte slice is a complete `data: {...}\n\n` payload. +func (a *V1Adapter) Adapt(part fantasy.StreamPart) [][]byte { + switch part.Type { + case fantasy.StreamPartTypeTextStart: + if !a.roleSent { + a.roleSent = true + return [][]byte{a.makeChunk(&oaiDelta{Role: "assistant"}, nil)} + } + return nil + + case fantasy.StreamPartTypeTextDelta: + if part.Delta == "" { + return nil + } + a.ensureRoleSent() + return [][]byte{a.makeChunk(&oaiDelta{Content: part.Delta}, nil)} + + case fantasy.StreamPartTypeReasoningStart: + if !a.roleSent { + a.roleSent = true + return [][]byte{a.makeChunk(&oaiDelta{Role: "assistant"}, nil)} + } + return nil + + case fantasy.StreamPartTypeReasoningDelta: + if part.Delta == "" { + return nil + } + a.ensureRoleSent() + return [][]byte{a.makeChunk(&oaiDelta{ReasoningContent: part.Delta}, nil)} + + case fantasy.StreamPartTypeToolInputStart: + a.ensureRoleSent() + tc := oaiToolCall{ + Index: a.toolIdx, + ID: part.ID, + Type: "function", + Function: oaiToolFunction{ + Name: part.ToolCallName, + }, + } + return [][]byte{a.makeChunk(&oaiDelta{ToolCalls: []oaiToolCall{tc}}, nil)} + + case fantasy.StreamPartTypeToolInputDelta: + if part.ToolCallInput == "" { + return nil + } + tc := oaiToolCall{ + Index: a.toolIdx, + Function: oaiToolFunction{ + Arguments: part.ToolCallInput, + }, + } + return [][]byte{a.makeChunk(&oaiDelta{ToolCalls: []oaiToolCall{tc}}, nil)} + + case fantasy.StreamPartTypeToolInputEnd: + a.toolIdx++ + return nil + + case fantasy.StreamPartTypeFinish: + reason := string(part.FinishReason) + if reason == "" { + reason = "stop" + } + chunks := [][]byte{a.makeChunk(nil, &reason)} + // If include_usage was not requested, attach usage to the finish chunk. + if !a.includeUsage && part.Usage.TotalTokens > 0 { + usage := translateUsage(part.Usage) + chunks = append(chunks, a.makeUsageChunk(usage)) + } + return chunks + + case fantasy.StreamPartTypeError: + // Errors are handled by the caller; we don't emit them as chunks. + return nil + + default: + return nil + } +} + +// UsageChunk returns a final usage-only chunk (empty choices) for when +// stream_options.include_usage was requested. Returns nil if no usage. +func (a *V1Adapter) UsageChunk(usage AccumulatedUsage) []byte { + if !usage.HasUsage() { + return nil + } + oaiU := &oaiUsage{ + PromptTokens: usage.InputTokens, + CompletionTokens: usage.OutputTokens, + TotalTokens: usage.TotalTokens, + } + if usage.CacheReadTokens > 0 { + oaiU.PromptTokensDetails = &oaiPromptTokensDetails{CachedTokens: usage.CacheReadTokens} + } + if usage.ReasoningTokens > 0 { + oaiU.CompletionTokensDetails = &oaiCompletionDetails{ReasoningTokens: usage.ReasoningTokens} + } + return a.makeUsageChunk(oaiU) +} + +func (a *V1Adapter) ensureRoleSent() { + if !a.roleSent { + a.roleSent = true + } +} + +func (a *V1Adapter) makeChunk(delta *oaiDelta, finishReason *string) []byte { + chunk := oaiChunk{ + ID: a.chunkID, + Object: "chat.completion.chunk", + Created: a.created, + Model: a.model, + Choices: []oaiChoice{{ + Index: 0, + Delta: delta, + FinishReason: finishReason, + }}, + } + b, _ := json.Marshal(chunk) + return append([]byte("data: "), append(b, '\n', '\n')...) +} + +func (a *V1Adapter) makeUsageChunk(usage *oaiUsage) []byte { + chunk := oaiChunk{ + ID: a.chunkID, + Object: "chat.completion.chunk", + Created: a.created, + Model: a.model, + Choices: []oaiChoice{}, + Usage: usage, + } + b, _ := json.Marshal(chunk) + return append([]byte("data: "), append(b, '\n', '\n')...) +} + +func translateUsage(u fantasy.Usage) *oaiUsage { + usage := &oaiUsage{ + PromptTokens: u.InputTokens, + CompletionTokens: u.OutputTokens, + TotalTokens: u.TotalTokens, + } + if u.CacheReadTokens > 0 { + usage.PromptTokensDetails = &oaiPromptTokensDetails{CachedTokens: u.CacheReadTokens} + } + if u.ReasoningTokens > 0 { + usage.CompletionTokensDetails = &oaiCompletionDetails{ReasoningTokens: u.ReasoningTokens} + } + return usage +} + +// WriteError writes an OpenAI-shaped error as an SSE event followed by [DONE]. +// Used when an error occurs after headers have been sent. +func WriteError(w http.ResponseWriter, msg, code string) { + errJSON, _ := json.Marshal(map[string]any{ + "error": map[string]any{ + "message": msg, + "type": "server_error", + "code": code, + }, + }) + fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", errJSON) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} diff --git a/server/internal/provider/fantasy/v1_adapter_test.go b/server/internal/provider/fantasy/v1_adapter_test.go new file mode 100644 index 0000000..c19d972 --- /dev/null +++ b/server/internal/provider/fantasy/v1_adapter_test.go @@ -0,0 +1,228 @@ +package fantasy + +import ( + "encoding/json" + "strings" + "testing" + + "charm.land/fantasy" +) + +func parseSSEChunk(t *testing.T, data []byte) oaiChunk { + t.Helper() + s := strings.TrimPrefix(string(data), "data: ") + s = strings.TrimSuffix(s, "\n\n") + var chunk oaiChunk + if err := json.Unmarshal([]byte(s), &chunk); err != nil { + t.Fatalf("failed to parse SSE chunk: %v\nraw: %s", err, string(data)) + } + return chunk +} + +func TestV1Adapter_TextDelta(t *testing.T) { + a := NewV1Adapter("claude-sonnet-4", false) + chunks := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeTextDelta, + Delta: "Hello", + }) + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + parsed := parseSSEChunk(t, chunks[0]) + if parsed.Model != "claude-sonnet-4" { + t.Errorf("expected model claude-sonnet-4, got %s", parsed.Model) + } + if len(parsed.Choices) != 1 { + t.Fatalf("expected 1 choice, got %d", len(parsed.Choices)) + } + if parsed.Choices[0].Delta.Content != "Hello" { + t.Errorf("expected content Hello, got %s", parsed.Choices[0].Delta.Content) + } +} + +func TestV1Adapter_RoleSentOnce(t *testing.T) { + a := NewV1Adapter("test-model", false) + // First delta should include role + chunks := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeTextDelta, + Delta: "Hi", + }) + parsed := parseSSEChunk(t, chunks[0]) + // The adapter doesn't send role on TextDelta directly; it's sent via TextStart. + // But if TextStart wasn't received, the first delta still works. + if parsed.Choices[0].Delta.Content != "Hi" { + t.Errorf("expected content Hi, got %s", parsed.Choices[0].Delta.Content) + } +} + +func TestV1Adapter_ReasoningDelta(t *testing.T) { + a := NewV1Adapter("test-model", false) + chunks := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeReasoningDelta, + Delta: "thinking...", + }) + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + parsed := parseSSEChunk(t, chunks[0]) + if parsed.Choices[0].Delta.ReasoningContent != "thinking..." { + t.Errorf("expected reasoning_content, got %s", parsed.Choices[0].Delta.ReasoningContent) + } +} + +func TestV1Adapter_ToolCallLifecycle(t *testing.T) { + a := NewV1Adapter("test-model", false) + + // ToolInputStart + startChunks := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeToolInputStart, + ID: "call_abc", + ToolCallName: "search", + }) + if len(startChunks) != 1 { + t.Fatalf("expected 1 start chunk, got %d", len(startChunks)) + } + startParsed := parseSSEChunk(t, startChunks[0]) + tc := startParsed.Choices[0].Delta.ToolCalls[0] + if tc.ID != "call_abc" { + t.Errorf("expected tool call ID call_abc, got %s", tc.ID) + } + if tc.Function.Name != "search" { + t.Errorf("expected function name search, got %s", tc.Function.Name) + } + if tc.Index != 0 { + t.Errorf("expected index 0, got %d", tc.Index) + } + + // ToolInputDelta + deltaChunks := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeToolInputDelta, + ToolCallInput: `{"query":`, + }) + if len(deltaChunks) != 1 { + t.Fatalf("expected 1 delta chunk, got %d", len(deltaChunks)) + } + deltaParsed := parseSSEChunk(t, deltaChunks[0]) + if deltaParsed.Choices[0].Delta.ToolCalls[0].Function.Arguments != `{"query":` { + t.Errorf("unexpected arguments: %s", deltaParsed.Choices[0].Delta.ToolCalls[0].Function.Arguments) + } + + // ToolInputEnd increments index + endChunks := a.Adapt(fantasy.StreamPart{Type: fantasy.StreamPartTypeToolInputEnd}) + if len(endChunks) != 0 { + t.Errorf("expected 0 chunks for ToolInputEnd, got %d", len(endChunks)) + } + + // Second tool should have index 1 + start2 := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeToolInputStart, + ID: "call_def", + ToolCallName: "calculate", + }) + start2Parsed := parseSSEChunk(t, start2[0]) + if start2Parsed.Choices[0].Delta.ToolCalls[0].Index != 1 { + t.Errorf("expected second tool index 1, got %d", start2Parsed.Choices[0].Delta.ToolCalls[0].Index) + } +} + +func TestV1Adapter_Finish(t *testing.T) { + a := NewV1Adapter("test-model", false) + chunks := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeFinish, + FinishReason: "stop", + }) + if len(chunks) < 1 { + t.Fatal("expected at least 1 chunk for finish") + } + parsed := parseSSEChunk(t, chunks[0]) + if parsed.Choices[0].FinishReason == nil || *parsed.Choices[0].FinishReason != "stop" { + t.Errorf("expected finish_reason stop") + } +} + +func TestV1Adapter_FinishWithUsage_NoIncludeUsage(t *testing.T) { + a := NewV1Adapter("test-model", false) + chunks := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeFinish, + FinishReason: "stop", + Usage: fantasy.Usage{ + InputTokens: 100, + OutputTokens: 50, + TotalTokens: 150, + }, + }) + // Should have finish chunk + usage chunk + if len(chunks) != 2 { + t.Fatalf("expected 2 chunks (finish + usage), got %d", len(chunks)) + } + usageParsed := parseSSEChunk(t, chunks[1]) + if usageParsed.Usage == nil { + t.Fatal("expected usage in second chunk") + } + if usageParsed.Usage.PromptTokens != 100 { + t.Errorf("expected prompt_tokens 100, got %d", usageParsed.Usage.PromptTokens) + } +} + +func TestV1Adapter_FinishWithUsage_IncludeUsage(t *testing.T) { + a := NewV1Adapter("test-model", true) // include_usage = true + chunks := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeFinish, + FinishReason: "stop", + Usage: fantasy.Usage{ + InputTokens: 100, + OutputTokens: 50, + TotalTokens: 150, + }, + }) + // With include_usage, finish chunk should NOT have usage attached + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk (finish only), got %d", len(chunks)) + } + parsed := parseSSEChunk(t, chunks[0]) + if parsed.Usage != nil { + t.Error("expected no usage on finish chunk when include_usage is true") + } + + // Usage comes via separate UsageChunk call + usageData := a.UsageChunk(AccumulatedUsage{ + InputTokens: 100, + OutputTokens: 50, + TotalTokens: 150, + }) + if usageData == nil { + t.Fatal("expected usage chunk") + } + usageParsed := parseSSEChunk(t, usageData) + if len(usageParsed.Choices) != 0 { + t.Errorf("expected empty choices in usage chunk, got %d", len(usageParsed.Choices)) + } + if usageParsed.Usage.TotalTokens != 150 { + t.Errorf("expected total_tokens 150, got %d", usageParsed.Usage.TotalTokens) + } +} + +func TestV1Adapter_EmptyDeltas_Skipped(t *testing.T) { + a := NewV1Adapter("test-model", false) + for _, part := range []fantasy.StreamPart{ + {Type: fantasy.StreamPartTypeTextDelta, Delta: ""}, + {Type: fantasy.StreamPartTypeReasoningDelta, Delta: ""}, + {Type: fantasy.StreamPartTypeToolInputDelta, ToolCallInput: ""}, + } { + chunks := a.Adapt(part) + if len(chunks) != 0 { + t.Errorf("expected 0 chunks for empty %s, got %d", part.Type, len(chunks)) + } + } +} + +func TestV1Adapter_ConsistentChunkID(t *testing.T) { + a := NewV1Adapter("test-model", false) + c1 := a.Adapt(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, Delta: "a"}) + c2 := a.Adapt(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, Delta: "b"}) + p1 := parseSSEChunk(t, c1[0]) + p2 := parseSSEChunk(t, c2[0]) + if p1.ID != p2.ID { + t.Errorf("chunk IDs should be consistent: %s vs %s", p1.ID, p2.ID) + } +} diff --git a/server/internal/provider/fantasy/web_adapter.go b/server/internal/provider/fantasy/web_adapter.go new file mode 100644 index 0000000..6a16aaa --- /dev/null +++ b/server/internal/provider/fantasy/web_adapter.go @@ -0,0 +1,115 @@ +package fantasy + +import ( + "encoding/json" + + "charm.land/fantasy" + + "github.com/taciturnaxolotl/potluck/internal/stream" +) + +// WebAdapter converts fantasy StreamParts into stream.Events for the web UI. +// It maintains internal state for tool-call accumulation and sequence numbering. +type WebAdapter struct { + seq int64 +} + +// NewWebAdapter creates a new adapter for converting StreamParts to stream.Events. +func NewWebAdapter() *WebAdapter { + return &WebAdapter{} +} + +// Adapt converts a single StreamPart into zero or more stream.Events. +// Returns nil if the part doesn't produce an event (e.g., text_start, warnings). +func (a *WebAdapter) Adapt(part fantasy.StreamPart) []stream.Event { + a.seq++ + seq := a.seq + + switch part.Type { + case fantasy.StreamPartTypeTextDelta: + if part.Delta == "" { + a.seq-- // don't count empty deltas + return nil + } + return []stream.Event{makeEvent(seq, "delta", map[string]any{ + "type": "delta", + "content": part.Delta, + })} + + case fantasy.StreamPartTypeReasoningDelta: + if part.Delta == "" { + a.seq-- + return nil + } + return []stream.Event{makeEvent(seq, "reasoning", map[string]any{ + "type": "reasoning", + "content": part.Delta, + })} + + case fantasy.StreamPartTypeToolInputStart: + return []stream.Event{makeEvent(seq, "tool_call", map[string]any{ + "type": "tool_call", + "id": part.ID, + "name": part.ToolCallName, + "arguments": "", + })} + + case fantasy.StreamPartTypeToolInputDelta: + // Tool input deltas are accumulated by the caller; we emit them as + // incremental tool_call events with just the arguments fragment. + if part.ToolCallInput == "" { + a.seq-- + return nil + } + return []stream.Event{makeEvent(seq, "tool_call_delta", map[string]any{ + "type": "tool_call_delta", + "arguments": part.ToolCallInput, + })} + + case fantasy.StreamPartTypeFinish: + ev := map[string]any{"type": "done"} + return []stream.Event{makeEvent(seq, "done", ev)} + + case fantasy.StreamPartTypeError: + msg := "unknown error" + if part.Error != nil { + msg = part.Error.Error() + } + return []stream.Event{makeEvent(seq, "error", map[string]any{ + "type": "error", + "message": msg, + })} + + default: + // text_start, text_end, reasoning_start, reasoning_end, + // tool_input_end, tool_call, tool_result, source, warnings + // — not emitted to the web UI. + a.seq-- + return nil + } +} + +// DoneWithUsage produces a "done" event that includes usage information. +// Call this instead of relying on the Finish part when you have accumulated usage. +func (a *WebAdapter) DoneWithUsage(usage AccumulatedUsage) stream.Event { + a.seq++ + payload := map[string]any{ + "type": "done", + "usage": map[string]any{ + "prompt_tokens": usage.InputTokens, + "completion_tokens": usage.OutputTokens, + "total_tokens": usage.TotalTokens, + }, + } + return makeEvent(a.seq, "done", payload) +} + +func makeEvent(seq int64, eventType string, payload map[string]any) stream.Event { + payload["seq"] = seq + b, _ := json.Marshal(payload) + return stream.Event{ + Seq: seq, + Type: eventType, + Raw: json.RawMessage(b), + } +} diff --git a/server/internal/provider/fantasy/web_adapter_test.go b/server/internal/provider/fantasy/web_adapter_test.go new file mode 100644 index 0000000..25e6c24 --- /dev/null +++ b/server/internal/provider/fantasy/web_adapter_test.go @@ -0,0 +1,168 @@ +package fantasy + +import ( + "encoding/json" + "errors" + "testing" + + "charm.land/fantasy" +) + +func TestWebAdapter_TextDelta(t *testing.T) { + a := NewWebAdapter() + events := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeTextDelta, + Delta: "Hello", + }) + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != "delta" { + t.Errorf("expected type delta, got %s", events[0].Type) + } + var payload map[string]any + json.Unmarshal(events[0].Raw, &payload) + if payload["content"] != "Hello" { + t.Errorf("expected content Hello, got %v", payload["content"]) + } + if payload["seq"] != float64(1) { + t.Errorf("expected seq 1, got %v", payload["seq"]) + } +} + +func TestWebAdapter_EmptyTextDelta_Skipped(t *testing.T) { + a := NewWebAdapter() + events := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeTextDelta, + Delta: "", + }) + if len(events) != 0 { + t.Fatalf("expected 0 events for empty delta, got %d", len(events)) + } +} + +func TestWebAdapter_ReasoningDelta(t *testing.T) { + a := NewWebAdapter() + events := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeReasoningDelta, + Delta: "thinking...", + }) + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != "reasoning" { + t.Errorf("expected type reasoning, got %s", events[0].Type) + } +} + +func TestWebAdapter_ToolInputStart(t *testing.T) { + a := NewWebAdapter() + events := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeToolInputStart, + ID: "call_123", + ToolCallName: "get_weather", + }) + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != "tool_call" { + t.Errorf("expected type tool_call, got %s", events[0].Type) + } + var payload map[string]any + json.Unmarshal(events[0].Raw, &payload) + if payload["id"] != "call_123" { + t.Errorf("expected id call_123, got %v", payload["id"]) + } + if payload["name"] != "get_weather" { + t.Errorf("expected name get_weather, got %v", payload["name"]) + } +} + +func TestWebAdapter_Finish(t *testing.T) { + a := NewWebAdapter() + events := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeFinish, + FinishReason: "stop", + }) + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != "done" { + t.Errorf("expected type done, got %s", events[0].Type) + } +} + +func TestWebAdapter_Error(t *testing.T) { + a := NewWebAdapter() + events := a.Adapt(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeError, + Error: errors.New("rate limited"), + }) + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != "error" { + t.Errorf("expected type error, got %s", events[0].Type) + } + var payload map[string]any + json.Unmarshal(events[0].Raw, &payload) + if payload["message"] != "rate limited" { + t.Errorf("expected message 'rate limited', got %v", payload["message"]) + } +} + +func TestWebAdapter_SkipsNonEmittedTypes(t *testing.T) { + a := NewWebAdapter() + skipTypes := []fantasy.StreamPartType{ + fantasy.StreamPartTypeTextStart, + fantasy.StreamPartTypeTextEnd, + fantasy.StreamPartTypeReasoningStart, + fantasy.StreamPartTypeReasoningEnd, + fantasy.StreamPartTypeToolInputEnd, + fantasy.StreamPartTypeWarnings, + fantasy.StreamPartTypeSource, + } + for _, st := range skipTypes { + events := a.Adapt(fantasy.StreamPart{Type: st}) + if len(events) != 0 { + t.Errorf("expected 0 events for %s, got %d", st, len(events)) + } + } +} + +func TestWebAdapter_SequenceNumbers(t *testing.T) { + a := NewWebAdapter() + // Emit two deltas and a finish + a.Adapt(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, Delta: "a"}) + a.Adapt(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, Delta: "b"}) + events := a.Adapt(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish}) + + if events[0].Seq != 3 { + t.Errorf("expected seq 3 for finish, got %d", events[0].Seq) + } +} + +func TestWebAdapter_DoneWithUsage(t *testing.T) { + a := NewWebAdapter() + usage := AccumulatedUsage{ + InputTokens: 100, + OutputTokens: 50, + TotalTokens: 150, + } + ev := a.DoneWithUsage(usage) + if ev.Type != "done" { + t.Errorf("expected type done, got %s", ev.Type) + } + var payload map[string]any + json.Unmarshal(ev.Raw, &payload) + u, ok := payload["usage"].(map[string]any) + if !ok { + t.Fatal("expected usage in payload") + } + if u["prompt_tokens"] != float64(100) { + t.Errorf("expected prompt_tokens 100, got %v", u["prompt_tokens"]) + } + if u["completion_tokens"] != float64(50) { + t.Errorf("expected completion_tokens 50, got %v", u["completion_tokens"]) + } +} diff --git a/server/internal/provider/registry/registry.go b/server/internal/provider/registry/registry.go new file mode 100644 index 0000000..a8f0dae --- /dev/null +++ b/server/internal/provider/registry/registry.go @@ -0,0 +1,174 @@ +// Package registry provides a multi-provider registry that maps provider IDs +// to fantasy.Provider instances. It handles construction of the correct +// fantasy provider based on the provider type stored in the database. +package registry + +import ( + "context" + "fmt" + "strings" + "sync" + + "charm.land/fantasy" + "charm.land/fantasy/providers/anthropic" + "charm.land/fantasy/providers/google" + "charm.land/fantasy/providers/openaicompat" + "charm.land/fantasy/providers/openrouter" + + "github.com/taciturnaxolotl/potluck/internal/store" +) + +// ProviderType identifies how to construct a fantasy provider. +type ProviderType string + +const ( + TypeOpenAICompat ProviderType = "openai_compat" + TypeAnthropic ProviderType = "anthropic" + TypeGoogle ProviderType = "google" + TypeOpenRouter ProviderType = "openrouter" + TypeFree ProviderType = "free" // no auth, openai-compat shape +) + +// ProviderConfig holds the configuration for a single upstream provider. +type ProviderConfig struct { + ID string + Type ProviderType + Name string + BaseURL string + Config map[string]string // provider-specific config from config_json +} + +// Registry manages multiple upstream providers and creates fantasy.Provider +// instances on demand. +type Registry struct { + mu sync.RWMutex + providers map[string]ProviderConfig +} + +// New creates a registry from a list of provider configs. +func New(configs []ProviderConfig) *Registry { + m := make(map[string]ProviderConfig, len(configs)) + for _, c := range configs { + m[c.ID] = c + } + return &Registry{providers: m} +} + +// LoadFromDB loads all active providers from the database. +func LoadFromDB(ctx context.Context, q *store.Queries) (*Registry, error) { + rows, err := q.ListActiveProviders(ctx) + if err != nil { + return nil, fmt.Errorf("list providers: %w", err) + } + configs := make([]ProviderConfig, 0, len(rows)) + for _, r := range rows { + configs = append(configs, ProviderConfig{ + ID: r.ID, + Type: ProviderType(r.Type), + Name: r.Name, + BaseURL: r.BaseUrl, + // TODO: parse config_json into map when needed + }) + } + return New(configs), nil +} + +// Get returns the config for a provider ID. +func (r *Registry) Get(providerID string) (ProviderConfig, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + c, ok := r.providers[providerID] + return c, ok +} + +// List returns all registered provider configs. +func (r *Registry) List() []ProviderConfig { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]ProviderConfig, 0, len(r.providers)) + for _, c := range r.providers { + out = append(out, c) + } + return out +} + +// ToFantasy creates a fantasy.Provider for the given provider ID and API key. +// For free providers, apiKey is ignored. +func (r *Registry) ToFantasy(providerID, apiKey string) (fantasy.Provider, error) { + cfg, ok := r.Get(providerID) + if !ok { + return nil, fmt.Errorf("unknown provider: %s", providerID) + } + return newFantasyProvider(cfg, apiKey) +} + +// ResolveModel splits a prefixed model ID into (providerID, upstreamModel). +// If no prefix is found, returns ("pioneer", modelID) as the default. +// Format: "provider_id/model_name" e.g. "openrouter/claude-sonnet-4" +func (r *Registry) ResolveModel(model string) (providerID, upstreamModel string) { + if idx := strings.IndexByte(model, '/'); idx > 0 { + prefix := model[:idx] + if _, ok := r.Get(prefix); ok { + return prefix, model[idx+1:] + } + } + // Default to pioneer for bare model names (backward compat). + return "pioneer", model +} + +func newFantasyProvider(cfg ProviderConfig, apiKey string) (fantasy.Provider, error) { + switch cfg.Type { + case TypeOpenAICompat: + opts := []openaicompat.Option{ + openaicompat.WithBaseURL(cfg.BaseURL), + openaicompat.WithName(cfg.Name), + } + if apiKey != "" { + opts = append(opts, openaicompat.WithAPIKey(apiKey)) + } + return openaicompat.New(opts...) + + case TypeAnthropic: + opts := []anthropic.Option{ + anthropic.WithName(cfg.Name), + } + if cfg.BaseURL != "" { + opts = append(opts, anthropic.WithBaseURL(cfg.BaseURL)) + } + if apiKey != "" { + opts = append(opts, anthropic.WithAPIKey(apiKey)) + } + return anthropic.New(opts...) + + case TypeGoogle: + opts := []google.Option{ + google.WithName(cfg.Name), + } + if apiKey != "" { + opts = append(opts, google.WithGeminiAPIKey(apiKey)) + } + if cfg.BaseURL != "" { + opts = append(opts, google.WithBaseURL(cfg.BaseURL)) + } + return google.New(opts...) + + case TypeOpenRouter: + opts := []openrouter.Option{ + openrouter.WithName(cfg.Name), + } + if apiKey != "" { + opts = append(opts, openrouter.WithAPIKey(apiKey)) + } + return openrouter.New(opts...) + + case TypeFree: + // Free providers use openaicompat with no auth. + return openaicompat.New( + openaicompat.WithBaseURL(cfg.BaseURL), + openaicompat.WithName(cfg.Name), + ) + + default: + return nil, fmt.Errorf("unsupported provider type: %s", cfg.Type) + } +} diff --git a/server/internal/provider/registry/registry_test.go b/server/internal/provider/registry/registry_test.go new file mode 100644 index 0000000..1576ef0 --- /dev/null +++ b/server/internal/provider/registry/registry_test.go @@ -0,0 +1,103 @@ +package registry + +import ( + "testing" +) + +func TestResolveModel_WithPrefix(t *testing.T) { + r := New([]ProviderConfig{ + {ID: "pioneer", Type: TypeOpenAICompat, Name: "Pioneer", BaseURL: "https://api.pioneer.ai"}, + {ID: "openrouter", Type: TypeOpenRouter, Name: "OpenRouter", BaseURL: "https://openrouter.ai/api/v1"}, + }) + + tests := []struct { + model string + wantProvider string + wantUpstream string + }{ + {"openrouter/claude-sonnet-4", "openrouter", "claude-sonnet-4"}, + {"pioneer/gpt-4o", "pioneer", "gpt-4o"}, + {"claude-sonnet-4", "pioneer", "claude-sonnet-4"}, // bare → default pioneer + {"unknown/model", "pioneer", "unknown/model"}, // unknown prefix → default + } + + for _, tt := range tests { + gotProvider, gotUpstream := r.ResolveModel(tt.model) + if gotProvider != tt.wantProvider { + t.Errorf("ResolveModel(%q) provider = %q, want %q", tt.model, gotProvider, tt.wantProvider) + } + if gotUpstream != tt.wantUpstream { + t.Errorf("ResolveModel(%q) upstream = %q, want %q", tt.model, gotUpstream, tt.wantUpstream) + } + } +} + +func TestGet(t *testing.T) { + r := New([]ProviderConfig{ + {ID: "pioneer", Type: TypeOpenAICompat, Name: "Pioneer", BaseURL: "https://api.pioneer.ai"}, + }) + + cfg, ok := r.Get("pioneer") + if !ok { + t.Fatal("expected to find pioneer") + } + if cfg.Name != "Pioneer" { + t.Errorf("expected name Pioneer, got %s", cfg.Name) + } + + _, ok = r.Get("nonexistent") + if ok { + t.Error("expected nonexistent to return false") + } +} + +func TestList(t *testing.T) { + r := New([]ProviderConfig{ + {ID: "pioneer", Type: TypeOpenAICompat, Name: "Pioneer", BaseURL: "https://api.pioneer.ai"}, + {ID: "free", Type: TypeFree, Name: "Free", BaseURL: "http://localhost:11434"}, + }) + + list := r.List() + if len(list) != 2 { + t.Errorf("expected 2 providers, got %d", len(list)) + } +} + +func TestToFantasy_OpenAICompat(t *testing.T) { + r := New([]ProviderConfig{ + {ID: "pioneer", Type: TypeOpenAICompat, Name: "Pioneer", BaseURL: "https://api.pioneer.ai"}, + }) + + fp, err := r.ToFantasy("pioneer", "test-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fp == nil { + t.Fatal("expected non-nil provider") + } + if fp.Name() != "Pioneer" { + t.Errorf("expected name Pioneer, got %s", fp.Name()) + } +} + +func TestToFantasy_Free(t *testing.T) { + r := New([]ProviderConfig{ + {ID: "free", Type: TypeFree, Name: "Free", BaseURL: "http://localhost:11434"}, + }) + + fp, err := r.ToFantasy("free", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fp == nil { + t.Fatal("expected non-nil provider") + } +} + +func TestToFantasy_Unknown(t *testing.T) { + r := New(nil) + _, err := r.ToFantasy("nonexistent", "key") + if err == nil { + t.Error("expected error for unknown provider") + } +} diff --git a/server/internal/store/models.go b/server/internal/store/models.go index 8edf513..b067992 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -107,6 +107,7 @@ type PoolKey struct { PendingValidation int64 `json:"pending_validation"` LastBillingSyncAt sql.NullInt64 `json:"last_billing_sync_at"` RevokedAt sql.NullInt64 `json:"revoked_at"` + ProviderID string `json:"provider_id"` } type PoolKeyBillingRow struct { @@ -139,6 +140,17 @@ type PotluckRequest struct { TotalTokens sql.NullInt64 `json:"total_tokens"` Status string `json:"status"` ErrorCode sql.NullString `json:"error_code"` + ProviderID sql.NullString `json:"provider_id"` +} + +type Provider struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + BaseUrl string `json:"base_url"` + ConfigJson string `json:"config_json"` + Active int64 `json:"active"` + CreatedAt int64 `json:"created_at"` } type Session struct { diff --git a/server/internal/store/pool_keys.sql.go b/server/internal/store/pool_keys.sql.go index d559211..6dd98cf 100644 --- a/server/internal/store/pool_keys.sql.go +++ b/server/internal/store/pool_keys.sql.go @@ -17,7 +17,7 @@ INSERT INTO pool_keys ( active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at ) VALUES (?, ?, ?, ?, ?, 1, ?, 0, 0, 0, 0, ?) -RETURNING id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at +RETURNING id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at, provider_id ` type CreatePoolKeyParams struct { @@ -72,6 +72,7 @@ func (q *Queries) CreatePoolKey(ctx context.Context, arg CreatePoolKeyParams) (P &i.PendingValidation, &i.LastBillingSyncAt, &i.RevokedAt, + &i.ProviderID, ) return i, err } @@ -91,7 +92,7 @@ func (q *Queries) DeletePoolKey(ctx context.Context, arg DeletePoolKeyParams) er } const getPoolKey = `-- name: GetPoolKey :one -SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at FROM pool_keys WHERE id = ? +SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at, provider_id FROM pool_keys WHERE id = ? ` func (q *Queries) GetPoolKey(ctx context.Context, id string) (PoolKey, error) { @@ -122,6 +123,7 @@ func (q *Queries) GetPoolKey(ctx context.Context, id string) (PoolKey, error) { &i.PendingValidation, &i.LastBillingSyncAt, &i.RevokedAt, + &i.ProviderID, ) return i, err } @@ -195,7 +197,7 @@ func (q *Queries) ListPoolAllocations(ctx context.Context) ([]ListPoolAllocation } const listPoolKeys = `-- name: ListPoolKeys :many -SELECT pk.id, pk.user_id, pk.label, pk.key_ciphertext, pk.key_fingerprint, pk.active, pk.daily_limit_micros, pk.today_date, pk.today_micros, pk.total_micros, pk.request_count, pk.created_at, pk.last_used_at, pk.max_micros, pk.shared_micros, pk.pioneer_team_id, pk.pioneer_payment_plan, pk.pioneer_credit_limit_micros, pk.pioneer_remaining_micros, pk.pioneer_health, pk.pioneer_unhealthy_since, pk.pending_validation, pk.last_billing_sync_at, pk.revoked_at, u.display_name AS owner_name, u.email AS owner_email +SELECT pk.id, pk.user_id, pk.label, pk.key_ciphertext, pk.key_fingerprint, pk.active, pk.daily_limit_micros, pk.today_date, pk.today_micros, pk.total_micros, pk.request_count, pk.created_at, pk.last_used_at, pk.max_micros, pk.shared_micros, pk.pioneer_team_id, pk.pioneer_payment_plan, pk.pioneer_credit_limit_micros, pk.pioneer_remaining_micros, pk.pioneer_health, pk.pioneer_unhealthy_since, pk.pending_validation, pk.last_billing_sync_at, pk.revoked_at, pk.provider_id, u.display_name AS owner_name, u.email AS owner_email FROM pool_keys pk JOIN users u ON u.id = pk.user_id ORDER BY pk.active DESC, pk.today_micros ASC @@ -226,6 +228,7 @@ type ListPoolKeysRow struct { PendingValidation int64 `json:"pending_validation"` LastBillingSyncAt sql.NullInt64 `json:"last_billing_sync_at"` RevokedAt sql.NullInt64 `json:"revoked_at"` + ProviderID string `json:"provider_id"` OwnerName string `json:"owner_name"` OwnerEmail string `json:"owner_email"` } @@ -265,6 +268,7 @@ func (q *Queries) ListPoolKeys(ctx context.Context) ([]ListPoolKeysRow, error) { &i.PendingValidation, &i.LastBillingSyncAt, &i.RevokedAt, + &i.ProviderID, &i.OwnerName, &i.OwnerEmail, ); err != nil { @@ -282,7 +286,7 @@ func (q *Queries) ListPoolKeys(ctx context.Context) ([]ListPoolKeysRow, error) { } const listPoolKeysForUser = `-- name: ListPoolKeysForUser :many -SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at FROM pool_keys WHERE user_id = ? ORDER BY created_at DESC +SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at, provider_id FROM pool_keys WHERE user_id = ? ORDER BY created_at DESC ` func (q *Queries) ListPoolKeysForUser(ctx context.Context, userID string) ([]PoolKey, error) { @@ -319,6 +323,7 @@ func (q *Queries) ListPoolKeysForUser(ctx context.Context, userID string) ([]Poo &i.PendingValidation, &i.LastBillingSyncAt, &i.RevokedAt, + &i.ProviderID, ); err != nil { return nil, err } @@ -334,7 +339,7 @@ func (q *Queries) ListPoolKeysForUser(ctx context.Context, userID string) ([]Poo } const pickPoolKey = `-- name: PickPoolKey :one -SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at FROM pool_keys +SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at, provider_id FROM pool_keys WHERE active = 1 AND (today_date < ?1 OR today_micros < daily_limit_micros) ORDER BY @@ -375,6 +380,7 @@ func (q *Queries) PickPoolKey(ctx context.Context, todayDate int64) (PoolKey, er &i.PendingValidation, &i.LastBillingSyncAt, &i.RevokedAt, + &i.ProviderID, ) return i, err } diff --git a/server/internal/store/pool_keys_multi_provider.sql.go b/server/internal/store/pool_keys_multi_provider.sql.go new file mode 100644 index 0000000..dcd40e8 --- /dev/null +++ b/server/internal/store/pool_keys_multi_provider.sql.go @@ -0,0 +1,187 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: pool_keys_multi_provider.sql + +package store + +import ( + "context" +) + +const hasHealthyKeyForProvider = `-- name: HasHealthyKeyForProvider :one +SELECT 1 FROM pool_keys +WHERE active = 1 + AND revoked_at IS NULL + AND pending_validation = 0 + AND provider_id = ? + AND pioneer_health = 1 + AND (pioneer_remaining_micros IS NULL OR pioneer_remaining_micros > 10000000) + AND today_micros < max_micros +LIMIT 1 +` + +// Check if there's at least one healthy key for a provider. +func (q *Queries) HasHealthyKeyForProvider(ctx context.Context, providerID string) (int64, error) { + row := q.db.QueryRowContext(ctx, hasHealthyKeyForProvider, providerID) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + +const listKeysByProvider = `-- name: ListKeysByProvider :many +SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at, provider_id FROM pool_keys WHERE provider_id = ? ORDER BY created_at DESC +` + +func (q *Queries) ListKeysByProvider(ctx context.Context, providerID string) ([]PoolKey, error) { + rows, err := q.db.QueryContext(ctx, listKeysByProvider, providerID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []PoolKey{} + for rows.Next() { + var i PoolKey + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Label, + &i.KeyCiphertext, + &i.KeyFingerprint, + &i.Active, + &i.DailyLimitMicros, + &i.TodayDate, + &i.TodayMicros, + &i.TotalMicros, + &i.RequestCount, + &i.CreatedAt, + &i.LastUsedAt, + &i.MaxMicros, + &i.SharedMicros, + &i.PioneerTeamID, + &i.PioneerPaymentPlan, + &i.PioneerCreditLimitMicros, + &i.PioneerRemainingMicros, + &i.PioneerHealth, + &i.PioneerUnhealthySince, + &i.PendingValidation, + &i.LastBillingSyncAt, + &i.RevokedAt, + &i.ProviderID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const pickOwnKeyForProvider = `-- name: PickOwnKeyForProvider :one +SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at, provider_id FROM pool_keys +WHERE user_id = ? + AND active = 1 + AND revoked_at IS NULL + AND pending_validation = 0 + AND provider_id = ? + AND pioneer_health = 1 + AND (pioneer_remaining_micros IS NULL OR pioneer_remaining_micros > 10000000) + AND today_micros < max_micros + AND max_micros > shared_micros +ORDER BY (max_micros - max(today_micros, shared_micros)) DESC, RANDOM() +LIMIT 1 +` + +type PickOwnKeyForProviderParams struct { + UserID string `json:"user_id"` + ProviderID string `json:"provider_id"` +} + +// User's own key with private budget for a specific provider. +func (q *Queries) PickOwnKeyForProvider(ctx context.Context, arg PickOwnKeyForProviderParams) (PoolKey, error) { + row := q.db.QueryRowContext(ctx, pickOwnKeyForProvider, arg.UserID, arg.ProviderID) + var i PoolKey + err := row.Scan( + &i.ID, + &i.UserID, + &i.Label, + &i.KeyCiphertext, + &i.KeyFingerprint, + &i.Active, + &i.DailyLimitMicros, + &i.TodayDate, + &i.TodayMicros, + &i.TotalMicros, + &i.RequestCount, + &i.CreatedAt, + &i.LastUsedAt, + &i.MaxMicros, + &i.SharedMicros, + &i.PioneerTeamID, + &i.PioneerPaymentPlan, + &i.PioneerCreditLimitMicros, + &i.PioneerRemainingMicros, + &i.PioneerHealth, + &i.PioneerUnhealthySince, + &i.PendingValidation, + &i.LastBillingSyncAt, + &i.RevokedAt, + &i.ProviderID, + ) + return i, err +} + +const pickPoolKeyForProvider = `-- name: PickPoolKeyForProvider :one + +SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at, provider_id FROM pool_keys +WHERE active = 1 + AND revoked_at IS NULL + AND pending_validation = 0 + AND provider_id = ? + AND pioneer_health = 1 + AND (pioneer_remaining_micros IS NULL OR pioneer_remaining_micros > 10000000) + AND today_micros < max_micros +ORDER BY today_micros ASC, RANDOM() +LIMIT 1 +` + +// Multi-provider pool key queries. +// These extend the v2 queries with provider_id filtering. +// Best active healthy key for a specific provider. +func (q *Queries) PickPoolKeyForProvider(ctx context.Context, providerID string) (PoolKey, error) { + row := q.db.QueryRowContext(ctx, pickPoolKeyForProvider, providerID) + var i PoolKey + err := row.Scan( + &i.ID, + &i.UserID, + &i.Label, + &i.KeyCiphertext, + &i.KeyFingerprint, + &i.Active, + &i.DailyLimitMicros, + &i.TodayDate, + &i.TodayMicros, + &i.TotalMicros, + &i.RequestCount, + &i.CreatedAt, + &i.LastUsedAt, + &i.MaxMicros, + &i.SharedMicros, + &i.PioneerTeamID, + &i.PioneerPaymentPlan, + &i.PioneerCreditLimitMicros, + &i.PioneerRemainingMicros, + &i.PioneerHealth, + &i.PioneerUnhealthySince, + &i.PendingValidation, + &i.LastBillingSyncAt, + &i.RevokedAt, + &i.ProviderID, + ) + return i, err +} diff --git a/server/internal/store/pool_keys_v2.sql.go b/server/internal/store/pool_keys_v2.sql.go index 37b0754..069a2ac 100644 --- a/server/internal/store/pool_keys_v2.sql.go +++ b/server/internal/store/pool_keys_v2.sql.go @@ -27,7 +27,7 @@ func (q *Queries) ActivatePoolKey(ctx context.Context, id string) error { } const listKeysNeedingHealthCheck = `-- name: ListKeysNeedingHealthCheck :many -SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at FROM pool_keys +SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at, provider_id FROM pool_keys WHERE revoked_at IS NULL ORDER BY (last_billing_sync_at IS NOT NULL) ASC, last_billing_sync_at ASC ` @@ -68,6 +68,7 @@ func (q *Queries) ListKeysNeedingHealthCheck(ctx context.Context) ([]PoolKey, er &i.PendingValidation, &i.LastBillingSyncAt, &i.RevokedAt, + &i.ProviderID, ); err != nil { return nil, err } @@ -83,7 +84,7 @@ func (q *Queries) ListKeysNeedingHealthCheck(ctx context.Context) ([]PoolKey, er } const listUnhealthyKeysOlderThan = `-- name: ListUnhealthyKeysOlderThan :many -SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at FROM pool_keys +SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at, provider_id FROM pool_keys WHERE pioneer_health = 2 AND pioneer_unhealthy_since IS NOT NULL AND pioneer_unhealthy_since < ? @@ -126,6 +127,7 @@ func (q *Queries) ListUnhealthyKeysOlderThan(ctx context.Context, pioneerUnhealt &i.PendingValidation, &i.LastBillingSyncAt, &i.RevokedAt, + &i.ProviderID, ); err != nil { return nil, err } @@ -158,7 +160,7 @@ func (q *Queries) MarkPoolKeyRevoked(ctx context.Context, arg MarkPoolKeyRevoked } const pickOwnKeyWithPrivateBudget = `-- name: PickOwnKeyWithPrivateBudget :one -SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at FROM pool_keys +SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at, provider_id FROM pool_keys WHERE user_id = ? AND active = 1 AND revoked_at IS NULL @@ -207,12 +209,13 @@ func (q *Queries) PickOwnKeyWithPrivateBudget(ctx context.Context, userID string &i.PendingValidation, &i.LastBillingSyncAt, &i.RevokedAt, + &i.ProviderID, ) return i, err } const pickPoolKeyV2 = `-- name: PickPoolKeyV2 :one -SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at FROM pool_keys +SELECT id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at, provider_id FROM pool_keys WHERE active = 1 AND revoked_at IS NULL AND pending_validation = 0 @@ -259,6 +262,7 @@ func (q *Queries) PickPoolKeyV2(ctx context.Context) (PoolKey, error) { &i.PendingValidation, &i.LastBillingSyncAt, &i.RevokedAt, + &i.ProviderID, ) return i, err } diff --git a/server/internal/store/potluck_requests.sql.go b/server/internal/store/potluck_requests.sql.go index 79133e6..8231961 100644 --- a/server/internal/store/potluck_requests.sql.go +++ b/server/internal/store/potluck_requests.sql.go @@ -32,7 +32,7 @@ const createPotluckRequest = `-- name: CreatePotluckRequest :one INSERT INTO potluck_requests ( id, user_id, api_key_id, pool_key_id, surface, model, started_at, status ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending') -RETURNING id, user_id, api_key_id, pool_key_id, surface, model, started_at, finished_at, prompt_tokens, completion_tokens, total_tokens, status, error_code +RETURNING id, user_id, api_key_id, pool_key_id, surface, model, started_at, finished_at, prompt_tokens, completion_tokens, total_tokens, status, error_code, provider_id ` type CreatePotluckRequestParams struct { @@ -71,6 +71,7 @@ func (q *Queries) CreatePotluckRequest(ctx context.Context, arg CreatePotluckReq &i.TotalTokens, &i.Status, &i.ErrorCode, + &i.ProviderID, ) return i, err } @@ -107,7 +108,7 @@ func (q *Queries) FinishPotluckRequest(ctx context.Context, arg FinishPotluckReq } const listUnmatchedRequestsForKey = `-- name: ListUnmatchedRequestsForKey :many -SELECT pr.id, pr.user_id, pr.api_key_id, pr.pool_key_id, pr.surface, pr.model, pr.started_at, pr.finished_at, pr.prompt_tokens, pr.completion_tokens, pr.total_tokens, pr.status, pr.error_code FROM potluck_requests pr +SELECT pr.id, pr.user_id, pr.api_key_id, pr.pool_key_id, pr.surface, pr.model, pr.started_at, pr.finished_at, pr.prompt_tokens, pr.completion_tokens, pr.total_tokens, pr.status, pr.error_code, pr.provider_id FROM potluck_requests pr WHERE pr.pool_key_id = ? AND pr.finished_at >= ? AND pr.finished_at <= ? @@ -150,6 +151,7 @@ func (q *Queries) ListUnmatchedRequestsForKey(ctx context.Context, arg ListUnmat &i.TotalTokens, &i.Status, &i.ErrorCode, + &i.ProviderID, ); err != nil { return nil, err } diff --git a/server/internal/store/providers.sql.go b/server/internal/store/providers.sql.go new file mode 100644 index 0000000..58eadf8 --- /dev/null +++ b/server/internal/store/providers.sql.go @@ -0,0 +1,134 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: providers.sql + +package store + +import ( + "context" +) + +const createProvider = `-- name: CreateProvider :exec +INSERT INTO providers (id, type, name, base_url, config_json, active, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +` + +type CreateProviderParams struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + BaseUrl string `json:"base_url"` + ConfigJson string `json:"config_json"` + Active int64 `json:"active"` + CreatedAt int64 `json:"created_at"` +} + +func (q *Queries) CreateProvider(ctx context.Context, arg CreateProviderParams) error { + _, err := q.db.ExecContext(ctx, createProvider, + arg.ID, + arg.Type, + arg.Name, + arg.BaseUrl, + arg.ConfigJson, + arg.Active, + arg.CreatedAt, + ) + return err +} + +const deleteProvider = `-- name: DeleteProvider :exec +DELETE FROM providers WHERE id = ? +` + +func (q *Queries) DeleteProvider(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteProvider, id) + return err +} + +const getProvider = `-- name: GetProvider :one +SELECT id, type, name, base_url, config_json, active, created_at FROM providers WHERE id = ? +` + +func (q *Queries) GetProvider(ctx context.Context, id string) (Provider, error) { + row := q.db.QueryRowContext(ctx, getProvider, id) + var i Provider + err := row.Scan( + &i.ID, + &i.Type, + &i.Name, + &i.BaseUrl, + &i.ConfigJson, + &i.Active, + &i.CreatedAt, + ) + return i, err +} + +const listActiveProviders = `-- name: ListActiveProviders :many + +SELECT id, type, name, base_url, config_json, active, created_at FROM providers WHERE active = 1 ORDER BY id +` + +// Provider registry queries for multi-provider support. +func (q *Queries) ListActiveProviders(ctx context.Context) ([]Provider, error) { + rows, err := q.db.QueryContext(ctx, listActiveProviders) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Provider{} + for rows.Next() { + var i Provider + if err := rows.Scan( + &i.ID, + &i.Type, + &i.Name, + &i.BaseUrl, + &i.ConfigJson, + &i.Active, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateProvider = `-- name: UpdateProvider :exec +UPDATE providers SET + type = ?, + name = ?, + base_url = ?, + config_json = ?, + active = ? +WHERE id = ? +` + +type UpdateProviderParams struct { + Type string `json:"type"` + Name string `json:"name"` + BaseUrl string `json:"base_url"` + ConfigJson string `json:"config_json"` + Active int64 `json:"active"` + ID string `json:"id"` +} + +func (q *Queries) UpdateProvider(ctx context.Context, arg UpdateProviderParams) error { + _, err := q.db.ExecContext(ctx, updateProvider, + arg.Type, + arg.Name, + arg.BaseUrl, + arg.ConfigJson, + arg.Active, + arg.ID, + ) + return err +} diff --git a/server/internal/store/querier.go b/server/internal/store/querier.go index 5c091d6..c0560f9 100644 --- a/server/internal/store/querier.go +++ b/server/internal/store/querier.go @@ -32,6 +32,7 @@ type Querier interface { CreatePoolKey(ctx context.Context, arg CreatePoolKeyParams) (PoolKey, error) // potluck_requests: per-request log for attribution and billing join. CreatePotluckRequest(ctx context.Context, arg CreatePotluckRequestParams) (PotluckRequest, error) + CreateProvider(ctx context.Context, arg CreateProviderParams) error CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) CreateStream(ctx context.Context, arg CreateStreamParams) (Stream, error) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) @@ -39,6 +40,7 @@ type Querier interface { DeleteExpiredIdempotency(ctx context.Context, expiresAt int64) error DeleteExpiredSessions(ctx context.Context, expiresAt int64) error DeletePoolKey(ctx context.Context, arg DeletePoolKeyParams) error + DeleteProvider(ctx context.Context, id string) error DeleteSession(ctx context.Context, id string) error DeleteSessionForUser(ctx context.Context, arg DeleteSessionForUserParams) error DeleteUserByID(ctx context.Context, id string) error @@ -54,6 +56,11 @@ type Querier interface { GetModelCatalogRefreshedAt(ctx context.Context) (interface{}, error) GetModelPrice(ctx context.Context, model string) (ModelPrice, error) GetPoolKey(ctx context.Context, id string) (PoolKey, error) + GetProvider(ctx context.Context, id string) (Provider, error) + // Returns the most-recently-started stream for a conversation that is + // currently running. Used by handleConversationEvents to bootstrap + // observers who connect after the stream has already started. + GetRunningStreamForConversation(ctx context.Context, conversationID string) (Stream, error) GetSession(ctx context.Context, arg GetSessionParams) (Session, error) GetStream(ctx context.Context, id string) (Stream, error) GetStreamByIdempotencyKey(ctx context.Context, arg GetStreamByIdempotencyKeyParams) (Stream, error) @@ -70,11 +77,15 @@ type Querier interface { GetUserLiveSpendToday(ctx context.Context, arg GetUserLiveSpendTodayParams) (GetUserLiveSpendTodayRow, error) GetUserMemory(ctx context.Context, userID string) ([]UserMemory, error) GetUserMemoryKey(ctx context.Context, arg GetUserMemoryKeyParams) (UserMemory, error) + // Check if there's at least one healthy key for a provider. + HasHealthyKeyForProvider(ctx context.Context, providerID string) (int64, error) // Most recent pioneer_created_at for a key. // COALESCE(SUM(pioneer_created_at*0) + MAX(...)) is a workaround for // sqlc's sqlite parser rejecting bare COALESCE(MAX(...), 0). LatestBillingRowTime(ctx context.Context, poolKeyID string) (interface{}, error) ListAPIKeysForUser(ctx context.Context, userID string) ([]ApiKey, error) + // Provider registry queries for multi-provider support. + ListActiveProviders(ctx context.Context) ([]Provider, error) ListAllUsers(ctx context.Context) ([]User, error) // Live spend for ALL users since dayStart, grouped by user. // Used by RunSmartAllocation so it doesn't depend on the stale @@ -88,6 +99,7 @@ type Querier interface { ListContributionsForUser(ctx context.Context, arg ListContributionsForUserParams) ([]Contribution, error) ListConversationsForUser(ctx context.Context, arg ListConversationsForUserParams) ([]Conversation, error) ListEstimatedSpends(ctx context.Context, limit int64) ([]Spend, error) + ListKeysByProvider(ctx context.Context, providerID string) ([]PoolKey, error) // Keys the reconciler should probe on each tick. // Excludes permanently revoked keys. ListKeysNeedingHealthCheck(ctx context.Context) ([]PoolKey, error) @@ -121,6 +133,8 @@ type Querier interface { ListUserDailySpendForDay(ctx context.Context, day int64) ([]UserDailySpend, error) MarkPoolKeyRevoked(ctx context.Context, arg MarkPoolKeyRevokedParams) error MaxStreamChunkSeq(ctx context.Context, streamID string) (interface{}, error) + // User's own key with private budget for a specific provider. + PickOwnKeyForProvider(ctx context.Context, arg PickOwnKeyForProviderParams) (PoolKey, error) // First leg of user-aware picking: try to find the user's own key that // still has private reservation room (max_micros > shared_micros). // If this returns no rows, fall back to PickPoolKeyV2. @@ -135,6 +149,10 @@ type Querier interface { // (compare today_date to current UTC day and update if stale). // ?1 = current UTC day (unix / 86400) PickPoolKey(ctx context.Context, todayDate int64) (PoolKey, error) + // Multi-provider pool key queries. + // These extend the v2 queries with provider_id filtering. + // Best active healthy key for a specific provider. + PickPoolKeyForProvider(ctx context.Context, providerID string) (PoolKey, error) // Best active healthy key for a request: // active=1, not revoked, not pending validation // pioneer_health=1 (healthy) @@ -207,6 +225,7 @@ type Querier interface { // Updates the two-budget limits. Server enforces 0 <= shared <= max. UpdatePoolKeyLimits(ctx context.Context, arg UpdatePoolKeyLimitsParams) error UpdatePoolKeyTotalMicros(ctx context.Context, arg UpdatePoolKeyTotalMicrosParams) error + UpdateProvider(ctx context.Context, arg UpdateProviderParams) error // pool_key_billing_rows: ingested pioneer billing log entries. // // attribution integer enum: diff --git a/server/internal/store/streams.sql.go b/server/internal/store/streams.sql.go index 115f4b6..3c409c4 100644 --- a/server/internal/store/streams.sql.go +++ b/server/internal/store/streams.sql.go @@ -91,6 +91,35 @@ func (q *Queries) CreateStream(ctx context.Context, arg CreateStreamParams) (Str return i, err } +const getRunningStreamForConversation = `-- name: GetRunningStreamForConversation :one +SELECT id, conversation_id, user_id, assistant_message_id, idempotency_key, model, status, error_code, error_message, started_at, finished_at FROM streams +WHERE conversation_id = ? AND status = 'running' +ORDER BY started_at DESC +LIMIT 1 +` + +// Returns the most-recently-started stream for a conversation that is +// currently running. Used by handleConversationEvents to bootstrap +// observers who connect after the stream has already started. +func (q *Queries) GetRunningStreamForConversation(ctx context.Context, conversationID string) (Stream, error) { + row := q.db.QueryRowContext(ctx, getRunningStreamForConversation, conversationID) + var i Stream + err := row.Scan( + &i.ID, + &i.ConversationID, + &i.UserID, + &i.AssistantMessageID, + &i.IdempotencyKey, + &i.Model, + &i.Status, + &i.ErrorCode, + &i.ErrorMessage, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + const getStream = `-- name: GetStream :one SELECT id, conversation_id, user_id, assistant_message_id, idempotency_key, model, status, error_code, error_message, started_at, finished_at FROM streams WHERE id = ? ` @@ -223,27 +252,3 @@ func (q *Queries) SetStreamStatus(ctx context.Context, arg SetStreamStatusParams ) return err } - -// GetRunningStreamForConversation returns the most-recently-started stream for -// a conversation that is currently running. Used by handleConversationEvents to -// bootstrap observers who connect after the stream has already started. -func (q *Queries) GetRunningStreamForConversation(ctx context.Context, conversationID string) (Stream, error) { - const q2 = `SELECT id, conversation_id, user_id, assistant_message_id, idempotency_key, model, status, error_code, error_message, started_at, finished_at -FROM streams WHERE conversation_id = ? AND status = 'running' ORDER BY started_at DESC LIMIT 1` - row := q.db.QueryRowContext(ctx, q2, conversationID) - var i Stream - err := row.Scan( - &i.ID, - &i.ConversationID, - &i.UserID, - &i.AssistantMessageID, - &i.IdempotencyKey, - &i.Model, - &i.Status, - &i.ErrorCode, - &i.ErrorMessage, - &i.StartedAt, - &i.FinishedAt, - ) - return i, err -} diff --git a/server/internal/tools/tools.go b/server/internal/tools/tools.go index 758fb5d..b862f86 100644 --- a/server/internal/tools/tools.go +++ b/server/internal/tools/tools.go @@ -10,6 +10,8 @@ import ( "strings" "time" + "charm.land/fantasy" + "github.com/taciturnaxolotl/potluck/internal/fetch" "github.com/taciturnaxolotl/potluck/internal/provider" "github.com/taciturnaxolotl/potluck/internal/search" @@ -108,6 +110,20 @@ func Definitions() []provider.ToolDef { } } +// FantasyDefinitions returns tool definitions in fantasy's format. +func FantasyDefinitions() []fantasy.Tool { + defs := Definitions() + out := make([]fantasy.Tool, len(defs)) + for i, d := range defs { + out[i] = fantasy.FunctionTool{ + Name: d.Function.Name, + Description: d.Function.Description, + InputSchema: d.Function.Parameters, + } + } + return out +} + // Execute runs a tool call and returns the result string. func Execute(ctx context.Context, q *store.Queries, userID string, name string, arguments string) (string, error) { switch name {