diff --git a/AGENTS.md b/AGENTS.md index 158eb22..3959c3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -415,15 +415,17 @@ client-side, no backend surface. carries the thinking level. NO websocket, NO base_instructions. CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` → `openai_codex_wire_models`, mapped through the SAME - `platform_wire_model_to_entry` output): the 7 `visibility=list` && - `supported_in_api=true` models the backend served on 2026-09-22 - (`gpt-6-astra/sol/luna`, `gpt-5.6-sol/terra/luna`, `gpt-5.5`, ctx 272000, - per-model efforts) — NO live `/models` fetch, NO codex-CLI / `~/.codex` + `platform_wire_model_to_entry` output): the 8 `visibility=list` && + `supported_in_api=true` models the backend served on 2026-09-29 + (`gpt-6.1-sol`, `gpt-6-astra/sol/luna`, `gpt-5.6-sol/terra/luna`, + `gpt-5.5`, ctx 272000, per-model efforts) — NO live `/models` fetch, NO codex-CLI / `~/.codex` dependency; the hidden `gpt-reserve` and `codex-auto-review` are EXCLUDED. Refresh the table by reading `GET {base}/models?client_version=` with a ChatGPT bearer: an old `client_version` is answered with a truncated list, so a stale version reads as "the model does not exist". + `CODEX_USER_AGENT` (`codex_cli_rs/0.159.0`) moves with the release used + for that read. - `OAuthFlow::GithubDeviceCopilot` → `auth::github_copilot` (TWO-STAGE). Provider: `github-copilot` (`scope_key oauth/github-copilot`, base `api.individual.githubcopilot.com`, ChatCompletions wire). Stage 1 is an @@ -457,6 +459,39 @@ client-side, no backend surface. token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes `platform.oauth().is_some()` → `platform.base_url()` (kimi-code alone → `proxy_url()`). Tokens/codes/verifiers are NEVER logged. +- CUSTOM PROVIDERS (0.1.19): `[platforms.]` with `base_url` and `api = + "openai" | "anthropic"` declares a provider outside the closed registry. + `kigi-models/src/custom.rs` owns the validated type (name `[a-z0-9][a-z0-9_-]{0,31}`, + never a registry id; base URL `http(s)://host[/path]`, no userinfo, query or + fragment); `kigi-shell/src/agent/custom_providers/` owns the rest. Key order: + auth.json scope ``, then the table's `api_key`; a key is REQUIRED (a + keyless declaration is inert). Wires: openai = `/chat/completions`, Bearer, + `GET {base}/models`; anthropic = `/messages`, `x-api-key`, + `GET {base}/models?limit=1000`. Catalog keys are `/`; the entry's + `description` carries the provider name for the picker. Entries reach the disk + cache without a key; `stamp_credentials` stamps `api_key` in memory, only onto + entries still on the provider's own host, and `drop_orphans` removes fetched + entries of a provider that is gone or keyless BEFORE the prefetched map replaces + the catalog (a stale unkeyed entry would fall through to the house key). + CREDENTIAL RULE: `EndpointsConfig.custom_provider_bases` (derived from + `[platforms.*]` in `Config::new_from_toml_cfg` and `from_config_value`, never + serialized) makes `CredentialAuthority::is_session_coding_endpoint` false for a + declared base, loopback included — without it an Ollama-style provider on + localhost classifies `Primary` and the Kimi bearer replaces its key. Every + authority is built from the models manager's config, so the field reaches all of + them. Turns and subagents read `auth_scheme` and BYOK status from the LIVE + catalog entry (`auth_facts_of`), not from a disk reload: fetched entries never + live on disk, and the reload sent fetched Messages models out with `Bearer` + instead of `x-api-key` (this also repaired `anthropic` and `minimax`). + Login: picker rows `custom-openai` / `custom-anthropic` are not loginable + (`AuthMethodKind::Unknown`); the TUI asks base URL, name (default from the + host), key, then `save_custom_provider` writes config.toml (through + `config_write_dest`) and auth.json, and `authenticate("custom:")` + validates `GET {base}/models` (401/403 = rejected key, other non-2xx and network + errors surface). Limits: context window 256K unless the listing serves + `context_length` (Anthropic: `max_input_tokens`); no models.dev enrichment; a + failed key check leaves the saved definition (login again with the same name to + overwrite); no delete UI, edit config.toml and auth.json. - A stored subscription-OAuth session IS a catalog fetch source. Every fetch-PLAN decision that cannot afford real token resolution — the startup prefetch arming gate, `on_auth_changed`'s wipe guard diff --git a/README.md b/README.md index 4a94382..c2c3706 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,8 @@ On by default. `KIGI_GRAPH=0` turns it off; `KIGI_GRAPH_CONCURRENCY` (default ## Providers and API keys -29 platforms ship compiled in: 5 you sign into, 24 you hand a key. Nothing is -registered at runtime — if it's not in this list, it's not there. +29 platforms ship compiled in: 5 you sign into, 24 you hand a key. Anything +else is an endpoint you declare yourself ([your own endpoint](#your-own-endpoint)). **Sign in with a subscription you already pay for.** Run `kigi login` and pick. Each provider's token is stored under its own key, and one provider's @@ -149,6 +149,28 @@ api_key = "sk-..." api_key = "xai-..." ``` +### Your own endpoint + +Anything that speaks the OpenAI Chat Completions API or the Anthropic Messages +API works: a gateway, a proxy, Ollama, LM Studio, vLLM. In `kigi`, open `/login` +(or the welcome login screen) and pick **Custom provider (OpenAI compatible)** +or **(Anthropic compatible)**, then enter a base URL, a name, and an API key. +Kigi checks the key against +`GET {base}/models`, saves the provider, and `/model` lists what the endpoint +serves as `name/model`. + +```toml +# ~/.kigi/config.toml +[platforms.my-gateway] +base_url = "https://gateway.example.com/v1" # include /v1 when the API lives there +api = "openai" # or "anthropic" +api_key = "sk-..." # or store it in ~/.kigi/auth.json under "my-gateway" +``` + +A key is required. A local server that ignores auth takes any value. The +context window is 256K unless the endpoint reports one. To remove a provider, +delete its table and its `auth.json` entry. + Model lists sync on startup. Pick one with `/model`, set its thinking level with `/effort`. diff --git a/crates/codegen/kigi-tui/tests/pty_e2e/custom_provider.rs b/crates/codegen/kigi-tui/tests/pty_e2e/custom_provider.rs index 14dd496..8d742c1 100644 --- a/crates/codegen/kigi-tui/tests/pty_e2e/custom_provider.rs +++ b/crates/codegen/kigi-tui/tests/pty_e2e/custom_provider.rs @@ -127,3 +127,38 @@ async fn custom_anthropic_provider_uses_messages_and_x_api_key() { } harness.quit().expect("clean quit"); } + +/// A saved provider is a complete login: with no house key and no session, the +/// pager starts authenticated and the turn reaches the provider. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore] +async fn custom_provider_alone_authenticates_at_startup() { + let content = ContentController::start_with_models(vec![MockModel::new("custom-m1")]) + .await + .expect("start content"); + declare_provider(&content, "openai"); + content.set_response(format!( + "{MOCK_RESPONSE_SENTINEL} from the custom provider." + )); + let env: Vec<(String, String)> = content + .env_for_pager() + .into_iter() + .filter(|(name, _)| name != "KIGI_API_KEY") + .collect(); + let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + let binary = pager_binary().expect("resolve pager binary"); + let mut harness = + PtyHarness::new_in_dir(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs, None) + .expect("spawn pager without a house key"); + + harness + .wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT) + .expect("welcome text"); + harness + .inject_keys(format!("{PROMPT}\r").as_bytes()) + .expect("submit prompt"); + harness + .wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30)) + .expect("the turn reaches the custom provider without a login screen"); + harness.quit().expect("clean quit"); +}