Compare commits

..
22 Commits
Author SHA1 Message Date
雷电芽衣 c56660fe07 chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.19 workspace version
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
2026-09-29 16:54:21 -04:00
雷电芽衣 3bde7fe851 chore(release): bump workspace version to 0.1.19 2026-09-29 16:53:53 -04:00
雷电芽衣 793b93b74d test(subagent): the auth-scheme test joins the existing tests module 2026-09-29 16:53:07 -04:00
雷电芽衣 c9855c4bb6 fix(review): custom provider login comments, doc placement, subagent scheme test
The subagent auth-scheme lookup is now a function with a test. The failed-login
span carries the same method id as the successful one. README order matches the
code: the provider is saved, then the key is checked.
2026-09-29 16:50:03 -04:00
雷电芽衣 c4ef8a3779 docs+test: custom providers in README and AGENTS.md, provider-only startup PTY test
Also records the codex catalog at eight models and the 0.159.0 User-Agent.
2026-09-29 16:37:56 -04:00
雷电芽衣 ae40ba8da2 test(inspect): serialize the version_overrides test against KIGI_TEST_VERSION setters
It read the installed version while serial tests set a broken one, and failed
about one full run in four.
2026-09-29 16:34:19 -04:00
雷电芽衣 de42bb55c9 feat(models): gpt-6.1-sol joins the compiled-in codex catalog; codex User-Agent follows 0.159.0
The Codex backend (client_version 0.159.0) served eight list+supported models on
2026-09-29, gpt-6.1-sol first: context 272000, efforts low..max, default low.
POST /codex/responses with kigi's codex headers answers 200 and completes.
2026-09-29 16:34:19 -04:00
雷电芽衣 791f953c07 feat(login): custom provider rows, URL / name / key steps, and custom:<name> authenticate
The picker lists Custom provider (OpenAI compatible) and (Anthropic compatible).
The TUI asks for a base URL, a name (default from the host), and a key, saves the
definition and the key, and the agent validates the key against GET {base}/models.
PTY e2e covers both wires against the mock inference server.
2026-09-29 16:10:41 -04:00
雷电芽衣 eb86d7ec26 fix(shell): turns read the auth scheme and BYOK status from the live catalog
reconstruct_full_config and the subagent spawn resolved both from a disk reload
that never holds fetched entries, so a fetched Messages model (anthropic,
minimax) went out with Authorization: Bearer instead of x-api-key.
2026-09-29 16:10:41 -04:00
雷电芽衣 2ca1f3baa7 feat(shell): custom providers declared in config.toml join the model catalog
[platforms.<name>] with base_url and api (openai or anthropic) declares a provider.
Its key lives in auth.json under the name or in the table; /models is fetched per
provider, entries are keyed <name>/<model>, and the key is stamped in memory only.
2026-09-29 14:50:24 -04:00
雷电芽衣 4514b66ffe feat(models): custom provider wire kinds, name and base-URL rules 2026-09-29 14:08:29 -04:00
雷电芽衣 45edd7bf33 docs(agents): record the 0.1.19 upstream sync window and what it left behind 2026-09-29 14:03:38 -04:00
雷电芽衣 ceff1d7cc7 fix(tui): a second Ctrl+C cancels a plan comment
The first Ctrl+C clears the draft; the second did nothing and left the user in
comment mode. Both the approval and the casual preview paths now cancel.
2026-09-29 14:02:43 -04:00
雷电芽衣 041c5ca030 fix(tui): a bracketed paste that is not the clipboard never attaches its image
The origin check ran only on Otty. An IME commit or a diverged tmux buffer on any
other terminal attached whatever image sat on the clipboard.
2026-09-29 13:54:45 -04:00
雷电芽衣 0e063e5b02 fix(tui): y copies the whole queued prompt; a finished queue edit refocuses the composer
y copied the truncated display row. Saving or discarding a row edit left focus on
the queue pane, so the next Enter re-opened the edit instead of sending.
2026-09-29 13:43:56 -04:00
雷电芽衣 b840fff80c fix(sampler): Messages streams keep every thinking block and join split signatures
A turn with interleaved thinking lost all but its last signed block on
replay, and a signature split across deltas kept only its last part.
2026-09-29 13:32:03 -04:00
雷电芽衣 cb4c2eaad9 update version
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
2026-09-22 18:43:42 -04:00
雷电芽衣 cfde4e91a1 chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.18 workspace version 2026-09-22 18:42:49 -04:00
雷电芽衣 5082bd47ee chore(release): bump workspace version to 0.1.18 2026-09-22 18:42:49 -04:00
雷电芽衣 5d70140d62 docs(agents): the codex table is seven models 2026-09-22 18:42:49 -04:00
雷电芽衣 299c6bcfcb chore(sampler): codex User-Agent follows the 0.156.0 release 2026-09-22 18:42:49 -04:00
雷电芽衣 6be9ab0aea feat(models): gpt-6-sol and gpt-6-luna join the compiled-in codex catalog
The Codex backend (client_version 0.156.0) served seven list+supported
models on 2026-09-22: gpt-6-astra, gpt-6-sol, gpt-6-luna, then
gpt-5.6-sol/terra/luna and gpt-5.5. Sol and Luna carry context 272000,
efforts low..max, default medium. POST /codex/responses with kigi's codex
headers answers 200 and completes for both.
2026-09-22 18:42:49 -04:00
48 changed files with 8138 additions and 5297 deletions

No files matched your search

+52 -5
View File
@@ -18,7 +18,8 @@ Upstream sync record (update on every sync): fork baseline is upstream
snapshot `8adf901` (2026-07-16, ≈v0.2.102); bug fixes evaluated and
selectively ported through upstream `77cd7eb` (2026-08-25, ≈v1.0.10) in
the 0.1.13 cycle, through `3794978` (2026-09-09, ≈v1.0.24) in the 0.1.15
cycle, and through `a28ee2b` (2026-09-17, ≈v1.0.35) in the 0.1.16 cycle.
cycle, through `a28ee2b` (2026-09-17, ≈v1.0.35) in the 0.1.16 cycle, and
through `2bdd1d6` (2026-09-29, ≈v1.0.45) in the 0.1.19 cycle.
Upstream is daily "Synced from monorepo" snapshots —
the commit BODIES carry per-change bullet lists, and
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
@@ -39,6 +40,17 @@ POST in turn, so the cancel queues behind the hung call;
then drops the connection. stdio and SSE-mode servers get the cancel.
The cure is rmcp 3.x (upstream runs 3.2), a major bump not yet taken.
Evaluated in the 0.1.19 window and left for a later cycle: headless `-p`
interrupt handling (upstream `SignalStreams` plus a headless signals module; Kigi's
`headless.rs` installs no handler, so SIGINT kills the run without session
cleanup), the subagent "Cancelling" overlay that outlives the child turn (needs
upstream's child-turn finalizer and its prompt-id bookkeeping), the honest
answer for a `spawn_subagent` call cancelled by send-now (a chat-state answers
channel), the effort picker preselecting the model's default effort and
accepting menu labels (tied to upstream's `ModelChoice` refactor), and every MCP
change that rides rmcp 3.x (`bearer_token_file`, argument coercion, elicitation
notes). The rmcp and tree-sitter major bumps stay open.
Evaluated in the 0.1.16 window and left for a later cycle: MCP qualified
tool names past 64 chars (first prove the name never reaches a provider
function list), the auto-update guard for a binary still in use (needs
@@ -403,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 5 `visibility=list` &&
`supported_in_api=true` models the backend served on 2026-09-21
(`gpt-6-astra`, `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=<a current codex release>` 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
@@ -445,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.<name>]` 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 `<name>`, 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 `<name>/<model>`; 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:<name>")`
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
Generated
+62 -62
View File
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]]
name = "kigi-acp-lib"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"ansi-width",
"anstyle-parse 0.2.7",
@@ -6110,7 +6110,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-textarea"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"async-openai",
"async-stream",
@@ -6155,7 +6155,7 @@ dependencies = [
[[package]]
name = "kigi-sampling-types"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"assert_matches",
"async-openai",
@@ -6172,7 +6172,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anyhow",
"chrono",
@@ -6194,7 +6194,7 @@ dependencies = [
[[package]]
name = "kigi-secrets"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"regex",
"serde_json",
@@ -6232,7 +6232,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6370,7 +6370,7 @@ dependencies = [
[[package]]
name = "kigi-shell-base"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anyhow",
"chrono",
@@ -6395,7 +6395,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"libc",
"rusqlite",
@@ -6406,7 +6406,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6421,7 +6421,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6429,7 +6429,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6451,7 +6451,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"runfiles",
"tracing",
@@ -6460,11 +6460,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.17"
version = "0.1.19"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6475,7 +6475,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anyhow",
"async-trait",
@@ -6493,7 +6493,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6503,7 +6503,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anyhow",
"arc-swap",
@@ -6580,7 +6580,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6593,11 +6593,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.17"
version = "0.1.19"
[[package]]
name = "kigi-tty-utils"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6607,7 +6607,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6694,7 +6694,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anyhow",
"dunce",
@@ -6723,14 +6723,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6809,7 +6809,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"base64",
"chrono",
@@ -8843,7 +8843,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8861,7 +8861,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.17"
version = "0.1.19"
edition = "2024"
license = "Apache-2.0"
+24 -2
View File
@@ -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 saves the provider,
checks the key against `GET {base}/models`, 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`.
+5029 -5028
View File
File diff suppressed because it is too large. Load diff
+324
View File
@@ -0,0 +1,324 @@
//! User-declared providers: a name, a base URL, and a wire.
use crate::{ListingDialect, PlatformChatCompat, PlatformKeyHeader, PlatformWireApi};
const MAX_NAME_LEN: usize = 32;
/// Wire dialect of a custom provider.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CustomApi {
/// Chat Completions with a Bearer key.
OpenAi,
/// Messages with an `x-api-key` header.
Anthropic,
}
impl CustomApi {
pub fn as_str(self) -> &'static str {
match self {
Self::OpenAi => "openai",
Self::Anthropic => "anthropic",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"openai" => Some(Self::OpenAi),
"anthropic" => Some(Self::Anthropic),
_ => None,
}
}
/// Login picker wording.
pub fn label(self) -> &'static str {
match self {
Self::OpenAi => "OpenAI compatible",
Self::Anthropic => "Anthropic compatible",
}
}
pub fn wire_api(self) -> PlatformWireApi {
match self {
Self::OpenAi => PlatformWireApi::ChatCompletions,
Self::Anthropic => PlatformWireApi::Messages,
}
}
pub fn listing(self) -> ListingDialect {
match self {
Self::OpenAi => ListingDialect::OpenAi,
Self::Anthropic => ListingDialect::Anthropic,
}
}
pub fn key_header(self) -> PlatformKeyHeader {
match self {
Self::OpenAi => PlatformKeyHeader::Bearer,
Self::Anthropic => PlatformKeyHeader::XApiKey,
}
}
pub fn chat_compat(self) -> PlatformChatCompat {
PlatformChatCompat::Passthrough
}
}
/// Why a custom provider definition was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CustomProviderError {
Name(String),
BaseUrl(String),
}
impl std::fmt::Display for CustomProviderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Name(reason) => write!(f, "provider name: {reason}"),
Self::BaseUrl(reason) => write!(f, "provider base_url: {reason}"),
}
}
}
impl std::error::Error for CustomProviderError {}
/// A validated provider definition.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomProvider {
pub name: String,
pub api: CustomApi,
/// Without a trailing slash.
pub base_url: String,
}
impl CustomProvider {
pub fn new(name: &str, api: CustomApi, base_url: &str) -> Result<Self, CustomProviderError> {
validate_name(name)?;
Ok(Self {
name: name.to_owned(),
api,
base_url: normalize_base_url(base_url)?,
})
}
/// The listing endpoint, with the dialect's query.
pub fn models_url(&self) -> String {
match self.api {
CustomApi::OpenAi => format!("{}/models", self.base_url),
// Anthropic paginates (default 20); 1000 is the documented max.
CustomApi::Anthropic => format!("{}/models?limit=1000", self.base_url),
}
}
/// Catalog key `{name}/{model_id}`.
pub fn managed_model_key(&self, model_id: &str) -> String {
format!("{}/{model_id}", self.name)
}
}
/// Names become auth.json scopes and catalog key prefixes.
pub fn validate_name(name: &str) -> Result<(), CustomProviderError> {
let err = |reason: &str| Err(CustomProviderError::Name(reason.to_owned()));
if name.is_empty() || name.len() > MAX_NAME_LEN {
return err("must be 1 to 32 characters");
}
let mut chars = name.chars();
if !chars
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
{
return err("must start with a lowercase letter or digit");
}
if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
return err("may hold only lowercase letters, digits, '-' and '_'");
}
if crate::PlatformId::parse(name).is_some() {
return err("is a built-in platform id");
}
Ok(())
}
/// Accepts `http(s)://host[:port][/path]`; the URL is logged, so no secrets.
pub fn normalize_base_url(raw: &str) -> Result<String, CustomProviderError> {
let err = |reason: &str| Err(CustomProviderError::BaseUrl(reason.to_owned()));
let url = raw.trim().trim_end_matches('/');
if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
return err("must not contain whitespace");
}
let rest = ["https://", "http://"].iter().find_map(|scheme| {
url.get(..scheme.len())
.filter(|head| head.eq_ignore_ascii_case(scheme))
.map(|_| &url[scheme.len()..])
});
let Some(rest) = rest else {
return err("must start with http:// or https://");
};
let authority = rest.split('/').next().unwrap_or("");
if authority.is_empty() {
return err("has no host");
}
if authority.contains('@') {
return err("must not carry credentials");
}
if url.contains('?') || url.contains('#') {
return err("must not carry a query or fragment");
}
Ok(url.to_owned())
}
/// A valid provider name derived from a host.
pub fn default_name(base_url: &str) -> String {
let host = base_url
.split("://")
.nth(1)
.and_then(|rest| rest.split('/').next())
.and_then(|authority| authority.split(':').next())
.unwrap_or("")
.to_ascii_lowercase();
let labels: Vec<&str> = host
.split('.')
.filter(|label| !label.is_empty() && !matches!(*label, "api" | "www"))
.collect();
let joined: String = labels
.join("-")
.chars()
.filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
.take(MAX_NAME_LEN)
.collect();
let name = joined.trim_matches('-').to_owned();
if validate_name(&name).is_ok() {
name
} else {
"custom".to_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_names_come_from_the_host() {
for (url, expected) in [
("https://api.together.xyz/v1", "together-xyz"),
("https://openrouter.ai/api/v1", "openrouter-ai"),
("http://localhost:11434/v1", "localhost"),
("http://127.0.0.1:8080/v1", "127-0-0-1"),
("https://api.openai.com/v1", "openai-com"),
(
"https://gateway.internal.example.org/anthropic",
"gateway-internal-example-org",
),
("https://API.Example.COM/v1", "example-com"),
("not a url", "custom"),
("https://api/v1", "custom"),
("https://[::1]:9000/v1", "custom"),
] {
assert_eq!(default_name(url), expected, "{url}");
assert!(validate_name(&default_name(url)).is_ok(), "{url}");
}
assert!(default_name(&format!("https://{}.example/v1", "a".repeat(60))).len() <= 32);
}
#[test]
fn api_round_trips_and_maps_to_the_wire() {
for api in [CustomApi::OpenAi, CustomApi::Anthropic] {
assert_eq!(CustomApi::parse(api.as_str()), Some(api));
}
assert_eq!(CustomApi::parse(" OpenAI "), Some(CustomApi::OpenAi));
assert_eq!(CustomApi::parse("responses"), None);
assert_eq!(
CustomApi::OpenAi.wire_api(),
PlatformWireApi::ChatCompletions
);
assert_eq!(CustomApi::Anthropic.wire_api(), PlatformWireApi::Messages);
assert_eq!(CustomApi::OpenAi.key_header(), PlatformKeyHeader::Bearer);
assert_eq!(
CustomApi::Anthropic.key_header(),
PlatformKeyHeader::XApiKey
);
assert_eq!(CustomApi::OpenAi.listing(), ListingDialect::OpenAi);
assert_eq!(CustomApi::Anthropic.listing(), ListingDialect::Anthropic);
for api in [CustomApi::OpenAi, CustomApi::Anthropic] {
assert_eq!(api.chat_compat(), PlatformChatCompat::Passthrough);
}
}
#[test]
fn names_follow_the_rules() {
for ok in ["proxy", "my-proxy_2", "0x", "a"] {
assert!(validate_name(ok).is_ok(), "{ok}");
}
for bad in [
"",
"Proxy",
"pRoxy",
"-x",
"a/b",
"a b",
"a:b",
"é",
&"x".repeat(33),
] {
assert!(validate_name(bad).is_err(), "{bad}");
}
for id in crate::PlatformId::ALL {
assert!(validate_name(id.as_str()).is_err(), "{}", id.as_str());
}
}
#[test]
fn base_urls_are_normalized_or_refused() {
assert_eq!(
normalize_base_url(" https://api.example.com/v1/ ").unwrap(),
"https://api.example.com/v1"
);
assert_eq!(
normalize_base_url("HTTP://localhost:11434/v1").unwrap(),
"HTTP://localhost:11434/v1"
);
for bad in [
"",
"example.com/v1",
"ftp://example.com",
"https://",
"https:///v1",
"https://user:pw@example.com/v1",
"https://example.com/v1?key=x",
"https://example.com/v1#frag",
"https://exa mple.com",
"https://h\u{0}x",
"ht€ps://example.com",
"€https://example.com",
"https:/€",
"http:/€",
] {
assert!(normalize_base_url(bad).is_err(), "{bad}");
}
}
#[test]
fn listing_urls_and_keys_follow_the_dialect() {
let openai =
CustomProvider::new("proxy", CustomApi::OpenAi, "https://h.example/v1/").unwrap();
assert_eq!(openai.models_url(), "https://h.example/v1/models");
assert_eq!(openai.managed_model_key("gpt-x"), "proxy/gpt-x");
let anthropic =
CustomProvider::new("claude-gw", CustomApi::Anthropic, "https://h.example/v1").unwrap();
assert_eq!(
anthropic.models_url(),
"https://h.example/v1/models?limit=1000"
);
}
#[test]
fn provider_new_reports_which_field_failed() {
assert!(matches!(
CustomProvider::new("openai", CustomApi::OpenAi, "https://h.example/v1"),
Err(CustomProviderError::Name(_))
));
assert!(matches!(
CustomProvider::new("proxy", CustomApi::OpenAi, "h.example"),
Err(CustomProviderError::BaseUrl(_))
));
}
}
+48 -11
View File
@@ -16,6 +16,7 @@
use std::sync::LazyLock;
pub mod custom;
pub mod enrichment;
// Platform registry (PRD F2)
@@ -1285,8 +1286,8 @@ const OPENAI_CODEX_SPEC: PlatformSpec = PlatformSpec {
strip_listing_id_prefix: None,
};
/// The platform registry. Platforms are compiled-in spec rows; there is no
/// dynamic provider registration (PRD F2).
/// The platform registry. Platforms are compiled-in spec rows; user-declared
/// providers live in [`custom`], never here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum PlatformId {
/// Kimi Code subscription (OAuth bearer from the F1 device flow).
@@ -1570,8 +1571,8 @@ impl PlatformId {
/// The compiled-in catalog for a platform that serves NO live `/models`
/// listing (openai-codex), or `None` when the catalog comes from the wire.
///
/// openai-codex's 5 models are HARDCODED (read from the Codex backend
/// catalog on 2026-09-21, the `visibility=="list"` AND
/// openai-codex's 8 models are HARDCODED (read from the Codex backend
/// catalog on 2026-09-29, the `visibility=="list"` AND
/// `supported_in_api==true` set) because OpenAI exposes no stable public
/// models endpoint for the
/// ChatGPT Codex backend. Each entry carries context window + per-model
@@ -1607,21 +1608,39 @@ fn codex_wire_model(slug: &str, display_name: &str, efforts: &[&str], default: &
}
}
/// The HARDCODED openai-codex catalog: exactly the 5 `visibility=="list"` AND
/// `supported_in_api==true` models the Codex backend served on 2026-09-21, in
/// The HARDCODED openai-codex catalog: exactly the 8 `visibility=="list"` AND
/// `supported_in_api==true` models the Codex backend served on 2026-09-29, in
/// its own priority order. `gpt-reserve` and `codex-auto-review`
/// (visibility="hide") are deliberately EXCLUDED — they are not user-facing
/// (fail-fast: never advertise a model the backend rejects). `gpt-6-astra`
/// also advertises an `ultra` tier above `max`; kigi has no such level, so it
/// (fail-fast: never advertise a model the backend rejects). Several models
/// also advertise an `ultra` tier above `max`; kigi has no such level, so it
/// is not listed here.
fn openai_codex_wire_models() -> Vec<WireModel> {
vec![
codex_wire_model(
"gpt-6.1-sol",
"GPT-6.1-Sol",
&["low", "medium", "high", "xhigh", "max"],
"low",
),
codex_wire_model(
"gpt-6-astra",
"GPT-6-Astra",
&["low", "medium", "high", "xhigh", "max"],
"medium",
),
codex_wire_model(
"gpt-6-sol",
"GPT-6-Sol",
&["low", "medium", "high", "xhigh", "max"],
"medium",
),
codex_wire_model(
"gpt-6-luna",
"GPT-6-Luna",
&["low", "medium", "high", "xhigh", "max"],
"medium",
),
codex_wire_model(
"gpt-5.6-sol",
"GPT-5.6-Sol",
@@ -2661,13 +2680,13 @@ mod tests {
assert_eq!(c.base_url(), "https://mock.codex/codex");
}
/// The HARDCODED openai-codex catalog is exactly the 5 supported+listed
/// The HARDCODED openai-codex catalog is exactly the 8 supported+listed
/// models, keyed by slug, ctx 272000, each exposing its exact supported
/// efforts (incl. the codex-only `xhigh`/`max` tiers). The hidden models
/// are absent. Every other platform serves NO hardcoded catalog (its
/// models come from the live wire).
#[test]
fn openai_codex_hardcoded_catalog_is_the_five_supported_models() {
fn openai_codex_hardcoded_catalog_is_the_eight_supported_models() {
let catalog = PlatformId::OpenaiCodex
.hardcoded_catalog()
.expect("openai-codex serves a hardcoded catalog");
@@ -2675,13 +2694,16 @@ mod tests {
assert_eq!(
ids,
vec![
"gpt-6.1-sol",
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5"
],
"exactly the 5 visibility=list AND supported_in_api=true models"
"exactly the 8 visibility=list AND supported_in_api=true models"
);
// Excluded: hidden models never appear.
for absent in ["gpt-reserve", "codex-auto-review"] {
@@ -2714,6 +2736,18 @@ mod tests {
["low", "medium", "high", "xhigh", "max"],
"`ultra` is advertised above `max` but kigi has no such level"
);
assert_eq!(
efforts("gpt-6.1-sol"),
["low", "medium", "high", "xhigh", "max"]
);
assert_eq!(
efforts("gpt-6-sol"),
["low", "medium", "high", "xhigh", "max"]
);
assert_eq!(
efforts("gpt-6-luna"),
["low", "medium", "high", "xhigh", "max"]
);
assert_eq!(
efforts("gpt-5.6-sol"),
["low", "medium", "high", "xhigh", "max"]
@@ -2739,7 +2773,10 @@ mod tests {
.default_effort
.clone()
};
assert_eq!(default("gpt-6.1-sol").as_deref(), Some("low"));
assert_eq!(default("gpt-6-astra").as_deref(), Some("medium"));
assert_eq!(default("gpt-6-sol").as_deref(), Some("medium"));
assert_eq!(default("gpt-6-luna").as_deref(), Some("medium"));
assert_eq!(default("gpt-5.6-sol").as_deref(), Some("low"));
assert_eq!(default("gpt-5.6-terra").as_deref(), Some("medium"));
// Only openai-codex has a hardcoded catalog; every wire platform is None.
@@ -189,11 +189,6 @@ impl TerminalName {
| Self::Zed
)
}
/// Only Otty is known to wrap macOS IME commits in bracketed paste.
pub fn delivers_ime_as_bracketed_paste(self) -> bool {
matches!(self, Self::Otty)
}
}
impl TerminalContext {
@@ -51,24 +51,6 @@ fn brand_otty_from_term_program() {
assert_eq!(detect_terminal_brand_from_env(&env), TerminalName::Otty);
}
#[test]
fn otty_delivers_ime_as_bracketed_paste_only() {
assert!(TerminalName::Otty.delivers_ime_as_bracketed_paste());
for brand in [
TerminalName::AppleTerminal,
TerminalName::Ghostty,
TerminalName::Iterm2,
TerminalName::Unknown,
TerminalName::WezTerm,
TerminalName::Kitty,
] {
assert!(
!brand.delivers_ime_as_bracketed_paste(),
"{brand:?} must not gate IME bracketed-paste origin"
);
}
}
#[test]
fn otty_is_capability_unclassified_like_unknown() {
assert!(TerminalName::Otty.is_capability_unclassified());
@@ -46,6 +46,8 @@ struct BlockState {
args_acc: String,
thinking_acc: String,
signature: String,
/// The first `SignatureDelta` replaces a start-seeded signature; later ones append.
signature_delta_seen: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -109,13 +111,10 @@ pub fn stream_messages<'a>(
let mut final_stop_reason: Option<StopReason> = None;
let mut final_stop_message: Option<String> = None;
// Assistant-response accumulators (built up as ContentBlockStop
// events fire). Reasoning is collected into a synthesized
// `rs::ReasoningItem` and emitted as a sibling
// `ConversationItem::Reasoning` before the trailing Assistant.
// Assistant-response accumulators, filled as ContentBlockStop events fire.
let mut assistant_text = String::new();
let mut assistant_tool_calls: Vec<ToolCall> = Vec::new();
let mut assistant_reasoning: Option<rs::ReasoningItem> = None;
let mut assistant_reasoning: Vec<rs::ReasoningItem> = Vec::new();
// Index counters
let mut chunk_index: u64 = 0;
@@ -185,6 +184,7 @@ pub fn stream_messages<'a>(
args_acc: String::new(),
thinking_acc: thinking.clone(),
signature: signature.clone(),
signature_delta_seen: false,
},
);
if !first_token_emitted {
@@ -205,6 +205,7 @@ pub fn stream_messages<'a>(
args_acc: String::new(),
thinking_acc: String::new(),
signature: String::new(),
signature_delta_seen: false,
},
);
if !first_token_emitted {
@@ -237,6 +238,7 @@ pub fn stream_messages<'a>(
args_acc: String::new(),
thinking_acc: String::new(),
signature: String::new(),
signature_delta_seen: false,
},
);
@@ -276,7 +278,11 @@ pub fn stream_messages<'a>(
}
}
StreamDelta::SignatureDelta { signature } => {
state.signature = signature;
if !state.signature_delta_seen {
state.signature_delta_seen = true;
state.signature.clear();
}
state.signature.push_str(&signature);
}
StreamDelta::TextDelta { text } => {
if !text.is_empty() {
@@ -347,7 +353,7 @@ pub fn stream_messages<'a>(
} else {
Some(state.signature)
};
assistant_reasoning = Some(rs::ReasoningItem {
assistant_reasoning.push(rs::ReasoningItem {
id: String::new(),
summary,
content: None,
@@ -513,9 +519,7 @@ pub fn stream_messages<'a>(
});
let mut items: Vec<ConversationItem> = Vec::new();
if let Some(r) = assistant_reasoning {
items.push(ConversationItem::Reasoning(r));
}
items.extend(assistant_reasoning.into_iter().map(ConversationItem::Reasoning));
items.push(assistant_item);
let stream_end = Instant::now();
@@ -208,6 +208,91 @@ async fn thinking_block_emits_reasoning_channel_and_preserved_in_response() {
}
}
fn thinking_block(
index: u32,
start_signature: &str,
thinking: &str,
signature_deltas: &[&str],
) -> Vec<Result<MessageStreamEvent, SamplingError>> {
let mut events = vec![
Ok(MessageStreamEvent::ContentBlockStart {
index,
content_block: ContentBlock::Thinking {
thinking: String::new(),
signature: start_signature.into(),
},
}),
Ok(MessageStreamEvent::ContentBlockDelta {
index,
delta: StreamDelta::ThinkingDelta {
thinking: thinking.into(),
},
}),
];
for signature in signature_deltas {
events.push(Ok(MessageStreamEvent::ContentBlockDelta {
index,
delta: StreamDelta::SignatureDelta {
signature: (*signature).into(),
},
}));
}
events.push(Ok(block_stop(index)));
events
}
async fn reasoning_signatures(
blocks: Vec<Vec<Result<MessageStreamEvent, SamplingError>>>,
) -> Vec<(String, Option<String>)> {
let mut events = vec![Ok(message_start())];
events.extend(blocks.into_iter().flatten());
events.push(Ok(MessageStreamEvent::MessageStop));
let raw = stream::iter(events).boxed();
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => response
.reasoning_items()
.map(|r| {
let text = r
.summary
.iter()
.map(|rs::SummaryPart::SummaryText(t)| t.text.as_str())
.collect::<String>();
(text, r.encrypted_content.clone())
})
.collect(),
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn every_thinking_block_is_kept_in_order() {
let items = reasoning_signatures(vec![
thinking_block(0, "", "first", &["sig-a"]),
thinking_block(1, "", "second", &["sig-b"]),
])
.await;
assert_eq!(
items,
vec![
("first".to_string(), Some("sig-a".to_string())),
("second".to_string(), Some("sig-b".to_string())),
]
);
}
#[tokio::test]
async fn split_signature_deltas_join_into_one_signature() {
let items = reasoning_signatures(vec![thinking_block(0, "", "t", &["abc", "def"])]).await;
assert_eq!(items, vec![("t".to_string(), Some("abcdef".to_string()))]);
}
#[tokio::test]
async fn first_signature_delta_replaces_a_start_seeded_signature() {
let items = reasoning_signatures(vec![thinking_block(0, "sig", "t", &["sig"])]).await;
assert_eq!(items, vec![("t".to_string(), Some("sig".to_string()))]);
}
#[tokio::test]
async fn tool_use_block_assembles_into_tool_call() {
let tool_start = MessageStreamEvent::ContentBlockStart {
@@ -1156,7 +1156,7 @@ pub const ANTHROPIC_OAUTH_BETA: &str = "claude-code-20250219,oauth-2025-04-20";
/// User-Agent kigi presents on the Claude-Code OAuth path (mirrors the
/// official Claude Code CLI). OAuth-gated: unrelated to the default kigi UA.
pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.75";
pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.280";
/// System-prompt prefix REQUIRED on the Claude-Code OAuth Messages path: the
/// OAuth token is Claude-Code-scoped, so Anthropic rejects the request unless
@@ -1202,7 +1202,7 @@ pub const CODEX_OPENAI_BETA: &str = "responses=experimental";
/// openai-codex-gated). The Codex backend does not strictly validate the UA
/// string (Pi ships its own and it works), so this is a stable best-effort
/// identity, not a pinned build.
pub const CODEX_USER_AGENT: &str = "codex_cli_rs/0.104.0";
pub const CODEX_USER_AGENT: &str = "codex_cli_rs/0.159.0";
/// JWT payload claim namespace carrying the ChatGPT account id.
const CODEX_JWT_AUTH_CLAIM: &str = "https://api.openai.com/auth";
@@ -114,6 +114,7 @@ pub struct BuiltAuthMethods {
/// `PlatformId::ALL` order — interactive logins after `kimi-code`
/// 5. every API-key registry platform, in `PlatformId::ALL` order
/// (`moonshot-cn`, `moonshot-ai`, …), always advertised
/// 6. the two custom provider rows (`custom-openai`, `custom-anthropic`)
///
/// The platform methods are for the INTERACTIVE login picker only: they come
/// after `kimi-code` so they can never become `auth_methods.first()` (the
@@ -174,6 +175,7 @@ pub fn build_auth_methods(inputs: AuthMethodsBuildInputs<'_>) -> BuiltAuthMethod
methods.push(platform_auth_method(platform));
}
}
methods.extend(crate::agent::custom_providers::picker_methods());
BuiltAuthMethods {
methods,
@@ -189,6 +191,8 @@ pub enum AuthMethodKind {
KimiCode,
/// Registry API-key platform login (method id = the platform id).
ApiKeyPlatform(kigi_models::PlatformId),
/// A saved custom provider's login (method id = `custom:<name>`).
CustomProvider,
/// Generic device-code OAuth platform login (method id = the platform id,
/// e.g. `xai-grok`). Interactive, like Kimi Code.
OAuthPlatform(kigi_models::PlatformId),
@@ -201,6 +205,9 @@ impl AuthMethodKind {
XAI_API_KEY_METHOD_ID => Self::XaiApiKey,
CACHED_TOKEN_AUTH_METHOD_ID => Self::CachedToken,
KIMI_CODE_METHOD_ID => Self::KimiCode,
other if crate::agent::custom_providers::login_provider_name(other).is_some() => {
Self::CustomProvider
}
other => match kigi_models::PlatformId::parse(other) {
Some(p) if p.oauth().is_some() => Self::OAuthPlatform(p),
Some(p) if !p.uses_oauth() => Self::ApiKeyPlatform(p),
@@ -213,7 +220,10 @@ impl AuthMethodKind {
/// The registry platform methods qualify — they validate a configured
/// platform key and then behave exactly like an external-API-key session.
pub fn is_api_key(self) -> bool {
matches!(self, Self::XaiApiKey | Self::ApiKeyPlatform(_))
matches!(
self,
Self::XaiApiKey | Self::ApiKeyPlatform(_) | Self::CustomProvider
)
}
/// `true` for session-based methods (cached_token, interactive login).
@@ -589,6 +599,23 @@ mod tests {
);
}
/// `custom:<name>` logs in; picker rows cannot.
#[test]
fn custom_provider_method_ids_classify_as_api_key() {
let kind = AuthMethodKind::from_id(&acp::AuthMethodId::new("custom:my-proxy"));
assert_eq!(kind, AuthMethodKind::CustomProvider);
assert!(kind.is_api_key());
assert!(!kind.is_session_based());
assert!(!kind.needs_interactive_login());
for picker in ["custom-openai", "custom-anthropic"] {
assert_eq!(
AuthMethodKind::from_id(&acp::AuthMethodId::new(picker)),
AuthMethodKind::Unknown,
"{picker} only opens the TUI flow; it cannot authenticate"
);
}
}
/// Classifier matrix for all auth method variants.
#[test]
fn auth_method_kind_classifier_matrix() {
@@ -896,7 +923,9 @@ mod tests {
"xiaomi",
"xiaomi-token-plan-cn",
"minimax",
"minimax-cn"
"minimax-cn",
"custom-openai",
"custom-anthropic"
]
);
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
@@ -948,7 +977,9 @@ mod tests {
"xiaomi",
"xiaomi-token-plan-cn",
"minimax",
"minimax-cn"
"minimax-cn",
"custom-openai",
"custom-anthropic"
]
);
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
@@ -993,7 +1024,9 @@ mod tests {
"xiaomi",
"xiaomi-token-plan-cn",
"minimax",
"minimax-cn"
"minimax-cn",
"custom-openai",
"custom-anthropic"
]
);
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
@@ -1041,7 +1074,9 @@ mod tests {
"xiaomi",
"xiaomi-token-plan-cn",
"minimax",
"minimax-cn"
"minimax-cn",
"custom-openai",
"custom-anthropic"
]
);
assert_eq!(default_id(&built), None);
+78 -3
View File
@@ -178,6 +178,9 @@ pub struct EndpointsConfig {
/// Read by `load_gcs_service_account_key_sync()`. Declared for `serde_ignored`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gcs_service_account_key: Option<String>,
/// Base URLs of declared custom providers; derived, never serialized.
#[serde(skip)]
pub custom_provider_bases: Vec<String>,
}
/// A blank or whitespace-only override counts as unset. Single source of truth
/// for the "empty value = not configured" rule shared by the endpoint resolvers.
@@ -210,7 +213,13 @@ impl EndpointsConfig {
if let Some(endpoints) = config.get("endpoints") {
crate::config::deep_merge_toml(&mut base, endpoints);
}
base.try_into().unwrap_or_default()
let mut endpoints: Self = base.try_into().unwrap_or_default();
if let Some(platforms) = config.get("platforms").cloned()
&& let Ok(platforms) = platforms.try_into::<PlatformsConfig>()
{
endpoints.custom_provider_bases = super::custom_providers::bases(&platforms);
}
endpoints
}
/// The subscription proxy base URL through which all auxiliary services (and
/// OAuth/session inference) resolve: explicit `coding_api_base_url`, else
@@ -265,6 +274,7 @@ impl Default for EndpointsConfig {
managed_config_url: env_string("KIGI_MANAGED_CONFIG_URL"),
management_api_key: None,
gcs_service_account_key: None,
custom_provider_bases: Vec::new(),
}
}
}
@@ -771,8 +781,11 @@ impl PlatformsConfig {
/// registry platform, so a typo like `moonshot_cn` fails loudly instead of
/// silently never matching. Key values are not logged.
pub fn warn_unknown_platforms(&self) {
for id in self.entries.keys() {
if kigi_models::PlatformId::parse(id).is_none() {
super::custom_providers::warn_invalid_declarations(self);
for (id, entry) in &self.entries {
if kigi_models::PlatformId::parse(id).is_none()
&& !super::custom_providers::is_declared(entry)
{
tracing::warn!(
platform = %id,
known = ?kigi_models::PlatformId::ALL
@@ -793,6 +806,12 @@ pub struct PlatformCredentialConfig {
/// API key for this platform. NEVER logged; never re-serialized.
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
/// With `api`, declares a custom provider outside the registry.
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// `"openai"` or `"anthropic"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
}
/// Resolve the API key for an API-key registry platform with injected env
@@ -1582,6 +1601,7 @@ impl Config {
config.config_models = config_models;
config.model_override_warnings = model_override_warnings;
config.platforms.warn_unknown_platforms();
config.endpoints.custom_provider_bases = super::custom_providers::bases(&config.platforms);
if config.client_version.is_none() {
config.client_version = Self::default().client_version;
}
@@ -2569,6 +2589,13 @@ pub(crate) fn resolve_model_list(
tracing::debug!(count = defaults.len(), "loaded default models");
resolved.extend(defaults);
}
let prefetched = prefetched.map(|models| {
if cfg.endpoints.has_custom_endpoint() {
models
} else {
crate::agent::custom_providers::drop_orphans(models, platform_keys.custom())
}
});
if let Some(mut prefetched) = prefetched {
tracing::debug!(count = prefetched.len(), "loaded prefetched models");
let default_cw = DEFAULT_CONTEXT_WINDOW;
@@ -2676,6 +2703,7 @@ pub(crate) fn resolve_model_list(
apply_global_extra_headers(&mut resolved, &cfg.models);
apply_global_scalar_defaults(&mut resolved, &cfg.models);
apply_platform_credentials(&mut resolved, &cfg.platforms, platform_keys);
crate::agent::custom_providers::stamp_credentials(&mut resolved, platform_keys.custom());
for entry in resolved.values_mut() {
entry.info.derive_reasoning_effort_fields();
}
@@ -3932,6 +3960,17 @@ pub fn resolve_model_auth_facts(model_id: &str) -> ModelAuthFacts {
},
})
}
/// Auth facts of a held live-catalog entry.
pub(crate) fn auth_facts_of(entry: &ModelEntry) -> ModelAuthFacts {
ModelAuthFacts {
byok: if entry.has_own_credentials() {
ModelByok::Byok
} else {
ModelByok::NotByok
},
auth_scheme: entry.info().auth_scheme,
}
}
fn byok_from_lookup(lookup: &ModelLookup) -> ModelByok {
match lookup {
ModelLookup::ConfigUnavailable => ModelByok::Unknown,
@@ -5246,6 +5285,17 @@ reasoning_effort = "low"
auth_scheme: Default::default(),
}
}
/// Scheme and BYOK status come from the entry.
#[test]
fn auth_facts_come_from_the_held_entry() {
let mut model = test_model_entry("claude-x", "https://gw.example/v1", None, None, None);
model.info.auth_scheme = AuthScheme::XApiKey;
let facts = auth_facts_of(&model);
assert_eq!(facts.auth_scheme, AuthScheme::XApiKey);
assert_eq!(facts.byok, ModelByok::NotByok);
model.api_key = Some("sk-own".into());
assert_eq!(auth_facts_of(&model).byok, ModelByok::Byok);
}
#[test]
fn x_api_key_auth_scheme_flows_from_config_to_sampler() {
let mut model = test_model_entry(
@@ -9673,6 +9723,31 @@ default = "kigi-4.5"
"non-BYOK config overlay must preserve bundled supported_in_api=false"
);
}
/// Custom provider bases reach `EndpointsConfig` from both entry points.
#[test]
fn declared_custom_provider_bases_reach_the_endpoints() {
let raw: toml::Value = toml::from_str(
r#"
[platforms.proxy]
base_url = "http://localhost:11434/v1/"
api = "openai"
[platforms.broken]
api = "openai"
[platforms.moonshot-cn]
api_key = "sk-cn"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
assert_eq!(
cfg.endpoints.custom_provider_bases,
vec!["http://localhost:11434/v1".to_owned()]
);
assert_eq!(
EndpointsConfig::from_config_value(&raw).custom_provider_bases,
vec!["http://localhost:11434/v1".to_owned()]
);
}
/// PRD F2: a `[platforms.<id>].api_key` from config.toml is stamped onto
/// that platform's catalog entries (in-memory only), making them usable
/// and API-key-visible — and only onto that platform.
@@ -0,0 +1,450 @@
//! Listing fetch and key stamping for custom providers.
use indexmap::IndexMap;
use kigi_models::WireModel;
use kigi_models::custom::{CustomApi, CustomProvider};
use super::CredentialedProvider;
use crate::agent::config::{ModelEntry, ModelEntryConfig};
use crate::agent::models_fetch::{BackendError, wire_model_to_entry};
/// `GET {base}/models` for one provider; every model is keyed `{name}/{id}`.
pub(crate) fn fetch_models_blocking(
credentialed: &CredentialedProvider,
) -> Result<Vec<ModelEntryConfig>, BackendError> {
let provider = &credentialed.provider;
let url = provider.models_url();
tracing::info!(provider = %provider.name, api = provider.api.as_str(), %url, "fetching custom provider models");
let client = crate::http::shared_blocking_client();
let request = match provider.api {
CustomApi::OpenAi => client
.get(&url)
.header("Authorization", format!("Bearer {}", credentialed.key())),
CustomApi::Anthropic => client
.get(&url)
.header("x-api-key", credentialed.key())
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
};
let response = request.send()?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().unwrap_or_default();
return Err(BackendError::RequestFailed { status, body });
}
let body = response.text()?;
let listing = match provider.api {
CustomApi::OpenAi => kigi_models::parse_openai_listing(&body),
CustomApi::Anthropic => kigi_models::parse_anthropic_listing(&body),
};
let wire = listing.map_err(|e| BackendError::RequestFailed {
status: 200,
body: format!("{} listing parse failed: {e}", provider.api.as_str()),
})?;
if wire.is_empty() {
tracing::warn!(provider = %provider.name, "custom provider listed no models");
}
Ok(wire.into_iter().map(|w| entry(provider, w)).collect())
}
fn entry(provider: &CustomProvider, wire: WireModel) -> ModelEntryConfig {
let mut entry = wire_model_to_entry(
provider.managed_model_key(&wire.id),
provider.api.wire_api(),
provider.api.key_header(),
None,
true,
wire,
&provider.base_url,
);
entry.description = Some(provider.name.clone());
entry
}
/// Drops fetched entries of a provider that is gone or keyless.
pub(crate) fn drop_orphans(
prefetched: IndexMap<String, ModelEntry>,
keyed: &[CredentialedProvider],
) -> IndexMap<String, ModelEntry> {
prefetched
.into_iter()
.filter(|(key, entry)| {
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
let Some((prefix, _)) = id.split_once('/') else {
return true;
};
let known = kigi_models::PlatformId::parse(prefix).is_some()
|| keyed.iter().any(|k| k.provider.name == prefix);
if !known {
tracing::warn!(model_key = %key, "dropping a fetched entry of an unknown or keyless provider");
}
known
})
.collect()
}
/// Stamps each entry with its provider's key, in memory only.
pub(crate) fn stamp_credentials(
resolved: &mut IndexMap<String, ModelEntry>,
keyed: &[CredentialedProvider],
) {
for (key, entry) in resolved.iter_mut() {
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
let Some((name, _)) = id.split_once('/') else {
continue;
};
let Some(owner) = keyed.iter().find(|k| k.provider.name == name) else {
continue;
};
if entry.api_key.is_some() {
continue;
}
if !crate::util::matches_trusted_base_url(&entry.info.base_url, &owner.provider.base_url) {
tracing::warn!(
model_key = %key, provider = %name,
"entry points away from its custom provider; key not stamped"
);
continue;
}
entry.api_key = Some(owner.key().to_owned());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::models::{ModelFetchAuth, PlatformApiKeys};
use crate::agent::models_fetch::fetch_models_blocking as fetch_all;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn credentialed(name: &str, api: CustomApi, base: &str, key: &str) -> CredentialedProvider {
CredentialedProvider::for_test(CustomProvider::new(name, api, base).unwrap(), key)
}
async fn fetch_on_thread(
c: CredentialedProvider,
) -> Result<Vec<ModelEntryConfig>, BackendError> {
tokio::task::spawn_blocking(move || fetch_models_blocking(&c))
.await
.unwrap()
}
#[tokio::test(flavor = "multi_thread")]
async fn openai_listing_becomes_chat_completions_entries() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("authorization", "Bearer sk-proxy"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"object": "list",
"data": [{"id": "m1", "context_length": 32000}, {"id": "m2"}]
})))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/v1/", server.uri());
let models = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "sk-proxy"))
.await
.unwrap();
let ids: Vec<_> = models.iter().map(|m| m.id.as_deref().unwrap()).collect();
assert_eq!(ids, ["proxy/m1", "proxy/m2"]);
assert_eq!(models[0].model, "m1");
assert_eq!(models[0].context_window.get(), 32_000);
assert_eq!(
models[1].context_window.get(),
crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW
);
assert_eq!(models[0].base_url, format!("{}/v1", server.uri()));
assert_eq!(
models[0].api_backend,
crate::sampling::ApiBackend::ChatCompletions
);
assert_eq!(models[0].auth_scheme, None);
assert_eq!(models[0].description.as_deref(), Some("proxy"));
assert!(models[0].api_key.is_none() && models[0].env_key.is_none());
assert!(models[0].supported_in_api);
}
#[tokio::test(flavor = "multi_thread")]
async fn anthropic_listing_becomes_messages_entries() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(query_param("limit", "1000"))
.and(header("x-api-key", "sk-gw"))
.and(header(
"anthropic-version",
kigi_sampling_types::ANTHROPIC_VERSION,
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{
"id": "claude-x", "display_name": "Claude X",
"max_input_tokens": 200000, "max_tokens": 64000
}],
"has_more": false
})))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
let models = fetch_on_thread(credentialed("gw", CustomApi::Anthropic, &base, "sk-gw"))
.await
.unwrap();
assert_eq!(models.len(), 1);
let m = &models[0];
assert_eq!(m.id.as_deref(), Some("gw/claude-x"));
assert_eq!(m.name.as_deref(), Some("Claude X"));
assert_eq!(m.context_window.get(), 200_000);
assert_eq!(m.max_completion_tokens, Some(64_000));
assert_eq!(m.api_backend, crate::sampling::ApiBackend::Messages);
assert_eq!(m.auth_scheme, Some(kigi_sampler::AuthScheme::XApiKey));
}
#[tokio::test(flavor = "multi_thread")]
async fn listing_failures_surface_status_and_parse_errors() {
let server = MockServer::start().await;
Mock::given(path("/v1/models"))
.respond_with(ResponseTemplate::new(401).set_body_string("nope"))
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
let err = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "bad"))
.await
.unwrap_err();
assert!(
matches!(err, BackendError::RequestFailed { status: 401, .. }),
"{err}"
);
let server = MockServer::start().await;
Mock::given(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_string("not json"))
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
let err = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "k"))
.await
.unwrap_err();
assert!(
err.to_string().contains("openai listing parse failed"),
"{err}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn custom_providers_alone_satisfy_the_platform_fetch() {
let server = MockServer::start().await;
Mock::given(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "m1"}]
})))
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
let keys = PlatformApiKeys::test_custom(vec![credentialed(
"proxy",
CustomApi::OpenAi,
&base,
"sk-origin-secret",
)]);
let endpoints = crate::agent::config::EndpointsConfig::default();
let origin = crate::agent::models_fetch::models_fetch_origin(
&endpoints,
ModelFetchAuth::Platforms,
false,
&Default::default(),
&keys,
);
assert!(origin.contains(&format!("proxy={base}/models")), "{origin}");
assert!(
!origin.contains("sk"),
"the origin must never carry a key: {origin}"
);
let result = tokio::task::spawn_blocking(move || {
fetch_all(
&endpoints,
None,
&Default::default(),
ModelFetchAuth::Platforms,
&keys,
)
})
.await
.unwrap()
.expect("a custom provider is a complete catalog source");
assert_eq!(result.models.len(), 1);
assert_eq!(result.models[0].id.as_deref(), Some("proxy/m1"));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failing_custom_provider_alone_fails_the_fetch() {
let server = MockServer::start().await;
Mock::given(path("/v1/models"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
let keys = PlatformApiKeys::test_custom(vec![credentialed(
"proxy",
CustomApi::OpenAi,
&base,
"sk-origin-secret",
)]);
let err = tokio::task::spawn_blocking(move || {
fetch_all(
&crate::agent::config::EndpointsConfig::default(),
None,
&Default::default(),
ModelFetchAuth::Platforms,
&keys,
)
})
.await
.unwrap()
.err()
.expect("500 with no other source must fail");
assert!(
matches!(err, BackendError::RequestFailed { status: 500, .. }),
"{err}"
);
}
fn resolved_entry(provider: &CustomProvider, id: &str, base: &str) -> (String, ModelEntry) {
let wire: WireModel = serde_json::from_value(serde_json::json!({ "id": id })).unwrap();
let mut cfg = entry(provider, wire);
cfg.base_url = base.to_owned();
let key = cfg.id.clone().unwrap();
(key, ModelEntry::from_config_entry(&cfg))
}
#[test]
fn stamp_gives_each_entry_only_its_own_providers_key_on_its_own_host() {
let proxy =
CustomProvider::new("proxy", CustomApi::OpenAi, "https://p.example/v1").unwrap();
let gw = CustomProvider::new("gw", CustomApi::Anthropic, "https://g.example/v1").unwrap();
let mut resolved = IndexMap::new();
for (key, entry) in [
resolved_entry(&proxy, "m1", "https://p.example/v1"),
resolved_entry(&gw, "c1", "https://g.example/v1"),
resolved_entry(&proxy, "moved", "https://evil.example/v1"),
] {
resolved.insert(key, entry);
}
let (own_key, mut own) = resolved_entry(&proxy, "own", "https://p.example/v1");
own.api_key = Some("per-model".into());
resolved.insert(own_key, own);
let snapshot = vec![
CredentialedProvider::for_test(proxy, "sk-proxy"),
CredentialedProvider::for_test(gw, "sk-gw"),
];
stamp_credentials(&mut resolved, &snapshot);
assert_eq!(resolved["proxy/m1"].api_key.as_deref(), Some("sk-proxy"));
assert_eq!(resolved["gw/c1"].api_key.as_deref(), Some("sk-gw"));
assert_eq!(
resolved["proxy/moved"].api_key, None,
"off-host entry gets nothing"
);
assert_eq!(resolved["proxy/own"].api_key.as_deref(), Some("per-model"));
}
#[test]
fn resolve_model_list_stamps_fetched_custom_entries() {
let raw: toml::Value = toml::from_str(
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\n",
)
.unwrap();
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
let proxy =
CustomProvider::new("proxy", CustomApi::OpenAi, "https://p.example/v1").unwrap();
let (key, entry) = resolved_entry(&proxy, "m1", "https://p.example/v1");
let keys =
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
let resolved = crate::agent::config::resolve_model_list(
&cfg,
Some(IndexMap::from([(key, entry)])),
&keys,
);
let m = resolved
.get("proxy/m1")
.expect("fetched entry survives resolution");
assert_eq!(m.api_key.as_deref(), Some("sk-proxy"));
assert!(m.has_own_credentials());
assert!(m.visible_for_auth(false), "API-key users see the entry");
assert_eq!(
m.info.api_backend,
crate::sampling::ApiBackend::ChatCompletions
);
}
#[test]
fn orphaned_custom_entries_never_enter_the_catalog() {
let proxy =
CustomProvider::new("proxy", CustomApi::OpenAi, "https://p.example/v1").unwrap();
let gone = CustomProvider::new("gone", CustomApi::OpenAi, "https://g.example/v1").unwrap();
let mut prefetched = IndexMap::new();
for (key, entry) in [
resolved_entry(&proxy, "m1", "https://p.example/v1"),
resolved_entry(&gone, "m2", "https://g.example/v1"),
] {
prefetched.insert(key, entry);
}
let registry: WireModel =
serde_json::from_value(serde_json::json!({ "id": "kimi-k2-turbo-preview" })).unwrap();
let registry = ModelEntry::from_config_entry(
&crate::agent::models_fetch::platform_wire_model_to_entry(
kigi_models::PlatformId::MoonshotCn,
registry,
"https://api.moonshot.cn/v1",
),
);
prefetched.insert("moonshot-cn/kimi-k2-turbo-preview".into(), registry);
let keyed = vec![CredentialedProvider::for_test(proxy, "sk-proxy")];
let cfg = crate::agent::config::Config::default();
let resolved = crate::agent::config::resolve_model_list(
&cfg,
Some(prefetched.clone()),
&PlatformApiKeys::test_custom(keyed.clone()),
);
let ids: Vec<_> = resolved.keys().map(String::as_str).collect();
assert_eq!(ids, ["proxy/m1", "moonshot-cn/kimi-k2-turbo-preview"]);
let unkeyed =
crate::agent::config::resolve_model_list(&cfg, Some(prefetched), &Default::default());
assert!(
!unkeyed.contains_key("proxy/m1") && !unkeyed.contains_key("gone/m2"),
"a keyless provider leaves no entries behind"
);
}
#[test]
fn custom_endpoint_mode_keeps_slashed_remote_ids() {
let raw: toml::Value =
toml::from_str("[endpoints]\nmodels_base_url = \"https://m.example/v1\"\n").unwrap();
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
let other = CustomProvider::new("org", CustomApi::OpenAi, "https://o.example/v1").unwrap();
let (key, entry) = resolved_entry(&other, "llama", "https://o.example/v1");
let resolved = crate::agent::config::resolve_model_list(
&cfg,
Some(IndexMap::from([(key, entry)])),
&Default::default(),
);
assert!(
resolved.contains_key("org/llama"),
"{:?}",
resolved.keys().collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,171 @@
//! Login for custom providers: picker rows, method ids, key validation.
use agent_client_protocol as acp;
use kigi_models::custom::CustomApi;
use super::CredentialedProvider;
const PICKER_PREFIX: &str = "custom-";
const LOGIN_PREFIX: &str = "custom:";
const ALL_APIS: [CustomApi; 2] = [CustomApi::OpenAi, CustomApi::Anthropic];
/// The picker row for one wire.
pub fn picker_method(api: CustomApi) -> acp::AuthMethod {
acp::AuthMethod::Agent(
acp::AuthMethodAgent::new(
acp::AuthMethodId::new(format!("{PICKER_PREFIX}{}", api.as_str())),
format!("Custom provider ({})", api.label()),
)
.description(Some("Your own base URL and API key".to_owned())),
)
}
/// Both picker rows, in picker order.
pub fn picker_methods() -> Vec<acp::AuthMethod> {
ALL_APIS.into_iter().map(picker_method).collect()
}
/// The wire behind a picker row id.
pub fn picker_api(id: &str) -> Option<CustomApi> {
let suffix = id.strip_prefix(PICKER_PREFIX)?;
ALL_APIS.into_iter().find(|api| api.as_str() == suffix)
}
/// The id `authenticate` takes once the provider is saved.
pub fn login_method_id(name: &str) -> acp::AuthMethodId {
acp::AuthMethodId::new(format!("{LOGIN_PREFIX}{name}"))
}
/// The provider name inside an `authenticate` method id.
pub fn login_provider_name(id: &str) -> Option<&str> {
id.strip_prefix(LOGIN_PREFIX)
}
/// Probes the listing endpoint; errors never hold the key.
pub(crate) async fn validate_key(credentialed: &CredentialedProvider) -> Result<(), String> {
let provider = &credentialed.provider;
let url = provider.models_url();
let client = crate::http::shared_client();
let request = match provider.api {
CustomApi::OpenAi => client
.get(&url)
.header("Authorization", format!("Bearer {}", credentialed.key())),
CustomApi::Anthropic => client
.get(&url)
.header("x-api-key", credentialed.key())
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
};
let response = request
.send()
.await
.map_err(|e| format!("Couldn't reach {url}: {e}"))?;
let status = response.status().as_u16();
match status {
200..=299 => Ok(()),
401 | 403 => Err(format!(
"{} rejected the API key (HTTP {status})",
provider.name
)),
_ => Err(format!("{url} answered HTTP {status}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_models::custom::CustomProvider;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn credentialed(api: CustomApi, base: &str, key: &str) -> CredentialedProvider {
CredentialedProvider::for_test(CustomProvider::new("proxy", api, base).unwrap(), key)
}
#[test]
fn picker_ids_round_trip_and_reject_lookalikes() {
for api in ALL_APIS {
let method = picker_method(api);
assert_eq!(picker_api(method.id().0.as_ref()), Some(api));
}
assert_eq!(picker_api("custom-responses"), None);
assert_eq!(picker_api("custom-OpenAI"), None);
assert_eq!(picker_api("custom:proxy"), None);
assert_eq!(picker_api("moonshot-cn"), None);
assert_eq!(picker_methods().len(), 2);
}
#[test]
fn login_ids_carry_the_provider_name() {
let id = login_method_id("my-proxy");
assert_eq!(id.0.as_ref(), "custom:my-proxy");
assert_eq!(login_provider_name(id.0.as_ref()), Some("my-proxy"));
assert_eq!(login_provider_name("custom-openai"), None);
assert_eq!(login_provider_name("proxy"), None);
}
#[tokio::test]
async fn validation_sends_the_dialects_key_header() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("authorization", "Bearer sk-openai"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
validate_key(&credentialed(CustomApi::OpenAi, &base, "sk-openai"))
.await
.unwrap();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("x-api-key", "sk-anthropic"))
.and(header(
"anthropic-version",
kigi_sampling_types::ANTHROPIC_VERSION,
))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
validate_key(&credentialed(CustomApi::Anthropic, &base, "sk-anthropic"))
.await
.unwrap();
}
#[tokio::test]
async fn validation_names_the_failure_without_the_key() {
let server = MockServer::start().await;
for (status, path_suffix) in [(401, "a"), (403, "b"), (404, "c"), (500, "d")] {
Mock::given(path(format!("/{path_suffix}/models")))
.respond_with(ResponseTemplate::new(status))
.mount(&server)
.await;
}
let check = |suffix: &str| {
let base = format!("{}/{suffix}", server.uri());
async move {
validate_key(&credentialed(CustomApi::OpenAi, &base, "sk-secret-key"))
.await
.unwrap_err()
}
};
assert!(check("a").await.contains("rejected the API key (HTTP 401)"));
assert!(check("b").await.contains("rejected the API key (HTTP 403)"));
assert!(check("c").await.contains("answered HTTP 404"));
assert!(check("d").await.contains("answered HTTP 500"));
let err = validate_key(&credentialed(
CustomApi::OpenAi,
"http://127.0.0.1:1/v1",
"sk-secret-key",
))
.await
.unwrap_err();
assert!(err.contains("Couldn't reach"), "{err}");
assert!(!err.contains("sk-secret-key"), "{err}");
}
}
@@ -0,0 +1,229 @@
//! `[platforms.<name>]` tables that declare custom providers.
use kigi_models::custom::{CustomApi, CustomProvider};
use crate::agent::config::{PlatformCredentialConfig, PlatformsConfig};
mod fetch;
mod login;
mod save;
pub(crate) use fetch::{drop_orphans, fetch_models_blocking, stamp_credentials};
pub(crate) use login::validate_key;
pub use login::{login_method_id, login_provider_name, picker_api, picker_methods};
pub use save::save_custom_provider;
/// Tables setting `base_url` or `api`, valid or not.
fn declarations(platforms: &PlatformsConfig) -> Vec<(&str, Result<CustomProvider, String>)> {
platforms
.entries
.iter()
.filter(|(_, entry)| entry.base_url.is_some() || entry.api.is_some())
.map(|(name, entry)| (name.as_str(), parse_declaration(name, entry)))
.collect()
}
fn parse_declaration(
name: &str,
entry: &PlatformCredentialConfig,
) -> Result<CustomProvider, String> {
let api = entry
.api
.as_deref()
.ok_or("api is required (\"openai\" or \"anthropic\")")?;
let api = CustomApi::parse(api)
.ok_or_else(|| format!("api {api:?} is not \"openai\" or \"anthropic\""))?;
let base_url = entry.base_url.as_deref().ok_or("base_url is required")?;
CustomProvider::new(name, api, base_url).map_err(|e| e.to_string())
}
/// The valid declarations.
pub(crate) fn providers(platforms: &PlatformsConfig) -> Vec<CustomProvider> {
declarations(platforms)
.into_iter()
.filter_map(|(_, parsed)| parsed.ok())
.collect()
}
/// Base URLs of the valid declarations.
pub(crate) fn bases(platforms: &PlatformsConfig) -> Vec<String> {
providers(platforms)
.into_iter()
.map(|p| p.base_url)
.collect()
}
/// True when the table declares a provider, valid or not.
pub(crate) fn is_declared(entry: &PlatformCredentialConfig) -> bool {
entry.base_url.is_some() || entry.api.is_some()
}
/// Warns once per refused declaration; the table stays inert.
pub(crate) fn warn_invalid_declarations(platforms: &PlatformsConfig) {
for (name, parsed) in declarations(platforms) {
if let Err(reason) = parsed {
tracing::warn!(platform = %name, %reason, "[platforms.{name}] is not a valid custom provider; ignored");
}
}
}
/// A declared provider with a key; `Debug` hides the key.
#[derive(Clone)]
pub(crate) struct CredentialedProvider {
pub(crate) provider: CustomProvider,
key: String,
}
impl CredentialedProvider {
pub(crate) fn key(&self) -> &str {
&self.key
}
#[cfg(test)]
pub(crate) fn for_test(provider: CustomProvider, key: &str) -> Self {
Self {
provider,
key: key.to_owned(),
}
}
}
impl std::fmt::Debug for CredentialedProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CredentialedProvider")
.field("provider", &self.provider)
.field("key", &"<set>")
.finish()
}
}
/// Key order: auth.json scope, then the table's `api_key`.
pub(crate) fn credentialed(
platforms: &PlatformsConfig,
stored: impl Fn(&str) -> Option<String>,
) -> Vec<CredentialedProvider> {
providers(platforms)
.into_iter()
.filter_map(|provider| {
let key = stored(&provider.name)
.filter(|k| !k.trim().is_empty())
.or_else(|| {
platforms
.entries
.get(&provider.name)
.and_then(|e| e.api_key.clone())
.filter(|k| !k.trim().is_empty())
})?;
Some(CredentialedProvider { provider, key })
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn platforms(toml_src: &str) -> PlatformsConfig {
toml::from_str(toml_src).expect("platforms table parses")
}
#[test]
fn declarations_read_api_and_base_url() {
let cfg = platforms(
r#"
[proxy]
base_url = "https://h.example/v1/"
api = "openai"
[gw]
base_url = "https://g.example/v1"
api = "Anthropic"
"#,
);
let found = providers(&cfg);
assert_eq!(found.len(), 2);
assert_eq!(found[0].name, "proxy");
assert_eq!(found[0].api, CustomApi::OpenAi);
assert_eq!(found[0].base_url, "https://h.example/v1");
assert_eq!(found[1].api, CustomApi::Anthropic);
}
#[test]
fn invalid_declarations_are_refused_with_a_reason() {
let cfg = platforms(
r#"
[no-api]
base_url = "https://h.example/v1"
[no-url]
api = "openai"
[bad-api]
base_url = "https://h.example/v1"
api = "responses"
[bad-url]
base_url = "h.example"
api = "openai"
[openai]
base_url = "https://h.example/v1"
api = "openai"
[Upper]
base_url = "https://h.example/v1"
api = "openai"
"#,
);
assert!(providers(&cfg).is_empty());
let reasons: Vec<String> = declarations(&cfg)
.into_iter()
.map(|(_, parsed)| parsed.expect_err("every declaration is invalid"))
.collect();
assert_eq!(reasons.len(), 6);
assert!(reasons[0].contains("api is required"));
assert!(reasons[1].contains("base_url is required"));
assert!(reasons[2].contains("responses"));
assert!(reasons[3].contains("base_url"));
assert!(reasons[4].contains("built-in platform id"));
assert!(reasons[5].contains("provider name"));
}
#[test]
fn a_table_with_only_a_key_is_not_a_declaration() {
let cfg = platforms("[moonshot-cn]\napi_key = \"sk-x\"\n");
assert!(declarations(&cfg).is_empty());
assert!(!is_declared(&cfg.entries["moonshot-cn"]));
}
#[test]
fn key_prefers_auth_json_over_config_and_blank_is_unset() {
let cfg = platforms(
r#"
[proxy]
base_url = "https://h.example/v1"
api = "openai"
api_key = "from-config"
[blank]
base_url = "https://b.example/v1"
api = "openai"
api_key = " "
[keyless]
base_url = "https://k.example/v1"
api = "openai"
"#,
);
let stored = |name: &str| (name == "proxy").then(|| "from-auth-json".to_owned());
let found = credentialed(&cfg, stored);
assert_eq!(found.len(), 1);
assert_eq!(found[0].provider.name, "proxy");
assert_eq!(found[0].key(), "from-auth-json");
let found = credentialed(&cfg, |_| None);
assert_eq!(found.len(), 1);
assert_eq!(found[0].key(), "from-config");
}
#[test]
fn debug_never_prints_the_key() {
let cfg = platforms(
"[proxy]\nbase_url = \"https://h.example/v1\"\napi = \"openai\"\napi_key = \"sk-secret\"\n",
);
let found = credentialed(&cfg, |_| None);
assert!(!format!("{found:?}").contains("sk-secret"));
}
}
@@ -0,0 +1,169 @@
use anyhow::{Context, Result, anyhow};
use kigi_models::custom::CustomProvider;
use toml::Value as TomlValue;
use toml::map::Map as TomlMap;
/// Writes `[platforms.<name>]` to config.toml and the key to auth.json.
pub async fn save_custom_provider(provider: &CustomProvider, api_key: &str) -> Result<()> {
let provider = provider.clone();
let api_key = api_key.to_owned();
let _guard = crate::util::config::SAVE_LOCK.lock().await;
tokio::task::spawn_blocking(move || {
save_custom_provider_in(
&crate::util::config::user_config_path(),
&crate::util::kigi_home::kigi_home(),
&provider,
&api_key,
)
})
.await
.map_err(|e| anyhow!("custom provider save task: {e}"))?
}
/// Config first; a keyless definition is inert.
pub(crate) fn save_custom_provider_in(
config_path: &std::path::Path,
kigi_home: &std::path::Path,
provider: &CustomProvider,
api_key: &str,
) -> Result<()> {
let api_key = api_key.trim();
anyhow::ensure!(!api_key.is_empty(), "API key must not be empty");
let raw = crate::util::config::read_to_string_or_empty(config_path)
.with_context(|| format!("reading {}", config_path.display()))?;
let mut root: TomlValue = toml::from_str(&raw).map_err(|e| {
anyhow!(
"refusing to overwrite unparseable {}: {e}; fix the syntax error first",
config_path.display()
)
})?;
upsert_definition(&mut root, provider)?;
let dest = crate::util::config::config_write_dest(config_path)?;
crate::util::config::atomic_write_string(&dest, &toml::to_string_pretty(&root)?)
.with_context(|| format!("writing {}", dest.display()))?;
crate::auth::store_scoped_api_key(kigi_home, &provider.name, api_key)
.with_context(|| format!("saving the key for {} to auth.json", provider.name))?;
tracing::info!(
provider = %provider.name,
api = provider.api.as_str(),
base_url = %provider.base_url,
"custom provider saved"
);
Ok(())
}
fn upsert_definition(root: &mut TomlValue, provider: &CustomProvider) -> Result<()> {
let top = root
.as_table_mut()
.ok_or_else(|| anyhow!("config root is not a table"))?;
let platforms = top
.entry("platforms")
.or_insert_with(|| TomlValue::Table(TomlMap::new()))
.as_table_mut()
.ok_or_else(|| anyhow!("[platforms] is not a table"))?;
let entry = platforms
.entry(provider.name.as_str())
.or_insert_with(|| TomlValue::Table(TomlMap::new()))
.as_table_mut()
.ok_or_else(|| anyhow!("[platforms.{}] is not a table", provider.name))?;
entry.insert("api".into(), provider.api.as_str().into());
entry.insert("base_url".into(), provider.base_url.as_str().into());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_models::custom::CustomApi;
fn provider(name: &str, api: CustomApi, url: &str) -> CustomProvider {
CustomProvider::new(name, api, url).unwrap()
}
#[test]
fn save_writes_definition_and_key_and_keeps_the_rest() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.toml");
std::fs::write(
&config,
"[ui]\ntheme = \"kiginight\"\n\n[platforms.moonshot-cn]\napi_key = \"sk-cn\"\n\n[platforms.proxy]\napi_key = \"hand-written\"\n",
)
.unwrap();
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1/");
save_custom_provider_in(&config, dir.path(), &p, " sk-new ").unwrap();
let saved: TomlValue = toml::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
assert_eq!(saved["ui"]["theme"].as_str(), Some("kiginight"));
assert_eq!(
saved["platforms"]["moonshot-cn"]["api_key"].as_str(),
Some("sk-cn")
);
let entry = &saved["platforms"]["proxy"];
assert_eq!(entry["api"].as_str(), Some("openai"));
assert_eq!(entry["base_url"].as_str(), Some("https://h.example/v1"));
assert_eq!(entry["api_key"].as_str(), Some("hand-written"));
let stored = crate::auth::read_auth_json(&dir.path().join("auth.json")).unwrap();
assert_eq!(stored["proxy"].key, "sk-new");
}
#[test]
fn save_creates_config_and_a_second_save_rewrites_the_definition() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.toml");
save_custom_provider_in(
&config,
dir.path(),
&provider("gw", CustomApi::OpenAi, "https://a.example/v1"),
"k1",
)
.unwrap();
save_custom_provider_in(
&config,
dir.path(),
&provider("gw", CustomApi::Anthropic, "https://b.example/v1"),
"k2",
)
.unwrap();
let saved: TomlValue = toml::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
assert_eq!(saved["platforms"]["gw"]["api"].as_str(), Some("anthropic"));
assert_eq!(
saved["platforms"]["gw"]["base_url"].as_str(),
Some("https://b.example/v1")
);
let stored = crate::auth::read_auth_json(&dir.path().join("auth.json")).unwrap();
assert_eq!(stored["gw"].key, "k2");
}
#[test]
fn empty_key_and_broken_config_fail_before_any_write() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.toml");
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1");
assert!(save_custom_provider_in(&config, dir.path(), &p, " ").is_err());
assert!(!config.exists());
assert!(!dir.path().join("auth.json").exists());
std::fs::write(&config, "[platforms\nnope").unwrap();
let err = save_custom_provider_in(&config, dir.path(), &p, "sk").unwrap_err();
assert!(err.to_string().contains("unparseable"), "{err}");
assert_eq!(
std::fs::read_to_string(&config).unwrap(),
"[platforms\nnope"
);
assert!(!dir.path().join("auth.json").exists());
}
#[test]
fn a_non_table_platforms_value_is_refused() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.toml");
std::fs::write(&config, "platforms = \"x\"\n").unwrap();
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1");
let err = save_custom_provider_in(&config, dir.path(), &p, "sk").unwrap_err();
assert!(err.to_string().contains("not a table"), "{err}");
assert!(!dir.path().join("auth.json").exists());
}
}
@@ -4,6 +4,7 @@ pub mod auth_method;
pub mod chat_modes;
pub mod config;
pub mod config_model_override_parse;
pub mod custom_providers;
pub(crate) mod enrichment_fetch;
mod ext_parsers;
pub(crate) mod feedback_client;
+29 -3
View File
@@ -73,6 +73,7 @@ enum CacheAuthMethod {
#[derive(Clone, Default)]
pub(crate) struct PlatformApiKeys {
keys: std::collections::BTreeMap<kigi_models::PlatformId, String>,
custom: Vec<super::custom_providers::CredentialedProvider>,
}
impl std::fmt::Debug for PlatformApiKeys {
@@ -83,6 +84,7 @@ impl std::fmt::Debug for PlatformApiKeys {
s.field(platform.as_str(), &self.keys.contains_key(&platform));
}
}
s.field("custom", &self.custom);
s.finish()
}
}
@@ -114,7 +116,13 @@ impl PlatformApiKeys {
keys.insert(platform, key);
}
}
Self { keys }
let custom = super::custom_providers::credentialed(platforms, |name| {
stored
.as_ref()
.and_then(|m| m.get(name))
.map(|a| a.key.clone())
});
Self { keys, custom }
}
/// Resolve from the effective on-disk config (startup paths that have no
@@ -135,7 +143,11 @@ impl PlatformApiKeys {
/// Any API-key platform credentialed? Drives "should we prefetch without
/// a session" and the F2 acceptance path (platform key only, no login).
pub(crate) fn any(&self) -> bool {
!self.keys.is_empty()
!self.keys.is_empty() || !self.custom.is_empty()
}
pub(crate) fn custom(&self) -> &[super::custom_providers::CredentialedProvider] {
&self.custom
}
/// Test-only constructor (fields are private to this module).
@@ -148,7 +160,18 @@ impl PlatformApiKeys {
if let Some(k) = ai {
keys.insert(kigi_models::PlatformId::MoonshotAi, k.to_owned());
}
Self { keys }
Self {
keys,
custom: Vec::new(),
}
}
#[cfg(test)]
pub(crate) fn test_custom(custom: Vec<super::custom_providers::CredentialedProvider>) -> Self {
Self {
keys: Default::default(),
custom,
}
}
/// Test-only constructor for a single API-key platform.
@@ -156,6 +179,7 @@ impl PlatformApiKeys {
pub(crate) fn test_single(platform: kigi_models::PlatformId, key: &str) -> Self {
Self {
keys: std::collections::BTreeMap::from([(platform, key.to_owned())]),
custom: Vec::new(),
}
}
}
@@ -3698,12 +3722,14 @@ mod tests {
"moonshot-cn".into(),
config::PlatformCredentialConfig {
api_key: Some("cfg-cn".into()),
..Default::default()
},
);
platforms.entries.insert(
"moonshot-ai".into(),
config::PlatformCredentialConfig {
api_key: Some("cfg-ai".into()),
..Default::default()
},
);
@@ -114,6 +114,12 @@ pub(crate) fn models_fetch_origin(
let parts: Vec<String> = enabled_platforms(has_oauth, oauth_tokens, platform_keys)
.into_iter()
.map(|p| format!("{}={}", p.as_str(), platform_models_url(p, endpoints)))
.chain(
platform_keys
.custom()
.iter()
.map(|c| format!("{}={}", c.provider.name, c.provider.models_url())),
)
.collect();
format!("platforms[{}]", parts.join(";"))
}
@@ -265,7 +271,7 @@ fn fetch_platform_models_blocking(
platform_keys: &crate::agent::models::PlatformApiKeys,
) -> Result<FetchModelsResult, BackendError> {
let enabled = enabled_platforms(auth.is_some(), oauth_tokens, platform_keys);
if enabled.is_empty() {
if enabled.is_empty() && platform_keys.custom().is_empty() {
return Err(BackendError::Auth(
"No platform credentials: log in with `kigi login`, paste a platform API key in \
the login screen (stored in ~/.kigi/auth.json), or set a platform env var such \
@@ -330,6 +336,28 @@ fn fetch_platform_models_blocking(
}
}
for credentialed in platform_keys.custom() {
match crate::agent::custom_providers::fetch_models_blocking(credentialed) {
Ok(custom_models) => {
tracing::info!(
provider = %credentialed.provider.name,
count = custom_models.len(),
"custom provider models fetch succeeded"
);
successes += 1;
models.extend(custom_models);
}
Err(e) => {
tracing::warn!(
provider = %credentialed.provider.name,
error = %e,
"custom provider models fetch failed"
);
last_error = Some(e);
}
}
}
if successes == 0 {
// All enabled platforms failed. When the failure includes an OAuth
// 401, return `Ok` with the flag set (and no models) so the async
@@ -613,6 +641,37 @@ pub(crate) fn platform_wire_model_to_entry(
platform: kigi_models::PlatformId,
wire: kigi_models::WireModel,
base_url: &str,
) -> crate::agent::config::ModelEntryConfig {
let env_key = (!platform.uses_oauth())
.then(|| crate::agent::config::EnvKeys::new(platform.api_key_env_names().iter().copied()));
// `supported_in_api: false` hides a model unless the PRIMARY session is
// an OAuth session (`ModelInfo::visible_for_auth`). Only `kimi-code` rides
// that primary session, so only it may be gated on it. Every other OAuth
// platform (claude-pro-max, openai-codex, github-copilot, xai-grok) carries
// its OWN pooled credential and its models only enter the catalog once THAT
// provider is signed in — gating them on the Kimi session would hide every
// model from a user who signed in with only a Claude/ChatGPT/Copilot/Grok
// subscription.
wire_model_to_entry(
platform.managed_model_key(&wire.id),
platform.wire_api(),
platform.key_header(),
env_key,
platform != kigi_models::PlatformId::KimiCode,
wire,
base_url,
)
}
/// Wire-to-catalog mapping shared by platforms and custom providers.
pub(crate) fn wire_model_to_entry(
id: String,
wire_api: kigi_models::PlatformWireApi,
key_header: kigi_models::PlatformKeyHeader,
env_key: Option<crate::agent::config::EnvKeys>,
supported_in_api: bool,
wire: kigi_models::WireModel,
base_url: &str,
) -> crate::agent::config::ModelEntryConfig {
let capabilities = wire.capabilities();
// Selectable thinking levels (live wire `think_efforts`, e.g. K3's
@@ -626,21 +685,19 @@ pub(crate) fn platform_wire_model_to_entry(
);
std::num::NonZeroU64::new(DEFAULT_CONTEXT_WINDOW).expect("non-zero")
});
let env_key = (!platform.uses_oauth())
.then(|| crate::agent::config::EnvKeys::new(platform.api_key_env_names().iter().copied()));
let api_backend = match platform.wire_api() {
let api_backend = match wire_api {
kigi_models::PlatformWireApi::ChatCompletions => {
crate::sampling::ApiBackend::ChatCompletions
}
kigi_models::PlatformWireApi::Responses => crate::sampling::ApiBackend::Responses,
kigi_models::PlatformWireApi::Messages => crate::sampling::ApiBackend::Messages,
};
let auth_scheme = match platform.key_header() {
let auth_scheme = match key_header {
kigi_models::PlatformKeyHeader::Bearer => None,
kigi_models::PlatformKeyHeader::XApiKey => Some(kigi_sampler::AuthScheme::XApiKey),
};
crate::agent::config::ModelEntryConfig {
id: Some(platform.managed_model_key(&wire.id)),
id: Some(id),
name: Some(wire.display_name.clone().unwrap_or_else(|| wire.id.clone())),
model: wire.id,
base_url: base_url.to_owned(),
@@ -674,15 +731,7 @@ pub(crate) fn platform_wire_model_to_entry(
inference_idle_timeout_secs: None,
max_retries: None,
hidden: false,
// `supported_in_api: false` hides a model unless the PRIMARY session is
// an OAuth session (`ModelInfo::visible_for_auth`). Only `kimi-code`
// rides that primary session, so only it may be gated on it. Every
// other OAuth platform (claude-pro-max, openai-codex, github-copilot,
// xai-grok) carries its OWN pooled credential, and its models only
// enter the catalog once THAT provider is signed in — gating them on
// the Kimi session would hide every model from a user who signed in
// with only a Claude/ChatGPT/Copilot/Grok subscription.
supported_in_api: platform != kigi_models::PlatformId::KimiCode,
supported_in_api,
supports_backend_search: false,
compactions_remaining: None,
compaction_at_tokens: None,
@@ -1473,7 +1522,7 @@ mod tests {
/// openai-codex fetch: the catalog is HARDCODED, so the fetch path
/// short-circuits BEFORE any HTTP — there is NO mock `/models` server, yet
/// the fetch returns exactly the 5 compiled-in models keyed
/// the fetch returns exactly the 7 compiled-in models keyed
/// `openai-codex/<slug>` on the Responses backend, ctx 272000, each exposing
/// its exact reasoning efforts (incl. the codex-only `xhigh`/`max`).
/// A BOGUS base URL confirms no live `/models` request is attempted (it would
@@ -1510,13 +1559,16 @@ mod tests {
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec![
"openai-codex/gpt-6.1-sol",
"openai-codex/gpt-6-astra",
"openai-codex/gpt-6-sol",
"openai-codex/gpt-6-luna",
"openai-codex/gpt-5.6-sol",
"openai-codex/gpt-5.6-terra",
"openai-codex/gpt-5.6-luna",
"openai-codex/gpt-5.5",
],
"exactly the 5 hardcoded models, keyed openai-codex/<slug>"
"exactly the 8 hardcoded models, keyed openai-codex/<slug>"
);
// Excluded models never appear.
for absent in ["openai-codex/gpt-reserve", "openai-codex/codex-auto-review"] {
@@ -1528,9 +1580,9 @@ mod tests {
"{absent} must be absent from the hardcoded catalog"
);
}
let astra = &result.models[0];
assert_eq!(astra.name.as_deref(), Some("GPT-6-Astra"));
assert_eq!(astra.context_window.get(), 272_000);
let first = &result.models[0];
assert_eq!(first.name.as_deref(), Some("GPT-6.1-Sol"));
assert_eq!(first.context_window.get(), 272_000);
let sol = result
.models
.iter()
@@ -557,6 +557,11 @@ impl acp::Agent for MvpAgent {
.oauth_platform()
{
self.authenticate_oauth_platform(platform, arguments).await
} else if let Some(name) = crate::agent::custom_providers::login_provider_name(
arguments.method_id.0.as_ref(),
) {
self.authenticate_custom_provider(name, arguments.method_id.clone())
.await
} else if let Some(platform) =
auth_method::platform_for_method_id(&arguments.method_id)
{
@@ -526,6 +526,43 @@ impl MvpAgent {
Some("platform_key_invalid_or_missing"),
);
})?;
Ok(self.finish_api_key_login(method_id).await)
}
/// `authenticate(custom:<name>)`: validate the saved provider's key.
pub(super) async fn authenticate_custom_provider(
&self,
name: &str,
method_id: acp::AuthMethodId,
) -> Result<AuthenticateResponse, acp::Error> {
let keys =
crate::agent::models::PlatformApiKeys::resolve_from_effective_config();
let auth_err = |message: String| {
let mut err = acp::Error::auth_required();
err.message = message;
err
};
let outcome = match keys.custom().iter().find(|c| c.provider.name == name) {
Some(credentialed) => {
crate::agent::custom_providers::validate_key(credentialed).await
}
None => Err(format!(
"No custom provider named {name} with an API key in config.toml or auth.json"
)),
};
if let Err(message) = outcome {
tracing::warn!(provider = name, %message, "custom provider login failed");
emit_login_span(
false,
method_id.0.as_ref(),
None,
Some("custom_provider_invalid_or_missing"),
);
return Err(auth_err(message));
}
Ok(self.finish_api_key_login(method_id).await)
}
/// Shared tail of an API-key login.
async fn finish_api_key_login(&self, method_id: acp::AuthMethodId) -> AuthenticateResponse {
// Rebuild the catalog from the on-disk config: the rebuild freshly
// resolves platform keys (env > auth.json > config), so the key just
// persisted to auth.json is stamped onto the platform's entries; a
@@ -557,7 +594,7 @@ impl MvpAgent {
let meta = serde_json::to_value(auth_meta)
.ok()
.and_then(|v| v.as_object().cloned());
Ok(AuthenticateResponse::new().meta(meta))
AuthenticateResponse::new().meta(meta)
}
/// `authenticate(<generic-oauth platform id>)`: interactive device-code
/// login for a `uses_oauth` platform carrying an `OAuthConfig` (xai-grok).
@@ -879,6 +879,20 @@ fn log_subagent_model_resolution(
)),
);
}
/// The parent model's auth scheme from the live catalog.
fn parent_auth_scheme(
available: &indexmap::IndexMap<String, crate::agent::config::ModelEntry>,
catalog_id: &acp::ModelId,
slug: &str,
) -> kigi_sampler::AuthScheme {
crate::agent::models::entry_for_slug(available, Some(catalog_id.0.as_ref()), slug)
.map(|entry| crate::agent::config::auth_facts_of(entry).auth_scheme)
.or_else(|| {
crate::agent::config::try_resolve_model_credentials(slug, None).map(|r| r.auth_scheme)
})
.unwrap_or_default()
}
/// Read the parent session's actual current sampling config.
///
/// Prefers the live state from `ChatStateHandle` (authoritative). Falls back
@@ -897,9 +911,7 @@ async fn read_parent_sampling_config(
creds.alpha_test_key.as_deref(),
&cfg.base_url,
);
let auth_scheme = crate::agent::config::try_resolve_model_credentials(&cfg.model, None)
.map(|r| r.auth_scheme)
.unwrap_or_default();
let auth_scheme = parent_auth_scheme(&ctx.available_models, &ctx.model_id, &cfg.model);
// Claude Pro/Max OAuth Messages adaptation inherits from the parent
// model's platform (claude-pro-max → true); every other platform,
// and BYOK, → false, so the API-key paths stay byte-identical.
@@ -3322,3 +3322,24 @@ async fn parent_ack_is_bounded_when_the_parent_never_answers() {
.expect("parent acks must complete in bounded time");
assert!(!acked, "an unanswered ack must report a miss");
}
/// A fetched Messages model exists only in the live catalog.
#[test]
fn a_fetched_entry_gives_its_own_scheme_to_the_child() {
let wire: kigi_models::WireModel =
serde_json::from_value(serde_json::json!({ "id": "claude-x" })).unwrap();
let cfg = crate::agent::models_fetch::wire_model_to_entry(
"gw/claude-x".into(),
kigi_models::PlatformWireApi::Messages,
kigi_models::PlatformKeyHeader::XApiKey,
None,
true,
wire,
"https://gw.example/v1",
);
let available = indexmap::IndexMap::from([(
"gw/claude-x".to_owned(),
crate::agent::config::ModelEntry::from_config_entry(&cfg),
)]);
let scheme = parent_auth_scheme(&available, &acp::ModelId::new("gw/claude-x"), "claude-x");
assert_eq!(scheme, kigi_sampler::AuthScheme::XApiKey);
}
@@ -202,6 +202,15 @@ impl CredentialAuthority {
/// Deliberately NOT [`crate::util::is_first_party_url`], which is
/// production-only and would break every custom deployment.
fn is_session_coding_endpoint(&self, base_url: &str) -> bool {
// Declared custom providers are never the session's endpoint.
if self
.endpoints
.custom_provider_bases
.iter()
.any(|base| crate::util::matches_trusted_base_url(base_url, base))
{
return false;
}
if crate::util::is_effective_coding_endpoint_url(base_url) {
return true;
}
@@ -422,6 +431,38 @@ mod tests {
}
}
/// LEAK guard: a declared loopback custom provider never gets the primary.
#[test]
fn declared_custom_provider_hosts_never_receive_the_primary() {
let (_d, kimi) = primary("kimi-tok");
let endpoints = EndpointsConfig {
custom_provider_bases: vec![
"http://localhost:11434/v1".to_owned(),
"https://gw.example/v1".to_owned(),
],
..EndpointsConfig::default()
};
let auth = authority(endpoints, kimi);
for url in [
"http://localhost:11434/v1",
"http://localhost:11434/v1/chat/completions",
"https://gw.example/v1",
] {
assert_eq!(
auth.credential_class(None, url),
CredentialClass::None,
"{url}"
);
assert!(auth.credential_for(None, url).is_none(), "{url}");
assert!(auth.manager_for(None, url).is_none(), "{url}");
}
assert_eq!(
auth.credential_class(None, "http://localhost:3000/v1"),
CredentialClass::Primary,
"an undeclared loopback proxy keeps the session bearer"
);
}
/// LEAK guard: every API-key registry platform, and any platform-less model
/// on a third-party host, gets NO session credential and NO manager.
#[test]
@@ -32,6 +32,7 @@ pub use manager::{AuthManager, shared_api_key_provider};
pub use meta::AuthMeta;
pub use model::{AuthMode, KimiAuth, lookup_auth};
pub(crate) use model::{TOKEN_TTL, is_expired, token_suffix};
pub(crate) use storage::store_scoped_api_key;
pub use storage::{
clear_api_key, read_api_key, read_auth_json, read_platform_api_key, read_token_by_scope,
store_api_key, store_platform_api_key,
+10 -1
View File
@@ -619,6 +619,15 @@ pub fn store_platform_api_key(
),
));
}
store_scoped_api_key(kigi_home, platform.as_str(), api_key)
}
/// Stores a key under `scope`; callers own the name.
pub(crate) fn store_scoped_api_key(
kigi_home: &Path,
scope: &str,
api_key: &str,
) -> std::io::Result<()> {
let path = kigi_home.join("auth.json");
// Serialize with the manager's cross-process auth.json writers (token
// refresh holds the same flock): an unlocked read-modify-write here
@@ -641,7 +650,7 @@ pub fn store_platform_api_key(
};
let mut map = read_auth_json_or_empty_recovering_corrupt(&path)?;
map.insert(
platform.as_str().to_owned(),
scope.to_owned(),
KimiAuth {
key: api_key.to_owned(),
auth_mode: AuthMode::ApiKey,
@@ -1605,6 +1605,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn describe_requirements_file_flags_invalid_version_overrides_as_parse_error() {
// Valid TOML but invalid `[[version_overrides]]` is rejected by the real
// loader, so it must read "parse error", not "empty".
@@ -264,6 +264,16 @@ impl SessionActor {
fn aux_model_auth_facts(&self, model_id: &str) -> crate::agent::config::ModelAuthFacts {
self.resolve_auth_facts(model_id, false)
}
/// Facts from the live catalog entry the session samples against.
fn live_auth_facts(&self, model_id: &str) -> Option<crate::agent::config::ModelAuthFacts> {
let models = self.models_manager.models();
crate::agent::models::entry_for_slug(
&models,
self.selected_catalog_key().as_deref(),
model_id,
)
.map(crate::agent::config::auth_facts_of)
}
/// Shared body of [`Self::model_auth_facts`] / [`Self::aux_model_auth_facts`].
/// `memoize` is the ONLY difference, so the two can never resolve differently.
fn resolve_auth_facts(
@@ -278,7 +288,9 @@ impl SessionActor {
{
return *facts;
}
let fresh = crate::agent::config::resolve_model_auth_facts(model_id);
let fresh = self
.live_auth_facts(model_id)
.unwrap_or_else(|| crate::agent::config::resolve_model_auth_facts(model_id));
if fresh.byok == ModelByok::Unknown {
if let Some((cached_id, facts)) = self.model_auth_facts.borrow().as_ref()
&& cached_id == model_id
@@ -9,7 +9,7 @@ use toml::map::Map as TomlMap;
///
/// Serializes the read-modify-write in `save_config` so two rapid
/// settings toggles can't interleave and clobber each other.
static SAVE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub(crate) static SAVE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub async fn save_config(config: &Config) -> Result<()> {
let _guard = SAVE_LOCK.lock().await;
+11 -1
View File
@@ -570,6 +570,10 @@ pub enum Action {
/// under the platform-id scope, then authenticate with the platform's
/// method id. The key must never be logged.
SubmitPlatformApiKey(String),
/// Custom provider row picked: open the URL step.
BeginCustomProviderEntry(kigi_shell::models::custom::CustomApi),
/// Enter in a custom provider step.
SubmitCustomProviderInput(String),
/// Copy the auth URL to the clipboard during authentication.
CopyAuthUrl,
/// Show the raw auth URL with mouse capture disabled for manual copy.
@@ -1258,7 +1262,7 @@ pub enum ProbedAttachment {
PersistFailed(String),
/// The pasteboard probe completed normally without raster data.
NoRaster,
/// The attachment result was intentionally discarded because its baseline went stale.
/// The attachment result was discarded: stale baseline, or the paste was not the clipboard.
ProbeDropped,
/// The attachment probe task failed or timed out.
ProbeFailed,
@@ -1614,6 +1618,12 @@ pub enum Effect {
target: crate::app::app_view::PlatformLogin,
key: String,
},
/// Saves the provider, then authenticates; never log the key.
PersistCustomProviderAndAuthenticate {
request_seq: u64,
provider: kigi_shell::models::custom::CustomProvider,
key: String,
},
/// Fetch MCP server list from the shell (kigi/mcp/list).
FetchMcpsList {
agent_id: AgentId,
@@ -260,6 +260,26 @@ impl AgentView {
}
self.prompt.set_text("");
}
fn cancel_plan_comment(&mut self) -> InputOutcome {
let stashed = if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Preview;
pav.editing_comment_id = None;
pav.commenting_range = None;
pav.stashed_feedback_prompt.take()
} else {
None
};
if let Some(stashed) = stashed {
self.prompt.restore(stashed);
} else {
self.prompt.set_text("");
}
InputOutcome::Changed
}
/// Ctrl+C on an empty draft; the first one clears it.
fn ctrl_c_cancels_empty_draft(&self, key: &KeyEvent) -> bool {
crate::key!('c', CONTROL).matches(key) && self.prompt.text().is_empty()
}
pub(super) fn handle_plan_feedback_key(&mut self, key: &KeyEvent) -> InputOutcome {
let is_commenting = self
.plan_approval_view
@@ -297,26 +317,16 @@ impl AgentView {
return InputOutcome::Changed;
}
if is_commenting {
let stashed = if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Preview;
pav.editing_comment_id = None;
pav.commenting_range = None;
pav.stashed_feedback_prompt.take()
} else {
None
};
if let Some(stashed) = stashed {
self.prompt.restore(stashed);
} else {
self.prompt.set_text("");
}
return InputOutcome::Changed;
return self.cancel_plan_comment();
}
if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Preview;
}
return InputOutcome::Changed;
}
if is_commenting && self.ctrl_c_cancels_empty_draft(key) {
return self.cancel_plan_comment();
}
match self.prompt.route_enter(key) {
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
EnterOutcome::Submit => {
@@ -569,6 +579,9 @@ impl AgentView {
}
return self.cancel_casual_plan_commenting();
}
if self.ctrl_c_cancels_empty_draft(key) {
return self.cancel_casual_plan_commenting();
}
match self.prompt.route_enter(key) {
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
EnterOutcome::Submit => return self.save_casual_plan_comment(),
@@ -823,3 +836,52 @@ mod plan_chip_tests {
));
}
}
#[cfg(test)]
mod plan_comment_cancel_tests {
use super::test_fixtures::make_agent;
use super::*;
fn ctrl_c() -> KeyEvent {
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
}
fn stash_of(text: &str) -> crate::views::prompt_widget::StashedPrompt {
let mut stash = crate::views::prompt_widget::StashedPrompt::default();
stash.text = text.to_owned();
stash
}
#[test]
fn second_ctrl_c_cancels_a_plan_approval_comment() {
let mut agent = make_agent();
let mut pav =
crate::app::agent_view::paste::paste_key_tests::make_plan_approval_view_state();
pav.focus = PlanApprovalFocus::Commenting;
pav.commenting_range = Some(0..1);
pav.stashed_feedback_prompt = Some(stash_of("freeform notes"));
agent.plan_approval_view = Some(pav);
agent.prompt.set_text("half a comment");
let _ = agent.handle_plan_feedback_key(&ctrl_c());
assert_eq!(agent.prompt.text(), "");
assert_eq!(
agent.plan_approval_view.as_ref().map(|p| p.focus),
Some(PlanApprovalFocus::Commenting),
"the first Ctrl+C only clears the draft"
);
let _ = agent.handle_plan_feedback_key(&ctrl_c());
let pav = agent.plan_approval_view.as_ref().unwrap();
assert_eq!(pav.focus, PlanApprovalFocus::Preview);
assert!(pav.commenting_range.is_none());
assert_eq!(agent.prompt.text(), "freeform notes");
}
#[test]
fn second_ctrl_c_cancels_a_casual_plan_comment() {
let mut agent = make_agent();
agent.casual_commenting_range = Some(0..1);
agent.casual_stashed_prompt = Some(stash_of("earlier draft"));
agent.prompt.set_text("half a comment");
let _ = agent.handle_casual_plan_feedback_key(&ctrl_c());
assert_eq!(agent.prompt.text(), "");
assert!(agent.casual_commenting_range.is_some());
let _ = agent.handle_casual_plan_feedback_key(&ctrl_c());
assert!(agent.casual_commenting_range.is_none());
assert_eq!(agent.prompt.text(), "earlier draft");
}
}
+55 -8
View File
@@ -262,6 +262,8 @@ pub enum AuthMode {
/// Open-platform API-key entry: paste box for a Moonshot key selected
/// from the welcome login picker. Esc returns to the picker (no quit).
ApiKeyEntry(PlatformLogin),
/// Custom provider login, one field per step.
CustomProviderEntry(crate::app::custom_entry::CustomEntryStep),
}
/// API-key platform login target, selected from the welcome picker. Wraps a
/// non-OAuth registry platform ([`kigi_shell::models::PlatformId`]);
@@ -310,6 +312,11 @@ pub enum PendingMenuItem {
target: PlatformLogin,
label: String,
},
/// Custom provider login for one wire.
CustomProvider {
api: kigi_shell::models::custom::CustomApi,
label: String,
},
Quit,
}
impl PendingMenuItem {
@@ -324,7 +331,9 @@ impl PendingMenuItem {
}
pub fn label(&self) -> &str {
match self {
Self::Login { label, .. } | Self::ApiKey { label, .. } => label,
Self::Login { label, .. }
| Self::ApiKey { label, .. }
| Self::CustomProvider { label, .. } => label,
Self::Quit => "Quit",
}
}
@@ -366,7 +375,12 @@ pub fn pending_menu_items(
use kigi_shell::agent::auth_method::AuthMethodKind;
let mut items: Vec<PendingMenuItem> = Vec::new();
for method in auth_methods {
if let Some(target) = PlatformLogin::from_method_id(method.id()) {
if let Some(api) = kigi_shell::agent::custom_providers::picker_api(method.id().0.as_ref()) {
items.push(PendingMenuItem::CustomProvider {
api,
label: method.name().to_string(),
});
} else if let Some(target) = PlatformLogin::from_method_id(method.id()) {
items.push(PendingMenuItem::ApiKey {
target,
label: method.name().to_string(),
@@ -908,6 +922,8 @@ pub struct AppView {
pub auth_start_mode: AuthMode,
/// Text buffer for manual auth token paste (loopback mode).
pub auth_code_input: String,
/// Values collected by the custom provider login steps.
pub custom_entry: crate::app::custom_entry::CustomEntryDraft,
/// Monotonically increasing sequence number for auth requests.
pub next_auth_request_seq: u64,
/// Every session/chat/worktree/prompt action deferred behind startup gates.
@@ -1111,6 +1127,7 @@ impl AppView {
login_method_id: None,
auth_start_mode: AuthMode::Pending,
auth_code_input: String::new(),
custom_entry: Default::default(),
next_auth_request_seq: 1,
deferred_startup: Default::default(),
auth_use_oauth: false,
@@ -2648,7 +2665,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
return InputOutcome::Unchanged;
}
AuthState::Authenticating {
mode: AuthMode::ApiKeyEntry(_),
mode: mode @ (AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_)),
..
} => {
// Esc cancels BACK TO THE PICKER (unlike the OAuth flows,
@@ -2664,6 +2681,9 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
}
if key!(Enter).matches(key) {
let trimmed = ctx.auth_code_input.trim().to_string();
if matches!(mode, AuthMode::CustomProviderEntry(_)) {
return InputOutcome::Action(Action::SubmitCustomProviderInput(trimmed));
}
if !trimmed.is_empty() {
return InputOutcome::Action(Action::SubmitPlatformApiKey(trimmed));
}
@@ -2726,7 +2746,8 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
return InputOutcome::ActionThenForward(Action::NewSession);
}
AuthState::Authenticating {
mode: AuthMode::Loopback | AuthMode::ApiKeyEntry(_),
mode:
AuthMode::Loopback | AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
..
} => {
let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
@@ -2889,6 +2910,9 @@ fn dispatch_pending_menu_action(
Some(PendingMenuItem::ApiKey { target, .. }) => {
InputOutcome::Action(Action::BeginPlatformKeyEntry(*target))
}
Some(PendingMenuItem::CustomProvider { api, .. }) => {
InputOutcome::Action(Action::BeginCustomProviderEntry(*api))
}
Some(PendingMenuItem::Quit) if mid_session => InputOutcome::Action(Action::CancelLogin),
Some(PendingMenuItem::Quit) => InputOutcome::Action(Action::Quit),
None => InputOutcome::Unchanged,
@@ -3223,6 +3247,7 @@ impl AppView {
auth_methods: &self.auth_methods,
login_label: self.login_label.as_deref(),
auth_code_input: &self.auth_code_input,
custom_entry: &self.custom_entry,
clipboard_copied: self.auth_clipboard_copied,
show_raw_url: self.auth_show_raw_url,
tip,
@@ -4358,6 +4383,7 @@ pub(crate) mod tests {
login_method_id: None,
auth_start_mode: AuthMode::Pending,
auth_code_input: String::new(),
custom_entry: Default::default(),
next_auth_request_seq: 1,
deferred_startup: Default::default(),
auth_use_oauth: false,
@@ -7005,7 +7031,7 @@ pub(crate) mod tests {
#[test]
fn pending_menu_items_lists_interactive_methods_plus_quit() {
let items = pending_menu_items(&fresh_user_auth_methods(), None);
assert_eq!(items.len(), 30, "29 login rows + Quit, got {items:?}");
assert_eq!(items.len(), 32, "31 login rows + Quit, got {items:?}");
assert_login_row(&items[0], "kimi-code", "Kimi Code (OAuth)");
assert_login_row(&items[1], "xai-grok", "xAI Grok (subscription) (OAuth)");
assert_login_row(
@@ -7192,7 +7218,28 @@ pub(crate) mod tests {
label: "MiniMax China (API key)".into(),
}
);
assert_eq!(items[29], PendingMenuItem::Quit);
assert_eq!(
items[29],
PendingMenuItem::CustomProvider {
api: kigi_shell::models::custom::CustomApi::OpenAi,
label: "Custom provider (OpenAI compatible)".into(),
}
);
assert_eq!(
items[30],
PendingMenuItem::CustomProvider {
api: kigi_shell::models::custom::CustomApi::Anthropic,
label: "Custom provider (Anthropic compatible)".into(),
}
);
assert_eq!(items[31], PendingMenuItem::Quit);
assert!(!items[29].connected(&fresh_user_auth_methods()));
assert!(matches!(
dispatch_pending_menu_action(&items, 30, false),
InputOutcome::Action(Action::BeginCustomProviderEntry(
kigi_shell::models::custom::CustomApi::Anthropic
))
));
// The non-interactive methods must never appear as rows.
let byok = kigi_shell::agent::auth_method::build_auth_methods(
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
@@ -7203,8 +7250,8 @@ pub(crate) mod tests {
);
assert_eq!(
pending_menu_items(&byok.methods, None).len(),
30,
"xai.api_key / cached_token must not add rows (29 login rows + Quit)"
32,
"xai.api_key / cached_token must not add rows (31 login rows + Quit)"
);
}
/// Startup lands on the picker only when there is a real choice: the
@@ -0,0 +1,239 @@
//! Custom provider login steps: base URL, name, key.
use kigi_shell::models::custom::{
CustomApi, CustomProvider, default_name, normalize_base_url, validate_name,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CustomEntryField {
BaseUrl,
Name,
Key,
}
/// Which field the input box holds, for which wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CustomEntryStep {
pub api: CustomApi,
pub field: CustomEntryField,
}
impl CustomEntryStep {
pub fn first(api: CustomApi) -> Self {
Self {
api,
field: CustomEntryField::BaseUrl,
}
}
/// Only the key is masked.
pub fn masks_input(self) -> bool {
self.field == CustomEntryField::Key
}
pub fn placeholder(self) -> &'static str {
match self.field {
CustomEntryField::BaseUrl => "https://host/v1",
CustomEntryField::Name => "provider name",
CustomEntryField::Key => "Paste your API key here...",
}
}
}
/// What one Enter press did.
#[derive(Debug, PartialEq, Eq)]
pub enum CustomEntryOutcome {
/// The input stays; the draft error says why.
Stay,
/// Next field, with this text in the box.
Next {
field: CustomEntryField,
prefill: String,
},
/// Every field is valid.
Done {
provider: CustomProvider,
key: String,
},
}
/// Values collected so far, and the last refusal.
#[derive(Debug, Default, Clone)]
pub struct CustomEntryDraft {
base_url: String,
name: String,
pub error: Option<String>,
}
impl CustomEntryDraft {
pub const fn new() -> Self {
Self {
base_url: String::new(),
name: String::new(),
error: None,
}
}
pub fn clear(&mut self) {
*self = Self::new();
}
pub fn instruction(&self, step: CustomEntryStep) -> String {
match step.field {
CustomEntryField::BaseUrl => format!(
"Base URL of the {} API, for example https://host/v1",
step.api.label()
),
CustomEntryField::Name => {
"Name for this provider. Its models appear as name/model".to_owned()
}
CustomEntryField::Key => format!(
"API key for {}. A local server that ignores auth takes any value",
self.name
),
}
}
pub fn submit(&mut self, step: CustomEntryStep, input: &str) -> CustomEntryOutcome {
let input = input.trim();
if input.is_empty() {
self.error = None;
return CustomEntryOutcome::Stay;
}
self.error = None;
match step.field {
CustomEntryField::BaseUrl => match normalize_base_url(input) {
Ok(url) => {
let prefill = default_name(&url);
self.base_url = url;
CustomEntryOutcome::Next {
field: CustomEntryField::Name,
prefill,
}
}
Err(e) => self.refuse(e),
},
CustomEntryField::Name => match validate_name(input) {
Ok(()) => {
self.name = input.to_owned();
CustomEntryOutcome::Next {
field: CustomEntryField::Key,
prefill: String::new(),
}
}
Err(e) => self.refuse(e),
},
CustomEntryField::Key => {
match CustomProvider::new(&self.name, step.api, &self.base_url) {
Ok(provider) => CustomEntryOutcome::Done {
provider,
key: input.to_owned(),
},
Err(e) => self.refuse(e),
}
}
}
}
fn refuse(&mut self, reason: impl std::fmt::Display) -> CustomEntryOutcome {
self.error = Some(reason.to_string());
CustomEntryOutcome::Stay
}
}
#[cfg(test)]
mod tests {
use super::*;
fn step(field: CustomEntryField) -> CustomEntryStep {
CustomEntryStep {
api: CustomApi::Anthropic,
field,
}
}
#[test]
fn three_valid_steps_build_the_provider() {
let mut draft = CustomEntryDraft::default();
let next = draft.submit(
step(CustomEntryField::BaseUrl),
" https://api.gw.example/v1/ ",
);
assert_eq!(
next,
CustomEntryOutcome::Next {
field: CustomEntryField::Name,
prefill: "gw-example".to_owned()
}
);
let next = draft.submit(step(CustomEntryField::Name), "gw");
assert_eq!(
next,
CustomEntryOutcome::Next {
field: CustomEntryField::Key,
prefill: String::new()
}
);
assert!(
draft
.instruction(step(CustomEntryField::Key))
.contains("API key for gw")
);
let done = draft.submit(step(CustomEntryField::Key), " sk-1 ");
let CustomEntryOutcome::Done { provider, key } = done else {
panic!("expected Done, got {done:?}");
};
assert_eq!(provider.name, "gw");
assert_eq!(provider.api, CustomApi::Anthropic);
assert_eq!(provider.base_url, "https://api.gw.example/v1");
assert_eq!(key, "sk-1");
assert!(draft.error.is_none());
}
#[test]
fn bad_input_stays_on_the_field_with_a_reason() {
let mut draft = CustomEntryDraft::default();
assert_eq!(
draft.submit(step(CustomEntryField::BaseUrl), "host.example"),
CustomEntryOutcome::Stay
);
assert!(draft.error.as_deref().is_some_and(|e| e.contains("http")));
draft.submit(step(CustomEntryField::BaseUrl), "https://h.example/v1");
assert_eq!(
draft.submit(step(CustomEntryField::Name), "OpenAI"),
CustomEntryOutcome::Stay
);
assert!(draft.error.is_some());
assert_eq!(
draft.submit(step(CustomEntryField::Name), "openai"),
CustomEntryOutcome::Stay
);
assert!(
draft
.error
.as_deref()
.is_some_and(|e| e.contains("built-in"))
);
}
#[test]
fn empty_input_stays_and_clears_the_last_error() {
let mut draft = CustomEntryDraft::default();
draft.submit(step(CustomEntryField::BaseUrl), "nope");
assert!(draft.error.is_some());
assert_eq!(
draft.submit(step(CustomEntryField::BaseUrl), " "),
CustomEntryOutcome::Stay
);
assert!(draft.error.is_none());
}
#[test]
fn only_the_key_is_masked() {
assert!(step(CustomEntryField::Key).masks_input());
assert!(!step(CustomEntryField::BaseUrl).masks_input());
assert!(!step(CustomEntryField::Name).masks_input());
}
}
@@ -8,6 +8,7 @@ use crate::app::actions::{Action, Effect};
use crate::app::agent::AgentId;
use crate::app::agent_view::AgentView;
use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState, PlatformLogin};
use crate::app::custom_entry::{CustomEntryOutcome, CustomEntryStep};
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
@@ -325,6 +326,73 @@ pub(super) fn dispatch_begin_platform_key_entry(
vec![]
}
/// Custom provider row selected: open the URL step.
pub(super) fn dispatch_begin_custom_provider_entry(
app: &mut AppView,
api: kigi_shell::models::custom::CustomApi,
) -> Vec<Effect> {
let request_seq = app.next_auth_request_seq;
app.next_auth_request_seq += 1;
app.auth_code_input.clear();
app.custom_entry.clear();
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
auth_url: None,
mode: AuthMode::CustomProviderEntry(CustomEntryStep::first(api)),
};
vec![]
}
/// Enter in a custom provider step.
pub(super) fn dispatch_submit_custom_provider_input(
app: &mut AppView,
input: String,
) -> Vec<Effect> {
let (request_seq, step) = match &app.auth_state {
AuthState::Authenticating {
request_seq,
mode: AuthMode::CustomProviderEntry(step),
..
} => (*request_seq, *step),
_ => return vec![],
};
match app.custom_entry.submit(step, &input) {
CustomEntryOutcome::Stay => vec![],
CustomEntryOutcome::Next { field, prefill } => {
app.auth_code_input = prefill;
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
auth_url: None,
mode: AuthMode::CustomProviderEntry(CustomEntryStep {
api: step.api,
field,
}),
};
vec![]
}
CustomEntryOutcome::Done { provider, key } => {
app.auth_in_flight_method = Some(kigi_shell::agent::custom_providers::login_method_id(
&provider.name,
));
app.custom_entry.clear();
app.auth_code_input.clear();
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
};
vec![Effect::PersistCustomProviderAndAuthenticate {
request_seq,
provider,
key,
}]
}
}
}
/// Esc in the API-key paste box: back to the login picker (no error line).
/// Bumps the request seq so any stale in-flight auth result is dropped by
/// the `AuthComplete`/`AuthFailed` guards.
@@ -332,7 +400,7 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
if !matches!(
app.auth_state,
AuthState::Authenticating {
mode: AuthMode::ApiKeyEntry(_),
mode: AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
..
}
) {
@@ -340,6 +408,7 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
}
app.next_auth_request_seq += 1;
app.auth_code_input.clear();
app.custom_entry.clear();
app.auth_state = AuthState::Pending { error: None };
vec![]
}
@@ -1,8 +1,9 @@
//! Top-level action router: maps actions and action results to handlers.
use super::auth::{
dispatch_begin_platform_key_entry, dispatch_cancel_login, dispatch_cancel_platform_key_entry,
dispatch_login, dispatch_login_with, dispatch_logout, dispatch_open_login_picker,
dispatch_submit_auth_code, dispatch_submit_platform_api_key, dispatch_switch_account,
dispatch_begin_custom_provider_entry, dispatch_begin_platform_key_entry, dispatch_cancel_login,
dispatch_cancel_platform_key_entry, dispatch_login, dispatch_login_with, dispatch_logout,
dispatch_open_login_picker, dispatch_submit_auth_code, dispatch_submit_custom_provider_input,
dispatch_submit_platform_api_key, dispatch_switch_account,
};
use super::ctx::{
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
@@ -889,6 +890,10 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::BeginPlatformKeyEntry(target) => dispatch_begin_platform_key_entry(app, target),
Action::CancelPlatformKeyEntry => dispatch_cancel_platform_key_entry(app),
Action::SubmitPlatformApiKey(key) => dispatch_submit_platform_api_key(app, key),
Action::BeginCustomProviderEntry(api) => dispatch_begin_custom_provider_entry(app, api),
Action::SubmitCustomProviderInput(input) => {
dispatch_submit_custom_provider_input(app, input)
}
Action::CopyAuthUrl => {
if let AuthState::Authenticating {
auth_url: Some(url),
@@ -571,3 +571,140 @@ fn auth_complete_marks_the_authenticated_method_connected() {
"other methods must stay unmarked"
);
}
fn custom_field(app: &AppView) -> crate::app::custom_entry::CustomEntryField {
match &app.auth_state {
AuthState::Authenticating {
mode: AuthMode::CustomProviderEntry(step),
..
} => step.field,
other => panic!("expected a custom provider step, got {other:?}"),
}
}
/// URL, name, key; the last Enter emits one persist effect.
#[test]
fn custom_provider_steps_end_in_one_persist_effect() {
use crate::app::custom_entry::CustomEntryField;
use kigi_shell::models::custom::CustomApi;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
let effects = dispatch(
Action::BeginCustomProviderEntry(CustomApi::Anthropic),
&mut app,
);
assert!(effects.is_empty());
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
let seq = match &app.auth_state {
AuthState::Authenticating { request_seq, .. } => *request_seq,
_ => unreachable!(),
};
dispatch(
Action::SubmitCustomProviderInput("https://api.gw.example/v1/".into()),
&mut app,
);
assert_eq!(custom_field(&app), CustomEntryField::Name);
assert_eq!(
app.auth_code_input, "gw-example",
"the name is prefilled from the host"
);
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
assert_eq!(custom_field(&app), CustomEntryField::Key);
assert!(app.auth_code_input.is_empty(), "the key box starts empty");
let effects = dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
match effects.as_slice() {
[
Effect::PersistCustomProviderAndAuthenticate {
request_seq,
provider,
key,
},
] => {
assert_eq!(*request_seq, seq);
assert_eq!(provider.name, "gw");
assert_eq!(provider.api, CustomApi::Anthropic);
assert_eq!(provider.base_url, "https://api.gw.example/v1");
assert_eq!(key, "sk-gw");
}
other => panic!("expected exactly the persist effect, got {other:?}"),
}
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
request_seq,
mode: AuthMode::Pending,
..
} if request_seq == seq
));
assert_eq!(
app.auth_in_flight_method.as_ref().map(|m| m.0.as_ref()),
Some("custom:gw")
);
assert!(app.auth_code_input.is_empty());
}
/// A refused field keeps the user on it.
#[test]
fn custom_provider_refusals_stay_on_the_field() {
use crate::app::custom_entry::CustomEntryField;
use kigi_shell::models::custom::CustomApi;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
&mut app,
);
assert!(
dispatch(
Action::SubmitCustomProviderInput("host.example".into()),
&mut app
)
.is_empty()
);
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
assert!(app.custom_entry.error.is_some());
dispatch(
Action::SubmitCustomProviderInput("https://h.example/v1".into()),
&mut app,
);
assert!(
dispatch(
Action::SubmitCustomProviderInput("anthropic".into()),
&mut app
)
.is_empty()
);
assert_eq!(custom_field(&app), CustomEntryField::Name);
assert!(app.custom_entry.error.is_some());
}
/// Esc returns to the picker and drops the draft.
#[test]
fn cancel_custom_provider_entry_returns_to_picker() {
use kigi_shell::models::custom::CustomApi;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
&mut app,
);
dispatch(Action::SubmitCustomProviderInput("nope".into()), &mut app);
assert!(app.custom_entry.error.is_some());
let seq_before = app.next_auth_request_seq;
let effects = dispatch(Action::CancelPlatformKeyEntry, &mut app);
assert!(effects.is_empty());
assert!(matches!(app.auth_state, AuthState::Pending { error: None }));
assert!(app.custom_entry.error.is_none());
assert!(app.next_auth_request_seq > seq_before);
}
@@ -126,6 +126,7 @@ fn test_app() -> AppView {
login_method_id: None,
auth_start_mode: AuthMode::Pending,
auth_code_input: String::new(),
custom_entry: Default::default(),
next_auth_request_seq: 1,
deferred_startup: Default::default(),
auth_use_oauth: false,
@@ -1103,6 +1103,27 @@ pub(super) fn parse_usage_response(
.map(|usage| usage.rows)
.map_err(|e| format!("Parse error: {e}"))
}
/// `Some` ends a clipboard probe before any attachment read.
pub(super) fn bracketed_origin_verdict(
probe_bracketed: bool,
payload: Option<&str>,
) -> Option<crate::app::actions::ProbedAttachment> {
use crate::app::actions::ProbedAttachment;
if !probe_bracketed {
return None;
}
match crate::clipboard::bracketed_payload_came_from_clipboard_result(payload.unwrap_or("")) {
Ok(true) => None,
Ok(false) => {
tracing::debug!("bracketed paste differs from the clipboard; no attachment probe");
Some(ProbedAttachment::ProbeDropped)
}
Err(_) => {
tracing::debug!("clipboard unreadable for a bracketed paste; no attachment probe");
Some(ProbedAttachment::ProbeDropped)
}
}
}
/// A blocking flock on the shared, possibly-network `~/.kigi` lock must never
/// stall the event-loop thread (and would hang exit on `/quit`); the registry
/// is best-effort, so skip on contention.
+34 -12
View File
@@ -1637,18 +1637,11 @@ pub(crate) fn execute(
{
return (ProbedAttachment::ProbeDropped, None);
}
if probe_bracketed
&& crate::terminal::terminal_context()
.brand
.delivers_ime_as_bracketed_paste()
{
match crate::clipboard::bracketed_payload_came_from_clipboard_result(
probe_text.as_deref().unwrap_or(""),
) {
Ok(true) => {}
Ok(false) => return (ProbedAttachment::ProbeDropped, None),
Err(_) => return (ProbedAttachment::ProbeFailed, None),
}
if let Some(verdict) = helpers::bracketed_origin_verdict(
probe_bracketed,
probe_text.as_deref(),
) {
return (verdict, None);
}
let (image_data, file_urls) = match crate::clipboard::system_clipboard_probe_attachments(
probe_text.as_deref(),
@@ -1918,6 +1911,35 @@ pub(crate) fn execute(
});
meta.auth_abort_handle = Some((request_seq, abort_handle));
}
Effect::PersistCustomProviderAndAuthenticate {
request_seq,
provider,
key,
} => {
let tx = acp_tx.clone();
let abort_handle = tasks.spawn(async move {
// Persist first so authenticate finds the definition and key.
if let Err(e) =
kigi_shell::agent::custom_providers::save_custom_provider(&provider, &key)
.await
{
let error = format!("Couldn't save the provider: {e:#}");
ulog::error(
"custom provider persist failed",
None,
Some(serde_json::json!({ "error" : & error })),
);
return TaskResult::AuthFailed {
request_seq,
error,
};
}
let method_id =
kigi_shell::agent::custom_providers::login_method_id(&provider.name);
send_authenticate(&tx, request_seq, method_id, false, false).await
});
meta.auth_abort_handle = Some((request_seq, abort_handle));
}
Effect::SubmitAuthCode { request_seq, code } => {
let tx = acp_tx.clone();
tasks
@@ -1664,3 +1664,37 @@ fn session_picker_entry_maps_to_dormant_roster_row() {
assert_eq!(roster.origin.kind, "local");
assert_eq!(roster.origin.host.as_deref(), Some("box"));
}
fn origin_verdict_with_clipboard(
clipboard: Option<&str>,
read_failed: bool,
probe_bracketed: bool,
payload: &str,
) -> Option<crate::app::actions::ProbedAttachment> {
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
text: clipboard.map(str::to_owned),
text_read_failed: read_failed,
..Default::default()
});
let verdict = bracketed_origin_verdict(probe_bracketed, Some(payload));
crate::clipboard::clear_clipboard_probe_hook();
verdict
}
#[test]
fn bracketed_paste_that_is_not_the_clipboard_drops_the_probe() {
let verdict = origin_verdict_with_clipboard(Some("copied text"), false, true, "typed by ime");
assert!(matches!(verdict, Some(crate::app::actions::ProbedAttachment::ProbeDropped)));
}
#[test]
fn bracketed_paste_matching_the_clipboard_keeps_probing() {
assert!(origin_verdict_with_clipboard(Some("copied text"), false, true, "copied text").is_none());
}
#[test]
fn unreadable_clipboard_drops_a_bracketed_probe_without_an_error() {
let verdict = origin_verdict_with_clipboard(None, true, true, "anything");
assert!(matches!(verdict, Some(crate::app::actions::ProbedAttachment::ProbeDropped)));
}
#[test]
fn clipboard_key_probe_skips_the_origin_check() {
assert!(origin_verdict_with_clipboard(Some("copied text"), false, false, "other").is_none());
}
+1
View File
@@ -15,6 +15,7 @@ pub mod agent_view;
pub mod app_view;
pub mod bundle;
pub mod cli;
pub mod custom_entry;
pub use crate::link_opener;
/// Off-thread full-file syntax highlight upgrade for edit diffs.
pub mod edit_highlight_worker;
+45 -9
View File
@@ -106,8 +106,16 @@ impl AgentView {
return Some(false);
}
// Clean edit — silently exit editing mode.
// The exit cleared the overlay flip; restore it.
self.exit_editing_mode();
self.active_pane = target;
self.set_active_pane(target, true);
match target {
AgentPane::Queue => self.queue.overlay.focused = true,
AgentPane::Todo => self.todo.overlay.focused = true,
AgentPane::Tasks => self.tasks.overlay.focused = true,
AgentPane::Catalog => self.catalog.overlay.focused = true,
_ => {}
}
return Some(true);
}
None
@@ -487,7 +495,7 @@ impl AgentView {
self.show_toast("Queued prompt is no longer in the queue");
}
/// Exit editing mode: restore stashed text, clear mode, focus queue pane.
/// Exit editing mode: restore stashed text, clear mode, focus the composer.
/// No-op unless `EditingQueued`.
///
/// Always resets `prompt_input_mode` to `Normal` so it doesn't leak
@@ -513,13 +521,8 @@ impl AgentView {
if matches!(self.active_modal, Some(ActiveModal::EditConfirm { .. })) {
self.active_modal = None;
}
// Return focus to queue pane (if still visible).
// Force=true: we just cleared editing mode, no lock to check.
if self.queue.is_visible() {
self.set_active_pane(AgentPane::Queue, true);
} else {
self.set_active_pane(AgentPane::Scrollback, true);
}
// Queue focus would make the next Enter re-open the edit.
self.set_active_pane(AgentPane::Prompt, true);
}
}
@@ -554,6 +557,39 @@ mod tests {
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
}
fn enter_edit_local_row() -> AgentView {
let mut agent = make_running_agent();
let registry = non_vscode_registry();
let ids = agent.queue.entry_ids();
agent.queue.list_state.select_by_id(ids[1]);
let _ = agent.handle_queue_key(&edit_key(), &registry);
agent
}
#[test]
fn save_returns_focus_to_the_composer() {
let mut agent = enter_edit_local_row();
agent.prompt.set_text("local one EDITED");
let _ = agent.handle_prompt_key_for_test(&enter_key());
assert_eq!(agent.active_pane, AgentPane::Prompt);
}
#[test]
fn discard_returns_focus_to_the_composer() {
let mut agent = enter_edit_local_row();
let _ = agent.handle_prompt_key_for_test(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(agent.active_pane, AgentPane::Prompt);
}
#[test]
fn toggle_queue_pane_with_clean_edit_lands_focused_on_the_queue() {
let mut agent = enter_edit_local_row();
agent.toggle_queue_pane();
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
assert_eq!(agent.active_pane, AgentPane::Queue);
assert!(agent.queue.overlay.focused);
}
fn attach_image_to_local_row(agent: &mut AgentView) {
let mut image = test_pasted_image();
image.display_number = 1;
@@ -322,6 +322,10 @@ impl ListItem for QueuedPromptEntry {
fn search_text(&self) -> &str {
&self.text
}
fn copy_text(&self) -> String {
self.text.clone()
}
}
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind};
@@ -999,6 +1003,13 @@ mod tests {
QueuedPrompt::plain(id, text, QueueEntryKind::Prompt)
}
#[test]
fn y_copies_the_full_multiline_prompt_not_the_display_row() {
let text = "first line of a long prompt\nsecond line\nthird line";
let entry = QueuedPromptEntry::new(&local_prompt(1, text), 1);
assert_eq!(ListItem::copy_text(&entry), text);
}
#[test]
fn reset_auto_show_edge_allows_requeue_after_external_hide() {
let mut pane = QueuePane::new();
+208 -67
View File
@@ -461,6 +461,7 @@ pub struct WelcomeRenderParams<'a> {
pub auth_methods: &'a [acp::AuthMethod],
pub login_label: Option<&'a str>,
pub auth_code_input: &'a str,
pub custom_entry: &'a crate::app::custom_entry::CustomEntryDraft,
pub clipboard_copied: bool,
pub show_raw_url: bool,
pub tip: Option<&'a str>,
@@ -603,6 +604,7 @@ pub fn render_welcome(
auth_url.as_deref(),
*mode,
params.auth_code_input,
params.custom_entry,
params.clipboard_copied,
params.show_raw_url,
);
@@ -1176,6 +1178,7 @@ fn render_welcome_authenticating(
auth_url: Option<&str>,
mode: AuthMode,
auth_code_input: &str,
custom_entry: &crate::app::custom_entry::CustomEntryDraft,
clipboard_copied: bool,
show_raw_url: bool,
) -> (Option<Rect>, Option<Rect>) {
@@ -1270,6 +1273,7 @@ fn render_welcome_authenticating(
theme,
auth_code_input,
"Paste your token here...",
true,
);
// Hints
@@ -1290,81 +1294,45 @@ fn render_welcome_authenticating(
}
AuthMode::ApiKeyEntry(target) => {
// Platform API-key paste box: instruction + input + hints. No
// auth-URL machinery — the key comes from the platform console.
let h_pad: u16 = content_area.width / 6;
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
// Platform API-key paste box.
let instruction = format!(
"Paste your {} API key (from {})",
target.vendor(),
target.console_host()
);
let msg_height = (instruction.len() as u16).div_ceil(inner_width);
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(logo_line_count),
// gap
Constraint::Length(1),
// instruction
Constraint::Length(msg_height),
// gap
Constraint::Min(1),
// prompt box
Constraint::Length(5),
// gap
Constraint::Length(1),
// hints
Constraint::Length(1),
Constraint::Min(0),
])
.areas(content_area);
render_logo(logo_area, buf, theme, content_area.height);
let msg = Line::from(Span::styled(
instruction,
Style::default().fg(theme.gray_bright),
))
.alignment(Alignment::Center);
Paragraph::new(msg)
.wrap(Wrap { trim: false })
.block(Block::default().padding(Padding::horizontal(h_pad)))
.render(msg_area, buf);
let [_, prompt_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(content_area.width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(prompt_area);
render_auth_input_box(
prompt_centered,
render_entry_box(
content_area,
buf,
theme,
auth_code_input,
"Paste your API key here...",
top_pad,
logo_line_count,
&EntryBox {
instruction: &instruction,
error: None,
input: auth_code_input,
placeholder: "Paste your API key here...",
masked: true,
},
);
(None, None)
}
let hints = Line::from(vec![
Span::styled(
"enter",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" submit ", Style::default().fg(theme.gray)),
Span::styled(
"esc",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" back", Style::default().fg(theme.gray)),
])
.alignment(Alignment::Center);
Paragraph::new(hints).render(hint_area, buf);
AuthMode::CustomProviderEntry(step) => {
let instruction = custom_entry.instruction(step);
render_entry_box(
content_area,
buf,
theme,
top_pad,
logo_line_count,
&EntryBox {
instruction: &instruction,
error: custom_entry.error.as_deref(),
input: auth_code_input,
placeholder: step.placeholder(),
masked: step.masks_input(),
},
);
(None, None)
}
@@ -1970,6 +1938,102 @@ pub(crate) fn render_session_picker(
)
}
/// One text field under the logo.
struct EntryBox<'a> {
instruction: &'a str,
error: Option<&'a str>,
input: &'a str,
placeholder: &'a str,
masked: bool,
}
fn render_entry_box(
content_area: Rect,
buf: &mut Buffer,
theme: &Theme,
top_pad: u16,
logo_line_count: u16,
entry: &EntryBox<'_>,
) {
let h_pad: u16 = content_area.width / 6;
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
let rows = |text: &str| (text.len() as u16).div_ceil(inner_width);
let msg_height = rows(entry.instruction) + entry.error.map_or(0, rows);
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(logo_line_count),
// gap
Constraint::Length(1),
// instruction
Constraint::Length(msg_height),
// gap
Constraint::Min(1),
// prompt box
Constraint::Length(5),
// gap
Constraint::Length(1),
// hints
Constraint::Length(1),
Constraint::Min(0),
])
.areas(content_area);
render_logo(logo_area, buf, theme, content_area.height);
let mut lines = vec![
Line::from(Span::styled(
entry.instruction,
Style::default().fg(theme.gray_bright),
))
.alignment(Alignment::Center),
];
if let Some(error) = entry.error {
lines.push(
Line::from(Span::styled(error, Style::default().fg(theme.warning)))
.alignment(Alignment::Center),
);
}
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default().padding(Padding::horizontal(h_pad)))
.render(msg_area, buf);
let [_, prompt_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(content_area.width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(prompt_area);
render_auth_input_box(
prompt_centered,
buf,
theme,
entry.input,
entry.placeholder,
entry.masked,
);
let hints = Line::from(vec![
Span::styled(
"enter",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" submit ", Style::default().fg(theme.gray)),
Span::styled(
"esc",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" back", Style::default().fg(theme.gray)),
])
.alignment(Alignment::Center);
Paragraph::new(hints).render(hint_area, buf);
}
/// Render the auth token input box (loopback mode).
fn render_auth_input_box(
area: Rect,
@@ -1977,6 +2041,7 @@ fn render_auth_input_box(
theme: &Theme,
input: &str,
placeholder: &str,
masked: bool,
) {
let prompt_block = Block::default()
.borders(Borders::ALL)
@@ -1993,8 +2058,10 @@ fn render_auth_input_box(
if inner.height > 0 && inner.width > 2 {
let display = if input.is_empty() {
placeholder.to_string()
} else {
} else if masked {
mask_auth_token_for_display(input)
} else {
input.to_string()
};
let style = if input.is_empty() {
@@ -2078,6 +2145,8 @@ fn mask_auth_token_for_display(input: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
static EMPTY_CUSTOM_ENTRY: crate::app::custom_entry::CustomEntryDraft =
crate::app::custom_entry::CustomEntryDraft::new();
use crate::app::app_view::SessionPickerEntry;
use crate::views::picker::PickerState;
use crate::views::session_picker::{build_grouped_picker_entries, build_session_entry_data};
@@ -2127,6 +2196,7 @@ mod tests {
auth_methods: &[],
login_label: None,
auth_code_input: "",
custom_entry: &EMPTY_CUSTOM_ENTRY,
clipboard_copied: false,
show_raw_url: false,
tip: None,
@@ -2323,6 +2393,71 @@ mod tests {
assert!(!text.contains("Moonshot"), "{text}");
}
fn render_custom_entry(
step: crate::app::custom_entry::CustomEntryStep,
draft: &crate::app::custom_entry::CustomEntryDraft,
input: &str,
) -> String {
let area = Rect::new(0, 0, 100, 40);
let mut buf = Buffer::empty(area);
render_welcome_authenticating(
area,
&mut buf,
&Theme::current(),
logo_line_count(area.height),
None,
AuthMode::CustomProviderEntry(step),
input,
draft,
false,
false,
);
buffer_text(&buf)
}
/// Only the key step masks its input.
#[test]
fn custom_entry_arm_masks_only_the_key() {
use crate::app::custom_entry::{CustomEntryDraft, CustomEntryField, CustomEntryStep};
use kigi_shell::models::custom::CustomApi;
let step = |field| CustomEntryStep {
api: CustomApi::OpenAi,
field,
};
let draft = CustomEntryDraft::new();
let url = render_custom_entry(
step(CustomEntryField::BaseUrl),
&draft,
"https://very-long-host.example/v1",
);
assert!(url.contains("https://very-long-host.example/v1"), "{url}");
assert!(url.contains("OpenAI compatible"), "{url}");
let key = render_custom_entry(step(CustomEntryField::Key), &draft, "sk-abcdefghijklmnop");
assert!(
!key.contains("sk-abcdefghij"),
"the key prefix must stay hidden: {key}"
);
assert!(key.contains("mnop"), "{key}");
let empty = render_custom_entry(step(CustomEntryField::Name), &draft, "");
assert!(empty.contains("provider name"), "{empty}");
}
/// A refusal shows under the instruction.
#[test]
fn custom_entry_arm_shows_the_refusal() {
use crate::app::custom_entry::{CustomEntryDraft, CustomEntryStep};
use kigi_shell::models::custom::CustomApi;
let step = CustomEntryStep::first(CustomApi::Anthropic);
let mut draft = CustomEntryDraft::new();
draft.submit(step, "host.example");
let text = render_custom_entry(step, &draft, "host.example");
assert!(text.contains("Anthropic compatible"), "{text}");
assert!(text.contains("must start with http"), "{text}");
}
/// The Moonshot API-key entry arm renders the platform copy, the paste
/// box, and the esc-back hint — and no OAuth-URL affordances.
#[test]
@@ -2343,6 +2478,7 @@ mod tests {
)),
// auth_code_input
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
// clipboard_copied
false,
// show_raw_url
@@ -3010,6 +3146,7 @@ mod tests {
AuthMode::Device,
// auth_code_input — unused in device mode
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
// clipboard_copied
false,
// show_raw_url
@@ -3066,6 +3203,7 @@ mod tests {
Some(url),
AuthMode::Device,
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
false,
// show_raw_url
true,
@@ -3093,6 +3231,7 @@ mod tests {
Some(url),
AuthMode::Device,
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
false,
// show_raw_url
true,
@@ -3131,6 +3270,7 @@ mod tests {
Some(url),
AuthMode::Device,
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
false,
// show_raw_url
true,
@@ -3172,6 +3312,7 @@ mod tests {
AuthMode::Command,
// auth_code_input — unused
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
// clipboard_copied
false,
// show_raw_url
@@ -0,0 +1,159 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
const CUSTOM_KEY: &str = "sk-custom-provider-key";
const HOUSE_KEY: &str = "test-key-for-ci";
/// Declares `[platforms.proxy]` in the pager's isolated home.
fn declare_provider(content: &ContentController, api: &str) {
let kigi_home = content.home().join(".kigi");
std::fs::create_dir_all(&kigi_home).expect("create .kigi");
std::fs::write(
kigi_home.join("config.toml"),
format!(
"[platforms.proxy]\nbase_url = \"{}\"\napi = \"{api}\"\napi_key = \"{CUSTOM_KEY}\"\n",
content.url()
),
)
.expect("write config.toml");
}
fn turn_requests(
content: &ContentController,
path: &str,
) -> Vec<kigi_test_support::mock_server::LogEntry> {
content
.requests()
.into_iter()
.filter(|e| {
e.path == path
&& e.body
.as_ref()
.and_then(|b| b.get("model"))
.and_then(|m| m.as_str())
== Some("custom-m1")
})
.collect()
}
async fn run_turn(content: &ContentController) -> PtyHarness {
content.set_response(format!(
"{MOCK_RESPONSE_SENTINEL} from the custom provider."
));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, content, &[])
.expect("spawn pager with content");
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("mock response on screen");
harness
}
/// An OpenAI provider lists models and uses its own key.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn custom_openai_provider_lists_models_and_uses_its_own_key() {
let content = ContentController::start_with_models(vec![MockModel::new("custom-m1")])
.await
.expect("start content");
declare_provider(&content, "openai");
let mut harness = run_turn(&content).await;
let turns = turn_requests(&content, "/v1/chat/completions");
assert!(
!turns.is_empty(),
"no chat completion for custom-m1: {:?}",
content.requests()
);
for turn in &turns {
assert_eq!(
turn.authorization.as_deref(),
Some(format!("Bearer {CUSTOM_KEY}").as_str()),
"the custom provider's own key must ride, never {HOUSE_KEY}"
);
}
harness
.inject_keys(b"/model ")
.expect("open the /model list");
harness
.wait_for_text("proxy", Duration::from_secs(10))
.expect("the fetched model is listed with its provider name");
harness.quit().expect("clean quit");
}
/// An Anthropic provider uses `/messages` with `x-api-key`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn custom_anthropic_provider_uses_messages_and_x_api_key() {
let content = ContentController::start_with_models(vec![MockModel::new("custom-m1")])
.await
.expect("start content");
declare_provider(&content, "anthropic");
let mut harness = run_turn(&content).await;
let turns = turn_requests(&content, "/v1/messages");
assert!(
!turns.is_empty(),
"no messages request for custom-m1: {:?}",
content.requests()
);
for turn in &turns {
assert_eq!(
turn.header("x-api-key"),
Some(CUSTOM_KEY),
"request headers: {:?}",
turn.headers
);
assert!(turn.header("anthropic-version").is_some());
assert_eq!(
turn.authorization, None,
"an x-api-key wire carries no Bearer"
);
}
harness.quit().expect("clean quit");
}
/// A saved provider alone is a complete login.
#[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");
}
@@ -15,6 +15,8 @@ mod agent_response;
mod auto_compact_top_row;
#[path = "pty_e2e/basename_path_demo_pty.rs"]
mod basename_path_demo_pty;
#[path = "pty_e2e/custom_provider.rs"]
mod custom_provider;
#[path = "pty_e2e/doubled_lines_out_of_band_repro.rs"]
mod doubled_lines_out_of_band_repro;
#[path = "pty_e2e/initial_prompt_positional_auto_submits.rs"]