Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -18,7 +18,8 @@ Upstream sync record (update on every sync): fork baseline is upstream
|
|||||||
snapshot `8adf901` (2026-07-16, ≈v0.2.102); bug fixes evaluated and
|
snapshot `8adf901` (2026-07-16, ≈v0.2.102); bug fixes evaluated and
|
||||||
selectively ported through upstream `77cd7eb` (2026-08-25, ≈v1.0.10) in
|
selectively ported through upstream `77cd7eb` (2026-08-25, ≈v1.0.10) in
|
||||||
the 0.1.13 cycle, through `3794978` (2026-09-09, ≈v1.0.24) in the 0.1.15
|
the 0.1.13 cycle, through `3794978` (2026-09-09, ≈v1.0.24) in the 0.1.15
|
||||||
cycle, and through `a28ee2b` (2026-09-17, ≈v1.0.35) in the 0.1.16 cycle.
|
cycle, through `a28ee2b` (2026-09-17, ≈v1.0.35) in the 0.1.16 cycle, and
|
||||||
|
through `2bdd1d6` (2026-09-29, ≈v1.0.45) in the 0.1.19 cycle.
|
||||||
Upstream is daily "Synced from monorepo" snapshots —
|
Upstream is daily "Synced from monorepo" snapshots —
|
||||||
the commit BODIES carry per-change bullet lists, and
|
the commit BODIES carry per-change bullet lists, and
|
||||||
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
|
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
|
||||||
@@ -39,6 +40,17 @@ POST in turn, so the cancel queues behind the hung call;
|
|||||||
then drops the connection. stdio and SSE-mode servers get the cancel.
|
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.
|
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
|
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
|
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
|
function list), the auto-update guard for a binary still in use (needs
|
||||||
@@ -116,6 +128,24 @@ a design against the 0.1.15 teardown), MCP servers on protocol 2026-07-28
|
|||||||
- Graph node worktrees are removed right after a successful merge-back;
|
- Graph node worktrees are removed right after a successful merge-back;
|
||||||
only FAILED nodes keep theirs for postmortem.
|
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
|
## Test seams
|
||||||
|
|
||||||
Cross-crate test hooks are behind the `test-support` cargo feature
|
Cross-crate test hooks are behind the `test-support` cargo feature
|
||||||
@@ -385,11 +415,17 @@ client-side, no backend surface.
|
|||||||
carries the thinking level. NO websocket, NO base_instructions.
|
carries the thinking level. NO websocket, NO base_instructions.
|
||||||
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
|
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
|
||||||
`openai_codex_wire_models`, mapped through the SAME
|
`openai_codex_wire_models`, mapped through the SAME
|
||||||
`platform_wire_model_to_entry` output): exactly the 4 `visibility=list` &&
|
`platform_wire_model_to_entry` output): the 8 `visibility=list` &&
|
||||||
`supported_in_api=true` models (`gpt-5.6-sol/terra/luna`, `gpt-5.5`, ctx
|
`supported_in_api=true` models the backend served on 2026-09-29
|
||||||
272000, per-model efforts) — NO live `/models` fetch, NO codex-CLI /
|
(`gpt-6.1-sol`, `gpt-6-astra/sol/luna`, `gpt-5.6-sol/terra/luna`,
|
||||||
`~/.codex` dependency; `gpt-5.3-codex-spark` (api=false) and
|
`gpt-5.5`, ctx 272000, per-model efforts) — NO live `/models` fetch, NO codex-CLI / `~/.codex`
|
||||||
`gpt-5.4`/`gpt-5.4-mini`/`codex-auto-review` (hidden) are EXCLUDED.
|
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).
|
- `OAuthFlow::GithubDeviceCopilot` → `auth::github_copilot` (TWO-STAGE).
|
||||||
Provider: `github-copilot` (`scope_key oauth/github-copilot`, base
|
Provider: `github-copilot` (`scope_key oauth/github-copilot`, base
|
||||||
`api.individual.githubcopilot.com`, ChatCompletions wire). Stage 1 is an
|
`api.individual.githubcopilot.com`, ChatCompletions wire). Stage 1 is an
|
||||||
@@ -423,6 +459,39 @@ client-side, no backend surface.
|
|||||||
token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes
|
token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes
|
||||||
`platform.oauth().is_some()` → `platform.base_url()` (kimi-code alone →
|
`platform.oauth().is_some()` → `platform.base_url()` (kimi-code alone →
|
||||||
`proxy_url()`). Tokens/codes/verifiers are NEVER logged.
|
`proxy_url()`). Tokens/codes/verifiers are NEVER logged.
|
||||||
|
- CUSTOM PROVIDERS (0.1.19): `[platforms.<name>]` with `base_url` and `api =
|
||||||
|
"openai" | "anthropic"` declares a provider outside the closed registry.
|
||||||
|
`kigi-models/src/custom.rs` owns the validated type (name `[a-z0-9][a-z0-9_-]{0,31}`,
|
||||||
|
never a registry id; base URL `http(s)://host[/path]`, no userinfo, query or
|
||||||
|
fragment); `kigi-shell/src/agent/custom_providers/` owns the rest. Key order:
|
||||||
|
auth.json scope `<name>`, then the table's `api_key`; a key is REQUIRED (a
|
||||||
|
keyless declaration is inert). Wires: openai = `/chat/completions`, Bearer,
|
||||||
|
`GET {base}/models`; anthropic = `/messages`, `x-api-key`,
|
||||||
|
`GET {base}/models?limit=1000`. Catalog keys are `<name>/<model>`; the entry's
|
||||||
|
`description` carries the provider name for the picker. Entries reach the disk
|
||||||
|
cache without a key; `stamp_credentials` stamps `api_key` in memory, only onto
|
||||||
|
entries still on the provider's own host, and `drop_orphans` removes fetched
|
||||||
|
entries of a provider that is gone or keyless BEFORE the prefetched map replaces
|
||||||
|
the catalog (a stale unkeyed entry would fall through to the house key).
|
||||||
|
CREDENTIAL RULE: `EndpointsConfig.custom_provider_bases` (derived from
|
||||||
|
`[platforms.*]` in `Config::new_from_toml_cfg` and `from_config_value`, never
|
||||||
|
serialized) makes `CredentialAuthority::is_session_coding_endpoint` false for a
|
||||||
|
declared base, loopback included — without it an Ollama-style provider on
|
||||||
|
localhost classifies `Primary` and the Kimi bearer replaces its key. Every
|
||||||
|
authority is built from the models manager's config, so the field reaches all of
|
||||||
|
them. Turns and subagents read `auth_scheme` and BYOK status from the LIVE
|
||||||
|
catalog entry (`auth_facts_of`), not from a disk reload: fetched entries never
|
||||||
|
live on disk, and the reload sent fetched Messages models out with `Bearer`
|
||||||
|
instead of `x-api-key` (this also repaired `anthropic` and `minimax`).
|
||||||
|
Login: picker rows `custom-openai` / `custom-anthropic` are not loginable
|
||||||
|
(`AuthMethodKind::Unknown`); the TUI asks base URL, name (default from the
|
||||||
|
host), key, then `save_custom_provider` writes config.toml (through
|
||||||
|
`config_write_dest`) and auth.json, and `authenticate("custom:<name>")`
|
||||||
|
validates `GET {base}/models` (401/403 = rejected key, other non-2xx and network
|
||||||
|
errors surface). Limits: context window 256K unless the listing serves
|
||||||
|
`context_length` (Anthropic: `max_input_tokens`); no models.dev enrichment; a
|
||||||
|
failed key check leaves the saved definition (login again with the same name to
|
||||||
|
overwrite); no delete UI, edit config.toml and auth.json.
|
||||||
- A stored subscription-OAuth session IS a catalog fetch source. Every
|
- A stored subscription-OAuth session IS a catalog fetch source. Every
|
||||||
fetch-PLAN decision that cannot afford real token resolution — the startup
|
fetch-PLAN decision that cannot afford real token resolution — the startup
|
||||||
prefetch arming gate, `on_auth_changed`'s wipe guard
|
prefetch arming gate, `on_auth_changed`'s wipe guard
|
||||||
@@ -532,9 +601,10 @@ client-side, no backend surface.
|
|||||||
`~/.kigi/models_dev_cache.json`). Wire values always win; enrichment
|
`~/.kigi/models_dev_cache.json`). Wire values always win; enrichment
|
||||||
never invents model availability. Canonical reasoning efforts:
|
never invents model availability. Canonical reasoning efforts:
|
||||||
none/minimal/low/medium/high/xhigh/max (`max` split from `xhigh` 2026-07;
|
none/minimal/low/medium/high/xhigh/max (`max` split from `xhigh` 2026-07;
|
||||||
`ultra` was dropped the same month, no backend accepts it; Kimi wire
|
the Codex backend advertises an `ultra` tier above `max` that kigi has no
|
||||||
spells its top tier `max`, kimi_compat renames). config.toml accepts any
|
level for; Kimi wire spells its top tier `max`, kimi_compat renames).
|
||||||
letter case: `ReasoningEffort` deserializes through `FromStr`.
|
config.toml accepts any letter case: `ReasoningEffort` deserializes
|
||||||
|
through `FromStr`.
|
||||||
|
|
||||||
## Milestones (PRD §8.3)
|
## Milestones (PRD §8.3)
|
||||||
|
|
||||||
|
|||||||
Generated
+62
-62
@@ -5442,7 +5442,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-acp-lib"
|
name = "kigi-acp-lib"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5456,7 +5456,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-agent"
|
name = "kigi-agent"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
@@ -5486,7 +5486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-agent-lifecycle"
|
name = "kigi-agent-lifecycle"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -5495,7 +5495,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-auth"
|
name = "kigi-auth"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
@@ -5508,7 +5508,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-bin"
|
name = "kigi-bin"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-chat-state"
|
name = "kigi-chat-state"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"kigi-compaction",
|
"kigi-compaction",
|
||||||
@@ -5560,7 +5560,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-codebase-graph"
|
name = "kigi-codebase-graph"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-compaction"
|
name = "kigi-compaction"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5609,7 +5609,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-config"
|
name = "kigi-config"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -5632,7 +5632,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-config-types"
|
name = "kigi-config-types"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-crash-handler"
|
name = "kigi-crash-handler"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5657,7 +5657,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-env"
|
name = "kigi-env"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-fast-worktree"
|
name = "kigi-fast-worktree"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -5697,7 +5697,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-file-utils"
|
name = "kigi-file-utils"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
@@ -5721,7 +5721,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-fsnotify"
|
name = "kigi-fsnotify"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"criterion",
|
"criterion",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -5742,7 +5742,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-gix-status"
|
name = "kigi-gix-status"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gix",
|
"gix",
|
||||||
"kigi-test-utils",
|
"kigi-test-utils",
|
||||||
@@ -5752,7 +5752,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hooks"
|
name = "kigi-hooks"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"kigi-config",
|
"kigi-config",
|
||||||
@@ -5771,7 +5771,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hooks-plugins-types"
|
name = "kigi-hooks-plugins-types"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -5779,7 +5779,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-http"
|
name = "kigi-http"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-auth",
|
"kigi-auth",
|
||||||
"kigi-log",
|
"kigi-log",
|
||||||
@@ -5794,7 +5794,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hunk-tracker"
|
name = "kigi-hunk-tracker"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -5815,14 +5815,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-interjection-core"
|
name = "kigi-interjection-core"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-log"
|
name = "kigi-log"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -5840,7 +5840,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-markdown"
|
name = "kigi-markdown"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"anstyle-lossy",
|
"anstyle-lossy",
|
||||||
@@ -5864,14 +5864,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-markdown-core"
|
name = "kigi-markdown-core"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-mcp"
|
name = "kigi-mcp"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5908,7 +5908,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-memory"
|
name = "kigi-memory"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -5942,7 +5942,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-mermaid"
|
name = "kigi-mermaid"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fontdb",
|
"fontdb",
|
||||||
"image",
|
"image",
|
||||||
@@ -5960,7 +5960,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-models"
|
name = "kigi-models"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-env",
|
"kigi-env",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5970,7 +5970,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-minimal"
|
name = "kigi-pager-minimal"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
@@ -5987,7 +5987,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-pty-harness"
|
name = "kigi-pager-pty-harness"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6012,7 +6012,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-render"
|
name = "kigi-pager-render"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -6064,7 +6064,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-paths"
|
name = "kigi-paths"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"camino",
|
"camino",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6074,7 +6074,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-prompt-queue"
|
name = "kigi-prompt-queue"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6082,7 +6082,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-proto-build"
|
name = "kigi-proto-build"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pbjson-build",
|
"pbjson-build",
|
||||||
@@ -6093,7 +6093,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-ratatui-inline"
|
name = "kigi-ratatui-inline"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ansi-width",
|
"ansi-width",
|
||||||
"anstyle-parse 0.2.7",
|
"anstyle-parse 0.2.7",
|
||||||
@@ -6110,7 +6110,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-ratatui-textarea"
|
name = "kigi-ratatui-textarea"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6131,7 +6131,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sampler"
|
name = "kigi-sampler"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-openai",
|
"async-openai",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -6155,7 +6155,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sampling-types"
|
name = "kigi-sampling-types"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assert_matches",
|
"assert_matches",
|
||||||
"async-openai",
|
"async-openai",
|
||||||
@@ -6172,7 +6172,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sandbox"
|
name = "kigi-sandbox"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6194,7 +6194,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-secrets"
|
name = "kigi-secrets"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6232,7 +6232,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell"
|
name = "kigi-shell"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6370,7 +6370,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell-base"
|
name = "kigi-shell-base"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6395,7 +6395,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sqlite-journal"
|
name = "kigi-sqlite-journal"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -6406,7 +6406,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-subagent-resolution"
|
name = "kigi-subagent-resolution"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-sampling-types",
|
"kigi-sampling-types",
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
@@ -6421,7 +6421,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-system-power"
|
name = "kigi-system-power"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
"zbus",
|
"zbus",
|
||||||
@@ -6429,7 +6429,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-support"
|
name = "kigi-test-support"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6451,7 +6451,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-utils"
|
name = "kigi-test-utils"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"runfiles",
|
"runfiles",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -6460,11 +6460,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-token-estimation"
|
name = "kigi-token-estimation"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-protocol"
|
name = "kigi-tool-protocol"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6475,7 +6475,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-runtime"
|
name = "kigi-tool-runtime"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -6493,7 +6493,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-types"
|
name = "kigi-tool-types"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"minijinja",
|
"minijinja",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
@@ -6503,7 +6503,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools"
|
name = "kigi-tools"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -6580,7 +6580,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools-api"
|
name = "kigi-tools-api"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-proto-build",
|
"kigi-proto-build",
|
||||||
"kigi-tool-protocol",
|
"kigi-tool-protocol",
|
||||||
@@ -6593,11 +6593,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tracing-macros"
|
name = "kigi-tracing-macros"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tty-utils"
|
name = "kigi-tty-utils"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"nix 0.30.1",
|
"nix 0.30.1",
|
||||||
@@ -6607,7 +6607,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tui"
|
name = "kigi-tui"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"ansi-to-tui",
|
"ansi-to-tui",
|
||||||
@@ -6694,7 +6694,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-update"
|
name = "kigi-update"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -6723,14 +6723,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-version"
|
name = "kigi-version"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace"
|
name = "kigi-workspace"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6809,7 +6809,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace-types"
|
name = "kigi-workspace-types"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -8843,7 +8843,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl"
|
name = "ptyctl"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -8861,7 +8861,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl-cli"
|
name = "ptyctl-cli"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "Apache-2.0"
|
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
|
## Providers and API keys
|
||||||
|
|
||||||
29 platforms ship compiled in: 5 you sign into, 24 you hand a key. Nothing is
|
29 platforms ship compiled in: 5 you sign into, 24 you hand a key. Anything
|
||||||
registered at runtime — if it's not in this list, it's not there.
|
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.
|
**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
|
Each provider's token is stored under its own key, and one provider's
|
||||||
@@ -149,6 +149,28 @@ api_key = "sk-..."
|
|||||||
api_key = "xai-..."
|
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
|
Model lists sync on startup. Pick one with `/model`, set its thinking level
|
||||||
with `/effort`.
|
with `/effort`.
|
||||||
|
|
||||||
|
|||||||
+308
-307
@@ -17,7 +17,7 @@ cargo about generate about.hbs -o THIRD-PARTY-NOTICES.md
|
|||||||
- ISC License (27 crates)
|
- ISC License (27 crates)
|
||||||
- BSD 3-Clause "New" or "Revised" License (19 crates)
|
- BSD 3-Clause "New" or "Revised" License (19 crates)
|
||||||
- Unicode License v3 (19 crates)
|
- Unicode License v3 (19 crates)
|
||||||
- Mozilla Public License 2.0 (8 crates)
|
- Mozilla Public License 2.0 (9 crates)
|
||||||
- Do What The F*ck You Want To Public License (4 crates)
|
- Do What The F*ck You Want To Public License (4 crates)
|
||||||
- zlib License (4 crates)
|
- zlib License (4 crates)
|
||||||
- Boost Software License 1.0 (2 crates)
|
- Boost Software License 1.0 (2 crates)
|
||||||
@@ -3972,6 +3972,218 @@ Applies to:
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
|
- [dagre_rust 0.0.5](https://github.com/r3alst/dagre-rust)
|
||||||
|
- [graphlib_rust 0.0.2](https://github.com/r3alst/graphlib-rust)
|
||||||
|
- [ordered_hashmap 0.0.3](https://github.com/r3alst/ordered-hashmap)
|
||||||
|
|
||||||
|
```
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2023 Ameer Hamza
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apache License 2.0
|
||||||
|
|
||||||
|
Applies to:
|
||||||
|
|
||||||
- [zerocopy-derive 0.8.54](https://github.com/google/zerocopy)
|
- [zerocopy-derive 0.8.54](https://github.com/google/zerocopy)
|
||||||
- [zerocopy 0.8.54](https://github.com/google/zerocopy)
|
- [zerocopy 0.8.54](https://github.com/google/zerocopy)
|
||||||
|
|
||||||
@@ -6543,218 +6755,6 @@ Applies to:
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
- [dagre_rust 0.0.5](https://github.com/r3alst/dagre-rust)
|
|
||||||
- [graphlib_rust 0.0.2](https://github.com/r3alst/graphlib-rust)
|
|
||||||
- [ordered_hashmap 0.0.3](https://github.com/r3alst/ordered-hashmap)
|
|
||||||
|
|
||||||
```
|
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright 2023 Ameer Hamza
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Apache License 2.0
|
|
||||||
|
|
||||||
Applies to:
|
|
||||||
|
|
||||||
- [cassowary 0.3.0](https://github.com/dylanede/cassowary-rs)
|
- [cassowary 0.3.0](https://github.com/dylanede/cassowary-rs)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -16436,68 +16436,68 @@ limitations under the License.
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
- [kigi-proto-build 0.1.16](https://crates.io/crates/kigi-proto-build)
|
- [kigi-proto-build 0.1.19](https://crates.io/crates/kigi-proto-build)
|
||||||
- [kigi-acp-lib 0.1.16](https://crates.io/crates/kigi-acp-lib)
|
- [kigi-acp-lib 0.1.19](https://crates.io/crates/kigi-acp-lib)
|
||||||
- [kigi-agent 0.1.16](https://crates.io/crates/kigi-agent)
|
- [kigi-agent 0.1.19](https://crates.io/crates/kigi-agent)
|
||||||
- [kigi-agent-lifecycle 0.1.16](https://crates.io/crates/kigi-agent-lifecycle)
|
- [kigi-agent-lifecycle 0.1.19](https://crates.io/crates/kigi-agent-lifecycle)
|
||||||
- [kigi-auth 0.1.16](https://crates.io/crates/kigi-auth)
|
- [kigi-auth 0.1.19](https://crates.io/crates/kigi-auth)
|
||||||
- [kigi-bin 0.1.16](https://crates.io/crates/kigi-bin)
|
- [kigi-bin 0.1.19](https://crates.io/crates/kigi-bin)
|
||||||
- [kigi-chat-state 0.1.16](https://crates.io/crates/kigi-chat-state)
|
- [kigi-chat-state 0.1.19](https://crates.io/crates/kigi-chat-state)
|
||||||
- [kigi-codebase-graph 0.1.16](https://crates.io/crates/kigi-codebase-graph)
|
- [kigi-codebase-graph 0.1.19](https://crates.io/crates/kigi-codebase-graph)
|
||||||
- [kigi-config 0.1.16](https://crates.io/crates/kigi-config)
|
- [kigi-config 0.1.19](https://crates.io/crates/kigi-config)
|
||||||
- [kigi-config-types 0.1.16](https://crates.io/crates/kigi-config-types)
|
- [kigi-config-types 0.1.19](https://crates.io/crates/kigi-config-types)
|
||||||
- [kigi-crash-handler 0.1.16](https://crates.io/crates/kigi-crash-handler)
|
- [kigi-crash-handler 0.1.19](https://crates.io/crates/kigi-crash-handler)
|
||||||
- [kigi-env 0.1.16](https://crates.io/crates/kigi-env)
|
- [kigi-env 0.1.19](https://crates.io/crates/kigi-env)
|
||||||
- [kigi-fast-worktree 0.1.16](https://crates.io/crates/kigi-fast-worktree)
|
- [kigi-fast-worktree 0.1.19](https://crates.io/crates/kigi-fast-worktree)
|
||||||
- [kigi-file-utils 0.1.16](https://crates.io/crates/kigi-file-utils)
|
- [kigi-file-utils 0.1.19](https://crates.io/crates/kigi-file-utils)
|
||||||
- [kigi-fsnotify 0.1.16](https://crates.io/crates/kigi-fsnotify)
|
- [kigi-fsnotify 0.1.19](https://crates.io/crates/kigi-fsnotify)
|
||||||
- [kigi-gix-status 0.1.16](https://crates.io/crates/kigi-gix-status)
|
- [kigi-gix-status 0.1.19](https://crates.io/crates/kigi-gix-status)
|
||||||
- [kigi-hooks 0.1.16](https://crates.io/crates/kigi-hooks)
|
- [kigi-hooks 0.1.19](https://crates.io/crates/kigi-hooks)
|
||||||
- [kigi-hooks-plugins-types 0.1.16](https://crates.io/crates/kigi-hooks-plugins-types)
|
- [kigi-hooks-plugins-types 0.1.19](https://crates.io/crates/kigi-hooks-plugins-types)
|
||||||
- [kigi-http 0.1.16](https://crates.io/crates/kigi-http)
|
- [kigi-http 0.1.19](https://crates.io/crates/kigi-http)
|
||||||
- [kigi-hunk-tracker 0.1.16](https://crates.io/crates/kigi-hunk-tracker)
|
- [kigi-hunk-tracker 0.1.19](https://crates.io/crates/kigi-hunk-tracker)
|
||||||
- [kigi-log 0.1.16](https://crates.io/crates/kigi-log)
|
- [kigi-log 0.1.19](https://crates.io/crates/kigi-log)
|
||||||
- [kigi-markdown 0.1.16](https://crates.io/crates/kigi-markdown)
|
- [kigi-markdown 0.1.19](https://crates.io/crates/kigi-markdown)
|
||||||
- [kigi-markdown-core 0.1.16](https://crates.io/crates/kigi-markdown-core)
|
- [kigi-markdown-core 0.1.19](https://crates.io/crates/kigi-markdown-core)
|
||||||
- [kigi-mcp 0.1.16](https://crates.io/crates/kigi-mcp)
|
- [kigi-mcp 0.1.19](https://crates.io/crates/kigi-mcp)
|
||||||
- [kigi-memory 0.1.16](https://crates.io/crates/kigi-memory)
|
- [kigi-memory 0.1.19](https://crates.io/crates/kigi-memory)
|
||||||
- [kigi-models 0.1.16](https://crates.io/crates/kigi-models)
|
- [kigi-models 0.1.19](https://crates.io/crates/kigi-models)
|
||||||
- [kigi-pager-minimal 0.1.16](https://crates.io/crates/kigi-pager-minimal)
|
- [kigi-pager-minimal 0.1.19](https://crates.io/crates/kigi-pager-minimal)
|
||||||
- [kigi-pager-pty-harness 0.1.16](https://crates.io/crates/kigi-pager-pty-harness)
|
- [kigi-pager-pty-harness 0.1.19](https://crates.io/crates/kigi-pager-pty-harness)
|
||||||
- [kigi-pager-render 0.1.16](https://crates.io/crates/kigi-pager-render)
|
- [kigi-pager-render 0.1.19](https://crates.io/crates/kigi-pager-render)
|
||||||
- [kigi-paths 0.1.16](https://crates.io/crates/kigi-paths)
|
- [kigi-paths 0.1.19](https://crates.io/crates/kigi-paths)
|
||||||
- [kigi-prompt-queue 0.1.16](https://crates.io/crates/kigi-prompt-queue)
|
- [kigi-prompt-queue 0.1.19](https://crates.io/crates/kigi-prompt-queue)
|
||||||
- [kigi-ratatui-inline 0.1.16](https://crates.io/crates/kigi-ratatui-inline)
|
- [kigi-ratatui-inline 0.1.19](https://crates.io/crates/kigi-ratatui-inline)
|
||||||
- [kigi-ratatui-textarea 0.1.16](https://crates.io/crates/kigi-ratatui-textarea)
|
- [kigi-ratatui-textarea 0.1.19](https://crates.io/crates/kigi-ratatui-textarea)
|
||||||
- [kigi-sampler 0.1.16](https://crates.io/crates/kigi-sampler)
|
- [kigi-sampler 0.1.19](https://crates.io/crates/kigi-sampler)
|
||||||
- [kigi-sampling-types 0.1.16](https://crates.io/crates/kigi-sampling-types)
|
- [kigi-sampling-types 0.1.19](https://crates.io/crates/kigi-sampling-types)
|
||||||
- [kigi-sandbox 0.1.16](https://crates.io/crates/kigi-sandbox)
|
- [kigi-sandbox 0.1.19](https://crates.io/crates/kigi-sandbox)
|
||||||
- [kigi-secrets 0.1.16](https://crates.io/crates/kigi-secrets)
|
- [kigi-secrets 0.1.19](https://crates.io/crates/kigi-secrets)
|
||||||
- [kigi-shared 0.0.0](https://crates.io/crates/kigi-shared)
|
- [kigi-shared 0.0.0](https://crates.io/crates/kigi-shared)
|
||||||
- [kigi-shell 0.1.16](https://crates.io/crates/kigi-shell)
|
- [kigi-shell 0.1.19](https://crates.io/crates/kigi-shell)
|
||||||
- [kigi-shell-base 0.1.16](https://crates.io/crates/kigi-shell-base)
|
- [kigi-shell-base 0.1.19](https://crates.io/crates/kigi-shell-base)
|
||||||
- [kigi-sqlite-journal 0.1.16](https://crates.io/crates/kigi-sqlite-journal)
|
- [kigi-sqlite-journal 0.1.19](https://crates.io/crates/kigi-sqlite-journal)
|
||||||
- [kigi-subagent-resolution 0.1.16](https://crates.io/crates/kigi-subagent-resolution)
|
- [kigi-subagent-resolution 0.1.19](https://crates.io/crates/kigi-subagent-resolution)
|
||||||
- [kigi-system-power 0.1.16](https://crates.io/crates/kigi-system-power)
|
- [kigi-system-power 0.1.19](https://crates.io/crates/kigi-system-power)
|
||||||
- [kigi-test-support 0.1.16](https://crates.io/crates/kigi-test-support)
|
- [kigi-test-support 0.1.19](https://crates.io/crates/kigi-test-support)
|
||||||
- [kigi-token-estimation 0.1.16](https://crates.io/crates/kigi-token-estimation)
|
- [kigi-token-estimation 0.1.19](https://crates.io/crates/kigi-token-estimation)
|
||||||
- [kigi-tools 0.1.16](https://crates.io/crates/kigi-tools)
|
- [kigi-tools 0.1.19](https://crates.io/crates/kigi-tools)
|
||||||
- [kigi-tools-api 0.1.16](https://crates.io/crates/kigi-tools-api)
|
- [kigi-tools-api 0.1.19](https://crates.io/crates/kigi-tools-api)
|
||||||
- [kigi-tracing-macros 0.1.16](https://crates.io/crates/kigi-tracing-macros)
|
- [kigi-tracing-macros 0.1.19](https://crates.io/crates/kigi-tracing-macros)
|
||||||
- [kigi-tty-utils 0.1.16](https://crates.io/crates/kigi-tty-utils)
|
- [kigi-tty-utils 0.1.19](https://crates.io/crates/kigi-tty-utils)
|
||||||
- [kigi-tui 0.1.16](https://crates.io/crates/kigi-tui)
|
- [kigi-tui 0.1.19](https://crates.io/crates/kigi-tui)
|
||||||
- [kigi-update 0.1.16](https://crates.io/crates/kigi-update)
|
- [kigi-update 0.1.19](https://crates.io/crates/kigi-update)
|
||||||
- [kigi-version 0.1.16](https://crates.io/crates/kigi-version)
|
- [kigi-version 0.1.19](https://crates.io/crates/kigi-version)
|
||||||
- [kigi-workspace 0.1.16](https://crates.io/crates/kigi-workspace)
|
- [kigi-workspace 0.1.19](https://crates.io/crates/kigi-workspace)
|
||||||
- [kigi-workspace-types 0.1.16](https://crates.io/crates/kigi-workspace-types)
|
- [kigi-workspace-types 0.1.19](https://crates.io/crates/kigi-workspace-types)
|
||||||
- [ptyctl 0.1.16](https://crates.io/crates/ptyctl)
|
- [ptyctl 0.1.19](https://crates.io/crates/ptyctl)
|
||||||
- [ptyctl-cli 0.1.16](https://crates.io/crates/ptyctl-cli)
|
- [ptyctl-cli 0.1.19](https://crates.io/crates/ptyctl-cli)
|
||||||
- [kigi-compaction 0.1.16](https://crates.io/crates/kigi-compaction)
|
- [kigi-compaction 0.1.19](https://crates.io/crates/kigi-compaction)
|
||||||
- [kigi-interjection-core 0.1.16](https://crates.io/crates/kigi-interjection-core)
|
- [kigi-interjection-core 0.1.19](https://crates.io/crates/kigi-interjection-core)
|
||||||
- [kigi-test-utils 0.1.16](https://crates.io/crates/kigi-test-utils)
|
- [kigi-test-utils 0.1.19](https://crates.io/crates/kigi-test-utils)
|
||||||
- [kigi-tool-protocol 0.1.16](https://crates.io/crates/kigi-tool-protocol)
|
- [kigi-tool-protocol 0.1.19](https://crates.io/crates/kigi-tool-protocol)
|
||||||
- [kigi-tool-runtime 0.1.16](https://crates.io/crates/kigi-tool-runtime)
|
- [kigi-tool-runtime 0.1.19](https://crates.io/crates/kigi-tool-runtime)
|
||||||
- [kigi-tool-types 0.1.16](https://crates.io/crates/kigi-tool-types)
|
- [kigi-tool-types 0.1.19](https://crates.io/crates/kigi-tool-types)
|
||||||
- [agent-client-protocol 0.10.4](https://github.com/agentclientprotocol/rust-sdk)
|
- [agent-client-protocol 0.10.4](https://github.com/agentclientprotocol/rust-sdk)
|
||||||
- [allocator-api2 0.2.21](https://github.com/zakarumych/allocator-api2)
|
- [allocator-api2 0.2.21](https://github.com/zakarumych/allocator-api2)
|
||||||
- [anes 0.1.6](https://github.com/zrzka/anes-rs)
|
- [anes 0.1.6](https://github.com/zrzka/anes-rs)
|
||||||
@@ -16772,7 +16772,7 @@ limitations under the License.
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
- [kigi-mermaid 0.1.16](https://crates.io/crates/kigi-mermaid)
|
- [kigi-mermaid 0.1.19](https://crates.io/crates/kigi-mermaid)
|
||||||
|
|
||||||
```
|
```
|
||||||
Roboto-Regular.ttf
|
Roboto-Regular.ttf
|
||||||
@@ -23473,6 +23473,37 @@ SOFTWARE.
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
|
- [mermaid-to-svg 0.1.0](https://github.com/warpdotdev/mermaid-to-svg)
|
||||||
|
|
||||||
|
```
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2025-2026 Denver Technologies, Inc.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### MIT License
|
||||||
|
|
||||||
|
Applies to:
|
||||||
|
|
||||||
- [rustybuzz 0.20.1](https://github.com/harfbuzz/rustybuzz)
|
- [rustybuzz 0.20.1](https://github.com/harfbuzz/rustybuzz)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -23746,37 +23777,6 @@ THE SOFTWARE.
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
- [mermaid-to-svg 0.1.0](https://github.com/warpdotdev/mermaid-to-svg)
|
|
||||||
|
|
||||||
```
|
|
||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2025-2026 Denver Technologies, Inc.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
### MIT License
|
|
||||||
|
|
||||||
Applies to:
|
|
||||||
|
|
||||||
- [urlencoding 2.1.3](https://github.com/kornelski/rust_urlencoding)
|
- [urlencoding 2.1.3](https://github.com/kornelski/rust_urlencoding)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -23834,6 +23834,8 @@ SOFTWARE.
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
|
- [nucleo-matcher 0.3.1](https://github.com/helix-editor/nucleo)
|
||||||
|
- [nucleo 0.5.0](https://github.com/helix-editor/nucleo)
|
||||||
- [nucleo 0.5.0](https://github.com/helix-editor/nucleo)
|
- [nucleo 0.5.0](https://github.com/helix-editor/nucleo)
|
||||||
- [dtoa-short 0.3.5](https://github.com/upsuper/dtoa-short)
|
- [dtoa-short 0.3.5](https://github.com/upsuper/dtoa-short)
|
||||||
|
|
||||||
@@ -24603,7 +24605,6 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
- [nucleo-matcher 0.3.1](https://github.com/helix-editor/nucleo)
|
|
||||||
- [option-ext 0.2.0](https://github.com/soc/option-ext.git)
|
- [option-ext 0.2.0](https://github.com/soc/option-ext.git)
|
||||||
- [selectors 0.26.0](https://github.com/servo/stylo)
|
- [selectors 0.26.0](https://github.com/servo/stylo)
|
||||||
|
|
||||||
|
|||||||
@@ -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,324 @@
|
|||||||
|
//! User-declared providers: a name, a base URL, and a wire.
|
||||||
|
|
||||||
|
use crate::{ListingDialect, PlatformChatCompat, PlatformKeyHeader, PlatformWireApi};
|
||||||
|
|
||||||
|
const MAX_NAME_LEN: usize = 32;
|
||||||
|
|
||||||
|
/// Wire dialect of a custom provider.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CustomApi {
|
||||||
|
/// Chat Completions with a Bearer key.
|
||||||
|
OpenAi,
|
||||||
|
/// Messages with an `x-api-key` header.
|
||||||
|
Anthropic,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CustomApi {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::OpenAi => "openai",
|
||||||
|
Self::Anthropic => "anthropic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
match s.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"openai" => Some(Self::OpenAi),
|
||||||
|
"anthropic" => Some(Self::Anthropic),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login picker wording.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::OpenAi => "OpenAI compatible",
|
||||||
|
Self::Anthropic => "Anthropic compatible",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wire_api(self) -> PlatformWireApi {
|
||||||
|
match self {
|
||||||
|
Self::OpenAi => PlatformWireApi::ChatCompletions,
|
||||||
|
Self::Anthropic => PlatformWireApi::Messages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn listing(self) -> ListingDialect {
|
||||||
|
match self {
|
||||||
|
Self::OpenAi => ListingDialect::OpenAi,
|
||||||
|
Self::Anthropic => ListingDialect::Anthropic,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key_header(self) -> PlatformKeyHeader {
|
||||||
|
match self {
|
||||||
|
Self::OpenAi => PlatformKeyHeader::Bearer,
|
||||||
|
Self::Anthropic => PlatformKeyHeader::XApiKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chat_compat(self) -> PlatformChatCompat {
|
||||||
|
PlatformChatCompat::Passthrough
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a custom provider definition was refused.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CustomProviderError {
|
||||||
|
Name(String),
|
||||||
|
BaseUrl(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CustomProviderError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Name(reason) => write!(f, "provider name: {reason}"),
|
||||||
|
Self::BaseUrl(reason) => write!(f, "provider base_url: {reason}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CustomProviderError {}
|
||||||
|
|
||||||
|
/// A validated provider definition.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CustomProvider {
|
||||||
|
pub name: String,
|
||||||
|
pub api: CustomApi,
|
||||||
|
/// Without a trailing slash.
|
||||||
|
pub base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CustomProvider {
|
||||||
|
pub fn new(name: &str, api: CustomApi, base_url: &str) -> Result<Self, CustomProviderError> {
|
||||||
|
validate_name(name)?;
|
||||||
|
Ok(Self {
|
||||||
|
name: name.to_owned(),
|
||||||
|
api,
|
||||||
|
base_url: normalize_base_url(base_url)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The listing endpoint, with the dialect's query.
|
||||||
|
pub fn models_url(&self) -> String {
|
||||||
|
match self.api {
|
||||||
|
CustomApi::OpenAi => format!("{}/models", self.base_url),
|
||||||
|
// Anthropic paginates (default 20); 1000 is the documented max.
|
||||||
|
CustomApi::Anthropic => format!("{}/models?limit=1000", self.base_url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Catalog key `{name}/{model_id}`.
|
||||||
|
pub fn managed_model_key(&self, model_id: &str) -> String {
|
||||||
|
format!("{}/{model_id}", self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names become auth.json scopes and catalog key prefixes.
|
||||||
|
pub fn validate_name(name: &str) -> Result<(), CustomProviderError> {
|
||||||
|
let err = |reason: &str| Err(CustomProviderError::Name(reason.to_owned()));
|
||||||
|
if name.is_empty() || name.len() > MAX_NAME_LEN {
|
||||||
|
return err("must be 1 to 32 characters");
|
||||||
|
}
|
||||||
|
let mut chars = name.chars();
|
||||||
|
if !chars
|
||||||
|
.next()
|
||||||
|
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return err("must start with a lowercase letter or digit");
|
||||||
|
}
|
||||||
|
if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
|
||||||
|
return err("may hold only lowercase letters, digits, '-' and '_'");
|
||||||
|
}
|
||||||
|
if crate::PlatformId::parse(name).is_some() {
|
||||||
|
return err("is a built-in platform id");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts `http(s)://host[:port][/path]`; the URL is logged, so no secrets.
|
||||||
|
pub fn normalize_base_url(raw: &str) -> Result<String, CustomProviderError> {
|
||||||
|
let err = |reason: &str| Err(CustomProviderError::BaseUrl(reason.to_owned()));
|
||||||
|
let url = raw.trim().trim_end_matches('/');
|
||||||
|
if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||||
|
return err("must not contain whitespace");
|
||||||
|
}
|
||||||
|
let rest = ["https://", "http://"].iter().find_map(|scheme| {
|
||||||
|
url.get(..scheme.len())
|
||||||
|
.filter(|head| head.eq_ignore_ascii_case(scheme))
|
||||||
|
.map(|_| &url[scheme.len()..])
|
||||||
|
});
|
||||||
|
let Some(rest) = rest else {
|
||||||
|
return err("must start with http:// or https://");
|
||||||
|
};
|
||||||
|
let authority = rest.split('/').next().unwrap_or("");
|
||||||
|
if authority.is_empty() {
|
||||||
|
return err("has no host");
|
||||||
|
}
|
||||||
|
if authority.contains('@') {
|
||||||
|
return err("must not carry credentials");
|
||||||
|
}
|
||||||
|
if url.contains('?') || url.contains('#') {
|
||||||
|
return err("must not carry a query or fragment");
|
||||||
|
}
|
||||||
|
Ok(url.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid provider name derived from a host.
|
||||||
|
pub fn default_name(base_url: &str) -> String {
|
||||||
|
let host = base_url
|
||||||
|
.split("://")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|rest| rest.split('/').next())
|
||||||
|
.and_then(|authority| authority.split(':').next())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
let labels: Vec<&str> = host
|
||||||
|
.split('.')
|
||||||
|
.filter(|label| !label.is_empty() && !matches!(*label, "api" | "www"))
|
||||||
|
.collect();
|
||||||
|
let joined: String = labels
|
||||||
|
.join("-")
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
|
||||||
|
.take(MAX_NAME_LEN)
|
||||||
|
.collect();
|
||||||
|
let name = joined.trim_matches('-').to_owned();
|
||||||
|
if validate_name(&name).is_ok() {
|
||||||
|
name
|
||||||
|
} else {
|
||||||
|
"custom".to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_names_come_from_the_host() {
|
||||||
|
for (url, expected) in [
|
||||||
|
("https://api.together.xyz/v1", "together-xyz"),
|
||||||
|
("https://openrouter.ai/api/v1", "openrouter-ai"),
|
||||||
|
("http://localhost:11434/v1", "localhost"),
|
||||||
|
("http://127.0.0.1:8080/v1", "127-0-0-1"),
|
||||||
|
("https://api.openai.com/v1", "openai-com"),
|
||||||
|
(
|
||||||
|
"https://gateway.internal.example.org/anthropic",
|
||||||
|
"gateway-internal-example-org",
|
||||||
|
),
|
||||||
|
("https://API.Example.COM/v1", "example-com"),
|
||||||
|
("not a url", "custom"),
|
||||||
|
("https://api/v1", "custom"),
|
||||||
|
("https://[::1]:9000/v1", "custom"),
|
||||||
|
] {
|
||||||
|
assert_eq!(default_name(url), expected, "{url}");
|
||||||
|
assert!(validate_name(&default_name(url)).is_ok(), "{url}");
|
||||||
|
}
|
||||||
|
assert!(default_name(&format!("https://{}.example/v1", "a".repeat(60))).len() <= 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_round_trips_and_maps_to_the_wire() {
|
||||||
|
for api in [CustomApi::OpenAi, CustomApi::Anthropic] {
|
||||||
|
assert_eq!(CustomApi::parse(api.as_str()), Some(api));
|
||||||
|
}
|
||||||
|
assert_eq!(CustomApi::parse(" OpenAI "), Some(CustomApi::OpenAi));
|
||||||
|
assert_eq!(CustomApi::parse("responses"), None);
|
||||||
|
assert_eq!(
|
||||||
|
CustomApi::OpenAi.wire_api(),
|
||||||
|
PlatformWireApi::ChatCompletions
|
||||||
|
);
|
||||||
|
assert_eq!(CustomApi::Anthropic.wire_api(), PlatformWireApi::Messages);
|
||||||
|
assert_eq!(CustomApi::OpenAi.key_header(), PlatformKeyHeader::Bearer);
|
||||||
|
assert_eq!(
|
||||||
|
CustomApi::Anthropic.key_header(),
|
||||||
|
PlatformKeyHeader::XApiKey
|
||||||
|
);
|
||||||
|
assert_eq!(CustomApi::OpenAi.listing(), ListingDialect::OpenAi);
|
||||||
|
assert_eq!(CustomApi::Anthropic.listing(), ListingDialect::Anthropic);
|
||||||
|
for api in [CustomApi::OpenAi, CustomApi::Anthropic] {
|
||||||
|
assert_eq!(api.chat_compat(), PlatformChatCompat::Passthrough);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_follow_the_rules() {
|
||||||
|
for ok in ["proxy", "my-proxy_2", "0x", "a"] {
|
||||||
|
assert!(validate_name(ok).is_ok(), "{ok}");
|
||||||
|
}
|
||||||
|
for bad in [
|
||||||
|
"",
|
||||||
|
"Proxy",
|
||||||
|
"pRoxy",
|
||||||
|
"-x",
|
||||||
|
"a/b",
|
||||||
|
"a b",
|
||||||
|
"a:b",
|
||||||
|
"é",
|
||||||
|
&"x".repeat(33),
|
||||||
|
] {
|
||||||
|
assert!(validate_name(bad).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
for id in crate::PlatformId::ALL {
|
||||||
|
assert!(validate_name(id.as_str()).is_err(), "{}", id.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_urls_are_normalized_or_refused() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_base_url(" https://api.example.com/v1/ ").unwrap(),
|
||||||
|
"https://api.example.com/v1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_base_url("HTTP://localhost:11434/v1").unwrap(),
|
||||||
|
"HTTP://localhost:11434/v1"
|
||||||
|
);
|
||||||
|
for bad in [
|
||||||
|
"",
|
||||||
|
"example.com/v1",
|
||||||
|
"ftp://example.com",
|
||||||
|
"https://",
|
||||||
|
"https:///v1",
|
||||||
|
"https://user:pw@example.com/v1",
|
||||||
|
"https://example.com/v1?key=x",
|
||||||
|
"https://example.com/v1#frag",
|
||||||
|
"https://exa mple.com",
|
||||||
|
"https://h\u{0}x",
|
||||||
|
"ht€ps://example.com",
|
||||||
|
"€https://example.com",
|
||||||
|
"https:/€",
|
||||||
|
"http:/€",
|
||||||
|
] {
|
||||||
|
assert!(normalize_base_url(bad).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn listing_urls_and_keys_follow_the_dialect() {
|
||||||
|
let openai =
|
||||||
|
CustomProvider::new("proxy", CustomApi::OpenAi, "https://h.example/v1/").unwrap();
|
||||||
|
assert_eq!(openai.models_url(), "https://h.example/v1/models");
|
||||||
|
assert_eq!(openai.managed_model_key("gpt-x"), "proxy/gpt-x");
|
||||||
|
let anthropic =
|
||||||
|
CustomProvider::new("claude-gw", CustomApi::Anthropic, "https://h.example/v1").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
anthropic.models_url(),
|
||||||
|
"https://h.example/v1/models?limit=1000"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_new_reports_which_field_failed() {
|
||||||
|
assert!(matches!(
|
||||||
|
CustomProvider::new("openai", CustomApi::OpenAi, "https://h.example/v1"),
|
||||||
|
Err(CustomProviderError::Name(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
CustomProvider::new("proxy", CustomApi::OpenAi, "h.example"),
|
||||||
|
Err(CustomProviderError::BaseUrl(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -223,7 +223,7 @@ mod tests {
|
|||||||
"snapshot lost providers: {}",
|
"snapshot lost providers: {}",
|
||||||
catalog.len()
|
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.context, 1_048_576, "k3 context must match the live wire");
|
||||||
assert_eq!(k3.efforts, ["low", "high", "max"]);
|
assert_eq!(k3.efforts, ["low", "high", "max"]);
|
||||||
assert!(k3.reasoning);
|
assert!(k3.reasoning);
|
||||||
@@ -232,6 +232,21 @@ mod tests {
|
|||||||
assert_eq!(opus.efforts, ["low", "medium", "high", "xhigh", "max"]);
|
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.
|
/// Wire-served fields are never overwritten; absent fields are filled.
|
||||||
#[test]
|
#[test]
|
||||||
fn enrich_fills_gaps_and_never_overwrites_wire() {
|
fn enrich_fills_gaps_and_never_overwrites_wire() {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub mod custom;
|
||||||
pub mod enrichment;
|
pub mod enrichment;
|
||||||
|
|
||||||
// Platform registry (PRD F2)
|
// Platform registry (PRD F2)
|
||||||
@@ -400,7 +401,7 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
vendor: "Kimi",
|
vendor: "Kimi",
|
||||||
console_host: None,
|
console_host: None,
|
||||||
login_label: None,
|
login_label: None,
|
||||||
models_dev_id: Some("kimi-for-coding"),
|
models_dev_id: Some("kimi-code-plan-cn"),
|
||||||
wire_serves_metadata: true,
|
wire_serves_metadata: true,
|
||||||
wire_api: PlatformWireApi::ChatCompletions,
|
wire_api: PlatformWireApi::ChatCompletions,
|
||||||
listing: ListingDialect::OpenAi,
|
listing: ListingDialect::OpenAi,
|
||||||
@@ -987,7 +988,7 @@ const KIMI_CODING_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
vendor: "Kimi",
|
vendor: "Kimi",
|
||||||
console_host: Some("www.kimi.com"),
|
console_host: Some("www.kimi.com"),
|
||||||
login_label: Some("Kimi For Coding (API key)"),
|
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_serves_metadata: true,
|
||||||
wire_api: PlatformWireApi::ChatCompletions,
|
wire_api: PlatformWireApi::ChatCompletions,
|
||||||
listing: ListingDialect::OpenAi,
|
listing: ListingDialect::OpenAi,
|
||||||
@@ -1285,8 +1286,8 @@ const OPENAI_CODEX_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
strip_listing_id_prefix: None,
|
strip_listing_id_prefix: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The platform registry. Platforms are compiled-in spec rows; there is no
|
/// The platform registry. Platforms are compiled-in spec rows; user-declared
|
||||||
/// dynamic provider registration (PRD F2).
|
/// providers live in [`custom`], never here.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
pub enum PlatformId {
|
pub enum PlatformId {
|
||||||
/// Kimi Code subscription (OAuth bearer from the F1 device flow).
|
/// 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`
|
/// The compiled-in catalog for a platform that serves NO live `/models`
|
||||||
/// listing (openai-codex), or `None` when the catalog comes from the wire.
|
/// 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
|
/// openai-codex's 8 models are HARDCODED (read from the Codex backend
|
||||||
/// `models_cache.json`, the `visibility=="list"` AND `supported_in_api==true`
|
/// catalog on 2026-09-29, the `visibility=="list"` AND
|
||||||
/// set) because OpenAI exposes no stable public models endpoint for the
|
/// `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
|
/// ChatGPT Codex backend. Each entry carries context window + per-model
|
||||||
/// selectable reasoning efforts (incl. the codex-only `xhigh`/`max`
|
/// selectable reasoning efforts (incl. the codex-only `xhigh`/`max`
|
||||||
/// tiers), so the fetch path maps them through the SAME
|
/// 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
|
/// The HARDCODED openai-codex catalog: exactly the 8 `visibility=="list"` AND
|
||||||
/// `supported_in_api==true` models from the Codex CLI model cache. The
|
/// `supported_in_api==true` models the Codex backend served on 2026-09-29, in
|
||||||
/// `gpt-5.3-codex-spark` (supported_in_api=false → not served by /responses),
|
/// its own priority order. `gpt-reserve` and `codex-auto-review`
|
||||||
/// `gpt-5.4`, `gpt-5.4-mini`, and `codex-auto-review` (visibility="hide") models
|
/// (visibility="hide") are deliberately EXCLUDED — they are not user-facing
|
||||||
/// are deliberately EXCLUDED — they would list-but-not-work or are not
|
/// (fail-fast: never advertise a model the backend rejects). Several models
|
||||||
/// user-facing (fail-fast: never advertise a model the backend rejects).
|
/// 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> {
|
fn openai_codex_wire_models() -> Vec<WireModel> {
|
||||||
vec![
|
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(
|
codex_wire_model(
|
||||||
"gpt-5.6-sol",
|
"gpt-5.6-sol",
|
||||||
"GPT-5.6-Sol",
|
"GPT-5.6-Sol",
|
||||||
@@ -2653,29 +2680,33 @@ mod tests {
|
|||||||
assert_eq!(c.base_url(), "https://mock.codex/codex");
|
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
|
/// models, keyed by slug, ctx 272000, each exposing its exact supported
|
||||||
/// efforts (incl. the codex-only `xhigh`/`max` tiers). The
|
/// efforts (incl. the codex-only `xhigh`/`max` tiers). The hidden models
|
||||||
/// list-but-broken / hidden models are absent. Every other platform serves
|
/// are absent. Every other platform serves NO hardcoded catalog (its
|
||||||
/// NO hardcoded catalog (its models come from the live wire).
|
/// models come from the live wire).
|
||||||
#[test]
|
#[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
|
let catalog = PlatformId::OpenaiCodex
|
||||||
.hardcoded_catalog()
|
.hardcoded_catalog()
|
||||||
.expect("openai-codex serves a hardcoded catalog");
|
.expect("openai-codex serves a hardcoded catalog");
|
||||||
let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
|
let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ids,
|
ids,
|
||||||
vec!["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"],
|
vec![
|
||||||
"exactly the 4 visibility=list AND supported_in_api=true models"
|
"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.
|
// Excluded: hidden models never appear.
|
||||||
for absent in [
|
for absent in ["gpt-reserve", "codex-auto-review"] {
|
||||||
"gpt-5.3-codex-spark",
|
|
||||||
"gpt-5.4",
|
|
||||||
"gpt-5.4-mini",
|
|
||||||
"codex-auto-review",
|
|
||||||
] {
|
|
||||||
assert!(
|
assert!(
|
||||||
!ids.contains(&absent),
|
!ids.contains(&absent),
|
||||||
"{absent} must be excluded from the hardcoded catalog"
|
"{absent} must be excluded from the hardcoded catalog"
|
||||||
@@ -2700,6 +2731,23 @@ mod tests {
|
|||||||
.valid_efforts
|
.valid_efforts
|
||||||
.clone()
|
.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!(
|
assert_eq!(
|
||||||
efforts("gpt-5.6-sol"),
|
efforts("gpt-5.6-sol"),
|
||||||
["low", "medium", "high", "xhigh", "max"]
|
["low", "medium", "high", "xhigh", "max"]
|
||||||
@@ -2725,6 +2773,10 @@ mod tests {
|
|||||||
.default_effort
|
.default_effort
|
||||||
.clone()
|
.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-sol").as_deref(), Some("low"));
|
||||||
assert_eq!(default("gpt-5.6-terra").as_deref(), Some("medium"));
|
assert_eq!(default("gpt-5.6-terra").as_deref(), Some("medium"));
|
||||||
// Only openai-codex has a hardcoded catalog; every wire platform is None.
|
// Only openai-codex has a hardcoded catalog; every wire platform is None.
|
||||||
|
|||||||
@@ -189,11 +189,6 @@ impl TerminalName {
|
|||||||
| Self::Zed
|
| 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 {
|
impl TerminalContext {
|
||||||
@@ -375,7 +370,6 @@ impl TerminalContext {
|
|||||||
return Some("apple_terminal");
|
return Some("apple_terminal");
|
||||||
}
|
}
|
||||||
if self.is_vte_based() {
|
if self.is_vte_based() {
|
||||||
// WHY: central helper replaces VTE duplication
|
|
||||||
return Some("vte");
|
return Some("vte");
|
||||||
}
|
}
|
||||||
if self.brand == TerminalName::WindowsTerminal {
|
if self.brand == TerminalName::WindowsTerminal {
|
||||||
@@ -429,74 +423,29 @@ impl TerminalContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the running terminal cannot distinguish `Shift+Enter` from
|
/// Whether the running terminal cannot distinguish `Shift+Enter` from
|
||||||
/// bare `Enter` at the byte level, so the UI should advertise
|
/// bare `Enter`, so the UI should advertise `Alt+Enter` (delivered as
|
||||||
/// `Alt+Enter` for newline insertion instead.
|
/// `ESC`+`CR`) for newline insertion instead.
|
||||||
///
|
///
|
||||||
/// Distinguishing `Shift+Enter` requires the Kitty keyboard protocol
|
/// Follows [`Self::kitty_skip_reason`] rather than a second brand list:
|
||||||
/// (KKP) to be negotiated. This returns `true` for the environments
|
/// when the protocol is not negotiated the modifier is lost, and the two
|
||||||
/// where the pager cannot rely on KKP for a usable `Shift+Enter`:
|
/// 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
|
/// KNOWN GAP: WezTerm inside screen or an old tmux gets Alt+Enter as the
|
||||||
/// `VTE_VERSION` is below `8200` (VTE 0.82.0, the first release with
|
/// advice while its own default binding spends that chord on
|
||||||
/// KKP, merged in
|
/// ToggleFullScreen. Neither chord reaches kigi there; the multiplexer
|
||||||
/// [MR !14](https://gitlab.gnome.org/GNOME/vte/-/merge_requests/14)).
|
/// warning carries the real fix, and `\`+Enter still inserts a newline.
|
||||||
/// 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.
|
|
||||||
pub fn shift_enter_unavailable(&self) -> bool {
|
pub fn shift_enter_unavailable(&self) -> bool {
|
||||||
// WHY: central helper + version gating
|
match self.kitty_skip_reason() {
|
||||||
let is_vte = self.is_vte_based();
|
None | Some("apple_terminal") => false,
|
||||||
if is_vte {
|
// A bare ConHost is only optimistically refined to Windows
|
||||||
return match self
|
// Terminal; it still advertises Alt+Enter.
|
||||||
.vte_version
|
Some("windows_terminal") => self.env_brand.is_capability_unclassified(),
|
||||||
.as_deref()
|
Some(_) => true,
|
||||||
.and_then(|v| v.parse::<u32>().ok())
|
|
||||||
{
|
|
||||||
Some(ver) => ver < 8200,
|
|
||||||
// Brand=Vte but no parseable version — conservative: assume old.
|
|
||||||
None => 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.
|
/// 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);
|
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]
|
#[test]
|
||||||
fn otty_is_capability_unclassified_like_unknown() {
|
fn otty_is_capability_unclassified_like_unknown() {
|
||||||
assert!(TerminalName::Otty.is_capability_unclassified());
|
assert!(TerminalName::Otty.is_capability_unclassified());
|
||||||
@@ -1443,7 +1425,7 @@ fn kitty_skip_vscode_over_tmux() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn kitty_skip_vte_version() {
|
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
|
// can false-positive on it. https://gitlab.gnome.org/GNOME/vte/-/issues/2601
|
||||||
let ctx = TerminalContext {
|
let ctx = TerminalContext {
|
||||||
vte_version: Some("7402".to_owned()),
|
vte_version: Some("7402".to_owned()),
|
||||||
@@ -1461,73 +1443,68 @@ fn kitty_skip_vte_brand() {
|
|||||||
assert_eq!(ctx.kitty_skip_reason(), Some("vte"));
|
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
|
// No VTE release carries the Kitty keyboard protocol: at 0.82.0 the word
|
||||||
// Kitty keyboard protocol; earlier versions cannot distinguish
|
// "kitty" appears nowhere in the parser or widget, and
|
||||||
// Shift+Enter from bare Enter, so the UI should advertise Alt+Enter
|
// `_vte_keymap_GDK_Return` encodes only Alt (`ESC CR`), so Shift+Enter is
|
||||||
// for newline insertion instead.
|
// the same bare CR as Enter. The UI advertises Alt+Enter instead.
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shift_enter_unavailable_legacy_vte_version() {
|
fn shift_enter_unavailable_on_every_vte() {
|
||||||
// VTE 0.64.2 (real user report) — well below the KKP cutoff.
|
for version in [Some("6402"), Some("8200"), Some("8401"), None] {
|
||||||
let ctx = TerminalContext {
|
let ctx = TerminalContext {
|
||||||
vte_version: Some("6402".to_owned()),
|
brand: TerminalName::Vte,
|
||||||
..Default::default()
|
env_brand: TerminalName::Vte,
|
||||||
};
|
vte_version: version.map(str::to_owned),
|
||||||
assert!(ctx.shift_enter_unavailable());
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(ctx.kitty_skip_reason(), Some("vte"), "VTE {version:?}");
|
||||||
|
assert!(ctx.shift_enter_unavailable(), "VTE {version:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shift_enter_unavailable_just_below_cutoff() {
|
fn shift_enter_unavailable_follows_every_kitty_skip() {
|
||||||
// VTE 0.81.99 — anything below 8200 must return true.
|
// A skipped protocol means the modifier never arrives, whatever the
|
||||||
let ctx = TerminalContext {
|
// reason — JediTerm, screen and a multiplexer that eats extended keys
|
||||||
vte_version: Some("8199".to_owned()),
|
// included. Apple Terminal and Windows Terminal are the two skips with
|
||||||
..Default::default()
|
// another way in.
|
||||||
};
|
for ctx in [
|
||||||
assert!(ctx.shift_enter_unavailable());
|
TerminalContext {
|
||||||
}
|
brand: TerminalName::JetBrains,
|
||||||
|
env_brand: TerminalName::JetBrains,
|
||||||
#[test]
|
..Default::default()
|
||||||
fn shift_enter_available_modern_vte() {
|
},
|
||||||
// VTE 0.82.0 — first release with KKP.
|
TerminalContext {
|
||||||
let ctx = TerminalContext {
|
brand: TerminalName::Kitty,
|
||||||
vte_version: Some("8200".to_owned()),
|
env_brand: TerminalName::Kitty,
|
||||||
..Default::default()
|
multiplexer: MultiplexerKind::Screen,
|
||||||
};
|
..Default::default()
|
||||||
assert!(!ctx.shift_enter_unavailable());
|
},
|
||||||
}
|
TerminalContext {
|
||||||
|
brand: TerminalName::Kitty,
|
||||||
#[test]
|
env_brand: TerminalName::Kitty,
|
||||||
fn shift_enter_available_future_vte() {
|
multiplexer: MultiplexerKind::Tmux,
|
||||||
// VTE 0.84.1 — well above the cutoff.
|
tmux_version: Some("3.2".to_owned()),
|
||||||
let ctx = TerminalContext {
|
..Default::default()
|
||||||
vte_version: Some("8401".to_owned()),
|
},
|
||||||
..Default::default()
|
// A VTE carrying a recent VTE_VERSION is no different.
|
||||||
};
|
TerminalContext {
|
||||||
assert!(!ctx.shift_enter_unavailable());
|
brand: TerminalName::Vte,
|
||||||
}
|
env_brand: TerminalName::Vte,
|
||||||
|
vte_version: Some("8200".to_owned()),
|
||||||
#[test]
|
multiplexer: MultiplexerKind::Screen,
|
||||||
fn shift_enter_unavailable_vte_brand_no_version() {
|
..Default::default()
|
||||||
// Brand detected as VTE but VTE_VERSION missing — conservative: old.
|
},
|
||||||
let ctx = TerminalContext {
|
] {
|
||||||
brand: TerminalName::Vte,
|
assert!(ctx.kitty_skip_reason().is_some());
|
||||||
vte_version: None,
|
assert!(
|
||||||
..Default::default()
|
ctx.shift_enter_unavailable(),
|
||||||
};
|
"{:?} skips the protocol, so Shift+Enter cannot arrive",
|
||||||
assert!(ctx.shift_enter_unavailable());
|
ctx.brand
|
||||||
}
|
);
|
||||||
|
}
|
||||||
#[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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ struct BlockState {
|
|||||||
args_acc: String,
|
args_acc: String,
|
||||||
thinking_acc: String,
|
thinking_acc: String,
|
||||||
signature: String,
|
signature: String,
|
||||||
|
/// The first `SignatureDelta` replaces a start-seeded signature; later ones append.
|
||||||
|
signature_delta_seen: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[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_reason: Option<StopReason> = None;
|
||||||
let mut final_stop_message: Option<String> = None;
|
let mut final_stop_message: Option<String> = None;
|
||||||
|
|
||||||
// Assistant-response accumulators (built up as ContentBlockStop
|
// Assistant-response accumulators, filled as ContentBlockStop events fire.
|
||||||
// events fire). Reasoning is collected into a synthesized
|
|
||||||
// `rs::ReasoningItem` and emitted as a sibling
|
|
||||||
// `ConversationItem::Reasoning` before the trailing Assistant.
|
|
||||||
let mut assistant_text = String::new();
|
let mut assistant_text = String::new();
|
||||||
let mut assistant_tool_calls: Vec<ToolCall> = Vec::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
|
// Index counters
|
||||||
let mut chunk_index: u64 = 0;
|
let mut chunk_index: u64 = 0;
|
||||||
@@ -185,6 +184,7 @@ pub fn stream_messages<'a>(
|
|||||||
args_acc: String::new(),
|
args_acc: String::new(),
|
||||||
thinking_acc: thinking.clone(),
|
thinking_acc: thinking.clone(),
|
||||||
signature: signature.clone(),
|
signature: signature.clone(),
|
||||||
|
signature_delta_seen: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if !first_token_emitted {
|
if !first_token_emitted {
|
||||||
@@ -205,6 +205,7 @@ pub fn stream_messages<'a>(
|
|||||||
args_acc: String::new(),
|
args_acc: String::new(),
|
||||||
thinking_acc: String::new(),
|
thinking_acc: String::new(),
|
||||||
signature: String::new(),
|
signature: String::new(),
|
||||||
|
signature_delta_seen: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if !first_token_emitted {
|
if !first_token_emitted {
|
||||||
@@ -237,6 +238,7 @@ pub fn stream_messages<'a>(
|
|||||||
args_acc: String::new(),
|
args_acc: String::new(),
|
||||||
thinking_acc: String::new(),
|
thinking_acc: String::new(),
|
||||||
signature: String::new(),
|
signature: String::new(),
|
||||||
|
signature_delta_seen: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -276,7 +278,11 @@ pub fn stream_messages<'a>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
StreamDelta::SignatureDelta { signature } => {
|
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 } => {
|
StreamDelta::TextDelta { text } => {
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
@@ -347,7 +353,7 @@ pub fn stream_messages<'a>(
|
|||||||
} else {
|
} else {
|
||||||
Some(state.signature)
|
Some(state.signature)
|
||||||
};
|
};
|
||||||
assistant_reasoning = Some(rs::ReasoningItem {
|
assistant_reasoning.push(rs::ReasoningItem {
|
||||||
id: String::new(),
|
id: String::new(),
|
||||||
summary,
|
summary,
|
||||||
content: None,
|
content: None,
|
||||||
@@ -513,9 +519,7 @@ pub fn stream_messages<'a>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
let mut items: Vec<ConversationItem> = Vec::new();
|
let mut items: Vec<ConversationItem> = Vec::new();
|
||||||
if let Some(r) = assistant_reasoning {
|
items.extend(assistant_reasoning.into_iter().map(ConversationItem::Reasoning));
|
||||||
items.push(ConversationItem::Reasoning(r));
|
|
||||||
}
|
|
||||||
items.push(assistant_item);
|
items.push(assistant_item);
|
||||||
|
|
||||||
let stream_end = Instant::now();
|
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]
|
#[tokio::test]
|
||||||
async fn tool_use_block_assembles_into_tool_call() {
|
async fn tool_use_block_assembles_into_tool_call() {
|
||||||
let tool_start = MessageStreamEvent::ContentBlockStart {
|
let tool_start = MessageStreamEvent::ContentBlockStart {
|
||||||
|
|||||||
@@ -1156,7 +1156,7 @@ pub const ANTHROPIC_OAUTH_BETA: &str = "claude-code-20250219,oauth-2025-04-20";
|
|||||||
|
|
||||||
/// User-Agent kigi presents on the Claude-Code OAuth path (mirrors the
|
/// User-Agent kigi presents on the Claude-Code OAuth path (mirrors the
|
||||||
/// official Claude Code CLI). OAuth-gated: unrelated to the default kigi UA.
|
/// 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
|
/// System-prompt prefix REQUIRED on the Claude-Code OAuth Messages path: the
|
||||||
/// OAuth token is Claude-Code-scoped, so Anthropic rejects the request unless
|
/// OAuth token is Claude-Code-scoped, so Anthropic rejects the request unless
|
||||||
@@ -1202,7 +1202,7 @@ pub const CODEX_OPENAI_BETA: &str = "responses=experimental";
|
|||||||
/// openai-codex-gated). The Codex backend does not strictly validate the UA
|
/// 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
|
/// string (Pi ships its own and it works), so this is a stable best-effort
|
||||||
/// identity, not a pinned build.
|
/// 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.
|
/// JWT payload claim namespace carrying the ChatGPT account id.
|
||||||
const CODEX_JWT_AUTH_CLAIM: &str = "https://api.openai.com/auth";
|
const CODEX_JWT_AUTH_CLAIM: &str = "https://api.openai.com/auth";
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ pub struct BuiltAuthMethods {
|
|||||||
/// `PlatformId::ALL` order — interactive logins after `kimi-code`
|
/// `PlatformId::ALL` order — interactive logins after `kimi-code`
|
||||||
/// 5. every API-key registry platform, in `PlatformId::ALL` order
|
/// 5. every API-key registry platform, in `PlatformId::ALL` order
|
||||||
/// (`moonshot-cn`, `moonshot-ai`, …), always advertised
|
/// (`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
|
/// The platform methods are for the INTERACTIVE login picker only: they come
|
||||||
/// after `kimi-code` so they can never become `auth_methods.first()` (the
|
/// 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.push(platform_auth_method(platform));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
methods.extend(crate::agent::custom_providers::picker_methods());
|
||||||
|
|
||||||
BuiltAuthMethods {
|
BuiltAuthMethods {
|
||||||
methods,
|
methods,
|
||||||
@@ -189,6 +191,8 @@ pub enum AuthMethodKind {
|
|||||||
KimiCode,
|
KimiCode,
|
||||||
/// Registry API-key platform login (method id = the platform id).
|
/// Registry API-key platform login (method id = the platform id).
|
||||||
ApiKeyPlatform(kigi_models::PlatformId),
|
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,
|
/// Generic device-code OAuth platform login (method id = the platform id,
|
||||||
/// e.g. `xai-grok`). Interactive, like Kimi Code.
|
/// e.g. `xai-grok`). Interactive, like Kimi Code.
|
||||||
OAuthPlatform(kigi_models::PlatformId),
|
OAuthPlatform(kigi_models::PlatformId),
|
||||||
@@ -201,6 +205,9 @@ impl AuthMethodKind {
|
|||||||
XAI_API_KEY_METHOD_ID => Self::XaiApiKey,
|
XAI_API_KEY_METHOD_ID => Self::XaiApiKey,
|
||||||
CACHED_TOKEN_AUTH_METHOD_ID => Self::CachedToken,
|
CACHED_TOKEN_AUTH_METHOD_ID => Self::CachedToken,
|
||||||
KIMI_CODE_METHOD_ID => Self::KimiCode,
|
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) {
|
other => match kigi_models::PlatformId::parse(other) {
|
||||||
Some(p) if p.oauth().is_some() => Self::OAuthPlatform(p),
|
Some(p) if p.oauth().is_some() => Self::OAuthPlatform(p),
|
||||||
Some(p) if !p.uses_oauth() => Self::ApiKeyPlatform(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
|
/// The registry platform methods qualify — they validate a configured
|
||||||
/// platform key and then behave exactly like an external-API-key session.
|
/// platform key and then behave exactly like an external-API-key session.
|
||||||
pub fn is_api_key(self) -> bool {
|
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).
|
/// `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.
|
/// Classifier matrix for all auth method variants.
|
||||||
#[test]
|
#[test]
|
||||||
fn auth_method_kind_classifier_matrix() {
|
fn auth_method_kind_classifier_matrix() {
|
||||||
@@ -896,7 +923,9 @@ mod tests {
|
|||||||
"xiaomi",
|
"xiaomi",
|
||||||
"xiaomi-token-plan-cn",
|
"xiaomi-token-plan-cn",
|
||||||
"minimax",
|
"minimax",
|
||||||
"minimax-cn"
|
"minimax-cn",
|
||||||
|
"custom-openai",
|
||||||
|
"custom-anthropic"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
|
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
|
||||||
@@ -948,7 +977,9 @@ mod tests {
|
|||||||
"xiaomi",
|
"xiaomi",
|
||||||
"xiaomi-token-plan-cn",
|
"xiaomi-token-plan-cn",
|
||||||
"minimax",
|
"minimax",
|
||||||
"minimax-cn"
|
"minimax-cn",
|
||||||
|
"custom-openai",
|
||||||
|
"custom-anthropic"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||||
@@ -993,7 +1024,9 @@ mod tests {
|
|||||||
"xiaomi",
|
"xiaomi",
|
||||||
"xiaomi-token-plan-cn",
|
"xiaomi-token-plan-cn",
|
||||||
"minimax",
|
"minimax",
|
||||||
"minimax-cn"
|
"minimax-cn",
|
||||||
|
"custom-openai",
|
||||||
|
"custom-anthropic"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||||
@@ -1041,7 +1074,9 @@ mod tests {
|
|||||||
"xiaomi",
|
"xiaomi",
|
||||||
"xiaomi-token-plan-cn",
|
"xiaomi-token-plan-cn",
|
||||||
"minimax",
|
"minimax",
|
||||||
"minimax-cn"
|
"minimax-cn",
|
||||||
|
"custom-openai",
|
||||||
|
"custom-anthropic"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(default_id(&built), None);
|
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`.
|
/// Read by `load_gcs_service_account_key_sync()`. Declared for `serde_ignored`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub gcs_service_account_key: Option<String>,
|
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
|
/// 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.
|
/// 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") {
|
if let Some(endpoints) = config.get("endpoints") {
|
||||||
crate::config::deep_merge_toml(&mut base, 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
|
/// The subscription proxy base URL through which all auxiliary services (and
|
||||||
/// OAuth/session inference) resolve: explicit `coding_api_base_url`, else
|
/// 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"),
|
managed_config_url: env_string("KIGI_MANAGED_CONFIG_URL"),
|
||||||
management_api_key: None,
|
management_api_key: None,
|
||||||
gcs_service_account_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
|
/// registry platform, so a typo like `moonshot_cn` fails loudly instead of
|
||||||
/// silently never matching. Key values are not logged.
|
/// silently never matching. Key values are not logged.
|
||||||
pub fn warn_unknown_platforms(&self) {
|
pub fn warn_unknown_platforms(&self) {
|
||||||
for id in self.entries.keys() {
|
super::custom_providers::warn_invalid_declarations(self);
|
||||||
if kigi_models::PlatformId::parse(id).is_none() {
|
for (id, entry) in &self.entries {
|
||||||
|
if kigi_models::PlatformId::parse(id).is_none()
|
||||||
|
&& !super::custom_providers::is_declared(entry)
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
platform = %id,
|
platform = %id,
|
||||||
known = ?kigi_models::PlatformId::ALL
|
known = ?kigi_models::PlatformId::ALL
|
||||||
@@ -793,6 +806,12 @@ pub struct PlatformCredentialConfig {
|
|||||||
/// API key for this platform. NEVER logged; never re-serialized.
|
/// API key for this platform. NEVER logged; never re-serialized.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
|
/// With `api`, declares a custom provider outside the registry.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
/// `"openai"` or `"anthropic"`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the API key for an API-key registry platform with injected env
|
/// Resolve the API key for an API-key registry platform with injected env
|
||||||
@@ -1582,6 +1601,7 @@ impl Config {
|
|||||||
config.config_models = config_models;
|
config.config_models = config_models;
|
||||||
config.model_override_warnings = model_override_warnings;
|
config.model_override_warnings = model_override_warnings;
|
||||||
config.platforms.warn_unknown_platforms();
|
config.platforms.warn_unknown_platforms();
|
||||||
|
config.endpoints.custom_provider_bases = super::custom_providers::bases(&config.platforms);
|
||||||
if config.client_version.is_none() {
|
if config.client_version.is_none() {
|
||||||
config.client_version = Self::default().client_version;
|
config.client_version = Self::default().client_version;
|
||||||
}
|
}
|
||||||
@@ -2569,6 +2589,13 @@ pub(crate) fn resolve_model_list(
|
|||||||
tracing::debug!(count = defaults.len(), "loaded default models");
|
tracing::debug!(count = defaults.len(), "loaded default models");
|
||||||
resolved.extend(defaults);
|
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 {
|
if let Some(mut prefetched) = prefetched {
|
||||||
tracing::debug!(count = prefetched.len(), "loaded prefetched models");
|
tracing::debug!(count = prefetched.len(), "loaded prefetched models");
|
||||||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||||||
@@ -2676,6 +2703,7 @@ pub(crate) fn resolve_model_list(
|
|||||||
apply_global_extra_headers(&mut resolved, &cfg.models);
|
apply_global_extra_headers(&mut resolved, &cfg.models);
|
||||||
apply_global_scalar_defaults(&mut resolved, &cfg.models);
|
apply_global_scalar_defaults(&mut resolved, &cfg.models);
|
||||||
apply_platform_credentials(&mut resolved, &cfg.platforms, platform_keys);
|
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() {
|
for entry in resolved.values_mut() {
|
||||||
entry.info.derive_reasoning_effort_fields();
|
entry.info.derive_reasoning_effort_fields();
|
||||||
}
|
}
|
||||||
@@ -3932,6 +3960,17 @@ pub fn resolve_model_auth_facts(model_id: &str) -> ModelAuthFacts {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
/// Auth facts of a held live-catalog entry.
|
||||||
|
pub(crate) fn auth_facts_of(entry: &ModelEntry) -> ModelAuthFacts {
|
||||||
|
ModelAuthFacts {
|
||||||
|
byok: if entry.has_own_credentials() {
|
||||||
|
ModelByok::Byok
|
||||||
|
} else {
|
||||||
|
ModelByok::NotByok
|
||||||
|
},
|
||||||
|
auth_scheme: entry.info().auth_scheme,
|
||||||
|
}
|
||||||
|
}
|
||||||
fn byok_from_lookup(lookup: &ModelLookup) -> ModelByok {
|
fn byok_from_lookup(lookup: &ModelLookup) -> ModelByok {
|
||||||
match lookup {
|
match lookup {
|
||||||
ModelLookup::ConfigUnavailable => ModelByok::Unknown,
|
ModelLookup::ConfigUnavailable => ModelByok::Unknown,
|
||||||
@@ -5246,6 +5285,17 @@ reasoning_effort = "low"
|
|||||||
auth_scheme: Default::default(),
|
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]
|
#[test]
|
||||||
fn x_api_key_auth_scheme_flows_from_config_to_sampler() {
|
fn x_api_key_auth_scheme_flows_from_config_to_sampler() {
|
||||||
let mut model = test_model_entry(
|
let mut model = test_model_entry(
|
||||||
@@ -9673,6 +9723,31 @@ default = "kigi-4.5"
|
|||||||
"non-BYOK config overlay must preserve bundled supported_in_api=false"
|
"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
|
/// PRD F2: a `[platforms.<id>].api_key` from config.toml is stamped onto
|
||||||
/// that platform's catalog entries (in-memory only), making them usable
|
/// that platform's catalog entries (in-memory only), making them usable
|
||||||
/// and API-key-visible — and only onto that platform.
|
/// and API-key-visible — and only onto that platform.
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
//! Listing fetch and key stamping for custom providers.
|
||||||
|
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use kigi_models::WireModel;
|
||||||
|
use kigi_models::custom::{CustomApi, CustomProvider};
|
||||||
|
|
||||||
|
use super::CredentialedProvider;
|
||||||
|
use crate::agent::config::{ModelEntry, ModelEntryConfig};
|
||||||
|
use crate::agent::models_fetch::{BackendError, wire_model_to_entry};
|
||||||
|
|
||||||
|
/// `GET {base}/models` for one provider; every model is keyed `{name}/{id}`.
|
||||||
|
pub(crate) fn fetch_models_blocking(
|
||||||
|
credentialed: &CredentialedProvider,
|
||||||
|
) -> Result<Vec<ModelEntryConfig>, BackendError> {
|
||||||
|
let provider = &credentialed.provider;
|
||||||
|
let url = provider.models_url();
|
||||||
|
tracing::info!(provider = %provider.name, api = provider.api.as_str(), %url, "fetching custom provider models");
|
||||||
|
let client = crate::http::shared_blocking_client();
|
||||||
|
let request = match provider.api {
|
||||||
|
CustomApi::OpenAi => client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", credentialed.key())),
|
||||||
|
CustomApi::Anthropic => client
|
||||||
|
.get(&url)
|
||||||
|
.header("x-api-key", credentialed.key())
|
||||||
|
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
|
||||||
|
};
|
||||||
|
let response = request.send()?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let body = response.text().unwrap_or_default();
|
||||||
|
return Err(BackendError::RequestFailed { status, body });
|
||||||
|
}
|
||||||
|
let body = response.text()?;
|
||||||
|
let listing = match provider.api {
|
||||||
|
CustomApi::OpenAi => kigi_models::parse_openai_listing(&body),
|
||||||
|
CustomApi::Anthropic => kigi_models::parse_anthropic_listing(&body),
|
||||||
|
};
|
||||||
|
let wire = listing.map_err(|e| BackendError::RequestFailed {
|
||||||
|
status: 200,
|
||||||
|
body: format!("{} listing parse failed: {e}", provider.api.as_str()),
|
||||||
|
})?;
|
||||||
|
if wire.is_empty() {
|
||||||
|
tracing::warn!(provider = %provider.name, "custom provider listed no models");
|
||||||
|
}
|
||||||
|
Ok(wire.into_iter().map(|w| entry(provider, w)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(provider: &CustomProvider, wire: WireModel) -> ModelEntryConfig {
|
||||||
|
let mut entry = wire_model_to_entry(
|
||||||
|
provider.managed_model_key(&wire.id),
|
||||||
|
provider.api.wire_api(),
|
||||||
|
provider.api.key_header(),
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
wire,
|
||||||
|
&provider.base_url,
|
||||||
|
);
|
||||||
|
entry.description = Some(provider.name.clone());
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops fetched entries of a provider that is gone or keyless.
|
||||||
|
pub(crate) fn drop_orphans(
|
||||||
|
prefetched: IndexMap<String, ModelEntry>,
|
||||||
|
keyed: &[CredentialedProvider],
|
||||||
|
) -> IndexMap<String, ModelEntry> {
|
||||||
|
prefetched
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(key, entry)| {
|
||||||
|
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
|
||||||
|
let Some((prefix, _)) = id.split_once('/') else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let known = kigi_models::PlatformId::parse(prefix).is_some()
|
||||||
|
|| keyed.iter().any(|k| k.provider.name == prefix);
|
||||||
|
if !known {
|
||||||
|
tracing::warn!(model_key = %key, "dropping a fetched entry of an unknown or keyless provider");
|
||||||
|
}
|
||||||
|
known
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamps each entry with its provider's key, in memory only.
|
||||||
|
pub(crate) fn stamp_credentials(
|
||||||
|
resolved: &mut IndexMap<String, ModelEntry>,
|
||||||
|
keyed: &[CredentialedProvider],
|
||||||
|
) {
|
||||||
|
for (key, entry) in resolved.iter_mut() {
|
||||||
|
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
|
||||||
|
let Some((name, _)) = id.split_once('/') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(owner) = keyed.iter().find(|k| k.provider.name == name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if entry.api_key.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !crate::util::matches_trusted_base_url(&entry.info.base_url, &owner.provider.base_url) {
|
||||||
|
tracing::warn!(
|
||||||
|
model_key = %key, provider = %name,
|
||||||
|
"entry points away from its custom provider; key not stamped"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.api_key = Some(owner.key().to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::agent::models::{ModelFetchAuth, PlatformApiKeys};
|
||||||
|
use crate::agent::models_fetch::fetch_models_blocking as fetch_all;
|
||||||
|
use wiremock::matchers::{header, method, path, query_param};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
|
fn credentialed(name: &str, api: CustomApi, base: &str, key: &str) -> CredentialedProvider {
|
||||||
|
CredentialedProvider::for_test(CustomProvider::new(name, api, base).unwrap(), key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_on_thread(
|
||||||
|
c: CredentialedProvider,
|
||||||
|
) -> Result<Vec<ModelEntryConfig>, BackendError> {
|
||||||
|
tokio::task::spawn_blocking(move || fetch_models_blocking(&c))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn openai_listing_becomes_chat_completions_entries() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/v1/models"))
|
||||||
|
.and(header("authorization", "Bearer sk-proxy"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||||
|
"object": "list",
|
||||||
|
"data": [{"id": "m1", "context_length": 32000}, {"id": "m2"}]
|
||||||
|
})))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let base = format!("{}/v1/", server.uri());
|
||||||
|
|
||||||
|
let models = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "sk-proxy"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ids: Vec<_> = models.iter().map(|m| m.id.as_deref().unwrap()).collect();
|
||||||
|
assert_eq!(ids, ["proxy/m1", "proxy/m2"]);
|
||||||
|
assert_eq!(models[0].model, "m1");
|
||||||
|
assert_eq!(models[0].context_window.get(), 32_000);
|
||||||
|
assert_eq!(
|
||||||
|
models[1].context_window.get(),
|
||||||
|
crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW
|
||||||
|
);
|
||||||
|
assert_eq!(models[0].base_url, format!("{}/v1", server.uri()));
|
||||||
|
assert_eq!(
|
||||||
|
models[0].api_backend,
|
||||||
|
crate::sampling::ApiBackend::ChatCompletions
|
||||||
|
);
|
||||||
|
assert_eq!(models[0].auth_scheme, None);
|
||||||
|
assert_eq!(models[0].description.as_deref(), Some("proxy"));
|
||||||
|
assert!(models[0].api_key.is_none() && models[0].env_key.is_none());
|
||||||
|
assert!(models[0].supported_in_api);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn anthropic_listing_becomes_messages_entries() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/v1/models"))
|
||||||
|
.and(query_param("limit", "1000"))
|
||||||
|
.and(header("x-api-key", "sk-gw"))
|
||||||
|
.and(header(
|
||||||
|
"anthropic-version",
|
||||||
|
kigi_sampling_types::ANTHROPIC_VERSION,
|
||||||
|
))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||||
|
"data": [{
|
||||||
|
"id": "claude-x", "display_name": "Claude X",
|
||||||
|
"max_input_tokens": 200000, "max_tokens": 64000
|
||||||
|
}],
|
||||||
|
"has_more": false
|
||||||
|
})))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let base = format!("{}/v1", server.uri());
|
||||||
|
|
||||||
|
let models = fetch_on_thread(credentialed("gw", CustomApi::Anthropic, &base, "sk-gw"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(models.len(), 1);
|
||||||
|
let m = &models[0];
|
||||||
|
assert_eq!(m.id.as_deref(), Some("gw/claude-x"));
|
||||||
|
assert_eq!(m.name.as_deref(), Some("Claude X"));
|
||||||
|
assert_eq!(m.context_window.get(), 200_000);
|
||||||
|
assert_eq!(m.max_completion_tokens, Some(64_000));
|
||||||
|
assert_eq!(m.api_backend, crate::sampling::ApiBackend::Messages);
|
||||||
|
assert_eq!(m.auth_scheme, Some(kigi_sampler::AuthScheme::XApiKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn listing_failures_surface_status_and_parse_errors() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(path("/v1/models"))
|
||||||
|
.respond_with(ResponseTemplate::new(401).set_body_string("nope"))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let base = format!("{}/v1", server.uri());
|
||||||
|
let err = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "bad"))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, BackendError::RequestFailed { status: 401, .. }),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(path("/v1/models"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_string("not json"))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let base = format!("{}/v1", server.uri());
|
||||||
|
let err = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "k"))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("openai listing parse failed"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn custom_providers_alone_satisfy_the_platform_fetch() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(path("/v1/models"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||||
|
"data": [{"id": "m1"}]
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let base = format!("{}/v1", server.uri());
|
||||||
|
let keys = PlatformApiKeys::test_custom(vec![credentialed(
|
||||||
|
"proxy",
|
||||||
|
CustomApi::OpenAi,
|
||||||
|
&base,
|
||||||
|
"sk-origin-secret",
|
||||||
|
)]);
|
||||||
|
let endpoints = crate::agent::config::EndpointsConfig::default();
|
||||||
|
|
||||||
|
let origin = crate::agent::models_fetch::models_fetch_origin(
|
||||||
|
&endpoints,
|
||||||
|
ModelFetchAuth::Platforms,
|
||||||
|
false,
|
||||||
|
&Default::default(),
|
||||||
|
&keys,
|
||||||
|
);
|
||||||
|
assert!(origin.contains(&format!("proxy={base}/models")), "{origin}");
|
||||||
|
assert!(
|
||||||
|
!origin.contains("sk"),
|
||||||
|
"the origin must never carry a key: {origin}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
fetch_all(
|
||||||
|
&endpoints,
|
||||||
|
None,
|
||||||
|
&Default::default(),
|
||||||
|
ModelFetchAuth::Platforms,
|
||||||
|
&keys,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("a custom provider is a complete catalog source");
|
||||||
|
assert_eq!(result.models.len(), 1);
|
||||||
|
assert_eq!(result.models[0].id.as_deref(), Some("proxy/m1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn a_failing_custom_provider_alone_fails_the_fetch() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(path("/v1/models"))
|
||||||
|
.respond_with(ResponseTemplate::new(500))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let base = format!("{}/v1", server.uri());
|
||||||
|
let keys = PlatformApiKeys::test_custom(vec![credentialed(
|
||||||
|
"proxy",
|
||||||
|
CustomApi::OpenAi,
|
||||||
|
&base,
|
||||||
|
"sk-origin-secret",
|
||||||
|
)]);
|
||||||
|
let err = tokio::task::spawn_blocking(move || {
|
||||||
|
fetch_all(
|
||||||
|
&crate::agent::config::EndpointsConfig::default(),
|
||||||
|
None,
|
||||||
|
&Default::default(),
|
||||||
|
ModelFetchAuth::Platforms,
|
||||||
|
&keys,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.err()
|
||||||
|
.expect("500 with no other source must fail");
|
||||||
|
assert!(
|
||||||
|
matches!(err, BackendError::RequestFailed { status: 500, .. }),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolved_entry(provider: &CustomProvider, id: &str, base: &str) -> (String, ModelEntry) {
|
||||||
|
let wire: WireModel = serde_json::from_value(serde_json::json!({ "id": id })).unwrap();
|
||||||
|
let mut cfg = entry(provider, wire);
|
||||||
|
cfg.base_url = base.to_owned();
|
||||||
|
let key = cfg.id.clone().unwrap();
|
||||||
|
(key, ModelEntry::from_config_entry(&cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stamp_gives_each_entry_only_its_own_providers_key_on_its_own_host() {
|
||||||
|
let proxy =
|
||||||
|
CustomProvider::new("proxy", CustomApi::OpenAi, "https://p.example/v1").unwrap();
|
||||||
|
let gw = CustomProvider::new("gw", CustomApi::Anthropic, "https://g.example/v1").unwrap();
|
||||||
|
let mut resolved = IndexMap::new();
|
||||||
|
for (key, entry) in [
|
||||||
|
resolved_entry(&proxy, "m1", "https://p.example/v1"),
|
||||||
|
resolved_entry(&gw, "c1", "https://g.example/v1"),
|
||||||
|
resolved_entry(&proxy, "moved", "https://evil.example/v1"),
|
||||||
|
] {
|
||||||
|
resolved.insert(key, entry);
|
||||||
|
}
|
||||||
|
let (own_key, mut own) = resolved_entry(&proxy, "own", "https://p.example/v1");
|
||||||
|
own.api_key = Some("per-model".into());
|
||||||
|
resolved.insert(own_key, own);
|
||||||
|
let snapshot = vec![
|
||||||
|
CredentialedProvider::for_test(proxy, "sk-proxy"),
|
||||||
|
CredentialedProvider::for_test(gw, "sk-gw"),
|
||||||
|
];
|
||||||
|
|
||||||
|
stamp_credentials(&mut resolved, &snapshot);
|
||||||
|
|
||||||
|
assert_eq!(resolved["proxy/m1"].api_key.as_deref(), Some("sk-proxy"));
|
||||||
|
assert_eq!(resolved["gw/c1"].api_key.as_deref(), Some("sk-gw"));
|
||||||
|
assert_eq!(
|
||||||
|
resolved["proxy/moved"].api_key, None,
|
||||||
|
"off-host entry gets nothing"
|
||||||
|
);
|
||||||
|
assert_eq!(resolved["proxy/own"].api_key.as_deref(), Some("per-model"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_model_list_stamps_fetched_custom_entries() {
|
||||||
|
let raw: toml::Value = toml::from_str(
|
||||||
|
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||||
|
let proxy =
|
||||||
|
CustomProvider::new("proxy", CustomApi::OpenAi, "https://p.example/v1").unwrap();
|
||||||
|
let (key, entry) = resolved_entry(&proxy, "m1", "https://p.example/v1");
|
||||||
|
let keys =
|
||||||
|
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
|
||||||
|
|
||||||
|
let resolved = crate::agent::config::resolve_model_list(
|
||||||
|
&cfg,
|
||||||
|
Some(IndexMap::from([(key, entry)])),
|
||||||
|
&keys,
|
||||||
|
);
|
||||||
|
|
||||||
|
let m = resolved
|
||||||
|
.get("proxy/m1")
|
||||||
|
.expect("fetched entry survives resolution");
|
||||||
|
assert_eq!(m.api_key.as_deref(), Some("sk-proxy"));
|
||||||
|
assert!(m.has_own_credentials());
|
||||||
|
assert!(m.visible_for_auth(false), "API-key users see the entry");
|
||||||
|
assert_eq!(
|
||||||
|
m.info.api_backend,
|
||||||
|
crate::sampling::ApiBackend::ChatCompletions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orphaned_custom_entries_never_enter_the_catalog() {
|
||||||
|
let proxy =
|
||||||
|
CustomProvider::new("proxy", CustomApi::OpenAi, "https://p.example/v1").unwrap();
|
||||||
|
let gone = CustomProvider::new("gone", CustomApi::OpenAi, "https://g.example/v1").unwrap();
|
||||||
|
let mut prefetched = IndexMap::new();
|
||||||
|
for (key, entry) in [
|
||||||
|
resolved_entry(&proxy, "m1", "https://p.example/v1"),
|
||||||
|
resolved_entry(&gone, "m2", "https://g.example/v1"),
|
||||||
|
] {
|
||||||
|
prefetched.insert(key, entry);
|
||||||
|
}
|
||||||
|
let registry: WireModel =
|
||||||
|
serde_json::from_value(serde_json::json!({ "id": "kimi-k2-turbo-preview" })).unwrap();
|
||||||
|
let registry = ModelEntry::from_config_entry(
|
||||||
|
&crate::agent::models_fetch::platform_wire_model_to_entry(
|
||||||
|
kigi_models::PlatformId::MoonshotCn,
|
||||||
|
registry,
|
||||||
|
"https://api.moonshot.cn/v1",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
prefetched.insert("moonshot-cn/kimi-k2-turbo-preview".into(), registry);
|
||||||
|
let keyed = vec![CredentialedProvider::for_test(proxy, "sk-proxy")];
|
||||||
|
let cfg = crate::agent::config::Config::default();
|
||||||
|
|
||||||
|
let resolved = crate::agent::config::resolve_model_list(
|
||||||
|
&cfg,
|
||||||
|
Some(prefetched.clone()),
|
||||||
|
&PlatformApiKeys::test_custom(keyed.clone()),
|
||||||
|
);
|
||||||
|
let ids: Vec<_> = resolved.keys().map(String::as_str).collect();
|
||||||
|
assert_eq!(ids, ["proxy/m1", "moonshot-cn/kimi-k2-turbo-preview"]);
|
||||||
|
|
||||||
|
let unkeyed =
|
||||||
|
crate::agent::config::resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||||||
|
assert!(
|
||||||
|
!unkeyed.contains_key("proxy/m1") && !unkeyed.contains_key("gone/m2"),
|
||||||
|
"a keyless provider leaves no entries behind"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_endpoint_mode_keeps_slashed_remote_ids() {
|
||||||
|
let raw: toml::Value =
|
||||||
|
toml::from_str("[endpoints]\nmodels_base_url = \"https://m.example/v1\"\n").unwrap();
|
||||||
|
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||||
|
let other = CustomProvider::new("org", CustomApi::OpenAi, "https://o.example/v1").unwrap();
|
||||||
|
let (key, entry) = resolved_entry(&other, "llama", "https://o.example/v1");
|
||||||
|
|
||||||
|
let resolved = crate::agent::config::resolve_model_list(
|
||||||
|
&cfg,
|
||||||
|
Some(IndexMap::from([(key, entry)])),
|
||||||
|
&Default::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
resolved.contains_key("org/llama"),
|
||||||
|
"{:?}",
|
||||||
|
resolved.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//! Login for custom providers: picker rows, method ids, key validation.
|
||||||
|
|
||||||
|
use agent_client_protocol as acp;
|
||||||
|
use kigi_models::custom::CustomApi;
|
||||||
|
|
||||||
|
use super::CredentialedProvider;
|
||||||
|
|
||||||
|
const PICKER_PREFIX: &str = "custom-";
|
||||||
|
const LOGIN_PREFIX: &str = "custom:";
|
||||||
|
const ALL_APIS: [CustomApi; 2] = [CustomApi::OpenAi, CustomApi::Anthropic];
|
||||||
|
|
||||||
|
/// The picker row for one wire.
|
||||||
|
pub fn picker_method(api: CustomApi) -> acp::AuthMethod {
|
||||||
|
acp::AuthMethod::Agent(
|
||||||
|
acp::AuthMethodAgent::new(
|
||||||
|
acp::AuthMethodId::new(format!("{PICKER_PREFIX}{}", api.as_str())),
|
||||||
|
format!("Custom provider ({})", api.label()),
|
||||||
|
)
|
||||||
|
.description(Some("Your own base URL and API key".to_owned())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both picker rows, in picker order.
|
||||||
|
pub fn picker_methods() -> Vec<acp::AuthMethod> {
|
||||||
|
ALL_APIS.into_iter().map(picker_method).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wire behind a picker row id.
|
||||||
|
pub fn picker_api(id: &str) -> Option<CustomApi> {
|
||||||
|
let suffix = id.strip_prefix(PICKER_PREFIX)?;
|
||||||
|
ALL_APIS.into_iter().find(|api| api.as_str() == suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The id `authenticate` takes once the provider is saved.
|
||||||
|
pub fn login_method_id(name: &str) -> acp::AuthMethodId {
|
||||||
|
acp::AuthMethodId::new(format!("{LOGIN_PREFIX}{name}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The provider name inside an `authenticate` method id.
|
||||||
|
pub fn login_provider_name(id: &str) -> Option<&str> {
|
||||||
|
id.strip_prefix(LOGIN_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probes the listing endpoint; errors never hold the key.
|
||||||
|
pub(crate) async fn validate_key(credentialed: &CredentialedProvider) -> Result<(), String> {
|
||||||
|
let provider = &credentialed.provider;
|
||||||
|
let url = provider.models_url();
|
||||||
|
let client = crate::http::shared_client();
|
||||||
|
let request = match provider.api {
|
||||||
|
CustomApi::OpenAi => client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", credentialed.key())),
|
||||||
|
CustomApi::Anthropic => client
|
||||||
|
.get(&url)
|
||||||
|
.header("x-api-key", credentialed.key())
|
||||||
|
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
|
||||||
|
};
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Couldn't reach {url}: {e}"))?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
match status {
|
||||||
|
200..=299 => Ok(()),
|
||||||
|
401 | 403 => Err(format!(
|
||||||
|
"{} rejected the API key (HTTP {status})",
|
||||||
|
provider.name
|
||||||
|
)),
|
||||||
|
_ => Err(format!("{url} answered HTTP {status}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use kigi_models::custom::CustomProvider;
|
||||||
|
use wiremock::matchers::{header, method, path};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
|
fn credentialed(api: CustomApi, base: &str, key: &str) -> CredentialedProvider {
|
||||||
|
CredentialedProvider::for_test(CustomProvider::new("proxy", api, base).unwrap(), key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picker_ids_round_trip_and_reject_lookalikes() {
|
||||||
|
for api in ALL_APIS {
|
||||||
|
let method = picker_method(api);
|
||||||
|
assert_eq!(picker_api(method.id().0.as_ref()), Some(api));
|
||||||
|
}
|
||||||
|
assert_eq!(picker_api("custom-responses"), None);
|
||||||
|
assert_eq!(picker_api("custom-OpenAI"), None);
|
||||||
|
assert_eq!(picker_api("custom:proxy"), None);
|
||||||
|
assert_eq!(picker_api("moonshot-cn"), None);
|
||||||
|
assert_eq!(picker_methods().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_ids_carry_the_provider_name() {
|
||||||
|
let id = login_method_id("my-proxy");
|
||||||
|
assert_eq!(id.0.as_ref(), "custom:my-proxy");
|
||||||
|
assert_eq!(login_provider_name(id.0.as_ref()), Some("my-proxy"));
|
||||||
|
assert_eq!(login_provider_name("custom-openai"), None);
|
||||||
|
assert_eq!(login_provider_name("proxy"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn validation_sends_the_dialects_key_header() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/v1/models"))
|
||||||
|
.and(header("authorization", "Bearer sk-openai"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let base = format!("{}/v1", server.uri());
|
||||||
|
validate_key(&credentialed(CustomApi::OpenAi, &base, "sk-openai"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/v1/models"))
|
||||||
|
.and(header("x-api-key", "sk-anthropic"))
|
||||||
|
.and(header(
|
||||||
|
"anthropic-version",
|
||||||
|
kigi_sampling_types::ANTHROPIC_VERSION,
|
||||||
|
))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let base = format!("{}/v1", server.uri());
|
||||||
|
validate_key(&credentialed(CustomApi::Anthropic, &base, "sk-anthropic"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn validation_names_the_failure_without_the_key() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
for (status, path_suffix) in [(401, "a"), (403, "b"), (404, "c"), (500, "d")] {
|
||||||
|
Mock::given(path(format!("/{path_suffix}/models")))
|
||||||
|
.respond_with(ResponseTemplate::new(status))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
let check = |suffix: &str| {
|
||||||
|
let base = format!("{}/{suffix}", server.uri());
|
||||||
|
async move {
|
||||||
|
validate_key(&credentialed(CustomApi::OpenAi, &base, "sk-secret-key"))
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert!(check("a").await.contains("rejected the API key (HTTP 401)"));
|
||||||
|
assert!(check("b").await.contains("rejected the API key (HTTP 403)"));
|
||||||
|
assert!(check("c").await.contains("answered HTTP 404"));
|
||||||
|
assert!(check("d").await.contains("answered HTTP 500"));
|
||||||
|
|
||||||
|
let err = validate_key(&credentialed(
|
||||||
|
CustomApi::OpenAi,
|
||||||
|
"http://127.0.0.1:1/v1",
|
||||||
|
"sk-secret-key",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("Couldn't reach"), "{err}");
|
||||||
|
assert!(!err.contains("sk-secret-key"), "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//! `[platforms.<name>]` tables that declare custom providers.
|
||||||
|
|
||||||
|
use kigi_models::custom::{CustomApi, CustomProvider};
|
||||||
|
|
||||||
|
use crate::agent::config::{PlatformCredentialConfig, PlatformsConfig};
|
||||||
|
|
||||||
|
mod fetch;
|
||||||
|
mod login;
|
||||||
|
mod save;
|
||||||
|
|
||||||
|
pub(crate) use fetch::{drop_orphans, fetch_models_blocking, stamp_credentials};
|
||||||
|
pub(crate) use login::validate_key;
|
||||||
|
pub use login::{login_method_id, login_provider_name, picker_api, picker_methods};
|
||||||
|
pub use save::save_custom_provider;
|
||||||
|
|
||||||
|
/// Tables setting `base_url` or `api`, valid or not.
|
||||||
|
fn declarations(platforms: &PlatformsConfig) -> Vec<(&str, Result<CustomProvider, String>)> {
|
||||||
|
platforms
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, entry)| entry.base_url.is_some() || entry.api.is_some())
|
||||||
|
.map(|(name, entry)| (name.as_str(), parse_declaration(name, entry)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_declaration(
|
||||||
|
name: &str,
|
||||||
|
entry: &PlatformCredentialConfig,
|
||||||
|
) -> Result<CustomProvider, String> {
|
||||||
|
let api = entry
|
||||||
|
.api
|
||||||
|
.as_deref()
|
||||||
|
.ok_or("api is required (\"openai\" or \"anthropic\")")?;
|
||||||
|
let api = CustomApi::parse(api)
|
||||||
|
.ok_or_else(|| format!("api {api:?} is not \"openai\" or \"anthropic\""))?;
|
||||||
|
let base_url = entry.base_url.as_deref().ok_or("base_url is required")?;
|
||||||
|
CustomProvider::new(name, api, base_url).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The valid declarations.
|
||||||
|
pub(crate) fn providers(platforms: &PlatformsConfig) -> Vec<CustomProvider> {
|
||||||
|
declarations(platforms)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(_, parsed)| parsed.ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base URLs of the valid declarations.
|
||||||
|
pub(crate) fn bases(platforms: &PlatformsConfig) -> Vec<String> {
|
||||||
|
providers(platforms)
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| p.base_url)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the table declares a provider, valid or not.
|
||||||
|
pub(crate) fn is_declared(entry: &PlatformCredentialConfig) -> bool {
|
||||||
|
entry.base_url.is_some() || entry.api.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warns once per refused declaration; the table stays inert.
|
||||||
|
pub(crate) fn warn_invalid_declarations(platforms: &PlatformsConfig) {
|
||||||
|
for (name, parsed) in declarations(platforms) {
|
||||||
|
if let Err(reason) = parsed {
|
||||||
|
tracing::warn!(platform = %name, %reason, "[platforms.{name}] is not a valid custom provider; ignored");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A declared provider with a key; `Debug` hides the key.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct CredentialedProvider {
|
||||||
|
pub(crate) provider: CustomProvider,
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredentialedProvider {
|
||||||
|
pub(crate) fn key(&self) -> &str {
|
||||||
|
&self.key
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn for_test(provider: CustomProvider, key: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
provider,
|
||||||
|
key: key.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for CredentialedProvider {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("CredentialedProvider")
|
||||||
|
.field("provider", &self.provider)
|
||||||
|
.field("key", &"<set>")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key order: auth.json scope, then the table's `api_key`.
|
||||||
|
pub(crate) fn credentialed(
|
||||||
|
platforms: &PlatformsConfig,
|
||||||
|
stored: impl Fn(&str) -> Option<String>,
|
||||||
|
) -> Vec<CredentialedProvider> {
|
||||||
|
providers(platforms)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|provider| {
|
||||||
|
let key = stored(&provider.name)
|
||||||
|
.filter(|k| !k.trim().is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
platforms
|
||||||
|
.entries
|
||||||
|
.get(&provider.name)
|
||||||
|
.and_then(|e| e.api_key.clone())
|
||||||
|
.filter(|k| !k.trim().is_empty())
|
||||||
|
})?;
|
||||||
|
Some(CredentialedProvider { provider, key })
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn platforms(toml_src: &str) -> PlatformsConfig {
|
||||||
|
toml::from_str(toml_src).expect("platforms table parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn declarations_read_api_and_base_url() {
|
||||||
|
let cfg = platforms(
|
||||||
|
r#"
|
||||||
|
[proxy]
|
||||||
|
base_url = "https://h.example/v1/"
|
||||||
|
api = "openai"
|
||||||
|
[gw]
|
||||||
|
base_url = "https://g.example/v1"
|
||||||
|
api = "Anthropic"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let found = providers(&cfg);
|
||||||
|
assert_eq!(found.len(), 2);
|
||||||
|
assert_eq!(found[0].name, "proxy");
|
||||||
|
assert_eq!(found[0].api, CustomApi::OpenAi);
|
||||||
|
assert_eq!(found[0].base_url, "https://h.example/v1");
|
||||||
|
assert_eq!(found[1].api, CustomApi::Anthropic);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_declarations_are_refused_with_a_reason() {
|
||||||
|
let cfg = platforms(
|
||||||
|
r#"
|
||||||
|
[no-api]
|
||||||
|
base_url = "https://h.example/v1"
|
||||||
|
[no-url]
|
||||||
|
api = "openai"
|
||||||
|
[bad-api]
|
||||||
|
base_url = "https://h.example/v1"
|
||||||
|
api = "responses"
|
||||||
|
[bad-url]
|
||||||
|
base_url = "h.example"
|
||||||
|
api = "openai"
|
||||||
|
[openai]
|
||||||
|
base_url = "https://h.example/v1"
|
||||||
|
api = "openai"
|
||||||
|
[Upper]
|
||||||
|
base_url = "https://h.example/v1"
|
||||||
|
api = "openai"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert!(providers(&cfg).is_empty());
|
||||||
|
let reasons: Vec<String> = declarations(&cfg)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, parsed)| parsed.expect_err("every declaration is invalid"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(reasons.len(), 6);
|
||||||
|
assert!(reasons[0].contains("api is required"));
|
||||||
|
assert!(reasons[1].contains("base_url is required"));
|
||||||
|
assert!(reasons[2].contains("responses"));
|
||||||
|
assert!(reasons[3].contains("base_url"));
|
||||||
|
assert!(reasons[4].contains("built-in platform id"));
|
||||||
|
assert!(reasons[5].contains("provider name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_table_with_only_a_key_is_not_a_declaration() {
|
||||||
|
let cfg = platforms("[moonshot-cn]\napi_key = \"sk-x\"\n");
|
||||||
|
assert!(declarations(&cfg).is_empty());
|
||||||
|
assert!(!is_declared(&cfg.entries["moonshot-cn"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_prefers_auth_json_over_config_and_blank_is_unset() {
|
||||||
|
let cfg = platforms(
|
||||||
|
r#"
|
||||||
|
[proxy]
|
||||||
|
base_url = "https://h.example/v1"
|
||||||
|
api = "openai"
|
||||||
|
api_key = "from-config"
|
||||||
|
[blank]
|
||||||
|
base_url = "https://b.example/v1"
|
||||||
|
api = "openai"
|
||||||
|
api_key = " "
|
||||||
|
[keyless]
|
||||||
|
base_url = "https://k.example/v1"
|
||||||
|
api = "openai"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let stored = |name: &str| (name == "proxy").then(|| "from-auth-json".to_owned());
|
||||||
|
let found = credentialed(&cfg, stored);
|
||||||
|
assert_eq!(found.len(), 1);
|
||||||
|
assert_eq!(found[0].provider.name, "proxy");
|
||||||
|
assert_eq!(found[0].key(), "from-auth-json");
|
||||||
|
|
||||||
|
let found = credentialed(&cfg, |_| None);
|
||||||
|
assert_eq!(found.len(), 1);
|
||||||
|
assert_eq!(found[0].key(), "from-config");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_never_prints_the_key() {
|
||||||
|
let cfg = platforms(
|
||||||
|
"[proxy]\nbase_url = \"https://h.example/v1\"\napi = \"openai\"\napi_key = \"sk-secret\"\n",
|
||||||
|
);
|
||||||
|
let found = credentialed(&cfg, |_| None);
|
||||||
|
assert!(!format!("{found:?}").contains("sk-secret"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use kigi_models::custom::CustomProvider;
|
||||||
|
use toml::Value as TomlValue;
|
||||||
|
use toml::map::Map as TomlMap;
|
||||||
|
|
||||||
|
/// Writes `[platforms.<name>]` to config.toml and the key to auth.json.
|
||||||
|
pub async fn save_custom_provider(provider: &CustomProvider, api_key: &str) -> Result<()> {
|
||||||
|
let provider = provider.clone();
|
||||||
|
let api_key = api_key.to_owned();
|
||||||
|
let _guard = crate::util::config::SAVE_LOCK.lock().await;
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
save_custom_provider_in(
|
||||||
|
&crate::util::config::user_config_path(),
|
||||||
|
&crate::util::kigi_home::kigi_home(),
|
||||||
|
&provider,
|
||||||
|
&api_key,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("custom provider save task: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Config first; a keyless definition is inert.
|
||||||
|
pub(crate) fn save_custom_provider_in(
|
||||||
|
config_path: &std::path::Path,
|
||||||
|
kigi_home: &std::path::Path,
|
||||||
|
provider: &CustomProvider,
|
||||||
|
api_key: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let api_key = api_key.trim();
|
||||||
|
anyhow::ensure!(!api_key.is_empty(), "API key must not be empty");
|
||||||
|
let raw = crate::util::config::read_to_string_or_empty(config_path)
|
||||||
|
.with_context(|| format!("reading {}", config_path.display()))?;
|
||||||
|
let mut root: TomlValue = toml::from_str(&raw).map_err(|e| {
|
||||||
|
anyhow!(
|
||||||
|
"refusing to overwrite unparseable {}: {e}; fix the syntax error first",
|
||||||
|
config_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
upsert_definition(&mut root, provider)?;
|
||||||
|
let dest = crate::util::config::config_write_dest(config_path)?;
|
||||||
|
crate::util::config::atomic_write_string(&dest, &toml::to_string_pretty(&root)?)
|
||||||
|
.with_context(|| format!("writing {}", dest.display()))?;
|
||||||
|
crate::auth::store_scoped_api_key(kigi_home, &provider.name, api_key)
|
||||||
|
.with_context(|| format!("saving the key for {} to auth.json", provider.name))?;
|
||||||
|
tracing::info!(
|
||||||
|
provider = %provider.name,
|
||||||
|
api = provider.api.as_str(),
|
||||||
|
base_url = %provider.base_url,
|
||||||
|
"custom provider saved"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_definition(root: &mut TomlValue, provider: &CustomProvider) -> Result<()> {
|
||||||
|
let top = root
|
||||||
|
.as_table_mut()
|
||||||
|
.ok_or_else(|| anyhow!("config root is not a table"))?;
|
||||||
|
let platforms = top
|
||||||
|
.entry("platforms")
|
||||||
|
.or_insert_with(|| TomlValue::Table(TomlMap::new()))
|
||||||
|
.as_table_mut()
|
||||||
|
.ok_or_else(|| anyhow!("[platforms] is not a table"))?;
|
||||||
|
let entry = platforms
|
||||||
|
.entry(provider.name.as_str())
|
||||||
|
.or_insert_with(|| TomlValue::Table(TomlMap::new()))
|
||||||
|
.as_table_mut()
|
||||||
|
.ok_or_else(|| anyhow!("[platforms.{}] is not a table", provider.name))?;
|
||||||
|
entry.insert("api".into(), provider.api.as_str().into());
|
||||||
|
entry.insert("base_url".into(), provider.base_url.as_str().into());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use kigi_models::custom::CustomApi;
|
||||||
|
|
||||||
|
fn provider(name: &str, api: CustomApi, url: &str) -> CustomProvider {
|
||||||
|
CustomProvider::new(name, api, url).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_writes_definition_and_key_and_keeps_the_rest() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config = dir.path().join("config.toml");
|
||||||
|
std::fs::write(
|
||||||
|
&config,
|
||||||
|
"[ui]\ntheme = \"kiginight\"\n\n[platforms.moonshot-cn]\napi_key = \"sk-cn\"\n\n[platforms.proxy]\napi_key = \"hand-written\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1/");
|
||||||
|
|
||||||
|
save_custom_provider_in(&config, dir.path(), &p, " sk-new ").unwrap();
|
||||||
|
|
||||||
|
let saved: TomlValue = toml::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
|
||||||
|
assert_eq!(saved["ui"]["theme"].as_str(), Some("kiginight"));
|
||||||
|
assert_eq!(
|
||||||
|
saved["platforms"]["moonshot-cn"]["api_key"].as_str(),
|
||||||
|
Some("sk-cn")
|
||||||
|
);
|
||||||
|
let entry = &saved["platforms"]["proxy"];
|
||||||
|
assert_eq!(entry["api"].as_str(), Some("openai"));
|
||||||
|
assert_eq!(entry["base_url"].as_str(), Some("https://h.example/v1"));
|
||||||
|
assert_eq!(entry["api_key"].as_str(), Some("hand-written"));
|
||||||
|
let stored = crate::auth::read_auth_json(&dir.path().join("auth.json")).unwrap();
|
||||||
|
assert_eq!(stored["proxy"].key, "sk-new");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_creates_config_and_a_second_save_rewrites_the_definition() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config = dir.path().join("config.toml");
|
||||||
|
save_custom_provider_in(
|
||||||
|
&config,
|
||||||
|
dir.path(),
|
||||||
|
&provider("gw", CustomApi::OpenAi, "https://a.example/v1"),
|
||||||
|
"k1",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
save_custom_provider_in(
|
||||||
|
&config,
|
||||||
|
dir.path(),
|
||||||
|
&provider("gw", CustomApi::Anthropic, "https://b.example/v1"),
|
||||||
|
"k2",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let saved: TomlValue = toml::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
|
||||||
|
assert_eq!(saved["platforms"]["gw"]["api"].as_str(), Some("anthropic"));
|
||||||
|
assert_eq!(
|
||||||
|
saved["platforms"]["gw"]["base_url"].as_str(),
|
||||||
|
Some("https://b.example/v1")
|
||||||
|
);
|
||||||
|
let stored = crate::auth::read_auth_json(&dir.path().join("auth.json")).unwrap();
|
||||||
|
assert_eq!(stored["gw"].key, "k2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_key_and_broken_config_fail_before_any_write() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config = dir.path().join("config.toml");
|
||||||
|
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1");
|
||||||
|
|
||||||
|
assert!(save_custom_provider_in(&config, dir.path(), &p, " ").is_err());
|
||||||
|
assert!(!config.exists());
|
||||||
|
assert!(!dir.path().join("auth.json").exists());
|
||||||
|
|
||||||
|
std::fs::write(&config, "[platforms\nnope").unwrap();
|
||||||
|
let err = save_custom_provider_in(&config, dir.path(), &p, "sk").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("unparseable"), "{err}");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&config).unwrap(),
|
||||||
|
"[platforms\nnope"
|
||||||
|
);
|
||||||
|
assert!(!dir.path().join("auth.json").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_table_platforms_value_is_refused() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config = dir.path().join("config.toml");
|
||||||
|
std::fs::write(&config, "platforms = \"x\"\n").unwrap();
|
||||||
|
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1");
|
||||||
|
let err = save_custom_provider_in(&config, dir.path(), &p, "sk").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("not a table"), "{err}");
|
||||||
|
assert!(!dir.path().join("auth.json").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -367,7 +367,7 @@ mod tests {
|
|||||||
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, "http://127.0.0.1:1/api.json");
|
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, "http://127.0.0.1:1/api.json");
|
||||||
let catalog = load_enrichment_catalog_at(&path);
|
let catalog = load_enrichment_catalog_at(&path);
|
||||||
assert!(
|
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"
|
"bundled snapshot must back a total refresh failure"
|
||||||
);
|
);
|
||||||
assert!(!path.exists(), "failed refresh must not write a cache");
|
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);
|
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, token);
|
||||||
assert!(refresh_url().is_none(), "token {token:?} must disable");
|
assert!(refresh_url().is_none(), "token {token:?} must disable");
|
||||||
let catalog = load_enrichment_catalog_at(&path);
|
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 chat_modes;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod config_model_override_parse;
|
pub mod config_model_override_parse;
|
||||||
|
pub mod custom_providers;
|
||||||
pub(crate) mod enrichment_fetch;
|
pub(crate) mod enrichment_fetch;
|
||||||
mod ext_parsers;
|
mod ext_parsers;
|
||||||
pub(crate) mod feedback_client;
|
pub(crate) mod feedback_client;
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ enum CacheAuthMethod {
|
|||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub(crate) struct PlatformApiKeys {
|
pub(crate) struct PlatformApiKeys {
|
||||||
keys: std::collections::BTreeMap<kigi_models::PlatformId, String>,
|
keys: std::collections::BTreeMap<kigi_models::PlatformId, String>,
|
||||||
|
custom: Vec<super::custom_providers::CredentialedProvider>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for PlatformApiKeys {
|
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(platform.as_str(), &self.keys.contains_key(&platform));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
s.field("custom", &self.custom);
|
||||||
s.finish()
|
s.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +116,13 @@ impl PlatformApiKeys {
|
|||||||
keys.insert(platform, key);
|
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
|
/// 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
|
/// Any API-key platform credentialed? Drives "should we prefetch without
|
||||||
/// a session" and the F2 acceptance path (platform key only, no login).
|
/// a session" and the F2 acceptance path (platform key only, no login).
|
||||||
pub(crate) fn any(&self) -> bool {
|
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).
|
/// Test-only constructor (fields are private to this module).
|
||||||
@@ -148,7 +160,18 @@ impl PlatformApiKeys {
|
|||||||
if let Some(k) = ai {
|
if let Some(k) = ai {
|
||||||
keys.insert(kigi_models::PlatformId::MoonshotAi, k.to_owned());
|
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.
|
/// 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 {
|
pub(crate) fn test_single(platform: kigi_models::PlatformId, key: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
keys: std::collections::BTreeMap::from([(platform, key.to_owned())]),
|
keys: std::collections::BTreeMap::from([(platform, key.to_owned())]),
|
||||||
|
custom: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3698,12 +3722,14 @@ mod tests {
|
|||||||
"moonshot-cn".into(),
|
"moonshot-cn".into(),
|
||||||
config::PlatformCredentialConfig {
|
config::PlatformCredentialConfig {
|
||||||
api_key: Some("cfg-cn".into()),
|
api_key: Some("cfg-cn".into()),
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
platforms.entries.insert(
|
platforms.entries.insert(
|
||||||
"moonshot-ai".into(),
|
"moonshot-ai".into(),
|
||||||
config::PlatformCredentialConfig {
|
config::PlatformCredentialConfig {
|
||||||
api_key: Some("cfg-ai".into()),
|
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)
|
let parts: Vec<String> = enabled_platforms(has_oauth, oauth_tokens, platform_keys)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|p| format!("{}={}", p.as_str(), platform_models_url(p, endpoints)))
|
.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();
|
.collect();
|
||||||
format!("platforms[{}]", parts.join(";"))
|
format!("platforms[{}]", parts.join(";"))
|
||||||
}
|
}
|
||||||
@@ -265,7 +271,7 @@ fn fetch_platform_models_blocking(
|
|||||||
platform_keys: &crate::agent::models::PlatformApiKeys,
|
platform_keys: &crate::agent::models::PlatformApiKeys,
|
||||||
) -> Result<FetchModelsResult, BackendError> {
|
) -> Result<FetchModelsResult, BackendError> {
|
||||||
let enabled = enabled_platforms(auth.is_some(), oauth_tokens, platform_keys);
|
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(
|
return Err(BackendError::Auth(
|
||||||
"No platform credentials: log in with `kigi login`, paste a platform API key in \
|
"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 \
|
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 {
|
if successes == 0 {
|
||||||
// All enabled platforms failed. When the failure includes an OAuth
|
// All enabled platforms failed. When the failure includes an OAuth
|
||||||
// 401, return `Ok` with the flag set (and no models) so the async
|
// 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,
|
platform: kigi_models::PlatformId,
|
||||||
wire: kigi_models::WireModel,
|
wire: kigi_models::WireModel,
|
||||||
base_url: &str,
|
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 {
|
) -> crate::agent::config::ModelEntryConfig {
|
||||||
let capabilities = wire.capabilities();
|
let capabilities = wire.capabilities();
|
||||||
// Selectable thinking levels (live wire `think_efforts`, e.g. K3's
|
// 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")
|
std::num::NonZeroU64::new(DEFAULT_CONTEXT_WINDOW).expect("non-zero")
|
||||||
});
|
});
|
||||||
let env_key = (!platform.uses_oauth())
|
let api_backend = match wire_api {
|
||||||
.then(|| crate::agent::config::EnvKeys::new(platform.api_key_env_names().iter().copied()));
|
|
||||||
let api_backend = match platform.wire_api() {
|
|
||||||
kigi_models::PlatformWireApi::ChatCompletions => {
|
kigi_models::PlatformWireApi::ChatCompletions => {
|
||||||
crate::sampling::ApiBackend::ChatCompletions
|
crate::sampling::ApiBackend::ChatCompletions
|
||||||
}
|
}
|
||||||
kigi_models::PlatformWireApi::Responses => crate::sampling::ApiBackend::Responses,
|
kigi_models::PlatformWireApi::Responses => crate::sampling::ApiBackend::Responses,
|
||||||
kigi_models::PlatformWireApi::Messages => crate::sampling::ApiBackend::Messages,
|
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::Bearer => None,
|
||||||
kigi_models::PlatformKeyHeader::XApiKey => Some(kigi_sampler::AuthScheme::XApiKey),
|
kigi_models::PlatformKeyHeader::XApiKey => Some(kigi_sampler::AuthScheme::XApiKey),
|
||||||
};
|
};
|
||||||
crate::agent::config::ModelEntryConfig {
|
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())),
|
name: Some(wire.display_name.clone().unwrap_or_else(|| wire.id.clone())),
|
||||||
model: wire.id,
|
model: wire.id,
|
||||||
base_url: base_url.to_owned(),
|
base_url: base_url.to_owned(),
|
||||||
@@ -674,15 +731,7 @@ pub(crate) fn platform_wire_model_to_entry(
|
|||||||
inference_idle_timeout_secs: None,
|
inference_idle_timeout_secs: None,
|
||||||
max_retries: None,
|
max_retries: None,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
// `supported_in_api: false` hides a model unless the PRIMARY session is
|
supported_in_api,
|
||||||
// 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,
|
|
||||||
supports_backend_search: false,
|
supports_backend_search: false,
|
||||||
compactions_remaining: None,
|
compactions_remaining: None,
|
||||||
compaction_at_tokens: None,
|
compaction_at_tokens: None,
|
||||||
@@ -1473,7 +1522,7 @@ mod tests {
|
|||||||
|
|
||||||
/// openai-codex fetch: the catalog is HARDCODED, so the fetch path
|
/// openai-codex fetch: the catalog is HARDCODED, so the fetch path
|
||||||
/// short-circuits BEFORE any HTTP — there is NO mock `/models` server, yet
|
/// 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
|
/// `openai-codex/<slug>` on the Responses backend, ctx 272000, each exposing
|
||||||
/// its exact reasoning efforts (incl. the codex-only `xhigh`/`max`).
|
/// its exact reasoning efforts (incl. the codex-only `xhigh`/`max`).
|
||||||
/// A BOGUS base URL confirms no live `/models` request is attempted (it would
|
/// 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())
|
.map(|m| m.id.as_deref().unwrap_or_default())
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
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-sol",
|
||||||
"openai-codex/gpt-5.6-terra",
|
"openai-codex/gpt-5.6-terra",
|
||||||
"openai-codex/gpt-5.6-luna",
|
"openai-codex/gpt-5.6-luna",
|
||||||
"openai-codex/gpt-5.5",
|
"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.
|
// Excluded models never appear.
|
||||||
for absent in [
|
for absent in ["openai-codex/gpt-reserve", "openai-codex/codex-auto-review"] {
|
||||||
"openai-codex/gpt-5.3-codex-spark",
|
|
||||||
"openai-codex/gpt-5.4",
|
|
||||||
"openai-codex/gpt-5.4-mini",
|
|
||||||
"openai-codex/codex-auto-review",
|
|
||||||
] {
|
|
||||||
assert!(
|
assert!(
|
||||||
!result
|
!result
|
||||||
.models
|
.models
|
||||||
@@ -1532,7 +1580,14 @@ mod tests {
|
|||||||
"{absent} must be absent from the hardcoded catalog"
|
"{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!(
|
assert_eq!(
|
||||||
sol.api_backend,
|
sol.api_backend,
|
||||||
crate::sampling::ApiBackend::Responses,
|
crate::sampling::ApiBackend::Responses,
|
||||||
|
|||||||
@@ -557,6 +557,11 @@ impl acp::Agent for MvpAgent {
|
|||||||
.oauth_platform()
|
.oauth_platform()
|
||||||
{
|
{
|
||||||
self.authenticate_oauth_platform(platform, arguments).await
|
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) =
|
} else if let Some(platform) =
|
||||||
auth_method::platform_for_method_id(&arguments.method_id)
|
auth_method::platform_for_method_id(&arguments.method_id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -526,6 +526,43 @@ impl MvpAgent {
|
|||||||
Some("platform_key_invalid_or_missing"),
|
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
|
// Rebuild the catalog from the on-disk config: the rebuild freshly
|
||||||
// resolves platform keys (env > auth.json > config), so the key just
|
// resolves platform keys (env > auth.json > config), so the key just
|
||||||
// persisted to auth.json is stamped onto the platform's entries; a
|
// 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)
|
let meta = serde_json::to_value(auth_meta)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.as_object().cloned());
|
.and_then(|v| v.as_object().cloned());
|
||||||
Ok(AuthenticateResponse::new().meta(meta))
|
AuthenticateResponse::new().meta(meta)
|
||||||
}
|
}
|
||||||
/// `authenticate(<generic-oauth platform id>)`: interactive device-code
|
/// `authenticate(<generic-oauth platform id>)`: interactive device-code
|
||||||
/// login for a `uses_oauth` platform carrying an `OAuthConfig` (xai-grok).
|
/// 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.
|
/// Read the parent session's actual current sampling config.
|
||||||
///
|
///
|
||||||
/// Prefers the live state from `ChatStateHandle` (authoritative). Falls back
|
/// 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(),
|
creds.alpha_test_key.as_deref(),
|
||||||
&cfg.base_url,
|
&cfg.base_url,
|
||||||
);
|
);
|
||||||
let auth_scheme = crate::agent::config::try_resolve_model_credentials(&cfg.model, None)
|
let auth_scheme = parent_auth_scheme(&ctx.available_models, &ctx.model_id, &cfg.model);
|
||||||
.map(|r| r.auth_scheme)
|
|
||||||
.unwrap_or_default();
|
|
||||||
// Claude Pro/Max OAuth Messages adaptation inherits from the parent
|
// Claude Pro/Max OAuth Messages adaptation inherits from the parent
|
||||||
// model's platform (claude-pro-max → true); every other platform,
|
// model's platform (claude-pro-max → true); every other platform,
|
||||||
// and BYOK, → false, so the API-key paths stay byte-identical.
|
// 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");
|
.expect("parent acks must complete in bounded time");
|
||||||
assert!(!acked, "an unanswered ack must report a miss");
|
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
|
/// Deliberately NOT [`crate::util::is_first_party_url`], which is
|
||||||
/// production-only and would break every custom deployment.
|
/// production-only and would break every custom deployment.
|
||||||
fn is_session_coding_endpoint(&self, base_url: &str) -> bool {
|
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) {
|
if crate::util::is_effective_coding_endpoint_url(base_url) {
|
||||||
return true;
|
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
|
/// LEAK guard: every API-key registry platform, and any platform-less model
|
||||||
/// on a third-party host, gets NO session credential and NO manager.
|
/// on a third-party host, gets NO session credential and NO manager.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ pub use manager::{AuthManager, shared_api_key_provider};
|
|||||||
pub use meta::AuthMeta;
|
pub use meta::AuthMeta;
|
||||||
pub use model::{AuthMode, KimiAuth, lookup_auth};
|
pub use model::{AuthMode, KimiAuth, lookup_auth};
|
||||||
pub(crate) use model::{TOKEN_TTL, is_expired, token_suffix};
|
pub(crate) use model::{TOKEN_TTL, is_expired, token_suffix};
|
||||||
|
pub(crate) use storage::store_scoped_api_key;
|
||||||
pub use storage::{
|
pub use storage::{
|
||||||
clear_api_key, read_api_key, read_auth_json, read_platform_api_key, read_token_by_scope,
|
clear_api_key, read_api_key, read_auth_json, read_platform_api_key, read_token_by_scope,
|
||||||
store_api_key, store_platform_api_key,
|
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");
|
let path = kigi_home.join("auth.json");
|
||||||
// Serialize with the manager's cross-process auth.json writers (token
|
// Serialize with the manager's cross-process auth.json writers (token
|
||||||
// refresh holds the same flock): an unlocked read-modify-write here
|
// 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)?;
|
let mut map = read_auth_json_or_empty_recovering_corrupt(&path)?;
|
||||||
map.insert(
|
map.insert(
|
||||||
platform.as_str().to_owned(),
|
scope.to_owned(),
|
||||||
KimiAuth {
|
KimiAuth {
|
||||||
key: api_key.to_owned(),
|
key: api_key.to_owned(),
|
||||||
auth_mode: AuthMode::ApiKey,
|
auth_mode: AuthMode::ApiKey,
|
||||||
|
|||||||
@@ -1605,6 +1605,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
fn describe_requirements_file_flags_invalid_version_overrides_as_parse_error() {
|
fn describe_requirements_file_flags_invalid_version_overrides_as_parse_error() {
|
||||||
// Valid TOML but invalid `[[version_overrides]]` is rejected by the real
|
// Valid TOML but invalid `[[version_overrides]]` is rejected by the real
|
||||||
// loader, so it must read "parse error", not "empty".
|
// loader, so it must read "parse error", not "empty".
|
||||||
|
|||||||
@@ -264,6 +264,16 @@ impl SessionActor {
|
|||||||
fn aux_model_auth_facts(&self, model_id: &str) -> crate::agent::config::ModelAuthFacts {
|
fn aux_model_auth_facts(&self, model_id: &str) -> crate::agent::config::ModelAuthFacts {
|
||||||
self.resolve_auth_facts(model_id, false)
|
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`].
|
/// Shared body of [`Self::model_auth_facts`] / [`Self::aux_model_auth_facts`].
|
||||||
/// `memoize` is the ONLY difference, so the two can never resolve differently.
|
/// `memoize` is the ONLY difference, so the two can never resolve differently.
|
||||||
fn resolve_auth_facts(
|
fn resolve_auth_facts(
|
||||||
@@ -278,7 +288,9 @@ impl SessionActor {
|
|||||||
{
|
{
|
||||||
return *facts;
|
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 fresh.byok == ModelByok::Unknown {
|
||||||
if let Some((cached_id, facts)) = self.model_auth_facts.borrow().as_ref()
|
if let Some((cached_id, facts)) = self.model_auth_facts.borrow().as_ref()
|
||||||
&& cached_id == model_id
|
&& cached_id == model_id
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use toml::map::Map as TomlMap;
|
|||||||
///
|
///
|
||||||
/// Serializes the read-modify-write in `save_config` so two rapid
|
/// Serializes the read-modify-write in `save_config` so two rapid
|
||||||
/// settings toggles can't interleave and clobber each other.
|
/// 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<()> {
|
pub async fn save_config(config: &Config) -> Result<()> {
|
||||||
let _guard = SAVE_LOCK.lock().await;
|
let _guard = SAVE_LOCK.lock().await;
|
||||||
|
|||||||
@@ -570,6 +570,10 @@ pub enum Action {
|
|||||||
/// under the platform-id scope, then authenticate with the platform's
|
/// under the platform-id scope, then authenticate with the platform's
|
||||||
/// method id. The key must never be logged.
|
/// method id. The key must never be logged.
|
||||||
SubmitPlatformApiKey(String),
|
SubmitPlatformApiKey(String),
|
||||||
|
/// Custom provider row picked: open the URL step.
|
||||||
|
BeginCustomProviderEntry(kigi_shell::models::custom::CustomApi),
|
||||||
|
/// Enter in a custom provider step.
|
||||||
|
SubmitCustomProviderInput(String),
|
||||||
/// Copy the auth URL to the clipboard during authentication.
|
/// Copy the auth URL to the clipboard during authentication.
|
||||||
CopyAuthUrl,
|
CopyAuthUrl,
|
||||||
/// Show the raw auth URL with mouse capture disabled for manual copy.
|
/// Show the raw auth URL with mouse capture disabled for manual copy.
|
||||||
@@ -1258,7 +1262,7 @@ pub enum ProbedAttachment {
|
|||||||
PersistFailed(String),
|
PersistFailed(String),
|
||||||
/// The pasteboard probe completed normally without raster data.
|
/// The pasteboard probe completed normally without raster data.
|
||||||
NoRaster,
|
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,
|
ProbeDropped,
|
||||||
/// The attachment probe task failed or timed out.
|
/// The attachment probe task failed or timed out.
|
||||||
ProbeFailed,
|
ProbeFailed,
|
||||||
@@ -1614,6 +1618,12 @@ pub enum Effect {
|
|||||||
target: crate::app::app_view::PlatformLogin,
|
target: crate::app::app_view::PlatformLogin,
|
||||||
key: String,
|
key: String,
|
||||||
},
|
},
|
||||||
|
/// Saves the provider, then authenticates; never log the key.
|
||||||
|
PersistCustomProviderAndAuthenticate {
|
||||||
|
request_seq: u64,
|
||||||
|
provider: kigi_shell::models::custom::CustomProvider,
|
||||||
|
key: String,
|
||||||
|
},
|
||||||
/// Fetch MCP server list from the shell (kigi/mcp/list).
|
/// Fetch MCP server list from the shell (kigi/mcp/list).
|
||||||
FetchMcpsList {
|
FetchMcpsList {
|
||||||
agent_id: AgentId,
|
agent_id: AgentId,
|
||||||
|
|||||||
@@ -260,6 +260,26 @@ impl AgentView {
|
|||||||
}
|
}
|
||||||
self.prompt.set_text("");
|
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 {
|
pub(super) fn handle_plan_feedback_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||||
let is_commenting = self
|
let is_commenting = self
|
||||||
.plan_approval_view
|
.plan_approval_view
|
||||||
@@ -297,26 +317,16 @@ impl AgentView {
|
|||||||
return InputOutcome::Changed;
|
return InputOutcome::Changed;
|
||||||
}
|
}
|
||||||
if is_commenting {
|
if is_commenting {
|
||||||
let stashed = if let Some(ref mut pav) = self.plan_approval_view {
|
return self.cancel_plan_comment();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
if let Some(ref mut pav) = self.plan_approval_view {
|
if let Some(ref mut pav) = self.plan_approval_view {
|
||||||
pav.focus = PlanApprovalFocus::Preview;
|
pav.focus = PlanApprovalFocus::Preview;
|
||||||
}
|
}
|
||||||
return InputOutcome::Changed;
|
return InputOutcome::Changed;
|
||||||
}
|
}
|
||||||
|
if is_commenting && self.ctrl_c_cancels_empty_draft(key) {
|
||||||
|
return self.cancel_plan_comment();
|
||||||
|
}
|
||||||
match self.prompt.route_enter(key) {
|
match self.prompt.route_enter(key) {
|
||||||
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
|
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
|
||||||
EnterOutcome::Submit => {
|
EnterOutcome::Submit => {
|
||||||
@@ -569,6 +579,9 @@ impl AgentView {
|
|||||||
}
|
}
|
||||||
return self.cancel_casual_plan_commenting();
|
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) {
|
match self.prompt.route_enter(key) {
|
||||||
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
|
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
|
||||||
EnterOutcome::Submit => return self.save_casual_plan_comment(),
|
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,8 @@ pub enum AuthMode {
|
|||||||
/// Open-platform API-key entry: paste box for a Moonshot key selected
|
/// Open-platform API-key entry: paste box for a Moonshot key selected
|
||||||
/// from the welcome login picker. Esc returns to the picker (no quit).
|
/// from the welcome login picker. Esc returns to the picker (no quit).
|
||||||
ApiKeyEntry(PlatformLogin),
|
ApiKeyEntry(PlatformLogin),
|
||||||
|
/// Custom provider login, one field per step.
|
||||||
|
CustomProviderEntry(crate::app::custom_entry::CustomEntryStep),
|
||||||
}
|
}
|
||||||
/// API-key platform login target, selected from the welcome picker. Wraps a
|
/// API-key platform login target, selected from the welcome picker. Wraps a
|
||||||
/// non-OAuth registry platform ([`kigi_shell::models::PlatformId`]);
|
/// non-OAuth registry platform ([`kigi_shell::models::PlatformId`]);
|
||||||
@@ -310,6 +312,11 @@ pub enum PendingMenuItem {
|
|||||||
target: PlatformLogin,
|
target: PlatformLogin,
|
||||||
label: String,
|
label: String,
|
||||||
},
|
},
|
||||||
|
/// Custom provider login for one wire.
|
||||||
|
CustomProvider {
|
||||||
|
api: kigi_shell::models::custom::CustomApi,
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
Quit,
|
Quit,
|
||||||
}
|
}
|
||||||
impl PendingMenuItem {
|
impl PendingMenuItem {
|
||||||
@@ -324,7 +331,9 @@ impl PendingMenuItem {
|
|||||||
}
|
}
|
||||||
pub fn label(&self) -> &str {
|
pub fn label(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::Login { label, .. } | Self::ApiKey { label, .. } => label,
|
Self::Login { label, .. }
|
||||||
|
| Self::ApiKey { label, .. }
|
||||||
|
| Self::CustomProvider { label, .. } => label,
|
||||||
Self::Quit => "Quit",
|
Self::Quit => "Quit",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,7 +375,12 @@ pub fn pending_menu_items(
|
|||||||
use kigi_shell::agent::auth_method::AuthMethodKind;
|
use kigi_shell::agent::auth_method::AuthMethodKind;
|
||||||
let mut items: Vec<PendingMenuItem> = Vec::new();
|
let mut items: Vec<PendingMenuItem> = Vec::new();
|
||||||
for method in auth_methods {
|
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 {
|
items.push(PendingMenuItem::ApiKey {
|
||||||
target,
|
target,
|
||||||
label: method.name().to_string(),
|
label: method.name().to_string(),
|
||||||
@@ -908,6 +922,8 @@ pub struct AppView {
|
|||||||
pub auth_start_mode: AuthMode,
|
pub auth_start_mode: AuthMode,
|
||||||
/// Text buffer for manual auth token paste (loopback mode).
|
/// Text buffer for manual auth token paste (loopback mode).
|
||||||
pub auth_code_input: String,
|
pub auth_code_input: String,
|
||||||
|
/// Values collected by the custom provider login steps.
|
||||||
|
pub custom_entry: crate::app::custom_entry::CustomEntryDraft,
|
||||||
/// Monotonically increasing sequence number for auth requests.
|
/// Monotonically increasing sequence number for auth requests.
|
||||||
pub next_auth_request_seq: u64,
|
pub next_auth_request_seq: u64,
|
||||||
/// Every session/chat/worktree/prompt action deferred behind startup gates.
|
/// Every session/chat/worktree/prompt action deferred behind startup gates.
|
||||||
@@ -1111,6 +1127,7 @@ impl AppView {
|
|||||||
login_method_id: None,
|
login_method_id: None,
|
||||||
auth_start_mode: AuthMode::Pending,
|
auth_start_mode: AuthMode::Pending,
|
||||||
auth_code_input: String::new(),
|
auth_code_input: String::new(),
|
||||||
|
custom_entry: Default::default(),
|
||||||
next_auth_request_seq: 1,
|
next_auth_request_seq: 1,
|
||||||
deferred_startup: Default::default(),
|
deferred_startup: Default::default(),
|
||||||
auth_use_oauth: false,
|
auth_use_oauth: false,
|
||||||
@@ -2648,7 +2665,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
|||||||
return InputOutcome::Unchanged;
|
return InputOutcome::Unchanged;
|
||||||
}
|
}
|
||||||
AuthState::Authenticating {
|
AuthState::Authenticating {
|
||||||
mode: AuthMode::ApiKeyEntry(_),
|
mode: mode @ (AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_)),
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
// Esc cancels BACK TO THE PICKER (unlike the OAuth flows,
|
// Esc cancels BACK TO THE PICKER (unlike the OAuth flows,
|
||||||
@@ -2664,6 +2681,9 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
|||||||
}
|
}
|
||||||
if key!(Enter).matches(key) {
|
if key!(Enter).matches(key) {
|
||||||
let trimmed = ctx.auth_code_input.trim().to_string();
|
let trimmed = ctx.auth_code_input.trim().to_string();
|
||||||
|
if matches!(mode, AuthMode::CustomProviderEntry(_)) {
|
||||||
|
return InputOutcome::Action(Action::SubmitCustomProviderInput(trimmed));
|
||||||
|
}
|
||||||
if !trimmed.is_empty() {
|
if !trimmed.is_empty() {
|
||||||
return InputOutcome::Action(Action::SubmitPlatformApiKey(trimmed));
|
return InputOutcome::Action(Action::SubmitPlatformApiKey(trimmed));
|
||||||
}
|
}
|
||||||
@@ -2726,7 +2746,8 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
|||||||
return InputOutcome::ActionThenForward(Action::NewSession);
|
return InputOutcome::ActionThenForward(Action::NewSession);
|
||||||
}
|
}
|
||||||
AuthState::Authenticating {
|
AuthState::Authenticating {
|
||||||
mode: AuthMode::Loopback | AuthMode::ApiKeyEntry(_),
|
mode:
|
||||||
|
AuthMode::Loopback | AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||||
@@ -2889,6 +2910,9 @@ fn dispatch_pending_menu_action(
|
|||||||
Some(PendingMenuItem::ApiKey { target, .. }) => {
|
Some(PendingMenuItem::ApiKey { target, .. }) => {
|
||||||
InputOutcome::Action(Action::BeginPlatformKeyEntry(*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) if mid_session => InputOutcome::Action(Action::CancelLogin),
|
||||||
Some(PendingMenuItem::Quit) => InputOutcome::Action(Action::Quit),
|
Some(PendingMenuItem::Quit) => InputOutcome::Action(Action::Quit),
|
||||||
None => InputOutcome::Unchanged,
|
None => InputOutcome::Unchanged,
|
||||||
@@ -3223,6 +3247,7 @@ impl AppView {
|
|||||||
auth_methods: &self.auth_methods,
|
auth_methods: &self.auth_methods,
|
||||||
login_label: self.login_label.as_deref(),
|
login_label: self.login_label.as_deref(),
|
||||||
auth_code_input: &self.auth_code_input,
|
auth_code_input: &self.auth_code_input,
|
||||||
|
custom_entry: &self.custom_entry,
|
||||||
clipboard_copied: self.auth_clipboard_copied,
|
clipboard_copied: self.auth_clipboard_copied,
|
||||||
show_raw_url: self.auth_show_raw_url,
|
show_raw_url: self.auth_show_raw_url,
|
||||||
tip,
|
tip,
|
||||||
@@ -4358,6 +4383,7 @@ pub(crate) mod tests {
|
|||||||
login_method_id: None,
|
login_method_id: None,
|
||||||
auth_start_mode: AuthMode::Pending,
|
auth_start_mode: AuthMode::Pending,
|
||||||
auth_code_input: String::new(),
|
auth_code_input: String::new(),
|
||||||
|
custom_entry: Default::default(),
|
||||||
next_auth_request_seq: 1,
|
next_auth_request_seq: 1,
|
||||||
deferred_startup: Default::default(),
|
deferred_startup: Default::default(),
|
||||||
auth_use_oauth: false,
|
auth_use_oauth: false,
|
||||||
@@ -7005,7 +7031,7 @@ pub(crate) mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pending_menu_items_lists_interactive_methods_plus_quit() {
|
fn pending_menu_items_lists_interactive_methods_plus_quit() {
|
||||||
let items = pending_menu_items(&fresh_user_auth_methods(), None);
|
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[0], "kimi-code", "Kimi Code (OAuth)");
|
||||||
assert_login_row(&items[1], "xai-grok", "xAI Grok (subscription) (OAuth)");
|
assert_login_row(&items[1], "xai-grok", "xAI Grok (subscription) (OAuth)");
|
||||||
assert_login_row(
|
assert_login_row(
|
||||||
@@ -7192,7 +7218,28 @@ pub(crate) mod tests {
|
|||||||
label: "MiniMax China (API key)".into(),
|
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.
|
// The non-interactive methods must never appear as rows.
|
||||||
let byok = kigi_shell::agent::auth_method::build_auth_methods(
|
let byok = kigi_shell::agent::auth_method::build_auth_methods(
|
||||||
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
|
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
|
||||||
@@ -7203,8 +7250,8 @@ pub(crate) mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pending_menu_items(&byok.methods, None).len(),
|
pending_menu_items(&byok.methods, None).len(),
|
||||||
30,
|
32,
|
||||||
"xai.api_key / cached_token must not add rows (29 login rows + Quit)"
|
"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
|
/// Startup lands on the picker only when there is a real choice: the
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//! Custom provider login steps: base URL, name, key.
|
||||||
|
|
||||||
|
use kigi_shell::models::custom::{
|
||||||
|
CustomApi, CustomProvider, default_name, normalize_base_url, validate_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CustomEntryField {
|
||||||
|
BaseUrl,
|
||||||
|
Name,
|
||||||
|
Key,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which field the input box holds, for which wire.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct CustomEntryStep {
|
||||||
|
pub api: CustomApi,
|
||||||
|
pub field: CustomEntryField,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CustomEntryStep {
|
||||||
|
pub fn first(api: CustomApi) -> Self {
|
||||||
|
Self {
|
||||||
|
api,
|
||||||
|
field: CustomEntryField::BaseUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the key is masked.
|
||||||
|
pub fn masks_input(self) -> bool {
|
||||||
|
self.field == CustomEntryField::Key
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn placeholder(self) -> &'static str {
|
||||||
|
match self.field {
|
||||||
|
CustomEntryField::BaseUrl => "https://host/v1",
|
||||||
|
CustomEntryField::Name => "provider name",
|
||||||
|
CustomEntryField::Key => "Paste your API key here...",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one Enter press did.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum CustomEntryOutcome {
|
||||||
|
/// The input stays; the draft error says why.
|
||||||
|
Stay,
|
||||||
|
/// Next field, with this text in the box.
|
||||||
|
Next {
|
||||||
|
field: CustomEntryField,
|
||||||
|
prefill: String,
|
||||||
|
},
|
||||||
|
/// Every field is valid.
|
||||||
|
Done {
|
||||||
|
provider: CustomProvider,
|
||||||
|
key: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Values collected so far, and the last refusal.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct CustomEntryDraft {
|
||||||
|
base_url: String,
|
||||||
|
name: String,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CustomEntryDraft {
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
base_url: String::new(),
|
||||||
|
name: String::new(),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
*self = Self::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn instruction(&self, step: CustomEntryStep) -> String {
|
||||||
|
match step.field {
|
||||||
|
CustomEntryField::BaseUrl => format!(
|
||||||
|
"Base URL of the {} API, for example https://host/v1",
|
||||||
|
step.api.label()
|
||||||
|
),
|
||||||
|
CustomEntryField::Name => {
|
||||||
|
"Name for this provider. Its models appear as name/model".to_owned()
|
||||||
|
}
|
||||||
|
CustomEntryField::Key => format!(
|
||||||
|
"API key for {}. A local server that ignores auth takes any value",
|
||||||
|
self.name
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn submit(&mut self, step: CustomEntryStep, input: &str) -> CustomEntryOutcome {
|
||||||
|
let input = input.trim();
|
||||||
|
if input.is_empty() {
|
||||||
|
self.error = None;
|
||||||
|
return CustomEntryOutcome::Stay;
|
||||||
|
}
|
||||||
|
self.error = None;
|
||||||
|
match step.field {
|
||||||
|
CustomEntryField::BaseUrl => match normalize_base_url(input) {
|
||||||
|
Ok(url) => {
|
||||||
|
let prefill = default_name(&url);
|
||||||
|
self.base_url = url;
|
||||||
|
CustomEntryOutcome::Next {
|
||||||
|
field: CustomEntryField::Name,
|
||||||
|
prefill,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.refuse(e),
|
||||||
|
},
|
||||||
|
CustomEntryField::Name => match validate_name(input) {
|
||||||
|
Ok(()) => {
|
||||||
|
self.name = input.to_owned();
|
||||||
|
CustomEntryOutcome::Next {
|
||||||
|
field: CustomEntryField::Key,
|
||||||
|
prefill: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.refuse(e),
|
||||||
|
},
|
||||||
|
CustomEntryField::Key => {
|
||||||
|
match CustomProvider::new(&self.name, step.api, &self.base_url) {
|
||||||
|
Ok(provider) => CustomEntryOutcome::Done {
|
||||||
|
provider,
|
||||||
|
key: input.to_owned(),
|
||||||
|
},
|
||||||
|
Err(e) => self.refuse(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refuse(&mut self, reason: impl std::fmt::Display) -> CustomEntryOutcome {
|
||||||
|
self.error = Some(reason.to_string());
|
||||||
|
CustomEntryOutcome::Stay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn step(field: CustomEntryField) -> CustomEntryStep {
|
||||||
|
CustomEntryStep {
|
||||||
|
api: CustomApi::Anthropic,
|
||||||
|
field,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn three_valid_steps_build_the_provider() {
|
||||||
|
let mut draft = CustomEntryDraft::default();
|
||||||
|
let next = draft.submit(
|
||||||
|
step(CustomEntryField::BaseUrl),
|
||||||
|
" https://api.gw.example/v1/ ",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
next,
|
||||||
|
CustomEntryOutcome::Next {
|
||||||
|
field: CustomEntryField::Name,
|
||||||
|
prefill: "gw-example".to_owned()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let next = draft.submit(step(CustomEntryField::Name), "gw");
|
||||||
|
assert_eq!(
|
||||||
|
next,
|
||||||
|
CustomEntryOutcome::Next {
|
||||||
|
field: CustomEntryField::Key,
|
||||||
|
prefill: String::new()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
draft
|
||||||
|
.instruction(step(CustomEntryField::Key))
|
||||||
|
.contains("API key for gw")
|
||||||
|
);
|
||||||
|
|
||||||
|
let done = draft.submit(step(CustomEntryField::Key), " sk-1 ");
|
||||||
|
let CustomEntryOutcome::Done { provider, key } = done else {
|
||||||
|
panic!("expected Done, got {done:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(provider.name, "gw");
|
||||||
|
assert_eq!(provider.api, CustomApi::Anthropic);
|
||||||
|
assert_eq!(provider.base_url, "https://api.gw.example/v1");
|
||||||
|
assert_eq!(key, "sk-1");
|
||||||
|
assert!(draft.error.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_input_stays_on_the_field_with_a_reason() {
|
||||||
|
let mut draft = CustomEntryDraft::default();
|
||||||
|
assert_eq!(
|
||||||
|
draft.submit(step(CustomEntryField::BaseUrl), "host.example"),
|
||||||
|
CustomEntryOutcome::Stay
|
||||||
|
);
|
||||||
|
assert!(draft.error.as_deref().is_some_and(|e| e.contains("http")));
|
||||||
|
|
||||||
|
draft.submit(step(CustomEntryField::BaseUrl), "https://h.example/v1");
|
||||||
|
assert_eq!(
|
||||||
|
draft.submit(step(CustomEntryField::Name), "OpenAI"),
|
||||||
|
CustomEntryOutcome::Stay
|
||||||
|
);
|
||||||
|
assert!(draft.error.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
draft.submit(step(CustomEntryField::Name), "openai"),
|
||||||
|
CustomEntryOutcome::Stay
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
draft
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|e| e.contains("built-in"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_input_stays_and_clears_the_last_error() {
|
||||||
|
let mut draft = CustomEntryDraft::default();
|
||||||
|
draft.submit(step(CustomEntryField::BaseUrl), "nope");
|
||||||
|
assert!(draft.error.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
draft.submit(step(CustomEntryField::BaseUrl), " "),
|
||||||
|
CustomEntryOutcome::Stay
|
||||||
|
);
|
||||||
|
assert!(draft.error.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_key_is_masked() {
|
||||||
|
assert!(step(CustomEntryField::Key).masks_input());
|
||||||
|
assert!(!step(CustomEntryField::BaseUrl).masks_input());
|
||||||
|
assert!(!step(CustomEntryField::Name).masks_input());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use crate::app::actions::{Action, Effect};
|
|||||||
use crate::app::agent::AgentId;
|
use crate::app::agent::AgentId;
|
||||||
use crate::app::agent_view::AgentView;
|
use crate::app::agent_view::AgentView;
|
||||||
use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState, PlatformLogin};
|
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::block::RenderBlock;
|
||||||
use crate::scrollback::blocks::SessionEvent;
|
use crate::scrollback::blocks::SessionEvent;
|
||||||
|
|
||||||
@@ -325,6 +326,73 @@ pub(super) fn dispatch_begin_platform_key_entry(
|
|||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Custom provider row selected: open the URL step.
|
||||||
|
pub(super) fn dispatch_begin_custom_provider_entry(
|
||||||
|
app: &mut AppView,
|
||||||
|
api: kigi_shell::models::custom::CustomApi,
|
||||||
|
) -> Vec<Effect> {
|
||||||
|
let request_seq = app.next_auth_request_seq;
|
||||||
|
app.next_auth_request_seq += 1;
|
||||||
|
app.auth_code_input.clear();
|
||||||
|
app.custom_entry.clear();
|
||||||
|
app.auth_state = AuthState::Authenticating {
|
||||||
|
request_seq,
|
||||||
|
handle: None,
|
||||||
|
auth_url: None,
|
||||||
|
mode: AuthMode::CustomProviderEntry(CustomEntryStep::first(api)),
|
||||||
|
};
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter in a custom provider step.
|
||||||
|
pub(super) fn dispatch_submit_custom_provider_input(
|
||||||
|
app: &mut AppView,
|
||||||
|
input: String,
|
||||||
|
) -> Vec<Effect> {
|
||||||
|
let (request_seq, step) = match &app.auth_state {
|
||||||
|
AuthState::Authenticating {
|
||||||
|
request_seq,
|
||||||
|
mode: AuthMode::CustomProviderEntry(step),
|
||||||
|
..
|
||||||
|
} => (*request_seq, *step),
|
||||||
|
_ => return vec![],
|
||||||
|
};
|
||||||
|
match app.custom_entry.submit(step, &input) {
|
||||||
|
CustomEntryOutcome::Stay => vec![],
|
||||||
|
CustomEntryOutcome::Next { field, prefill } => {
|
||||||
|
app.auth_code_input = prefill;
|
||||||
|
app.auth_state = AuthState::Authenticating {
|
||||||
|
request_seq,
|
||||||
|
handle: None,
|
||||||
|
auth_url: None,
|
||||||
|
mode: AuthMode::CustomProviderEntry(CustomEntryStep {
|
||||||
|
api: step.api,
|
||||||
|
field,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
CustomEntryOutcome::Done { provider, key } => {
|
||||||
|
app.auth_in_flight_method = Some(kigi_shell::agent::custom_providers::login_method_id(
|
||||||
|
&provider.name,
|
||||||
|
));
|
||||||
|
app.custom_entry.clear();
|
||||||
|
app.auth_code_input.clear();
|
||||||
|
app.auth_state = AuthState::Authenticating {
|
||||||
|
request_seq,
|
||||||
|
handle: None,
|
||||||
|
auth_url: None,
|
||||||
|
mode: AuthMode::Pending,
|
||||||
|
};
|
||||||
|
vec![Effect::PersistCustomProviderAndAuthenticate {
|
||||||
|
request_seq,
|
||||||
|
provider,
|
||||||
|
key,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Esc in the API-key paste box: back to the login picker (no error line).
|
/// 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
|
/// Bumps the request seq so any stale in-flight auth result is dropped by
|
||||||
/// the `AuthComplete`/`AuthFailed` guards.
|
/// the `AuthComplete`/`AuthFailed` guards.
|
||||||
@@ -332,7 +400,7 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
|
|||||||
if !matches!(
|
if !matches!(
|
||||||
app.auth_state,
|
app.auth_state,
|
||||||
AuthState::Authenticating {
|
AuthState::Authenticating {
|
||||||
mode: AuthMode::ApiKeyEntry(_),
|
mode: AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
@@ -340,6 +408,7 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
|
|||||||
}
|
}
|
||||||
app.next_auth_request_seq += 1;
|
app.next_auth_request_seq += 1;
|
||||||
app.auth_code_input.clear();
|
app.auth_code_input.clear();
|
||||||
|
app.custom_entry.clear();
|
||||||
app.auth_state = AuthState::Pending { error: None };
|
app.auth_state = AuthState::Pending { error: None };
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
//! Top-level action router: maps actions and action results to handlers.
|
//! Top-level action router: maps actions and action results to handlers.
|
||||||
use super::auth::{
|
use super::auth::{
|
||||||
dispatch_begin_platform_key_entry, dispatch_cancel_login, dispatch_cancel_platform_key_entry,
|
dispatch_begin_custom_provider_entry, dispatch_begin_platform_key_entry, dispatch_cancel_login,
|
||||||
dispatch_login, dispatch_login_with, dispatch_logout, dispatch_open_login_picker,
|
dispatch_cancel_platform_key_entry, dispatch_login, dispatch_login_with, dispatch_logout,
|
||||||
dispatch_submit_auth_code, dispatch_submit_platform_api_key, dispatch_switch_account,
|
dispatch_open_login_picker, dispatch_submit_auth_code, dispatch_submit_custom_provider_input,
|
||||||
|
dispatch_submit_platform_api_key, dispatch_switch_account,
|
||||||
};
|
};
|
||||||
use super::ctx::{
|
use super::ctx::{
|
||||||
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
|
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
|
||||||
@@ -889,6 +890,10 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
|||||||
Action::BeginPlatformKeyEntry(target) => dispatch_begin_platform_key_entry(app, target),
|
Action::BeginPlatformKeyEntry(target) => dispatch_begin_platform_key_entry(app, target),
|
||||||
Action::CancelPlatformKeyEntry => dispatch_cancel_platform_key_entry(app),
|
Action::CancelPlatformKeyEntry => dispatch_cancel_platform_key_entry(app),
|
||||||
Action::SubmitPlatformApiKey(key) => dispatch_submit_platform_api_key(app, key),
|
Action::SubmitPlatformApiKey(key) => dispatch_submit_platform_api_key(app, key),
|
||||||
|
Action::BeginCustomProviderEntry(api) => dispatch_begin_custom_provider_entry(app, api),
|
||||||
|
Action::SubmitCustomProviderInput(input) => {
|
||||||
|
dispatch_submit_custom_provider_input(app, input)
|
||||||
|
}
|
||||||
Action::CopyAuthUrl => {
|
Action::CopyAuthUrl => {
|
||||||
if let AuthState::Authenticating {
|
if let AuthState::Authenticating {
|
||||||
auth_url: Some(url),
|
auth_url: Some(url),
|
||||||
|
|||||||
@@ -571,3 +571,140 @@ fn auth_complete_marks_the_authenticated_method_connected() {
|
|||||||
"other methods must stay unmarked"
|
"other methods must stay unmarked"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn custom_field(app: &AppView) -> crate::app::custom_entry::CustomEntryField {
|
||||||
|
match &app.auth_state {
|
||||||
|
AuthState::Authenticating {
|
||||||
|
mode: AuthMode::CustomProviderEntry(step),
|
||||||
|
..
|
||||||
|
} => step.field,
|
||||||
|
other => panic!("expected a custom provider step, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL, name, key; the last Enter emits one persist effect.
|
||||||
|
#[test]
|
||||||
|
fn custom_provider_steps_end_in_one_persist_effect() {
|
||||||
|
use crate::app::custom_entry::CustomEntryField;
|
||||||
|
use kigi_shell::models::custom::CustomApi;
|
||||||
|
|
||||||
|
let mut app = test_app();
|
||||||
|
app.auth_state = AuthState::Pending { error: None };
|
||||||
|
|
||||||
|
let effects = dispatch(
|
||||||
|
Action::BeginCustomProviderEntry(CustomApi::Anthropic),
|
||||||
|
&mut app,
|
||||||
|
);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
|
||||||
|
let seq = match &app.auth_state {
|
||||||
|
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
Action::SubmitCustomProviderInput("https://api.gw.example/v1/".into()),
|
||||||
|
&mut app,
|
||||||
|
);
|
||||||
|
assert_eq!(custom_field(&app), CustomEntryField::Name);
|
||||||
|
assert_eq!(
|
||||||
|
app.auth_code_input, "gw-example",
|
||||||
|
"the name is prefilled from the host"
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
|
||||||
|
assert_eq!(custom_field(&app), CustomEntryField::Key);
|
||||||
|
assert!(app.auth_code_input.is_empty(), "the key box starts empty");
|
||||||
|
|
||||||
|
let effects = dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||||
|
match effects.as_slice() {
|
||||||
|
[
|
||||||
|
Effect::PersistCustomProviderAndAuthenticate {
|
||||||
|
request_seq,
|
||||||
|
provider,
|
||||||
|
key,
|
||||||
|
},
|
||||||
|
] => {
|
||||||
|
assert_eq!(*request_seq, seq);
|
||||||
|
assert_eq!(provider.name, "gw");
|
||||||
|
assert_eq!(provider.api, CustomApi::Anthropic);
|
||||||
|
assert_eq!(provider.base_url, "https://api.gw.example/v1");
|
||||||
|
assert_eq!(key, "sk-gw");
|
||||||
|
}
|
||||||
|
other => panic!("expected exactly the persist effect, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
app.auth_state,
|
||||||
|
AuthState::Authenticating {
|
||||||
|
request_seq,
|
||||||
|
mode: AuthMode::Pending,
|
||||||
|
..
|
||||||
|
} if request_seq == seq
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
app.auth_in_flight_method.as_ref().map(|m| m.0.as_ref()),
|
||||||
|
Some("custom:gw")
|
||||||
|
);
|
||||||
|
assert!(app.auth_code_input.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A refused field keeps the user on it.
|
||||||
|
#[test]
|
||||||
|
fn custom_provider_refusals_stay_on_the_field() {
|
||||||
|
use crate::app::custom_entry::CustomEntryField;
|
||||||
|
use kigi_shell::models::custom::CustomApi;
|
||||||
|
|
||||||
|
let mut app = test_app();
|
||||||
|
app.auth_state = AuthState::Pending { error: None };
|
||||||
|
dispatch(
|
||||||
|
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||||
|
&mut app,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
dispatch(
|
||||||
|
Action::SubmitCustomProviderInput("host.example".into()),
|
||||||
|
&mut app
|
||||||
|
)
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
|
||||||
|
assert!(app.custom_entry.error.is_some());
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
Action::SubmitCustomProviderInput("https://h.example/v1".into()),
|
||||||
|
&mut app,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dispatch(
|
||||||
|
Action::SubmitCustomProviderInput("anthropic".into()),
|
||||||
|
&mut app
|
||||||
|
)
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(custom_field(&app), CustomEntryField::Name);
|
||||||
|
assert!(app.custom_entry.error.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Esc returns to the picker and drops the draft.
|
||||||
|
#[test]
|
||||||
|
fn cancel_custom_provider_entry_returns_to_picker() {
|
||||||
|
use kigi_shell::models::custom::CustomApi;
|
||||||
|
|
||||||
|
let mut app = test_app();
|
||||||
|
app.auth_state = AuthState::Pending { error: None };
|
||||||
|
dispatch(
|
||||||
|
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||||
|
&mut app,
|
||||||
|
);
|
||||||
|
dispatch(Action::SubmitCustomProviderInput("nope".into()), &mut app);
|
||||||
|
assert!(app.custom_entry.error.is_some());
|
||||||
|
let seq_before = app.next_auth_request_seq;
|
||||||
|
|
||||||
|
let effects = dispatch(Action::CancelPlatformKeyEntry, &mut app);
|
||||||
|
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
assert!(matches!(app.auth_state, AuthState::Pending { error: None }));
|
||||||
|
assert!(app.custom_entry.error.is_none());
|
||||||
|
assert!(app.next_auth_request_seq > seq_before);
|
||||||
|
}
|
||||||
@@ -126,6 +126,7 @@ fn test_app() -> AppView {
|
|||||||
login_method_id: None,
|
login_method_id: None,
|
||||||
auth_start_mode: AuthMode::Pending,
|
auth_start_mode: AuthMode::Pending,
|
||||||
auth_code_input: String::new(),
|
auth_code_input: String::new(),
|
||||||
|
custom_entry: Default::default(),
|
||||||
next_auth_request_seq: 1,
|
next_auth_request_seq: 1,
|
||||||
deferred_startup: Default::default(),
|
deferred_startup: Default::default(),
|
||||||
auth_use_oauth: false,
|
auth_use_oauth: false,
|
||||||
|
|||||||
@@ -1103,6 +1103,27 @@ pub(super) fn parse_usage_response(
|
|||||||
.map(|usage| usage.rows)
|
.map(|usage| usage.rows)
|
||||||
.map_err(|e| format!("Parse error: {e}"))
|
.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
|
/// 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
|
/// stall the event-loop thread (and would hang exit on `/quit`); the registry
|
||||||
/// is best-effort, so skip on contention.
|
/// is best-effort, so skip on contention.
|
||||||
|
|||||||
@@ -1637,18 +1637,11 @@ pub(crate) fn execute(
|
|||||||
{
|
{
|
||||||
return (ProbedAttachment::ProbeDropped, None);
|
return (ProbedAttachment::ProbeDropped, None);
|
||||||
}
|
}
|
||||||
if probe_bracketed
|
if let Some(verdict) = helpers::bracketed_origin_verdict(
|
||||||
&& crate::terminal::terminal_context()
|
probe_bracketed,
|
||||||
.brand
|
probe_text.as_deref(),
|
||||||
.delivers_ime_as_bracketed_paste()
|
) {
|
||||||
{
|
return (verdict, None);
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let (image_data, file_urls) = match crate::clipboard::system_clipboard_probe_attachments(
|
let (image_data, file_urls) = match crate::clipboard::system_clipboard_probe_attachments(
|
||||||
probe_text.as_deref(),
|
probe_text.as_deref(),
|
||||||
@@ -1918,6 +1911,35 @@ pub(crate) fn execute(
|
|||||||
});
|
});
|
||||||
meta.auth_abort_handle = Some((request_seq, abort_handle));
|
meta.auth_abort_handle = Some((request_seq, abort_handle));
|
||||||
}
|
}
|
||||||
|
Effect::PersistCustomProviderAndAuthenticate {
|
||||||
|
request_seq,
|
||||||
|
provider,
|
||||||
|
key,
|
||||||
|
} => {
|
||||||
|
let tx = acp_tx.clone();
|
||||||
|
let abort_handle = tasks.spawn(async move {
|
||||||
|
// Persist first so authenticate finds the definition and key.
|
||||||
|
if let Err(e) =
|
||||||
|
kigi_shell::agent::custom_providers::save_custom_provider(&provider, &key)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let error = format!("Couldn't save the provider: {e:#}");
|
||||||
|
ulog::error(
|
||||||
|
"custom provider persist failed",
|
||||||
|
None,
|
||||||
|
Some(serde_json::json!({ "error" : & error })),
|
||||||
|
);
|
||||||
|
return TaskResult::AuthFailed {
|
||||||
|
request_seq,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let method_id =
|
||||||
|
kigi_shell::agent::custom_providers::login_method_id(&provider.name);
|
||||||
|
send_authenticate(&tx, request_seq, method_id, false, false).await
|
||||||
|
});
|
||||||
|
meta.auth_abort_handle = Some((request_seq, abort_handle));
|
||||||
|
}
|
||||||
Effect::SubmitAuthCode { request_seq, code } => {
|
Effect::SubmitAuthCode { request_seq, code } => {
|
||||||
let tx = acp_tx.clone();
|
let tx = acp_tx.clone();
|
||||||
tasks
|
tasks
|
||||||
|
|||||||
@@ -1664,3 +1664,37 @@ fn session_picker_entry_maps_to_dormant_roster_row() {
|
|||||||
assert_eq!(roster.origin.kind, "local");
|
assert_eq!(roster.origin.kind, "local");
|
||||||
assert_eq!(roster.origin.host.as_deref(), Some("box"));
|
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 app_view;
|
||||||
pub mod bundle;
|
pub mod bundle;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
pub mod custom_entry;
|
||||||
pub use crate::link_opener;
|
pub use crate::link_opener;
|
||||||
/// Off-thread full-file syntax highlight upgrade for edit diffs.
|
/// Off-thread full-file syntax highlight upgrade for edit diffs.
|
||||||
pub mod edit_highlight_worker;
|
pub mod edit_highlight_worker;
|
||||||
|
|||||||
@@ -106,8 +106,16 @@ impl AgentView {
|
|||||||
return Some(false);
|
return Some(false);
|
||||||
}
|
}
|
||||||
// Clean edit — silently exit editing mode.
|
// Clean edit — silently exit editing mode.
|
||||||
|
// The exit cleared the overlay flip; restore it.
|
||||||
self.exit_editing_mode();
|
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);
|
return Some(true);
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -487,7 +495,7 @@ impl AgentView {
|
|||||||
self.show_toast("Queued prompt is no longer in the queue");
|
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`.
|
/// No-op unless `EditingQueued`.
|
||||||
///
|
///
|
||||||
/// Always resets `prompt_input_mode` to `Normal` so it doesn't leak
|
/// 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 { .. })) {
|
if matches!(self.active_modal, Some(ActiveModal::EditConfirm { .. })) {
|
||||||
self.active_modal = None;
|
self.active_modal = None;
|
||||||
}
|
}
|
||||||
// Return focus to queue pane (if still visible).
|
// Queue focus would make the next Enter re-open the edit.
|
||||||
// Force=true: we just cleared editing mode, no lock to check.
|
self.set_active_pane(AgentPane::Prompt, true);
|
||||||
if self.queue.is_visible() {
|
|
||||||
self.set_active_pane(AgentPane::Queue, true);
|
|
||||||
} else {
|
|
||||||
self.set_active_pane(AgentPane::Scrollback, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,6 +557,39 @@ mod tests {
|
|||||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
|
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) {
|
fn attach_image_to_local_row(agent: &mut AgentView) {
|
||||||
let mut image = test_pasted_image();
|
let mut image = test_pasted_image();
|
||||||
image.display_number = 1;
|
image.display_number = 1;
|
||||||
|
|||||||
@@ -96,16 +96,15 @@ impl SlashCommand for TerminalSetupCommand {
|
|||||||
|
|
||||||
// Some terminals can't distinguish Shift+Enter from bare Enter at
|
// Some terminals can't distinguish Shift+Enter from bare Enter at
|
||||||
// the byte level because the Kitty keyboard protocol isn't
|
// the byte level because the Kitty keyboard protocol isn't
|
||||||
// negotiated (VTE < 0.82, or VS Code's xterm.js which mis-encodes
|
// negotiated (VTE, VS Code's xterm.js, a multiplexer that eats
|
||||||
// shifted keys). Point users at Alt+Enter, which is reliably
|
// extended keys). Point users at Alt+Enter, which is reliably
|
||||||
// delivered as ESC+CR. Suppressed when the WezTerm warning fired:
|
// delivered as ESC+CR. Never for WezTerm: it binds Alt+Enter to
|
||||||
// stock WezTerm binds Alt+Enter to ToggleFullScreen, so advertising
|
// ToggleFullScreen, so its own warning's `\`+Enter guidance stands.
|
||||||
// it would contradict that warning's `\`+Enter guidance.
|
if ctx.shift_enter_unavailable() && !wezterm_kkp_off && ctx.brand != TerminalName::WezTerm {
|
||||||
if ctx.shift_enter_unavailable() && !wezterm_kkp_off {
|
|
||||||
let detail = if ctx.vte_version.is_some() || ctx.brand == TerminalName::Vte {
|
let detail = if ctx.vte_version.is_some() || ctx.brand == TerminalName::Vte {
|
||||||
match ctx.vte_version.as_deref() {
|
match ctx.vte_version.as_deref() {
|
||||||
Some(v) => format!("VTE {v}; need >= 8200 for Shift+Enter"),
|
Some(v) => format!("VTE {v}: no Kitty keyboard protocol"),
|
||||||
None => "legacy VTE; need VTE >= 0.82 for Shift+Enter".to_owned(),
|
None => "VTE: no Kitty keyboard protocol".to_owned(),
|
||||||
}
|
}
|
||||||
} else if matches!(
|
} else if matches!(
|
||||||
ctx.brand,
|
ctx.brand,
|
||||||
|
|||||||
@@ -322,6 +322,10 @@ impl ListItem for QueuedPromptEntry {
|
|||||||
fn search_text(&self) -> &str {
|
fn search_text(&self) -> &str {
|
||||||
&self.text
|
&self.text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn copy_text(&self) -> String {
|
||||||
|
self.text.clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind};
|
||||||
@@ -999,6 +1003,13 @@ mod tests {
|
|||||||
QueuedPrompt::plain(id, text, QueueEntryKind::Prompt)
|
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]
|
#[test]
|
||||||
fn reset_auto_show_edge_allows_requeue_after_external_hide() {
|
fn reset_auto_show_edge_allows_requeue_after_external_hide() {
|
||||||
let mut pane = QueuePane::new();
|
let mut pane = QueuePane::new();
|
||||||
|
|||||||
@@ -461,6 +461,7 @@ pub struct WelcomeRenderParams<'a> {
|
|||||||
pub auth_methods: &'a [acp::AuthMethod],
|
pub auth_methods: &'a [acp::AuthMethod],
|
||||||
pub login_label: Option<&'a str>,
|
pub login_label: Option<&'a str>,
|
||||||
pub auth_code_input: &'a str,
|
pub auth_code_input: &'a str,
|
||||||
|
pub custom_entry: &'a crate::app::custom_entry::CustomEntryDraft,
|
||||||
pub clipboard_copied: bool,
|
pub clipboard_copied: bool,
|
||||||
pub show_raw_url: bool,
|
pub show_raw_url: bool,
|
||||||
pub tip: Option<&'a str>,
|
pub tip: Option<&'a str>,
|
||||||
@@ -603,6 +604,7 @@ pub fn render_welcome(
|
|||||||
auth_url.as_deref(),
|
auth_url.as_deref(),
|
||||||
*mode,
|
*mode,
|
||||||
params.auth_code_input,
|
params.auth_code_input,
|
||||||
|
params.custom_entry,
|
||||||
params.clipboard_copied,
|
params.clipboard_copied,
|
||||||
params.show_raw_url,
|
params.show_raw_url,
|
||||||
);
|
);
|
||||||
@@ -1176,6 +1178,7 @@ fn render_welcome_authenticating(
|
|||||||
auth_url: Option<&str>,
|
auth_url: Option<&str>,
|
||||||
mode: AuthMode,
|
mode: AuthMode,
|
||||||
auth_code_input: &str,
|
auth_code_input: &str,
|
||||||
|
custom_entry: &crate::app::custom_entry::CustomEntryDraft,
|
||||||
clipboard_copied: bool,
|
clipboard_copied: bool,
|
||||||
show_raw_url: bool,
|
show_raw_url: bool,
|
||||||
) -> (Option<Rect>, Option<Rect>) {
|
) -> (Option<Rect>, Option<Rect>) {
|
||||||
@@ -1270,6 +1273,7 @@ fn render_welcome_authenticating(
|
|||||||
theme,
|
theme,
|
||||||
auth_code_input,
|
auth_code_input,
|
||||||
"Paste your token here...",
|
"Paste your token here...",
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hints
|
// Hints
|
||||||
@@ -1290,81 +1294,45 @@ fn render_welcome_authenticating(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AuthMode::ApiKeyEntry(target) => {
|
AuthMode::ApiKeyEntry(target) => {
|
||||||
// Platform API-key paste box: instruction + input + hints. No
|
// Platform API-key paste box.
|
||||||
// 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);
|
|
||||||
let instruction = format!(
|
let instruction = format!(
|
||||||
"Paste your {} API key (from {})",
|
"Paste your {} API key (from {})",
|
||||||
target.vendor(),
|
target.vendor(),
|
||||||
target.console_host()
|
target.console_host()
|
||||||
);
|
);
|
||||||
let msg_height = (instruction.len() as u16).div_ceil(inner_width);
|
render_entry_box(
|
||||||
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
|
content_area,
|
||||||
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,
|
|
||||||
buf,
|
buf,
|
||||||
theme,
|
theme,
|
||||||
auth_code_input,
|
top_pad,
|
||||||
"Paste your API key here...",
|
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![
|
AuthMode::CustomProviderEntry(step) => {
|
||||||
Span::styled(
|
let instruction = custom_entry.instruction(step);
|
||||||
"enter",
|
render_entry_box(
|
||||||
Style::default()
|
content_area,
|
||||||
.fg(theme.accent_user)
|
buf,
|
||||||
.add_modifier(Modifier::BOLD),
|
theme,
|
||||||
),
|
top_pad,
|
||||||
Span::styled(" submit ", Style::default().fg(theme.gray)),
|
logo_line_count,
|
||||||
Span::styled(
|
&EntryBox {
|
||||||
"esc",
|
instruction: &instruction,
|
||||||
Style::default()
|
error: custom_entry.error.as_deref(),
|
||||||
.fg(theme.accent_user)
|
input: auth_code_input,
|
||||||
.add_modifier(Modifier::BOLD),
|
placeholder: step.placeholder(),
|
||||||
),
|
masked: step.masks_input(),
|
||||||
Span::styled(" back", Style::default().fg(theme.gray)),
|
},
|
||||||
])
|
);
|
||||||
.alignment(Alignment::Center);
|
|
||||||
Paragraph::new(hints).render(hint_area, buf);
|
|
||||||
|
|
||||||
(None, None)
|
(None, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1970,6 +1938,102 @@ pub(crate) fn render_session_picker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One text field under the logo.
|
||||||
|
struct EntryBox<'a> {
|
||||||
|
instruction: &'a str,
|
||||||
|
error: Option<&'a str>,
|
||||||
|
input: &'a str,
|
||||||
|
placeholder: &'a str,
|
||||||
|
masked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_entry_box(
|
||||||
|
content_area: Rect,
|
||||||
|
buf: &mut Buffer,
|
||||||
|
theme: &Theme,
|
||||||
|
top_pad: u16,
|
||||||
|
logo_line_count: u16,
|
||||||
|
entry: &EntryBox<'_>,
|
||||||
|
) {
|
||||||
|
let h_pad: u16 = content_area.width / 6;
|
||||||
|
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
|
||||||
|
let rows = |text: &str| (text.len() as u16).div_ceil(inner_width);
|
||||||
|
let msg_height = rows(entry.instruction) + entry.error.map_or(0, rows);
|
||||||
|
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
|
||||||
|
Constraint::Length(top_pad),
|
||||||
|
Constraint::Length(logo_line_count),
|
||||||
|
// gap
|
||||||
|
Constraint::Length(1),
|
||||||
|
// instruction
|
||||||
|
Constraint::Length(msg_height),
|
||||||
|
// gap
|
||||||
|
Constraint::Min(1),
|
||||||
|
// prompt box
|
||||||
|
Constraint::Length(5),
|
||||||
|
// gap
|
||||||
|
Constraint::Length(1),
|
||||||
|
// hints
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(0),
|
||||||
|
])
|
||||||
|
.areas(content_area);
|
||||||
|
|
||||||
|
render_logo(logo_area, buf, theme, content_area.height);
|
||||||
|
|
||||||
|
let mut lines = vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
entry.instruction,
|
||||||
|
Style::default().fg(theme.gray_bright),
|
||||||
|
))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
];
|
||||||
|
if let Some(error) = entry.error {
|
||||||
|
lines.push(
|
||||||
|
Line::from(Span::styled(error, Style::default().fg(theme.warning)))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Paragraph::new(lines)
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.block(Block::default().padding(Padding::horizontal(h_pad)))
|
||||||
|
.render(msg_area, buf);
|
||||||
|
|
||||||
|
let [_, prompt_centered, _] = Layout::horizontal([
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(content_area.width),
|
||||||
|
Constraint::Min(0),
|
||||||
|
])
|
||||||
|
.flex(Flex::Center)
|
||||||
|
.areas(prompt_area);
|
||||||
|
render_auth_input_box(
|
||||||
|
prompt_centered,
|
||||||
|
buf,
|
||||||
|
theme,
|
||||||
|
entry.input,
|
||||||
|
entry.placeholder,
|
||||||
|
entry.masked,
|
||||||
|
);
|
||||||
|
|
||||||
|
let hints = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"enter",
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.accent_user)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(" submit ", Style::default().fg(theme.gray)),
|
||||||
|
Span::styled(
|
||||||
|
"esc",
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.accent_user)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(" back", Style::default().fg(theme.gray)),
|
||||||
|
])
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
Paragraph::new(hints).render(hint_area, buf);
|
||||||
|
}
|
||||||
|
|
||||||
/// Render the auth token input box (loopback mode).
|
/// Render the auth token input box (loopback mode).
|
||||||
fn render_auth_input_box(
|
fn render_auth_input_box(
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@@ -1977,6 +2041,7 @@ fn render_auth_input_box(
|
|||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
input: &str,
|
input: &str,
|
||||||
placeholder: &str,
|
placeholder: &str,
|
||||||
|
masked: bool,
|
||||||
) {
|
) {
|
||||||
let prompt_block = Block::default()
|
let prompt_block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
@@ -1993,8 +2058,10 @@ fn render_auth_input_box(
|
|||||||
if inner.height > 0 && inner.width > 2 {
|
if inner.height > 0 && inner.width > 2 {
|
||||||
let display = if input.is_empty() {
|
let display = if input.is_empty() {
|
||||||
placeholder.to_string()
|
placeholder.to_string()
|
||||||
} else {
|
} else if masked {
|
||||||
mask_auth_token_for_display(input)
|
mask_auth_token_for_display(input)
|
||||||
|
} else {
|
||||||
|
input.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let style = if input.is_empty() {
|
let style = if input.is_empty() {
|
||||||
@@ -2078,6 +2145,8 @@ fn mask_auth_token_for_display(input: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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::app::app_view::SessionPickerEntry;
|
||||||
use crate::views::picker::PickerState;
|
use crate::views::picker::PickerState;
|
||||||
use crate::views::session_picker::{build_grouped_picker_entries, build_session_entry_data};
|
use crate::views::session_picker::{build_grouped_picker_entries, build_session_entry_data};
|
||||||
@@ -2127,6 +2196,7 @@ mod tests {
|
|||||||
auth_methods: &[],
|
auth_methods: &[],
|
||||||
login_label: None,
|
login_label: None,
|
||||||
auth_code_input: "",
|
auth_code_input: "",
|
||||||
|
custom_entry: &EMPTY_CUSTOM_ENTRY,
|
||||||
clipboard_copied: false,
|
clipboard_copied: false,
|
||||||
show_raw_url: false,
|
show_raw_url: false,
|
||||||
tip: None,
|
tip: None,
|
||||||
@@ -2323,6 +2393,71 @@ mod tests {
|
|||||||
assert!(!text.contains("Moonshot"), "{text}");
|
assert!(!text.contains("Moonshot"), "{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_custom_entry(
|
||||||
|
step: crate::app::custom_entry::CustomEntryStep,
|
||||||
|
draft: &crate::app::custom_entry::CustomEntryDraft,
|
||||||
|
input: &str,
|
||||||
|
) -> String {
|
||||||
|
let area = Rect::new(0, 0, 100, 40);
|
||||||
|
let mut buf = Buffer::empty(area);
|
||||||
|
render_welcome_authenticating(
|
||||||
|
area,
|
||||||
|
&mut buf,
|
||||||
|
&Theme::current(),
|
||||||
|
logo_line_count(area.height),
|
||||||
|
None,
|
||||||
|
AuthMode::CustomProviderEntry(step),
|
||||||
|
input,
|
||||||
|
draft,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
buffer_text(&buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the key step masks its input.
|
||||||
|
#[test]
|
||||||
|
fn custom_entry_arm_masks_only_the_key() {
|
||||||
|
use crate::app::custom_entry::{CustomEntryDraft, CustomEntryField, CustomEntryStep};
|
||||||
|
use kigi_shell::models::custom::CustomApi;
|
||||||
|
let step = |field| CustomEntryStep {
|
||||||
|
api: CustomApi::OpenAi,
|
||||||
|
field,
|
||||||
|
};
|
||||||
|
let draft = CustomEntryDraft::new();
|
||||||
|
|
||||||
|
let url = render_custom_entry(
|
||||||
|
step(CustomEntryField::BaseUrl),
|
||||||
|
&draft,
|
||||||
|
"https://very-long-host.example/v1",
|
||||||
|
);
|
||||||
|
assert!(url.contains("https://very-long-host.example/v1"), "{url}");
|
||||||
|
assert!(url.contains("OpenAI compatible"), "{url}");
|
||||||
|
|
||||||
|
let key = render_custom_entry(step(CustomEntryField::Key), &draft, "sk-abcdefghijklmnop");
|
||||||
|
assert!(
|
||||||
|
!key.contains("sk-abcdefghij"),
|
||||||
|
"the key prefix must stay hidden: {key}"
|
||||||
|
);
|
||||||
|
assert!(key.contains("mnop"), "{key}");
|
||||||
|
|
||||||
|
let empty = render_custom_entry(step(CustomEntryField::Name), &draft, "");
|
||||||
|
assert!(empty.contains("provider name"), "{empty}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A refusal shows under the instruction.
|
||||||
|
#[test]
|
||||||
|
fn custom_entry_arm_shows_the_refusal() {
|
||||||
|
use crate::app::custom_entry::{CustomEntryDraft, CustomEntryStep};
|
||||||
|
use kigi_shell::models::custom::CustomApi;
|
||||||
|
let step = CustomEntryStep::first(CustomApi::Anthropic);
|
||||||
|
let mut draft = CustomEntryDraft::new();
|
||||||
|
draft.submit(step, "host.example");
|
||||||
|
let text = render_custom_entry(step, &draft, "host.example");
|
||||||
|
assert!(text.contains("Anthropic compatible"), "{text}");
|
||||||
|
assert!(text.contains("must start with http"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
/// The Moonshot API-key entry arm renders the platform copy, the paste
|
/// The Moonshot API-key entry arm renders the platform copy, the paste
|
||||||
/// box, and the esc-back hint — and no OAuth-URL affordances.
|
/// box, and the esc-back hint — and no OAuth-URL affordances.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2343,6 +2478,7 @@ mod tests {
|
|||||||
)),
|
)),
|
||||||
// auth_code_input
|
// auth_code_input
|
||||||
"",
|
"",
|
||||||
|
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||||
// clipboard_copied
|
// clipboard_copied
|
||||||
false,
|
false,
|
||||||
// show_raw_url
|
// show_raw_url
|
||||||
@@ -3010,6 +3146,7 @@ mod tests {
|
|||||||
AuthMode::Device,
|
AuthMode::Device,
|
||||||
// auth_code_input — unused in device mode
|
// auth_code_input — unused in device mode
|
||||||
"",
|
"",
|
||||||
|
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||||
// clipboard_copied
|
// clipboard_copied
|
||||||
false,
|
false,
|
||||||
// show_raw_url
|
// show_raw_url
|
||||||
@@ -3066,6 +3203,7 @@ mod tests {
|
|||||||
Some(url),
|
Some(url),
|
||||||
AuthMode::Device,
|
AuthMode::Device,
|
||||||
"",
|
"",
|
||||||
|
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||||
false,
|
false,
|
||||||
// show_raw_url
|
// show_raw_url
|
||||||
true,
|
true,
|
||||||
@@ -3093,6 +3231,7 @@ mod tests {
|
|||||||
Some(url),
|
Some(url),
|
||||||
AuthMode::Device,
|
AuthMode::Device,
|
||||||
"",
|
"",
|
||||||
|
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||||
false,
|
false,
|
||||||
// show_raw_url
|
// show_raw_url
|
||||||
true,
|
true,
|
||||||
@@ -3131,6 +3270,7 @@ mod tests {
|
|||||||
Some(url),
|
Some(url),
|
||||||
AuthMode::Device,
|
AuthMode::Device,
|
||||||
"",
|
"",
|
||||||
|
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||||
false,
|
false,
|
||||||
// show_raw_url
|
// show_raw_url
|
||||||
true,
|
true,
|
||||||
@@ -3172,6 +3312,7 @@ mod tests {
|
|||||||
AuthMode::Command,
|
AuthMode::Command,
|
||||||
// auth_code_input — unused
|
// auth_code_input — unused
|
||||||
"",
|
"",
|
||||||
|
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||||
// clipboard_copied
|
// clipboard_copied
|
||||||
false,
|
false,
|
||||||
// show_raw_url
|
// 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;
|
mod auto_compact_top_row;
|
||||||
#[path = "pty_e2e/basename_path_demo_pty.rs"]
|
#[path = "pty_e2e/basename_path_demo_pty.rs"]
|
||||||
mod basename_path_demo_pty;
|
mod basename_path_demo_pty;
|
||||||
|
#[path = "pty_e2e/custom_provider.rs"]
|
||||||
|
mod custom_provider;
|
||||||
#[path = "pty_e2e/doubled_lines_out_of_band_repro.rs"]
|
#[path = "pty_e2e/doubled_lines_out_of_band_repro.rs"]
|
||||||
mod doubled_lines_out_of_band_repro;
|
mod doubled_lines_out_of_band_repro;
|
||||||
#[path = "pty_e2e/initial_prompt_positional_auto_submits.rs"]
|
#[path = "pty_e2e/initial_prompt_positional_auto_submits.rs"]
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ TARGETS = [
|
|||||||
"anthropic", "azure", "openai", "deepseek", "nvidia", "google",
|
"anthropic", "azure", "openai", "deepseek", "nvidia", "google",
|
||||||
"amazon-bedrock", "mistral", "groq", "cerebras", "cloudflare-ai-gateway",
|
"amazon-bedrock", "mistral", "groq", "cerebras", "cloudflare-ai-gateway",
|
||||||
"xai", "togetherai", "fireworks-ai", "opencode", "opencode-go",
|
"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",
|
"alibaba-token-plan", "alibaba-token-plan-cn", "xiaomi",
|
||||||
"xiaomi-token-plan-cn", "zai-coding-plan", "zhipuai-coding-plan",
|
"xiaomi-token-plan-cn", "zai-coding-plan", "zhipuai-coding-plan",
|
||||||
"github-copilot", "vercel",
|
"github-copilot", "vercel",
|
||||||
|
|||||||
Reference in new issue
Block a user