Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f8f637c81 | ||
|
|
2c9164b7fb | ||
|
|
b0e252c871 | ||
|
|
dc0cff220b | ||
|
|
10cbb8a5f1 | ||
|
|
0894206a9f | ||
|
|
086c3debe0 | ||
|
|
2bfcc72443 | ||
|
|
c5bcc035ca | ||
|
|
e352631791 | ||
|
|
47e5cb862f | ||
|
|
c0da2bd1e2 | ||
|
|
d8e0046ad6
|
||
|
|
ce792f180d
|
||
|
|
a848b8f06c
|
||
|
|
a21cb266c5
|
||
|
|
4d034cc5c8
|
||
|
|
ddecaea075
|
||
|
|
963a3383df | ||
|
|
cdeae6db89
|
||
|
|
719638875b | ||
|
|
e63d36468b | ||
|
|
389a931cad | ||
|
|
9a2a9eac99 | ||
|
|
1d631a07fa | ||
|
|
c56660fe07 | ||
|
|
3bde7fe851 | ||
|
|
793b93b74d | ||
|
|
c9855c4bb6 | ||
|
|
c4ef8a3779 | ||
|
|
ae40ba8da2 | ||
|
|
de42bb55c9 | ||
|
|
791f953c07 | ||
|
|
eb86d7ec26 | ||
|
|
2ca1f3baa7 | ||
|
|
4514b66ffe | ||
|
|
45edd7bf33 | ||
|
|
ceff1d7cc7 | ||
|
|
041c5ca030 | ||
|
|
0e063e5b02 | ||
|
|
b840fff80c |
No files matched your search
@@ -0,0 +1,49 @@
|
||||
name: Bug Report
|
||||
description: Something behaves, draws or builds wrong.
|
||||
title: "[BUG REPORT] "
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: where
|
||||
attributes:
|
||||
label: Where
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: A screenshot or a short recording helps most.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What you expected
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: "Version (`kigi --version`)"
|
||||
placeholder: "0.1.21"
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: Terminal output or error messages.
|
||||
render: shell
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Kigi
|
||||
url: https://kigicli.dev
|
||||
about: "Install steps and docs."
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Feature Request
|
||||
description: Something new, or something that should do more.
|
||||
title: "[FEATURE REQUEST] "
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: use
|
||||
attributes:
|
||||
label: What it is for
|
||||
description: The problem, and the moment you need it in.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: behavior
|
||||
attributes:
|
||||
label: How it should work
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: References
|
||||
description: Links or screenshots of it elsewhere.
|
||||
- type: checkboxes
|
||||
id: searched
|
||||
attributes:
|
||||
label: Checked
|
||||
options:
|
||||
- label: I searched the open issues for the same request.
|
||||
required: true
|
||||
@@ -0,0 +1,11 @@
|
||||
name: Other
|
||||
description: Questions, docs, ideas, anything else.
|
||||
title: "[Other] "
|
||||
labels: [question]
|
||||
body:
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What's on your mind
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,35 @@
|
||||
<!--
|
||||
Title: start with the type, for example
|
||||
[FEATURE REQUEST] Add dark mode to the settings page
|
||||
[BUG REPORT] Fix a crash when opening an empty file
|
||||
[Other] Update the README install steps
|
||||
-->
|
||||
|
||||
## Type
|
||||
|
||||
- [ ] **[FEATURE REQUEST]**: a new feature or an improvement
|
||||
- [ ] **[BUG REPORT]**: a bug fix
|
||||
- [ ] **[Other]**: docs, refactoring, CI, dependencies or chores
|
||||
|
||||
## What and why
|
||||
|
||||
<!-- One or two sentences. Link the issue: Closes #123 -->
|
||||
|
||||
## Bug details
|
||||
|
||||
<!-- [BUG REPORT] only; delete this section otherwise. -->
|
||||
|
||||
- Steps to reproduce:
|
||||
- Expected:
|
||||
- Actual:
|
||||
- Cause and fix:
|
||||
|
||||
## How it was tested
|
||||
|
||||
<!-- Commands you ran, and what you checked by hand. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] The title starts with `[FEATURE REQUEST]`, `[BUG REPORT]` or `[Other]`
|
||||
- [ ] Tests and the build pass locally
|
||||
- [ ] Docs are updated if behaviour changed
|
||||
+178
-70
@@ -1,12 +1,26 @@
|
||||
name: Release
|
||||
|
||||
# PRD F8: on tag push v*, build the single-file kigi binary for the five
|
||||
# supported targets, package per-target archives named
|
||||
# kigi-<version>-<target-triple>.{tar.gz|zip} (binary + LICENSE + NOTICE +
|
||||
# THIRD-PARTY-NOTICES), generate SHA256SUMS, and publish everything as a
|
||||
# GitHub Release. install.sh / install.ps1 and the in-app self-updater
|
||||
# (kigi-update) both resolve these exact asset names — keep the naming in
|
||||
# lockstep with auto_update::release_asset_name().
|
||||
# PRD F8. On a v* tag push, build the single-file kigi binary for the five
|
||||
# supported targets, package kigi-<version>-<target-triple>.{tar.gz|zip}
|
||||
# (binary + LICENSE + NOTICE + THIRD-PARTY-NOTICES), and publish them as a
|
||||
# GitHub Release alongside SHA256SUMS. install.sh, install.ps1 and
|
||||
# auto_update::release_asset_name() all resolve these exact names — keep the
|
||||
# naming in lockstep with them.
|
||||
#
|
||||
# RUNNERS. GitHub-hosted only; no job needs local state, and the repo is
|
||||
# public, so nothing here may move to a self-hosted runner.
|
||||
#
|
||||
# ubuntu-latest x86_64-unknown-linux-gnu
|
||||
# ubuntu-24.04-arm aarch64-unknown-linux-gnu, built natively (no cross C
|
||||
# toolchain for ring, mimalloc, zstd-sys, libz-sys)
|
||||
# macos-latest both Darwin targets; the runner is arm64, so x86_64 is
|
||||
# a cross-compile against the same SDK
|
||||
# windows-latest x86_64-pc-windows-msvc (VS Build Tools preinstalled,
|
||||
# needed for the static MSVC CRT link)
|
||||
#
|
||||
# PUBLISH ORDER. The release is created as a draft and only made public once
|
||||
# SHA256SUMS is attached, so `releases/latest` never hands the installer a
|
||||
# release whose checksum manifest is still missing.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -17,27 +31,17 @@ permissions:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
# Matches the version pinned by the dotslash launcher in bin/protoc. That
|
||||
# launcher is not used here: it has no Windows entry and needs a `cargo
|
||||
# install dotslash` first, so every platform fetches protoc directly.
|
||||
PROTOC_VERSION: "29.3"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build (${{ matrix.target }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-14
|
||||
# macos-14 runners are arm64; the x86_64 slice is a cross-compile
|
||||
# against the same SDK (macos-13 Intel runners are deprecated).
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-14
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-24.04
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-24.04-arm
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-2022
|
||||
runs-on: ${{ matrix.os }}
|
||||
prepare:
|
||||
name: prepare draft release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -51,39 +55,116 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install toolchain (rust-toolchain.toml)
|
||||
run: rustup show
|
||||
# Idempotent so a re-run of a failed tag build reuses the same draft
|
||||
# instead of erroring on a duplicate tag_name.
|
||||
- name: Create or reuse draft release
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
if gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1; then
|
||||
echo "Reusing release $GITHUB_REF_NAME"
|
||||
exit 0
|
||||
fi
|
||||
prerelease=""
|
||||
case "$GITHUB_REF_NAME" in *-*) prerelease="--prerelease" ;; esac
|
||||
gh release create "$GITHUB_REF_NAME" --draft --verify-tag \
|
||||
--title "$GITHUB_REF_NAME" --generate-notes $prerelease
|
||||
echo "Created draft release $GITHUB_REF_NAME ${prerelease}"
|
||||
|
||||
- name: Add build target
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
build:
|
||||
name: build (${{ matrix.target }})
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
runner: ubuntu-latest
|
||||
protoc: linux-x86_64
|
||||
ext: tar.gz
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
runner: ubuntu-24.04-arm
|
||||
protoc: linux-aarch_64
|
||||
ext: tar.gz
|
||||
- target: aarch64-apple-darwin
|
||||
runner: macos-latest
|
||||
protoc: osx-aarch_64
|
||||
ext: tar.gz
|
||||
- target: x86_64-apple-darwin
|
||||
runner: macos-latest
|
||||
protoc: osx-aarch_64
|
||||
ext: tar.gz
|
||||
- target: x86_64-pc-windows-msvc
|
||||
runner: windows-latest
|
||||
protoc: win64
|
||||
ext: zip
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dotslash (protoc launcher)
|
||||
run: cargo install dotslash --locked
|
||||
# ── protoc ────────────────────────────────────────────────────────────
|
||||
- name: Install protoc (unix)
|
||||
if: matrix.ext == 'tar.gz'
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
url="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-${{ matrix.protoc }}.zip"
|
||||
curl -fsSL -o protoc.zip "$url"
|
||||
rm -rf "$HOME/protoc"
|
||||
unzip -q -o protoc.zip -d "$HOME/protoc"
|
||||
chmod +x "$HOME/protoc/bin/protoc"
|
||||
echo "$HOME/protoc/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# Windows cannot exec the dotslash launcher directly (no shebang
|
||||
# support → os error 193), and the build script's documented fallback
|
||||
# is a protoc on PATH — install the same version the dotslash file
|
||||
# pins (v29.3).
|
||||
- name: Install protoc (Windows PATH fallback)
|
||||
if: runner.os == 'Windows'
|
||||
- name: Install protoc (windows)
|
||||
if: matrix.ext == 'zip'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-win64.zip"
|
||||
$ErrorActionPreference = "Stop"
|
||||
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v$env:PROTOC_VERSION/protoc-$env:PROTOC_VERSION-${{ matrix.protoc }}.zip"
|
||||
Invoke-WebRequest -Uri $url -OutFile protoc.zip
|
||||
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\protoc"
|
||||
Add-Content $env:GITHUB_PATH "$env:USERPROFILE\protoc\bin"
|
||||
$dest = Join-Path $env:USERPROFILE "protoc"
|
||||
if (Test-Path $dest) { Remove-Item $dest -Recurse -Force }
|
||||
Expand-Archive protoc.zip -DestinationPath $dest -Force
|
||||
Add-Content -Path $env:GITHUB_PATH -Value (Join-Path $dest "bin")
|
||||
|
||||
# ── toolchain ─────────────────────────────────────────────────────────
|
||||
# rust-toolchain.toml pins the channel (1.97.0); `rustup show`
|
||||
# materialises exactly that.
|
||||
- name: Install toolchain
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
# The arm64 Linux image is not guaranteed to ship rustup.
|
||||
# `--default-toolchain none` leaves the channel choice to
|
||||
# rust-toolchain.toml.
|
||||
if ! command -v rustup >/dev/null; then
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal --default-toolchain none
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
fi
|
||||
rustup show
|
||||
rustup target add ${{ matrix.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Build kigi (release-dist)
|
||||
# ── build ─────────────────────────────────────────────────────────────
|
||||
- name: Build kigi
|
||||
shell: bash
|
||||
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
|
||||
|
||||
- name: Package archive (tar.gz)
|
||||
if: runner.os != 'Windows'
|
||||
# ── package, upload, checksum ─────────────────────────────────────────
|
||||
# Archives go straight to the release rather than through the artifact
|
||||
# store: they are large, and the release is the only place install.sh
|
||||
# can read them from anyway. Only the checksum line travels as an
|
||||
# artifact, for the publish job to concatenate.
|
||||
- name: Package (unix)
|
||||
if: matrix.ext == 'tar.gz'
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
archive="kigi-${version}-${{ matrix.target }}.tar.gz"
|
||||
staging="$(mktemp -d)"
|
||||
@@ -94,54 +175,81 @@ jobs:
|
||||
[ -f "$f" ] && cp "$f" "$staging/"
|
||||
done
|
||||
tar -C "$staging" -czf "$archive" .
|
||||
shasum -a 256 "$archive" || sha256sum "$archive"
|
||||
echo "ARCHIVE=$archive" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Package archive (zip)
|
||||
if: runner.os == 'Windows'
|
||||
- name: Package (windows)
|
||||
if: matrix.ext == 'zip'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$version = $env:GITHUB_REF_NAME.TrimStart("v")
|
||||
$archive = "kigi-$version-${{ matrix.target }}.zip"
|
||||
$staging = New-Item -ItemType Directory -Path (Join-Path $env:RUNNER_TEMP "staging")
|
||||
$staging = Join-Path $env:RUNNER_TEMP "staging"
|
||||
if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $staging | Out-Null
|
||||
Copy-Item "target/${{ matrix.target }}/release-dist/kigi.exe" (Join-Path $staging "kigi.exe")
|
||||
Copy-Item LICENSE $staging
|
||||
if (Test-Path NOTICE) { Copy-Item NOTICE $staging }
|
||||
foreach ($f in @("THIRD-PARTY-NOTICES", "THIRD-PARTY-NOTICES.md")) {
|
||||
if (Test-Path $f) { Copy-Item $f $staging }
|
||||
}
|
||||
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive
|
||||
Get-FileHash -Algorithm SHA256 $archive
|
||||
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive -Force
|
||||
Add-Content -Path $env:GITHUB_ENV -Value "ARCHIVE=$archive"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
# Git Bash on Windows; sha256sum is available on every runner except
|
||||
# macOS, which has shasum.
|
||||
- name: Upload and checksum
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
gh release upload "$GITHUB_REF_NAME" "$ARCHIVE" --clobber
|
||||
echo "Uploaded $ARCHIVE"
|
||||
if command -v sha256sum >/dev/null; then
|
||||
sha256sum "$ARCHIVE" > "checksum-${{ matrix.target }}.txt"
|
||||
else
|
||||
shasum -a 256 "$ARCHIVE" > "checksum-${{ matrix.target }}.txt"
|
||||
fi
|
||||
cat "checksum-${{ matrix.target }}.txt"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.ARCHIVE }}
|
||||
path: ${{ env.ARCHIVE }}
|
||||
name: checksum-${{ matrix.target }}
|
||||
path: checksum-${{ matrix.target }}.txt
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: publish GitHub Release
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
publish:
|
||||
name: publish release
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
path: checksums
|
||||
pattern: checksum-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
working-directory: dist
|
||||
- name: Assemble SHA256SUMS
|
||||
shell: bash
|
||||
run: |
|
||||
ls -l
|
||||
sha256sum kigi-* > SHA256SUMS
|
||||
set -eu
|
||||
# Strip CR (Git Bash may emit CRLF) and the binary-mode `*` marker
|
||||
# so every line is `<hash> <name>` for install.sh.
|
||||
cat checksums/checksum-*.txt \
|
||||
| tr -d '\r' | sed 's/ \*/ /' | grep -v '^$' | sort -k2 > SHA256SUMS
|
||||
lines="$(wc -l < SHA256SUMS)"
|
||||
if [ "$lines" -ne 5 ]; then
|
||||
echo "expected 5 checksum lines, got $lines" >&2
|
||||
cat SHA256SUMS >&2
|
||||
exit 1
|
||||
fi
|
||||
cat SHA256SUMS
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/*
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
# Flip the draft public only now that every asset install.sh looks for
|
||||
# is attached.
|
||||
- name: Attach SHA256SUMS and publish
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
gh release upload "$GITHUB_REF_NAME" SHA256SUMS --clobber
|
||||
gh release edit "$GITHUB_REF_NAME" --draft=false
|
||||
echo "Published $GITHUB_REF_NAME"
|
||||
@@ -1,85 +0,0 @@
|
||||
name: Warm build cache
|
||||
|
||||
# Release builds run on TAG refs, and GitHub Actions cache isolation only
|
||||
# lets a run restore caches created on its OWN ref or the DEFAULT branch.
|
||||
# No workflow ran on `main`, so every release compiled the whole workspace
|
||||
# cold on all five targets (~50 min wall clock, gated by Windows). This
|
||||
# workflow builds the same `release-dist` profile on `main` so tag builds
|
||||
# restore a warm default-branch cache.
|
||||
#
|
||||
# Triggers: dependency-affecting pushes to main (each release's version-bump
|
||||
# commit warms the cache for the NEXT release), a weekly refresh so the
|
||||
# cache never hits GitHub's 7-day unused-eviction, and manual dispatch.
|
||||
#
|
||||
# The setup steps mirror release.yml's build job (toolchain, target,
|
||||
# dotslash/protoc, rust-cache key) — keep them in lockstep, or the cache
|
||||
# key won't match and releases go back to cold builds.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "Cargo.lock"
|
||||
- "Cargo.toml"
|
||||
- "rust-toolchain.toml"
|
||||
- ".github/workflows/warm-cache.yml"
|
||||
schedule:
|
||||
- cron: "17 5 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
concurrency:
|
||||
group: warm-cache
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
warm:
|
||||
name: warm (${{ matrix.target }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-14
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-14
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-24.04
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-24.04-arm
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-2022
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install toolchain (rust-toolchain.toml)
|
||||
run: rustup show
|
||||
|
||||
- name: Add build target
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install dotslash (protoc launcher)
|
||||
run: cargo install dotslash --locked
|
||||
|
||||
- name: Install protoc (Windows PATH fallback)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-win64.zip"
|
||||
Invoke-WebRequest -Uri $url -OutFile protoc.zip
|
||||
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\protoc"
|
||||
Add-Content $env:GITHUB_PATH "$env:USERPROFILE\protoc\bin"
|
||||
|
||||
# Same key as release.yml so tag builds restore this cache verbatim.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Build kigi (release-dist)
|
||||
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
|
||||
+10
-1
@@ -6,5 +6,14 @@
|
||||
.wrangler/
|
||||
node_modules/
|
||||
|
||||
# Runner-setup prompt; holds a Gitea registration token once filled in.
|
||||
# Runner-setup prompt; may hold a runner registration token once filled in.
|
||||
/prompt.txt
|
||||
|
||||
# Local test credentials
|
||||
cred.md
|
||||
|
||||
# Upstream xai-org/grok-build reference clone
|
||||
/grok-build/
|
||||
|
||||
# Local API keys (MiniMax TTS key for kigi-videos)
|
||||
env.md
|
||||
@@ -18,7 +18,17 @@ Upstream sync record (update on every sync): fork baseline is upstream
|
||||
snapshot `8adf901` (2026-07-16, ≈v0.2.102); bug fixes evaluated and
|
||||
selectively ported through upstream `77cd7eb` (2026-08-25, ≈v1.0.10) in
|
||||
the 0.1.13 cycle, through `3794978` (2026-09-09, ≈v1.0.24) in the 0.1.15
|
||||
cycle, and through `a28ee2b` (2026-09-17, ≈v1.0.35) in the 0.1.16 cycle.
|
||||
cycle, through `a28ee2b` (2026-09-17, ≈v1.0.35) in the 0.1.16 cycle, and
|
||||
through `2bdd1d6` (2026-09-29, ≈v1.0.45) in the 0.1.19 cycle. The
|
||||
0.1.22 check (2026-10-31) found upstream still at `2bdd1d6`: nothing new
|
||||
to port.
|
||||
The local release is 0.1.22; it self-updates from
|
||||
`ELY-Open-Source/Kigi-CLI` on GitHub (0.1.21 read Gitea). Custom
|
||||
providers declared by base URL (`[platforms.<name>]`, see CUSTOM
|
||||
PROVIDERS below) arrived in 0.1.19.
|
||||
Every upstream reference comes from the local clone at `./grok-build`
|
||||
(gitignored, never committed): `git -C grok-build pull` before a sync,
|
||||
then read upstream code and history there, not from the web.
|
||||
Upstream is daily "Synced from monorepo" snapshots —
|
||||
the commit BODIES carry per-change bullet lists, and
|
||||
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
|
||||
@@ -39,6 +49,17 @@ POST in turn, so the cancel queues behind the hung call;
|
||||
then drops the connection. stdio and SSE-mode servers get the cancel.
|
||||
The cure is rmcp 3.x (upstream runs 3.2), a major bump not yet taken.
|
||||
|
||||
Evaluated in the 0.1.19 window and left for a later cycle: headless `-p`
|
||||
interrupt handling (upstream `SignalStreams` plus a headless signals module; Kigi's
|
||||
`headless.rs` installs no handler, so SIGINT kills the run without session
|
||||
cleanup), the subagent "Cancelling" overlay that outlives the child turn (needs
|
||||
upstream's child-turn finalizer and its prompt-id bookkeeping), the honest
|
||||
answer for a `spawn_subagent` call cancelled by send-now (a chat-state answers
|
||||
channel), the effort picker preselecting the model's default effort and
|
||||
accepting menu labels (tied to upstream's `ModelChoice` refactor), and every MCP
|
||||
change that rides rmcp 3.x (`bearer_token_file`, argument coercion, elicitation
|
||||
notes). The rmcp and tree-sitter major bumps stay open.
|
||||
|
||||
Evaluated in the 0.1.16 window and left for a later cycle: MCP qualified
|
||||
tool names past 64 chars (first prove the name never reaches a provider
|
||||
function list), the auto-update guard for a binary still in use (needs
|
||||
@@ -403,15 +424,17 @@ client-side, no backend surface.
|
||||
carries the thinking level. NO websocket, NO base_instructions.
|
||||
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
|
||||
`openai_codex_wire_models`, mapped through the SAME
|
||||
`platform_wire_model_to_entry` output): the 7 `visibility=list` &&
|
||||
`supported_in_api=true` models the backend served on 2026-09-22
|
||||
(`gpt-6-astra/sol/luna`, `gpt-5.6-sol/terra/luna`, `gpt-5.5`, ctx 272000,
|
||||
per-model efforts) — NO live `/models` fetch, NO codex-CLI / `~/.codex`
|
||||
`platform_wire_model_to_entry` output): the 8 `visibility=list` &&
|
||||
`supported_in_api=true` models the backend served on 2026-09-29
|
||||
(`gpt-6.1-sol`, `gpt-6-astra/sol/luna`, `gpt-5.6-sol/terra/luna`,
|
||||
`gpt-5.5`, ctx 272000, per-model efforts) — NO live `/models` fetch, NO codex-CLI / `~/.codex`
|
||||
dependency; the hidden `gpt-reserve` and `codex-auto-review` are
|
||||
EXCLUDED. Refresh the table by reading
|
||||
`GET {base}/models?client_version=<a current codex release>` with a
|
||||
ChatGPT bearer: an old `client_version` is answered with a truncated
|
||||
list, so a stale version reads as "the model does not exist".
|
||||
`CODEX_USER_AGENT` (`codex_cli_rs/0.159.0`) moves with the release used
|
||||
for that read.
|
||||
- `OAuthFlow::GithubDeviceCopilot` → `auth::github_copilot` (TWO-STAGE).
|
||||
Provider: `github-copilot` (`scope_key oauth/github-copilot`, base
|
||||
`api.individual.githubcopilot.com`, ChatCompletions wire). Stage 1 is an
|
||||
@@ -445,6 +468,58 @@ client-side, no backend surface.
|
||||
token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes
|
||||
`platform.oauth().is_some()` → `platform.base_url()` (kimi-code alone →
|
||||
`proxy_url()`). Tokens/codes/verifiers are NEVER logged.
|
||||
- CUSTOM PROVIDERS (0.1.19): `[platforms.<name>]` with `base_url` and `api =
|
||||
"openai" | "anthropic"` declares a provider outside the closed registry.
|
||||
`kigi-models/src/custom.rs` owns the validated type (name `[a-z0-9][a-z0-9_-]{0,31}`,
|
||||
never a registry id; base URL `http(s)://host[/path]`, no userinfo, query or
|
||||
fragment); `kigi-shell/src/agent/custom_providers/` owns the rest. Key order:
|
||||
auth.json scope `<name>`, then the table's `api_key`; a key is REQUIRED (a
|
||||
keyless declaration is inert). Wires: openai = `/chat/completions`, Bearer,
|
||||
`GET {base}/models`; anthropic = `/messages`, `x-api-key`,
|
||||
`GET {base}/models?limit=1000`. Catalog keys are `<name>/<model>`; the entry's
|
||||
`description` carries the provider name for the picker. Entries reach the disk
|
||||
cache without a key; `stamp_credentials` stamps `api_key` in memory, only onto
|
||||
entries still on the provider's own host, and `drop_orphans` removes fetched
|
||||
entries of a provider that is gone or keyless BEFORE the prefetched map replaces
|
||||
the catalog (a stale unkeyed entry would fall through to the house key).
|
||||
CREDENTIAL RULE: `EndpointsConfig.custom_provider_bases` (derived from
|
||||
`[platforms.*]` in `Config::new_from_toml_cfg` and `from_config_value`, never
|
||||
serialized) makes `CredentialAuthority::is_session_coding_endpoint` false for a
|
||||
declared base, loopback included — without it an Ollama-style provider on
|
||||
localhost classifies `Primary` and the Kimi bearer replaces its key. Every
|
||||
authority is built from the models manager's config, so the field reaches all of
|
||||
them. Turns and subagents read `auth_scheme` and BYOK status from the LIVE
|
||||
catalog entry (`auth_facts_of`), not from a disk reload: fetched entries never
|
||||
live on disk, and the reload sent fetched Messages models out with `Bearer`
|
||||
instead of `x-api-key` (this also repaired `anthropic` and `minimax`).
|
||||
Login: picker rows `custom-openai` / `custom-anthropic` are not loginable
|
||||
(`AuthMethodKind::Unknown`); the TUI asks base URL, name (default from the
|
||||
host), key, then opens a MODEL-SELECTION screen (`custom_entry.rs`
|
||||
`CustomSelectState`, welcome `AuthMode::CustomProviderSelect`): the listing
|
||||
fetch (`fetch_listing_for_login`) runs concurrently with the manual-id input
|
||||
— both visible at once, failure shows its reason — space toggles fetched
|
||||
rows, enter adds a typed id or finishes (a finish pressed mid-fetch waits for
|
||||
it: `finish_requested`), and the chosen ids persist as `[platforms.<name>]
|
||||
models = [...]` via `save_custom_provider`. The listing fetch probes
|
||||
`{base}/v1` once when `{base}/models` 404s or answers unparsable (a WAF
|
||||
challenge is HTTP 200 HTML) and the base has no `/vN` tail, adopting the
|
||||
probed base for the saved definition; anthropic-wire listings that 401 on
|
||||
`x-api-key` retry once with `Bearer` (relays gate `GET /models` on Bearer
|
||||
while `/messages` takes `x-api-key`); listing errors never quote response
|
||||
content (line/column only — a reflected body could carry the key). Catalog
|
||||
assembly honors the allowlist in `selection.rs`: `synthesize_selected` (BEFORE
|
||||
`[model.*]` overrides, so an override composes with a synthesized entry)
|
||||
builds wire-default entries for selected ids the listing never served, and
|
||||
`filter_unselected` (AFTER overrides) is authoritative in the provider's
|
||||
namespace — even a `[model."name/x"]` entry for an unselected id is dropped.
|
||||
Provider-owned entries never take donor `api_backend` inheritance in
|
||||
`resolve_model_list` (the declared wire wins); no `models` key = the whole
|
||||
listing (the declarative path). `authenticate("custom:<name>")` then
|
||||
validates `GET {base}/models` (401/403 = rejected key, other non-2xx and network
|
||||
errors surface). Limits: context window 256K unless the listing serves
|
||||
`context_length` (Anthropic: `max_input_tokens`); no models.dev enrichment; a
|
||||
failed key check leaves the saved definition (login again with the same name to
|
||||
overwrite); no delete UI, edit config.toml and auth.json.
|
||||
- A stored subscription-OAuth session IS a catalog fetch source. Every
|
||||
fetch-PLAN decision that cannot afford real token resolution — the startup
|
||||
prefetch arming gate, `on_auth_changed`'s wipe guard
|
||||
|
||||
@@ -20,3 +20,10 @@ cargo deny check advisories
|
||||
first-party endpoints lives in `crates/codegen/kigi-env`.
|
||||
- Upstream syncs are evaluated commit-by-commit and cherry-picked manually;
|
||||
see the PRD §8.1 policy (`git range-diff` records the adopted set).
|
||||
|
||||
## Opening a pull request
|
||||
|
||||
Fork [ELY-Open-Source/Kigi-CLI](https://github.com/ELY-Open-Source/Kigi-CLI),
|
||||
push your branch to the fork, and open a pull request against `main`. Report
|
||||
bugs and request features on the
|
||||
[issue tracker](https://github.com/ELY-Open-Source/Kigi-CLI/issues).
|
||||
Generated
+119
-449
File diff suppressed because it is too large.
Load diff
+3
-4
@@ -76,7 +76,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.18"
|
||||
version = "0.1.22"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -191,7 +191,6 @@ reqwest = { version = "0.12", features = ["rustls-tls", "stream", "json", "multi
|
||||
reqwest-middleware = { version = "0.4.1", features = ["json", "multipart"] }
|
||||
resvg = { version = "0.47", default-features = false, features = ["text"] }
|
||||
ring = "0.17"
|
||||
rsa = "0.9"
|
||||
runfiles = "0.1"
|
||||
rustc-hash = "2"
|
||||
schemars = "1"
|
||||
@@ -295,8 +294,8 @@ kigi-tool-runtime = { path = "crates/common/kigi-tool-runtime" }
|
||||
kigi-tool-types = { path = "crates/common/kigi-tool-types" }
|
||||
kigi-tty-utils = { path = "crates/codegen/kigi-tty-utils" }
|
||||
zbus = { version = "5" }
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
zstd = "0.13"
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
zstd = "0.14"
|
||||
|
||||
[profile.release]
|
||||
incremental = true
|
||||
|
||||
@@ -86,8 +86,8 @@ On by default. `KIGI_GRAPH=0` turns it off; `KIGI_GRAPH_CONCURRENCY` (default
|
||||
|
||||
## Providers and API keys
|
||||
|
||||
29 platforms ship compiled in: 5 you sign into, 24 you hand a key. Nothing is
|
||||
registered at runtime — if it's not in this list, it's not there.
|
||||
29 platforms ship compiled in: 5 you sign into, 24 you hand a key. Anything
|
||||
else is an endpoint you declare yourself ([your own endpoint](#your-own-endpoint)).
|
||||
|
||||
**Sign in with a subscription you already pay for.** Run `kigi login` and pick.
|
||||
Each provider's token is stored under its own key, and one provider's
|
||||
@@ -149,6 +149,28 @@ api_key = "sk-..."
|
||||
api_key = "xai-..."
|
||||
```
|
||||
|
||||
### Your own endpoint
|
||||
|
||||
Anything that speaks the OpenAI Chat Completions API or the Anthropic Messages
|
||||
API works: a gateway, a proxy, Ollama, LM Studio, vLLM. In `kigi`, open `/login`
|
||||
(or the welcome login screen) and pick **Custom provider (OpenAI compatible)**
|
||||
or **(Anthropic compatible)**, then enter a base URL, a name, and an API key.
|
||||
Kigi saves the provider,
|
||||
checks the key against `GET {base}/models`, and `/model` lists what the endpoint
|
||||
serves as `name/model`.
|
||||
|
||||
```toml
|
||||
# ~/.kigi/config.toml
|
||||
[platforms.my-gateway]
|
||||
base_url = "https://gateway.example.com/v1" # include /v1 when the API lives there
|
||||
api = "openai" # or "anthropic"
|
||||
api_key = "sk-..." # or store it in ~/.kigi/auth.json under "my-gateway"
|
||||
```
|
||||
|
||||
A key is required. A local server that ignores auth takes any value. The
|
||||
context window is 256K unless the endpoint reports one. To remove a provider,
|
||||
delete its table and its `auth.json` entry.
|
||||
|
||||
Model lists sync on startup. Pick one with `/model`, set its thinking level
|
||||
with `/effort`.
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Security Policy
|
||||
|
||||
Kigi is a community build; report vulnerabilities on the
|
||||
[Kigi issue tracker](https://github.com/ZacharyZhang-NY/Kigi-CLI/issues).
|
||||
[Kigi issue tracker](https://github.com/ELY-Open-Source/Kigi-CLI/issues).
|
||||
Issues there are public, so please leave out any detail that would make a
|
||||
report directly exploitable — open with the affected area and impact, and a
|
||||
maintainer will follow up for the rest.
|
||||
|
||||
+261
-875
File diff suppressed because it is too large.
Load diff
@@ -43,3 +43,11 @@ targets = [
|
||||
# SPDX expression parsing cannot classify on its own.
|
||||
[ring]
|
||||
accepted = ["ISC", "MIT", "OpenSSL"]
|
||||
|
||||
# tonic 0.14.9 sources carry MIT headers; take only LICENSE.
|
||||
[tonic.clarify]
|
||||
license = "MIT"
|
||||
|
||||
[[tonic.clarify.files]]
|
||||
path = "LICENSE"
|
||||
checksum = "5c686e0f7978412b49187ae0e9f77e9b52eea9d8d2e6c0de8694b35817d873b1"
|
||||
@@ -21,7 +21,7 @@ pub struct KigiEndpoints {
|
||||
pub const PRODUCTION_ENDPOINTS: KigiEndpoints = KigiEndpoints {
|
||||
coding_api_base_url: "https://api.kimi.com/coding/v1",
|
||||
oauth_host: "https://auth.kimi.com",
|
||||
update_base_url: "https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases",
|
||||
update_base_url: "https://api.github.com/repos/ELY-Open-Source/Kigi-CLI/releases",
|
||||
upgrade_page_url: "https://www.kimi.com/code/",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
//! User-declared providers: a name, a base URL, and a wire.
|
||||
|
||||
use crate::{ListingDialect, PlatformChatCompat, PlatformKeyHeader, PlatformWireApi};
|
||||
|
||||
const MAX_NAME_LEN: usize = 32;
|
||||
|
||||
/// Wire dialect of a custom provider.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CustomApi {
|
||||
/// Chat Completions with a Bearer key.
|
||||
OpenAi,
|
||||
/// Messages with an `x-api-key` header.
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
impl CustomApi {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAi => "openai",
|
||||
Self::Anthropic => "anthropic",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"openai" => Some(Self::OpenAi),
|
||||
"anthropic" => Some(Self::Anthropic),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Login picker wording.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAi => "OpenAI compatible",
|
||||
Self::Anthropic => "Anthropic compatible",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wire_api(self) -> PlatformWireApi {
|
||||
match self {
|
||||
Self::OpenAi => PlatformWireApi::ChatCompletions,
|
||||
Self::Anthropic => PlatformWireApi::Messages,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn listing(self) -> ListingDialect {
|
||||
match self {
|
||||
Self::OpenAi => ListingDialect::OpenAi,
|
||||
Self::Anthropic => ListingDialect::Anthropic,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key_header(self) -> PlatformKeyHeader {
|
||||
match self {
|
||||
Self::OpenAi => PlatformKeyHeader::Bearer,
|
||||
Self::Anthropic => PlatformKeyHeader::XApiKey,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chat_compat(self) -> PlatformChatCompat {
|
||||
PlatformChatCompat::Passthrough
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a custom provider definition was refused.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CustomProviderError {
|
||||
Name(String),
|
||||
BaseUrl(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CustomProviderError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Name(reason) => write!(f, "provider name: {reason}"),
|
||||
Self::BaseUrl(reason) => write!(f, "provider base_url: {reason}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CustomProviderError {}
|
||||
|
||||
/// A validated provider definition.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CustomProvider {
|
||||
pub name: String,
|
||||
pub api: CustomApi,
|
||||
/// Without a trailing slash.
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl CustomProvider {
|
||||
pub fn new(name: &str, api: CustomApi, base_url: &str) -> Result<Self, CustomProviderError> {
|
||||
validate_name(name)?;
|
||||
Ok(Self {
|
||||
name: name.to_owned(),
|
||||
api,
|
||||
base_url: normalize_base_url(base_url)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The listing endpoint, with the dialect's query.
|
||||
pub fn models_url(&self) -> String {
|
||||
match self.api {
|
||||
CustomApi::OpenAi => format!("{}/models", self.base_url),
|
||||
// Anthropic paginates (default 20); 1000 is the documented max.
|
||||
CustomApi::Anthropic => format!("{}/models?limit=1000", self.base_url),
|
||||
}
|
||||
}
|
||||
|
||||
/// Catalog key `{name}/{model_id}`.
|
||||
pub fn managed_model_key(&self, model_id: &str) -> String {
|
||||
format!("{}/{model_id}", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Names become auth.json scopes and catalog key prefixes.
|
||||
pub fn validate_name(name: &str) -> Result<(), CustomProviderError> {
|
||||
let err = |reason: &str| Err(CustomProviderError::Name(reason.to_owned()));
|
||||
if name.is_empty() || name.len() > MAX_NAME_LEN {
|
||||
return err("must be 1 to 32 characters");
|
||||
}
|
||||
let mut chars = name.chars();
|
||||
if !chars
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
{
|
||||
return err("must start with a lowercase letter or digit");
|
||||
}
|
||||
if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
|
||||
return err("may hold only lowercase letters, digits, '-' and '_'");
|
||||
}
|
||||
if crate::PlatformId::parse(name).is_some() {
|
||||
return err("is a built-in platform id");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accepts `http(s)://host[:port][/path]`; the URL is logged, so no secrets.
|
||||
pub fn normalize_base_url(raw: &str) -> Result<String, CustomProviderError> {
|
||||
let err = |reason: &str| Err(CustomProviderError::BaseUrl(reason.to_owned()));
|
||||
let url = raw.trim().trim_end_matches('/');
|
||||
if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||
return err("must not contain whitespace");
|
||||
}
|
||||
let rest = ["https://", "http://"].iter().find_map(|scheme| {
|
||||
url.get(..scheme.len())
|
||||
.filter(|head| head.eq_ignore_ascii_case(scheme))
|
||||
.map(|_| &url[scheme.len()..])
|
||||
});
|
||||
let Some(rest) = rest else {
|
||||
return err("must start with http:// or https://");
|
||||
};
|
||||
let authority = rest.split('/').next().unwrap_or("");
|
||||
if authority.is_empty() {
|
||||
return err("has no host");
|
||||
}
|
||||
if authority.contains('@') {
|
||||
return err("must not carry credentials");
|
||||
}
|
||||
if url.contains('?') || url.contains('#') {
|
||||
return err("must not carry a query or fragment");
|
||||
}
|
||||
Ok(url.to_owned())
|
||||
}
|
||||
|
||||
/// A manually added model id: no whitespace, no controls, bounded length.
|
||||
pub fn validate_model_id(id: &str) -> Result<(), CustomProviderError> {
|
||||
let err = |reason: &str| Err(CustomProviderError::Name(reason.to_owned()));
|
||||
if id.is_empty() || id.len() > 128 {
|
||||
return err("model id must be 1 to 128 characters");
|
||||
}
|
||||
if id.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||
return err("model id must not contain whitespace");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A valid provider name derived from a host.
|
||||
pub fn default_name(base_url: &str) -> String {
|
||||
let host = base_url
|
||||
.split("://")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('/').next())
|
||||
.and_then(|authority| authority.split(':').next())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
let labels: Vec<&str> = host
|
||||
.split('.')
|
||||
.filter(|label| !label.is_empty() && !matches!(*label, "api" | "www"))
|
||||
.collect();
|
||||
let joined: String = labels
|
||||
.join("-")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
|
||||
.take(MAX_NAME_LEN)
|
||||
.collect();
|
||||
let name = joined.trim_matches('-').to_owned();
|
||||
if validate_name(&name).is_ok() {
|
||||
name
|
||||
} else {
|
||||
"custom".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_names_come_from_the_host() {
|
||||
for (url, expected) in [
|
||||
("https://api.together.xyz/v1", "together-xyz"),
|
||||
("https://openrouter.ai/api/v1", "openrouter-ai"),
|
||||
("http://localhost:11434/v1", "localhost"),
|
||||
("http://127.0.0.1:8080/v1", "127-0-0-1"),
|
||||
("https://api.openai.com/v1", "openai-com"),
|
||||
(
|
||||
"https://gateway.internal.example.org/anthropic",
|
||||
"gateway-internal-example-org",
|
||||
),
|
||||
("https://API.Example.COM/v1", "example-com"),
|
||||
("not a url", "custom"),
|
||||
("https://api/v1", "custom"),
|
||||
("https://[::1]:9000/v1", "custom"),
|
||||
] {
|
||||
assert_eq!(default_name(url), expected, "{url}");
|
||||
assert!(validate_name(&default_name(url)).is_ok(), "{url}");
|
||||
}
|
||||
assert!(default_name(&format!("https://{}.example/v1", "a".repeat(60))).len() <= 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_round_trips_and_maps_to_the_wire() {
|
||||
for api in [CustomApi::OpenAi, CustomApi::Anthropic] {
|
||||
assert_eq!(CustomApi::parse(api.as_str()), Some(api));
|
||||
}
|
||||
assert_eq!(CustomApi::parse(" OpenAI "), Some(CustomApi::OpenAi));
|
||||
assert_eq!(CustomApi::parse("responses"), None);
|
||||
assert_eq!(
|
||||
CustomApi::OpenAi.wire_api(),
|
||||
PlatformWireApi::ChatCompletions
|
||||
);
|
||||
assert_eq!(CustomApi::Anthropic.wire_api(), PlatformWireApi::Messages);
|
||||
assert_eq!(CustomApi::OpenAi.key_header(), PlatformKeyHeader::Bearer);
|
||||
assert_eq!(
|
||||
CustomApi::Anthropic.key_header(),
|
||||
PlatformKeyHeader::XApiKey
|
||||
);
|
||||
assert_eq!(CustomApi::OpenAi.listing(), ListingDialect::OpenAi);
|
||||
assert_eq!(CustomApi::Anthropic.listing(), ListingDialect::Anthropic);
|
||||
for api in [CustomApi::OpenAi, CustomApi::Anthropic] {
|
||||
assert_eq!(api.chat_compat(), PlatformChatCompat::Passthrough);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_follow_the_rules() {
|
||||
for ok in ["proxy", "my-proxy_2", "0x", "a"] {
|
||||
assert!(validate_name(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"",
|
||||
"Proxy",
|
||||
"pRoxy",
|
||||
"-x",
|
||||
"a/b",
|
||||
"a b",
|
||||
"a:b",
|
||||
"é",
|
||||
&"x".repeat(33),
|
||||
] {
|
||||
assert!(validate_name(bad).is_err(), "{bad}");
|
||||
}
|
||||
for id in crate::PlatformId::ALL {
|
||||
assert!(validate_name(id.as_str()).is_err(), "{}", id.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_urls_are_normalized_or_refused() {
|
||||
assert_eq!(
|
||||
normalize_base_url(" https://api.example.com/v1/ ").unwrap(),
|
||||
"https://api.example.com/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_base_url("HTTP://localhost:11434/v1").unwrap(),
|
||||
"HTTP://localhost:11434/v1"
|
||||
);
|
||||
for bad in [
|
||||
"",
|
||||
"example.com/v1",
|
||||
"ftp://example.com",
|
||||
"https://",
|
||||
"https:///v1",
|
||||
"https://user:pw@example.com/v1",
|
||||
"https://example.com/v1?key=x",
|
||||
"https://example.com/v1#frag",
|
||||
"https://exa mple.com",
|
||||
"https://h\u{0}x",
|
||||
"ht€ps://example.com",
|
||||
"€https://example.com",
|
||||
"https:/€",
|
||||
"http:/€",
|
||||
] {
|
||||
assert!(normalize_base_url(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_urls_and_keys_follow_the_dialect() {
|
||||
let openai =
|
||||
CustomProvider::new("proxy", CustomApi::OpenAi, "https://h.example/v1/").unwrap();
|
||||
assert_eq!(openai.models_url(), "https://h.example/v1/models");
|
||||
assert_eq!(openai.managed_model_key("gpt-x"), "proxy/gpt-x");
|
||||
let anthropic =
|
||||
CustomProvider::new("claude-gw", CustomApi::Anthropic, "https://h.example/v1").unwrap();
|
||||
assert_eq!(
|
||||
anthropic.models_url(),
|
||||
"https://h.example/v1/models?limit=1000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_new_reports_which_field_failed() {
|
||||
assert!(matches!(
|
||||
CustomProvider::new("openai", CustomApi::OpenAi, "https://h.example/v1"),
|
||||
Err(CustomProviderError::Name(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
CustomProvider::new("proxy", CustomApi::OpenAi, "h.example"),
|
||||
Err(CustomProviderError::BaseUrl(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub mod custom;
|
||||
pub mod enrichment;
|
||||
|
||||
// Platform registry (PRD F2)
|
||||
@@ -1285,8 +1286,8 @@ const OPENAI_CODEX_SPEC: PlatformSpec = PlatformSpec {
|
||||
strip_listing_id_prefix: None,
|
||||
};
|
||||
|
||||
/// The platform registry. Platforms are compiled-in spec rows; there is no
|
||||
/// dynamic provider registration (PRD F2).
|
||||
/// The platform registry. Platforms are compiled-in spec rows; user-declared
|
||||
/// providers live in [`custom`], never here.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum PlatformId {
|
||||
/// Kimi Code subscription (OAuth bearer from the F1 device flow).
|
||||
@@ -1570,8 +1571,8 @@ impl PlatformId {
|
||||
/// The compiled-in catalog for a platform that serves NO live `/models`
|
||||
/// listing (openai-codex), or `None` when the catalog comes from the wire.
|
||||
///
|
||||
/// openai-codex's 7 models are HARDCODED (read from the Codex backend
|
||||
/// catalog on 2026-09-22, the `visibility=="list"` AND
|
||||
/// openai-codex's 8 models are HARDCODED (read from the Codex backend
|
||||
/// catalog on 2026-09-29, the `visibility=="list"` AND
|
||||
/// `supported_in_api==true` set) because OpenAI exposes no stable public
|
||||
/// models endpoint for the
|
||||
/// ChatGPT Codex backend. Each entry carries context window + per-model
|
||||
@@ -1607,8 +1608,8 @@ fn codex_wire_model(slug: &str, display_name: &str, efforts: &[&str], default: &
|
||||
}
|
||||
}
|
||||
|
||||
/// The HARDCODED openai-codex catalog: exactly the 7 `visibility=="list"` AND
|
||||
/// `supported_in_api==true` models the Codex backend served on 2026-09-22, in
|
||||
/// The HARDCODED openai-codex catalog: exactly the 8 `visibility=="list"` AND
|
||||
/// `supported_in_api==true` models the Codex backend served on 2026-09-29, in
|
||||
/// its own priority order. `gpt-reserve` and `codex-auto-review`
|
||||
/// (visibility="hide") are deliberately EXCLUDED — they are not user-facing
|
||||
/// (fail-fast: never advertise a model the backend rejects). Several models
|
||||
@@ -1616,6 +1617,12 @@ fn codex_wire_model(slug: &str, display_name: &str, efforts: &[&str], default: &
|
||||
/// is not listed here.
|
||||
fn openai_codex_wire_models() -> Vec<WireModel> {
|
||||
vec![
|
||||
codex_wire_model(
|
||||
"gpt-6.1-sol",
|
||||
"GPT-6.1-Sol",
|
||||
&["low", "medium", "high", "xhigh", "max"],
|
||||
"low",
|
||||
),
|
||||
codex_wire_model(
|
||||
"gpt-6-astra",
|
||||
"GPT-6-Astra",
|
||||
@@ -1749,6 +1756,23 @@ pub struct WireThinkEfforts {
|
||||
pub default_effort: Option<String>,
|
||||
}
|
||||
|
||||
impl WireModel {
|
||||
/// An id-only model, as if the listing served no metadata.
|
||||
pub fn bare(id: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
context_length: 0,
|
||||
supports_reasoning: false,
|
||||
supports_image_in: false,
|
||||
supports_video_in: false,
|
||||
display_name: None,
|
||||
max_output_tokens: 0,
|
||||
supports_thinking_type: None,
|
||||
think_efforts: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET {base}/models` response envelope.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct WireModelsResponse {
|
||||
@@ -2673,13 +2697,13 @@ mod tests {
|
||||
assert_eq!(c.base_url(), "https://mock.codex/codex");
|
||||
}
|
||||
|
||||
/// The HARDCODED openai-codex catalog is exactly the 7 supported+listed
|
||||
/// The HARDCODED openai-codex catalog is exactly the 8 supported+listed
|
||||
/// models, keyed by slug, ctx 272000, each exposing its exact supported
|
||||
/// efforts (incl. the codex-only `xhigh`/`max` tiers). The hidden models
|
||||
/// are absent. Every other platform serves NO hardcoded catalog (its
|
||||
/// models come from the live wire).
|
||||
#[test]
|
||||
fn openai_codex_hardcoded_catalog_is_the_seven_supported_models() {
|
||||
fn openai_codex_hardcoded_catalog_is_the_eight_supported_models() {
|
||||
let catalog = PlatformId::OpenaiCodex
|
||||
.hardcoded_catalog()
|
||||
.expect("openai-codex serves a hardcoded catalog");
|
||||
@@ -2687,6 +2711,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
"gpt-6.1-sol",
|
||||
"gpt-6-astra",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
@@ -2695,7 +2720,7 @@ mod tests {
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.5"
|
||||
],
|
||||
"exactly the 7 visibility=list AND supported_in_api=true models"
|
||||
"exactly the 8 visibility=list AND supported_in_api=true models"
|
||||
);
|
||||
// Excluded: hidden models never appear.
|
||||
for absent in ["gpt-reserve", "codex-auto-review"] {
|
||||
@@ -2728,6 +2753,10 @@ mod tests {
|
||||
["low", "medium", "high", "xhigh", "max"],
|
||||
"`ultra` is advertised above `max` but kigi has no such level"
|
||||
);
|
||||
assert_eq!(
|
||||
efforts("gpt-6.1-sol"),
|
||||
["low", "medium", "high", "xhigh", "max"]
|
||||
);
|
||||
assert_eq!(
|
||||
efforts("gpt-6-sol"),
|
||||
["low", "medium", "high", "xhigh", "max"]
|
||||
@@ -2761,6 +2790,7 @@ mod tests {
|
||||
.default_effort
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(default("gpt-6.1-sol").as_deref(), Some("low"));
|
||||
assert_eq!(default("gpt-6-astra").as_deref(), Some("medium"));
|
||||
assert_eq!(default("gpt-6-sol").as_deref(), Some("medium"));
|
||||
assert_eq!(default("gpt-6-luna").as_deref(), Some("medium"));
|
||||
|
||||
@@ -189,11 +189,6 @@ impl TerminalName {
|
||||
| Self::Zed
|
||||
)
|
||||
}
|
||||
|
||||
/// Only Otty is known to wrap macOS IME commits in bracketed paste.
|
||||
pub fn delivers_ime_as_bracketed_paste(self) -> bool {
|
||||
matches!(self, Self::Otty)
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalContext {
|
||||
|
||||
@@ -51,24 +51,6 @@ fn brand_otty_from_term_program() {
|
||||
assert_eq!(detect_terminal_brand_from_env(&env), TerminalName::Otty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn otty_delivers_ime_as_bracketed_paste_only() {
|
||||
assert!(TerminalName::Otty.delivers_ime_as_bracketed_paste());
|
||||
for brand in [
|
||||
TerminalName::AppleTerminal,
|
||||
TerminalName::Ghostty,
|
||||
TerminalName::Iterm2,
|
||||
TerminalName::Unknown,
|
||||
TerminalName::WezTerm,
|
||||
TerminalName::Kitty,
|
||||
] {
|
||||
assert!(
|
||||
!brand.delivers_ime_as_bracketed_paste(),
|
||||
"{brand:?} must not gate IME bracketed-paste origin"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn otty_is_capability_unclassified_like_unknown() {
|
||||
assert!(TerminalName::Otty.is_capability_unclassified());
|
||||
|
||||
@@ -46,6 +46,8 @@ struct BlockState {
|
||||
args_acc: String,
|
||||
thinking_acc: String,
|
||||
signature: String,
|
||||
/// The first `SignatureDelta` replaces a start-seeded signature; later ones append.
|
||||
signature_delta_seen: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -109,13 +111,10 @@ pub fn stream_messages<'a>(
|
||||
let mut final_stop_reason: Option<StopReason> = None;
|
||||
let mut final_stop_message: Option<String> = None;
|
||||
|
||||
// Assistant-response accumulators (built up as ContentBlockStop
|
||||
// events fire). Reasoning is collected into a synthesized
|
||||
// `rs::ReasoningItem` and emitted as a sibling
|
||||
// `ConversationItem::Reasoning` before the trailing Assistant.
|
||||
// Assistant-response accumulators, filled as ContentBlockStop events fire.
|
||||
let mut assistant_text = String::new();
|
||||
let mut assistant_tool_calls: Vec<ToolCall> = Vec::new();
|
||||
let mut assistant_reasoning: Option<rs::ReasoningItem> = None;
|
||||
let mut assistant_reasoning: Vec<rs::ReasoningItem> = Vec::new();
|
||||
|
||||
// Index counters
|
||||
let mut chunk_index: u64 = 0;
|
||||
@@ -185,6 +184,7 @@ pub fn stream_messages<'a>(
|
||||
args_acc: String::new(),
|
||||
thinking_acc: thinking.clone(),
|
||||
signature: signature.clone(),
|
||||
signature_delta_seen: false,
|
||||
},
|
||||
);
|
||||
if !first_token_emitted {
|
||||
@@ -205,6 +205,7 @@ pub fn stream_messages<'a>(
|
||||
args_acc: String::new(),
|
||||
thinking_acc: String::new(),
|
||||
signature: String::new(),
|
||||
signature_delta_seen: false,
|
||||
},
|
||||
);
|
||||
if !first_token_emitted {
|
||||
@@ -237,6 +238,7 @@ pub fn stream_messages<'a>(
|
||||
args_acc: String::new(),
|
||||
thinking_acc: String::new(),
|
||||
signature: String::new(),
|
||||
signature_delta_seen: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -276,7 +278,11 @@ pub fn stream_messages<'a>(
|
||||
}
|
||||
}
|
||||
StreamDelta::SignatureDelta { signature } => {
|
||||
state.signature = signature;
|
||||
if !state.signature_delta_seen {
|
||||
state.signature_delta_seen = true;
|
||||
state.signature.clear();
|
||||
}
|
||||
state.signature.push_str(&signature);
|
||||
}
|
||||
StreamDelta::TextDelta { text } => {
|
||||
if !text.is_empty() {
|
||||
@@ -347,7 +353,7 @@ pub fn stream_messages<'a>(
|
||||
} else {
|
||||
Some(state.signature)
|
||||
};
|
||||
assistant_reasoning = Some(rs::ReasoningItem {
|
||||
assistant_reasoning.push(rs::ReasoningItem {
|
||||
id: String::new(),
|
||||
summary,
|
||||
content: None,
|
||||
@@ -513,9 +519,7 @@ pub fn stream_messages<'a>(
|
||||
});
|
||||
|
||||
let mut items: Vec<ConversationItem> = Vec::new();
|
||||
if let Some(r) = assistant_reasoning {
|
||||
items.push(ConversationItem::Reasoning(r));
|
||||
}
|
||||
items.extend(assistant_reasoning.into_iter().map(ConversationItem::Reasoning));
|
||||
items.push(assistant_item);
|
||||
|
||||
let stream_end = Instant::now();
|
||||
|
||||
@@ -208,6 +208,91 @@ async fn thinking_block_emits_reasoning_channel_and_preserved_in_response() {
|
||||
}
|
||||
}
|
||||
|
||||
fn thinking_block(
|
||||
index: u32,
|
||||
start_signature: &str,
|
||||
thinking: &str,
|
||||
signature_deltas: &[&str],
|
||||
) -> Vec<Result<MessageStreamEvent, SamplingError>> {
|
||||
let mut events = vec![
|
||||
Ok(MessageStreamEvent::ContentBlockStart {
|
||||
index,
|
||||
content_block: ContentBlock::Thinking {
|
||||
thinking: String::new(),
|
||||
signature: start_signature.into(),
|
||||
},
|
||||
}),
|
||||
Ok(MessageStreamEvent::ContentBlockDelta {
|
||||
index,
|
||||
delta: StreamDelta::ThinkingDelta {
|
||||
thinking: thinking.into(),
|
||||
},
|
||||
}),
|
||||
];
|
||||
for signature in signature_deltas {
|
||||
events.push(Ok(MessageStreamEvent::ContentBlockDelta {
|
||||
index,
|
||||
delta: StreamDelta::SignatureDelta {
|
||||
signature: (*signature).into(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
events.push(Ok(block_stop(index)));
|
||||
events
|
||||
}
|
||||
|
||||
async fn reasoning_signatures(
|
||||
blocks: Vec<Vec<Result<MessageStreamEvent, SamplingError>>>,
|
||||
) -> Vec<(String, Option<String>)> {
|
||||
let mut events = vec![Ok(message_start())];
|
||||
events.extend(blocks.into_iter().flatten());
|
||||
events.push(Ok(MessageStreamEvent::MessageStop));
|
||||
let raw = stream::iter(events).boxed();
|
||||
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
|
||||
match evs.last().unwrap() {
|
||||
SamplingEvent::Completed { response, .. } => response
|
||||
.reasoning_items()
|
||||
.map(|r| {
|
||||
let text = r
|
||||
.summary
|
||||
.iter()
|
||||
.map(|rs::SummaryPart::SummaryText(t)| t.text.as_str())
|
||||
.collect::<String>();
|
||||
(text, r.encrypted_content.clone())
|
||||
})
|
||||
.collect(),
|
||||
other => panic!("expected Completed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_thinking_block_is_kept_in_order() {
|
||||
let items = reasoning_signatures(vec![
|
||||
thinking_block(0, "", "first", &["sig-a"]),
|
||||
thinking_block(1, "", "second", &["sig-b"]),
|
||||
])
|
||||
.await;
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![
|
||||
("first".to_string(), Some("sig-a".to_string())),
|
||||
("second".to_string(), Some("sig-b".to_string())),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn split_signature_deltas_join_into_one_signature() {
|
||||
let items = reasoning_signatures(vec![thinking_block(0, "", "t", &["abc", "def"])]).await;
|
||||
assert_eq!(items, vec![("t".to_string(), Some("abcdef".to_string()))]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_signature_delta_replaces_a_start_seeded_signature() {
|
||||
let items = reasoning_signatures(vec![thinking_block(0, "sig", "t", &["sig"])]).await;
|
||||
assert_eq!(items, vec![("t".to_string(), Some("sig".to_string()))]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_use_block_assembles_into_tool_call() {
|
||||
let tool_start = MessageStreamEvent::ContentBlockStart {
|
||||
|
||||
@@ -1202,7 +1202,7 @@ pub const CODEX_OPENAI_BETA: &str = "responses=experimental";
|
||||
/// openai-codex-gated). The Codex backend does not strictly validate the UA
|
||||
/// string (Pi ships its own and it works), so this is a stable best-effort
|
||||
/// identity, not a pinned build.
|
||||
pub const CODEX_USER_AGENT: &str = "codex_cli_rs/0.156.0";
|
||||
pub const CODEX_USER_AGENT: &str = "codex_cli_rs/0.159.0";
|
||||
/// JWT payload claim namespace carrying the ChatGPT account id.
|
||||
const CODEX_JWT_AUTH_CLAIM: &str = "https://api.openai.com/auth";
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ infer = "0.15"
|
||||
image = { workspace = true, features = ["png", "jpeg", "gif", "webp"] }
|
||||
moka = { workspace = true, features = ["future"] }
|
||||
base64 = { workspace = true }
|
||||
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
|
||||
sha2 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
@@ -147,7 +146,6 @@ kigi-http = { workspace = true }
|
||||
kigi-models = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
fs2 = { workspace = true }
|
||||
zstd = { workspace = true }
|
||||
tar = { workspace = true }
|
||||
url = { workspace = true }
|
||||
kigi-secrets = { path = "../kigi-secrets" }
|
||||
@@ -194,7 +192,6 @@ kigi-config = { workspace = true, features = ["test-support"] }
|
||||
ring = { workspace = true }
|
||||
tar = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
rsa = { workspace = true }
|
||||
semver = { workspace = true }
|
||||
serial_test = { workspace = true }
|
||||
# Test-only builds expose `env::EnvVarGuard` and the cpu_profile test seams to
|
||||
|
||||
@@ -114,6 +114,7 @@ pub struct BuiltAuthMethods {
|
||||
/// `PlatformId::ALL` order — interactive logins after `kimi-code`
|
||||
/// 5. every API-key registry platform, in `PlatformId::ALL` order
|
||||
/// (`moonshot-cn`, `moonshot-ai`, …), always advertised
|
||||
/// 6. the two custom provider rows (`custom-openai`, `custom-anthropic`)
|
||||
///
|
||||
/// The platform methods are for the INTERACTIVE login picker only: they come
|
||||
/// after `kimi-code` so they can never become `auth_methods.first()` (the
|
||||
@@ -174,6 +175,7 @@ pub fn build_auth_methods(inputs: AuthMethodsBuildInputs<'_>) -> BuiltAuthMethod
|
||||
methods.push(platform_auth_method(platform));
|
||||
}
|
||||
}
|
||||
methods.extend(crate::agent::custom_providers::picker_methods());
|
||||
|
||||
BuiltAuthMethods {
|
||||
methods,
|
||||
@@ -189,6 +191,8 @@ pub enum AuthMethodKind {
|
||||
KimiCode,
|
||||
/// Registry API-key platform login (method id = the platform id).
|
||||
ApiKeyPlatform(kigi_models::PlatformId),
|
||||
/// A saved custom provider's login (method id = `custom:<name>`).
|
||||
CustomProvider,
|
||||
/// Generic device-code OAuth platform login (method id = the platform id,
|
||||
/// e.g. `xai-grok`). Interactive, like Kimi Code.
|
||||
OAuthPlatform(kigi_models::PlatformId),
|
||||
@@ -201,6 +205,9 @@ impl AuthMethodKind {
|
||||
XAI_API_KEY_METHOD_ID => Self::XaiApiKey,
|
||||
CACHED_TOKEN_AUTH_METHOD_ID => Self::CachedToken,
|
||||
KIMI_CODE_METHOD_ID => Self::KimiCode,
|
||||
other if crate::agent::custom_providers::login_provider_name(other).is_some() => {
|
||||
Self::CustomProvider
|
||||
}
|
||||
other => match kigi_models::PlatformId::parse(other) {
|
||||
Some(p) if p.oauth().is_some() => Self::OAuthPlatform(p),
|
||||
Some(p) if !p.uses_oauth() => Self::ApiKeyPlatform(p),
|
||||
@@ -213,7 +220,10 @@ impl AuthMethodKind {
|
||||
/// The registry platform methods qualify — they validate a configured
|
||||
/// platform key and then behave exactly like an external-API-key session.
|
||||
pub fn is_api_key(self) -> bool {
|
||||
matches!(self, Self::XaiApiKey | Self::ApiKeyPlatform(_))
|
||||
matches!(
|
||||
self,
|
||||
Self::XaiApiKey | Self::ApiKeyPlatform(_) | Self::CustomProvider
|
||||
)
|
||||
}
|
||||
|
||||
/// `true` for session-based methods (cached_token, interactive login).
|
||||
@@ -589,6 +599,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `custom:<name>` logs in; picker rows cannot.
|
||||
#[test]
|
||||
fn custom_provider_method_ids_classify_as_api_key() {
|
||||
let kind = AuthMethodKind::from_id(&acp::AuthMethodId::new("custom:my-proxy"));
|
||||
assert_eq!(kind, AuthMethodKind::CustomProvider);
|
||||
assert!(kind.is_api_key());
|
||||
assert!(!kind.is_session_based());
|
||||
assert!(!kind.needs_interactive_login());
|
||||
for picker in ["custom-openai", "custom-anthropic"] {
|
||||
assert_eq!(
|
||||
AuthMethodKind::from_id(&acp::AuthMethodId::new(picker)),
|
||||
AuthMethodKind::Unknown,
|
||||
"{picker} only opens the TUI flow; it cannot authenticate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier matrix for all auth method variants.
|
||||
#[test]
|
||||
fn auth_method_kind_classifier_matrix() {
|
||||
@@ -896,7 +923,9 @@ mod tests {
|
||||
"xiaomi",
|
||||
"xiaomi-token-plan-cn",
|
||||
"minimax",
|
||||
"minimax-cn"
|
||||
"minimax-cn",
|
||||
"custom-openai",
|
||||
"custom-anthropic"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
|
||||
@@ -948,7 +977,9 @@ mod tests {
|
||||
"xiaomi",
|
||||
"xiaomi-token-plan-cn",
|
||||
"minimax",
|
||||
"minimax-cn"
|
||||
"minimax-cn",
|
||||
"custom-openai",
|
||||
"custom-anthropic"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||
@@ -993,7 +1024,9 @@ mod tests {
|
||||
"xiaomi",
|
||||
"xiaomi-token-plan-cn",
|
||||
"minimax",
|
||||
"minimax-cn"
|
||||
"minimax-cn",
|
||||
"custom-openai",
|
||||
"custom-anthropic"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||
@@ -1041,7 +1074,9 @@ mod tests {
|
||||
"xiaomi",
|
||||
"xiaomi-token-plan-cn",
|
||||
"minimax",
|
||||
"minimax-cn"
|
||||
"minimax-cn",
|
||||
"custom-openai",
|
||||
"custom-anthropic"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), None);
|
||||
|
||||
@@ -178,6 +178,9 @@ pub struct EndpointsConfig {
|
||||
/// Read by `load_gcs_service_account_key_sync()`. Declared for `serde_ignored`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gcs_service_account_key: Option<String>,
|
||||
/// Base URLs of declared custom providers; derived, never serialized.
|
||||
#[serde(skip)]
|
||||
pub custom_provider_bases: Vec<String>,
|
||||
}
|
||||
/// A blank or whitespace-only override counts as unset. Single source of truth
|
||||
/// for the "empty value = not configured" rule shared by the endpoint resolvers.
|
||||
@@ -210,7 +213,13 @@ impl EndpointsConfig {
|
||||
if let Some(endpoints) = config.get("endpoints") {
|
||||
crate::config::deep_merge_toml(&mut base, endpoints);
|
||||
}
|
||||
base.try_into().unwrap_or_default()
|
||||
let mut endpoints: Self = base.try_into().unwrap_or_default();
|
||||
if let Some(platforms) = config.get("platforms").cloned()
|
||||
&& let Ok(platforms) = platforms.try_into::<PlatformsConfig>()
|
||||
{
|
||||
endpoints.custom_provider_bases = super::custom_providers::bases(&platforms);
|
||||
}
|
||||
endpoints
|
||||
}
|
||||
/// The subscription proxy base URL through which all auxiliary services (and
|
||||
/// OAuth/session inference) resolve: explicit `coding_api_base_url`, else
|
||||
@@ -265,6 +274,7 @@ impl Default for EndpointsConfig {
|
||||
managed_config_url: env_string("KIGI_MANAGED_CONFIG_URL"),
|
||||
management_api_key: None,
|
||||
gcs_service_account_key: None,
|
||||
custom_provider_bases: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -771,8 +781,11 @@ impl PlatformsConfig {
|
||||
/// registry platform, so a typo like `moonshot_cn` fails loudly instead of
|
||||
/// silently never matching. Key values are not logged.
|
||||
pub fn warn_unknown_platforms(&self) {
|
||||
for id in self.entries.keys() {
|
||||
if kigi_models::PlatformId::parse(id).is_none() {
|
||||
super::custom_providers::warn_invalid_declarations(self);
|
||||
for (id, entry) in &self.entries {
|
||||
if kigi_models::PlatformId::parse(id).is_none()
|
||||
&& !super::custom_providers::is_declared(entry)
|
||||
{
|
||||
tracing::warn!(
|
||||
platform = %id,
|
||||
known = ?kigi_models::PlatformId::ALL
|
||||
@@ -793,6 +806,15 @@ pub struct PlatformCredentialConfig {
|
||||
/// API key for this platform. NEVER logged; never re-serialized.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
/// With `api`, declares a custom provider outside the registry.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
/// `"openai"` or `"anthropic"`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api: Option<String>,
|
||||
/// A custom provider's login-curated model allowlist; absent = whole listing.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub models: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Resolve the API key for an API-key registry platform with injected env
|
||||
@@ -1582,6 +1604,7 @@ impl Config {
|
||||
config.config_models = config_models;
|
||||
config.model_override_warnings = model_override_warnings;
|
||||
config.platforms.warn_unknown_platforms();
|
||||
config.endpoints.custom_provider_bases = super::custom_providers::bases(&config.platforms);
|
||||
if config.client_version.is_none() {
|
||||
config.client_version = Self::default().client_version;
|
||||
}
|
||||
@@ -2569,6 +2592,13 @@ pub(crate) fn resolve_model_list(
|
||||
tracing::debug!(count = defaults.len(), "loaded default models");
|
||||
resolved.extend(defaults);
|
||||
}
|
||||
let prefetched = prefetched.map(|models| {
|
||||
if cfg.endpoints.has_custom_endpoint() {
|
||||
models
|
||||
} else {
|
||||
crate::agent::custom_providers::drop_orphans(models, platform_keys.custom())
|
||||
}
|
||||
});
|
||||
if let Some(mut prefetched) = prefetched {
|
||||
tracing::debug!(count = prefetched.len(), "loaded prefetched models");
|
||||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||||
@@ -2601,6 +2631,11 @@ pub(crate) fn resolve_model_list(
|
||||
}
|
||||
resolved = prefetched;
|
||||
}
|
||||
crate::agent::custom_providers::synthesize_selected(
|
||||
&mut resolved,
|
||||
&cfg.platforms,
|
||||
platform_keys.custom(),
|
||||
);
|
||||
let mut explicit_api_backend_keys = std::collections::HashSet::new();
|
||||
for (key, model_override) in &cfg.config_models {
|
||||
let had_base = resolved.contains_key(key);
|
||||
@@ -2629,8 +2664,18 @@ pub(crate) fn resolve_model_list(
|
||||
);
|
||||
resolved.insert(key.clone(), entry);
|
||||
}
|
||||
crate::agent::custom_providers::filter_unselected(
|
||||
&mut resolved,
|
||||
&cfg.platforms,
|
||||
platform_keys.custom(),
|
||||
);
|
||||
{
|
||||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||||
let custom_prefixes: Vec<String> = platform_keys
|
||||
.custom()
|
||||
.iter()
|
||||
.map(|c| format!("{}/", c.provider.name))
|
||||
.collect();
|
||||
let donors: std::collections::HashMap<String, (std::num::NonZeroU64, ApiBackend)> =
|
||||
resolved
|
||||
.values()
|
||||
@@ -2653,7 +2698,11 @@ pub(crate) fn resolve_model_list(
|
||||
entry.info.context_window = *donor_cw;
|
||||
}
|
||||
// An explicit `[model.X] api_backend` wins even when it is the default.
|
||||
// A provider-owned entry keeps its declared wire too.
|
||||
if !explicit_api_backend_keys.contains(key.as_str())
|
||||
&& !custom_prefixes
|
||||
.iter()
|
||||
.any(|p| key.as_str().starts_with(p.as_str()))
|
||||
&& entry.info.api_backend == ApiBackend::default()
|
||||
&& *donor_backend != ApiBackend::default()
|
||||
{
|
||||
@@ -2676,6 +2725,7 @@ pub(crate) fn resolve_model_list(
|
||||
apply_global_extra_headers(&mut resolved, &cfg.models);
|
||||
apply_global_scalar_defaults(&mut resolved, &cfg.models);
|
||||
apply_platform_credentials(&mut resolved, &cfg.platforms, platform_keys);
|
||||
crate::agent::custom_providers::stamp_credentials(&mut resolved, platform_keys.custom());
|
||||
for entry in resolved.values_mut() {
|
||||
entry.info.derive_reasoning_effort_fields();
|
||||
}
|
||||
@@ -3932,6 +3982,17 @@ pub fn resolve_model_auth_facts(model_id: &str) -> ModelAuthFacts {
|
||||
},
|
||||
})
|
||||
}
|
||||
/// Auth facts of a held live-catalog entry.
|
||||
pub(crate) fn auth_facts_of(entry: &ModelEntry) -> ModelAuthFacts {
|
||||
ModelAuthFacts {
|
||||
byok: if entry.has_own_credentials() {
|
||||
ModelByok::Byok
|
||||
} else {
|
||||
ModelByok::NotByok
|
||||
},
|
||||
auth_scheme: entry.info().auth_scheme,
|
||||
}
|
||||
}
|
||||
fn byok_from_lookup(lookup: &ModelLookup) -> ModelByok {
|
||||
match lookup {
|
||||
ModelLookup::ConfigUnavailable => ModelByok::Unknown,
|
||||
@@ -5246,6 +5307,17 @@ reasoning_effort = "low"
|
||||
auth_scheme: Default::default(),
|
||||
}
|
||||
}
|
||||
/// Scheme and BYOK status come from the entry.
|
||||
#[test]
|
||||
fn auth_facts_come_from_the_held_entry() {
|
||||
let mut model = test_model_entry("claude-x", "https://gw.example/v1", None, None, None);
|
||||
model.info.auth_scheme = AuthScheme::XApiKey;
|
||||
let facts = auth_facts_of(&model);
|
||||
assert_eq!(facts.auth_scheme, AuthScheme::XApiKey);
|
||||
assert_eq!(facts.byok, ModelByok::NotByok);
|
||||
model.api_key = Some("sk-own".into());
|
||||
assert_eq!(auth_facts_of(&model).byok, ModelByok::Byok);
|
||||
}
|
||||
#[test]
|
||||
fn x_api_key_auth_scheme_flows_from_config_to_sampler() {
|
||||
let mut model = test_model_entry(
|
||||
@@ -9673,6 +9745,31 @@ default = "kigi-4.5"
|
||||
"non-BYOK config overlay must preserve bundled supported_in_api=false"
|
||||
);
|
||||
}
|
||||
/// Custom provider bases reach `EndpointsConfig` from both entry points.
|
||||
#[test]
|
||||
fn declared_custom_provider_bases_reach_the_endpoints() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[platforms.proxy]
|
||||
base_url = "http://localhost:11434/v1/"
|
||||
api = "openai"
|
||||
[platforms.broken]
|
||||
api = "openai"
|
||||
[platforms.moonshot-cn]
|
||||
api_key = "sk-cn"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||||
assert_eq!(
|
||||
cfg.endpoints.custom_provider_bases,
|
||||
vec!["http://localhost:11434/v1".to_owned()]
|
||||
);
|
||||
assert_eq!(
|
||||
EndpointsConfig::from_config_value(&raw).custom_provider_bases,
|
||||
vec!["http://localhost:11434/v1".to_owned()]
|
||||
);
|
||||
}
|
||||
/// PRD F2: a `[platforms.<id>].api_key` from config.toml is stamped onto
|
||||
/// that platform's catalog entries (in-memory only), making them usable
|
||||
/// and API-key-visible — and only onto that platform.
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
//! Listing fetch and key stamping for custom providers.
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use kigi_models::WireModel;
|
||||
use kigi_models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
use super::CredentialedProvider;
|
||||
use crate::agent::config::{ModelEntry, ModelEntryConfig};
|
||||
use crate::agent::models_fetch::{BackendError, wire_model_to_entry};
|
||||
|
||||
/// `GET {base}/models` for one provider; every model is keyed `{name}/{id}`.
|
||||
pub(crate) fn fetch_models_blocking(
|
||||
credentialed: &CredentialedProvider,
|
||||
) -> Result<Vec<ModelEntryConfig>, BackendError> {
|
||||
let provider = &credentialed.provider;
|
||||
tracing::info!(provider = %provider.name, api = provider.api.as_str(), url = %provider.models_url(), "fetching custom provider models");
|
||||
let body = listing_body(provider, credentialed.key())?;
|
||||
let wire = parse_listing(provider, &body).map_err(|e| BackendError::RequestFailed {
|
||||
// No error content: serde quotes the offending value, a reflected key risk.
|
||||
status: 200,
|
||||
body: format!(
|
||||
"{} listing parse failed at line {} column {}",
|
||||
provider.api.as_str(),
|
||||
e.line(),
|
||||
e.column()
|
||||
),
|
||||
})?;
|
||||
if wire.is_empty() {
|
||||
tracing::warn!(provider = %provider.name, "custom provider listed no models");
|
||||
}
|
||||
Ok(wire.into_iter().map(|w| entry(provider, w)).collect())
|
||||
}
|
||||
|
||||
/// The listing GET with the dialect's key header; status-gated body.
|
||||
fn listing_body(provider: &CustomProvider, key: &str) -> Result<String, BackendError> {
|
||||
let url = provider.models_url();
|
||||
let client = crate::http::shared_blocking_client();
|
||||
let send = |bearer_fallback: bool| {
|
||||
let request = match (provider.api, bearer_fallback) {
|
||||
(CustomApi::OpenAi, _) => client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {key}")),
|
||||
(CustomApi::Anthropic, false) => client
|
||||
.get(&url)
|
||||
.header("x-api-key", key)
|
||||
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
|
||||
(CustomApi::Anthropic, true) => client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {key}"))
|
||||
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
|
||||
};
|
||||
request.send()
|
||||
};
|
||||
let mut response = send(false)?;
|
||||
// Relays may gate the listing on Bearer even for the Messages wire.
|
||||
if response.status().as_u16() == 401 && provider.api == CustomApi::Anthropic {
|
||||
tracing::info!(provider = %provider.name, "listing 401 with x-api-key; retrying Bearer");
|
||||
response = send(true)?;
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().unwrap_or_default();
|
||||
return Err(BackendError::RequestFailed { status, body });
|
||||
}
|
||||
Ok(response.text()?)
|
||||
}
|
||||
|
||||
fn parse_listing(
|
||||
provider: &CustomProvider,
|
||||
body: &str,
|
||||
) -> Result<Vec<WireModel>, serde_json::Error> {
|
||||
match provider.api {
|
||||
CustomApi::OpenAi => kigi_models::parse_openai_listing(body),
|
||||
CustomApi::Anthropic => kigi_models::parse_anthropic_listing(body),
|
||||
}
|
||||
}
|
||||
|
||||
/// The login-step listing result; `provider` carries the adopted base URL.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LoginFetch {
|
||||
pub provider: CustomProvider,
|
||||
pub model_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Login listing fetch; probes `{base}/v1` once on a 404 or unparsable body and adopts it.
|
||||
pub fn fetch_listing_for_login(provider: &CustomProvider, key: &str) -> Result<LoginFetch, String> {
|
||||
match list_wire(provider, key) {
|
||||
Ok(model_ids) => Ok(LoginFetch {
|
||||
provider: provider.clone(),
|
||||
model_ids,
|
||||
}),
|
||||
Err(first) => {
|
||||
if !first.retryable() || has_version_tail(&provider.base_url) {
|
||||
return Err(first.into_message());
|
||||
}
|
||||
let probed = CustomProvider::new(
|
||||
&provider.name,
|
||||
provider.api,
|
||||
&format!("{}/v1", provider.base_url),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tracing::info!(
|
||||
provider = %provider.name,
|
||||
base_url = %probed.base_url,
|
||||
"custom provider listing retrying with a /v1 base"
|
||||
);
|
||||
match list_wire(&probed, key) {
|
||||
Ok(model_ids) => Ok(LoginFetch {
|
||||
provider: probed,
|
||||
model_ids,
|
||||
}),
|
||||
Err(second) => Err(format!(
|
||||
"{}; {} also failed: {}",
|
||||
first.into_message(),
|
||||
probed.models_url(),
|
||||
second.into_message()
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A listing failure; `retryable` marks wrong-base-URL shapes only.
|
||||
enum ListFail {
|
||||
Retryable(String),
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
impl ListFail {
|
||||
fn retryable(&self) -> bool {
|
||||
matches!(self, Self::Retryable(_))
|
||||
}
|
||||
|
||||
fn into_message(self) -> String {
|
||||
match self {
|
||||
Self::Retryable(m) | Self::Fatal(m) => m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list_wire(provider: &CustomProvider, key: &str) -> Result<Vec<String>, ListFail> {
|
||||
let url = provider.models_url();
|
||||
let body = listing_body(provider, key).map_err(|e| match &e {
|
||||
BackendError::RequestFailed { status, .. } => {
|
||||
// No body: a reflected error could echo the key.
|
||||
let msg = format!("{url} answered HTTP {status}");
|
||||
if *status == 404 {
|
||||
ListFail::Retryable(msg)
|
||||
} else {
|
||||
ListFail::Fatal(msg)
|
||||
}
|
||||
}
|
||||
BackendError::Network(_) => ListFail::Fatal(format!("Couldn't reach {url}: {e}")),
|
||||
BackendError::Auth(_) => ListFail::Fatal(e.to_string()),
|
||||
})?;
|
||||
let wire = parse_listing(provider, &body).map_err(|e| {
|
||||
ListFail::Retryable(format!(
|
||||
"{url} did not serve a {} model listing (invalid JSON at line {} column {})",
|
||||
provider.api.as_str(),
|
||||
e.line(),
|
||||
e.column()
|
||||
))
|
||||
})?;
|
||||
Ok(wire.into_iter().map(|w| w.id).collect())
|
||||
}
|
||||
|
||||
/// True when the base URL's last path segment is a version like `v1`.
|
||||
fn has_version_tail(base_url: &str) -> bool {
|
||||
let Some(tail) = base_url.rsplit('/').next() else {
|
||||
return false;
|
||||
};
|
||||
let digits = tail.strip_prefix('v').unwrap_or("");
|
||||
!digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// A selected id the listing never served becomes an entry with wire defaults.
|
||||
pub(crate) fn manual_entry(provider: &CustomProvider, id: &str) -> ModelEntryConfig {
|
||||
entry(provider, WireModel::bare(id.to_owned()))
|
||||
}
|
||||
|
||||
fn entry(provider: &CustomProvider, wire: WireModel) -> ModelEntryConfig {
|
||||
let mut entry = wire_model_to_entry(
|
||||
provider.managed_model_key(&wire.id),
|
||||
provider.api.wire_api(),
|
||||
provider.api.key_header(),
|
||||
None,
|
||||
true,
|
||||
wire,
|
||||
&provider.base_url,
|
||||
);
|
||||
entry.description = Some(provider.name.clone());
|
||||
entry
|
||||
}
|
||||
|
||||
/// Drops fetched entries of a provider that is gone or keyless.
|
||||
pub(crate) fn drop_orphans(
|
||||
prefetched: IndexMap<String, ModelEntry>,
|
||||
keyed: &[CredentialedProvider],
|
||||
) -> IndexMap<String, ModelEntry> {
|
||||
prefetched
|
||||
.into_iter()
|
||||
.filter(|(key, entry)| {
|
||||
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
|
||||
let Some((prefix, _)) = id.split_once('/') else {
|
||||
return true;
|
||||
};
|
||||
let known = kigi_models::PlatformId::parse(prefix).is_some()
|
||||
|| keyed.iter().any(|k| k.provider.name == prefix);
|
||||
if !known {
|
||||
tracing::warn!(model_key = %key, "dropping a fetched entry of an unknown or keyless provider");
|
||||
}
|
||||
known
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stamps each entry with its provider's key, in memory only.
|
||||
pub(crate) fn stamp_credentials(
|
||||
resolved: &mut IndexMap<String, ModelEntry>,
|
||||
keyed: &[CredentialedProvider],
|
||||
) {
|
||||
for (key, entry) in resolved.iter_mut() {
|
||||
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
|
||||
let Some((name, _)) = id.split_once('/') else {
|
||||
continue;
|
||||
};
|
||||
let Some(owner) = keyed.iter().find(|k| k.provider.name == name) else {
|
||||
continue;
|
||||
};
|
||||
if entry.api_key.is_some() {
|
||||
continue;
|
||||
}
|
||||
if !crate::util::matches_trusted_base_url(&entry.info.base_url, &owner.provider.base_url) {
|
||||
tracing::warn!(
|
||||
model_key = %key, provider = %name,
|
||||
"entry points away from its custom provider; key not stamped"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
entry.api_key = Some(owner.key().to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::models::{ModelFetchAuth, PlatformApiKeys};
|
||||
use crate::agent::models_fetch::fetch_models_blocking as fetch_all;
|
||||
use wiremock::matchers::{header, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn credentialed(name: &str, api: CustomApi, base: &str, key: &str) -> CredentialedProvider {
|
||||
CredentialedProvider::for_test(CustomProvider::new(name, api, base).unwrap(), key)
|
||||
}
|
||||
|
||||
async fn fetch_on_thread(
|
||||
c: CredentialedProvider,
|
||||
) -> Result<Vec<ModelEntryConfig>, BackendError> {
|
||||
tokio::task::spawn_blocking(move || fetch_models_blocking(&c))
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn login_fetch_on_thread(
|
||||
provider: CustomProvider,
|
||||
key: &str,
|
||||
) -> Result<LoginFetch, String> {
|
||||
let key = key.to_owned();
|
||||
tokio::task::spawn_blocking(move || fetch_listing_for_login(&provider, &key))
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_tail_detection() {
|
||||
assert!(has_version_tail("https://h.example/v1"));
|
||||
assert!(has_version_tail("https://h.example/api/v2"));
|
||||
assert!(!has_version_tail("https://h.example"));
|
||||
assert!(!has_version_tail("https://h.example/coding"));
|
||||
assert!(!has_version_tail("https://h.example/v"));
|
||||
assert!(!has_version_tail("https://h.example/v1x"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn login_fetch_returns_the_served_ids() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/v1/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": [{"id": "m1"}, {"id": "m2"}]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &base).unwrap();
|
||||
|
||||
let fetch = login_fetch_on_thread(provider, "sk-k").await.unwrap();
|
||||
|
||||
assert_eq!(fetch.provider.base_url, base);
|
||||
assert_eq!(fetch.model_ids, ["m1", "m2"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn login_fetch_probes_v1_on_a_404_and_adopts_the_base() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/coding/models"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(path("/coding/v1/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": [{"id": "m1"}]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/coding", server.uri());
|
||||
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &base).unwrap();
|
||||
|
||||
let fetch = login_fetch_on_thread(provider, "sk-k").await.unwrap();
|
||||
|
||||
assert_eq!(fetch.provider.base_url, format!("{base}/v1"));
|
||||
assert_eq!(fetch.model_ids, ["m1"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn login_fetch_probes_v1_when_the_200_body_is_not_a_listing() {
|
||||
// A WAF answers every path with an HTML challenge page at HTTP 200.
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("<html>challenge</html>"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(path("/v1/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": [{"id": "m1"}]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &server.uri()).unwrap();
|
||||
|
||||
let fetch = login_fetch_on_thread(provider, "sk-k").await.unwrap();
|
||||
|
||||
assert_eq!(fetch.provider.base_url, format!("{}/v1", server.uri()));
|
||||
assert_eq!(fetch.model_ids, ["m1"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn login_fetch_does_not_probe_auth_failures_or_versioned_bases() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/models"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &server.uri()).unwrap();
|
||||
let err = login_fetch_on_thread(provider, "bad").await.unwrap_err();
|
||||
assert!(err.contains("401"), "{err}");
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/v2/models"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v2", server.uri());
|
||||
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &base).unwrap();
|
||||
let err = login_fetch_on_thread(provider, "sk-k").await.unwrap_err();
|
||||
assert!(err.contains("404") && !err.contains("also failed"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn login_fetch_reports_both_hops_when_the_probe_fails_too() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &server.uri()).unwrap();
|
||||
|
||||
let err = login_fetch_on_thread(provider, "sk-k").await.unwrap_err();
|
||||
|
||||
assert!(err.contains("also failed"), "{err}");
|
||||
assert!(!err.contains("sk-k"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn openai_listing_becomes_chat_completions_entries() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("authorization", "Bearer sk-proxy"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"object": "list",
|
||||
"data": [{"id": "m1", "context_length": 32000}, {"id": "m2"}]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1/", server.uri());
|
||||
|
||||
let models = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "sk-proxy"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ids: Vec<_> = models.iter().map(|m| m.id.as_deref().unwrap()).collect();
|
||||
assert_eq!(ids, ["proxy/m1", "proxy/m2"]);
|
||||
assert_eq!(models[0].model, "m1");
|
||||
assert_eq!(models[0].context_window.get(), 32_000);
|
||||
assert_eq!(
|
||||
models[1].context_window.get(),
|
||||
crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW
|
||||
);
|
||||
assert_eq!(models[0].base_url, format!("{}/v1", server.uri()));
|
||||
assert_eq!(
|
||||
models[0].api_backend,
|
||||
crate::sampling::ApiBackend::ChatCompletions
|
||||
);
|
||||
assert_eq!(models[0].auth_scheme, None);
|
||||
assert_eq!(models[0].description.as_deref(), Some("proxy"));
|
||||
assert!(models[0].api_key.is_none() && models[0].env_key.is_none());
|
||||
assert!(models[0].supported_in_api);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn anthropic_listing_becomes_messages_entries() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(query_param("limit", "1000"))
|
||||
.and(header("x-api-key", "sk-gw"))
|
||||
.and(header(
|
||||
"anthropic-version",
|
||||
kigi_sampling_types::ANTHROPIC_VERSION,
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": [{
|
||||
"id": "claude-x", "display_name": "Claude X",
|
||||
"max_input_tokens": 200000, "max_tokens": 64000
|
||||
}],
|
||||
"has_more": false
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
|
||||
let models = fetch_on_thread(credentialed("gw", CustomApi::Anthropic, &base, "sk-gw"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(models.len(), 1);
|
||||
let m = &models[0];
|
||||
assert_eq!(m.id.as_deref(), Some("gw/claude-x"));
|
||||
assert_eq!(m.name.as_deref(), Some("Claude X"));
|
||||
assert_eq!(m.context_window.get(), 200_000);
|
||||
assert_eq!(m.max_completion_tokens, Some(64_000));
|
||||
assert_eq!(m.api_backend, crate::sampling::ApiBackend::Messages);
|
||||
assert_eq!(m.auth_scheme, Some(kigi_sampler::AuthScheme::XApiKey));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn anthropic_listing_falls_back_to_bearer_on_a_401() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("x-api-key", "sk-gw"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("authorization", "Bearer sk-gw"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": [{"id": "c1"}], "has_more": false
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
|
||||
let models = fetch_on_thread(credentialed("gw", CustomApi::Anthropic, &base, "sk-gw"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0].id.as_deref(), Some("gw/c1"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn openai_listing_never_retries_on_a_401() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
let err = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "bad"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
BackendError::RequestFailed { status: 401, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn listing_failures_surface_status_and_parse_errors() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/v1/models"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_string("nope"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
let err = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "bad"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, BackendError::RequestFailed { status: 401, .. }),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/v1/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("not json"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
let err = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "k"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("openai listing parse failed"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn custom_providers_alone_satisfy_the_platform_fetch() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/v1/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": [{"id": "m1"}]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
let keys = PlatformApiKeys::test_custom(vec![credentialed(
|
||||
"proxy",
|
||||
CustomApi::OpenAi,
|
||||
&base,
|
||||
"sk-origin-secret",
|
||||
)]);
|
||||
let endpoints = crate::agent::config::EndpointsConfig::default();
|
||||
|
||||
let origin = crate::agent::models_fetch::models_fetch_origin(
|
||||
&endpoints,
|
||||
ModelFetchAuth::Platforms,
|
||||
false,
|
||||
&Default::default(),
|
||||
&keys,
|
||||
);
|
||||
assert!(origin.contains(&format!("proxy={base}/models")), "{origin}");
|
||||
assert!(
|
||||
!origin.contains("sk"),
|
||||
"the origin must never carry a key: {origin}"
|
||||
);
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
fetch_all(
|
||||
&endpoints,
|
||||
None,
|
||||
&Default::default(),
|
||||
ModelFetchAuth::Platforms,
|
||||
&keys,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("a custom provider is a complete catalog source");
|
||||
assert_eq!(result.models.len(), 1);
|
||||
assert_eq!(result.models[0].id.as_deref(), Some("proxy/m1"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failing_custom_provider_alone_fails_the_fetch() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/v1/models"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
let keys = PlatformApiKeys::test_custom(vec![credentialed(
|
||||
"proxy",
|
||||
CustomApi::OpenAi,
|
||||
&base,
|
||||
"sk-origin-secret",
|
||||
)]);
|
||||
let err = tokio::task::spawn_blocking(move || {
|
||||
fetch_all(
|
||||
&crate::agent::config::EndpointsConfig::default(),
|
||||
None,
|
||||
&Default::default(),
|
||||
ModelFetchAuth::Platforms,
|
||||
&keys,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.err()
|
||||
.expect("500 with no other source must fail");
|
||||
assert!(
|
||||
matches!(err, BackendError::RequestFailed { status: 500, .. }),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
fn resolved_entry(provider: &CustomProvider, id: &str, base: &str) -> (String, ModelEntry) {
|
||||
let wire: WireModel = serde_json::from_value(serde_json::json!({ "id": id })).unwrap();
|
||||
let mut cfg = entry(provider, wire);
|
||||
cfg.base_url = base.to_owned();
|
||||
let key = cfg.id.clone().unwrap();
|
||||
(key, ModelEntry::from_config_entry(&cfg))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_gives_each_entry_only_its_own_providers_key_on_its_own_host() {
|
||||
let proxy =
|
||||
CustomProvider::new("proxy", CustomApi::OpenAi, "https://p.example/v1").unwrap();
|
||||
let gw = CustomProvider::new("gw", CustomApi::Anthropic, "https://g.example/v1").unwrap();
|
||||
let mut resolved = IndexMap::new();
|
||||
for (key, entry) in [
|
||||
resolved_entry(&proxy, "m1", "https://p.example/v1"),
|
||||
resolved_entry(&gw, "c1", "https://g.example/v1"),
|
||||
resolved_entry(&proxy, "moved", "https://evil.example/v1"),
|
||||
] {
|
||||
resolved.insert(key, entry);
|
||||
}
|
||||
let (own_key, mut own) = resolved_entry(&proxy, "own", "https://p.example/v1");
|
||||
own.api_key = Some("per-model".into());
|
||||
resolved.insert(own_key, own);
|
||||
let snapshot = vec![
|
||||
CredentialedProvider::for_test(proxy, "sk-proxy"),
|
||||
CredentialedProvider::for_test(gw, "sk-gw"),
|
||||
];
|
||||
|
||||
stamp_credentials(&mut resolved, &snapshot);
|
||||
|
||||
assert_eq!(resolved["proxy/m1"].api_key.as_deref(), Some("sk-proxy"));
|
||||
assert_eq!(resolved["gw/c1"].api_key.as_deref(), Some("sk-gw"));
|
||||
assert_eq!(
|
||||
resolved["proxy/moved"].api_key, None,
|
||||
"off-host entry gets nothing"
|
||||
);
|
||||
assert_eq!(resolved["proxy/own"].api_key.as_deref(), Some("per-model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_model_list_stamps_fetched_custom_entries() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let proxy =
|
||||
CustomProvider::new("proxy", CustomApi::OpenAi, "https://p.example/v1").unwrap();
|
||||
let (key, entry) = resolved_entry(&proxy, "m1", "https://p.example/v1");
|
||||
let keys =
|
||||
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
|
||||
|
||||
let resolved = crate::agent::config::resolve_model_list(
|
||||
&cfg,
|
||||
Some(IndexMap::from([(key, entry)])),
|
||||
&keys,
|
||||
);
|
||||
|
||||
let m = resolved
|
||||
.get("proxy/m1")
|
||||
.expect("fetched entry survives resolution");
|
||||
assert_eq!(m.api_key.as_deref(), Some("sk-proxy"));
|
||||
assert!(m.has_own_credentials());
|
||||
assert!(m.visible_for_auth(false), "API-key users see the entry");
|
||||
assert_eq!(
|
||||
m.info.api_backend,
|
||||
crate::sampling::ApiBackend::ChatCompletions
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphaned_custom_entries_never_enter_the_catalog() {
|
||||
let proxy =
|
||||
CustomProvider::new("proxy", CustomApi::OpenAi, "https://p.example/v1").unwrap();
|
||||
let gone = CustomProvider::new("gone", CustomApi::OpenAi, "https://g.example/v1").unwrap();
|
||||
let mut prefetched = IndexMap::new();
|
||||
for (key, entry) in [
|
||||
resolved_entry(&proxy, "m1", "https://p.example/v1"),
|
||||
resolved_entry(&gone, "m2", "https://g.example/v1"),
|
||||
] {
|
||||
prefetched.insert(key, entry);
|
||||
}
|
||||
let registry: WireModel =
|
||||
serde_json::from_value(serde_json::json!({ "id": "kimi-k2-turbo-preview" })).unwrap();
|
||||
let registry = ModelEntry::from_config_entry(
|
||||
&crate::agent::models_fetch::platform_wire_model_to_entry(
|
||||
kigi_models::PlatformId::MoonshotCn,
|
||||
registry,
|
||||
"https://api.moonshot.cn/v1",
|
||||
),
|
||||
);
|
||||
prefetched.insert("moonshot-cn/kimi-k2-turbo-preview".into(), registry);
|
||||
let keyed = vec![CredentialedProvider::for_test(proxy, "sk-proxy")];
|
||||
let cfg = crate::agent::config::Config::default();
|
||||
|
||||
let resolved = crate::agent::config::resolve_model_list(
|
||||
&cfg,
|
||||
Some(prefetched.clone()),
|
||||
&PlatformApiKeys::test_custom(keyed.clone()),
|
||||
);
|
||||
let ids: Vec<_> = resolved.keys().map(String::as_str).collect();
|
||||
assert_eq!(ids, ["proxy/m1", "moonshot-cn/kimi-k2-turbo-preview"]);
|
||||
|
||||
let unkeyed =
|
||||
crate::agent::config::resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||||
assert!(
|
||||
!unkeyed.contains_key("proxy/m1") && !unkeyed.contains_key("gone/m2"),
|
||||
"a keyless provider leaves no entries behind"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_endpoint_mode_keeps_slashed_remote_ids() {
|
||||
let raw: toml::Value =
|
||||
toml::from_str("[endpoints]\nmodels_base_url = \"https://m.example/v1\"\n").unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let other = CustomProvider::new("org", CustomApi::OpenAi, "https://o.example/v1").unwrap();
|
||||
let (key, entry) = resolved_entry(&other, "llama", "https://o.example/v1");
|
||||
|
||||
let resolved = crate::agent::config::resolve_model_list(
|
||||
&cfg,
|
||||
Some(IndexMap::from([(key, entry)])),
|
||||
&Default::default(),
|
||||
);
|
||||
|
||||
assert!(
|
||||
resolved.contains_key("org/llama"),
|
||||
"{:?}",
|
||||
resolved.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Login for custom providers: picker rows, method ids, key validation.
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_models::custom::CustomApi;
|
||||
|
||||
use super::CredentialedProvider;
|
||||
|
||||
const PICKER_PREFIX: &str = "custom-";
|
||||
const LOGIN_PREFIX: &str = "custom:";
|
||||
const ALL_APIS: [CustomApi; 2] = [CustomApi::OpenAi, CustomApi::Anthropic];
|
||||
|
||||
/// The picker row for one wire.
|
||||
pub fn picker_method(api: CustomApi) -> acp::AuthMethod {
|
||||
acp::AuthMethod::Agent(
|
||||
acp::AuthMethodAgent::new(
|
||||
acp::AuthMethodId::new(format!("{PICKER_PREFIX}{}", api.as_str())),
|
||||
format!("Custom provider ({})", api.label()),
|
||||
)
|
||||
.description(Some("Your own base URL and API key".to_owned())),
|
||||
)
|
||||
}
|
||||
|
||||
/// Both picker rows, in picker order.
|
||||
pub fn picker_methods() -> Vec<acp::AuthMethod> {
|
||||
ALL_APIS.into_iter().map(picker_method).collect()
|
||||
}
|
||||
|
||||
/// The wire behind a picker row id.
|
||||
pub fn picker_api(id: &str) -> Option<CustomApi> {
|
||||
let suffix = id.strip_prefix(PICKER_PREFIX)?;
|
||||
ALL_APIS.into_iter().find(|api| api.as_str() == suffix)
|
||||
}
|
||||
|
||||
/// The id `authenticate` takes once the provider is saved.
|
||||
pub fn login_method_id(name: &str) -> acp::AuthMethodId {
|
||||
acp::AuthMethodId::new(format!("{LOGIN_PREFIX}{name}"))
|
||||
}
|
||||
|
||||
/// The provider name inside an `authenticate` method id.
|
||||
pub fn login_provider_name(id: &str) -> Option<&str> {
|
||||
id.strip_prefix(LOGIN_PREFIX)
|
||||
}
|
||||
|
||||
/// Probes the listing endpoint; errors never hold the key.
|
||||
pub(crate) async fn validate_key(credentialed: &CredentialedProvider) -> Result<(), String> {
|
||||
let provider = &credentialed.provider;
|
||||
let url = provider.models_url();
|
||||
let client = crate::http::shared_client();
|
||||
let send = |bearer_fallback: bool| {
|
||||
let request = match (provider.api, bearer_fallback) {
|
||||
(CustomApi::OpenAi, _) => client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", credentialed.key())),
|
||||
(CustomApi::Anthropic, false) => client
|
||||
.get(&url)
|
||||
.header("x-api-key", credentialed.key())
|
||||
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
|
||||
(CustomApi::Anthropic, true) => client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", credentialed.key()))
|
||||
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
|
||||
};
|
||||
request.send()
|
||||
};
|
||||
let mut response = send(false)
|
||||
.await
|
||||
.map_err(|e| format!("Couldn't reach {url}: {e}"))?;
|
||||
// Mirrors listing_body: relays may gate the listing on Bearer.
|
||||
if response.status().as_u16() == 401 && provider.api == CustomApi::Anthropic {
|
||||
tracing::info!(provider = %provider.name, "validation 401 with x-api-key; retrying Bearer");
|
||||
response = send(true)
|
||||
.await
|
||||
.map_err(|e| format!("Couldn't reach {url}: {e}"))?;
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
match status {
|
||||
200..=299 => Ok(()),
|
||||
401 | 403 => Err(format!(
|
||||
"{} rejected the API key (HTTP {status})",
|
||||
provider.name
|
||||
)),
|
||||
_ => Err(format!("{url} answered HTTP {status}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kigi_models::custom::CustomProvider;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn credentialed(api: CustomApi, base: &str, key: &str) -> CredentialedProvider {
|
||||
CredentialedProvider::for_test(CustomProvider::new("proxy", api, base).unwrap(), key)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_ids_round_trip_and_reject_lookalikes() {
|
||||
for api in ALL_APIS {
|
||||
let method = picker_method(api);
|
||||
assert_eq!(picker_api(method.id().0.as_ref()), Some(api));
|
||||
}
|
||||
assert_eq!(picker_api("custom-responses"), None);
|
||||
assert_eq!(picker_api("custom-OpenAI"), None);
|
||||
assert_eq!(picker_api("custom:proxy"), None);
|
||||
assert_eq!(picker_api("moonshot-cn"), None);
|
||||
assert_eq!(picker_methods().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_ids_carry_the_provider_name() {
|
||||
let id = login_method_id("my-proxy");
|
||||
assert_eq!(id.0.as_ref(), "custom:my-proxy");
|
||||
assert_eq!(login_provider_name(id.0.as_ref()), Some("my-proxy"));
|
||||
assert_eq!(login_provider_name("custom-openai"), None);
|
||||
assert_eq!(login_provider_name("proxy"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_sends_the_dialects_key_header() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("authorization", "Bearer sk-openai"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
validate_key(&credentialed(CustomApi::OpenAi, &base, "sk-openai"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("x-api-key", "sk-anthropic"))
|
||||
.and(header(
|
||||
"anthropic-version",
|
||||
kigi_sampling_types::ANTHROPIC_VERSION,
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
validate_key(&credentialed(CustomApi::Anthropic, &base, "sk-anthropic"))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_falls_back_to_bearer_for_anthropic_relays() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("x-api-key", "sk-gw"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("authorization", "Bearer sk-gw"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
validate_key(&credentialed(CustomApi::Anthropic, &base, "sk-gw"))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_names_the_failure_without_the_key() {
|
||||
let server = MockServer::start().await;
|
||||
for (status, path_suffix) in [(401, "a"), (403, "b"), (404, "c"), (500, "d")] {
|
||||
Mock::given(path(format!("/{path_suffix}/models")))
|
||||
.respond_with(ResponseTemplate::new(status))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
let check = |suffix: &str| {
|
||||
let base = format!("{}/{suffix}", server.uri());
|
||||
async move {
|
||||
validate_key(&credentialed(CustomApi::OpenAi, &base, "sk-secret-key"))
|
||||
.await
|
||||
.unwrap_err()
|
||||
}
|
||||
};
|
||||
assert!(check("a").await.contains("rejected the API key (HTTP 401)"));
|
||||
assert!(check("b").await.contains("rejected the API key (HTTP 403)"));
|
||||
assert!(check("c").await.contains("answered HTTP 404"));
|
||||
assert!(check("d").await.contains("answered HTTP 500"));
|
||||
|
||||
let err = validate_key(&credentialed(
|
||||
CustomApi::OpenAi,
|
||||
"http://127.0.0.1:1/v1",
|
||||
"sk-secret-key",
|
||||
))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("Couldn't reach"), "{err}");
|
||||
assert!(!err.contains("sk-secret-key"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! `[platforms.<name>]` tables that declare custom providers.
|
||||
|
||||
use kigi_models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
use crate::agent::config::{PlatformCredentialConfig, PlatformsConfig};
|
||||
|
||||
mod fetch;
|
||||
mod login;
|
||||
mod save;
|
||||
mod selection;
|
||||
|
||||
pub use fetch::{LoginFetch, fetch_listing_for_login};
|
||||
pub(crate) use fetch::{drop_orphans, fetch_models_blocking, stamp_credentials};
|
||||
pub(crate) use login::validate_key;
|
||||
pub use login::{login_method_id, login_provider_name, picker_api, picker_methods};
|
||||
pub use save::save_custom_provider;
|
||||
pub(crate) use selection::{filter_unselected, synthesize_selected};
|
||||
|
||||
/// Tables setting `base_url` or `api`, valid or not.
|
||||
fn declarations(platforms: &PlatformsConfig) -> Vec<(&str, Result<CustomProvider, String>)> {
|
||||
platforms
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.base_url.is_some() || entry.api.is_some())
|
||||
.map(|(name, entry)| (name.as_str(), parse_declaration(name, entry)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_declaration(
|
||||
name: &str,
|
||||
entry: &PlatformCredentialConfig,
|
||||
) -> Result<CustomProvider, String> {
|
||||
let api = entry
|
||||
.api
|
||||
.as_deref()
|
||||
.ok_or("api is required (\"openai\" or \"anthropic\")")?;
|
||||
let api = CustomApi::parse(api)
|
||||
.ok_or_else(|| format!("api {api:?} is not \"openai\" or \"anthropic\""))?;
|
||||
let base_url = entry.base_url.as_deref().ok_or("base_url is required")?;
|
||||
CustomProvider::new(name, api, base_url).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The valid declarations.
|
||||
pub(crate) fn providers(platforms: &PlatformsConfig) -> Vec<CustomProvider> {
|
||||
declarations(platforms)
|
||||
.into_iter()
|
||||
.filter_map(|(_, parsed)| parsed.ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Base URLs of the valid declarations.
|
||||
pub(crate) fn bases(platforms: &PlatformsConfig) -> Vec<String> {
|
||||
providers(platforms)
|
||||
.into_iter()
|
||||
.map(|p| p.base_url)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// True when the table declares a provider, valid or not.
|
||||
pub(crate) fn is_declared(entry: &PlatformCredentialConfig) -> bool {
|
||||
entry.base_url.is_some() || entry.api.is_some()
|
||||
}
|
||||
|
||||
/// Warns once per refused declaration; the table stays inert.
|
||||
pub(crate) fn warn_invalid_declarations(platforms: &PlatformsConfig) {
|
||||
for (name, parsed) in declarations(platforms) {
|
||||
if let Err(reason) = parsed {
|
||||
tracing::warn!(platform = %name, %reason, "[platforms.{name}] is not a valid custom provider; ignored");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A declared provider with a key; `Debug` hides the key.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CredentialedProvider {
|
||||
pub(crate) provider: CustomProvider,
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl CredentialedProvider {
|
||||
pub(crate) fn key(&self) -> &str {
|
||||
&self.key
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_test(provider: CustomProvider, key: &str) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
key: key.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for CredentialedProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CredentialedProvider")
|
||||
.field("provider", &self.provider)
|
||||
.field("key", &"<set>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Key order: auth.json scope, then the table's `api_key`.
|
||||
pub(crate) fn credentialed(
|
||||
platforms: &PlatformsConfig,
|
||||
stored: impl Fn(&str) -> Option<String>,
|
||||
) -> Vec<CredentialedProvider> {
|
||||
providers(platforms)
|
||||
.into_iter()
|
||||
.filter_map(|provider| {
|
||||
let key = stored(&provider.name)
|
||||
.filter(|k| !k.trim().is_empty())
|
||||
.or_else(|| {
|
||||
platforms
|
||||
.entries
|
||||
.get(&provider.name)
|
||||
.and_then(|e| e.api_key.clone())
|
||||
.filter(|k| !k.trim().is_empty())
|
||||
})?;
|
||||
Some(CredentialedProvider { provider, key })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn platforms(toml_src: &str) -> PlatformsConfig {
|
||||
toml::from_str(toml_src).expect("platforms table parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declarations_read_api_and_base_url() {
|
||||
let cfg = platforms(
|
||||
r#"
|
||||
[proxy]
|
||||
base_url = "https://h.example/v1/"
|
||||
api = "openai"
|
||||
[gw]
|
||||
base_url = "https://g.example/v1"
|
||||
api = "Anthropic"
|
||||
"#,
|
||||
);
|
||||
let found = providers(&cfg);
|
||||
assert_eq!(found.len(), 2);
|
||||
assert_eq!(found[0].name, "proxy");
|
||||
assert_eq!(found[0].api, CustomApi::OpenAi);
|
||||
assert_eq!(found[0].base_url, "https://h.example/v1");
|
||||
assert_eq!(found[1].api, CustomApi::Anthropic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_declarations_are_refused_with_a_reason() {
|
||||
let cfg = platforms(
|
||||
r#"
|
||||
[no-api]
|
||||
base_url = "https://h.example/v1"
|
||||
[no-url]
|
||||
api = "openai"
|
||||
[bad-api]
|
||||
base_url = "https://h.example/v1"
|
||||
api = "responses"
|
||||
[bad-url]
|
||||
base_url = "h.example"
|
||||
api = "openai"
|
||||
[openai]
|
||||
base_url = "https://h.example/v1"
|
||||
api = "openai"
|
||||
[Upper]
|
||||
base_url = "https://h.example/v1"
|
||||
api = "openai"
|
||||
"#,
|
||||
);
|
||||
assert!(providers(&cfg).is_empty());
|
||||
let reasons: Vec<String> = declarations(&cfg)
|
||||
.into_iter()
|
||||
.map(|(_, parsed)| parsed.expect_err("every declaration is invalid"))
|
||||
.collect();
|
||||
assert_eq!(reasons.len(), 6);
|
||||
assert!(reasons[0].contains("api is required"));
|
||||
assert!(reasons[1].contains("base_url is required"));
|
||||
assert!(reasons[2].contains("responses"));
|
||||
assert!(reasons[3].contains("base_url"));
|
||||
assert!(reasons[4].contains("built-in platform id"));
|
||||
assert!(reasons[5].contains("provider name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_with_only_a_key_is_not_a_declaration() {
|
||||
let cfg = platforms("[moonshot-cn]\napi_key = \"sk-x\"\n");
|
||||
assert!(declarations(&cfg).is_empty());
|
||||
assert!(!is_declared(&cfg.entries["moonshot-cn"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_prefers_auth_json_over_config_and_blank_is_unset() {
|
||||
let cfg = platforms(
|
||||
r#"
|
||||
[proxy]
|
||||
base_url = "https://h.example/v1"
|
||||
api = "openai"
|
||||
api_key = "from-config"
|
||||
[blank]
|
||||
base_url = "https://b.example/v1"
|
||||
api = "openai"
|
||||
api_key = " "
|
||||
[keyless]
|
||||
base_url = "https://k.example/v1"
|
||||
api = "openai"
|
||||
"#,
|
||||
);
|
||||
let stored = |name: &str| (name == "proxy").then(|| "from-auth-json".to_owned());
|
||||
let found = credentialed(&cfg, stored);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].provider.name, "proxy");
|
||||
assert_eq!(found[0].key(), "from-auth-json");
|
||||
|
||||
let found = credentialed(&cfg, |_| None);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].key(), "from-config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_never_prints_the_key() {
|
||||
let cfg = platforms(
|
||||
"[proxy]\nbase_url = \"https://h.example/v1\"\napi = \"openai\"\napi_key = \"sk-secret\"\n",
|
||||
);
|
||||
let found = credentialed(&cfg, |_| None);
|
||||
assert!(!format!("{found:?}").contains("sk-secret"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use kigi_models::custom::CustomProvider;
|
||||
use toml::Value as TomlValue;
|
||||
use toml::map::Map as TomlMap;
|
||||
|
||||
/// Writes `[platforms.<name>]` to config.toml and the key to auth.json.
|
||||
pub async fn save_custom_provider(
|
||||
provider: &CustomProvider,
|
||||
api_key: &str,
|
||||
models: &[String],
|
||||
) -> Result<()> {
|
||||
let provider = provider.clone();
|
||||
let api_key = api_key.to_owned();
|
||||
let models = models.to_owned();
|
||||
let _guard = crate::util::config::SAVE_LOCK.lock().await;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
save_custom_provider_in(
|
||||
&crate::util::config::user_config_path(),
|
||||
&crate::util::kigi_home::kigi_home(),
|
||||
&provider,
|
||||
&api_key,
|
||||
&models,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("custom provider save task: {e}"))?
|
||||
}
|
||||
|
||||
/// Config first; a keyless definition is inert.
|
||||
pub(crate) fn save_custom_provider_in(
|
||||
config_path: &std::path::Path,
|
||||
kigi_home: &std::path::Path,
|
||||
provider: &CustomProvider,
|
||||
api_key: &str,
|
||||
models: &[String],
|
||||
) -> Result<()> {
|
||||
let api_key = api_key.trim();
|
||||
anyhow::ensure!(!api_key.is_empty(), "API key must not be empty");
|
||||
let raw = crate::util::config::read_to_string_or_empty(config_path)
|
||||
.with_context(|| format!("reading {}", config_path.display()))?;
|
||||
let mut root: TomlValue = toml::from_str(&raw).map_err(|e| {
|
||||
anyhow!(
|
||||
"refusing to overwrite unparseable {}: {e}; fix the syntax error first",
|
||||
config_path.display()
|
||||
)
|
||||
})?;
|
||||
upsert_definition(&mut root, provider, models)?;
|
||||
let dest = crate::util::config::config_write_dest(config_path)?;
|
||||
crate::util::config::atomic_write_string(&dest, &toml::to_string_pretty(&root)?)
|
||||
.with_context(|| format!("writing {}", dest.display()))?;
|
||||
crate::auth::store_scoped_api_key(kigi_home, &provider.name, api_key)
|
||||
.with_context(|| format!("saving the key for {} to auth.json", provider.name))?;
|
||||
tracing::info!(
|
||||
provider = %provider.name,
|
||||
api = provider.api.as_str(),
|
||||
base_url = %provider.base_url,
|
||||
models = models.len(),
|
||||
"custom provider saved"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_definition(
|
||||
root: &mut TomlValue,
|
||||
provider: &CustomProvider,
|
||||
models: &[String],
|
||||
) -> Result<()> {
|
||||
let top = root
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| anyhow!("config root is not a table"))?;
|
||||
let platforms = top
|
||||
.entry("platforms")
|
||||
.or_insert_with(|| TomlValue::Table(TomlMap::new()))
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| anyhow!("[platforms] is not a table"))?;
|
||||
let entry = platforms
|
||||
.entry(provider.name.as_str())
|
||||
.or_insert_with(|| TomlValue::Table(TomlMap::new()))
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| anyhow!("[platforms.{}] is not a table", provider.name))?;
|
||||
entry.insert("api".into(), provider.api.as_str().into());
|
||||
entry.insert("base_url".into(), provider.base_url.as_str().into());
|
||||
entry.insert(
|
||||
"models".into(),
|
||||
TomlValue::Array(
|
||||
models
|
||||
.iter()
|
||||
.map(|m| TomlValue::String(m.clone()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kigi_models::custom::CustomApi;
|
||||
|
||||
fn provider(name: &str, api: CustomApi, url: &str) -> CustomProvider {
|
||||
CustomProvider::new(name, api, url).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_writes_definition_and_key_and_keeps_the_rest() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&config,
|
||||
"[ui]\ntheme = \"kiginight\"\n\n[platforms.moonshot-cn]\napi_key = \"sk-cn\"\n\n[platforms.proxy]\napi_key = \"hand-written\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1/");
|
||||
|
||||
save_custom_provider_in(&config, dir.path(), &p, " sk-new ", &["m1".to_owned()]).unwrap();
|
||||
|
||||
let saved: TomlValue = toml::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
|
||||
assert_eq!(saved["ui"]["theme"].as_str(), Some("kiginight"));
|
||||
assert_eq!(
|
||||
saved["platforms"]["moonshot-cn"]["api_key"].as_str(),
|
||||
Some("sk-cn")
|
||||
);
|
||||
let entry = &saved["platforms"]["proxy"];
|
||||
assert_eq!(entry["api"].as_str(), Some("openai"));
|
||||
assert_eq!(entry["base_url"].as_str(), Some("https://h.example/v1"));
|
||||
assert_eq!(entry["api_key"].as_str(), Some("hand-written"));
|
||||
assert_eq!(
|
||||
entry["models"].as_array().unwrap(),
|
||||
&[TomlValue::String("m1".into())]
|
||||
);
|
||||
let stored = crate::auth::read_auth_json(&dir.path().join("auth.json")).unwrap();
|
||||
assert_eq!(stored["proxy"].key, "sk-new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_creates_config_and_a_second_save_rewrites_the_definition() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = dir.path().join("config.toml");
|
||||
save_custom_provider_in(
|
||||
&config,
|
||||
dir.path(),
|
||||
&provider("gw", CustomApi::OpenAi, "https://a.example/v1"),
|
||||
"k1",
|
||||
&["a1".to_owned()],
|
||||
)
|
||||
.unwrap();
|
||||
save_custom_provider_in(
|
||||
&config,
|
||||
dir.path(),
|
||||
&provider("gw", CustomApi::Anthropic, "https://b.example/v1"),
|
||||
"k2",
|
||||
&["b1".to_owned()],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let saved: TomlValue = toml::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
|
||||
assert_eq!(saved["platforms"]["gw"]["api"].as_str(), Some("anthropic"));
|
||||
assert_eq!(
|
||||
saved["platforms"]["gw"]["base_url"].as_str(),
|
||||
Some("https://b.example/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
saved["platforms"]["gw"]["models"].as_array().unwrap(),
|
||||
&[TomlValue::String("b1".into())]
|
||||
);
|
||||
let stored = crate::auth::read_auth_json(&dir.path().join("auth.json")).unwrap();
|
||||
assert_eq!(stored["gw"].key, "k2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_key_and_broken_config_fail_before_any_write() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = dir.path().join("config.toml");
|
||||
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1");
|
||||
|
||||
assert!(save_custom_provider_in(&config, dir.path(), &p, " ", &[]).is_err());
|
||||
assert!(!config.exists());
|
||||
assert!(!dir.path().join("auth.json").exists());
|
||||
|
||||
std::fs::write(&config, "[platforms\nnope").unwrap();
|
||||
let err = save_custom_provider_in(&config, dir.path(), &p, "sk", &[]).unwrap_err();
|
||||
assert!(err.to_string().contains("unparseable"), "{err}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&config).unwrap(),
|
||||
"[platforms\nnope"
|
||||
);
|
||||
assert!(!dir.path().join("auth.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_table_platforms_value_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = dir.path().join("config.toml");
|
||||
std::fs::write(&config, "platforms = \"x\"\n").unwrap();
|
||||
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1");
|
||||
let err = save_custom_provider_in(&config, dir.path(), &p, "sk", &[]).unwrap_err();
|
||||
assert!(err.to_string().contains("not a table"), "{err}");
|
||||
assert!(!dir.path().join("auth.json").exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! The `[platforms.<name>] models` allowlist: keep the selected, synthesize the manual.
|
||||
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use super::CredentialedProvider;
|
||||
use crate::agent::config::{ModelEntry, PlatformsConfig};
|
||||
|
||||
/// Synthesizes selected ids the listing never served; runs before `[model.*]` overrides.
|
||||
pub(crate) fn synthesize_selected(
|
||||
resolved: &mut IndexMap<String, ModelEntry>,
|
||||
platforms: &PlatformsConfig,
|
||||
keyed: &[CredentialedProvider],
|
||||
) {
|
||||
for credentialed in keyed {
|
||||
let provider = &credentialed.provider;
|
||||
let Some(list) = platforms
|
||||
.entries
|
||||
.get(&provider.name)
|
||||
.and_then(|e| e.models.as_ref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for id in list.iter().map(|m| m.trim()) {
|
||||
if let Err(e) = kigi_models::custom::validate_model_id(id) {
|
||||
tracing::warn!(provider = %provider.name, %e, "skipping an invalid selected model id");
|
||||
continue;
|
||||
}
|
||||
let key = provider.managed_model_key(id);
|
||||
if resolved.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
let entry = super::fetch::manual_entry(provider, id);
|
||||
resolved.insert(key, ModelEntry::from_config_entry(&entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops `{name}/*` entries outside the provider's selection; runs after `[model.*]` overrides.
|
||||
pub(crate) fn filter_unselected(
|
||||
resolved: &mut IndexMap<String, ModelEntry>,
|
||||
platforms: &PlatformsConfig,
|
||||
keyed: &[CredentialedProvider],
|
||||
) {
|
||||
for credentialed in keyed {
|
||||
let provider = &credentialed.provider;
|
||||
let Some(list) = platforms
|
||||
.entries
|
||||
.get(&provider.name)
|
||||
.and_then(|e| e.models.as_ref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let list: Vec<&str> = list.iter().map(|m| m.trim()).collect();
|
||||
let prefix = format!("{}/", provider.name);
|
||||
resolved.retain(|key, entry| {
|
||||
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
|
||||
id.strip_prefix(&prefix)
|
||||
.is_none_or(|bare| list.contains(&bare))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kigi_models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
use crate::agent::models::PlatformApiKeys;
|
||||
|
||||
fn provider(name: &str, base: &str) -> CustomProvider {
|
||||
CustomProvider::new(name, CustomApi::OpenAi, base).unwrap()
|
||||
}
|
||||
|
||||
fn fetched_entry(provider: &CustomProvider, id: &str) -> (String, ModelEntry) {
|
||||
let cfg = super::super::fetch::manual_entry(provider, id);
|
||||
let key = cfg.id.clone().unwrap();
|
||||
(key, ModelEntry::from_config_entry(&cfg))
|
||||
}
|
||||
|
||||
fn keyed(provider: CustomProvider) -> Vec<CredentialedProvider> {
|
||||
vec![CredentialedProvider::for_test(provider, "sk-x")]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_filters_the_listing_and_synthesizes_the_manual() {
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let mut resolved = IndexMap::new();
|
||||
for id in ["m1", "m2"] {
|
||||
let (key, entry) = fetched_entry(&proxy, id);
|
||||
resolved.insert(key, entry);
|
||||
}
|
||||
let platforms: PlatformsConfig = toml::from_str(
|
||||
"[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"m2\", \"manual-1\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
|
||||
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
|
||||
|
||||
let ids: Vec<_> = resolved.keys().map(String::as_str).collect();
|
||||
assert_eq!(ids, ["proxy/m2", "proxy/manual-1"]);
|
||||
assert_eq!(resolved["proxy/manual-1"].info.model, "manual-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_provider_without_the_models_key_keeps_its_whole_listing() {
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let mut resolved = IndexMap::new();
|
||||
let (key, entry) = fetched_entry(&proxy, "m1");
|
||||
resolved.insert(key, entry);
|
||||
let platforms: PlatformsConfig =
|
||||
toml::from_str("[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\n")
|
||||
.unwrap();
|
||||
|
||||
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
|
||||
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
|
||||
|
||||
assert!(resolved.contains_key("proxy/m1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_selection_drops_every_fetched_entry() {
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let mut resolved = IndexMap::new();
|
||||
let (key, entry) = fetched_entry(&proxy, "m1");
|
||||
resolved.insert(key, entry);
|
||||
let platforms: PlatformsConfig = toml::from_str(
|
||||
"[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = []\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
|
||||
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
|
||||
|
||||
assert!(resolved.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_selected_ids_are_skipped_with_a_warning() {
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let mut resolved = IndexMap::new();
|
||||
let platforms: PlatformsConfig = toml::from_str(
|
||||
"[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"ok\", \"has space\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
|
||||
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
|
||||
|
||||
assert!(resolved.contains_key("proxy/ok"));
|
||||
assert_eq!(resolved.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_applies_through_resolve_model_list_and_gets_the_key() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"manual-1\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let keys =
|
||||
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
|
||||
|
||||
let resolved = crate::agent::config::resolve_model_list(&cfg, None, &keys);
|
||||
|
||||
let entry = resolved
|
||||
.get("proxy/manual-1")
|
||||
.expect("a manual model survives without any fetched catalog");
|
||||
assert_eq!(entry.api_key.as_deref(), Some("sk-proxy"));
|
||||
assert!(entry.has_own_credentials());
|
||||
assert_eq!(entry.info.context_window.get(), 256_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_model_override_composes_with_the_synthesized_entry() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"manual-1\"]\n\n[model.\"proxy/manual-1\"]\ncontext_window = 1000000\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let keys =
|
||||
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
|
||||
|
||||
let resolved = crate::agent::config::resolve_model_list(&cfg, None, &keys);
|
||||
|
||||
let entry = &resolved["proxy/manual-1"];
|
||||
assert_eq!(entry.info.context_window.get(), 1_000_000);
|
||||
assert_eq!(entry.info.base_url, "https://p.example/v1");
|
||||
assert_eq!(entry.api_key.as_deref(), Some("sk-proxy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_is_authoritative_in_the_provider_namespace() {
|
||||
// A context-only override cannot resurrect a deselected model.
|
||||
let raw: toml::Value = toml::from_str(
|
||||
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"m2\"]\n\n[model.\"proxy/m1\"]\ncontext_window = 123456\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let (key, entry) = fetched_entry(&proxy, "m1");
|
||||
let prefetched = IndexMap::from([(key, entry)]);
|
||||
let keys =
|
||||
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
|
||||
|
||||
let resolved = crate::agent::config::resolve_model_list(&cfg, Some(prefetched), &keys);
|
||||
|
||||
assert!(!resolved.contains_key("proxy/m1"));
|
||||
assert!(resolved.contains_key("proxy/m2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_owned_entries_keep_their_declared_wire_against_donors() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"shared\"]\n\n[model.donorx]\nmodel = \"shared\"\napi_backend = \"messages\"\ncontext_window = 500000\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let keys =
|
||||
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
|
||||
|
||||
let resolved = crate::agent::config::resolve_model_list(&cfg, None, &keys);
|
||||
|
||||
let entry = &resolved["proxy/shared"];
|
||||
assert_eq!(
|
||||
entry.info.api_backend,
|
||||
crate::sampling::ApiBackend::ChatCompletions
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use std::sync::Arc;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Where non-subscription users (no OAuth session) submit feedback instead.
|
||||
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/issues";
|
||||
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ELY-Open-Source/Kigi-CLI/issues/new";
|
||||
|
||||
/// HTTP error from the feedback endpoint with a preserved status code, so
|
||||
/// callers can distinguish auth failures (401) without string matching.
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod auth_method;
|
||||
pub mod chat_modes;
|
||||
pub mod config;
|
||||
pub mod config_model_override_parse;
|
||||
pub mod custom_providers;
|
||||
pub(crate) mod enrichment_fetch;
|
||||
mod ext_parsers;
|
||||
pub(crate) mod feedback_client;
|
||||
|
||||
@@ -73,6 +73,7 @@ enum CacheAuthMethod {
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct PlatformApiKeys {
|
||||
keys: std::collections::BTreeMap<kigi_models::PlatformId, String>,
|
||||
custom: Vec<super::custom_providers::CredentialedProvider>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PlatformApiKeys {
|
||||
@@ -83,6 +84,7 @@ impl std::fmt::Debug for PlatformApiKeys {
|
||||
s.field(platform.as_str(), &self.keys.contains_key(&platform));
|
||||
}
|
||||
}
|
||||
s.field("custom", &self.custom);
|
||||
s.finish()
|
||||
}
|
||||
}
|
||||
@@ -114,7 +116,13 @@ impl PlatformApiKeys {
|
||||
keys.insert(platform, key);
|
||||
}
|
||||
}
|
||||
Self { keys }
|
||||
let custom = super::custom_providers::credentialed(platforms, |name| {
|
||||
stored
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(name))
|
||||
.map(|a| a.key.clone())
|
||||
});
|
||||
Self { keys, custom }
|
||||
}
|
||||
|
||||
/// Resolve from the effective on-disk config (startup paths that have no
|
||||
@@ -135,7 +143,11 @@ impl PlatformApiKeys {
|
||||
/// Any API-key platform credentialed? Drives "should we prefetch without
|
||||
/// a session" and the F2 acceptance path (platform key only, no login).
|
||||
pub(crate) fn any(&self) -> bool {
|
||||
!self.keys.is_empty()
|
||||
!self.keys.is_empty() || !self.custom.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn custom(&self) -> &[super::custom_providers::CredentialedProvider] {
|
||||
&self.custom
|
||||
}
|
||||
|
||||
/// Test-only constructor (fields are private to this module).
|
||||
@@ -148,7 +160,18 @@ impl PlatformApiKeys {
|
||||
if let Some(k) = ai {
|
||||
keys.insert(kigi_models::PlatformId::MoonshotAi, k.to_owned());
|
||||
}
|
||||
Self { keys }
|
||||
Self {
|
||||
keys,
|
||||
custom: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_custom(custom: Vec<super::custom_providers::CredentialedProvider>) -> Self {
|
||||
Self {
|
||||
keys: Default::default(),
|
||||
custom,
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only constructor for a single API-key platform.
|
||||
@@ -156,6 +179,7 @@ impl PlatformApiKeys {
|
||||
pub(crate) fn test_single(platform: kigi_models::PlatformId, key: &str) -> Self {
|
||||
Self {
|
||||
keys: std::collections::BTreeMap::from([(platform, key.to_owned())]),
|
||||
custom: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3698,12 +3722,14 @@ mod tests {
|
||||
"moonshot-cn".into(),
|
||||
config::PlatformCredentialConfig {
|
||||
api_key: Some("cfg-cn".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
platforms.entries.insert(
|
||||
"moonshot-ai".into(),
|
||||
config::PlatformCredentialConfig {
|
||||
api_key: Some("cfg-ai".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -114,6 +114,12 @@ pub(crate) fn models_fetch_origin(
|
||||
let parts: Vec<String> = enabled_platforms(has_oauth, oauth_tokens, platform_keys)
|
||||
.into_iter()
|
||||
.map(|p| format!("{}={}", p.as_str(), platform_models_url(p, endpoints)))
|
||||
.chain(
|
||||
platform_keys
|
||||
.custom()
|
||||
.iter()
|
||||
.map(|c| format!("{}={}", c.provider.name, c.provider.models_url())),
|
||||
)
|
||||
.collect();
|
||||
format!("platforms[{}]", parts.join(";"))
|
||||
}
|
||||
@@ -265,7 +271,7 @@ fn fetch_platform_models_blocking(
|
||||
platform_keys: &crate::agent::models::PlatformApiKeys,
|
||||
) -> Result<FetchModelsResult, BackendError> {
|
||||
let enabled = enabled_platforms(auth.is_some(), oauth_tokens, platform_keys);
|
||||
if enabled.is_empty() {
|
||||
if enabled.is_empty() && platform_keys.custom().is_empty() {
|
||||
return Err(BackendError::Auth(
|
||||
"No platform credentials: log in with `kigi login`, paste a platform API key in \
|
||||
the login screen (stored in ~/.kigi/auth.json), or set a platform env var such \
|
||||
@@ -330,6 +336,28 @@ fn fetch_platform_models_blocking(
|
||||
}
|
||||
}
|
||||
|
||||
for credentialed in platform_keys.custom() {
|
||||
match crate::agent::custom_providers::fetch_models_blocking(credentialed) {
|
||||
Ok(custom_models) => {
|
||||
tracing::info!(
|
||||
provider = %credentialed.provider.name,
|
||||
count = custom_models.len(),
|
||||
"custom provider models fetch succeeded"
|
||||
);
|
||||
successes += 1;
|
||||
models.extend(custom_models);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
provider = %credentialed.provider.name,
|
||||
error = %e,
|
||||
"custom provider models fetch failed"
|
||||
);
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if successes == 0 {
|
||||
// All enabled platforms failed. When the failure includes an OAuth
|
||||
// 401, return `Ok` with the flag set (and no models) so the async
|
||||
@@ -613,6 +641,37 @@ pub(crate) fn platform_wire_model_to_entry(
|
||||
platform: kigi_models::PlatformId,
|
||||
wire: kigi_models::WireModel,
|
||||
base_url: &str,
|
||||
) -> crate::agent::config::ModelEntryConfig {
|
||||
let env_key = (!platform.uses_oauth())
|
||||
.then(|| crate::agent::config::EnvKeys::new(platform.api_key_env_names().iter().copied()));
|
||||
// `supported_in_api: false` hides a model unless the PRIMARY session is
|
||||
// an OAuth session (`ModelInfo::visible_for_auth`). Only `kimi-code` rides
|
||||
// that primary session, so only it may be gated on it. Every other OAuth
|
||||
// platform (claude-pro-max, openai-codex, github-copilot, xai-grok) carries
|
||||
// its OWN pooled credential and its models only enter the catalog once THAT
|
||||
// provider is signed in — gating them on the Kimi session would hide every
|
||||
// model from a user who signed in with only a Claude/ChatGPT/Copilot/Grok
|
||||
// subscription.
|
||||
wire_model_to_entry(
|
||||
platform.managed_model_key(&wire.id),
|
||||
platform.wire_api(),
|
||||
platform.key_header(),
|
||||
env_key,
|
||||
platform != kigi_models::PlatformId::KimiCode,
|
||||
wire,
|
||||
base_url,
|
||||
)
|
||||
}
|
||||
|
||||
/// Wire-to-catalog mapping shared by platforms and custom providers.
|
||||
pub(crate) fn wire_model_to_entry(
|
||||
id: String,
|
||||
wire_api: kigi_models::PlatformWireApi,
|
||||
key_header: kigi_models::PlatformKeyHeader,
|
||||
env_key: Option<crate::agent::config::EnvKeys>,
|
||||
supported_in_api: bool,
|
||||
wire: kigi_models::WireModel,
|
||||
base_url: &str,
|
||||
) -> crate::agent::config::ModelEntryConfig {
|
||||
let capabilities = wire.capabilities();
|
||||
// Selectable thinking levels (live wire `think_efforts`, e.g. K3's
|
||||
@@ -626,21 +685,19 @@ pub(crate) fn platform_wire_model_to_entry(
|
||||
);
|
||||
std::num::NonZeroU64::new(DEFAULT_CONTEXT_WINDOW).expect("non-zero")
|
||||
});
|
||||
let env_key = (!platform.uses_oauth())
|
||||
.then(|| crate::agent::config::EnvKeys::new(platform.api_key_env_names().iter().copied()));
|
||||
let api_backend = match platform.wire_api() {
|
||||
let api_backend = match wire_api {
|
||||
kigi_models::PlatformWireApi::ChatCompletions => {
|
||||
crate::sampling::ApiBackend::ChatCompletions
|
||||
}
|
||||
kigi_models::PlatformWireApi::Responses => crate::sampling::ApiBackend::Responses,
|
||||
kigi_models::PlatformWireApi::Messages => crate::sampling::ApiBackend::Messages,
|
||||
};
|
||||
let auth_scheme = match platform.key_header() {
|
||||
let auth_scheme = match key_header {
|
||||
kigi_models::PlatformKeyHeader::Bearer => None,
|
||||
kigi_models::PlatformKeyHeader::XApiKey => Some(kigi_sampler::AuthScheme::XApiKey),
|
||||
};
|
||||
crate::agent::config::ModelEntryConfig {
|
||||
id: Some(platform.managed_model_key(&wire.id)),
|
||||
id: Some(id),
|
||||
name: Some(wire.display_name.clone().unwrap_or_else(|| wire.id.clone())),
|
||||
model: wire.id,
|
||||
base_url: base_url.to_owned(),
|
||||
@@ -674,15 +731,7 @@ pub(crate) fn platform_wire_model_to_entry(
|
||||
inference_idle_timeout_secs: None,
|
||||
max_retries: None,
|
||||
hidden: false,
|
||||
// `supported_in_api: false` hides a model unless the PRIMARY session is
|
||||
// an OAuth session (`ModelInfo::visible_for_auth`). Only `kimi-code`
|
||||
// rides that primary session, so only it may be gated on it. Every
|
||||
// other OAuth platform (claude-pro-max, openai-codex, github-copilot,
|
||||
// xai-grok) carries its OWN pooled credential, and its models only
|
||||
// enter the catalog once THAT provider is signed in — gating them on
|
||||
// the Kimi session would hide every model from a user who signed in
|
||||
// with only a Claude/ChatGPT/Copilot/Grok subscription.
|
||||
supported_in_api: platform != kigi_models::PlatformId::KimiCode,
|
||||
supported_in_api,
|
||||
supports_backend_search: false,
|
||||
compactions_remaining: None,
|
||||
compaction_at_tokens: None,
|
||||
@@ -1510,6 +1559,7 @@ mod tests {
|
||||
.map(|m| m.id.as_deref().unwrap_or_default())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"openai-codex/gpt-6.1-sol",
|
||||
"openai-codex/gpt-6-astra",
|
||||
"openai-codex/gpt-6-sol",
|
||||
"openai-codex/gpt-6-luna",
|
||||
@@ -1518,7 +1568,7 @@ mod tests {
|
||||
"openai-codex/gpt-5.6-luna",
|
||||
"openai-codex/gpt-5.5",
|
||||
],
|
||||
"exactly the 7 hardcoded models, keyed openai-codex/<slug>"
|
||||
"exactly the 8 hardcoded models, keyed openai-codex/<slug>"
|
||||
);
|
||||
// Excluded models never appear.
|
||||
for absent in ["openai-codex/gpt-reserve", "openai-codex/codex-auto-review"] {
|
||||
@@ -1530,9 +1580,9 @@ mod tests {
|
||||
"{absent} must be absent from the hardcoded catalog"
|
||||
);
|
||||
}
|
||||
let astra = &result.models[0];
|
||||
assert_eq!(astra.name.as_deref(), Some("GPT-6-Astra"));
|
||||
assert_eq!(astra.context_window.get(), 272_000);
|
||||
let first = &result.models[0];
|
||||
assert_eq!(first.name.as_deref(), Some("GPT-6.1-Sol"));
|
||||
assert_eq!(first.context_window.get(), 272_000);
|
||||
let sol = result
|
||||
.models
|
||||
.iter()
|
||||
|
||||
@@ -557,6 +557,11 @@ impl acp::Agent for MvpAgent {
|
||||
.oauth_platform()
|
||||
{
|
||||
self.authenticate_oauth_platform(platform, arguments).await
|
||||
} else if let Some(name) = crate::agent::custom_providers::login_provider_name(
|
||||
arguments.method_id.0.as_ref(),
|
||||
) {
|
||||
self.authenticate_custom_provider(name, arguments.method_id.clone())
|
||||
.await
|
||||
} else if let Some(platform) =
|
||||
auth_method::platform_for_method_id(&arguments.method_id)
|
||||
{
|
||||
|
||||
@@ -526,6 +526,43 @@ impl MvpAgent {
|
||||
Some("platform_key_invalid_or_missing"),
|
||||
);
|
||||
})?;
|
||||
Ok(self.finish_api_key_login(method_id).await)
|
||||
}
|
||||
/// `authenticate(custom:<name>)`: validate the saved provider's key.
|
||||
pub(super) async fn authenticate_custom_provider(
|
||||
&self,
|
||||
name: &str,
|
||||
method_id: acp::AuthMethodId,
|
||||
) -> Result<AuthenticateResponse, acp::Error> {
|
||||
let keys =
|
||||
crate::agent::models::PlatformApiKeys::resolve_from_effective_config();
|
||||
let auth_err = |message: String| {
|
||||
let mut err = acp::Error::auth_required();
|
||||
err.message = message;
|
||||
err
|
||||
};
|
||||
let outcome = match keys.custom().iter().find(|c| c.provider.name == name) {
|
||||
Some(credentialed) => {
|
||||
crate::agent::custom_providers::validate_key(credentialed).await
|
||||
}
|
||||
None => Err(format!(
|
||||
"No custom provider named {name} with an API key in config.toml or auth.json"
|
||||
)),
|
||||
};
|
||||
if let Err(message) = outcome {
|
||||
tracing::warn!(provider = name, %message, "custom provider login failed");
|
||||
emit_login_span(
|
||||
false,
|
||||
method_id.0.as_ref(),
|
||||
None,
|
||||
Some("custom_provider_invalid_or_missing"),
|
||||
);
|
||||
return Err(auth_err(message));
|
||||
}
|
||||
Ok(self.finish_api_key_login(method_id).await)
|
||||
}
|
||||
/// Shared tail of an API-key login.
|
||||
async fn finish_api_key_login(&self, method_id: acp::AuthMethodId) -> AuthenticateResponse {
|
||||
// Rebuild the catalog from the on-disk config: the rebuild freshly
|
||||
// resolves platform keys (env > auth.json > config), so the key just
|
||||
// persisted to auth.json is stamped onto the platform's entries; a
|
||||
@@ -557,7 +594,7 @@ impl MvpAgent {
|
||||
let meta = serde_json::to_value(auth_meta)
|
||||
.ok()
|
||||
.and_then(|v| v.as_object().cloned());
|
||||
Ok(AuthenticateResponse::new().meta(meta))
|
||||
AuthenticateResponse::new().meta(meta)
|
||||
}
|
||||
/// `authenticate(<generic-oauth platform id>)`: interactive device-code
|
||||
/// login for a `uses_oauth` platform carrying an `OAuthConfig` (xai-grok).
|
||||
|
||||
@@ -879,6 +879,20 @@ fn log_subagent_model_resolution(
|
||||
)),
|
||||
);
|
||||
}
|
||||
/// The parent model's auth scheme from the live catalog.
|
||||
fn parent_auth_scheme(
|
||||
available: &indexmap::IndexMap<String, crate::agent::config::ModelEntry>,
|
||||
catalog_id: &acp::ModelId,
|
||||
slug: &str,
|
||||
) -> kigi_sampler::AuthScheme {
|
||||
crate::agent::models::entry_for_slug(available, Some(catalog_id.0.as_ref()), slug)
|
||||
.map(|entry| crate::agent::config::auth_facts_of(entry).auth_scheme)
|
||||
.or_else(|| {
|
||||
crate::agent::config::try_resolve_model_credentials(slug, None).map(|r| r.auth_scheme)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the parent session's actual current sampling config.
|
||||
///
|
||||
/// Prefers the live state from `ChatStateHandle` (authoritative). Falls back
|
||||
@@ -897,9 +911,7 @@ async fn read_parent_sampling_config(
|
||||
creds.alpha_test_key.as_deref(),
|
||||
&cfg.base_url,
|
||||
);
|
||||
let auth_scheme = crate::agent::config::try_resolve_model_credentials(&cfg.model, None)
|
||||
.map(|r| r.auth_scheme)
|
||||
.unwrap_or_default();
|
||||
let auth_scheme = parent_auth_scheme(&ctx.available_models, &ctx.model_id, &cfg.model);
|
||||
// Claude Pro/Max OAuth Messages adaptation inherits from the parent
|
||||
// model's platform (claude-pro-max → true); every other platform,
|
||||
// and BYOK, → false, so the API-key paths stay byte-identical.
|
||||
|
||||
@@ -3322,3 +3322,24 @@ async fn parent_ack_is_bounded_when_the_parent_never_answers() {
|
||||
.expect("parent acks must complete in bounded time");
|
||||
assert!(!acked, "an unanswered ack must report a miss");
|
||||
}
|
||||
/// A fetched Messages model exists only in the live catalog.
|
||||
#[test]
|
||||
fn a_fetched_entry_gives_its_own_scheme_to_the_child() {
|
||||
let wire: kigi_models::WireModel =
|
||||
serde_json::from_value(serde_json::json!({ "id": "claude-x" })).unwrap();
|
||||
let cfg = crate::agent::models_fetch::wire_model_to_entry(
|
||||
"gw/claude-x".into(),
|
||||
kigi_models::PlatformWireApi::Messages,
|
||||
kigi_models::PlatformKeyHeader::XApiKey,
|
||||
None,
|
||||
true,
|
||||
wire,
|
||||
"https://gw.example/v1",
|
||||
);
|
||||
let available = indexmap::IndexMap::from([(
|
||||
"gw/claude-x".to_owned(),
|
||||
crate::agent::config::ModelEntry::from_config_entry(&cfg),
|
||||
)]);
|
||||
let scheme = parent_auth_scheme(&available, &acp::ModelId::new("gw/claude-x"), "claude-x");
|
||||
assert_eq!(scheme, kigi_sampler::AuthScheme::XApiKey);
|
||||
}
|
||||
@@ -202,6 +202,15 @@ impl CredentialAuthority {
|
||||
/// Deliberately NOT [`crate::util::is_first_party_url`], which is
|
||||
/// production-only and would break every custom deployment.
|
||||
fn is_session_coding_endpoint(&self, base_url: &str) -> bool {
|
||||
// Declared custom providers are never the session's endpoint.
|
||||
if self
|
||||
.endpoints
|
||||
.custom_provider_bases
|
||||
.iter()
|
||||
.any(|base| crate::util::matches_trusted_base_url(base_url, base))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if crate::util::is_effective_coding_endpoint_url(base_url) {
|
||||
return true;
|
||||
}
|
||||
@@ -422,6 +431,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// LEAK guard: a declared loopback custom provider never gets the primary.
|
||||
#[test]
|
||||
fn declared_custom_provider_hosts_never_receive_the_primary() {
|
||||
let (_d, kimi) = primary("kimi-tok");
|
||||
let endpoints = EndpointsConfig {
|
||||
custom_provider_bases: vec![
|
||||
"http://localhost:11434/v1".to_owned(),
|
||||
"https://gw.example/v1".to_owned(),
|
||||
],
|
||||
..EndpointsConfig::default()
|
||||
};
|
||||
let auth = authority(endpoints, kimi);
|
||||
for url in [
|
||||
"http://localhost:11434/v1",
|
||||
"http://localhost:11434/v1/chat/completions",
|
||||
"https://gw.example/v1",
|
||||
] {
|
||||
assert_eq!(
|
||||
auth.credential_class(None, url),
|
||||
CredentialClass::None,
|
||||
"{url}"
|
||||
);
|
||||
assert!(auth.credential_for(None, url).is_none(), "{url}");
|
||||
assert!(auth.manager_for(None, url).is_none(), "{url}");
|
||||
}
|
||||
assert_eq!(
|
||||
auth.credential_class(None, "http://localhost:3000/v1"),
|
||||
CredentialClass::Primary,
|
||||
"an undeclared loopback proxy keeps the session bearer"
|
||||
);
|
||||
}
|
||||
|
||||
/// LEAK guard: every API-key registry platform, and any platform-less model
|
||||
/// on a third-party host, gets NO session credential and NO manager.
|
||||
#[test]
|
||||
|
||||
@@ -32,6 +32,7 @@ pub use manager::{AuthManager, shared_api_key_provider};
|
||||
pub use meta::AuthMeta;
|
||||
pub use model::{AuthMode, KimiAuth, lookup_auth};
|
||||
pub(crate) use model::{TOKEN_TTL, is_expired, token_suffix};
|
||||
pub(crate) use storage::store_scoped_api_key;
|
||||
pub use storage::{
|
||||
clear_api_key, read_api_key, read_auth_json, read_platform_api_key, read_token_by_scope,
|
||||
store_api_key, store_platform_api_key,
|
||||
|
||||
@@ -619,6 +619,15 @@ pub fn store_platform_api_key(
|
||||
),
|
||||
));
|
||||
}
|
||||
store_scoped_api_key(kigi_home, platform.as_str(), api_key)
|
||||
}
|
||||
|
||||
/// Stores a key under `scope`; callers own the name.
|
||||
pub(crate) fn store_scoped_api_key(
|
||||
kigi_home: &Path,
|
||||
scope: &str,
|
||||
api_key: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let path = kigi_home.join("auth.json");
|
||||
// Serialize with the manager's cross-process auth.json writers (token
|
||||
// refresh holds the same flock): an unlocked read-modify-write here
|
||||
@@ -641,7 +650,7 @@ pub fn store_platform_api_key(
|
||||
};
|
||||
let mut map = read_auth_json_or_empty_recovering_corrupt(&path)?;
|
||||
map.insert(
|
||||
platform.as_str().to_owned(),
|
||||
scope.to_owned(),
|
||||
KimiAuth {
|
||||
key: api_key.to_owned(),
|
||||
auth_mode: AuthMode::ApiKey,
|
||||
|
||||
@@ -1605,6 +1605,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn describe_requirements_file_flags_invalid_version_overrides_as_parse_error() {
|
||||
// Valid TOML but invalid `[[version_overrides]]` is rejected by the real
|
||||
// loader, so it must read "parse error", not "empty".
|
||||
|
||||
@@ -264,6 +264,16 @@ impl SessionActor {
|
||||
fn aux_model_auth_facts(&self, model_id: &str) -> crate::agent::config::ModelAuthFacts {
|
||||
self.resolve_auth_facts(model_id, false)
|
||||
}
|
||||
/// Facts from the live catalog entry the session samples against.
|
||||
fn live_auth_facts(&self, model_id: &str) -> Option<crate::agent::config::ModelAuthFacts> {
|
||||
let models = self.models_manager.models();
|
||||
crate::agent::models::entry_for_slug(
|
||||
&models,
|
||||
self.selected_catalog_key().as_deref(),
|
||||
model_id,
|
||||
)
|
||||
.map(crate::agent::config::auth_facts_of)
|
||||
}
|
||||
/// Shared body of [`Self::model_auth_facts`] / [`Self::aux_model_auth_facts`].
|
||||
/// `memoize` is the ONLY difference, so the two can never resolve differently.
|
||||
fn resolve_auth_facts(
|
||||
@@ -278,7 +288,9 @@ impl SessionActor {
|
||||
{
|
||||
return *facts;
|
||||
}
|
||||
let fresh = crate::agent::config::resolve_model_auth_facts(model_id);
|
||||
let fresh = self
|
||||
.live_auth_facts(model_id)
|
||||
.unwrap_or_else(|| crate::agent::config::resolve_model_auth_facts(model_id));
|
||||
if fresh.byok == ModelByok::Unknown {
|
||||
if let Some((cached_id, facts)) = self.model_auth_facts.borrow().as_ref()
|
||||
&& cached_id == model_id
|
||||
|
||||
@@ -9,7 +9,7 @@ use toml::map::Map as TomlMap;
|
||||
///
|
||||
/// Serializes the read-modify-write in `save_config` so two rapid
|
||||
/// settings toggles can't interleave and clobber each other.
|
||||
static SAVE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
pub(crate) static SAVE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
pub async fn save_config(config: &Config) -> Result<()> {
|
||||
let _guard = SAVE_LOCK.lock().await;
|
||||
|
||||
@@ -31,11 +31,7 @@ infer = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
image = { workspace = true, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"] }
|
||||
pdf_oxide = { workspace = true }
|
||||
# PPTX text extraction (implementations/read_file/pptx.rs). Deliberately NOT
|
||||
# `workspace = true`: the workspace `zip` pin has default features on, which
|
||||
# drag aes/bzip2/lzma/zstd into the shipped pager binary. PPTX entries are
|
||||
# only ever stored or deflated.
|
||||
zip = { version = "3", default-features = false, features = ["deflate-flate2"] }
|
||||
zip = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
indexmap = { workspace = true, features = ["serde"] }
|
||||
minijinja = { version = "2", features = ["custom_syntax"] }
|
||||
|
||||
@@ -106,7 +106,8 @@ mod linux {
|
||||
// SAFETY: `raw` comes from a successful `inotify_init1`, so it is a
|
||||
// live fd that nothing else owns.
|
||||
let owned = unsafe { OwnedFd::from_raw_fd(raw) };
|
||||
let fd = AsyncFd::with_interest(owned, Interest::READABLE)?;
|
||||
// SAFETY: the AsyncFd owns `owned`, so the fd stays open and fixed.
|
||||
let fd = unsafe { AsyncFd::register_with_interest(owned, Interest::READABLE) }?;
|
||||
Ok(Inotify { fd })
|
||||
}
|
||||
|
||||
|
||||
@@ -31,10 +31,14 @@ pub(crate) fn extract_pptx_text_from_bytes(bytes: &[u8]) -> Result<String, Strin
|
||||
let mut archive = ZipArchive::new(Cursor::new(bytes))
|
||||
.map_err(|e| format!("Failed to open PPTX archive: {e}"))?;
|
||||
|
||||
let names = archive
|
||||
.file_names()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Failed to read PPTX entry names: {e}"))?;
|
||||
// Collect slide numbers from `ppt/slides/slideN.xml` entries and sort
|
||||
// numerically so slide ordering matches the presentation.
|
||||
let mut slide_numbers: Vec<u32> = archive
|
||||
.file_names()
|
||||
let mut slide_numbers: Vec<u32> = names
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
name.strip_prefix("ppt/slides/slide")?
|
||||
.strip_suffix(".xml")?
|
||||
|
||||
@@ -570,6 +570,12 @@ pub enum Action {
|
||||
/// under the platform-id scope, then authenticate with the platform's
|
||||
/// method id. The key must never be logged.
|
||||
SubmitPlatformApiKey(String),
|
||||
/// Custom provider row picked: open the URL step.
|
||||
BeginCustomProviderEntry(kigi_shell::models::custom::CustomApi),
|
||||
/// Enter in a custom provider step.
|
||||
SubmitCustomProviderInput(String),
|
||||
/// Enter on an empty manual-model box: finish the model selection.
|
||||
SubmitCustomProviderSelection,
|
||||
/// Copy the auth URL to the clipboard during authentication.
|
||||
CopyAuthUrl,
|
||||
/// Show the raw auth URL with mouse capture disabled for manual copy.
|
||||
@@ -1258,7 +1264,7 @@ pub enum ProbedAttachment {
|
||||
PersistFailed(String),
|
||||
/// The pasteboard probe completed normally without raster data.
|
||||
NoRaster,
|
||||
/// The attachment result was intentionally discarded because its baseline went stale.
|
||||
/// The attachment result was discarded: stale baseline, or the paste was not the clipboard.
|
||||
ProbeDropped,
|
||||
/// The attachment probe task failed or timed out.
|
||||
ProbeFailed,
|
||||
@@ -1614,6 +1620,19 @@ pub enum Effect {
|
||||
target: crate::app::app_view::PlatformLogin,
|
||||
key: String,
|
||||
},
|
||||
/// Saves the provider, then authenticates; never log the key.
|
||||
PersistCustomProviderAndAuthenticate {
|
||||
request_seq: u64,
|
||||
provider: kigi_shell::models::custom::CustomProvider,
|
||||
key: String,
|
||||
models: Vec<String>,
|
||||
},
|
||||
/// List the provider's models for the login selection screen.
|
||||
FetchCustomProviderModels {
|
||||
request_seq: u64,
|
||||
provider: kigi_shell::models::custom::CustomProvider,
|
||||
key: String,
|
||||
},
|
||||
/// Fetch MCP server list from the shell (kigi/mcp/list).
|
||||
FetchMcpsList {
|
||||
agent_id: AgentId,
|
||||
@@ -2156,6 +2175,11 @@ pub enum TaskResult {
|
||||
AuthCodeSubmitted {
|
||||
request_seq: u64,
|
||||
},
|
||||
/// The login-step model listing came back (or failed).
|
||||
CustomProviderModelsListed {
|
||||
request_seq: u64,
|
||||
result: Result<kigi_shell::agent::custom_providers::LoginFetch, String>,
|
||||
},
|
||||
/// MCP server list fetched from shell.
|
||||
McpsListLoaded {
|
||||
agent_id: AgentId,
|
||||
|
||||
@@ -260,6 +260,26 @@ impl AgentView {
|
||||
}
|
||||
self.prompt.set_text("");
|
||||
}
|
||||
fn cancel_plan_comment(&mut self) -> InputOutcome {
|
||||
let stashed = if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Preview;
|
||||
pav.editing_comment_id = None;
|
||||
pav.commenting_range = None;
|
||||
pav.stashed_feedback_prompt.take()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(stashed) = stashed {
|
||||
self.prompt.restore(stashed);
|
||||
} else {
|
||||
self.prompt.set_text("");
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
/// Ctrl+C on an empty draft; the first one clears it.
|
||||
fn ctrl_c_cancels_empty_draft(&self, key: &KeyEvent) -> bool {
|
||||
crate::key!('c', CONTROL).matches(key) && self.prompt.text().is_empty()
|
||||
}
|
||||
pub(super) fn handle_plan_feedback_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
let is_commenting = self
|
||||
.plan_approval_view
|
||||
@@ -297,26 +317,16 @@ impl AgentView {
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if is_commenting {
|
||||
let stashed = if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Preview;
|
||||
pav.editing_comment_id = None;
|
||||
pav.commenting_range = None;
|
||||
pav.stashed_feedback_prompt.take()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(stashed) = stashed {
|
||||
self.prompt.restore(stashed);
|
||||
} else {
|
||||
self.prompt.set_text("");
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
return self.cancel_plan_comment();
|
||||
}
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.focus = PlanApprovalFocus::Preview;
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if is_commenting && self.ctrl_c_cancels_empty_draft(key) {
|
||||
return self.cancel_plan_comment();
|
||||
}
|
||||
match self.prompt.route_enter(key) {
|
||||
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
|
||||
EnterOutcome::Submit => {
|
||||
@@ -569,6 +579,9 @@ impl AgentView {
|
||||
}
|
||||
return self.cancel_casual_plan_commenting();
|
||||
}
|
||||
if self.ctrl_c_cancels_empty_draft(key) {
|
||||
return self.cancel_casual_plan_commenting();
|
||||
}
|
||||
match self.prompt.route_enter(key) {
|
||||
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
|
||||
EnterOutcome::Submit => return self.save_casual_plan_comment(),
|
||||
@@ -823,3 +836,52 @@ mod plan_chip_tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod plan_comment_cancel_tests {
|
||||
use super::test_fixtures::make_agent;
|
||||
use super::*;
|
||||
fn ctrl_c() -> KeyEvent {
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
|
||||
}
|
||||
fn stash_of(text: &str) -> crate::views::prompt_widget::StashedPrompt {
|
||||
let mut stash = crate::views::prompt_widget::StashedPrompt::default();
|
||||
stash.text = text.to_owned();
|
||||
stash
|
||||
}
|
||||
#[test]
|
||||
fn second_ctrl_c_cancels_a_plan_approval_comment() {
|
||||
let mut agent = make_agent();
|
||||
let mut pav =
|
||||
crate::app::agent_view::paste::paste_key_tests::make_plan_approval_view_state();
|
||||
pav.focus = PlanApprovalFocus::Commenting;
|
||||
pav.commenting_range = Some(0..1);
|
||||
pav.stashed_feedback_prompt = Some(stash_of("freeform notes"));
|
||||
agent.plan_approval_view = Some(pav);
|
||||
agent.prompt.set_text("half a comment");
|
||||
let _ = agent.handle_plan_feedback_key(&ctrl_c());
|
||||
assert_eq!(agent.prompt.text(), "");
|
||||
assert_eq!(
|
||||
agent.plan_approval_view.as_ref().map(|p| p.focus),
|
||||
Some(PlanApprovalFocus::Commenting),
|
||||
"the first Ctrl+C only clears the draft"
|
||||
);
|
||||
let _ = agent.handle_plan_feedback_key(&ctrl_c());
|
||||
let pav = agent.plan_approval_view.as_ref().unwrap();
|
||||
assert_eq!(pav.focus, PlanApprovalFocus::Preview);
|
||||
assert!(pav.commenting_range.is_none());
|
||||
assert_eq!(agent.prompt.text(), "freeform notes");
|
||||
}
|
||||
#[test]
|
||||
fn second_ctrl_c_cancels_a_casual_plan_comment() {
|
||||
let mut agent = make_agent();
|
||||
agent.casual_commenting_range = Some(0..1);
|
||||
agent.casual_stashed_prompt = Some(stash_of("earlier draft"));
|
||||
agent.prompt.set_text("half a comment");
|
||||
let _ = agent.handle_casual_plan_feedback_key(&ctrl_c());
|
||||
assert_eq!(agent.prompt.text(), "");
|
||||
assert!(agent.casual_commenting_range.is_some());
|
||||
let _ = agent.handle_casual_plan_feedback_key(&ctrl_c());
|
||||
assert!(agent.casual_commenting_range.is_none());
|
||||
assert_eq!(agent.prompt.text(), "earlier draft");
|
||||
}
|
||||
}
|
||||
@@ -262,6 +262,10 @@ pub enum AuthMode {
|
||||
/// Open-platform API-key entry: paste box for a Moonshot key selected
|
||||
/// from the welcome login picker. Esc returns to the picker (no quit).
|
||||
ApiKeyEntry(PlatformLogin),
|
||||
/// Custom provider login, one field per step.
|
||||
CustomProviderEntry(crate::app::custom_entry::CustomEntryStep),
|
||||
/// Custom provider login, the model-selection screen.
|
||||
CustomProviderSelect,
|
||||
}
|
||||
/// API-key platform login target, selected from the welcome picker. Wraps a
|
||||
/// non-OAuth registry platform ([`kigi_shell::models::PlatformId`]);
|
||||
@@ -310,6 +314,11 @@ pub enum PendingMenuItem {
|
||||
target: PlatformLogin,
|
||||
label: String,
|
||||
},
|
||||
/// Custom provider login for one wire.
|
||||
CustomProvider {
|
||||
api: kigi_shell::models::custom::CustomApi,
|
||||
label: String,
|
||||
},
|
||||
Quit,
|
||||
}
|
||||
impl PendingMenuItem {
|
||||
@@ -324,7 +333,9 @@ impl PendingMenuItem {
|
||||
}
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
Self::Login { label, .. } | Self::ApiKey { label, .. } => label,
|
||||
Self::Login { label, .. }
|
||||
| Self::ApiKey { label, .. }
|
||||
| Self::CustomProvider { label, .. } => label,
|
||||
Self::Quit => "Quit",
|
||||
}
|
||||
}
|
||||
@@ -366,7 +377,12 @@ pub fn pending_menu_items(
|
||||
use kigi_shell::agent::auth_method::AuthMethodKind;
|
||||
let mut items: Vec<PendingMenuItem> = Vec::new();
|
||||
for method in auth_methods {
|
||||
if let Some(target) = PlatformLogin::from_method_id(method.id()) {
|
||||
if let Some(api) = kigi_shell::agent::custom_providers::picker_api(method.id().0.as_ref()) {
|
||||
items.push(PendingMenuItem::CustomProvider {
|
||||
api,
|
||||
label: method.name().to_string(),
|
||||
});
|
||||
} else if let Some(target) = PlatformLogin::from_method_id(method.id()) {
|
||||
items.push(PendingMenuItem::ApiKey {
|
||||
target,
|
||||
label: method.name().to_string(),
|
||||
@@ -908,6 +924,10 @@ pub struct AppView {
|
||||
pub auth_start_mode: AuthMode,
|
||||
/// Text buffer for manual auth token paste (loopback mode).
|
||||
pub auth_code_input: String,
|
||||
/// Values collected by the custom provider login steps.
|
||||
pub custom_entry: crate::app::custom_entry::CustomEntryDraft,
|
||||
/// Model-selection state of a custom provider login; present in that mode.
|
||||
pub custom_select: Option<crate::app::custom_entry::CustomSelectState>,
|
||||
/// Monotonically increasing sequence number for auth requests.
|
||||
pub next_auth_request_seq: u64,
|
||||
/// Every session/chat/worktree/prompt action deferred behind startup gates.
|
||||
@@ -1111,6 +1131,8 @@ impl AppView {
|
||||
login_method_id: None,
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
custom_entry: Default::default(),
|
||||
custom_select: None,
|
||||
next_auth_request_seq: 1,
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
@@ -1646,6 +1668,7 @@ impl AppView {
|
||||
mid_session_login: self.auth_return_view.is_some(),
|
||||
auth_methods: &self.auth_methods,
|
||||
auth_code_input: &mut self.auth_code_input,
|
||||
custom_select: &mut self.custom_select,
|
||||
prompt: &mut self.welcome_prompt,
|
||||
prompt_focused: &mut self.welcome_prompt_focused,
|
||||
new_worktree_dialog: &mut self.new_worktree_dialog,
|
||||
@@ -2171,6 +2194,7 @@ struct WelcomeInputCtx<'a> {
|
||||
/// the `AuthState::Pending` welcome menu.
|
||||
auth_methods: &'a [acp::AuthMethod],
|
||||
auth_code_input: &'a mut String,
|
||||
custom_select: &'a mut Option<crate::app::custom_entry::CustomSelectState>,
|
||||
prompt: &'a mut PromptWidget,
|
||||
prompt_focused: &'a mut bool,
|
||||
new_worktree_dialog: &'a mut Option<NewWorktreeDialogState>,
|
||||
@@ -2648,7 +2672,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::ApiKeyEntry(_),
|
||||
mode: mode @ (AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_)),
|
||||
..
|
||||
} => {
|
||||
// Esc cancels BACK TO THE PICKER (unlike the OAuth flows,
|
||||
@@ -2664,16 +2688,97 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
}
|
||||
if key!(Enter).matches(key) {
|
||||
let trimmed = ctx.auth_code_input.trim().to_string();
|
||||
if matches!(mode, AuthMode::CustomProviderEntry(_)) {
|
||||
return InputOutcome::Action(Action::SubmitCustomProviderInput(trimmed));
|
||||
}
|
||||
if !trimmed.is_empty() {
|
||||
return InputOutcome::Action(Action::SubmitPlatformApiKey(trimmed));
|
||||
}
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if crate::input::key::is_paste_key(key)
|
||||
|| crate::input::key::is_inline_paste_key(key)
|
||||
{
|
||||
if let Some(text) = crate::clipboard::system_clipboard_get() {
|
||||
let cleaned: String =
|
||||
text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||
ctx.auth_code_input.push_str(&cleaned);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
crate::clipboard::log_paste_key_empty_host_clipboard("auth input");
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if key!(Backspace).matches(key) {
|
||||
ctx.auth_code_input.pop();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if let crossterm::event::KeyCode::Char(c) = key.code {
|
||||
if let crossterm::event::KeyCode::Char(c) = key.code
|
||||
&& crate::input::key::is_text_input_key(key)
|
||||
{
|
||||
ctx.auth_code_input.push(c);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} => {
|
||||
// Esc cancels back to the picker, like the entry steps.
|
||||
if key!(Esc).matches(key) {
|
||||
return InputOutcome::Action(Action::CancelPlatformKeyEntry);
|
||||
}
|
||||
if key!('q', CONTROL).matches(key) || key!('c', CONTROL).matches(key) {
|
||||
if ctx.mid_session_login {
|
||||
return InputOutcome::Action(Action::CancelLogin);
|
||||
}
|
||||
return InputOutcome::Action(Action::QuitConfirmed);
|
||||
}
|
||||
let Some(select) = ctx.custom_select.as_mut() else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
if key!(Up).matches(key) {
|
||||
select.move_highlight(-1);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key!(Down).matches(key) {
|
||||
select.move_highlight(1);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// Model ids hold no spaces, so the spacebar toggles rows.
|
||||
if key!(Char(' ')).matches(key) {
|
||||
select.toggle_highlighted();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if crate::input::key::is_paste_key(key)
|
||||
|| crate::input::key::is_inline_paste_key(key)
|
||||
{
|
||||
if let Some(text) = crate::clipboard::system_clipboard_get() {
|
||||
let cleaned: String =
|
||||
text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||
ctx.auth_code_input.push_str(&cleaned);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
crate::clipboard::log_paste_key_empty_host_clipboard("auth input");
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if key!(Enter).matches(key) {
|
||||
let trimmed = ctx.auth_code_input.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return InputOutcome::Action(Action::SubmitCustomProviderSelection);
|
||||
}
|
||||
select.add_manual(&trimmed);
|
||||
if select.error.is_none() {
|
||||
ctx.auth_code_input.clear();
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if key!(Backspace).matches(key) {
|
||||
ctx.auth_code_input.pop();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if let crossterm::event::KeyCode::Char(c) = key.code
|
||||
&& crate::input::key::is_text_input_key(key)
|
||||
{
|
||||
ctx.auth_code_input.push(c);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
@@ -2698,11 +2803,25 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
}
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if crate::input::key::is_paste_key(key)
|
||||
|| crate::input::key::is_inline_paste_key(key)
|
||||
{
|
||||
if let Some(text) = crate::clipboard::system_clipboard_get() {
|
||||
let cleaned: String =
|
||||
text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||
ctx.auth_code_input.push_str(&cleaned);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
crate::clipboard::log_paste_key_empty_host_clipboard("auth input");
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if key!(Backspace).matches(key) {
|
||||
ctx.auth_code_input.pop();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if let crossterm::event::KeyCode::Char(c) = key.code {
|
||||
if let crossterm::event::KeyCode::Char(c) = key.code
|
||||
&& crate::input::key::is_text_input_key(key)
|
||||
{
|
||||
ctx.auth_code_input.push(c);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
@@ -2726,7 +2845,11 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
return InputOutcome::ActionThenForward(Action::NewSession);
|
||||
}
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::Loopback | AuthMode::ApiKeyEntry(_),
|
||||
mode:
|
||||
AuthMode::Loopback
|
||||
| AuthMode::ApiKeyEntry(_)
|
||||
| AuthMode::CustomProviderEntry(_)
|
||||
| AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} => {
|
||||
let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||
@@ -2889,6 +3012,9 @@ fn dispatch_pending_menu_action(
|
||||
Some(PendingMenuItem::ApiKey { target, .. }) => {
|
||||
InputOutcome::Action(Action::BeginPlatformKeyEntry(*target))
|
||||
}
|
||||
Some(PendingMenuItem::CustomProvider { api, .. }) => {
|
||||
InputOutcome::Action(Action::BeginCustomProviderEntry(*api))
|
||||
}
|
||||
Some(PendingMenuItem::Quit) if mid_session => InputOutcome::Action(Action::CancelLogin),
|
||||
Some(PendingMenuItem::Quit) => InputOutcome::Action(Action::Quit),
|
||||
None => InputOutcome::Unchanged,
|
||||
@@ -3223,6 +3349,8 @@ impl AppView {
|
||||
auth_methods: &self.auth_methods,
|
||||
login_label: self.login_label.as_deref(),
|
||||
auth_code_input: &self.auth_code_input,
|
||||
custom_entry: &self.custom_entry,
|
||||
custom_select: self.custom_select.as_ref(),
|
||||
clipboard_copied: self.auth_clipboard_copied,
|
||||
show_raw_url: self.auth_show_raw_url,
|
||||
tip,
|
||||
@@ -4358,6 +4486,8 @@ pub(crate) mod tests {
|
||||
login_method_id: None,
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
custom_entry: Default::default(),
|
||||
custom_select: None,
|
||||
next_auth_request_seq: 1,
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
@@ -7005,7 +7135,7 @@ pub(crate) mod tests {
|
||||
#[test]
|
||||
fn pending_menu_items_lists_interactive_methods_plus_quit() {
|
||||
let items = pending_menu_items(&fresh_user_auth_methods(), None);
|
||||
assert_eq!(items.len(), 30, "29 login rows + Quit, got {items:?}");
|
||||
assert_eq!(items.len(), 32, "31 login rows + Quit, got {items:?}");
|
||||
assert_login_row(&items[0], "kimi-code", "Kimi Code (OAuth)");
|
||||
assert_login_row(&items[1], "xai-grok", "xAI Grok (subscription) (OAuth)");
|
||||
assert_login_row(
|
||||
@@ -7192,7 +7322,28 @@ pub(crate) mod tests {
|
||||
label: "MiniMax China (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(items[29], PendingMenuItem::Quit);
|
||||
assert_eq!(
|
||||
items[29],
|
||||
PendingMenuItem::CustomProvider {
|
||||
api: kigi_shell::models::custom::CustomApi::OpenAi,
|
||||
label: "Custom provider (OpenAI compatible)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[30],
|
||||
PendingMenuItem::CustomProvider {
|
||||
api: kigi_shell::models::custom::CustomApi::Anthropic,
|
||||
label: "Custom provider (Anthropic compatible)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(items[31], PendingMenuItem::Quit);
|
||||
assert!(!items[29].connected(&fresh_user_auth_methods()));
|
||||
assert!(matches!(
|
||||
dispatch_pending_menu_action(&items, 30, false),
|
||||
InputOutcome::Action(Action::BeginCustomProviderEntry(
|
||||
kigi_shell::models::custom::CustomApi::Anthropic
|
||||
))
|
||||
));
|
||||
// The non-interactive methods must never appear as rows.
|
||||
let byok = kigi_shell::agent::auth_method::build_auth_methods(
|
||||
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
|
||||
@@ -7203,8 +7354,8 @@ pub(crate) mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
pending_menu_items(&byok.methods, None).len(),
|
||||
30,
|
||||
"xai.api_key / cached_token must not add rows (29 login rows + Quit)"
|
||||
32,
|
||||
"xai.api_key / cached_token must not add rows (31 login rows + Quit)"
|
||||
);
|
||||
}
|
||||
/// Startup lands on the picker only when there is a real choice: the
|
||||
@@ -7554,6 +7705,71 @@ pub(crate) mod tests {
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert_eq!(app.auth_code_input, "token_value");
|
||||
}
|
||||
/// Ctrl+V / Super+V paste the host clipboard into every auth input.
|
||||
#[test]
|
||||
fn auth_inputs_paste_key_chords_insert_clipboard_text() {
|
||||
let select_state = || {
|
||||
crate::app::custom_entry::CustomSelectState::new(
|
||||
kigi_shell::models::custom::CustomProvider::new(
|
||||
"gw",
|
||||
kigi_shell::models::custom::CustomApi::OpenAi,
|
||||
"https://h.example/v1",
|
||||
)
|
||||
.unwrap(),
|
||||
"sk".to_owned(),
|
||||
)
|
||||
};
|
||||
let modes = [
|
||||
AuthMode::Loopback,
|
||||
AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
|
||||
AuthMode::CustomProviderEntry(crate::app::custom_entry::CustomEntryStep::first(
|
||||
kigi_shell::models::custom::CustomApi::OpenAi,
|
||||
)),
|
||||
AuthMode::CustomProviderSelect,
|
||||
];
|
||||
for mode in modes {
|
||||
for mods in [KeyModifiers::CONTROL, KeyModifiers::SUPER] {
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq: 1,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode,
|
||||
};
|
||||
if matches!(mode, AuthMode::CustomProviderSelect) {
|
||||
app.custom_select = Some(select_state());
|
||||
}
|
||||
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
|
||||
text: Some("sk-pasted\nkey".to_owned()),
|
||||
..Default::default()
|
||||
});
|
||||
let outcome = app.handle_input(&key_event(KeyCode::Char('v'), mods));
|
||||
crate::clipboard::clear_clipboard_probe_hook();
|
||||
assert!(
|
||||
matches!(outcome, InputOutcome::Changed),
|
||||
"{mode:?} + {mods:?} must paste, got {outcome:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.auth_code_input, "sk-pastedkey",
|
||||
"newlines stripped and no literal 'v' for {mode:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Ctrl+letter chords are not text input (the old Ctrl+V inserted 'v').
|
||||
#[test]
|
||||
fn auth_inputs_ignore_control_letter_chords() {
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq: 1,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Loopback,
|
||||
};
|
||||
let outcome = app.handle_input(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL));
|
||||
assert!(matches!(outcome, InputOutcome::Unchanged));
|
||||
assert!(app.auth_code_input.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn authenticating_loopback_enter_empty_is_noop() {
|
||||
let mut app = test_app();
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
//! Custom provider login steps: base URL, name, key.
|
||||
|
||||
use kigi_shell::models::custom::{
|
||||
CustomApi, CustomProvider, default_name, normalize_base_url, validate_model_id, validate_name,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CustomEntryField {
|
||||
BaseUrl,
|
||||
Name,
|
||||
Key,
|
||||
}
|
||||
|
||||
/// Which field the input box holds, for which wire.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CustomEntryStep {
|
||||
pub api: CustomApi,
|
||||
pub field: CustomEntryField,
|
||||
}
|
||||
|
||||
impl CustomEntryStep {
|
||||
pub fn first(api: CustomApi) -> Self {
|
||||
Self {
|
||||
api,
|
||||
field: CustomEntryField::BaseUrl,
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the key is masked.
|
||||
pub fn masks_input(self) -> bool {
|
||||
self.field == CustomEntryField::Key
|
||||
}
|
||||
|
||||
pub fn placeholder(self) -> &'static str {
|
||||
match self.field {
|
||||
CustomEntryField::BaseUrl => "https://host/v1",
|
||||
CustomEntryField::Name => "provider name",
|
||||
CustomEntryField::Key => "Paste your API key here...",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What one Enter press did.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum CustomEntryOutcome {
|
||||
/// The input stays; the draft error says why.
|
||||
Stay,
|
||||
/// Next field, with this text in the box.
|
||||
Next {
|
||||
field: CustomEntryField,
|
||||
prefill: String,
|
||||
},
|
||||
/// Every field is valid.
|
||||
Done {
|
||||
provider: CustomProvider,
|
||||
key: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Values collected so far, and the last refusal.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct CustomEntryDraft {
|
||||
base_url: String,
|
||||
name: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl CustomEntryDraft {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
base_url: String::new(),
|
||||
name: String::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
*self = Self::new();
|
||||
}
|
||||
|
||||
pub fn instruction(&self, step: CustomEntryStep) -> String {
|
||||
match step.field {
|
||||
CustomEntryField::BaseUrl => format!(
|
||||
"Base URL of the {} API, for example https://host/v1",
|
||||
step.api.label()
|
||||
),
|
||||
CustomEntryField::Name => {
|
||||
"Name for this provider. Its models appear as name/model".to_owned()
|
||||
}
|
||||
CustomEntryField::Key => format!(
|
||||
"API key for {}. A local server that ignores auth takes any value",
|
||||
self.name
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit(&mut self, step: CustomEntryStep, input: &str) -> CustomEntryOutcome {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
self.error = None;
|
||||
return CustomEntryOutcome::Stay;
|
||||
}
|
||||
self.error = None;
|
||||
match step.field {
|
||||
CustomEntryField::BaseUrl => match normalize_base_url(input) {
|
||||
Ok(url) => {
|
||||
let prefill = default_name(&url);
|
||||
self.base_url = url;
|
||||
CustomEntryOutcome::Next {
|
||||
field: CustomEntryField::Name,
|
||||
prefill,
|
||||
}
|
||||
}
|
||||
Err(e) => self.refuse(e),
|
||||
},
|
||||
CustomEntryField::Name => match validate_name(input) {
|
||||
Ok(()) => {
|
||||
self.name = input.to_owned();
|
||||
CustomEntryOutcome::Next {
|
||||
field: CustomEntryField::Key,
|
||||
prefill: String::new(),
|
||||
}
|
||||
}
|
||||
Err(e) => self.refuse(e),
|
||||
},
|
||||
CustomEntryField::Key => {
|
||||
match CustomProvider::new(&self.name, step.api, &self.base_url) {
|
||||
Ok(provider) => CustomEntryOutcome::Done {
|
||||
provider,
|
||||
key: input.to_owned(),
|
||||
},
|
||||
Err(e) => self.refuse(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refuse(&mut self, reason: impl std::fmt::Display) -> CustomEntryOutcome {
|
||||
self.error = Some(reason.to_string());
|
||||
CustomEntryOutcome::Stay
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch status of the model-selection screen.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SelectFetch {
|
||||
InFlight,
|
||||
Listed,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// One selectable model row; `manual` marks user-typed ids.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SelectRow {
|
||||
pub id: String,
|
||||
pub selected: bool,
|
||||
pub manual: bool,
|
||||
}
|
||||
|
||||
/// Model selection after the key step: fetched rows plus manual adds.
|
||||
#[derive(Clone)]
|
||||
pub struct CustomSelectState {
|
||||
/// The fetch may adopt a `/v1` base URL.
|
||||
pub provider: CustomProvider,
|
||||
key: String,
|
||||
pub rows: Vec<SelectRow>,
|
||||
pub highlight: usize,
|
||||
pub fetch: SelectFetch,
|
||||
pub error: Option<String>,
|
||||
/// Enter hit while the listing is in flight; it completes on arrival.
|
||||
pub finish_requested: bool,
|
||||
}
|
||||
|
||||
impl CustomSelectState {
|
||||
pub fn new(provider: CustomProvider, key: String) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
key,
|
||||
rows: Vec::new(),
|
||||
highlight: 0,
|
||||
fetch: SelectFetch::InFlight,
|
||||
error: None,
|
||||
finish_requested: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_key(self) -> String {
|
||||
self.key
|
||||
}
|
||||
|
||||
pub fn apply_fetch(&mut self, fetch: kigi_shell::agent::custom_providers::LoginFetch) {
|
||||
self.provider = fetch.provider;
|
||||
for id in fetch.model_ids {
|
||||
if self.rows.iter().any(|r| r.id == id) {
|
||||
continue;
|
||||
}
|
||||
self.rows.push(SelectRow {
|
||||
id,
|
||||
selected: false,
|
||||
manual: false,
|
||||
});
|
||||
}
|
||||
self.fetch = SelectFetch::Listed;
|
||||
}
|
||||
|
||||
pub fn fail_fetch(&mut self, reason: String) {
|
||||
self.fetch = SelectFetch::Failed(reason);
|
||||
}
|
||||
|
||||
pub fn move_highlight(&mut self, delta: isize) {
|
||||
if self.rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let max = self.rows.len() - 1;
|
||||
self.highlight = self.highlight.saturating_add_signed(delta).min(max);
|
||||
}
|
||||
|
||||
pub fn toggle_highlighted(&mut self) {
|
||||
if let Some(row) = self.rows.get_mut(self.highlight) {
|
||||
row.selected = !row.selected;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a typed id selected; an id already listed just gets selected.
|
||||
pub fn add_manual(&mut self, raw: &str) {
|
||||
let id = raw.trim();
|
||||
if let Err(e) = validate_model_id(id) {
|
||||
self.error = Some(e.to_string());
|
||||
return;
|
||||
}
|
||||
if let Some(row) = self.rows.iter_mut().find(|r| r.id == id) {
|
||||
row.selected = true;
|
||||
} else {
|
||||
self.rows.push(SelectRow {
|
||||
id: id.to_owned(),
|
||||
selected: true,
|
||||
manual: true,
|
||||
});
|
||||
}
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// The chosen ids, or a refusal when nothing is chosen.
|
||||
pub fn try_finish(&mut self) -> Option<Vec<String>> {
|
||||
// Wait for the listing: it may adopt a probed `/v1` base URL.
|
||||
if self.fetch == SelectFetch::InFlight {
|
||||
self.finish_requested = true;
|
||||
return None;
|
||||
}
|
||||
let ids: Vec<String> = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|r| r.selected)
|
||||
.map(|r| r.id.clone())
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
self.finish_requested = false;
|
||||
self.error = Some("Select at least one model, or type one and press enter".to_owned());
|
||||
return None;
|
||||
}
|
||||
Some(ids)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for CustomSelectState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CustomSelectState")
|
||||
.field("provider", &self.provider)
|
||||
.field("key", &"<set>")
|
||||
.field("rows", &self.rows)
|
||||
.field("highlight", &self.highlight)
|
||||
.field("fetch", &self.fetch)
|
||||
.field("error", &self.error)
|
||||
.field("finish_requested", &self.finish_requested)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn step(field: CustomEntryField) -> CustomEntryStep {
|
||||
CustomEntryStep {
|
||||
api: CustomApi::Anthropic,
|
||||
field,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_valid_steps_build_the_provider() {
|
||||
let mut draft = CustomEntryDraft::default();
|
||||
let next = draft.submit(
|
||||
step(CustomEntryField::BaseUrl),
|
||||
" https://api.gw.example/v1/ ",
|
||||
);
|
||||
assert_eq!(
|
||||
next,
|
||||
CustomEntryOutcome::Next {
|
||||
field: CustomEntryField::Name,
|
||||
prefill: "gw-example".to_owned()
|
||||
}
|
||||
);
|
||||
let next = draft.submit(step(CustomEntryField::Name), "gw");
|
||||
assert_eq!(
|
||||
next,
|
||||
CustomEntryOutcome::Next {
|
||||
field: CustomEntryField::Key,
|
||||
prefill: String::new()
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
draft
|
||||
.instruction(step(CustomEntryField::Key))
|
||||
.contains("API key for gw")
|
||||
);
|
||||
|
||||
let done = draft.submit(step(CustomEntryField::Key), " sk-1 ");
|
||||
let CustomEntryOutcome::Done { provider, key } = done else {
|
||||
panic!("expected Done, got {done:?}");
|
||||
};
|
||||
assert_eq!(provider.name, "gw");
|
||||
assert_eq!(provider.api, CustomApi::Anthropic);
|
||||
assert_eq!(provider.base_url, "https://api.gw.example/v1");
|
||||
assert_eq!(key, "sk-1");
|
||||
assert!(draft.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_stays_on_the_field_with_a_reason() {
|
||||
let mut draft = CustomEntryDraft::default();
|
||||
assert_eq!(
|
||||
draft.submit(step(CustomEntryField::BaseUrl), "host.example"),
|
||||
CustomEntryOutcome::Stay
|
||||
);
|
||||
assert!(draft.error.as_deref().is_some_and(|e| e.contains("http")));
|
||||
|
||||
draft.submit(step(CustomEntryField::BaseUrl), "https://h.example/v1");
|
||||
assert_eq!(
|
||||
draft.submit(step(CustomEntryField::Name), "OpenAI"),
|
||||
CustomEntryOutcome::Stay
|
||||
);
|
||||
assert!(draft.error.is_some());
|
||||
assert_eq!(
|
||||
draft.submit(step(CustomEntryField::Name), "openai"),
|
||||
CustomEntryOutcome::Stay
|
||||
);
|
||||
assert!(
|
||||
draft
|
||||
.error
|
||||
.as_deref()
|
||||
.is_some_and(|e| e.contains("built-in"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_stays_and_clears_the_last_error() {
|
||||
let mut draft = CustomEntryDraft::default();
|
||||
draft.submit(step(CustomEntryField::BaseUrl), "nope");
|
||||
assert!(draft.error.is_some());
|
||||
assert_eq!(
|
||||
draft.submit(step(CustomEntryField::BaseUrl), " "),
|
||||
CustomEntryOutcome::Stay
|
||||
);
|
||||
assert!(draft.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_key_is_masked() {
|
||||
assert!(step(CustomEntryField::Key).masks_input());
|
||||
assert!(!step(CustomEntryField::BaseUrl).masks_input());
|
||||
assert!(!step(CustomEntryField::Name).masks_input());
|
||||
}
|
||||
|
||||
fn select_state() -> CustomSelectState {
|
||||
let provider =
|
||||
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
|
||||
CustomSelectState::new(provider, "sk-secret".to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_state_toggles_adds_and_finishes() {
|
||||
let mut state = select_state();
|
||||
assert!(state.try_finish().is_none(), "in flight: finish waits");
|
||||
assert!(state.finish_requested && state.error.is_none());
|
||||
state.finish_requested = false;
|
||||
|
||||
state.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: state.provider.clone(),
|
||||
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
|
||||
});
|
||||
assert!(state.try_finish().is_none());
|
||||
assert!(state.error.is_some(), "listed: an empty selection refuses");
|
||||
assert_eq!(state.rows.len(), 2);
|
||||
|
||||
state.toggle_highlighted();
|
||||
state.move_highlight(1);
|
||||
state.toggle_highlighted();
|
||||
state.move_highlight(9);
|
||||
assert_eq!(state.highlight, 1, "highlight clamps at the last row");
|
||||
|
||||
state.add_manual(" claude-opus-5-5[1m] ");
|
||||
assert_eq!(state.rows.len(), 3);
|
||||
state.add_manual("m1");
|
||||
assert_eq!(
|
||||
state.rows.len(),
|
||||
3,
|
||||
"a listed id is selected, not duplicated"
|
||||
);
|
||||
state.add_manual("has space");
|
||||
assert!(state.error.is_some());
|
||||
assert_eq!(state.rows.len(), 3);
|
||||
|
||||
let ids = state.try_finish().expect("three selected");
|
||||
assert_eq!(ids, ["m1", "m2", "claude-opus-5-5[1m]"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_state_apply_fetch_adopts_the_probed_base_and_dedups() {
|
||||
let mut state = select_state();
|
||||
state.add_manual("m1");
|
||||
let adopted =
|
||||
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/coding/v1").unwrap();
|
||||
state.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: adopted,
|
||||
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
|
||||
});
|
||||
assert_eq!(state.provider.base_url, "https://h.example/coding/v1");
|
||||
assert_eq!(state.rows.len(), 2, "the manual row covers the fetched id");
|
||||
assert!(state.rows[0].manual && state.rows[0].selected);
|
||||
assert!(!state.rows[1].selected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_state_debug_hides_the_key() {
|
||||
let state = select_state();
|
||||
assert!(!format!("{state:?}").contains("sk-secret"));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use crate::app::actions::{Action, Effect};
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState, PlatformLogin};
|
||||
use crate::app::custom_entry::{CustomEntryOutcome, CustomEntryStep};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::SessionEvent;
|
||||
|
||||
@@ -281,6 +282,7 @@ pub(super) fn dispatch_cancel_login(app: &mut AppView) -> Vec<Effect> {
|
||||
app.auth_state = AuthState::Done;
|
||||
app.auth_show_raw_url = false;
|
||||
app.auth_code_input.clear();
|
||||
app.custom_select = None;
|
||||
app.auth_in_flight_method = None;
|
||||
restore_auth_return_view(app, return_view);
|
||||
// The user bailed out of re-auth — drop stashed prompts and strip the
|
||||
@@ -325,6 +327,151 @@ pub(super) fn dispatch_begin_platform_key_entry(
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Custom provider row selected: open the URL step.
|
||||
pub(super) fn dispatch_begin_custom_provider_entry(
|
||||
app: &mut AppView,
|
||||
api: kigi_shell::models::custom::CustomApi,
|
||||
) -> Vec<Effect> {
|
||||
let request_seq = app.next_auth_request_seq;
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.custom_entry.clear();
|
||||
app.custom_select = None;
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::CustomProviderEntry(CustomEntryStep::first(api)),
|
||||
};
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Enter in a custom provider step.
|
||||
pub(super) fn dispatch_submit_custom_provider_input(
|
||||
app: &mut AppView,
|
||||
input: String,
|
||||
) -> Vec<Effect> {
|
||||
let (request_seq, step) = match &app.auth_state {
|
||||
AuthState::Authenticating {
|
||||
request_seq,
|
||||
mode: AuthMode::CustomProviderEntry(step),
|
||||
..
|
||||
} => (*request_seq, *step),
|
||||
_ => return vec![],
|
||||
};
|
||||
match app.custom_entry.submit(step, &input) {
|
||||
CustomEntryOutcome::Stay => vec![],
|
||||
CustomEntryOutcome::Next { field, prefill } => {
|
||||
app.auth_code_input = prefill;
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::CustomProviderEntry(CustomEntryStep {
|
||||
api: step.api,
|
||||
field,
|
||||
}),
|
||||
};
|
||||
vec![]
|
||||
}
|
||||
CustomEntryOutcome::Done { provider, key } => {
|
||||
// Key accepted: open the model selection and fetch concurrently.
|
||||
app.custom_entry.clear();
|
||||
app.auth_code_input.clear();
|
||||
app.custom_select = Some(crate::app::custom_entry::CustomSelectState::new(
|
||||
provider.clone(),
|
||||
key.clone(),
|
||||
));
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
};
|
||||
vec![Effect::FetchCustomProviderModels {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter on an empty manual-model box: persist the chosen models, then auth.
|
||||
pub(super) fn dispatch_submit_custom_provider_selection(app: &mut AppView) -> Vec<Effect> {
|
||||
let request_seq = match &app.auth_state {
|
||||
AuthState::Authenticating {
|
||||
request_seq,
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} => *request_seq,
|
||||
_ => return vec![],
|
||||
};
|
||||
complete_custom_provider_selection(app, request_seq)
|
||||
}
|
||||
|
||||
/// Persists the selection once the listing no longer gates the base URL.
|
||||
fn complete_custom_provider_selection(app: &mut AppView, request_seq: u64) -> Vec<Effect> {
|
||||
let Some(state) = app.custom_select.as_mut() else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(models) = state.try_finish() else {
|
||||
return vec![];
|
||||
};
|
||||
let state = app.custom_select.take().expect("checked above");
|
||||
let provider = state.provider.clone();
|
||||
let key = state.take_key();
|
||||
app.auth_in_flight_method = Some(kigi_shell::agent::custom_providers::login_method_id(
|
||||
&provider.name,
|
||||
));
|
||||
app.auth_code_input.clear();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Pending,
|
||||
};
|
||||
vec![Effect::PersistCustomProviderAndAuthenticate {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
models,
|
||||
}]
|
||||
}
|
||||
|
||||
/// The login-step model listing came back; fill or flag the selection.
|
||||
pub(super) fn handle_custom_provider_models_listed(
|
||||
app: &mut AppView,
|
||||
request_seq: u64,
|
||||
result: Result<kigi_shell::agent::custom_providers::LoginFetch, String>,
|
||||
) -> Vec<Effect> {
|
||||
let in_flight = matches!(
|
||||
&app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
request_seq: current_seq,
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} if *current_seq == request_seq
|
||||
);
|
||||
if !in_flight {
|
||||
return vec![];
|
||||
}
|
||||
match (app.custom_select.as_mut(), result) {
|
||||
(Some(state), Ok(fetch)) => state.apply_fetch(fetch),
|
||||
(Some(state), Err(reason)) => state.fail_fetch(reason),
|
||||
(None, _) => {}
|
||||
}
|
||||
// A finish pressed during the fetch completes now, with the adopted base.
|
||||
if app
|
||||
.custom_select
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.finish_requested)
|
||||
{
|
||||
return complete_custom_provider_selection(app, request_seq);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Esc in the API-key paste box: back to the login picker (no error line).
|
||||
/// Bumps the request seq so any stale in-flight auth result is dropped by
|
||||
/// the `AuthComplete`/`AuthFailed` guards.
|
||||
@@ -332,7 +479,9 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
|
||||
if !matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::ApiKeyEntry(_),
|
||||
mode: AuthMode::ApiKeyEntry(_)
|
||||
| AuthMode::CustomProviderEntry(_)
|
||||
| AuthMode::CustomProviderSelect,
|
||||
..
|
||||
}
|
||||
) {
|
||||
@@ -340,6 +489,8 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
|
||||
}
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.custom_entry.clear();
|
||||
app.custom_select = None;
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
vec![]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Top-level action router: maps actions and action results to handlers.
|
||||
use super::auth::{
|
||||
dispatch_begin_platform_key_entry, dispatch_cancel_login, dispatch_cancel_platform_key_entry,
|
||||
dispatch_login, dispatch_login_with, dispatch_logout, dispatch_open_login_picker,
|
||||
dispatch_submit_auth_code, dispatch_submit_platform_api_key, dispatch_switch_account,
|
||||
dispatch_begin_custom_provider_entry, dispatch_begin_platform_key_entry, dispatch_cancel_login,
|
||||
dispatch_cancel_platform_key_entry, dispatch_login, dispatch_login_with, dispatch_logout,
|
||||
dispatch_open_login_picker, dispatch_submit_auth_code, dispatch_submit_custom_provider_input,
|
||||
dispatch_submit_custom_provider_selection, dispatch_submit_platform_api_key,
|
||||
dispatch_switch_account,
|
||||
};
|
||||
use super::ctx::{
|
||||
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
|
||||
@@ -889,6 +891,11 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
||||
Action::BeginPlatformKeyEntry(target) => dispatch_begin_platform_key_entry(app, target),
|
||||
Action::CancelPlatformKeyEntry => dispatch_cancel_platform_key_entry(app),
|
||||
Action::SubmitPlatformApiKey(key) => dispatch_submit_platform_api_key(app, key),
|
||||
Action::BeginCustomProviderEntry(api) => dispatch_begin_custom_provider_entry(app, api),
|
||||
Action::SubmitCustomProviderInput(input) => {
|
||||
dispatch_submit_custom_provider_input(app, input)
|
||||
}
|
||||
Action::SubmitCustomProviderSelection => dispatch_submit_custom_provider_selection(app),
|
||||
Action::CopyAuthUrl => {
|
||||
if let AuthState::Authenticating {
|
||||
auth_url: Some(url),
|
||||
|
||||
@@ -509,9 +509,14 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
{
|
||||
app.auth_state = AuthState::Pending { error: Some(error) };
|
||||
app.auth_code_input.clear();
|
||||
app.custom_select = None;
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
TaskResult::CustomProviderModelsListed {
|
||||
request_seq,
|
||||
result,
|
||||
} => super::auth::handle_custom_provider_models_listed(app, request_seq, result),
|
||||
TaskResult::AuthUrlReady {
|
||||
request_seq,
|
||||
auth_url,
|
||||
@@ -798,6 +803,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
app.login_method_id = None;
|
||||
ensure_login_method(app);
|
||||
app.auth_clipboard_copied = false;
|
||||
app.custom_select = None;
|
||||
let effects = dispatch_exit_session(app);
|
||||
app.welcome_prompt_focused = false;
|
||||
effects
|
||||
|
||||
@@ -571,3 +571,355 @@ fn auth_complete_marks_the_authenticated_method_connected() {
|
||||
"other methods must stay unmarked"
|
||||
);
|
||||
}
|
||||
|
||||
fn custom_field(app: &AppView) -> crate::app::custom_entry::CustomEntryField {
|
||||
match &app.auth_state {
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::CustomProviderEntry(step),
|
||||
..
|
||||
} => step.field,
|
||||
other => panic!("expected a custom provider step, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// URL, name, key; the last Enter opens the selection screen and fetches.
|
||||
#[test]
|
||||
fn custom_provider_steps_end_in_the_model_selection() {
|
||||
use crate::app::custom_entry::CustomEntryField;
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
|
||||
let effects = dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::Anthropic),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
|
||||
let seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://api.gw.example/v1/".into()),
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(custom_field(&app), CustomEntryField::Name);
|
||||
assert_eq!(
|
||||
app.auth_code_input, "gw-example",
|
||||
"the name is prefilled from the host"
|
||||
);
|
||||
|
||||
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
|
||||
assert_eq!(custom_field(&app), CustomEntryField::Key);
|
||||
assert!(app.auth_code_input.is_empty(), "the key box starts empty");
|
||||
|
||||
let effects = dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||
match effects.as_slice() {
|
||||
[
|
||||
Effect::FetchCustomProviderModels {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
},
|
||||
] => {
|
||||
assert_eq!(*request_seq, seq);
|
||||
assert_eq!(provider.name, "gw");
|
||||
assert_eq!(provider.api, CustomApi::Anthropic);
|
||||
assert_eq!(provider.base_url, "https://api.gw.example/v1");
|
||||
assert_eq!(key, "sk-gw");
|
||||
}
|
||||
other => panic!("expected exactly the fetch effect, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
request_seq,
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
} if request_seq == seq
|
||||
));
|
||||
assert!(app.custom_select.is_some());
|
||||
assert!(app.auth_code_input.is_empty());
|
||||
}
|
||||
|
||||
/// The listing fills rows; the selection persists only the chosen models.
|
||||
#[test]
|
||||
fn custom_provider_selection_persists_the_chosen_models() {
|
||||
use kigi_shell::models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://h.example".into()),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
|
||||
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||
let seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// The listing lands: rows appear, the probed base is adopted.
|
||||
let adopted = CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
|
||||
let listed = kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: adopted,
|
||||
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
|
||||
};
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
|
||||
request_seq: seq,
|
||||
result: Ok(listed),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
let state = app.custom_select.as_ref().expect("selection lives");
|
||||
assert_eq!(state.provider.base_url, "https://h.example/v1");
|
||||
assert_eq!(state.rows.len(), 2);
|
||||
assert!(!state.rows[0].selected);
|
||||
|
||||
// An empty selection refuses to finish once the listing resolved.
|
||||
assert!(dispatch(Action::SubmitCustomProviderSelection, &mut app).is_empty());
|
||||
assert!(
|
||||
app.custom_select
|
||||
.as_ref()
|
||||
.and_then(|s| s.error.as_deref())
|
||||
.is_some()
|
||||
);
|
||||
|
||||
// A late manual id typed before the fetch survives the fill.
|
||||
let state = app.custom_select.as_mut().expect("selection lives");
|
||||
state.add_manual("claude-opus-5-5[1m]");
|
||||
assert_eq!(state.rows.len(), 3);
|
||||
assert!(state.rows[2].manual && state.rows[2].selected);
|
||||
|
||||
// Select m2 as well, then finish.
|
||||
let state = app.custom_select.as_mut().expect("selection lives");
|
||||
state.highlight = 1;
|
||||
state.toggle_highlighted();
|
||||
let effects = dispatch(Action::SubmitCustomProviderSelection, &mut app);
|
||||
match effects.as_slice() {
|
||||
[
|
||||
Effect::PersistCustomProviderAndAuthenticate {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
models,
|
||||
},
|
||||
] => {
|
||||
assert_eq!(*request_seq, seq);
|
||||
assert_eq!(provider.base_url, "https://h.example/v1");
|
||||
assert_eq!(key, "sk-gw");
|
||||
assert_eq!(models, &["m2".to_owned(), "claude-opus-5-5[1m]".to_owned()]);
|
||||
}
|
||||
other => panic!("expected the persist effect, got {other:?}"),
|
||||
}
|
||||
assert!(app.custom_select.is_none());
|
||||
assert!(matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::Pending,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(
|
||||
app.auth_in_flight_method.as_ref().map(|m| m.0.as_ref()),
|
||||
Some("custom:gw")
|
||||
);
|
||||
}
|
||||
|
||||
/// Finishing while the listing is in flight completes when it lands.
|
||||
#[test]
|
||||
fn custom_provider_selection_finish_waits_for_the_fetch() {
|
||||
use kigi_shell::models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://h.example".into()),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
|
||||
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||
let seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let state = app.custom_select.as_mut().expect("selection lives");
|
||||
state.add_manual("m1");
|
||||
// Enter with an empty box while the fetch runs: no persist yet.
|
||||
assert!(dispatch(Action::SubmitCustomProviderSelection, &mut app).is_empty());
|
||||
assert!(app.custom_select.as_ref().expect("lives").finish_requested);
|
||||
assert!(matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::CustomProviderSelect,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
// The listing lands with an adopted /v1 base; the finish fires now.
|
||||
let adopted = CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
|
||||
request_seq: seq,
|
||||
result: Ok(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: adopted,
|
||||
model_ids: vec!["m2".to_owned()],
|
||||
}),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
match effects.as_slice() {
|
||||
[
|
||||
Effect::PersistCustomProviderAndAuthenticate {
|
||||
provider, models, ..
|
||||
},
|
||||
] => {
|
||||
assert_eq!(provider.base_url, "https://h.example/v1");
|
||||
assert_eq!(models, &["m1".to_owned()]);
|
||||
}
|
||||
other => panic!("expected the persist effect, got {other:?}"),
|
||||
}
|
||||
assert!(app.custom_select.is_none());
|
||||
}
|
||||
|
||||
/// A failed listing keeps the manual input usable and shows the reason.
|
||||
#[test]
|
||||
fn custom_provider_selection_survives_a_failed_fetch() {
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://h.example/v1".into()),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
|
||||
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||
let seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
|
||||
request_seq: seq,
|
||||
result: Err("https://h.example/v1/models answered HTTP 404".to_owned()),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
let state = app.custom_select.as_ref().expect("selection lives");
|
||||
assert!(matches!(
|
||||
&state.fetch,
|
||||
crate::app::custom_entry::SelectFetch::Failed(reason) if reason.contains("404")
|
||||
));
|
||||
|
||||
// A stale result for an older request never clobbers the screen.
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
|
||||
request_seq: seq + 99,
|
||||
result: Ok(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: state.provider.clone(),
|
||||
model_ids: vec!["stale".to_owned()],
|
||||
}),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
assert!(
|
||||
app.custom_select
|
||||
.as_ref()
|
||||
.expect("selection lives")
|
||||
.rows
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// Manual add still finishes the flow.
|
||||
let state = app.custom_select.as_mut().expect("selection lives");
|
||||
state.add_manual("m1");
|
||||
let effects = dispatch(Action::SubmitCustomProviderSelection, &mut app);
|
||||
assert!(matches!(
|
||||
effects.as_slice(),
|
||||
[Effect::PersistCustomProviderAndAuthenticate { models, .. }] if models == &["m1".to_owned()]
|
||||
));
|
||||
}
|
||||
|
||||
/// A refused field keeps the user on it.
|
||||
#[test]
|
||||
fn custom_provider_refusals_stay_on_the_field() {
|
||||
use crate::app::custom_entry::CustomEntryField;
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("host.example".into()),
|
||||
&mut app
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
|
||||
assert!(app.custom_entry.error.is_some());
|
||||
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://h.example/v1".into()),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("anthropic".into()),
|
||||
&mut app
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(custom_field(&app), CustomEntryField::Name);
|
||||
assert!(app.custom_entry.error.is_some());
|
||||
}
|
||||
|
||||
/// Esc returns to the picker and drops the draft.
|
||||
#[test]
|
||||
fn cancel_custom_provider_entry_returns_to_picker() {
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(Action::SubmitCustomProviderInput("nope".into()), &mut app);
|
||||
assert!(app.custom_entry.error.is_some());
|
||||
let seq_before = app.next_auth_request_seq;
|
||||
|
||||
let effects = dispatch(Action::CancelPlatformKeyEntry, &mut app);
|
||||
|
||||
assert!(effects.is_empty());
|
||||
assert!(matches!(app.auth_state, AuthState::Pending { error: None }));
|
||||
assert!(app.custom_entry.error.is_none());
|
||||
assert!(app.next_auth_request_seq > seq_before);
|
||||
}
|
||||
@@ -126,6 +126,8 @@ fn test_app() -> AppView {
|
||||
login_method_id: None,
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
custom_entry: Default::default(),
|
||||
custom_select: None,
|
||||
next_auth_request_seq: 1,
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
|
||||
@@ -1103,6 +1103,27 @@ pub(super) fn parse_usage_response(
|
||||
.map(|usage| usage.rows)
|
||||
.map_err(|e| format!("Parse error: {e}"))
|
||||
}
|
||||
/// `Some` ends a clipboard probe before any attachment read.
|
||||
pub(super) fn bracketed_origin_verdict(
|
||||
probe_bracketed: bool,
|
||||
payload: Option<&str>,
|
||||
) -> Option<crate::app::actions::ProbedAttachment> {
|
||||
use crate::app::actions::ProbedAttachment;
|
||||
if !probe_bracketed {
|
||||
return None;
|
||||
}
|
||||
match crate::clipboard::bracketed_payload_came_from_clipboard_result(payload.unwrap_or("")) {
|
||||
Ok(true) => None,
|
||||
Ok(false) => {
|
||||
tracing::debug!("bracketed paste differs from the clipboard; no attachment probe");
|
||||
Some(ProbedAttachment::ProbeDropped)
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!("clipboard unreadable for a bracketed paste; no attachment probe");
|
||||
Some(ProbedAttachment::ProbeDropped)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A blocking flock on the shared, possibly-network `~/.kigi` lock must never
|
||||
/// stall the event-loop thread (and would hang exit on `/quit`); the registry
|
||||
/// is best-effort, so skip on contention.
|
||||
|
||||
@@ -1637,18 +1637,11 @@ pub(crate) fn execute(
|
||||
{
|
||||
return (ProbedAttachment::ProbeDropped, None);
|
||||
}
|
||||
if probe_bracketed
|
||||
&& crate::terminal::terminal_context()
|
||||
.brand
|
||||
.delivers_ime_as_bracketed_paste()
|
||||
{
|
||||
match crate::clipboard::bracketed_payload_came_from_clipboard_result(
|
||||
probe_text.as_deref().unwrap_or(""),
|
||||
) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return (ProbedAttachment::ProbeDropped, None),
|
||||
Err(_) => return (ProbedAttachment::ProbeFailed, None),
|
||||
}
|
||||
if let Some(verdict) = helpers::bracketed_origin_verdict(
|
||||
probe_bracketed,
|
||||
probe_text.as_deref(),
|
||||
) {
|
||||
return (verdict, None);
|
||||
}
|
||||
let (image_data, file_urls) = match crate::clipboard::system_clipboard_probe_attachments(
|
||||
probe_text.as_deref(),
|
||||
@@ -1918,6 +1911,54 @@ pub(crate) fn execute(
|
||||
});
|
||||
meta.auth_abort_handle = Some((request_seq, abort_handle));
|
||||
}
|
||||
Effect::PersistCustomProviderAndAuthenticate {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
models,
|
||||
} => {
|
||||
let tx = acp_tx.clone();
|
||||
let abort_handle = tasks.spawn(async move {
|
||||
// Persist first so authenticate finds the definition and key.
|
||||
if let Err(e) = kigi_shell::agent::custom_providers::save_custom_provider(
|
||||
&provider, &key, &models,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let error = format!("Couldn't save the provider: {e:#}");
|
||||
ulog::error(
|
||||
"custom provider persist failed",
|
||||
None,
|
||||
Some(serde_json::json!({ "error" : & error })),
|
||||
);
|
||||
return TaskResult::AuthFailed {
|
||||
request_seq,
|
||||
error,
|
||||
};
|
||||
}
|
||||
let method_id =
|
||||
kigi_shell::agent::custom_providers::login_method_id(&provider.name);
|
||||
send_authenticate(&tx, request_seq, method_id, false, false).await
|
||||
});
|
||||
meta.auth_abort_handle = Some((request_seq, abort_handle));
|
||||
}
|
||||
Effect::FetchCustomProviderModels {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
} => {
|
||||
tasks.spawn(async move {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
kigi_shell::agent::custom_providers::fetch_listing_for_login(&provider, &key)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("listing task failed: {e}")));
|
||||
TaskResult::CustomProviderModelsListed {
|
||||
request_seq,
|
||||
result,
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::SubmitAuthCode { request_seq, code } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
|
||||
@@ -1664,3 +1664,37 @@ fn session_picker_entry_maps_to_dormant_roster_row() {
|
||||
assert_eq!(roster.origin.kind, "local");
|
||||
assert_eq!(roster.origin.host.as_deref(), Some("box"));
|
||||
}
|
||||
|
||||
fn origin_verdict_with_clipboard(
|
||||
clipboard: Option<&str>,
|
||||
read_failed: bool,
|
||||
probe_bracketed: bool,
|
||||
payload: &str,
|
||||
) -> Option<crate::app::actions::ProbedAttachment> {
|
||||
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
|
||||
text: clipboard.map(str::to_owned),
|
||||
text_read_failed: read_failed,
|
||||
..Default::default()
|
||||
});
|
||||
let verdict = bracketed_origin_verdict(probe_bracketed, Some(payload));
|
||||
crate::clipboard::clear_clipboard_probe_hook();
|
||||
verdict
|
||||
}
|
||||
#[test]
|
||||
fn bracketed_paste_that_is_not_the_clipboard_drops_the_probe() {
|
||||
let verdict = origin_verdict_with_clipboard(Some("copied text"), false, true, "typed by ime");
|
||||
assert!(matches!(verdict, Some(crate::app::actions::ProbedAttachment::ProbeDropped)));
|
||||
}
|
||||
#[test]
|
||||
fn bracketed_paste_matching_the_clipboard_keeps_probing() {
|
||||
assert!(origin_verdict_with_clipboard(Some("copied text"), false, true, "copied text").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn unreadable_clipboard_drops_a_bracketed_probe_without_an_error() {
|
||||
let verdict = origin_verdict_with_clipboard(None, true, true, "anything");
|
||||
assert!(matches!(verdict, Some(crate::app::actions::ProbedAttachment::ProbeDropped)));
|
||||
}
|
||||
#[test]
|
||||
fn clipboard_key_probe_skips_the_origin_check() {
|
||||
assert!(origin_verdict_with_clipboard(Some("copied text"), false, false, "other").is_none());
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod agent_view;
|
||||
pub mod app_view;
|
||||
pub mod bundle;
|
||||
pub mod cli;
|
||||
pub mod custom_entry;
|
||||
pub use crate::link_opener;
|
||||
/// Off-thread full-file syntax highlight upgrade for edit diffs.
|
||||
pub mod edit_highlight_worker;
|
||||
|
||||
@@ -106,8 +106,16 @@ impl AgentView {
|
||||
return Some(false);
|
||||
}
|
||||
// Clean edit — silently exit editing mode.
|
||||
// The exit cleared the overlay flip; restore it.
|
||||
self.exit_editing_mode();
|
||||
self.active_pane = target;
|
||||
self.set_active_pane(target, true);
|
||||
match target {
|
||||
AgentPane::Queue => self.queue.overlay.focused = true,
|
||||
AgentPane::Todo => self.todo.overlay.focused = true,
|
||||
AgentPane::Tasks => self.tasks.overlay.focused = true,
|
||||
AgentPane::Catalog => self.catalog.overlay.focused = true,
|
||||
_ => {}
|
||||
}
|
||||
return Some(true);
|
||||
}
|
||||
None
|
||||
@@ -487,7 +495,7 @@ impl AgentView {
|
||||
self.show_toast("Queued prompt is no longer in the queue");
|
||||
}
|
||||
|
||||
/// Exit editing mode: restore stashed text, clear mode, focus queue pane.
|
||||
/// Exit editing mode: restore stashed text, clear mode, focus the composer.
|
||||
/// No-op unless `EditingQueued`.
|
||||
///
|
||||
/// Always resets `prompt_input_mode` to `Normal` so it doesn't leak
|
||||
@@ -513,13 +521,8 @@ impl AgentView {
|
||||
if matches!(self.active_modal, Some(ActiveModal::EditConfirm { .. })) {
|
||||
self.active_modal = None;
|
||||
}
|
||||
// Return focus to queue pane (if still visible).
|
||||
// Force=true: we just cleared editing mode, no lock to check.
|
||||
if self.queue.is_visible() {
|
||||
self.set_active_pane(AgentPane::Queue, true);
|
||||
} else {
|
||||
self.set_active_pane(AgentPane::Scrollback, true);
|
||||
}
|
||||
// Queue focus would make the next Enter re-open the edit.
|
||||
self.set_active_pane(AgentPane::Prompt, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,6 +557,39 @@ mod tests {
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
fn enter_edit_local_row() -> AgentView {
|
||||
let mut agent = make_running_agent();
|
||||
let registry = non_vscode_registry();
|
||||
let ids = agent.queue.entry_ids();
|
||||
agent.queue.list_state.select_by_id(ids[1]);
|
||||
let _ = agent.handle_queue_key(&edit_key(), ®istry);
|
||||
agent
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_returns_focus_to_the_composer() {
|
||||
let mut agent = enter_edit_local_row();
|
||||
agent.prompt.set_text("local one EDITED");
|
||||
let _ = agent.handle_prompt_key_for_test(&enter_key());
|
||||
assert_eq!(agent.active_pane, AgentPane::Prompt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_returns_focus_to_the_composer() {
|
||||
let mut agent = enter_edit_local_row();
|
||||
let _ = agent.handle_prompt_key_for_test(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert_eq!(agent.active_pane, AgentPane::Prompt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_queue_pane_with_clean_edit_lands_focused_on_the_queue() {
|
||||
let mut agent = enter_edit_local_row();
|
||||
agent.toggle_queue_pane();
|
||||
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
|
||||
assert_eq!(agent.active_pane, AgentPane::Queue);
|
||||
assert!(agent.queue.overlay.focused);
|
||||
}
|
||||
|
||||
fn attach_image_to_local_row(agent: &mut AgentView) {
|
||||
let mut image = test_pasted_image();
|
||||
image.display_number = 1;
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, Slas
|
||||
|
||||
/// Online docs landing page (hardcoded like other TUI deep-links; the
|
||||
/// project README is the docs entry point for the repo-hosted docs).
|
||||
pub const BUILD_DOCS_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/blob/main/README.md";
|
||||
pub const BUILD_DOCS_URL: &str = "https://github.com/ELY-Open-Source/Kigi-CLI/blob/main/README.md";
|
||||
|
||||
/// Open How-to Guides or online Build docs.
|
||||
pub struct DocsCommand;
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
/// Where feedback goes: the project's own issue tracker. Kigi is a community
|
||||
/// build, so its feedback belongs on its own repo — mirroring the official
|
||||
/// kimi-cli, whose `/feedback` opens its repo's issues page.
|
||||
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/issues";
|
||||
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ELY-Open-Source/Kigi-CLI/issues/new";
|
||||
|
||||
/// Open the Kigi issue tracker in the browser.
|
||||
pub struct FeedbackCommand;
|
||||
|
||||
@@ -322,6 +322,10 @@ impl ListItem for QueuedPromptEntry {
|
||||
fn search_text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
fn copy_text(&self) -> String {
|
||||
self.text.clone()
|
||||
}
|
||||
}
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind};
|
||||
@@ -999,6 +1003,13 @@ mod tests {
|
||||
QueuedPrompt::plain(id, text, QueueEntryKind::Prompt)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn y_copies_the_full_multiline_prompt_not_the_display_row() {
|
||||
let text = "first line of a long prompt\nsecond line\nthird line";
|
||||
let entry = QueuedPromptEntry::new(&local_prompt(1, text), 1);
|
||||
assert_eq!(ListItem::copy_text(&entry), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_auto_show_edge_allows_requeue_after_external_hide() {
|
||||
let mut pane = QueuePane::new();
|
||||
|
||||
@@ -461,6 +461,8 @@ pub struct WelcomeRenderParams<'a> {
|
||||
pub auth_methods: &'a [acp::AuthMethod],
|
||||
pub login_label: Option<&'a str>,
|
||||
pub auth_code_input: &'a str,
|
||||
pub custom_entry: &'a crate::app::custom_entry::CustomEntryDraft,
|
||||
pub custom_select: Option<&'a crate::app::custom_entry::CustomSelectState>,
|
||||
pub clipboard_copied: bool,
|
||||
pub show_raw_url: bool,
|
||||
pub tip: Option<&'a str>,
|
||||
@@ -603,6 +605,8 @@ pub fn render_welcome(
|
||||
auth_url.as_deref(),
|
||||
*mode,
|
||||
params.auth_code_input,
|
||||
params.custom_entry,
|
||||
params.custom_select,
|
||||
params.clipboard_copied,
|
||||
params.show_raw_url,
|
||||
);
|
||||
@@ -1176,6 +1180,8 @@ fn render_welcome_authenticating(
|
||||
auth_url: Option<&str>,
|
||||
mode: AuthMode,
|
||||
auth_code_input: &str,
|
||||
custom_entry: &crate::app::custom_entry::CustomEntryDraft,
|
||||
custom_select: Option<&crate::app::custom_entry::CustomSelectState>,
|
||||
clipboard_copied: bool,
|
||||
show_raw_url: bool,
|
||||
) -> (Option<Rect>, Option<Rect>) {
|
||||
@@ -1270,6 +1276,7 @@ fn render_welcome_authenticating(
|
||||
theme,
|
||||
auth_code_input,
|
||||
"Paste your token here...",
|
||||
true,
|
||||
);
|
||||
|
||||
// Hints
|
||||
@@ -1290,81 +1297,60 @@ fn render_welcome_authenticating(
|
||||
}
|
||||
|
||||
AuthMode::ApiKeyEntry(target) => {
|
||||
// Platform API-key paste box: instruction + input + hints. No
|
||||
// auth-URL machinery — the key comes from the platform console.
|
||||
let h_pad: u16 = content_area.width / 6;
|
||||
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
|
||||
// Platform API-key paste box.
|
||||
let instruction = format!(
|
||||
"Paste your {} API key (from {})",
|
||||
target.vendor(),
|
||||
target.console_host()
|
||||
);
|
||||
let msg_height = (instruction.len() as u16).div_ceil(inner_width);
|
||||
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(logo_line_count),
|
||||
// gap
|
||||
Constraint::Length(1),
|
||||
// instruction
|
||||
Constraint::Length(msg_height),
|
||||
// gap
|
||||
Constraint::Min(1),
|
||||
// prompt box
|
||||
Constraint::Length(5),
|
||||
// gap
|
||||
Constraint::Length(1),
|
||||
// hints
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(content_area);
|
||||
|
||||
render_logo(logo_area, buf, theme, content_area.height);
|
||||
|
||||
let msg = Line::from(Span::styled(
|
||||
instruction,
|
||||
Style::default().fg(theme.gray_bright),
|
||||
))
|
||||
.alignment(Alignment::Center);
|
||||
Paragraph::new(msg)
|
||||
.wrap(Wrap { trim: false })
|
||||
.block(Block::default().padding(Padding::horizontal(h_pad)))
|
||||
.render(msg_area, buf);
|
||||
|
||||
let [_, prompt_centered, _] = Layout::horizontal([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(content_area.width),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.flex(Flex::Center)
|
||||
.areas(prompt_area);
|
||||
render_auth_input_box(
|
||||
prompt_centered,
|
||||
render_entry_box(
|
||||
content_area,
|
||||
buf,
|
||||
theme,
|
||||
auth_code_input,
|
||||
"Paste your API key here...",
|
||||
top_pad,
|
||||
logo_line_count,
|
||||
&EntryBox {
|
||||
instruction: &instruction,
|
||||
error: None,
|
||||
input: auth_code_input,
|
||||
placeholder: "Paste your API key here...",
|
||||
masked: true,
|
||||
},
|
||||
);
|
||||
(None, None)
|
||||
}
|
||||
|
||||
let hints = Line::from(vec![
|
||||
Span::styled(
|
||||
"enter",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" submit ", Style::default().fg(theme.gray)),
|
||||
Span::styled(
|
||||
"esc",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" back", Style::default().fg(theme.gray)),
|
||||
])
|
||||
.alignment(Alignment::Center);
|
||||
Paragraph::new(hints).render(hint_area, buf);
|
||||
AuthMode::CustomProviderEntry(step) => {
|
||||
let instruction = custom_entry.instruction(step);
|
||||
render_entry_box(
|
||||
content_area,
|
||||
buf,
|
||||
theme,
|
||||
top_pad,
|
||||
logo_line_count,
|
||||
&EntryBox {
|
||||
instruction: &instruction,
|
||||
error: custom_entry.error.as_deref(),
|
||||
input: auth_code_input,
|
||||
placeholder: step.placeholder(),
|
||||
masked: step.masks_input(),
|
||||
},
|
||||
);
|
||||
(None, None)
|
||||
}
|
||||
|
||||
AuthMode::CustomProviderSelect => {
|
||||
if let Some(select) = custom_select {
|
||||
render_custom_select(
|
||||
content_area,
|
||||
buf,
|
||||
theme,
|
||||
top_pad,
|
||||
logo_line_count,
|
||||
select,
|
||||
auth_code_input,
|
||||
);
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
|
||||
@@ -1970,6 +1956,278 @@ pub(crate) fn render_session_picker(
|
||||
)
|
||||
}
|
||||
|
||||
/// One text field under the logo.
|
||||
struct EntryBox<'a> {
|
||||
instruction: &'a str,
|
||||
error: Option<&'a str>,
|
||||
input: &'a str,
|
||||
placeholder: &'a str,
|
||||
masked: bool,
|
||||
}
|
||||
|
||||
fn render_entry_box(
|
||||
content_area: Rect,
|
||||
buf: &mut Buffer,
|
||||
theme: &Theme,
|
||||
top_pad: u16,
|
||||
logo_line_count: u16,
|
||||
entry: &EntryBox<'_>,
|
||||
) {
|
||||
let h_pad: u16 = content_area.width / 6;
|
||||
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
|
||||
let rows = |text: &str| (text.len() as u16).div_ceil(inner_width);
|
||||
let msg_height = rows(entry.instruction) + entry.error.map_or(0, rows);
|
||||
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(logo_line_count),
|
||||
// gap
|
||||
Constraint::Length(1),
|
||||
// instruction
|
||||
Constraint::Length(msg_height),
|
||||
// gap
|
||||
Constraint::Min(1),
|
||||
// prompt box
|
||||
Constraint::Length(5),
|
||||
// gap
|
||||
Constraint::Length(1),
|
||||
// hints
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(content_area);
|
||||
|
||||
render_logo(logo_area, buf, theme, content_area.height);
|
||||
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled(
|
||||
entry.instruction,
|
||||
Style::default().fg(theme.gray_bright),
|
||||
))
|
||||
.alignment(Alignment::Center),
|
||||
];
|
||||
if let Some(error) = entry.error {
|
||||
lines.push(
|
||||
Line::from(Span::styled(error, Style::default().fg(theme.warning)))
|
||||
.alignment(Alignment::Center),
|
||||
);
|
||||
}
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.block(Block::default().padding(Padding::horizontal(h_pad)))
|
||||
.render(msg_area, buf);
|
||||
|
||||
let [_, prompt_centered, _] = Layout::horizontal([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(content_area.width),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.flex(Flex::Center)
|
||||
.areas(prompt_area);
|
||||
render_auth_input_box(
|
||||
prompt_centered,
|
||||
buf,
|
||||
theme,
|
||||
entry.input,
|
||||
entry.placeholder,
|
||||
entry.masked,
|
||||
);
|
||||
|
||||
let hints = Line::from(vec![
|
||||
Span::styled(
|
||||
"enter",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" submit ", Style::default().fg(theme.gray)),
|
||||
Span::styled(
|
||||
"esc",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" back", Style::default().fg(theme.gray)),
|
||||
])
|
||||
.alignment(Alignment::Center);
|
||||
Paragraph::new(hints).render(hint_area, buf);
|
||||
}
|
||||
|
||||
/// The custom provider model-selection screen: fetched rows + manual input.
|
||||
fn render_custom_select(
|
||||
content_area: Rect,
|
||||
buf: &mut Buffer,
|
||||
theme: &Theme,
|
||||
top_pad: u16,
|
||||
logo_line_count: u16,
|
||||
select: &crate::app::custom_entry::CustomSelectState,
|
||||
input: &str,
|
||||
) {
|
||||
use crate::app::custom_entry::SelectFetch;
|
||||
let h_pad: u16 = content_area.width / 8;
|
||||
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
|
||||
let instruction = format!(
|
||||
"Select the models to add for {} ({})",
|
||||
select.provider.name, select.provider.base_url
|
||||
);
|
||||
let status: Option<(String, Style)> = match &select.fetch {
|
||||
SelectFetch::InFlight if select.finish_requested => Some((
|
||||
"Fetching models... finishing when the list lands".to_owned(),
|
||||
Style::default().fg(theme.gray),
|
||||
)),
|
||||
SelectFetch::InFlight => Some((
|
||||
"Fetching models...".to_owned(),
|
||||
Style::default().fg(theme.gray),
|
||||
)),
|
||||
SelectFetch::Listed if select.rows.is_empty() => Some((
|
||||
"The provider served no models; add one below".to_owned(),
|
||||
Style::default().fg(theme.warning),
|
||||
)),
|
||||
SelectFetch::Listed => None,
|
||||
SelectFetch::Failed(reason) => Some((
|
||||
format!("Fetch failed: {reason}"),
|
||||
Style::default().fg(theme.warning),
|
||||
)),
|
||||
};
|
||||
// Word-wrap-aware rows; byte math undercounts and clips the failure reason.
|
||||
let rows_of = |text: &str| -> u16 {
|
||||
crate::render::wrapping::word_wrap_line(
|
||||
&Line::from(Span::raw(text.to_owned())),
|
||||
inner_width as usize,
|
||||
)
|
||||
.len() as u16
|
||||
};
|
||||
let msg_height = rows_of(&instruction)
|
||||
+ status.as_ref().map_or(0, |(s, _)| rows_of(s))
|
||||
+ select.error.as_deref().map_or(0, rows_of);
|
||||
let [
|
||||
_,
|
||||
logo_area,
|
||||
_,
|
||||
msg_area,
|
||||
_,
|
||||
rows_area,
|
||||
_,
|
||||
prompt_area,
|
||||
_,
|
||||
hint_area,
|
||||
_,
|
||||
] = Layout::vertical([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(logo_line_count),
|
||||
Constraint::Length(1),
|
||||
// Min: spare rows land here, so a mis-measured wrap never clips.
|
||||
Constraint::Min(msg_height),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(content_area);
|
||||
|
||||
render_logo(logo_area, buf, theme, content_area.height);
|
||||
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled(
|
||||
instruction,
|
||||
Style::default().fg(theme.gray_bright),
|
||||
))
|
||||
.alignment(Alignment::Center),
|
||||
];
|
||||
if let Some((text, style)) = status {
|
||||
lines.push(Line::from(Span::styled(text, style)).alignment(Alignment::Center));
|
||||
}
|
||||
if let Some(error) = select.error.as_deref() {
|
||||
lines.push(
|
||||
Line::from(Span::styled(error, Style::default().fg(theme.warning)))
|
||||
.alignment(Alignment::Center),
|
||||
);
|
||||
}
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.block(Block::default().padding(Padding::horizontal(h_pad)))
|
||||
.render(msg_area, buf);
|
||||
|
||||
let visible = rows_area.height as usize;
|
||||
let start = select
|
||||
.highlight
|
||||
.saturating_add(1)
|
||||
.saturating_sub(visible)
|
||||
.min(select.rows.len().saturating_sub(visible));
|
||||
let mut row_lines = Vec::new();
|
||||
for (idx, row) in select.rows.iter().enumerate().skip(start).take(visible) {
|
||||
let highlighted = idx == select.highlight;
|
||||
let check = if row.selected { "[x]" } else { "[ ]" };
|
||||
let suffix = if row.manual { " (manual)" } else { "" };
|
||||
let style = if highlighted {
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.gray_bright)
|
||||
};
|
||||
let marker = if highlighted { "› " } else { " " };
|
||||
row_lines.push(Line::from(Span::styled(
|
||||
format!("{marker}{check} {}{suffix}", row.id),
|
||||
style,
|
||||
)));
|
||||
}
|
||||
Paragraph::new(row_lines)
|
||||
.block(Block::default().padding(Padding::horizontal(h_pad)))
|
||||
.render(rows_area, buf);
|
||||
|
||||
let [_, prompt_centered, _] = Layout::horizontal([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(content_area.width),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.flex(Flex::Center)
|
||||
.areas(prompt_area);
|
||||
render_auth_input_box(
|
||||
prompt_centered,
|
||||
buf,
|
||||
theme,
|
||||
input,
|
||||
"Type a model id and press enter to add",
|
||||
false,
|
||||
);
|
||||
|
||||
let hints = Line::from(vec![
|
||||
Span::styled(
|
||||
"↑↓",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" navigate ", Style::default().fg(theme.gray)),
|
||||
Span::styled(
|
||||
"space",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" toggle ", Style::default().fg(theme.gray)),
|
||||
Span::styled(
|
||||
"enter",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" add model / done ", Style::default().fg(theme.gray)),
|
||||
Span::styled(
|
||||
"esc",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" back", Style::default().fg(theme.gray)),
|
||||
])
|
||||
.alignment(Alignment::Center);
|
||||
Paragraph::new(hints).render(hint_area, buf);
|
||||
}
|
||||
|
||||
/// Render the auth token input box (loopback mode).
|
||||
fn render_auth_input_box(
|
||||
area: Rect,
|
||||
@@ -1977,6 +2235,7 @@ fn render_auth_input_box(
|
||||
theme: &Theme,
|
||||
input: &str,
|
||||
placeholder: &str,
|
||||
masked: bool,
|
||||
) {
|
||||
let prompt_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
@@ -1993,8 +2252,10 @@ fn render_auth_input_box(
|
||||
if inner.height > 0 && inner.width > 2 {
|
||||
let display = if input.is_empty() {
|
||||
placeholder.to_string()
|
||||
} else {
|
||||
} else if masked {
|
||||
mask_auth_token_for_display(input)
|
||||
} else {
|
||||
input.to_string()
|
||||
};
|
||||
|
||||
let style = if input.is_empty() {
|
||||
@@ -2078,6 +2339,8 @@ fn mask_auth_token_for_display(input: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
static EMPTY_CUSTOM_ENTRY: crate::app::custom_entry::CustomEntryDraft =
|
||||
crate::app::custom_entry::CustomEntryDraft::new();
|
||||
use crate::app::app_view::SessionPickerEntry;
|
||||
use crate::views::picker::PickerState;
|
||||
use crate::views::session_picker::{build_grouped_picker_entries, build_session_entry_data};
|
||||
@@ -2127,6 +2390,8 @@ mod tests {
|
||||
auth_methods: &[],
|
||||
login_label: None,
|
||||
auth_code_input: "",
|
||||
custom_entry: &EMPTY_CUSTOM_ENTRY,
|
||||
custom_select: None,
|
||||
clipboard_copied: false,
|
||||
show_raw_url: false,
|
||||
tip: None,
|
||||
@@ -2323,6 +2588,146 @@ mod tests {
|
||||
assert!(!text.contains("Moonshot"), "{text}");
|
||||
}
|
||||
|
||||
fn render_custom_entry(
|
||||
step: crate::app::custom_entry::CustomEntryStep,
|
||||
draft: &crate::app::custom_entry::CustomEntryDraft,
|
||||
input: &str,
|
||||
) -> String {
|
||||
let area = Rect::new(0, 0, 100, 40);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_welcome_authenticating(
|
||||
area,
|
||||
&mut buf,
|
||||
&Theme::current(),
|
||||
logo_line_count(area.height),
|
||||
None,
|
||||
AuthMode::CustomProviderEntry(step),
|
||||
input,
|
||||
draft,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
buffer_text(&buf)
|
||||
}
|
||||
|
||||
fn render_custom_select(
|
||||
select: &crate::app::custom_entry::CustomSelectState,
|
||||
input: &str,
|
||||
) -> String {
|
||||
let area = Rect::new(0, 0, 100, 40);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_welcome_authenticating(
|
||||
area,
|
||||
&mut buf,
|
||||
&Theme::current(),
|
||||
logo_line_count(area.height),
|
||||
None,
|
||||
AuthMode::CustomProviderSelect,
|
||||
input,
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
Some(select),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
buffer_text(&buf)
|
||||
}
|
||||
|
||||
/// The selection screen lists rows, status, and the manual input together.
|
||||
#[test]
|
||||
fn custom_select_arm_lists_rows_status_and_input() {
|
||||
use crate::app::custom_entry::CustomSelectState;
|
||||
use kigi_shell::models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
let provider =
|
||||
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
|
||||
let mut select = CustomSelectState::new(provider, "sk".to_owned());
|
||||
let fetching = render_custom_select(&select, "");
|
||||
assert!(fetching.contains("Fetching models"), "{fetching}");
|
||||
assert!(fetching.contains("add model / done"), "{fetching}");
|
||||
|
||||
select.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
|
||||
provider: select.provider.clone(),
|
||||
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
|
||||
});
|
||||
select.toggle_highlighted();
|
||||
select.add_manual("claude-opus-5-5[1m]");
|
||||
let listed = render_custom_select(&select, "typed-so-far");
|
||||
assert!(listed.contains("[x] m1"), "{listed}");
|
||||
assert!(listed.contains("[ ] m2"), "{listed}");
|
||||
assert!(listed.contains("claude-opus-5-5[1m] (manual)"), "{listed}");
|
||||
assert!(listed.contains("typed-so-far"), "{listed}");
|
||||
|
||||
select.fail_fetch("boom".to_owned());
|
||||
let failed = render_custom_select(&select, "");
|
||||
assert!(failed.contains("Fetch failed: boom"), "{failed}");
|
||||
|
||||
// A two-hop failure reason must render whole, not clip at the wrap.
|
||||
let reason = "https://h.example/models answered HTTP 404; https://h.example/v1/models also failed: https://h.example/v1/models answered HTTP 500";
|
||||
select.fail_fetch(reason.to_owned());
|
||||
let text = render_custom_select(&select, "");
|
||||
assert!(text.contains("Fetch failed:"), "{text}");
|
||||
assert!(text.contains("HTTP 500"), "{text}");
|
||||
}
|
||||
|
||||
/// An empty selection refuses once the fetch resolved.
|
||||
#[test]
|
||||
fn custom_select_arm_shows_the_finish_refusal() {
|
||||
use crate::app::custom_entry::CustomSelectState;
|
||||
use kigi_shell::models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
let provider =
|
||||
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
|
||||
let mut select = CustomSelectState::new(provider, "sk".to_owned());
|
||||
select.fail_fetch("boom".to_owned());
|
||||
assert!(select.try_finish().is_none());
|
||||
let text = render_custom_select(&select, "");
|
||||
assert!(text.contains("Select at least one model"), "{text}");
|
||||
}
|
||||
|
||||
/// Only the key step masks its input.
|
||||
#[test]
|
||||
fn custom_entry_arm_masks_only_the_key() {
|
||||
use crate::app::custom_entry::{CustomEntryDraft, CustomEntryField, CustomEntryStep};
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
let step = |field| CustomEntryStep {
|
||||
api: CustomApi::OpenAi,
|
||||
field,
|
||||
};
|
||||
let draft = CustomEntryDraft::new();
|
||||
|
||||
let url = render_custom_entry(
|
||||
step(CustomEntryField::BaseUrl),
|
||||
&draft,
|
||||
"https://very-long-host.example/v1",
|
||||
);
|
||||
assert!(url.contains("https://very-long-host.example/v1"), "{url}");
|
||||
assert!(url.contains("OpenAI compatible"), "{url}");
|
||||
|
||||
let key = render_custom_entry(step(CustomEntryField::Key), &draft, "sk-abcdefghijklmnop");
|
||||
assert!(
|
||||
!key.contains("sk-abcdefghij"),
|
||||
"the key prefix must stay hidden: {key}"
|
||||
);
|
||||
assert!(key.contains("mnop"), "{key}");
|
||||
|
||||
let empty = render_custom_entry(step(CustomEntryField::Name), &draft, "");
|
||||
assert!(empty.contains("provider name"), "{empty}");
|
||||
}
|
||||
|
||||
/// A refusal shows under the instruction.
|
||||
#[test]
|
||||
fn custom_entry_arm_shows_the_refusal() {
|
||||
use crate::app::custom_entry::{CustomEntryDraft, CustomEntryStep};
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
let step = CustomEntryStep::first(CustomApi::Anthropic);
|
||||
let mut draft = CustomEntryDraft::new();
|
||||
draft.submit(step, "host.example");
|
||||
let text = render_custom_entry(step, &draft, "host.example");
|
||||
assert!(text.contains("Anthropic compatible"), "{text}");
|
||||
assert!(text.contains("must start with http"), "{text}");
|
||||
}
|
||||
|
||||
/// The Moonshot API-key entry arm renders the platform copy, the paste
|
||||
/// box, and the esc-back hint — and no OAuth-URL affordances.
|
||||
#[test]
|
||||
@@ -2343,6 +2748,8 @@ mod tests {
|
||||
)),
|
||||
// auth_code_input
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
None,
|
||||
// clipboard_copied
|
||||
false,
|
||||
// show_raw_url
|
||||
@@ -3010,6 +3417,8 @@ mod tests {
|
||||
AuthMode::Device,
|
||||
// auth_code_input — unused in device mode
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
None,
|
||||
// clipboard_copied
|
||||
false,
|
||||
// show_raw_url
|
||||
@@ -3066,6 +3475,8 @@ mod tests {
|
||||
Some(url),
|
||||
AuthMode::Device,
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
None,
|
||||
false,
|
||||
// show_raw_url
|
||||
true,
|
||||
@@ -3093,6 +3504,8 @@ mod tests {
|
||||
Some(url),
|
||||
AuthMode::Device,
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
None,
|
||||
false,
|
||||
// show_raw_url
|
||||
true,
|
||||
@@ -3131,6 +3544,8 @@ mod tests {
|
||||
Some(url),
|
||||
AuthMode::Device,
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
None,
|
||||
false,
|
||||
// show_raw_url
|
||||
true,
|
||||
@@ -3172,6 +3587,8 @@ mod tests {
|
||||
AuthMode::Command,
|
||||
// auth_code_input — unused
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
None,
|
||||
// clipboard_copied
|
||||
false,
|
||||
// show_raw_url
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
const CUSTOM_KEY: &str = "sk-custom-provider-key";
|
||||
const HOUSE_KEY: &str = "test-key-for-ci";
|
||||
|
||||
/// Declares `[platforms.proxy]` in the pager's isolated home.
|
||||
fn declare_provider(content: &ContentController, api: &str) {
|
||||
let kigi_home = content.home().join(".kigi");
|
||||
std::fs::create_dir_all(&kigi_home).expect("create .kigi");
|
||||
std::fs::write(
|
||||
kigi_home.join("config.toml"),
|
||||
format!(
|
||||
"[platforms.proxy]\nbase_url = \"{}\"\napi = \"{api}\"\napi_key = \"{CUSTOM_KEY}\"\n",
|
||||
content.url()
|
||||
),
|
||||
)
|
||||
.expect("write config.toml");
|
||||
}
|
||||
|
||||
fn turn_requests(
|
||||
content: &ContentController,
|
||||
path: &str,
|
||||
) -> Vec<kigi_test_support::mock_server::LogEntry> {
|
||||
content
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
e.path == path
|
||||
&& e.body
|
||||
.as_ref()
|
||||
.and_then(|b| b.get("model"))
|
||||
.and_then(|m| m.as_str())
|
||||
== Some("custom-m1")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn run_turn(content: &ContentController) -> PtyHarness {
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} from the custom provider."
|
||||
));
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, content, &[])
|
||||
.expect("spawn pager with content");
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("mock response on screen");
|
||||
harness
|
||||
}
|
||||
|
||||
/// An OpenAI provider lists models and uses its own key.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn custom_openai_provider_lists_models_and_uses_its_own_key() {
|
||||
let content = ContentController::start_with_models(vec![MockModel::new("custom-m1")])
|
||||
.await
|
||||
.expect("start content");
|
||||
declare_provider(&content, "openai");
|
||||
|
||||
let mut harness = run_turn(&content).await;
|
||||
|
||||
let turns = turn_requests(&content, "/v1/chat/completions");
|
||||
assert!(
|
||||
!turns.is_empty(),
|
||||
"no chat completion for custom-m1: {:?}",
|
||||
content.requests()
|
||||
);
|
||||
for turn in &turns {
|
||||
assert_eq!(
|
||||
turn.authorization.as_deref(),
|
||||
Some(format!("Bearer {CUSTOM_KEY}").as_str()),
|
||||
"the custom provider's own key must ride, never {HOUSE_KEY}"
|
||||
);
|
||||
}
|
||||
|
||||
harness
|
||||
.inject_keys(b"/model ")
|
||||
.expect("open the /model list");
|
||||
harness
|
||||
.wait_for_text("proxy", Duration::from_secs(10))
|
||||
.expect("the fetched model is listed with its provider name");
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
|
||||
/// An Anthropic provider uses `/messages` with `x-api-key`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn custom_anthropic_provider_uses_messages_and_x_api_key() {
|
||||
let content = ContentController::start_with_models(vec![MockModel::new("custom-m1")])
|
||||
.await
|
||||
.expect("start content");
|
||||
declare_provider(&content, "anthropic");
|
||||
|
||||
let mut harness = run_turn(&content).await;
|
||||
|
||||
let turns = turn_requests(&content, "/v1/messages");
|
||||
assert!(
|
||||
!turns.is_empty(),
|
||||
"no messages request for custom-m1: {:?}",
|
||||
content.requests()
|
||||
);
|
||||
for turn in &turns {
|
||||
assert_eq!(
|
||||
turn.header("x-api-key"),
|
||||
Some(CUSTOM_KEY),
|
||||
"request headers: {:?}",
|
||||
turn.headers
|
||||
);
|
||||
assert!(turn.header("anthropic-version").is_some());
|
||||
assert_eq!(
|
||||
turn.authorization, None,
|
||||
"an x-api-key wire carries no Bearer"
|
||||
);
|
||||
}
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
|
||||
/// A saved provider alone is a complete login.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn custom_provider_alone_authenticates_at_startup() {
|
||||
let content = ContentController::start_with_models(vec![MockModel::new("custom-m1")])
|
||||
.await
|
||||
.expect("start content");
|
||||
declare_provider(&content, "openai");
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} from the custom provider."
|
||||
));
|
||||
let env: Vec<(String, String)> = content
|
||||
.env_for_pager()
|
||||
.into_iter()
|
||||
.filter(|(name, _)| name != "KIGI_API_KEY")
|
||||
.collect();
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::new_in_dir(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs, None)
|
||||
.expect("spawn pager without a house key");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("the turn reaches the custom provider without a login screen");
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -15,6 +15,8 @@ mod agent_response;
|
||||
mod auto_compact_top_row;
|
||||
#[path = "pty_e2e/basename_path_demo_pty.rs"]
|
||||
mod basename_path_demo_pty;
|
||||
#[path = "pty_e2e/custom_provider.rs"]
|
||||
mod custom_provider;
|
||||
#[path = "pty_e2e/doubled_lines_out_of_band_repro.rs"]
|
||||
mod doubled_lines_out_of_band_repro;
|
||||
#[path = "pty_e2e/initial_prompt_positional_auto_submits.rs"]
|
||||
|
||||
@@ -1108,7 +1108,7 @@ fn extract_kigi_binary_blocking(archive: &Path, dest_tmp: &Path) -> Result<()> {
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i)?;
|
||||
let base_name = entry
|
||||
.name()
|
||||
.name()?
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
@@ -2798,6 +2798,52 @@ mod tests {
|
||||
assert!(extract_kigi_binary(&archive, &out).await.is_err());
|
||||
}
|
||||
|
||||
// Archive extraction (Windows: zip)
|
||||
|
||||
#[cfg(windows)]
|
||||
fn make_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
use std::io::Write as _;
|
||||
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, data) in entries {
|
||||
writer
|
||||
.start_file(*name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(data).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn test_extract_kigi_binary_finds_kigi_exe_entry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let archive = dir.path().join("kigi-0.1.0-test.zip");
|
||||
std::fs::write(
|
||||
&archive,
|
||||
make_zip(&[("LICENSE", b"license text"), ("kigi.exe", b"MZ binary")]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let out = dir.path().join("kigi-extracted");
|
||||
extract_kigi_binary(&archive, &out).await.unwrap();
|
||||
assert_eq!(std::fs::read(&out).unwrap(), b"MZ binary");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn test_extract_kigi_binary_missing_exe_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let archive = dir.path().join("kigi-0.1.0-test.zip");
|
||||
std::fs::write(&archive, make_zip(&[("LICENSE", b"license only")])).unwrap();
|
||||
|
||||
let out = dir.path().join("kigi-extracted");
|
||||
let err = extract_kigi_binary(&archive, &out).await.unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("no 'kigi.exe' binary"),
|
||||
"err: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// UpdateStatus serialization (camelCase contract for --json clients)
|
||||
|
||||
fn make_status() -> UpdateStatus {
|
||||
|
||||
@@ -10,7 +10,7 @@ const TTL_SECONDS_BEFORE_AUTO_UPDATE: Duration = Duration::from_secs(60 * 30);
|
||||
|
||||
/// Release channel: this repo's Releases (PRD F8). The API base is resolved
|
||||
/// through [`kigi_env::update_base_url`] — production default
|
||||
/// `https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases`,
|
||||
/// `https://api.github.com/repos/ELY-Open-Source/Kigi-CLI/releases`,
|
||||
/// overridable via `KIGI_UPDATE_BASE_URL` for mirrors and tests.
|
||||
pub(crate) fn update_base_url() -> String {
|
||||
kigi_env::update_base_url()
|
||||
@@ -533,7 +533,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_release_json_parses_github_wire_shape() {
|
||||
let json = r#"{
|
||||
"url": "https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases/1",
|
||||
"url": "https://api.github.com/repos/ELY-Open-Source/Kigi-CLI/releases/1",
|
||||
"tag_name": "v0.1.0",
|
||||
"name": "Kigi 0.1.0",
|
||||
"draft": false,
|
||||
@@ -541,13 +541,13 @@ mod tests {
|
||||
"assets": [
|
||||
{
|
||||
"name": "kigi-0.1.0-aarch64-apple-darwin.tar.gz",
|
||||
"browser_download_url": "https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz",
|
||||
"browser_download_url": "https://github.com/ELY-Open-Source/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz",
|
||||
"size": 123,
|
||||
"content_type": "application/gzip"
|
||||
},
|
||||
{
|
||||
"name": "SHA256SUMS",
|
||||
"browser_download_url": "https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/SHA256SUMS"
|
||||
"browser_download_url": "https://github.com/ELY-Open-Source/Kigi-CLI/releases/download/v0.1.0/SHA256SUMS"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
@@ -560,7 +560,7 @@ mod tests {
|
||||
let asset = r.asset("kigi-0.1.0-aarch64-apple-darwin.tar.gz").unwrap();
|
||||
assert_eq!(
|
||||
asset.browser_download_url,
|
||||
"https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz"
|
||||
"https://github.com/ELY-Open-Source/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz"
|
||||
);
|
||||
assert_eq!(
|
||||
r.asset("SHA256SUMS").unwrap().name,
|
||||
|
||||
@@ -114,7 +114,7 @@ pub fn host_platform() -> String {
|
||||
}
|
||||
|
||||
/// Host Rust target triple, matching `auto_update::target_triple()` and the
|
||||
/// release-asset naming in `.gitea/workflows/release.yml`.
|
||||
/// release-asset naming in `.github/workflows/release.yml`.
|
||||
pub fn host_triple() -> &'static str {
|
||||
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
|
||||
"aarch64-apple-darwin"
|
||||
|
||||
@@ -5,11 +5,6 @@
|
||||
version = 2
|
||||
yanked = "deny"
|
||||
ignore = [
|
||||
# rsa: Marvin timing sidechannel. Reached only through jsonwebtoken's
|
||||
# RS256 *signature verification* in the enterprise-OIDC login path
|
||||
# (public-key operation; Marvin concerns private-key decryption). The
|
||||
# entire OIDC stack is replaced by the Kimi device flow in M1.
|
||||
{ id = "RUSTSEC-2023-0071", reason = "verification-only use; auth stack replaced in M1" },
|
||||
# quick-xml 0.39 (two advisories): pinned by wayland-scanner 0.31.10
|
||||
# (latest), a build-time proc-macro parsing vendored Wayland protocol
|
||||
# XML — no attacker-controlled input reaches it.
|
||||
@@ -25,6 +20,11 @@ ignore = [
|
||||
{ id = "RUSTSEC-2026-0192", reason = "rustybuzz via pdf_oxide/resvg (latest); local rendering" },
|
||||
{ id = "RUSTSEC-2026-0195", reason = "ttf-parser via fontdb/resvg (latest); local font parsing" },
|
||||
{ id = "RUSTSEC-2024-0320", reason = "yaml-rust via syntect (latest); parses bundled syntax defs" },
|
||||
{ id = "RUSTSEC-2024-0449", reason = "unsafe-libyaml via serde_yaml 0.9 (final release)" },
|
||||
# zbus 6 absorbed these crates; dark-light 3 (latest) still needs zbus 5.
|
||||
{ id = "RUSTSEC-2026-0370", reason = "zvariant via zbus 5" },
|
||||
{ id = "RUSTSEC-2026-0371", reason = "zbus_names via zbus 5" },
|
||||
{ id = "RUSTSEC-2026-0372", reason = "zvariant_derive via zbus 5" },
|
||||
]
|
||||
|
||||
[licenses]
|
||||
|
||||
+50
-29
@@ -1,23 +1,27 @@
|
||||
# Release checklist (PRD F8)
|
||||
|
||||
Kigi ships as a single-file binary for five targets, published on this
|
||||
repo's GitHub Releases by `.github/workflows/release.yml`. Users install via
|
||||
`install.sh` / `install.ps1` and stay current through the in-app
|
||||
self-updater (`kigi-update`), which resolves the same Releases API.
|
||||
repo's GitHub Releases
|
||||
(<https://github.com/ELY-Open-Source/Kigi-CLI/releases>) by
|
||||
`.github/workflows/release.yml`. Users install via `install.sh` /
|
||||
`install.ps1` and stay current through the in-app self-updater
|
||||
(`kigi-update`), which resolves the same Releases API.
|
||||
|
||||
## Cutting a release
|
||||
|
||||
1. Bump `[workspace.package] version` in `Cargo.toml` (and refresh
|
||||
`Cargo.lock` with `cargo update --workspace`); land the change on
|
||||
1. Bump `[workspace.package] version` in `Cargo.toml`; land the change on
|
||||
`main` with green CI.
|
||||
2. Regenerate the third-party notices (not enforced by CI — this is the
|
||||
step that keeps `THIRD-PARTY-NOTICES.md` fresh):
|
||||
2. Regenerate the third-party notices on Linux (not enforced by CI — this
|
||||
is the step that keeps `THIRD-PARTY-NOTICES.md` fresh):
|
||||
|
||||
```sh
|
||||
cargo install cargo-about --locked # once
|
||||
cargo install cargo-about@0.9.2 --locked --features cli # once
|
||||
cargo about generate about.hbs -o THIRD-PARTY-NOTICES.md
|
||||
```
|
||||
|
||||
Keep cargo-about at 0.9.2: 0.9.3 pastes ring's source files in as
|
||||
license text. A Windows run lists a different crate set and order.
|
||||
|
||||
Commit the result if it changed.
|
||||
3. Tag and push:
|
||||
|
||||
@@ -31,10 +35,15 @@ self-updater (`kigi-update`), which resolves the same Releases API.
|
||||
`release-dist` profile, packages
|
||||
`kigi-<version>-<target-triple>.{tar.gz|zip}` archives (binary +
|
||||
LICENSE + NOTICE + THIRD-PARTY-NOTICES), generates `SHA256SUMS`, and
|
||||
publishes the GitHub Release with every asset attached in one step, so
|
||||
`releases/latest` never serves a release whose checksum manifest is
|
||||
missing. Tags containing `-` (e.g. `v0.2.0-alpha.1`) publish as
|
||||
pre-releases, which only the `alpha` update channel picks up.
|
||||
publishes the GitHub Release. Tags containing `-` (e.g.
|
||||
`v0.2.0-alpha.1`) publish as pre-releases, which only the `alpha`
|
||||
update channel picks up.
|
||||
|
||||
The release is created as a **draft** and only flipped public once
|
||||
`SHA256SUMS` is attached, so `releases/latest` never hands the installer
|
||||
a release whose checksum manifest is still missing. A failed run leaves
|
||||
the draft in place; re-running the workflow reuses it rather than
|
||||
erroring on a duplicate `tag_name`.
|
||||
5. Smoke-test an installed artifact:
|
||||
|
||||
```sh
|
||||
@@ -44,27 +53,39 @@ self-updater (`kigi-update`), which resolves the same Releases API.
|
||||
|
||||
## Build runners
|
||||
|
||||
All five legs run on GitHub-hosted runners — nothing self-hosted to keep
|
||||
alive:
|
||||
Every leg runs on a GitHub-hosted runner; none needs local state, so no
|
||||
self-hosted runner is involved. The repo is public, so keep it that way: a
|
||||
self-hosted runner must never be reachable from a `pull_request` event, and
|
||||
this workflow only triggers on tag pushes.
|
||||
|
||||
| `runs-on` | Builds |
|
||||
| Runner | Builds |
|
||||
| ------------------ | ------------------------------------------------------- |
|
||||
| `macos-14` (arm64) | `aarch64-apple-darwin`, `x86_64-apple-darwin` (cross) |
|
||||
| `ubuntu-24.04` | `x86_64-unknown-linux-gnu` |
|
||||
| `ubuntu-24.04-arm` | `aarch64-unknown-linux-gnu` (native) |
|
||||
| `windows-2022` | `x86_64-pc-windows-msvc` |
|
||||
| `ubuntu-latest` | `x86_64-unknown-linux-gnu` |
|
||||
| `ubuntu-24.04-arm` | `aarch64-unknown-linux-gnu` (native, no cross toolchain) |
|
||||
| `macos-latest` | `aarch64-apple-darwin`, `x86_64-apple-darwin` |
|
||||
| `windows-latest` | `x86_64-pc-windows-msvc` |
|
||||
|
||||
`warm-cache.yml` rebuilds the `release-dist` profile on `main` whenever the
|
||||
dependency graph changes (plus a weekly refresh), so tag builds restore a
|
||||
warm default-branch cache instead of compiling the workspace cold. Its setup
|
||||
steps mirror `release.yml`'s build job — keep them in lockstep, or the cache
|
||||
key stops matching.
|
||||
`macos-latest` is Apple Silicon; `x86_64-apple-darwin` is a cross-compile
|
||||
against the same SDK. The Darwin targets need the real Apple linker
|
||||
(`.cargo/config.toml` passes `-ObjC` and `-undefined dynamic_lookup`), so they
|
||||
cannot move to a Linux runner.
|
||||
|
||||
> History note: between 2026-08 and 2026-09 this pipeline ran as
|
||||
> `.gitea/workflows/release.yml` on self-hosted runners against
|
||||
> `git.zacharyzhang.com` while the GitHub account was unavailable. The Gitea
|
||||
> instance still hosts the pre-migration releases (`v0.1.12`, `v0.1.13`);
|
||||
> everything from `v0.1.14` on is built and published here.
|
||||
`windows-latest` ships the VS Build Tools the static MSVC CRT link needs
|
||||
(`ring`, `mimalloc`, `zstd-sys` and `libz-sys` build with
|
||||
`-C target-feature=+crt-static`). Every job installs protoc
|
||||
(`PROTOC_VERSION`, matching the `bin/protoc` dotslash pin) and runs
|
||||
`rustup show` to materialise the `rust-toolchain.toml` pin; the arm64 Linux
|
||||
leg installs rustup first if the image lacks it.
|
||||
|
||||
The release steps use the preinstalled `gh` CLI with `secrets.GITHUB_TOKEN`
|
||||
(`permissions: contents: write`): `gh release create --draft`, then
|
||||
`gh release upload` per target, then `SHA256SUMS` and
|
||||
`gh release edit --draft=false`. Only the five checksum lines travel through
|
||||
`actions/upload-artifact@v4`; the archives go straight to the release.
|
||||
|
||||
Build caches come from `Swatinem/rust-cache`. GitHub only lets a tag run
|
||||
restore caches made on its own ref or the default branch, and nothing builds
|
||||
on `main`, so a tag build starts cold; the cache helps re-runs of the same tag.
|
||||
|
||||
## Invariants to keep in lockstep
|
||||
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@ the `kigi-frontend` Worker on `kigicli.dev` and `www.kigicli.dev`.
|
||||
Assets are matched first and served directly. Requests with no matching file
|
||||
fall through to the Worker, which owns:
|
||||
|
||||
- `GET /install.sh` — proxies `install.sh` from the Kigi repo on Gitea
|
||||
- `GET /install.ps1` — proxies `install.ps1` from the Kigi repo on Gitea
|
||||
- `GET /install.sh` — proxies `install.sh` from the Kigi repo on GitHub
|
||||
- `GET /install.ps1` — proxies `install.ps1` from the Kigi repo on GitHub
|
||||
- `GET /health` — plain-text liveness check
|
||||
- everything else — `env.ASSETS.fetch()`, which serves `index.html` for unknown
|
||||
paths (single-page-application fallback)
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ Error generating stack: `+e.message+`
|
||||
<path d="M12 16.25C11.5858 16.25 11.25 16.5858 11.25 17C11.25 17.4142 11.5858 17.75 12 17.75H17C17.4142 17.75 17.75 17.4142 17.75 17C17.75 16.5858 17.4142 16.25 17 16.25H12Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.1318 2.25H5.86821C5.12513 2.24999 4.52196 2.24999 4.03273 2.28996C3.52778 2.33121 3.07851 2.41876 2.66103 2.63148C2.00247 2.96703 1.46703 3.50247 1.13148 4.16103C0.918762 4.57851 0.831214 5.02778 0.789958 5.53273C0.749986 6.02196 0.749993 6.62511 0.75 7.36819V16.6318C0.749993 17.3749 0.749986 17.978 0.789958 18.4673C0.831214 18.9722 0.918762 19.4215 1.13148 19.839C1.46703 20.4975 2.00247 21.033 2.66103 21.3685C3.07851 21.5812 3.52778 21.6688 4.03273 21.71C4.52195 21.75 5.12507 21.75 5.86811 21.75H18.1318C18.8748 21.75 19.4781 21.75 19.9673 21.71C20.4722 21.6688 20.9215 21.5812 21.339 21.3685C21.9975 21.033 22.533 20.4975 22.8685 19.839C23.0812 19.4215 23.1688 18.9722 23.21 18.4673C23.25 17.9781 23.25 17.3749 23.25 16.6319V7.36819C23.25 6.62515 23.25 6.02195 23.21 5.53273C23.1688 5.02778 23.0812 4.57851 22.8685 4.16103C22.533 3.50247 21.9975 2.96703 21.339 2.63148C20.9215 2.41876 20.4722 2.33121 19.9673 2.28996C19.478 2.24999 18.8749 2.24999 18.1318 2.25ZM21.75 7.25C21.7498 6.54743 21.7472 6.04838 21.715 5.65488C21.6813 5.24269 21.619 5.0128 21.532 4.84202C21.3403 4.4657 21.0343 4.15973 20.658 3.96799C20.4872 3.88097 20.2573 3.81865 19.8451 3.78498C19.4242 3.75058 18.8824 3.75 18.1 3.75H5.9C5.11755 3.75 4.57582 3.75058 4.15488 3.78498C3.74269 3.81865 3.5128 3.88097 3.34202 3.96799C2.9657 4.15973 2.65973 4.4657 2.46799 4.84202C2.38097 5.0128 2.31865 5.24269 2.28498 5.65488C2.25283 6.04838 2.25022 6.54743 2.25002 7.25H21.75ZM2.25 8.75H21.75V16.6C21.75 17.3824 21.7494 17.9242 21.715 18.3451C21.6813 18.7573 21.619 18.9872 21.532 19.158C21.3403 19.5343 21.0343 19.8403 20.658 20.032C20.4872 20.119 20.2573 20.1813 19.8451 20.215C19.4242 20.2494 18.8824 20.25 18.1 20.25H5.9C5.11755 20.25 4.57582 20.2494 4.15488 20.215C3.74269 20.1813 3.5128 20.119 3.34202 20.032C2.9657 19.8403 2.65973 19.5343 2.46799 19.158C2.38097 18.9872 2.31865 18.7573 2.28498 18.3451C2.25058 17.9242 2.25 17.3824 2.25 16.6V8.75Z" fill="currentColor"/>`,F:`<path d="M7.46967 10.4697C7.76256 10.1768 8.23744 10.1768 8.53033 10.4697L11.5303 13.4697C11.8232 13.7626 11.8232 14.2374 11.5303 14.5303L8.53033 17.5303C8.23744 17.8232 7.76256 17.8232 7.46967 17.5303C7.17678 17.2374 7.17678 16.7626 7.46967 16.4697L9.93934 14L7.46967 11.5303C7.17678 11.2374 7.17678 10.7626 7.46967 10.4697Z" fill="currentColor"/>
|
||||
<path d="M12 16.25C11.5858 16.25 11.25 16.5858 11.25 17C11.25 17.4142 11.5858 17.75 12 17.75H17C17.4142 17.75 17.75 17.4142 17.75 17C17.75 16.5858 17.4142 16.25 17 16.25H12Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.03273 2.28996C4.52196 2.24999 5.12511 2.24999 5.86819 2.25H18.1318C18.8749 2.24999 19.478 2.24999 19.9673 2.28996C20.4722 2.33121 20.9215 2.41876 21.339 2.63148C21.9975 2.96703 22.533 3.50247 22.8685 4.16103C23.0812 4.57851 23.1688 5.02778 23.21 5.53273C23.25 6.02195 23.25 6.62507 23.25 7.36811V16.6318C23.25 17.3748 23.25 17.9781 23.21 18.4673C23.1688 18.9722 23.0812 19.4215 22.8685 19.839C22.533 20.4975 21.9975 21.033 21.339 21.3685C20.9215 21.5812 20.4722 21.6688 19.9673 21.71C19.4781 21.75 18.8749 21.75 18.1319 21.75H5.86819C5.12515 21.75 4.52195 21.75 4.03273 21.71C3.52778 21.6688 3.07851 21.5812 2.66103 21.3685C2.00247 21.033 1.46703 20.4975 1.13148 19.839C0.918762 19.4215 0.831214 18.9722 0.789958 18.4673C0.749986 17.978 0.749993 17.3749 0.75 16.6318V7.36821C0.749993 6.62513 0.749986 6.02196 0.789958 5.53273C0.831214 5.02778 0.918762 4.57851 1.13148 4.16103C1.46703 3.50247 2.00247 2.96703 2.66103 2.63148C3.07851 2.41876 3.52778 2.33121 4.03273 2.28996ZM2.25 8.75V16.6C2.25 17.3824 2.25058 17.9242 2.28498 18.3451C2.31865 18.7573 2.38097 18.9872 2.46799 19.158C2.65973 19.5343 2.9657 19.8403 3.34202 20.032C3.5128 20.119 3.74269 20.1813 4.15488 20.215C4.57582 20.2494 5.11755 20.25 5.9 20.25H18.1C18.8824 20.25 19.4242 20.2494 19.8451 20.215C20.2573 20.1813 20.4872 20.119 20.658 20.032C21.0343 19.8403 21.3403 19.5343 21.532 19.158C21.619 18.9872 21.6813 18.7573 21.715 18.3451C21.7494 17.9242 21.75 17.3824 21.75 16.6V8.75H2.25Z" fill="currentColor"/>`}),m=d(`Check`,{O:`<path d="M21.5303 5.46967C21.8232 5.76256 21.8232 6.23744 21.5303 6.53033L9.53033 18.5303C9.23744 18.8232 8.76256 18.8232 8.46967 18.5303L2.46967 12.5303C2.17678 12.2374 2.17678 11.7626 2.46967 11.4697C2.76256 11.1768 3.23744 11.1768 3.53033 11.4697L9 16.9393L20.4697 5.46967C20.7626 5.17678 21.2374 5.17678 21.5303 5.46967Z" fill="currentColor"/>`,F:`<path d="M21.7071 5.29289C22.0976 5.68342 22.0976 6.31658 21.7071 6.70711L9.70711 18.7071C9.31658 19.0976 8.68342 19.0976 8.29289 18.7071L2.29289 12.7071C1.90237 12.3166 1.90237 11.6834 2.29289 11.2929C2.68342 10.9024 3.31658 10.9024 3.70711 11.2929L9 16.5858L20.2929 5.29289C20.6834 4.90237 21.3166 4.90237 21.7071 5.29289Z" fill="currentColor"/>`}),h=d(`ChevronDown`,{F:`<g transform="scale(1.33333)"><path d="M9,13.5c-.192,0-.384-.073-.53-.22L2.22,7.03c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l5.72,5.72,5.72-5.72c.293-.293,.768-.293,1.061,0s.293,.768,0,1.061l-6.25,6.25c-.146,.146-.338,.22-.53,.22Z" fill="currentColor"></path></g>`,O:`<g transform="scale(1.33333)"><polyline points="15.25 6.5 9 12.75 2.75 6.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></polyline></g>`}),g=d(`Code`,{O:`<path d="M14.1809 4.2755C14.581 4.3827 14.8185 4.79396 14.7113 5.19406L10.7377 20.0238C10.6304 20.4239 10.2192 20.6613 9.81909 20.5541C9.41899 20.4469 9.18156 20.0356 9.28876 19.6355L13.2624 4.80583C13.3696 4.40573 13.7808 4.16829 14.1809 4.2755Z" fill="currentColor"/><path d="M16.4425 7.32781C16.7196 7.01993 17.1938 6.99497 17.5017 7.27206L19.2392 8.8358C19.9756 9.49847 20.5864 10.0482 21.0058 10.5467C21.4468 11.071 21.7603 11.6342 21.7603 12.3295C21.7603 13.0248 21.4468 13.5881 21.0058 14.1123C20.5864 14.6109 19.9756 15.1606 19.2392 15.8233L17.5017 17.387C17.1938 17.6641 16.7196 17.6391 16.4425 17.3313C16.1654 17.0234 16.1904 16.5492 16.4983 16.2721L18.1947 14.7452C18.9826 14.0362 19.5138 13.5558 19.8579 13.1467C20.1882 12.7541 20.2603 12.525 20.2603 12.3295C20.2603 12.1341 20.1882 11.9049 19.8579 11.5123C19.5138 11.1033 18.9826 10.6229 18.1947 9.91383L16.4983 8.387C16.1904 8.10991 16.1654 7.63569 16.4425 7.32781Z" fill="currentColor"/><path d="M7.50178 8.387C7.80966 8.10991 7.83462 7.63569 7.55752 7.32781C7.28043 7.01993 6.80621 6.99497 6.49833 7.27206L4.76084 8.8358C4.0245 9.49847 3.41369 10.0482 2.99428 10.5467C2.55325 11.071 2.23975 11.6342 2.23975 12.3295C2.23975 13.0248 2.55325 13.5881 2.99428 14.1123C3.41369 14.6109 4.02449 15.1606 4.76082 15.8232L6.49833 17.387C6.80621 17.6641 7.28043 17.6391 7.55752 17.3313C7.83462 17.0234 7.80966 16.5492 7.50178 16.2721L5.80531 14.7452C5.01743 14.0362 4.48623 13.5558 4.14213 13.1467C3.81188 12.7541 3.73975 12.525 3.73975 12.3295C3.73975 12.1341 3.81188 11.9049 4.14213 11.5123C4.48623 11.1033 5.01743 10.6229 5.80531 9.91383L7.50178 8.387Z" fill="currentColor"/>`,F:`<path d="M14.1809 4.2755C14.581 4.3827 14.8185 4.79396 14.7113 5.19406L10.7377 20.0238C10.6304 20.4239 10.2192 20.6613 9.81909 20.5541C9.41899 20.4469 9.18156 20.0356 9.28876 19.6355L13.2624 4.80583C13.3696 4.40573 13.7808 4.16829 14.1809 4.2755Z" fill="currentColor"/><path d="M16.4425 7.32781C16.7196 7.01993 17.1938 6.99497 17.5017 7.27206L19.2392 8.8358C19.9756 9.49847 20.5864 10.0482 21.0058 10.5467C21.4468 11.071 21.7603 11.6342 21.7603 12.3295C21.7603 13.0248 21.4468 13.5881 21.0058 14.1123C20.5864 14.6109 19.9756 15.1606 19.2392 15.8233L17.5017 17.387C17.1938 17.6641 16.7196 17.6391 16.4425 17.331Line truncated
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.03273 2.28996C4.52196 2.24999 5.12511 2.24999 5.86819 2.25H18.1318C18.8749 2.24999 19.478 2.24999 19.9673 2.28996C20.4722 2.33121 20.9215 2.41876 21.339 2.63148C21.9975 2.96703 22.533 3.50247 22.8685 4.16103C23.0812 4.57851 23.1688 5.02778 23.21 5.53273C23.25 6.02195 23.25 6.62507 23.25 7.36811V16.6318C23.25 17.3748 23.25 17.9781 23.21 18.4673C23.1688 18.9722 23.0812 19.4215 22.8685 19.839C22.533 20.4975 21.9975 21.033 21.339 21.3685C20.9215 21.5812 20.4722 21.6688 19.9673 21.71C19.4781 21.75 18.8749 21.75 18.1319 21.75H5.86819C5.12515 21.75 4.52195 21.75 4.03273 21.71C3.52778 21.6688 3.07851 21.5812 2.66103 21.3685C2.00247 21.033 1.46703 20.4975 1.13148 19.839C0.918762 19.4215 0.831214 18.9722 0.789958 18.4673C0.749986 17.978 0.749993 17.3749 0.75 16.6318V7.36821C0.749993 6.62513 0.749986 6.02196 0.789958 5.53273C0.831214 5.02778 0.918762 4.57851 1.13148 4.16103C1.46703 3.50247 2.00247 2.96703 2.66103 2.63148C3.07851 2.41876 3.52778 2.33121 4.03273 2.28996ZM2.25 8.75V16.6C2.25 17.3824 2.25058 17.9242 2.28498 18.3451C2.31865 18.7573 2.38097 18.9872 2.46799 19.158C2.65973 19.5343 2.9657 19.8403 3.34202 20.032C3.5128 20.119 3.74269 20.1813 4.15488 20.215C4.57582 20.2494 5.11755 20.25 5.9 20.25H18.1C18.8824 20.25 19.4242 20.2494 19.8451 20.215C20.2573 20.1813 20.4872 20.119 20.658 20.032C21.0343 19.8403 21.3403 19.5343 21.532 19.158C21.619 18.9872 21.6813 18.7573 21.715 18.3451C21.7494 17.9242 21.75 17.3824 21.75 16.6V8.75H2.25Z" fill="currentColor"/>`}),m=d(`Check`,{O:`<path d="M21.5303 5.46967C21.8232 5.76256 21.8232 6.23744 21.5303 6.53033L9.53033 18.5303C9.23744 18.8232 8.76256 18.8232 8.46967 18.5303L2.46967 12.5303C2.17678 12.2374 2.17678 11.7626 2.46967 11.4697C2.76256 11.1768 3.23744 11.1768 3.53033 11.4697L9 16.9393L20.4697 5.46967C20.7626 5.17678 21.2374 5.17678 21.5303 5.46967Z" fill="currentColor"/>`,F:`<path d="M21.7071 5.29289C22.0976 5.68342 22.0976 6.31658 21.7071 6.70711L9.70711 18.7071C9.31658 19.0976 8.68342 19.0976 8.29289 18.7071L2.29289 12.7071C1.90237 12.3166 1.90237 11.6834 2.29289 11.2929C2.68342 10.9024 3.31658 10.9024 3.70711 11.2929L9 16.5858L20.2929 5.29289C20.6834 4.90237 21.3166 4.90237 21.7071 5.29289Z" fill="currentColor"/>`}),h=d(`ChevronDown`,{F:`<g transform="scale(1.33333)"><path d="M9,13.5c-.192,0-.384-.073-.53-.22L2.22,7.03c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l5.72,5.72,5.72-5.72c.293-.293,.768-.293,1.061,0s.293,.768,0,1.061l-6.25,6.25c-.146,.146-.338,.22-.53,.22Z" fill="currentColor"></path></g>`,O:`<g transform="scale(1.33333)"><polyline points="15.25 6.5 9 12.75 2.75 6.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></polyline></g>`}),g=d(`Code`,{O:`<path d="M14.1809 4.2755C14.581 4.3827 14.8185 4.79396 14.7113 5.19406L10.7377 20.0238C10.6304 20.4239 10.2192 20.6613 9.81909 20.5541C9.41899 20.4469 9.18156 20.0356 9.28876 19.6355L13.2624 4.80583C13.3696 4.40573 13.7808 4.16829 14.1809 4.2755Z" fill="currentColor"/><path d="M16.4425 7.32781C16.7196 7.01993 17.1938 6.99497 17.5017 7.27206L19.2392 8.8358C19.9756 9.49847 20.5864 10.0482 21.0058 10.5467C21.4468 11.071 21.7603 11.6342 21.7603 12.3295C21.7603 13.0248 21.4468 13.5881 21.0058 14.1123C20.5864 14.6109 19.9756 15.1606 19.2392 15.8233L17.5017 17.387C17.1938 17.6641 16.7196 17.6391 16.4425 17.3313C16.1654 17.0234 16.1904 16.5492 16.4983 16.2721L18.1947 14.7452C18.9826 14.0362 19.5138 13.5558 19.8579 13.1467C20.1882 12.7541 20.2603 12.525 20.2603 12.3295C20.2603 12.1341 20.1882 11.9049 19.8579 11.5123C19.5138 11.1033 18.9826 10.6229 18.1947 9.91383L16.4983 8.387C16.1904 8.10991 16.1654 7.63569 16.4425 7.32781Z" fill="currentColor"/><path d="M7.50178 8.387C7.80966 8.10991 7.83462 7.63569 7.55752 7.32781C7.28043 7.01993 6.80621 6.99497 6.49833 7.27206L4.76084 8.8358C4.0245 9.49847 3.41369 10.0482 2.99428 10.5467C2.55325 11.071 2.23975 11.6342 2.23975 12.3295C2.23975 13.0248 2.55325 13.5881 2.99428 14.1123C3.41369 14.6109 4.02449 15.1606 4.76082 15.8232L6.49833 17.387C6.80621 17.6641 7.28043 17.6391 7.55752 17.3313C7.83462 17.0234 7.80966 16.5492 7.50178 16.2721L5.80531 14.7452C5.01743 14.0362 4.48623 13.5558 4.14213 13.1467C3.81188 12.7541 3.73975 12.525 3.73975 12.3295C3.73975 12.1341 3.81188 11.9049 4.14213 11.5123C4.48623 11.1033 5.01743 10.6229 5.80531 9.91383L7.50178 8.387Z" fill="currentColor"/>`,F:`<path d="M14.1809 4.2755C14.581 4.3827 14.8185 4.79396 14.7113 5.19406L10.7377 20.0238C10.6304 20.4239 10.2192 20.6613 9.81909 20.5541C9.41899 20.4469 9.18156 20.0356 9.28876 19.6355L13.2624 4.80583C13.3696 4.40573 13.7808 4.16829 14.1809 4.2755Z" fill="currentColor"/><path d="M16.4425 7.32781C16.7196 7.01993 17.1938 6.99497 17.5017 7.27206L19.2392 8.8358C19.9756 9.49847 20.5864 10.0482 21.0058 10.5467C21.4468 11.071 21.7603 11.6342 21.7603 12.3295C21.7603 13.0248 21.4468 13.5881 21.0058 14.1123C20.5864 14.6109 19.9756 15.1606 19.2392 15.8233L17.5017 17.387C17.1938 17.6641 16.7196 17.6391 16.4425 17.331Line truncated
|
||||
@@ -17,10 +17,10 @@ interface Env {
|
||||
}
|
||||
|
||||
/** Raw-file base for the canonical Kigi repo (GitHub, `main` branch). */
|
||||
const UPSTREAM = "https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main";
|
||||
const UPSTREAM = "https://raw.githubusercontent.com/ELY-Open-Source/Kigi-CLI/main";
|
||||
|
||||
/** Human-facing repo URL, used in the installer-unavailable message. */
|
||||
const REPO_URL = "https://github.com/ZacharyZhang-NY/Kigi-CLI";
|
||||
const REPO_URL = "https://github.com/ELY-Open-Source/Kigi-CLI";
|
||||
|
||||
interface Installer {
|
||||
file: string;
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@
|
||||
# Environment:
|
||||
# KIGI_SHARE_DIR install root (default: %USERPROFILE%\.kigi)
|
||||
# KIGI_UPDATE_BASE_URL releases API base, GitHub/Gitea-shaped (default:
|
||||
# https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases)
|
||||
# https://api.github.com/repos/ELY-Open-Source/Kigi-CLI/releases)
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -26,7 +26,7 @@ function Fail([string]$Message) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
$Repo = "ZacharyZhang-NY/Kigi-CLI"
|
||||
$Repo = "ELY-Open-Source/Kigi-CLI"
|
||||
$ApiBase = if ($env:KIGI_UPDATE_BASE_URL) { $env:KIGI_UPDATE_BASE_URL } else { "https://api.github.com/repos/$Repo/releases" }
|
||||
$KigiHome = if ($env:KIGI_SHARE_DIR) { $env:KIGI_SHARE_DIR } else { Join-Path $env:USERPROFILE ".kigi" }
|
||||
$Triple = "x86_64-pc-windows-msvc"
|
||||
|
||||
+2
-2
@@ -14,13 +14,13 @@
|
||||
# Environment:
|
||||
# KIGI_SHARE_DIR install root (default: ~/.kigi)
|
||||
# KIGI_UPDATE_BASE_URL releases API base, GitHub/Gitea-shaped (default:
|
||||
# https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases)
|
||||
# https://api.github.com/repos/ELY-Open-Source/Kigi-CLI/releases)
|
||||
#
|
||||
# Fails fast on any error; never leaves a partial binary as the active kigi.
|
||||
|
||||
set -eu
|
||||
|
||||
REPO="ZacharyZhang-NY/Kigi-CLI"
|
||||
REPO="ELY-Open-Source/Kigi-CLI"
|
||||
API_BASE="${KIGI_UPDATE_BASE_URL:-https://api.github.com/repos/${REPO}/releases}"
|
||||
KIGI_HOME="${KIGI_SHARE_DIR:-$HOME/.kigi}"
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# HyperFrames Composition Project
|
||||
|
||||
## Skills — USE THESE FIRST
|
||||
|
||||
**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
|
||||
|
||||
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
|
||||
|
||||
- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
|
||||
- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
|
||||
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
|
||||
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage **packaged with designed graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath. (Plain captions/subtitles → `/embedded-captions`.)
|
||||
- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → 30-90s code-change explainer (changelog / feature reveal / fix / refactor).
|
||||
- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
|
||||
- `/music-to-video` — a **music track** (audio file, video to pull audio from, or one generated from a mood brief) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
|
||||
- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
|
||||
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
|
||||
|
||||
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
|
||||
|
||||
The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-keyframes`, `/hyperframes-creative`, `/hyperframes-cli`, `/media-use`, `/hyperframes-audio`, `/hyperframes-registry`, `/figma`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent.
|
||||
|
||||
**Changing how real footage or images look or reveal?** Load `/media-use` and read its `references/media-treatments.md` before editing, even when the request only says dark, flat, boring, retro, private, or “make the reveal cooler.” It governs how footage is treated, never whether media may be used. Use canonical media treatments and seek-safe motion; do not improvise equivalent CSS/SVG filters or overlays.
|
||||
|
||||
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`.
|
||||
|
||||
> **Using a HyperFrames plugin?** Load skills from that installed bundle and follow
|
||||
> its `hyperframes/references/plugin-installation.md` execution rules. Update via
|
||||
> the plugin manager, not the standalone commands below.
|
||||
>
|
||||
> **Standalone skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
|
||||
> the specific skill you need (the `/hyperframes` router does this automatically before
|
||||
> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
|
||||
> everything already installed — neither pulls the full set. Restart the agent session so
|
||||
> newly installed skills load.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # human-operated foreground preview (blocks until stopped)
|
||||
npx hyperframes preview --background # agent-safe persistent Studio preview
|
||||
npx hyperframes preview --status # verify the persistent preview is listening
|
||||
npx hyperframes preview --stop # stop it when review is finished
|
||||
npm run check # lint + runtime + layout + motion + contrast (one command)
|
||||
npm run render # render to MP4
|
||||
npm run publish # publish and get a shareable link
|
||||
npx hyperframes lint --verbose # include info-level findings
|
||||
npx hyperframes lint --json # machine-readable output for CI
|
||||
npx hyperframes docs <topic> # reference docs in terminal
|
||||
```
|
||||
|
||||
> **Agents must use `npx hyperframes preview --background` for Studio handoff.** Do not rely
|
||||
> on a shell/tool `run_in_background` wrapper around `npm run dev`: that foreground process
|
||||
> remains owned by the invoking session and can disappear while the browser stays open,
|
||||
> leaving refreshes at `ERR_CONNECTION_TIMED_OUT`. Verify with `preview --status`, keep it
|
||||
> alive through review, and stop it explicitly with `preview --stop` afterward.
|
||||
|
||||
> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
|
||||
|
||||
## Documentation
|
||||
|
||||
**For quick reference**, use the local CLI docs command (no network required):
|
||||
|
||||
```bash
|
||||
npx hyperframes docs <topic>
|
||||
```
|
||||
|
||||
Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `examples`, `troubleshooting`
|
||||
|
||||
**For full documentation**, discover pages via the machine-readable index — do NOT guess URLs:
|
||||
|
||||
```
|
||||
https://hyperframes.heygen.com/llms.txt
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `index.html` — main composition (root timeline)
|
||||
- `compositions/` — sub-compositions referenced via `data-composition-src`
|
||||
- `meta.json` — project metadata (id, name)
|
||||
- `transcript.json` — whisper word-level transcript (if generated)
|
||||
|
||||
## Linting — ALWAYS RUN AFTER CHANGES
|
||||
|
||||
After creating or editing any `.html` composition, **always** run the full check before considering the task complete:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
Fix all errors before presenting the result. Warnings should be reviewed before rendering.
|
||||
|
||||
## Key Rules
|
||||
|
||||
1. Every timed element needs `data-start` and a duration. `data-start` is what marks it as timed; `data-track-index` is an optional Studio display lane the render never reads
|
||||
2. Give timed visual elements `class="clip"`. The framework keys visibility off `data-start`, not the class, but the shared `.clip` CSS is what gives a scene its full-frame box, and `lint` warns without it
|
||||
3. Register one paused root timeline per composition on `window.__timelines`:
|
||||
```js
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["composition-id"] = gsap.timeline({ paused: true });
|
||||
```
|
||||
Scene timelines manually added to this root must not be paused. A paused
|
||||
child does not advance when the root is seeked. The runtime activates
|
||||
registered composition siblings, not arbitrary nested scene timelines.
|
||||
4. A video with sound keeps it on the `<video>` (`data-has-audio="true"`, no `muted`). Use a separate `<audio>` for music, voiceover, replacement audio, J/L cuts, or audio detached in Studio. Silent footage and b-roll: `muted`.
|
||||
5. Sub-compositions use `data-composition-src="compositions/file.html"` to reference other HTML files
|
||||
6. Only deterministic logic — no `Date.now()`, no `Math.random()`, no network fetches
|
||||
@@ -0,0 +1,107 @@
|
||||
# HyperFrames Composition Project
|
||||
|
||||
## Skills — USE THESE FIRST
|
||||
|
||||
**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
|
||||
|
||||
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
|
||||
|
||||
- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
|
||||
- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
|
||||
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
|
||||
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage **packaged with designed graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath. (Plain captions/subtitles → `/embedded-captions`.)
|
||||
- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → 30-90s code-change explainer (changelog / feature reveal / fix / refactor).
|
||||
- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
|
||||
- `/music-to-video` — a **music track** (audio file, video to pull audio from, or one generated from a mood brief) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
|
||||
- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
|
||||
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
|
||||
|
||||
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
|
||||
|
||||
The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-keyframes`, `/hyperframes-creative`, `/hyperframes-cli`, `/media-use`, `/hyperframes-audio`, `/hyperframes-registry`, `/figma`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent.
|
||||
|
||||
**Changing how real footage or images look or reveal?** Load `/media-use` and read its `references/media-treatments.md` before editing, even when the request only says dark, flat, boring, retro, private, or “make the reveal cooler.” It governs how footage is treated, never whether media may be used. Use canonical media treatments and seek-safe motion; do not improvise equivalent CSS/SVG filters or overlays.
|
||||
|
||||
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`.
|
||||
|
||||
> **Using a HyperFrames plugin?** Load skills from that installed bundle and follow
|
||||
> its `hyperframes/references/plugin-installation.md` execution rules. Update via
|
||||
> the plugin manager, not the standalone commands below.
|
||||
>
|
||||
> **Standalone skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
|
||||
> the specific skill you need (the `/hyperframes` router does this automatically before
|
||||
> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
|
||||
> everything already installed — neither pulls the full set. Restart the agent session so
|
||||
> newly installed skills load.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # human-operated foreground preview (blocks until stopped)
|
||||
npx hyperframes preview --background # agent-safe persistent Studio preview
|
||||
npx hyperframes preview --status # verify the persistent preview is listening
|
||||
npx hyperframes preview --stop # stop it when review is finished
|
||||
npm run check # lint + runtime + layout + motion + contrast (one command)
|
||||
npm run render # render to MP4
|
||||
npm run publish # publish and get a shareable link
|
||||
npx hyperframes lint --verbose # include info-level findings
|
||||
npx hyperframes lint --json # machine-readable output for CI
|
||||
npx hyperframes docs <topic> # reference docs in terminal
|
||||
```
|
||||
|
||||
> **Agents must use `npx hyperframes preview --background` for Studio handoff.** Do not rely
|
||||
> on a shell/tool `run_in_background` wrapper around `npm run dev`: that foreground process
|
||||
> remains owned by the invoking session and can disappear while the browser stays open,
|
||||
> leaving refreshes at `ERR_CONNECTION_TIMED_OUT`. Verify with `preview --status`, keep it
|
||||
> alive through review, and stop it explicitly with `preview --stop` afterward.
|
||||
|
||||
> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
|
||||
|
||||
## Documentation
|
||||
|
||||
**For quick reference**, use the local CLI docs command (no network required):
|
||||
|
||||
```bash
|
||||
npx hyperframes docs <topic>
|
||||
```
|
||||
|
||||
Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `examples`, `troubleshooting`
|
||||
|
||||
**For full documentation**, discover pages via the machine-readable index — do NOT guess URLs:
|
||||
|
||||
```
|
||||
https://hyperframes.heygen.com/llms.txt
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `index.html` — main composition (root timeline)
|
||||
- `compositions/` — sub-compositions referenced via `data-composition-src`
|
||||
- `meta.json` — project metadata (id, name)
|
||||
- `transcript.json` — whisper word-level transcript (if generated)
|
||||
|
||||
## Linting — ALWAYS RUN AFTER CHANGES
|
||||
|
||||
After creating or editing any `.html` composition, **always** run the full check before considering the task complete:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
Fix all errors before presenting the result. Warnings should be reviewed before rendering.
|
||||
|
||||
## Key Rules
|
||||
|
||||
1. Every timed element needs `data-start` and a duration. `data-start` is what marks it as timed; `data-track-index` is an optional Studio display lane the render never reads
|
||||
2. Give timed visual elements `class="clip"`. The framework keys visibility off `data-start`, not the class, but the shared `.clip` CSS is what gives a scene its full-frame box, and `lint` warns without it
|
||||
3. Register one paused root timeline per composition on `window.__timelines`:
|
||||
```js
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["composition-id"] = gsap.timeline({ paused: true });
|
||||
```
|
||||
Scene timelines manually added to this root must not be paused. A paused
|
||||
child does not advance when the root is seeked. The runtime activates
|
||||
registered composition siblings, not arbitrary nested scene timelines.
|
||||
4. A video with sound keeps it on the `<video>` (`data-has-audio="true"`, no `muted`). Use a separate `<audio>` for music, voiceover, replacement audio, J/L cuts, or audio detached in Studio. Silent footage and b-roll: `muted`.
|
||||
5. Sub-compositions use `data-composition-src="compositions/file.html"` to reference other HTML files
|
||||
6. Only deterministic logic — no `Date.now()`, no `Math.random()`, no network fetches
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
import json, pathlib, concurrent.futures as cf
|
||||
from tts import tts
|
||||
VOICE = "moss_audio_ce44fc67-7ce3-11f0-8de5-96e35d26fb85"
|
||||
script = json.load(open("script.json", encoding="utf-8"))
|
||||
def run(sc):
|
||||
text = "".join(s.get("tts", s["zh"]) for s in sc["segs"])
|
||||
info, subs = tts(text, f"audio/vo_{sc['id']}.mp3", VOICE, speed=1.05)
|
||||
words = [w for seg in subs for w in seg["timestamped_words"]]
|
||||
# map segment char ranges -> times
|
||||
out, pos = [], 0
|
||||
for s in sc["segs"]:
|
||||
t = s.get("tts", s["zh"]); a, b = pos, pos + len(t); pos = b
|
||||
ws = [w for w in words if w["word_begin"] >= a and w["word_end"] <= b and w["word"].strip(",。:;——?!、“” ")]
|
||||
out.append({"zh": s["zh"], "en": s["en"], "t0": ws[0]["time_begin"]/1000, "t1": ws[-1]["time_end"]/1000})
|
||||
return sc["id"], {"len": info["audio_length"]/1000, "segs": out}
|
||||
with cf.ThreadPoolExecutor(6) as ex:
|
||||
res = dict(ex.map(run, script))
|
||||
json.dump({k: res[k] for k in sorted(res)}, open("vo_timing.json","w",encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||
for k in sorted(res): print(k, res[k]["len"], [ (round(s["t0"],2), round(s["t1"],2)) for s in res[k]["segs"]])
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/hyperframes.json",
|
||||
"registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
|
||||
"paths": {
|
||||
"blocks": "compositions",
|
||||
"components": "compositions/components",
|
||||
"assets": "assets"
|
||||
},
|
||||
"media": {
|
||||
"autoProxy": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Kigi /graph — Bauhaus</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
@font-face { font-family: "Jost"; src: url("fonts/Jost.ttf") format("truetype"); font-weight: 100 900; }
|
||||
@font-face { font-family: "Noto Sans SC"; src: url("fonts/NotoSansSC.ttf") format("truetype"); font-weight: 100 900; }
|
||||
@font-face { font-family: "JetBrains Mono"; src: url("fonts/JetBrainsMono.ttf") format("truetype"); font-weight: 100 800; }
|
||||
:root { --paper:#EEE6D6; --ink:#141414; --red:#D42A1E; --yellow:#F2B300; --blue:#1D4FA0; --grey:#BDB5A6; }
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html, body { width:1920px; height:1080px; overflow:hidden; background:var(--paper); }
|
||||
#root { position:relative; width:100%; height:100%; overflow:hidden; font-family:"Jost", sans-serif; color:var(--ink); }
|
||||
.layer { position:absolute; inset:0; }
|
||||
.abs { position:absolute; display:block; }
|
||||
.mono { font-family:"JetBrains Mono", monospace; }
|
||||
.zh { font-family:"Noto Sans SC", sans-serif; }
|
||||
.sq { background:var(--red); }
|
||||
.ci { background:var(--blue); border-radius:50%; }
|
||||
.tri { background:var(--yellow); clip-path:polygon(50% 0, 100% 100%, 0 100%); }
|
||||
.nowrap { white-space:nowrap; }
|
||||
|
||||
/* grid */
|
||||
.gl { position:absolute; background:var(--ink); opacity:.08; }
|
||||
.glv { width:1px; top:0; height:1080px; transform-origin:50% 0; }
|
||||
.glh { height:1px; left:0; width:1920px; transform-origin:0 50%; }
|
||||
|
||||
/* s1 */
|
||||
#s1-goal { left:1080px; top:120px; width:600px; height:600px; }
|
||||
.s1-cell { position:absolute; width:200px; height:200px; background:var(--ink); }
|
||||
#s1-goal-txt { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--paper); }
|
||||
#s1-goal-zh { font-size:180px; font-weight:900; line-height:1; }
|
||||
#s1-goal-en { font-size:44px; font-weight:600; letter-spacing:.4em; margin-top:24px; padding-left:.4em; }
|
||||
#s1-agent { left:240px; top:600px; width:120px; height:120px; }
|
||||
#s1-agent-in { width:120px; height:120px; }
|
||||
#s1-agent-dot { width:120px; height:120px; }
|
||||
#s1-agent-lbl { left:240px; top:744px; font-size:22px; letter-spacing:.2em; font-weight:600; }
|
||||
#s1-svg { position:absolute; left:0; top:0; }
|
||||
#s1-floor { left:120px; top:720px; width:1680px; height:8px; background:var(--ink); transform-origin:0 50%; }
|
||||
|
||||
/* s2 */
|
||||
#s2-field { left:0; top:0; width:1080px; height:1080px; background:var(--red); }
|
||||
#s2-kigi { left:104px; top:70px; display:flex; font-size:330px; font-weight:800; line-height:1; color:var(--paper); letter-spacing:-.01em; }
|
||||
#s2-kigi span { display:block; }
|
||||
#s2-slash { left:120px; top:470px; height:170px; overflow:hidden; font-size:150px; font-weight:700; line-height:170px; color:var(--ink); width:0; }
|
||||
#s2-cursor { left:120px; top:490px; width:80px; height:130px; background:var(--ink); }
|
||||
#s2-tu { left:1188px; top:60px; font-size:560px; font-weight:900; line-height:1; }
|
||||
#s2-circle { left:840px; top:420px; width:360px; height:360px; mix-blend-mode:multiply; }
|
||||
#s2-tri { left:1500px; top:600px; width:300px; height:240px; transform-origin:50% 100%; }
|
||||
#s2-sub { left:120px; top:720px; font-size:30px; font-weight:600; letter-spacing:.32em; color:var(--paper); }
|
||||
|
||||
/* s3 */
|
||||
#s3-cam { position:absolute; inset:0; transform-origin:960px 360px; }
|
||||
.s3-node { position:absolute; top:240px; width:240px; height:240px; }
|
||||
.s3-ring { position:absolute; left:40px; top:40px; width:160px; height:160px; border:3px solid var(--paper); border-radius:50%; opacity:.9; }
|
||||
.s3-orb { position:absolute; left:0; top:0; width:240px; height:240px; }
|
||||
.s3-ball { position:absolute; left:96px; top:16px; width:48px; height:48px; }
|
||||
.s3-tag { position:absolute; font-size:11px; color:var(--paper); letter-spacing:.12em; }
|
||||
.s3-edge { position:absolute; top:356px; height:8px; width:360px; background:var(--ink); transform-origin:0 50%; }
|
||||
.s3-ruler { position:absolute; top:372px; height:14px; width:360px; transform-origin:0 50%;
|
||||
background:repeating-linear-gradient(90deg, var(--ink) 0 2px, transparent 2px 30px); }
|
||||
.s3-arrow { position:absolute; top:340px; width:28px; height:40px; background:var(--ink); clip-path:polygon(0 0, 100% 50%, 0 100%); }
|
||||
.s3-elbl { position:absolute; top:396px; font-size:22px; font-weight:500; }
|
||||
#s3-big1 { left:120px; top:540px; font-size:104px; font-weight:800; line-height:1; color:var(--blue); letter-spacing:-.01em; }
|
||||
#s3-big2 { left:120px; top:660px; font-size:104px; font-weight:800; line-height:1; letter-spacing:-.01em; }
|
||||
.s3-leg { position:absolute; left:1200px; display:flex; align-items:center; gap:28px; height:64px; }
|
||||
.s3-leg-shape { width:56px; height:56px; flex:none; }
|
||||
.s3-leg-txt { font-size:30px; font-weight:700; letter-spacing:.08em; }
|
||||
.s3-leg-txt .zh { font-size:26px; font-weight:500; margin-left:14px; letter-spacing:0; }
|
||||
#s3-foot { left:1200px; top:780px; font-size:17px; opacity:.75; letter-spacing:.04em; }
|
||||
|
||||
/* s4 */
|
||||
#s4-ground { background:var(--ink); }
|
||||
#s4-head { left:120px; top:84px; font-size:24px; color:var(--paper); letter-spacing:.06em; overflow:hidden; height:32px; width:0; }
|
||||
#s4-x3 { left:1560px; top:40px; font-size:150px; font-weight:800; line-height:1; color:var(--yellow); }
|
||||
.s4-lane { position:absolute; left:360px; width:1320px; height:3px; background:var(--paper); transform-origin:0 50%; }
|
||||
.s4-lbl { position:absolute; left:360px; font-size:20px; color:var(--paper); opacity:.8; }
|
||||
.s4-node { position:absolute; width:96px; height:96px; }
|
||||
.s4-w { position:absolute; width:72px; height:72px; }
|
||||
.s4-commit { position:absolute; width:24px; height:24px; background:var(--paper); }
|
||||
.s4-tri { position:absolute; left:1584px; width:96px; height:84px; transform-origin:50% 100%; }
|
||||
.s4-check { position:absolute; left:1584px; width:96px; text-align:center; font-size:40px; font-weight:800; color:var(--ink); }
|
||||
.s4-verdict { position:absolute; left:1176px; font-size:20px; text-align:right; width:380px; }
|
||||
.s4-round { position:absolute; left:1700px; font-size:22px; color:var(--paper); width:100px; }
|
||||
|
||||
/* s5 */
|
||||
#s5-yellow { background:var(--yellow); clip-path:circle(0% at 960px 690px); }
|
||||
#s5-svg { position:absolute; left:0; top:0; }
|
||||
.s5-node { position:absolute; width:96px; height:96px; }
|
||||
.s5-name { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:var(--paper); font-size:22px; font-weight:600; letter-spacing:.06em; }
|
||||
.s5-id { position:absolute; width:160px; text-align:center; font-size:14px; }
|
||||
.s5-flesh { position:absolute; left:18px; top:18px; width:60px; height:60px; }
|
||||
#s5-final { left:810px; top:560px; width:300px; height:260px; transform-origin:50% 100%; }
|
||||
#s5-final-lbl { left:1150px; top:700px; font-size:22px; }
|
||||
#s5-final-lbl b { display:block; font-family:"Jost"; font-size:34px; font-weight:700; letter-spacing:.12em; }
|
||||
#s5-final-lbl i { display:block; font-style:normal; font-family:"Noto Sans SC"; font-size:24px; font-weight:700; }
|
||||
#s5-bones-wrap { left:-220px; top:410px; width:800px; height:100px; transform:rotate(-90deg); }
|
||||
#s5-bones { font-size:62px; font-weight:800; line-height:100px; text-align:center; letter-spacing:.02em; }
|
||||
#s5-flesh-wrap { left:1340px; top:410px; width:800px; height:100px; transform:rotate(90deg); }
|
||||
#s5-flesh-t { font-size:62px; font-weight:800; line-height:100px; text-align:center; color:var(--blue); letter-spacing:.02em; }
|
||||
#s5-zh1 { left:270px; top:120px; writing-mode:vertical-rl; font-size:44px; font-weight:900; letter-spacing:.2em; }
|
||||
#s5-zh2 { left:1594px; top:120px; writing-mode:vertical-rl; font-size:44px; font-weight:900; letter-spacing:.2em; color:var(--blue); }
|
||||
/* logo */
|
||||
#s5-logo { background:var(--paper); }
|
||||
#s5-kigi { left:104px; top:110px; font-size:340px; font-weight:800; line-height:1; letter-spacing:-.01em; }
|
||||
#s5-kigi span { display:inline-block; }
|
||||
#s5-graph { left:120px; top:470px; font-size:130px; font-weight:700; color:var(--red); line-height:1; overflow:hidden; height:150px; width:0; }
|
||||
#s5-l-sq { left:1080px; top:300px; width:200px; height:200px; }
|
||||
#s5-l-ci { left:1320px; top:300px; width:200px; height:200px; }
|
||||
#s5-l-tri { left:1560px; top:300px; width:220px; height:200px; transform-origin:50% 100%; }
|
||||
#s5-rule { left:1080px; top:560px; width:700px; height:8px; background:var(--ink); transform-origin:0 50%; }
|
||||
#s5-tag { left:1080px; top:596px; font-size:22px; letter-spacing:.06em; }
|
||||
#s5-tag2 { left:1080px; top:640px; font-size:28px; font-weight:700; }
|
||||
#s5-cursor { width:22px; height:40px; background:var(--red); display:inline-block; vertical-align:-6px; margin-left:8px; }
|
||||
|
||||
/* transitions */
|
||||
.wipe { position:absolute; left:0; top:0; width:1920px; height:1080px; }
|
||||
|
||||
/* HUD */
|
||||
#hud { position:absolute; inset:0; pointer-events:none; }
|
||||
#hud .hud-l { left:40px; top:22px; font-size:15px; letter-spacing:.14em; font-weight:500; }
|
||||
#hud .hud-l span { font-weight:800; }
|
||||
.beat { position:absolute; top:24px; width:14px; height:14px; border:2px solid var(--ink); }
|
||||
|
||||
/* captions */
|
||||
#band { position:absolute; left:0; top:880px; width:1920px; height:200px; background:var(--paper); }
|
||||
#band-rule { left:120px; top:888px; width:1680px; height:3px; background:var(--ink); transform-origin:0 50%; }
|
||||
#band-mark { left:120px; top:922px; width:28px; height:28px; background:var(--red); }
|
||||
.cap { position:absolute; left:180px; top:906px; width:1620px; height:150px; }
|
||||
.cap-zh { position:absolute; left:0; top:0; font-family:"Noto Sans SC"; font-size:42px; font-weight:700; line-height:60px; white-space:nowrap; }
|
||||
.cap-en { position:absolute; left:0; top:68px; font-size:28px; font-weight:500; line-height:40px; letter-spacing:.02em; opacity:.72; white-space:nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="34" data-width="1920" data-height="1080">
|
||||
|
||||
<div id="bg" class="clip layer" data-start="0" data-duration="34" data-track-index="0" style="background:#EEE6D6"></div>
|
||||
<div id="grid" class="clip layer" data-start="0" data-duration="34" data-track-index="1"></div>
|
||||
|
||||
<!-- ============ S1 · HOOK ============ -->
|
||||
<section id="sc1" class="clip layer" data-start="0" data-duration="6" data-track-index="2">
|
||||
<div id="s1-floor" class="abs"></div>
|
||||
<svg id="s1-svg" width="1920" height="1080" viewBox="0 0 1920 1080">
|
||||
<path id="s1-path" d="M840 660 C 700 300, 300 820, 420 420 S 900 180, 660 560 S 180 780, 300 300 S 780 120, 560 690 S 200 520, 380 640"
|
||||
fill="none" stroke="#141414" stroke-width="6" stroke-linecap="round" />
|
||||
</svg>
|
||||
<div id="s1-goal" class="abs">
|
||||
<div class="s1-cell" style="left:0;top:0"></div><div class="s1-cell" style="left:200px;top:0"></div><div class="s1-cell" style="left:400px;top:0"></div>
|
||||
<div class="s1-cell" style="left:0;top:200px"></div><div class="s1-cell" style="left:200px;top:200px"></div><div class="s1-cell" style="left:400px;top:200px"></div>
|
||||
<div class="s1-cell" style="left:0;top:400px"></div><div class="s1-cell" style="left:200px;top:400px"></div><div class="s1-cell" style="left:400px;top:400px"></div>
|
||||
<div id="s1-goal-txt"><div id="s1-goal-zh" class="zh">目标</div><div id="s1-goal-en">GOAL</div></div>
|
||||
</div>
|
||||
<div id="s1-agent" class="abs"><div id="s1-agent-in" class="abs"><div id="s1-agent-dot" class="ci abs"></div></div></div>
|
||||
<div id="s1-agent-lbl" class="abs nowrap">AGENT <span class="zh">智能体</span></div>
|
||||
</section>
|
||||
|
||||
<!-- ============ S2 · TITLE ============ -->
|
||||
<section id="sc2" class="clip layer" data-start="6" data-duration="4" data-track-index="2">
|
||||
<div id="s2-field" class="abs"></div>
|
||||
<div id="s2-kigi" class="abs"><span>K</span><span>I</span><span>G</span><span>I</span></div>
|
||||
<div id="s2-slash" class="abs mono nowrap">/graph</div>
|
||||
<div id="s2-cursor" class="abs"></div>
|
||||
<div id="s2-tu" class="abs zh">图</div>
|
||||
<div id="s2-circle" class="abs ci"></div>
|
||||
<div id="s2-tri" class="abs tri"></div>
|
||||
<div id="s2-sub" class="abs nowrap">GRAPH ENGINE · <span class="zh">图引擎</span></div>
|
||||
</section>
|
||||
|
||||
<!-- ============ S3 · PRINCIPLE ============ -->
|
||||
<section id="sc3" class="clip layer" data-start="10" data-duration="8" data-track-index="2">
|
||||
<div id="s3-cam">
|
||||
<div class="s3-edge" id="s3-e1" style="left:480px"></div><div class="s3-ruler" id="s3-r1" style="left:480px"></div>
|
||||
<div class="s3-edge" id="s3-e2" style="left:1080px"></div><div class="s3-ruler" id="s3-r2" style="left:1080px"></div>
|
||||
<div class="s3-arrow" id="s3-a1" style="left:812px"></div><div class="s3-arrow" id="s3-a2" style="left:1412px"></div>
|
||||
<div class="s3-elbl mono nowrap" id="s3-l1" style="left:492px">fn ready() → bool</div>
|
||||
<div class="s3-elbl mono nowrap" id="s3-l2" style="left:1092px">DepKind::Blocks</div>
|
||||
<div class="s3-node sq" id="s3-n1" style="left:240px"><div class="s3-ring"></div><div class="s3-orb" id="s3-o1"><div class="s3-ball ci"></div></div></div>
|
||||
<div class="s3-node sq" id="s3-n2" style="left:840px"><div class="s3-ring"></div><div class="s3-orb" id="s3-o2"><div class="s3-ball ci"></div></div>
|
||||
<div class="s3-tag mono nowrap" id="s3-t1" style="left:98px;top:96px">PLAN</div>
|
||||
<div class="s3-tag mono nowrap" id="s3-t2" style="left:103px;top:116px">ACT</div>
|
||||
<div class="s3-tag mono nowrap" id="s3-t3" style="left:94px;top:136px">CHECK</div>
|
||||
<div class="s3-tag nowrap" id="s3-t4" style="left:12px;top:210px;font-family:Jost;font-weight:700">AGENT LOOP · <span class="zh">智能体循环</span></div>
|
||||
</div>
|
||||
<div class="s3-node sq" id="s3-n3" style="left:1440px"><div class="s3-ring"></div><div class="s3-orb" id="s3-o3"><div class="s3-ball ci"></div></div></div>
|
||||
</div>
|
||||
<div id="s3-big1" class="abs nowrap">LOOP INSIDE.</div>
|
||||
<div id="s3-big2" class="abs nowrap">EDGES: RUST.</div>
|
||||
<div class="s3-leg" id="s3-g1" style="top:530px"><div class="s3-leg-shape ci"></div><div class="s3-leg-txt nowrap">AGENT<span class="zh">智能体</span></div></div>
|
||||
<div class="s3-leg" id="s3-g2" style="top:610px"><div class="s3-leg-shape sq"></div><div class="s3-leg-txt nowrap">NODE<span class="zh">结构</span></div></div>
|
||||
<div class="s3-leg" id="s3-g3" style="top:690px"><div class="s3-leg-shape tri"></div><div class="s3-leg-txt nowrap">SKEPTIC<span class="zh">审查者</span></div></div>
|
||||
<div id="s3-foot" class="abs mono nowrap">KANDINSKY · BAUHAUS 1923 · ▲ YELLOW ■ RED ● BLUE</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ S4 · FAN-OUT ============ -->
|
||||
<section id="sc4" class="clip layer" data-start="18" data-duration="6" data-track-index="2">
|
||||
<div id="s4-ground" class="layer"></div>
|
||||
<div id="s4-head" class="abs mono nowrap">$ KIGI_GRAPH_CONCURRENCY=3 · git worktree add ×3</div>
|
||||
<div id="s4-x3" class="abs nowrap">×3</div>
|
||||
<div id="s4-lanes"></div>
|
||||
</section>
|
||||
|
||||
<!-- ============ S5 · gn-final → bones/flesh → logo ============ -->
|
||||
<section id="sc5" class="clip layer" data-start="24" data-duration="10" data-track-index="2">
|
||||
<div id="s5-yellow" class="layer"></div>
|
||||
<svg id="s5-svg" width="1920" height="1080" viewBox="0 0 1920 1080"></svg>
|
||||
<div id="s5-nodes"></div>
|
||||
<div id="s5-final" class="abs tri"></div>
|
||||
<div id="s5-final-lbl" class="abs mono nowrap">gn-final<b>FINAL VERIFICATION</b><i>最终验收 · 整个目标</i></div>
|
||||
<div id="s5-bones-wrap" class="abs"><div id="s5-bones" class="nowrap">DETERMINISTIC BONES</div></div>
|
||||
<div id="s5-flesh-wrap" class="abs"><div id="s5-flesh-t" class="nowrap">INTELLIGENT FLESH</div></div>
|
||||
<div id="s5-zh1" class="abs zh">确定性的骨架</div>
|
||||
<div id="s5-zh2" class="abs zh">智能的血肉</div>
|
||||
<div id="s5-logo" class="layer">
|
||||
<div id="s5-kigi" class="abs nowrap"><span>K</span><span>I</span><span>G</span><span>I</span></div>
|
||||
<div id="s5-graph" class="abs mono nowrap">/graph</div>
|
||||
<div id="s5-l-sq" class="abs sq"></div>
|
||||
<div id="s5-l-ci" class="abs ci"></div>
|
||||
<div id="s5-l-tri" class="abs tri"></div>
|
||||
<div id="s5-rule" class="abs"></div>
|
||||
<div id="s5-tag" class="abs mono nowrap">/graph <objective><span id="s5-cursor"></span></div>
|
||||
<div id="s5-tag2" class="abs nowrap">Kimi Code CLI · <span class="zh">社区构建版</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- transitions -->
|
||||
<div id="wipes" class="clip layer" data-start="0" data-duration="34" data-track-index="3">
|
||||
<div id="w6" class="wipe" style="background:#D42A1E;width:1080px"></div>
|
||||
<div id="w10" class="wipe" style="background:#F2B300"></div>
|
||||
<div id="w18" class="wipe" style="background:#1D4FA0;border-radius:50%"></div>
|
||||
<div id="w24" class="wipe" style="background:#D42A1E"></div>
|
||||
<div id="w30" class="wipe" style="background:#141414"></div>
|
||||
</div>
|
||||
|
||||
<!-- captions band -->
|
||||
<div id="capband" class="clip layer" data-start="0" data-duration="34" data-track-index="4">
|
||||
<div id="band"></div>
|
||||
<div id="band-rule" class="abs"></div>
|
||||
<div id="band-mark" class="abs"></div>
|
||||
</div>
|
||||
<div id="cap0" class="clip cap" data-start="1.44" data-duration="3.4" data-track-index="5"><div class="cap-zh" id="cap0-zh">一个智能体,扛不动一个庞大的目标。</div><div class="cap-en" id="cap0-en">One agent can't carry one huge goal.</div></div>
|
||||
<div id="cap1" class="clip cap" data-start="6.44" data-duration="2.26" data-track-index="5"><div class="cap-zh" id="cap1-zh">Kigi 的答案:一张图。</div><div class="cap-en" id="cap1-en">Kigi's answer: a graph.</div></div>
|
||||
<div id="cap2" class="clip cap" data-start="10.44" data-duration="2.42" data-track-index="5"><div class="cap-zh" id="cap2-zh">智能体的循环,活在节点内部;</div><div class="cap-en" id="cap2-en">The agentic loop lives inside each node;</div></div>
|
||||
<div id="cap3" class="clip cap" data-start="12.88" data-duration="3.52" data-track-index="5"><div class="cap-zh" id="cap3-zh">节点之间的边,是确定性的 Rust。</div><div class="cap-en" id="cap3-en">the edges between them are deterministic Rust.</div></div>
|
||||
<div id="cap4" class="clip cap" data-start="18.19" data-duration="3.03" data-track-index="5"><div class="cap-zh" id="cap4-zh">就绪的节点三路并行,各占一棵工作树,</div><div class="cap-en" id="cap4-en">Ready nodes run three at a time, each in its own worktree,</div></div>
|
||||
<div id="cap5" class="clip cap" data-start="21.24" data-duration="2.87" data-track-index="5"><div class="cap-zh" id="cap5-zh">由怀疑论者逐一审查。</div><div class="cap-en" id="cap5-en">each checked by a skeptic.</div></div>
|
||||
<div id="cap6" class="clip cap" data-start="24.44" data-duration="3.03" data-track-index="5"><div class="cap-zh" id="cap6-zh">最后,gn-final 重新验收整个目标。</div><div class="cap-en" id="cap6-en">Finally, gn-final re-verifies the whole objective.</div></div>
|
||||
<div id="cap7" class="clip cap" data-start="27.49" data-duration="2.46" data-track-index="5"><div class="cap-zh" id="cap7-zh">确定性的骨架,智能的血肉。</div><div class="cap-en" id="cap7-en">Deterministic bones. Intelligent flesh.</div></div>
|
||||
<div id="cap8" class="clip cap" data-start="29.97" data-duration="3.03" data-track-index="5"><div class="cap-zh" id="cap8-zh">Kigi /graph。</div><div class="cap-en" id="cap8-en">Kigi /graph.</div></div>
|
||||
|
||||
<!-- HUD -->
|
||||
<div id="hud" class="clip" data-start="0" data-duration="34" data-track-index="6">
|
||||
<div id="hud-0" class="abs mono nowrap hud-l">KIGI <span>/GRAPH</span> — <span>01</span>/05 · HOOK</div>
|
||||
<div id="hud-1" class="abs mono nowrap hud-l">KIGI <span>/GRAPH</span> — <span>02</span>/05 · TITLE</div>
|
||||
<div id="hud-2" class="abs mono nowrap hud-l">KIGI <span>/GRAPH</span> — <span>03</span>/05 · PRINCIPLE</div>
|
||||
<div id="hud-3" class="abs mono nowrap hud-l">KIGI <span>/GRAPH</span> — <span>04</span>/05 · FAN-OUT</div>
|
||||
<div id="hud-4" class="abs mono nowrap hud-l">KIGI <span>/GRAPH</span> — <span>05</span>/05 · VERIFY</div>
|
||||
<div class="beat" id="bt0" style="left:1806px"></div><div class="beat" id="bt1" style="left:1828px"></div>
|
||||
<div class="beat" id="bt2" style="left:1850px"></div><div class="beat" id="bt3" style="left:1872px"></div>
|
||||
</div>
|
||||
|
||||
<audio id="mix" src="audio/mix.wav" data-start="0" data-duration="34" data-track-index="9" data-volume="1"></audio>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const mk = (parent, cls, css, html) => { const d = document.createElement("div"); d.className = cls; Object.assign(d.style, css); if (html) d.innerHTML = html; parent.appendChild(d); return d; };
|
||||
const SVGNS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/* ---------- grid (16×9 modules of 120px) ---------- */
|
||||
const grid = $("#grid");
|
||||
for (let i = 1; i < 16; i++) { const l = mk(grid, "gl glv", { left: i * 120 + "px" }); tl.fromTo(l, { scaleY: 0 }, { scaleY: 1, duration: 0.5, ease: "expo.out" }, (i % 4) * 0.125); }
|
||||
for (let j = 1; j < 9; j++) { const l = mk(grid, "gl glh", { top: j * 120 + "px" }); tl.fromTo(l, { scaleX: 0 }, { scaleX: 1, duration: 0.5, ease: "expo.out" }, (j % 4) * 0.125); }
|
||||
|
||||
/* ---------- HUD: scene index + beat meter ---------- */
|
||||
const sceneT = [0, 6, 10, 18, 24];
|
||||
sceneT.forEach((t, i) => { if (i > 0) tl.fromTo("#hud-" + i, { opacity: 0 }, { opacity: 1, duration: 0.01 }, t); if (i < 4) tl.to("#hud-" + i, { opacity: 0, duration: 0.01 }, sceneT[i + 1]); });
|
||||
for (let b = 0; b < 68; b++) {
|
||||
const t = b * 0.5, k = b % 4;
|
||||
tl.set("#bt" + k, { backgroundColor: "#D42A1E" }, t);
|
||||
tl.set("#bt" + k, { backgroundColor: "rgba(0,0,0,0)" }, t + 0.5);
|
||||
}
|
||||
tl.set([".hud-l", ".beat"], { color: "#EEE6D6", borderColor: "#EEE6D6" }, 18);
|
||||
tl.set([".hud-l", ".beat"], { color: "#141414", borderColor: "#141414" }, 24);
|
||||
|
||||
/* ---------- S1 · HOOK (0–6) ---------- */
|
||||
tl.fromTo("#s1-floor", { scaleX: 0 }, { scaleX: 1, duration: 0.5, ease: "expo.out" }, 0);
|
||||
tl.fromTo("#s1-goal", { y: -900 }, { y: 0, duration: 0.25, ease: "power3.in" }, 0.25);
|
||||
tl.fromTo("#s1-goal", { scaleY: 1 }, { keyframes: [{ scaleY: 0.9, duration: 0.06 }, { scaleY: 1, duration: 0.44, ease: "elastic.out(1,0.4)" }], transformOrigin: "50% 100%" }, 0.5);
|
||||
tl.fromTo("#s1-agent", { scale: 0 }, { scale: 1, duration: 0.5, ease: "back.out(2)" }, 1.0);
|
||||
tl.fromTo("#s1-agent-lbl", { opacity: 0, x: -30 }, { opacity: 1, x: 0, duration: 0.25, ease: "expo.out" }, 1.0);
|
||||
tl.to("#s1-agent-lbl", { opacity: 0, duration: 0.25 }, 3.5);
|
||||
// push, push — the goal doesn't move
|
||||
tl.to("#s1-agent", { x: 720, rotation: 360, duration: 0.5, ease: "power2.in" }, 1.5);
|
||||
tl.to("#s1-agent-dot", { keyframes: [{ scaleX: 0.7, scaleY: 1.2, duration: 0.08 }, { scaleX: 1, scaleY: 1, duration: 0.3, ease: "elastic.out(1,0.3)" }], transformOrigin: "100% 50%" }, 2.0);
|
||||
tl.to("#s1-goal", { keyframes: [{ x: 14, duration: 0.05 }, { x: 0, duration: 0.2, ease: "power2.out" }] }, 2.0);
|
||||
tl.to("#s1-agent", { x: 480, rotation: 120, duration: 0.5, ease: "power2.out" }, 2.0);
|
||||
tl.to("#s1-agent", { x: 720, rotation: 360, duration: 0.5, ease: "power2.in" }, 2.5);
|
||||
tl.to("#s1-agent-dot", { keyframes: [{ scaleX: 0.7, scaleY: 1.2, duration: 0.08 }, { scaleX: 1, scaleY: 1, duration: 0.3, ease: "elastic.out(1,0.3)" }], transformOrigin: "100% 50%" }, 3.0);
|
||||
tl.to("#s1-goal", { keyframes: [{ x: 14, duration: 0.05 }, { x: 0, duration: 0.2, ease: "power2.out" }] }, 3.0);
|
||||
tl.to("#s1-agent", { x: 540, rotation: 200, duration: 0.5, ease: "power2.out" }, 3.0);
|
||||
// lost: the agent wanders off the grid, leaving a scribble
|
||||
const path = $("#s1-path"), L = path.getTotalLength();
|
||||
gsap.set(path, { strokeDasharray: L, strokeDashoffset: L });
|
||||
tl.fromTo(path, { opacity: 0 }, { opacity: 1, duration: 0.01 }, 3.5);
|
||||
tl.fromTo(path, { strokeDashoffset: L }, { strokeDashoffset: 0, duration: 1.5, ease: "none" }, 3.5);
|
||||
const p0 = path.getPointAtLength(0), kf = [];
|
||||
for (let i = 1; i <= 48; i++) { const p = path.getPointAtLength((L * i) / 48); kf.push({ x: p.x - p0.x, y: p.y - p0.y, duration: 1.5 / 48, ease: "none" }); }
|
||||
tl.fromTo("#s1-agent-in", { x: 0, y: 0 }, { keyframes: kf, immediateRender: false }, 3.5);
|
||||
tl.to("#s1-agent-dot", { backgroundColor: "#BDB5A6", scale: 0.6, duration: 0.5, ease: "power2.inOut" }, 4.5);
|
||||
tl.to(path, { stroke: "#BDB5A6", duration: 0.5 }, 5.0);
|
||||
// the goal decomposes into nodes
|
||||
tl.to("#s1-goal-txt", { opacity: 0, duration: 0.25 }, 5.0);
|
||||
tl.to(".s1-cell", { backgroundColor: "#D42A1E", scale: 0.6, duration: 0.25, ease: "back.out(2)", stagger: 0.0625 }, 5.0);
|
||||
|
||||
/* ---------- S2 · TITLE (6–10) ---------- */
|
||||
tl.fromTo("#w6", { x: -1080 }, { x: 0, duration: 0.25, ease: "power3.in" }, 5.75);
|
||||
tl.set("#w6", { opacity: 0 }, 6.0);
|
||||
tl.set("#w6", { opacity: 1 }, 0);
|
||||
["6.5", "6.75", "7.0", "7.25"].forEach((t, i) =>
|
||||
tl.fromTo(`#s2-kigi span:nth-child(${i + 1})`, { opacity: 0, scale: 1.5, y: -40 }, { opacity: 1, scale: 1, y: 0, duration: 0.125, ease: "power4.in" }, +t));
|
||||
tl.fromTo("#s2-slash", { width: 0 }, { width: 560, duration: 0.5, ease: "steps(6)" }, 7.5);
|
||||
tl.fromTo("#s2-cursor", { x: 0, opacity: 0 }, { x: 560, opacity: 1, duration: 0.5, ease: "steps(6)" }, 7.5);
|
||||
tl.to("#s2-cursor", { opacity: 0, duration: 0.01, repeat: 7, yoyo: true, repeatDelay: 0.24 }, 8.0);
|
||||
tl.fromTo("#s2-tu", { y: -900 }, { y: 0, duration: 0.25, ease: "power4.in" }, 7.75);
|
||||
tl.to("#s2-tu", { keyframes: [{ scaleY: 0.92, duration: 0.05 }, { scaleY: 1, duration: 0.4, ease: "elastic.out(1,0.35)" }], transformOrigin: "50% 100%" }, 8.0);
|
||||
tl.fromTo("#s2-circle", { scale: 0 }, { scale: 1, duration: 0.5, ease: "back.out(1.8)" }, 8.5);
|
||||
tl.fromTo("#s2-tri", { scaleY: 0 }, { scaleY: 1, duration: 0.25, ease: "expo.out" }, 9.0);
|
||||
tl.fromTo("#s2-sub", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(8)" }, 9.0);
|
||||
tl.to("#s2-circle", { x: 120, duration: 0.5, ease: "power2.inOut" }, 9.5);
|
||||
|
||||
/* ---------- transition 10 (yellow, left→right) ---------- */
|
||||
const wipe = (id, t, from, mid, to) => {
|
||||
tl.fromTo(id, from, { ...mid, duration: 0.25, ease: "power3.in", immediateRender: true }, t - 0.25);
|
||||
tl.to(id, { ...to, duration: 0.25, ease: "power3.out" }, t);
|
||||
};
|
||||
wipe("#w10", 10, { x: -1920 }, { x: 0 }, { x: 1920 });
|
||||
|
||||
/* ---------- S3 · PRINCIPLE (10–18) ---------- */
|
||||
[1, 2, 3].forEach((n, i) => {
|
||||
tl.fromTo("#s3-n" + n, { y: -480 }, { y: 0, duration: 0.25, ease: "power3.in" }, 10 + i * 0.25);
|
||||
tl.fromTo("#s3-o" + n, { rotation: i * 120 }, { rotation: i * 120 + 360 * 4, duration: 8, ease: "none" }, 10);
|
||||
});
|
||||
// zoom into the middle node: the loop lives inside
|
||||
tl.fromTo("#s3-cam", { scale: 1 }, { scale: 2.6, duration: 0.5, ease: "expo.inOut" }, 11.0);
|
||||
tl.fromTo(["#s3-t1", "#s3-t2", "#s3-t3"], { opacity: 0 }, { opacity: 1, duration: 0.125, stagger: 0.25 }, 11.5);
|
||||
tl.fromTo("#s3-t4", { opacity: 0 }, { opacity: 1, duration: 0.25 }, 12.0);
|
||||
tl.to("#s3-cam", { scale: 1, duration: 0.5, ease: "expo.inOut" }, 12.5);
|
||||
tl.to(["#s3-t1", "#s3-t2", "#s3-t3", "#s3-t4"], { opacity: 0, duration: 0.25 }, 12.75);
|
||||
tl.fromTo("#s3-big1", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(12)" }, 12.0);
|
||||
// edges: ruler-straight, stepped — deterministic
|
||||
tl.fromTo(["#s3-e1", "#s3-e2"], { scaleX: 0 }, { scaleX: 1, duration: 1.0, ease: "steps(12)", stagger: 0.5 }, 13.0);
|
||||
tl.fromTo(["#s3-r1", "#s3-r2"], { scaleX: 0 }, { scaleX: 1, duration: 1.0, ease: "steps(12)", stagger: 0.5 }, 13.0);
|
||||
tl.fromTo(["#s3-a1", "#s3-a2"], { scale: 0 }, { scale: 1, duration: 0.25, ease: "back.out(3)", stagger: 0.5 }, 14.0);
|
||||
tl.fromTo(["#s3-l1", "#s3-l2"], { opacity: 0, y: 12 }, { opacity: 1, y: 0, duration: 0.25, ease: "expo.out", stagger: 0.5 }, 14.0);
|
||||
tl.fromTo("#s3-big2", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(12)" }, 14.5);
|
||||
// legend: Kandinsky's triad
|
||||
[1, 2, 3].forEach((n, i) => tl.fromTo("#s3-g" + n, { x: 80, opacity: 0 }, { x: 0, opacity: 1, duration: 0.25, ease: "expo.out" }, 16 + i * 0.5));
|
||||
tl.fromTo("#s3-foot", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(16)" }, 17.0);
|
||||
|
||||
/* ---------- transition 18 (blue circle iris) ---------- */
|
||||
tl.fromTo("#w18", { scale: 0, x: 0 }, { scale: 2.4, duration: 0.25, ease: "power3.in", immediateRender: true }, 17.75);
|
||||
tl.to("#w18", { scale: 0, duration: 0.25, ease: "power3.out" }, 18.0);
|
||||
|
||||
/* ---------- S4 · FAN-OUT (18–24) ---------- */
|
||||
tl.fromTo("#s4-head", { width: 0 }, { width: 900, duration: 1.0, ease: "steps(36)" }, 18.0);
|
||||
tl.fromTo("#s4-x3", { scale: 0 }, { scale: 1, duration: 0.25, ease: "back.out(2.2)" }, 18.5);
|
||||
const lanesEl = $("#s4-lanes");
|
||||
const lanes = [{ y: 300, id: "gn-386aaa87", name: "api" }, { y: 500, id: "gn-0d903d89", name: "cli" }, { y: 700, id: "gn-527d0336", name: "schema" }];
|
||||
lanes.forEach((ln, i) => {
|
||||
const off = i * 0.125;
|
||||
const lane = mk(lanesEl, "s4-lane", { top: ln.y + "px" });
|
||||
const lbl = mk(lanesEl, "s4-lbl mono nowrap", { top: ln.y - 90 + "px" }, `worktree/${ln.id} · ${ln.name}`);
|
||||
const node = mk(lanesEl, "s4-node sq", { left: "360px", top: ln.y - 48 + "px" });
|
||||
const w = mk(lanesEl, "s4-w ci", { left: "372px", top: ln.y - 36 + "px" });
|
||||
const tri = mk(lanesEl, "s4-tri tri", { top: ln.y - 84 + "px" });
|
||||
const chk = mk(lanesEl, "s4-check", { top: ln.y - 66 + "px" }, "✓");
|
||||
const verdict = mk(lanesEl, "s4-verdict mono nowrap", { top: ln.y + 50 + "px", color: "#F2B300" }, "NODE_VERDICT: achieved");
|
||||
const round = mk(lanesEl, "s4-round mono nowrap", { top: ln.y - 14 + "px" }, "1/3");
|
||||
tl.fromTo(lane, { scaleX: 0 }, { scaleX: 1, duration: 0.5, ease: "expo.out" }, 18.0 + off);
|
||||
tl.fromTo(lbl, { opacity: 0 }, { opacity: 0.8, duration: 0.25 }, 18.25 + off);
|
||||
tl.fromTo(node, { x: -240 }, { x: 0, duration: 0.25, ease: "expo.out" }, 18.5);
|
||||
tl.fromTo(tri, { scaleY: 0 }, { scaleY: 1, duration: 0.25, ease: "expo.out" }, 18.75 + off);
|
||||
tl.fromTo(round, { opacity: 0 }, { opacity: 1, duration: 0.25 }, 18.75 + off);
|
||||
tl.fromTo(w, { scale: 0 }, { scale: 1, duration: 0.25, ease: "back.out(2)" }, 18.75);
|
||||
// the worker builds: four steps, a commit dropped on every step
|
||||
const failRound = i === 1;
|
||||
const steps = [0, 1, 2, 3];
|
||||
steps.forEach((s) => {
|
||||
const t = 19.0 + s * 0.5 + off;
|
||||
tl.to(w, { x: 240 * (s + 1), duration: 0.25, ease: "power2.inOut" }, t);
|
||||
const c = mk(lanesEl, "s4-commit", { left: 480 + 240 * s + 24 + "px", top: ln.y - 12 + "px" });
|
||||
tl.fromTo(c, { scale: 0 }, { scale: 1, duration: 0.125, ease: "back.out(3)" }, t + 0.125);
|
||||
});
|
||||
// skeptic reviews
|
||||
if (failRound) {
|
||||
const gaps = mk(lanesEl, "s4-verdict mono nowrap", { top: ln.y + 50 + "px", color: "#D42A1E" }, "NODE_VERDICT: not_achieved");
|
||||
tl.fromTo(tri, { backgroundColor: "#F2B300" }, { backgroundColor: "#D42A1E", duration: 0.01, immediateRender: false }, 21.0);
|
||||
tl.fromTo(gaps, { opacity: 0 }, { opacity: 1, duration: 0.01 }, 21.0);
|
||||
tl.to(gaps, { opacity: 0, duration: 0.01 }, 22.0);
|
||||
tl.to(tri, { backgroundColor: "#F2B300", duration: 0.01 }, 21.5);
|
||||
tl.to(w, { x: 480, duration: 0.25, ease: "power2.inOut" }, 21.25);
|
||||
const round2 = mk(lanesEl, "s4-round mono nowrap", { top: ln.y - 14 + "px", color: "#F2B300" }, "2/3");
|
||||
tl.fromTo(round2, { opacity: 0 }, { opacity: 1, duration: 0.01 }, 21.5);
|
||||
tl.to(round, { opacity: 0, duration: 0.01 }, 21.5);
|
||||
tl.to(w, { x: 960, duration: 0.5, ease: "power2.inOut" }, 22.0);
|
||||
tl.to(tri, { keyframes: [{ scale: 1.3, duration: 0.06 }, { scale: 1, duration: 0.2 }] }, 23.0);
|
||||
tl.fromTo(chk, { opacity: 0, scale: 2 }, { opacity: 1, scale: 1, duration: 0.125, ease: "power4.in" }, 23.0);
|
||||
tl.fromTo(verdict, { opacity: 0 }, { opacity: 1, duration: 0.01 }, 23.0);
|
||||
} else {
|
||||
const tv = i === 0 ? 22.0 : 22.5;
|
||||
tl.to(tri, { keyframes: [{ scale: 1.3, duration: 0.06 }, { scale: 1, duration: 0.2 }] }, tv);
|
||||
tl.fromTo(chk, { opacity: 0, scale: 2 }, { opacity: 1, scale: 1, duration: 0.125, ease: "power4.in" }, tv);
|
||||
tl.fromTo(verdict, { opacity: 0 }, { opacity: 1, duration: 0.01 }, tv);
|
||||
}
|
||||
// sequential merge-back
|
||||
tl.to(node, { x: 1320 - 96, backgroundColor: "#F2B300", duration: 0.25, ease: "expo.inOut" }, 23.5 + i * 0.125);
|
||||
});
|
||||
|
||||
/* ---------- transition 24 (red, top→down) ---------- */
|
||||
wipe("#w24", 24, { y: -1080 }, { y: 0 }, { y: 1080 });
|
||||
|
||||
/* ---------- S5 · gn-final (24–30) ---------- */
|
||||
const svg = $("#s5-svg"), nodesEl = $("#s5-nodes");
|
||||
const N = { core: [960, 100, "gn-dd5e607e"], api: [600, 260, "gn-386aaa87"], cli: [960, 260, "gn-0d903d89"], schema: [1320, 260, "gn-527d0336"], docs: [780, 420, "gn-28eb34d2"], tests: [1140, 420, "gn-5b22cf22"] };
|
||||
const layerT = { core: 24.0, api: 24.5, cli: 24.5, schema: 24.5, docs: 25.0, tests: 25.0 };
|
||||
const path5 = (d, w, t, dur, color) => {
|
||||
const p = document.createElementNS(SVGNS, "path");
|
||||
p.setAttribute("d", d); p.setAttribute("fill", "none"); p.setAttribute("stroke", color || "#141414");
|
||||
p.setAttribute("stroke-width", w); p.setAttribute("stroke-linecap", "square"); svg.appendChild(p);
|
||||
const len = p.getTotalLength(); p.style.strokeDasharray = len;
|
||||
tl.fromTo(p, { strokeDashoffset: len }, { strokeDashoffset: 0, duration: dur, ease: "power2.inOut" }, t);
|
||||
return p;
|
||||
};
|
||||
// structural edges (orthogonal routing via a bus between layers)
|
||||
const E = [["core", "api"], ["core", "cli"], ["core", "schema"], ["api", "docs"], ["cli", "docs"], ["cli", "tests"], ["schema", "tests"]];
|
||||
E.forEach(([a, b], k) => {
|
||||
const [ax, ay] = N[a], [bx, by] = N[b], busY = ay + 96 + (by - ay - 96) / 2;
|
||||
path5(`M${ax} ${ay + 96} V${busY} H${bx} V${by}`, 6, (layerT[b]) - 0.25, 0.5);
|
||||
});
|
||||
// every node → gn-final
|
||||
Object.entries(N).forEach(([k, [x, y]], i) => {
|
||||
const busY = 530 - 0;
|
||||
path5(`M${x + (k === "core" || k === "cli" ? 30 : 0)} ${y + 96} V${busY} H960 V560`, 3, 26.0, 0.5, "#141414");
|
||||
});
|
||||
Object.entries(N).forEach(([k, [x, y, id]]) => {
|
||||
const n = mk(nodesEl, "s5-node sq", { left: x - 48 + "px", top: y + "px" }, `<div class="s5-name">${k}</div>`);
|
||||
const lab = mk(nodesEl, "s5-id mono nowrap", { left: x + 52 + "px", top: y + 4 + "px", width: "auto", textAlign: "left" }, id);
|
||||
const flesh = mk(n, "s5-flesh ci", {});
|
||||
tl.fromTo(n, { scale: 0 }, { scale: 1, duration: 0.25, ease: "back.out(2.2)" }, layerT[k]);
|
||||
tl.fromTo(lab, { opacity: 0 }, { opacity: 1, duration: 0.125 }, layerT[k] + 0.25);
|
||||
// bones & flesh: nodes become outlined bones, then fill with agents
|
||||
tl.to(n, { backgroundColor: "rgba(0,0,0,0)", boxShadow: "inset 0 0 0 6px #141414", duration: 0.01 }, 27.5);
|
||||
tl.to(n.querySelector(".s5-name"), { color: "#141414", opacity: 0, duration: 0.01 }, 27.5);
|
||||
tl.fromTo(flesh, { scale: 0 }, { scale: 1, duration: 0.25, ease: "back.out(2.4)" }, 28.5 + Object.keys(N).indexOf(k) * 0.125);
|
||||
tl.to(lab, { opacity: 0, duration: 0.01 }, 27.5);
|
||||
});
|
||||
tl.fromTo("#s5-final", { scaleY: 0 }, { scaleY: 1, duration: 0.5, ease: "expo.out" }, 26.5);
|
||||
tl.fromTo("#s5-final-lbl", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(10)" }, 26.5);
|
||||
// the verdict: yellow floods out of the triangle
|
||||
tl.fromTo("#s5-yellow", { clipPath: "circle(0% at 960px 690px)" }, { clipPath: "circle(150% at 960px 690px)", duration: 0.5, ease: "expo.out" }, 27.5);
|
||||
tl.to("#s5-final", { backgroundColor: "#141414", duration: 0.01 }, 27.5);
|
||||
tl.to("#s5-final-lbl", { opacity: 0, duration: 0.01 }, 27.5);
|
||||
tl.fromTo("#s5-bones", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(19)" }, 27.75);
|
||||
tl.fromTo("#s5-zh1", { clipPath: "inset(0 0 100% 0)" }, { clipPath: "inset(0 0 0% 0)", duration: 0.5, ease: "steps(6)" }, 28.0);
|
||||
tl.fromTo("#s5-flesh-t", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(17)" }, 28.75);
|
||||
tl.fromTo("#s5-zh2", { clipPath: "inset(0 0 100% 0)" }, { clipPath: "inset(0 0 0% 0)", duration: 0.5, ease: "steps(5)" }, 29.0);
|
||||
|
||||
/* ---------- transition 30 (ink, right→left) + LOGO (30–34) ---------- */
|
||||
wipe("#w30", 30, { x: 1920 }, { x: 0 }, { x: -1920 });
|
||||
tl.fromTo("#s5-logo", { opacity: 0 }, { opacity: 1, duration: 0.01 }, 30.0);
|
||||
["30.25", "30.375", "30.5", "30.625"].forEach((t, i) =>
|
||||
tl.fromTo(`#s5-kigi span:nth-child(${i + 1})`, { y: -500, opacity: 0 }, { y: 0, opacity: 1, duration: 0.125, ease: "power4.in" }, +t));
|
||||
tl.fromTo("#s5-graph", { width: 0 }, { width: 500, duration: 0.5, ease: "steps(6)" }, 30.5);
|
||||
tl.fromTo("#s5-l-sq", { y: -700 }, { y: 0, duration: 0.25, ease: "power4.in" }, 30.75);
|
||||
tl.fromTo("#s5-l-ci", { scale: 0 }, { scale: 1, duration: 0.5, ease: "back.out(2)" }, 31.25);
|
||||
tl.fromTo("#s5-l-tri", { scaleY: 0 }, { scaleY: 1, duration: 0.25, ease: "expo.out" }, 32.0);
|
||||
tl.fromTo("#s5-rule", { scaleX: 0 }, { scaleX: 1, duration: 0.5, ease: "expo.out" }, 32.0);
|
||||
tl.fromTo("#s5-tag", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(19)" }, 32.25);
|
||||
tl.fromTo("#s5-tag2", { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.25, ease: "expo.out" }, 32.75);
|
||||
tl.to("#s5-cursor", { opacity: 0, duration: 0.01, repeat: 3, yoyo: true, repeatDelay: 0.49 }, 32.75);
|
||||
// final downbeat: the three shapes nudge together on 33.0
|
||||
tl.to(["#s5-l-sq", "#s5-l-ci", "#s5-l-tri"], { y: -20, duration: 0.125, ease: "power2.out", yoyo: true, repeat: 1 }, 33.0);
|
||||
|
||||
/* ---------- caption band ---------- */
|
||||
tl.fromTo("#band-rule", { scaleX: 0 }, { scaleX: 1, duration: 1.0, ease: "expo.out" }, 0.5);
|
||||
tl.fromTo("#band-mark", { scale: 0 }, { scale: 1, duration: 0.25, ease: "back.out(2)" }, 1.5);
|
||||
tl.fromTo("#cap0-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 1.44);
|
||||
tl.fromTo("#cap0-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 1.565);
|
||||
tl.fromTo("#cap1-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 6.44);
|
||||
tl.fromTo("#cap1-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 6.565);
|
||||
tl.fromTo("#cap2-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 10.44);
|
||||
tl.fromTo("#cap2-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 10.565);
|
||||
tl.fromTo("#cap3-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 12.88);
|
||||
tl.fromTo("#cap3-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 13.005);
|
||||
tl.fromTo("#cap4-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 18.19);
|
||||
tl.fromTo("#cap4-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 18.315);
|
||||
tl.fromTo("#cap5-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 21.24);
|
||||
tl.fromTo("#cap5-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 21.365);
|
||||
tl.fromTo("#cap6-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 24.44);
|
||||
tl.fromTo("#cap6-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 24.565);
|
||||
tl.fromTo("#cap7-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 27.49);
|
||||
tl.fromTo("#cap7-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 27.615);
|
||||
tl.fromTo("#cap8-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 29.97);
|
||||
tl.fromTo("#cap8-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 30.095);
|
||||
|
||||
window.__timelines["main"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id": "kigi-graph-bauhaus",
|
||||
"name": "kigi-graph-bauhaus",
|
||||
"createdAt": "2026-10-04T17:49:43.118Z"
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Procedural 120 BPM score for the 34 s cut: every hit is placed on the same beat grid the picture uses."""
|
||||
import json, subprocess, numpy as np, soundfile as sf
|
||||
from scipy.signal import fftconvolve, butter, sosfilt
|
||||
|
||||
FFMPEG = r"C:\Users\zhang\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0.2-full_build\bin\ffmpeg.exe"
|
||||
SR, TOTAL, BEAT = 48000, 34.0, 0.5
|
||||
N = int(SR * TOTAL)
|
||||
rng = np.random.default_rng(7)
|
||||
bus = {k: np.zeros(N) for k in ["drums", "music", "fx"]}
|
||||
|
||||
|
||||
def env(n, decay, attack=0.002):
|
||||
t = np.arange(n) / SR
|
||||
a = np.minimum(1, t / attack) if attack > 0 else 1
|
||||
return a * np.exp(-t / decay)
|
||||
|
||||
|
||||
def put(b, t, sig, gain=1.0):
|
||||
i = int(round(t * SR))
|
||||
if i >= N: return
|
||||
sig = sig[: N - i]
|
||||
bus[b][i : i + len(sig)] += sig * gain
|
||||
|
||||
|
||||
def hz(m): return 440 * 2 ** ((m - 69) / 12)
|
||||
|
||||
|
||||
def lp(x, f, order=2): return sosfilt(butter(order, f, "low", fs=SR, output="sos"), x)
|
||||
def hp(x, f, order=2): return sosfilt(butter(order, f, "high", fs=SR, output="sos"), x)
|
||||
def bp(x, lo, hi): return sosfilt(butter(2, [lo, hi], "band", fs=SR, output="sos"), x)
|
||||
|
||||
|
||||
# ---------- instruments ----------
|
||||
def kick(g=1.0):
|
||||
n = int(0.45 * SR); t = np.arange(n) / SR
|
||||
f = 45 + 110 * np.exp(-t / 0.03)
|
||||
s = np.sin(2 * np.pi * np.cumsum(f) / SR) * env(n, 0.22)
|
||||
s += hp(rng.standard_normal(n), 2000) * env(n, 0.004) * 0.4
|
||||
return np.tanh(s * 1.6) * g
|
||||
|
||||
def hat(open_=False):
|
||||
n = int((0.25 if open_ else 0.06) * SR)
|
||||
return hp(rng.standard_normal(n), 7000, 4) * env(n, 0.09 if open_ else 0.018) * 0.35
|
||||
|
||||
def clap():
|
||||
n = int(0.3 * SR); s = bp(rng.standard_normal(n), 900, 3000)
|
||||
e = sum(env(n, 0.008) * (np.arange(n) >= int(k * SR)) for k in (0, 0.011, 0.022)) + env(n, 0.09) * 0.6
|
||||
return s * e * 0.55
|
||||
|
||||
def tick():
|
||||
n = int(0.03 * SR); return hp(rng.standard_normal(n), 4000) * env(n, 0.004) * 0.5
|
||||
|
||||
def marimba(m, dur=0.35, g=0.3):
|
||||
n = int(dur * 2 * SR); t = np.arange(n) / SR; f = hz(m)
|
||||
s = np.sin(2*np.pi*f*t)*env(n, dur*0.6) + 0.35*np.sin(2*np.pi*f*3.93*t)*env(n, dur*0.12) + 0.1*np.sin(2*np.pi*f*9.2*t)*env(n, 0.02)
|
||||
return s * g
|
||||
|
||||
def bass(m, dur=0.24, g=0.5):
|
||||
n = int(dur * SR); t = np.arange(n) / SR; f = hz(m)
|
||||
saw = 2 * ((f * t) % 1) - 1
|
||||
s = lp(saw, 500) + 0.6 * np.sin(2 * np.pi * f * t)
|
||||
e = env(n, dur * 0.8, 0.004) * np.minimum(1, (n - np.arange(n)) / (0.01 * SR))
|
||||
return np.tanh(s * e * 1.4) * g
|
||||
|
||||
def pad(ms, dur, g=0.11):
|
||||
n = int(dur * SR); t = np.arange(n) / SR; s = np.zeros(n)
|
||||
for m in ms:
|
||||
for d in (-0.08, 0.0, 0.08):
|
||||
f = hz(m + d); s += 2 * ((f * t + rng.random()) % 1) - 1
|
||||
s = lp(s, 1400)
|
||||
a = np.minimum(1, t / 0.4) * np.minimum(1, (dur - t) / 0.5)
|
||||
return s * a * g / len(ms)
|
||||
|
||||
def impact(g=1.0):
|
||||
n = int(2.5 * SR); t = np.arange(n) / SR
|
||||
boom = np.sin(2 * np.pi * (38 + 40 * np.exp(-t / 0.08)) * t) * env(n, 0.7)
|
||||
crash = hp(rng.standard_normal(n), 3000) * env(n, 0.9) * 0.25
|
||||
return (np.tanh(boom * 1.4) * 0.9 + crash) * g
|
||||
|
||||
def riser(dur=1.0, g=0.35):
|
||||
n = int(dur * SR); t = np.arange(n) / SR
|
||||
x = rng.standard_normal(n); out = np.zeros(n); seg = int(0.05 * SR)
|
||||
for i in range(0, n, seg): # stepped filter sweep
|
||||
f = 400 + 7000 * (i / n) ** 2
|
||||
out[i:i+seg] = bp(x[i:i+seg], f, min(f * 1.6, 20000))
|
||||
return out * (t / dur) ** 2 * g
|
||||
|
||||
def whoosh(g=0.5):
|
||||
n = int(0.35 * SR); t = np.arange(n) / SR
|
||||
s = bp(rng.standard_normal(n), 600, 6000)
|
||||
return s * np.sin(np.pi * t / t[-1]) ** 2 * g
|
||||
|
||||
def stamp(g=0.8, pitch=60):
|
||||
n = int(0.4 * SR); t = np.arange(n) / SR
|
||||
s = np.sin(2 * np.pi * (hz(pitch - 24) + 80 * np.exp(-t / 0.02)) * t) * env(n, 0.12)
|
||||
s += bp(rng.standard_normal(n), 1500, 5000) * env(n, 0.01) * 0.6
|
||||
return np.tanh(s * 2) * g
|
||||
|
||||
def buzz(g=0.35):
|
||||
n = int(0.45 * SR); t = np.arange(n) / SR
|
||||
s = np.sign(np.sin(2 * np.pi * 110 * t)) + np.sign(np.sin(2 * np.pi * 116.5 * t))
|
||||
return lp(s, 1800) * env(n, 0.18) * g
|
||||
|
||||
|
||||
# ---------- arrangement (A minor; bar = 2 s) ----------
|
||||
CHORDS = {0: (57, [57, 60, 64]), 1: (53, [53, 57, 60]), 2: (48, [52, 55, 60]), 3: (55, [55, 59, 62])} # Am F C G
|
||||
def chord(bar): return CHORDS[bar % 4]
|
||||
def on(t0, t1, step):
|
||||
return [round(x, 4) for x in np.arange(t0, t1 - 1e-9, step)]
|
||||
|
||||
# intro: metronome eighths 0-2, kick at 0.5 (square drop)
|
||||
for t in on(0, 2, 0.25): put("drums", t, tick(), 0.6 if (t * 4) % 2 == 0 else 0.3)
|
||||
put("fx", 0.5, impact(0.7)); put("drums", 0.5, kick(1.0))
|
||||
|
||||
def groove(t0, t1, hats=0.5, claps=True, kicks=True, hat16=False):
|
||||
for t in on(t0, t1, BEAT):
|
||||
if kicks: put("drums", t, kick())
|
||||
if claps and round((t / BEAT)) % 2 == 1: put("drums", t, clap())
|
||||
for t in on(t0, t1, 0.125 if hat16 else 0.25):
|
||||
off = abs((t / 0.5) % 1 - 0.5) < 1e-6
|
||||
put("drums", t, hat(open_=off and not hat16), hats * (1.0 if off else 0.6))
|
||||
|
||||
def ostinato(t0, t1, octave=12, g=0.26, step=0.125, cycle=3):
|
||||
for k, t in enumerate(on(t0, t1, step)):
|
||||
root, tri = chord(int(t // 2)); note = tri[[0, 1, 2, 1, 2][k % cycle] if cycle == 5 else k % 3]
|
||||
put("music", t, marimba(note + octave, 0.3, g * (1.0 if k % 4 == 0 else 0.7)))
|
||||
|
||||
def bassline(t0, t1, g=0.5):
|
||||
for t in on(t0, t1, 0.25):
|
||||
root, _ = chord(int(t // 2)); pos = round((t % 0.5) / 0.25)
|
||||
if pos == 1 or (t % 2) < 1e-6: put("music", t, bass(root - 12 + (12 if pos == 1 and (t % 1) > 0.5 else 0), 0.22, g))
|
||||
|
||||
def pads(t0, t1):
|
||||
for b in range(int(t0 // 2), int(t1 // 2)):
|
||||
put("music", b * 2.0, pad(chord(b)[1], 2.05))
|
||||
|
||||
# hook 2-6
|
||||
groove(2, 6, hats=0.35, claps=False); ostinato(2, 6, g=0.2); bassline(4, 6, 0.4)
|
||||
put("fx", 5.0, riser(1.0, 0.3))
|
||||
# title 6-10
|
||||
put("fx", 6.0, impact(1.0))
|
||||
groove(6, 10); ostinato(6, 10); bassline(6, 10); pads(6, 10)
|
||||
for t in (6.5, 6.75, 7.0, 7.25): put("fx", t, stamp(0.45, 72))
|
||||
for t in on(7.5, 8.0, 0.0625): put("fx", t, tick(), 0.5)
|
||||
put("fx", 8.0, stamp(0.9, 48))
|
||||
# principle 10-18
|
||||
put("fx", 10.0, impact(0.55))
|
||||
groove(10, 18, hats=0.45); ostinato(10, 18); bassline(10, 18); pads(10, 18)
|
||||
for t in on(13.0, 15.5, 0.25): put("fx", t, tick(), 0.35) # ruler ticks while edges draw
|
||||
for t in (16.0, 16.5, 17.0): put("fx", t, marimba(84, 0.4, 0.35))
|
||||
put("fx", 17.0, riser(1.0, 0.35))
|
||||
# fan-out 18-24 (peak)
|
||||
put("fx", 18.0, impact(0.9))
|
||||
groove(18, 24, hats=0.45, hat16=True); ostinato(18, 24, g=0.24); ostinato(18.125, 24, octave=24, g=0.12, step=0.25, cycle=5)
|
||||
bassline(18, 24, 0.55); pads(18, 24)
|
||||
put("fx", 21.0, buzz())
|
||||
for t in (22.0, 22.5, 23.0): put("fx", t, stamp(0.8, 67))
|
||||
put("fx", 23.0, riser(1.0, 0.35))
|
||||
# gn-final 24-27.5: suspense (no kick), then the verdict hit
|
||||
put("fx", 24.0, impact(0.8))
|
||||
ostinato(24, 27.5, g=0.2); pads(24, 28)
|
||||
for t in on(24, 27.5, 0.5): put("drums", t, hat(), 0.25)
|
||||
for t in (24.0, 24.5, 25.0, 25.5): put("fx", t, marimba(88, 0.25, 0.25))
|
||||
put("fx", 26.5, riser(1.0, 0.4))
|
||||
put("fx", 27.5, impact(1.1)); put("fx", 27.5, stamp(0.9, 52))
|
||||
# bones & flesh 27.5-30: full groove
|
||||
groove(27.5, 30, hats=0.5); ostinato(27.5, 30); bassline(27.5, 30); pads(28, 30)
|
||||
# logo 30-34
|
||||
put("fx", 30.0, impact(0.7)); groove(30, 32, hats=0.4); ostinato(30, 32, g=0.22); bassline(30, 32)
|
||||
for t, m in ((31.0, 76), (31.5, 79), (32.0, 81)): put("fx", t, marimba(m, 0.6, 0.45))
|
||||
put("fx", 32.0, impact(1.0)); put("drums", 32.0, kick(1.1))
|
||||
put("music", 32.0, pad([45, 57, 60, 64, 69], 2.0, 0.22))
|
||||
for m in (57, 60, 64, 69): put("music", 32.0, marimba(m, 1.2, 0.25))
|
||||
# transition whooshes (end on the cut)
|
||||
for t in (6.0, 10.0, 18.0, 24.0, 30.0): put("fx", t - 0.3, whoosh(0.45))
|
||||
|
||||
# ---------- reverb, voice, ducking, master ----------
|
||||
def reverb(x, sec=1.4, wet=0.18):
|
||||
n = int(sec * SR); ir = rng.standard_normal(n) * np.exp(-np.arange(n) / SR / (sec / 5))
|
||||
ir = lp(ir, 5000); ir /= np.sqrt((ir ** 2).sum())
|
||||
return x + fftconvolve(x, ir)[: len(x)] * wet
|
||||
|
||||
music = reverb(bus["music"], wet=0.25) + bus["drums"] * 0.9 + reverb(bus["fx"], wet=0.2)
|
||||
|
||||
vo = np.zeros(N)
|
||||
tl = json.load(open("timeline.json", encoding="utf-8"))["scenes"]
|
||||
for sid, (st, dur, off) in tl.items():
|
||||
raw = subprocess.run([FFMPEG, "-v", "quiet", "-i", f"audio/vo_{sid}.mp3", "-f", "f32le", "-ac", "1", "-ar", str(SR), "-"], capture_output=True).stdout
|
||||
v = np.frombuffer(raw, dtype=np.float32).astype(float)
|
||||
i = int((st + off) * SR); vo[i : i + len(v)] += v[: N - i]
|
||||
vo = hp(vo, 70) / (np.abs(vo).max() + 1e-9) * 0.85
|
||||
|
||||
# sidechain: duck music ~7 dB under narration, 30 ms attack, 250 ms release
|
||||
lvl = np.abs(vo); k = int(0.02 * SR)
|
||||
lvl = np.convolve(lvl, np.ones(k) / k, mode="same")
|
||||
gate = (lvl > 0.02).astype(float)
|
||||
duck = np.zeros(N); a, r = np.exp(-1 / (0.03 * SR)), np.exp(-1 / (0.25 * SR)); g = 0.0
|
||||
for i in range(0, N, 64): # block-rate envelope follower
|
||||
target = gate[i]; c = a ** 64 if target > g else r ** 64
|
||||
g = target + (g - target) * c; duck[i : i + 64] = g
|
||||
music *= 1 - 0.55 * duck
|
||||
music = music / (np.abs(music).max() + 1e-9) * 0.62
|
||||
|
||||
mix = np.tanh((music + vo) * 1.1)
|
||||
fade = np.ones(N); fl = int(0.6 * SR); fade[-fl:] = np.linspace(1, 0, fl)
|
||||
mix *= fade
|
||||
mix = mix / np.abs(mix).max() * 0.94
|
||||
stereo = np.stack([mix, mix], 1)
|
||||
sf.write("audio/mix.wav", stereo, SR, subtype="PCM_16")
|
||||
sf.write("audio/score.wav", np.stack([music, music], 1) * 0.9, SR, subtype="PCM_16")
|
||||
print("ok", stereo.shape, "peak", np.abs(mix).max())
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "kigi-graph-bauhaus",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx --yes hyperframes@0.8.124 preview",
|
||||
"check": "npx --yes hyperframes@0.8.124 check",
|
||||
"render": "npx --yes hyperframes@0.8.124 render",
|
||||
"publish": "npx --yes hyperframes@0.8.124 publish"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"id":"s1","segs":[{"zh":"一个智能体,扛不动一个庞大的目标。","en":"One agent can't carry one huge goal."}]},
|
||||
{"id":"s2","segs":[{"zh":"Kigi 的答案:一张图。","en":"Kigi's answer: a graph."}]},
|
||||
{"id":"s3","segs":[{"zh":"智能体的循环,活在节点内部;","en":"The agentic loop lives inside each node;"},{"zh":"节点之间的边,是确定性的 Rust。","en":"the edges between them are deterministic Rust."}]},
|
||||
{"id":"s4","segs":[{"zh":"就绪的节点三路并行,各占一棵工作树,","en":"Ready nodes run three at a time, each in its own worktree,"},{"zh":"由怀疑论者逐一审查。","en":"each checked by a skeptic."}]},
|
||||
{"id":"s5","segs":[{"zh":"最后,gn-final 重新验收整个目标。","tts":"最后,G N final 重新验收整个目标。","en":"Finally, gn-final re-verifies the whole objective."},{"zh":"确定性的骨架,智能的血肉。","en":"Deterministic bones. Intelligent flesh."},{"zh":"Kigi /graph。","tts":"Kigi,斜杠 graph。","en":"Kigi /graph."}]}
|
||||
]
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user