Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e63d36468b | ||
|
|
389a931cad | ||
|
|
9a2a9eac99 | ||
|
|
1d631a07fa | ||
|
|
c56660fe07 | ||
|
|
3bde7fe851 | ||
|
|
793b93b74d | ||
|
|
c9855c4bb6 | ||
|
|
c4ef8a3779 | ||
|
|
ae40ba8da2 | ||
|
|
de42bb55c9 | ||
|
|
791f953c07 | ||
|
|
eb86d7ec26 | ||
|
|
2ca1f3baa7 | ||
|
|
4514b66ffe | ||
|
|
45edd7bf33 | ||
|
|
ceff1d7cc7 | ||
|
|
041c5ca030 | ||
|
|
0e063e5b02 | ||
|
|
b840fff80c | ||
|
|
cb4c2eaad9 | ||
|
|
cfde4e91a1 | ||
|
|
5082bd47ee | ||
|
|
5d70140d62 | ||
|
|
299c6bcfcb | ||
|
|
6be9ab0aea | ||
|
|
0e4e446fe4 | ||
|
|
008b6623f6 | ||
|
|
f8cd33d19d | ||
|
|
2e40ebb08b | ||
|
|
fcb7588312 | ||
|
|
9ab038f573 | ||
|
|
dd5711f24f | ||
|
|
a690987ea2 | ||
|
|
95d4f19650 | ||
|
|
99e09cfaf5 | ||
|
|
0c580afb5f | ||
|
|
c61977cb70 | ||
|
|
781fb44659 | ||
|
|
a640816c38 | ||
|
|
256ff2528e | ||
|
|
2820dd5f2b | ||
|
|
11b5a47ef0 | ||
|
|
bc93b540cd | ||
|
|
0189009822 | ||
|
|
206711bed5 | ||
|
|
443de79e2f | ||
|
|
be0983237e | ||
|
|
3ccb6e5e91 |
No files matched your search
@@ -8,3 +8,6 @@ node_modules/
|
||||
|
||||
# Runner-setup prompt; holds a Gitea registration token once filled in.
|
||||
/prompt.txt
|
||||
|
||||
# Local test credentials
|
||||
cred.md
|
||||
@@ -17,8 +17,10 @@ import) or any `KIMI_*` env var.
|
||||
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 and through `3794978` (2026-09-09, ≈v1.0.24) in the
|
||||
0.1.15 cycle. Upstream is daily "Synced from monorepo" snapshots —
|
||||
the 0.1.13 cycle, through `3794978` (2026-09-09, ≈v1.0.24) in the 0.1.15
|
||||
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;
|
||||
start any future sync from those two, not from raw diffs.
|
||||
@@ -30,6 +32,34 @@ that stops reading still blocks exit-path prints behind std's stderr lock
|
||||
builder re-checks `Path::exists("/data")` and can omit the devbox bind on
|
||||
a stat error that the profile classification treated as present.
|
||||
|
||||
Known limitation left by the 0.1.16 sync: `notifications/cancelled` for a
|
||||
timed-out MCP call cannot reach a streamable-HTTP server that answers
|
||||
`tools/call` with a plain JSON body. rmcp 2.2.0's HTTP worker awaits each
|
||||
POST in turn, so the cancel queues behind the hung call;
|
||||
`servers/cancel_aware.rs` waits 500 ms, warns, and the transport reset
|
||||
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
|
||||
per-OS process scans), the DA1 fence around the kitty keyboard pop (needs
|
||||
a design against the 0.1.15 teardown), MCP servers on protocol 2026-07-28
|
||||
(rmcp 3.x again), and `pager.toml`, whose writer in
|
||||
`kigi-pager-render/src/appearance/config.rs` still ends in a bare
|
||||
`fs::rename` and replaces a symlink.
|
||||
|
||||
## Hard constraints
|
||||
|
||||
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
|
||||
@@ -56,6 +86,15 @@ a stat error that the profile classification treated as present.
|
||||
switch that never stuck). Plain rename stays correct only for true moves
|
||||
whose destination doesn't pre-exist (worktree-pool markers, corrupt-file
|
||||
backups). Write failures must at least `warn!` — never `let _ =`.
|
||||
- **config.toml writers resolve their destination through
|
||||
`util::config::config_write_dest` before they name the tmp file.** A
|
||||
symlinked `~/.kigi/config.toml` (dotfiles) keeps its link: the tmp sits
|
||||
beside the referent and replaces it. Only that one path is followed, and
|
||||
only while `user_kigi_home()` resolves (with no home, `kigi_home()` is the
|
||||
project's own `./.kigi`). A project `.kigi/config.toml` is
|
||||
repo-controlled, and following its symlink would overwrite whatever file
|
||||
a hostile repo points it at; there the link itself is replaced.
|
||||
Credential and managed stores never follow symlinks.
|
||||
- The root `Cargo.toml` is hand-maintained (upstream's generator is not in
|
||||
this repo). Members sorted; versions inherited from
|
||||
`workspace.package.version` — the single source of truth for the release
|
||||
@@ -89,6 +128,24 @@ a stat error that the profile classification treated as present.
|
||||
- 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
|
||||
@@ -355,15 +412,20 @@ client-side, no backend surface.
|
||||
reasoning replay requires `include:["reasoning.encrypted_content"]`.
|
||||
API-key `openai` Responses requests carry NONE of this
|
||||
(byte-identical, pinned by a control wire test). `reasoning.effort`
|
||||
carries the thinking level (incl. the codex-only `ultra`). NO
|
||||
websocket, NO base_instructions.
|
||||
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
|
||||
@@ -397,6 +459,58 @@ 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 opens a MODEL-SELECTION screen (`custom_entry.rs`
|
||||
`CustomSelectState`, welcome `AuthMode::CustomProviderSelect`): the listing
|
||||
fetch (`fetch_listing_for_login`) runs concurrently with the manual-id input
|
||||
— both visible at once, failure shows its reason — space toggles fetched
|
||||
rows, enter adds a typed id or finishes (a finish pressed mid-fetch waits for
|
||||
it: `finish_requested`), and the chosen ids persist as `[platforms.<name>]
|
||||
models = [...]` via `save_custom_provider`. The listing fetch probes
|
||||
`{base}/v1` once when `{base}/models` 404s or answers unparsable (a WAF
|
||||
challenge is HTTP 200 HTML) and the base has no `/vN` tail, adopting the
|
||||
probed base for the saved definition; anthropic-wire listings that 401 on
|
||||
`x-api-key` retry once with `Bearer` (relays gate `GET /models` on Bearer
|
||||
while `/messages` takes `x-api-key`); listing errors never quote response
|
||||
content (line/column only — a reflected body could carry the key). Catalog
|
||||
assembly honors the allowlist in `selection.rs`: `synthesize_selected` (BEFORE
|
||||
`[model.*]` overrides, so an override composes with a synthesized entry)
|
||||
builds wire-default entries for selected ids the listing never served, and
|
||||
`filter_unselected` (AFTER overrides) is authoritative in the provider's
|
||||
namespace — even a `[model."name/x"]` entry for an unselected id is dropped.
|
||||
Provider-owned entries never take donor `api_backend` inheritance in
|
||||
`resolve_model_list` (the declared wire wins); no `models` key = the whole
|
||||
listing (the declarative path). `authenticate("custom:<name>")` then
|
||||
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
|
||||
@@ -505,10 +619,11 @@ client-side, no backend surface.
|
||||
`parse_api_json` for bundled + runtime refresh; 24h cache
|
||||
`~/.kigi/models_dev_cache.json`). Wire values always win; enrichment
|
||||
never invents model availability. Canonical reasoning efforts:
|
||||
none/minimal/low/medium/high/xhigh/max/ultra (`max` split from `xhigh`
|
||||
2026-07; `ultra` is codex-only, above `max`, surfaced only via a model's
|
||||
server-declared effort menu; Kimi wire spells its top tier `max`, kimi_compat
|
||||
renames).
|
||||
none/minimal/low/medium/high/xhigh/max (`max` split from `xhigh` 2026-07;
|
||||
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
+70
-70
@@ -720,9 +720,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.17.1"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
@@ -731,9 +731,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.42.0"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
@@ -5442,7 +5442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-acp-lib"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5456,7 +5456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -5486,7 +5486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent-lifecycle"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"tokio",
|
||||
@@ -5495,7 +5495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-auth"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"http 1.4.2",
|
||||
@@ -5508,7 +5508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-bin"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-chat-state"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"kigi-compaction",
|
||||
@@ -5560,7 +5560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-codebase-graph"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"clap",
|
||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-compaction"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -5609,7 +5609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"blake3",
|
||||
@@ -5632,7 +5632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"indexmap",
|
||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-crash-handler"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"libc",
|
||||
@@ -5657,7 +5657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-env"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fast-worktree"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -5697,7 +5697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-file-utils"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-config",
|
||||
@@ -5721,7 +5721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fsnotify"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"dunce",
|
||||
@@ -5742,7 +5742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-gix-status"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"gix",
|
||||
"kigi-test-utils",
|
||||
@@ -5752,7 +5752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"kigi-config",
|
||||
@@ -5771,7 +5771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks-plugins-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5779,7 +5779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-http"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"kigi-auth",
|
||||
"kigi-log",
|
||||
@@ -5794,7 +5794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hunk-tracker"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -5815,14 +5815,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-interjection-core"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-log"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -5840,7 +5840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-lossy",
|
||||
@@ -5864,14 +5864,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown-core"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"pulldown-cmark",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mcp"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5908,7 +5908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-memory"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -5942,7 +5942,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mermaid"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"fontdb",
|
||||
"image",
|
||||
@@ -5960,7 +5960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-models"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"kigi-env",
|
||||
"serde",
|
||||
@@ -5970,7 +5970,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-minimal"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crossterm",
|
||||
@@ -5987,7 +5987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-pty-harness"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -6012,7 +6012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-render"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anstyle",
|
||||
@@ -6064,7 +6064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-paths"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"serde",
|
||||
@@ -6074,7 +6074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-prompt-queue"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6082,7 +6082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-proto-build"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pbjson-build",
|
||||
@@ -6093,7 +6093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-ratatui-inline"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"ansi-width",
|
||||
"anstyle-parse 0.2.7",
|
||||
@@ -6110,7 +6110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-ratatui-textarea"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6131,7 +6131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sampler"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"async-openai",
|
||||
"async-stream",
|
||||
@@ -6155,7 +6155,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sampling-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"assert_matches",
|
||||
"async-openai",
|
||||
@@ -6172,7 +6172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sandbox"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6194,7 +6194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-secrets"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde_json",
|
||||
@@ -6232,7 +6232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-shell"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6370,7 +6370,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-shell-base"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6395,7 +6395,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sqlite-journal"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rusqlite",
|
||||
@@ -6406,7 +6406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-subagent-resolution"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"kigi-sampling-types",
|
||||
"kigi-tool-types",
|
||||
@@ -6421,7 +6421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-system-power"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"zbus",
|
||||
@@ -6429,7 +6429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-support"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6451,7 +6451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-utils"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"runfiles",
|
||||
"tracing",
|
||||
@@ -6460,11 +6460,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-token-estimation"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-protocol"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"kigi-tool-types",
|
||||
"serde",
|
||||
@@ -6475,7 +6475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-runtime"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -6493,7 +6493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"minijinja",
|
||||
"schemars 1.2.1",
|
||||
@@ -6503,7 +6503,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -6580,7 +6580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools-api"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"kigi-proto-build",
|
||||
"kigi-tool-protocol",
|
||||
@@ -6593,11 +6593,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tracing-macros"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tty-utils"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"nix 0.30.1",
|
||||
@@ -6607,7 +6607,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tui"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"ansi-to-tui",
|
||||
@@ -6694,7 +6694,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-update"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
@@ -6723,14 +6723,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-version"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6809,7 +6809,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
@@ -8843,7 +8843,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ptyctl"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -8861,7 +8861,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ptyctl-cli"
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -9636,9 +9636,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.42"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -9701,9 +9701,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.15"
|
||||
version = "0.1.20"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
+70
-69
@@ -12629,7 +12629,7 @@ Applies to:
|
||||
- [rustix 0.38.44](https://github.com/bytecodealliance/rustix)
|
||||
- [rustix 1.1.4](https://github.com/bytecodealliance/rustix)
|
||||
- [rustls-native-certs 0.8.4](https://github.com/rustls/rustls-native-certs)
|
||||
- [rustls 0.23.42](https://github.com/rustls/rustls)
|
||||
- [rustls 0.23.45](https://github.com/rustls/rustls)
|
||||
- [scopeguard 1.2.0](https://github.com/bluss/scopeguard)
|
||||
- [security-framework-sys 2.17.0](https://github.com/kornelski/rust-security-framework)
|
||||
- [security-framework 3.7.0](https://github.com/kornelski/rust-security-framework)
|
||||
@@ -16436,68 +16436,68 @@ limitations under the License.
|
||||
|
||||
Applies to:
|
||||
|
||||
- [kigi-proto-build 0.1.15](https://crates.io/crates/kigi-proto-build)
|
||||
- [kigi-acp-lib 0.1.15](https://crates.io/crates/kigi-acp-lib)
|
||||
- [kigi-agent 0.1.15](https://crates.io/crates/kigi-agent)
|
||||
- [kigi-agent-lifecycle 0.1.15](https://crates.io/crates/kigi-agent-lifecycle)
|
||||
- [kigi-auth 0.1.15](https://crates.io/crates/kigi-auth)
|
||||
- [kigi-bin 0.1.15](https://crates.io/crates/kigi-bin)
|
||||
- [kigi-chat-state 0.1.15](https://crates.io/crates/kigi-chat-state)
|
||||
- [kigi-codebase-graph 0.1.15](https://crates.io/crates/kigi-codebase-graph)
|
||||
- [kigi-config 0.1.15](https://crates.io/crates/kigi-config)
|
||||
- [kigi-config-types 0.1.15](https://crates.io/crates/kigi-config-types)
|
||||
- [kigi-crash-handler 0.1.15](https://crates.io/crates/kigi-crash-handler)
|
||||
- [kigi-env 0.1.15](https://crates.io/crates/kigi-env)
|
||||
- [kigi-fast-worktree 0.1.15](https://crates.io/crates/kigi-fast-worktree)
|
||||
- [kigi-file-utils 0.1.15](https://crates.io/crates/kigi-file-utils)
|
||||
- [kigi-fsnotify 0.1.15](https://crates.io/crates/kigi-fsnotify)
|
||||
- [kigi-gix-status 0.1.15](https://crates.io/crates/kigi-gix-status)
|
||||
- [kigi-hooks 0.1.15](https://crates.io/crates/kigi-hooks)
|
||||
- [kigi-hooks-plugins-types 0.1.15](https://crates.io/crates/kigi-hooks-plugins-types)
|
||||
- [kigi-http 0.1.15](https://crates.io/crates/kigi-http)
|
||||
- [kigi-hunk-tracker 0.1.15](https://crates.io/crates/kigi-hunk-tracker)
|
||||
- [kigi-log 0.1.15](https://crates.io/crates/kigi-log)
|
||||
- [kigi-markdown 0.1.15](https://crates.io/crates/kigi-markdown)
|
||||
- [kigi-markdown-core 0.1.15](https://crates.io/crates/kigi-markdown-core)
|
||||
- [kigi-mcp 0.1.15](https://crates.io/crates/kigi-mcp)
|
||||
- [kigi-memory 0.1.15](https://crates.io/crates/kigi-memory)
|
||||
- [kigi-models 0.1.15](https://crates.io/crates/kigi-models)
|
||||
- [kigi-pager-minimal 0.1.15](https://crates.io/crates/kigi-pager-minimal)
|
||||
- [kigi-pager-pty-harness 0.1.15](https://crates.io/crates/kigi-pager-pty-harness)
|
||||
- [kigi-pager-render 0.1.15](https://crates.io/crates/kigi-pager-render)
|
||||
- [kigi-paths 0.1.15](https://crates.io/crates/kigi-paths)
|
||||
- [kigi-prompt-queue 0.1.15](https://crates.io/crates/kigi-prompt-queue)
|
||||
- [kigi-ratatui-inline 0.1.15](https://crates.io/crates/kigi-ratatui-inline)
|
||||
- [kigi-ratatui-textarea 0.1.15](https://crates.io/crates/kigi-ratatui-textarea)
|
||||
- [kigi-sampler 0.1.15](https://crates.io/crates/kigi-sampler)
|
||||
- [kigi-sampling-types 0.1.15](https://crates.io/crates/kigi-sampling-types)
|
||||
- [kigi-sandbox 0.1.15](https://crates.io/crates/kigi-sandbox)
|
||||
- [kigi-secrets 0.1.15](https://crates.io/crates/kigi-secrets)
|
||||
- [kigi-proto-build 0.1.20](https://crates.io/crates/kigi-proto-build)
|
||||
- [kigi-acp-lib 0.1.20](https://crates.io/crates/kigi-acp-lib)
|
||||
- [kigi-agent 0.1.20](https://crates.io/crates/kigi-agent)
|
||||
- [kigi-agent-lifecycle 0.1.20](https://crates.io/crates/kigi-agent-lifecycle)
|
||||
- [kigi-auth 0.1.20](https://crates.io/crates/kigi-auth)
|
||||
- [kigi-bin 0.1.20](https://crates.io/crates/kigi-bin)
|
||||
- [kigi-chat-state 0.1.20](https://crates.io/crates/kigi-chat-state)
|
||||
- [kigi-codebase-graph 0.1.20](https://crates.io/crates/kigi-codebase-graph)
|
||||
- [kigi-config 0.1.20](https://crates.io/crates/kigi-config)
|
||||
- [kigi-config-types 0.1.20](https://crates.io/crates/kigi-config-types)
|
||||
- [kigi-crash-handler 0.1.20](https://crates.io/crates/kigi-crash-handler)
|
||||
- [kigi-env 0.1.20](https://crates.io/crates/kigi-env)
|
||||
- [kigi-fast-worktree 0.1.20](https://crates.io/crates/kigi-fast-worktree)
|
||||
- [kigi-file-utils 0.1.20](https://crates.io/crates/kigi-file-utils)
|
||||
- [kigi-fsnotify 0.1.20](https://crates.io/crates/kigi-fsnotify)
|
||||
- [kigi-gix-status 0.1.20](https://crates.io/crates/kigi-gix-status)
|
||||
- [kigi-hooks 0.1.20](https://crates.io/crates/kigi-hooks)
|
||||
- [kigi-hooks-plugins-types 0.1.20](https://crates.io/crates/kigi-hooks-plugins-types)
|
||||
- [kigi-http 0.1.20](https://crates.io/crates/kigi-http)
|
||||
- [kigi-hunk-tracker 0.1.20](https://crates.io/crates/kigi-hunk-tracker)
|
||||
- [kigi-log 0.1.20](https://crates.io/crates/kigi-log)
|
||||
- [kigi-markdown 0.1.20](https://crates.io/crates/kigi-markdown)
|
||||
- [kigi-markdown-core 0.1.20](https://crates.io/crates/kigi-markdown-core)
|
||||
- [kigi-mcp 0.1.20](https://crates.io/crates/kigi-mcp)
|
||||
- [kigi-memory 0.1.20](https://crates.io/crates/kigi-memory)
|
||||
- [kigi-models 0.1.20](https://crates.io/crates/kigi-models)
|
||||
- [kigi-pager-minimal 0.1.20](https://crates.io/crates/kigi-pager-minimal)
|
||||
- [kigi-pager-pty-harness 0.1.20](https://crates.io/crates/kigi-pager-pty-harness)
|
||||
- [kigi-pager-render 0.1.20](https://crates.io/crates/kigi-pager-render)
|
||||
- [kigi-paths 0.1.20](https://crates.io/crates/kigi-paths)
|
||||
- [kigi-prompt-queue 0.1.20](https://crates.io/crates/kigi-prompt-queue)
|
||||
- [kigi-ratatui-inline 0.1.20](https://crates.io/crates/kigi-ratatui-inline)
|
||||
- [kigi-ratatui-textarea 0.1.20](https://crates.io/crates/kigi-ratatui-textarea)
|
||||
- [kigi-sampler 0.1.20](https://crates.io/crates/kigi-sampler)
|
||||
- [kigi-sampling-types 0.1.20](https://crates.io/crates/kigi-sampling-types)
|
||||
- [kigi-sandbox 0.1.20](https://crates.io/crates/kigi-sandbox)
|
||||
- [kigi-secrets 0.1.20](https://crates.io/crates/kigi-secrets)
|
||||
- [kigi-shared 0.0.0](https://crates.io/crates/kigi-shared)
|
||||
- [kigi-shell 0.1.15](https://crates.io/crates/kigi-shell)
|
||||
- [kigi-shell-base 0.1.15](https://crates.io/crates/kigi-shell-base)
|
||||
- [kigi-sqlite-journal 0.1.15](https://crates.io/crates/kigi-sqlite-journal)
|
||||
- [kigi-subagent-resolution 0.1.15](https://crates.io/crates/kigi-subagent-resolution)
|
||||
- [kigi-system-power 0.1.15](https://crates.io/crates/kigi-system-power)
|
||||
- [kigi-test-support 0.1.15](https://crates.io/crates/kigi-test-support)
|
||||
- [kigi-token-estimation 0.1.15](https://crates.io/crates/kigi-token-estimation)
|
||||
- [kigi-tools 0.1.15](https://crates.io/crates/kigi-tools)
|
||||
- [kigi-tools-api 0.1.15](https://crates.io/crates/kigi-tools-api)
|
||||
- [kigi-tracing-macros 0.1.15](https://crates.io/crates/kigi-tracing-macros)
|
||||
- [kigi-tty-utils 0.1.15](https://crates.io/crates/kigi-tty-utils)
|
||||
- [kigi-tui 0.1.15](https://crates.io/crates/kigi-tui)
|
||||
- [kigi-update 0.1.15](https://crates.io/crates/kigi-update)
|
||||
- [kigi-version 0.1.15](https://crates.io/crates/kigi-version)
|
||||
- [kigi-workspace 0.1.15](https://crates.io/crates/kigi-workspace)
|
||||
- [kigi-workspace-types 0.1.15](https://crates.io/crates/kigi-workspace-types)
|
||||
- [ptyctl 0.1.15](https://crates.io/crates/ptyctl)
|
||||
- [ptyctl-cli 0.1.15](https://crates.io/crates/ptyctl-cli)
|
||||
- [kigi-compaction 0.1.15](https://crates.io/crates/kigi-compaction)
|
||||
- [kigi-interjection-core 0.1.15](https://crates.io/crates/kigi-interjection-core)
|
||||
- [kigi-test-utils 0.1.15](https://crates.io/crates/kigi-test-utils)
|
||||
- [kigi-tool-protocol 0.1.15](https://crates.io/crates/kigi-tool-protocol)
|
||||
- [kigi-tool-runtime 0.1.15](https://crates.io/crates/kigi-tool-runtime)
|
||||
- [kigi-tool-types 0.1.15](https://crates.io/crates/kigi-tool-types)
|
||||
- [kigi-shell 0.1.20](https://crates.io/crates/kigi-shell)
|
||||
- [kigi-shell-base 0.1.20](https://crates.io/crates/kigi-shell-base)
|
||||
- [kigi-sqlite-journal 0.1.20](https://crates.io/crates/kigi-sqlite-journal)
|
||||
- [kigi-subagent-resolution 0.1.20](https://crates.io/crates/kigi-subagent-resolution)
|
||||
- [kigi-system-power 0.1.20](https://crates.io/crates/kigi-system-power)
|
||||
- [kigi-test-support 0.1.20](https://crates.io/crates/kigi-test-support)
|
||||
- [kigi-token-estimation 0.1.20](https://crates.io/crates/kigi-token-estimation)
|
||||
- [kigi-tools 0.1.20](https://crates.io/crates/kigi-tools)
|
||||
- [kigi-tools-api 0.1.20](https://crates.io/crates/kigi-tools-api)
|
||||
- [kigi-tracing-macros 0.1.20](https://crates.io/crates/kigi-tracing-macros)
|
||||
- [kigi-tty-utils 0.1.20](https://crates.io/crates/kigi-tty-utils)
|
||||
- [kigi-tui 0.1.20](https://crates.io/crates/kigi-tui)
|
||||
- [kigi-update 0.1.20](https://crates.io/crates/kigi-update)
|
||||
- [kigi-version 0.1.20](https://crates.io/crates/kigi-version)
|
||||
- [kigi-workspace 0.1.20](https://crates.io/crates/kigi-workspace)
|
||||
- [kigi-workspace-types 0.1.20](https://crates.io/crates/kigi-workspace-types)
|
||||
- [ptyctl 0.1.20](https://crates.io/crates/ptyctl)
|
||||
- [ptyctl-cli 0.1.20](https://crates.io/crates/ptyctl-cli)
|
||||
- [kigi-compaction 0.1.20](https://crates.io/crates/kigi-compaction)
|
||||
- [kigi-interjection-core 0.1.20](https://crates.io/crates/kigi-interjection-core)
|
||||
- [kigi-test-utils 0.1.20](https://crates.io/crates/kigi-test-utils)
|
||||
- [kigi-tool-protocol 0.1.20](https://crates.io/crates/kigi-tool-protocol)
|
||||
- [kigi-tool-runtime 0.1.20](https://crates.io/crates/kigi-tool-runtime)
|
||||
- [kigi-tool-types 0.1.20](https://crates.io/crates/kigi-tool-types)
|
||||
- [agent-client-protocol 0.10.4](https://github.com/agentclientprotocol/rust-sdk)
|
||||
- [allocator-api2 0.2.21](https://github.com/zakarumych/allocator-api2)
|
||||
- [anes 0.1.6](https://github.com/zrzka/anes-rs)
|
||||
@@ -16506,7 +16506,7 @@ Applies to:
|
||||
- [async-trait 0.1.89](https://github.com/dtolnay/async-trait)
|
||||
- [aws-config 1.9.0](https://github.com/smithy-lang/smithy-rs)
|
||||
- [aws-credential-types 1.3.0](https://github.com/smithy-lang/smithy-rs)
|
||||
- [aws-lc-sys 0.42.0](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-lc-sys 0.45.0](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-runtime 1.8.1](https://github.com/smithy-lang/smithy-rs)
|
||||
- [aws-sigv4 1.5.1](https://github.com/smithy-lang/smithy-rs)
|
||||
- [aws-smithy-async 1.3.0](https://github.com/smithy-lang/smithy-rs)
|
||||
@@ -16772,7 +16772,7 @@ limitations under the License.
|
||||
|
||||
Applies to:
|
||||
|
||||
- [kigi-mermaid 0.1.15](https://crates.io/crates/kigi-mermaid)
|
||||
- [kigi-mermaid 0.1.20](https://crates.io/crates/kigi-mermaid)
|
||||
|
||||
```
|
||||
Roboto-Regular.ttf
|
||||
@@ -17633,7 +17633,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Applies to:
|
||||
|
||||
- [alloc-stdlib 0.2.4](https://github.com/dropbox/rust-alloc-no-stdlib)
|
||||
- [aws-lc-sys 0.42.0](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-lc-sys 0.45.0](https://github.com/aws/aws-lc-rs)
|
||||
- [curve25519-dalek 4.1.3](https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek)
|
||||
- [sha1_smol 1.0.1](https://github.com/mitsuhiko/sha1-smol)
|
||||
- [sigstore-trust-root 0.6.3](https://github.com/prefix-dev/sigstore-rust)
|
||||
@@ -18819,7 +18819,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
Applies to:
|
||||
|
||||
- [rustls-webpki 0.103.13](https://github.com/rustls/webpki)
|
||||
- [rustls-webpki 0.103.15](https://github.com/rustls/webpki)
|
||||
|
||||
```
|
||||
Except as otherwise noted, this project is licensed under the following
|
||||
@@ -18848,8 +18848,8 @@ third-party/chromium/LICENSE.
|
||||
|
||||
Applies to:
|
||||
|
||||
- [aws-lc-rs 1.17.1](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-lc-sys 0.42.0](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-lc-rs 1.18.1](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-lc-sys 0.45.0](https://github.com/aws/aws-lc-rs)
|
||||
- [scraper 0.23.1](https://github.com/causal-agent/scraper)
|
||||
|
||||
```
|
||||
@@ -22846,7 +22846,7 @@ SOFTWARE.
|
||||
|
||||
Applies to:
|
||||
|
||||
- [aws-lc-sys 0.42.0](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-lc-sys 0.45.0](https://github.com/aws/aws-lc-rs)
|
||||
|
||||
```
|
||||
The MIT License (MIT)
|
||||
@@ -25334,3 +25334,4 @@ the following restrictions:
|
||||
3. This notice may not be removed or altered from any source distribution.
|
||||
```
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ use rmcp::{
|
||||
|
||||
use crate::oauth_config::McpOAuthConfig;
|
||||
|
||||
mod cancel_aware;
|
||||
use cancel_aware::call_tool_cancel_aware;
|
||||
|
||||
use kigi_tools::types::{
|
||||
output::{MCPOutput, MCPOutputDetails, ToolOutput},
|
||||
tool::{ToolKind, ToolNamespace},
|
||||
@@ -1599,12 +1602,24 @@ impl McpErasedTool {
|
||||
let mut params = CallToolRequestParams::new(self.tool.name.clone());
|
||||
params.arguments = raw.as_object().cloned();
|
||||
|
||||
let result =
|
||||
tokio::time::timeout(timeout_duration, mcp_service.call_tool(params.clone())).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(call_result)) => Ok(call_result),
|
||||
Ok(Err(service_err))
|
||||
match call_tool_cancel_aware(&mcp_service, params.clone(), timeout_duration).await {
|
||||
Ok(call_result) => Ok(call_result),
|
||||
Err(ServiceError::Timeout { .. }) => {
|
||||
*is_timeout = true;
|
||||
// Reset for the next call but don't retry — a slow side-effecting tool must not run twice.
|
||||
if client.is_http() && !*reconnect_attempted {
|
||||
client.reset_transport().await;
|
||||
*reconnect_attempted = true;
|
||||
}
|
||||
Err(kigi_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
format!(
|
||||
"MCP tool '{}' timed out after {} seconds",
|
||||
self.tool.name, tool_timeout
|
||||
),
|
||||
))
|
||||
}
|
||||
Err(service_err)
|
||||
if should_recover_service_error(
|
||||
&service_err,
|
||||
client.is_http(),
|
||||
@@ -1623,25 +1638,10 @@ impl McpErasedTool {
|
||||
)
|
||||
.await
|
||||
}
|
||||
Ok(Err(e)) => Err(kigi_tool_runtime::ToolError::custom(
|
||||
Err(e) => Err(kigi_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
e.to_string(),
|
||||
)),
|
||||
Err(_) => {
|
||||
*is_timeout = true;
|
||||
// Reset for the next call but don't retry — a slow side-effecting tool must not run twice.
|
||||
if client.is_http() && !*reconnect_attempted {
|
||||
client.reset_transport().await;
|
||||
*reconnect_attempted = true;
|
||||
}
|
||||
Err(kigi_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
format!(
|
||||
"MCP tool '{}' timed out after {} seconds",
|
||||
self.tool.name, tool_timeout
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1692,13 +1692,9 @@ impl McpErasedTool {
|
||||
));
|
||||
}
|
||||
};
|
||||
match tokio::time::timeout(timeout_duration, mcp_service.call_tool(params)).await {
|
||||
Ok(Ok(call_result)) => Ok(call_result),
|
||||
Ok(Err(retry_err)) => Err(kigi_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
retry_err.to_string(),
|
||||
)),
|
||||
Err(_) => {
|
||||
match call_tool_cancel_aware(&mcp_service, params, timeout_duration).await {
|
||||
Ok(call_result) => Ok(call_result),
|
||||
Err(ServiceError::Timeout { .. }) => {
|
||||
*is_timeout = true;
|
||||
Err(kigi_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
@@ -1708,6 +1704,10 @@ impl McpErasedTool {
|
||||
),
|
||||
))
|
||||
}
|
||||
Err(retry_err) => Err(kigi_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
retry_err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6223,11 +6223,17 @@ mod tests {
|
||||
let mut reconnect = false;
|
||||
let mut is_timeout = false;
|
||||
let raw = serde_json::json!({});
|
||||
let started = std::time::Instant::now();
|
||||
let err = tool
|
||||
.try_call_tool(&client, &raw, &mut reconnect, &mut is_timeout, &ew)
|
||||
.await
|
||||
.expect_err("call must time out");
|
||||
|
||||
assert!(
|
||||
started.elapsed() < std::time::Duration::from_millis(2500),
|
||||
"cancel must not wait out the 3s hang: {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
assert!(err.to_string().contains("timed out"), "got: {err}");
|
||||
assert!(is_timeout, "is_timeout must be set");
|
||||
assert!(reconnect, "timeout arm flags the reconnect after resetting");
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! `tools/call` that tells the server when the host stops waiting.
|
||||
|
||||
use rmcp::model::{
|
||||
CallToolRequest, CallToolRequestParams, CallToolResult, CancelledNotificationParam,
|
||||
ClientRequest, RequestId, ServerResult,
|
||||
};
|
||||
use rmcp::service::{Peer, PeerRequestOptions};
|
||||
use rmcp::{RoleClient, ServiceError};
|
||||
|
||||
use super::McpService;
|
||||
|
||||
/// rmcp 2.2.0's HTTP worker queues the cancel behind a hung POST, so the wait is bounded.
|
||||
const CANCEL_DELIVERY_BUDGET: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
|
||||
/// A timeout cancels inline, before the caller can reset the transport; a drop cancels from a task.
|
||||
pub(super) async fn call_tool_cancel_aware(
|
||||
service: &McpService,
|
||||
params: CallToolRequestParams,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<CallToolResult, ServiceError> {
|
||||
let handle = service
|
||||
.peer()
|
||||
.send_cancellable_request(
|
||||
ClientRequest::CallToolRequest(CallToolRequest::new(params)),
|
||||
PeerRequestOptions::no_options(),
|
||||
)
|
||||
.await?;
|
||||
let mut guard = CancelOnDrop {
|
||||
peer: service.peer().clone(),
|
||||
request_id: Some(handle.id.clone()),
|
||||
};
|
||||
let Ok(response) = tokio::time::timeout(timeout, handle.await_response()).await else {
|
||||
guard.cancel_within_budget().await;
|
||||
return Err(ServiceError::Timeout { timeout });
|
||||
};
|
||||
guard.request_id = None;
|
||||
match response? {
|
||||
ServerResult::CallToolResult(result) => Ok(result),
|
||||
_ => Err(ServiceError::UnexpectedResponse),
|
||||
}
|
||||
}
|
||||
|
||||
/// Armed while the request is in flight; disarmed once it settles.
|
||||
struct CancelOnDrop {
|
||||
peer: Peer<RoleClient>,
|
||||
request_id: Option<RequestId>,
|
||||
}
|
||||
|
||||
impl CancelOnDrop {
|
||||
async fn cancel_within_budget(&mut self) {
|
||||
let Some(request_id) = self.request_id.clone() else {
|
||||
return;
|
||||
};
|
||||
let delivery = notify_cancelled(self.peer.clone(), request_id.clone());
|
||||
if tokio::time::timeout(CANCEL_DELIVERY_BUDGET, delivery)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
?request_id,
|
||||
"notifications/cancelled still queued after budget"
|
||||
);
|
||||
}
|
||||
self.request_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CancelOnDrop {
|
||||
fn drop(&mut self) {
|
||||
let Some(request_id) = self.request_id.take() else {
|
||||
return;
|
||||
};
|
||||
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||
tracing::warn!(
|
||||
?request_id,
|
||||
"MCP call dropped outside a runtime; cancel not sent"
|
||||
);
|
||||
return;
|
||||
};
|
||||
runtime.spawn(notify_cancelled(self.peer.clone(), request_id));
|
||||
}
|
||||
}
|
||||
|
||||
async fn notify_cancelled(peer: Peer<RoleClient>, request_id: RequestId) {
|
||||
let params = CancelledNotificationParam::new(
|
||||
Some(request_id.clone()),
|
||||
Some("client cancelled".to_owned()),
|
||||
);
|
||||
if let Err(e) = peer.notify_cancelled(params).await {
|
||||
tracing::warn!(?request_id, error = %e, "notifications/cancelled not delivered");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rmcp::ServiceExt;
|
||||
use serde_json::Value;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use super::super::{KigiClientHandler, McpClient};
|
||||
use super::*;
|
||||
|
||||
type Received = Arc<parking_lot::Mutex<Vec<Value>>>;
|
||||
|
||||
/// Duplex MCP server: answers `initialize`, records all, answers `tools/call` only when `reply`.
|
||||
async fn recording_service(reply: bool) -> (McpService, Received) {
|
||||
let received = Received::default();
|
||||
let (client_read, mut server_write) = tokio::io::duplex(64 * 1024);
|
||||
let (server_read, client_write) = tokio::io::duplex(64 * 1024);
|
||||
let sink = received.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(server_read).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let msg: Value = serde_json::from_str(&line).expect("client sends JSON");
|
||||
sink.lock().push(msg.clone());
|
||||
let result = match msg["method"].as_str() {
|
||||
Some("initialize") => serde_json::json!({
|
||||
"protocolVersion": msg["params"]["protocolVersion"],
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "recording", "version": "0.0.0" },
|
||||
}),
|
||||
Some("tools/call") if reply => serde_json::json!({
|
||||
"content": [{ "type": "text", "text": "done" }],
|
||||
"isError": false,
|
||||
}),
|
||||
_ => continue,
|
||||
};
|
||||
let response =
|
||||
serde_json::json!({ "jsonrpc": "2.0", "id": msg["id"], "result": result });
|
||||
let encoded = format!("{response}\n");
|
||||
server_write
|
||||
.write_all(encoded.as_bytes())
|
||||
.await
|
||||
.expect("client reads replies");
|
||||
}
|
||||
});
|
||||
let handler = KigiClientHandler {
|
||||
info: McpClient::make_client_info("recording"),
|
||||
server_name: "recording".to_string(),
|
||||
notify_tx: Arc::default(),
|
||||
};
|
||||
let transport = rmcp::transport::async_rw::AsyncRwTransport::<RoleClient, _, _>::new(
|
||||
client_read,
|
||||
client_write,
|
||||
);
|
||||
let service = Arc::new(handler.serve(transport).await.expect("handshake"));
|
||||
(service, received)
|
||||
}
|
||||
|
||||
fn values_at(received: &Received, method: &str, pointer: &str) -> Vec<Value> {
|
||||
received
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|m| m["method"] == method)
|
||||
.map(|m| m.pointer(pointer).cloned().unwrap_or(Value::Null))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn call_ids(received: &Received) -> Vec<Value> {
|
||||
values_at(received, "tools/call", "/id")
|
||||
}
|
||||
|
||||
fn cancelled_ids(received: &Received) -> Vec<Value> {
|
||||
values_at(received, "notifications/cancelled", "/params/requestId")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropped_call_cancels_its_request() {
|
||||
let (service, received) = recording_service(false).await;
|
||||
let mut call = Box::pin(call_tool_cancel_aware(
|
||||
&service,
|
||||
CallToolRequestParams::new("slow"),
|
||||
Duration::from_secs(30),
|
||||
));
|
||||
let pending = tokio::time::timeout(Duration::from_millis(100), &mut call).await;
|
||||
assert!(pending.is_err(), "server never replies");
|
||||
drop(call);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let ids = call_ids(&received);
|
||||
assert_eq!(ids.len(), 1, "{ids:?}");
|
||||
assert_eq!(cancelled_ids(&received), ids);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timed_out_call_cancels_its_request_once() {
|
||||
let (service, received) = recording_service(false).await;
|
||||
let round = call_tool_cancel_aware(
|
||||
&service,
|
||||
CallToolRequestParams::new("slow"),
|
||||
Duration::from_millis(50),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(round, Err(ServiceError::Timeout { .. })),
|
||||
"{round:?}"
|
||||
);
|
||||
// The HTTP timeout arm resets the transport, which drops the last service handle.
|
||||
drop(service);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let ids = call_ids(&received);
|
||||
assert_eq!(ids.len(), 1, "{ids:?}");
|
||||
assert_eq!(cancelled_ids(&received), ids);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_call_sends_no_cancellation() {
|
||||
let (service, received) = recording_service(true).await;
|
||||
let result = call_tool_cancel_aware(
|
||||
&service,
|
||||
CallToolRequestParams::new("fast"),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await
|
||||
.expect("server replies");
|
||||
assert_eq!(result.is_error, Some(false));
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(cancelled_ids(&received), Vec::<Value>::new());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,336 @@
|
||||
//! 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 manually added model id: no whitespace, no controls, bounded length.
|
||||
pub fn validate_model_id(id: &str) -> Result<(), CustomProviderError> {
|
||||
let err = |reason: &str| Err(CustomProviderError::Name(reason.to_owned()));
|
||||
if id.is_empty() || id.len() > 128 {
|
||||
return err("model id must be 1 to 128 characters");
|
||||
}
|
||||
if id.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||
return err("model id must not contain whitespace");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
@@ -1729,6 +1756,23 @@ pub struct WireThinkEfforts {
|
||||
pub default_effort: Option<String>,
|
||||
}
|
||||
|
||||
impl WireModel {
|
||||
/// An id-only model, as if the listing served no metadata.
|
||||
pub fn bare(id: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
context_length: 0,
|
||||
supports_reasoning: false,
|
||||
supports_image_in: false,
|
||||
supports_video_in: false,
|
||||
display_name: None,
|
||||
max_output_tokens: 0,
|
||||
supports_thinking_type: None,
|
||||
think_efforts: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET {base}/models` response envelope.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct WireModelsResponse {
|
||||
@@ -2653,29 +2697,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 +2748,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 +2790,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]
|
||||
|
||||
@@ -267,6 +267,13 @@ struct WrapCache {
|
||||
lines: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
/// Whether a wrapped row's exclusive end is a cursor position on that row.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum RowEnd {
|
||||
HardBreak,
|
||||
SoftWrap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct TextAreaState {
|
||||
/// Index into wrapped lines of the first visible line.
|
||||
@@ -382,27 +389,13 @@ impl TextArea {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp a buffer position so it stays within a wrapped line's range
|
||||
/// `[line_start, line_end)`. Without this, `display_col_to_buffer_pos`
|
||||
/// can return `line_end` when the column exceeds the line's display
|
||||
/// width — and `line_end` equals the *next* wrapped line's start,
|
||||
/// which confuses `effective_scroll` into thinking the cursor hasn't
|
||||
/// actually moved to the target line.
|
||||
///
|
||||
/// Uses `self.text` to find the last valid char boundary inside the line
|
||||
/// so we never land in the middle of a multi-byte character.
|
||||
/// Clamp `pos` to the start of the last grapheme in `[line_start, line_end)`.
|
||||
fn clamp_to_line(&self, pos: usize, line_start: usize, line_end: usize) -> usize {
|
||||
if line_end > line_start {
|
||||
// Find the start of the last character in the line.
|
||||
let last_char_start = self.text[line_start..line_end]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| line_start + i)
|
||||
.unwrap_or(line_start);
|
||||
pos.min(last_char_start)
|
||||
} else {
|
||||
line_start
|
||||
}
|
||||
let last_grapheme_start = self.text[line_start..line_end]
|
||||
.grapheme_indices(true)
|
||||
.next_back()
|
||||
.map_or(line_start, |(i, _)| line_start + i);
|
||||
pos.min(last_grapheme_start)
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
@@ -1699,6 +1692,35 @@ impl TextArea {
|
||||
if idx == 0 { None } else { Some(idx - 1) }
|
||||
}
|
||||
|
||||
/// Soft-wrapped rows are byte-adjacent; a hard row leaves its newline between it and the next.
|
||||
fn row_with_end(lines: &[Range<usize>], idx: usize) -> Option<(Range<usize>, RowEnd)> {
|
||||
let row = lines.get(idx)?;
|
||||
let end_kind = match lines.get(idx + 1) {
|
||||
Some(next) if next.start == row.end => RowEnd::SoftWrap,
|
||||
_ => RowEnd::HardBreak,
|
||||
};
|
||||
Some((row.clone(), end_kind))
|
||||
}
|
||||
|
||||
fn clamp_to_row(&self, pos: usize, row: &Range<usize>, end_kind: RowEnd) -> usize {
|
||||
match end_kind {
|
||||
RowEnd::HardBreak => pos,
|
||||
RowEnd::SoftWrap => {
|
||||
let last_grapheme_start = self.clamp_to_line(pos, row.start, row.end);
|
||||
let Some(idx) = self.find_element_containing(last_grapheme_start) else {
|
||||
return last_grapheme_start;
|
||||
};
|
||||
let element_start = self.elements[idx].range.start;
|
||||
// A display-less element wraps as text; one that began on an earlier row keeps `pos`.
|
||||
if element_start < row.start {
|
||||
pos
|
||||
} else {
|
||||
element_start
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a display column to a buffer byte position on a given wrapped line.
|
||||
///
|
||||
/// Pure query — does not mutate any state. Handles elements (snapping to
|
||||
@@ -1791,13 +1813,12 @@ impl TextArea {
|
||||
|
||||
fn move_to_display_col_on_line(
|
||||
&mut self,
|
||||
line_start: usize,
|
||||
line_end: usize,
|
||||
row: Range<usize>,
|
||||
target_col: usize,
|
||||
end_kind: RowEnd,
|
||||
) {
|
||||
self.cursor_pos = self
|
||||
.display_col_to_buffer_pos(line_start, line_end, target_col)
|
||||
.0;
|
||||
let (pos, _hit_element) = self.display_col_to_buffer_pos(row.start, row.end, target_col);
|
||||
self.cursor_pos = self.clamp_to_row(pos, &row, end_kind);
|
||||
}
|
||||
|
||||
fn beginning_of_line(&self, pos: usize) -> usize {
|
||||
@@ -2652,14 +2673,10 @@ impl TextArea {
|
||||
let target_col = self.preferred_col.unwrap_or_else(|| {
|
||||
self.display_width_of_range(cur_range.start, self.cursor_pos)
|
||||
});
|
||||
if idx > 0 {
|
||||
let prev = &lines[idx - 1];
|
||||
let line_start = prev.start;
|
||||
let line_end = prev.end;
|
||||
Some((target_col, Some((line_start, line_end))))
|
||||
} else {
|
||||
Some((target_col, None))
|
||||
}
|
||||
let prev = idx
|
||||
.checked_sub(1)
|
||||
.and_then(|prev_idx| Self::row_with_end(lines, prev_idx));
|
||||
Some((target_col, prev))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -2669,11 +2686,11 @@ impl TextArea {
|
||||
} {
|
||||
// We had wrapping info. Apply movement accordingly.
|
||||
match maybe_line {
|
||||
Some((line_start, line_end)) => {
|
||||
Some((prev, end_kind)) => {
|
||||
if self.preferred_col.is_none() {
|
||||
self.preferred_col = Some(target_col);
|
||||
}
|
||||
self.move_to_display_col_on_line(line_start, line_end, target_col);
|
||||
self.move_to_display_col_on_line(prev, target_col, end_kind);
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
@@ -2697,7 +2714,11 @@ impl TextArea {
|
||||
};
|
||||
let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0);
|
||||
let prev_line_end = prev_nl;
|
||||
self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col);
|
||||
self.move_to_display_col_on_line(
|
||||
prev_line_start..prev_line_end,
|
||||
target_col,
|
||||
RowEnd::HardBreak,
|
||||
);
|
||||
} else {
|
||||
self.cursor_pos = 0;
|
||||
self.preferred_col = None;
|
||||
@@ -2716,14 +2737,7 @@ impl TextArea {
|
||||
let target_col = self.preferred_col.unwrap_or_else(|| {
|
||||
self.display_width_of_range(cur_range.start, self.cursor_pos)
|
||||
});
|
||||
if idx + 1 < lines.len() {
|
||||
let next = &lines[idx + 1];
|
||||
let line_start = next.start;
|
||||
let line_end = next.end;
|
||||
Some((target_col, Some((line_start, line_end))))
|
||||
} else {
|
||||
Some((target_col, None))
|
||||
}
|
||||
Some((target_col, Self::row_with_end(lines, idx + 1)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -2732,11 +2746,11 @@ impl TextArea {
|
||||
}
|
||||
} {
|
||||
match move_to_last {
|
||||
Some((line_start, line_end)) => {
|
||||
Some((next, end_kind)) => {
|
||||
if self.preferred_col.is_none() {
|
||||
self.preferred_col = Some(target_col);
|
||||
}
|
||||
self.move_to_display_col_on_line(line_start, line_end, target_col);
|
||||
self.move_to_display_col_on_line(next, target_col, end_kind);
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
@@ -2766,7 +2780,11 @@ impl TextArea {
|
||||
.find('\n')
|
||||
.map(|i| i + next_line_start)
|
||||
.unwrap_or(self.text.len());
|
||||
self.move_to_display_col_on_line(next_line_start, next_line_end, target_col);
|
||||
self.move_to_display_col_on_line(
|
||||
next_line_start..next_line_end,
|
||||
target_col,
|
||||
RowEnd::HardBreak,
|
||||
);
|
||||
} else {
|
||||
self.cursor_pos = self.text.len();
|
||||
self.preferred_col = None;
|
||||
@@ -3899,6 +3917,9 @@ fn truncate_line_display(line: &Line<'static>, max_width: usize) -> Line<'static
|
||||
Line::from(new_spans)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod soft_wrap_nav_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -6293,6 +6314,7 @@ mod tests {
|
||||
assert_eq!(t.cursor(), "👍👍".len());
|
||||
}
|
||||
|
||||
/// Each ZWJ cluster fills a 4-col row; Up from the end lands on row 1's cluster start.
|
||||
#[test]
|
||||
fn wrapped_navigation_with_zwj_graphemes() {
|
||||
let grapheme = "👩\u{200D}💻";
|
||||
@@ -6306,7 +6328,7 @@ mod tests {
|
||||
assert!(pos_after_down >= grapheme.len() * 2);
|
||||
|
||||
t.move_cursor_up();
|
||||
assert_eq!(t.cursor(), grapheme.len() * 2);
|
||||
assert_eq!(t.cursor(), grapheme.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Up/Down across soft-wrapped rows: a soft row's exclusive end is the next row's start.
|
||||
|
||||
use rand::prelude::*;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use super::{ElementKind, TextArea};
|
||||
|
||||
fn ta_with(text: &str) -> TextArea {
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str(text);
|
||||
t
|
||||
}
|
||||
|
||||
fn cursor_row(t: &TextArea, width: u16) -> usize {
|
||||
TextArea::wrapped_line_index_by_start(&t.wrapped_lines(width), t.cursor()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_from_wide_row_lands_on_last_char_of_narrower_soft_row() {
|
||||
let width = 8;
|
||||
let mut t = ta_with("aaa bb ccccccc");
|
||||
assert_eq!(vec![0..7, 7..14], *t.wrapped_lines(width));
|
||||
|
||||
t.move_cursor_up();
|
||||
assert_eq!(6, t.cursor());
|
||||
assert_eq!(0, cursor_row(&t, width));
|
||||
assert_eq!(Some((6, 0)), t.cursor_pos(Rect::new(0, 0, width, 5)));
|
||||
|
||||
t.move_cursor_down();
|
||||
assert_eq!(14, t.cursor());
|
||||
|
||||
t.move_cursor_up();
|
||||
assert_eq!(6, t.cursor());
|
||||
t.move_cursor_up();
|
||||
assert_eq!(0, t.cursor());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_onto_newline_terminated_row_lands_on_the_newline() {
|
||||
let mut t = ta_with("ab\ncdef");
|
||||
assert_eq!(vec![0..2, 3..7], *t.wrapped_lines(8));
|
||||
|
||||
t.move_cursor_up();
|
||||
assert_eq!(2, t.cursor());
|
||||
assert_eq!(0, cursor_row(&t, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_from_wide_row_lands_on_last_char_of_narrower_soft_row() {
|
||||
let mut t = ta_with("ccccccc aaa bb ddddddd");
|
||||
assert_eq!(vec![0..8, 8..15, 15..22], *t.wrapped_lines(8));
|
||||
t.set_cursor(7);
|
||||
|
||||
t.move_cursor_down();
|
||||
assert_eq!(14, t.cursor());
|
||||
assert_eq!(1, cursor_row(&t, 8));
|
||||
|
||||
t.move_cursor_down();
|
||||
assert_eq!(22, t.cursor());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_onto_soft_row_closed_by_element_lands_before_the_element() {
|
||||
let mut t = TextArea::new();
|
||||
t.insert_element("xyz", ElementKind(0), Some(Line::from("[ELEM]")));
|
||||
t.insert_str("longword");
|
||||
assert_eq!(vec![0..3, 3..11], *t.wrapped_lines(8));
|
||||
|
||||
t.move_cursor_up();
|
||||
assert_eq!(0, t.cursor());
|
||||
assert_eq!(0, cursor_row(&t, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_through_element_spanning_rows_reaches_the_end() {
|
||||
let mut t = TextArea::new();
|
||||
t.insert_element("abcdefgh", ElementKind(0), None);
|
||||
t.insert_str("z");
|
||||
assert_eq!(vec![0..3, 3..6, 6..9], *t.wrapped_lines(3));
|
||||
t.set_cursor(0);
|
||||
|
||||
t.move_cursor_down();
|
||||
assert_eq!(8, t.cursor());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_and_down_move_one_row_at_a_time() {
|
||||
let mut rng = StdRng::seed_from_u64(7);
|
||||
for _ in 0..500 {
|
||||
let words = rng.random_range(1..12);
|
||||
let text = (0..words)
|
||||
.map(|_| "x".repeat(rng.random_range(1..9)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let width = rng.random_range(3..12);
|
||||
let mut t = ta_with(&text);
|
||||
let rows = t.wrapped_lines(width).len();
|
||||
t.set_cursor(rng.random_range(0..=text.len()));
|
||||
|
||||
let mut row = cursor_row(&t, width);
|
||||
while row + 1 < rows {
|
||||
t.move_cursor_down();
|
||||
assert_eq!(
|
||||
row + 1,
|
||||
cursor_row(&t, width),
|
||||
"down in {text:?} at width {width}"
|
||||
);
|
||||
row += 1;
|
||||
}
|
||||
while row > 0 {
|
||||
t.move_cursor_up();
|
||||
assert_eq!(
|
||||
row - 1,
|
||||
cursor_row(&t, width),
|
||||
"up in {text:?} at width {width}"
|
||||
);
|
||||
row -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -897,7 +897,7 @@ impl CompactionsRemaining {
|
||||
}
|
||||
|
||||
/// Reasoning effort level. `None`/`Minimal` are omitted on the Anthropic Messages API.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ReasoningEffort {
|
||||
None,
|
||||
@@ -982,6 +982,16 @@ impl std::str::FromStr for ReasoningEffort {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for ReasoningEffort {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
raw.parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical wire parse; remapped menu ids need a model catalog.
|
||||
pub fn parse_canonical_effort_token(token: &str) -> Option<ReasoningEffort> {
|
||||
token.parse().ok()
|
||||
@@ -1146,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
|
||||
@@ -1192,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";
|
||||
|
||||
@@ -1849,6 +1859,24 @@ mod tests {
|
||||
assert!(serde_json::from_str::<ReasoningEffort>("\"BOGUS\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_deserializes_mixed_case() {
|
||||
for (token, want) in [
|
||||
("\"High\"", ReasoningEffort::High),
|
||||
("\"XHIGH\"", ReasoningEffort::Xhigh),
|
||||
("\"mAx\"", ReasoningEffort::Max),
|
||||
] {
|
||||
let got: ReasoningEffort = serde_json::from_str(token).unwrap();
|
||||
assert_eq!(got, want, "deserialize {token}");
|
||||
}
|
||||
let err = serde_json::from_str::<ReasoningEffort>("\"Highest\"").unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("invalid reasoning effort"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(serde_json::from_str::<ReasoningEffort>("7").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_from_str_max_and_xhigh_are_distinct() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -544,13 +544,16 @@ mod attachments_protocol {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
mod osascript_probe;
|
||||
|
||||
// macOS: subprocess-based clipboard (no AppKit linkage)
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::attachments_protocol::{FURL_MARKER, IMAGE_MARKER, parse_attachments_output};
|
||||
use super::osascript_probe::{ProbeTemps, osascript_attachments, read_probe_raster};
|
||||
use super::{ClipboardAttachments, ImageData};
|
||||
|
||||
// Fast pasteboard probes (NSPasteboard via lazy dlopen)
|
||||
@@ -759,153 +762,15 @@ mod platform {
|
||||
Ok(output.stdout)
|
||||
}
|
||||
|
||||
fn attachments_probe_temp_paths() -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf)
|
||||
{
|
||||
let temp_dir = std::env::temp_dir();
|
||||
(
|
||||
temp_dir.join("kigi-clipboard-probe.png"),
|
||||
temp_dir.join("kigi-clipboard-probe.tiff"),
|
||||
temp_dir.join("kigi-clipboard-probe.jpg"),
|
||||
)
|
||||
}
|
||||
|
||||
fn read_clipboard_image_from_class(
|
||||
class: &str,
|
||||
path_png: &std::path::Path,
|
||||
path_tiff: &std::path::Path,
|
||||
path_jpg: &std::path::Path,
|
||||
) -> anyhow::Result<Option<ImageData>> {
|
||||
let (temp_path, mime) = match class {
|
||||
"PNGf" => (path_png, "image/png"),
|
||||
"TIFF" => (path_tiff, "image/tiff"),
|
||||
"JPEG" => (path_jpg, "image/jpeg"),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let data = match std::fs::read(temp_path) {
|
||||
Ok(bytes) if !bytes.is_empty() => bytes,
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(temp_path);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(temp_path);
|
||||
return Err(anyhow::anyhow!("failed to read clipboard temp file: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
let _ = std::fs::remove_file(temp_path);
|
||||
Ok(Some(ImageData {
|
||||
data,
|
||||
mime_type: mime.to_owned(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn remove_attachment_probe_temps(
|
||||
path_png: &std::path::Path,
|
||||
path_tiff: &std::path::Path,
|
||||
path_jpg: &std::path::Path,
|
||||
) {
|
||||
let _ = std::fs::remove_file(path_png);
|
||||
let _ = std::fs::remove_file(path_tiff);
|
||||
let _ = std::fs::remove_file(path_jpg);
|
||||
}
|
||||
|
||||
fn attachments_osascript(
|
||||
path_png: &std::path::Path,
|
||||
path_tiff: &std::path::Path,
|
||||
path_jpg: &std::path::Path,
|
||||
) -> String {
|
||||
format!(
|
||||
"set furlOut to \"none\"\n\
|
||||
try\n\
|
||||
set urlList to the clipboard as list\n\
|
||||
set out to \"\"\n\
|
||||
repeat with u in urlList\n\
|
||||
try\n\
|
||||
set itemRef to contents of u as \u{00AB}class furl\u{00BB}\n\
|
||||
set out to out & POSIX path of itemRef & \"\\n\"\n\
|
||||
end try\n\
|
||||
end repeat\n\
|
||||
if out is not \"\" then\n\
|
||||
set furlOut to out\n\
|
||||
else\n\
|
||||
try\n\
|
||||
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
|
||||
set furlOut to POSIX path of urlRef\n\
|
||||
on error\n\
|
||||
end try\n\
|
||||
end if\n\
|
||||
on error\n\
|
||||
try\n\
|
||||
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
|
||||
set furlOut to POSIX path of urlRef\n\
|
||||
on error\n\
|
||||
end try\n\
|
||||
end try\n\
|
||||
set imageOut to \"NONE\"\n\
|
||||
if furlOut is \"none\" then\n\
|
||||
try\n\
|
||||
set imgData to the clipboard as \u{00AB}class PNGf\u{00BB}\n\
|
||||
set filePath to POSIX file \"{png}\" as text\n\
|
||||
set fRef to open for access file filePath with write permission\n\
|
||||
set eof of fRef to 0\n\
|
||||
write imgData to fRef\n\
|
||||
close access fRef\n\
|
||||
set imageOut to \"PNGf\"\n\
|
||||
on error\n\
|
||||
try\n\
|
||||
set imgData to the clipboard as \u{00AB}class TIFF\u{00BB}\n\
|
||||
set filePath to POSIX file \"{tiff}\" as text\n\
|
||||
set fRef to open for access file filePath with write permission\n\
|
||||
set eof of fRef to 0\n\
|
||||
write imgData to fRef\n\
|
||||
close access fRef\n\
|
||||
set imageOut to \"TIFF\"\n\
|
||||
on error\n\
|
||||
try\n\
|
||||
set imgData to the clipboard as \u{00AB}class JPEG\u{00BB}\n\
|
||||
set filePath to POSIX file \"{jpg}\" as text\n\
|
||||
set fRef to open for access file filePath with write permission\n\
|
||||
set eof of fRef to 0\n\
|
||||
write imgData to fRef\n\
|
||||
close access fRef\n\
|
||||
set imageOut to \"JPEG\"\n\
|
||||
on error\n\
|
||||
end try\n\
|
||||
end try\n\
|
||||
end try\n\
|
||||
end if\n\
|
||||
return \"{furl_marker}\" & linefeed & furlOut & linefeed & \"{image_marker}\" & linefeed & \"IMAGE:\" & imageOut",
|
||||
png = path_png.display(),
|
||||
tiff = path_tiff.display(),
|
||||
jpg = path_jpg.display(),
|
||||
furl_marker = FURL_MARKER,
|
||||
image_marker = IMAGE_MARKER,
|
||||
)
|
||||
}
|
||||
|
||||
fn run_attachments_osascript() -> anyhow::Result<String> {
|
||||
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
|
||||
let script = attachments_osascript(&path_png, &path_tiff, &path_jpg);
|
||||
|
||||
fn run_osascript(script: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let mut cmd = Command::new("osascript");
|
||||
cmd.arg("-e")
|
||||
.arg(&script)
|
||||
.arg(script)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
kigi_tools::util::detach_std_command(&mut cmd);
|
||||
let stdout = match checked_command_stdout("osascript", cmd.output()) {
|
||||
Ok(stdout) => stdout,
|
||||
Err(error) => {
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
Ok(String::from_utf8_lossy(&stdout).into_owned())
|
||||
checked_command_stdout("osascript", cmd.output())
|
||||
}
|
||||
|
||||
/// Unified furl-then-image pasteboard probe.
|
||||
@@ -923,28 +788,7 @@ mod platform {
|
||||
image: Some(image),
|
||||
});
|
||||
}
|
||||
let raw = run_attachments_osascript()?;
|
||||
if raw.trim().is_empty() {
|
||||
return Ok(ClipboardAttachments::default());
|
||||
}
|
||||
|
||||
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
|
||||
let (file_urls, image_class) = parse_attachments_output(&raw);
|
||||
let image = if file_urls.is_some() {
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
None
|
||||
} else {
|
||||
match image_class {
|
||||
Some(class) => {
|
||||
read_clipboard_image_from_class(class, &path_png, &path_tiff, &path_jpg)?
|
||||
}
|
||||
None => {
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(ClipboardAttachments { file_urls, image })
|
||||
osascript_attachments(ProbeTemps::new(), run_osascript)
|
||||
}
|
||||
|
||||
/// Read text via `pbpaste -Prefer txt`.
|
||||
@@ -1020,8 +864,8 @@ mod platform {
|
||||
return Ok(Some(image));
|
||||
}
|
||||
|
||||
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
let temps = ProbeTemps::new()?;
|
||||
let (path_png, path_tiff, path_jpg) = temps.paths();
|
||||
|
||||
// Image-only AppleScript (ImageOnly paste route). Unicode guillemets
|
||||
// (\u{AB}/\u{BB}) are required for `«class …»` in `osascript -e`.
|
||||
@@ -1069,20 +913,13 @@ mod platform {
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
kigi_tools::util::detach_std_command(&mut cmd);
|
||||
let stdout = match checked_command_stdout("osascript", cmd.output()) {
|
||||
Ok(stdout) => stdout,
|
||||
Err(error) => {
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let stdout = checked_command_stdout("osascript", cmd.output())?;
|
||||
let class = String::from_utf8_lossy(&stdout);
|
||||
let class = class.trim();
|
||||
if class == "none" {
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
return Ok(None);
|
||||
}
|
||||
read_clipboard_image_from_class(class, &path_png, &path_tiff, &path_jpg)
|
||||
read_probe_raster(class, &temps)
|
||||
}
|
||||
|
||||
/// Read file URLs from the macOS pasteboard via `osascript`.
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! The macOS `osascript` attachment probe: scratch files, script text, and the read flow.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::attachments_protocol::{FURL_MARKER, IMAGE_MARKER, parse_attachments_output};
|
||||
use super::{ClipboardAttachments, ImageData};
|
||||
|
||||
/// Private 0700 dir, removed on drop: a shared path let a concurrent kigi swap the raster.
|
||||
pub(crate) struct ProbeTemps(tempfile::TempDir);
|
||||
|
||||
impl ProbeTemps {
|
||||
pub(crate) fn new() -> anyhow::Result<Self> {
|
||||
let mut builder = tempfile::Builder::new();
|
||||
builder.prefix("kigi-clipboard-probe-");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
builder.permissions(std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
builder
|
||||
.tempdir()
|
||||
.map(Self)
|
||||
.map_err(|e| anyhow::anyhow!("failed to create clipboard probe dir: {e}"))
|
||||
}
|
||||
|
||||
/// PNG, TIFF, JPEG: the AppleScript writes the first class that coerces.
|
||||
pub(crate) fn paths(&self) -> (PathBuf, PathBuf, PathBuf) {
|
||||
(
|
||||
self.0.path().join("probe.png"),
|
||||
self.0.path().join("probe.tiff"),
|
||||
self.0.path().join("probe.jpg"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// File URLs first, a raster only when none are present; without `temps` the raster half is skipped.
|
||||
pub(crate) fn attachments_osascript(temps: Option<&ProbeTemps>) -> String {
|
||||
let image_probe = temps.map_or_else(String::new, raster_half);
|
||||
format!(
|
||||
"set furlOut to \"none\"\n\
|
||||
try\n\
|
||||
set urlList to the clipboard as list\n\
|
||||
set out to \"\"\n\
|
||||
repeat with u in urlList\n\
|
||||
try\n\
|
||||
set itemRef to contents of u as \u{00AB}class furl\u{00BB}\n\
|
||||
set out to out & POSIX path of itemRef & \"\\n\"\n\
|
||||
end try\n\
|
||||
end repeat\n\
|
||||
if out is not \"\" then\n\
|
||||
set furlOut to out\n\
|
||||
else\n\
|
||||
try\n\
|
||||
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
|
||||
set furlOut to POSIX path of urlRef\n\
|
||||
on error\n\
|
||||
end try\n\
|
||||
end if\n\
|
||||
on error\n\
|
||||
try\n\
|
||||
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
|
||||
set furlOut to POSIX path of urlRef\n\
|
||||
on error\n\
|
||||
end try\n\
|
||||
end try\n\
|
||||
set imageOut to \"NONE\"\n\
|
||||
{image_probe}\
|
||||
return \"{furl_marker}\" & linefeed & furlOut & linefeed & \"{image_marker}\" & linefeed & \"IMAGE:\" & imageOut",
|
||||
furl_marker = FURL_MARKER,
|
||||
image_marker = IMAGE_MARKER,
|
||||
)
|
||||
}
|
||||
|
||||
fn raster_half(temps: &ProbeTemps) -> String {
|
||||
let (path_png, path_tiff, path_jpg) = temps.paths();
|
||||
format!(
|
||||
"if furlOut is \"none\" then\n\
|
||||
try\n\
|
||||
set imgData to the clipboard as \u{00AB}class PNGf\u{00BB}\n\
|
||||
set filePath to POSIX file \"{png}\" as text\n\
|
||||
set fRef to open for access file filePath with write permission\n\
|
||||
set eof of fRef to 0\n\
|
||||
write imgData to fRef\n\
|
||||
close access fRef\n\
|
||||
set imageOut to \"PNGf\"\n\
|
||||
on error\n\
|
||||
try\n\
|
||||
set imgData to the clipboard as \u{00AB}class TIFF\u{00BB}\n\
|
||||
set filePath to POSIX file \"{tiff}\" as text\n\
|
||||
set fRef to open for access file filePath with write permission\n\
|
||||
set eof of fRef to 0\n\
|
||||
write imgData to fRef\n\
|
||||
close access fRef\n\
|
||||
set imageOut to \"TIFF\"\n\
|
||||
on error\n\
|
||||
try\n\
|
||||
set imgData to the clipboard as \u{00AB}class JPEG\u{00BB}\n\
|
||||
set filePath to POSIX file \"{jpg}\" as text\n\
|
||||
set fRef to open for access file filePath with write permission\n\
|
||||
set eof of fRef to 0\n\
|
||||
write imgData to fRef\n\
|
||||
close access fRef\n\
|
||||
set imageOut to \"JPEG\"\n\
|
||||
on error\n\
|
||||
end try\n\
|
||||
end try\n\
|
||||
end try\n\
|
||||
end if\n",
|
||||
png = path_png.display(),
|
||||
tiff = path_tiff.display(),
|
||||
jpg = path_jpg.display(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The class on stdout picks the file and MIME; an empty file means no raster.
|
||||
pub(crate) fn read_probe_raster(
|
||||
class: &str,
|
||||
temps: &ProbeTemps,
|
||||
) -> anyhow::Result<Option<ImageData>> {
|
||||
let (path_png, path_tiff, path_jpg) = temps.paths();
|
||||
let (temp_path, mime) = match class {
|
||||
"PNGf" => (path_png, "image/png"),
|
||||
"TIFF" => (path_tiff, "image/tiff"),
|
||||
"JPEG" => (path_jpg, "image/jpeg"),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let data = std::fs::read(&temp_path)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read clipboard temp file: {e}"))?;
|
||||
if data.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(ImageData {
|
||||
data,
|
||||
mime_type: mime.to_owned(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `run` executes a script and returns its stdout; a file paste needs no scratch dir.
|
||||
pub(crate) fn osascript_attachments(
|
||||
temps: anyhow::Result<ProbeTemps>,
|
||||
run: impl FnOnce(&str) -> anyhow::Result<Vec<u8>>,
|
||||
) -> anyhow::Result<ClipboardAttachments> {
|
||||
if let Err(error) = &temps {
|
||||
tracing::warn!(%error, "clipboard probe reads file URLs only");
|
||||
}
|
||||
let stdout = run(&attachments_osascript(temps.as_ref().ok()))?;
|
||||
let (file_urls, image_class) = parse_attachments_output(&String::from_utf8_lossy(&stdout));
|
||||
if file_urls.is_some() {
|
||||
return Ok(ClipboardAttachments {
|
||||
file_urls,
|
||||
image: None,
|
||||
});
|
||||
}
|
||||
let temps = temps?;
|
||||
let image = match image_class {
|
||||
Some(class) => read_probe_raster(class, &temps)?,
|
||||
None => None,
|
||||
};
|
||||
Ok(ClipboardAttachments {
|
||||
file_urls: None,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn attachments_stdout(furl: &str, image: &str) -> Vec<u8> {
|
||||
format!("{FURL_MARKER}\n{furl}\n{IMAGE_MARKER}\nIMAGE:{image}").into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_probes_never_share_a_path() {
|
||||
let first = ProbeTemps::new().unwrap();
|
||||
let second = ProbeTemps::new().unwrap();
|
||||
assert_ne!(first.paths().0, second.paths().0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_dir_goes_away_with_the_probe() {
|
||||
let temps = ProbeTemps::new().unwrap();
|
||||
let (png, tiff, jpg) = temps.paths();
|
||||
let dir = png.parent().unwrap().to_path_buf();
|
||||
assert_eq!(tiff.parent().unwrap(), dir);
|
||||
assert_eq!(jpg.parent().unwrap(), dir);
|
||||
std::fs::write(&png, b"raster").unwrap();
|
||||
|
||||
drop(temps);
|
||||
assert!(!dir.exists(), "{} must be removed", dir.display());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn scratch_dir_is_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temps = ProbeTemps::new().unwrap();
|
||||
let dir = temps.paths().0.parent().unwrap().to_path_buf();
|
||||
let mode = std::fs::metadata(dir).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reported_class_is_read_from_this_probes_file() {
|
||||
let temps = ProbeTemps::new().unwrap();
|
||||
let (_png, tiff, _jpg) = temps.paths();
|
||||
std::fs::write(&tiff, b"MM\x00\x2a").unwrap();
|
||||
let script_names_the_file = tiff.display().to_string();
|
||||
|
||||
let read = osascript_attachments(Ok(temps), |script| {
|
||||
assert!(script.contains(&script_names_the_file), "{script}");
|
||||
Ok(attachments_stdout("none", "TIFF"))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let image = read.image.expect("raster attached");
|
||||
assert_eq!(image.mime_type, "image/tiff");
|
||||
assert_eq!(image.data, b"MM\x00\x2a".to_vec());
|
||||
assert_eq!(read.file_urls, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_urls_survive_a_missing_scratch_dir() {
|
||||
let read = osascript_attachments(Err(anyhow::anyhow!("no scratch dir")), |script| {
|
||||
assert!(
|
||||
!script.contains("open for access"),
|
||||
"raster half must be skipped"
|
||||
);
|
||||
Ok(attachments_stdout("/tmp/a.txt\n", "NONE"))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(read.file_urls.as_deref(), Some("/tmp/a.txt"));
|
||||
assert_eq!(read.image, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_scratch_dir_and_no_file_url_is_an_error_not_an_empty_board() {
|
||||
let err = osascript_attachments(Err(anyhow::anyhow!("no scratch dir")), |_| {
|
||||
Ok(attachments_stdout("none", "NONE"))
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("no scratch dir"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,15 @@ 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>,
|
||||
/// A custom provider's login-curated model allowlist; absent = whole listing.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub models: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Resolve the API key for an API-key registry platform with injected env
|
||||
@@ -1582,6 +1604,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 +2592,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;
|
||||
@@ -2601,6 +2631,11 @@ pub(crate) fn resolve_model_list(
|
||||
}
|
||||
resolved = prefetched;
|
||||
}
|
||||
crate::agent::custom_providers::synthesize_selected(
|
||||
&mut resolved,
|
||||
&cfg.platforms,
|
||||
platform_keys.custom(),
|
||||
);
|
||||
let mut explicit_api_backend_keys = std::collections::HashSet::new();
|
||||
for (key, model_override) in &cfg.config_models {
|
||||
let had_base = resolved.contains_key(key);
|
||||
@@ -2629,8 +2664,18 @@ pub(crate) fn resolve_model_list(
|
||||
);
|
||||
resolved.insert(key.clone(), entry);
|
||||
}
|
||||
crate::agent::custom_providers::filter_unselected(
|
||||
&mut resolved,
|
||||
&cfg.platforms,
|
||||
platform_keys.custom(),
|
||||
);
|
||||
{
|
||||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||||
let custom_prefixes: Vec<String> = platform_keys
|
||||
.custom()
|
||||
.iter()
|
||||
.map(|c| format!("{}/", c.provider.name))
|
||||
.collect();
|
||||
let donors: std::collections::HashMap<String, (std::num::NonZeroU64, ApiBackend)> =
|
||||
resolved
|
||||
.values()
|
||||
@@ -2653,7 +2698,11 @@ pub(crate) fn resolve_model_list(
|
||||
entry.info.context_window = *donor_cw;
|
||||
}
|
||||
// An explicit `[model.X] api_backend` wins even when it is the default.
|
||||
// A provider-owned entry keeps its declared wire too.
|
||||
if !explicit_api_backend_keys.contains(key.as_str())
|
||||
&& !custom_prefixes
|
||||
.iter()
|
||||
.any(|p| key.as_str().starts_with(p.as_str()))
|
||||
&& entry.info.api_backend == ApiBackend::default()
|
||||
&& *donor_backend != ApiBackend::default()
|
||||
{
|
||||
@@ -2676,6 +2725,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 +3982,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 +5307,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(
|
||||
@@ -6707,7 +6779,10 @@ reasoning_effort = "low"
|
||||
assert_eq!(model.info.base_url, "https://inference.example.com/v1");
|
||||
}
|
||||
#[test]
|
||||
#[serial]
|
||||
fn e2e_default_model_with_session_routes_to_proxy() {
|
||||
// Hermetic: serial tests point KIGI_CODE_BASE_URL at wiremock.
|
||||
let _base = EnvGuard::unset(kigi_env::CODE_BASE_URL_ENV);
|
||||
let (_, models) = resolve_models_from_toml("", None);
|
||||
let model = models
|
||||
.get(BUNDLED_DEFAULT_KEY)
|
||||
@@ -6839,7 +6914,10 @@ reasoning_effort = "low"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[serial]
|
||||
fn e2e_duplicate_model_field_both_entries_survive() {
|
||||
// Hermetic: serial tests point KIGI_CODE_BASE_URL at wiremock.
|
||||
let _base = EnvGuard::unset(kigi_env::CODE_BASE_URL_ENV);
|
||||
let dm = crate::models::default_model();
|
||||
let (_, models) = resolve_models_from_toml(
|
||||
&format!(
|
||||
@@ -9667,6 +9745,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,757 @@
|
||||
//! 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;
|
||||
tracing::info!(provider = %provider.name, api = provider.api.as_str(), url = %provider.models_url(), "fetching custom provider models");
|
||||
let body = listing_body(provider, credentialed.key())?;
|
||||
let wire = parse_listing(provider, &body).map_err(|e| BackendError::RequestFailed {
|
||||
// No error content: serde quotes the offending value, a reflected key risk.
|
||||
status: 200,
|
||||
body: format!(
|
||||
"{} listing parse failed at line {} column {}",
|
||||
provider.api.as_str(),
|
||||
e.line(),
|
||||
e.column()
|
||||
),
|
||||
})?;
|
||||
if wire.is_empty() {
|
||||
tracing::warn!(provider = %provider.name, "custom provider listed no models");
|
||||
}
|
||||
Ok(wire.into_iter().map(|w| entry(provider, w)).collect())
|
||||
}
|
||||
|
||||
/// The listing GET with the dialect's key header; status-gated body.
|
||||
fn listing_body(provider: &CustomProvider, key: &str) -> Result<String, BackendError> {
|
||||
let url = provider.models_url();
|
||||
let client = crate::http::shared_blocking_client();
|
||||
let send = |bearer_fallback: bool| {
|
||||
let request = match (provider.api, bearer_fallback) {
|
||||
(CustomApi::OpenAi, _) => client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {key}")),
|
||||
(CustomApi::Anthropic, false) => client
|
||||
.get(&url)
|
||||
.header("x-api-key", key)
|
||||
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
|
||||
(CustomApi::Anthropic, true) => client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {key}"))
|
||||
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
|
||||
};
|
||||
request.send()
|
||||
};
|
||||
let mut response = send(false)?;
|
||||
// Relays may gate the listing on Bearer even for the Messages wire.
|
||||
if response.status().as_u16() == 401 && provider.api == CustomApi::Anthropic {
|
||||
tracing::info!(provider = %provider.name, "listing 401 with x-api-key; retrying Bearer");
|
||||
response = send(true)?;
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().unwrap_or_default();
|
||||
return Err(BackendError::RequestFailed { status, body });
|
||||
}
|
||||
Ok(response.text()?)
|
||||
}
|
||||
|
||||
fn parse_listing(
|
||||
provider: &CustomProvider,
|
||||
body: &str,
|
||||
) -> Result<Vec<WireModel>, serde_json::Error> {
|
||||
match provider.api {
|
||||
CustomApi::OpenAi => kigi_models::parse_openai_listing(body),
|
||||
CustomApi::Anthropic => kigi_models::parse_anthropic_listing(body),
|
||||
}
|
||||
}
|
||||
|
||||
/// The login-step listing result; `provider` carries the adopted base URL.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LoginFetch {
|
||||
pub provider: CustomProvider,
|
||||
pub model_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Login listing fetch; probes `{base}/v1` once on a 404 or unparsable body and adopts it.
|
||||
pub fn fetch_listing_for_login(provider: &CustomProvider, key: &str) -> Result<LoginFetch, String> {
|
||||
match list_wire(provider, key) {
|
||||
Ok(model_ids) => Ok(LoginFetch {
|
||||
provider: provider.clone(),
|
||||
model_ids,
|
||||
}),
|
||||
Err(first) => {
|
||||
if !first.retryable() || has_version_tail(&provider.base_url) {
|
||||
return Err(first.into_message());
|
||||
}
|
||||
let probed = CustomProvider::new(
|
||||
&provider.name,
|
||||
provider.api,
|
||||
&format!("{}/v1", provider.base_url),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tracing::info!(
|
||||
provider = %provider.name,
|
||||
base_url = %probed.base_url,
|
||||
"custom provider listing retrying with a /v1 base"
|
||||
);
|
||||
match list_wire(&probed, key) {
|
||||
Ok(model_ids) => Ok(LoginFetch {
|
||||
provider: probed,
|
||||
model_ids,
|
||||
}),
|
||||
Err(second) => Err(format!(
|
||||
"{}; {} also failed: {}",
|
||||
first.into_message(),
|
||||
probed.models_url(),
|
||||
second.into_message()
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A listing failure; `retryable` marks wrong-base-URL shapes only.
|
||||
enum ListFail {
|
||||
Retryable(String),
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
impl ListFail {
|
||||
fn retryable(&self) -> bool {
|
||||
matches!(self, Self::Retryable(_))
|
||||
}
|
||||
|
||||
fn into_message(self) -> String {
|
||||
match self {
|
||||
Self::Retryable(m) | Self::Fatal(m) => m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list_wire(provider: &CustomProvider, key: &str) -> Result<Vec<String>, ListFail> {
|
||||
let url = provider.models_url();
|
||||
let body = listing_body(provider, key).map_err(|e| match &e {
|
||||
BackendError::RequestFailed { status, .. } => {
|
||||
// No body: a reflected error could echo the key.
|
||||
let msg = format!("{url} answered HTTP {status}");
|
||||
if *status == 404 {
|
||||
ListFail::Retryable(msg)
|
||||
} else {
|
||||
ListFail::Fatal(msg)
|
||||
}
|
||||
}
|
||||
BackendError::Network(_) => ListFail::Fatal(format!("Couldn't reach {url}: {e}")),
|
||||
BackendError::Auth(_) => ListFail::Fatal(e.to_string()),
|
||||
})?;
|
||||
let wire = parse_listing(provider, &body).map_err(|e| {
|
||||
ListFail::Retryable(format!(
|
||||
"{url} did not serve a {} model listing (invalid JSON at line {} column {})",
|
||||
provider.api.as_str(),
|
||||
e.line(),
|
||||
e.column()
|
||||
))
|
||||
})?;
|
||||
Ok(wire.into_iter().map(|w| w.id).collect())
|
||||
}
|
||||
|
||||
/// True when the base URL's last path segment is a version like `v1`.
|
||||
fn has_version_tail(base_url: &str) -> bool {
|
||||
let Some(tail) = base_url.rsplit('/').next() else {
|
||||
return false;
|
||||
};
|
||||
let digits = tail.strip_prefix('v').unwrap_or("");
|
||||
!digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// A selected id the listing never served becomes an entry with wire defaults.
|
||||
pub(crate) fn manual_entry(provider: &CustomProvider, id: &str) -> ModelEntryConfig {
|
||||
entry(provider, WireModel::bare(id.to_owned()))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
async fn login_fetch_on_thread(
|
||||
provider: CustomProvider,
|
||||
key: &str,
|
||||
) -> Result<LoginFetch, String> {
|
||||
let key = key.to_owned();
|
||||
tokio::task::spawn_blocking(move || fetch_listing_for_login(&provider, &key))
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_tail_detection() {
|
||||
assert!(has_version_tail("https://h.example/v1"));
|
||||
assert!(has_version_tail("https://h.example/api/v2"));
|
||||
assert!(!has_version_tail("https://h.example"));
|
||||
assert!(!has_version_tail("https://h.example/coding"));
|
||||
assert!(!has_version_tail("https://h.example/v"));
|
||||
assert!(!has_version_tail("https://h.example/v1x"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn login_fetch_returns_the_served_ids() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/v1/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": [{"id": "m1"}, {"id": "m2"}]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &base).unwrap();
|
||||
|
||||
let fetch = login_fetch_on_thread(provider, "sk-k").await.unwrap();
|
||||
|
||||
assert_eq!(fetch.provider.base_url, base);
|
||||
assert_eq!(fetch.model_ids, ["m1", "m2"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn login_fetch_probes_v1_on_a_404_and_adopts_the_base() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/coding/models"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(path("/coding/v1/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": [{"id": "m1"}]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/coding", server.uri());
|
||||
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &base).unwrap();
|
||||
|
||||
let fetch = login_fetch_on_thread(provider, "sk-k").await.unwrap();
|
||||
|
||||
assert_eq!(fetch.provider.base_url, format!("{base}/v1"));
|
||||
assert_eq!(fetch.model_ids, ["m1"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn login_fetch_probes_v1_when_the_200_body_is_not_a_listing() {
|
||||
// A WAF answers every path with an HTML challenge page at HTTP 200.
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("<html>challenge</html>"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(path("/v1/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": [{"id": "m1"}]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &server.uri()).unwrap();
|
||||
|
||||
let fetch = login_fetch_on_thread(provider, "sk-k").await.unwrap();
|
||||
|
||||
assert_eq!(fetch.provider.base_url, format!("{}/v1", server.uri()));
|
||||
assert_eq!(fetch.model_ids, ["m1"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn login_fetch_does_not_probe_auth_failures_or_versioned_bases() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/models"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &server.uri()).unwrap();
|
||||
let err = login_fetch_on_thread(provider, "bad").await.unwrap_err();
|
||||
assert!(err.contains("401"), "{err}");
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/v2/models"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v2", server.uri());
|
||||
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &base).unwrap();
|
||||
let err = login_fetch_on_thread(provider, "sk-k").await.unwrap_err();
|
||||
assert!(err.contains("404") && !err.contains("also failed"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn login_fetch_reports_both_hops_when_the_probe_fails_too() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &server.uri()).unwrap();
|
||||
|
||||
let err = login_fetch_on_thread(provider, "sk-k").await.unwrap_err();
|
||||
|
||||
assert!(err.contains("also failed"), "{err}");
|
||||
assert!(!err.contains("sk-k"), "{err}");
|
||||
}
|
||||
|
||||
#[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 anthropic_listing_falls_back_to_bearer_on_a_401() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("x-api-key", "sk-gw"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("authorization", "Bearer sk-gw"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": [{"id": "c1"}], "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);
|
||||
assert_eq!(models[0].id.as_deref(), Some("gw/c1"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn openai_listing_never_retries_on_a_401() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.expect(1)
|
||||
.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, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[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,207 @@
|
||||
//! 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 send = |bearer_fallback: bool| {
|
||||
let request = match (provider.api, bearer_fallback) {
|
||||
(CustomApi::OpenAi, _) => client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", credentialed.key())),
|
||||
(CustomApi::Anthropic, false) => client
|
||||
.get(&url)
|
||||
.header("x-api-key", credentialed.key())
|
||||
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
|
||||
(CustomApi::Anthropic, true) => client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", credentialed.key()))
|
||||
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
|
||||
};
|
||||
request.send()
|
||||
};
|
||||
let mut response = send(false)
|
||||
.await
|
||||
.map_err(|e| format!("Couldn't reach {url}: {e}"))?;
|
||||
// Mirrors listing_body: relays may gate the listing on Bearer.
|
||||
if response.status().as_u16() == 401 && provider.api == CustomApi::Anthropic {
|
||||
tracing::info!(provider = %provider.name, "validation 401 with x-api-key; retrying Bearer");
|
||||
response = send(true)
|
||||
.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_falls_back_to_bearer_for_anthropic_relays() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("x-api-key", "sk-gw"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("authorization", "Bearer sk-gw"))
|
||||
.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-gw"))
|
||||
.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,232 @@
|
||||
//! `[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;
|
||||
mod selection;
|
||||
|
||||
pub use fetch::{LoginFetch, fetch_listing_for_login};
|
||||
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;
|
||||
pub(crate) use selection::{filter_unselected, synthesize_selected};
|
||||
|
||||
/// 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,200 @@
|
||||
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,
|
||||
models: &[String],
|
||||
) -> Result<()> {
|
||||
let provider = provider.clone();
|
||||
let api_key = api_key.to_owned();
|
||||
let models = models.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,
|
||||
&models,
|
||||
)
|
||||
})
|
||||
.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,
|
||||
models: &[String],
|
||||
) -> 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, models)?;
|
||||
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,
|
||||
models = models.len(),
|
||||
"custom provider saved"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_definition(
|
||||
root: &mut TomlValue,
|
||||
provider: &CustomProvider,
|
||||
models: &[String],
|
||||
) -> 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());
|
||||
entry.insert(
|
||||
"models".into(),
|
||||
TomlValue::Array(
|
||||
models
|
||||
.iter()
|
||||
.map(|m| TomlValue::String(m.clone()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
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 ", &["m1".to_owned()]).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"));
|
||||
assert_eq!(
|
||||
entry["models"].as_array().unwrap(),
|
||||
&[TomlValue::String("m1".into())]
|
||||
);
|
||||
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",
|
||||
&["a1".to_owned()],
|
||||
)
|
||||
.unwrap();
|
||||
save_custom_provider_in(
|
||||
&config,
|
||||
dir.path(),
|
||||
&provider("gw", CustomApi::Anthropic, "https://b.example/v1"),
|
||||
"k2",
|
||||
&["b1".to_owned()],
|
||||
)
|
||||
.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")
|
||||
);
|
||||
assert_eq!(
|
||||
saved["platforms"]["gw"]["models"].as_array().unwrap(),
|
||||
&[TomlValue::String("b1".into())]
|
||||
);
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! The `[platforms.<name>] models` allowlist: keep the selected, synthesize the manual.
|
||||
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use super::CredentialedProvider;
|
||||
use crate::agent::config::{ModelEntry, PlatformsConfig};
|
||||
|
||||
/// Synthesizes selected ids the listing never served; runs before `[model.*]` overrides.
|
||||
pub(crate) fn synthesize_selected(
|
||||
resolved: &mut IndexMap<String, ModelEntry>,
|
||||
platforms: &PlatformsConfig,
|
||||
keyed: &[CredentialedProvider],
|
||||
) {
|
||||
for credentialed in keyed {
|
||||
let provider = &credentialed.provider;
|
||||
let Some(list) = platforms
|
||||
.entries
|
||||
.get(&provider.name)
|
||||
.and_then(|e| e.models.as_ref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for id in list.iter().map(|m| m.trim()) {
|
||||
if let Err(e) = kigi_models::custom::validate_model_id(id) {
|
||||
tracing::warn!(provider = %provider.name, %e, "skipping an invalid selected model id");
|
||||
continue;
|
||||
}
|
||||
let key = provider.managed_model_key(id);
|
||||
if resolved.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
let entry = super::fetch::manual_entry(provider, id);
|
||||
resolved.insert(key, ModelEntry::from_config_entry(&entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops `{name}/*` entries outside the provider's selection; runs after `[model.*]` overrides.
|
||||
pub(crate) fn filter_unselected(
|
||||
resolved: &mut IndexMap<String, ModelEntry>,
|
||||
platforms: &PlatformsConfig,
|
||||
keyed: &[CredentialedProvider],
|
||||
) {
|
||||
for credentialed in keyed {
|
||||
let provider = &credentialed.provider;
|
||||
let Some(list) = platforms
|
||||
.entries
|
||||
.get(&provider.name)
|
||||
.and_then(|e| e.models.as_ref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let list: Vec<&str> = list.iter().map(|m| m.trim()).collect();
|
||||
let prefix = format!("{}/", provider.name);
|
||||
resolved.retain(|key, entry| {
|
||||
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
|
||||
id.strip_prefix(&prefix)
|
||||
.is_none_or(|bare| list.contains(&bare))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kigi_models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
use crate::agent::models::PlatformApiKeys;
|
||||
|
||||
fn provider(name: &str, base: &str) -> CustomProvider {
|
||||
CustomProvider::new(name, CustomApi::OpenAi, base).unwrap()
|
||||
}
|
||||
|
||||
fn fetched_entry(provider: &CustomProvider, id: &str) -> (String, ModelEntry) {
|
||||
let cfg = super::super::fetch::manual_entry(provider, id);
|
||||
let key = cfg.id.clone().unwrap();
|
||||
(key, ModelEntry::from_config_entry(&cfg))
|
||||
}
|
||||
|
||||
fn keyed(provider: CustomProvider) -> Vec<CredentialedProvider> {
|
||||
vec![CredentialedProvider::for_test(provider, "sk-x")]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_filters_the_listing_and_synthesizes_the_manual() {
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let mut resolved = IndexMap::new();
|
||||
for id in ["m1", "m2"] {
|
||||
let (key, entry) = fetched_entry(&proxy, id);
|
||||
resolved.insert(key, entry);
|
||||
}
|
||||
let platforms: PlatformsConfig = toml::from_str(
|
||||
"[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"m2\", \"manual-1\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
|
||||
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
|
||||
|
||||
let ids: Vec<_> = resolved.keys().map(String::as_str).collect();
|
||||
assert_eq!(ids, ["proxy/m2", "proxy/manual-1"]);
|
||||
assert_eq!(resolved["proxy/manual-1"].info.model, "manual-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_provider_without_the_models_key_keeps_its_whole_listing() {
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let mut resolved = IndexMap::new();
|
||||
let (key, entry) = fetched_entry(&proxy, "m1");
|
||||
resolved.insert(key, entry);
|
||||
let platforms: PlatformsConfig =
|
||||
toml::from_str("[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\n")
|
||||
.unwrap();
|
||||
|
||||
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
|
||||
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
|
||||
|
||||
assert!(resolved.contains_key("proxy/m1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_selection_drops_every_fetched_entry() {
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let mut resolved = IndexMap::new();
|
||||
let (key, entry) = fetched_entry(&proxy, "m1");
|
||||
resolved.insert(key, entry);
|
||||
let platforms: PlatformsConfig = toml::from_str(
|
||||
"[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = []\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
|
||||
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
|
||||
|
||||
assert!(resolved.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_selected_ids_are_skipped_with_a_warning() {
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let mut resolved = IndexMap::new();
|
||||
let platforms: PlatformsConfig = toml::from_str(
|
||||
"[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"ok\", \"has space\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
|
||||
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
|
||||
|
||||
assert!(resolved.contains_key("proxy/ok"));
|
||||
assert_eq!(resolved.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_applies_through_resolve_model_list_and_gets_the_key() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"manual-1\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let proxy = provider("proxy", "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, None, &keys);
|
||||
|
||||
let entry = resolved
|
||||
.get("proxy/manual-1")
|
||||
.expect("a manual model survives without any fetched catalog");
|
||||
assert_eq!(entry.api_key.as_deref(), Some("sk-proxy"));
|
||||
assert!(entry.has_own_credentials());
|
||||
assert_eq!(entry.info.context_window.get(), 256_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_model_override_composes_with_the_synthesized_entry() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"manual-1\"]\n\n[model.\"proxy/manual-1\"]\ncontext_window = 1000000\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let proxy = provider("proxy", "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, None, &keys);
|
||||
|
||||
let entry = &resolved["proxy/manual-1"];
|
||||
assert_eq!(entry.info.context_window.get(), 1_000_000);
|
||||
assert_eq!(entry.info.base_url, "https://p.example/v1");
|
||||
assert_eq!(entry.api_key.as_deref(), Some("sk-proxy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_is_authoritative_in_the_provider_namespace() {
|
||||
// A context-only override cannot resurrect a deselected model.
|
||||
let raw: toml::Value = toml::from_str(
|
||||
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"m2\"]\n\n[model.\"proxy/m1\"]\ncontext_window = 123456\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let (key, entry) = fetched_entry(&proxy, "m1");
|
||||
let prefetched = IndexMap::from([(key, entry)]);
|
||||
let keys =
|
||||
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
|
||||
|
||||
let resolved = crate::agent::config::resolve_model_list(&cfg, Some(prefetched), &keys);
|
||||
|
||||
assert!(!resolved.contains_key("proxy/m1"));
|
||||
assert!(resolved.contains_key("proxy/m2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_owned_entries_keep_their_declared_wire_against_donors() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"shared\"]\n\n[model.donorx]\nmodel = \"shared\"\napi_backend = \"messages\"\ncontext_window = 500000\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let proxy = provider("proxy", "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, None, &keys);
|
||||
|
||||
let entry = &resolved["proxy/shared"];
|
||||
assert_eq!(
|
||||
entry.info.api_backend,
|
||||
crate::sampling::ApiBackend::ChatCompletions
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -638,6 +638,7 @@ fn write_import_marker(config_path: &Path) -> anyhow::Result<()> {
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let config_path = &crate::util::config::config_write_dest(config_path)?;
|
||||
let tmp = config_path.with_extension("toml.tmp");
|
||||
// Best-effort cleanup of the .tmp file if either write or rename fails so
|
||||
// a failed marker write doesn't leave a stale artefact next to the real
|
||||
@@ -820,6 +821,7 @@ fn apply_items_to_config(config_path: &Path, items: &[ImportableItem]) -> anyhow
|
||||
|
||||
if count > 0 {
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let config_path = &crate::util::config::config_write_dest(config_path)?;
|
||||
let tmp = config_path.with_extension("toml.tmp");
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -513,6 +513,7 @@ pub fn apply_at(plan: &KimiImportPlan, kigi_home: &Path) -> anyhow::Result<KimiA
|
||||
if applied.total_added() > 0 {
|
||||
// Atomic write: tmp + rename (same pattern as save_mcp_server_config).
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let config_path = crate::util::config::config_write_dest(&config_path)?;
|
||||
let tmp = config_path.with_extension("toml.tmp");
|
||||
if let Err(e) = std::fs::write(&tmp, &toml_str) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1020,6 +1020,7 @@ pub(super) async fn build_skill_information_for_refs(
|
||||
slash_skills: &[SkillInfo],
|
||||
session_id: &str,
|
||||
) -> Option<String> {
|
||||
use kigi_tools::implementations::skills::body_cap::cap_skill_body;
|
||||
use kigi_tools::implementations::skills::skill::{
|
||||
SkillRef, SubstitutionContext, apply_substitutions, build_skill_block,
|
||||
build_skill_information, load_skill_content,
|
||||
@@ -1034,6 +1035,13 @@ pub(super) async fn build_skill_information_for_refs(
|
||||
};
|
||||
match load_skill_content(info).await {
|
||||
Ok(mut content) => {
|
||||
if cap_skill_body(&mut content) {
|
||||
tracing::info!(
|
||||
skill = %sk.name,
|
||||
path = %info.path,
|
||||
"skill body truncated at read cap"
|
||||
);
|
||||
}
|
||||
let skill_dir = std::path::Path::new(&info.path)
|
||||
.parent()
|
||||
.and_then(|p| p.to_str());
|
||||
@@ -1465,6 +1473,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A 220 KB skill body stops at the read cap; the tail stays on disk.
|
||||
#[tokio::test]
|
||||
async fn build_skill_information_for_refs_caps_an_oversized_body() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("SKILL.md");
|
||||
let body = (1..=1100)
|
||||
.map(|n| format!("{n:05} {}", "x".repeat(194)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
std::fs::write(&path, &body).unwrap();
|
||||
|
||||
let mut skill = make_skill("big", true);
|
||||
skill.path = path.to_string_lossy().to_string();
|
||||
let skills = vec![skill];
|
||||
|
||||
let parsed =
|
||||
parse_skill_references("/big", &skills, all_gated()).expect("known skill must parse");
|
||||
let info = build_skill_information_for_refs(&parsed, &skills, "sid-1")
|
||||
.await
|
||||
.expect("skill body must load");
|
||||
assert!(info.contains("00001 "), "head must survive");
|
||||
assert!(!info.contains("01100 "), "tail must be cut");
|
||||
assert!(info.contains("Skill body truncated at the 25000-token cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_loop_annotates_block_with_compact_display_text() {
|
||||
let outcome = resolve(
|
||||
|
||||
@@ -359,6 +359,7 @@ pub async fn save_mcp_disabled_tools(server_name: &str, disabled_tools: &[String
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let path = super::config_write_dest(&path)?;
|
||||
let tmp = path.with_extension("toml.tmp");
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
@@ -413,6 +414,7 @@ pub async fn save_mcp_server_enabled(server_name: &str, enabled: bool) -> Result
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let path = super::config_write_dest(&path)?;
|
||||
let tmp = path.with_extension("toml.tmp");
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
@@ -472,6 +474,7 @@ pub async fn save_mcp_server_config_at(
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let path = super::config_write_dest(path)?;
|
||||
let tmp = path.with_extension("toml.tmp");
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
@@ -552,6 +555,7 @@ pub async fn delete_mcp_server_config_at(
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let path = super::config_write_dest(path)?;
|
||||
let tmp = path.with_extension("toml.tmp");
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
|
||||
@@ -10,6 +10,7 @@ mod resolve;
|
||||
mod settings_writes;
|
||||
mod tips;
|
||||
mod worktree;
|
||||
mod write_dest;
|
||||
|
||||
pub use campaigns::{
|
||||
load_effective_config, load_effective_config_disk_only, persist_models_default,
|
||||
@@ -30,3 +31,4 @@ pub use resolve::*;
|
||||
pub use settings_writes::*;
|
||||
pub use tips::*;
|
||||
pub use worktree::*;
|
||||
pub(crate) use write_dest::config_write_dest;
|
||||
@@ -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;
|
||||
@@ -52,6 +52,7 @@ pub async fn save_config(config: &Config) -> Result<()> {
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let path = super::config_write_dest(&path)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Where a config write lands when the path is a symlink.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The user's own config.toml keeps its symlink; every other path comes back unchanged.
|
||||
pub(crate) fn config_write_dest(path: &Path) -> io::Result<PathBuf> {
|
||||
// With no home, `kigi_home()` is `./.kigi`: the project's own tree, never trusted.
|
||||
let user_config = kigi_config::user_kigi_home().map(|home| home.join("config.toml"));
|
||||
write_dest(path, user_config.as_deref())
|
||||
}
|
||||
|
||||
/// A project config is repo-controlled: following its symlink would overwrite any file it names.
|
||||
fn write_dest(path: &Path, user_config: Option<&Path>) -> io::Result<PathBuf> {
|
||||
if user_config != Some(path) {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => dunce::canonicalize(path).map_err(|e| {
|
||||
let context = format!("config symlink {} does not resolve: {e}", path.display());
|
||||
io::Error::new(e.kind(), context)
|
||||
}),
|
||||
Ok(_) => Ok(path.to_path_buf()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(path.to_path_buf()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::config::{McpServerConfig, save_mcp_server_config_at};
|
||||
|
||||
fn symlinked_config(dir: &Path, referent_content: &str) -> (PathBuf, PathBuf) {
|
||||
let referent = dir.join("dotfiles.toml");
|
||||
std::fs::write(&referent, referent_content).unwrap();
|
||||
let link = dir.join("config.toml");
|
||||
std::os::unix::fs::symlink(&referent, &link).unwrap();
|
||||
(link, referent)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_config_symlink_resolves_to_its_referent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (link, referent) = symlinked_config(dir.path(), "");
|
||||
|
||||
let dest = write_dest(&link, Some(&link)).unwrap();
|
||||
assert_eq!(dest, dunce::canonicalize(&referent).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_and_missing_user_config_keep_their_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let regular = dir.path().join("config.toml");
|
||||
std::fs::write(®ular, "").unwrap();
|
||||
let missing = dir.path().join("absent.toml");
|
||||
|
||||
assert_eq!(write_dest(®ular, Some(®ular)).unwrap(), regular);
|
||||
assert_eq!(write_dest(&missing, Some(&missing)).unwrap(), missing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangling_user_config_symlink_is_an_error_not_a_clobber() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let link = dir.path().join("config.toml");
|
||||
std::os::unix::fs::symlink(dir.path().join("gone.toml"), &link).unwrap();
|
||||
|
||||
let err = write_dest(&link, Some(&link)).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
||||
assert!(err.to_string().contains("does not resolve"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_config_symlink_is_never_followed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (link, _referent) = symlinked_config(dir.path(), "");
|
||||
let user_config = dir.path().join("home").join("config.toml");
|
||||
|
||||
assert_eq!(write_dest(&link, Some(&user_config)).unwrap(), link);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_followed_when_no_user_home_resolves() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (link, _referent) = symlinked_config(dir.path(), "");
|
||||
|
||||
assert_eq!(write_dest(&link, None).unwrap(), link);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_scope_save_replaces_the_link_and_spares_its_target() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (link, target) = symlinked_config(dir.path(), "victim = true\n");
|
||||
|
||||
let server: McpServerConfig =
|
||||
toml::from_str("command = \"echo\"\nargs = [\"hi\"]\n").unwrap();
|
||||
save_mcp_server_config_at(&link, "demo", &server)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let meta = std::fs::symlink_metadata(&link).unwrap();
|
||||
assert!(
|
||||
meta.file_type().is_file(),
|
||||
"the link is replaced, not followed"
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap(), "victim = true\n");
|
||||
let saved = std::fs::read_to_string(&link).unwrap();
|
||||
assert!(saved.contains("[mcp_servers.demo]"), "{saved}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! A symlinked `~/.kigi/config.toml` survives a save: the write lands on the referent.
|
||||
#![cfg(unix)]
|
||||
|
||||
use kigi_shell::util::config::{McpServerConfig, save_mcp_server_config};
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_config_symlink_survives_a_save() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let dotfiles = tempfile::tempdir().unwrap();
|
||||
// SAFETY: the only test in this binary; set before `kigi_home()` first runs.
|
||||
unsafe { std::env::set_var("KIGI_SHARE_DIR", home.path()) };
|
||||
|
||||
let referent = dotfiles.path().join("kigi.toml");
|
||||
std::fs::write(&referent, "[ui]\ntheme = \"dark\"\n").unwrap();
|
||||
let link = home.path().join("config.toml");
|
||||
std::os::unix::fs::symlink(&referent, &link).unwrap();
|
||||
|
||||
let server: McpServerConfig = toml::from_str("command = \"echo\"\nargs = [\"hi\"]\n").unwrap();
|
||||
save_mcp_server_config("demo", &server).await.unwrap();
|
||||
|
||||
let meta = std::fs::symlink_metadata(&link).unwrap();
|
||||
assert!(meta.file_type().is_symlink(), "the link must survive");
|
||||
let saved = std::fs::read_to_string(&referent).unwrap();
|
||||
assert!(saved.contains("[mcp_servers.demo]"), "{saved}");
|
||||
assert!(saved.contains("theme = \"dark\""), "{saved}");
|
||||
let leftovers: Vec<_> = std::fs::read_dir(home.path())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.filter(|name| name != "config.toml")
|
||||
.collect();
|
||||
assert!(
|
||||
leftovers.is_empty(),
|
||||
"no tmp beside the link: {leftovers:?}"
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,12 @@ impl ReadFileVersion {
|
||||
}
|
||||
}
|
||||
pub(crate) const MAX_NUM_TOKENS: usize = 25_000;
|
||||
pub(crate) const MAX_NUM_BYTES: usize =
|
||||
MAX_NUM_TOKENS * kigi_token_estimation::BYTES_PER_TOKEN as usize;
|
||||
/// True when `text` is over the per-call token cap.
|
||||
pub(crate) fn exceeds_read_cap(text: &str) -> bool {
|
||||
crate::util::truncate::estimate_tokens(text) > MAX_NUM_TOKENS
|
||||
}
|
||||
pub const MAX_LINES_READ: usize = 1_000;
|
||||
pub use crate::implementations::read_file::{
|
||||
FileMetadata, PDF_MAX_PAGES_PER_READ, bytes_to_metadata, parse_page_range,
|
||||
@@ -434,22 +440,25 @@ pub(crate) async fn run_read_file(
|
||||
.map(|t| t.0.max_lines_read())
|
||||
.unwrap_or_else(|| TruncationConfig::default().max_lines_read())
|
||||
};
|
||||
let (effective_offset, effective_limit) = if is_skill_file {
|
||||
(None, None)
|
||||
} else {
|
||||
(
|
||||
input.offset,
|
||||
Some(input.limit.unwrap_or(usize::MAX).min(max_lines)),
|
||||
)
|
||||
// A skill file under the cap comes back whole; over it, it reads like any other file.
|
||||
let skill_full = is_skill_file
|
||||
.then(|| extract_file_content_lines(&file_content, None, None, total_lines))
|
||||
.filter(|full| !exceeds_read_cap(&full.content));
|
||||
let (extracted, stored_offset, stored_limit) = match skill_full {
|
||||
Some(full) => (full, None, None),
|
||||
None => (
|
||||
extract_file_content_lines(
|
||||
&file_content,
|
||||
input.offset,
|
||||
Some(input.limit.unwrap_or(usize::MAX).min(max_lines)),
|
||||
total_lines,
|
||||
),
|
||||
stored_read_offset(input.offset),
|
||||
input.limit,
|
||||
),
|
||||
};
|
||||
let extracted = extract_file_content_lines(
|
||||
&file_content,
|
||||
effective_offset,
|
||||
effective_limit,
|
||||
total_lines,
|
||||
);
|
||||
let token_count = crate::util::truncate::estimate_tokens(&extracted.content);
|
||||
if !is_skill_file && token_count > MAX_NUM_TOKENS {
|
||||
if exceeds_read_cap(&extracted.content) {
|
||||
let token_count = crate::util::truncate::estimate_tokens(&extracted.content);
|
||||
let (grep_name, execute_name);
|
||||
{
|
||||
let res = resources.lock().await;
|
||||
@@ -497,11 +506,6 @@ pub(crate) async fn run_read_file(
|
||||
};
|
||||
return Ok(ReadFileOutput::FileTooLarge(msg));
|
||||
}
|
||||
let (stored_offset, stored_limit) = if is_skill_file {
|
||||
(None, None)
|
||||
} else {
|
||||
(stored_read_offset(input.offset), input.limit)
|
||||
};
|
||||
if let Some(flag) = streamable_out {
|
||||
*flag = true;
|
||||
}
|
||||
@@ -1660,9 +1664,8 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
|
||||
other => panic!("Expected FileContent, got {:?}", other),
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn skill_file_skips_token_limit() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
/// 1100 lines of 200 bytes: about 55k tokens, over the 25k cap.
|
||||
fn write_oversized_skill(tmp: &TempDir) {
|
||||
let line = "x".repeat(200);
|
||||
let big_content = std::iter::repeat_n(line.as_str(), 1100)
|
||||
.collect::<Vec<_>>()
|
||||
@@ -1670,6 +1673,11 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
|
||||
let skill_dir = tmp.path().join("skills");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(skill_dir.join("SKILL.md"), &big_content).unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn skill_file_over_cap_returns_file_too_large() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write_oversized_skill(&tmp);
|
||||
let tool = ReadFileTool;
|
||||
let mut resources = test_resources(tmp.path());
|
||||
resources.insert(TemplateRenderer::new(
|
||||
@@ -1687,11 +1695,36 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result, ReadFileOutput::FileContent(_)),
|
||||
"SKILL.md should not be truncated, got {:?}",
|
||||
matches!(result, ReadFileOutput::FileTooLarge(_)),
|
||||
"got {:?}",
|
||||
std::mem::discriminant(&result),
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn skill_file_over_cap_honors_offset_and_limit() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write_oversized_skill(&tmp);
|
||||
let tool = ReadFileTool;
|
||||
let resources = test_resources(tmp.path());
|
||||
let input = ReadFileInput {
|
||||
path: "skills/SKILL.md".to_string(),
|
||||
offset: Some(3),
|
||||
limit: Some(2),
|
||||
pages: None,
|
||||
format: None,
|
||||
};
|
||||
let result = kigi_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
|
||||
.await
|
||||
.unwrap();
|
||||
match result {
|
||||
ReadFileOutput::FileContent(fc) => {
|
||||
assert_eq!((Some(3), Some(2)), (fc.offset, fc.limit));
|
||||
let line = "x".repeat(200);
|
||||
assert_eq!(format!("3→{line}\n{line}"), fc.content);
|
||||
}
|
||||
other => panic!("Expected FileContent, got {:?}", other),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parse_single_page() {
|
||||
assert_eq!(parse_page_range("3", 10).unwrap(), vec![2]);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::implementations::skills::body_cap::cap_skill_body;
|
||||
use crate::implementations::skills::skill::{SkillOutput, extract_skill_body, format_skill_name};
|
||||
use crate::implementations::skills::types::SkillInfo;
|
||||
use crate::types::requirements::{Expr, ToolRequirement};
|
||||
@@ -279,7 +280,7 @@ impl kigi_tool_runtime::Tool for SkillTool {
|
||||
}
|
||||
};
|
||||
|
||||
let content = match load_skill_content(&skill).await {
|
||||
let mut content = match load_skill_content(&skill).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Ok(SkillOutput {
|
||||
@@ -292,6 +293,14 @@ impl kigi_tool_runtime::Tool for SkillTool {
|
||||
}
|
||||
};
|
||||
|
||||
if cap_skill_body(&mut content) {
|
||||
tracing::info!(
|
||||
skill = %skill.name,
|
||||
path = %skill.path,
|
||||
"skill body truncated at read cap"
|
||||
);
|
||||
}
|
||||
|
||||
let files = list_skill_files(&skill, 10).await;
|
||||
|
||||
let output = format_output(&skill, &content, &files);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Cap an injected skill body at the file-read token cap.
|
||||
|
||||
use crate::implementations::kigi::read_file::{MAX_NUM_BYTES, MAX_NUM_TOKENS, exceeds_read_cap};
|
||||
use crate::util::truncate::floor_char_boundary;
|
||||
|
||||
/// Cuts `body` on a line boundary under the read cap and appends a note; true when it cut.
|
||||
pub fn cap_skill_body(body: &mut String) -> bool {
|
||||
if !exceeds_read_cap(body) {
|
||||
return false;
|
||||
}
|
||||
let note = format!(
|
||||
"[Skill body truncated at the {MAX_NUM_TOKENS}-token cap. Read the rest of the \
|
||||
skill file with the file read tool using a line offset and limit.]"
|
||||
);
|
||||
let budget = MAX_NUM_BYTES.saturating_sub(note.len() + 2);
|
||||
let end = floor_char_boundary(body, budget);
|
||||
let cut = body[..end].rfind('\n').filter(|&i| i > 0).unwrap_or(end);
|
||||
body.truncate(cut);
|
||||
body.push_str("\n\n");
|
||||
body.push_str(¬e);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn leaves_body_under_cap_untouched() {
|
||||
let mut body = "# Small skill\n\nDo the thing.".to_owned();
|
||||
assert!(!cap_skill_body(&mut body));
|
||||
assert_eq!("# Small skill\n\nDo the thing.", body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuts_on_line_boundary_under_cap() {
|
||||
let mut body = (1..=1100)
|
||||
.map(|n| format!("{n:05} {}", "x".repeat(194)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
assert!(cap_skill_body(&mut body));
|
||||
assert!(!exceeds_read_cap(&body));
|
||||
assert!(!body.contains("01100 "));
|
||||
assert!(body.contains("offset"));
|
||||
let (head, _note) = body.rsplit_once("\n\n").expect("note separator");
|
||||
let last_line = head.rsplit('\n').next().expect("head has a line");
|
||||
assert_eq!(200, last_line.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_long_line_falls_back_to_char_cut() {
|
||||
let mut body = "é".repeat(60_000);
|
||||
|
||||
assert!(cap_skill_body(&mut body));
|
||||
assert!(!exceeds_read_cap(&body));
|
||||
assert!(body.starts_with("éé"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod body_cap;
|
||||
pub mod discovery;
|
||||
pub mod skill;
|
||||
pub mod types;
|
||||
@@ -8,8 +8,8 @@ const MAX_LINES_READ_DEFAULT: usize = 1_000;
|
||||
/// There is deliberately no per-line cap: clipping long lines silently
|
||||
/// corrupts single-line files (minified JSON, data dumps) with no way for
|
||||
/// the model to recover the clipped bytes. Non-skill reads are bounded by
|
||||
/// the whole-read `MAX_NUM_TOKENS` cap instead (skill files are exempt from
|
||||
/// all read limits by design). Other agent CLIs likewise apply no
|
||||
/// the whole-read `MAX_NUM_TOKENS` cap instead (a skill file under that cap
|
||||
/// comes back whole, offset and limit ignored). Other agent CLIs likewise apply no
|
||||
/// per-line cap. The wire field (`TruncationConfig.max_chars_per_line` in
|
||||
/// kigi-tools.proto) is deprecated and ignored.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
|
||||
@@ -570,6 +570,12 @@ 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),
|
||||
/// Enter on an empty manual-model box: finish the model selection.
|
||||
SubmitCustomProviderSelection,
|
||||
/// Copy the auth URL to the clipboard during authentication.
|
||||
CopyAuthUrl,
|
||||
/// Show the raw auth URL with mouse capture disabled for manual copy.
|
||||
@@ -1258,7 +1264,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 +1620,19 @@ 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,
|
||||
models: Vec<String>,
|
||||
},
|
||||
/// List the provider's models for the login selection screen.
|
||||
FetchCustomProviderModels {
|
||||
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,
|
||||
@@ -2156,6 +2175,11 @@ pub enum TaskResult {
|
||||
AuthCodeSubmitted {
|
||||
request_seq: u64,
|
||||
},
|
||||
/// The login-step model listing came back (or failed).
|
||||
CustomProviderModelsListed {
|
||||
request_seq: u64,
|
||||
result: Result<kigi_shell::agent::custom_providers::LoginFetch, String>,
|
||||
},
|
||||
/// MCP server list fetched from shell.
|
||||
McpsListLoaded {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -262,6 +262,10 @@ 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),
|
||||
/// Custom provider login, the model-selection screen.
|
||||
CustomProviderSelect,
|
||||
}
|
||||
/// API-key platform login target, selected from the welcome picker. Wraps a
|
||||
/// non-OAuth registry platform ([`kigi_shell::models::PlatformId`]);
|
||||
@@ -310,6 +314,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 +333,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 +377,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 +924,10 @@ 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,
|
||||
/// Model-selection state of a custom provider login; present in that mode.
|
||||
pub custom_select: Option<crate::app::custom_entry::CustomSelectState>,
|
||||
/// 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 +1131,8 @@ impl AppView {
|
||||
login_method_id: None,
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
custom_entry: Default::default(),
|
||||
custom_select: None,
|
||||
next_auth_request_seq: 1,
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
@@ -1646,6 +1668,7 @@ impl AppView {
|
||||
mid_session_login: self.auth_return_view.is_some(),
|
||||
auth_methods: &self.auth_methods,
|
||||
auth_code_input: &mut self.auth_code_input,
|
||||
custom_select: &mut self.custom_select,
|
||||
prompt: &mut self.welcome_prompt,
|
||||
prompt_focused: &mut self.welcome_prompt_focused,
|
||||
new_worktree_dialog: &mut self.new_worktree_dialog,
|
||||
@@ -2171,6 +2194,7 @@ struct WelcomeInputCtx<'a> {
|
||||
/// the `AuthState::Pending` welcome menu.
|
||||
auth_methods: &'a [acp::AuthMethod],
|
||||
auth_code_input: &'a mut String,
|
||||
custom_select: &'a mut Option<crate::app::custom_entry::CustomSelectState>,
|
||||
prompt: &'a mut PromptWidget,
|
||||
prompt_focused: &'a mut bool,
|
||||
new_worktree_dialog: &'a mut Option<NewWorktreeDialogState>,
|
||||
@@ -2648,7 +2672,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,16 +2688,97 @@ 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));
|
||||
}
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if crate::input::key::is_paste_key(key)
|
||||
|| crate::input::key::is_inline_paste_key(key)
|
||||
{
|
||||
if let Some(text) = crate::clipboard::system_clipboard_get() {
|
||||
let cleaned: String =
|
||||
text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||
ctx.auth_code_input.push_str(&cleaned);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
crate::clipboard::log_paste_key_empty_host_clipboard("auth input");
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if key!(Backspace).matches(key) {
|
||||
ctx.auth_code_input.pop();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if let crossterm::event::KeyCode::Char(c) = key.code {
|
||||
if let crossterm::event::KeyCode::Char(c) = key.code
|
||||
&& crate::input::key::is_text_input_key(key)
|
||||
{
|
||||
ctx.auth_code_input.push(c);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} => {
|
||||
// Esc cancels back to the picker, like the entry steps.
|
||||
if key!(Esc).matches(key) {
|
||||
return InputOutcome::Action(Action::CancelPlatformKeyEntry);
|
||||
}
|
||||
if key!('q', CONTROL).matches(key) || key!('c', CONTROL).matches(key) {
|
||||
if ctx.mid_session_login {
|
||||
return InputOutcome::Action(Action::CancelLogin);
|
||||
}
|
||||
return InputOutcome::Action(Action::QuitConfirmed);
|
||||
}
|
||||
let Some(select) = ctx.custom_select.as_mut() else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
if key!(Up).matches(key) {
|
||||
select.move_highlight(-1);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key!(Down).matches(key) {
|
||||
select.move_highlight(1);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// Model ids hold no spaces, so the spacebar toggles rows.
|
||||
if key!(Char(' ')).matches(key) {
|
||||
select.toggle_highlighted();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if crate::input::key::is_paste_key(key)
|
||||
|| crate::input::key::is_inline_paste_key(key)
|
||||
{
|
||||
if let Some(text) = crate::clipboard::system_clipboard_get() {
|
||||
let cleaned: String =
|
||||
text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||
ctx.auth_code_input.push_str(&cleaned);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
crate::clipboard::log_paste_key_empty_host_clipboard("auth input");
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if key!(Enter).matches(key) {
|
||||
let trimmed = ctx.auth_code_input.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return InputOutcome::Action(Action::SubmitCustomProviderSelection);
|
||||
}
|
||||
select.add_manual(&trimmed);
|
||||
if select.error.is_none() {
|
||||
ctx.auth_code_input.clear();
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key!(Backspace).matches(key) {
|
||||
ctx.auth_code_input.pop();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if let crossterm::event::KeyCode::Char(c) = key.code
|
||||
&& crate::input::key::is_text_input_key(key)
|
||||
{
|
||||
ctx.auth_code_input.push(c);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
@@ -2698,11 +2803,25 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
}
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if crate::input::key::is_paste_key(key)
|
||||
|| crate::input::key::is_inline_paste_key(key)
|
||||
{
|
||||
if let Some(text) = crate::clipboard::system_clipboard_get() {
|
||||
let cleaned: String =
|
||||
text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||
ctx.auth_code_input.push_str(&cleaned);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
crate::clipboard::log_paste_key_empty_host_clipboard("auth input");
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if key!(Backspace).matches(key) {
|
||||
ctx.auth_code_input.pop();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if let crossterm::event::KeyCode::Char(c) = key.code {
|
||||
if let crossterm::event::KeyCode::Char(c) = key.code
|
||||
&& crate::input::key::is_text_input_key(key)
|
||||
{
|
||||
ctx.auth_code_input.push(c);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
@@ -2726,7 +2845,11 @@ 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(_)
|
||||
| AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} => {
|
||||
let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||
@@ -2889,6 +3012,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 +3349,8 @@ 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,
|
||||
custom_select: self.custom_select.as_ref(),
|
||||
clipboard_copied: self.auth_clipboard_copied,
|
||||
show_raw_url: self.auth_show_raw_url,
|
||||
tip,
|
||||
@@ -4358,6 +4486,8 @@ pub(crate) mod tests {
|
||||
login_method_id: None,
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
custom_entry: Default::default(),
|
||||
custom_select: None,
|
||||
next_auth_request_seq: 1,
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
@@ -7005,7 +7135,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 +7322,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 +7354,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
|
||||
@@ -7554,6 +7705,71 @@ pub(crate) mod tests {
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(app.auth_code_input, "token_value");
|
||||
}
|
||||
/// Ctrl+V / Super+V paste the host clipboard into every auth input.
|
||||
#[test]
|
||||
fn auth_inputs_paste_key_chords_insert_clipboard_text() {
|
||||
let select_state = || {
|
||||
crate::app::custom_entry::CustomSelectState::new(
|
||||
kigi_shell::models::custom::CustomProvider::new(
|
||||
"gw",
|
||||
kigi_shell::models::custom::CustomApi::OpenAi,
|
||||
"https://h.example/v1",
|
||||
)
|
||||
.unwrap(),
|
||||
"sk".to_owned(),
|
||||
)
|
||||
};
|
||||
let modes = [
|
||||
AuthMode::Loopback,
|
||||
AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
|
||||
AuthMode::CustomProviderEntry(crate::app::custom_entry::CustomEntryStep::first(
|
||||
kigi_shell::models::custom::CustomApi::OpenAi,
|
||||
)),
|
||||
AuthMode::CustomProviderSelect,
|
||||
];
|
||||
for mode in modes {
|
||||
for mods in [KeyModifiers::CONTROL, KeyModifiers::SUPER] {
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq: 1,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode,
|
||||
};
|
||||
if matches!(mode, AuthMode::CustomProviderSelect) {
|
||||
app.custom_select = Some(select_state());
|
||||
}
|
||||
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
|
||||
text: Some("sk-pasted\nkey".to_owned()),
|
||||
..Default::default()
|
||||
});
|
||||
let outcome = app.handle_input(&key_event(KeyCode::Char('v'), mods));
|
||||
crate::clipboard::clear_clipboard_probe_hook();
|
||||
assert!(
|
||||
matches!(outcome, InputOutcome::Changed),
|
||||
"{mode:?} + {mods:?} must paste, got {outcome:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.auth_code_input, "sk-pastedkey",
|
||||
"newlines stripped and no literal 'v' for {mode:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Ctrl+letter chords are not text input (the old Ctrl+V inserted 'v').
|
||||
#[test]
|
||||
fn auth_inputs_ignore_control_letter_chords() {
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq: 1,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Loopback,
|
||||
};
|
||||
let outcome = app.handle_input(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL));
|
||||
assert!(matches!(outcome, InputOutcome::Unchanged));
|
||||
assert!(app.auth_code_input.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn authenticating_loopback_enter_empty_is_noop() {
|
||||
let mut app = test_app();
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
//! Custom provider login steps: base URL, name, key.
|
||||
|
||||
use kigi_shell::models::custom::{
|
||||
CustomApi, CustomProvider, default_name, normalize_base_url, validate_model_id, 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch status of the model-selection screen.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SelectFetch {
|
||||
InFlight,
|
||||
Listed,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// One selectable model row; `manual` marks user-typed ids.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SelectRow {
|
||||
pub id: String,
|
||||
pub selected: bool,
|
||||
pub manual: bool,
|
||||
}
|
||||
|
||||
/// Model selection after the key step: fetched rows plus manual adds.
|
||||
#[derive(Clone)]
|
||||
pub struct CustomSelectState {
|
||||
/// The fetch may adopt a `/v1` base URL.
|
||||
pub provider: CustomProvider,
|
||||
key: String,
|
||||
pub rows: Vec<SelectRow>,
|
||||
pub highlight: usize,
|
||||
pub fetch: SelectFetch,
|
||||
pub error: Option<String>,
|
||||
/// Enter hit while the listing is in flight; it completes on arrival.
|
||||
pub finish_requested: bool,
|
||||
}
|
||||
|
||||
impl CustomSelectState {
|
||||
pub fn new(provider: CustomProvider, key: String) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
key,
|
||||
rows: Vec::new(),
|
||||
highlight: 0,
|
||||
fetch: SelectFetch::InFlight,
|
||||
error: None,
|
||||
finish_requested: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_key(self) -> String {
|
||||
self.key
|
||||
}
|
||||
|
||||
pub fn apply_fetch(&mut self, fetch: kigi_shell::agent::custom_providers::LoginFetch) {
|
||||
self.provider = fetch.provider;
|
||||
for id in fetch.model_ids {
|
||||
if self.rows.iter().any(|r| r.id == id) {
|
||||
continue;
|
||||
}
|
||||
self.rows.push(SelectRow {
|
||||
id,
|
||||
selected: false,
|
||||
manual: false,
|
||||
});
|
||||
}
|
||||
self.fetch = SelectFetch::Listed;
|
||||
}
|
||||
|
||||
pub fn fail_fetch(&mut self, reason: String) {
|
||||
self.fetch = SelectFetch::Failed(reason);
|
||||
}
|
||||
|
||||
pub fn move_highlight(&mut self, delta: isize) {
|
||||
if self.rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let max = self.rows.len() - 1;
|
||||
self.highlight = self.highlight.saturating_add_signed(delta).min(max);
|
||||
}
|
||||
|
||||
pub fn toggle_highlighted(&mut self) {
|
||||
if let Some(row) = self.rows.get_mut(self.highlight) {
|
||||
row.selected = !row.selected;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a typed id selected; an id already listed just gets selected.
|
||||
pub fn add_manual(&mut self, raw: &str) {
|
||||
let id = raw.trim();
|
||||
if let Err(e) = validate_model_id(id) {
|
||||
self.error = Some(e.to_string());
|
||||
return;
|
||||
}
|
||||
if let Some(row) = self.rows.iter_mut().find(|r| r.id == id) {
|
||||
row.selected = true;
|
||||
} else {
|
||||
self.rows.push(SelectRow {
|
||||
id: id.to_owned(),
|
||||
selected: true,
|
||||
manual: true,
|
||||
});
|
||||
}
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// The chosen ids, or a refusal when nothing is chosen.
|
||||
pub fn try_finish(&mut self) -> Option<Vec<String>> {
|
||||
// Wait for the listing: it may adopt a probed `/v1` base URL.
|
||||
if self.fetch == SelectFetch::InFlight {
|
||||
self.finish_requested = true;
|
||||
return None;
|
||||
}
|
||||
let ids: Vec<String> = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|r| r.selected)
|
||||
.map(|r| r.id.clone())
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
self.finish_requested = false;
|
||||
self.error = Some("Select at least one model, or type one and press enter".to_owned());
|
||||
return None;
|
||||
}
|
||||
Some(ids)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for CustomSelectState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CustomSelectState")
|
||||
.field("provider", &self.provider)
|
||||
.field("key", &"<set>")
|
||||
.field("rows", &self.rows)
|
||||
.field("highlight", &self.highlight)
|
||||
.field("fetch", &self.fetch)
|
||||
.field("error", &self.error)
|
||||
.field("finish_requested", &self.finish_requested)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
fn select_state() -> CustomSelectState {
|
||||
let provider =
|
||||
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
|
||||
CustomSelectState::new(provider, "sk-secret".to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_state_toggles_adds_and_finishes() {
|
||||
let mut state = select_state();
|
||||
assert!(state.try_finish().is_none(), "in flight: finish waits");
|
||||
assert!(state.finish_requested && state.error.is_none());
|
||||
state.finish_requested = false;
|
||||
|
||||
state.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: state.provider.clone(),
|
||||
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
|
||||
});
|
||||
assert!(state.try_finish().is_none());
|
||||
assert!(state.error.is_some(), "listed: an empty selection refuses");
|
||||
assert_eq!(state.rows.len(), 2);
|
||||
|
||||
state.toggle_highlighted();
|
||||
state.move_highlight(1);
|
||||
state.toggle_highlighted();
|
||||
state.move_highlight(9);
|
||||
assert_eq!(state.highlight, 1, "highlight clamps at the last row");
|
||||
|
||||
state.add_manual(" claude-opus-5-5[1m] ");
|
||||
assert_eq!(state.rows.len(), 3);
|
||||
state.add_manual("m1");
|
||||
assert_eq!(
|
||||
state.rows.len(),
|
||||
3,
|
||||
"a listed id is selected, not duplicated"
|
||||
);
|
||||
state.add_manual("has space");
|
||||
assert!(state.error.is_some());
|
||||
assert_eq!(state.rows.len(), 3);
|
||||
|
||||
let ids = state.try_finish().expect("three selected");
|
||||
assert_eq!(ids, ["m1", "m2", "claude-opus-5-5[1m]"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_state_apply_fetch_adopts_the_probed_base_and_dedups() {
|
||||
let mut state = select_state();
|
||||
state.add_manual("m1");
|
||||
let adopted =
|
||||
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/coding/v1").unwrap();
|
||||
state.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: adopted,
|
||||
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
|
||||
});
|
||||
assert_eq!(state.provider.base_url, "https://h.example/coding/v1");
|
||||
assert_eq!(state.rows.len(), 2, "the manual row covers the fetched id");
|
||||
assert!(state.rows[0].manual && state.rows[0].selected);
|
||||
assert!(!state.rows[1].selected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_state_debug_hides_the_key() {
|
||||
let state = select_state();
|
||||
assert!(!format!("{state:?}").contains("sk-secret"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -281,6 +282,7 @@ pub(super) fn dispatch_cancel_login(app: &mut AppView) -> Vec<Effect> {
|
||||
app.auth_state = AuthState::Done;
|
||||
app.auth_show_raw_url = false;
|
||||
app.auth_code_input.clear();
|
||||
app.custom_select = None;
|
||||
app.auth_in_flight_method = None;
|
||||
restore_auth_return_view(app, return_view);
|
||||
// The user bailed out of re-auth — drop stashed prompts and strip the
|
||||
@@ -325,6 +327,151 @@ 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.custom_select = None;
|
||||
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 } => {
|
||||
// Key accepted: open the model selection and fetch concurrently.
|
||||
app.custom_entry.clear();
|
||||
app.auth_code_input.clear();
|
||||
app.custom_select = Some(crate::app::custom_entry::CustomSelectState::new(
|
||||
provider.clone(),
|
||||
key.clone(),
|
||||
));
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
};
|
||||
vec![Effect::FetchCustomProviderModels {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter on an empty manual-model box: persist the chosen models, then auth.
|
||||
pub(super) fn dispatch_submit_custom_provider_selection(app: &mut AppView) -> Vec<Effect> {
|
||||
let request_seq = match &app.auth_state {
|
||||
AuthState::Authenticating {
|
||||
request_seq,
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} => *request_seq,
|
||||
_ => return vec![],
|
||||
};
|
||||
complete_custom_provider_selection(app, request_seq)
|
||||
}
|
||||
|
||||
/// Persists the selection once the listing no longer gates the base URL.
|
||||
fn complete_custom_provider_selection(app: &mut AppView, request_seq: u64) -> Vec<Effect> {
|
||||
let Some(state) = app.custom_select.as_mut() else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(models) = state.try_finish() else {
|
||||
return vec![];
|
||||
};
|
||||
let state = app.custom_select.take().expect("checked above");
|
||||
let provider = state.provider.clone();
|
||||
let key = state.take_key();
|
||||
app.auth_in_flight_method = Some(kigi_shell::agent::custom_providers::login_method_id(
|
||||
&provider.name,
|
||||
));
|
||||
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,
|
||||
models,
|
||||
}]
|
||||
}
|
||||
|
||||
/// The login-step model listing came back; fill or flag the selection.
|
||||
pub(super) fn handle_custom_provider_models_listed(
|
||||
app: &mut AppView,
|
||||
request_seq: u64,
|
||||
result: Result<kigi_shell::agent::custom_providers::LoginFetch, String>,
|
||||
) -> Vec<Effect> {
|
||||
let in_flight = matches!(
|
||||
&app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
request_seq: current_seq,
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} if *current_seq == request_seq
|
||||
);
|
||||
if !in_flight {
|
||||
return vec![];
|
||||
}
|
||||
match (app.custom_select.as_mut(), result) {
|
||||
(Some(state), Ok(fetch)) => state.apply_fetch(fetch),
|
||||
(Some(state), Err(reason)) => state.fail_fetch(reason),
|
||||
(None, _) => {}
|
||||
}
|
||||
// A finish pressed during the fetch completes now, with the adopted base.
|
||||
if app
|
||||
.custom_select
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.finish_requested)
|
||||
{
|
||||
return complete_custom_provider_selection(app, request_seq);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// 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 +479,9 @@ 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(_)
|
||||
| AuthMode::CustomProviderSelect,
|
||||
..
|
||||
}
|
||||
) {
|
||||
@@ -340,6 +489,8 @@ 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.custom_select = None;
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
vec![]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! 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_custom_provider_selection, dispatch_submit_platform_api_key,
|
||||
dispatch_switch_account,
|
||||
};
|
||||
use super::ctx::{
|
||||
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
|
||||
@@ -889,6 +891,11 @@ 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::SubmitCustomProviderSelection => dispatch_submit_custom_provider_selection(app),
|
||||
Action::CopyAuthUrl => {
|
||||
if let AuthState::Authenticating {
|
||||
auth_url: Some(url),
|
||||
|
||||
@@ -509,9 +509,14 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
{
|
||||
app.auth_state = AuthState::Pending { error: Some(error) };
|
||||
app.auth_code_input.clear();
|
||||
app.custom_select = None;
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
TaskResult::CustomProviderModelsListed {
|
||||
request_seq,
|
||||
result,
|
||||
} => super::auth::handle_custom_provider_models_listed(app, request_seq, result),
|
||||
TaskResult::AuthUrlReady {
|
||||
request_seq,
|
||||
auth_url,
|
||||
@@ -798,6 +803,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
app.login_method_id = None;
|
||||
ensure_login_method(app);
|
||||
app.auth_clipboard_copied = false;
|
||||
app.custom_select = None;
|
||||
let effects = dispatch_exit_session(app);
|
||||
app.welcome_prompt_focused = false;
|
||||
effects
|
||||
|
||||
@@ -571,3 +571,355 @@ 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 opens the selection screen and fetches.
|
||||
#[test]
|
||||
fn custom_provider_steps_end_in_the_model_selection() {
|
||||
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::FetchCustomProviderModels {
|
||||
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 fetch effect, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
request_seq,
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} if request_seq == seq
|
||||
));
|
||||
assert!(app.custom_select.is_some());
|
||||
assert!(app.auth_code_input.is_empty());
|
||||
}
|
||||
|
||||
/// The listing fills rows; the selection persists only the chosen models.
|
||||
#[test]
|
||||
fn custom_provider_selection_persists_the_chosen_models() {
|
||||
use kigi_shell::models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://h.example".into()),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
|
||||
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||
let seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// The listing lands: rows appear, the probed base is adopted.
|
||||
let adopted = CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
|
||||
let listed = kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: adopted,
|
||||
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
|
||||
};
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
|
||||
request_seq: seq,
|
||||
result: Ok(listed),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
let state = app.custom_select.as_ref().expect("selection lives");
|
||||
assert_eq!(state.provider.base_url, "https://h.example/v1");
|
||||
assert_eq!(state.rows.len(), 2);
|
||||
assert!(!state.rows[0].selected);
|
||||
|
||||
// An empty selection refuses to finish once the listing resolved.
|
||||
assert!(dispatch(Action::SubmitCustomProviderSelection, &mut app).is_empty());
|
||||
assert!(
|
||||
app.custom_select
|
||||
.as_ref()
|
||||
.and_then(|s| s.error.as_deref())
|
||||
.is_some()
|
||||
);
|
||||
|
||||
// A late manual id typed before the fetch survives the fill.
|
||||
let state = app.custom_select.as_mut().expect("selection lives");
|
||||
state.add_manual("claude-opus-5-5[1m]");
|
||||
assert_eq!(state.rows.len(), 3);
|
||||
assert!(state.rows[2].manual && state.rows[2].selected);
|
||||
|
||||
// Select m2 as well, then finish.
|
||||
let state = app.custom_select.as_mut().expect("selection lives");
|
||||
state.highlight = 1;
|
||||
state.toggle_highlighted();
|
||||
let effects = dispatch(Action::SubmitCustomProviderSelection, &mut app);
|
||||
match effects.as_slice() {
|
||||
[
|
||||
Effect::PersistCustomProviderAndAuthenticate {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
models,
|
||||
},
|
||||
] => {
|
||||
assert_eq!(*request_seq, seq);
|
||||
assert_eq!(provider.base_url, "https://h.example/v1");
|
||||
assert_eq!(key, "sk-gw");
|
||||
assert_eq!(models, &["m2".to_owned(), "claude-opus-5-5[1m]".to_owned()]);
|
||||
}
|
||||
other => panic!("expected the persist effect, got {other:?}"),
|
||||
}
|
||||
assert!(app.custom_select.is_none());
|
||||
assert!(matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::Pending,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(
|
||||
app.auth_in_flight_method.as_ref().map(|m| m.0.as_ref()),
|
||||
Some("custom:gw")
|
||||
);
|
||||
}
|
||||
|
||||
/// Finishing while the listing is in flight completes when it lands.
|
||||
#[test]
|
||||
fn custom_provider_selection_finish_waits_for_the_fetch() {
|
||||
use kigi_shell::models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://h.example".into()),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
|
||||
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||
let seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let state = app.custom_select.as_mut().expect("selection lives");
|
||||
state.add_manual("m1");
|
||||
// Enter with an empty box while the fetch runs: no persist yet.
|
||||
assert!(dispatch(Action::SubmitCustomProviderSelection, &mut app).is_empty());
|
||||
assert!(app.custom_select.as_ref().expect("lives").finish_requested);
|
||||
assert!(matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
// The listing lands with an adopted /v1 base; the finish fires now.
|
||||
let adopted = CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
|
||||
request_seq: seq,
|
||||
result: Ok(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: adopted,
|
||||
model_ids: vec!["m2".to_owned()],
|
||||
}),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
match effects.as_slice() {
|
||||
[
|
||||
Effect::PersistCustomProviderAndAuthenticate {
|
||||
provider, models, ..
|
||||
},
|
||||
] => {
|
||||
assert_eq!(provider.base_url, "https://h.example/v1");
|
||||
assert_eq!(models, &["m1".to_owned()]);
|
||||
}
|
||||
other => panic!("expected the persist effect, got {other:?}"),
|
||||
}
|
||||
assert!(app.custom_select.is_none());
|
||||
}
|
||||
|
||||
/// A failed listing keeps the manual input usable and shows the reason.
|
||||
#[test]
|
||||
fn custom_provider_selection_survives_a_failed_fetch() {
|
||||
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("https://h.example/v1".into()),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
|
||||
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||
let seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
|
||||
request_seq: seq,
|
||||
result: Err("https://h.example/v1/models answered HTTP 404".to_owned()),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
let state = app.custom_select.as_ref().expect("selection lives");
|
||||
assert!(matches!(
|
||||
&state.fetch,
|
||||
crate::app::custom_entry::SelectFetch::Failed(reason) if reason.contains("404")
|
||||
));
|
||||
|
||||
// A stale result for an older request never clobbers the screen.
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
|
||||
request_seq: seq + 99,
|
||||
result: Ok(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: state.provider.clone(),
|
||||
model_ids: vec!["stale".to_owned()],
|
||||
}),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
assert!(
|
||||
app.custom_select
|
||||
.as_ref()
|
||||
.expect("selection lives")
|
||||
.rows
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// Manual add still finishes the flow.
|
||||
let state = app.custom_select.as_mut().expect("selection lives");
|
||||
state.add_manual("m1");
|
||||
let effects = dispatch(Action::SubmitCustomProviderSelection, &mut app);
|
||||
assert!(matches!(
|
||||
effects.as_slice(),
|
||||
[Effect::PersistCustomProviderAndAuthenticate { models, .. }] if models == &["m1".to_owned()]
|
||||
));
|
||||
}
|
||||
|
||||
/// 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,8 @@ fn test_app() -> AppView {
|
||||
login_method_id: None,
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
custom_entry: Default::default(),
|
||||
custom_select: None,
|
||||
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.
|
||||
|
||||
@@ -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,54 @@ pub(crate) fn execute(
|
||||
});
|
||||
meta.auth_abort_handle = Some((request_seq, abort_handle));
|
||||
}
|
||||
Effect::PersistCustomProviderAndAuthenticate {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
models,
|
||||
} => {
|
||||
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, &models,
|
||||
)
|
||||
.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::FetchCustomProviderModels {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
} => {
|
||||
tasks.spawn(async move {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
kigi_shell::agent::custom_providers::fetch_listing_for_login(&provider, &key)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("listing task failed: {e}")));
|
||||
TaskResult::CustomProviderModelsListed {
|
||||
request_seq,
|
||||
result,
|
||||
}
|
||||
});
|
||||
}
|
||||
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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(), ®istry);
|
||||
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();
|
||||
|
||||
@@ -461,6 +461,8 @@ 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 custom_select: Option<&'a crate::app::custom_entry::CustomSelectState>,
|
||||
pub clipboard_copied: bool,
|
||||
pub show_raw_url: bool,
|
||||
pub tip: Option<&'a str>,
|
||||
@@ -603,6 +605,8 @@ pub fn render_welcome(
|
||||
auth_url.as_deref(),
|
||||
*mode,
|
||||
params.auth_code_input,
|
||||
params.custom_entry,
|
||||
params.custom_select,
|
||||
params.clipboard_copied,
|
||||
params.show_raw_url,
|
||||
);
|
||||
@@ -1176,6 +1180,8 @@ fn render_welcome_authenticating(
|
||||
auth_url: Option<&str>,
|
||||
mode: AuthMode,
|
||||
auth_code_input: &str,
|
||||
custom_entry: &crate::app::custom_entry::CustomEntryDraft,
|
||||
custom_select: Option<&crate::app::custom_entry::CustomSelectState>,
|
||||
clipboard_copied: bool,
|
||||
show_raw_url: bool,
|
||||
) -> (Option<Rect>, Option<Rect>) {
|
||||
@@ -1270,6 +1276,7 @@ fn render_welcome_authenticating(
|
||||
theme,
|
||||
auth_code_input,
|
||||
"Paste your token here...",
|
||||
true,
|
||||
);
|
||||
|
||||
// Hints
|
||||
@@ -1290,81 +1297,60 @@ 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)
|
||||
}
|
||||
|
||||
AuthMode::CustomProviderSelect => {
|
||||
if let Some(select) = custom_select {
|
||||
render_custom_select(
|
||||
content_area,
|
||||
buf,
|
||||
theme,
|
||||
top_pad,
|
||||
logo_line_count,
|
||||
select,
|
||||
auth_code_input,
|
||||
);
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
|
||||
@@ -1970,6 +1956,278 @@ 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);
|
||||
}
|
||||
|
||||
/// The custom provider model-selection screen: fetched rows + manual input.
|
||||
fn render_custom_select(
|
||||
content_area: Rect,
|
||||
buf: &mut Buffer,
|
||||
theme: &Theme,
|
||||
top_pad: u16,
|
||||
logo_line_count: u16,
|
||||
select: &crate::app::custom_entry::CustomSelectState,
|
||||
input: &str,
|
||||
) {
|
||||
use crate::app::custom_entry::SelectFetch;
|
||||
let h_pad: u16 = content_area.width / 8;
|
||||
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
|
||||
let instruction = format!(
|
||||
"Select the models to add for {} ({})",
|
||||
select.provider.name, select.provider.base_url
|
||||
);
|
||||
let status: Option<(String, Style)> = match &select.fetch {
|
||||
SelectFetch::InFlight if select.finish_requested => Some((
|
||||
"Fetching models... finishing when the list lands".to_owned(),
|
||||
Style::default().fg(theme.gray),
|
||||
)),
|
||||
SelectFetch::InFlight => Some((
|
||||
"Fetching models...".to_owned(),
|
||||
Style::default().fg(theme.gray),
|
||||
)),
|
||||
SelectFetch::Listed if select.rows.is_empty() => Some((
|
||||
"The provider served no models; add one below".to_owned(),
|
||||
Style::default().fg(theme.warning),
|
||||
)),
|
||||
SelectFetch::Listed => None,
|
||||
SelectFetch::Failed(reason) => Some((
|
||||
format!("Fetch failed: {reason}"),
|
||||
Style::default().fg(theme.warning),
|
||||
)),
|
||||
};
|
||||
// Word-wrap-aware rows; byte math undercounts and clips the failure reason.
|
||||
let rows_of = |text: &str| -> u16 {
|
||||
crate::render::wrapping::word_wrap_line(
|
||||
&Line::from(Span::raw(text.to_owned())),
|
||||
inner_width as usize,
|
||||
)
|
||||
.len() as u16
|
||||
};
|
||||
let msg_height = rows_of(&instruction)
|
||||
+ status.as_ref().map_or(0, |(s, _)| rows_of(s))
|
||||
+ select.error.as_deref().map_or(0, rows_of);
|
||||
let [
|
||||
_,
|
||||
logo_area,
|
||||
_,
|
||||
msg_area,
|
||||
_,
|
||||
rows_area,
|
||||
_,
|
||||
prompt_area,
|
||||
_,
|
||||
hint_area,
|
||||
_,
|
||||
] = Layout::vertical([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(logo_line_count),
|
||||
Constraint::Length(1),
|
||||
// Min: spare rows land here, so a mis-measured wrap never clips.
|
||||
Constraint::Min(msg_height),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(1),
|
||||
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(
|
||||
instruction,
|
||||
Style::default().fg(theme.gray_bright),
|
||||
))
|
||||
.alignment(Alignment::Center),
|
||||
];
|
||||
if let Some((text, style)) = status {
|
||||
lines.push(Line::from(Span::styled(text, style)).alignment(Alignment::Center));
|
||||
}
|
||||
if let Some(error) = select.error.as_deref() {
|
||||
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 visible = rows_area.height as usize;
|
||||
let start = select
|
||||
.highlight
|
||||
.saturating_add(1)
|
||||
.saturating_sub(visible)
|
||||
.min(select.rows.len().saturating_sub(visible));
|
||||
let mut row_lines = Vec::new();
|
||||
for (idx, row) in select.rows.iter().enumerate().skip(start).take(visible) {
|
||||
let highlighted = idx == select.highlight;
|
||||
let check = if row.selected { "[x]" } else { "[ ]" };
|
||||
let suffix = if row.manual { " (manual)" } else { "" };
|
||||
let style = if highlighted {
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.gray_bright)
|
||||
};
|
||||
let marker = if highlighted { "› " } else { " " };
|
||||
row_lines.push(Line::from(Span::styled(
|
||||
format!("{marker}{check} {}{suffix}", row.id),
|
||||
style,
|
||||
)));
|
||||
}
|
||||
Paragraph::new(row_lines)
|
||||
.block(Block::default().padding(Padding::horizontal(h_pad)))
|
||||
.render(rows_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,
|
||||
input,
|
||||
"Type a model id and press enter to add",
|
||||
false,
|
||||
);
|
||||
|
||||
let hints = Line::from(vec![
|
||||
Span::styled(
|
||||
"↑↓",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" navigate ", Style::default().fg(theme.gray)),
|
||||
Span::styled(
|
||||
"space",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" toggle ", Style::default().fg(theme.gray)),
|
||||
Span::styled(
|
||||
"enter",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" add model / done ", 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 +2235,7 @@ fn render_auth_input_box(
|
||||
theme: &Theme,
|
||||
input: &str,
|
||||
placeholder: &str,
|
||||
masked: bool,
|
||||
) {
|
||||
let prompt_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
@@ -1993,8 +2252,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 +2339,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 +2390,8 @@ mod tests {
|
||||
auth_methods: &[],
|
||||
login_label: None,
|
||||
auth_code_input: "",
|
||||
custom_entry: &EMPTY_CUSTOM_ENTRY,
|
||||
custom_select: None,
|
||||
clipboard_copied: false,
|
||||
show_raw_url: false,
|
||||
tip: None,
|
||||
@@ -2323,6 +2588,146 @@ 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,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
buffer_text(&buf)
|
||||
}
|
||||
|
||||
fn render_custom_select(
|
||||
select: &crate::app::custom_entry::CustomSelectState,
|
||||
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::CustomProviderSelect,
|
||||
input,
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
Some(select),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
buffer_text(&buf)
|
||||
}
|
||||
|
||||
/// The selection screen lists rows, status, and the manual input together.
|
||||
#[test]
|
||||
fn custom_select_arm_lists_rows_status_and_input() {
|
||||
use crate::app::custom_entry::CustomSelectState;
|
||||
use kigi_shell::models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
let provider =
|
||||
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
|
||||
let mut select = CustomSelectState::new(provider, "sk".to_owned());
|
||||
let fetching = render_custom_select(&select, "");
|
||||
assert!(fetching.contains("Fetching models"), "{fetching}");
|
||||
assert!(fetching.contains("add model / done"), "{fetching}");
|
||||
|
||||
select.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: select.provider.clone(),
|
||||
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
|
||||
});
|
||||
select.toggle_highlighted();
|
||||
select.add_manual("claude-opus-5-5[1m]");
|
||||
let listed = render_custom_select(&select, "typed-so-far");
|
||||
assert!(listed.contains("[x] m1"), "{listed}");
|
||||
assert!(listed.contains("[ ] m2"), "{listed}");
|
||||
assert!(listed.contains("claude-opus-5-5[1m] (manual)"), "{listed}");
|
||||
assert!(listed.contains("typed-so-far"), "{listed}");
|
||||
|
||||
select.fail_fetch("boom".to_owned());
|
||||
let failed = render_custom_select(&select, "");
|
||||
assert!(failed.contains("Fetch failed: boom"), "{failed}");
|
||||
|
||||
// A two-hop failure reason must render whole, not clip at the wrap.
|
||||
let reason = "https://h.example/models answered HTTP 404; https://h.example/v1/models also failed: https://h.example/v1/models answered HTTP 500";
|
||||
select.fail_fetch(reason.to_owned());
|
||||
let text = render_custom_select(&select, "");
|
||||
assert!(text.contains("Fetch failed:"), "{text}");
|
||||
assert!(text.contains("HTTP 500"), "{text}");
|
||||
}
|
||||
|
||||
/// An empty selection refuses once the fetch resolved.
|
||||
#[test]
|
||||
fn custom_select_arm_shows_the_finish_refusal() {
|
||||
use crate::app::custom_entry::CustomSelectState;
|
||||
use kigi_shell::models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
let provider =
|
||||
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
|
||||
let mut select = CustomSelectState::new(provider, "sk".to_owned());
|
||||
select.fail_fetch("boom".to_owned());
|
||||
assert!(select.try_finish().is_none());
|
||||
let text = render_custom_select(&select, "");
|
||||
assert!(text.contains("Select at least one model"), "{text}");
|
||||
}
|
||||
|
||||
/// 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 +2748,8 @@ mod tests {
|
||||
)),
|
||||
// auth_code_input
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
None,
|
||||
// clipboard_copied
|
||||
false,
|
||||
// show_raw_url
|
||||
@@ -3010,6 +3417,8 @@ mod tests {
|
||||
AuthMode::Device,
|
||||
// auth_code_input — unused in device mode
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
None,
|
||||
// clipboard_copied
|
||||
false,
|
||||
// show_raw_url
|
||||
@@ -3066,6 +3475,8 @@ mod tests {
|
||||
Some(url),
|
||||
AuthMode::Device,
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
None,
|
||||
false,
|
||||
// show_raw_url
|
||||
true,
|
||||
@@ -3093,6 +3504,8 @@ mod tests {
|
||||
Some(url),
|
||||
AuthMode::Device,
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
None,
|
||||
false,
|
||||
// show_raw_url
|
||||
true,
|
||||
@@ -3131,6 +3544,8 @@ mod tests {
|
||||
Some(url),
|
||||
AuthMode::Device,
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
None,
|
||||
false,
|
||||
// show_raw_url
|
||||
true,
|
||||
@@ -3172,6 +3587,8 @@ mod tests {
|
||||
AuthMode::Command,
|
||||
// auth_code_input — unused
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
None,
|
||||
// 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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user