Compare commits

...
34 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
ZacharyZhang-NY 0e4e446fe4 docs(agents): record where Shift+Enter can and cannot arrive
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / publish GitHub Release (push) Canceled after 0s
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
2026-09-21 19:25:20 -04:00
ZacharyZhang-NY 008b6623f6 docs(tui): correct the VTE and WezTerm notes around the Shift+Enter hint 2026-09-21 19:25:04 -04:00
ZacharyZhang-NY f8cd33d19d fix(tui): the Shift+Enter hint follows the protocol, not a brand list
`shift_enter_unavailable` kept its own list of brands and missed every
other reason the Kitty keyboard protocol gets skipped: JetBrains, screen,
tmux below 3.3 and tmux with `extended-keys off` all swallow it, yet the
prompt still advertised Shift+Enter there. It now reads
`kitty_skip_reason` directly. Two skips deliver the modifier another way
and stay available: Apple Terminal, where `route_enter` polls
CoreGraphics, and Windows Terminal, where the console key record carries
SHIFT — a bare ConHost, only optimistically refined to Windows Terminal,
keeps advertising Alt+Enter.

The old list also carried a `VTE_VERSION >= 8200` cutoff, citing VTE
0.82.0 as the first release with the protocol. It is not: at tag 0.82.0
the string "kitty" appears nowhere in `src/parser-seq.py`, `src/vte.cc`
or `src/widget.cc`, and `_vte_keymap_GDK_Return` encodes only Alt
(`ESC CR`), so Shift+Enter leaves a VTE terminal as the same bare CR as
Enter. Every VTE now advertises Alt+Enter, and `/terminal-setup` stops
telling GNOME Terminal users to upgrade to a version that would not
help. It also stops advertising Alt+Enter to WezTerm, which binds that
chord to ToggleFullScreen.
2026-09-21 19:19:21 -04:00
ZacharyZhang-NY 2e40ebb08b docs(agents): the codex table is five models, and how to refresh it 2026-09-21 18:43:46 -04:00
ZacharyZhang-NY fcb7588312 feat(models): gpt-6-astra joins the compiled-in codex catalog
The Codex backend served five `visibility=list` + `supported_in_api`
models on 2026-09-21: `gpt-6-astra` ahead of `gpt-5.6-sol/terra/luna`
and `gpt-5.5`. Astra carries context 272000 and efforts
low/medium/high/xhigh/max, default medium; the `ultra` tier it also
advertises has no kigi level and is left out. `gpt-reserve` and
`codex-auto-review` stay hidden.

Inference checked against the live backend: `POST
/backend-api/codex/responses` with `model: gpt-6-astra` and the codex
`User-Agent` kigi sends answers 200 and streams, so the model works on
the compiled-in path.
2026-09-21 18:42:22 -04:00
ZacharyZhang-NY 9ab038f573 docs(agents): rewrap the reasoning-effort paragraph 2026-09-21 18:39:46 -04:00
ZacharyZhang-NY dd5711f24f docs(agents): the codex backend does advertise an ultra tier 2026-09-21 18:39:40 -04:00
ZacharyZhang-NY a690987ea2 revert: openai-codex goes back to the compiled-in catalog
Reverts c61977c. The live listing needed no codex CLI on the user's
machine — kigi issued the request itself and `client_version` was a
compiled-in string — but the project keeps the catalog in the binary, so
the fetch path, the `OpenaiCodex` listing dialect, its parser and the
pinned client version are gone again, and the codex `User-Agent` returns
to its previous value.

The models.dev provider rename `kimi-for-coding` → `kimi-code-plan-cn`
rode in the same commit and stays: the regenerated snapshot ships under
the new id.
2026-09-21 18:36:46 -04:00
ZacharyZhang-NY 95d4f19650 chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.17 workspace version 2026-09-21 15:16:47 -04:00
ZacharyZhang-NY 99e09cfaf5 chore(release): bump workspace version to 0.1.17 2026-09-21 15:16:13 -04:00
ZacharyZhang-NY 0c580afb5f chore(models): regenerate the models.dev enrichment snapshot
`xai` and `openai` carry `restrict_to_enriched`, so a live listing id the
enrichment catalog does not know is dropped. The bundled snapshot knew
neither `grok-4.7` nor `gpt-6-astra`, which left both invisible offline
and until the 24h models.dev refresh landed.

Regeneration also tripped the generator's fail-fast: models.dev renamed
the Kimi coding provider. `kimi-for-coding` is now `kimi-code-plan-cn`
(api.kimi.com — kigi's endpoint), with `kimi-code-plan-global` the
api.kimi.ai twin; the model set is unchanged. Both registry rows, the
generator target and the two snapshot lookups follow the new id, and the
cache `keep_set` guard invalidates any cache filtered under the old one.
2026-09-21 15:13:00 -04:00
ZacharyZhang-NY c61977cb70 feat(models): fetch the openai-codex catalog live
The ChatGPT Codex catalog was compiled in: four models read once from the
Codex CLI cache. The backend moved on — `gpt-6-astra` shipped, and
`gpt-5.3-codex-spark` left — so the picker showed a table that no longer
matched the account.

`GET https://chatgpt.com/backend-api/codex/models?client_version=<v>`
serves that table. Verified against a live ChatGPT account: the OAuth
Bearer alone returns 200, the query is required (400 without it), and the
backend gates the catalog on it — `0.104.0` answers with
`codex-auto-review` alone, `0.150.0` omits `gpt-6-astra`, `0.155.1`
serves the full set. `CODEX_CLIENT_VERSION` pins the current upstream
release (openai/codex rust-v0.155.1) and the codex `User-Agent` now
derives from it, so the two can never drift.

`ListingDialect::OpenaiCodex` + `parse_openai_codex_listing` keep the
`visibility == "list" && supported_in_api` models and carry the wire
context window and per-model reasoning levels; `hardcoded_catalog` and
its short-circuit in the fetch path are gone. The catalog advertises an
`ultra` tier kigi has no level for — `think_efforts_to_options` drops it
with a warning, as it does any unknown token.
2026-09-21 15:12:54 -04:00
53 changed files with 8306 additions and 5461 deletions

No files matched your search

+79 -9
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
@@ -116,6 +128,24 @@ a design against the 0.1.15 teardown), MCP servers on protocol 2026-07-28
- Graph node worktrees are removed right after a successful merge-back;
only FAILED nodes keep theirs for postmortem.
## Terminal keyboard
`Shift+Enter` inserts a newline in the prompt wherever the terminal can
report the modifier, and `kitty_skip_reason` decides where that is —
`shift_enter_unavailable` matches on it rather than keeping a second brand
list, because the two drifted once and the UI advertised a chord that never
arrived. Two skips still deliver the modifier: Apple Terminal, where
`route_enter` polls CoreGraphics, and Windows Terminal, where the console
key record carries SHIFT with no protocol at all (a bare ConHost is only
optimistically refined to Windows Terminal, so it keeps advertising
Alt+Enter). Everywhere else — VTE, xterm.js, JediTerm, screen, tmux below
3.3, tmux with `extended-keys off` — the byte on the wire is a plain CR and
no client-side trick recovers it: crossterm 0.28 parses kitty `CSI 13;2u`
but not xterm's `CSI 27;2;13~`, so `modifyOtherKeys` buys nothing. NO VTE
release carries the protocol (checked at tag 0.82.0:
`_vte_keymap_GDK_Return` encodes only Alt); a version cutoff for it is a
bug, not a feature. `/terminal-setup` owns the per-terminal recipe.
## Test seams
Cross-crate test hooks are behind the `test-support` cargo feature
@@ -385,11 +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): exactly the 4 `visibility=list` &&
`supported_in_api=true` models (`gpt-5.6-sol/terra/luna`, `gpt-5.5`, ctx
272000, per-model efforts) — NO live `/models` fetch, NO codex-CLI /
`~/.codex` dependency; `gpt-5.3-codex-spark` (api=false) and
`gpt-5.4`/`gpt-5.4-mini`/`codex-auto-review` (hidden) are EXCLUDED.
`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
@@ -423,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
@@ -532,9 +601,10 @@ client-side, no backend surface.
`~/.kigi/models_dev_cache.json`). Wire values always win; enrichment
never invents model availability. Canonical reasoning efforts:
none/minimal/low/medium/high/xhigh/max (`max` split from `xhigh` 2026-07;
`ultra` was dropped the same month, no backend accepts it; Kimi wire
spells its top tier `max`, kimi_compat renames). config.toml accepts any
letter case: `ReasoningEffort` deserializes through `FromStr`.
the Codex backend advertises an `ultra` tier above `max` that kigi has no
level for; Kimi wire spells its top tier `max`, kimi_compat renames).
config.toml accepts any letter case: `ReasoningEffort` deserializes
through `FromStr`.
## Milestones (PRD §8.3)
Generated
+62 -62
View File
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]]
name = "kigi-acp-lib"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.16"
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.16"
version = "0.1.19"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"async-openai",
"async-stream",
@@ -6155,7 +6155,7 @@ dependencies = [
[[package]]
name = "kigi-sampling-types"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"assert_matches",
"async-openai",
@@ -6172,7 +6172,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anyhow",
"chrono",
@@ -6194,7 +6194,7 @@ dependencies = [
[[package]]
name = "kigi-secrets"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"regex",
"serde_json",
@@ -6232,7 +6232,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6370,7 +6370,7 @@ dependencies = [
[[package]]
name = "kigi-shell-base"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anyhow",
"chrono",
@@ -6395,7 +6395,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"libc",
"rusqlite",
@@ -6406,7 +6406,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6421,7 +6421,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6429,7 +6429,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6451,7 +6451,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"runfiles",
"tracing",
@@ -6460,11 +6460,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.16"
version = "0.1.19"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6475,7 +6475,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anyhow",
"async-trait",
@@ -6493,7 +6493,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6503,7 +6503,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anyhow",
"arc-swap",
@@ -6580,7 +6580,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6593,11 +6593,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.16"
version = "0.1.19"
[[package]]
name = "kigi-tty-utils"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6607,7 +6607,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6694,7 +6694,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anyhow",
"dunce",
@@ -6723,14 +6723,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6809,7 +6809,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"base64",
"chrono",
@@ -8843,7 +8843,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8861,7 +8861,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.16"
version = "0.1.19"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.16"
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
@@ -1 +1 @@
{"_meta":{"note":"filtered snapshot for kigi model-metadata enrichment; regenerate via scripts/gen_enrichment_snapshot.py","source":"https://models.dev/api.json (github.com/sst/models.dev, MIT)"},"alibaba-token-plan":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":24576},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"glm-5":{"limit":{"context":202752,"output":16384},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.1":{"limit":{"context":202752,"output":128000},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.1","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.2":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.2","reasoning":true,"reasoning_options":[{"type":"effort","values":["high","max"]}],"tool_call":true},"kimi-k2.5":{"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.5","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.6":{"limit":{"context":262144,"output":16384},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.6","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.7-code":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.7 Code","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"qwen-image-2.0":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0","reasoning":false,"tool_call":false},"qwen-image-2.0-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0 Pro","reasoning":false,"tool_call":false},"qwen3.6-flash":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":81920,"type":"budget_tokens"}],"tool_call":true},"qwen3.6-plus":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":81920,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-max":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"Qwen3.7 Max","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":262144,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-plus":{"limit":{"context":1000000,"output":64000},"modalities":{"input":["text","image"],"output":["text"]},"name":"Qwen3.7 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":262144,"type":"budget_tokens"}],"tool_call":true},"qwen3.8-max-preview":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.8 Max Preview","reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","xhigh"]},{"max":262144,"min":0,"type":"budget_tokens"}],"tool_call":true},"wan2.7-image":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Wan2.7 Image","reasoning":false,"tool_call":false},"wan2.7-image-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Wan2.7 Image Pro","reasoning":false,"tool_call":false}}},"alibaba-token-plan-cn":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":24576},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":3Line truncated
{"_meta":{"note":"filtered snapshot for kigi model-metadata enrichment; regenerate via scripts/gen_enrichment_snapshot.py","source":"https://models.dev/api.json (github.com/sst/models.dev, MIT)"},"alibaba-token-plan":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":32768},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-flash-0731":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash 0731","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro-0813":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro 0813","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4.1-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text","image"],"output":["text"]},"name":"DeepSeek V4.1 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"tool_call":true},"glm-5":{"limit":{"context":202752,"output":16384},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.1":{"limit":{"context":202752,"output":128000},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.1","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.2":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.2","reasoning":true,"reasoning_options":[{"type":"effort","values":["high","max"]}],"tool_call":true},"glm-5.3":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.3","reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high","max"]}],"tool_call":true},"happyhorse-1.1-i2v":{"limit":{"context":0,"output":0},"modalities":{"input":["image","text"],"output":["video"]},"name":"HappyHorse 1.1 Image-to-Video","reasoning":false,"tool_call":false},"happyhorse-1.1-r2v":{"limit":{"context":0,"output":0},"modalities":{"input":["image","text"],"output":["video"]},"name":"HappyHorse 1.1 Reference-to-Video","reasoning":false,"tool_call":false},"happyhorse-1.1-t2v":{"limit":{"context":0,"output":0},"modalities":{"input":["text"],"output":["video"]},"name":"HappyHorse 1.1 Text-to-Video","reasoning":false,"tool_call":false},"kimi-k2.5":{"limit":{"context":262144,"output":98304},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.5","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.6":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.6","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.7-code":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.7 Code","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"qwen-image-2.0":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0","reasoning":false,"tool_call":false},"qwen-image-2.0-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0 Pro","reasoning":false,"tool_call":false},"qwen3.6-flash":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":131072,"type":"budget_tokens"}],"tool_call":true},"qwen3.6-plus":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":131072,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-max":{"limit":{"context":1000000,Line truncated
+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(_))
));
}
}
+16 -1
View File
@@ -223,7 +223,7 @@ mod tests {
"snapshot lost providers: {}",
catalog.len()
);
let k3 = lookup(catalog, "kimi-for-coding", "k3").expect("k3 present");
let k3 = lookup(catalog, "kimi-code-plan-cn", "k3").expect("k3 present");
assert_eq!(k3.context, 1_048_576, "k3 context must match the live wire");
assert_eq!(k3.efforts, ["low", "high", "max"]);
assert!(k3.reasoning);
@@ -232,6 +232,21 @@ mod tests {
assert_eq!(opus.efforts, ["low", "medium", "high", "xhigh", "max"]);
}
/// `restrict_to_enriched` drops a live listing id the enrichment catalog
/// does not know, so the bundled snapshot must admit the current flagship
/// models offline: grok-4.7 on xai (the `xai`/`xai-grok` rows) and
/// gpt-6-astra on the API-key `openai` row.
#[test]
fn bundled_snapshot_admits_the_current_flagship_models() {
let catalog = bundled_enrichment();
let grok = lookup(catalog, "xai", "grok-4.7").expect("grok-4.7 present");
assert!(grok.tool_call, "a non-tool-calling model is dropped");
assert_eq!(grok.context, 500_000);
let astra = lookup(catalog, "openai", "gpt-6-astra").expect("gpt-6-astra present");
assert!(astra.tool_call);
assert_eq!(astra.context, 1_050_000);
}
/// Wire-served fields are never overwritten; absent fields are filled.
#[test]
fn enrich_fills_gaps_and_never_overwrites_wire() {
+79 -27
View File
@@ -16,6 +16,7 @@
use std::sync::LazyLock;
pub mod custom;
pub mod enrichment;
// Platform registry (PRD F2)
@@ -400,7 +401,7 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
vendor: "Kimi",
console_host: None,
login_label: None,
models_dev_id: Some("kimi-for-coding"),
models_dev_id: Some("kimi-code-plan-cn"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
listing: ListingDialect::OpenAi,
@@ -987,7 +988,7 @@ const KIMI_CODING_SPEC: PlatformSpec = PlatformSpec {
vendor: "Kimi",
console_host: Some("www.kimi.com"),
login_label: Some("Kimi For Coding (API key)"),
models_dev_id: Some("kimi-for-coding"),
models_dev_id: Some("kimi-code-plan-cn"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
listing: ListingDialect::OpenAi,
@@ -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,9 +1571,10 @@ 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 4 models are HARDCODED (read from the official Codex CLI's
/// `models_cache.json`, the `visibility=="list"` AND `supported_in_api==true`
/// set) because OpenAI exposes no stable public models endpoint for the
/// 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
/// selectable reasoning efforts (incl. the codex-only `xhigh`/`max`
/// tiers), so the fetch path maps them through the SAME
@@ -1606,14 +1608,39 @@ fn codex_wire_model(slug: &str, display_name: &str, efforts: &[&str], default: &
}
}
/// The HARDCODED openai-codex catalog: exactly the 4 `visibility=="list"` AND
/// `supported_in_api==true` models from the Codex CLI model cache. The
/// `gpt-5.3-codex-spark` (supported_in_api=false → not served by /responses),
/// `gpt-5.4`, `gpt-5.4-mini`, and `codex-auto-review` (visibility="hide") models
/// are deliberately EXCLUDED — they would list-but-not-work or are not
/// user-facing (fail-fast: never advertise a model the backend rejects).
/// 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). 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",
@@ -2653,29 +2680,33 @@ mod tests {
assert_eq!(c.base_url(), "https://mock.codex/codex");
}
/// The HARDCODED openai-codex catalog is exactly the 4 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
/// list-but-broken / hidden models are absent. Every other platform serves
/// NO hardcoded catalog (its models come from the live wire).
/// 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_four_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");
let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec!["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"],
"exactly the 4 visibility=list AND supported_in_api=true models"
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 8 visibility=list AND supported_in_api=true models"
);
// Excluded: list-but-not-served + hidden models never appear.
for absent in [
"gpt-5.3-codex-spark",
"gpt-5.4",
"gpt-5.4-mini",
"codex-auto-review",
] {
// Excluded: hidden models never appear.
for absent in ["gpt-reserve", "codex-auto-review"] {
assert!(
!ids.contains(&absent),
"{absent} must be excluded from the hardcoded catalog"
@@ -2700,6 +2731,23 @@ mod tests {
.valid_efforts
.clone()
};
assert_eq!(
efforts("gpt-6-astra"),
["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"]
@@ -2725,6 +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 {
@@ -375,7 +370,6 @@ impl TerminalContext {
return Some("apple_terminal");
}
if self.is_vte_based() {
// WHY: central helper replaces VTE duplication
return Some("vte");
}
if self.brand == TerminalName::WindowsTerminal {
@@ -429,74 +423,29 @@ impl TerminalContext {
}
/// Whether the running terminal cannot distinguish `Shift+Enter` from
/// bare `Enter` at the byte level, so the UI should advertise
/// `Alt+Enter` for newline insertion instead.
/// bare `Enter`, so the UI should advertise `Alt+Enter` (delivered as
/// `ESC`+`CR`) for newline insertion instead.
///
/// Distinguishing `Shift+Enter` requires the Kitty keyboard protocol
/// (KKP) to be negotiated. This returns `true` for the environments
/// where the pager cannot rely on KKP for a usable `Shift+Enter`:
/// Follows [`Self::kitty_skip_reason`] rather than a second brand list:
/// when the protocol is not negotiated the modifier is lost, and the two
/// drifted once already (VTE advertised a chord that never arrived — its
/// `Return` keymap encodes only Alt, through 0.82.0 at least, and no VTE
/// release carries the protocol). Two skips still deliver it — Apple
/// Terminal drops the flags but `route_enter` polls CoreGraphics, and
/// Windows console key records carry SHIFT with no protocol at all.
///
/// 1. **Legacy VTE** (GNOME Terminal, Ptyxis, kgx, Tilix, etc.) whose
/// `VTE_VERSION` is below `8200` (VTE 0.82.0, the first release with
/// KKP, merged in
/// [MR !14](https://gitlab.gnome.org/GNOME/vte/-/merge_requests/14)).
/// Also true when the brand is detected as VTE but `VTE_VERSION` is
/// missing or unparseable — we conservatively assume old.
/// 2. **VS Code's integrated terminal (xterm.js) and VS Code-family /
/// xterm.js IDE forks**. xterm.js only partially implements KKP —
/// it mis-encodes shifted printable keys — so the pager deliberately
/// never negotiates KKP for them (see [`Self::kitty_skip_reason`]
/// `== "vscode"` and [xterm.js#5823](https://github.com/xtermjs/xterm.js/issues/5823)).
/// Without KKP, xterm.js sends a bare `CR` for `Shift+Enter`,
/// byte-for-byte identical to `Enter`.
/// 3. **Unidentified terminals with no multiplexer**, where the pager
/// also skips KKP (no positive evidence of support — typically
/// VS Code's xterm.js reached over SSH, where `TERM_PROGRAM` isn't
/// forwarded and the brand falls back to `Unknown`).
///
/// In every case `Alt+Enter` (delivered as `ESC`+`CR`) is the reliable
/// newline chord and is what the UI advertises.
/// KNOWN GAP: WezTerm inside screen or an old tmux gets Alt+Enter as the
/// advice while its own default binding spends that chord on
/// ToggleFullScreen. Neither chord reaches kigi there; the multiplexer
/// warning carries the real fix, and `\`+Enter still inserts a newline.
pub fn shift_enter_unavailable(&self) -> bool {
// WHY: central helper + version gating
let is_vte = self.is_vte_based();
if is_vte {
return match self
.vte_version
.as_deref()
.and_then(|v| v.parse::<u32>().ok())
{
Some(ver) => ver < 8200,
// Brand=Vte but no parseable version — conservative: assume old.
None => true,
};
match self.kitty_skip_reason() {
None | Some("apple_terminal") => false,
// A bare ConHost is only optimistically refined to Windows
// Terminal; it still advertises Alt+Enter.
Some("windows_terminal") => self.env_brand.is_capability_unclassified(),
Some(_) => true,
}
// VS Code / xterm.js and its forks: KKP is never negotiated, so
// Shift+Enter arrives as a bare CR indistinguishable from Enter.
if matches!(
self.brand,
TerminalName::VsCode
| TerminalName::Cursor
| TerminalName::Windsurf
| TerminalName::Zed
) {
return true;
}
// Unidentified / unclassified brand with no multiplexer: KKP is
// skipped (see `kitty_skip_reason`). This is the common
// VS Code-over-SSH shape (brand falls back to Unknown). On native
// Windows the effective `brand` is refined to WindowsTerminal, so
// consult `env_brand` — a bare ConHost is still env-Unknown and
// must advertise Alt+Enter even though we optimistically treat it
// as WT for capabilities.
if self.env_brand.is_capability_unclassified()
&& self.multiplexer == MultiplexerKind::Undetected
{
return true;
}
false
}
/// True when `Ctrl+.` cannot be delivered reliably as a shortcuts primary.
@@ -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());
@@ -1443,7 +1425,7 @@ fn kitty_skip_vscode_over_tmux() {
#[test]
fn kitty_skip_vte_version() {
// VTE does not support Kitty keyboard protocol and crossterm's probe
// No VTE release has the Kitty keyboard protocol and crossterm's probe
// can false-positive on it. https://gitlab.gnome.org/GNOME/vte/-/issues/2601
let ctx = TerminalContext {
vte_version: Some("7402".to_owned()),
@@ -1461,73 +1443,68 @@ fn kitty_skip_vte_brand() {
assert_eq!(ctx.kitty_skip_reason(), Some("vte"));
}
// shift_enter_unavailable: VTE version gating for Shift+Enter
// shift_enter_unavailable: VTE never delivers Shift+Enter
//
// VTE 0.82.0 (= VTE_VERSION 8200) is the first release containing the
// Kitty keyboard protocol; earlier versions cannot distinguish
// Shift+Enter from bare Enter, so the UI should advertise Alt+Enter
// for newline insertion instead.
// No VTE release carries the Kitty keyboard protocol: at 0.82.0 the word
// "kitty" appears nowhere in the parser or widget, and
// `_vte_keymap_GDK_Return` encodes only Alt (`ESC CR`), so Shift+Enter is
// the same bare CR as Enter. The UI advertises Alt+Enter instead.
#[test]
fn shift_enter_unavailable_legacy_vte_version() {
// VTE 0.64.2 (real user report) — well below the KKP cutoff.
let ctx = TerminalContext {
vte_version: Some("6402".to_owned()),
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
fn shift_enter_unavailable_on_every_vte() {
for version in [Some("6402"), Some("8200"), Some("8401"), None] {
let ctx = TerminalContext {
brand: TerminalName::Vte,
env_brand: TerminalName::Vte,
vte_version: version.map(str::to_owned),
..Default::default()
};
assert_eq!(ctx.kitty_skip_reason(), Some("vte"), "VTE {version:?}");
assert!(ctx.shift_enter_unavailable(), "VTE {version:?}");
}
}
#[test]
fn shift_enter_unavailable_just_below_cutoff() {
// VTE 0.81.99 — anything below 8200 must return true.
let ctx = TerminalContext {
vte_version: Some("8199".to_owned()),
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_available_modern_vte() {
// VTE 0.82.0 — first release with KKP.
let ctx = TerminalContext {
vte_version: Some("8200".to_owned()),
..Default::default()
};
assert!(!ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_available_future_vte() {
// VTE 0.84.1 — well above the cutoff.
let ctx = TerminalContext {
vte_version: Some("8401".to_owned()),
..Default::default()
};
assert!(!ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_unavailable_vte_brand_no_version() {
// Brand detected as VTE but VTE_VERSION missing — conservative: old.
let ctx = TerminalContext {
brand: TerminalName::Vte,
vte_version: None,
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_unavailable_unparseable_version() {
// Garbage in VTE_VERSION — conservative: old.
let ctx = TerminalContext {
brand: TerminalName::Vte,
vte_version: Some("not-a-number".to_owned()),
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
fn shift_enter_unavailable_follows_every_kitty_skip() {
// A skipped protocol means the modifier never arrives, whatever the
// reason — JediTerm, screen and a multiplexer that eats extended keys
// included. Apple Terminal and Windows Terminal are the two skips with
// another way in.
for ctx in [
TerminalContext {
brand: TerminalName::JetBrains,
env_brand: TerminalName::JetBrains,
..Default::default()
},
TerminalContext {
brand: TerminalName::Kitty,
env_brand: TerminalName::Kitty,
multiplexer: MultiplexerKind::Screen,
..Default::default()
},
TerminalContext {
brand: TerminalName::Kitty,
env_brand: TerminalName::Kitty,
multiplexer: MultiplexerKind::Tmux,
tmux_version: Some("3.2".to_owned()),
..Default::default()
},
// A VTE carrying a recent VTE_VERSION is no different.
TerminalContext {
brand: TerminalName::Vte,
env_brand: TerminalName::Vte,
vte_version: Some("8200".to_owned()),
multiplexer: MultiplexerKind::Screen,
..Default::default()
},
] {
assert!(ctx.kitty_skip_reason().is_some());
assert!(
ctx.shift_enter_unavailable(),
"{:?} skips the protocol, so Shift+Enter cannot arrive",
ctx.brand
);
}
}
#[test]
@@ -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());
}
}
@@ -367,7 +367,7 @@ mod tests {
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, "http://127.0.0.1:1/api.json");
let catalog = load_enrichment_catalog_at(&path);
assert!(
kigi_models::enrichment::lookup(&catalog, "kimi-for-coding", "k3").is_some(),
kigi_models::enrichment::lookup(&catalog, "kimi-code-plan-cn", "k3").is_some(),
"bundled snapshot must back a total refresh failure"
);
assert!(!path.exists(), "failed refresh must not write a cache");
@@ -385,7 +385,7 @@ mod tests {
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, token);
assert!(refresh_url().is_none(), "token {token:?} must disable");
let catalog = load_enrichment_catalog_at(&path);
assert!(kigi_models::enrichment::lookup(&catalog, "kimi-for-coding", "k3").is_some());
assert!(kigi_models::enrichment::lookup(&catalog, "kimi-code-plan-cn", "k3").is_some());
}
}
@@ -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 4 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,20 +1559,19 @@ 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 4 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-5.3-codex-spark",
"openai-codex/gpt-5.4",
"openai-codex/gpt-5.4-mini",
"openai-codex/codex-auto-review",
] {
for absent in ["openai-codex/gpt-reserve", "openai-codex/codex-auto-review"] {
assert!(
!result
.models
@@ -1532,7 +1580,14 @@ mod tests {
"{absent} must be absent from the hardcoded catalog"
);
}
let sol = &result.models[0];
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()
.find(|m| m.id.as_deref() == Some("openai-codex/gpt-5.6-sol"))
.unwrap();
assert_eq!(
sol.api_backend,
crate::sampling::ApiBackend::Responses,
@@ -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;
@@ -96,16 +96,15 @@ impl SlashCommand for TerminalSetupCommand {
// Some terminals can't distinguish Shift+Enter from bare Enter at
// the byte level because the Kitty keyboard protocol isn't
// negotiated (VTE < 0.82, or VS Code's xterm.js which mis-encodes
// shifted keys). Point users at Alt+Enter, which is reliably
// delivered as ESC+CR. Suppressed when the WezTerm warning fired:
// stock WezTerm binds Alt+Enter to ToggleFullScreen, so advertising
// it would contradict that warning's `\`+Enter guidance.
if ctx.shift_enter_unavailable() && !wezterm_kkp_off {
// negotiated (VTE, VS Code's xterm.js, a multiplexer that eats
// extended keys). Point users at Alt+Enter, which is reliably
// delivered as ESC+CR. Never for WezTerm: it binds Alt+Enter to
// ToggleFullScreen, so its own warning's `\`+Enter guidance stands.
if ctx.shift_enter_unavailable() && !wezterm_kkp_off && ctx.brand != TerminalName::WezTerm {
let detail = if ctx.vte_version.is_some() || ctx.brand == TerminalName::Vte {
match ctx.vte_version.as_deref() {
Some(v) => format!("VTE {v}; need >= 8200 for Shift+Enter"),
None => "legacy VTE; need VTE >= 0.82 for Shift+Enter".to_owned(),
Some(v) => format!("VTE {v}: no Kitty keyboard protocol"),
None => "VTE: no Kitty keyboard protocol".to_owned(),
}
} else if matches!(
ctx.brand,
@@ -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"]
+1 -1
View File
@@ -24,7 +24,7 @@ TARGETS = [
"anthropic", "azure", "openai", "deepseek", "nvidia", "google",
"amazon-bedrock", "mistral", "groq", "cerebras", "cloudflare-ai-gateway",
"xai", "togetherai", "fireworks-ai", "opencode", "opencode-go",
"kimi-for-coding", "moonshotai", "moonshotai-cn", "minimax", "minimax-cn",
"kimi-code-plan-cn", "moonshotai", "moonshotai-cn", "minimax", "minimax-cn",
"alibaba-token-plan", "alibaba-token-plan-cn", "xiaomi",
"xiaomi-token-plan-cn", "zai-coding-plan", "zhipuai-coding-plan",
"github-copilot", "vercel",