mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3230516ad | ||
|
|
0280d849e9 | ||
|
|
8355b4f23c | ||
|
|
b78f75b06c | ||
|
|
188d6507c2 | ||
|
|
c3718ac3a4 |
No files matched your search
@@ -1,53 +0,0 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
|
||||
language: "en-US"
|
||||
tone_instructions: "Be direct and prioritize actionable correctness, safety, CLI UX, and test coverage issues over style nits."
|
||||
|
||||
reviews:
|
||||
profile: "chill"
|
||||
# Keep CodeRabbit as a review signal without letting stale bot reviews block merges.
|
||||
request_changes_workflow: true
|
||||
high_level_summary: true
|
||||
poem: false
|
||||
# Avoid noisy "review skipped" status comments when CodeRabbit decides not to run.
|
||||
review_status: false
|
||||
collapse_walkthrough: false
|
||||
path_filters:
|
||||
- "!dist/**"
|
||||
- "!out/**"
|
||||
- "!coverage/**"
|
||||
- "!*.tgz"
|
||||
- "!/zero"
|
||||
- "!/zero.exe"
|
||||
path_instructions:
|
||||
- path: "src/**/*.ts"
|
||||
instructions: |
|
||||
Review changed code as a Bun-first TypeScript terminal coding agent. Prioritize tool safety, permission metadata, provider/config correctness, session/stream event integrity, and avoiding raw execution paths that bypass ToolBase-style controls.
|
||||
- path: "src/tui/**/*.tsx"
|
||||
instructions: |
|
||||
Review Ink terminal UI for responsive layout, keyboard flow, approval prompts, startup cleanliness, and small-terminal behavior. Flag browser UI assumptions, hardcoded terminal dimensions, and clutter that weakens the CLI agent experience.
|
||||
- path: "tests/**/*.ts"
|
||||
instructions: |
|
||||
Review tests for Bun test idioms, deterministic behavior, and meaningful coverage of safety decisions, provider/runtime flows, session events, and TUI command behavior.
|
||||
tools:
|
||||
github-checks:
|
||||
timeout_ms: 300000
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: true
|
||||
auto_incremental_review: true
|
||||
auto_pause_after_reviewed_commits: 10
|
||||
ignore_title_keywords:
|
||||
- "WIP"
|
||||
- "DO NOT MERGE"
|
||||
- "[skip review]"
|
||||
labels:
|
||||
- "!do-not-review"
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
- "renovate[bot]"
|
||||
- "github-actions[bot]"
|
||||
# CodeRabbit reviews default-branch PRs by default. This extends automatic
|
||||
# reviews to PRs targeting any branch, including release and stacked branches.
|
||||
base_branches:
|
||||
- ".*"
|
||||
@@ -1,94 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: Smoke (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Go test
|
||||
run: bun run test:go
|
||||
|
||||
- name: Go build
|
||||
run: bun run build:go
|
||||
|
||||
- name: Go smoke
|
||||
run: bun run smoke:go
|
||||
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Build binary
|
||||
run: bun run build
|
||||
|
||||
- name: Smoke binary
|
||||
run: bun run smoke:build
|
||||
|
||||
performance:
|
||||
name: Performance Smoke
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build binary
|
||||
run: bun run build
|
||||
|
||||
- name: Performance smoke
|
||||
run: bun run scripts/perf-bench.ts --ci --output dist/perf/perf-bench.json
|
||||
|
||||
- name: Upload performance report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: zero-performance-smoke
|
||||
path: dist/perf/perf-bench.json
|
||||
if-no-files-found: warn
|
||||
@@ -1,183 +0,0 @@
|
||||
name: PR Auto Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
review:
|
||||
name: Zero Review
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Diff hygiene
|
||||
id: diffcheck
|
||||
continue-on-error: true
|
||||
run: git diff --check origin/${{ github.base_ref }}...HEAD
|
||||
|
||||
- name: Typecheck
|
||||
id: typecheck
|
||||
continue-on-error: true
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Test
|
||||
id: test
|
||||
continue-on-error: true
|
||||
run: bun run test
|
||||
|
||||
- name: Build
|
||||
id: build
|
||||
continue-on-error: true
|
||||
run: bun run build
|
||||
|
||||
- name: Smoke build
|
||||
id: smoke
|
||||
continue-on-error: true
|
||||
run: bun run smoke:build
|
||||
|
||||
- name: Collect changed files
|
||||
id: changed-files
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo 'files<<EOF'
|
||||
git diff --name-only "origin/${{ github.base_ref }}...HEAD" | sort
|
||||
echo EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Post review summary
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
ZERO_REVIEW_DIFF_CHECK: ${{ steps.diffcheck.outcome }}
|
||||
ZERO_REVIEW_TYPECHECK: ${{ steps.typecheck.outcome }}
|
||||
ZERO_REVIEW_TEST: ${{ steps.test.outcome }}
|
||||
ZERO_REVIEW_BUILD: ${{ steps.build.outcome }}
|
||||
ZERO_REVIEW_SMOKE: ${{ steps.smoke.outcome }}
|
||||
ZERO_CHANGED_FILES: ${{ steps.changed-files.outputs.files }}
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const marker = '<!-- zero-auto-review -->';
|
||||
const checks = [
|
||||
{ label: 'Diff hygiene', command: 'git diff --check', outcome: process.env.ZERO_REVIEW_DIFF_CHECK },
|
||||
{ label: 'Typecheck', command: 'bun run typecheck', outcome: process.env.ZERO_REVIEW_TYPECHECK },
|
||||
{ label: 'Tests', command: 'bun run test', outcome: process.env.ZERO_REVIEW_TEST },
|
||||
{ label: 'Build', command: 'bun run build', outcome: process.env.ZERO_REVIEW_BUILD },
|
||||
{ label: 'Smoke build', command: 'bun run smoke:build', outcome: process.env.ZERO_REVIEW_SMOKE },
|
||||
].map((check) => ({
|
||||
...check,
|
||||
outcome: normalizeOutcome(check.outcome),
|
||||
}));
|
||||
const blocking = new Set(['failure', 'cancelled', 'timed_out', 'action_required', 'unknown']);
|
||||
const blockers = checks.filter((check) => blocking.has(check.outcome));
|
||||
const files = (process.env.ZERO_CHANGED_FILES || '')
|
||||
.split(/\r?\n/)
|
||||
.map((file) => file.trim())
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
const visibleFiles = files.slice(0, 12);
|
||||
const fileSuffix = files.length > visibleFiles.length
|
||||
? `, and ${files.length - visibleFiles.length} more`
|
||||
: '';
|
||||
const fileLine = files.length > 0
|
||||
? `Changed files (${files.length}): ${visibleFiles.map((file) => `\`${file}\``).join(', ')}${fileSuffix}`
|
||||
: 'Changed files: unavailable in this run.';
|
||||
const body = [
|
||||
marker,
|
||||
'## Zero automated PR review',
|
||||
'',
|
||||
`Verdict: **${blockers.length > 0 ? 'Changes requested' : 'No blockers found'}**`,
|
||||
'',
|
||||
'### Blockers',
|
||||
'',
|
||||
blockers.length > 0
|
||||
? blockers.map((check) => `- \`${check.command}\` ended with \`${check.outcome}\`.`).join('\n')
|
||||
: '- None found.',
|
||||
'',
|
||||
'### Validation',
|
||||
'',
|
||||
...checks.map((check) => `- ${formatOutcome(check.outcome)} ${check.label}: \`${check.command}\``),
|
||||
'',
|
||||
'### Scope',
|
||||
'',
|
||||
`Head: \`${context.payload.pull_request.head.sha.slice(0, 12)}\``,
|
||||
fileLine,
|
||||
'',
|
||||
'This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find((comment) => comment.body?.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Unable to post automated review comment: ${error.message}`);
|
||||
await core.summary.addRaw(body, true).write();
|
||||
}
|
||||
|
||||
function normalizeOutcome(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase().replace(/-/g, '_');
|
||||
return [
|
||||
'success',
|
||||
'failure',
|
||||
'cancelled',
|
||||
'skipped',
|
||||
'timed_out',
|
||||
'action_required',
|
||||
'neutral',
|
||||
].includes(normalized) ? normalized : 'unknown';
|
||||
}
|
||||
|
||||
function formatOutcome(outcome) {
|
||||
if (outcome === 'success') return '[pass]';
|
||||
if (outcome === 'skipped' || outcome === 'neutral') return '[info]';
|
||||
return '[fail]';
|
||||
}
|
||||
|
||||
- name: Fail on review blockers
|
||||
if: ${{ steps.diffcheck.outcome != 'success' || steps.typecheck.outcome != 'success' || steps.test.outcome != 'success' || steps.build.outcome != 'success' || steps.smoke.outcome != 'success' }}
|
||||
run: exit 1
|
||||
@@ -1,68 +0,0 @@
|
||||
name: Release Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Git ref to package. Defaults to the workflow branch.
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: Package (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout requested ref
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Package release artifact
|
||||
run: bun run package:release
|
||||
|
||||
- name: Verify release checksums
|
||||
run: bun run verify:release
|
||||
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: zero-${{ runner.os }}-${{ runner.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
+5
-2
@@ -5,6 +5,7 @@ node_modules
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
/.zero-build
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
@@ -25,6 +26,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
.zero-test-cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
@@ -35,5 +37,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
.cursor
|
||||
|
||||
/zero
|
||||
/zero.exe
|
||||
zero
|
||||
zero.exe
|
||||
.*.bun-build
|
||||
@@ -1,222 +1,90 @@
|
||||
```
|
||||
███████████ ██████████ ███████████ ███████
|
||||
▒█▒▒▒▒▒▒███ ▒▒███▒▒▒▒▒█▒▒███▒▒▒▒▒███ ███▒▒▒▒▒███
|
||||
▒ ███▒ ▒███ █ ▒ ▒███ ▒███ ███ ▒▒███
|
||||
███ ▒██████ ▒██████████ ▒███ ▒███
|
||||
███ ▒███▒▒█ ▒███▒▒▒▒▒███ ▒███ ▒███
|
||||
████ █ ▒███ ▒ █ ▒███ ▒███ ▒▒███ ███
|
||||
███████████ ██████████ █████ █████ ▒▒▒███████▒
|
||||
▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒▒▒
|
||||
```
|
||||
# zero
|
||||
|
||||
# Zero
|
||||
|
||||
**A clean, terminal-first AI coding agent you fully own — multi-provider, scriptable, and safe by default.**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Zero is a coding agent that lives in your terminal. It runs an agentic tool loop —
|
||||
reading, editing, searching, and running commands in your repo — against **whatever
|
||||
model you choose**. Zero is migrating to a **Go-native core** with an npm
|
||||
distribution wrapper; the current TypeScript/Bun implementation remains a
|
||||
transitional reference while the Go M0 foundation lands.
|
||||
|
||||
> Zero treats the **model as a swappable, per-task choice** — no single-vendor lock-in —
|
||||
> and never mutates your system without a permission decision.
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- 🔌 **Multi-provider** — OpenAI-compatible, Anthropic, and Gemini behind one interface, with a model registry (capabilities, context limits, cost). Bring your own key and endpoint.
|
||||
- 🖥️ **Premium TUI** — Bubble Tea/Lip Gloss is the target TUI stack; the current Ink surface remains transitional during the Go migration.
|
||||
- 🤖 **Headless & scriptable** — `zero exec` with clean `text` / `json` / `stream-json` I/O and meaningful exit codes for CI and automation.
|
||||
- 🧰 **Real tools** — read / write / edit files, `apply_patch`, `grep`, `glob`, `bash`, directory listing, and a live plan/todo.
|
||||
- 🛡️ **Safe by default** — mutating tools are permission-gated; `--skip-permissions-unsafe` is an explicit, clearly-labeled opt-out.
|
||||
- 💾 **Durable sessions** — local, append-only session event store with full-text `search`.
|
||||
- 🩺 **Operable** — built-in `doctor`, `config` inspection, secret redaction everywhere, and `update --check`.
|
||||
|
||||
## Quick start
|
||||
|
||||
> Requires [Bun](https://bun.com) for the current transitional npm wrapper and
|
||||
> [Go](https://go.dev/) for Go-native validation commands.
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install --frozen-lockfile
|
||||
bun run dev # launch the interactive TUI
|
||||
bun install
|
||||
```
|
||||
|
||||
Point Zero at a model — either set environment variables:
|
||||
To run:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
# optional: any OpenAI-compatible endpoint / model
|
||||
export OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
export OPENAI_MODEL=gpt-4.1
|
||||
bun run dev
|
||||
```
|
||||
|
||||
…or save reusable provider profiles in `~/.config/zero/config.json` and manage them with
|
||||
`zero providers`. Run `zero doctor` anytime to verify your setup.
|
||||
|
||||
## Usage
|
||||
|
||||
### Interactive (TUI)
|
||||
To list built-in provider catalog entries:
|
||||
|
||||
```bash
|
||||
bun run dev # or: zero
|
||||
bun run src/index.ts providers catalog
|
||||
```
|
||||
|
||||
Inside the TUI: type to chat and press **Enter** to send. `/` opens command suggestions
|
||||
(**Tab** accepts the first). When the prompt is empty, the arrow keys, **PgUp/PgDn**, and
|
||||
**Home/End** scroll the transcript. **Ctrl+C** exits.
|
||||
## Provider catalog
|
||||
|
||||
### Headless (`exec`)
|
||||
Provider, gateway, and local endpoint catalog entries live in
|
||||
`src/providers/catalog/definitions`. Global model metadata lives in
|
||||
`src/providers/catalog/models`.
|
||||
|
||||
```bash
|
||||
# one-shot
|
||||
zero exec "explain src/agent/loop.ts and suggest one improvement"
|
||||
Top-level providers are services that self-publish model families, such as
|
||||
OpenAI with GPT/o-series, Anthropic with Claude/Opus, Google with Gemini, and
|
||||
DeepSeek with DeepSeek models. Gateways should usually reference those global
|
||||
models instead of redefining them.
|
||||
|
||||
# from a file, with a specific model, as JSON for scripts
|
||||
zero exec --file task.md --model claude-sonnet-4.5 --output-format json
|
||||
Minimal gateway example:
|
||||
|
||||
# multi-turn / programmatic over stdio
|
||||
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
|
||||
```toml
|
||||
id = "my-gateway"
|
||||
name = "My Gateway"
|
||||
kind = "gateway"
|
||||
description = "OpenAI-compatible gateway"
|
||||
category = "aggregating"
|
||||
baseURL = "https://api.example.com/v1"
|
||||
defaultModel = "vendor/model-name"
|
||||
apiKeyRequired = true
|
||||
credentialEnvVars = ["MY_GATEWAY_API_KEY"]
|
||||
costCurrency = "USD"
|
||||
|
||||
[setup]
|
||||
requiresAuth = true
|
||||
authMode = "api-key"
|
||||
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
maxTokensField = "max_completion_tokens"
|
||||
removeBodyFields = ["store", "stream_options"]
|
||||
authHeader.name = "authorization"
|
||||
authHeader.scheme = "bearer"
|
||||
headers.X-Trace-Source = "zero"
|
||||
headers.X-Tenant = "$MY_GATEWAY_TENANT"
|
||||
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discoveryCacheTtl = "1h"
|
||||
discoveryRefreshMode = "manual"
|
||||
discovery.kind = "openai-compatible"
|
||||
discovery.requiresAuth = true
|
||||
discovery.path = "/models"
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "gpt-4o"
|
||||
apiName = "openai/gpt-4o"
|
||||
contextWindow = 32000
|
||||
maxOutputTokens = 4096
|
||||
cost.inputPerMillion = 2.50
|
||||
cost.outputPerMillion = 10.00
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "claude-sonnet-4"
|
||||
apiName = "anthropic/claude-sonnet-4"
|
||||
contextWindow = 200000
|
||||
maxOutputTokens = 64000
|
||||
cost.inputPerMillion = 3.00
|
||||
cost.outputPerMillion = 15.00
|
||||
```
|
||||
|
||||
`exec` flags: `-f, --file` · `-m, --model` · `-C, --cwd` · `-i, --input-format <text|stream-json>` ·
|
||||
`-o, --output-format <text|json|stream-json>` · `--skip-permissions-unsafe`.
|
||||
stdout carries **only** program output; logs go to stderr. See
|
||||
[`docs/STREAM_JSON_PROTOCOL.md`](docs/STREAM_JSON_PROTOCOL.md).
|
||||
Detailed docs:
|
||||
|
||||
### Other commands
|
||||
- [Provider definitions](docs/provider-catalog/providers.md)
|
||||
- [Model catalog entries](docs/provider-catalog/models.md)
|
||||
- [Examples](docs/provider-catalog/examples.md)
|
||||
|
||||
```bash
|
||||
zero providers list|switch <name>|current # manage provider profiles
|
||||
zero search "<query>" [--json --session <id> --type <event>] # search local sessions
|
||||
zero doctor [--connectivity] [--json] # health checks
|
||||
zero config [--json] # inspect resolved configuration
|
||||
zero update --check [--json] # check for a newer release
|
||||
```
|
||||
|
||||
## Providers & models
|
||||
|
||||
Selectable per task and per session. The model registry knows each model's provider,
|
||||
capabilities, context window, and cost.
|
||||
|
||||
| Provider | Example models |
|
||||
|---|---|
|
||||
| OpenAI-compatible | `gpt-4.1`, `gpt-4.1-mini`, `gpt-4o`, `gpt-4o-mini` |
|
||||
| Anthropic | `claude-opus-4.1`, `claude-sonnet-4.5`, `claude-haiku-4.5` |
|
||||
| Google Gemini | `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` |
|
||||
|
||||
Any OpenAI-compatible endpoint works with just a base URL, key, and model — so local
|
||||
runtimes (Ollama, gateways, etc.) plug in the same way.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose | Side effect |
|
||||
|---|---|---|
|
||||
| `read_file` · `list_directory` · `grep` · `glob` | explore & search | read |
|
||||
| `update_plan` | maintain a live task plan | plan |
|
||||
| `write_file` · `edit_file` · `apply_patch` | create & modify files | write (gated) |
|
||||
| `bash` | run shell commands | shell (gated) |
|
||||
|
||||
Write/shell tools route through the permission policy before any side effect.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Terminal (Bubble Tea target) headless `exec` (future) editor ext
|
||||
└───────────────────┬───────────────────────┘
|
||||
Go-native core target (loop, events, tools)
|
||||
┌──────────┬───────────┬──────────┬──────────┬───────────┬──────────┐
|
||||
providers tools sessions usage redaction doctor / stream-json
|
||||
+ registry registry + search + cost config
|
||||
```
|
||||
|
||||
- **Go-native target**: the new `cmd/zero` entrypoint is the foundation for the production core; TS/Bun code remains in place during migration.
|
||||
- **Surface-agnostic core**: the agent loop streams text + tool calls, executes tools, and emits a typed event stream consumed identically by every surface.
|
||||
- **Edges are interfaces**: `Provider`, `Tool`, `SessionStore`, and the permission policy are swappable.
|
||||
- **Model is data**: capabilities, cost, and routing live in the registry — never hard-coded.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
cmd/zero # Go-native entrypoint
|
||||
internal/cli # minimal Go CLI surface during migration
|
||||
src/
|
||||
agent/ # transitional TS agent loop + system prompts
|
||||
cli/ # transitional TS headless exec + command surface
|
||||
providers/ # openai · anthropic · gemini · base
|
||||
tools/ # read/write/edit/bash/grep/glob/apply_patch/plan
|
||||
tui/ # transitional Ink interface + startup/
|
||||
config/ # layered configuration
|
||||
zero-model-registry/ # models, capabilities, cost
|
||||
zero-provider-runtime/ # provider resolution/routing
|
||||
zero-sessions/ # append-only session event store
|
||||
zero-search/ # session search
|
||||
zero-usage/ # token usage tracking
|
||||
zero-redaction/ # secret redaction
|
||||
zero-doctor/ # health checks
|
||||
zero-config-inspection/# config inspection
|
||||
zero-stream-json/ # headless stream-json protocol
|
||||
docs/ # PRD + protocol/install/perf docs
|
||||
tests/ # bun test suite
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun test # run the test suite
|
||||
bun run test:go # run Go tests
|
||||
bun run typecheck # tsc --noEmit
|
||||
bun run build # compile the release-facing Go binary
|
||||
bun run build:go # same Go binary builder, kept as a stable validation command
|
||||
bun run smoke:build # verify the release-facing Go binary
|
||||
bun run smoke:go # verify the Go binary through the Go smoke path
|
||||
bun run perf:bench # performance benchmarks (see docs/PERFORMANCE.md)
|
||||
```
|
||||
|
||||
Cross-compile by passing Go targets to the builder:
|
||||
|
||||
```bash
|
||||
bun run scripts/build.ts --goos linux --goarch amd64
|
||||
bun run scripts/build.ts --goos windows --goarch amd64 --output dist/zero.exe
|
||||
```
|
||||
|
||||
### Install from a release
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
scripts/install.sh
|
||||
|
||||
# Windows
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1
|
||||
```
|
||||
|
||||
See [`docs/INSTALL.md`](docs/INSTALL.md) for version, repository, and install-path overrides,
|
||||
and [`docs/UPDATE.md`](docs/UPDATE.md) for the update flow.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Product Requirements (PRD)](docs/PRD.md) — vision, goals, full feature spec, roadmap
|
||||
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md) — headless I/O contract
|
||||
- [Headless exec PRD](docs/M1_HEADLESS_EXEC_PRD.md)
|
||||
- [npm wrapper smoke checklist](docs/NPM_WRAPPER_SMOKE.md)
|
||||
- [Performance](docs/PERFORMANCE.md) · [Install](docs/INSTALL.md) · [Update](docs/UPDATE.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Please run
|
||||
`bun test` and `bun run typecheck` before opening a PR.
|
||||
|
||||
## License
|
||||
|
||||
License is being finalized; a `LICENSE` file will be added before a public release.
|
||||
|
||||
---
|
||||
|
||||
<sub>Targeting a Go-native core with an npm distribution wrapper. The current Bun · TypeScript · Ink implementation is transitional during migration.</sub>
|
||||
This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runNpmWrapper } from '../src/npm-wrapper';
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
process.exitCode = await runNpmWrapper({ root: packageRoot });
|
||||
@@ -12,14 +12,17 @@
|
||||
"ink-text-input": "^6.0.0",
|
||||
"openai": "^6.39.1",
|
||||
"react": "^19.2.6",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"shiki": "^4.1.0",
|
||||
"smol-toml": "^1.6.1",
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^19.2.15",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -206,6 +209,8 @@
|
||||
|
||||
"slice-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="],
|
||||
|
||||
"smol-toml": ["[email protected]", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
|
||||
|
||||
"space-separated-tokens": ["[email protected]", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"stack-utils": ["[email protected]", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||
@@ -252,7 +257,7 @@
|
||||
|
||||
"wrap-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
|
||||
|
||||
"ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="],
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"yoctocolors": ["[email protected]", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
@@ -264,13 +269,11 @@
|
||||
|
||||
"ink/signal-exit": ["[email protected]", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"ink/ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"ink-text-input/type-fest": ["[email protected]", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"npm-run-path/path-key": ["[email protected]", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"openai/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
"react-devtools-core/ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="],
|
||||
|
||||
"restore-cursor/signal-exit": ["[email protected]", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
# Zero Install Scripts
|
||||
|
||||
Zero release archives are published as:
|
||||
|
||||
- `zero-v<version>-linux-<arch>.tar.gz`
|
||||
- `zero-v<version>-macos-<arch>.tar.gz`
|
||||
- `zero-v<version>-windows-<arch>.zip`
|
||||
|
||||
Each archive must have a matching `.sha256` file. The install scripts download both files and verify the checksum before copying the binary.
|
||||
|
||||
Maintainers can verify the release directory before upload:
|
||||
|
||||
```bash
|
||||
bun run package:release
|
||||
bun run verify:release
|
||||
```
|
||||
|
||||
`verify:release` requires every archive in `dist/release` to have a same-directory `.sha256` file whose contents are:
|
||||
|
||||
```text
|
||||
<sha256> <archive-name>
|
||||
```
|
||||
|
||||
## Linux And macOS
|
||||
|
||||
From a checkout:
|
||||
|
||||
```bash
|
||||
scripts/install.sh
|
||||
```
|
||||
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
ZERO_VERSION=0.1.0 scripts/install.sh
|
||||
```
|
||||
|
||||
Install to a custom directory:
|
||||
|
||||
```bash
|
||||
ZERO_INSTALL_DIR="$HOME/bin" scripts/install.sh
|
||||
```
|
||||
|
||||
Install from a fork or internal repository:
|
||||
|
||||
```bash
|
||||
scripts/install.sh --repo owner/repo
|
||||
```
|
||||
|
||||
Defaults:
|
||||
|
||||
- Repository: `Gitlawb/zero`
|
||||
- Version: latest GitHub release
|
||||
- Install path: `~/.local/bin/zero`
|
||||
|
||||
Requirements: `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
|
||||
|
||||
## Windows
|
||||
|
||||
From a checkout:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1
|
||||
```
|
||||
|
||||
Install a specific version:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Version 0.1.0
|
||||
```
|
||||
|
||||
Install to a custom directory:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:USERPROFILE\bin"
|
||||
```
|
||||
|
||||
Install from a fork or internal repository:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Repository owner/repo
|
||||
```
|
||||
|
||||
Defaults:
|
||||
|
||||
- Repository: `Gitlawb/zero`
|
||||
- Version: latest GitHub release
|
||||
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
|
||||
|
||||
## Updating
|
||||
|
||||
Check whether a newer release exists:
|
||||
|
||||
```bash
|
||||
zero update --check
|
||||
```
|
||||
|
||||
For M2, updates are check-only. Re-run the installer to replace the local binary after reviewing the release.
|
||||
@@ -1,87 +0,0 @@
|
||||
# M1 Headless Exec PRD
|
||||
|
||||
Status: Draft
|
||||
Owner: Vasanth
|
||||
Milestone: M1
|
||||
Scope: `zero exec` CLI surface
|
||||
|
||||
## Goal
|
||||
|
||||
Ship the first production-shaped headless command for Zero:
|
||||
|
||||
```sh
|
||||
zero exec "inspect this repo and summarize the next fix"
|
||||
```
|
||||
|
||||
This command should be useful for local scripts, CI smoke runs, and later VS Code integration without pulling in the full future protocol, session, MCP, sandbox, or plugin roadmap.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No daemon mode.
|
||||
- No JSON-RPC input protocol.
|
||||
- No MCP, plugin, marketplace, or subagent support.
|
||||
- No session resume/fork/search.
|
||||
- No new provider adapters in this slice.
|
||||
- No architecture rewrite of the agent loop.
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As a CLI user, I can run one prompt headlessly and receive the assistant answer on stdout.
|
||||
2. As a script author, I can choose `text` or JSONL output.
|
||||
3. As a reviewer, I can see tool activity without corrupting text stdout.
|
||||
4. As a model tester, I can override the configured model for one run.
|
||||
5. As a cautious user, prompt-gated tools stay disabled unless I pass an explicit unsafe flag.
|
||||
|
||||
## CLI Contract
|
||||
|
||||
```sh
|
||||
zero exec [prompt...] [options]
|
||||
```
|
||||
|
||||
Options for M1:
|
||||
|
||||
- `-f, --file <path>`: read the prompt from a file.
|
||||
- `-m, --model <model>`: override the configured model for this run.
|
||||
- `-C, --cwd <path>`: run from another working directory.
|
||||
- `-o, --output-format <text|json>`: choose stdout format.
|
||||
- `--skip-permissions-unsafe`: grant prompt-gated tools for this run.
|
||||
|
||||
The existing `zero -p "prompt"` shortcut remains as a compatibility path and should call the same runner.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Text mode:
|
||||
|
||||
- stdout contains assistant text only.
|
||||
- stderr contains tool rows, warnings, and errors.
|
||||
|
||||
JSON mode:
|
||||
|
||||
- stdout is newline-delimited JSON events.
|
||||
- Required event types for this slice: `run_start`, `text`, `tool_call`, `tool_result`, `final`, `warning`, `error`, `done`.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0`: success
|
||||
- `1`: unexpected crash
|
||||
- `2`: usage error
|
||||
- `3`: provider/config/runtime error
|
||||
- `4`: reserved for future tool failure policy
|
||||
- `5`: reserved for future permission-denied policy
|
||||
|
||||
## Permission Behavior
|
||||
|
||||
Default headless mode advertises only tools marked `permission: "allow"`.
|
||||
|
||||
`--skip-permissions-unsafe` advertises tools that are not marked `deny` and grants prompt-gated tools through `ToolRegistry.run`. It must not bypass schema validation or direct tool execution safeguards.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `bun run typecheck` passes.
|
||||
- `bun test ./tests --timeout 15000` passes.
|
||||
- `bun run build` passes.
|
||||
- `zero exec --help` documents the M1 flags.
|
||||
- Usage errors do not initialize providers.
|
||||
- Provider failures return exit code `3`.
|
||||
- Normal mode does not advertise `bash`, `write_file`, `edit_file`, or `apply_patch`.
|
||||
- Unsafe mode still uses the registry execution path.
|
||||
@@ -1,37 +0,0 @@
|
||||
# npm Wrapper Smoke Checklist
|
||||
|
||||
Run this checklist when a PR changes npm distribution files such as
|
||||
`package.json`, `bun.lock`, `bin/zero.ts`, `src/npm-wrapper.ts`, build scripts,
|
||||
package release scripts, or the npm `bin` wrapper.
|
||||
|
||||
## Required Checks
|
||||
|
||||
```bash
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
bun run smoke:build
|
||||
```
|
||||
|
||||
`build` and `build:go` both compile the Go-native release binary and inject the
|
||||
version from `package.json`. Keep the Bun install/test checks because the npm
|
||||
wrapper and transitional TypeScript surface still ship from this package.
|
||||
|
||||
Also run the Go checks when the PR changes Go entrypoint, CLI, or release
|
||||
artifact behavior:
|
||||
|
||||
```bash
|
||||
bun run test:go
|
||||
bun run build:go
|
||||
bun run smoke:go
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- `package.json` has the expected package name, version, and `bin.zero` entry.
|
||||
- `bun install --frozen-lockfile` succeeds without lockfile changes.
|
||||
- The wrapper binary resolves through the package `bin` entry and
|
||||
`node_modules/.bin` in a package-install smoke test.
|
||||
- The built binary exits 0 for `zero --version` or `zero --help`.
|
||||
- `zero --version` reports `zero <package.json version>`.
|
||||
- Release packaging still emits the expected archive and checksum names when
|
||||
package release files change.
|
||||
@@ -1,62 +0,0 @@
|
||||
# Zero Performance Benchmarks
|
||||
|
||||
The M2 performance harness tracks three release-facing signals:
|
||||
|
||||
- Cold start: process startup time for `zero --version`.
|
||||
- TTFT: time from starting the local agent loop to the first streamed text event from a deterministic provider.
|
||||
- Memory: peak RSS for the benchmarked in-process agent path.
|
||||
|
||||
When a built Go binary exists at `./zero` or `./zero.exe`, cold start uses that artifact. Otherwise it falls back to `bun src/index.ts --version` so the benchmark can run before a local build.
|
||||
|
||||
## Run Locally
|
||||
|
||||
```bash
|
||||
bun run perf:bench
|
||||
```
|
||||
|
||||
Run against a freshly built binary:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
bun run perf:bench
|
||||
```
|
||||
|
||||
Write the JSON report used by CI:
|
||||
|
||||
```bash
|
||||
bun run perf:smoke
|
||||
```
|
||||
|
||||
Default warning thresholds:
|
||||
|
||||
- Cold start p95: 300 ms
|
||||
- TTFT p95: 500 ms
|
||||
- Agent RSS peak: 256 MB
|
||||
|
||||
The default sample count is intentionally small for CI smoke coverage. `p95` uses nearest-rank percentile selection, so with the default 5 measured samples it is the slowest sample. Increase `--iterations` for local baseline investigations.
|
||||
|
||||
Override thresholds with CLI flags:
|
||||
|
||||
```bash
|
||||
bun run scripts/perf-bench.ts --cold-start-warn-ms=350 --ttft-warn-ms=600 --rss-warn-mb=384
|
||||
```
|
||||
|
||||
Or with environment variables:
|
||||
|
||||
```bash
|
||||
ZERO_PERF_COLD_START_WARN_MS=350 bun run perf:bench
|
||||
```
|
||||
|
||||
Supported environment variables:
|
||||
|
||||
- `ZERO_PERF_ITERATIONS`
|
||||
- `ZERO_PERF_WARMUP_ITERATIONS`
|
||||
- `ZERO_PERF_COLD_START_WARN_MS`
|
||||
- `ZERO_PERF_TTFT_WARN_MS`
|
||||
- `ZERO_PERF_RSS_WARN_MB`
|
||||
|
||||
## CI Behavior
|
||||
|
||||
The `Performance Smoke` job builds the binary, runs `bun run perf:smoke`, and uploads `dist/perf/perf-bench.json`.
|
||||
|
||||
Threshold drift is emitted as GitHub Actions warnings. The job fails only if the benchmark cannot run, the build fails, or `--fail-on-warning` is passed explicitly.
|
||||
-602
@@ -1,602 +0,0 @@
|
||||
# Zero — Product Requirements Document (v1.0)
|
||||
|
||||
**Status:** DRAFT v1.0
|
||||
**Target:** Compete with Claude Code + Cursor + OpenCode
|
||||
**Timeline:** 6-12 months
|
||||
**Stack:** Go native core + npm distribution wrapper
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
**Zero is a terminal-first, editor-aware, multi-provider AI coding platform.**
|
||||
|
||||
It combines the best of:
|
||||
- **Claude Code** — terminal-native agentic coding
|
||||
- **Cursor** — IDE integration + smart editing
|
||||
- **OpenCode** — multi-provider + headless
|
||||
|
||||
**One Go-native agent core. Three surfaces. Zero lock-in.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Surfaces
|
||||
|
||||
### 2.1 Terminal (TUI) — Primary
|
||||
- Interactive coding agent in terminal
|
||||
- Streaming, append-style (like Claude Code)
|
||||
- Bubble Tea + Lip Gloss-based
|
||||
- Full keyboard control
|
||||
- Mouse support
|
||||
|
||||
### 2.2 Editor (VS Code Extension)
|
||||
- Same agent core, different UI
|
||||
- Inline suggestions + chat panel
|
||||
- File editing with diff view
|
||||
- Diagnostics integration
|
||||
- LSP-aware
|
||||
|
||||
### 2.3 Headless (CLI/CI)
|
||||
- `zero exec "prompt"` for scripts
|
||||
- `zero serve` for daemon mode
|
||||
- JSON-RPC over stdio/WebSocket
|
||||
- MCP server support
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ZERO PLATFORM │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ TUI │ │ VSCode │ │ Headless │ │
|
||||
│ │ Surface │ │ Surface │ │ Surface │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────┼─────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼──────┐ │
|
||||
│ │ Agent Core │ │
|
||||
│ │ (ReAct) │ │
|
||||
│ └──────┬──────┘ │
|
||||
│ │ │
|
||||
│ ┌──────┬──────┬────┴────┬──────┬──────┐ │
|
||||
│ │Tools │Perms │Sessions │Provi │Memory│ │
|
||||
│ └──────┴──────┴─────────┴──────┴──────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Layering Rules:**
|
||||
- Agent Core never imports a surface
|
||||
- Surfaces are thin shells (I/O + rendering)
|
||||
- All state persists to local files
|
||||
- No cloud dependency
|
||||
|
||||
---
|
||||
|
||||
## 4. Functional Requirements (24 Features)
|
||||
|
||||
### F1 — Agent Core (P0)
|
||||
- ReAct loop: think → tool → result → think
|
||||
- Stream text + tool calls + thinking
|
||||
- Parallel tool execution where safe
|
||||
- Bound turns (max 100)
|
||||
- Clean interrupt (Esc/Ctrl-C)
|
||||
- Event stream: `text | tool_call | tool_result | thinking | usage | plan_update | error | turn_end`
|
||||
|
||||
### F2 — Tools (P0)
|
||||
|
||||
**Core (8):**
|
||||
- `read_file` — read with line ranges, images
|
||||
- `write_file` — create/overwrite
|
||||
- `edit_file` — exact-string edit (uniqueness guard)
|
||||
- `bash` — run shell command
|
||||
- `grep` — ripgrep content search
|
||||
- `glob` — file pattern matching
|
||||
- `apply_patch` — multi-hunk patches
|
||||
- `update_plan` — todo list
|
||||
|
||||
**Advanced (4):**
|
||||
- `web_fetch` / `web_search` — web access
|
||||
- `task` — spawn subagent
|
||||
- `structured_output` — JSON-Schema validated
|
||||
- `notebook_edit` — Jupyter notebooks
|
||||
|
||||
**Tool Requirements:**
|
||||
- Go typed parameter schemas -> JSON Schema
|
||||
- Side-effect class: `read | write | shell | network | out_of_workspace`
|
||||
- Structured results: `{ status, output, truncated?, meta? }`
|
||||
- Atomic execution
|
||||
- Permission gating
|
||||
|
||||
### F3 — Providers & Model Registry (P0)
|
||||
- OpenAI-compatible (works with OpenAI, OpenRouter, Ollama, etc.)
|
||||
- Anthropic (Claude)
|
||||
- Google Gemini
|
||||
- Azure OpenAI
|
||||
- AWS Bedrock
|
||||
- xAI (Grok)
|
||||
|
||||
**Model Registry:**
|
||||
|
||||
```go
|
||||
type ModelEntry struct {
|
||||
ID string
|
||||
Name string
|
||||
Provider string
|
||||
APIProviders []string
|
||||
ContextLimits ContextLimits
|
||||
ReasoningEffort ReasoningEffort
|
||||
Capabilities ModelCapabilities
|
||||
Cost ModelCost
|
||||
Tier string
|
||||
MatchPatterns []string
|
||||
}
|
||||
```
|
||||
|
||||
- Per-task model selection (`-m`)
|
||||
- Provider rotation/failover
|
||||
- Cost tracking
|
||||
- "Spec model" for planning
|
||||
- Fallback model on failure
|
||||
|
||||
### F4 — Terminal UI (P0)
|
||||
- **Layout:** header (cwd · git · model) → tool rows → diff → response → input → footer
|
||||
- **Streaming:** append-only transcript model with live tail updates
|
||||
- **Markdown:** full CommonMark + syntax highlighting
|
||||
- **Tool rows:** collapsible with one-line summaries
|
||||
- **Input:** `/` commands, `@` files, `!` bash, `Esc` interrupt
|
||||
- **Robustness:** resize, wide-char, ANSI, non-TTY fallback
|
||||
- **Splash:** ZERO wordmark
|
||||
- **Themes:** light/dark + custom
|
||||
|
||||
### F5 — Sessions (P0)
|
||||
- Persist to `~/.local/share/zero/sessions/<id>/`
|
||||
- `meta.json` + `events.jsonl` + `messages.jsonl` + `checkpoints/`
|
||||
- `--resume [id]` (default: latest)
|
||||
- `--fork <id>` (copy with lineage)
|
||||
- Search: `zero search <query>` over messages/tools/results
|
||||
- Atomic writes (write-temp-then-rename)
|
||||
- Schema versioning
|
||||
- Export to Markdown/JSON
|
||||
|
||||
### F6 — Configuration (P0)
|
||||
- Layered: defaults → user → project → env → CLI
|
||||
- `~/.config/zero/config.json` (user)
|
||||
- `./.zero/config.json` (project)
|
||||
- Runtime overlay (`--settings <path>`)
|
||||
- Feature flags for staged rollout
|
||||
- Validation on load
|
||||
|
||||
### F7 — MCP (Model Context Protocol) (P0)
|
||||
- Connect to stdio/HTTP/SSE servers
|
||||
- Surface MCP tools into registry
|
||||
- `zero mcp add/remove/list`
|
||||
- Per-server and per-tool permissions
|
||||
- Persistent grants with revoke/clear
|
||||
- MCP server mode (zero as MCP server)
|
||||
|
||||
### F8 — Headless / Programmatic (P0)
|
||||
- `zero exec [prompt]` — one-shot
|
||||
- `zero serve` — daemon mode
|
||||
- `--input-format stream-json` — line-delimited events
|
||||
- `--input-format stream-jsonrpc` — JSON-RPC protocol
|
||||
- `-o text|json` output formats
|
||||
- Exit codes: `0=success, 1=crash, 2=usage, 3=provider, 4=tool, 5=permission, 6=budget`
|
||||
|
||||
### F9 — Permissions & Autonomy (P0)
|
||||
- **Side-effect classes:** read, write, shell, network, out_of_workspace
|
||||
- **Autonomy levels:** low (ask), medium (smart), high (auto)
|
||||
- `--skip-permissions-unsafe` (loud warning)
|
||||
- TUI: inline prompts (allow/deny/always)
|
||||
- Headless: default-deny unless granted
|
||||
- Persistent grants with list/revoke/clear
|
||||
- Audit log in `events.jsonl`
|
||||
|
||||
**Permission Matrix:**
|
||||
|
||||
| Tool Class | low | medium | high |
|
||||
|------------|-----|--------|------|
|
||||
| Read | allow | allow | allow |
|
||||
| Workspace write | prompt | allow | allow |
|
||||
| Shell | prompt | prompt | allow |
|
||||
| Network | prompt | allow | allow |
|
||||
| Out-of-workspace | deny | prompt | prompt |
|
||||
|
||||
### F10 — Observability (P0)
|
||||
- Structured logging (stdout/stderr split)
|
||||
- Secret redaction (API keys, tokens, passwords)
|
||||
- No vendor telemetry (local only)
|
||||
- `zero doctor` — health check command
|
||||
- Crash reports (opt-in, local)
|
||||
- Performance metrics (TTFT, tokens/sec)
|
||||
|
||||
### F11 — Subagents (P1)
|
||||
- `task` tool spawns scoped subagent
|
||||
- Built-in agents: code-review, security-review, refactor, test-gen
|
||||
- Recursion depth limit
|
||||
- Parent session linkage
|
||||
- Shared/isolated budgets
|
||||
|
||||
### F12 — Plugins (P1)
|
||||
- User/project-scoped plugins
|
||||
- Commands, tools, prompts, hooks
|
||||
- Plugin manifest: `plugin.json`
|
||||
- `zero plugin install <path|url>`
|
||||
- No marketplace in v1 (manual install)
|
||||
|
||||
### F13 — Distribution (P0)
|
||||
- **Single binary** via Go cross-compilation
|
||||
- npm package as a thin installer/launcher for the platform binary
|
||||
- Cross-platform: Linux, macOS, Windows
|
||||
- `zero update [--check]` self-update
|
||||
- Checksum verification
|
||||
- Rollback on failure
|
||||
|
||||
### F14 — VS Code Extension (P1)
|
||||
- Same agent core via stream protocol
|
||||
- Inline chat panel
|
||||
- Diff view in editor
|
||||
- Diagnostics integration
|
||||
- LSP awareness
|
||||
- File watching
|
||||
|
||||
### F15 — Memory & Context (P1)
|
||||
- `AGENTS.md` hierarchical memory
|
||||
- `@import` for modular rules
|
||||
- Auto-generated from `init` command
|
||||
- Project conventions learning
|
||||
- Skills system (reusable instruction packs)
|
||||
- Context budgeting (token tracking)
|
||||
- Auto-compaction when full
|
||||
|
||||
### F16 — Hooks System (P1)
|
||||
- Lifecycle hooks: `PreToolUse`, `PostToolUse`, `SessionStart`, `SessionEnd`
|
||||
- User-defined shell commands
|
||||
- JSON output for conditional flow
|
||||
- `.zero/hooks/` directory
|
||||
|
||||
### F17 — Slash Commands (P0)
|
||||
|
||||
**Built-in (20):**
|
||||
- Session: `/clear`, `/compact`, `/context`, `/rewind`, `/resume`, `/exit`
|
||||
- Model: `/model`, `/effort`, `/style`, `/permissions`
|
||||
- Project: `/init`, `/memory`, `/skills`
|
||||
- Extensibility: `/mcp`, `/hooks`, `/agents`
|
||||
- Meta: `/help`, `/config`, `/doctor`, `/feedback`
|
||||
|
||||
**Custom:** `./.zero/commands/*.md`
|
||||
|
||||
### F18 — Git Integration (P1)
|
||||
- Worktree isolation per task
|
||||
- Auto-commit with messages
|
||||
- Branch management
|
||||
- PR creation
|
||||
- Diff review
|
||||
- Conflict resolution
|
||||
|
||||
### F19 — Testing & Verification (P1)
|
||||
- `run_tests` tool
|
||||
- Self-verification loop
|
||||
- Test runner integration
|
||||
- Coverage reporting
|
||||
- Diagnostics (typecheck, lint)
|
||||
- LSP integration
|
||||
|
||||
### F20 — Web Access (P1)
|
||||
- `web_fetch` — fetch URL content
|
||||
- `web_search` — search engine
|
||||
- Provider/permission gated
|
||||
- Rate limiting
|
||||
- Caching
|
||||
|
||||
### F21 — Sandbox (P2)
|
||||
- Linux: bubblewrap
|
||||
- macOS: sandbox-exec
|
||||
- Windows: AppContainer
|
||||
- Filesystem policies
|
||||
- Network policies
|
||||
- Unix socket controls
|
||||
|
||||
### F22 — Multi-modal (P2)
|
||||
- Image input (screenshots, diagrams)
|
||||
- PDF parsing
|
||||
- Voice input (optional)
|
||||
- File attachments
|
||||
- Paste support
|
||||
|
||||
### F23 — Telemetry (P2)
|
||||
- Local-only metrics
|
||||
- Token usage tracking
|
||||
- Cost reports
|
||||
- Performance profiling
|
||||
- Crash reports (opt-in)
|
||||
- NO vendor telemetry
|
||||
|
||||
### F24 — Advanced Features (P2)
|
||||
- Code review automation
|
||||
- Auto-refactor
|
||||
- Dependency updates
|
||||
- Security scanning
|
||||
- Documentation generation
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Models
|
||||
|
||||
### 5.1 Headless Event Stream
|
||||
|
||||
```json
|
||||
{ "type": "run_start", "runId": "r1", "sessionId": "s1", "cwd": "/repo", "model": "..." }
|
||||
{ "type": "text", "delta": "..." }
|
||||
{ "type": "tool_call", "id": "t1", "name": "bash", "args": {...}, "sideEffect": "shell" }
|
||||
{ "type": "permission", "id": "t1", "decision": "needs_approval", "reason": "shell" }
|
||||
{ "type": "tool_result", "id": "t1", "status": "ok", "output": "...", "truncated": false }
|
||||
{ "type": "usage", "promptTokens": 1234, "completionTokens": 567, "costUsd": 0.012 }
|
||||
{ "type": "plan_update", "plan": [{ "id": "1", "content": "...", "status": "in_progress" }] }
|
||||
{ "type": "turn_end", "stopReason": "end_turn" }
|
||||
{ "type": "error", "code": "rate_limit", "message": "...", "recoverable": true }
|
||||
{ "type": "run_end", "status": "success", "exitCode": 0 }
|
||||
```
|
||||
|
||||
### 5.2 Session On Disk
|
||||
|
||||
```
|
||||
~/.local/share/zero/sessions/<id>/
|
||||
meta.json # { schemaVersion, id, parentId?, title, cwd, model, createdAt, updatedAt, tokens, cost }
|
||||
events.jsonl # append-only AgentEvent stream
|
||||
messages.jsonl # provider-facing messages
|
||||
checkpoints/ # snapshots for restore/fork/compact
|
||||
```
|
||||
|
||||
### 5.3 Tool Contract
|
||||
|
||||
```go
|
||||
type Tool interface {
|
||||
Name() string
|
||||
Description() string
|
||||
Parameters() JSONSchema
|
||||
SideEffect() SideEffect
|
||||
Execute(ctx ToolContext, args map[string]any) ToolResult
|
||||
}
|
||||
|
||||
type ToolResult struct {
|
||||
Status string
|
||||
Output string
|
||||
Truncated bool
|
||||
Meta map[string]any
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Config Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"providers": {
|
||||
"anthropic": { "apiKey": "env:AN..._KEY" },
|
||||
"openai": { "apiKey": "env:OP..._KEY" }
|
||||
},
|
||||
"permissions": {
|
||||
"autonomy": "medium",
|
||||
"grants": [{ "tool": "bash", "pattern": "git status", "decision": "allow" }]
|
||||
},
|
||||
"ui": { "theme": "dark", "syntaxHighlighting": true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Non-Functional Requirements
|
||||
|
||||
### Performance
|
||||
- TTFT < 500ms after provider's first byte
|
||||
- No full-transcript repaint per token
|
||||
- Cold start < 300ms (warm cache)
|
||||
- Tool execution < 100ms overhead
|
||||
- Session resume < 1s
|
||||
|
||||
### Reliability
|
||||
- Crash-free TUI under resize, wide-char, non-TTY
|
||||
- Clean interrupt (no half-written state)
|
||||
- Atomic session writes
|
||||
- Recovery from malformed JSONL
|
||||
- Provider failover
|
||||
|
||||
### Security
|
||||
- No mutation without permission decision (hard invariant)
|
||||
- API keys never logged
|
||||
- Secret redaction everywhere
|
||||
- Headless stdout free of logs
|
||||
- Workspace boundary by default
|
||||
- Sandboxing for untrusted code
|
||||
|
||||
### Portability
|
||||
- Linux, macOS, Windows
|
||||
- Any OpenAI-compatible endpoint
|
||||
- x86_64, ARM64
|
||||
- Single binary distribution
|
||||
|
||||
### Testability
|
||||
- Mock provider for unit tests
|
||||
- Fake MCP server/client
|
||||
- Fake terminal/PTY
|
||||
- Sandbox simulator
|
||||
- Deterministic event replay
|
||||
|
||||
---
|
||||
|
||||
## 7. Milestones
|
||||
|
||||
### M0 — Foundation (Weeks 1-2)
|
||||
- Go module setup
|
||||
- Agent core skeleton
|
||||
- 8 core tools (read, write, edit, bash, grep, glob, apply_patch, update_plan)
|
||||
- OpenAI-compatible provider
|
||||
- Basic Bubble Tea TUI
|
||||
- Permission gate (read vs mutate)
|
||||
- Session persistence
|
||||
|
||||
**Exit:** `zero` runs, can read/edit files, basic chat works.
|
||||
|
||||
### M1 — Multi-Provider (Weeks 3-4)
|
||||
- Anthropic provider
|
||||
- Gemini provider
|
||||
- Model registry
|
||||
- Per-task model selection
|
||||
- Provider rotation
|
||||
- Cost tracking
|
||||
- Headless `exec` mode
|
||||
|
||||
**Exit:** Switch between Claude/GPT/Gemini, see cost, run headless.
|
||||
|
||||
### M2 — Polish (Weeks 5-8)
|
||||
- Advanced TUI features
|
||||
- Full markdown + syntax highlighting
|
||||
- Tool result truncation/pagination
|
||||
- Auto-compaction
|
||||
- Context budgeting
|
||||
- 20 slash commands
|
||||
- Doctor command
|
||||
- Search command
|
||||
|
||||
**Exit:** Production-ready TUI, smooth streaming, all commands work.
|
||||
|
||||
### M3 — Distribution (Weeks 9-10)
|
||||
- Single binary build
|
||||
- Self-update
|
||||
- Cross-platform CI
|
||||
- VS Code extension MVP
|
||||
- Documentation site
|
||||
|
||||
**Exit:** `zero` ships as one binary, works on Win/Mac/Linux.
|
||||
|
||||
### M4 — Extensibility (Weeks 11-14)
|
||||
- MCP client + permissions
|
||||
- Hooks system
|
||||
- Skills system
|
||||
- Memory (AGENTS.md)
|
||||
- Plugin loading
|
||||
- Subagents (built-in: review, security)
|
||||
|
||||
**Exit:** Plugins work, MCP servers connect, memory persists.
|
||||
|
||||
### M5 — Advanced (Weeks 15-20)
|
||||
- Web fetch/search
|
||||
- Git worktrees
|
||||
- Self-verification
|
||||
- Diagnostics (LSP, typecheck)
|
||||
- Testing integration
|
||||
- Code review automation
|
||||
|
||||
**Exit:** Zero can review code, run tests, manage git.
|
||||
|
||||
### M6 — Sandbox & Security (Weeks 21-24)
|
||||
- Linux sandbox (bubblewrap)
|
||||
- macOS sandbox
|
||||
- Windows AppContainer
|
||||
- Network policies
|
||||
- Filesystem policies
|
||||
|
||||
**Exit:** Untrusted code runs safely in sandbox.
|
||||
|
||||
### Beyond v1.0
|
||||
- Multi-modal (images, PDFs)
|
||||
- Voice input
|
||||
- Cloud sync (optional)
|
||||
- Team features
|
||||
- Marketplace (if demand)
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks & Mitigations
|
||||
|
||||
| # | Risk | Mitigation |
|
||||
|---|------|------------|
|
||||
| R1 | Scope creep | Stick to milestones, defer features |
|
||||
| R2 | Provider API changes | Abstract via interface, test with mocks |
|
||||
| R3 | TUI complexity | Use Bubble Tea/Lip Gloss, follow mature terminal agent UX patterns |
|
||||
| R4 | Performance | Benchmark early, optimize streaming |
|
||||
| R5 | Security vulnerabilities | Permission gate from day 1, security review |
|
||||
| R6 | Cross-platform bugs | CI on Win/Mac/Linux from M0 |
|
||||
| R7 | Breaking changes | Schema versioning, migration tools |
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Metrics
|
||||
|
||||
### v1.0 (6 months)
|
||||
- 5+ providers supported
|
||||
- 12+ tools
|
||||
- 100% test coverage on core
|
||||
- < 300ms cold start
|
||||
- Single binary < 50MB
|
||||
- Works on Win/Mac/Linux
|
||||
- 1000+ GitHub stars
|
||||
- 50+ contributors
|
||||
|
||||
### v2.0 (12 months)
|
||||
- VS Code extension published
|
||||
- MCP server mode
|
||||
- Plugin ecosystem (20+ plugins)
|
||||
- 10K+ active users
|
||||
- Compete with Claude Code on benchmarks
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of Scope (v1.0)
|
||||
|
||||
- Cloud sync / hosted backend
|
||||
- Relay / remote SSH execution
|
||||
- Multi-agent mission mode
|
||||
- Plugin marketplace
|
||||
- Auto-generated wikis
|
||||
- Git authorship notes
|
||||
- Vendor telemetry
|
||||
- Voice input
|
||||
- Mobile support
|
||||
|
||||
---
|
||||
|
||||
## 11. Open Decisions
|
||||
|
||||
1. **TUI library:** Bubble Tea + Lip Gloss — **Decide: Go-native TUI** (single-binary friendly, mature terminal ecosystem)
|
||||
2. **Storage:** JSONL files vs SQLite — **Decide: JSONL** (inspectable, simple)
|
||||
3. **Package structure:** Single vs monorepo — **Decide: Single** until M4
|
||||
4. **Provider abstraction:** Direct API vs wrapper — **Decide: Direct** (portability)
|
||||
5. **Patch strategy:** apply_patch vs edit_file — **Decide: Both** (apply_patch for bulk, edit_file for small)
|
||||
|
||||
---
|
||||
|
||||
## 12. Team & Timeline
|
||||
|
||||
**Team (3 people):**
|
||||
- You (lead + TUI + tools)
|
||||
- Backend dev (providers + core)
|
||||
- Designer (VS Code extension + docs)
|
||||
|
||||
**Timeline:**
|
||||
- M0-M1: 4 weeks (foundation)
|
||||
- M2-M3: 6 weeks (polish + distribution)
|
||||
- M4-M5: 8 weeks (extensibility)
|
||||
- M6: 4 weeks (sandbox)
|
||||
- **Total v1.0: 6 months**
|
||||
|
||||
---
|
||||
|
||||
## 13. Why This Will Work
|
||||
|
||||
1. **Proven patterns** — Based on clean-room analysis of existing terminal coding-agent UX patterns
|
||||
2. **Multi-provider** — Unique advantage vs Claude Code/Cursor
|
||||
3. **Open source** — Community contributions
|
||||
4. **Go** — Native binary, fast startup, strong concurrency, cross-platform CLI ergonomics
|
||||
5. **npm distribution wrapper** — Keeps JavaScript ecosystem installation while the runtime stays native
|
||||
6. **Clear milestones** — Ship early, iterate
|
||||
|
||||
---
|
||||
|
||||
**This is the real PRD. 13 sections, 24 features, 6 months to v1.0.**
|
||||
@@ -1,53 +0,0 @@
|
||||
# Zero Stream-JSON Protocol
|
||||
|
||||
Zero stream-json is a line-delimited protocol for headless clients such as
|
||||
editor extensions and automation wrappers.
|
||||
|
||||
Every line is one JSON object. Empty input lines are ignored. Output events are
|
||||
redacted before they are written to stdout.
|
||||
|
||||
## Version
|
||||
|
||||
Current schema version: `1`
|
||||
|
||||
Every input and output event must include:
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 1, "type": "..." }
|
||||
```
|
||||
|
||||
Output events also include `runId`.
|
||||
|
||||
## Input Events
|
||||
|
||||
`zero exec --input-format stream-json` accepts these JSONL events from stdin or
|
||||
`--file`.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 1, "type": "message", "role": "user", "content": "Inspect this repo." }
|
||||
{ "schemaVersion": 1, "type": "prompt", "content": "Return only blockers." }
|
||||
```
|
||||
|
||||
Zero combines accepted input event content in order, separated by blank lines.
|
||||
Unknown fields are rejected so protocol clients catch drift early.
|
||||
|
||||
## Output Events
|
||||
|
||||
`zero exec --output-format stream-json` emits schema-versioned JSONL events.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 1, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
|
||||
{ "schemaVersion": 1, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
|
||||
{ "schemaVersion": 1, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
|
||||
{ "schemaVersion": 1, "type": "tool_result", "runId": "run_20260603_abc123", "id": "call_1", "status": "ok", "output": "...", "truncated": false }
|
||||
{ "schemaVersion": 1, "type": "usage", "runId": "run_20260603_abc123", "promptTokens": 12, "completionTokens": 8, "totalTokens": 20 }
|
||||
{ "schemaVersion": 1, "type": "final", "runId": "run_20260603_abc123", "text": "..." }
|
||||
{ "schemaVersion": 1, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
|
||||
```
|
||||
|
||||
Errors are part of the protocol and are followed by `run_end`.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 1, "type": "error", "runId": "run_20260603_abc123", "code": "provider_error", "message": "...", "recoverable": false }
|
||||
{ "schemaVersion": 1, "type": "run_end", "runId": "run_20260603_abc123", "status": "error", "exitCode": 3 }
|
||||
```
|
||||
@@ -1,21 +0,0 @@
|
||||
# Zero Update Flow
|
||||
|
||||
`zero update --check` checks the latest GitHub release for `Gitlawb/zero` and compares it with the local CLI version.
|
||||
|
||||
For M2 this command is intentionally check-only:
|
||||
|
||||
- It does not replace the running binary.
|
||||
- It exits with code `0` when the check succeeds, even when an update is available.
|
||||
- It exits with code `1` when the release check cannot be completed.
|
||||
- `--json` prints the same result in a machine-readable format for scripts and CI.
|
||||
- Release checks time out after 5 seconds by default.
|
||||
|
||||
The release endpoint resolves in this order:
|
||||
|
||||
- `options.endpoint` when calling `checkForUpdate` from code.
|
||||
- `ZERO_UPDATE_RELEASE_URL` from the environment.
|
||||
- `https://api.github.com/repos/Gitlawb/zero/releases/latest`.
|
||||
|
||||
`options.endpoint` and `ZERO_UPDATE_RELEASE_URL` may be a full URL or an `owner/repo` slug.
|
||||
|
||||
Installer scripts download the matching release asset for the local platform and verify its `.sha256` file. If Zero is already installed, use this command before re-running the installer.
|
||||
@@ -1,572 +0,0 @@
|
||||
# Zero WorkSplit PRD v2: Balanced And Conflict-Free Team Plan
|
||||
|
||||
Status: Draft v2.0
|
||||
Date: 2026-06-02
|
||||
Timeline: 6 months to v1.0
|
||||
Team: Vasanth, Gnanam, Anandan
|
||||
|
||||
This v2 replaces the conflict points in the original WorkSplit PRD:
|
||||
|
||||
- UI/TUI surfaces are separated from backend capabilities.
|
||||
- Headless `exec` is split into CLI surface, runtime protocol, and distribution responsibilities.
|
||||
- Search is scheduled after a minimal session event store exists.
|
||||
- Slash commands are split into command UI, backend APIs, and integration commands.
|
||||
- Anandan's workload is narrowed to infra/distribution/integrations, with clear dependencies on Gnanam runtime contracts.
|
||||
- Each milestone gives all 3 people similar effort and reviewable PR slices.
|
||||
|
||||
## Product Goal
|
||||
|
||||
Zero is a production coding agent CLI/TUI that can:
|
||||
|
||||
- Chat and stream responses in a polished terminal UI.
|
||||
- Read, write, edit, search, and run tools safely.
|
||||
- Use OpenAI-compatible, Anthropic, and Gemini providers.
|
||||
- Track context and cost.
|
||||
- Run headless for scripts, automation, CI, and VS Code.
|
||||
- Persist, resume, fork, search, and export sessions.
|
||||
- Connect to MCP servers and load local plugins.
|
||||
- Run tests, manage git, self-verify changes, and operate inside sandbox policies.
|
||||
|
||||
## Ownership Rules
|
||||
|
||||
These rules prevent future conflicts:
|
||||
|
||||
1. Every feature has one Directly Responsible Owner.
|
||||
2. UI/TUI commands and backend modules are separate deliverables.
|
||||
3. Cross-owner features must land the backend contract before UI/integration work depends on it.
|
||||
4. Provider-specific logic stays inside provider modules.
|
||||
5. Session/event protocol is owned by Gnanam and consumed by TUI, VS Code, MCP, and automation.
|
||||
6. Distribution work packages stable behavior; it does not define runtime semantics.
|
||||
7. Permission prompts are Vasanth's UI, but grants/policy/sandbox enforcement are Gnanam's backend.
|
||||
8. Windows platform implementation is Anandan's, but it must use the shared sandbox policy interface.
|
||||
|
||||
## Team Roles
|
||||
|
||||
| Person | Primary role | Owns | Does not own |
|
||||
|---|---|---|---|
|
||||
| Vasanth | Product lead, TUI, tools UX | Bubble Tea TUI, slash command UI, tool result rendering, local tool UX, permission prompts, themes, command palette, user-facing flows | Provider internals, session protocol, MCP transports, release pipeline |
|
||||
| Gnanam | Runtime backend, providers, protocols | Model registry, provider factory, Anthropic/Gemini, usage/cost, session store, stream-json, config validation, doctor/search backend, MCP/plugin backend, permissions/grants, sandbox policy | TUI rendering, binary packaging, VS Code extension UI |
|
||||
| Anandan | Infra, distribution, external integrations | CI, build, single binary, release packages, installers, self-update, performance benchmarks, VS Code extension, GitHub PR integration, Windows sandbox, release security | Provider internals, TUI command rendering, model registry |
|
||||
|
||||
## Shared Contracts
|
||||
|
||||
These contracts are required before dependent work starts:
|
||||
|
||||
| Contract | Owner | Consumers | Needed by |
|
||||
|---|---|---|---|
|
||||
| Provider stream event schema | Gnanam | Vasanth, Anandan | TUI streaming, headless output, VS Code |
|
||||
| Model registry API | Gnanam | Vasanth | `/model`, `/effort`, cost, context budget |
|
||||
| Session event schema | Gnanam | Vasanth, Anandan | `/resume`, `/rewind`, search, VS Code, audit |
|
||||
| Command registry shape | Vasanth | Gnanam, Anandan | Slash command registration and help |
|
||||
| Build/test scripts | Anandan | Vasanth, Gnanam | CI, DoD, release |
|
||||
| Permission/grant policy | Gnanam | Vasanth, Anandan | prompts, MCP permissions, sandbox, Windows |
|
||||
| Stream-json protocol | Gnanam | Anandan | VS Code extension and automation |
|
||||
|
||||
## Milestone M0: Foundation Baseline, Weeks 1-2
|
||||
|
||||
Goal: Zero runs locally, has basic tools, one OpenAI-compatible provider, basic TUI, tests, and project scripts.
|
||||
|
||||
Current status: the earlier TS/Bun M0 baseline is historical context only. The Go migration resets M0 around a new Go module, `cmd/zero` entrypoint, Bubble Tea TUI shell, core tools, tests, and validation commands. M1 work should start only after these Go foundation PRs are merged.
|
||||
|
||||
### Vasanth
|
||||
|
||||
- Tool interface with JSON Schema-compatible parameter validation.
|
||||
- Core file tools: read, write, edit, grep, list directory, bash, plan.
|
||||
- Basic Bubble Tea TUI and message/tool rendering.
|
||||
- Basic agent loop integration.
|
||||
- PR: `feat/go-m0-tools-tui`.
|
||||
|
||||
### Gnanam
|
||||
|
||||
- Define the first Go provider contract for the M0 OpenAI-compatible provider.
|
||||
- Define first draft of normalized `Usage` type.
|
||||
- Define first draft of model registry type.
|
||||
- Review config loader and list missing provider/config fields.
|
||||
- PR: `feat/go-m0-runtime-contracts`.
|
||||
|
||||
### Anandan
|
||||
|
||||
- Add stable project commands: `go test ./...`, `go build ./cmd/zero`, and the npm wrapper smoke checklist.
|
||||
- Add initial CI smoke job.
|
||||
- Verify Go toolchain version, module cache behavior, and npm wrapper lockfile behavior.
|
||||
- Add build artifact smoke check.
|
||||
- PR: `feat/m0-ci-scripts`.
|
||||
|
||||
### M0 Done
|
||||
|
||||
- `go test ./...` passes.
|
||||
- `go build ./cmd/zero` passes.
|
||||
- npm wrapper smoke checklist exists when npm packaging files are touched.
|
||||
- `zero` can run, read a file, edit a file, and stream a response.
|
||||
|
||||
### npm Wrapper Smoke Checklist
|
||||
|
||||
When npm distribution files are changed, the smoke check must verify:
|
||||
|
||||
- `package.json` exists with the expected package name and version.
|
||||
- `npm ci` succeeds from the wrapper package directory.
|
||||
- The wrapper binary resolves through the package `bin` entry and `node_modules/.bin`.
|
||||
- `zero --version` or `zero --help` exits 0 and reports the expected version or command surface.
|
||||
- The designated npm smoke/build script exits 0.
|
||||
|
||||
## Milestone M1: Multi-Provider And Headless Foundation, Weeks 3-4
|
||||
|
||||
Goal: Zero can select models/providers, use Claude/Gemini/OpenAI-compatible providers, and run headless with stable output.
|
||||
|
||||
### Vasanth: TUI And CLI Surface
|
||||
|
||||
- Model selector UI and `/model` command shell.
|
||||
- Reasoning effort UI and `/effort` command shell.
|
||||
- Header/footer model display.
|
||||
- Streaming output polish in TUI.
|
||||
- Headless command surface: parse `zero exec`, flags, output mode selection.
|
||||
- Exit code mapping display and CLI user messages.
|
||||
- PRs:
|
||||
- `feat/m1-model-selector-ui`
|
||||
- `feat/m1-headless-cli-surface`
|
||||
|
||||
### Gnanam: Provider Runtime
|
||||
|
||||
- Model registry with 10+ models, cost, context limits, capabilities.
|
||||
- Provider factory that resolves model/profile/provider.
|
||||
- Anthropic provider with streaming text, tool calls, system prompt, usage.
|
||||
- Gemini provider with streaming text, tool calls, usage, vision-ready input shape.
|
||||
- Normalized provider event schema and usage type.
|
||||
- PRs:
|
||||
- `feat/m1-model-registry`
|
||||
- `feat/m1-provider-factory`
|
||||
- `feat/m1-anthropic-provider`
|
||||
- `feat/m1-gemini-provider`
|
||||
|
||||
### Anandan: Build And CI
|
||||
|
||||
- Single binary build spike with Go cross-compilation.
|
||||
- CI matrix for Linux/macOS/Windows test and build smoke.
|
||||
- Release packaging formats: tar.gz for Unix, zip for Windows.
|
||||
- Artifact upload on tags or manual workflow.
|
||||
- PRs:
|
||||
- `feat/m1-ci-matrix`
|
||||
- `feat/m1-binary-build`
|
||||
- `feat/m1-release-packaging`
|
||||
|
||||
### M1 Dependency Order
|
||||
|
||||
1. Gnanam lands model registry.
|
||||
2. Gnanam lands provider factory.
|
||||
3. Vasanth connects model selector to registry.
|
||||
4. Gnanam lands Anthropic/Gemini.
|
||||
5. Vasanth finishes provider-aware TUI/headless surface.
|
||||
6. Anandan packages once scripts and entrypoints are stable.
|
||||
|
||||
### M1 Done
|
||||
|
||||
- `zero exec -m <model> "list files"` runs through provider factory.
|
||||
- OpenAI-compatible provider still works.
|
||||
- Anthropic and Gemini providers pass mocked stream tests.
|
||||
- Model selector reads registry data.
|
||||
- CI runs tests and build checks on PR.
|
||||
- Binary build has at least one working platform artifact or documented blocker.
|
||||
|
||||
## Milestone M2: Core Commands, Cost, Observability, Install Flow, Weeks 5-8
|
||||
|
||||
Goal: Core slash commands work, cost and diagnostics are visible, sessions have a minimal event store, and installation/update flows begin.
|
||||
|
||||
### Vasanth: Core Command UX
|
||||
|
||||
- Slash command framework and help registry.
|
||||
- Session commands UI: `/clear`, `/compact`, `/context`, `/resume`, `/exit`.
|
||||
- Model commands UI: `/model`, `/effort`, `/style`.
|
||||
- Meta commands UI: `/help`, `/config`, `/doctor`.
|
||||
- Tool result truncation and pagination UI.
|
||||
- Auto-compaction prompt UX.
|
||||
- PRs:
|
||||
- `feat/m2-slash-framework`
|
||||
- `feat/m2-session-model-commands`
|
||||
- `feat/m2-tool-truncation`
|
||||
- `feat/m2-compaction-ui`
|
||||
|
||||
### Gnanam: Observability Backend
|
||||
|
||||
- Minimal session event store to support cost/search/resume later.
|
||||
- Cost tracking from normalized usage and model registry.
|
||||
- `zero doctor` backend checks: binary, config, provider, connectivity, model validity.
|
||||
- `zero search` backend over local session events.
|
||||
- Config inspection and validation API for `/config`.
|
||||
- Secret redaction helper used by logs, provider errors, doctor, feedback.
|
||||
- PRs:
|
||||
- `feat/m2-minimal-session-events`
|
||||
- `feat/m2-cost-tracking`
|
||||
- `feat/m2-doctor`
|
||||
- `feat/m2-search`
|
||||
- `feat/m2-secret-redaction`
|
||||
|
||||
### Anandan: Install, Update, Performance
|
||||
|
||||
- Self-update design and `zero update --check`.
|
||||
- Install scripts for Linux/macOS and PowerShell draft for Windows.
|
||||
- Performance benchmark harness: cold start, TTFT, memory.
|
||||
- CI performance smoke job with threshold warnings.
|
||||
- Release checksum generation.
|
||||
- PRs:
|
||||
- `feat/m2-update-check`
|
||||
- `feat/m2-install-scripts`
|
||||
- `feat/m2-perf-bench`
|
||||
- `feat/m2-release-checksums`
|
||||
|
||||
### M2 Command Scope
|
||||
|
||||
Fully working in M2:
|
||||
|
||||
- `/clear`
|
||||
- `/compact`
|
||||
- `/context`
|
||||
- `/resume`
|
||||
- `/exit`
|
||||
- `/model`
|
||||
- `/effort`
|
||||
- `/style`
|
||||
- `/help`
|
||||
- `/config`
|
||||
- `/doctor`
|
||||
|
||||
Registered but full backend lands later:
|
||||
|
||||
- `/rewind`: full in M3 with session event replay.
|
||||
- `/permissions`: full in M4/M6 with permission/grant system.
|
||||
- `/init`, `/memory`, `/skills`: full in M4 with memory/skills.
|
||||
- `/mcp`, `/hooks`, `/agents`: full in M4 with MCP/hooks/subagents.
|
||||
- `/feedback`: full in M3/M5 with integration/redaction.
|
||||
|
||||
### M2 Done
|
||||
|
||||
- TUI command framework is stable.
|
||||
- Core 11 commands work.
|
||||
- Cost shows in footer/session summary.
|
||||
- `zero doctor` gives redacted pass/warn/fail checks.
|
||||
- `zero search` can search persisted local events.
|
||||
- Install scripts and update check have smoke tests.
|
||||
|
||||
## Milestone M3: Sessions, Stream Protocol, VS Code MVP, Weeks 9-10
|
||||
|
||||
Goal: Sessions are durable, stream-json is stable, and VS Code can drive Zero through the protocol.
|
||||
|
||||
### Vasanth: TUI Session UX
|
||||
|
||||
- Themes: light, dark, custom config.
|
||||
- Mouse support for selectors and command palette where feasible.
|
||||
- Collapsible tool rows with one-line summaries.
|
||||
- `/rewind` interactive UI using Gnanam's session event API.
|
||||
- Session picker polish for `/resume` and fork display.
|
||||
- PRs:
|
||||
- `feat/m3-themes`
|
||||
- `feat/m3-tool-rows`
|
||||
- `feat/m3-rewind-ui`
|
||||
|
||||
### Gnanam: Headless And Sessions
|
||||
|
||||
- Full session persistence under `~/.local/share/zero/sessions/<id>/`.
|
||||
- Append-only `events.jsonl`.
|
||||
- `--resume` and `--fork` support.
|
||||
- Stream-json input/output protocol with schema tests.
|
||||
- Config validation errors with source paths.
|
||||
- Session search indexing improvements.
|
||||
- PRs:
|
||||
- `feat/m3-sessions`
|
||||
- `feat/m3-resume-fork`
|
||||
- `feat/m3-stream-json`
|
||||
- `feat/m3-config-validation`
|
||||
|
||||
### Anandan: VS Code And Docs Distribution
|
||||
|
||||
- VS Code extension MVP using stream-json.
|
||||
- Inline chat panel.
|
||||
- Diff view integration.
|
||||
- Homebrew tap or formula draft.
|
||||
- Documentation site skeleton with install and CLI docs.
|
||||
- PRs:
|
||||
- `feat/m3-vscode-mvp`
|
||||
- `feat/m3-homebrew`
|
||||
- `feat/m3-docs-site`
|
||||
|
||||
### M3 Dependency Order
|
||||
|
||||
1. Gnanam freezes stream-json event schema.
|
||||
2. Anandan builds VS Code against that schema.
|
||||
3. Gnanam lands session resume/fork.
|
||||
4. Vasanth wires `/rewind` and session picker.
|
||||
5. Anandan documents protocol and install path.
|
||||
|
||||
### M3 Done
|
||||
|
||||
- Sessions survive process restart.
|
||||
- `zero exec --resume <id>` works.
|
||||
- `zero exec --fork <id>` creates a new session branch.
|
||||
- Stream-json is line-delimited and schema-tested.
|
||||
- VS Code MVP can send prompt and render streamed text/tool events.
|
||||
|
||||
## Milestone M4: Extensibility, MCP, Skills, Subagents, Weeks 11-14
|
||||
|
||||
Goal: Users can extend Zero with memory, skills, hooks, MCP servers, plugins, and subagents.
|
||||
|
||||
### Vasanth: Extensibility UX
|
||||
|
||||
- `/init` generates project memory scaffold.
|
||||
- `/memory` inspect/reload UI.
|
||||
- `/skills` manager UI.
|
||||
- `/hooks` manager UI.
|
||||
- `/mcp` manager UI shell.
|
||||
- `/agents` selector UI.
|
||||
- PRs:
|
||||
- `feat/m4-memory-ui`
|
||||
- `feat/m4-skills-ui`
|
||||
- `feat/m4-extensibility-commands`
|
||||
|
||||
### Gnanam: MCP And Plugin Backend
|
||||
|
||||
- MCP client with stdio, HTTP, and SSE transports.
|
||||
- Surface MCP tools in Zero tool registry.
|
||||
- MCP permissions: per-server and per-tool grants, list/revoke/clear.
|
||||
- Local plugin loader from `~/.config/zero/plugins/` and `.zero/plugins/`.
|
||||
- Plugin manifest validation with `plugin.json`.
|
||||
- MCP server mode: `zero serve --mcp`.
|
||||
- Hook config storage and audit events.
|
||||
- PRs:
|
||||
- `feat/m4-mcp-client`
|
||||
- `feat/m4-mcp-permissions`
|
||||
- `feat/m4-plugin-loader`
|
||||
- `feat/m4-mcp-server`
|
||||
- `feat/m4-hook-backend`
|
||||
|
||||
### Anandan: Subagents And Integration Docs
|
||||
|
||||
- Subagent framework using Gnanam's parent/child session linkage.
|
||||
- `task` tool implementation for scoped subagents.
|
||||
- Built-in agents: code-review, security-review, refactor, test-gen.
|
||||
- VS Code support for subagent session tree.
|
||||
- Extensibility docs for MCP/plugins/agents.
|
||||
- PRs:
|
||||
- `feat/m4-subagent-framework`
|
||||
- `feat/m4-builtin-agents`
|
||||
- `feat/m4-vscode-agents`
|
||||
- `feat/m4-extensibility-docs`
|
||||
|
||||
### M4 Dependency Order
|
||||
|
||||
1. Gnanam lands MCP tool registry integration.
|
||||
2. Vasanth wires `/mcp` UI to backend.
|
||||
3. Gnanam lands parent/child session APIs.
|
||||
4. Anandan lands subagent framework.
|
||||
5. Vasanth wires `/agents` UI.
|
||||
|
||||
### M4 Done
|
||||
|
||||
- User can add and call an MCP tool.
|
||||
- User can list/revoke/clear MCP permissions.
|
||||
- User can load a local plugin manifest.
|
||||
- User can run `zero serve --mcp`.
|
||||
- User can spawn a scoped subagent through `task`.
|
||||
- Extensibility commands are fully functional.
|
||||
|
||||
## Milestone M5: Advanced Agent Operations, Weeks 15-20
|
||||
|
||||
Goal: Zero can verify work, manage git/test flows, and assist code review.
|
||||
|
||||
### Vasanth: Advanced Tool UX
|
||||
|
||||
- `web_fetch` and `web_search` tools with safe display and truncation.
|
||||
- LSP diagnostics display in TUI.
|
||||
- Typecheck/lint result rendering.
|
||||
- Rich test failure rendering in TUI.
|
||||
- Review summary UI for agent findings.
|
||||
- PRs:
|
||||
- `feat/m5-web-tools-ui`
|
||||
- `feat/m5-diagnostics-ui`
|
||||
- `feat/m5-verification-ui`
|
||||
|
||||
### Gnanam: Git And Verification Backend
|
||||
|
||||
- Git worktree isolation per task.
|
||||
- Auto-commit backend: detect changes and generate commit messages.
|
||||
- Test runner detection and execution.
|
||||
- Parse test results into structured summaries.
|
||||
- Self-verification loop: run checks after edits, retry within budget.
|
||||
- PRs:
|
||||
- `feat/m5-worktrees`
|
||||
- `feat/m5-autocommit`
|
||||
- `feat/m5-test-runner`
|
||||
- `feat/m5-self-verify`
|
||||
|
||||
### Anandan: GitHub And Review Automation
|
||||
|
||||
- GitHub API integration for PR metadata and comments.
|
||||
- Automated code-review workflow using built-in agent.
|
||||
- Security-review workflow.
|
||||
- Dependency update detection.
|
||||
- CI integration for agent-generated reports.
|
||||
- PRs:
|
||||
- `feat/m5-github-integration`
|
||||
- `feat/m5-code-review-agent`
|
||||
- `feat/m5-security-review-agent`
|
||||
- `feat/m5-dependency-updates`
|
||||
|
||||
### M5 Dependency Order
|
||||
|
||||
1. Gnanam lands git/test backend APIs.
|
||||
2. Vasanth renders verification output.
|
||||
3. Anandan builds GitHub PR review workflow on git/test/session APIs.
|
||||
4. Anandan publishes CI report integration.
|
||||
|
||||
### M5 Done
|
||||
|
||||
- Zero can create/use a git worktree for a task.
|
||||
- Zero can run project tests and parse failures.
|
||||
- Zero can self-verify after edits.
|
||||
- Zero can produce a code-review report.
|
||||
- GitHub PR comment posting works when configured.
|
||||
|
||||
## Milestone M6: Sandbox And Security, Weeks 21-24
|
||||
|
||||
Goal: Zero can safely run higher-autonomy workflows with OS-aware sandboxing and durable permission grants.
|
||||
|
||||
### Vasanth: Security UX
|
||||
|
||||
- Permission prompts: allow, deny, always allow, always deny.
|
||||
- Autonomy level UI: low, medium, high.
|
||||
- Permission audit log view.
|
||||
- Sandbox violation rendering.
|
||||
- Security warnings in TUI and headless text output.
|
||||
- PRs:
|
||||
- `feat/m6-permission-prompts`
|
||||
- `feat/m6-autonomy-ui`
|
||||
- `feat/m6-audit-ui`
|
||||
|
||||
### Gnanam: Sandbox Backend
|
||||
|
||||
- Shared sandbox policy interface.
|
||||
- Linux sandbox using bubblewrap.
|
||||
- macOS sandbox using sandbox-exec or best available policy backend.
|
||||
- Persistent grants: allow/deny list/revoke/clear.
|
||||
- Network and filesystem policy enforcement.
|
||||
- Structured sandbox violation errors.
|
||||
- PRs:
|
||||
- `feat/m6-sandbox-policy`
|
||||
- `feat/m6-sandbox-linux`
|
||||
- `feat/m6-sandbox-macos`
|
||||
- `feat/m6-persistent-grants`
|
||||
|
||||
### Anandan: Windows And Release Security
|
||||
|
||||
- Windows sandbox using AppContainer or best supported Windows isolation.
|
||||
- Release signing/checksum verification.
|
||||
- Security CI hardening.
|
||||
- Dependency vulnerability scanning.
|
||||
- Security audit and penetration test coordination.
|
||||
- PRs:
|
||||
- `feat/m6-sandbox-windows`
|
||||
- `feat/m6-release-signing`
|
||||
- `feat/m6-security-ci`
|
||||
- `feat/m6-security-audit`
|
||||
|
||||
### M6 Dependency Order
|
||||
|
||||
1. Gnanam lands shared sandbox policy interface.
|
||||
2. Vasanth wires prompts to the shared policy.
|
||||
3. Gnanam implements Linux/macOS policy adapters.
|
||||
4. Anandan implements Windows adapter using same policy.
|
||||
5. Anandan finalizes release security.
|
||||
|
||||
### M6 Done
|
||||
|
||||
- `zero --auto high` uses permission policy and sandbox backend.
|
||||
- Persistent grants work across sessions.
|
||||
- Linux/macOS sandboxing works or has documented OS-specific fallback.
|
||||
- Windows sandbox adapter works or has documented fallback.
|
||||
- Security audit findings are triaged and fixed or accepted.
|
||||
|
||||
## Slash Command Ownership Matrix
|
||||
|
||||
| Command | UI owner | Backend owner | Fully done by | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `/clear` | Vasanth | Gnanam | M2 | New session/reset event. |
|
||||
| `/compact` | Vasanth | Gnanam | M2 | Requires token/cost/context backend. |
|
||||
| `/context` | Vasanth | Gnanam | M2 | Registry + usage + tool/MCP context. |
|
||||
| `/rewind` | Vasanth | Gnanam | M3 | Requires durable event store. |
|
||||
| `/resume` | Vasanth | Gnanam | M3 | M2 can list minimal sessions; M3 full resume/fork. |
|
||||
| `/exit` | Vasanth | Gnanam | M2 | Must flush session-end event. |
|
||||
| `/model` | Vasanth | Gnanam | M1/M2 | Registry lands M1, UI polished M2. |
|
||||
| `/effort` | Vasanth | Gnanam | M1/M2 | Reasoning metadata from registry. |
|
||||
| `/style` | Vasanth | Vasanth | M2 | UI/config preference only unless style affects prompts. |
|
||||
| `/permissions` | Vasanth | Gnanam | M4/M6 | MCP permissions in M4, sandbox grants in M6. |
|
||||
| `/init` | Vasanth | Vasanth | M4 | Project memory scaffold. |
|
||||
| `/memory` | Vasanth | Vasanth | M4 | Memory inspect/reload. |
|
||||
| `/skills` | Vasanth | Gnanam | M4 | UI by Vasanth; plugin-provided skills backend by Gnanam. |
|
||||
| `/mcp` | Vasanth | Gnanam | M4 | MCP client/server/backend by Gnanam. |
|
||||
| `/hooks` | Vasanth | Gnanam | M4 | UI by Vasanth; hook config/audit backend by Gnanam. |
|
||||
| `/agents` | Vasanth | Anandan | M4 | Requires Gnanam parent/child session API. |
|
||||
| `/help` | Vasanth | Vasanth | M2 | Command metadata registry. |
|
||||
| `/config` | Vasanth | Gnanam | M2/M3 | Inspect in M2, validation source errors in M3. |
|
||||
| `/doctor` | Vasanth | Gnanam | M2 | Backend CLI and TUI share same checks. |
|
||||
| `/feedback` | Vasanth | Anandan | M3/M5 | Redaction by Gnanam; GitHub/product integration by Anandan. |
|
||||
|
||||
## Headless Exec Ownership
|
||||
|
||||
Headless `exec` is split to avoid conflict:
|
||||
|
||||
| Area | Owner | Scope |
|
||||
|---|---|---|
|
||||
| CLI command and flags | Vasanth | `zero exec`, `--model`, `--output-format`, help text, exit message display. |
|
||||
| Runtime execution | Gnanam | Provider factory, stream events, usage, tool loop hooks, session recording, stream-json. |
|
||||
| Packaging and CI | Anandan | Binary command works across platforms, CI tests headless modes, release artifacts. |
|
||||
|
||||
## Permission Ownership
|
||||
|
||||
| Area | Owner | Scope |
|
||||
|---|---|---|
|
||||
| Prompt UI | Vasanth | Allow/deny/always controls and TUI display. |
|
||||
| Policy engine | Gnanam | Risk classification, grants, persistent decisions, MCP permissions, sandbox policy. |
|
||||
| Platform adapters | Gnanam + Anandan | Gnanam owns Linux/macOS; Anandan owns Windows using same policy. |
|
||||
|
||||
## Definition Of Done Per PR
|
||||
|
||||
Every PR must include:
|
||||
|
||||
- Tests for changed behavior.
|
||||
- `go test ./...` must pass when the PR modifies any Go code; docs-only PRs are exempt and must say so in the PR description.
|
||||
- `go build ./cmd/zero` must pass when the PR modifies `cmd/zero` or runtime packages used by the entrypoint; docs-only and npm-wrapper-only PRs are exempt and must say so in the PR description.
|
||||
- The npm wrapper smoke checklist must pass when the PR modifies npm distribution files.
|
||||
- PR description explains what changed and why.
|
||||
- No unrelated refactors.
|
||||
- Redaction used for secrets in logs/errors/tests.
|
||||
- Cross-owner API changes include a small contract note or test.
|
||||
|
||||
## Review Rotation
|
||||
|
||||
| Author | Primary reviewer | Secondary reviewer when cross-owner |
|
||||
|---|---|---|
|
||||
| Vasanth | Gnanam | Anandan for distribution/VS Code impact |
|
||||
| Gnanam | Anandan | Vasanth for TUI/UX impact |
|
||||
| Anandan | Vasanth | Gnanam for runtime/protocol impact |
|
||||
|
||||
## Balanced Workload Summary
|
||||
|
||||
| Milestone | Vasanth load | Gnanam load | Anandan load |
|
||||
|---|---|---|---|
|
||||
| M0 | Core tools + TUI baseline | Runtime contracts | Scripts + CI baseline |
|
||||
| M1 | Model/headless UI | Registry + providers | CI + binary + packaging |
|
||||
| M2 | Core slash UX + compaction UI | Cost + doctor + search + events | Install + update + perf |
|
||||
| M3 | Session UI + themes | Sessions + stream-json | VS Code + docs + Homebrew |
|
||||
| M4 | Extensibility UI | MCP + plugins + permissions | Subagents + agent docs |
|
||||
| M5 | Verification/web UX | Git + tests + self-verify | GitHub review automation |
|
||||
| M6 | Security UX | Linux/macOS sandbox + grants | Windows sandbox + release security |
|
||||
|
||||
This keeps workload balanced while preserving clean ownership boundaries.
|
||||
|
||||
## Immediate Next Steps
|
||||
|
||||
1. Reset M0 for the Go migration and treat old TS/Bun PR #6 as historical only.
|
||||
2. Anandan starts `feat/go-m0-module-entrypoint` with `go.mod`, `cmd/zero`, `go test ./...`, and `go build ./cmd/zero`.
|
||||
3. Gnanam starts `feat/go-m0-runtime-contracts` for the Go provider/tool/runtime contracts.
|
||||
4. Vasanth starts `feat/go-m0-tools-tui` for core tools, Bubble Tea startup shell, and basic agent loop integration.
|
||||
5. Anandan adds CI smoke coverage for the Go commands and the npm wrapper smoke checklist when wrapper files land.
|
||||
6. Start M1 model registry, provider factory, and model selector work only after the Go M0 foundation PRs are merged.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Provider catalog examples
|
||||
|
||||
Top-level providers self-publish model families, such as OpenAI with
|
||||
GPT/o-series, Anthropic with Claude/Opus, Google with Gemini, and DeepSeek with
|
||||
DeepSeek models. Gateways and local endpoints normally reference those global
|
||||
models with `globalModelId`.
|
||||
|
||||
## Gateway with discovery and curated models
|
||||
|
||||
```toml
|
||||
id = "my-gateway"
|
||||
name = "My Gateway"
|
||||
kind = "gateway"
|
||||
description = "OpenAI-compatible model gateway"
|
||||
category = "aggregating"
|
||||
baseURL = "https://api.example.com/v1"
|
||||
defaultModel = "vendor/model-name"
|
||||
supportsModelRouting = true
|
||||
apiKeyLabel = "My Gateway API key"
|
||||
apiKeyPlaceholder = "gw_live_..."
|
||||
apiKeyRequired = true
|
||||
credentialEnvVars = ["MY_GATEWAY_API_KEY"]
|
||||
costCurrency = "USD"
|
||||
|
||||
[setup]
|
||||
requiresAuth = true
|
||||
authMode = "api-key"
|
||||
|
||||
[validation]
|
||||
kind = "credential-env"
|
||||
missingCredentialMessage = "An API key is required."
|
||||
matchBaseUrlHosts = ["api.example.com"]
|
||||
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
maxTokensField = "max_completion_tokens"
|
||||
removeBodyFields = ["store", "stream_options"]
|
||||
authHeader.name = "authorization"
|
||||
authHeader.scheme = "bearer"
|
||||
headers.X-Trace-Source = "zero"
|
||||
headers.X-Tenant = "$MY_GATEWAY_TENANT"
|
||||
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discoveryCacheTtl = "1h"
|
||||
discoveryRefreshMode = "manual"
|
||||
allowManualRefresh = true
|
||||
discovery.kind = "openai-compatible"
|
||||
discovery.requiresAuth = true
|
||||
discovery.path = "/models"
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "gpt-4o"
|
||||
apiName = "openai/gpt-4o"
|
||||
contextWindow = 32000
|
||||
maxOutputTokens = 4096
|
||||
cost.inputPerMillion = 2.50
|
||||
cost.outputPerMillion = 10.00
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "claude-sonnet-4"
|
||||
apiName = "anthropic/claude-sonnet-4"
|
||||
contextWindow = 200000
|
||||
maxOutputTokens = 64000
|
||||
cost.inputPerMillion = 3.00
|
||||
cost.outputPerMillion = 15.00
|
||||
|
||||
[preset]
|
||||
id = "my-gateway"
|
||||
description = "My Gateway hosted models"
|
||||
fallbackBaseUrl = "https://api.example.com/v1"
|
||||
fallbackModel = "vendor/model-name"
|
||||
badge.text = "HOSTED"
|
||||
badge.color = "success"
|
||||
```
|
||||
|
||||
## Local Ollama-style provider
|
||||
|
||||
```toml
|
||||
id = "ollama"
|
||||
name = "Ollama"
|
||||
kind = "localhost"
|
||||
description = "Local models via Ollama"
|
||||
category = "local"
|
||||
baseURL = "http://localhost:11434/v1"
|
||||
defaultModel = "llama3.2"
|
||||
apiKeyRequired = false
|
||||
|
||||
[setup]
|
||||
requiresAuth = false
|
||||
authMode = "none"
|
||||
|
||||
[startup]
|
||||
autoDetectable = true
|
||||
probeReadiness = "openai-compatible-models"
|
||||
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
|
||||
[catalog]
|
||||
source = "dynamic"
|
||||
discoveryCacheTtl = "5m"
|
||||
discoveryRefreshMode = "on-open"
|
||||
allowManualRefresh = true
|
||||
discovery.kind = "ollama"
|
||||
discovery.requiresAuth = false
|
||||
|
||||
[[catalog.models]]
|
||||
id = "llama3.2"
|
||||
name = "Llama 3.2"
|
||||
tier = "local"
|
||||
|
||||
[[catalog.models]]
|
||||
id = "mistral"
|
||||
name = "Mistral"
|
||||
tier = "local"
|
||||
```
|
||||
|
||||
## First-party provider
|
||||
|
||||
```toml
|
||||
id = "openai"
|
||||
name = "OpenAI"
|
||||
kind = "provider"
|
||||
description = "OpenAI direct API"
|
||||
category = "hosted"
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
defaultModel = "gpt-4o"
|
||||
isFirstParty = true
|
||||
apiKeyRequired = true
|
||||
credentialEnvVars = ["OPENAI_API_KEY"]
|
||||
costCurrency = "USD"
|
||||
|
||||
[setup]
|
||||
requiresAuth = true
|
||||
authMode = "api-key"
|
||||
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discoveryCacheTtl = "1h"
|
||||
discoveryRefreshMode = "manual"
|
||||
allowManualRefresh = true
|
||||
discovery.kind = "openai-compatible"
|
||||
discovery.requiresAuth = true
|
||||
discovery.path = "/models"
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "gpt-4o"
|
||||
cost.inputPerMillion = 2.50
|
||||
cost.outputPerMillion = 10.00
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "gpt-4o-mini"
|
||||
cost.inputPerMillion = 0.15
|
||||
cost.outputPerMillion = 0.60
|
||||
```
|
||||
@@ -0,0 +1,129 @@
|
||||
# Provider model catalog
|
||||
|
||||
Global model metadata lives in `src/providers/catalog/models`. Files use TOML
|
||||
`[[model]]` entries. Global models describe identity, ownership, limits, and
|
||||
capabilities only; all pricing belongs on provider `[[catalog.models]]` route
|
||||
entries.
|
||||
|
||||
Only top-level providers should add global model metadata. A top-level provider
|
||||
self-publishes the model family, such as OpenAI with GPT/o-series, Anthropic
|
||||
with Claude/Opus, Google with Gemini, or DeepSeek with DeepSeek models.
|
||||
|
||||
Each model entry must stay as one TOML block. Nested model subtables such as
|
||||
`[model.cost]`, `[model.capabilities]`, or `[catalog.models.cost]` are not
|
||||
allowed; use dotted keys inside the `[[model]]` or `[[catalog.models]]` block.
|
||||
|
||||
## Global model example
|
||||
|
||||
Use one `[[model]]` block per global model owned by a top-level provider. Add
|
||||
additional models by adding additional `[[model]]` blocks in the same family
|
||||
file.
|
||||
|
||||
```toml
|
||||
[[model]]
|
||||
id = "gpt-4o"
|
||||
name = "GPT-4o"
|
||||
apiName = "gpt-4o"
|
||||
ownerProviderId = "openai"
|
||||
tier = "first-party"
|
||||
description = "OpenAI flagship multimodal model"
|
||||
classification = ["chat", "vision", "coding"]
|
||||
contextWindow = 128000
|
||||
maxOutputTokens = 16384
|
||||
defaultTemperature = 0.7
|
||||
temperatureRange.min = 0
|
||||
temperatureRange.max = 2
|
||||
capabilities.supportsVision = true
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = false
|
||||
capabilities.supportsPreciseTokenCount = true
|
||||
capabilities.supportsEmbeddings = false
|
||||
capabilities.supportsTemperature = true
|
||||
notes = "Optional maintainer note."
|
||||
|
||||
[[model]]
|
||||
id = "gpt-4o-mini"
|
||||
name = "GPT-4o Mini"
|
||||
apiName = "gpt-4o-mini"
|
||||
ownerProviderId = "openai"
|
||||
tier = "first-party"
|
||||
description = "OpenAI small multimodal model"
|
||||
classification = ["chat", "vision"]
|
||||
contextWindow = 128000
|
||||
maxOutputTokens = 16384
|
||||
capabilities.supportsVision = true
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = false
|
||||
```
|
||||
|
||||
## Model fields
|
||||
|
||||
Global `[[model]]` entries and provider `[[catalog.models]]` entries share the
|
||||
identity, capability, and limit fields below. Provider entries may also define
|
||||
route-specific pricing with `cost.*`.
|
||||
|
||||
| Field | Required | Accepted values or behavior |
|
||||
|-------|----------|-----------------------------|
|
||||
| `id` | Yes for global models; no for provider entries with `globalModelId` | Stable catalog ID. |
|
||||
| `apiName` | No | Exact model name sent to the API. Defaults to `id`; with `globalModelId`, defaults to the global model ID unless overridden. |
|
||||
| `globalModelId` | No | Inherits metadata from a global model, then applies provider-entry overrides. |
|
||||
| `ownerProviderId` | No | First-party provider that owns a global model. Used by `isFirstParty` providers. |
|
||||
| `name` / `label` | No | Display metadata. |
|
||||
| `default` / `hidden` | No | Optional selection metadata. |
|
||||
| `classification` | No | Any of `chat`, `reasoning`, `vision`, and `coding`. |
|
||||
| `tier` | No | `first-party`, `hosted`, `local`, or `community`. |
|
||||
| `contextWindow` | No | Input/context token limit for this model or route. |
|
||||
| `maxOutputTokens` | No | Output token limit for this model or route. |
|
||||
| `defaultTemperature` | No | Suggested temperature default. |
|
||||
| `temperatureRange.min` / `temperatureRange.max` | No | Supported temperature bounds. |
|
||||
| `capabilities.*` | No | Feature flags listed below. |
|
||||
| `transportOverrides.*` | No | Per-model transport overrides merged over provider `transportConfig`. |
|
||||
| `notes` | No | Free-form maintainer note. |
|
||||
|
||||
## Provider model pricing and overrides
|
||||
|
||||
When a provider or gateway exposes a global model under a provider-specific
|
||||
name, limit, or price, keep those route-specific values in that provider's
|
||||
single `[[catalog.models]]` block. Put the shared pricing currency once at the
|
||||
provider top level with `costCurrency`.
|
||||
|
||||
```toml
|
||||
costCurrency = "USD"
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "claude-sonnet-4"
|
||||
apiName = "anthropic/claude-sonnet-4"
|
||||
contextWindow = 200000
|
||||
maxOutputTokens = 64000
|
||||
capabilities.supportsReasoning = true
|
||||
cost.inputPerMillion = 3.00
|
||||
cost.outputPerMillion = 15.00
|
||||
cost.cachePerMillion = 0.30
|
||||
transportOverrides.maxTokensField = "max_tokens"
|
||||
transportOverrides.removeBodyFields = ["store"]
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "deepseek-v3"
|
||||
apiName = "deepseek/deepseek-chat"
|
||||
contextWindow = 65536
|
||||
maxOutputTokens = 8192
|
||||
cost.inputPerMillion = 0.14
|
||||
cost.outputPerMillion = 0.28
|
||||
```
|
||||
|
||||
`capabilities` flags are `supportsVision`, `supportsStreaming`,
|
||||
`supportsFunctionCalling`, `supportsJsonMode`, `supportsReasoning`,
|
||||
`supportsPreciseTokenCount`, `supportsEmbeddings`, and
|
||||
`supportsTemperature`.
|
||||
|
||||
Provider model `cost` requires `inputPerMillion` and `outputPerMillion` when
|
||||
present. `cachePerMillion` is optional. Do not put `cost` or `costCurrency` in
|
||||
global model files.
|
||||
|
||||
`transportOverrides` accepts the same fields as `transportConfig`. Header
|
||||
overrides are merged with provider headers, and `removeBodyFields` is unioned
|
||||
with provider-level removals.
|
||||
@@ -0,0 +1,213 @@
|
||||
# Provider catalog definitions
|
||||
|
||||
Provider catalog entries are TOML files in `src/providers/catalog/definitions`.
|
||||
Use one definition file for each API provider, gateway, or local endpoint.
|
||||
|
||||
A top-level provider is the company or service that self-publishes a model
|
||||
family. Examples: OpenAI publishes GPT and o-series models, Anthropic publishes
|
||||
Claude and Opus/Sonnet models, Google publishes Gemini models, and DeepSeek
|
||||
publishes DeepSeek models.
|
||||
|
||||
Gateways and hosted proxies do not own those global model families. They expose
|
||||
routes to models owned by top-level providers, so their catalog entries should
|
||||
usually reference global models with `globalModelId`.
|
||||
|
||||
## Pick The Right File
|
||||
|
||||
| What you're adding | Files to touch | Brand/provider examples |
|
||||
|--------------------|----------------|-------------------------|
|
||||
| Top-level provider with no new global models | `definitions/<provider>.toml` | OpenAI after GPT models already exist, Anthropic after Claude models already exist |
|
||||
| Top-level provider with owned global models | `definitions/<provider>.toml` and `models/<family>.toml` | OpenAI + GPT/o-series, Anthropic + Claude/Opus, Google + Gemini, DeepSeek + DeepSeek |
|
||||
| Gateway or hosted proxy | `definitions/<gateway>.toml` | OpenRouter, OpenGateway, Groq |
|
||||
| Local endpoint | `definitions/<local>.toml` | Ollama, LM Studio |
|
||||
|
||||
## Recommended Shape
|
||||
|
||||
Keep credential env vars at the provider top level:
|
||||
|
||||
```toml
|
||||
credentialEnvVars = ["OPENAI_API_KEY"]
|
||||
```
|
||||
|
||||
The catalog normalizer copies that value into setup, validation, and preset
|
||||
metadata for runtime consumers. Do not repeat the same env var in every section.
|
||||
|
||||
Keep these nested objects inside one TOML block with dotted keys:
|
||||
|
||||
```toml
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
authHeader.name = "authorization"
|
||||
headers.X-Trace-Source = "zero"
|
||||
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discovery.kind = "openai-compatible"
|
||||
|
||||
[preset]
|
||||
id = "my-provider"
|
||||
badge.text = "HOSTED"
|
||||
```
|
||||
|
||||
Use provider-level `costCurrency` for pricing currency. Individual provider
|
||||
model routes can define numeric `cost.*` fields, but global model files should
|
||||
not define pricing.
|
||||
|
||||
## Required Provider Fields
|
||||
|
||||
Every definition needs these top-level fields:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `id` | Stable provider ID. |
|
||||
| `name` | Human-readable provider name. |
|
||||
| `kind` | `provider`, `gateway`, or `localhost`. |
|
||||
| `description` | Short provider description. |
|
||||
| `baseURL` | Provider API base URL. |
|
||||
| `defaultModel` | Model used when the user does not choose one. |
|
||||
|
||||
## Common Optional Fields
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `category` | `local`, `hosted`, or `aggregating`. |
|
||||
| `vendorId` | Inherit another provider's `transportConfig` when this definition omits one. |
|
||||
| `isFirstParty` | Auto-add owned global models from `models/*.toml`; use this for top-level providers. |
|
||||
| `supportsModelRouting` | Marks providers that can route multiple model families. |
|
||||
| `apiKeyLabel` / `apiKeyPlaceholder` | UI labels for credential entry. |
|
||||
| `apiKeyRequired` | `false` for local/no-auth providers; otherwise defaults from `setup.requiresAuth`. |
|
||||
| `credentialEnvVars` | Canonical env vars that may contain provider credentials. |
|
||||
| `costCurrency` | Currency code for provider-level model pricing, such as `USD`. |
|
||||
|
||||
## Setup
|
||||
|
||||
Use setup for credential flow metadata:
|
||||
|
||||
```toml
|
||||
[setup]
|
||||
requiresAuth = true
|
||||
authMode = "api-key"
|
||||
```
|
||||
|
||||
| Field | Required | Accepted values or behavior |
|
||||
|-------|----------|-----------------------------|
|
||||
| `requiresAuth` | Yes when `setup` is present | `true` for credentialed providers; `false` for no-auth local endpoints. |
|
||||
| `authMode` | Yes when `setup` is present | `api-key`, `oauth`, `adc`, `token`, or `none`. |
|
||||
| `credentialEnvVars` | No | Normally omit this and use top-level `credentialEnvVars`. |
|
||||
| `setupPrompt` | No | Optional credential setup prompt text. |
|
||||
|
||||
## Validation
|
||||
|
||||
Use validation when Zero should check credentials or match configured provider
|
||||
URLs:
|
||||
|
||||
```toml
|
||||
[validation]
|
||||
kind = "credential-env"
|
||||
missingCredentialMessage = "An API key is required."
|
||||
matchBaseUrlHosts = ["api.example.com"]
|
||||
```
|
||||
|
||||
| Field | Required | Accepted values or behavior |
|
||||
|-------|----------|-----------------------------|
|
||||
| `kind` | Yes when `validation` is present | Currently `credential-env`. |
|
||||
| `credentialEnvVars` | No | Normally omit this and use top-level `credentialEnvVars`. |
|
||||
| `missingCredentialMessage` | No | Message shown when required credentials are missing. |
|
||||
| `matchBaseUrlHosts` | No | Hostnames used to match an existing provider config. |
|
||||
|
||||
## Transport
|
||||
|
||||
OpenAI-compatible transports are supported at runtime. Anthropic-compatible
|
||||
descriptors are recognized by the catalog, but runtime creation currently
|
||||
rejects them until that transport is implemented.
|
||||
|
||||
```toml
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
maxTokensField = "max_completion_tokens"
|
||||
removeBodyFields = ["store", "stream_options"]
|
||||
authHeader.name = "authorization"
|
||||
authHeader.scheme = "bearer"
|
||||
```
|
||||
|
||||
| Field | Required | Accepted values or behavior |
|
||||
|-------|----------|-----------------------------|
|
||||
| `kind` | Yes when `transportConfig` is present | `openai-compatible` or `anthropic-compatible`. |
|
||||
| `headers.*` | No | Static headers. Values beginning with `$` are read from `process.env`; unset env vars are omitted. |
|
||||
| `authHeader.name` | No | Header name used for API key auth. Defaults to `authorization` during discovery. |
|
||||
| `authHeader.scheme` | No | `bearer` sends `Bearer <key>`; `raw` sends the key value directly. Defaults to `bearer`. |
|
||||
| `maxTokensField` | No | `max_tokens` or `max_completion_tokens`. |
|
||||
| `removeBodyFields` | No | Request body fields stripped before sending to the provider. |
|
||||
| `endpointPath` | No | Optional transport endpoint path metadata. |
|
||||
|
||||
For runtime OpenAI-compatible calls, the OpenAI SDK supplies its normal
|
||||
`Authorization` header unless a custom raw or non-Authorization `authHeader` is
|
||||
configured. For model discovery, Zero builds fetch headers directly and sends
|
||||
the configured auth header when discovery requires auth.
|
||||
|
||||
## Catalog And Discovery
|
||||
|
||||
Use `catalog` for static model routes, dynamic discovery, or both:
|
||||
|
||||
```toml
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discoveryCacheTtl = "1h"
|
||||
discoveryRefreshMode = "manual"
|
||||
allowManualRefresh = true
|
||||
discovery.kind = "openai-compatible"
|
||||
discovery.requiresAuth = true
|
||||
discovery.path = "/models"
|
||||
```
|
||||
|
||||
| Field | Required | Accepted values or behavior |
|
||||
|-------|----------|-----------------------------|
|
||||
| `source` | Yes when `catalog` is present | `static`, `dynamic`, or `hybrid`. |
|
||||
| `models` | No | Static `[[catalog.models]]` entries. These are returned before discovered models. |
|
||||
| `discovery.kind` | No | `openai-compatible`, `ollama`, or `custom`; only OpenAI-compatible and Ollama discovery execute today. |
|
||||
| `discovery.path` | No | Discovery path appended to `baseURL`; defaults to `/models`. |
|
||||
| `discovery.requiresAuth` | No | `false` skips auth headers and cache key partitioning by API key. |
|
||||
| `discoveryCacheTtl` | No | Milliseconds as a number, or a string ending in `m`, `h`, or `d`; defaults to `1h`. |
|
||||
| `discoveryRefreshMode` | No | `manual`, `on-open`, `background-if-stale`, or `startup` metadata. |
|
||||
| `allowManualRefresh` | No | Metadata for UI refresh affordances. |
|
||||
|
||||
OpenAI-compatible discovery accepts either `{ data: [{ id: "..." }] }` or a raw
|
||||
array of model IDs/objects. Ollama discovery calls `/api/tags` on the base URL
|
||||
without the `/v1` suffix and reads `models[].name`.
|
||||
|
||||
## Usage And Presets
|
||||
|
||||
Use `usage` for usage-accounting metadata:
|
||||
|
||||
```toml
|
||||
[usage]
|
||||
supported = false
|
||||
silentlyIgnore = true
|
||||
```
|
||||
|
||||
| Usage field | Required | Accepted values or behavior |
|
||||
|-------------|----------|-----------------------------|
|
||||
| `supported` | Yes when `usage` is present | Whether usage accounting is expected for this provider. |
|
||||
| `silentlyIgnore` | No | Suppress usage-accounting noise when unsupported. |
|
||||
|
||||
Use `preset` for setup fallbacks and display metadata:
|
||||
|
||||
```toml
|
||||
[preset]
|
||||
id = "my-provider"
|
||||
description = "My Provider hosted models"
|
||||
fallbackBaseUrl = "https://api.example.com/v1"
|
||||
fallbackModel = "vendor/model-name"
|
||||
badge.text = "HOSTED"
|
||||
badge.color = "success"
|
||||
```
|
||||
|
||||
| Preset field | Required | Accepted values or behavior |
|
||||
|--------------|----------|-----------------------------|
|
||||
| `id` | Yes when `preset` is present | Stable preset ID. |
|
||||
| `description` | Yes when `preset` is present | Preset description. |
|
||||
| `label` | No | Optional UI label. |
|
||||
| `apiKeyEnvVars` | No | Normally omit this and use top-level `credentialEnvVars`. |
|
||||
| `fallbackBaseUrl` | No | Base URL used when config does not provide one. |
|
||||
| `fallbackModel` | No | Model used when config does not provide one. |
|
||||
| `badge.text` / `badge.color` | No | Optional display badge metadata. |
|
||||
@@ -1,35 +0,0 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.24.2
|
||||
|
||||
toolchain go1.24.13
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
)
|
||||
@@ -1,52 +0,0 @@
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
@@ -1,209 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
const defaultSystemPrompt = "You are Zero, a terminal coding agent. Help with the current workspace and use tools when needed."
|
||||
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
|
||||
|
||||
func Run(ctx context.Context, prompt string, provider Provider, options Options) (Result, error) {
|
||||
if provider == nil {
|
||||
return Result{}, errors.New("agent provider is required")
|
||||
}
|
||||
|
||||
maxTurns := options.MaxTurns
|
||||
if maxTurns <= 0 {
|
||||
maxTurns = 12
|
||||
}
|
||||
|
||||
registry := options.Registry
|
||||
if registry == nil {
|
||||
registry = tools.NewRegistry()
|
||||
}
|
||||
|
||||
permissionMode := options.PermissionMode
|
||||
if permissionMode == "" {
|
||||
permissionMode = PermissionModeAuto
|
||||
}
|
||||
|
||||
messages := zeroruntime.SeedMessages(defaultSystemPrompt, prompt)
|
||||
|
||||
result := Result{Messages: copyMessages(messages)}
|
||||
for turn := 0; turn < maxTurns; turn++ {
|
||||
result.Turns = turn + 1
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: toolDefinitions(registry, permissionMode),
|
||||
}
|
||||
|
||||
stream, err := provider.StreamCompletion(ctx, request)
|
||||
if err != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, err
|
||||
}
|
||||
|
||||
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, zeroruntime.CollectOptions{
|
||||
OnText: options.OnText,
|
||||
OnUsage: options.OnUsage,
|
||||
})
|
||||
if collected.Error != "" {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, errors.New(collected.Error)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: collected.Text,
|
||||
ToolCalls: collected.ToolCalls,
|
||||
})
|
||||
|
||||
if len(collected.ToolCalls) == 0 {
|
||||
result.FinalAnswer = collected.Text
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
for _, call := range collected.ToolCalls {
|
||||
if options.OnToolCall != nil {
|
||||
options.OnToolCall(call)
|
||||
}
|
||||
toolResult := executeToolCall(ctx, registry, call, permissionMode)
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.Output,
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
result.FinalAnswer = maxTurnsAnswer
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode) ToolResult {
|
||||
args := map[string]any{}
|
||||
if call.Arguments != "" {
|
||||
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Failed to parse arguments for " + call.Name + ": " + err.Error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
permissionGranted := permissionMode == PermissionModeUnsafe
|
||||
if tool, ok := registry.Get(call.Name); ok && tool.Safety().Permission == tools.PermissionAllow {
|
||||
permissionGranted = true
|
||||
}
|
||||
|
||||
result := registry.RunWithOptions(ctx, call.Name, args, tools.RunOptions{
|
||||
PermissionGranted: permissionGranted,
|
||||
})
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
}
|
||||
}
|
||||
|
||||
func toolDefinitions(registry *tools.Registry, permissionMode PermissionMode) []zeroruntime.ToolDefinition {
|
||||
registeredTools := registry.All()
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(registeredTools))
|
||||
for _, tool := range registeredTools {
|
||||
if !isAdvertised(tool, permissionMode) {
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: tool.Name(),
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
})
|
||||
return definitions
|
||||
}
|
||||
|
||||
func schemaToRuntimeMap(schema tools.Schema) map[string]any {
|
||||
parameters := map[string]any{
|
||||
"type": schema.Type,
|
||||
"additionalProperties": schema.AdditionalProperties,
|
||||
}
|
||||
|
||||
if len(schema.Required) > 0 {
|
||||
parameters["required"] = append([]string{}, schema.Required...)
|
||||
}
|
||||
|
||||
if len(schema.Properties) > 0 {
|
||||
properties := make(map[string]any, len(schema.Properties))
|
||||
for name, property := range schema.Properties {
|
||||
properties[name] = propertyToRuntimeMap(property)
|
||||
}
|
||||
parameters["properties"] = properties
|
||||
}
|
||||
|
||||
return parameters
|
||||
}
|
||||
|
||||
func propertyToRuntimeMap(property tools.PropertySchema) map[string]any {
|
||||
schema := map[string]any{
|
||||
"type": property.Type,
|
||||
}
|
||||
if property.Description != "" {
|
||||
schema["description"] = property.Description
|
||||
}
|
||||
if len(property.Enum) > 0 {
|
||||
schema["enum"] = append([]string{}, property.Enum...)
|
||||
}
|
||||
if property.Default != nil {
|
||||
schema["default"] = property.Default
|
||||
}
|
||||
if property.Minimum != nil {
|
||||
schema["minimum"] = *property.Minimum
|
||||
}
|
||||
if property.Maximum != nil {
|
||||
schema["maximum"] = *property.Maximum
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func isAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
|
||||
if tool.Safety().Permission == tools.PermissionDeny {
|
||||
return false
|
||||
}
|
||||
if permissionMode == PermissionModeAuto {
|
||||
return tool.Safety().Permission == tools.PermissionAllow
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func copyMessages(messages []Message) []Message {
|
||||
copied := make([]Message, len(messages))
|
||||
for index, message := range messages {
|
||||
copied[index] = message
|
||||
if message.ToolCalls != nil {
|
||||
copied[index].ToolCalls = append([]ToolCall{}, message.ToolCalls...)
|
||||
}
|
||||
}
|
||||
return copied
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type mockProvider struct {
|
||||
turns [][]zeroruntime.StreamEvent
|
||||
requests []zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func (provider *mockProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
provider.requests = append(provider.requests, request)
|
||||
|
||||
events := []zeroruntime.StreamEvent{{Type: zeroruntime.StreamEventDone}}
|
||||
if len(provider.turns) >= len(provider.requests) {
|
||||
events = provider.turns[len(provider.requests)-1]
|
||||
}
|
||||
|
||||
ch := make(chan zeroruntime.StreamEvent, len(events))
|
||||
for _, event := range events {
|
||||
ch <- event
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestRunReturnsProviderText(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "hello"},
|
||||
{Type: zeroruntime.StreamEventText, Content: " zero"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "say hi", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "hello zero" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("expected one provider turn, got %d", len(provider.requests))
|
||||
}
|
||||
assertMessage(t, provider.requests[0].Messages[0], zeroruntime.MessageRoleSystem, "")
|
||||
assertMessage(t, provider.requests[0].Messages[1], zeroruntime.MessageRoleUser, "say hi")
|
||||
}
|
||||
|
||||
func TestRunEmitsTextDeltas(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "hello"},
|
||||
{Type: zeroruntime.StreamEventText, Content: " zero"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}},
|
||||
}
|
||||
|
||||
var deltas []string
|
||||
_, err := Run(context.Background(), "say hi", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnText: func(delta string) { deltas = append(deltas, delta) },
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(deltas, "|") != "hello| zero" {
|
||||
t.Fatalf("expected text deltas, got %#v", deltas)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEmitsUsageEvents(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: 12, CompletionTokens: 5, CachedInputTokens: 2}},
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}},
|
||||
}
|
||||
|
||||
var usages []zeroruntime.Usage
|
||||
_, err := Run(context.Background(), "track usage", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnUsage: func(usage zeroruntime.Usage) { usages = append(usages, usage) },
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(usages) != 1 {
|
||||
t.Fatalf("expected one usage event, got %#v", usages)
|
||||
}
|
||||
if usages[0].PromptTokens != 12 || usages[0].CompletionTokens != 5 || usages[0].CachedInputTokens != 2 {
|
||||
t.Fatalf("unexpected usage event: %#v", usages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAdvertisesRuntimeToolDefinitions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), "what tools exist?", provider, Options{
|
||||
Registry: registry,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("expected one request, got %d", len(provider.requests))
|
||||
}
|
||||
if len(provider.requests[0].Tools) != 1 {
|
||||
t.Fatalf("expected one advertised tool, got %#v", provider.requests[0].Tools)
|
||||
}
|
||||
|
||||
toolDefinition := provider.requests[0].Tools[0]
|
||||
if toolDefinition.Name != "read_file" {
|
||||
t.Fatalf("expected read_file definition, got %#v", toolDefinition)
|
||||
}
|
||||
parameters := toolDefinition.Parameters
|
||||
if parameters["type"] != "object" {
|
||||
t.Fatalf("expected object schema, got %#v", parameters)
|
||||
}
|
||||
if parameters["additionalProperties"] != false {
|
||||
t.Fatalf("expected additionalProperties=false, got %#v", parameters["additionalProperties"])
|
||||
}
|
||||
properties, ok := parameters["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected properties map, got %#v", parameters["properties"])
|
||||
}
|
||||
pathProperty, ok := properties["path"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected path property map, got %#v", properties["path"])
|
||||
}
|
||||
if pathProperty["type"] != "string" || pathProperty["description"] == "" {
|
||||
t.Fatalf("unexpected path property schema: %#v", pathProperty)
|
||||
}
|
||||
required, ok := parameters["required"].([]string)
|
||||
if !ok || len(required) != 1 || required[0] != "path" {
|
||||
t.Fatalf("unexpected required fields: %#v", parameters["required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecutesToolCallThroughRegistry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\n")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "read done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var toolResults []ToolResult
|
||||
result, err := Run(context.Background(), "read notes", provider, Options{
|
||||
Registry: registry,
|
||||
OnToolResult: func(result ToolResult) { toolResults = append(toolResults, result) },
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "read done" {
|
||||
t.Fatalf("expected final answer from second turn, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected provider to be called twice, got %d", len(provider.requests))
|
||||
}
|
||||
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
assertMessage(t, lastMessage, zeroruntime.MessageRoleTool, "alpha")
|
||||
if lastMessage.ToolCallID != "call-1" {
|
||||
t.Fatalf("expected tool_call_id call-1, got %q", lastMessage.ToolCallID)
|
||||
}
|
||||
if len(toolResults) != 1 || toolResults[0].Status != tools.StatusOK {
|
||||
t.Fatalf("expected one ok tool result, got %#v", toolResults)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write denied")
|
||||
|
||||
result, err := Run(context.Background(), "write notes", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "write denied" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "notes.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected denied write to leave file missing, stat error: %v", err)
|
||||
}
|
||||
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(lastMessage.Content, "Permission required for write_file") {
|
||||
t.Fatalf("expected permission denial tool result, got %q", lastMessage.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
|
||||
result, err := Run(context.Background(), "write notes", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeUnsafe,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "write done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "notes.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "hello" {
|
||||
t.Fatalf("expected written content, got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsAfterMaxTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "loop", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "Agent reached maximum number of turns without a final answer." {
|
||||
t.Fatalf("expected max-turns answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if result.Turns != 1 {
|
||||
t.Fatalf("expected one turn, got %d", result.Turns)
|
||||
}
|
||||
}
|
||||
|
||||
func providerCallingWriteFileThenAnswer(answer string) *mockProvider {
|
||||
return &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "write_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt","content":"hello"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: answer},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertMessage(t *testing.T, message zeroruntime.Message, role zeroruntime.MessageRole, contentContains string) {
|
||||
t.Helper()
|
||||
|
||||
if message.Role != role {
|
||||
t.Fatalf("expected role %s, got %s", role, message.Role)
|
||||
}
|
||||
if contentContains != "" && !strings.Contains(message.Content, contentContains) {
|
||||
t.Fatalf("expected message content to contain %q, got %q", contentContains, message.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAgentTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type Message = zeroruntime.Message
|
||||
type Provider = zeroruntime.Provider
|
||||
type ToolCall = zeroruntime.ToolCall
|
||||
type Usage = zeroruntime.Usage
|
||||
|
||||
type PermissionMode string
|
||||
|
||||
const (
|
||||
PermissionModeAuto PermissionMode = "auto"
|
||||
PermissionModeAsk PermissionMode = "ask"
|
||||
PermissionModeUnsafe PermissionMode = "unsafe"
|
||||
)
|
||||
|
||||
type ToolResult struct {
|
||||
ToolCallID string
|
||||
Name string
|
||||
Status tools.Status
|
||||
Output string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
MaxTurns int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
OnText func(string)
|
||||
OnToolCall func(ToolCall)
|
||||
OnToolResult func(ToolResult)
|
||||
OnUsage func(Usage)
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
FinalAnswer string
|
||||
Turns int
|
||||
Messages []Message
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
type appDeps struct {
|
||||
getwd func() (string, error)
|
||||
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
newSessionStore func() *sessions.Store
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Run executes the minimal Go CLI surface. It returns an exit code so tests can
|
||||
// exercise command behavior without terminating the test process.
|
||||
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
return runWithDeps(args, stdout, stderr, defaultAppDeps())
|
||||
}
|
||||
|
||||
func defaultAppDeps() appDeps {
|
||||
return appDeps{
|
||||
getwd: os.Getwd,
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
options, err := config.DefaultResolveOptions(workspaceRoot)
|
||||
if err != nil {
|
||||
return config.ResolvedConfig{}, err
|
||||
}
|
||||
options.Overrides = overrides
|
||||
return config.Resolve(options)
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return providers.New(profile, providers.Options{UserAgent: userAgent()})
|
||||
},
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return sessions.NewStore(sessions.StoreOptions{})
|
||||
},
|
||||
runTUI: tui.Run,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func userAgent() string {
|
||||
return "zero/" + version
|
||||
}
|
||||
|
||||
func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
deps = fillAppDeps(deps)
|
||||
|
||||
if len(args) == 0 {
|
||||
return runInteractiveTUI(stderr, deps)
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "-h", "--help", "help":
|
||||
if err := writeHelp(stdout); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "-v", "--version", "version":
|
||||
if _, err := fmt.Fprintf(stdout, "zero %s\n", version); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "-p", "--prompt":
|
||||
if len(args) < 2 {
|
||||
return writePromptRequired(stderr)
|
||||
}
|
||||
execArgs := append([]string{"--prompt", args[1]}, args[2:]...)
|
||||
return runExec(execArgs, stdout, stderr, deps)
|
||||
case "exec":
|
||||
return runExec(args[1:], stdout, stderr, deps)
|
||||
case "doctor":
|
||||
return runDoctor(args[1:], stdout, stderr, deps)
|
||||
case "search", "find":
|
||||
return runSearch(args[1:], stdout, stderr, deps)
|
||||
default:
|
||||
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
|
||||
return 1
|
||||
}
|
||||
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func fillAppDeps(deps appDeps) appDeps {
|
||||
defaults := defaultAppDeps()
|
||||
if deps.getwd == nil {
|
||||
deps.getwd = defaults.getwd
|
||||
}
|
||||
if deps.resolveConfig == nil {
|
||||
deps.resolveConfig = defaults.resolveConfig
|
||||
}
|
||||
if deps.newProvider == nil {
|
||||
deps.newProvider = defaults.newProvider
|
||||
}
|
||||
if deps.newSessionStore == nil {
|
||||
deps.newSessionStore = defaults.newSessionStore
|
||||
}
|
||||
if deps.runTUI == nil {
|
||||
deps.runTUI = defaults.runTUI
|
||||
}
|
||||
if deps.now == nil {
|
||||
deps.now = defaults.now
|
||||
}
|
||||
return deps
|
||||
}
|
||||
|
||||
func runInteractiveTUI(stderr io.Writer, deps appDeps) int {
|
||||
workspaceRoot, err := deps.getwd()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
|
||||
}
|
||||
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
|
||||
provider, err := buildProvider(resolved, deps)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
|
||||
registry := newCoreRegistry(workspaceRoot)
|
||||
permissionMode := agent.PermissionModeAuto
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
ProviderName: resolved.Provider.Name,
|
||||
ModelName: resolved.Provider.Model,
|
||||
Provider: provider,
|
||||
Registry: registry,
|
||||
AgentOptions: agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
},
|
||||
PermissionMode: permissionMode,
|
||||
})
|
||||
}
|
||||
|
||||
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
|
||||
if resolved.Provider == (config.ProviderProfile{}) {
|
||||
return nil, nil
|
||||
}
|
||||
return deps.newProvider(resolved.Provider)
|
||||
}
|
||||
|
||||
func newCoreRegistry(workspaceRoot string) *tools.Registry {
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreTools(workspaceRoot) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func writeAppError(stderr io.Writer, message string, exitCode int) int {
|
||||
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
|
||||
return 1
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
func writeHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `ZERO terminal coding agent
|
||||
|
||||
Usage:
|
||||
zero [command]
|
||||
|
||||
Commands:
|
||||
exec Run a one-shot prompt through the Go agent runtime
|
||||
doctor Run backend health checks for config and provider setup
|
||||
search Search persisted local Zero session events
|
||||
find Alias for search
|
||||
help Show this help
|
||||
version Print version
|
||||
|
||||
Flags:
|
||||
-h, --help Show this help
|
||||
-v, --version Print version
|
||||
-p, --prompt Run a one-shot prompt
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writePromptRequired(stderr io.Writer) int {
|
||||
if _, err := fmt.Fprintln(stderr, "[zero] Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func writeExecHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero exec [flags] [prompt]
|
||||
|
||||
Runs a one-shot prompt through the Go agent runtime.
|
||||
|
||||
Flags:
|
||||
-f, --file <path> Read prompt text from a file
|
||||
-m, --model <model> Select the model for provider setup
|
||||
--max-turns <number> Override the maximum agent loop turns
|
||||
-C, --cwd <path> Set the workspace directory
|
||||
-o, --output-format text|json Select text or newline-delimited JSON output
|
||||
--prompt <prompt> Provide prompt text as a flag
|
||||
--skip-permissions-unsafe Allow prompt-gated tools without approval
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
var errWriteFailed = errors.New("write failed")
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) {
|
||||
return 0, errWriteFailed
|
||||
}
|
||||
|
||||
func TestRunPrintsVersion(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{"--version"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d", exitCode)
|
||||
}
|
||||
if got := stdout.String(); got != "zero dev\n" {
|
||||
t.Fatalf("expected version output, got %q", got)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPrintsHelp(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--help"},
|
||||
{"-h"},
|
||||
{"help"},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
assertHelpOutput(t, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsLaunchesTUIWithNilProviderWhenNoProviderConfigured(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
var launchedOptions tui.Options
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
return config.ResolvedConfig{MaxTurns: 12}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("newProvider should not be called without a resolved provider")
|
||||
return nil, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launchedOptions = options
|
||||
return 7
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 7 {
|
||||
t.Fatalf("expected TUI exit code 7, got %d", exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
if launchedOptions.Cwd != cwd {
|
||||
t.Fatalf("Cwd = %q, want %q", launchedOptions.Cwd, cwd)
|
||||
}
|
||||
if launchedOptions.Provider != nil {
|
||||
t.Fatalf("Provider = %#v, want nil", launchedOptions.Provider)
|
||||
}
|
||||
if launchedOptions.ProviderName != "" || launchedOptions.ModelName != "" {
|
||||
t.Fatalf("provider metadata = %q/%q, want empty", launchedOptions.ProviderName, launchedOptions.ModelName)
|
||||
}
|
||||
assertCoreRegistry(t, launchedOptions.Registry)
|
||||
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAuto)
|
||||
}
|
||||
|
||||
func TestRunNoArgsLaunchesTUIWithResolvedProviderMetadata(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
fake := &cliFakeProvider{}
|
||||
var launchedOptions tui.Options
|
||||
var providerProfile config.ProviderProfile
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
return config.ResolvedConfig{
|
||||
ActiveProvider: "work",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "work",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-test",
|
||||
},
|
||||
MaxTurns: 5,
|
||||
}, nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerProfile = profile
|
||||
return fake, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d", exitCode)
|
||||
}
|
||||
if providerProfile.Name != "work" || providerProfile.Model != "gpt-test" {
|
||||
t.Fatalf("providerProfile = %#v, want resolved provider", providerProfile)
|
||||
}
|
||||
if launchedOptions.Provider != fake {
|
||||
t.Fatalf("Provider = %#v, want fake provider", launchedOptions.Provider)
|
||||
}
|
||||
if launchedOptions.ProviderName != "work" {
|
||||
t.Fatalf("ProviderName = %q, want work", launchedOptions.ProviderName)
|
||||
}
|
||||
if launchedOptions.ModelName != "gpt-test" {
|
||||
t.Fatalf("ModelName = %q, want gpt-test", launchedOptions.ModelName)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
assertCoreRegistry(t, launchedOptions.Registry)
|
||||
assertAgentOptions(t, launchedOptions, 5, agent.PermissionModeAuto)
|
||||
}
|
||||
|
||||
func TestRunNoArgsReportsConfigErrorsWithoutLaunchingTUI(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
launchCalled := false
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, errors.New("bad config")
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launchCalled = true
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", exitCode)
|
||||
}
|
||||
if launchCalled {
|
||||
t.Fatal("TUI launcher should not be called when config fails")
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "bad config") {
|
||||
t.Fatalf("expected config error on stderr, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandsDoNotLaunchTUI(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--help"},
|
||||
{"-h"},
|
||||
{"help"},
|
||||
{"--version"},
|
||||
{"version"},
|
||||
{"wat"},
|
||||
{"exec"},
|
||||
{"doctor"},
|
||||
{"search"},
|
||||
{"find"},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
launchCalled := false
|
||||
|
||||
_ = runWithDeps(args, &stdout, &stderr, appDeps{
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launchCalled = true
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if launchCalled {
|
||||
t.Fatalf("TUI launcher should not be called for args %#v", args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertHelpOutput(t *testing.T, args []string) {
|
||||
t.Helper()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run(args, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d", exitCode)
|
||||
}
|
||||
|
||||
output := stdout.String()
|
||||
for _, want := range []string{
|
||||
"ZERO terminal coding agent",
|
||||
"Usage:",
|
||||
"zero [command]",
|
||||
"exec",
|
||||
"doctor",
|
||||
"search",
|
||||
"--version",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("expected help output to contain %q, got %q", want, output)
|
||||
}
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecRequiresPrompt(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"exec"},
|
||||
{"exec", ""},
|
||||
{"exec", " "},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run(args, &stdout, &stderr)
|
||||
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("expected exit code 2, got %d", exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Prompt required") {
|
||||
t.Fatalf("expected prompt error, got %q", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsFailureWhenStdoutWriteFails(t *testing.T) {
|
||||
exitCode := Run([]string{"--version"}, failingWriter{}, io.Discard)
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsFailureWhenStderrWriteFails(t *testing.T) {
|
||||
exitCode := Run([]string{"wat"}, io.Discard, failingWriter{})
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsUnknownCommand(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{"wat"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("expected exit code 2, got %d", exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, `unknown command "wat"`) {
|
||||
t.Fatalf("expected unknown command error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
type cliFakeProvider struct{}
|
||||
|
||||
func (cliFakeProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
ch := make(chan zeroruntime.StreamEvent)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func assertCoreRegistry(t *testing.T, registry *tools.Registry) {
|
||||
t.Helper()
|
||||
|
||||
if registry == nil {
|
||||
t.Fatal("Registry = nil, want core tool registry")
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
"read_file",
|
||||
"list_directory",
|
||||
"glob",
|
||||
"grep",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"apply_patch",
|
||||
"update_plan",
|
||||
"bash",
|
||||
} {
|
||||
if _, ok := registry.Get(name); !ok {
|
||||
t.Fatalf("expected registry to include core tool %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertAgentOptions(t *testing.T, options tui.Options, maxTurns int, permissionMode agent.PermissionMode) {
|
||||
t.Helper()
|
||||
|
||||
if options.AgentOptions.MaxTurns != maxTurns {
|
||||
t.Fatalf("AgentOptions.MaxTurns = %d, want %d", options.AgentOptions.MaxTurns, maxTurns)
|
||||
}
|
||||
if options.AgentOptions.PermissionMode != permissionMode {
|
||||
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", options.AgentOptions.PermissionMode, permissionMode)
|
||||
}
|
||||
if options.PermissionMode != permissionMode {
|
||||
t.Fatalf("PermissionMode = %q, want %q", options.PermissionMode, permissionMode)
|
||||
}
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
exitSuccess = 0
|
||||
exitCrash = 1
|
||||
exitUsage = 2
|
||||
exitProvider = 3
|
||||
)
|
||||
|
||||
type execOutputFormat string
|
||||
|
||||
const (
|
||||
execOutputText execOutputFormat = "text"
|
||||
execOutputJSON execOutputFormat = "json"
|
||||
)
|
||||
|
||||
type execOptions struct {
|
||||
promptParts []string
|
||||
file string
|
||||
model string
|
||||
maxTurns int
|
||||
cwd string
|
||||
outputFormat execOutputFormat
|
||||
skipPermissionsUnsafe bool
|
||||
}
|
||||
|
||||
type execUsageError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (err execUsageError) Error() string {
|
||||
return err.message
|
||||
}
|
||||
|
||||
func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseExecArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeExecHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
prompt, err := resolveExecPrompt(options, workspaceRoot)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
overrides := config.Overrides{}
|
||||
if options.model != "" {
|
||||
overrides.Provider.Model = options.model
|
||||
}
|
||||
if options.maxTurns > 0 {
|
||||
overrides.MaxTurns = options.maxTurns
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, overrides)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
if resolved.Provider == (config.ProviderProfile{}) {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", "No provider configured. Set OPENAI_MODEL/OPENAI_API_KEY or add .zero/config.json.")
|
||||
}
|
||||
|
||||
provider, err := buildProvider(resolved, deps)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
|
||||
permissionMode := agent.PermissionModeAuto
|
||||
if options.skipPermissionsUnsafe {
|
||||
permissionMode = agent.PermissionModeUnsafe
|
||||
}
|
||||
|
||||
registry := newCoreRegistry(workspaceRoot)
|
||||
writer := execEventWriter{
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
format: options.outputFormat,
|
||||
streamedText: &strings.Builder{},
|
||||
}
|
||||
writer.runStart(workspaceRoot, resolved.Provider, permissionMode)
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if options.skipPermissionsUnsafe {
|
||||
writer.warning("Unsafe permissions are active for this run because --skip-permissions-unsafe was passed.")
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
|
||||
result, err := agent.Run(context.Background(), prompt, provider, agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
OnText: writer.text,
|
||||
OnToolCall: writer.toolCall,
|
||||
OnToolResult: writer.toolResult,
|
||||
OnUsage: writer.usage,
|
||||
})
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
|
||||
writer.final(result.FinalAnswer)
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
options := execOptions{outputFormat: execOutputText}
|
||||
if len(args) == 0 {
|
||||
return options, false, execUsageError{"Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."}
|
||||
}
|
||||
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--skip-permissions-unsafe":
|
||||
options.skipPermissionsUnsafe = true
|
||||
case arg == "-f" || arg == "--file":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.file = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--file="):
|
||||
options.file = strings.TrimSpace(strings.TrimPrefix(arg, "--file="))
|
||||
case arg == "-m" || arg == "--model":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.model = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--model="):
|
||||
options.model = strings.TrimSpace(strings.TrimPrefix(arg, "--model="))
|
||||
case arg == "--max-turns":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
maxTurns, err := parseExecMaxTurns(value)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.maxTurns = maxTurns
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--max-turns="):
|
||||
maxTurns, err := parseExecMaxTurns(strings.TrimSpace(strings.TrimPrefix(arg, "--max-turns=")))
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.maxTurns = maxTurns
|
||||
case arg == "-C" || arg == "--cwd":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.cwd = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--cwd="):
|
||||
options.cwd = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd="))
|
||||
case arg == "-o" || arg == "--output-format":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
format, err := parseExecOutputFormat(value)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.outputFormat = format
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--output-format="):
|
||||
format, err := parseExecOutputFormat(strings.TrimSpace(strings.TrimPrefix(arg, "--output-format=")))
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.outputFormat = format
|
||||
case arg == "--prompt":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.promptParts = append(options.promptParts, value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--prompt="):
|
||||
options.promptParts = append(options.promptParts, strings.TrimSpace(strings.TrimPrefix(arg, "--prompt=")))
|
||||
case arg == "--":
|
||||
options.promptParts = append(options.promptParts, args[index+1:]...)
|
||||
index = len(args)
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown exec flag %q", arg)}
|
||||
default:
|
||||
options.promptParts = append(options.promptParts, arg)
|
||||
}
|
||||
}
|
||||
|
||||
if options.file == "" && strings.TrimSpace(strings.Join(options.promptParts, " ")) == "" {
|
||||
return options, false, execUsageError{"Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func parseExecMaxTurns(value string) (int, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 0, execUsageError{"--max-turns requires a value"}
|
||||
}
|
||||
maxTurns, err := strconv.Atoi(trimmed)
|
||||
if err != nil || maxTurns <= 0 {
|
||||
return 0, execUsageError{fmt.Sprintf("invalid --max-turns %q. Expected a positive integer.", value)}
|
||||
}
|
||||
return maxTurns, nil
|
||||
}
|
||||
|
||||
func nextFlagValue(args []string, index int, flag string) (string, int, error) {
|
||||
if index+1 >= len(args) || strings.TrimSpace(args[index+1]) == "" {
|
||||
return "", index, execUsageError{fmt.Sprintf("%s requires a value", flag)}
|
||||
}
|
||||
return strings.TrimSpace(args[index+1]), index + 1, nil
|
||||
}
|
||||
|
||||
func parseExecOutputFormat(value string) (execOutputFormat, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", string(execOutputText):
|
||||
return execOutputText, nil
|
||||
case string(execOutputJSON):
|
||||
return execOutputJSON, nil
|
||||
default:
|
||||
return "", execUsageError{fmt.Sprintf("invalid output format %q. Expected text or json.", value)}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWorkspaceRoot(cwd string, deps appDeps) (string, error) {
|
||||
current, err := deps.getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve workspace: %w", err)
|
||||
}
|
||||
|
||||
workspaceRoot := strings.TrimSpace(cwd)
|
||||
if workspaceRoot == "" {
|
||||
workspaceRoot = current
|
||||
} else if !filepath.IsAbs(workspaceRoot) {
|
||||
workspaceRoot = filepath.Join(current, workspaceRoot)
|
||||
}
|
||||
workspaceRoot = filepath.Clean(workspaceRoot)
|
||||
|
||||
info, err := os.Stat(workspaceRoot)
|
||||
if err != nil || !info.IsDir() {
|
||||
return "", execUsageError{fmt.Sprintf("cwd must be an existing directory: %s", workspaceRoot)}
|
||||
}
|
||||
return workspaceRoot, nil
|
||||
}
|
||||
|
||||
func resolveExecPrompt(options execOptions, workspaceRoot string) (string, error) {
|
||||
parts := []string{}
|
||||
inlinePrompt := strings.TrimSpace(strings.Join(options.promptParts, " "))
|
||||
if inlinePrompt != "" {
|
||||
parts = append(parts, inlinePrompt)
|
||||
}
|
||||
|
||||
if options.file != "" {
|
||||
promptPath := options.file
|
||||
if !filepath.IsAbs(promptPath) {
|
||||
promptPath = filepath.Join(workspaceRoot, promptPath)
|
||||
}
|
||||
data, err := os.ReadFile(promptPath)
|
||||
if err != nil {
|
||||
return "", execUsageError{fmt.Sprintf("prompt file not found: %s", promptPath)}
|
||||
}
|
||||
filePrompt := strings.TrimSpace(string(data))
|
||||
if filePrompt == "" {
|
||||
return "", execUsageError{fmt.Sprintf("prompt file is empty: %s", promptPath)}
|
||||
}
|
||||
parts = append(parts, filePrompt)
|
||||
}
|
||||
|
||||
prompt := strings.TrimSpace(strings.Join(parts, "\n\n"))
|
||||
if prompt == "" {
|
||||
return "", execUsageError{"Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."}
|
||||
}
|
||||
return prompt, nil
|
||||
}
|
||||
|
||||
func writeExecUsageError(stderr io.Writer, message string) int {
|
||||
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
func writeExecProviderError(stdout io.Writer, stderr io.Writer, format execOutputFormat, code string, message string) int {
|
||||
if format == execOutputJSON {
|
||||
if err := writeJSONLine(stdout, map[string]any{
|
||||
"type": "error",
|
||||
"code": code,
|
||||
"message": message,
|
||||
}); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if err := writeJSONLine(stdout, map[string]any{
|
||||
"type": "done",
|
||||
"exit_code": exitProvider,
|
||||
}); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitProvider
|
||||
}
|
||||
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitProvider
|
||||
}
|
||||
|
||||
type execEventWriter struct {
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
format execOutputFormat
|
||||
streamedText *strings.Builder
|
||||
err error
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) runStart(cwd string, provider config.ProviderProfile, permissionMode agent.PermissionMode) {
|
||||
if writer.format != execOutputJSON {
|
||||
return
|
||||
}
|
||||
writer.writeJSON(map[string]any{
|
||||
"type": "run_start",
|
||||
"cwd": cwd,
|
||||
"provider": provider.Name,
|
||||
"model": provider.Model,
|
||||
"permission_mode": string(permissionMode),
|
||||
})
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) warning(message string) {
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{"type": "warning", "message": message})
|
||||
return
|
||||
}
|
||||
writer.writeStderr("[zero] WARNING: " + message + "\n")
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) text(delta string) {
|
||||
writer.streamedText.WriteString(delta)
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{"type": "text", "delta": delta})
|
||||
return
|
||||
}
|
||||
writer.writeStdout(delta)
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) toolCall(call agent.ToolCall) {
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{
|
||||
"type": "tool_call",
|
||||
"id": call.ID,
|
||||
"name": call.Name,
|
||||
"arguments": call.Arguments,
|
||||
})
|
||||
return
|
||||
}
|
||||
writer.writeStderr("[tool] " + call.Name + "\n")
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) toolResult(result agent.ToolResult) {
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_call_id": result.ToolCallID,
|
||||
"name": result.Name,
|
||||
"status": string(result.Status),
|
||||
"output": result.Output,
|
||||
})
|
||||
return
|
||||
}
|
||||
writer.writeStderr("[result] " + truncateForStatus(result.Output) + "\n")
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) usage(usage agent.Usage) {
|
||||
if writer.format != execOutputJSON {
|
||||
return
|
||||
}
|
||||
writer.writeJSON(map[string]any{
|
||||
"type": "usage",
|
||||
"prompt_tokens": usage.PromptTokens,
|
||||
"completion_tokens": usage.CompletionTokens,
|
||||
"total_tokens": usage.TotalTokens(),
|
||||
})
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) final(answer string) {
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{"type": "final", "text": answer})
|
||||
writer.writeJSON(map[string]any{"type": "done", "exit_code": exitSuccess})
|
||||
return
|
||||
}
|
||||
|
||||
if writer.streamedText.Len() == 0 && answer != "" {
|
||||
writer.writeStdout(answer)
|
||||
writer.streamedText.WriteString(answer)
|
||||
}
|
||||
if writer.streamedText.Len() > 0 && !strings.HasSuffix(writer.streamedText.String(), "\n") {
|
||||
writer.writeStdout("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) writeJSON(payload map[string]any) {
|
||||
if writer.err != nil {
|
||||
return
|
||||
}
|
||||
writer.err = writeJSONLine(writer.stdout, payload)
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) writeStdout(value string) {
|
||||
if writer.err != nil {
|
||||
return
|
||||
}
|
||||
_, writer.err = io.WriteString(writer.stdout, value)
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) writeStderr(value string) {
|
||||
if writer.err != nil {
|
||||
return
|
||||
}
|
||||
_, writer.err = io.WriteString(writer.stderr, value)
|
||||
}
|
||||
|
||||
func writeJSONLine(w io.Writer, payload map[string]any) error {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(append(data, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncateForStatus(value string) string {
|
||||
compact := strings.Join(strings.Fields(value), " ")
|
||||
if len(compact) > 200 {
|
||||
return compact[:200] + "..."
|
||||
}
|
||||
return compact
|
||||
}
|
||||
@@ -1,565 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestRunExecHelpDocumentsM1Flags(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"exec", "--help"},
|
||||
{"exec", "--help", "--model", "m1"},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run(args, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d", exitCode)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"-f, --file",
|
||||
"-m, --model",
|
||||
"--max-turns",
|
||||
"-C, --cwd",
|
||||
"-o, --output-format text|json",
|
||||
"--prompt",
|
||||
"--skip-permissions-unsafe",
|
||||
} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("expected exec help to contain %q, got %q", want, stdout.String())
|
||||
}
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecRejectsInvalidMaxTurnsBeforeRuntime(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{value: "nope", want: "invalid --max-turns"},
|
||||
{value: "-1", want: "invalid --max-turns"},
|
||||
{value: "0", want: "invalid --max-turns"},
|
||||
} {
|
||||
t.Run(tc.value, func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{"exec", "--max-turns", tc.value, "hello"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("expected max-turns validation error containing %q, got %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("equals-empty", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{"exec", "--max-turns=", "hello"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "--max-turns requires a value") {
|
||||
t.Fatalf("expected empty max-turns validation error, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunExecMaxTurnsReachesConfigOverrides(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
var gotMaxTurns int
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"exec", "--max-turns", "7", "hello"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
gotMaxTurns = overrides.MaxTurns
|
||||
return config.ResolvedConfig{}, errors.New("stop before provider")
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("expected provider exit %d, got %d", exitProvider, exitCode)
|
||||
}
|
||||
if gotMaxTurns != 7 {
|
||||
t.Fatalf("overrides.MaxTurns = %d, want 7", gotMaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecJSONRunStartWriteFailureSkipsAgent(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
called := false
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "-o", "json", "hello"}, failingWriter{}, io.Discard, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return recordingExecProvider{called: &called}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitCrash {
|
||||
t.Fatalf("expected exit code %d, got %d", exitCrash, exitCode)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("expected agent provider not to run after run_start write failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecUnsafeWarningWriteFailureSkipsAgent(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
called := false
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--skip-permissions-unsafe", "hello"}, io.Discard, failingWriter{}, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return recordingExecProvider{called: &called}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitCrash {
|
||||
t.Fatalf("expected exit code %d, got %d", exitCrash, exitCode)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("expected agent provider not to run after warning write failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecJSONProviderErrorWriteFailureReturnsCrash(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "-o", "json", "hello"}, failingWriter{}, io.Discard, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, errors.New("provider config failed")
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitCrash {
|
||||
t.Fatalf("expected exit code %d, got %d", exitCrash, exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func execResolvedConfig() config.ResolvedConfig {
|
||||
return config.ResolvedConfig{
|
||||
ActiveProvider: "echo",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "echo",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://127.0.0.1/v1",
|
||||
Model: "echo-model",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type recordingExecProvider struct {
|
||||
called *bool
|
||||
}
|
||||
|
||||
func (provider recordingExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
*provider.called = true
|
||||
return nil, errors.New("provider should not run")
|
||||
}
|
||||
|
||||
func TestRunPromptFlagRoutesToExecRunner(t *testing.T) {
|
||||
execExitCode, execStdout, execStderr := runExecWithEcho(t, []string{"exec", "hello zero"})
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"-p", "hello zero"},
|
||||
{"--prompt", "hello zero"},
|
||||
} {
|
||||
t.Run(args[0], func(t *testing.T) {
|
||||
exitCode, stdout, stderr := runExecWithEcho(t, args)
|
||||
|
||||
if exitCode != execExitCode {
|
||||
t.Fatalf("expected exit code %d, got %d", execExitCode, exitCode)
|
||||
}
|
||||
if stdout != execStdout {
|
||||
t.Fatalf("expected stdout %q, got %q", execStdout, stdout)
|
||||
}
|
||||
if stderr != execStderr {
|
||||
t.Fatalf("expected stderr %q, got %q", execStderr, stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecAssemblesInlineAndFilePromptRelativeToCwd(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "prompt.txt"), []byte("file prompt\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--cwd", root, "--file", "prompt.txt", "inline prompt"})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "inline prompt\n\nfile prompt") {
|
||||
t.Fatalf("expected inline and file prompt joined by blank line, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecAcceptsFileOnlyPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "prompt.txt"), []byte("file only prompt\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "-C", root, "-f", "prompt.txt"})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "file only prompt") {
|
||||
t.Fatalf("expected file prompt output, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecRejectsInvalidCwdBeforeRuntime(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
filePath := filepath.Join(root, "not-a-directory.txt")
|
||||
if err := os.WriteFile(filePath, []byte("nope"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cwd string
|
||||
}{
|
||||
{name: "missing", cwd: filepath.Join(root, "missing")},
|
||||
{name: "file", cwd: filePath},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{"exec", "--cwd", tc.cwd, "hello"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("expected exit code 2, got %d", exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "cwd must be an existing directory") {
|
||||
t.Fatalf("expected cwd validation error, got %q", got)
|
||||
}
|
||||
if strings.Contains(stdout.String()+stderr.String(), "Go agent runtime ready") {
|
||||
t.Fatalf("expected validation before runtime, got stdout %q stderr %q", stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecRejectsInvalidOutputFormatBeforeRuntime(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{"exec", "-o", "yaml", "hello"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("expected exit code 2, got %d", exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, `invalid output format "yaml"`) {
|
||||
t.Fatalf("expected output format validation error, got %q", got)
|
||||
}
|
||||
if strings.Contains(stdout.String()+stderr.String(), "Go agent runtime ready") {
|
||||
t.Fatalf("expected validation before runtime, got stdout %q stderr %q", stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecUnsafeTextModeWarns(t *testing.T) {
|
||||
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--skip-permissions-unsafe", "hello"})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "hello") {
|
||||
t.Fatalf("expected prompt in stdout, got %q", stdout)
|
||||
}
|
||||
if got := stderr; !strings.Contains(got, "WARNING") || !strings.Contains(got, "--skip-permissions-unsafe") {
|
||||
t.Fatalf("expected unsafe warning, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecJSONOutputsNDJSONEvents(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--cwd", root, "-m", "m1-test", "-o", "json", "hello json"})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
eventTypes := jsonEventTypes(events)
|
||||
for _, want := range []string{"run_start", "text", "final", "done"} {
|
||||
if !slices.Contains(eventTypes, want) {
|
||||
t.Fatalf("expected JSON event %q in %v; output %q", want, eventTypes, stdout)
|
||||
}
|
||||
}
|
||||
if got := events[0]["type"]; got != "run_start" {
|
||||
t.Fatalf("expected first event run_start, got %v", got)
|
||||
}
|
||||
if got := events[0]["model"]; got != "m1-test" {
|
||||
t.Fatalf("expected run_start model m1-test, got %v", got)
|
||||
}
|
||||
if got := events[0]["cwd"]; got != root {
|
||||
t.Fatalf("expected run_start cwd %q, got %v", root, got)
|
||||
}
|
||||
if got := events[0]["permission_mode"]; got != "auto" {
|
||||
t.Fatalf("expected default permission_mode auto, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecJSONUnsafeOutputsWarningEvent(t *testing.T) {
|
||||
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--skip-permissions-unsafe", "-o", "json", "hello"})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
eventTypes := jsonEventTypes(events)
|
||||
if !slices.Contains(eventTypes, "warning") {
|
||||
t.Fatalf("expected JSON warning event in %v; output %q", eventTypes, stdout)
|
||||
}
|
||||
if got := events[0]["permission_mode"]; got != "unsafe" {
|
||||
t.Fatalf("expected run_start permission_mode unsafe, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) {
|
||||
clearProviderEnv(t)
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, ".zero")
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var gotAuth string
|
||||
var gotMethod string
|
||||
var gotPath string
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode provider request: %v", err)
|
||||
}
|
||||
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"provider ok\"}}]}\n\n")
|
||||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
writeConfig := `{
|
||||
"activeProvider": "local",
|
||||
"providers": [{
|
||||
"name": "local",
|
||||
"provider_kind": "openai-compatible",
|
||||
"base_url": "` + server.URL + `",
|
||||
"api_key": "sk-local",
|
||||
"model": "local-model"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(writeConfig), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := Run([]string{"exec", "--cwd", root, "hello provider"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String())
|
||||
}
|
||||
if strings.TrimSpace(stdout.String()) != "provider ok" {
|
||||
t.Fatalf("stdout = %q, want provider response", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
if gotAuth != "Bearer sk-local" {
|
||||
t.Fatalf("Authorization = %q, want project config token", gotAuth)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %q, want %q", gotMethod, http.MethodPost)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, "/chat/completions") {
|
||||
t.Fatalf("path = %q, want suffix /chat/completions", gotPath)
|
||||
}
|
||||
if gotBody["model"] != "local-model" {
|
||||
t.Fatalf("provider model = %v, want local-model", gotBody["model"])
|
||||
}
|
||||
messages, ok := gotBody["messages"].([]any)
|
||||
if !ok || len(messages) == 0 {
|
||||
t.Fatalf("messages = %#v, want non-empty []any", gotBody["messages"])
|
||||
}
|
||||
lastMessage, ok := messages[len(messages)-1].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("last message = %#v, want map[string]any", messages[len(messages)-1])
|
||||
}
|
||||
if lastMessage["content"] != "hello provider" {
|
||||
t.Fatalf("last provider message = %#v, want prompt", lastMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func runExecWithEcho(t *testing.T, args []string) (int, string, string) {
|
||||
t.Helper()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "echo-model"
|
||||
if overrides.Provider.Model != "" {
|
||||
model = overrides.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{
|
||||
ActiveProvider: "echo",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "echo",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://127.0.0.1/v1",
|
||||
Model: model,
|
||||
},
|
||||
MaxTurns: 3,
|
||||
}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return echoExecProvider{}, nil
|
||||
},
|
||||
})
|
||||
return exitCode, stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
type echoExecProvider struct{}
|
||||
|
||||
func (echoExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
prompt := ""
|
||||
for index := len(request.Messages) - 1; index >= 0; index-- {
|
||||
if request.Messages[index].Role == zeroruntime.MessageRoleUser {
|
||||
prompt = request.Messages[index].Content
|
||||
break
|
||||
}
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent, 2)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
close(ch)
|
||||
return ch, ctx.Err()
|
||||
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: prompt}:
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func clearProviderEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
for _, key := range []string{
|
||||
"ZERO_PROVIDER_COMMAND",
|
||||
"ZERO_PROVIDER",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_MODEL",
|
||||
} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSONLines(t *testing.T, output string) []map[string]any {
|
||||
t.Helper()
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
if len(lines) == 0 || lines[0] == "" {
|
||||
t.Fatalf("expected JSON lines, got %q", output)
|
||||
}
|
||||
|
||||
events := make([]map[string]any, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("expected JSON object line, got %q: %v", line, err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func jsonEventTypes(events []map[string]any) []string {
|
||||
types := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
eventType, _ := event["type"].(string)
|
||||
types = append(types, eventType)
|
||||
}
|
||||
return types
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/doctor"
|
||||
zsearch "github.com/Gitlawb/zero/internal/search"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
type doctorOptions struct {
|
||||
json bool
|
||||
connectivity bool
|
||||
}
|
||||
|
||||
func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseDoctorArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeDoctorHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
workspaceRoot, err := resolveWorkspaceRoot("", deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
|
||||
report := doctor.Run(doctor.Options{
|
||||
Now: deps.now,
|
||||
Runtime: "go",
|
||||
Provider: resolved.Provider,
|
||||
Connectivity: options.connectivity,
|
||||
})
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, report); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if !report.OK {
|
||||
return exitProvider
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, doctor.Format(report)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if !report.OK {
|
||||
return exitProvider
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
type searchOptions struct {
|
||||
query string
|
||||
json bool
|
||||
limit int
|
||||
contextChars int
|
||||
sessionID string
|
||||
eventType sessions.EventType
|
||||
reindex bool
|
||||
}
|
||||
|
||||
func runSearch(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseSearchArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeSearchHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if strings.TrimSpace(options.query) == "" {
|
||||
return writeExecUsageError(stderr, "search query required. Use `zero search <query>`.")
|
||||
}
|
||||
|
||||
result, err := zsearch.Sessions(options.query, zsearch.Options{
|
||||
Store: deps.newSessionStore(),
|
||||
Limit: options.limit,
|
||||
ContextChars: options.contextChars,
|
||||
SessionID: options.sessionID,
|
||||
Type: options.eventType,
|
||||
Reindex: options.reindex,
|
||||
Now: deps.now,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, zsearch.RedactResult(result)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, zsearch.FormatResult(result)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func parseDoctorArgs(args []string) (doctorOptions, bool, error) {
|
||||
options := doctorOptions{}
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "-h", "--help", "help":
|
||||
return options, true, nil
|
||||
case "--json":
|
||||
options.json = true
|
||||
case "--connectivity":
|
||||
options.connectivity = true
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown doctor flag %q", arg)}
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func parseSearchArgs(args []string) (searchOptions, bool, error) {
|
||||
options := searchOptions{}
|
||||
queryParts := []string{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case arg == "--reindex":
|
||||
options.reindex = true
|
||||
case arg == "--limit":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
limit, err := parsePositiveOrZeroInt(value, "--limit")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.limit = limit
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--limit="):
|
||||
limit, err := parsePositiveOrZeroInt(strings.TrimSpace(strings.TrimPrefix(arg, "--limit=")), "--limit")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.limit = limit
|
||||
case arg == "--context-chars":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
contextChars, err := parsePositiveOrZeroInt(value, "--context-chars")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.contextChars = contextChars
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--context-chars="):
|
||||
contextChars, err := parsePositiveOrZeroInt(strings.TrimSpace(strings.TrimPrefix(arg, "--context-chars=")), "--context-chars")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.contextChars = contextChars
|
||||
case arg == "--session-id":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.sessionID = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--session-id="):
|
||||
options.sessionID = strings.TrimSpace(strings.TrimPrefix(arg, "--session-id="))
|
||||
case arg == "--type":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.eventType = sessions.EventType(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--type="):
|
||||
options.eventType = sessions.EventType(strings.TrimSpace(strings.TrimPrefix(arg, "--type=")))
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown search flag %q", arg)}
|
||||
default:
|
||||
queryParts = append(queryParts, arg)
|
||||
}
|
||||
}
|
||||
options.query = strings.TrimSpace(strings.Join(queryParts, " "))
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func parsePositiveOrZeroInt(value string, label string) (int, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 0, execUsageError{fmt.Sprintf("%s requires a value", label)}
|
||||
}
|
||||
parsed, err := strconv.Atoi(trimmed)
|
||||
if err != nil || parsed < 0 {
|
||||
return 0, execUsageError{fmt.Sprintf("invalid %s %q. Expected a non-negative integer.", label, value)}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func writePrettyJSON(w io.Writer, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(append(data, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeDoctorHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero doctor [flags]
|
||||
|
||||
Runs Go backend health checks for config and provider setup.
|
||||
|
||||
Flags:
|
||||
--json Print JSON report
|
||||
--connectivity Include provider endpoint connectivity probe when available
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeSearchHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero search [flags] <query>
|
||||
|
||||
Searches persisted local Zero session events.
|
||||
|
||||
Flags:
|
||||
--json Print JSON results
|
||||
--limit <number> Maximum hits to return
|
||||
--context-chars <n> Characters of context around each match
|
||||
--session-id <id> Search one session
|
||||
--type <event-type> Filter by session event type
|
||||
--reindex Rebuild cached search indexes
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func TestRunDoctorFormatsRedactedProviderDiagnostics(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
|
||||
exitCode := runWithDeps([]string{"doctor"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
return config.ResolvedConfig{
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: "sk-proj-secret1234567890",
|
||||
Model: "gpt-4.1",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
now: fixedCLITime("2026-06-04T16:00:00Z"),
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
output := stdout.String()
|
||||
for _, want := range []string{"Zero doctor report", "Overall: pass", "[pass] provider.config", "[warn] provider.connectivity"} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("expected doctor output to contain %q, got %q", want, output)
|
||||
}
|
||||
}
|
||||
if strings.Contains(output, "sk-proj-secret") {
|
||||
t.Fatalf("doctor output leaked secret: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorJSONReturnsFailureForMissingProvider(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"doctor", "--json"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
now: fixedCLITime("2026-06-04T16:30:00Z"),
|
||||
})
|
||||
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("expected provider exit %d, got %d", exitProvider, exitCode)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
var report struct {
|
||||
OK bool `json:"ok"`
|
||||
Checks []struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
|
||||
t.Fatalf("doctor JSON did not decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("expected report ok=false, got %#v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSearchFindsSessionEventsFromInjectedStore(t *testing.T) {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLITime("2026-06-04T17:00:00Z")})
|
||||
session, err := store.Create(sessions.CreateInput{SessionID: "cli_search", Title: "CLI Search", Cwd: "/repo", ModelID: "gpt-4.1", Provider: "openai"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": "needle with token=sk-secret1234567890"}}); err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"search", "needle", "--limit", "3"}, &stdout, &stderr, appDeps{
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return store
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "Found 1 local session event") || !strings.Contains(output, "cli_search") {
|
||||
t.Fatalf("unexpected search output: %q", output)
|
||||
}
|
||||
if strings.Contains(output, "sk-secret") {
|
||||
t.Fatalf("search output leaked secret: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSearchJSONAndValidation(t *testing.T) {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLITime("2026-06-04T17:30:00Z")})
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"search", "--json", "missing"}, &stdout, &stderr, appDeps{
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return store
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
var result struct {
|
||||
Query string `json:"query"`
|
||||
TotalHits int `json:"totalHits"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
t.Fatalf("search JSON did not decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if result.Query != "missing" || result.TotalHits != 0 {
|
||||
t.Fatalf("unexpected search result: %#v", result)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"search"}, &stdout, &stderr, appDeps{})
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "search query required") {
|
||||
t.Fatalf("expected search validation error, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSearchJSONRedactsQueryAndSessionMetadata(t *testing.T) {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLITime("2026-06-04T18:00:00Z")})
|
||||
querySecret := "sk-proj-querysecret1234567890"
|
||||
metadataSecret := "sk-proj-metadatasecret1234567890"
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"search", "--json", querySecret}, &stdout, &stderr, appDeps{
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return store
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), querySecret) {
|
||||
t.Fatalf("search JSON leaked raw query secret: %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "[REDACTED]") {
|
||||
t.Fatalf("expected redacted query marker in JSON output: %q", stdout.String())
|
||||
}
|
||||
|
||||
session, err := store.Create(sessions.CreateInput{
|
||||
SessionID: "json_metadata_secret",
|
||||
Title: "Title " + metadataSecret,
|
||||
Cwd: "/repo/" + metadataSecret,
|
||||
ModelID: "model-" + metadataSecret,
|
||||
Provider: "provider-token=" + metadataSecret,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": "metadata needle"}}); err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"search", "--json", "metadata", "needle"}, &stdout, &stderr, appDeps{
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return store
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), metadataSecret) {
|
||||
t.Fatalf("search JSON leaked session metadata secret: %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "[REDACTED]") {
|
||||
t.Fatalf("expected redacted metadata marker in JSON output: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func fixedCLITime(value string) func() time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return func() time.Time { return parsed }
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const providerCommandTimeout = 5 * time.Second
|
||||
|
||||
func LoadProviderCommand(command string) (FileConfig, error) {
|
||||
stdout, stderr, err := runProviderCommand(command, providerCommandTimeout)
|
||||
if err != nil {
|
||||
if errors.Is(err, errProviderCommandTimeout) {
|
||||
return FileConfig{}, fmt.Errorf("provider command timed out after 5s")
|
||||
}
|
||||
return FileConfig{}, fmt.Errorf("provider command failed: %w%s", err, commandOutput(stderr))
|
||||
}
|
||||
|
||||
cfg, err := parseProviderCommandJSON(stdout)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
if len(cfg.Providers) == 0 {
|
||||
return FileConfig{}, fmt.Errorf("provider command returned no providers")
|
||||
}
|
||||
|
||||
providers, _, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
cfg.Providers = providers
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
var errProviderCommandTimeout = errors.New("provider command timeout")
|
||||
|
||||
func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte, error) {
|
||||
cmd := shellCommand(command)
|
||||
configureCommandProcess(cmd)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return stdout.Bytes(), stderr.Bytes(), err
|
||||
case <-timer.C:
|
||||
terminateCommandProcess(cmd)
|
||||
<-done
|
||||
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
|
||||
}
|
||||
}
|
||||
|
||||
func shellCommand(command string) *exec.Cmd {
|
||||
if runtime.GOOS == "windows" {
|
||||
if strings.HasPrefix(strings.TrimSpace(command), `"`) {
|
||||
command = "call " + command
|
||||
}
|
||||
return exec.Command("cmd", "/C", command)
|
||||
}
|
||||
return exec.Command("sh", "-c", command)
|
||||
}
|
||||
|
||||
func commandOutput(stderr []byte) string {
|
||||
output := strings.TrimSpace(string(stderr))
|
||||
if output == "" {
|
||||
return ""
|
||||
}
|
||||
return ": " + redactSecrets(output)
|
||||
}
|
||||
|
||||
func parseProviderCommandJSON(data []byte) (FileConfig, error) {
|
||||
data = []byte(strings.TrimSpace(string(data)))
|
||||
if len(data) == 0 {
|
||||
return FileConfig{}, fmt.Errorf("provider command returned empty JSON")
|
||||
}
|
||||
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err == nil && (len(cfg.Providers) > 0 || cfg.ActiveProvider != "" || cfg.MaxTurns > 0) {
|
||||
if cfg.ActiveProvider == "" && len(cfg.Providers) == 1 {
|
||||
cfg.ActiveProvider = cfg.Providers[0].Name
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
var profile ProviderProfile
|
||||
if err := json.Unmarshal(data, &profile); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid provider command JSON: %w", err)
|
||||
}
|
||||
if profile.Name == "" {
|
||||
profile.Name = string(ProviderKindOpenAI)
|
||||
}
|
||||
return FileConfig{
|
||||
ActiveProvider: profile.Name,
|
||||
Providers: []ProviderProfile{profile},
|
||||
}, nil
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadProviderCommandSuccess(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
|
||||
})
|
||||
|
||||
cfg, err := LoadProviderCommand(command)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProviderCommand() error = %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.Providers) != 1 {
|
||||
t.Fatalf("providers length = %d, want 1", len(cfg.Providers))
|
||||
}
|
||||
provider := cfg.Providers[0]
|
||||
if provider.Name != "cmd" || provider.APIKey != "sk-command" || provider.Model != "gpt-command" {
|
||||
t.Fatalf("provider = %#v, want command provider", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProviderCommandFailureIncludesExitAndRedactsOutput(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{
|
||||
Stderr: "failed with sk-command-secret",
|
||||
ExitCode: 7,
|
||||
})
|
||||
|
||||
_, err := LoadProviderCommand(command)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want command failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "provider command failed") || !strings.Contains(err.Error(), "exit status") {
|
||||
t.Fatalf("error = %q, want command failure with exit status", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), "sk-command-secret") {
|
||||
t.Fatalf("error leaked command secret: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{SleepSeconds: 10})
|
||||
|
||||
start := time.Now()
|
||||
_, err := LoadProviderCommand(command)
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want timeout")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timed out after 5s") {
|
||||
t.Fatalf("error = %q, want timeout", err.Error())
|
||||
}
|
||||
maxElapsed := 7 * time.Second
|
||||
if runtime.GOOS == "windows" {
|
||||
maxElapsed = 9 * time.Second
|
||||
}
|
||||
if elapsed > maxElapsed {
|
||||
t.Fatalf("timeout returned after %s, want roughly 5s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProviderCommandInvalidJSON(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{Stdout: `{not-json`})
|
||||
|
||||
_, err := LoadProviderCommand(command)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want JSON error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid provider command JSON") {
|
||||
t.Fatalf("error = %q, want invalid JSON", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProviderCommandMissingModel(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command"}`,
|
||||
})
|
||||
|
||||
_, err := LoadProviderCommand(command)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want missing model")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "provider cmd requires model") {
|
||||
t.Fatalf("error = %q, want missing model", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type commandScript struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
SleepSeconds int
|
||||
}
|
||||
|
||||
func writeCommand(t *testing.T, script commandScript) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
if runtime.GOOS == "windows" {
|
||||
path := filepath.Join(dir, "provider.cmd")
|
||||
lines := []string{"@echo off"}
|
||||
if script.SleepSeconds > 0 {
|
||||
lines = append(lines, "powershell -NoProfile -Command \"Start-Sleep -Seconds "+itoa(script.SleepSeconds)+"\"")
|
||||
}
|
||||
if script.Stdout != "" {
|
||||
lines = append(lines, "echo "+script.Stdout)
|
||||
}
|
||||
if script.Stderr != "" {
|
||||
lines = append(lines, "echo "+script.Stderr+" 1>&2")
|
||||
}
|
||||
lines = append(lines, "exit /b "+itoa(script.ExitCode))
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\r\n")), 0o700); err != nil {
|
||||
t.Fatalf("write command: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "provider.sh")
|
||||
lines := []string{"#!/bin/sh"}
|
||||
if script.SleepSeconds > 0 {
|
||||
lines = append(lines, "sleep "+itoa(script.SleepSeconds))
|
||||
}
|
||||
if script.Stdout != "" {
|
||||
lines = append(lines, "printf '%s\\n' '"+script.Stdout+"'")
|
||||
}
|
||||
if script.Stderr != "" {
|
||||
lines = append(lines, "printf '%s\\n' '"+script.Stderr+"' >&2")
|
||||
}
|
||||
lines = append(lines, "exit "+itoa(script.ExitCode))
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o700); err != nil {
|
||||
t.Fatalf("write command: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
|
||||
var digits [20]byte
|
||||
index := len(digits)
|
||||
for value > 0 {
|
||||
index--
|
||||
digits[index] = byte('0' + value%10)
|
||||
value /= 10
|
||||
}
|
||||
return string(digits[index:])
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package config
|
||||
|
||||
const ContractOwnerRuntime = "runtime"
|
||||
|
||||
type ContractGap struct {
|
||||
Field string
|
||||
Owner string
|
||||
Milestone string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func DefaultContractGaps() []ContractGap {
|
||||
return []ContractGap{
|
||||
{
|
||||
Field: "env.OPENAI_API_KEY",
|
||||
Owner: ContractOwnerRuntime,
|
||||
Milestone: "M0",
|
||||
Reason: "OpenAI-compatible provider startup needs a stable environment key contract.",
|
||||
},
|
||||
{
|
||||
Field: "env.ZERO_PROVIDER",
|
||||
Owner: ContractOwnerRuntime,
|
||||
Milestone: "M0",
|
||||
Reason: "Provider selection needs an explicit environment override before provider resolution is wired.",
|
||||
},
|
||||
{
|
||||
Field: "providers[].timeout",
|
||||
Owner: ContractOwnerRuntime,
|
||||
Milestone: "M1",
|
||||
Reason: "Provider adapters need bounded request and stream waits.",
|
||||
},
|
||||
{
|
||||
Field: "providers[].retryPolicy",
|
||||
Owner: ContractOwnerRuntime,
|
||||
Milestone: "M1",
|
||||
Reason: "Provider runtime needs consistent retry behavior across adapters.",
|
||||
},
|
||||
{
|
||||
Field: "providers[].maxOutputTokens",
|
||||
Owner: ContractOwnerRuntime,
|
||||
Milestone: "M0",
|
||||
Reason: "The provider contract must bound generated output for chat and tool loops.",
|
||||
},
|
||||
{
|
||||
Field: "providers[].reasoningEffort",
|
||||
Owner: ContractOwnerRuntime,
|
||||
Milestone: "M1",
|
||||
Reason: "Reasoning-capable models need a normalized config surface.",
|
||||
},
|
||||
{
|
||||
Field: "permissions.mode",
|
||||
Owner: ContractOwnerRuntime,
|
||||
Milestone: "M0",
|
||||
Reason: "Runtime permission behavior must be explicit before tools execute mutating actions.",
|
||||
},
|
||||
{
|
||||
Field: "sandbox.policy",
|
||||
Owner: ContractOwnerRuntime,
|
||||
Milestone: "M0",
|
||||
Reason: "Sandbox policy needs a shared backend contract before platform adapters land.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func FindContractGapsByMilestone(gaps []ContractGap, milestone string) []ContractGap {
|
||||
var matches []ContractGap
|
||||
for _, gap := range gaps {
|
||||
if gap.Milestone == milestone {
|
||||
matches = append(matches, gap)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultContractGapsCoverProviderAndPolicyFields(t *testing.T) {
|
||||
gaps := DefaultContractGaps()
|
||||
fields := make(map[string]ContractGap)
|
||||
for _, gap := range gaps {
|
||||
fields[gap.Field] = gap
|
||||
}
|
||||
|
||||
for _, field := range []string{
|
||||
"env.OPENAI_API_KEY",
|
||||
"env.ZERO_PROVIDER",
|
||||
"providers[].timeout",
|
||||
"providers[].retryPolicy",
|
||||
"providers[].maxOutputTokens",
|
||||
"providers[].reasoningEffort",
|
||||
"permissions.mode",
|
||||
"sandbox.policy",
|
||||
} {
|
||||
gap, ok := fields[field]
|
||||
if !ok {
|
||||
t.Fatalf("missing config contract gap for %s", field)
|
||||
}
|
||||
if gap.Owner != ContractOwnerRuntime {
|
||||
t.Fatalf("gap %s owner = %q, want %q", field, gap.Owner, ContractOwnerRuntime)
|
||||
}
|
||||
if gap.Milestone == "" {
|
||||
t.Fatalf("gap %s should include milestone", field)
|
||||
}
|
||||
if gap.Reason == "" {
|
||||
t.Fatalf("gap %s should include reason", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindContractGapsByMilestone(t *testing.T) {
|
||||
gaps := FindContractGapsByMilestone(DefaultContractGaps(), "M0")
|
||||
if len(gaps) == 0 {
|
||||
t.Fatal("expected M0 gaps")
|
||||
}
|
||||
for _, gap := range gaps {
|
||||
if gap.Milestone != "M0" {
|
||||
t.Fatalf("got milestone %q, want M0", gap.Milestone)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultResolveOptions builds config resolution inputs from the local process
|
||||
// environment and workspace.
|
||||
func DefaultResolveOptions(workspaceRoot string) (ResolveOptions, error) {
|
||||
userConfigDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ResolveOptions{}, fmt.Errorf("resolve user config directory: %w", err)
|
||||
}
|
||||
|
||||
userConfigPath, err := existingConfigFile(filepath.Join(userConfigDir, "zero", "config.json"))
|
||||
if err != nil {
|
||||
return ResolveOptions{}, err
|
||||
}
|
||||
|
||||
projectConfigPath, err := existingConfigFile(filepath.Join(workspaceRoot, ".zero", "config.json"))
|
||||
if err != nil {
|
||||
return ResolveOptions{}, err
|
||||
}
|
||||
|
||||
return ResolveOptions{
|
||||
UserConfigPath: userConfigPath,
|
||||
ProjectConfigPath: projectConfigPath,
|
||||
ProviderCommand: strings.TrimSpace(os.Getenv("ZERO_PROVIDER_COMMAND")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func existingConfigFile(path string) (string, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("inspect config %s: %w", path, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", fmt.Errorf("config path %s is a directory, want a file", path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultResolveOptionsUsesExistingConfigPathsAndProviderCommand(t *testing.T) {
|
||||
userConfigRoot := setUserConfigRoot(t)
|
||||
workspaceRoot := t.TempDir()
|
||||
userPath := filepath.Join(userConfigRoot, "zero", "config.json")
|
||||
projectPath := filepath.Join(workspaceRoot, ".zero", "config.json")
|
||||
writeFileAt(t, userPath, "{}")
|
||||
writeFileAt(t, projectPath, "{}")
|
||||
t.Setenv("ZERO_PROVIDER_COMMAND", " zero-provider ")
|
||||
|
||||
options, err := DefaultResolveOptions(workspaceRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultResolveOptions() error = %v", err)
|
||||
}
|
||||
|
||||
if options.UserConfigPath != userPath {
|
||||
t.Fatalf("UserConfigPath = %q, want %q", options.UserConfigPath, userPath)
|
||||
}
|
||||
if options.ProjectConfigPath != projectPath {
|
||||
t.Fatalf("ProjectConfigPath = %q, want %q", options.ProjectConfigPath, projectPath)
|
||||
}
|
||||
if options.ProviderCommand != "zero-provider" {
|
||||
t.Fatalf("ProviderCommand = %q, want zero-provider", options.ProviderCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultResolveOptionsIgnoresMissingConfigFiles(t *testing.T) {
|
||||
setUserConfigRoot(t)
|
||||
workspaceRoot := t.TempDir()
|
||||
|
||||
options, err := DefaultResolveOptions(workspaceRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultResolveOptions() error = %v", err)
|
||||
}
|
||||
|
||||
if options.UserConfigPath != "" {
|
||||
t.Fatalf("UserConfigPath = %q, want empty for missing file", options.UserConfigPath)
|
||||
}
|
||||
if options.ProjectConfigPath != "" {
|
||||
t.Fatalf("ProjectConfigPath = %q, want empty for missing file", options.ProjectConfigPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultResolveOptionsRejectsDirectoryConfigPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
makeDirectory func(t *testing.T, userConfigRoot string, workspaceRoot string) string
|
||||
}{
|
||||
{
|
||||
name: "user config",
|
||||
makeDirectory: func(t *testing.T, userConfigRoot string, workspaceRoot string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(userConfigRoot, "zero", "config.json")
|
||||
mkdirAll(t, path)
|
||||
return path
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project config",
|
||||
makeDirectory: func(t *testing.T, userConfigRoot string, workspaceRoot string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(workspaceRoot, ".zero", "config.json")
|
||||
mkdirAll(t, path)
|
||||
return path
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
userConfigRoot := setUserConfigRoot(t)
|
||||
workspaceRoot := t.TempDir()
|
||||
path := tc.makeDirectory(t, userConfigRoot, workspaceRoot)
|
||||
|
||||
_, err := DefaultResolveOptions(workspaceRoot)
|
||||
if err == nil {
|
||||
t.Fatal("DefaultResolveOptions() error = nil, want directory error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), path) || !strings.Contains(err.Error(), "is a directory") {
|
||||
t.Fatalf("error = %q, want clear directory message for %q", err.Error(), path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setUserConfigRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
t.Setenv("APPDATA", root)
|
||||
case "darwin":
|
||||
t.Setenv("HOME", root)
|
||||
default:
|
||||
t.Setenv("XDG_CONFIG_HOME", root)
|
||||
}
|
||||
|
||||
configRoot, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserConfigDir() error = %v", err)
|
||||
}
|
||||
return configRoot
|
||||
}
|
||||
|
||||
func writeFileAt(t *testing.T, path string, body string) {
|
||||
t.Helper()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("create parent directories: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mkdirAll(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
t.Fatalf("create directory %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func configureCommandProcess(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
func terminateCommandProcess(cmd *exec.Cmd) {
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func configureCommandProcess(cmd *exec.Cmd) {}
|
||||
|
||||
func terminateCommandProcess(cmd *exec.Cmd) {
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)).Run()
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var secretPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`sk-[A-Za-z0-9._-]+`),
|
||||
regexp.MustCompile(`(?i)(api[_-]?key["'=:\s]+)[^"',\s)]+`),
|
||||
regexp.MustCompile(`(?i)(bearer\s+)[A-Za-z0-9._-]+`),
|
||||
}
|
||||
|
||||
func redactSecrets(value string, knownSecrets ...string) string {
|
||||
redacted := value
|
||||
for _, secret := range knownSecrets {
|
||||
if secret == "" {
|
||||
continue
|
||||
}
|
||||
redacted = strings.ReplaceAll(redacted, secret, "[REDACTED]")
|
||||
}
|
||||
for _, pattern := range secretPatterns {
|
||||
redacted = pattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
for _, prefix := range []string{"apiKey=", "api_key=", "api-key=", "Bearer "} {
|
||||
if strings.HasPrefix(strings.ToLower(match), strings.ToLower(prefix)) {
|
||||
return match[:len(prefix)] + "[REDACTED]"
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(match, "sk-") {
|
||||
return "[REDACTED]"
|
||||
}
|
||||
return "[REDACTED]"
|
||||
})
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultMaxTurns = 12
|
||||
|
||||
func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
cfg := FileConfig{
|
||||
MaxTurns: defaultMaxTurns,
|
||||
}
|
||||
|
||||
for _, path := range []string{options.UserConfigPath, options.ProjectConfigPath} {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
fileConfig, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return ResolvedConfig{}, err
|
||||
}
|
||||
mergeConfig(&cfg, fileConfig)
|
||||
}
|
||||
|
||||
applyEnv(&cfg, options.Env)
|
||||
|
||||
if options.ProviderCommand != "" {
|
||||
commandConfig, err := LoadProviderCommand(options.ProviderCommand)
|
||||
if err != nil {
|
||||
return ResolvedConfig{}, err
|
||||
}
|
||||
mergeConfig(&cfg, commandConfig)
|
||||
}
|
||||
|
||||
applyOverrides(&cfg, options.Overrides)
|
||||
|
||||
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider)
|
||||
if err != nil {
|
||||
return ResolvedConfig{}, err
|
||||
}
|
||||
|
||||
return ResolvedConfig{
|
||||
ActiveProvider: active.Name,
|
||||
Providers: providers,
|
||||
Provider: active,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadConfigFile(path string) (FileConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
if activeProvider := strings.TrimSpace(src.ActiveProvider); activeProvider != "" {
|
||||
dst.ActiveProvider = activeProvider
|
||||
}
|
||||
if src.MaxTurns > 0 {
|
||||
dst.MaxTurns = src.MaxTurns
|
||||
}
|
||||
for _, provider := range src.Providers {
|
||||
mergeProvider(dst, provider)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeProvider(cfg *FileConfig, provider ProviderProfile) {
|
||||
provider.Name = strings.TrimSpace(provider.Name)
|
||||
if provider.Name == "" {
|
||||
provider.Name = strings.TrimSpace(cfg.ActiveProvider)
|
||||
}
|
||||
if provider.Name == "" {
|
||||
provider.Name = string(ProviderKindOpenAI)
|
||||
}
|
||||
|
||||
for index := range cfg.Providers {
|
||||
if cfg.Providers[index].Name == provider.Name {
|
||||
cfg.Providers[index] = mergeProfile(cfg.Providers[index], provider)
|
||||
return
|
||||
}
|
||||
}
|
||||
cfg.Providers = append(cfg.Providers, provider)
|
||||
}
|
||||
|
||||
func mergeProfile(base ProviderProfile, next ProviderProfile) ProviderProfile {
|
||||
if next.Name != "" {
|
||||
base.Name = next.Name
|
||||
}
|
||||
if next.Provider != "" {
|
||||
base.Provider = next.Provider
|
||||
}
|
||||
if next.ProviderKind != "" {
|
||||
base.ProviderKind = next.ProviderKind
|
||||
}
|
||||
if next.BaseURL != "" {
|
||||
base.BaseURL = next.BaseURL
|
||||
}
|
||||
if next.APIKey != "" {
|
||||
base.APIKey = next.APIKey
|
||||
}
|
||||
if next.Model != "" {
|
||||
base.Model = next.Model
|
||||
}
|
||||
if next.Description != "" {
|
||||
base.Description = next.Description
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func applyEnv(cfg *FileConfig, env map[string]string) {
|
||||
activeProvider := strings.TrimSpace(envValue(env, "ZERO_PROVIDER"))
|
||||
if activeProvider != "" {
|
||||
cfg.ActiveProvider = activeProvider
|
||||
}
|
||||
|
||||
applyProviderEnv(cfg, ProviderKindOpenAI, envProfile{
|
||||
Name: string(ProviderKindOpenAI),
|
||||
APIKey: envValue(env, "OPENAI_API_KEY"),
|
||||
BaseURL: envValue(env, "OPENAI_BASE_URL"),
|
||||
Model: envValue(env, "OPENAI_MODEL"),
|
||||
})
|
||||
applyProviderEnv(cfg, ProviderKindAnthropic, envProfile{
|
||||
Name: string(ProviderKindAnthropic),
|
||||
APIKey: envValue(env, "ANTHROPIC_API_KEY"),
|
||||
BaseURL: envValue(env, "ANTHROPIC_BASE_URL"),
|
||||
Model: envValue(env, "ANTHROPIC_MODEL"),
|
||||
})
|
||||
applyProviderEnv(cfg, ProviderKindGoogle, envProfile{
|
||||
Name: string(ProviderKindGoogle),
|
||||
APIKey: firstNonEmpty(envValue(env, "GEMINI_API_KEY"), envValue(env, "GOOGLE_API_KEY")),
|
||||
BaseURL: firstNonEmpty(envValue(env, "GEMINI_BASE_URL"), envValue(env, "GOOGLE_BASE_URL")),
|
||||
Model: firstNonEmpty(envValue(env, "GEMINI_MODEL"), envValue(env, "GOOGLE_MODEL")),
|
||||
})
|
||||
}
|
||||
|
||||
type envProfile struct {
|
||||
Name string
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
}
|
||||
|
||||
func applyProviderEnv(cfg *FileConfig, providerKind ProviderKind, env envProfile) {
|
||||
apiKey := strings.TrimSpace(env.APIKey)
|
||||
baseURL := strings.TrimSpace(env.BaseURL)
|
||||
model := strings.TrimSpace(env.Model)
|
||||
if apiKey == "" && baseURL == "" && model == "" {
|
||||
return
|
||||
}
|
||||
|
||||
profile := ProviderProfile{
|
||||
Name: providerEnvTargetName(cfg, providerKind, env.Name),
|
||||
ProviderKind: providerKind,
|
||||
APIKey: apiKey,
|
||||
BaseURL: baseURL,
|
||||
Model: model,
|
||||
}
|
||||
if profile.Name == "" {
|
||||
profile.Name = string(providerKind)
|
||||
}
|
||||
if providerKind == ProviderKindOpenAI && baseURL != "" && !isOfficialOpenAIBaseURL(baseURL) {
|
||||
profile.ProviderKind = ProviderKindOpenAICompatible
|
||||
}
|
||||
mergeProvider(cfg, profile)
|
||||
}
|
||||
|
||||
func providerEnvTargetName(cfg *FileConfig, providerKind ProviderKind, fallback string) string {
|
||||
if activeName := strings.TrimSpace(cfg.ActiveProvider); activeName != "" {
|
||||
for _, provider := range cfg.Providers {
|
||||
if strings.TrimSpace(provider.Name) != activeName {
|
||||
continue
|
||||
}
|
||||
if normalizedProfileKind(provider) == providerKind {
|
||||
return activeName
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, provider := range cfg.Providers {
|
||||
if normalizedProfileKind(provider) != providerKind {
|
||||
continue
|
||||
}
|
||||
if name := strings.TrimSpace(provider.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
func normalizedProfileKind(profile ProviderProfile) ProviderKind {
|
||||
kind := strings.TrimSpace(string(profile.ProviderKind))
|
||||
if kind == "" {
|
||||
kind = strings.TrimSpace(profile.Provider)
|
||||
}
|
||||
return ProviderKind(strings.ToLower(kind))
|
||||
}
|
||||
|
||||
func envValue(env map[string]string, key string) string {
|
||||
if env != nil {
|
||||
return env[key]
|
||||
}
|
||||
return os.Getenv(key)
|
||||
}
|
||||
|
||||
func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
if activeProvider := strings.TrimSpace(overrides.ActiveProvider); activeProvider != "" {
|
||||
cfg.ActiveProvider = activeProvider
|
||||
}
|
||||
if overrides.MaxTurns > 0 {
|
||||
cfg.MaxTurns = overrides.MaxTurns
|
||||
}
|
||||
for _, provider := range overrides.Providers {
|
||||
mergeProvider(cfg, provider)
|
||||
}
|
||||
if hasProviderFields(overrides.Provider) {
|
||||
mergeProvider(cfg, overrides.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func hasProviderFields(profile ProviderProfile) bool {
|
||||
return profile.Name != "" ||
|
||||
profile.Provider != "" ||
|
||||
profile.ProviderKind != "" ||
|
||||
profile.BaseURL != "" ||
|
||||
profile.APIKey != "" ||
|
||||
profile.Model != "" ||
|
||||
profile.Description != ""
|
||||
}
|
||||
|
||||
func normalizeProviders(providers []ProviderProfile, activeName string) ([]ProviderProfile, ProviderProfile, error) {
|
||||
activeName = strings.TrimSpace(activeName)
|
||||
if len(providers) == 0 {
|
||||
if activeName != "" {
|
||||
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
|
||||
}
|
||||
return []ProviderProfile{}, ProviderProfile{}, nil
|
||||
}
|
||||
|
||||
if activeName == "" && len(providers) == 1 {
|
||||
activeName = providers[0].Name
|
||||
}
|
||||
|
||||
normalized := make([]ProviderProfile, 0, len(providers))
|
||||
var active ProviderProfile
|
||||
activeFound := false
|
||||
for _, provider := range providers {
|
||||
next, err := normalizeProvider(provider)
|
||||
if err != nil {
|
||||
return nil, ProviderProfile{}, err
|
||||
}
|
||||
normalized = append(normalized, next)
|
||||
if next.Name == activeName {
|
||||
active = next
|
||||
activeFound = true
|
||||
}
|
||||
}
|
||||
|
||||
if !activeFound {
|
||||
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
|
||||
}
|
||||
if active.Model == "" {
|
||||
return nil, ProviderProfile{}, providerError(active, "provider %s requires model", active.Name)
|
||||
}
|
||||
|
||||
return normalized, active, nil
|
||||
}
|
||||
|
||||
func normalizeProvider(profile ProviderProfile) (ProviderProfile, error) {
|
||||
profile.Name = strings.TrimSpace(profile.Name)
|
||||
profile.Provider = strings.TrimSpace(profile.Provider)
|
||||
profile.ProviderKind = ProviderKind(strings.TrimSpace(strings.ToLower(string(profile.ProviderKind))))
|
||||
profile.BaseURL = strings.TrimSpace(profile.BaseURL)
|
||||
profile.Model = strings.TrimSpace(profile.Model)
|
||||
|
||||
if profile.Name == "" {
|
||||
profile.Name = string(ProviderKindOpenAI)
|
||||
}
|
||||
if profile.ProviderKind == "" && profile.Provider != "" {
|
||||
profile.ProviderKind = ProviderKind(strings.ToLower(profile.Provider))
|
||||
}
|
||||
if profile.ProviderKind == "" {
|
||||
profile.ProviderKind = ProviderKindOpenAI
|
||||
}
|
||||
|
||||
switch profile.ProviderKind {
|
||||
case ProviderKindOpenAI:
|
||||
if profile.BaseURL == "" || isOfficialOpenAIBaseURL(profile.BaseURL) {
|
||||
profile.BaseURL = OpenAIBaseURL
|
||||
return profile, nil
|
||||
}
|
||||
return ProviderProfile{}, providerError(profile, "openai provider %s requires official baseURL %s", profile.Name, OpenAIBaseURL)
|
||||
case ProviderKindOpenAICompatible:
|
||||
if profile.BaseURL == "" {
|
||||
return ProviderProfile{}, providerError(profile, "openai-compatible provider %s requires baseURL", profile.Name)
|
||||
}
|
||||
if isOfficialOpenAIBaseURL(profile.BaseURL) {
|
||||
return ProviderProfile{}, providerError(profile, "openai-compatible provider %s requires custom baseURL", profile.Name)
|
||||
}
|
||||
return profile, nil
|
||||
case ProviderKindAnthropic:
|
||||
if profile.BaseURL == "" {
|
||||
profile.BaseURL = AnthropicBaseURL
|
||||
}
|
||||
return profile, nil
|
||||
case ProviderKindGoogle:
|
||||
if profile.BaseURL == "" {
|
||||
profile.BaseURL = GoogleBaseURL
|
||||
}
|
||||
return profile, nil
|
||||
default:
|
||||
return ProviderProfile{}, providerError(profile, "unknown provider kind %q for provider %s", profile.ProviderKind, profile.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func isOfficialOpenAIBaseURL(baseURL string) bool {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
return baseURL == "" ||
|
||||
baseURL == "openai" ||
|
||||
baseURL == strings.TrimRight(OpenAIBaseURL, "/")
|
||||
}
|
||||
|
||||
func providerError(profile ProviderProfile, format string, args ...any) error {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
if profile.APIKey != "" {
|
||||
message += " (apiKey=[REDACTED])"
|
||||
}
|
||||
return fmt.Errorf("%s", redactSecrets(message, profile.APIKey))
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAppliesLayerPrecedence(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"activeProvider": "user",
|
||||
"maxTurns": 3,
|
||||
"providers": [{
|
||||
"name": "user",
|
||||
"provider": "openai",
|
||||
"api_key": "sk-user",
|
||||
"model_id": "gpt-user"
|
||||
}]
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"activeProvider": "project",
|
||||
"maxTurns": 4,
|
||||
"providers": [{
|
||||
"name": "project",
|
||||
"provider_kind": "openai-compatible",
|
||||
"base_url": "https://project.example/v1",
|
||||
"apiKey": "sk-project",
|
||||
"model": "project-model"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
Env: map[string]string{
|
||||
"ZERO_PROVIDER": "env",
|
||||
"OPENAI_MODEL": "env-model",
|
||||
},
|
||||
Overrides: Overrides{
|
||||
ActiveProvider: "cli",
|
||||
MaxTurns: 9,
|
||||
Provider: ProviderProfile{
|
||||
Name: "cli",
|
||||
ProviderKind: "openai-compatible",
|
||||
BaseURL: "https://cli.example/v1",
|
||||
APIKey: "sk-cli",
|
||||
Model: "cli-model",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.ActiveProvider != "cli" {
|
||||
t.Fatalf("ActiveProvider = %q, want cli", resolved.ActiveProvider)
|
||||
}
|
||||
if resolved.MaxTurns != 9 {
|
||||
t.Fatalf("MaxTurns = %d, want 9", resolved.MaxTurns)
|
||||
}
|
||||
if resolved.Provider.Name != "cli" || resolved.Provider.Model != "cli-model" {
|
||||
t.Fatalf("Provider = %#v, want CLI provider", resolved.Provider)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://cli.example/v1" {
|
||||
t.Fatalf("BaseURL = %q, want CLI custom URL", resolved.Provider.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSelectsActiveProviderProfile(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "beta",
|
||||
"providers": [
|
||||
{"name": "alpha", "provider": "openai", "apiKey": "sk-alpha", "model": "gpt-alpha"},
|
||||
{"name": "beta", "provider": "openai", "apiKey": "sk-beta", "model": "gpt-beta"}
|
||||
]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.Provider.Name != "beta" {
|
||||
t.Fatalf("Provider.Name = %q, want beta", resolved.Provider.Name)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-beta" {
|
||||
t.Fatalf("Provider.APIKey = %q, want sk-beta", resolved.Provider.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIgnoresWhitespaceOnlyActiveProviderLayers(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"activeProvider": "alpha",
|
||||
"providers": [
|
||||
{"name": "alpha", "provider": "openai", "apiKey": "sk-alpha", "model": "gpt-alpha"},
|
||||
{"name": "beta", "provider": "openai", "apiKey": "sk-beta", "model": "gpt-beta"}
|
||||
]
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{"activeProvider": " "}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
Env: map[string]string{},
|
||||
Overrides: Overrides{ActiveProvider: " "},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.ActiveProvider != "alpha" {
|
||||
t.Fatalf("ActiveProvider = %q, want alpha", resolved.ActiveProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsesOpenAIEnvFallback(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
Env: map[string]string{
|
||||
"OPENAI_API_KEY": "sk-env",
|
||||
"OPENAI_BASE_URL": "https://env.example/v1",
|
||||
"OPENAI_MODEL": "env-model",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.ActiveProvider != "openai" {
|
||||
t.Fatalf("ActiveProvider = %q, want openai", resolved.ActiveProvider)
|
||||
}
|
||||
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
|
||||
t.Fatalf("ProviderKind = %q, want openai-compatible", resolved.Provider.ProviderKind)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-env" || resolved.Provider.Model != "env-model" {
|
||||
t.Fatalf("Provider = %#v, want env credentials/model", resolved.Provider)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://env.example/v1" {
|
||||
t.Fatalf("BaseURL = %q, want env URL", resolved.Provider.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsesAnthropicEnvFallback(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
Env: map[string]string{
|
||||
"ZERO_PROVIDER": "anthropic",
|
||||
"ANTHROPIC_API_KEY": "sk-ant-env",
|
||||
"ANTHROPIC_BASE_URL": "https://anthropic.example",
|
||||
"ANTHROPIC_MODEL": "claude-sonnet-4.5",
|
||||
"OPENAI_API_KEY": "sk-openai-env",
|
||||
"OPENAI_MODEL": "gpt-4.1",
|
||||
"GEMINI_API_KEY": "sk-google-env",
|
||||
"GEMINI_MODEL": "gemini-2.5-flash",
|
||||
"GEMINI_BASE_URL": "https://google.example",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.ActiveProvider != "anthropic" {
|
||||
t.Fatalf("ActiveProvider = %q, want anthropic", resolved.ActiveProvider)
|
||||
}
|
||||
if resolved.Provider.ProviderKind != ProviderKindAnthropic {
|
||||
t.Fatalf("ProviderKind = %q, want anthropic", resolved.Provider.ProviderKind)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-ant-env" || resolved.Provider.Model != "claude-sonnet-4.5" {
|
||||
t.Fatalf("Provider = %#v, want Anthropic env credentials/model", resolved.Provider)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://anthropic.example" {
|
||||
t.Fatalf("BaseURL = %q, want Anthropic env URL", resolved.Provider.BaseURL)
|
||||
}
|
||||
if len(resolved.Providers) != 3 {
|
||||
t.Fatalf("Providers = %#v, want openai, anthropic, and google env profiles", resolved.Providers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsesAnthropicEnvFallbackWithCustomProfile(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "claude-prod",
|
||||
"providers": [{
|
||||
"name": "claude-prod",
|
||||
"provider_kind": "anthropic",
|
||||
"description": "production Claude"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
ProjectConfigPath: path,
|
||||
Env: map[string]string{
|
||||
"ZERO_PROVIDER": "claude-prod",
|
||||
"ANTHROPIC_API_KEY": "sk-ant-env",
|
||||
"ANTHROPIC_BASE_URL": "https://anthropic.example",
|
||||
"ANTHROPIC_MODEL": "claude-sonnet-4.5",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.ActiveProvider != "claude-prod" {
|
||||
t.Fatalf("ActiveProvider = %q, want claude-prod", resolved.ActiveProvider)
|
||||
}
|
||||
if resolved.Provider.ProviderKind != ProviderKindAnthropic {
|
||||
t.Fatalf("ProviderKind = %q, want anthropic", resolved.Provider.ProviderKind)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-ant-env" || resolved.Provider.Model != "claude-sonnet-4.5" {
|
||||
t.Fatalf("Provider = %#v, want Anthropic env credentials/model on custom profile", resolved.Provider)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://anthropic.example" {
|
||||
t.Fatalf("BaseURL = %q, want Anthropic env URL", resolved.Provider.BaseURL)
|
||||
}
|
||||
if resolved.Provider.Description != "production Claude" {
|
||||
t.Fatalf("Description = %q, want existing custom profile metadata preserved", resolved.Provider.Description)
|
||||
}
|
||||
if len(resolved.Providers) != 1 {
|
||||
t.Fatalf("Providers = %#v, want only custom Anthropic profile", resolved.Providers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsesGoogleEnvFallbackAliases(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
Env: map[string]string{
|
||||
"ZERO_PROVIDER": "google",
|
||||
"GOOGLE_API_KEY": "sk-google-env",
|
||||
"GOOGLE_BASE_URL": "https://google.example",
|
||||
"GOOGLE_MODEL": "gemini-2.5-pro",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.ActiveProvider != "google" {
|
||||
t.Fatalf("ActiveProvider = %q, want google", resolved.ActiveProvider)
|
||||
}
|
||||
if resolved.Provider.ProviderKind != ProviderKindGoogle {
|
||||
t.Fatalf("ProviderKind = %q, want google", resolved.Provider.ProviderKind)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-google-env" || resolved.Provider.Model != "gemini-2.5-pro" {
|
||||
t.Fatalf("Provider = %#v, want Google env credentials/model", resolved.Provider)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://google.example" {
|
||||
t.Fatalf("BaseURL = %q, want Google env URL", resolved.Provider.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderCommandOverridesEnvProviderFields(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
|
||||
})
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
ProviderCommand: command,
|
||||
Env: map[string]string{
|
||||
"OPENAI_API_KEY": "sk-env",
|
||||
"OPENAI_MODEL": "gpt-env",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.ActiveProvider != "cmd" {
|
||||
t.Fatalf("ActiveProvider = %q, want cmd", resolved.ActiveProvider)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-command" {
|
||||
t.Fatalf("APIKey = %q, want provider command key", resolved.Provider.APIKey)
|
||||
}
|
||||
if resolved.Provider.Model != "gpt-command" {
|
||||
t.Fatalf("Model = %q, want provider command model", resolved.Provider.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAllowsNoConfiguredProviders(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.ActiveProvider != "" {
|
||||
t.Fatalf("ActiveProvider = %q, want empty", resolved.ActiveProvider)
|
||||
}
|
||||
if len(resolved.Providers) != 0 {
|
||||
t.Fatalf("Providers = %#v, want empty", resolved.Providers)
|
||||
}
|
||||
if resolved.Provider != (ProviderProfile{}) {
|
||||
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
|
||||
}
|
||||
if resolved.MaxTurns != defaultMaxTurns {
|
||||
t.Fatalf("MaxTurns = %d, want %d", resolved.MaxTurns, defaultMaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
|
||||
path := writeConfig(t, `{"activeProvider":"ghost","providers":[]}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err == nil {
|
||||
t.Fatal("Resolve() error = nil, want missing active provider error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `active provider "ghost" not found`) {
|
||||
t.Fatalf("error = %q, want active provider missing message", err.Error())
|
||||
}
|
||||
if resolved.ActiveProvider != "" {
|
||||
t.Fatalf("ActiveProvider = %q, want empty", resolved.ActiveProvider)
|
||||
}
|
||||
if len(resolved.Providers) != 0 {
|
||||
t.Fatalf("Providers = %#v, want empty", resolved.Providers)
|
||||
}
|
||||
if resolved.Provider != (ProviderProfile{}) {
|
||||
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
|
||||
}
|
||||
if resolved.MaxTurns != 0 {
|
||||
t.Fatalf("MaxTurns = %d, want zero on failed resolve", resolved.MaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "custom",
|
||||
"providers": [{
|
||||
"name": "custom",
|
||||
"provider_kind": "openai-compatible",
|
||||
"baseURL": " ",
|
||||
"base_url": "https://custom.example/v1",
|
||||
"apiKey": " ",
|
||||
"api_key": "sk-custom",
|
||||
"model": " ",
|
||||
"model_id": "custom-model"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://custom.example/v1" {
|
||||
t.Fatalf("BaseURL = %q, want alias fallback", resolved.Provider.BaseURL)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-custom" {
|
||||
t.Fatalf("APIKey = %q, want alias fallback", resolved.Provider.APIKey)
|
||||
}
|
||||
if resolved.Provider.Model != "custom-model" {
|
||||
t.Fatalf("Model = %q, want alias fallback", resolved.Provider.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNormalizesOfficialOpenAIBaseURL(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"provider": "openai",
|
||||
"baseURL": "openai",
|
||||
"apiKey": "sk-official",
|
||||
"model": "gpt-4.1"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.Provider.ProviderKind != ProviderKindOpenAI {
|
||||
t.Fatalf("ProviderKind = %q, want openai", resolved.Provider.ProviderKind)
|
||||
}
|
||||
if resolved.Provider.BaseURL != OpenAIBaseURL {
|
||||
t.Fatalf("BaseURL = %q, want %q", resolved.Provider.BaseURL, OpenAIBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAcceptsOfficialAnthropicAndGoogleProfiles(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "claude",
|
||||
"providers": [
|
||||
{"name": "claude", "provider": "anthropic", "apiKey": "sk-ant", "model": "claude-sonnet-4.5"},
|
||||
{"name": "gemini", "provider": "google", "apiKey": "sk-google", "model": "gemini-2.5-flash"}
|
||||
]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.Provider.ProviderKind != ProviderKindAnthropic {
|
||||
t.Fatalf("ProviderKind = %q, want anthropic", resolved.Provider.ProviderKind)
|
||||
}
|
||||
if resolved.Provider.BaseURL != AnthropicBaseURL {
|
||||
t.Fatalf("BaseURL = %q, want default Anthropic URL", resolved.Provider.BaseURL)
|
||||
}
|
||||
if resolved.Providers[1].ProviderKind != ProviderKindGoogle {
|
||||
t.Fatalf("Google ProviderKind = %q, want google", resolved.Providers[1].ProviderKind)
|
||||
}
|
||||
if resolved.Providers[1].BaseURL != GoogleBaseURL {
|
||||
t.Fatalf("Google BaseURL = %q, want default Google URL", resolved.Providers[1].BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsOpenAICompatibleWithoutBaseURL(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "custom",
|
||||
"providers": [{
|
||||
"name": "custom",
|
||||
"provider_kind": "openai-compatible",
|
||||
"apiKey": "sk-custom",
|
||||
"model": "custom-model"
|
||||
}]
|
||||
}`)
|
||||
|
||||
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err == nil {
|
||||
t.Fatal("Resolve() error = nil, want validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "openai-compatible provider custom requires baseURL") {
|
||||
t.Fatalf("error = %q, want missing baseURL message", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsUnknownProviderKind(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "bad",
|
||||
"providers": [{
|
||||
"name": "bad",
|
||||
"provider": "bedrock",
|
||||
"apiKey": "sk-bad",
|
||||
"model": "model"
|
||||
}]
|
||||
}`)
|
||||
|
||||
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err == nil {
|
||||
t.Fatal("Resolve() error = nil, want validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unknown provider kind "bedrock"`) {
|
||||
t.Fatalf("error = %q, want unknown provider kind", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRedactsSecretsFromErrors(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "custom",
|
||||
"providers": [{
|
||||
"name": "custom",
|
||||
"provider_kind": "openai-compatible",
|
||||
"apiKey": "sk-secret-value",
|
||||
"model": "custom-model"
|
||||
}]
|
||||
}`)
|
||||
|
||||
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err == nil {
|
||||
t.Fatal("Resolve() error = nil, want validation error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "sk-secret-value") {
|
||||
t.Fatalf("error leaked secret: %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "[REDACTED]") {
|
||||
t.Fatalf("error = %q, want redaction marker", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const OpenAIBaseURL = "https://api.openai.com/v1"
|
||||
const AnthropicBaseURL = "https://api.anthropic.com"
|
||||
const GoogleBaseURL = "https://generativelanguage.googleapis.com"
|
||||
|
||||
type ProviderKind string
|
||||
|
||||
const (
|
||||
ProviderKindOpenAI ProviderKind = "openai"
|
||||
ProviderKindAnthropic ProviderKind = "anthropic"
|
||||
ProviderKindGoogle ProviderKind = "google"
|
||||
ProviderKindOpenAICompatible ProviderKind = "openai-compatible"
|
||||
)
|
||||
|
||||
type ProviderProfile struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ProviderKind ProviderKind `json:"provider_kind,omitempty"`
|
||||
BaseURL string `json:"baseURL,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type FileConfig struct {
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
}
|
||||
|
||||
type ResolveOptions struct {
|
||||
UserConfigPath string
|
||||
ProjectConfigPath string
|
||||
ProviderCommand string
|
||||
Env map[string]string
|
||||
Overrides Overrides
|
||||
}
|
||||
|
||||
type Overrides struct {
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
MaxTurns int
|
||||
}
|
||||
|
||||
type ResolvedConfig struct {
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
MaxTurns int
|
||||
}
|
||||
|
||||
func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
|
||||
type rawProfile struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderKind string `json:"provider_kind"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
BaseURLSnake string `json:"base_url"`
|
||||
APIKey string `json:"apiKey"`
|
||||
APIKeySnake string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
ModelID string `json:"model_id"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
var raw rawProfile
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profile.Name = strings.TrimSpace(raw.Name)
|
||||
profile.Provider = strings.TrimSpace(raw.Provider)
|
||||
profile.ProviderKind = ProviderKind(firstNonEmpty(raw.ProviderKind, raw.Provider))
|
||||
profile.BaseURL = strings.TrimSpace(firstNonEmpty(raw.BaseURL, raw.BaseURLSnake))
|
||||
profile.APIKey = firstNonEmpty(raw.APIKey, raw.APIKeySnake)
|
||||
profile.Model = strings.TrimSpace(firstNonEmpty(raw.Model, raw.ModelID))
|
||||
profile.Description = strings.TrimSpace(raw.Description)
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPass Status = "pass"
|
||||
StatusWarn Status = "warn"
|
||||
StatusFail Status = "fail"
|
||||
)
|
||||
|
||||
type Check struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Status Status `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
OK bool `json:"ok"`
|
||||
Checks []Check `json:"checks"`
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Now func() time.Time
|
||||
Runtime string
|
||||
UserConfig string
|
||||
ProjectConfig string
|
||||
Provider config.ProviderProfile
|
||||
Connectivity bool
|
||||
}
|
||||
|
||||
func Run(options Options) Report {
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
checks := []Check{
|
||||
runtimeCheck(options.Runtime),
|
||||
configFilesCheck(options.UserConfig, options.ProjectConfig),
|
||||
}
|
||||
providerCheck := providerConfigCheck(options.Provider)
|
||||
checks = append(checks, providerCheck)
|
||||
modelCheck := providerModelCheck(options.Provider)
|
||||
checks = append(checks, modelCheck)
|
||||
checks = append(checks, connectivityCheck(options.Provider, options.Connectivity, modelCheck.Status))
|
||||
|
||||
report := Report{
|
||||
GeneratedAt: now().UTC().Format(time.RFC3339),
|
||||
OK: true,
|
||||
Checks: checks,
|
||||
}
|
||||
for _, check := range checks {
|
||||
if check.Status == StatusFail {
|
||||
report.OK = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func (report Report) Check(id string) *Check {
|
||||
for index := range report.Checks {
|
||||
if report.Checks[index].ID == id {
|
||||
return &report.Checks[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Format(report Report) string {
|
||||
lines := []string{
|
||||
fmt.Sprintf("Zero doctor report (%s)", redaction.RedactString(report.GeneratedAt, redaction.Options{})),
|
||||
fmt.Sprintf("Overall: %s", passFail(report.OK)),
|
||||
}
|
||||
for _, check := range report.Checks {
|
||||
lines = append(lines, fmt.Sprintf("[%s] %s - %s", check.Status, redaction.RedactString(check.ID, redaction.Options{}), redaction.RedactString(check.Message, redaction.Options{})))
|
||||
if details := formatDetails(check.Details); details != "" {
|
||||
lines = append(lines, " "+details)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func runtimeCheck(runtime string) Check {
|
||||
runtime = strings.TrimSpace(runtime)
|
||||
if runtime == "" {
|
||||
runtime = "go"
|
||||
}
|
||||
return check("runtime.go", "Go runtime", StatusPass, fmt.Sprintf("Zero Go runtime is available (%s).", runtime), map[string]any{"runtime": runtime})
|
||||
}
|
||||
|
||||
func configFilesCheck(userPath string, projectPath string) Check {
|
||||
details := map[string]any{}
|
||||
if strings.TrimSpace(userPath) != "" {
|
||||
details["userConfigPath"] = userPath
|
||||
}
|
||||
if strings.TrimSpace(projectPath) != "" {
|
||||
details["projectConfigPath"] = projectPath
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return check("config.files", "Config files", StatusWarn, "No explicit Zero config files were inspected.", nil)
|
||||
}
|
||||
return check("config.files", "Config files", StatusPass, "Zero config file inputs are available for inspection.", details)
|
||||
}
|
||||
|
||||
func providerConfigCheck(profile config.ProviderProfile) Check {
|
||||
if profile == (config.ProviderProfile{}) {
|
||||
return check("provider.config", "Provider config", StatusFail, "No LLM provider is configured.", map[string]any{"help": "Set a provider in config or environment."})
|
||||
}
|
||||
return check("provider.config", "Provider config", StatusPass, fmt.Sprintf("Provider config loaded for %s.", providerName(profile)), map[string]any{
|
||||
"name": profile.Name,
|
||||
"provider": profile.ProviderKind,
|
||||
"baseURL": profile.BaseURL,
|
||||
"model": profile.Model,
|
||||
"apiKey": profile.APIKey,
|
||||
})
|
||||
}
|
||||
|
||||
func providerModelCheck(profile config.ProviderProfile) Check {
|
||||
if profile == (config.ProviderProfile{}) {
|
||||
return check("provider.model", "Provider model", StatusWarn, "Model validity was skipped because provider config is unavailable.", nil)
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return check("provider.model", "Provider model", StatusFail, "Model registry could not be loaded: "+err.Error(), nil)
|
||||
}
|
||||
model, err := registry.Require(profile.Model)
|
||||
if err != nil {
|
||||
if profile.ProviderKind == config.ProviderKindOpenAICompatible {
|
||||
return check("provider.model", "Provider model", StatusWarn, "Custom OpenAI-compatible model was not found in the Zero registry; runtime will pass it through to the configured provider.", map[string]any{"model": profile.Model, "provider": providerName(profile)})
|
||||
}
|
||||
return check("provider.model", "Provider model", StatusFail, "Provider model is invalid: "+err.Error(), map[string]any{"model": profile.Model})
|
||||
}
|
||||
if !model.AllowsProvider(toModelProvider(profile)) {
|
||||
return check("provider.model", "Provider model", StatusFail, fmt.Sprintf("Model %s is not available for provider %s.", model.ID, providerName(profile)), map[string]any{"model": model.ID, "provider": providerName(profile)})
|
||||
}
|
||||
return check("provider.model", "Provider model", StatusPass, fmt.Sprintf("Model %s resolves to %s.", model.ID, model.Provider), map[string]any{
|
||||
"modelId": model.ID,
|
||||
"apiModel": model.APIModel,
|
||||
"provider": model.Provider,
|
||||
"capabilities": model.Capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
func connectivityCheck(profile config.ProviderProfile, enabled bool, modelStatus Status) Check {
|
||||
if profile == (config.ProviderProfile{}) || modelStatus == StatusFail {
|
||||
return check("provider.connectivity", "Provider connectivity", StatusWarn, "Connectivity check was skipped because provider runtime did not resolve.", nil)
|
||||
}
|
||||
if !enabled {
|
||||
return check("provider.connectivity", "Provider connectivity", StatusWarn, "Connectivity probe skipped. Run `zero doctor --connectivity` to probe the provider endpoint.", map[string]any{"baseURL": profile.BaseURL})
|
||||
}
|
||||
return check("provider.connectivity", "Provider connectivity", StatusWarn, "Connectivity probing is not wired in the Go doctor backend yet.", map[string]any{"baseURL": profile.BaseURL})
|
||||
}
|
||||
|
||||
func check(id string, label string, status Status, message string, details map[string]any) Check {
|
||||
redacted := redaction.RedactValue(map[string]any{
|
||||
"id": id,
|
||||
"label": label,
|
||||
"status": string(status),
|
||||
"message": message,
|
||||
"details": details,
|
||||
}, redaction.Options{}).(map[string]any)
|
||||
out := Check{
|
||||
ID: redacted["id"].(string),
|
||||
Label: redacted["label"].(string),
|
||||
Status: Status(redacted["status"].(string)),
|
||||
Message: redacted["message"].(string),
|
||||
}
|
||||
if detailsValue, ok := redacted["details"].(map[string]any); ok && len(detailsValue) > 0 {
|
||||
out.Details = detailsValue
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func providerName(profile config.ProviderProfile) string {
|
||||
if strings.TrimSpace(profile.Name) != "" {
|
||||
return strings.TrimSpace(profile.Name)
|
||||
}
|
||||
if strings.TrimSpace(string(profile.ProviderKind)) != "" {
|
||||
return strings.TrimSpace(string(profile.ProviderKind))
|
||||
}
|
||||
return strings.TrimSpace(profile.Provider)
|
||||
}
|
||||
|
||||
func toModelProvider(profile config.ProviderProfile) modelregistry.ProviderKind {
|
||||
switch profile.ProviderKind {
|
||||
case config.ProviderKindAnthropic:
|
||||
return modelregistry.ProviderAnthropic
|
||||
case config.ProviderKindGoogle:
|
||||
return modelregistry.ProviderGoogle
|
||||
case config.ProviderKindOpenAICompatible:
|
||||
return modelregistry.ProviderOpenAICompatible
|
||||
default:
|
||||
return modelregistry.ProviderOpenAI
|
||||
}
|
||||
}
|
||||
|
||||
func formatDetails(details map[string]any) string {
|
||||
if len(details) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(details))
|
||||
for key := range details {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(details))
|
||||
for _, key := range keys {
|
||||
value := details[key]
|
||||
parts = append(parts, fmt.Sprintf("%s: %v", redaction.RedactString(key, redaction.Options{}), redaction.RedactValue(value, redaction.Options{})))
|
||||
}
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func passFail(ok bool) string {
|
||||
if ok {
|
||||
return "pass"
|
||||
}
|
||||
return "fail"
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
func TestRunReportRedactsProviderSecretsAndWarnsWithoutConnectivity(t *testing.T) {
|
||||
report := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-04T15:00:00Z"),
|
||||
Runtime: "go",
|
||||
UserConfig: "missing",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: "sk-proj-secret1234567890",
|
||||
Model: "gpt-4.1",
|
||||
},
|
||||
})
|
||||
|
||||
if !report.OK {
|
||||
t.Fatalf("report should be ok when only connectivity is skipped: %#v", report)
|
||||
}
|
||||
if got := report.Check("provider.config"); got == nil || got.Status != StatusPass {
|
||||
t.Fatalf("provider config check missing/pass expected: %#v", report.Checks)
|
||||
}
|
||||
formatted := Format(report)
|
||||
if strings.Contains(formatted, "sk-proj-secret") {
|
||||
t.Fatalf("formatted report leaked provider secret: %q", formatted)
|
||||
}
|
||||
if !strings.Contains(formatted, "[warn] provider.connectivity") {
|
||||
t.Fatalf("expected skipped connectivity warning: %q", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReportFailsInvalidModelAndMissingProvider(t *testing.T) {
|
||||
missing := Run(Options{Now: fixedDoctorClock("2026-06-04T15:30:00Z"), Runtime: "go"})
|
||||
if missing.OK {
|
||||
t.Fatalf("missing provider should fail: %#v", missing)
|
||||
}
|
||||
if check := missing.Check("provider.config"); check == nil || check.Status != StatusFail {
|
||||
t.Fatalf("expected provider config failure: %#v", missing.Checks)
|
||||
}
|
||||
|
||||
invalid := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-04T15:30:00Z"),
|
||||
Runtime: "go",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
Model: "not-a-zero-model",
|
||||
},
|
||||
})
|
||||
if invalid.OK {
|
||||
t.Fatalf("invalid model should fail: %#v", invalid)
|
||||
}
|
||||
if check := invalid.Check("provider.model"); check == nil || check.Status != StatusFail || !strings.Contains(check.Message, "unknown Zero model") {
|
||||
t.Fatalf("expected model failure: %#v", invalid.Checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReportWarnsForUnknownOpenAICompatibleModel(t *testing.T) {
|
||||
report := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
|
||||
Runtime: "go",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "local",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://127.0.0.1:11434/v1",
|
||||
Model: "local-custom-model",
|
||||
},
|
||||
})
|
||||
|
||||
if !report.OK {
|
||||
t.Fatalf("unknown custom model should warn, not fail: %#v", report)
|
||||
}
|
||||
if check := report.Check("provider.model"); check == nil || check.Status != StatusWarn || !strings.Contains(check.Message, "pass it through") {
|
||||
t.Fatalf("expected custom model warning: %#v", report.Checks)
|
||||
}
|
||||
}
|
||||
|
||||
func fixedDoctorClock(value string) func() time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return func() time.Time { return parsed }
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package modelregistry
|
||||
|
||||
import "fmt"
|
||||
|
||||
const DefaultModelID = "gpt-4.1"
|
||||
|
||||
const sourceLastVerified = "2026-06-04"
|
||||
|
||||
const (
|
||||
openAIPricingSource = "https://platform.openai.com/docs/pricing/"
|
||||
anthropicPricingSource = "https://platform.claude.com/docs/en/about-claude/pricing"
|
||||
googlePricingSource = "https://ai.google.dev/gemini-api/docs/pricing"
|
||||
)
|
||||
|
||||
type ListOptions struct {
|
||||
IncludeDeprecated bool
|
||||
Provider ProviderKind
|
||||
Capability ModelCapability
|
||||
}
|
||||
|
||||
func DefaultRegistry() (Registry, error) {
|
||||
return NewRegistry(DefaultModelEntries())
|
||||
}
|
||||
|
||||
func DefaultModelEntries() []ModelEntry {
|
||||
entries := []ModelEntry{
|
||||
openAIModel("gpt-4.1", "GPT-4.1", "gpt-4.1", ModelStatusActive, []string{"openai:gpt-4.1"}, ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 16_384}, ModelCost{InputPerMillion: 2, CachedInputPerMillion: 0.5, OutputPerMillion: 8}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityLongContext}, "OpenAI stable long-context model for general coding sessions."),
|
||||
openAIModel("gpt-4.1-mini", "GPT-4.1 mini", "gpt-4.1-mini", ModelStatusActive, []string{"openai:gpt-4.1-mini"}, ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 32_768}, ModelCost{InputPerMillion: 0.4, CachedInputPerMillion: 0.1, OutputPerMillion: 1.6}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityLongContext}, "OpenAI lower-cost long-context model for frequent edit loops."),
|
||||
openAIModel("gpt-4.1-nano", "GPT-4.1 nano", "gpt-4.1-nano", ModelStatusActive, []string{"openai:gpt-4.1-nano"}, ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 32_768}, ModelCost{InputPerMillion: 0.1, CachedInputPerMillion: 0.025, OutputPerMillion: 0.4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityLongContext}, "OpenAI smallest GPT-4.1 model for routing, summaries, and light checks."),
|
||||
openAIModel("gpt-4o", "GPT-4o", "gpt-4o", ModelStatusActive, []string{"openai:gpt-4o"}, ContextLimits{ContextWindow: 128_000, MaxOutputTokens: 16_384}, ModelCost{InputPerMillion: 2.5, CachedInputPerMillion: 1.25, OutputPerMillion: 10}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode}, "OpenAI multimodal model kept for compatibility with existing configs."),
|
||||
openAIModel("gpt-4o-mini", "GPT-4o mini", "gpt-4o-mini", ModelStatusActive, []string{"openai:gpt-4o-mini"}, ContextLimits{ContextWindow: 128_000, MaxOutputTokens: 16_384}, ModelCost{InputPerMillion: 0.15, CachedInputPerMillion: 0.075, OutputPerMillion: 0.6}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode}, "OpenAI low-cost multimodal model for lightweight sessions."),
|
||||
openAIModel("gpt-4-turbo", "GPT-4 Turbo", "gpt-4-turbo", ModelStatusDeprecated, []string{"openai:gpt-4-turbo"}, ContextLimits{ContextWindow: 128_000, MaxOutputTokens: 4_096}, ModelCost{InputPerMillion: 10, OutputPerMillion: 30}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode}, "Deprecated OpenAI model retained for config migration and historical usage display."),
|
||||
anthropicModel("claude-opus-4.1", "Claude Opus 4.1", "claude-opus-4-1-20250805", ModelStatusActive, []string{"anthropic:claude-opus-4.1", "opus-4.1"}, ContextLimits{ContextWindow: 200_000, MaxOutputTokens: 32_000}, ModelCost{InputPerMillion: 15, CachedInputPerMillion: 1.5, OutputPerMillion: 75}, []ModelCapability{ModelCapabilityVision, ModelCapabilityReasoning, ModelCapabilityPromptCache}, standardReasoningEfforts(), "Anthropic high-capability Opus model for deep coding and planning."),
|
||||
anthropicModel("claude-sonnet-4.5", "Claude Sonnet 4.5", "claude-sonnet-4-5-20250929", ModelStatusActive, []string{"anthropic:claude-sonnet-4.5", "sonnet-4.5"}, ContextLimits{ContextWindow: 200_000, MaxOutputTokens: 64_000}, ModelCost{InputPerMillion: 3, CachedInputPerMillion: 0.3, OutputPerMillion: 15}, []ModelCapability{ModelCapabilityVision, ModelCapabilityReasoning, ModelCapabilityPromptCache}, standardReasoningEfforts(), "Anthropic balanced coding model for high-quality daily agent work."),
|
||||
anthropicModel("claude-haiku-4.5", "Claude Haiku 4.5", "claude-haiku-4-5-20251001", ModelStatusActive, []string{"anthropic:claude-haiku-4.5", "haiku-4.5"}, ContextLimits{ContextWindow: 200_000, MaxOutputTokens: 64_000}, ModelCost{InputPerMillion: 1, CachedInputPerMillion: 0.1, OutputPerMillion: 5}, []ModelCapability{ModelCapabilityVision, ModelCapabilityReasoning, ModelCapabilityPromptCache}, standardReasoningEfforts(), "Anthropic fast model for lightweight coding support and summaries."),
|
||||
anthropicModel("claude-haiku-3.5", "Claude Haiku 3.5", "claude-3-5-haiku-20241022", ModelStatusDeprecated, []string{"anthropic:claude-haiku-3.5", "haiku-3.5"}, ContextLimits{ContextWindow: 200_000, MaxOutputTokens: 8_192}, ModelCost{InputPerMillion: 0.8, CachedInputPerMillion: 0.08, OutputPerMillion: 4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityPromptCache}, nil, "Retired Anthropic Haiku model retained for migration and historical usage display."),
|
||||
googleModel("gemini-2.5-pro", "Gemini 2.5 Pro", "gemini-2.5-pro", ModelStatusActive, []string{"google:gemini-2.5-pro", "gemini-pro"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{Tiers: []ModelCostTier{{UpToInputTokens: 200_000, InputPerMillion: 1.25, CachedInputPerMillion: 0.125, OutputPerMillion: 10, Note: "Prompts up to 200k tokens."}, {InputPerMillion: 2.5, CachedInputPerMillion: 0.25, OutputPerMillion: 15, Note: "Prompts above 200k tokens."}}}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google general-purpose Pro model with tiered long-context pricing."),
|
||||
googleModel("gemini-2.5-flash", "Gemini 2.5 Flash", "gemini-2.5-flash", ModelStatusActive, []string{"google:gemini-2.5-flash", "gemini-flash"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{InputPerMillion: 0.3, CachedInputPerMillion: 0.03, OutputPerMillion: 2.5}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google Flash model for low-latency coding interactions."),
|
||||
googleModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite", "gemini-2.5-flash-lite", ModelStatusActive, []string{"google:gemini-2.5-flash-lite", "gemini-flash-lite"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{InputPerMillion: 0.1, CachedInputPerMillion: 0.01, OutputPerMillion: 0.4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google low-cost Flash model for background routing and summaries."),
|
||||
}
|
||||
return cloneModelEntries(entries)
|
||||
}
|
||||
|
||||
func (registry Registry) List(options ListOptions) []ModelEntry {
|
||||
models := make([]ModelEntry, 0, len(registry.models))
|
||||
for _, model := range registry.models {
|
||||
if !options.IncludeDeprecated && model.Status == ModelStatusDeprecated {
|
||||
continue
|
||||
}
|
||||
if options.Provider != "" && model.Provider != options.Provider {
|
||||
continue
|
||||
}
|
||||
if options.Capability != "" && !model.Supports(options.Capability) {
|
||||
continue
|
||||
}
|
||||
models = append(models, cloneModelEntry(model))
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
func (registry Registry) ListByProvider(provider ProviderKind) []ModelEntry {
|
||||
return registry.List(ListOptions{Provider: provider})
|
||||
}
|
||||
|
||||
func (registry Registry) ListByCapability(capability ModelCapability) []ModelEntry {
|
||||
return registry.List(ListOptions{Capability: capability})
|
||||
}
|
||||
|
||||
func (registry Registry) ResolveID(pattern string) (string, bool) {
|
||||
model, ok := registry.Get(pattern)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return model.ID, true
|
||||
}
|
||||
|
||||
func (registry Registry) Require(pattern string) (ModelEntry, error) {
|
||||
model, ok := registry.Get(pattern)
|
||||
if !ok {
|
||||
return ModelEntry{}, fmt.Errorf("unknown Zero model %q", pattern)
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func (registry Registry) SupportsCapability(pattern string, capability ModelCapability) bool {
|
||||
model, ok := registry.Get(pattern)
|
||||
return ok && model.Supports(capability)
|
||||
}
|
||||
|
||||
func (registry Registry) ReasoningEfforts(pattern string) []ReasoningEffort {
|
||||
model, ok := registry.Get(pattern)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return append([]ReasoningEffort{}, model.ReasoningEfforts...)
|
||||
}
|
||||
|
||||
func (registry Registry) RequireProvider(pattern string, provider ProviderKind) (ModelEntry, error) {
|
||||
model, err := registry.Require(pattern)
|
||||
if err != nil {
|
||||
return ModelEntry{}, err
|
||||
}
|
||||
if model.Provider != provider {
|
||||
return ModelEntry{}, fmt.Errorf("zero model %s belongs to %s, not %s", model.ID, model.Provider, provider)
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func openAIModel(id string, displayName string, apiModel string, status ModelStatus, aliases []string, limits ContextLimits, cost ModelCost, extraCapabilities []ModelCapability, description string) ModelEntry {
|
||||
cost.Currency = "USD"
|
||||
cost.Unit = "per_1m_tokens"
|
||||
cost.Source = openAIPricingSource
|
||||
cost.SourceLastVerified = sourceLastVerified
|
||||
return ModelEntry{
|
||||
ID: id,
|
||||
DisplayName: displayName,
|
||||
APIModel: apiModel,
|
||||
Provider: ProviderOpenAI,
|
||||
APIProviders: []ProviderKind{ProviderOpenAI, ProviderOpenAICompatible},
|
||||
ContextLimits: limits,
|
||||
Capabilities: withBaseCapabilities(extraCapabilities...),
|
||||
Cost: cost,
|
||||
Status: status,
|
||||
Aliases: aliases,
|
||||
Description: description,
|
||||
}
|
||||
}
|
||||
|
||||
func anthropicModel(id string, displayName string, apiModel string, status ModelStatus, aliases []string, limits ContextLimits, cost ModelCost, extraCapabilities []ModelCapability, efforts []ReasoningEffort, description string) ModelEntry {
|
||||
cost.Currency = "USD"
|
||||
cost.Unit = "per_1m_tokens"
|
||||
cost.Source = anthropicPricingSource
|
||||
cost.SourceLastVerified = sourceLastVerified
|
||||
cost.Notes = append(cost.Notes, "Claude cached input pricing models cache hits and refreshes.")
|
||||
return ModelEntry{
|
||||
ID: id,
|
||||
DisplayName: displayName,
|
||||
APIModel: apiModel,
|
||||
Provider: ProviderAnthropic,
|
||||
ContextLimits: limits,
|
||||
ReasoningEfforts: efforts,
|
||||
Capabilities: withBaseCapabilities(extraCapabilities...),
|
||||
Cost: cost,
|
||||
Status: status,
|
||||
Aliases: aliases,
|
||||
Description: description,
|
||||
}
|
||||
}
|
||||
|
||||
func googleModel(id string, displayName string, apiModel string, status ModelStatus, aliases []string, limits ContextLimits, cost ModelCost, extraCapabilities []ModelCapability, efforts []ReasoningEffort, description string) ModelEntry {
|
||||
cost.Currency = "USD"
|
||||
cost.Unit = "per_1m_tokens"
|
||||
cost.Source = googlePricingSource
|
||||
cost.SourceLastVerified = sourceLastVerified
|
||||
return ModelEntry{
|
||||
ID: id,
|
||||
DisplayName: displayName,
|
||||
APIModel: apiModel,
|
||||
Provider: ProviderGoogle,
|
||||
ContextLimits: limits,
|
||||
ReasoningEfforts: efforts,
|
||||
Capabilities: withBaseCapabilities(extraCapabilities...),
|
||||
Cost: cost,
|
||||
Status: status,
|
||||
Aliases: aliases,
|
||||
Description: description,
|
||||
}
|
||||
}
|
||||
|
||||
func withBaseCapabilities(extra ...ModelCapability) []ModelCapability {
|
||||
capabilities := []ModelCapability{
|
||||
ModelCapabilityChat,
|
||||
ModelCapabilityStreaming,
|
||||
ModelCapabilityToolCalling,
|
||||
ModelCapabilitySystemPrompt,
|
||||
}
|
||||
return append(capabilities, extra...)
|
||||
}
|
||||
|
||||
func standardReasoningEfforts() []ReasoningEffort {
|
||||
return []ReasoningEffort{
|
||||
ReasoningEffortLow,
|
||||
ReasoningEffortMedium,
|
||||
ReasoningEffortHigh,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneModelEntries(entries []ModelEntry) []ModelEntry {
|
||||
cloned := make([]ModelEntry, len(entries))
|
||||
for index, entry := range entries {
|
||||
cloned[index] = cloneModelEntry(entry)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneModelEntry(entry ModelEntry) ModelEntry {
|
||||
entry.APIProviders = append([]ProviderKind{}, entry.APIProviders...)
|
||||
entry.ReasoningEfforts = append([]ReasoningEffort{}, entry.ReasoningEfforts...)
|
||||
entry.Capabilities = append([]ModelCapability{}, entry.Capabilities...)
|
||||
entry.Aliases = append([]string{}, entry.Aliases...)
|
||||
entry.Cost.Tiers = append([]ModelCostTier{}, entry.Cost.Tiers...)
|
||||
entry.Cost.Notes = append([]string{}, entry.Cost.Notes...)
|
||||
return entry
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package modelregistry
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultRegistryCoversM1ModelCatalog(t *testing.T) {
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry returned error: %v", err)
|
||||
}
|
||||
|
||||
models := registry.List(ListOptions{})
|
||||
if len(models) < 10 {
|
||||
t.Fatalf("default registry has %d active/preview models, want at least 10", len(models))
|
||||
}
|
||||
for _, model := range models {
|
||||
if model.Status == ModelStatusDeprecated {
|
||||
t.Fatalf("List without include deprecated returned deprecated model %q", model.ID)
|
||||
}
|
||||
if model.ID == "" || model.DisplayName == "" || model.APIModel == "" {
|
||||
t.Fatalf("model should expose complete identity metadata: %#v", model)
|
||||
}
|
||||
if model.ContextLimits.ContextWindow <= 0 || model.ContextLimits.MaxOutputTokens <= 0 {
|
||||
t.Fatalf("model should expose context limits: %#v", model)
|
||||
}
|
||||
if !model.Supports(ModelCapabilityChat) || !model.Supports(ModelCapabilityStreaming) {
|
||||
t.Fatalf("model %q should expose chat and streaming capabilities", model.ID)
|
||||
}
|
||||
if model.Cost.Currency != "USD" || model.Cost.Unit != "per_1m_tokens" {
|
||||
t.Fatalf("model %q should expose USD token pricing: %#v", model.ID, model.Cost)
|
||||
}
|
||||
if !strings.HasPrefix(model.Cost.Source, "https://") {
|
||||
t.Fatalf("model %q should expose source URL: %#v", model.ID, model.Cost)
|
||||
}
|
||||
}
|
||||
|
||||
providers := map[ProviderKind]bool{}
|
||||
for _, model := range models {
|
||||
providers[model.Provider] = true
|
||||
}
|
||||
for _, provider := range []ProviderKind{ProviderOpenAI, ProviderAnthropic, ProviderGoogle} {
|
||||
if !providers[provider] {
|
||||
t.Fatalf("default registry missing provider %q", provider)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := registry.Require("gpt-4-turbo"); err != nil {
|
||||
t.Fatalf("deprecated models should remain resolvable for migration/history: %v", err)
|
||||
}
|
||||
if !containsModelID(registry.List(ListOptions{IncludeDeprecated: true}), "gpt-4-turbo") {
|
||||
t.Fatal("include deprecated list should retain migration metadata")
|
||||
}
|
||||
if DefaultModelID != "gpt-4.1" {
|
||||
t.Fatalf("DefaultModelID = %q, want gpt-4.1", DefaultModelID)
|
||||
}
|
||||
|
||||
haiku, err := registry.Require("claude-haiku-4.5")
|
||||
if err != nil {
|
||||
t.Fatalf("claude-haiku-4.5 should be resolvable: %v", err)
|
||||
}
|
||||
if haiku.APIModel != "claude-haiku-4-5-20251001" {
|
||||
t.Fatalf("claude-haiku-4.5 API model = %q, want claude-haiku-4-5-20251001", haiku.APIModel)
|
||||
}
|
||||
if haiku.ContextLimits.ContextWindow != 200_000 || haiku.ContextLimits.MaxOutputTokens != 64_000 {
|
||||
t.Fatalf("claude-haiku-4.5 limits = %#v, want 200k context and 64k max output", haiku.ContextLimits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRegistryResolvesAliasesAndStableFilters(t *testing.T) {
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry returned error: %v", err)
|
||||
}
|
||||
|
||||
if id, ok := registry.ResolveID(" OPENAI:GPT-4.1 "); !ok || id != "gpt-4.1" {
|
||||
t.Fatalf("ResolveID(openai alias) = %q/%v, want gpt-4.1/true", id, ok)
|
||||
}
|
||||
if id, ok := registry.ResolveID("sonnet-4.5"); !ok || id != "claude-sonnet-4.5" {
|
||||
t.Fatalf("ResolveID(sonnet alias) = %q/%v, want claude-sonnet-4.5/true", id, ok)
|
||||
}
|
||||
if id, ok := registry.ResolveID("gemini-flash"); !ok || id != "gemini-2.5-flash" {
|
||||
t.Fatalf("ResolveID(gemini alias) = %q/%v, want gemini-2.5-flash/true", id, ok)
|
||||
}
|
||||
if _, ok := registry.ResolveID("gpt-latest"); ok {
|
||||
t.Fatal("registry should not expose speculative latest aliases")
|
||||
}
|
||||
|
||||
for _, model := range registry.ListByProvider(ProviderOpenAI) {
|
||||
if model.Provider != ProviderOpenAI {
|
||||
t.Fatalf("ListByProvider(openai) returned %q", model.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
visionModels := registry.ListByCapability(ModelCapabilityVision)
|
||||
if len(visionModels) == 0 {
|
||||
t.Fatal("expected at least one vision model")
|
||||
}
|
||||
for _, model := range visionModels {
|
||||
if !model.Supports(ModelCapabilityVision) {
|
||||
t.Fatalf("ListByCapability(vision) returned model without vision: %#v", model)
|
||||
}
|
||||
}
|
||||
if !registry.SupportsCapability("gemini-2.5-pro", ModelCapabilityReasoning) {
|
||||
t.Fatal("gemini-2.5-pro should expose reasoning capability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRegistryReasoningAndProviderAssertions(t *testing.T) {
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry returned error: %v", err)
|
||||
}
|
||||
|
||||
efforts := registry.ReasoningEfforts("gemini-2.5-pro")
|
||||
wantEfforts := []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
|
||||
if len(efforts) != len(wantEfforts) {
|
||||
t.Fatalf("ReasoningEfforts length = %d, want %d: %#v", len(efforts), len(wantEfforts), efforts)
|
||||
}
|
||||
for index, effort := range wantEfforts {
|
||||
if efforts[index] != effort {
|
||||
t.Fatalf("ReasoningEfforts[%d] = %q, want %q", index, efforts[index], effort)
|
||||
}
|
||||
}
|
||||
if efforts := registry.ReasoningEfforts("gpt-4o"); len(efforts) != 0 {
|
||||
t.Fatalf("gpt-4o reasoning efforts = %#v, want empty", efforts)
|
||||
}
|
||||
|
||||
model, err := registry.RequireProvider("gpt-4.1", ProviderOpenAI)
|
||||
if err != nil {
|
||||
t.Fatalf("RequireProvider(openai) returned error: %v", err)
|
||||
}
|
||||
if model.ID != "gpt-4.1" {
|
||||
t.Fatalf("RequireProvider returned %q, want gpt-4.1", model.ID)
|
||||
}
|
||||
if _, err := registry.RequireProvider("gpt-4.1", ProviderAnthropic); err == nil {
|
||||
t.Fatal("RequireProvider should reject provider mismatches")
|
||||
} else if !strings.Contains(err.Error(), "belongs to openai") {
|
||||
t.Fatalf("provider mismatch error = %q, want belongs to openai", err.Error())
|
||||
}
|
||||
if _, err := registry.Require("unknown-model"); err == nil {
|
||||
t.Fatal("Require should reject unknown models")
|
||||
} else if !strings.Contains(err.Error(), "unknown Zero model") {
|
||||
t.Fatalf("unknown model error = %q, want unknown Zero model", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func containsModelID(models []ModelEntry, id string) bool {
|
||||
for _, model := range models {
|
||||
if model.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package modelregistry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
const tokensPerMillion = 1_000_000
|
||||
|
||||
type CostBreakdown struct {
|
||||
ModelID string
|
||||
Provider ProviderKind
|
||||
Currency string
|
||||
InputTokens int
|
||||
CachedInputTokens int
|
||||
OutputTokens int
|
||||
ReasoningTokens int
|
||||
InputCost float64
|
||||
CachedInputCost float64
|
||||
OutputCost float64
|
||||
TotalCost float64
|
||||
PricingTier *ModelCostTier
|
||||
}
|
||||
|
||||
func (registry Registry) EstimateCost(pattern string, usage zeroruntime.Usage) (CostBreakdown, error) {
|
||||
model, err := registry.Require(pattern)
|
||||
if err != nil {
|
||||
return CostBreakdown{}, err
|
||||
}
|
||||
return CalculateCost(model, usage)
|
||||
}
|
||||
|
||||
func CalculateCost(model ModelEntry, usage zeroruntime.Usage) (CostBreakdown, error) {
|
||||
inputTokens, err := nonNegativeUsage(usage.EffectiveInputTokens(), "input tokens")
|
||||
if err != nil {
|
||||
return CostBreakdown{}, err
|
||||
}
|
||||
outputTokens, err := nonNegativeUsage(usage.EffectiveOutputTokens(), "output tokens")
|
||||
if err != nil {
|
||||
return CostBreakdown{}, err
|
||||
}
|
||||
reasoningTokens, err := nonNegativeUsage(usage.ReasoningTokens, "reasoning tokens")
|
||||
if err != nil {
|
||||
return CostBreakdown{}, err
|
||||
}
|
||||
requestedCachedInputTokens, err := nonNegativeUsage(usage.CachedInputTokens, "cached input tokens")
|
||||
if err != nil {
|
||||
return CostBreakdown{}, err
|
||||
}
|
||||
if requestedCachedInputTokens > inputTokens {
|
||||
requestedCachedInputTokens = inputTokens
|
||||
}
|
||||
|
||||
tier, err := selectCostTier(model.Cost, inputTokens)
|
||||
if err != nil {
|
||||
return CostBreakdown{}, err
|
||||
}
|
||||
|
||||
inputRate, outputRate, cachedRate, err := costRates(model.Cost, tier)
|
||||
if err != nil {
|
||||
return CostBreakdown{}, err
|
||||
}
|
||||
|
||||
cachedInputTokens := 0
|
||||
if cachedRate > 0 {
|
||||
cachedInputTokens = requestedCachedInputTokens
|
||||
}
|
||||
uncachedInputTokens := inputTokens - cachedInputTokens
|
||||
billableOutputTokens := outputTokens + reasoningTokens
|
||||
inputCost := costForTokens(uncachedInputTokens, inputRate)
|
||||
cachedInputCost := costForTokens(cachedInputTokens, cachedRate)
|
||||
outputCost := costForTokens(billableOutputTokens, outputRate)
|
||||
|
||||
breakdown := CostBreakdown{
|
||||
ModelID: model.ID,
|
||||
Provider: model.Provider,
|
||||
Currency: model.Cost.Currency,
|
||||
InputTokens: inputTokens,
|
||||
CachedInputTokens: cachedInputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
ReasoningTokens: reasoningTokens,
|
||||
InputCost: inputCost,
|
||||
CachedInputCost: cachedInputCost,
|
||||
OutputCost: outputCost,
|
||||
TotalCost: inputCost + cachedInputCost + outputCost,
|
||||
}
|
||||
if tier != nil {
|
||||
tierCopy := *tier
|
||||
breakdown.PricingTier = &tierCopy
|
||||
}
|
||||
return breakdown, nil
|
||||
}
|
||||
|
||||
func FormatCostUSD(cost float64) (string, error) {
|
||||
if math.IsNaN(cost) || math.IsInf(cost, 0) || cost < 0 {
|
||||
return "", fmt.Errorf("invalid model cost: %v", cost)
|
||||
}
|
||||
if cost > 0 && cost < 0.01 {
|
||||
return fmt.Sprintf("$%.6f", cost), nil
|
||||
}
|
||||
return fmt.Sprintf("$%.4f", cost), nil
|
||||
}
|
||||
|
||||
func selectCostTier(cost ModelCost, inputTokens int) (*ModelCostTier, error) {
|
||||
if len(cost.Tiers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tiers := append([]ModelCostTier{}, cost.Tiers...)
|
||||
sort.SliceStable(tiers, func(left int, right int) bool {
|
||||
leftBound := tiers[left].UpToInputTokens
|
||||
rightBound := tiers[right].UpToInputTokens
|
||||
if leftBound == 0 {
|
||||
return false
|
||||
}
|
||||
if rightBound == 0 {
|
||||
return true
|
||||
}
|
||||
return leftBound < rightBound
|
||||
})
|
||||
|
||||
for _, tier := range tiers {
|
||||
if tier.UpToInputTokens > 0 && inputTokens <= tier.UpToInputTokens {
|
||||
return &tier, nil
|
||||
}
|
||||
}
|
||||
for _, tier := range tiers {
|
||||
if tier.UpToInputTokens == 0 {
|
||||
return &tier, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no model cost tier covers %d input tokens", inputTokens)
|
||||
}
|
||||
|
||||
func costRates(cost ModelCost, tier *ModelCostTier) (float64, float64, float64, error) {
|
||||
inputRate := cost.InputPerMillion
|
||||
outputRate := cost.OutputPerMillion
|
||||
cachedRate := cost.CachedInputPerMillion
|
||||
if tier != nil {
|
||||
inputRate = tier.InputPerMillion
|
||||
outputRate = tier.OutputPerMillion
|
||||
cachedRate = tier.CachedInputPerMillion
|
||||
}
|
||||
if !validRate(inputRate) || inputRate == 0 {
|
||||
return 0, 0, 0, fmt.Errorf("missing model input pricing rate")
|
||||
}
|
||||
if !validRate(outputRate) || outputRate == 0 {
|
||||
return 0, 0, 0, fmt.Errorf("missing model output pricing rate")
|
||||
}
|
||||
if !validRate(cachedRate) {
|
||||
return 0, 0, 0, fmt.Errorf("invalid model cached input pricing rate")
|
||||
}
|
||||
return inputRate, outputRate, cachedRate, nil
|
||||
}
|
||||
|
||||
func costForTokens(tokens int, perMillionRate float64) float64 {
|
||||
return (float64(tokens) / tokensPerMillion) * perMillionRate
|
||||
}
|
||||
|
||||
func nonNegativeUsage(value int, label string) (int, error) {
|
||||
if value < 0 {
|
||||
return 0, fmt.Errorf("%s must be non-negative", label)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validRate(rate float64) bool {
|
||||
return !math.IsNaN(rate) && !math.IsInf(rate, 0) && rate >= 0
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
package modelregistry
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestRegistryEstimatesCostFromNormalizedUsage(t *testing.T) {
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry returned error: %v", err)
|
||||
}
|
||||
|
||||
cost, err := registry.EstimateCost("gpt-4.1", zeroruntime.Usage{
|
||||
InputTokens: 1_000_000,
|
||||
CachedInputTokens: 100_000,
|
||||
OutputTokens: 500_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EstimateCost returned error: %v", err)
|
||||
}
|
||||
|
||||
if cost.ModelID != "gpt-4.1" || cost.Provider != ProviderOpenAI {
|
||||
t.Fatalf("cost identity = %#v, want gpt-4.1/openai", cost)
|
||||
}
|
||||
assertClose(t, cost.InputCost, 1.8)
|
||||
assertClose(t, cost.CachedInputCost, 0.05)
|
||||
assertClose(t, cost.OutputCost, 4)
|
||||
assertClose(t, cost.TotalCost, 5.85)
|
||||
}
|
||||
|
||||
func TestRegistryCostSupportsAliasesAndFullyCachedInput(t *testing.T) {
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry returned error: %v", err)
|
||||
}
|
||||
|
||||
cost, err := registry.EstimateCost("openai:gpt-4.1-mini", zeroruntime.Usage{
|
||||
InputTokens: 1_000_000,
|
||||
CachedInputTokens: 1_000_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EstimateCost returned error: %v", err)
|
||||
}
|
||||
|
||||
if cost.InputCost != 0 {
|
||||
t.Fatalf("InputCost = %v, want 0 for fully cached input", cost.InputCost)
|
||||
}
|
||||
assertClose(t, cost.CachedInputCost, 0.1)
|
||||
assertClose(t, cost.TotalCost, 0.1)
|
||||
}
|
||||
|
||||
func TestRegistryCostUsesPromptAndCompletionAliases(t *testing.T) {
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry returned error: %v", err)
|
||||
}
|
||||
|
||||
cost, err := registry.EstimateCost("haiku-3.5", zeroruntime.Usage{
|
||||
PromptTokens: 2_000,
|
||||
CompletionTokens: 1_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EstimateCost returned error: %v", err)
|
||||
}
|
||||
|
||||
if cost.InputTokens != 2_000 || cost.OutputTokens != 1_000 {
|
||||
t.Fatalf("usage = %#v, want prompt/completion aliases", cost)
|
||||
}
|
||||
assertClose(t, cost.TotalCost, 0.0056)
|
||||
}
|
||||
|
||||
func TestRegistryCostIgnoresCachedInputWithoutCachePricing(t *testing.T) {
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry returned error: %v", err)
|
||||
}
|
||||
|
||||
cost, err := registry.EstimateCost("gpt-4-turbo", zeroruntime.Usage{
|
||||
InputTokens: 1_000,
|
||||
CachedInputTokens: 1_000,
|
||||
OutputTokens: 1_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EstimateCost returned error: %v", err)
|
||||
}
|
||||
|
||||
if cost.CachedInputTokens != 0 || cost.CachedInputCost != 0 {
|
||||
t.Fatalf("cached input should be ignored for uncached model pricing: %#v", cost)
|
||||
}
|
||||
assertClose(t, cost.InputCost, 0.01)
|
||||
assertClose(t, cost.OutputCost, 0.03)
|
||||
}
|
||||
|
||||
func TestRegistryCostSelectsTierForLongContextPricing(t *testing.T) {
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry returned error: %v", err)
|
||||
}
|
||||
|
||||
shortPrompt, err := registry.EstimateCost("gemini-2.5-pro", zeroruntime.Usage{
|
||||
InputTokens: 200_000,
|
||||
OutputTokens: 1_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EstimateCost(shortPrompt) returned error: %v", err)
|
||||
}
|
||||
longPrompt, err := registry.EstimateCost("gemini-2.5-pro", zeroruntime.Usage{
|
||||
InputTokens: 200_001,
|
||||
OutputTokens: 1_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EstimateCost(longPrompt) returned error: %v", err)
|
||||
}
|
||||
|
||||
if shortPrompt.PricingTier == nil || shortPrompt.PricingTier.InputPerMillion != 1.25 {
|
||||
t.Fatalf("short tier = %#v, want input rate 1.25", shortPrompt.PricingTier)
|
||||
}
|
||||
if longPrompt.PricingTier == nil || longPrompt.PricingTier.InputPerMillion != 2.5 {
|
||||
t.Fatalf("long tier = %#v, want input rate 2.5", longPrompt.PricingTier)
|
||||
}
|
||||
if longPrompt.TotalCost <= shortPrompt.TotalCost {
|
||||
t.Fatalf("long prompt cost %v should be greater than short prompt cost %v", longPrompt.TotalCost, shortPrompt.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostFormattingAndValidation(t *testing.T) {
|
||||
got, err := FormatCostUSD(0.000123)
|
||||
if err != nil {
|
||||
t.Fatalf("FormatCostUSD small returned error: %v", err)
|
||||
}
|
||||
if got != "$0.000123" {
|
||||
t.Fatalf("FormatCostUSD small = %q, want $0.000123", got)
|
||||
}
|
||||
got, err = FormatCostUSD(1.23456)
|
||||
if err != nil {
|
||||
t.Fatalf("FormatCostUSD regular returned error: %v", err)
|
||||
}
|
||||
if got != "$1.2346" {
|
||||
t.Fatalf("FormatCostUSD regular = %q, want $1.2346", got)
|
||||
}
|
||||
if _, err := FormatCostUSD(-1); err == nil {
|
||||
t.Fatal("FormatCostUSD should reject negative values")
|
||||
}
|
||||
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry returned error: %v", err)
|
||||
}
|
||||
_, err = registry.EstimateCost("gpt-4.1", zeroruntime.Usage{InputTokens: -1})
|
||||
if err == nil {
|
||||
t.Fatal("EstimateCost should reject negative usage")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "input tokens") {
|
||||
t.Fatalf("negative usage error = %q, want input tokens", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func assertClose(t *testing.T, got float64, want float64) {
|
||||
t.Helper()
|
||||
|
||||
diff := got - want
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 0.0000001 {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
package modelregistry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProviderKind string
|
||||
|
||||
const (
|
||||
ProviderOpenAI ProviderKind = "openai"
|
||||
ProviderAnthropic ProviderKind = "anthropic"
|
||||
ProviderGoogle ProviderKind = "google"
|
||||
ProviderOpenAICompatible ProviderKind = "openai-compatible"
|
||||
)
|
||||
|
||||
type ReasoningEffort string
|
||||
|
||||
const (
|
||||
ReasoningEffortNone ReasoningEffort = "none"
|
||||
ReasoningEffortMinimal ReasoningEffort = "minimal"
|
||||
ReasoningEffortLow ReasoningEffort = "low"
|
||||
ReasoningEffortMedium ReasoningEffort = "medium"
|
||||
ReasoningEffortHigh ReasoningEffort = "high"
|
||||
ReasoningEffortXHigh ReasoningEffort = "xhigh"
|
||||
)
|
||||
|
||||
type ModelCapability string
|
||||
|
||||
const (
|
||||
ModelCapabilityChat ModelCapability = "chat"
|
||||
ModelCapabilityStreaming ModelCapability = "streaming"
|
||||
ModelCapabilityToolCalling ModelCapability = "tool-calling"
|
||||
ModelCapabilityVision ModelCapability = "vision"
|
||||
ModelCapabilityJSONMode ModelCapability = "json-mode"
|
||||
ModelCapabilityReasoning ModelCapability = "reasoning"
|
||||
ModelCapabilitySystemPrompt ModelCapability = "system-prompt"
|
||||
ModelCapabilityPromptCache ModelCapability = "prompt-cache"
|
||||
ModelCapabilityLongContext ModelCapability = "long-context"
|
||||
)
|
||||
|
||||
type ModelCapabilities []ModelCapability
|
||||
|
||||
type ModelStatus string
|
||||
|
||||
const (
|
||||
ModelStatusActive ModelStatus = "active"
|
||||
ModelStatusPreview ModelStatus = "preview"
|
||||
ModelStatusDeprecated ModelStatus = "deprecated"
|
||||
)
|
||||
|
||||
type ContextLimits struct {
|
||||
ContextWindow int
|
||||
MaxOutputTokens int
|
||||
}
|
||||
|
||||
type ModelCost struct {
|
||||
Currency string
|
||||
Unit string
|
||||
InputPerMillion float64
|
||||
OutputPerMillion float64
|
||||
CachedInputPerMillion float64
|
||||
Tiers []ModelCostTier
|
||||
Source string
|
||||
SourceLastVerified string
|
||||
Notes []string
|
||||
}
|
||||
|
||||
type ModelCostTier struct {
|
||||
UpToInputTokens int
|
||||
InputPerMillion float64
|
||||
OutputPerMillion float64
|
||||
CachedInputPerMillion float64
|
||||
Note string
|
||||
}
|
||||
|
||||
type ModelEntry struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
APIModel string
|
||||
Provider ProviderKind
|
||||
APIProviders []ProviderKind
|
||||
ContextLimits ContextLimits
|
||||
ReasoningEfforts []ReasoningEffort
|
||||
Capabilities ModelCapabilities
|
||||
Cost ModelCost
|
||||
Status ModelStatus
|
||||
Aliases []string
|
||||
Description string
|
||||
}
|
||||
|
||||
func (model ModelEntry) Validate() error {
|
||||
if strings.TrimSpace(model.ID) == "" {
|
||||
return fmt.Errorf("model id is required")
|
||||
}
|
||||
if strings.TrimSpace(model.DisplayName) == "" {
|
||||
return fmt.Errorf("model display name is required")
|
||||
}
|
||||
if strings.TrimSpace(model.APIModel) == "" {
|
||||
return fmt.Errorf("api model is required")
|
||||
}
|
||||
if !ValidPrimaryProviderKind(model.Provider) {
|
||||
return fmt.Errorf("unknown primary provider %q", model.Provider)
|
||||
}
|
||||
if model.ContextLimits.ContextWindow <= 0 {
|
||||
return fmt.Errorf("context window must be positive")
|
||||
}
|
||||
if model.ContextLimits.MaxOutputTokens <= 0 {
|
||||
return fmt.Errorf("max output tokens must be positive")
|
||||
}
|
||||
if model.ContextLimits.MaxOutputTokens > model.ContextLimits.ContextWindow {
|
||||
return fmt.Errorf("max output tokens cannot exceed context window")
|
||||
}
|
||||
if len(model.Capabilities) == 0 {
|
||||
return fmt.Errorf("at least one model capability is required")
|
||||
}
|
||||
for _, capability := range model.Capabilities {
|
||||
if !ValidModelCapability(capability) {
|
||||
return fmt.Errorf("unknown model capability %q", capability)
|
||||
}
|
||||
}
|
||||
for _, effort := range model.ReasoningEfforts {
|
||||
if !ValidReasoningEffort(effort) {
|
||||
return fmt.Errorf("unknown reasoning effort %q", effort)
|
||||
}
|
||||
}
|
||||
if err := model.Cost.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !ValidModelStatus(model.Status) {
|
||||
return fmt.Errorf("unknown model status %q", model.Status)
|
||||
}
|
||||
if len(model.Aliases) == 0 {
|
||||
return fmt.Errorf("at least one model alias is required")
|
||||
}
|
||||
for _, alias := range model.Aliases {
|
||||
if strings.TrimSpace(alias) == "" {
|
||||
return fmt.Errorf("model aliases cannot be blank")
|
||||
}
|
||||
}
|
||||
for _, provider := range model.APIProviders {
|
||||
if !ValidRuntimeProviderKind(provider) {
|
||||
return fmt.Errorf("unknown api provider %q", provider)
|
||||
}
|
||||
}
|
||||
if len(model.APIProviders) > 0 && !model.AllowsProvider(model.Provider) {
|
||||
return fmt.Errorf("primary provider %q not allowed by api providers", model.Provider)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cost ModelCost) Validate() error {
|
||||
if cost.Currency != "USD" {
|
||||
return fmt.Errorf("model cost currency must be USD")
|
||||
}
|
||||
if cost.Unit != "per_1m_tokens" {
|
||||
return fmt.Errorf("model cost unit must be per_1m_tokens")
|
||||
}
|
||||
if cost.InputPerMillion < 0 || cost.OutputPerMillion < 0 || cost.CachedInputPerMillion < 0 {
|
||||
return fmt.Errorf("model cost values must be non-negative")
|
||||
}
|
||||
if len(cost.Tiers) == 0 {
|
||||
if cost.InputPerMillion == 0 && cost.OutputPerMillion == 0 {
|
||||
return fmt.Errorf("model cost must include base pricing or pricing tiers")
|
||||
}
|
||||
if cost.InputPerMillion == 0 || cost.OutputPerMillion == 0 {
|
||||
return fmt.Errorf("model cost base input and output rates must be positive")
|
||||
}
|
||||
}
|
||||
if err := validateCostTiers(cost.Tiers); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cost.Source) == "" {
|
||||
return fmt.Errorf("model cost source is required")
|
||||
}
|
||||
sourceLastVerified := strings.TrimSpace(cost.SourceLastVerified)
|
||||
if sourceLastVerified == "" {
|
||||
return fmt.Errorf("model cost source last verified date is required")
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", sourceLastVerified); err != nil {
|
||||
return fmt.Errorf("model cost source last verified date must use YYYY-MM-DD format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCostTiers(tiers []ModelCostTier) error {
|
||||
seenFallback := false
|
||||
for index, tier := range tiers {
|
||||
if tier.UpToInputTokens < 0 {
|
||||
return fmt.Errorf("model cost tier input ceiling must be non-negative")
|
||||
}
|
||||
if tier.InputPerMillion <= 0 || tier.OutputPerMillion <= 0 {
|
||||
return fmt.Errorf("model cost tier input and output rates must be positive")
|
||||
}
|
||||
if tier.CachedInputPerMillion < 0 {
|
||||
return fmt.Errorf("model cost tier cached input rate must be non-negative")
|
||||
}
|
||||
if tier.UpToInputTokens == 0 {
|
||||
if seenFallback {
|
||||
return fmt.Errorf("model cost tiers can include only one fallback tier")
|
||||
}
|
||||
if index != len(tiers)-1 {
|
||||
return fmt.Errorf("model cost fallback tier must be last")
|
||||
}
|
||||
seenFallback = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (model ModelEntry) Supports(capability ModelCapability) bool {
|
||||
for _, candidate := range model.Capabilities {
|
||||
if candidate == capability {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (model ModelEntry) AllowsProvider(provider ProviderKind) bool {
|
||||
if len(model.APIProviders) == 0 {
|
||||
return model.Provider == provider
|
||||
}
|
||||
for _, candidate := range model.APIProviders {
|
||||
if candidate == provider {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
entries map[string]ModelEntry
|
||||
models []ModelEntry
|
||||
}
|
||||
|
||||
func NewRegistry(entries []ModelEntry) (Registry, error) {
|
||||
registry := Registry{
|
||||
entries: make(map[string]ModelEntry),
|
||||
models: make([]ModelEntry, 0, len(entries)),
|
||||
}
|
||||
seenModelIDs := make(map[string]struct{})
|
||||
for _, entry := range entries {
|
||||
if err := entry.Validate(); err != nil {
|
||||
return Registry{}, fmt.Errorf("invalid model %q: %w", entry.ID, err)
|
||||
}
|
||||
clonedEntry := cloneModelEntry(entry)
|
||||
modelID := normalizePattern(entry.ID)
|
||||
if modelID == "" {
|
||||
return Registry{}, fmt.Errorf("model id is required")
|
||||
}
|
||||
if _, ok := seenModelIDs[modelID]; ok {
|
||||
return Registry{}, fmt.Errorf("duplicate model id %q", modelID)
|
||||
}
|
||||
seenModelIDs[modelID] = struct{}{}
|
||||
if err := registry.register(entry.ID, clonedEntry); err != nil {
|
||||
return Registry{}, err
|
||||
}
|
||||
if err := registry.register(entry.APIModel, clonedEntry); err != nil {
|
||||
return Registry{}, err
|
||||
}
|
||||
for _, alias := range entry.Aliases {
|
||||
if err := registry.register(alias, clonedEntry); err != nil {
|
||||
return Registry{}, err
|
||||
}
|
||||
}
|
||||
registry.models = append(registry.models, clonedEntry)
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func (registry Registry) Get(pattern string) (ModelEntry, bool) {
|
||||
entry, ok := registry.entries[normalizePattern(pattern)]
|
||||
if !ok {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
return cloneModelEntry(entry), true
|
||||
}
|
||||
|
||||
func (registry Registry) register(pattern string, entry ModelEntry) error {
|
||||
normalized := normalizePattern(pattern)
|
||||
if normalized == "" {
|
||||
return nil
|
||||
}
|
||||
if existing, ok := registry.entries[normalized]; ok && existing.ID != entry.ID {
|
||||
return fmt.Errorf("duplicate model lookup key %q for %q and %q", normalized, existing.ID, entry.ID)
|
||||
}
|
||||
registry.entries[normalized] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePattern(pattern string) string {
|
||||
return strings.ToLower(strings.TrimSpace(pattern))
|
||||
}
|
||||
|
||||
func ValidPrimaryProviderKind(provider ProviderKind) bool {
|
||||
switch provider {
|
||||
case ProviderOpenAI, ProviderAnthropic, ProviderGoogle:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidRuntimeProviderKind(provider ProviderKind) bool {
|
||||
return ValidPrimaryProviderKind(provider) || provider == ProviderOpenAICompatible
|
||||
}
|
||||
|
||||
func ValidReasoningEffort(effort ReasoningEffort) bool {
|
||||
switch effort {
|
||||
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidModelCapability(capability ModelCapability) bool {
|
||||
switch capability {
|
||||
case ModelCapabilityChat, ModelCapabilityStreaming, ModelCapabilityToolCalling, ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilitySystemPrompt, ModelCapabilityPromptCache, ModelCapabilityLongContext:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidModelStatus(status ModelStatus) bool {
|
||||
switch status {
|
||||
case ModelStatusActive, ModelStatusPreview, ModelStatusDeprecated:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
package modelregistry
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestModelEntryValidatesPRDShape(t *testing.T) {
|
||||
model := ModelEntry{
|
||||
ID: "gpt-4.1-mini",
|
||||
DisplayName: "GPT-4.1 mini",
|
||||
APIModel: "gpt-4.1-mini",
|
||||
Provider: ProviderOpenAI,
|
||||
APIProviders: []ProviderKind{
|
||||
ProviderOpenAI,
|
||||
ProviderOpenAICompatible,
|
||||
},
|
||||
ContextLimits: ContextLimits{
|
||||
ContextWindow: 1_047_576,
|
||||
MaxOutputTokens: 32_768,
|
||||
},
|
||||
Capabilities: ModelCapabilities{
|
||||
ModelCapabilityChat,
|
||||
ModelCapabilityStreaming,
|
||||
ModelCapabilityToolCalling,
|
||||
ModelCapabilitySystemPrompt,
|
||||
ModelCapabilityLongContext,
|
||||
},
|
||||
Cost: ModelCost{
|
||||
Currency: "USD",
|
||||
Unit: "per_1m_tokens",
|
||||
InputPerMillion: 0.4,
|
||||
OutputPerMillion: 1.6,
|
||||
CachedInputPerMillion: 0.1,
|
||||
Source: "https://platform.openai.com/docs/pricing/",
|
||||
SourceLastVerified: "2026-06-02",
|
||||
},
|
||||
Status: ModelStatusActive,
|
||||
Aliases: []string{"openai:gpt-4.1-mini"},
|
||||
Description: "OpenAI lower-cost long-context model for frequent edit loops.",
|
||||
}
|
||||
|
||||
if err := model.Validate(); err != nil {
|
||||
t.Fatalf("Validate returned error: %v", err)
|
||||
}
|
||||
if !model.Supports(ModelCapabilityToolCalling) {
|
||||
t.Fatal("model should support tool calling")
|
||||
}
|
||||
if !model.AllowsProvider(ProviderOpenAICompatible) {
|
||||
t.Fatal("model should allow OpenAI-compatible runtime adapter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelEntryRejectsInvalidContextLimits(t *testing.T) {
|
||||
model := validModelEntry()
|
||||
model.ContextLimits.ContextWindow = 1_000
|
||||
model.ContextLimits.MaxOutputTokens = 2_000
|
||||
|
||||
err := model.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected context limit validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "max output tokens") {
|
||||
t.Fatalf("error = %q, want max output tokens", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelEntryRejectsOpenAICompatibleAsPrimaryProvider(t *testing.T) {
|
||||
model := validModelEntry()
|
||||
model.Provider = ProviderOpenAICompatible
|
||||
|
||||
err := model.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected primary provider validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "primary provider") {
|
||||
t.Fatalf("error = %q, want primary provider", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelEntryRejectsProviderNotInAPIProviders(t *testing.T) {
|
||||
model := validModelEntry()
|
||||
model.APIProviders = []ProviderKind{ProviderAnthropic}
|
||||
|
||||
err := model.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected provider/api providers validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "api providers") {
|
||||
t.Fatalf("error = %q, want api providers", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelEntryRejectsUnknownContractEnums(t *testing.T) {
|
||||
model := validModelEntry()
|
||||
model.Capabilities = ModelCapabilities{"telepathy"}
|
||||
|
||||
err := model.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected capability validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "model capability") {
|
||||
t.Fatalf("error = %q, want model capability", err.Error())
|
||||
}
|
||||
|
||||
model = validModelEntry()
|
||||
model.ReasoningEfforts = []ReasoningEffort{"warp"}
|
||||
|
||||
err = model.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected reasoning effort validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reasoning effort") {
|
||||
t.Fatalf("error = %q, want reasoning effort", err.Error())
|
||||
}
|
||||
|
||||
model = validModelEntry()
|
||||
model.Status = "retired"
|
||||
|
||||
err = model.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected model status validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "model status") {
|
||||
t.Fatalf("error = %q, want model status", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelEntryRejectsInvalidSourceLastVerifiedDate(t *testing.T) {
|
||||
model := validModelEntry()
|
||||
model.Cost.SourceLastVerified = "2026/06/02"
|
||||
|
||||
err := model.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected source last verified date validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "YYYY-MM-DD") {
|
||||
t.Fatalf("error = %q, want YYYY-MM-DD", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCostRequiresCompleteBasePricing(t *testing.T) {
|
||||
model := validModelEntry()
|
||||
model.Cost.InputPerMillion = 0
|
||||
|
||||
err := model.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected incomplete base pricing validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "base input and output rates") {
|
||||
t.Fatalf("error = %q, want base input and output rates", err.Error())
|
||||
}
|
||||
|
||||
model = validModelEntry()
|
||||
model.Cost.OutputPerMillion = 0
|
||||
|
||||
err = model.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected incomplete base pricing validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "base input and output rates") {
|
||||
t.Fatalf("error = %q, want base input and output rates", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRegistryResolvesStablePatterns(t *testing.T) {
|
||||
registry, err := NewRegistry([]ModelEntry{validModelEntry()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRegistry returned error: %v", err)
|
||||
}
|
||||
|
||||
model, ok := registry.Get("OPENAI:GPT-4.1-MINI")
|
||||
if !ok {
|
||||
t.Fatal("expected model to resolve from case-insensitive match pattern")
|
||||
}
|
||||
if model.ID != "gpt-4.1-mini" {
|
||||
t.Fatalf("model ID = %q, want gpt-4.1-mini", model.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRegistryClonesLookupEntriesOnConstruction(t *testing.T) {
|
||||
entries := []ModelEntry{validModelEntry()}
|
||||
entries[0].Cost.Notes = []string{"original note"}
|
||||
registry, err := NewRegistry(entries)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRegistry returned error: %v", err)
|
||||
}
|
||||
|
||||
entries[0].Aliases[0] = "mutated-alias"
|
||||
entries[0].Capabilities[0] = ModelCapabilityVision
|
||||
entries[0].Cost.Notes[0] = "caller-owned mutation"
|
||||
|
||||
model, ok := registry.Get("openai:gpt-4.1-mini")
|
||||
if !ok {
|
||||
t.Fatal("expected original alias to remain registered")
|
||||
}
|
||||
if model.Aliases[0] != "openai:gpt-4.1-mini" {
|
||||
t.Fatalf("alias = %q, want original alias", model.Aliases[0])
|
||||
}
|
||||
if model.Capabilities[0] != ModelCapabilityChat {
|
||||
t.Fatalf("capability = %q, want original capability", model.Capabilities[0])
|
||||
}
|
||||
if len(model.Cost.Notes) != 1 || model.Cost.Notes[0] != "original note" {
|
||||
t.Fatalf("cost notes = %#v, want original note", model.Cost.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRejectsInvalidModelEntries(t *testing.T) {
|
||||
model := validModelEntry()
|
||||
model.ContextLimits.MaxOutputTokens = model.ContextLimits.ContextWindow + 1
|
||||
|
||||
_, err := NewRegistry([]ModelEntry{model})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid model validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "max output tokens") {
|
||||
t.Fatalf("error = %q, want max output tokens", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryDetectsDuplicateNormalizedLookupKeys(t *testing.T) {
|
||||
first := validModelEntry()
|
||||
second := validModelEntry()
|
||||
second.ID = "other-model"
|
||||
second.DisplayName = "Other model"
|
||||
second.APIModel = "other-model"
|
||||
second.Aliases = []string{"GPT-4.1-MINI"}
|
||||
|
||||
_, err := NewRegistry([]ModelEntry{first, second})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate lookup key validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate model lookup key") {
|
||||
t.Fatalf("error = %q, want duplicate model lookup key", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryDetectsDuplicateModelIDs(t *testing.T) {
|
||||
first := validModelEntry()
|
||||
second := validModelEntry()
|
||||
second.APIModel = "other-api-model"
|
||||
second.Aliases = []string{"other:model"}
|
||||
|
||||
_, err := NewRegistry([]ModelEntry{first, second})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate model id validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate model id") {
|
||||
t.Fatalf("error = %q, want duplicate model id", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func validModelEntry() ModelEntry {
|
||||
return ModelEntry{
|
||||
ID: "gpt-4.1-mini",
|
||||
DisplayName: "GPT-4.1 mini",
|
||||
APIModel: "gpt-4.1-mini",
|
||||
Provider: ProviderOpenAI,
|
||||
ContextLimits: ContextLimits{
|
||||
ContextWindow: 1_047_576,
|
||||
MaxOutputTokens: 32_768,
|
||||
},
|
||||
Capabilities: ModelCapabilities{ModelCapabilityChat},
|
||||
Cost: ModelCost{
|
||||
Currency: "USD",
|
||||
Unit: "per_1m_tokens",
|
||||
InputPerMillion: 0.4,
|
||||
OutputPerMillion: 1.6,
|
||||
Source: "https://platform.openai.com/docs/pricing/",
|
||||
SourceLastVerified: "2026-06-02",
|
||||
},
|
||||
Status: ModelStatusActive,
|
||||
Aliases: []string{"openai:gpt-4.1-mini"},
|
||||
}
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
const defaultBaseURL = "https://api.anthropic.com"
|
||||
const defaultVersion = "2023-06-01"
|
||||
const defaultMaxTokens = 4096
|
||||
|
||||
// Options configures an Anthropic Messages API provider.
|
||||
type Options struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
MaxTokens int
|
||||
Version string
|
||||
Beta string
|
||||
HTTPClient *http.Client
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// Provider streams completions from Anthropic's Messages API.
|
||||
type Provider struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
model string
|
||||
maxTokens int
|
||||
version string
|
||||
beta string
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// New creates an Anthropic provider.
|
||||
func New(options Options) (*Provider, error) {
|
||||
model := strings.TrimSpace(options.Model)
|
||||
if model == "" {
|
||||
return nil, errors.New("anthropic provider requires a model")
|
||||
}
|
||||
maxTokens, err := providerio.PositiveOrDefault(options.MaxTokens, defaultMaxTokens, "zero Anthropic provider maxTokens")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseURL, err := providerio.NormalizeBaseURL(options.BaseURL, defaultBaseURL, "Anthropic")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version := strings.TrimSpace(options.Version)
|
||||
if version == "" {
|
||||
version = defaultVersion
|
||||
}
|
||||
return &Provider{
|
||||
apiKey: options.APIKey,
|
||||
baseURL: baseURL,
|
||||
model: model,
|
||||
maxTokens: maxTokens,
|
||||
version: version,
|
||||
beta: strings.TrimSpace(options.Beta),
|
||||
httpClient: providerio.HTTPClient(options.HTTPClient),
|
||||
userAgent: options.UserAgent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StreamCompletion sends one streaming Anthropic Messages request.
|
||||
func (provider *Provider) StreamCompletion(
|
||||
ctx context.Context,
|
||||
request zeroruntime.CompletionRequest,
|
||||
) (<-chan zeroruntime.StreamEvent, error) {
|
||||
mapped, err := provider.anthropicRequest(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := json.Marshal(mapped)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode Anthropic request: %w", err)
|
||||
}
|
||||
|
||||
events := make(chan zeroruntime.StreamEvent, 16)
|
||||
go func() {
|
||||
defer close(events)
|
||||
provider.stream(ctx, body, events)
|
||||
}()
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, provider.baseURL+"/v1/messages", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider request error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
httpRequest.Header.Set("anthropic-version", provider.version)
|
||||
if provider.apiKey != "" {
|
||||
httpRequest.Header.Set("x-api-key", provider.apiKey)
|
||||
}
|
||||
if provider.beta != "" {
|
||||
httpRequest.Header.Set("anthropic-beta", provider.beta)
|
||||
}
|
||||
if provider.userAgent != "" {
|
||||
httpRequest.Header.Set("User-Agent", provider.userAgent)
|
||||
}
|
||||
|
||||
response, err := provider.httpClient.Do(httpRequest)
|
||||
if err != nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = response.Body.Close()
|
||||
}()
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
provider.emitHTTPError(ctx, response, events)
|
||||
return
|
||||
}
|
||||
|
||||
state := newStreamState()
|
||||
err = providerio.ScanSSEData(response.Body, func(data string) bool {
|
||||
return provider.emitPayload(ctx, data, state, events)
|
||||
})
|
||||
if err != nil {
|
||||
state.closeOpen(ctx, events)
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
state.closeOpen(ctx, events)
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
if !state.done {
|
||||
provider.emitDone(ctx, state, events)
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *Provider) emitPayload(ctx context.Context, data string, state *streamState, events chan<- zeroruntime.StreamEvent) bool {
|
||||
var payload streamPayload
|
||||
if err := json.Unmarshal([]byte(data), &payload); err != nil {
|
||||
state.closeOpen(ctx, events)
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.redact("provider stream error: malformed JSON: " + err.Error()),
|
||||
})
|
||||
state.done = true
|
||||
return false
|
||||
}
|
||||
|
||||
switch payload.Type {
|
||||
case "message_start":
|
||||
if payload.Message != nil {
|
||||
state.recordUsage(payload.Message.Usage)
|
||||
}
|
||||
case "content_block_start":
|
||||
if payload.ContentBlock != nil && payload.ContentBlock.Type == "tool_use" && payload.ContentBlock.ID != "" && payload.ContentBlock.Name != "" {
|
||||
state.startTool(ctx, payload.Index, payload.ContentBlock.ID, payload.ContentBlock.Name, events)
|
||||
if len(payload.ContentBlock.Input) > 0 {
|
||||
encoded, err := json.Marshal(payload.ContentBlock.Input)
|
||||
if err == nil {
|
||||
state.deltaTool(ctx, payload.Index, string(encoded), events)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "content_block_delta":
|
||||
if payload.Delta == nil {
|
||||
return true
|
||||
}
|
||||
switch payload.Delta.Type {
|
||||
case "text_delta":
|
||||
if payload.Delta.Text != "" {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: payload.Delta.Text})
|
||||
}
|
||||
case "input_json_delta":
|
||||
if payload.Delta.PartialJSON != "" {
|
||||
state.deltaTool(ctx, payload.Index, payload.Delta.PartialJSON, events)
|
||||
}
|
||||
}
|
||||
case "content_block_stop":
|
||||
state.stopTool(ctx, payload.Index, events)
|
||||
case "message_delta":
|
||||
if payload.Usage != nil {
|
||||
state.recordUsage(*payload.Usage)
|
||||
}
|
||||
case "message_stop":
|
||||
provider.emitDone(ctx, state, events)
|
||||
case "error":
|
||||
message := "Anthropic stream error"
|
||||
if payload.Error != nil {
|
||||
message = firstNonEmpty(payload.Error.Message, payload.Error.Type, message)
|
||||
}
|
||||
state.closeOpen(ctx, events)
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.classifiedError(http.StatusInternalServerError, message),
|
||||
})
|
||||
state.done = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (provider *Provider) emitDone(ctx context.Context, state *streamState, events chan<- zeroruntime.StreamEvent) {
|
||||
state.closeOpen(ctx, events)
|
||||
if state.hasInputUsage || state.hasOutputUsage {
|
||||
usage, err := zeroruntime.NormalizeUsage(zeroruntime.TokenUsage{
|
||||
InputTokens: state.inputTokens,
|
||||
OutputTokens: state.outputTokens,
|
||||
})
|
||||
if err == nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: usage})
|
||||
}
|
||||
}
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
state.done = true
|
||||
}
|
||||
|
||||
func (provider *Provider) emitHTTPError(ctx context.Context, response *http.Response, events chan<- zeroruntime.StreamEvent) {
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024))
|
||||
message := response.Status
|
||||
if parsed := parseErrorMessage(body); parsed != "" {
|
||||
message = parsed
|
||||
} else if trimmed := strings.TrimSpace(string(body)); trimmed != "" {
|
||||
message = trimmed
|
||||
}
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.classifiedError(response.StatusCode, message),
|
||||
})
|
||||
}
|
||||
|
||||
func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest) (messagesRequest, error) {
|
||||
system, messages, err := mapMessages(request.Messages)
|
||||
if err != nil {
|
||||
return messagesRequest{}, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return messagesRequest{}, errors.New("zero Anthropic provider requires at least one non-system message")
|
||||
}
|
||||
|
||||
mapped := messagesRequest{
|
||||
Model: provider.model,
|
||||
MaxTokens: provider.maxTokens,
|
||||
Messages: messages,
|
||||
System: system,
|
||||
Stream: true,
|
||||
}
|
||||
if len(request.Tools) > 0 {
|
||||
mapped.Tools = make([]anthropicTool, 0, len(request.Tools))
|
||||
for _, tool := range request.Tools {
|
||||
mapped.Tools = append(mapped.Tools, anthropicTool{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
InputSchema: tool.Parameters,
|
||||
})
|
||||
}
|
||||
}
|
||||
return mapped, nil
|
||||
}
|
||||
|
||||
func mapMessages(messages []zeroruntime.Message) (string, []anthropicMessage, error) {
|
||||
systemParts := []string{}
|
||||
mapped := []anthropicMessage{}
|
||||
for _, message := range messages {
|
||||
content := message.Content
|
||||
hasContent := strings.TrimSpace(content) != ""
|
||||
switch message.Role {
|
||||
case zeroruntime.MessageRoleSystem:
|
||||
if hasContent {
|
||||
systemParts = append(systemParts, content)
|
||||
}
|
||||
case zeroruntime.MessageRoleTool:
|
||||
if message.ToolCallID == "" {
|
||||
return "", nil, errors.New("zero Anthropic provider requires toolCallId on tool result messages")
|
||||
}
|
||||
appendUserBlocks(&mapped, []map[string]any{{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": message.ToolCallID,
|
||||
"content": content,
|
||||
}})
|
||||
case zeroruntime.MessageRoleAssistant:
|
||||
blocks := []map[string]any{}
|
||||
if hasContent {
|
||||
blocks = append(blocks, map[string]any{"type": "text", "text": content})
|
||||
}
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
input, err := parseToolArguments(toolCall.Arguments, toolCall.Name)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": toolCall.ID,
|
||||
"name": toolCall.Name,
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
continue
|
||||
}
|
||||
var messageContent any = blocks
|
||||
if len(blocks) == 1 && blocks[0]["type"] == "text" {
|
||||
messageContent = blocks[0]["text"]
|
||||
}
|
||||
mapped = append(mapped, anthropicMessage{Role: "assistant", Content: messageContent})
|
||||
default:
|
||||
if hasContent {
|
||||
appendUserBlocks(&mapped, []map[string]any{{"type": "text", "text": content}})
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(systemParts, "\n\n"), mapped, nil
|
||||
}
|
||||
|
||||
func appendUserBlocks(messages *[]anthropicMessage, blocks []map[string]any) {
|
||||
if len(*messages) > 0 && (*messages)[len(*messages)-1].Role == "user" {
|
||||
last := &(*messages)[len(*messages)-1]
|
||||
last.Content = append(contentBlocks(last.Content), blocks...)
|
||||
return
|
||||
}
|
||||
*messages = append(*messages, anthropicMessage{Role: "user", Content: blocks})
|
||||
}
|
||||
|
||||
func contentBlocks(content any) []map[string]any {
|
||||
if content == nil {
|
||||
return nil
|
||||
}
|
||||
if text, ok := content.(string); ok {
|
||||
return []map[string]any{{"type": "text", "text": text}}
|
||||
}
|
||||
if blocks, ok := content.([]map[string]any); ok {
|
||||
return blocks
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseToolArguments(argumentsJSON string, toolName string) (map[string]any, error) {
|
||||
if strings.TrimSpace(argumentsJSON) == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(argumentsJSON), &parsed); err != nil {
|
||||
return nil, fmt.Errorf("zero Anthropic provider could not parse tool arguments for %s as JSON", toolName)
|
||||
}
|
||||
object, ok := parsed.(map[string]any)
|
||||
if !ok || object == nil {
|
||||
return nil, fmt.Errorf("zero Anthropic provider requires tool arguments for %s to be a JSON object", toolName)
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
|
||||
func parseErrorMessage(body []byte) string {
|
||||
var parsed struct {
|
||||
Error apiError `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return ""
|
||||
}
|
||||
return firstNonEmpty(parsed.Error.Message, parsed.Message)
|
||||
}
|
||||
|
||||
func (provider *Provider) classifiedError(statusCode int, message string) string {
|
||||
return providerio.ClassifiedError(statusCode, message, provider.apiKey)
|
||||
}
|
||||
|
||||
func (provider *Provider) redact(message string) string {
|
||||
return providerio.Redact(message, provider.apiKey)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type toolBlock struct {
|
||||
id string
|
||||
name string
|
||||
}
|
||||
|
||||
type streamState struct {
|
||||
tools map[int]toolBlock
|
||||
inputTokens int
|
||||
outputTokens int
|
||||
hasInputUsage bool
|
||||
hasOutputUsage bool
|
||||
done bool
|
||||
}
|
||||
|
||||
func newStreamState() *streamState {
|
||||
return &streamState{tools: make(map[int]toolBlock)}
|
||||
}
|
||||
|
||||
func (state *streamState) recordUsage(usage usage) {
|
||||
if usage.InputTokens != 0 {
|
||||
state.inputTokens = usage.InputTokens
|
||||
state.hasInputUsage = true
|
||||
}
|
||||
if usage.OutputTokens != 0 {
|
||||
state.outputTokens = usage.OutputTokens
|
||||
state.hasOutputUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
func (state *streamState) startTool(ctx context.Context, index int, id string, name string, events chan<- zeroruntime.StreamEvent) {
|
||||
state.tools[index] = toolBlock{id: id, name: name}
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallStart,
|
||||
ToolCallID: id,
|
||||
ToolName: name,
|
||||
})
|
||||
}
|
||||
|
||||
func (state *streamState) deltaTool(ctx context.Context, index int, fragment string, events chan<- zeroruntime.StreamEvent) {
|
||||
tool, ok := state.tools[index]
|
||||
if !ok || fragment == "" {
|
||||
return
|
||||
}
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallDelta,
|
||||
ToolCallID: tool.id,
|
||||
ArgumentsFragment: fragment,
|
||||
})
|
||||
}
|
||||
|
||||
func (state *streamState) stopTool(ctx context.Context, index int, events chan<- zeroruntime.StreamEvent) {
|
||||
tool, ok := state.tools[index]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallEnd,
|
||||
ToolCallID: tool.id,
|
||||
})
|
||||
delete(state.tools, index)
|
||||
}
|
||||
|
||||
func (state *streamState) closeOpen(ctx context.Context, events chan<- zeroruntime.StreamEvent) {
|
||||
for index := range state.tools {
|
||||
state.stopTool(ctx, index, events)
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestStreamCompletionPostsMessagesRequest(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotAPIKey string
|
||||
var gotVersion string
|
||||
var gotUserAgent string
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotAPIKey = r.Header.Get("x-api-key")
|
||||
gotVersion = r.Header.Get("anthropic-version")
|
||||
gotUserAgent = r.Header.Get("User-Agent")
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSEEvent(w, "message_start", `{"type":"message_start","message":{"usage":{"input_tokens":3}}}`)
|
||||
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{
|
||||
APIKey: "sk-ant",
|
||||
BaseURL: server.URL + "/",
|
||||
Model: "claude-test",
|
||||
MaxTokens: 64_000,
|
||||
UserAgent: "zero-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "You are Zero."},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Read the file."},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "I will inspect it.",
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "toolu_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"src/index.ts"}`,
|
||||
}},
|
||||
},
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "file contents", ToolCallID: "toolu_1"},
|
||||
},
|
||||
Tools: []zeroruntime.ToolDefinition{{
|
||||
Name: "read_file",
|
||||
Description: "Read a file",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
if gotPath != "/v1/messages" {
|
||||
t.Fatalf("path = %q, want /v1/messages", gotPath)
|
||||
}
|
||||
if gotAPIKey != "sk-ant" {
|
||||
t.Fatalf("x-api-key = %q, want key", gotAPIKey)
|
||||
}
|
||||
if gotVersion != defaultVersion {
|
||||
t.Fatalf("anthropic-version = %q, want %q", gotVersion, defaultVersion)
|
||||
}
|
||||
if gotUserAgent != "zero-test" {
|
||||
t.Fatalf("User-Agent = %q, want zero-test", gotUserAgent)
|
||||
}
|
||||
if gotBody["model"] != "claude-test" || gotBody["stream"] != true || gotBody["max_tokens"] != float64(64_000) {
|
||||
t.Fatalf("unexpected model/stream/max_tokens: %#v", gotBody)
|
||||
}
|
||||
if gotBody["system"] != "You are Zero." {
|
||||
t.Fatalf("system = %#v, want system prompt", gotBody["system"])
|
||||
}
|
||||
messages := gotBody["messages"].([]any)
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("messages = %#v, want user, assistant, tool-result user", messages)
|
||||
}
|
||||
assistant := messages[1].(map[string]any)
|
||||
assistantBlocks := assistant["content"].([]any)
|
||||
toolUse := assistantBlocks[1].(map[string]any)
|
||||
if toolUse["type"] != "tool_use" || toolUse["id"] != "toolu_1" || toolUse["name"] != "read_file" {
|
||||
t.Fatalf("unexpected tool_use block: %#v", toolUse)
|
||||
}
|
||||
input := toolUse["input"].(map[string]any)
|
||||
if input["path"] != "src/index.ts" {
|
||||
t.Fatalf("tool input = %#v, want path", input)
|
||||
}
|
||||
toolResult := messages[2].(map[string]any)
|
||||
toolResultBlocks := toolResult["content"].([]any)
|
||||
if toolResultBlocks[0].(map[string]any)["tool_use_id"] != "toolu_1" {
|
||||
t.Fatalf("unexpected tool result: %#v", toolResultBlocks[0])
|
||||
}
|
||||
tools := gotBody["tools"].([]any)
|
||||
if tools[0].(map[string]any)["input_schema"].(map[string]any)["type"] != "object" {
|
||||
t.Fatalf("unexpected tool schema: %#v", tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsTextUsageAndDone(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "message_start", `{"type":"message_start","message":{"usage":{"input_tokens":25}}}`)
|
||||
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}`)
|
||||
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Zero"}}`)
|
||||
writeSSEEvent(w, "message_delta", `{"type":"message_delta","usage":{"output_tokens":15}}`)
|
||||
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
want := []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Hello"},
|
||||
{Type: zeroruntime.StreamEventText, Content: " Zero"},
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 25, OutputTokens: 15, PromptTokens: 25, CompletionTokens: 15}},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
if !reflect.DeepEqual(events, want) {
|
||||
t.Fatalf("events = %#v, want %#v", events, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsToolUseBlocks(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "content_block_start", `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"read_file","input":{}}}`)
|
||||
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}`)
|
||||
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"src/index.ts\"}"}}`)
|
||||
writeSSEEvent(w, "content_block_stop", `{"type":"content_block_stop","index":1}`)
|
||||
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
want := []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "toolu_1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "toolu_1", ArgumentsFragment: `{"path":`},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "toolu_1", ArgumentsFragment: `"src/index.ts"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "toolu_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
if !reflect.DeepEqual(events, want) {
|
||||
t.Fatalf("events = %#v, want %#v", events, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionClosesOpenToolCallOnEOF(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"grep","input":{}}}`)
|
||||
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"pattern\":\"Zero\"}"}}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
if len(eventsOfType(events, zeroruntime.StreamEventToolCallEnd)) != 1 {
|
||||
t.Fatalf("events = %#v, want one tool-call-end on EOF", events)
|
||||
}
|
||||
if len(eventsOfType(events, zeroruntime.StreamEventDone)) != 1 {
|
||||
t.Fatalf("events = %#v, want done on EOF", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionClassifiesHTTPErrorsAndRedactsToken(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
wantPrefix string
|
||||
}{
|
||||
{"auth", http.StatusUnauthorized, `{"error":{"message":"bad key sk-ant"}}`, "auth error:"},
|
||||
{"rate limit", http.StatusTooManyRequests, `{"error":{"message":"slow down"}}`, "rate limit error:"},
|
||||
{"overloaded", http.StatusServiceUnavailable, `{"error":{"message":"overloaded"}}`, "rate limit error:"},
|
||||
{"bad request", http.StatusBadRequest, `{"error":{"message":"bad request"}}`, "provider request error:"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provider := newTestProviderWithKey(t, "sk-ant", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, tc.body, tc.status)
|
||||
})
|
||||
stream, err := provider.StreamCompletion(context.Background(), validRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned setup error: %v", err)
|
||||
}
|
||||
events := readAll(stream)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want one error", events)
|
||||
}
|
||||
if !strings.HasPrefix(events[0].Error, tc.wantPrefix) {
|
||||
t.Fatalf("error = %q, want prefix %q", events[0].Error, tc.wantPrefix)
|
||||
}
|
||||
if strings.Contains(events[0].Error, "sk-ant") {
|
||||
t.Fatalf("error leaked token: %q", events[0].Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
|
||||
provider := newTestProviderWithKey(t, "sk-ant", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "error", `{"type":"error","error":{"message":"stream failed sk-ant","type":"overloaded_error"}}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want one error", events)
|
||||
}
|
||||
if !strings.HasPrefix(events[0].Error, "provider error:") {
|
||||
t.Fatalf("error = %q, want provider error prefix", events[0].Error)
|
||||
}
|
||||
if strings.Contains(events[0].Error, "sk-ant") {
|
||||
t.Fatalf("error leaked token: %q", events[0].Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionRejectsMalformedHistoryBeforeDispatch(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("provider should not dispatch malformed history")
|
||||
})
|
||||
|
||||
_, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleTool, Content: "missing id"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires toolCallId") {
|
||||
t.Fatalf("error = %v, want missing toolCallId", err)
|
||||
}
|
||||
|
||||
_, err = provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "call tool"},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
ToolCalls: []zeroruntime.ToolCall{{ID: "toolu_1", Name: "read_file", Arguments: `"src/index.ts"`}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires tool arguments for read_file to be a JSON object") {
|
||||
t.Fatalf("error = %v, want non-object tool argument error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresModelAndPositiveMaxTokens(t *testing.T) {
|
||||
if _, err := New(Options{}); err == nil {
|
||||
t.Fatal("New without model returned nil error")
|
||||
}
|
||||
if _, err := New(Options{Model: "claude-test", MaxTokens: -1}); err == nil {
|
||||
t.Fatal("New with negative max tokens returned nil error")
|
||||
}
|
||||
}
|
||||
|
||||
func newTestProvider(t *testing.T, handler http.HandlerFunc) *Provider {
|
||||
t.Helper()
|
||||
return newTestProviderWithKey(t, "", handler)
|
||||
}
|
||||
|
||||
func newTestProviderWithKey(t *testing.T, apiKey string, handler http.HandlerFunc) *Provider {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
provider, err := New(Options{
|
||||
APIKey: apiKey,
|
||||
BaseURL: server.URL,
|
||||
Model: "claude-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
func collectProviderEvents(t *testing.T, provider *Provider) []zeroruntime.StreamEvent {
|
||||
t.Helper()
|
||||
stream, err := provider.StreamCompletion(context.Background(), validRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned setup error: %v", err)
|
||||
}
|
||||
return readAll(stream)
|
||||
}
|
||||
|
||||
func validRequest() zeroruntime.CompletionRequest {
|
||||
return zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
}
|
||||
}
|
||||
|
||||
func readAll(stream <-chan zeroruntime.StreamEvent) []zeroruntime.StreamEvent {
|
||||
events := []zeroruntime.StreamEvent{}
|
||||
for event := range stream {
|
||||
events = append(events, event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func drain(stream <-chan zeroruntime.StreamEvent) {
|
||||
for range stream {
|
||||
}
|
||||
}
|
||||
|
||||
func writeSSEEvent(w http.ResponseWriter, eventName string, payload string) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
if eventName != "" {
|
||||
_, _ = w.Write([]byte("event: " + eventName + "\n"))
|
||||
}
|
||||
_, _ = w.Write([]byte("data: " + payload + "\n\n"))
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func eventsOfType(events []zeroruntime.StreamEvent, eventType zeroruntime.StreamEventType) []zeroruntime.StreamEvent {
|
||||
matching := []zeroruntime.StreamEvent{}
|
||||
for _, event := range events {
|
||||
if event.Type == eventType {
|
||||
matching = append(matching, event)
|
||||
}
|
||||
}
|
||||
return matching
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package anthropic
|
||||
|
||||
type messagesRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Messages []anthropicMessage `json:"messages"`
|
||||
System string `json:"system,omitempty"`
|
||||
Tools []anthropicTool `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type anthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
}
|
||||
|
||||
type anthropicTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema map[string]any `json:"input_schema,omitempty"`
|
||||
}
|
||||
|
||||
type streamPayload struct {
|
||||
Type string `json:"type"`
|
||||
Index int `json:"index"`
|
||||
Message *streamMessage `json:"message"`
|
||||
ContentBlock *contentBlock `json:"content_block"`
|
||||
Delta *streamDelta `json:"delta"`
|
||||
Usage *usage `json:"usage"`
|
||||
Error *apiError `json:"error"`
|
||||
}
|
||||
|
||||
type streamMessage struct {
|
||||
Usage usage `json:"usage"`
|
||||
}
|
||||
|
||||
type contentBlock struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input map[string]any `json:"input"`
|
||||
}
|
||||
|
||||
type streamDelta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
PartialJSON string `json:"partial_json"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
|
||||
type usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/providers/anthropic"
|
||||
"github.com/Gitlawb/zero/internal/providers/gemini"
|
||||
"github.com/Gitlawb/zero/internal/providers/openai"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// Options configures provider construction.
|
||||
type Options struct {
|
||||
UserAgent string
|
||||
HTTPClient *http.Client
|
||||
ModelRegistry *modelregistry.Registry
|
||||
}
|
||||
|
||||
// New creates a runtime provider for a resolved provider profile.
|
||||
func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider, error) {
|
||||
resolved, err := resolveProfile(profile, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch resolved.providerKind {
|
||||
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
|
||||
return openai.New(openai.Options{
|
||||
APIKey: profile.APIKey,
|
||||
BaseURL: resolved.baseURL,
|
||||
Model: resolved.apiModel,
|
||||
HTTPClient: options.HTTPClient,
|
||||
UserAgent: options.UserAgent,
|
||||
})
|
||||
case config.ProviderKindAnthropic:
|
||||
return anthropic.New(anthropic.Options{
|
||||
APIKey: profile.APIKey,
|
||||
BaseURL: resolved.baseURL,
|
||||
Model: resolved.apiModel,
|
||||
MaxTokens: resolved.maxOutputTokens,
|
||||
HTTPClient: options.HTTPClient,
|
||||
UserAgent: options.UserAgent,
|
||||
})
|
||||
case config.ProviderKindGoogle:
|
||||
return gemini.New(gemini.Options{
|
||||
APIKey: profile.APIKey,
|
||||
BaseURL: resolved.baseURL,
|
||||
Model: resolved.apiModel,
|
||||
MaxTokens: resolved.maxOutputTokens,
|
||||
HTTPClient: options.HTTPClient,
|
||||
UserAgent: options.UserAgent,
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported provider kind %q", resolved.providerKind)
|
||||
}
|
||||
}
|
||||
|
||||
type resolvedProfile struct {
|
||||
providerKind config.ProviderKind
|
||||
apiModel string
|
||||
baseURL string
|
||||
maxOutputTokens int
|
||||
}
|
||||
|
||||
func resolveProfile(profile config.ProviderProfile, options Options) (resolvedProfile, error) {
|
||||
model := strings.TrimSpace(profile.Model)
|
||||
if model == "" {
|
||||
return resolvedProfile{}, fmt.Errorf("provider %s requires model", profile.Name)
|
||||
}
|
||||
providerKind, explicitProvider := explicitProviderKind(profile)
|
||||
registry, err := defaultRegistry(options.ModelRegistry)
|
||||
if err != nil {
|
||||
return resolvedProfile{}, err
|
||||
}
|
||||
|
||||
if entry, ok := registry.Get(model); ok {
|
||||
modelProvider := configKind(entry.Provider)
|
||||
if !explicitProvider || isImplicitOpenAI(profile, providerKind) {
|
||||
providerKind = modelProvider
|
||||
}
|
||||
if providerKind == config.ProviderKindOpenAICompatible {
|
||||
if !entry.AllowsProvider(modelregistry.ProviderOpenAICompatible) {
|
||||
return resolvedProfile{}, fmt.Errorf("zero model %s belongs to %s, not %s", entry.ID, entry.Provider, modelregistry.ProviderOpenAICompatible)
|
||||
}
|
||||
} else if providerKind != modelProvider {
|
||||
return resolvedProfile{}, fmt.Errorf("zero model %s belongs to %s, not %s", entry.ID, entry.Provider, providerKind)
|
||||
}
|
||||
return resolvedProfile{
|
||||
providerKind: providerKind,
|
||||
apiModel: entry.APIModel,
|
||||
baseURL: strings.TrimSpace(profile.BaseURL),
|
||||
maxOutputTokens: entry.ContextLimits.MaxOutputTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if providerKind == "" {
|
||||
providerKind = config.ProviderKindOpenAI
|
||||
}
|
||||
return resolvedProfile{
|
||||
providerKind: providerKind,
|
||||
apiModel: model,
|
||||
baseURL: strings.TrimSpace(profile.BaseURL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func explicitProviderKind(profile config.ProviderProfile) (config.ProviderKind, bool) {
|
||||
providerKind := config.ProviderKind(strings.TrimSpace(strings.ToLower(string(profile.ProviderKind))))
|
||||
if providerKind != "" {
|
||||
return providerKind, true
|
||||
}
|
||||
provider := strings.TrimSpace(strings.ToLower(profile.Provider))
|
||||
if provider != "" {
|
||||
return config.ProviderKind(provider), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isImplicitOpenAI(profile config.ProviderProfile, providerKind config.ProviderKind) bool {
|
||||
return providerKind == config.ProviderKindOpenAI &&
|
||||
strings.TrimSpace(string(profile.ProviderKind)) == "" &&
|
||||
strings.TrimSpace(profile.Provider) == "" &&
|
||||
strings.TrimSpace(profile.BaseURL) == ""
|
||||
}
|
||||
|
||||
func configKind(provider modelregistry.ProviderKind) config.ProviderKind {
|
||||
return config.ProviderKind(provider)
|
||||
}
|
||||
|
||||
func defaultRegistry(registry *modelregistry.Registry) (modelregistry.Registry, error) {
|
||||
if registry != nil {
|
||||
return *registry, nil
|
||||
}
|
||||
return modelregistry.DefaultRegistry()
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestNewCreatesOpenAIProviderWithFactoryOptions(t *testing.T) {
|
||||
transport := &captureTransport{
|
||||
responseBody: "data: [DONE]\n\n",
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
provider, err := New(config.ProviderProfile{
|
||||
Name: "custom",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://provider.example/v1/",
|
||||
APIKey: "sk-factory",
|
||||
Model: "factory-model",
|
||||
}, Options{
|
||||
HTTPClient: client,
|
||||
UserAgent: "zero-factory-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
for range stream {
|
||||
}
|
||||
|
||||
if transport.request == nil {
|
||||
t.Fatal("HTTP client was not used")
|
||||
}
|
||||
if transport.request.URL.String() != "https://provider.example/v1/chat/completions" {
|
||||
t.Fatalf("request URL = %q, want provider base URL", transport.request.URL.String())
|
||||
}
|
||||
if transport.request.Header.Get("Authorization") != "Bearer sk-factory" {
|
||||
t.Fatalf("Authorization = %q, want bearer token", transport.request.Header.Get("Authorization"))
|
||||
}
|
||||
if transport.request.Header.Get("User-Agent") != "zero-factory-test" {
|
||||
t.Fatalf("User-Agent = %q, want factory user agent", transport.request.Header.Get("User-Agent"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSupportsOpenAIProviderKind(t *testing.T) {
|
||||
provider, err := New(config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
Model: "gpt-test",
|
||||
}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
if provider == nil {
|
||||
t.Fatal("New() returned nil provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewResolvesKnownModelToAPIModelAndProvider(t *testing.T) {
|
||||
transport := &captureTransport{
|
||||
responseBody: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
provider, err := New(config.ProviderProfile{
|
||||
Name: "claude",
|
||||
APIKey: "sk-ant",
|
||||
Model: "claude-sonnet-4.5",
|
||||
}, Options{
|
||||
HTTPClient: client,
|
||||
UserAgent: "zero-factory-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
for range stream {
|
||||
}
|
||||
|
||||
if transport.request == nil {
|
||||
t.Fatal("HTTP client was not used")
|
||||
}
|
||||
if transport.request.URL.String() != "https://api.anthropic.com/v1/messages" {
|
||||
t.Fatalf("request URL = %q, want Anthropic Messages API", transport.request.URL.String())
|
||||
}
|
||||
if transport.request.Header.Get("x-api-key") != "sk-ant" {
|
||||
t.Fatalf("x-api-key = %q, want Anthropic key", transport.request.Header.Get("x-api-key"))
|
||||
}
|
||||
if transport.request.Header.Get("User-Agent") != "zero-factory-test" {
|
||||
t.Fatalf("User-Agent = %q, want factory user agent", transport.request.Header.Get("User-Agent"))
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if body["model"] != "claude-sonnet-4-5-20250929" {
|
||||
t.Fatalf("model = %q, want registry API model", body["model"])
|
||||
}
|
||||
if body["max_tokens"] != float64(64000) {
|
||||
t.Fatalf("max_tokens = %#v, want registry output ceiling", body["max_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCreatesGeminiProviderFromFactoryOptions(t *testing.T) {
|
||||
transport := &captureTransport{
|
||||
responseBody: "data: {}\n\n",
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
provider, err := New(config.ProviderProfile{
|
||||
Name: "google",
|
||||
ProviderKind: config.ProviderKindGoogle,
|
||||
APIKey: "sk-google",
|
||||
Model: "gemini-2.5-flash",
|
||||
}, Options{
|
||||
HTTPClient: client,
|
||||
UserAgent: "zero-factory-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
for range stream {
|
||||
}
|
||||
|
||||
if transport.request == nil {
|
||||
t.Fatal("HTTP client was not used")
|
||||
}
|
||||
wantURL := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
if transport.request.URL.String() != wantURL {
|
||||
t.Fatalf("request URL = %q, want %s", transport.request.URL.String(), wantURL)
|
||||
}
|
||||
if transport.request.Header.Get("x-goog-api-key") != "sk-google" {
|
||||
t.Fatalf("x-goog-api-key = %q, want Google key", transport.request.Header.Get("x-goog-api-key"))
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
generationConfig := body["generationConfig"].(map[string]any)
|
||||
if generationConfig["maxOutputTokens"] != float64(65536) {
|
||||
t.Fatalf("maxOutputTokens = %#v, want registry output ceiling", generationConfig["maxOutputTokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsMismatchedOfficialProviderAndKnownModel(t *testing.T) {
|
||||
_, err := New(config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
Model: "claude-sonnet-4.5",
|
||||
}, Options{})
|
||||
if err == nil {
|
||||
t.Fatal("New() error = nil, want provider/model mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "belongs to anthropic, not openai") {
|
||||
t.Fatalf("error = %q, want model/provider mismatch", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsUnsupportedProviderKind(t *testing.T) {
|
||||
_, err := New(config.ProviderProfile{
|
||||
Name: "bad",
|
||||
ProviderKind: "bedrock",
|
||||
Model: "model",
|
||||
}, Options{})
|
||||
if err == nil {
|
||||
t.Fatal("New() error = nil, want unsupported kind error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unsupported provider kind "bedrock"`) {
|
||||
t.Fatalf("error = %q, want unsupported provider kind", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type captureTransport struct {
|
||||
request *http.Request
|
||||
requestBody string
|
||||
responseBody string
|
||||
}
|
||||
|
||||
func (transport *captureTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
transport.request = request
|
||||
if request.Body != nil {
|
||||
body, _ := io.ReadAll(request.Body)
|
||||
transport.requestBody = string(body)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(transport.responseBody)),
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (transport *captureTransport) body() io.Reader {
|
||||
return strings.NewReader(transport.requestBody)
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
const defaultBaseURL = "https://generativelanguage.googleapis.com"
|
||||
const defaultMaxTokens = 8192
|
||||
|
||||
// Options configures a Gemini streamGenerateContent provider.
|
||||
type Options struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
MaxTokens int
|
||||
HTTPClient *http.Client
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// Provider streams completions from the Gemini API.
|
||||
type Provider struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
model string
|
||||
maxTokens int
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// New creates a Gemini provider.
|
||||
func New(options Options) (*Provider, error) {
|
||||
model := normalizeModel(options.Model)
|
||||
if model == "" {
|
||||
return nil, errors.New("gemini provider requires a model")
|
||||
}
|
||||
maxTokens, err := providerio.PositiveOrDefault(options.MaxTokens, defaultMaxTokens, "zero Gemini provider maxTokens")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseURL, err := providerio.NormalizeBaseURL(options.BaseURL, defaultBaseURL, "Gemini")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Provider{
|
||||
apiKey: options.APIKey,
|
||||
baseURL: baseURL,
|
||||
model: model,
|
||||
maxTokens: maxTokens,
|
||||
httpClient: providerio.HTTPClient(options.HTTPClient),
|
||||
userAgent: options.UserAgent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StreamCompletion sends one streaming Gemini GenerateContent request.
|
||||
func (provider *Provider) StreamCompletion(
|
||||
ctx context.Context,
|
||||
request zeroruntime.CompletionRequest,
|
||||
) (<-chan zeroruntime.StreamEvent, error) {
|
||||
mapped, err := provider.geminiRequest(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := json.Marshal(mapped)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode Gemini request: %w", err)
|
||||
}
|
||||
|
||||
events := make(chan zeroruntime.StreamEvent, 16)
|
||||
go func() {
|
||||
defer close(events)
|
||||
provider.stream(ctx, body, events)
|
||||
}()
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, provider.streamURL(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider request error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
if provider.apiKey != "" {
|
||||
httpRequest.Header.Set("x-goog-api-key", provider.apiKey)
|
||||
}
|
||||
if provider.userAgent != "" {
|
||||
httpRequest.Header.Set("User-Agent", provider.userAgent)
|
||||
}
|
||||
|
||||
response, err := provider.httpClient.Do(httpRequest)
|
||||
if err != nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = response.Body.Close()
|
||||
}()
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
provider.emitHTTPError(ctx, response, events)
|
||||
return
|
||||
}
|
||||
|
||||
state := streamState{}
|
||||
err = providerio.ScanSSEData(response.Body, func(data string) bool {
|
||||
return provider.emitPayload(ctx, data, &state, events)
|
||||
})
|
||||
if err != nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
if !state.done {
|
||||
provider.emitDone(ctx, state, events)
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *Provider) emitPayload(ctx context.Context, data string, state *streamState, events chan<- zeroruntime.StreamEvent) bool {
|
||||
var payload streamPayload
|
||||
if err := json.Unmarshal([]byte(data), &payload); err != nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.redact("provider stream error: malformed JSON: " + err.Error()),
|
||||
})
|
||||
state.done = true
|
||||
return false
|
||||
}
|
||||
if payload.Error != nil {
|
||||
message := firstNonEmpty(payload.Error.Message, payload.Error.Status, "Gemini stream error")
|
||||
status := payload.Error.Code
|
||||
if status == 0 {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.classifiedError(status, message),
|
||||
})
|
||||
state.done = true
|
||||
return false
|
||||
}
|
||||
if payload.PromptFeedback != nil && payload.PromptFeedback.BlockReason != "" {
|
||||
message := firstNonEmpty(payload.PromptFeedback.BlockReasonMessage, payload.PromptFeedback.BlockReason)
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.redact("provider error: Content blocked: " + message),
|
||||
})
|
||||
state.done = true
|
||||
return false
|
||||
}
|
||||
if payload.UsageMetadata != nil {
|
||||
state.inputTokens = payload.UsageMetadata.PromptTokenCount
|
||||
state.outputTokens = payload.UsageMetadata.CandidatesTokenCount
|
||||
state.reasoningTokens = payload.UsageMetadata.ThoughtsTokenCount
|
||||
state.hasUsage = true
|
||||
}
|
||||
for _, candidate := range payload.Candidates {
|
||||
if candidate.Content == nil {
|
||||
continue
|
||||
}
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.Text != "" {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: part.Text})
|
||||
}
|
||||
if part.FunctionCall != nil && part.FunctionCall.Name != "" {
|
||||
state.syntheticToolIndex++
|
||||
if !provider.emitToolCall(ctx, *part.FunctionCall, state.syntheticToolIndex, events) {
|
||||
state.done = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, functionCall := range payload.FunctionCalls {
|
||||
if functionCall.Name == "" {
|
||||
continue
|
||||
}
|
||||
state.syntheticToolIndex++
|
||||
if !provider.emitToolCall(ctx, functionCall, state.syntheticToolIndex, events) {
|
||||
state.done = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (provider *Provider) emitDone(ctx context.Context, state streamState, events chan<- zeroruntime.StreamEvent) {
|
||||
if state.hasUsage {
|
||||
usage, err := zeroruntime.NormalizeUsage(zeroruntime.TokenUsage{
|
||||
InputTokens: state.inputTokens,
|
||||
OutputTokens: state.outputTokens,
|
||||
ReasoningTokens: state.reasoningTokens,
|
||||
})
|
||||
if err == nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: usage})
|
||||
}
|
||||
}
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
state.done = true
|
||||
}
|
||||
|
||||
func (provider *Provider) emitToolCall(ctx context.Context, functionCall functionCall, syntheticIndex int, events chan<- zeroruntime.StreamEvent) bool {
|
||||
id := functionCall.ID
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("gemini_tool_%d", syntheticIndex)
|
||||
}
|
||||
args, err := normalizeFunctionCallArgs(firstNonNil(functionCall.Args, functionCall.Arguments), functionCall.Name)
|
||||
if err != nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider error: " + err.Error())})
|
||||
return false
|
||||
}
|
||||
encoded, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider error: " + err.Error())})
|
||||
return false
|
||||
}
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: id, ToolName: functionCall.Name})
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: id, ArgumentsFragment: string(encoded)})
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: id})
|
||||
return true
|
||||
}
|
||||
|
||||
func (provider *Provider) emitHTTPError(ctx context.Context, response *http.Response, events chan<- zeroruntime.StreamEvent) {
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024))
|
||||
message := response.Status
|
||||
if parsed := parseErrorMessage(body); parsed != "" {
|
||||
message = parsed
|
||||
} else if trimmed := strings.TrimSpace(string(body)); trimmed != "" {
|
||||
message = trimmed
|
||||
}
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.classifiedError(response.StatusCode, message),
|
||||
})
|
||||
}
|
||||
|
||||
func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (generateContentRequest, error) {
|
||||
systemInstruction, contents, err := mapMessages(request.Messages)
|
||||
if err != nil {
|
||||
return generateContentRequest{}, err
|
||||
}
|
||||
if len(contents) == 0 {
|
||||
return generateContentRequest{}, errors.New("zero Gemini provider requires at least one non-system message")
|
||||
}
|
||||
|
||||
mapped := generateContentRequest{
|
||||
SystemInstruction: systemInstruction,
|
||||
Contents: contents,
|
||||
GenerationConfig: generationConfig{MaxOutputTokens: provider.maxTokens},
|
||||
}
|
||||
if len(request.Tools) > 0 {
|
||||
declarations := make([]geminiFunctionDeclaration, 0, len(request.Tools))
|
||||
for _, tool := range request.Tools {
|
||||
declarations = append(declarations, geminiFunctionDeclaration{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
Parameters: tool.Parameters,
|
||||
})
|
||||
}
|
||||
mapped.Tools = []geminiToolGroup{{FunctionDeclarations: declarations}}
|
||||
}
|
||||
return mapped, nil
|
||||
}
|
||||
|
||||
func mapMessages(messages []zeroruntime.Message) (*geminiContent, []geminiContent, error) {
|
||||
systemParts := []geminiPart{}
|
||||
contents := []geminiContent{}
|
||||
toolNamesByID := make(map[string]string)
|
||||
|
||||
for _, message := range messages {
|
||||
content := message.Content
|
||||
hasContent := strings.TrimSpace(content) != ""
|
||||
switch message.Role {
|
||||
case zeroruntime.MessageRoleSystem:
|
||||
if hasContent {
|
||||
systemParts = append(systemParts, geminiPart{Text: content})
|
||||
}
|
||||
case zeroruntime.MessageRoleTool:
|
||||
if message.ToolCallID == "" {
|
||||
return nil, nil, errors.New("zero Gemini provider requires toolCallId on tool result messages")
|
||||
}
|
||||
name := toolNamesByID[message.ToolCallID]
|
||||
if name == "" {
|
||||
name = message.ToolCallID
|
||||
}
|
||||
appendUserParts(&contents, []geminiPart{{
|
||||
FunctionResponse: &geminiFunctionResponse{
|
||||
ID: message.ToolCallID,
|
||||
Name: name,
|
||||
Response: map[string]interface{}{"result": content},
|
||||
},
|
||||
}})
|
||||
case zeroruntime.MessageRoleAssistant:
|
||||
parts := []geminiPart{}
|
||||
if hasContent {
|
||||
parts = append(parts, geminiPart{Text: content})
|
||||
}
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
args, err := parseToolArguments(toolCall.Arguments, toolCall.Name)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
toolNamesByID[toolCall.ID] = toolCall.Name
|
||||
parts = append(parts, geminiPart{FunctionCall: &geminiFunctionCall{
|
||||
ID: toolCall.ID,
|
||||
Name: toolCall.Name,
|
||||
Args: args,
|
||||
}})
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
contents = append(contents, geminiContent{Role: "model", Parts: parts})
|
||||
}
|
||||
default:
|
||||
if hasContent {
|
||||
appendUserParts(&contents, []geminiPart{{Text: content}})
|
||||
}
|
||||
}
|
||||
}
|
||||
var systemInstruction *geminiContent
|
||||
if len(systemParts) > 0 {
|
||||
systemInstruction = &geminiContent{Parts: systemParts}
|
||||
}
|
||||
return systemInstruction, contents, nil
|
||||
}
|
||||
|
||||
func appendUserParts(contents *[]geminiContent, parts []geminiPart) {
|
||||
if len(*contents) > 0 && (*contents)[len(*contents)-1].Role == "user" {
|
||||
(*contents)[len(*contents)-1].Parts = append((*contents)[len(*contents)-1].Parts, parts...)
|
||||
return
|
||||
}
|
||||
*contents = append(*contents, geminiContent{Role: "user", Parts: parts})
|
||||
}
|
||||
|
||||
func parseToolArguments(argumentsJSON string, toolName string) (map[string]any, error) {
|
||||
if strings.TrimSpace(argumentsJSON) == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(argumentsJSON), &parsed); err != nil {
|
||||
return nil, fmt.Errorf("zero Gemini provider could not parse tool arguments for %s as JSON", toolName)
|
||||
}
|
||||
object, ok := parsed.(map[string]any)
|
||||
if !ok || object == nil {
|
||||
return nil, fmt.Errorf("zero Gemini provider requires tool arguments for %s to be a JSON object", toolName)
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
|
||||
func normalizeFunctionCallArgs(value any, toolName string) (map[string]any, error) {
|
||||
if value == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok || object == nil {
|
||||
return nil, fmt.Errorf("zero Gemini provider requires streamed tool arguments for %s to be a JSON object", toolName)
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
|
||||
func parseErrorMessage(body []byte) string {
|
||||
var parsed struct {
|
||||
Error apiError `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return ""
|
||||
}
|
||||
return firstNonEmpty(parsed.Error.Message, parsed.Message)
|
||||
}
|
||||
|
||||
func (provider *Provider) streamURL() string {
|
||||
return provider.baseURL + "/v1beta/models/" + url.PathEscape(provider.model) + ":streamGenerateContent?alt=sse"
|
||||
}
|
||||
|
||||
func (provider *Provider) classifiedError(statusCode int, message string) string {
|
||||
return providerio.ClassifiedError(statusCode, message, provider.apiKey)
|
||||
}
|
||||
|
||||
func (provider *Provider) redact(message string) string {
|
||||
return providerio.Redact(message, provider.apiKey)
|
||||
}
|
||||
|
||||
func normalizeModel(model string) string {
|
||||
model = strings.TrimSpace(model)
|
||||
model = strings.TrimPrefix(model, "models/")
|
||||
return strings.TrimSpace(model)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonNil(values ...any) any {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type streamState struct {
|
||||
inputTokens int
|
||||
outputTokens int
|
||||
reasoningTokens int
|
||||
hasUsage bool
|
||||
syntheticToolIndex int
|
||||
done bool
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestStreamCompletionPostsGenerateContentRequest(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotQuery string
|
||||
var gotAPIKey string
|
||||
var gotUserAgent string
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotQuery = r.URL.RawQuery
|
||||
gotAPIKey = r.Header.Get("x-goog-api-key")
|
||||
gotUserAgent = r.Header.Get("User-Agent")
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSE(w, `{"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{
|
||||
APIKey: "sk-google",
|
||||
BaseURL: server.URL + "/",
|
||||
Model: "models/gemini-2.5-flash",
|
||||
MaxTokens: 65_536,
|
||||
UserAgent: "zero-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "You are Zero."},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Read the file."},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "I will inspect it.",
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"src/index.ts"}`,
|
||||
}},
|
||||
},
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "file contents", ToolCallID: "call_1"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Now grep for Zero."},
|
||||
},
|
||||
Tools: []zeroruntime.ToolDefinition{{
|
||||
Name: "read_file",
|
||||
Description: "Read a file",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
if gotPath != "/v1beta/models/gemini-2.5-flash:streamGenerateContent" {
|
||||
t.Fatalf("path = %q, want Gemini stream path", gotPath)
|
||||
}
|
||||
if gotQuery != "alt=sse" {
|
||||
t.Fatalf("query = %q, want alt=sse", gotQuery)
|
||||
}
|
||||
if gotAPIKey != "sk-google" {
|
||||
t.Fatalf("x-goog-api-key = %q, want key", gotAPIKey)
|
||||
}
|
||||
if gotUserAgent != "zero-test" {
|
||||
t.Fatalf("User-Agent = %q, want zero-test", gotUserAgent)
|
||||
}
|
||||
systemInstruction := gotBody["systemInstruction"].(map[string]any)
|
||||
if _, ok := systemInstruction["role"]; ok {
|
||||
t.Fatalf("systemInstruction = %#v, want omitted role", systemInstruction)
|
||||
}
|
||||
generationConfig := gotBody["generationConfig"].(map[string]any)
|
||||
if generationConfig["maxOutputTokens"] != float64(65_536) {
|
||||
t.Fatalf("maxOutputTokens = %#v, want 65536", generationConfig["maxOutputTokens"])
|
||||
}
|
||||
contents := gotBody["contents"].([]any)
|
||||
if len(contents) != 3 {
|
||||
t.Fatalf("contents = %#v, want user, model, merged user", contents)
|
||||
}
|
||||
modelTurn := contents[1].(map[string]any)
|
||||
modelParts := modelTurn["parts"].([]any)
|
||||
functionCall := modelParts[1].(map[string]any)["functionCall"].(map[string]any)
|
||||
if functionCall["id"] != "call_1" || functionCall["name"] != "read_file" {
|
||||
t.Fatalf("unexpected functionCall: %#v", functionCall)
|
||||
}
|
||||
args := functionCall["args"].(map[string]any)
|
||||
if args["path"] != "src/index.ts" {
|
||||
t.Fatalf("functionCall args = %#v, want path", args)
|
||||
}
|
||||
mergedUserParts := contents[2].(map[string]any)["parts"].([]any)
|
||||
if mergedUserParts[0].(map[string]any)["functionResponse"].(map[string]any)["name"] != "read_file" {
|
||||
t.Fatalf("unexpected functionResponse: %#v", mergedUserParts[0])
|
||||
}
|
||||
if mergedUserParts[1].(map[string]any)["text"] != "Now grep for Zero." {
|
||||
t.Fatalf("user text after tool result was not merged: %#v", mergedUserParts)
|
||||
}
|
||||
tools := gotBody["tools"].([]any)
|
||||
declarations := tools[0].(map[string]any)["functionDeclarations"].([]any)
|
||||
if declarations[0].(map[string]any)["name"] != "read_file" {
|
||||
t.Fatalf("unexpected tool declarations: %#v", declarations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsTextUsageAndReasoningTokens(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}`)
|
||||
writeSSE(w, `{"candidates":[{"content":{"parts":[{"text":" Zero"}]}}],"usageMetadata":{"promptTokenCount":25,"candidatesTokenCount":15,"thoughtsTokenCount":3}}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
want := []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Hello"},
|
||||
{Type: zeroruntime.StreamEventText, Content: " Zero"},
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 25, OutputTokens: 15, PromptTokens: 25, CompletionTokens: 15, ReasoningTokens: 3}},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
if !reflect.DeepEqual(events, want) {
|
||||
t.Fatalf("events = %#v, want %#v", events, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsCandidateFunctionCalls(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"candidates":[{"content":{"parts":[{"functionCall":{"id":"call_1","name":"read_file","args":{"path":"src/index.ts"}}},{"functionCall":{"id":"call_2","name":"grep","args":{"pattern":"Zero"}}}]}}]}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
want := []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{"path":"src/index.ts"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_2", ToolName: "grep"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_2", ArgumentsFragment: `{"pattern":"Zero"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
if !reflect.DeepEqual(events, want) {
|
||||
t.Fatalf("events = %#v, want %#v", events, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsTopLevelFunctionCalls(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"functionCalls":[{"id":"call_1","name":"read_file","args":{"path":"README.md"}}]}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
want := []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{"path":"README.md"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
if !reflect.DeepEqual(events, want) {
|
||||
t.Fatalf("events = %#v, want %#v", events, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionUsesSyntheticToolIDsWhenMissing(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"functionCalls":[{"name":"grep","args":{"pattern":"Zero"}}]}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
if events[0].ToolCallID != "gemini_tool_1" || events[0].ToolName != "grep" {
|
||||
t.Fatalf("events = %#v, want synthetic tool id", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionClassifiesHTTPAndPromptBlockErrors(t *testing.T) {
|
||||
authProvider := newTestProviderWithKey(t, "sk-google", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":{"message":"API key not valid"}}`, http.StatusUnauthorized)
|
||||
})
|
||||
stream, err := authProvider.StreamCompletion(context.Background(), validRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned setup error: %v", err)
|
||||
}
|
||||
events := readAll(stream)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError || !strings.HasPrefix(events[0].Error, "auth error:") {
|
||||
t.Fatalf("events = %#v, want auth error", events)
|
||||
}
|
||||
if strings.Contains(events[0].Error, "sk-google") {
|
||||
t.Fatalf("error leaked token: %q", events[0].Error)
|
||||
}
|
||||
|
||||
blockProvider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"promptFeedback":{"blockReason":"SAFETY","blockReasonMessage":"blocked by policy"}}`)
|
||||
})
|
||||
events = collectProviderEvents(t, blockProvider)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError || !strings.Contains(events[0].Error, "Content blocked: blocked by policy") {
|
||||
t.Fatalf("events = %#v, want content block error", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
|
||||
provider := newTestProviderWithKey(t, "sk-google", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"error":{"code":429,"message":"stream failed sk-google","status":"RESOURCE_EXHAUSTED"}}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want one error", events)
|
||||
}
|
||||
if !strings.HasPrefix(events[0].Error, "rate limit error:") {
|
||||
t.Fatalf("error = %q, want rate limit error prefix", events[0].Error)
|
||||
}
|
||||
if strings.Contains(events[0].Error, "sk-google") {
|
||||
t.Fatalf("error leaked token: %q", events[0].Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionStopsOnMalformedStreamToolArgs(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"functionCalls":[{"id":"call_1","name":"grep","args":"raw"}]}`)
|
||||
writeSSE(w, `{"candidates":[{"content":{"parts":[{"text":"should not emit"}]}}]}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want one error", events)
|
||||
}
|
||||
if !strings.Contains(events[0].Error, "streamed tool arguments for grep") {
|
||||
t.Fatalf("error = %q, want streamed tool arguments error", events[0].Error)
|
||||
}
|
||||
if len(eventsOfType(events, zeroruntime.StreamEventDone)) != 0 {
|
||||
t.Fatalf("events = %#v, want no done after stream tool arg error", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionRejectsMalformedHistoryBeforeDispatch(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("provider should not dispatch malformed history")
|
||||
})
|
||||
|
||||
_, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleTool, Content: "missing id"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires toolCallId") {
|
||||
t.Fatalf("error = %v, want missing toolCallId", err)
|
||||
}
|
||||
|
||||
_, err = provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "call tool"},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
ToolCalls: []zeroruntime.ToolCall{{ID: "call_1", Name: "read_file", Arguments: `"src/index.ts"`}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires tool arguments for read_file to be a JSON object") {
|
||||
t.Fatalf("error = %v, want non-object tool argument error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresModelAndPositiveMaxTokens(t *testing.T) {
|
||||
if _, err := New(Options{}); err == nil {
|
||||
t.Fatal("New without model returned nil error")
|
||||
}
|
||||
if _, err := New(Options{Model: "gemini-test", MaxTokens: -1}); err == nil {
|
||||
t.Fatal("New with negative max tokens returned nil error")
|
||||
}
|
||||
}
|
||||
|
||||
func newTestProvider(t *testing.T, handler http.HandlerFunc) *Provider {
|
||||
t.Helper()
|
||||
return newTestProviderWithKey(t, "", handler)
|
||||
}
|
||||
|
||||
func newTestProviderWithKey(t *testing.T, apiKey string, handler http.HandlerFunc) *Provider {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
provider, err := New(Options{
|
||||
APIKey: apiKey,
|
||||
BaseURL: server.URL,
|
||||
Model: "gemini-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
func collectProviderEvents(t *testing.T, provider *Provider) []zeroruntime.StreamEvent {
|
||||
t.Helper()
|
||||
stream, err := provider.StreamCompletion(context.Background(), validRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned setup error: %v", err)
|
||||
}
|
||||
return readAll(stream)
|
||||
}
|
||||
|
||||
func validRequest() zeroruntime.CompletionRequest {
|
||||
return zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
}
|
||||
}
|
||||
|
||||
func readAll(stream <-chan zeroruntime.StreamEvent) []zeroruntime.StreamEvent {
|
||||
events := []zeroruntime.StreamEvent{}
|
||||
for event := range stream {
|
||||
events = append(events, event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func drain(stream <-chan zeroruntime.StreamEvent) {
|
||||
for range stream {
|
||||
}
|
||||
}
|
||||
|
||||
func writeSSE(w http.ResponseWriter, payload string) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: " + payload + "\n\n"))
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func eventsOfType(events []zeroruntime.StreamEvent, eventType zeroruntime.StreamEventType) []zeroruntime.StreamEvent {
|
||||
matching := []zeroruntime.StreamEvent{}
|
||||
for _, event := range events {
|
||||
if event.Type == eventType {
|
||||
matching = append(matching, event)
|
||||
}
|
||||
}
|
||||
return matching
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package gemini
|
||||
|
||||
type generateContentRequest struct {
|
||||
SystemInstruction *geminiContent `json:"systemInstruction,omitempty"`
|
||||
Contents []geminiContent `json:"contents"`
|
||||
GenerationConfig generationConfig `json:"generationConfig"`
|
||||
Tools []geminiToolGroup `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type generationConfig struct {
|
||||
MaxOutputTokens int `json:"maxOutputTokens"`
|
||||
}
|
||||
|
||||
type geminiContent struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Parts []geminiPart `json:"parts"`
|
||||
}
|
||||
|
||||
type geminiPart struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
FunctionCall *geminiFunctionCall `json:"functionCall,omitempty"`
|
||||
FunctionResponse *geminiFunctionResponse `json:"functionResponse,omitempty"`
|
||||
}
|
||||
|
||||
type geminiFunctionCall struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
type geminiFunctionResponse struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Response map[string]interface{} `json:"response"`
|
||||
}
|
||||
|
||||
type geminiToolGroup struct {
|
||||
FunctionDeclarations []geminiFunctionDeclaration `json:"functionDeclarations"`
|
||||
}
|
||||
|
||||
type geminiFunctionDeclaration struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters map[string]any `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type streamPayload struct {
|
||||
Candidates []candidate `json:"candidates"`
|
||||
FunctionCalls []functionCall `json:"functionCalls"`
|
||||
PromptFeedback *promptFeedback `json:"promptFeedback"`
|
||||
UsageMetadata *usageMetadata `json:"usageMetadata"`
|
||||
Error *apiError `json:"error"`
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
Content *candidateContent `json:"content"`
|
||||
FinishReason string `json:"finishReason"`
|
||||
}
|
||||
|
||||
type candidateContent struct {
|
||||
Role string `json:"role"`
|
||||
Parts []streamPart `json:"parts"`
|
||||
}
|
||||
|
||||
type streamPart struct {
|
||||
Text string `json:"text"`
|
||||
FunctionCall *functionCall `json:"functionCall"`
|
||||
}
|
||||
|
||||
type functionCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Args any `json:"args"`
|
||||
Arguments any `json:"arguments"`
|
||||
}
|
||||
|
||||
type promptFeedback struct {
|
||||
BlockReason string `json:"blockReason"`
|
||||
BlockReasonMessage string `json:"blockReasonMessage"`
|
||||
}
|
||||
|
||||
type usageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
const defaultBaseURL = "https://api.openai.com/v1"
|
||||
|
||||
// Options configures an OpenAI-compatible chat completions provider.
|
||||
type Options struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
HTTPClient *http.Client
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// Provider streams completions from an OpenAI-compatible chat completions API.
|
||||
type Provider struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
model string
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// New creates an OpenAI-compatible provider.
|
||||
func New(options Options) (*Provider, error) {
|
||||
model := strings.TrimSpace(options.Model)
|
||||
if model == "" {
|
||||
return nil, errors.New("openai provider requires a model")
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSpace(options.BaseURL)
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
if _, err := url.ParseRequestURI(baseURL); err != nil {
|
||||
return nil, fmt.Errorf("invalid OpenAI base URL: %w", err)
|
||||
}
|
||||
|
||||
httpClient := options.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
|
||||
return &Provider{
|
||||
apiKey: options.APIKey,
|
||||
baseURL: baseURL,
|
||||
model: model,
|
||||
httpClient: httpClient,
|
||||
userAgent: options.UserAgent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StreamCompletion sends one streaming chat completion request.
|
||||
func (provider *Provider) StreamCompletion(
|
||||
ctx context.Context,
|
||||
request zeroruntime.CompletionRequest,
|
||||
) (<-chan zeroruntime.StreamEvent, error) {
|
||||
body, err := json.Marshal(provider.openAIRequest(request))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode OpenAI request: %w", err)
|
||||
}
|
||||
|
||||
events := make(chan zeroruntime.StreamEvent, 16)
|
||||
go func() {
|
||||
defer close(events)
|
||||
provider.stream(ctx, body, events)
|
||||
}()
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
|
||||
endpoint := provider.baseURL + "/chat/completions"
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider request error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if provider.userAgent != "" {
|
||||
request.Header.Set("User-Agent", provider.userAgent)
|
||||
}
|
||||
if provider.apiKey != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+provider.apiKey)
|
||||
}
|
||||
|
||||
response, err := provider.httpClient.Do(request)
|
||||
if err != nil {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = response.Body.Close()
|
||||
}()
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
provider.emitHTTPError(ctx, response, events)
|
||||
return
|
||||
}
|
||||
|
||||
state := newToolState()
|
||||
scanner := bufio.NewScanner(response.Body)
|
||||
scanner.Buffer(make([]byte, 0, 4096), 16*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "" {
|
||||
continue
|
||||
}
|
||||
if data == "[DONE]" {
|
||||
state.closeOpen(ctx, events)
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
return
|
||||
}
|
||||
|
||||
var chunk streamChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
state.closeOpen(ctx, events)
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.redact("provider stream error: malformed JSON: " + err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
if chunk.Error != nil {
|
||||
state.closeOpen(ctx, events)
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.classifiedError(http.StatusInternalServerError, chunk.Error.Message),
|
||||
})
|
||||
return
|
||||
}
|
||||
provider.emitChunk(ctx, chunk, state, events)
|
||||
}
|
||||
|
||||
state.closeOpen(ctx, events)
|
||||
if err := scanner.Err(); err != nil {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
}
|
||||
|
||||
func (provider *Provider) emitChunk(
|
||||
ctx context.Context,
|
||||
chunk streamChunk,
|
||||
state *toolState,
|
||||
events chan<- zeroruntime.StreamEvent,
|
||||
) {
|
||||
for _, choice := range chunk.Choices {
|
||||
if choice.Delta.Content != "" {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventText,
|
||||
Content: choice.Delta.Content,
|
||||
})
|
||||
}
|
||||
for _, toolCall := range choice.Delta.ToolCalls {
|
||||
state.applyDelta(ctx, toolCall, events)
|
||||
}
|
||||
if choice.FinishReason == "tool_calls" {
|
||||
state.closeOpen(ctx, events)
|
||||
}
|
||||
}
|
||||
|
||||
if chunk.Usage != nil {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventUsage,
|
||||
Usage: zeroruntime.Usage{
|
||||
PromptTokens: chunk.Usage.PromptTokens,
|
||||
CompletionTokens: chunk.Usage.CompletionTokens,
|
||||
CachedInputTokens: chunk.Usage.PromptTokensDetails.CachedTokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *Provider) emitHTTPError(ctx context.Context, response *http.Response, events chan<- zeroruntime.StreamEvent) {
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024))
|
||||
message := strings.TrimSpace(string(body))
|
||||
var parsed struct {
|
||||
Error apiError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err == nil && parsed.Error.Message != "" {
|
||||
message = parsed.Error.Message
|
||||
}
|
||||
if message == "" {
|
||||
message = response.Status
|
||||
}
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.classifiedError(response.StatusCode, message),
|
||||
})
|
||||
}
|
||||
|
||||
func (provider *Provider) classifiedError(statusCode int, message string) string {
|
||||
prefix := "provider error: "
|
||||
switch statusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
prefix = "auth error: "
|
||||
case http.StatusTooManyRequests:
|
||||
prefix = "rate limit error: "
|
||||
default:
|
||||
if statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError {
|
||||
prefix = "provider request error: "
|
||||
}
|
||||
}
|
||||
return provider.redact(prefix + message)
|
||||
}
|
||||
|
||||
func (provider *Provider) redact(message string) string {
|
||||
if provider.apiKey != "" {
|
||||
message = strings.ReplaceAll(message, provider.apiKey, "[REDACTED]")
|
||||
}
|
||||
words := strings.Fields(message)
|
||||
for index := 0; index < len(words)-1; index++ {
|
||||
if strings.EqualFold(strings.TrimRight(words[index], ":"), "Bearer") {
|
||||
words[index] = "authorization"
|
||||
words[index+1] = "[REDACTED]"
|
||||
}
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
func sendEvent(ctx context.Context, events chan<- zeroruntime.StreamEvent, event zeroruntime.StreamEvent) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if event.Type == zeroruntime.StreamEventError {
|
||||
select {
|
||||
case events <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case events <- event:
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) chatCompletionRequest {
|
||||
messages := make([]chatMessage, 0, len(request.Messages))
|
||||
for _, message := range request.Messages {
|
||||
messages = append(messages, mapMessage(message))
|
||||
}
|
||||
|
||||
mapped := chatCompletionRequest{
|
||||
Model: provider.model,
|
||||
Messages: messages,
|
||||
Stream: true,
|
||||
}
|
||||
if len(request.Tools) > 0 {
|
||||
mapped.Tools = make([]toolDefinition, 0, len(request.Tools))
|
||||
for _, tool := range request.Tools {
|
||||
mapped.Tools = append(mapped.Tools, toolDefinition{
|
||||
Type: "function",
|
||||
Function: toolFunction{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
Parameters: tool.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
func mapMessage(message zeroruntime.Message) chatMessage {
|
||||
mapped := chatMessage{
|
||||
Role: string(message.Role),
|
||||
Content: message.Content,
|
||||
ToolCallID: message.ToolCallID,
|
||||
}
|
||||
if len(message.ToolCalls) > 0 {
|
||||
mapped.ToolCalls = make([]requestToolCall, 0, len(message.ToolCalls))
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
mapped.ToolCalls = append(mapped.ToolCalls, requestToolCall{
|
||||
ID: toolCall.ID,
|
||||
Type: "function",
|
||||
Function: requestToolCallFunction{
|
||||
Name: toolCall.Name,
|
||||
Arguments: toolCall.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestStreamCompletionPostsChatCompletionRequest(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotAuth string
|
||||
var gotUserAgent string
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotUserAgent = r.Header.Get("User-Agent")
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSE(w, `{"choices":[]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{
|
||||
APIKey: "sk-secret",
|
||||
BaseURL: server.URL + "/",
|
||||
Model: "gpt-test",
|
||||
UserAgent: "zero-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "user"},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "using a tool",
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"README.md"}`,
|
||||
}},
|
||||
},
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "contents", ToolCallID: "call_1"},
|
||||
},
|
||||
Tools: []zeroruntime.ToolDefinition{{
|
||||
Name: "read_file",
|
||||
Description: "Read a file",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
if gotPath != "/chat/completions" {
|
||||
t.Fatalf("path = %q, want /chat/completions", gotPath)
|
||||
}
|
||||
if gotAuth != "Bearer sk-secret" {
|
||||
t.Fatalf("auth = %q, want bearer token", gotAuth)
|
||||
}
|
||||
if gotUserAgent != "zero-test" {
|
||||
t.Fatalf("user agent = %q, want zero-test", gotUserAgent)
|
||||
}
|
||||
if gotBody["model"] != "gpt-test" || gotBody["stream"] != true {
|
||||
t.Fatalf("unexpected model/stream: %#v", gotBody)
|
||||
}
|
||||
if _, ok := gotBody["stream_options"]; ok {
|
||||
t.Fatalf("stream_options should be omitted in M0 request: %#v", gotBody["stream_options"])
|
||||
}
|
||||
messages := gotBody["messages"].([]any)
|
||||
assistant := messages[2].(map[string]any)
|
||||
toolCalls := assistant["tool_calls"].([]any)
|
||||
toolCall := toolCalls[0].(map[string]any)
|
||||
if toolCall["id"] != "call_1" || toolCall["type"] != "function" {
|
||||
t.Fatalf("unexpected assistant tool call: %#v", toolCall)
|
||||
}
|
||||
function := toolCall["function"].(map[string]any)
|
||||
if function["name"] != "read_file" || function["arguments"] != `{"path":"README.md"}` {
|
||||
t.Fatalf("unexpected tool call function: %#v", function)
|
||||
}
|
||||
toolResult := messages[3].(map[string]any)
|
||||
if toolResult["role"] != "tool" || toolResult["tool_call_id"] != "call_1" {
|
||||
t.Fatalf("unexpected tool result message: %#v", toolResult)
|
||||
}
|
||||
tools := gotBody["tools"].([]any)
|
||||
tool := tools[0].(map[string]any)
|
||||
if tool["type"] != "function" {
|
||||
t.Fatalf("unexpected tool wrapper: %#v", tool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresModelButNotAPIKey(t *testing.T) {
|
||||
if _, err := New(Options{}); err == nil {
|
||||
t.Fatal("New without model returned nil error")
|
||||
}
|
||||
if _, err := New(Options{Model: "gpt-test"}); err != nil {
|
||||
t.Fatalf("New without API key returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionOmitsAuthAndToolsWhenEmpty(t *testing.T) {
|
||||
var gotAuth string
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSE(w, `[DONE]`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{BaseURL: server.URL, Model: "local-model"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
if gotAuth != "" {
|
||||
t.Fatalf("auth = %q, want empty", gotAuth)
|
||||
}
|
||||
if _, ok := gotBody["tools"]; ok {
|
||||
t.Fatalf("tools present for empty tools: %#v", gotBody["tools"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsTextUsageAndDone(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"content":"hello "}}]}`)
|
||||
writeSSE(w, `{"choices":[{"delta":{"content":"zero"}}],"usage":{"prompt_tokens":12,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":3}}}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
assertEvent(t, events[0], zeroruntime.StreamEventText, "hello ")
|
||||
assertEvent(t, events[1], zeroruntime.StreamEventText, "zero")
|
||||
if events[2].Type != zeroruntime.StreamEventUsage || events[2].Usage.PromptTokens != 12 || events[2].Usage.CompletionTokens != 5 || events[2].Usage.CachedInputTokens != 3 {
|
||||
t.Fatalf("unexpected usage event: %#v", events[2])
|
||||
}
|
||||
if events[3].Type != zeroruntime.StreamEventDone {
|
||||
t.Fatalf("last event = %#v, want done", events[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionBuffersToolArgsUntilIDAndNameArrive(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":"}}]}}]}`)
|
||||
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":"\"README.md\"}"}}]},"finish_reason":"tool_calls"}]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
if len(events) != 4 {
|
||||
t.Fatalf("events = %#v, want start, delta, end, done", events)
|
||||
}
|
||||
if events[0].Type != zeroruntime.StreamEventToolCallStart || events[0].ToolCallID != "call_1" || events[0].ToolName != "read_file" {
|
||||
t.Fatalf("unexpected start event: %#v", events[0])
|
||||
}
|
||||
if events[1].Type != zeroruntime.StreamEventToolCallDelta || events[1].ToolCallID != "call_1" || events[1].ArgumentsFragment != `{"path":"README.md"}` {
|
||||
t.Fatalf("unexpected delta event: %#v", events[1])
|
||||
}
|
||||
if events[2].Type != zeroruntime.StreamEventToolCallEnd || events[2].ToolCallID != "call_1" {
|
||||
t.Fatalf("unexpected end event: %#v", events[2])
|
||||
}
|
||||
if events[3].Type != zeroruntime.StreamEventDone {
|
||||
t.Fatalf("unexpected done event: %#v", events[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionTracksMultipleToolCallsByIndex(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\"query\":"}},{"index":0,"id":"call_a","function":{"name":"read_file","arguments":"{\"path\":\"a\"}"}}]}}]}`)
|
||||
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_b","function":{"name":"grep","arguments":"\"zero\"}"}}]},"finish_reason":"tool_calls"}]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
starts := eventsOfType(events, zeroruntime.StreamEventToolCallStart)
|
||||
deltas := eventsOfType(events, zeroruntime.StreamEventToolCallDelta)
|
||||
ends := eventsOfType(events, zeroruntime.StreamEventToolCallEnd)
|
||||
if len(starts) != 2 || len(deltas) != 2 || len(ends) != 2 {
|
||||
t.Fatalf("events = %#v, want two starts/deltas/ends", events)
|
||||
}
|
||||
if deltas[0].ToolCallID != "call_a" || deltas[0].ArgumentsFragment != `{"path":"a"}` {
|
||||
t.Fatalf("unexpected first delta: %#v", deltas[0])
|
||||
}
|
||||
if deltas[1].ToolCallID != "call_b" || deltas[1].ArgumentsFragment != `{"query":"zero"}` {
|
||||
t.Fatalf("unexpected second delta: %#v", deltas[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionClassifiesHTTPErrorsAndRedactsToken(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
wantPrefix string
|
||||
}{
|
||||
{"auth", http.StatusUnauthorized, `{"error":{"message":"bad key sk-secret","type":"invalid_request_error"}}`, "auth error:"},
|
||||
{"rate limit", http.StatusTooManyRequests, `{"error":{"message":"slow down"}}`, "rate limit error:"},
|
||||
{"bad request", http.StatusBadRequest, `{"error":{"message":"bad request"}}`, "provider request error:"},
|
||||
{"server", http.StatusInternalServerError, `server saw Bearer sk-secret`, "provider error:"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provider := newTestProviderWithKey(t, "sk-secret", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, tc.body, tc.status)
|
||||
})
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned setup error: %v", err)
|
||||
}
|
||||
events := readAll(stream)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want one error", events)
|
||||
}
|
||||
if !strings.HasPrefix(events[0].Error, tc.wantPrefix) {
|
||||
t.Fatalf("error = %q, want prefix %q", events[0].Error, tc.wantPrefix)
|
||||
}
|
||||
if strings.Contains(events[0].Error, "sk-secret") || strings.Contains(events[0].Error, "Bearer ") {
|
||||
t.Fatalf("error leaked token: %q", events[0].Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
|
||||
provider := newTestProviderWithKey(t, "sk-secret", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"error":{"message":"stream failed sk-secret","type":"server_error"}}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want one error", events)
|
||||
}
|
||||
if !strings.HasPrefix(events[0].Error, "provider error:") {
|
||||
t.Fatalf("error = %q, want provider error prefix", events[0].Error)
|
||||
}
|
||||
if strings.Contains(events[0].Error, "sk-secret") {
|
||||
t.Fatalf("error leaked token: %q", events[0].Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsErrorForMalformedJSON(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want one error", events)
|
||||
}
|
||||
if !strings.HasPrefix(events[0].Error, "provider stream error: malformed JSON") {
|
||||
t.Fatalf("error = %q, want malformed JSON provider stream error", events[0].Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsErrorWhenContextCancels(t *testing.T) {
|
||||
requestStarted := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
close(requestStarted)
|
||||
<-release
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
stream, err := provider.StreamCompletion(ctx, zeroruntime.CompletionRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned setup error: %v", err)
|
||||
}
|
||||
<-requestStarted
|
||||
cancel()
|
||||
close(release)
|
||||
|
||||
events := readAll(stream)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want context error", events)
|
||||
}
|
||||
if !strings.Contains(events[0].Error, "context canceled") {
|
||||
t.Fatalf("error = %q, want context canceled", events[0].Error)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestProvider(t *testing.T, handler http.HandlerFunc) *Provider {
|
||||
t.Helper()
|
||||
return newTestProviderWithKey(t, "", handler)
|
||||
}
|
||||
|
||||
func newTestProviderWithKey(t *testing.T, apiKey string, handler http.HandlerFunc) *Provider {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
provider, err := New(Options{
|
||||
APIKey: apiKey,
|
||||
BaseURL: server.URL,
|
||||
Model: "gpt-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
func collectProviderEvents(t *testing.T, provider *Provider) []zeroruntime.StreamEvent {
|
||||
t.Helper()
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned setup error: %v", err)
|
||||
}
|
||||
return readAll(stream)
|
||||
}
|
||||
|
||||
func readAll(stream <-chan zeroruntime.StreamEvent) []zeroruntime.StreamEvent {
|
||||
events := []zeroruntime.StreamEvent{}
|
||||
for event := range stream {
|
||||
events = append(events, event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func drain(stream <-chan zeroruntime.StreamEvent) {
|
||||
for range stream {
|
||||
}
|
||||
}
|
||||
|
||||
func writeSSE(w http.ResponseWriter, payload string) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: " + payload + "\n\n"))
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func assertEvent(t *testing.T, event zeroruntime.StreamEvent, eventType zeroruntime.StreamEventType, content string) {
|
||||
t.Helper()
|
||||
if event.Type != eventType || event.Content != content {
|
||||
t.Fatalf("event = %#v, want %s %q", event, eventType, content)
|
||||
}
|
||||
}
|
||||
|
||||
func eventsOfType(events []zeroruntime.StreamEvent, eventType zeroruntime.StreamEventType) []zeroruntime.StreamEvent {
|
||||
matching := []zeroruntime.StreamEvent{}
|
||||
for _, event := range events {
|
||||
if event.Type == eventType {
|
||||
matching = append(matching, event)
|
||||
}
|
||||
}
|
||||
return matching
|
||||
}
|
||||
|
||||
func TestStreamCompletionDoesNotHangOnEOFWithOpenToolCall(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":"{\"path\":\"README.md\"}"}}]}}]}`)
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
stream, err := provider.StreamCompletion(ctx, zeroruntime.CompletionRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned setup error: %v", err)
|
||||
}
|
||||
events := []zeroruntime.StreamEvent{}
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-stream:
|
||||
if !ok {
|
||||
if len(eventsOfType(events, zeroruntime.StreamEventToolCallEnd)) != 1 {
|
||||
t.Fatalf("events = %#v, want one tool-call-end on EOF", events)
|
||||
}
|
||||
if len(eventsOfType(events, zeroruntime.StreamEventDone)) != 1 {
|
||||
t.Fatalf("events = %#v, want done on EOF", events)
|
||||
}
|
||||
return
|
||||
}
|
||||
events = append(events, event)
|
||||
case <-ctx.Done():
|
||||
t.Fatal("stream did not close")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionSkipsNamelessToolCallOnEOF(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"arguments":"{\"path\":\"README.md\"}"}}]}}]}`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
if len(eventsOfType(events, zeroruntime.StreamEventToolCallStart)) != 0 {
|
||||
t.Fatalf("events = %#v, want no start for nameless tool call", events)
|
||||
}
|
||||
if len(eventsOfType(events, zeroruntime.StreamEventToolCallDelta)) != 0 {
|
||||
t.Fatalf("events = %#v, want no delta for nameless tool call", events)
|
||||
}
|
||||
if len(eventsOfType(events, zeroruntime.StreamEventToolCallEnd)) != 0 {
|
||||
t.Fatalf("events = %#v, want no end for nameless tool call", events)
|
||||
}
|
||||
if len(eventsOfType(events, zeroruntime.StreamEventDone)) != 1 {
|
||||
t.Fatalf("events = %#v, want done event", events)
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type toolState struct {
|
||||
calls map[int]*pendingToolCall
|
||||
}
|
||||
|
||||
type pendingToolCall struct {
|
||||
id string
|
||||
name string
|
||||
arguments string
|
||||
started bool
|
||||
ended bool
|
||||
}
|
||||
|
||||
func newToolState() *toolState {
|
||||
return &toolState{calls: make(map[int]*pendingToolCall)}
|
||||
}
|
||||
|
||||
func (state *toolState) applyDelta(
|
||||
ctx context.Context,
|
||||
delta streamToolCallDelta,
|
||||
events chan<- zeroruntime.StreamEvent,
|
||||
) {
|
||||
call := state.calls[delta.Index]
|
||||
if call == nil {
|
||||
call = &pendingToolCall{}
|
||||
state.calls[delta.Index] = call
|
||||
}
|
||||
|
||||
if delta.ID != "" {
|
||||
call.id = delta.ID
|
||||
}
|
||||
if delta.Function.Name != "" {
|
||||
call.name = delta.Function.Name
|
||||
}
|
||||
if delta.Function.Arguments != "" {
|
||||
call.arguments += delta.Function.Arguments
|
||||
}
|
||||
|
||||
if call.id == "" || call.name == "" || call.ended {
|
||||
return
|
||||
}
|
||||
|
||||
if !call.started {
|
||||
call.started = true
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallStart,
|
||||
ToolCallID: call.id,
|
||||
ToolName: call.name,
|
||||
})
|
||||
}
|
||||
if call.arguments != "" {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallDelta,
|
||||
ToolCallID: call.id,
|
||||
ArgumentsFragment: call.arguments,
|
||||
})
|
||||
call.arguments = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (state *toolState) closeOpen(ctx context.Context, events chan<- zeroruntime.StreamEvent) {
|
||||
indexes := make([]int, 0, len(state.calls))
|
||||
for index := range state.calls {
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
sort.Ints(indexes)
|
||||
|
||||
for _, index := range indexes {
|
||||
call := state.calls[index]
|
||||
if call == nil || call.ended || call.id == "" || call.name == "" {
|
||||
continue
|
||||
}
|
||||
if !call.started {
|
||||
call.started = true
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallStart,
|
||||
ToolCallID: call.id,
|
||||
ToolName: call.name,
|
||||
})
|
||||
}
|
||||
if call.arguments != "" {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallDelta,
|
||||
ToolCallID: call.id,
|
||||
ArgumentsFragment: call.arguments,
|
||||
})
|
||||
call.arguments = ""
|
||||
}
|
||||
call.ended = true
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallEnd,
|
||||
ToolCallID: call.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package openai
|
||||
|
||||
type chatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Tools []toolDefinition `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []requestToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
type requestToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function requestToolCallFunction `json:"function"`
|
||||
}
|
||||
|
||||
type requestToolCallFunction struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
type toolDefinition struct {
|
||||
Type string `json:"type"`
|
||||
Function toolFunction `json:"function"`
|
||||
}
|
||||
|
||||
type toolFunction struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters map[string]any `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type streamChunk struct {
|
||||
Choices []streamChoice `json:"choices"`
|
||||
Usage *usage `json:"usage"`
|
||||
Error *apiError `json:"error"`
|
||||
}
|
||||
|
||||
type streamChoice struct {
|
||||
Delta streamDelta `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type streamDelta struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []streamToolCallDelta `json:"tool_calls"`
|
||||
}
|
||||
|
||||
type streamToolCallDelta struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id"`
|
||||
Function streamFunctionDelta `json:"function"`
|
||||
}
|
||||
|
||||
type streamFunctionDelta struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
type usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
PromptTokensDetails promptTokenDetails `json:"prompt_tokens_details"`
|
||||
}
|
||||
|
||||
type promptTokenDetails struct {
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Code any `json:"code"`
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package providerio
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
const maxSSELineBytes = 16 * 1024 * 1024
|
||||
|
||||
// NormalizeBaseURL trims trailing slashes and validates an HTTP API base URL.
|
||||
func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (string, error) {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
if _, err := url.ParseRequestURI(baseURL); err != nil {
|
||||
return "", fmt.Errorf("invalid %s base URL: %w", label, err)
|
||||
}
|
||||
return baseURL, nil
|
||||
}
|
||||
|
||||
// HTTPClient returns the configured client or the process default.
|
||||
func HTTPClient(client *http.Client) *http.Client {
|
||||
if client != nil {
|
||||
return client
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// SendEvent writes a provider event without blocking cancellation cleanup.
|
||||
func SendEvent(ctx context.Context, events chan<- zeroruntime.StreamEvent, event zeroruntime.StreamEvent) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if event.Type == zeroruntime.StreamEventError {
|
||||
select {
|
||||
case events <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case events <- event:
|
||||
}
|
||||
}
|
||||
|
||||
// ScanSSEData parses Server-Sent Event data fields from a streaming response.
|
||||
func ScanSSEData(reader io.Reader, handle func(data string) bool) error {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 0, 4096), maxSSELineBytes)
|
||||
|
||||
dataLines := []string{}
|
||||
flush := func() bool {
|
||||
if len(dataLines) == 0 {
|
||||
return true
|
||||
}
|
||||
data := strings.TrimSpace(strings.Join(dataLines, "\n"))
|
||||
dataLines = dataLines[:0]
|
||||
if data == "" || data == "[DONE]" {
|
||||
return true
|
||||
}
|
||||
return handle(data)
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimRight(scanner.Text(), "\r")
|
||||
if line == "" {
|
||||
if !flush() {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, ":") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
dataLines = append(dataLines, strings.TrimLeft(strings.TrimPrefix(line, "data:"), " \t"))
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClassifiedError normalizes provider HTTP/stream errors and redacts secrets.
|
||||
func ClassifiedError(statusCode int, message string, apiKey string) string {
|
||||
prefix := "provider error: "
|
||||
switch statusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
prefix = "auth error: "
|
||||
case http.StatusTooManyRequests, http.StatusServiceUnavailable, 529:
|
||||
prefix = "rate limit error: "
|
||||
default:
|
||||
if statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError {
|
||||
prefix = "provider request error: "
|
||||
}
|
||||
}
|
||||
return Redact(prefix+message, apiKey)
|
||||
}
|
||||
|
||||
// Redact removes known API-key and bearer-token forms from provider messages.
|
||||
func Redact(message string, apiKey string) string {
|
||||
if apiKey != "" {
|
||||
message = strings.ReplaceAll(message, apiKey, "[REDACTED]")
|
||||
}
|
||||
words := strings.Fields(message)
|
||||
for index := 0; index < len(words)-1; index++ {
|
||||
if strings.EqualFold(strings.TrimRight(words[index], ":"), "Bearer") {
|
||||
words[index] = "authorization"
|
||||
words[index+1] = "[REDACTED]"
|
||||
}
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
// PositiveOrDefault validates optional max token settings.
|
||||
func PositiveOrDefault(value int, fallback int, label string) (int, error) {
|
||||
if value == 0 {
|
||||
return fallback, nil
|
||||
}
|
||||
if value < 0 {
|
||||
return 0, fmt.Errorf("%s must be a positive integer", label)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
package redaction
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
RedactedSecret = "[REDACTED]"
|
||||
CircularReference = "[Circular]"
|
||||
maxDepthDefault = 16
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Replacement string
|
||||
ExtraSensitiveKeys []string
|
||||
ExtraSecretValues []string
|
||||
MaxDepth int
|
||||
}
|
||||
|
||||
type RedactedError struct {
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
Stack string `json:"stack,omitempty"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
var sensitiveKeys = map[string]struct{}{
|
||||
"access_token": {},
|
||||
"anthropic_api_key": {},
|
||||
"api_key": {},
|
||||
"apikey": {},
|
||||
"auth_token": {},
|
||||
"authorization": {},
|
||||
"aws_secret_access_key": {},
|
||||
"aws_session_token": {},
|
||||
"bearer": {},
|
||||
"bearer_token": {},
|
||||
"client_secret": {},
|
||||
"cookie": {},
|
||||
"credential": {},
|
||||
"credentials": {},
|
||||
"gemini_api_key": {},
|
||||
"github_token": {},
|
||||
"gitlab_token": {},
|
||||
"google_api_key": {},
|
||||
"id_token": {},
|
||||
"jwt": {},
|
||||
"npm_token": {},
|
||||
"oauth_token": {},
|
||||
"openai_api_key": {},
|
||||
"passphrase": {},
|
||||
"password": {},
|
||||
"private_key": {},
|
||||
"proxy_authorization": {},
|
||||
"refresh_token": {},
|
||||
"secret": {},
|
||||
"session_token": {},
|
||||
"set_cookie": {},
|
||||
"token": {},
|
||||
"x_api_key": {},
|
||||
"zero_api_key": {},
|
||||
}
|
||||
|
||||
var textSecretPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`\bsk-(?:proj-)?[A-Za-z0-9._-]{12,}\b`),
|
||||
regexp.MustCompile(`\bsk-ant-api\d{2}-[A-Za-z0-9._-]{12,}\b`),
|
||||
regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{12,}\b`),
|
||||
regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9_]{12,}\b`),
|
||||
regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{12,}\b`),
|
||||
regexp.MustCompile(`\bAIza[0-9A-Za-z_-]{12,}\b`),
|
||||
regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{12,}\b`),
|
||||
regexp.MustCompile(`\b(?:AKIA|ASIA)[A-Z0-9]{16}\b`),
|
||||
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`),
|
||||
}
|
||||
|
||||
var (
|
||||
privateKeyPattern = regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`)
|
||||
jsonStringPattern = regexp.MustCompile(`("([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*)"([^"\\]*(?:\\.[^"\\]*)*)"`)
|
||||
assignPattern = regexp.MustCompile(`\b([A-Za-z_][A-Za-z0-9_.-]*)(\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s&]+))`)
|
||||
headerPattern = regexp.MustCompile(`(?i)\b(authorization|proxy-authorization)\s*:\s*(bearer|basic)\s+([^\s,;]+)`)
|
||||
secretHeader = regexp.MustCompile(`(?i)\b(x-api-key|api-key|cookie|set-cookie)\s*:\s*([^\r\n]+)`)
|
||||
queryPattern = regexp.MustCompile(`([?&])([^=&#\s]+)=([^&#\s]+)`)
|
||||
)
|
||||
|
||||
func IsSensitiveKey(key string, options Options) bool {
|
||||
normalized := normalizeKey(key)
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := sensitiveKeys[normalized]; ok {
|
||||
return true
|
||||
}
|
||||
for _, extra := range options.ExtraSensitiveKeys {
|
||||
if normalizeKey(extra) == normalized {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func RedactString(value string, options Options) string {
|
||||
replacement := replacement(options)
|
||||
redacted := value
|
||||
for _, secret := range options.ExtraSecretValues {
|
||||
if strings.TrimSpace(secret) != "" {
|
||||
redacted = strings.ReplaceAll(redacted, secret, replacement)
|
||||
}
|
||||
}
|
||||
|
||||
redacted = privateKeyPattern.ReplaceAllString(redacted, replacement)
|
||||
redacted = jsonStringPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
parts := jsonStringPattern.FindStringSubmatch(match)
|
||||
if len(parts) < 3 || !IsSensitiveKey(parts[2], options) {
|
||||
return match
|
||||
}
|
||||
return parts[1] + `"` + replacement + `"`
|
||||
})
|
||||
redacted = assignPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
parts := assignPattern.FindStringSubmatch(match)
|
||||
if len(parts) < 6 || !IsSensitiveKey(parts[1], options) {
|
||||
return match
|
||||
}
|
||||
if parts[3] != "" {
|
||||
return parts[1] + parts[2] + `"` + replacement + `"`
|
||||
}
|
||||
if parts[4] != "" {
|
||||
return parts[1] + parts[2] + `'` + replacement + `'`
|
||||
}
|
||||
return parts[1] + parts[2] + replacement
|
||||
})
|
||||
redacted = headerPattern.ReplaceAllString(redacted, "$1: $2 "+replacement)
|
||||
redacted = secretHeader.ReplaceAllString(redacted, "$1: "+replacement)
|
||||
redacted = redactURLPasswords(redacted, replacement)
|
||||
redacted = queryPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
parts := queryPattern.FindStringSubmatch(match)
|
||||
if len(parts) < 4 || !IsSensitiveKey(parts[2], options) {
|
||||
return match
|
||||
}
|
||||
return parts[1] + parts[2] + "=" + replacement
|
||||
})
|
||||
for _, pattern := range textSecretPatterns {
|
||||
redacted = pattern.ReplaceAllString(redacted, replacement)
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
func RedactValue(value any, options Options) any {
|
||||
return redactReflect(reflect.ValueOf(value), redactionContext{
|
||||
options: options,
|
||||
replacement: replacement(options),
|
||||
maxDepth: maxDepth(options),
|
||||
seen: map[uintptr]struct{}{},
|
||||
}, 0)
|
||||
}
|
||||
|
||||
func RedactError(err error, options Options) RedactedError {
|
||||
if err == nil {
|
||||
return RedactedError{Name: "Error", Message: ""}
|
||||
}
|
||||
redacted := RedactedError{
|
||||
Name: errorName(err),
|
||||
Message: RedactString(err.Error(), options),
|
||||
}
|
||||
fields := exportedFields(reflect.ValueOf(err), options)
|
||||
if len(fields) > 0 {
|
||||
redacted.Fields = fields
|
||||
}
|
||||
var stackTracer interface{ StackTrace() fmt.Stringer }
|
||||
if errors.As(err, &stackTracer) {
|
||||
redacted.Stack = RedactString(stackTracer.StackTrace().String(), options)
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
func ErrorMessage(err error, options Options) string {
|
||||
return RedactError(err, options).Message
|
||||
}
|
||||
|
||||
type redactionContext struct {
|
||||
options Options
|
||||
replacement string
|
||||
maxDepth int
|
||||
seen map[uintptr]struct{}
|
||||
}
|
||||
|
||||
func redactReflect(value reflect.Value, context redactionContext, depth int) any {
|
||||
if !value.IsValid() {
|
||||
return nil
|
||||
}
|
||||
for value.Kind() == reflect.Interface {
|
||||
if value.IsNil() {
|
||||
return nil
|
||||
}
|
||||
value = value.Elem()
|
||||
}
|
||||
if depth >= context.maxDepth {
|
||||
return "[MaxDepth]"
|
||||
}
|
||||
|
||||
switch value.Kind() {
|
||||
case reflect.String:
|
||||
return RedactString(value.String(), context.options)
|
||||
case reflect.Bool:
|
||||
return value.Bool()
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return value.Int()
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return value.Uint()
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return value.Float()
|
||||
case reflect.Pointer:
|
||||
if value.IsNil() {
|
||||
return nil
|
||||
}
|
||||
ptr := value.Pointer()
|
||||
if _, ok := context.seen[ptr]; ok {
|
||||
return CircularReference
|
||||
}
|
||||
context.seen[ptr] = struct{}{}
|
||||
return redactReflect(value.Elem(), context, depth+1)
|
||||
case reflect.Map:
|
||||
if value.IsNil() {
|
||||
return nil
|
||||
}
|
||||
ptr := value.Pointer()
|
||||
if _, ok := context.seen[ptr]; ok {
|
||||
return CircularReference
|
||||
}
|
||||
context.seen[ptr] = struct{}{}
|
||||
out := make(map[string]any, value.Len())
|
||||
iter := value.MapRange()
|
||||
for iter.Next() {
|
||||
key := fmt.Sprint(redactReflect(iter.Key(), context, depth+1))
|
||||
if IsSensitiveKey(key, context.options) {
|
||||
out[key] = context.replacement
|
||||
continue
|
||||
}
|
||||
out[key] = redactReflect(iter.Value(), context, depth+1)
|
||||
}
|
||||
return out
|
||||
case reflect.Slice, reflect.Array:
|
||||
out := make([]any, value.Len())
|
||||
for index := 0; index < value.Len(); index++ {
|
||||
out[index] = redactReflect(value.Index(index), context, depth+1)
|
||||
}
|
||||
return out
|
||||
case reflect.Struct:
|
||||
if value.CanInterface() {
|
||||
if err, ok := value.Interface().(error); ok {
|
||||
return RedactError(err, context.options)
|
||||
}
|
||||
}
|
||||
out := make(map[string]any, value.NumField())
|
||||
valueType := value.Type()
|
||||
for index := 0; index < value.NumField(); index++ {
|
||||
field := valueType.Field(index)
|
||||
if field.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
name := field.Name
|
||||
if tag := field.Tag.Get("json"); tag != "" {
|
||||
name = strings.Split(tag, ",")[0]
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if IsSensitiveKey(name, context.options) {
|
||||
out[name] = context.replacement
|
||||
continue
|
||||
}
|
||||
out[name] = redactReflect(value.Field(index), context, depth+1)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
if value.CanInterface() {
|
||||
return value.Interface()
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
}
|
||||
|
||||
func exportedFields(value reflect.Value, options Options) map[string]any {
|
||||
for value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface {
|
||||
if value.IsNil() {
|
||||
return nil
|
||||
}
|
||||
value = value.Elem()
|
||||
}
|
||||
if value.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
fields := map[string]any{}
|
||||
context := redactionContext{
|
||||
options: options,
|
||||
replacement: replacement(options),
|
||||
maxDepth: maxDepth(options),
|
||||
seen: map[uintptr]struct{}{},
|
||||
}
|
||||
valueType := value.Type()
|
||||
for index := 0; index < value.NumField(); index++ {
|
||||
field := valueType.Field(index)
|
||||
if field.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
name := field.Name
|
||||
if IsSensitiveKey(name, options) {
|
||||
fields[name] = context.replacement
|
||||
} else {
|
||||
fields[name] = redactReflect(value.Field(index), context, 1)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func errorName(err error) string {
|
||||
name := reflect.TypeOf(err).String()
|
||||
if name == "" {
|
||||
return "Error"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func replacement(options Options) string {
|
||||
if options.Replacement != "" {
|
||||
return options.Replacement
|
||||
}
|
||||
return RedactedSecret
|
||||
}
|
||||
|
||||
func maxDepth(options Options) int {
|
||||
if options.MaxDepth > 0 {
|
||||
return options.MaxDepth
|
||||
}
|
||||
return maxDepthDefault
|
||||
}
|
||||
|
||||
func normalizeKey(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
var builder strings.Builder
|
||||
var lastUnderscore bool
|
||||
for _, r := range key {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
builder.WriteRune(unicode.ToLower(r))
|
||||
lastUnderscore = false
|
||||
continue
|
||||
}
|
||||
if !lastUnderscore {
|
||||
builder.WriteByte('_')
|
||||
lastUnderscore = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(builder.String(), "_")
|
||||
}
|
||||
|
||||
func redactURLPasswords(value string, replacement string) string {
|
||||
return regexp.MustCompile(`\b(?:https?|wss?|ftp)://[^\s]+`).ReplaceAllStringFunc(value, func(candidate string) string {
|
||||
parsed, err := url.Parse(candidate)
|
||||
if err != nil || parsed.User == nil {
|
||||
return candidate
|
||||
}
|
||||
if _, hasPassword := parsed.User.Password(); !hasPassword {
|
||||
return candidate
|
||||
}
|
||||
parsed.User = url.UserPassword(parsed.User.Username(), replacement)
|
||||
return parsed.String()
|
||||
})
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package redaction
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactStringCoversCommonSecretShapes(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
`{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz"}`,
|
||||
"authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz123456",
|
||||
"https://zero:[email protected]/path?token=glpat-abcdefghijklmnopqrstuvwxyz",
|
||||
"-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
|
||||
}, "\n")
|
||||
|
||||
got := RedactString(input, Options{ExtraSecretValues: []string{"super-secret"}})
|
||||
|
||||
for _, leaked := range []string{
|
||||
"sk-proj-abcdefghijklmnopqrstuvwxyz",
|
||||
"ghp_abcdefghijklmnopqrstuvwxyz123456",
|
||||
"super-secret",
|
||||
"glpat-abcdefghijklmnopqrstuvwxyz",
|
||||
"abc123",
|
||||
} {
|
||||
if strings.Contains(got, leaked) {
|
||||
t.Fatalf("redacted string leaked %q in %q", leaked, got)
|
||||
}
|
||||
}
|
||||
if count := strings.Count(got, RedactedSecret); count < 5 {
|
||||
t.Fatalf("expected multiple redaction markers, got %d in %q", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactValueHandlesSensitiveKeysAndCycles(t *testing.T) {
|
||||
type node struct {
|
||||
Name string
|
||||
Password string
|
||||
Next *node
|
||||
}
|
||||
root := &node{Name: "root", Password: "open-sesame"}
|
||||
root.Next = root
|
||||
|
||||
got := RedactValue(root, Options{})
|
||||
asMap, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map output, got %T", got)
|
||||
}
|
||||
if asMap["Password"] != RedactedSecret {
|
||||
t.Fatalf("expected sensitive key redacted, got %#v", asMap["Password"])
|
||||
}
|
||||
if asMap["Next"] != CircularReference {
|
||||
t.Fatalf("expected circular reference marker, got %#v", asMap["Next"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactErrorRedactsMessageStackAndFields(t *testing.T) {
|
||||
err := withFieldsError{
|
||||
err: errors.New("request failed with api_key=sk-test-secret1234567890"),
|
||||
Token: "ghp_abcdefghijklmnopqrstuvwxyz123456",
|
||||
Detail: "safe",
|
||||
}
|
||||
|
||||
got := RedactError(err, Options{})
|
||||
|
||||
if strings.Contains(got.Message, "sk-test-secret") {
|
||||
t.Fatalf("message leaked secret: %#v", got)
|
||||
}
|
||||
if got.Fields["Token"] != RedactedSecret {
|
||||
t.Fatalf("token field was not redacted: %#v", got.Fields)
|
||||
}
|
||||
if got.Fields["Detail"] != "safe" {
|
||||
t.Fatalf("non-sensitive field changed: %#v", got.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
type withFieldsError struct {
|
||||
err error
|
||||
Token string
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (err withFieldsError) Error() string {
|
||||
return err.err.Error()
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
const (
|
||||
IndexFileName = "search-index.json"
|
||||
indexSchema = 1
|
||||
defaultLimit = 20
|
||||
defaultContext = 80
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Store *sessions.Store
|
||||
RootDir string
|
||||
Limit int
|
||||
ContextChars int
|
||||
SessionID string
|
||||
Type sessions.EventType
|
||||
Reindex bool
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type EventSummary struct {
|
||||
ID string `json:"id"`
|
||||
Sequence int `json:"sequence"`
|
||||
Type sessions.EventType `json:"type"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Hit struct {
|
||||
Session sessions.Metadata `json:"session"`
|
||||
Event EventSummary `json:"event"`
|
||||
Context string `json:"context"`
|
||||
Match Match `json:"match"`
|
||||
}
|
||||
|
||||
type Match struct {
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Query string `json:"query"`
|
||||
NormalizedQuery string `json:"normalizedQuery"`
|
||||
RootDir string `json:"rootDir"`
|
||||
SearchedSessions int `json:"searchedSessions"`
|
||||
TotalHits int `json:"totalHits"`
|
||||
Hits []Hit `json:"hits"`
|
||||
}
|
||||
|
||||
type Index struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
SessionID string `json:"sessionId"`
|
||||
SessionUpdatedAt string `json:"sessionUpdatedAt"`
|
||||
SessionEventCount int `json:"sessionEventCount"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Entries []IndexEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type IndexEntry struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
EventID string `json:"eventId"`
|
||||
Sequence int `json:"sequence"`
|
||||
Type sessions.EventType `json:"type"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func Sessions(query string, options Options) (Result, error) {
|
||||
normalized := NormalizeQuery(query)
|
||||
store := options.Store
|
||||
if store == nil {
|
||||
store = sessions.NewStore(sessions.StoreOptions{RootDir: options.RootDir})
|
||||
}
|
||||
limit := normalizeLimit(options.Limit)
|
||||
contextChars := normalizeContext(options.ContextChars)
|
||||
if normalized == "" || limit == 0 {
|
||||
return Result{Query: query, NormalizedQuery: normalized, RootDir: store.RootDir, Hits: []Hit{}}, nil
|
||||
}
|
||||
sessionList, err := resolveSessions(store, strings.TrimSpace(options.SessionID))
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
terms := splitTerms(normalized)
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
result := Result{
|
||||
Query: query,
|
||||
NormalizedQuery: normalized,
|
||||
RootDir: store.RootDir,
|
||||
SearchedSessions: len(sessionList),
|
||||
Hits: []Hit{},
|
||||
}
|
||||
for _, session := range sessionList {
|
||||
index, err := LoadIndex(store, session, LoadOptions{Reindex: options.Reindex, Now: now})
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
for _, entry := range index.Entries {
|
||||
if options.Type != "" && entry.Type != options.Type {
|
||||
continue
|
||||
}
|
||||
match, ok := findMatch(entry.Text, normalized, terms)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result.Hits = append(result.Hits, Hit{
|
||||
Session: session,
|
||||
Event: EventSummary{
|
||||
ID: entry.EventID,
|
||||
Sequence: entry.Sequence,
|
||||
Type: entry.Type,
|
||||
CreatedAt: entry.CreatedAt,
|
||||
},
|
||||
Context: buildContext(entry.Text, match.Start, match.End, contextChars),
|
||||
Match: match,
|
||||
})
|
||||
if len(result.Hits) >= limit {
|
||||
result.TotalHits = len(result.Hits)
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
result.TotalHits = len(result.Hits)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type LoadOptions struct {
|
||||
Reindex bool
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func LoadIndex(store *sessions.Store, session sessions.Metadata, options LoadOptions) (Index, error) {
|
||||
if !options.Reindex {
|
||||
index, ok := readIndex(store, session)
|
||||
if ok && current(index, session) {
|
||||
return index, nil
|
||||
}
|
||||
}
|
||||
return RebuildIndex(store, session, options.Now)
|
||||
}
|
||||
|
||||
func RebuildIndex(store *sessions.Store, session sessions.Metadata, now func() time.Time) (Index, error) {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
return Index{}, err
|
||||
}
|
||||
index := Index{
|
||||
SchemaVersion: indexSchema,
|
||||
SessionID: session.SessionID,
|
||||
SessionUpdatedAt: session.UpdatedAt,
|
||||
SessionEventCount: session.EventCount,
|
||||
GeneratedAt: now().UTC().Format(time.RFC3339),
|
||||
Entries: make([]IndexEntry, 0, len(events)),
|
||||
}
|
||||
for _, event := range events {
|
||||
index.Entries = append(index.Entries, IndexEntry{
|
||||
SessionID: session.SessionID,
|
||||
EventID: event.ID,
|
||||
Sequence: event.Sequence,
|
||||
Type: event.Type,
|
||||
CreatedAt: event.CreatedAt,
|
||||
Text: ExtractText(redactedPayload(event.Payload)),
|
||||
})
|
||||
}
|
||||
data, err := json.MarshalIndent(index, "", " ")
|
||||
if err != nil {
|
||||
return Index{}, fmt.Errorf("encode Zero search index: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(indexPath(store, session.SessionID), append(data, '\n'), 0o600); err != nil {
|
||||
return Index{}, fmt.Errorf("write Zero search index: %w", err)
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
func FormatResult(result Result) string {
|
||||
query := redaction.RedactString(strings.TrimSpace(result.Query), redaction.Options{})
|
||||
if len(result.Hits) == 0 {
|
||||
return fmt.Sprintf("No local session events matched %q. Searched %s.", query, count(result.SearchedSessions, "session"))
|
||||
}
|
||||
lines := []string{fmt.Sprintf("Found %s for %q:", count(result.TotalHits, "local session event"), query)}
|
||||
for index, hit := range result.Hits {
|
||||
title := ""
|
||||
if hit.Session.Title != "" {
|
||||
title = " - " + redaction.RedactString(hit.Session.Title, redaction.Options{})
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s #%d %s%s", index+1, redaction.RedactString(hit.Session.SessionID, redaction.Options{}), hit.Event.Sequence, redaction.RedactString(string(hit.Event.Type), redaction.Options{}), title))
|
||||
context := strings.Join(strings.Fields(redaction.RedactString(hit.Context, redaction.Options{})), " ")
|
||||
if context != "" {
|
||||
lines = append(lines, " "+context)
|
||||
}
|
||||
details := []string{}
|
||||
if hit.Session.Cwd != "" {
|
||||
details = append(details, "cwd: "+redaction.RedactString(hit.Session.Cwd, redaction.Options{}))
|
||||
}
|
||||
if hit.Session.ModelID != "" {
|
||||
details = append(details, "model: "+redaction.RedactString(hit.Session.ModelID, redaction.Options{}))
|
||||
}
|
||||
if hit.Session.Provider != "" {
|
||||
details = append(details, "provider: "+redaction.RedactString(hit.Session.Provider, redaction.Options{}))
|
||||
}
|
||||
details = append(details, "updated: "+redaction.RedactString(hit.Session.UpdatedAt, redaction.Options{}))
|
||||
lines = append(lines, " "+strings.Join(details, " | "))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func RedactResult(result Result) Result {
|
||||
options := redaction.Options{}
|
||||
redacted := result
|
||||
redacted.Query = redaction.RedactString(redacted.Query, options)
|
||||
redacted.NormalizedQuery = redaction.RedactString(redacted.NormalizedQuery, options)
|
||||
redacted.RootDir = redaction.RedactString(redacted.RootDir, options)
|
||||
redacted.Hits = make([]Hit, len(result.Hits))
|
||||
for index, hit := range result.Hits {
|
||||
redacted.Hits[index] = Hit{
|
||||
Session: redactMetadata(hit.Session, options),
|
||||
Event: redactEventSummary(hit.Event, options),
|
||||
Context: redaction.RedactString(hit.Context, options),
|
||||
Match: hit.Match,
|
||||
}
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
func redactMetadata(session sessions.Metadata, options redaction.Options) sessions.Metadata {
|
||||
session.SessionID = redaction.RedactString(session.SessionID, options)
|
||||
session.Title = redaction.RedactString(session.Title, options)
|
||||
session.Cwd = redaction.RedactString(session.Cwd, options)
|
||||
session.ModelID = redaction.RedactString(session.ModelID, options)
|
||||
session.Provider = redaction.RedactString(session.Provider, options)
|
||||
session.ParentSessionID = redaction.RedactString(session.ParentSessionID, options)
|
||||
session.ForkedFromEventID = redaction.RedactString(session.ForkedFromEventID, options)
|
||||
session.CreatedAt = redaction.RedactString(session.CreatedAt, options)
|
||||
session.UpdatedAt = redaction.RedactString(session.UpdatedAt, options)
|
||||
session.LastEventType = sessions.EventType(redaction.RedactString(string(session.LastEventType), options))
|
||||
return session
|
||||
}
|
||||
|
||||
func redactEventSummary(event EventSummary, options redaction.Options) EventSummary {
|
||||
event.ID = redaction.RedactString(event.ID, options)
|
||||
event.Type = sessions.EventType(redaction.RedactString(string(event.Type), options))
|
||||
event.CreatedAt = redaction.RedactString(event.CreatedAt, options)
|
||||
return event
|
||||
}
|
||||
|
||||
func NormalizeQuery(query string) string {
|
||||
return strings.ToLower(strings.Join(strings.Fields(query), " "))
|
||||
}
|
||||
|
||||
func ExtractText(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return typed
|
||||
case float64, float32, int, int64, bool:
|
||||
return fmt.Sprint(typed)
|
||||
case []any:
|
||||
parts := []string{}
|
||||
for _, item := range typed {
|
||||
if text := ExtractText(item); text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
case map[string]any:
|
||||
parts := []string{}
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key)
|
||||
item := typed[key]
|
||||
if text := ExtractText(item); text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
default:
|
||||
return fmt.Sprint(typed)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveSessions(store *sessions.Store, sessionID string) ([]sessions.Metadata, error) {
|
||||
if sessionID == "" {
|
||||
return store.List()
|
||||
}
|
||||
session, err := store.Get(sessionID)
|
||||
if err != nil || session == nil {
|
||||
return []sessions.Metadata{}, err
|
||||
}
|
||||
return []sessions.Metadata{*session}, nil
|
||||
}
|
||||
|
||||
func readIndex(store *sessions.Store, session sessions.Metadata) (Index, bool) {
|
||||
data, err := os.ReadFile(indexPath(store, session.SessionID))
|
||||
if err != nil {
|
||||
return Index{}, false
|
||||
}
|
||||
var index Index
|
||||
if err := json.Unmarshal(data, &index); err != nil {
|
||||
return Index{}, false
|
||||
}
|
||||
return index, true
|
||||
}
|
||||
|
||||
func current(index Index, session sessions.Metadata) bool {
|
||||
return index.SchemaVersion == indexSchema &&
|
||||
index.SessionID == session.SessionID &&
|
||||
index.SessionUpdatedAt == session.UpdatedAt &&
|
||||
index.SessionEventCount == session.EventCount &&
|
||||
len(index.Entries) == session.EventCount
|
||||
}
|
||||
|
||||
func redactedPayload(raw json.RawMessage) any {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var payload any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return redaction.RedactString(string(raw), redaction.Options{})
|
||||
}
|
||||
return redaction.RedactValue(payload, redaction.Options{})
|
||||
}
|
||||
|
||||
func findMatch(text string, query string, terms []string) (Match, bool) {
|
||||
normalizedText := strings.ToLower(text)
|
||||
if index := strings.Index(normalizedText, query); index >= 0 {
|
||||
return Match{Start: index, End: index + len(query)}, true
|
||||
}
|
||||
first := -1
|
||||
last := -1
|
||||
for _, term := range terms {
|
||||
index := strings.Index(normalizedText, term)
|
||||
if index < 0 {
|
||||
return Match{}, false
|
||||
}
|
||||
if first < 0 || index < first {
|
||||
first = index
|
||||
}
|
||||
if end := index + len(term); end > last {
|
||||
last = end
|
||||
}
|
||||
}
|
||||
return Match{Start: first, End: last}, first >= 0
|
||||
}
|
||||
|
||||
func buildContext(text string, start int, end int, contextChars int) string {
|
||||
left := start - contextChars
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
right := end + contextChars
|
||||
if right > len(text) {
|
||||
right = len(text)
|
||||
}
|
||||
return strings.TrimSpace(text[left:right])
|
||||
}
|
||||
|
||||
func splitTerms(query string) []string {
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(query)
|
||||
}
|
||||
|
||||
func normalizeLimit(limit int) int {
|
||||
if limit == 0 {
|
||||
return defaultLimit
|
||||
}
|
||||
if limit < 0 {
|
||||
return 0
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func normalizeContext(contextChars int) int {
|
||||
if contextChars == 0 {
|
||||
return defaultContext
|
||||
}
|
||||
if contextChars < 0 {
|
||||
return 0
|
||||
}
|
||||
return contextChars
|
||||
}
|
||||
|
||||
func indexPath(store *sessions.Store, sessionID string) string {
|
||||
return filepath.Join(store.RootDir, sessionID, IndexFileName)
|
||||
}
|
||||
|
||||
func count(value int, label string) string {
|
||||
suffix := "s"
|
||||
if value == 1 {
|
||||
suffix = ""
|
||||
}
|
||||
return fmt.Sprintf("%d %s%s", value, label, suffix)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func TestSearchSessionsFindsRedactedEventContextAndCachesIndex(t *testing.T) {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedSearchClock("2026-06-04T14:00:00Z")})
|
||||
session, err := store.Create(sessions.CreateInput{SessionID: "searchable", Title: "Search", Cwd: "/repo", ModelID: "gpt-4.1", Provider: "openai"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"content": "please rotate apiKey=sk-secret1234567890 before deploy"}}); err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventToolResult, Payload: map[string]any{"output": "deployment finished"}}); err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
|
||||
result, err := Sessions("rotate deploy", Options{Store: store, Limit: 5, ContextChars: 120})
|
||||
if err != nil {
|
||||
t.Fatalf("Sessions returned error: %v", err)
|
||||
}
|
||||
if result.TotalHits != 1 || result.SearchedSessions != 1 {
|
||||
t.Fatalf("unexpected result counts: %#v", result)
|
||||
}
|
||||
if hit := result.Hits[0]; hit.Session.SessionID != "searchable" || hit.Event.Sequence != 1 {
|
||||
t.Fatalf("unexpected hit: %#v", hit)
|
||||
}
|
||||
if strings.Contains(result.Hits[0].Context, "sk-secret") {
|
||||
t.Fatalf("search context leaked secret: %#v", result.Hits[0])
|
||||
}
|
||||
|
||||
indexPath := filepath.Join(store.RootDir, "searchable", IndexFileName)
|
||||
if _, err := os.Stat(indexPath); err != nil {
|
||||
t.Fatalf("expected search index to be written: %v", err)
|
||||
}
|
||||
formatted := FormatResult(result)
|
||||
if !strings.Contains(formatted, "Found 1 local session event") || strings.Contains(formatted, "sk-secret") {
|
||||
t.Fatalf("unexpected formatted result: %q", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSessionsSupportsFiltersAndEmptyQuery(t *testing.T) {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedSearchClock("2026-06-04T14:30:00Z")})
|
||||
one, err := store.Create(sessions.CreateInput{SessionID: "one"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create one returned error: %v", err)
|
||||
}
|
||||
two, err := store.Create(sessions.CreateInput{SessionID: "two"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create two returned error: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(one.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: "needle in message"}); err != nil {
|
||||
t.Fatalf("AppendEvent one returned error: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(two.SessionID, sessions.AppendEventInput{Type: sessions.EventToolResult, Payload: "needle in tool result"}); err != nil {
|
||||
t.Fatalf("AppendEvent two returned error: %v", err)
|
||||
}
|
||||
|
||||
result, err := Sessions("needle", Options{Store: store, SessionID: "two", Type: sessions.EventToolResult})
|
||||
if err != nil {
|
||||
t.Fatalf("Sessions returned error: %v", err)
|
||||
}
|
||||
if result.TotalHits != 1 || result.Hits[0].Session.SessionID != "two" {
|
||||
t.Fatalf("unexpected filtered result: %#v", result)
|
||||
}
|
||||
|
||||
empty, err := Sessions(" ", Options{Store: store})
|
||||
if err != nil {
|
||||
t.Fatalf("empty Sessions returned error: %v", err)
|
||||
}
|
||||
if empty.TotalHits != 0 || empty.SearchedSessions != 0 {
|
||||
t.Fatalf("empty query should not search sessions: %#v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSessionsMatchesMapKeys(t *testing.T) {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedSearchClock("2026-06-04T14:45:00Z")})
|
||||
session, err := store.Create(sessions.CreateInput{SessionID: "map_keys"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventError, Payload: map[string]any{"error": "", "status": "success"}}); err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
|
||||
result, err := Sessions("error", Options{Store: store})
|
||||
if err != nil {
|
||||
t.Fatalf("Sessions returned error: %v", err)
|
||||
}
|
||||
if result.TotalHits != 1 || result.Hits[0].Event.Type != sessions.EventError {
|
||||
t.Fatalf("expected map key search hit, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func fixedSearchClock(value string) func() time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return func() time.Time { return parsed }
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MetadataFile = "metadata.json"
|
||||
EventsFile = "events.jsonl"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventMessage EventType = "message"
|
||||
EventToolCall EventType = "tool_call"
|
||||
EventToolResult EventType = "tool_result"
|
||||
EventProviderUsage EventType = "provider_usage"
|
||||
EventError EventType = "error"
|
||||
EventSessionFork EventType = "session_fork"
|
||||
)
|
||||
|
||||
type Metadata struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
ModelID string `json:"modelId,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ParentSessionID string `json:"parentSessionId,omitempty"`
|
||||
ForkedFromEventID string `json:"forkedFromEventId,omitempty"`
|
||||
ForkedFromSequence int `json:"forkedFromSequence,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
EventCount int `json:"eventCount"`
|
||||
LastEventType EventType `json:"lastEventType,omitempty"`
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
SessionID string
|
||||
Title string
|
||||
Cwd string
|
||||
ModelID string
|
||||
Provider string
|
||||
ParentSessionID string
|
||||
ForkedFromEventID string
|
||||
ForkedFromSequence int
|
||||
}
|
||||
|
||||
type ForkInput struct {
|
||||
SessionID string
|
||||
Title string
|
||||
Cwd string
|
||||
ModelID string
|
||||
Provider string
|
||||
}
|
||||
|
||||
type AppendEventInput struct {
|
||||
Type EventType
|
||||
Payload any
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Sequence int `json:"sequence"`
|
||||
Type EventType `json:"type"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type StoreOptions struct {
|
||||
RootDir string
|
||||
Now func() time.Time
|
||||
Env map[string]string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
RootDir string
|
||||
now func() time.Time
|
||||
locksMu sync.Mutex
|
||||
sessionLocks map[string]*sync.Mutex
|
||||
idCounter atomic.Uint64
|
||||
}
|
||||
|
||||
var sessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
|
||||
|
||||
func NewStore(options StoreOptions) *Store {
|
||||
rootDir := strings.TrimSpace(options.RootDir)
|
||||
if rootDir == "" {
|
||||
rootDir = DefaultRoot(options.Env)
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Store{RootDir: rootDir, now: now, sessionLocks: map[string]*sync.Mutex{}}
|
||||
}
|
||||
|
||||
func DefaultRoot(env map[string]string) string {
|
||||
dataHome := strings.TrimSpace(envValue(env, "XDG_DATA_HOME"))
|
||||
home := strings.TrimSpace(envValue(env, "HOME"))
|
||||
if home == "" {
|
||||
if userHome, err := os.UserHomeDir(); err == nil {
|
||||
home = userHome
|
||||
}
|
||||
}
|
||||
base := dataHome
|
||||
if base == "" {
|
||||
base = filepath.Join(home, ".local", "share")
|
||||
}
|
||||
return filepath.Join(base, "zero", "sessions")
|
||||
}
|
||||
|
||||
func ValidSessionID(sessionID string) bool {
|
||||
return sessionIDPattern.MatchString(strings.TrimSpace(sessionID))
|
||||
}
|
||||
|
||||
func (store *Store) Create(input CreateInput) (Metadata, error) {
|
||||
sessionID := strings.TrimSpace(input.SessionID)
|
||||
if sessionID == "" {
|
||||
sessionID = store.createID()
|
||||
}
|
||||
if !ValidSessionID(sessionID) {
|
||||
return Metadata{}, fmt.Errorf("invalid Zero session id %q", input.SessionID)
|
||||
}
|
||||
|
||||
timestamp := store.timestamp()
|
||||
session := Metadata{
|
||||
SessionID: sessionID,
|
||||
Title: strings.TrimSpace(input.Title),
|
||||
Cwd: strings.TrimSpace(input.Cwd),
|
||||
ModelID: strings.TrimSpace(input.ModelID),
|
||||
Provider: strings.TrimSpace(input.Provider),
|
||||
ParentSessionID: strings.TrimSpace(input.ParentSessionID),
|
||||
ForkedFromEventID: strings.TrimSpace(input.ForkedFromEventID),
|
||||
ForkedFromSequence: input.ForkedFromSequence,
|
||||
CreatedAt: timestamp,
|
||||
UpdatedAt: timestamp,
|
||||
EventCount: 0,
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(store.RootDir, 0o700); err != nil {
|
||||
return Metadata{}, fmt.Errorf("create Zero session root: %w", err)
|
||||
}
|
||||
if err := os.Mkdir(store.sessionPath(sessionID), 0o700); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return Metadata{}, fmt.Errorf("zero session already exists: %s", sessionID)
|
||||
}
|
||||
return Metadata{}, fmt.Errorf("create Zero session directory: %w", err)
|
||||
}
|
||||
if err := store.writeMetadata(session); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return Metadata{}, fmt.Errorf("create Zero session events file: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return Metadata{}, fmt.Errorf("close Zero session events file: %w", err)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (store *Store) Get(sessionID string) (*Metadata, error) {
|
||||
if !ValidSessionID(sessionID) {
|
||||
return nil, fmt.Errorf("invalid Zero session id %q", sessionID)
|
||||
}
|
||||
session, err := store.readMetadata(sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (store *Store) List() ([]Metadata, error) {
|
||||
if err := os.MkdirAll(store.RootDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create Zero session root: %w", err)
|
||||
}
|
||||
entries, err := os.ReadDir(store.RootDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Zero session root: %w", err)
|
||||
}
|
||||
sessions := []Metadata{}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || !ValidSessionID(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
session, err := store.Get(entry.Name())
|
||||
if err != nil || session == nil {
|
||||
continue
|
||||
}
|
||||
sessions = append(sessions, *session)
|
||||
}
|
||||
sort.SliceStable(sessions, func(left int, right int) bool {
|
||||
if sessions[left].UpdatedAt == sessions[right].UpdatedAt {
|
||||
return sessions[left].SessionID < sessions[right].SessionID
|
||||
}
|
||||
return sessions[left].UpdatedAt > sessions[right].UpdatedAt
|
||||
})
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (store *Store) Latest() (*Metadata, error) {
|
||||
sessions, err := store.List()
|
||||
if err != nil || len(sessions) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &sessions[0], nil
|
||||
}
|
||||
|
||||
func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, error) {
|
||||
if !ValidSessionID(parentSessionID) {
|
||||
return Metadata{}, fmt.Errorf("invalid Zero session id %q", parentSessionID)
|
||||
}
|
||||
parent, err := store.Get(parentSessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
if parent == nil {
|
||||
return Metadata{}, fmt.Errorf("zero session not found: %s", parentSessionID)
|
||||
}
|
||||
events, err := store.ReadEvents(parentSessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
var last Event
|
||||
if len(events) > 0 {
|
||||
last = events[len(events)-1]
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" && parent.Title != "" {
|
||||
title = parent.Title + " (fork)"
|
||||
}
|
||||
fork, err := store.Create(CreateInput{
|
||||
SessionID: input.SessionID,
|
||||
Title: title,
|
||||
Cwd: firstNonEmpty(input.Cwd, parent.Cwd),
|
||||
ModelID: firstNonEmpty(input.ModelID, parent.ModelID),
|
||||
Provider: firstNonEmpty(input.Provider, parent.Provider),
|
||||
ParentSessionID: parent.SessionID,
|
||||
ForkedFromEventID: last.ID,
|
||||
ForkedFromSequence: last.Sequence,
|
||||
})
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
for _, event := range events {
|
||||
if _, err := store.AppendEvent(fork.SessionID, AppendEventInput{Type: event.Type, Payload: event.Payload}); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
}
|
||||
if _, err := store.AppendEvent(fork.SessionID, AppendEventInput{
|
||||
Type: EventSessionFork,
|
||||
Payload: map[string]any{
|
||||
"parentSessionId": parent.SessionID,
|
||||
"parentEventCount": parent.EventCount,
|
||||
"copiedEventCount": len(events),
|
||||
"forkedFromEventId": last.ID,
|
||||
"forkedFromSequence": last.Sequence,
|
||||
},
|
||||
}); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
loaded, err := store.readMetadata(fork.SessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
|
||||
func (store *Store) AppendEvent(sessionID string, input AppendEventInput) (Event, error) {
|
||||
if !ValidSessionID(sessionID) {
|
||||
return Event{}, fmt.Errorf("invalid Zero session id %q", sessionID)
|
||||
}
|
||||
if strings.TrimSpace(string(input.Type)) == "" {
|
||||
return Event{}, fmt.Errorf("zero session event type is required")
|
||||
}
|
||||
lock := store.sessionLock(sessionID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
session, err := store.readMetadata(sessionID)
|
||||
if err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
payload, err := rawPayload(input.Payload)
|
||||
if err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
sequence := session.EventCount + 1
|
||||
timestamp := store.timestamp()
|
||||
event := Event{
|
||||
ID: fmt.Sprintf("%s:%d", sessionID, sequence),
|
||||
SessionID: sessionID,
|
||||
Sequence: sequence,
|
||||
Type: input.Type,
|
||||
CreatedAt: timestamp,
|
||||
Payload: payload,
|
||||
}
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return Event{}, fmt.Errorf("encode Zero session event: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
return Event{}, fmt.Errorf("append Zero session event: %w", err)
|
||||
}
|
||||
if _, err := file.Write(append(data, '\n')); err != nil {
|
||||
_ = file.Close()
|
||||
return Event{}, fmt.Errorf("append Zero session event: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return Event{}, fmt.Errorf("close Zero session event file: %w", err)
|
||||
}
|
||||
session.UpdatedAt = timestamp
|
||||
session.EventCount = sequence
|
||||
session.LastEventType = input.Type
|
||||
if err := store.writeMetadata(session); err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (store *Store) ReadEvents(sessionID string) ([]Event, error) {
|
||||
if !ValidSessionID(sessionID) {
|
||||
return nil, fmt.Errorf("invalid Zero session id %q", sessionID)
|
||||
}
|
||||
data, err := os.ReadFile(store.eventsPath(sessionID))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []Event{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read Zero session events: %w", err)
|
||||
}
|
||||
events := []Event{}
|
||||
for index, line := range bytes.Split(data, []byte{'\n'}) {
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var event Event
|
||||
if err := json.Unmarshal(line, &event); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON in Zero session %s %s at line %d: %w", sessionID, EventsFile, index+1, err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (store *Store) timestamp() string {
|
||||
return store.now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func (store *Store) createID() string {
|
||||
timestamp := store.now().UTC()
|
||||
return fmt.Sprintf("zero_%s_%d_%d", timestamp.Format("20060102150405"), timestamp.UnixNano(), store.idCounter.Add(1))
|
||||
}
|
||||
|
||||
func (store *Store) sessionLock(sessionID string) *sync.Mutex {
|
||||
store.locksMu.Lock()
|
||||
defer store.locksMu.Unlock()
|
||||
if store.sessionLocks == nil {
|
||||
store.sessionLocks = map[string]*sync.Mutex{}
|
||||
}
|
||||
lock := store.sessionLocks[sessionID]
|
||||
if lock == nil {
|
||||
lock = &sync.Mutex{}
|
||||
store.sessionLocks[sessionID] = lock
|
||||
}
|
||||
return lock
|
||||
}
|
||||
|
||||
func (store *Store) sessionPath(sessionID string) string {
|
||||
return filepath.Join(store.RootDir, sessionID)
|
||||
}
|
||||
|
||||
func (store *Store) metadataPath(sessionID string) string {
|
||||
return filepath.Join(store.sessionPath(sessionID), MetadataFile)
|
||||
}
|
||||
|
||||
func (store *Store) eventsPath(sessionID string) string {
|
||||
return filepath.Join(store.sessionPath(sessionID), EventsFile)
|
||||
}
|
||||
|
||||
func (store *Store) readMetadata(sessionID string) (Metadata, error) {
|
||||
data, err := os.ReadFile(store.metadataPath(sessionID))
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
var session Metadata
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
return Metadata{}, fmt.Errorf("invalid Zero session metadata %s: %w", sessionID, err)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (store *Store) writeMetadata(session Metadata) error {
|
||||
data, err := json.MarshalIndent(session, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode Zero session metadata: %w", err)
|
||||
}
|
||||
path := store.metadataPath(session.SessionID)
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {
|
||||
return fmt.Errorf("write Zero session metadata: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("replace Zero session metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rawPayload(payload any) (json.RawMessage, error) {
|
||||
if payload == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if raw, ok := payload.(json.RawMessage); ok {
|
||||
copied := append(json.RawMessage{}, bytes.TrimSpace(raw)...)
|
||||
if len(copied) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if !json.Valid(copied) {
|
||||
return nil, fmt.Errorf("invalid raw JSON payload")
|
||||
}
|
||||
return copied, nil
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode Zero session payload: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func envValue(env map[string]string, key string) string {
|
||||
if env != nil {
|
||||
return env[key]
|
||||
}
|
||||
return os.Getenv(key)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStoreCreatesAppendsListsAndReadsEvents(t *testing.T) {
|
||||
now := fixedClock("2026-06-04T10:00:00Z")
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: now})
|
||||
|
||||
session, err := store.Create(CreateInput{
|
||||
SessionID: "zero_test_1",
|
||||
Title: "First run",
|
||||
Cwd: "/repo",
|
||||
ModelID: "gpt-4.1",
|
||||
Provider: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if session.EventCount != 0 || session.CreatedAt != "2026-06-04T10:00:00Z" {
|
||||
t.Fatalf("unexpected session metadata: %#v", session)
|
||||
}
|
||||
|
||||
event, err := store.AppendEvent(session.SessionID, AppendEventInput{
|
||||
Type: EventMessage,
|
||||
Payload: map[string]any{
|
||||
"role": "user",
|
||||
"content": "searchable hello",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
if event.ID != "zero_test_1:1" || event.Sequence != 1 {
|
||||
t.Fatalf("unexpected event identity: %#v", event)
|
||||
}
|
||||
|
||||
loaded, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.EventCount != 1 || loaded.LastEventType != EventMessage {
|
||||
t.Fatalf("metadata was not updated after append: %#v", loaded)
|
||||
}
|
||||
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("expected one event, got %#v", events)
|
||||
}
|
||||
payload := map[string]any{}
|
||||
if err := json.Unmarshal(events[0].Payload, &payload); err != nil {
|
||||
t.Fatalf("payload is not valid JSON: %v", err)
|
||||
}
|
||||
if payload["content"] != "searchable hello" {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
|
||||
sessions, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
if len(sessions) != 1 || sessions[0].SessionID != session.SessionID {
|
||||
t.Fatalf("unexpected session list: %#v", sessions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreForkCopiesEventsAndLineage(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T11:00:00Z")})
|
||||
parent, err := store.Create(CreateInput{SessionID: "parent", Title: "Parent", Cwd: "/repo", ModelID: "gpt-4.1", Provider: "openai"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
for _, content := range []string{"first", "second"} {
|
||||
if _, err := store.AppendEvent(parent.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]string{"content": content}}); err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
fork, err := store.Fork(parent.SessionID, ForkInput{SessionID: "fork"})
|
||||
if err != nil {
|
||||
t.Fatalf("Fork returned error: %v", err)
|
||||
}
|
||||
if fork.ParentSessionID != parent.SessionID || fork.ForkedFromEventID != "parent:2" || fork.ForkedFromSequence != 2 {
|
||||
t.Fatalf("fork lineage not recorded: %#v", fork)
|
||||
}
|
||||
if fork.EventCount != 3 || fork.LastEventType != EventSessionFork {
|
||||
t.Fatalf("fork event count/type wrong: %#v", fork)
|
||||
}
|
||||
events, err := store.ReadEvents(fork.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
if len(events) != 3 || events[0].ID != "fork:1" || events[2].Type != EventSessionFork {
|
||||
t.Fatalf("fork events not copied/remapped: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRootHonorsXDGDataHome(t *testing.T) {
|
||||
got := DefaultRoot(map[string]string{
|
||||
"XDG_DATA_HOME": "/tmp/zero-data",
|
||||
"HOME": "/tmp/home",
|
||||
})
|
||||
want := filepath.Join("/tmp/zero-data", "zero", "sessions")
|
||||
if got != want {
|
||||
t.Fatalf("DefaultRoot = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsUnsafeSessionIDsAndBadJSONL(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T12:00:00Z")})
|
||||
if _, err := store.Create(CreateInput{SessionID: "../escape"}); err == nil {
|
||||
t.Fatal("expected unsafe session id to be rejected")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(store.RootDir, "bad"), 0o700); err != nil {
|
||||
t.Fatalf("mkdir failed: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(store.RootDir, "bad", "metadata.json"), []byte(`{"sessionId":"bad","createdAt":"x","updatedAt":"x","eventCount":1}`), 0o600); err != nil {
|
||||
t.Fatalf("metadata write failed: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(store.RootDir, "bad", "events.jsonl"), []byte("{not-json}\n"), 0o600); err != nil {
|
||||
t.Fatalf("events write failed: %v", err)
|
||||
}
|
||||
|
||||
_, err := store.ReadEvents("bad")
|
||||
if err == nil || !strings.Contains(err.Error(), "events.jsonl at line 1") {
|
||||
t.Fatalf("expected JSONL line error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreGeneratesUniqueIDsWithFixedClock(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T12:30:00Z")})
|
||||
first, err := store.Create(CreateInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("Create first returned error: %v", err)
|
||||
}
|
||||
second, err := store.Create(CreateInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("Create second returned error: %v", err)
|
||||
}
|
||||
if first.SessionID == second.SessionID {
|
||||
t.Fatalf("generated session ids collided: %q", first.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendEventSerializesConcurrentWriters(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T13:00:00Z")})
|
||||
session, err := store.Create(CreateInput{SessionID: "concurrent"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
|
||||
const total = 24
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, total)
|
||||
for index := 0; index < total; index++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
_, err := store.AppendEvent(session.SessionID, AppendEventInput{
|
||||
Type: EventMessage,
|
||||
Payload: map[string]int{"index": index},
|
||||
})
|
||||
errs <- err
|
||||
}(index)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
if len(events) != total {
|
||||
t.Fatalf("expected %d events, got %d: %#v", total, len(events), events)
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for _, event := range events {
|
||||
if seen[event.Sequence] {
|
||||
t.Fatalf("duplicate event sequence %d in %#v", event.Sequence, events)
|
||||
}
|
||||
seen[event.Sequence] = true
|
||||
if event.ID != fmt.Sprintf("%s:%d", session.SessionID, event.Sequence) {
|
||||
t.Fatalf("event id/sequence mismatch: %#v", event)
|
||||
}
|
||||
}
|
||||
for sequence := 1; sequence <= total; sequence++ {
|
||||
if !seen[sequence] {
|
||||
t.Fatalf("missing event sequence %d in %#v", sequence, events)
|
||||
}
|
||||
}
|
||||
loaded, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.EventCount != total || loaded.LastEventType != EventMessage {
|
||||
t.Fatalf("metadata not updated after concurrent append: %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func fixedClock(value string) func() time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return func() time.Time { return parsed }
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type applyPatchTool struct {
|
||||
baseTool
|
||||
workspaceRoot string
|
||||
}
|
||||
|
||||
func NewApplyPatchTool(workspaceRoot string) Tool {
|
||||
return applyPatchTool{
|
||||
baseTool: baseTool{
|
||||
name: "apply_patch",
|
||||
description: "Apply a unified diff patch inside the workspace.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"patch": {Type: "string", Description: "Unified diff patch to apply."},
|
||||
"cwd": {Type: "string", Description: "Directory where the patch should be applied. Defaults to workspace root.", Default: "."},
|
||||
},
|
||||
Required: []string{"patch"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectWrite, "Applies patch hunks that can create, edit, or delete files."),
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
}
|
||||
}
|
||||
|
||||
func (tool applyPatchTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
patch, err := stringArg(args, "patch", "", true)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for apply_patch: " + err.Error())
|
||||
}
|
||||
cwd, err := stringArg(args, "cwd", ".", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for apply_patch: " + err.Error())
|
||||
}
|
||||
|
||||
applyRoot, relativeRoot, err := resolveWorkspacePath(tool.workspaceRoot, cwd)
|
||||
if err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
if err := validatePatchPaths(applyRoot, patch); err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp("", "zero-patch-*.patch")
|
||||
if err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
patchPath := tempFile.Name()
|
||||
defer func() {
|
||||
_ = os.Remove(patchPath)
|
||||
}()
|
||||
if _, err := tempFile.WriteString(patch); err != nil {
|
||||
_ = tempFile.Close()
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
|
||||
if err := recheckPatchWriteTargets(applyRoot, patch); err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
|
||||
command := exec.CommandContext(ctx, "git", "apply", "--whitespace=nowarn", patchPath)
|
||||
command.Dir = applyRoot
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(string(output))
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return errorResult("Error applying patch: " + message)
|
||||
}
|
||||
|
||||
if relativeRoot == "." {
|
||||
return okResult("Patch applied successfully.")
|
||||
}
|
||||
return okResult("Patch applied successfully in " + relativeRoot + ".")
|
||||
}
|
||||
|
||||
func validatePatchPaths(root string, patch string) error {
|
||||
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
|
||||
for _, path := range patchPathsFromLine(line) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, "../") {
|
||||
return fmt.Errorf("patch path %q must stay inside the workspace", path)
|
||||
}
|
||||
if _, _, err := resolveWorkspaceTargetPath(root, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recheckPatchWriteTargets(root string, patch string) error {
|
||||
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
|
||||
for _, path := range patchPathsFromLine(line) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
if err := recheckWorkspaceWriteTarget(root, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func patchPathsFromLine(line string) []string {
|
||||
if strings.HasPrefix(line, "diff --git ") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 4 {
|
||||
return []string{stripPatchPrefix(fields[2]), stripPatchPrefix(fields[3])}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
return []string{stripPatchPrefix(fields[1])}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stripPatchPrefix(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
path = strings.TrimPrefix(path, "a/")
|
||||
path = strings.TrimPrefix(path, "b/")
|
||||
return filepath.ToSlash(path)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
func stringArg(args map[string]any, key string, fallback string, required bool) (string, error) {
|
||||
return stringArgWithEmpty(args, key, fallback, required, false)
|
||||
}
|
||||
|
||||
func stringArgWithEmpty(args map[string]any, key string, fallback string, required bool, allowEmpty bool) (string, error) {
|
||||
value, ok := args[key]
|
||||
if !ok || value == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if !allowEmpty && text == "" {
|
||||
return "", fmt.Errorf("%s must be a non-empty string", key)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func boolArg(args map[string]any, key string, fallback bool) (bool, error) {
|
||||
value, ok := args[key]
|
||||
if !ok || value == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
boolean, ok := value.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s must be a boolean", key)
|
||||
}
|
||||
return boolean, nil
|
||||
}
|
||||
|
||||
func intArg(args map[string]any, key string, fallback int, min int, max int) (int, error) {
|
||||
value, ok := args[key]
|
||||
if !ok || value == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
var number int
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
number = typed
|
||||
case int32:
|
||||
number = int(typed)
|
||||
case int64:
|
||||
number = int(typed)
|
||||
case float64:
|
||||
if math.Trunc(typed) != typed {
|
||||
return 0, fmt.Errorf("%s must be an integer", key)
|
||||
}
|
||||
number = int(typed)
|
||||
default:
|
||||
return 0, fmt.Errorf("%s must be an integer", key)
|
||||
}
|
||||
|
||||
if number < min {
|
||||
return 0, fmt.Errorf("%s must be at least %d", key, min)
|
||||
}
|
||||
if max > 0 && number > max {
|
||||
return 0, fmt.Errorf("%s must be at most %d", key, max)
|
||||
}
|
||||
return number, nil
|
||||
}
|
||||
|
||||
func intPtr(value int) *int {
|
||||
return &value
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultBashTimeoutMS = 120000
|
||||
const maxBashTimeoutMS = 600000
|
||||
|
||||
type bashTool struct {
|
||||
baseTool
|
||||
workspaceRoot string
|
||||
}
|
||||
|
||||
func NewBashTool(workspaceRoot string) Tool {
|
||||
return bashTool{
|
||||
baseTool: baseTool{
|
||||
name: "bash",
|
||||
description: "Execute a shell command inside the workspace after permission is granted.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"command": {Type: "string", Description: "Shell command to execute."},
|
||||
"cwd": {Type: "string", Description: "Workspace directory to run the command in. Defaults to workspace root.", Default: "."},
|
||||
"timeout_ms": {Type: "integer", Description: "Command timeout in milliseconds.", Default: defaultBashTimeoutMS, Minimum: intPtr(1), Maximum: intPtr(maxBashTimeoutMS)},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
}
|
||||
}
|
||||
|
||||
func (tool bashTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
commandText, err := stringArg(args, "command", "", true)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for bash: " + err.Error())
|
||||
}
|
||||
cwd, err := stringArg(args, "cwd", ".", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for bash: " + err.Error())
|
||||
}
|
||||
timeoutMS, err := intArg(args, "timeout_ms", defaultBashTimeoutMS, 1, maxBashTimeoutMS)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for bash: " + err.Error())
|
||||
}
|
||||
|
||||
absoluteCwd, relativeCwd, err := resolveWorkspacePath(tool.workspaceRoot, cwd)
|
||||
if err != nil {
|
||||
return errorResult("Error running bash: " + err.Error())
|
||||
}
|
||||
|
||||
commandCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMS)*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
command := exec.CommandContext(commandCtx, shellExecutable(), shellArguments(commandText)...)
|
||||
command.Dir = absoluteCwd
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
command.Stdout = &stdout
|
||||
command.Stderr = &stderr
|
||||
|
||||
err = command.Run()
|
||||
exitCode := commandExitCode(err)
|
||||
meta := map[string]string{
|
||||
"cwd": relativeCwd,
|
||||
"exit_code": strconv.Itoa(exitCode),
|
||||
"timeout_ms": strconv.Itoa(timeoutMS),
|
||||
}
|
||||
|
||||
if errors.Is(commandCtx.Err(), context.DeadlineExceeded) {
|
||||
return Result{
|
||||
Status: StatusError,
|
||||
Output: fmt.Sprintf("Error: Command timed out after %dms.", timeoutMS),
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if exitCode < 0 {
|
||||
return Result{
|
||||
Status: StatusError,
|
||||
Output: "Error executing command: " + err.Error(),
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
Status: StatusError,
|
||||
Output: formatBashOutput(stdout.String(), stderr.String(), exitCode),
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
Output: formatBashOutput(stdout.String(), stderr.String(), exitCode),
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
func shellExecutable() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "cmd.exe"
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
func shellArguments(command string) []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{"/d", "/s", "/c", command}
|
||||
}
|
||||
return []string{"-c", command}
|
||||
}
|
||||
|
||||
func commandExitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
return exitError.ExitCode()
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func formatBashOutput(stdout string, stderr string, exitCode int) string {
|
||||
parts := []string{}
|
||||
stdout = strings.TrimRight(stdout, "\r\n")
|
||||
stderr = strings.TrimRight(stderr, "\r\n")
|
||||
if stdout != "" {
|
||||
parts = append(parts, "stdout:\n"+stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
parts = append(parts, "stderr:\n"+stderr)
|
||||
}
|
||||
if exitCode != 0 {
|
||||
parts = append(parts, fmt.Sprintf("exit_code: %d", exitCode))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "Command completed with no output."
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if len(os.Args) >= 3 && os.Args[1] == "--zero-bash-helper" {
|
||||
runBashToolHelper(os.Args[2])
|
||||
return
|
||||
}
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func runBashToolHelper(command string) {
|
||||
switch command {
|
||||
case "success":
|
||||
fmt.Println("hello from bash")
|
||||
case "stderr":
|
||||
fmt.Fprintln(os.Stderr, "warning from bash")
|
||||
case "fail":
|
||||
fmt.Println("before failure")
|
||||
fmt.Fprintln(os.Stderr, "failure details")
|
||||
os.Exit(7)
|
||||
case "pwd":
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(cwd)
|
||||
case "sleep":
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
fmt.Println("woke up")
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "unknown helper command")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreToolsExposeBashTool(t *testing.T) {
|
||||
toolset := CoreTools(t.TempDir())
|
||||
byName := make(map[string]Tool, len(toolset))
|
||||
for _, tool := range toolset {
|
||||
byName[tool.Name()] = tool
|
||||
}
|
||||
|
||||
tool, ok := byName["bash"]
|
||||
if !ok {
|
||||
t.Fatalf("expected core tools to include bash")
|
||||
}
|
||||
if tool.Safety().SideEffect != SideEffectShell {
|
||||
t.Fatalf("bash side effect = %s, want shell", tool.Safety().SideEffect)
|
||||
}
|
||||
if tool.Safety().Permission != PermissionPrompt {
|
||||
t.Fatalf("bash permission = %s, want prompt", tool.Safety().Permission)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryBlocksBashWithoutGrant(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewBashTool(t.TempDir()))
|
||||
|
||||
result := registry.Run(context.Background(), "bash", map[string]any{
|
||||
"command": helperCommand("success"),
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "Permission required for bash") {
|
||||
t.Fatalf("expected permission error, got %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolRunsCommandInWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
"command": helperCommand("success"),
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "stdout:\nhello from bash") {
|
||||
t.Fatalf("expected stdout in output, got %q", result.Output)
|
||||
}
|
||||
if result.Meta["exit_code"] != "0" {
|
||||
t.Fatalf("expected exit_code metadata 0, got %q", result.Meta["exit_code"])
|
||||
}
|
||||
if result.Meta["cwd"] != "." {
|
||||
t.Fatalf("expected cwd metadata ., got %q", result.Meta["cwd"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolUsesRequestedCwd(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
nested := filepath.Join(root, "nested")
|
||||
if err := os.MkdirAll(nested, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
"command": helperCommand("pwd"),
|
||||
"cwd": "nested",
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
normalizedOutput := filepath.ToSlash(strings.TrimSpace(result.Output))
|
||||
if !strings.Contains(normalizedOutput, "stdout:\n") || !strings.HasSuffix(normalizedOutput, "/nested") {
|
||||
t.Fatalf("expected command to run in nested cwd, got %q", result.Output)
|
||||
}
|
||||
if result.Meta["cwd"] != "nested" {
|
||||
t.Fatalf("expected cwd metadata nested, got %q", result.Meta["cwd"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolRejectsCwdOutsideWorkspace(t *testing.T) {
|
||||
outside := t.TempDir()
|
||||
|
||||
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
|
||||
"command": helperCommand("success"),
|
||||
"cwd": outside,
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "must stay inside the workspace") {
|
||||
t.Fatalf("expected workspace error, got %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolReturnsNonzeroExitAsError(t *testing.T) {
|
||||
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
|
||||
"command": helperCommand("fail"),
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: 7"} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
|
||||
}
|
||||
}
|
||||
if result.Meta["exit_code"] != "7" {
|
||||
t.Fatalf("expected exit_code metadata 7, got %q", result.Meta["exit_code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolTimesOut(t *testing.T) {
|
||||
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
|
||||
"command": helperCommand("sleep"),
|
||||
"timeout_ms": 20,
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "timed out after 20ms") {
|
||||
t.Fatalf("expected timeout error, got %q", result.Output)
|
||||
}
|
||||
if result.Meta["timeout_ms"] != "20" {
|
||||
t.Fatalf("expected timeout_ms metadata 20, got %q", result.Meta["timeout_ms"])
|
||||
}
|
||||
}
|
||||
|
||||
func helperCommand(name string) string {
|
||||
executable := shellQuote(os.Args[0])
|
||||
return executable + " --zero-bash-helper " + name
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return value
|
||||
}
|
||||
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type editFileTool struct {
|
||||
baseTool
|
||||
workspaceRoot string
|
||||
}
|
||||
|
||||
func NewEditFileTool(workspaceRoot string) Tool {
|
||||
return editFileTool{
|
||||
baseTool: baseTool{
|
||||
name: "edit_file",
|
||||
description: "Replace an exact string in an existing file with uniqueness protection by default.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"path": {Type: "string", Description: "Path of the file to edit."},
|
||||
"old_string": {Type: "string", Description: "Exact string to replace. Must match byte-for-byte."},
|
||||
"new_string": {Type: "string", Description: "Replacement string. May be empty."},
|
||||
"replace_all": {Type: "boolean", Description: "Replace every occurrence instead of requiring uniqueness.", Default: false},
|
||||
},
|
||||
Required: []string{"path", "old_string", "new_string"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectWrite, "Edits files in place."),
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
}
|
||||
}
|
||||
|
||||
func (tool editFileTool) Run(_ context.Context, args map[string]any) Result {
|
||||
requestedPath, err := stringArg(args, "path", "", true)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
||||
}
|
||||
oldString, err := stringArg(args, "old_string", "", true)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
||||
}
|
||||
newString, err := stringArgWithEmpty(args, "new_string", "", true, true)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
||||
}
|
||||
replaceAll, err := boolArg(args, "replace_all", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
||||
}
|
||||
|
||||
absolutePath, relativePath, err := resolveWorkspacePath(tool.workspaceRoot, requestedPath)
|
||||
if err != nil {
|
||||
return errorResult("Error reading " + requestedPath + ": " + err.Error())
|
||||
}
|
||||
contentBytes, err := os.ReadFile(absolutePath)
|
||||
if err != nil {
|
||||
return errorResult("Error reading " + relativePath + ": " + err.Error())
|
||||
}
|
||||
content := string(contentBytes)
|
||||
occurrences := strings.Count(content, oldString)
|
||||
|
||||
if occurrences == 0 {
|
||||
return errorResult("Error: Could not find the exact string to replace in " + relativePath + ". The old_string must match the file byte-for-byte.")
|
||||
}
|
||||
if !replaceAll && occurrences > 1 {
|
||||
return errorResult(fmt.Sprintf("Error: old_string matches %d locations in %s. Either make old_string more specific, or pass replace_all: true to replace every occurrence.", occurrences, relativePath))
|
||||
}
|
||||
|
||||
updated := strings.Replace(content, oldString, newString, 1)
|
||||
replacedCount := 1
|
||||
if replaceAll {
|
||||
updated = strings.ReplaceAll(content, oldString, newString)
|
||||
replacedCount = occurrences
|
||||
}
|
||||
if updated == content {
|
||||
return okResult("No changes: new_string is identical to old_string.")
|
||||
}
|
||||
if err := recheckWorkspaceWriteTarget(tool.workspaceRoot, requestedPath); err != nil {
|
||||
return errorResult("Error writing " + relativePath + ": " + err.Error())
|
||||
}
|
||||
if err := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil {
|
||||
return errorResult("Error writing " + relativePath + ": " + err.Error())
|
||||
}
|
||||
|
||||
suffix := ""
|
||||
if replacedCount != 1 {
|
||||
suffix = "s"
|
||||
}
|
||||
return okResult(fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix))
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadFileToolReadsLineRanges(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta")
|
||||
|
||||
result := NewReadFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": "notes.txt",
|
||||
"start_line": 2,
|
||||
"max_lines": 2,
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"File: notes.txt (lines 2-3 of 4)",
|
||||
"2 | beta",
|
||||
"3 | gamma",
|
||||
} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
|
||||
}
|
||||
}
|
||||
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
|
||||
t.Fatalf("line range leaked outside requested slice: %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileToolRejectsOutsideWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "secret.txt")
|
||||
writeTestFile(t, outside, "secret")
|
||||
|
||||
result := NewReadFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": outside,
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "must stay inside the workspace") {
|
||||
t.Fatalf("expected workspace error, got %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirectoryToolListsRecursivelyAndIgnoresJunk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "src", "main.go"), "package main")
|
||||
writeTestFile(t, filepath.Join(root, "node_modules", "leftpad", "index.js"), "module.exports = 1")
|
||||
writeTestFile(t, filepath.Join(root, "README.md"), "# Zero")
|
||||
|
||||
result := NewListDirectoryTool(root).Run(context.Background(), map[string]any{
|
||||
"path": ".",
|
||||
"recursive": true,
|
||||
"max_depth": 2,
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "src/") || !strings.Contains(result.Output, "main.go") {
|
||||
t.Fatalf("expected recursive source listing, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "README.md") {
|
||||
t.Fatalf("expected README.md, got %q", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "node_modules") || strings.Contains(result.Output, "leftpad") {
|
||||
t.Fatalf("expected ignored junk directory, got %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobToolFindsMatchesWithLimit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "a.go"), "package zero")
|
||||
writeTestFile(t, filepath.Join(root, "nested", "b.go"), "package nested")
|
||||
writeTestFile(t, filepath.Join(root, "nested", "c.txt"), "text")
|
||||
|
||||
result := NewGlobTool(root).Run(context.Background(), map[string]any{
|
||||
"pattern": "**/*.go",
|
||||
"limit": 1,
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if result.Truncated != true {
|
||||
t.Fatalf("expected truncated result")
|
||||
}
|
||||
matchedPaths := regexp.MustCompile(`(?m)^[^\n]*\.go\b`).FindAllString(result.Output, -1)
|
||||
if got := len(matchedPaths); got != 1 {
|
||||
t.Fatalf("expected exactly one go match, got %d in %q", got, result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobToolCanIncludeDirectoryMatches(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result := NewGlobTool(root).Run(context.Background(), map[string]any{
|
||||
"pattern": "src",
|
||||
"include_dirs": true,
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if strings.TrimSpace(result.Output) != "src" {
|
||||
t.Fatalf("expected src directory match, got %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrepToolSearchesContent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "cmd", "main.go"), "package main\nfunc main() {}\n")
|
||||
writeTestFile(t, filepath.Join(root, "README.md"), "main docs\n")
|
||||
|
||||
result := NewGrepTool(root).Run(context.Background(), map[string]any{
|
||||
"pattern": "func main",
|
||||
"path": ".",
|
||||
"glob": "**/*.go",
|
||||
"head_limit": 5,
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "cmd/main.go:2: func main() {}") {
|
||||
t.Fatalf("expected formatted grep result, got %q", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "README.md") {
|
||||
t.Fatalf("glob filter leaked README match: %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrepToolSupportsFilesAndCountModes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "a.txt"), "needle\nneedle\n")
|
||||
writeTestFile(t, filepath.Join(root, "b.txt"), "needle\n")
|
||||
|
||||
files := NewGrepTool(root).Run(context.Background(), map[string]any{
|
||||
"pattern": "needle",
|
||||
"output_mode": "files_with_matches",
|
||||
})
|
||||
if files.Status != StatusOK {
|
||||
t.Fatalf("expected files result, got %s: %s", files.Status, files.Output)
|
||||
}
|
||||
if !strings.Contains(files.Output, "a.txt") || !strings.Contains(files.Output, "b.txt") {
|
||||
t.Fatalf("expected both files, got %q", files.Output)
|
||||
}
|
||||
|
||||
count := NewGrepTool(root).Run(context.Background(), map[string]any{
|
||||
"pattern": "needle",
|
||||
"output_mode": "count",
|
||||
})
|
||||
if count.Status != StatusOK {
|
||||
t.Fatalf("expected count result, got %s: %s", count.Status, count.Output)
|
||||
}
|
||||
if count.Output != "3 matches found" {
|
||||
t.Fatalf("expected count output, got %q", count.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type globTool struct {
|
||||
baseTool
|
||||
workspaceRoot string
|
||||
}
|
||||
|
||||
func NewGlobTool(workspaceRoot string) Tool {
|
||||
return globTool{
|
||||
baseTool: baseTool{
|
||||
name: "glob",
|
||||
description: "Find files by glob pattern inside the workspace.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"pattern": {Type: "string", Description: `Glob pattern to match, for example "**/*.go".`},
|
||||
"cwd": {Type: "string", Description: "Directory to scan. Defaults to workspace root.", Default: "."},
|
||||
"limit": {Type: "integer", Description: "Maximum matches to return.", Default: 100, Minimum: intPtr(1), Maximum: intPtr(1000)},
|
||||
"include_dirs": {Type: "boolean", Description: "Whether directory matches should be included.", Default: false},
|
||||
},
|
||||
Required: []string{"pattern"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Finds matching paths without reading contents or modifying files."),
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
}
|
||||
}
|
||||
|
||||
func (tool globTool) Run(_ context.Context, args map[string]any) Result {
|
||||
pattern, err := stringArg(args, "pattern", "", true)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for glob: " + err.Error())
|
||||
}
|
||||
cwd, err := stringArg(args, "cwd", ".", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for glob: " + err.Error())
|
||||
}
|
||||
limit, err := intArg(args, "limit", 100, 1, 1000)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for glob: " + err.Error())
|
||||
}
|
||||
includeDirs, err := boolArg(args, "include_dirs", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for glob: " + err.Error())
|
||||
}
|
||||
|
||||
root, _, err := resolveWorkspacePath(tool.workspaceRoot, cwd)
|
||||
if err != nil {
|
||||
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
|
||||
}
|
||||
matcher, err := compileGlob(pattern)
|
||||
if err != nil {
|
||||
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
|
||||
}
|
||||
|
||||
matches, err := scanGlob(root, matcher, includeDirs)
|
||||
if err != nil {
|
||||
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return okResult("No matches found for " + pattern)
|
||||
}
|
||||
|
||||
sort.Strings(matches)
|
||||
truncated := len(matches) > limit
|
||||
if truncated {
|
||||
matches = matches[:limit]
|
||||
}
|
||||
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
Output: strings.Join(matches, "\n"),
|
||||
Truncated: truncated,
|
||||
Meta: map[string]string{
|
||||
"pattern": pattern,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func scanGlob(root string, matcher *regexp.Regexp, includeDirs bool) ([]string, error) {
|
||||
matches := []string{}
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if path == root {
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() && shouldSkipDirectory(entry.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if entry.IsDir() && !includeDirs {
|
||||
return nil
|
||||
}
|
||||
|
||||
relative, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normalized := filepath.ToSlash(relative)
|
||||
if matcher.MatchString(normalized) {
|
||||
matches = append(matches, normalized)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return matches, err
|
||||
}
|
||||
|
||||
func compileGlob(pattern string) (*regexp.Regexp, error) {
|
||||
if pattern == "" {
|
||||
return nil, fmt.Errorf("pattern is required")
|
||||
}
|
||||
return regexp.Compile("^" + globToRegexp(filepath.ToSlash(pattern)) + "$")
|
||||
}
|
||||
|
||||
func globToRegexp(pattern string) string {
|
||||
var builder strings.Builder
|
||||
for index := 0; index < len(pattern); index++ {
|
||||
char := pattern[index]
|
||||
switch char {
|
||||
case '*':
|
||||
if index+1 < len(pattern) && pattern[index+1] == '*' {
|
||||
index++
|
||||
if index+1 < len(pattern) && pattern[index+1] == '/' {
|
||||
index++
|
||||
builder.WriteString("(?:.*/)?")
|
||||
} else {
|
||||
builder.WriteString(".*")
|
||||
}
|
||||
} else {
|
||||
builder.WriteString("[^/]*")
|
||||
}
|
||||
case '?':
|
||||
builder.WriteString("[^/]")
|
||||
default:
|
||||
builder.WriteString(regexp.QuoteMeta(string(char)))
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type grepTool struct {
|
||||
baseTool
|
||||
workspaceRoot string
|
||||
}
|
||||
|
||||
type grepMatch struct {
|
||||
file string
|
||||
line int
|
||||
text string
|
||||
hits int
|
||||
}
|
||||
|
||||
func NewGrepTool(workspaceRoot string) Tool {
|
||||
return grepTool{
|
||||
baseTool: baseTool{
|
||||
name: "grep",
|
||||
description: "Search file contents with a regular expression inside the workspace.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"pattern": {Type: "string", Description: "Regular expression pattern to search for."},
|
||||
"path": {Type: "string", Description: "Directory or file to search. Defaults to workspace root.", Default: "."},
|
||||
"glob": {Type: "string", Description: `Optional glob filter, for example "**/*.go".`},
|
||||
"output_mode": {Type: "string", Description: "Output mode.", Enum: []string{"content", "files_with_matches", "count"}, Default: "content"},
|
||||
"-i": {Type: "boolean", Description: "Case insensitive search.", Default: false},
|
||||
"case_insensitive": {Type: "boolean", Description: "Case insensitive search.", Default: false},
|
||||
"head_limit": {Type: "integer", Description: "Maximum content results to return.", Default: 50, Minimum: intPtr(1)},
|
||||
},
|
||||
Required: []string{"pattern"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Searches file paths and matching lines without modifying files."),
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
}
|
||||
}
|
||||
|
||||
func (tool grepTool) Run(_ context.Context, args map[string]any) Result {
|
||||
pattern, err := stringArg(args, "pattern", "", true)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
||||
}
|
||||
targetPath, err := stringArg(args, "path", ".", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
||||
}
|
||||
globPattern, err := stringArg(args, "glob", "", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
||||
}
|
||||
outputMode, err := stringArg(args, "output_mode", "content", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
||||
}
|
||||
if outputMode != "content" && outputMode != "files_with_matches" && outputMode != "count" {
|
||||
return errorResult("Error: Invalid arguments for grep: output_mode must be content, files_with_matches, or count")
|
||||
}
|
||||
caseInsensitive, err := boolArg(args, "case_insensitive", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
||||
}
|
||||
shortInsensitive, err := boolArg(args, "-i", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
||||
}
|
||||
headLimit, err := intArg(args, "head_limit", 50, 1, 0)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
||||
}
|
||||
|
||||
if caseInsensitive || shortInsensitive {
|
||||
pattern = "(?i)" + pattern
|
||||
}
|
||||
compiled, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return errorResult("Error running grep: " + err.Error())
|
||||
}
|
||||
|
||||
target, _, err := resolveWorkspacePath(tool.workspaceRoot, targetPath)
|
||||
if err != nil {
|
||||
return errorResult("Error running grep: " + err.Error())
|
||||
}
|
||||
|
||||
var globMatcher *regexp.Regexp
|
||||
if globPattern != "" {
|
||||
globMatcher, err = compileGlob(globPattern)
|
||||
if err != nil {
|
||||
return errorResult("Error running grep: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
files, err := grepFiles(tool.workspaceRoot, target, globMatcher)
|
||||
if err != nil {
|
||||
return errorResult("Error running grep: " + err.Error())
|
||||
}
|
||||
|
||||
matches := collectGrepMatches(tool.workspaceRoot, files, compiled)
|
||||
if len(matches) == 0 {
|
||||
if outputMode == "count" {
|
||||
return okResult("0 matches found")
|
||||
}
|
||||
return okResult("No matches found.")
|
||||
}
|
||||
|
||||
switch outputMode {
|
||||
case "count":
|
||||
total := 0
|
||||
for _, match := range matches {
|
||||
total += match.hits
|
||||
}
|
||||
return okResult(fmt.Sprintf("%d matches found", total))
|
||||
case "files_with_matches":
|
||||
seen := map[string]bool{}
|
||||
files := []string{}
|
||||
for _, match := range matches {
|
||||
if !seen[match.file] {
|
||||
seen[match.file] = true
|
||||
files = append(files, match.file)
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
return okResult(strings.Join(files, "\n"))
|
||||
default:
|
||||
lines := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
if len(lines) >= headLimit {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s:%d: %s", match.file, match.line, match.text))
|
||||
}
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
Output: strings.Join(lines, "\n"),
|
||||
Truncated: len(matches) > headLimit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func grepFiles(workspaceRoot string, target string, globMatcher *regexp.Regexp) ([]string, error) {
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
relative, err := filepath.Rel(workspaceRoot, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if globMatcher == nil || globMatcher.MatchString(filepath.ToSlash(relative)) {
|
||||
return []string{target}, nil
|
||||
}
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
files := []string{}
|
||||
err = filepath.WalkDir(target, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if path == target {
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() && shouldSkipDirectory(entry.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
relative, err := filepath.Rel(workspaceRoot, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if globMatcher == nil || globMatcher.MatchString(filepath.ToSlash(relative)) {
|
||||
files = append(files, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func collectGrepMatches(workspaceRoot string, files []string, compiled *regexp.Regexp) []grepMatch {
|
||||
matches := []grepMatch{}
|
||||
for _, file := range files {
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
relative, err := filepath.Rel(workspaceRoot, file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for index, line := range strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n") {
|
||||
lineMatches := compiled.FindAllStringIndex(line, -1)
|
||||
if len(lineMatches) == 0 {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, grepMatch{
|
||||
file: filepath.ToSlash(relative),
|
||||
line: index + 1,
|
||||
text: strings.TrimRight(line, "\r"),
|
||||
hits: len(lineMatches),
|
||||
})
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type listDirectoryTool struct {
|
||||
baseTool
|
||||
workspaceRoot string
|
||||
}
|
||||
|
||||
func NewListDirectoryTool(workspaceRoot string) Tool {
|
||||
return listDirectoryTool{
|
||||
baseTool: baseTool{
|
||||
name: "list_directory",
|
||||
description: "List files and directories in a workspace path with optional recursion.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"path": {Type: "string", Description: "Directory to list. Defaults to workspace root.", Default: "."},
|
||||
"recursive": {Type: "boolean", Description: "Whether to list recursively.", Default: false},
|
||||
"max_depth": {Type: "integer", Description: "Maximum recursion depth when recursive is true.", Default: 2, Minimum: intPtr(1), Maximum: intPtr(5)},
|
||||
},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Lists directory entries without modifying files."),
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
}
|
||||
}
|
||||
|
||||
func (tool listDirectoryTool) Run(_ context.Context, args map[string]any) Result {
|
||||
requestedPath, err := stringArg(args, "path", ".", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for list_directory: " + err.Error())
|
||||
}
|
||||
recursive, err := boolArg(args, "recursive", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for list_directory: " + err.Error())
|
||||
}
|
||||
maxDepth, err := intArg(args, "max_depth", 2, 1, 5)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for list_directory: " + err.Error())
|
||||
}
|
||||
if !recursive {
|
||||
maxDepth = 0
|
||||
}
|
||||
|
||||
absolutePath, relativePath, err := resolveWorkspacePath(tool.workspaceRoot, requestedPath)
|
||||
if err != nil {
|
||||
return errorResult("Error listing directory " + requestedPath + ": " + err.Error())
|
||||
}
|
||||
|
||||
entries, err := listDirectoryEntries(absolutePath, 0, maxDepth)
|
||||
if err != nil {
|
||||
return errorResult("Error listing directory " + relativePath + ": " + err.Error())
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return okResult("Directory is empty: " + relativePath)
|
||||
}
|
||||
return okResult("Contents of " + relativePath + ":\n\n" + strings.Join(entries, "\n"))
|
||||
}
|
||||
|
||||
func listDirectoryEntries(path string, depth int, maxDepth int) ([]string, error) {
|
||||
dirEntries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(dirEntries, func(left int, right int) bool {
|
||||
if dirEntries[left].IsDir() != dirEntries[right].IsDir() {
|
||||
return dirEntries[left].IsDir()
|
||||
}
|
||||
return dirEntries[left].Name() < dirEntries[right].Name()
|
||||
})
|
||||
|
||||
results := make([]string, 0, len(dirEntries))
|
||||
for _, entry := range dirEntries {
|
||||
if entry.IsDir() && shouldSkipDirectory(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
|
||||
indent := strings.Repeat(" ", depth)
|
||||
if entry.IsDir() {
|
||||
results = append(results, indent+entry.Name()+"/")
|
||||
if depth < maxDepth {
|
||||
children, err := listDirectoryEntries(filepath.Join(path, entry.Name()), depth+1, maxDepth)
|
||||
if err == nil {
|
||||
results = append(results, children...)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, fmt.Sprintf("%s%s", indent, entry.Name()))
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdatePlanToolStoresAndFormatsPlan(t *testing.T) {
|
||||
tool := NewUpdatePlanTool()
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{
|
||||
"plan": []any{
|
||||
map[string]any{"id": "1", "content": "First step", "status": "completed"},
|
||||
map[string]any{"id": "2", "content": "Second step", "status": "in_progress", "notes": "halfway"},
|
||||
map[string]any{"id": "3", "content": "Third step", "status": "pending"},
|
||||
},
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Current Plan:",
|
||||
"1. [completed] First step",
|
||||
"2. [in_progress] Second step",
|
||||
"Notes: halfway",
|
||||
"3. [pending] Third step",
|
||||
} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
plan := tool.CurrentPlan()
|
||||
if len(plan) != 3 {
|
||||
t.Fatalf("expected 3 plan items, got %d", len(plan))
|
||||
}
|
||||
plan[0].Content = "mutated"
|
||||
if tool.CurrentPlan()[0].Content != "First step" {
|
||||
t.Fatalf("CurrentPlan returned mutable internal state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePlanToolRejectsInvalidStatus(t *testing.T) {
|
||||
result := NewUpdatePlanTool().Run(context.Background(), map[string]any{
|
||||
"plan": []any{
|
||||
map[string]any{"id": "1", "content": "Bad step", "status": "nope"},
|
||||
},
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "status must be pending, in_progress, completed, or failed") {
|
||||
t.Fatalf("unexpected output: %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePlanToolClearPlanResetsState(t *testing.T) {
|
||||
tool := NewUpdatePlanTool()
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{
|
||||
"plan": []any{
|
||||
map[string]any{"id": "1", "content": "First", "status": "pending"},
|
||||
map[string]any{"id": "2", "content": "Second", "status": "in_progress"},
|
||||
},
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if got := tool.CurrentPlan(); len(got) == 0 {
|
||||
t.Fatalf("expected stored plan before ClearPlan")
|
||||
}
|
||||
|
||||
tool.ClearPlan()
|
||||
if got := tool.CurrentPlan(); len(got) != 0 {
|
||||
t.Fatalf("expected empty plan after ClearPlan, got %d items", len(got))
|
||||
}
|
||||
if got := formatPlan(tool.CurrentPlan()); got != "Plan is currently empty." {
|
||||
t.Fatalf("expected empty plan formatting after ClearPlan, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type readFileTool struct {
|
||||
baseTool
|
||||
workspaceRoot string
|
||||
}
|
||||
|
||||
func NewReadFileTool(workspaceRoot string) Tool {
|
||||
return readFileTool{
|
||||
baseTool: baseTool{
|
||||
name: "read_file",
|
||||
description: "Read a file with optional 1-based inclusive line range and max line cap.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"path": {Type: "string", Description: "Path of the file to read."},
|
||||
"start_line": {Type: "integer", Description: "1-based inclusive line number to start reading from.", Minimum: intPtr(1)},
|
||||
"end_line": {Type: "integer", Description: "1-based inclusive line number to stop reading at.", Minimum: intPtr(1)},
|
||||
"max_lines": {Type: "integer", Description: "Maximum number of lines to return.", Minimum: intPtr(1)},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Reads file contents without modifying files."),
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
}
|
||||
}
|
||||
|
||||
func (tool readFileTool) Run(_ context.Context, args map[string]any) Result {
|
||||
requestedPath, err := stringArg(args, "path", "", true)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
|
||||
}
|
||||
startLine, err := intArg(args, "start_line", 1, 1, 0)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
|
||||
}
|
||||
endLine, err := intArg(args, "end_line", 0, 1, 0)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
|
||||
}
|
||||
maxLines, err := intArg(args, "max_lines", 0, 1, 0)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
|
||||
}
|
||||
|
||||
absolutePath, relativePath, err := resolveWorkspacePath(tool.workspaceRoot, requestedPath)
|
||||
if err != nil {
|
||||
return errorResult("Error reading file " + requestedPath + ": " + err.Error())
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(absolutePath)
|
||||
if err != nil {
|
||||
return errorResult("Error reading file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
|
||||
normalizedContent := strings.ReplaceAll(string(content), "\r\n", "\n")
|
||||
normalizedContent = strings.TrimSuffix(normalizedContent, "\n")
|
||||
lines := strings.Split(normalizedContent, "\n")
|
||||
total := len(lines)
|
||||
if startLine > total {
|
||||
return okResult(fmt.Sprintf("File: %s\n(start_line %d is past the end of the file, which has %d lines)", relativePath, startLine, total))
|
||||
}
|
||||
|
||||
if endLine == 0 || endLine > total {
|
||||
endLine = total
|
||||
}
|
||||
if endLine < startLine {
|
||||
return errorResult("Error: Invalid arguments for read_file: end_line must be greater than or equal to start_line")
|
||||
}
|
||||
|
||||
selected := lines[startLine-1 : endLine]
|
||||
truncated := false
|
||||
if maxLines > 0 && len(selected) > maxLines {
|
||||
selected = selected[:maxLines]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
lastLine := startLine + len(selected) - 1
|
||||
width := len(strconv.Itoa(lastLine))
|
||||
numbered := make([]string, 0, len(selected))
|
||||
for index, line := range selected {
|
||||
lineNumber := strconv.Itoa(startLine + index)
|
||||
numbered = append(numbered, strings.Repeat(" ", width-len(lineNumber))+lineNumber+" | "+line)
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("File: %s (%d lines)", relativePath, total)
|
||||
if startLine != 1 || endLine != total || maxLines > 0 {
|
||||
header = fmt.Sprintf("File: %s (lines %d-%d of %d)", relativePath, startLine, lastLine, total)
|
||||
}
|
||||
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
Output: header + "\n\n" + strings.Join(numbered, "\n"),
|
||||
Truncated: truncated,
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package tools
|
||||
|
||||
import "context"
|
||||
|
||||
type Registry struct {
|
||||
tools map[string]Tool
|
||||
}
|
||||
|
||||
type RunOptions struct {
|
||||
PermissionGranted bool
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{tools: make(map[string]Tool)}
|
||||
}
|
||||
|
||||
func (registry *Registry) Register(tool Tool) {
|
||||
registry.tools[tool.Name()] = tool
|
||||
}
|
||||
|
||||
func (registry *Registry) Get(name string) (Tool, bool) {
|
||||
tool, ok := registry.tools[name]
|
||||
return tool, ok
|
||||
}
|
||||
|
||||
func (registry *Registry) All() []Tool {
|
||||
tools := make([]Tool, 0, len(registry.tools))
|
||||
for _, tool := range registry.tools {
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func (registry *Registry) Run(ctx context.Context, name string, args map[string]any) Result {
|
||||
return registry.RunWithOptions(ctx, name, args, RunOptions{})
|
||||
}
|
||||
|
||||
func (registry *Registry) RunWithOptions(ctx context.Context, name string, args map[string]any, options RunOptions) Result {
|
||||
tool, ok := registry.Get(name)
|
||||
if !ok {
|
||||
return errorResult(`Error: Unknown tool "` + name + `".`)
|
||||
}
|
||||
|
||||
switch tool.Safety().Permission {
|
||||
case PermissionAllow:
|
||||
case PermissionPrompt:
|
||||
if !options.PermissionGranted {
|
||||
return errorResult("Error: Permission required for " + name + ": " + tool.Safety().Reason + ` The tool is marked "prompt" and was not executed.`)
|
||||
}
|
||||
default:
|
||||
return errorResult("Error: Permission denied for " + name + ": " + tool.Safety().Reason)
|
||||
}
|
||||
|
||||
return tool.Run(ctx, args)
|
||||
}
|
||||
|
||||
func CoreReadOnlyTools(workspaceRoot string) []Tool {
|
||||
return []Tool{
|
||||
NewReadFileTool(workspaceRoot),
|
||||
NewListDirectoryTool(workspaceRoot),
|
||||
NewGlobTool(workspaceRoot),
|
||||
NewGrepTool(workspaceRoot),
|
||||
}
|
||||
}
|
||||
|
||||
func CoreWriteTools(workspaceRoot string) []Tool {
|
||||
return []Tool{
|
||||
NewWriteFileTool(workspaceRoot),
|
||||
NewEditFileTool(workspaceRoot),
|
||||
NewApplyPatchTool(workspaceRoot),
|
||||
NewUpdatePlanTool(),
|
||||
}
|
||||
}
|
||||
|
||||
func CoreShellTools(workspaceRoot string) []Tool {
|
||||
return []Tool{
|
||||
NewBashTool(workspaceRoot),
|
||||
}
|
||||
}
|
||||
|
||||
func CoreTools(workspaceRoot string) []Tool {
|
||||
tools := append([]Tool{}, CoreReadOnlyTools(workspaceRoot)...)
|
||||
tools = append(tools, CoreWriteTools(workspaceRoot)...)
|
||||
tools = append(tools, CoreShellTools(workspaceRoot)...)
|
||||
return tools
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCoreReadOnlyToolsExposeSafeMetadata(t *testing.T) {
|
||||
toolset := CoreReadOnlyTools(t.TempDir())
|
||||
if len(toolset) != 4 {
|
||||
t.Fatalf("expected 4 core read-only tools, got %d", len(toolset))
|
||||
}
|
||||
|
||||
for _, tool := range toolset {
|
||||
if tool.Name() == "" {
|
||||
t.Fatalf("tool has empty name")
|
||||
}
|
||||
if tool.Description() == "" {
|
||||
t.Fatalf("%s has empty description", tool.Name())
|
||||
}
|
||||
if tool.Safety().SideEffect != SideEffectRead {
|
||||
t.Fatalf("%s side effect = %s, want read", tool.Name(), tool.Safety().SideEffect)
|
||||
}
|
||||
if tool.Safety().Permission != PermissionAllow {
|
||||
t.Fatalf("%s permission = %s, want allow", tool.Name(), tool.Safety().Permission)
|
||||
}
|
||||
if tool.Safety().Reason == "" {
|
||||
t.Fatalf("%s has empty safety reason", tool.Name())
|
||||
}
|
||||
|
||||
schema := tool.Parameters()
|
||||
if schema.Type != "object" {
|
||||
t.Fatalf("%s schema type = %s, want object", tool.Name(), schema.Type)
|
||||
}
|
||||
if schema.Properties == nil {
|
||||
t.Fatalf("%s schema properties are nil", tool.Name())
|
||||
}
|
||||
if schema.AdditionalProperties {
|
||||
t.Fatalf("%s schema should disallow additional properties", tool.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRunsToolsThroughSafePath(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewReadFileTool(t.TempDir()))
|
||||
|
||||
result := registry.Run(context.Background(), "read_file", map[string]any{
|
||||
"path": "missing.txt",
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected read error status, got %s", result.Status)
|
||||
}
|
||||
if result.Output == "" {
|
||||
t.Fatalf("expected an error output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryReportsUnknownTools(t *testing.T) {
|
||||
result := NewRegistry().Run(context.Background(), "missing", map[string]any{})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
if result.Output != `Error: Unknown tool "missing".` {
|
||||
t.Fatalf("unexpected output: %q", result.Output)
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package tools
|
||||
|
||||
import "context"
|
||||
|
||||
type SideEffect string
|
||||
type Permission string
|
||||
type Status string
|
||||
|
||||
const (
|
||||
SideEffectRead SideEffect = "read"
|
||||
SideEffectWrite SideEffect = "write"
|
||||
SideEffectShell SideEffect = "shell"
|
||||
SideEffectNetwork SideEffect = "network"
|
||||
SideEffectOutOfWorkspace SideEffect = "out_of_workspace"
|
||||
)
|
||||
|
||||
const (
|
||||
PermissionAllow Permission = "allow"
|
||||
PermissionPrompt Permission = "prompt"
|
||||
PermissionDeny Permission = "deny"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusOK Status = "ok"
|
||||
StatusError Status = "error"
|
||||
)
|
||||
|
||||
type Safety struct {
|
||||
SideEffect SideEffect
|
||||
Permission Permission
|
||||
Reason string
|
||||
}
|
||||
|
||||
type Schema struct {
|
||||
Type string `json:"type"`
|
||||
Properties map[string]PropertySchema `json:"properties,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
AdditionalProperties bool `json:"additionalProperties"`
|
||||
}
|
||||
|
||||
type PropertySchema struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Minimum *int `json:"minimum,omitempty"`
|
||||
Maximum *int `json:"maximum,omitempty"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Status Status
|
||||
Output string
|
||||
Truncated bool
|
||||
Meta map[string]string
|
||||
}
|
||||
|
||||
type Tool interface {
|
||||
Name() string
|
||||
Description() string
|
||||
Parameters() Schema
|
||||
Safety() Safety
|
||||
Run(ctx context.Context, args map[string]any) Result
|
||||
}
|
||||
|
||||
type baseTool struct {
|
||||
name string
|
||||
description string
|
||||
parameters Schema
|
||||
safety Safety
|
||||
}
|
||||
|
||||
func (tool baseTool) Name() string {
|
||||
return tool.name
|
||||
}
|
||||
|
||||
func (tool baseTool) Description() string {
|
||||
return tool.description
|
||||
}
|
||||
|
||||
func (tool baseTool) Parameters() Schema {
|
||||
return tool.parameters
|
||||
}
|
||||
|
||||
func (tool baseTool) Safety() Safety {
|
||||
return tool.safety
|
||||
}
|
||||
|
||||
func okResult(output string) Result {
|
||||
return Result{Status: StatusOK, Output: output}
|
||||
}
|
||||
|
||||
func errorResult(output string) Result {
|
||||
return Result{Status: StatusError, Output: output}
|
||||
}
|
||||
|
||||
func readOnlySafety(reason string) Safety {
|
||||
return Safety{
|
||||
SideEffect: SideEffectRead,
|
||||
Permission: PermissionAllow,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func promptSafety(sideEffect SideEffect, reason string) Safety {
|
||||
return Safety{
|
||||
SideEffect: sideEffect,
|
||||
Permission: PermissionPrompt,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type PlanItem struct {
|
||||
ID string
|
||||
Content string
|
||||
Status string
|
||||
Notes string
|
||||
}
|
||||
|
||||
type updatePlanTool struct {
|
||||
baseTool
|
||||
currentPlan []PlanItem
|
||||
}
|
||||
|
||||
func NewUpdatePlanTool() *updatePlanTool {
|
||||
return &updatePlanTool{
|
||||
baseTool: baseTool{
|
||||
name: "update_plan",
|
||||
description: "Create or update the in-memory plan for the current task.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"plan": {Type: "array", Description: "Ordered list of plan items."},
|
||||
},
|
||||
Required: []string{"plan"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Updates in-memory planning state only."),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (tool *updatePlanTool) Run(_ context.Context, args map[string]any) Result {
|
||||
plan, err := parsePlanItems(args["plan"])
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for update_plan: " + err.Error())
|
||||
}
|
||||
tool.currentPlan = plan
|
||||
return okResult(formatPlan(plan))
|
||||
}
|
||||
|
||||
func (tool *updatePlanTool) CurrentPlan() []PlanItem {
|
||||
return append([]PlanItem{}, tool.currentPlan...)
|
||||
}
|
||||
|
||||
func (tool *updatePlanTool) ClearPlan() {
|
||||
tool.currentPlan = nil
|
||||
}
|
||||
|
||||
func parsePlanItems(value any) ([]PlanItem, error) {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plan must be an array")
|
||||
}
|
||||
|
||||
plan := make([]PlanItem, 0, len(items))
|
||||
for index, item := range items {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plan item %d must be an object", index+1)
|
||||
}
|
||||
|
||||
id, err := stringArg(object, "id", "", true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plan item %d %s", index+1, err.Error())
|
||||
}
|
||||
content, err := stringArg(object, "content", "", true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plan item %d %s", index+1, err.Error())
|
||||
}
|
||||
status, err := stringArg(object, "status", "", true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plan item %d %s", index+1, err.Error())
|
||||
}
|
||||
if !isPlanStatus(status) {
|
||||
return nil, fmt.Errorf("plan item %d status must be pending, in_progress, completed, or failed", index+1)
|
||||
}
|
||||
notes, err := stringArgWithEmpty(object, "notes", "", false, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plan item %d %s", index+1, err.Error())
|
||||
}
|
||||
|
||||
plan = append(plan, PlanItem{
|
||||
ID: id,
|
||||
Content: content,
|
||||
Status: status,
|
||||
Notes: notes,
|
||||
})
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func isPlanStatus(status string) bool {
|
||||
return status == "pending" || status == "in_progress" || status == "completed" || status == "failed"
|
||||
}
|
||||
|
||||
func formatPlan(plan []PlanItem) string {
|
||||
if len(plan) == 0 {
|
||||
return "Plan is currently empty."
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(plan))
|
||||
for index, item := range plan {
|
||||
line := fmt.Sprintf("%d. [%s] %s", index+1, item.Status, item.Content)
|
||||
if item.Notes != "" {
|
||||
line += "\n Notes: " + item.Notes
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return "Current Plan:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ignoredDirectories = map[string]bool{
|
||||
".git": true,
|
||||
"node_modules": true,
|
||||
"dist": true,
|
||||
"build": true,
|
||||
".next": true,
|
||||
".turbo": true,
|
||||
"coverage": true,
|
||||
".cache": true,
|
||||
"tmp": true,
|
||||
"temp": true,
|
||||
}
|
||||
|
||||
func normalizeWorkspaceRoot(workspaceRoot string) string {
|
||||
root, err := filepath.Abs(workspaceRoot)
|
||||
if err != nil {
|
||||
return workspaceRoot
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func resolveWorkspacePath(workspaceRoot string, requestedPath string) (string, string, error) {
|
||||
if requestedPath == "" {
|
||||
requestedPath = "."
|
||||
}
|
||||
|
||||
root, err := filepath.Abs(workspaceRoot)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
root, err = filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
target := requestedPath
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(root, target)
|
||||
}
|
||||
|
||||
target, err = filepath.Abs(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
target, err = filepath.EvalSymlinks(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
relative, err := filepath.Rel(root, target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
||||
return "", "", fmt.Errorf("%s must stay inside the workspace", requestedPath)
|
||||
}
|
||||
if relative == "." {
|
||||
return target, ".", nil
|
||||
}
|
||||
return target, filepath.ToSlash(relative), nil
|
||||
}
|
||||
|
||||
func resolveWorkspaceTargetPath(workspaceRoot string, requestedPath string) (string, string, error) {
|
||||
if requestedPath == "" {
|
||||
requestedPath = "."
|
||||
}
|
||||
|
||||
root, err := filepath.Abs(workspaceRoot)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
root, err = filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
target := requestedPath
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(root, target)
|
||||
}
|
||||
target, err = filepath.Abs(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := recheckWorkspaceWriteTarget(root, target); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
existing := target
|
||||
missingSegments := []string{}
|
||||
for {
|
||||
if _, err := os.Lstat(existing); err == nil {
|
||||
break
|
||||
} else if os.IsNotExist(err) {
|
||||
parent := filepath.Dir(existing)
|
||||
if parent == existing {
|
||||
return "", "", err
|
||||
}
|
||||
missingSegments = append([]string{filepath.Base(existing)}, missingSegments...)
|
||||
existing = parent
|
||||
continue
|
||||
} else {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
resolved, err := filepath.EvalSymlinks(existing)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, segment := range missingSegments {
|
||||
resolved = filepath.Join(resolved, segment)
|
||||
}
|
||||
|
||||
relative, err := filepath.Rel(root, resolved)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
||||
return "", "", fmt.Errorf("%s must stay inside the workspace", requestedPath)
|
||||
}
|
||||
if relative == "." {
|
||||
return resolved, ".", nil
|
||||
}
|
||||
return resolved, filepath.ToSlash(relative), nil
|
||||
}
|
||||
|
||||
func recheckWorkspaceWriteTarget(workspaceRoot string, requestedPath string) error {
|
||||
if requestedPath == "" {
|
||||
requestedPath = "."
|
||||
}
|
||||
|
||||
root, err := filepath.Abs(workspaceRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err = filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target := requestedPath
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(root, target)
|
||||
}
|
||||
target, err = filepath.Abs(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relative, err := filepath.Rel(root, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
||||
return fmt.Errorf("%s must stay inside the workspace", requestedPath)
|
||||
}
|
||||
if relative == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
current := root
|
||||
for _, segment := range strings.Split(filepath.Clean(relative), string(filepath.Separator)) {
|
||||
if segment == "." || segment == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
current = filepath.Join(current, segment)
|
||||
info, err := os.Lstat(current)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
symlinkRelative, err := filepath.Rel(root, current)
|
||||
if err != nil {
|
||||
symlinkRelative = current
|
||||
}
|
||||
return fmt.Errorf("%s must not traverse symlink %s", requestedPath, filepath.ToSlash(symlinkRelative))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldSkipDirectory(name string) bool {
|
||||
return ignoredDirectories[name]
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type writeFileTool struct {
|
||||
baseTool
|
||||
workspaceRoot string
|
||||
}
|
||||
|
||||
func NewWriteFileTool(workspaceRoot string) Tool {
|
||||
return writeFileTool{
|
||||
baseTool: baseTool{
|
||||
name: "write_file",
|
||||
description: "Create a new file, refusing to overwrite existing files unless overwrite is true.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"path": {Type: "string", Description: "Absolute or relative path of the file to write."},
|
||||
"content": {Type: "string", Description: "Full file contents to write."},
|
||||
"overwrite": {Type: "boolean", Description: "Whether to allow overwriting an existing file.", Default: false},
|
||||
},
|
||||
Required: []string{"path", "content"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectWrite, "Creates or overwrites files."),
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
}
|
||||
}
|
||||
|
||||
func (tool writeFileTool) Run(_ context.Context, args map[string]any) Result {
|
||||
requestedPath, err := stringArg(args, "path", "", true)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
|
||||
}
|
||||
content, err := stringArgWithEmpty(args, "content", "", true, true)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
|
||||
}
|
||||
overwrite, err := boolArg(args, "overwrite", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
|
||||
}
|
||||
|
||||
absolutePath, relativePath, err := resolveWorkspaceTargetPath(tool.workspaceRoot, requestedPath)
|
||||
if err != nil {
|
||||
return errorResult("Error writing file " + requestedPath + ": " + err.Error())
|
||||
}
|
||||
|
||||
existed := false
|
||||
if _, err := os.Stat(absolutePath); err == nil {
|
||||
existed = true
|
||||
if !overwrite {
|
||||
return errorResult("Error: " + relativePath + " already exists. Pass overwrite: true to replace it.")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
if err := recheckWorkspaceWriteTarget(tool.workspaceRoot, requestedPath); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
|
||||
if existed {
|
||||
return okResult(fmt.Sprintf("Overwrote %s (%d bytes).", relativePath, len([]byte(content))))
|
||||
}
|
||||
return okResult(fmt.Sprintf("Created %s (%d bytes).", relativePath, len([]byte(content))))
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCoreToolsExposeWriteAndPlanTools(t *testing.T) {
|
||||
toolset := CoreTools(t.TempDir())
|
||||
byName := make(map[string]Tool, len(toolset))
|
||||
for _, tool := range toolset {
|
||||
byName[tool.Name()] = tool
|
||||
}
|
||||
|
||||
for _, name := range []string{"write_file", "edit_file", "apply_patch"} {
|
||||
tool, ok := byName[name]
|
||||
if !ok {
|
||||
t.Fatalf("expected core tools to include %s", name)
|
||||
}
|
||||
if tool.Safety().SideEffect != SideEffectWrite {
|
||||
t.Fatalf("%s side effect = %s, want write", name, tool.Safety().SideEffect)
|
||||
}
|
||||
if tool.Safety().Permission != PermissionPrompt {
|
||||
t.Fatalf("%s permission = %s, want prompt", name, tool.Safety().Permission)
|
||||
}
|
||||
}
|
||||
|
||||
planTool, ok := byName["update_plan"]
|
||||
if !ok {
|
||||
t.Fatalf("expected core tools to include update_plan")
|
||||
}
|
||||
if planTool.Safety().Permission != PermissionAllow {
|
||||
t.Fatalf("update_plan permission = %s, want allow", planTool.Safety().Permission)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryBlocksPromptToolsWithoutGrant(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "blocked.txt")
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewWriteFileTool(root))
|
||||
|
||||
result := registry.Run(context.Background(), "write_file", map[string]any{
|
||||
"path": "blocked.txt",
|
||||
"content": "nope",
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "Permission required for write_file") {
|
||||
t.Fatalf("expected permission error, got %q", result.Output)
|
||||
}
|
||||
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected file to remain absent, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRunsPromptToolsWithGrant(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewWriteFileTool(root))
|
||||
|
||||
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
||||
"path": "allowed.txt",
|
||||
"content": "hello",
|
||||
}, RunOptions{PermissionGranted: true})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "allowed.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "hello" {
|
||||
t.Fatalf("expected written content, got %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileToolCreatesAndProtectsExistingFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tool := NewWriteFileTool(root)
|
||||
|
||||
created := tool.Run(context.Background(), map[string]any{
|
||||
"path": "nested/file.txt",
|
||||
"content": "first",
|
||||
})
|
||||
if created.Status != StatusOK {
|
||||
t.Fatalf("expected create ok, got %s: %s", created.Status, created.Output)
|
||||
}
|
||||
if !strings.Contains(created.Output, "Created nested/file.txt") {
|
||||
t.Fatalf("unexpected create output: %q", created.Output)
|
||||
}
|
||||
|
||||
refused := tool.Run(context.Background(), map[string]any{
|
||||
"path": "nested/file.txt",
|
||||
"content": "second",
|
||||
})
|
||||
if refused.Status != StatusError {
|
||||
t.Fatalf("expected overwrite refusal, got %s", refused.Status)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "nested", "file.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "first" {
|
||||
t.Fatalf("expected original content, got %q", string(content))
|
||||
}
|
||||
|
||||
overwrote := tool.Run(context.Background(), map[string]any{
|
||||
"path": "nested/file.txt",
|
||||
"content": "second",
|
||||
"overwrite": true,
|
||||
})
|
||||
if overwrote.Status != StatusOK {
|
||||
t.Fatalf("expected overwrite ok, got %s: %s", overwrote.Status, overwrote.Output)
|
||||
}
|
||||
content, err = os.ReadFile(filepath.Join(root, "nested", "file.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "second" {
|
||||
t.Fatalf("expected overwritten content, got %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileToolAllowsEmptyContent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
result := NewWriteFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": "empty.txt",
|
||||
"content": "",
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "empty.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "" {
|
||||
t.Fatalf("expected empty file, got %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileToolReportsTypeErrorsForEmptyAllowedStrings(t *testing.T) {
|
||||
result := NewWriteFileTool(t.TempDir()).Run(context.Background(), map[string]any{
|
||||
"path": "bad.txt",
|
||||
"content": 42,
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "content must be a string") {
|
||||
t.Fatalf("expected string type error, got %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileToolRejectsOutsideWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "outside.txt")
|
||||
|
||||
result := NewWriteFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": outside,
|
||||
"content": "secret",
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "must stay inside the workspace") {
|
||||
t.Fatalf("expected workspace error, got %q", result.Output)
|
||||
}
|
||||
if _, err := os.Stat(outside); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected outside file to remain absent, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileToolRejectsSymlinkParent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
realDirectory := filepath.Join(root, "real")
|
||||
if err := os.MkdirAll(realDirectory, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(realDirectory, filepath.Join(root, "link")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
|
||||
result := NewWriteFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": "link/escape.txt",
|
||||
"content": "secret",
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "must not traverse symlink") {
|
||||
t.Fatalf("expected symlink error, got %q", result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(realDirectory, "escape.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected symlink target file to remain absent, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFileToolReplacesExactStrings(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "code.go")
|
||||
writeTestFile(t, path, "const a = 1\nconst b = 2\n")
|
||||
|
||||
result := NewEditFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": "code.go",
|
||||
"old_string": "const a = 1",
|
||||
"new_string": "const a = 42",
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "const a = 42\nconst b = 2\n" {
|
||||
t.Fatalf("unexpected edited content: %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFileToolAllowsDeletingRegions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "notes.txt")
|
||||
writeTestFile(t, path, "keep\nremove\nkeep\n")
|
||||
|
||||
result := NewEditFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": "notes.txt",
|
||||
"old_string": "remove\n",
|
||||
"new_string": "",
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "keep\nkeep\n" {
|
||||
t.Fatalf("unexpected edited content: %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFileToolRejectsMissingAndAmbiguousMatches(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "dup.txt")
|
||||
writeTestFile(t, path, "x\nx\n")
|
||||
tool := NewEditFileTool(root)
|
||||
|
||||
missing := tool.Run(context.Background(), map[string]any{
|
||||
"path": "dup.txt",
|
||||
"old_string": "missing",
|
||||
"new_string": "y",
|
||||
})
|
||||
if missing.Status != StatusError || !strings.Contains(missing.Output, "Could not find") {
|
||||
t.Fatalf("expected missing error, got %s: %s", missing.Status, missing.Output)
|
||||
}
|
||||
|
||||
ambiguous := tool.Run(context.Background(), map[string]any{
|
||||
"path": "dup.txt",
|
||||
"old_string": "x",
|
||||
"new_string": "y",
|
||||
})
|
||||
if ambiguous.Status != StatusError || !strings.Contains(ambiguous.Output, "matches 2 locations") {
|
||||
t.Fatalf("expected ambiguity error, got %s: %s", ambiguous.Status, ambiguous.Output)
|
||||
}
|
||||
|
||||
all := tool.Run(context.Background(), map[string]any{
|
||||
"path": "dup.txt",
|
||||
"old_string": "x",
|
||||
"new_string": "y",
|
||||
"replace_all": true,
|
||||
})
|
||||
if all.Status != StatusOK {
|
||||
t.Fatalf("expected replace_all ok, got %s: %s", all.Status, all.Output)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "y\ny\n" {
|
||||
t.Fatalf("expected all replacements, got %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolAppliesUnifiedDiff(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
|
||||
patch := strings.Join([]string{
|
||||
"diff --git a/hello.txt b/hello.txt",
|
||||
"--- a/hello.txt",
|
||||
"+++ b/hello.txt",
|
||||
"@@ -1,2 +1,2 @@",
|
||||
" hello",
|
||||
"-old",
|
||||
"+new",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{
|
||||
"patch": patch,
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected patch ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.ReplaceAll(string(content), "\r\n", "\n") != "hello\nnew\n" {
|
||||
t.Fatalf("unexpected patched content: %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolRejectsSymlinkPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
realDirectory := filepath.Join(root, "real")
|
||||
if err := os.MkdirAll(realDirectory, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(realDirectory, filepath.Join(root, "link")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
patch := strings.Join([]string{
|
||||
"diff --git a/link/new.txt b/link/new.txt",
|
||||
"new file mode 100644",
|
||||
"index 0000000..e965047",
|
||||
"--- /dev/null",
|
||||
"+++ b/link/new.txt",
|
||||
"@@ -0,0 +1 @@",
|
||||
"+hello",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{
|
||||
"patch": patch,
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "must not traverse symlink") {
|
||||
t.Fatalf("expected symlink error, got %q", result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(realDirectory, "new.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected symlink target file to remain absent, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolRejectsOutsideWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
|
||||
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{
|
||||
"cwd": outside,
|
||||
"patch": strings.Join([]string{
|
||||
"diff --git a/nope.txt b/nope.txt",
|
||||
"--- a/nope.txt",
|
||||
"+++ b/nope.txt",
|
||||
"@@ -0,0 +1 @@",
|
||||
"+nope",
|
||||
"",
|
||||
}, "\n"),
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "must stay inside the workspace") {
|
||||
t.Fatalf("expected workspace error, got %q", result.Output)
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
package tui
|
||||
|
||||
import "strings"
|
||||
|
||||
type commandKind int
|
||||
|
||||
const (
|
||||
commandEmpty commandKind = iota
|
||||
commandPrompt
|
||||
commandHelp
|
||||
commandClear
|
||||
commandExit
|
||||
commandTools
|
||||
commandPermissions
|
||||
commandProvider
|
||||
commandModel
|
||||
commandContext
|
||||
commandConfig
|
||||
commandDebug
|
||||
commandDoctor
|
||||
commandPlan
|
||||
commandSearch
|
||||
commandTheme
|
||||
commandInputStyle
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
type commandGroup string
|
||||
|
||||
const (
|
||||
commandGroupSession commandGroup = "session"
|
||||
commandGroupModel commandGroup = "model"
|
||||
commandGroupRuntime commandGroup = "runtime"
|
||||
commandGroupTools commandGroup = "tools"
|
||||
commandGroupMeta commandGroup = "meta"
|
||||
)
|
||||
|
||||
type commandDefinition struct {
|
||||
name string
|
||||
aliases []string
|
||||
usage string
|
||||
group commandGroup
|
||||
description string
|
||||
kind commandKind
|
||||
}
|
||||
|
||||
type parsedCommand struct {
|
||||
kind commandKind
|
||||
text string
|
||||
name string
|
||||
}
|
||||
|
||||
var commandDefinitions = []commandDefinition{
|
||||
{
|
||||
name: "/provider",
|
||||
usage: "/provider",
|
||||
group: commandGroupModel,
|
||||
description: "Show the active provider.",
|
||||
kind: commandProvider,
|
||||
},
|
||||
{
|
||||
name: "/model",
|
||||
usage: "/model [list|id]",
|
||||
group: commandGroupModel,
|
||||
description: "Show the active model and model-shell status.",
|
||||
kind: commandModel,
|
||||
},
|
||||
{
|
||||
name: "/plan",
|
||||
usage: "/plan",
|
||||
group: commandGroupSession,
|
||||
description: "Show planning mode status.",
|
||||
kind: commandPlan,
|
||||
},
|
||||
{
|
||||
name: "/permissions",
|
||||
usage: "/permissions",
|
||||
group: commandGroupRuntime,
|
||||
description: "Show the active permission mode.",
|
||||
kind: commandPermissions,
|
||||
},
|
||||
{
|
||||
name: "/tools",
|
||||
usage: "/tools",
|
||||
group: commandGroupTools,
|
||||
description: "List registered tools.",
|
||||
kind: commandTools,
|
||||
},
|
||||
{
|
||||
name: "/context",
|
||||
usage: "/context",
|
||||
group: commandGroupSession,
|
||||
description: "Show current workspace and runtime context.",
|
||||
kind: commandContext,
|
||||
},
|
||||
{
|
||||
name: "/clear",
|
||||
usage: "/clear",
|
||||
group: commandGroupMeta,
|
||||
description: "Clear the visible transcript.",
|
||||
kind: commandClear,
|
||||
},
|
||||
{
|
||||
name: "/search",
|
||||
usage: "/search",
|
||||
group: commandGroupSession,
|
||||
description: "Show code search shell status.",
|
||||
kind: commandSearch,
|
||||
},
|
||||
{
|
||||
name: "/doctor",
|
||||
usage: "/doctor",
|
||||
group: commandGroupRuntime,
|
||||
description: "Show local diagnostics shell status.",
|
||||
kind: commandDoctor,
|
||||
},
|
||||
{
|
||||
name: "/config",
|
||||
usage: "/config",
|
||||
group: commandGroupRuntime,
|
||||
description: "Show active configuration summary.",
|
||||
kind: commandConfig,
|
||||
},
|
||||
{
|
||||
name: "/debug",
|
||||
aliases: []string{"/debug-mode"},
|
||||
usage: "/debug",
|
||||
group: commandGroupRuntime,
|
||||
description: "Show debug mode status.",
|
||||
kind: commandDebug,
|
||||
},
|
||||
{
|
||||
name: "/theme",
|
||||
usage: "/theme",
|
||||
group: commandGroupSession,
|
||||
description: "Show theme shell status.",
|
||||
kind: commandTheme,
|
||||
},
|
||||
{
|
||||
name: "/input-style",
|
||||
usage: "/input-style",
|
||||
group: commandGroupSession,
|
||||
description: "Show input style shell status.",
|
||||
kind: commandInputStyle,
|
||||
},
|
||||
{
|
||||
name: "/help",
|
||||
usage: "/help",
|
||||
group: commandGroupMeta,
|
||||
description: "Show available commands.",
|
||||
kind: commandHelp,
|
||||
},
|
||||
{
|
||||
name: "/exit",
|
||||
aliases: []string{"/quit"},
|
||||
usage: "/exit",
|
||||
group: commandGroupMeta,
|
||||
description: "Exit Zero.",
|
||||
kind: commandExit,
|
||||
},
|
||||
}
|
||||
|
||||
func parseCommand(input string) parsedCommand {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
return parsedCommand{kind: commandEmpty}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "/") {
|
||||
name, args := splitCommand(trimmed)
|
||||
command, ok := resolveCommand(name)
|
||||
if ok {
|
||||
return parsedCommand{kind: command.kind, name: command.name, text: args}
|
||||
}
|
||||
return parsedCommand{kind: commandUnknown, text: trimmed}
|
||||
}
|
||||
|
||||
return parsedCommand{kind: commandPrompt, text: trimmed}
|
||||
}
|
||||
|
||||
func splitCommand(input string) (string, string) {
|
||||
parts := strings.Fields(input)
|
||||
if len(parts) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
name := parts[0]
|
||||
args := strings.TrimSpace(input[len(name):])
|
||||
return strings.ToLower(name), args
|
||||
}
|
||||
|
||||
func resolveCommand(name string) (commandDefinition, bool) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(name))
|
||||
for _, command := range commandDefinitions {
|
||||
if normalized == command.name {
|
||||
return command, true
|
||||
}
|
||||
for _, alias := range command.aliases {
|
||||
if normalized == alias {
|
||||
return command, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return commandDefinition{}, false
|
||||
}
|
||||
|
||||
func listCommandNames() []string {
|
||||
names := make([]string, 0, len(commandDefinitions))
|
||||
for _, command := range commandDefinitions {
|
||||
names = append(names, command.name)
|
||||
names = append(names, command.aliases...)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func formatCommandHelpLines() []string {
|
||||
lines := make([]string, 0, len(commandDefinitions))
|
||||
for _, command := range commandDefinitions {
|
||||
label := command.usage
|
||||
if len(command.aliases) > 0 {
|
||||
label += " (" + strings.Join(command.aliases, ", ") + ")"
|
||||
}
|
||||
lines = append(lines, label+" - "+command.description)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -1,453 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
var (
|
||||
headerStyle = lipgloss.NewStyle().Bold(true)
|
||||
footerStyle = lipgloss.NewStyle().Faint(true)
|
||||
)
|
||||
|
||||
type model struct {
|
||||
ctx context.Context
|
||||
cwd string
|
||||
providerName string
|
||||
modelName string
|
||||
provider zeroruntime.Provider
|
||||
registry *tools.Registry
|
||||
agentOptions agent.Options
|
||||
permissionMode agent.PermissionMode
|
||||
transcript []transcriptRow
|
||||
input textinput.Model
|
||||
pending bool
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
}
|
||||
|
||||
type agentResponseMsg struct {
|
||||
runID int
|
||||
rows []transcriptRow
|
||||
err error
|
||||
}
|
||||
|
||||
func newModel(ctx context.Context, options Options) model {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
cwd := options.Cwd
|
||||
if cwd == "" {
|
||||
if current, err := os.Getwd(); err == nil {
|
||||
cwd = current
|
||||
}
|
||||
}
|
||||
|
||||
registry := options.Registry
|
||||
if registry == nil {
|
||||
registry = options.AgentOptions.Registry
|
||||
}
|
||||
if registry == nil {
|
||||
registry = tools.NewRegistry()
|
||||
}
|
||||
|
||||
permissionMode := options.PermissionMode
|
||||
if permissionMode == "" {
|
||||
permissionMode = options.AgentOptions.PermissionMode
|
||||
}
|
||||
if permissionMode == "" {
|
||||
permissionMode = agent.PermissionModeAuto
|
||||
}
|
||||
|
||||
input := textinput.New()
|
||||
input.Prompt = "zero > "
|
||||
input.Placeholder = "type a prompt or /help"
|
||||
input.Focus()
|
||||
|
||||
return model{
|
||||
ctx: ctx,
|
||||
cwd: cwd,
|
||||
providerName: options.ProviderName,
|
||||
modelName: options.ModelName,
|
||||
provider: options.Provider,
|
||||
registry: registry,
|
||||
agentOptions: options.AgentOptions,
|
||||
permissionMode: permissionMode,
|
||||
transcript: initialTranscript(),
|
||||
input: input,
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC:
|
||||
m.cancelRun()
|
||||
m.exiting = true
|
||||
return m, tea.Quit
|
||||
case tea.KeyEsc:
|
||||
m.input.SetValue("")
|
||||
if m.pending {
|
||||
m.cancelRun()
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyEnter:
|
||||
return m.handleSubmit()
|
||||
}
|
||||
case agentResponseMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
return m, nil
|
||||
}
|
||||
m.pending = false
|
||||
m.runCancel = nil
|
||||
m.activeRunID = 0
|
||||
for _, row := range msg.rows {
|
||||
m.transcript = appendRow(m.transcript, row.kind, row.text)
|
||||
}
|
||||
if msg.err != nil {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendError,
|
||||
text: msg.err.Error(),
|
||||
})
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString(headerStyle.Render(fmt.Sprintf("ZERO %s %s", m.cwd, m.providerStatus())))
|
||||
builder.WriteString("\n\n")
|
||||
|
||||
for _, row := range m.transcript {
|
||||
builder.WriteString(renderRow(row))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
if m.pending {
|
||||
builder.WriteString("assistant: working...\n")
|
||||
}
|
||||
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(m.input.View())
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString(footerStyle.Render(commandFooterText()))
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
command := parseCommand(m.input.Value())
|
||||
if command.kind == commandPrompt && m.pending {
|
||||
return m, nil
|
||||
}
|
||||
m.input.SetValue("")
|
||||
|
||||
switch command.kind {
|
||||
case commandEmpty:
|
||||
return m, nil
|
||||
case commandHelp:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: helpText()})
|
||||
return m, nil
|
||||
case commandClear:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
|
||||
return m, nil
|
||||
case commandExit:
|
||||
m.exiting = true
|
||||
return m, tea.Quit
|
||||
case commandTools:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.toolsText()})
|
||||
return m, nil
|
||||
case commandPermissions:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.permissionsText()})
|
||||
return m, nil
|
||||
case commandProvider:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.providerText()})
|
||||
return m, nil
|
||||
case commandModel:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.modelText(command.text)})
|
||||
return m, nil
|
||||
case commandContext:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.contextText()})
|
||||
return m, nil
|
||||
case commandConfig:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.configText()})
|
||||
return m, nil
|
||||
case commandDebug:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.debugText()})
|
||||
return m, nil
|
||||
case commandPlan:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.planText()})
|
||||
return m, nil
|
||||
case commandDoctor, commandSearch, commandTheme, commandInputStyle:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: shellOnlyCommandText(command.name),
|
||||
})
|
||||
return m, nil
|
||||
case commandUnknown:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendError,
|
||||
text: "unknown command: " + command.text,
|
||||
})
|
||||
return m, nil
|
||||
case commandPrompt:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: command.text})
|
||||
if m.provider == nil {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendAssistant,
|
||||
text: "No provider configured.",
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(m.ctx)
|
||||
m.runID++
|
||||
m.activeRunID = m.runID
|
||||
m.runCancel = cancel
|
||||
m.pending = true
|
||||
return m, m.runAgent(m.activeRunID, runCtx, command.text)
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) cancelRun() {
|
||||
if m.runCancel != nil {
|
||||
m.runCancel()
|
||||
}
|
||||
m.pending = false
|
||||
m.runCancel = nil
|
||||
m.activeRunID = 0
|
||||
}
|
||||
|
||||
func (m model) runAgent(runID int, runCtx context.Context, prompt string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
rows := []transcriptRow{}
|
||||
options := m.agentOptions
|
||||
options.Registry = m.registry
|
||||
options.PermissionMode = m.permissionMode
|
||||
|
||||
onToolCall := options.OnToolCall
|
||||
options.OnToolCall = func(call agent.ToolCall) {
|
||||
rows = append(rows, transcriptRow{kind: rowToolCall, text: "tool call: " + call.Name})
|
||||
if onToolCall != nil {
|
||||
onToolCall(call)
|
||||
}
|
||||
}
|
||||
|
||||
onToolResult := options.OnToolResult
|
||||
options.OnToolResult = func(result agent.ToolResult) {
|
||||
rows = append(rows, transcriptRow{
|
||||
kind: rowToolResult,
|
||||
text: toolResultRowText(result),
|
||||
})
|
||||
if onToolResult != nil {
|
||||
onToolResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := agent.Run(runCtx, prompt, m.provider, options)
|
||||
if err != nil {
|
||||
return agentResponseMsg{runID: runID, rows: rows, err: err}
|
||||
}
|
||||
rows = append(rows, transcriptRow{kind: rowAssistant, text: result.FinalAnswer})
|
||||
return agentResponseMsg{runID: runID, rows: rows}
|
||||
}
|
||||
}
|
||||
|
||||
func toolResultRowText(result agent.ToolResult) string {
|
||||
status := result.Status
|
||||
if status == "" {
|
||||
status = tools.StatusOK
|
||||
}
|
||||
return fmt.Sprintf("tool result: %s %s %s", result.Name, status, result.Output)
|
||||
}
|
||||
|
||||
func (m model) providerStatus() string {
|
||||
provider := m.providerName
|
||||
if provider == "" {
|
||||
provider = "provider:none"
|
||||
}
|
||||
|
||||
if m.modelName == "" {
|
||||
return provider
|
||||
}
|
||||
return provider + "/" + m.modelName
|
||||
}
|
||||
|
||||
func (m model) toolsText() string {
|
||||
registered := m.registry.All()
|
||||
if len(registered) == 0 {
|
||||
return "No tools registered."
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(registered))
|
||||
for _, tool := range registered {
|
||||
names = append(names, tool.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
return "Tools: " + strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func (m model) permissionsText() string {
|
||||
return "Permission mode: " + string(m.permissionMode)
|
||||
}
|
||||
|
||||
func (m model) providerText() string {
|
||||
return strings.Join([]string{
|
||||
"Provider",
|
||||
"provider: " + displayValue(m.providerName, "none"),
|
||||
"model: " + displayValue(m.modelName, "none"),
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func (m model) modelText(args string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(args)) {
|
||||
case "list", "ls":
|
||||
return m.modelListText()
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
"Model",
|
||||
"Active model: " + displayValue(m.modelName, "none"),
|
||||
"provider: " + displayValue(m.providerName, "none"),
|
||||
}
|
||||
if args != "" {
|
||||
lines = append(lines, "Model switching is not wired in Go TUI yet.")
|
||||
} else {
|
||||
lines = append(lines, "Use /model list to inspect the current model shell.")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m model) contextText() string {
|
||||
return strings.Join([]string{
|
||||
"Context",
|
||||
"cwd: " + displayValue(m.cwd, "unknown"),
|
||||
"provider: " + displayValue(m.providerName, "none"),
|
||||
"model: " + displayValue(m.modelName, "none"),
|
||||
"permission mode: " + string(m.permissionMode),
|
||||
fmt.Sprintf("tools: %d", len(m.registry.All())),
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func (m model) configText() string {
|
||||
return strings.Join([]string{
|
||||
"Config",
|
||||
"provider: " + displayValue(m.providerName, "none"),
|
||||
"model: " + displayValue(m.modelName, "none"),
|
||||
"permission mode: " + string(m.permissionMode),
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func (m model) debugText() string {
|
||||
state := "idle"
|
||||
if m.pending {
|
||||
state = "running"
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"Debug",
|
||||
"run state: " + state,
|
||||
"active run: " + fmt.Sprint(m.activeRunID),
|
||||
"Debug mode is not wired in Go TUI yet.",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func displayValue(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func shellOnlyCommandText(name string) string {
|
||||
return fmt.Sprintf("%s is registered in the Go TUI shell but is not wired yet.", name)
|
||||
}
|
||||
|
||||
func helpText() string {
|
||||
return "Commands:\n" + strings.Join(formatCommandHelpLines(), "\n") + "\nSubmit text to ask the assistant."
|
||||
}
|
||||
|
||||
const defaultCommandFooterText = "/help /model /provider /context /tools /permissions /clear /exit Esc clear Ctrl+C quit"
|
||||
|
||||
func commandFooterText() string {
|
||||
return formatCommandFooterText(commandDefinitions)
|
||||
}
|
||||
|
||||
func formatCommandFooterText(commands []commandDefinition) string {
|
||||
if len(commands) == 0 {
|
||||
return defaultCommandFooterText
|
||||
}
|
||||
|
||||
namesByKind := make(map[commandKind]string, len(commands))
|
||||
for _, command := range commands {
|
||||
namesByKind[command.kind] = command.name
|
||||
}
|
||||
|
||||
featured := []commandKind{
|
||||
commandHelp,
|
||||
commandModel,
|
||||
commandProvider,
|
||||
commandContext,
|
||||
commandTools,
|
||||
commandPermissions,
|
||||
commandClear,
|
||||
commandExit,
|
||||
}
|
||||
parts := make([]string, 0, len(featured)+2)
|
||||
for _, kind := range featured {
|
||||
name := namesByKind[kind]
|
||||
if name != "" {
|
||||
parts = append(parts, name)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return defaultCommandFooterText
|
||||
}
|
||||
|
||||
parts = append(parts, "Esc clear", "Ctrl+C quit")
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func renderRow(row transcriptRow) string {
|
||||
switch row.kind {
|
||||
case rowWelcome:
|
||||
return row.text
|
||||
case rowUser:
|
||||
return "user: " + row.text
|
||||
case rowAssistant:
|
||||
return "assistant: " + row.text
|
||||
case rowToolCall:
|
||||
return row.text
|
||||
case rowToolResult:
|
||||
return row.text
|
||||
case rowError:
|
||||
return "error: " + row.text
|
||||
default:
|
||||
return row.text
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
)
|
||||
|
||||
func (m model) modelListText() string {
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return "Models\nFailed to load model catalog: " + err.Error()
|
||||
}
|
||||
|
||||
activeID := activeModelID(registry, m.modelName)
|
||||
lines := []string{
|
||||
"Models",
|
||||
"Active model: " + displayValue(m.modelName, "none"),
|
||||
"provider: " + displayValue(m.providerName, "none"),
|
||||
"Available models:",
|
||||
}
|
||||
models := registry.List(modelregistry.ListOptions{})
|
||||
sort.SliceStable(models, func(i, j int) bool {
|
||||
if models[i].Provider == models[j].Provider {
|
||||
return models[i].ID < models[j].ID
|
||||
}
|
||||
return models[i].Provider < models[j].Provider
|
||||
})
|
||||
for _, model := range models {
|
||||
marker := " "
|
||||
if activeID != "" && model.ID == activeID {
|
||||
marker = "*"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s %s (%s) - %s", marker, model.ID, model.Provider, model.DisplayName))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func activeModelID(registry modelregistry.Registry, modelName string) string {
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
if modelName == "" {
|
||||
return ""
|
||||
}
|
||||
if model, ok := registry.Get(modelName); ok {
|
||||
return model.ID
|
||||
}
|
||||
return strings.ToLower(modelName)
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type fakeProvider struct {
|
||||
events []zeroruntime.StreamEvent
|
||||
}
|
||||
|
||||
func (provider *fakeProvider) StreamCompletion(
|
||||
ctx context.Context,
|
||||
request zeroruntime.CompletionRequest,
|
||||
) (<-chan zeroruntime.StreamEvent, error) {
|
||||
ch := make(chan zeroruntime.StreamEvent, len(provider.events))
|
||||
for _, event := range provider.events {
|
||||
ch <- event
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestParseCommand(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
kind commandKind
|
||||
text string
|
||||
}{
|
||||
{input: "", kind: commandEmpty},
|
||||
{input: " ", kind: commandEmpty},
|
||||
{input: "/help", kind: commandHelp},
|
||||
{input: "/clear", kind: commandClear},
|
||||
{input: "/exit", kind: commandExit},
|
||||
{input: "/quit", kind: commandExit},
|
||||
{input: "/tools", kind: commandTools},
|
||||
{input: "/permissions", kind: commandPermissions},
|
||||
{input: "/context", kind: commandContext},
|
||||
{input: "/model", kind: commandModel},
|
||||
{input: "/model list", kind: commandModel, text: "list"},
|
||||
{input: "/debug-mode", kind: commandDebug},
|
||||
{input: "hello zero", kind: commandPrompt, text: "hello zero"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
command := parseCommand(tc.input)
|
||||
if command.kind != tc.kind || command.text != tc.text {
|
||||
t.Fatalf("expected kind=%v text=%q, got kind=%v text=%q", tc.kind, tc.text, command.kind, command.text)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandRegistryResolvesAliasesAndFormatsHelp(t *testing.T) {
|
||||
names := listCommandNames()
|
||||
for _, name := range []string{"/help", "/model", "/provider", "/context", "/debug-mode", "/quit"} {
|
||||
if !stringSliceContains(names, name) {
|
||||
t.Fatalf("expected command names to contain %s, got %#v", name, names)
|
||||
}
|
||||
}
|
||||
|
||||
resolved, ok := resolveCommand("/quit")
|
||||
if !ok || resolved.kind != commandExit {
|
||||
t.Fatalf("expected /quit to resolve to exit, got ok=%v command=%#v", ok, resolved)
|
||||
}
|
||||
|
||||
help := strings.Join(formatCommandHelpLines(), "\n")
|
||||
for _, want := range []string{"/model", "/context", "/debug", "/permissions", "model"} {
|
||||
assertContains(t, help, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptReducer(t *testing.T) {
|
||||
transcript := initialTranscript()
|
||||
transcript = reduceTranscript(transcript, transcriptAction{kind: actionAppendUser, text: "hello"})
|
||||
transcript = reduceTranscript(transcript, transcriptAction{kind: actionAppendAssistant, text: "hi"})
|
||||
transcript = reduceTranscript(transcript, transcriptAction{kind: actionAppendToolCall, name: "read_file"})
|
||||
transcript = reduceTranscript(transcript, transcriptAction{kind: actionAppendToolResult, name: "read_file", text: "ok"})
|
||||
|
||||
if len(transcript) != 5 {
|
||||
t.Fatalf("expected welcome plus four rows, got %#v", transcript)
|
||||
}
|
||||
if transcript[1].kind != rowUser || transcript[1].text != "hello" {
|
||||
t.Fatalf("expected user row, got %#v", transcript[1])
|
||||
}
|
||||
if transcript[3].kind != rowToolCall || !strings.Contains(transcript[3].text, "read_file") {
|
||||
t.Fatalf("expected tool-call placeholder, got %#v", transcript[3])
|
||||
}
|
||||
|
||||
cleared := reduceTranscript(transcript, transcriptAction{kind: actionClear})
|
||||
if len(cleared) != 1 || cleared[0].kind != rowWelcome {
|
||||
t.Fatalf("expected clear to reset to welcome row, got %#v", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialRenderContainsHeaderInputAndFooter(t *testing.T) {
|
||||
model := newModel(context.Background(), Options{
|
||||
Cwd: `D:\codings\Opensource\Zero`,
|
||||
ProviderName: "fake",
|
||||
ModelName: "m-test",
|
||||
})
|
||||
|
||||
view := model.View()
|
||||
assertContains(t, view, "ZERO")
|
||||
assertContains(t, view, `D:\codings\Opensource\Zero`)
|
||||
assertContains(t, view, "fake/m-test")
|
||||
assertContains(t, view, "zero >")
|
||||
assertContains(t, view, "/help")
|
||||
assertContains(t, view, "/clear")
|
||||
assertContains(t, view, "/exit")
|
||||
assertContains(t, view, "Ctrl+C")
|
||||
}
|
||||
|
||||
func TestCommandFooterTextUsesRegistryEntries(t *testing.T) {
|
||||
footer := commandFooterText()
|
||||
|
||||
for _, command := range []string{"/help", "/model", "/provider", "/context", "/tools", "/permissions", "/clear", "/exit"} {
|
||||
assertContains(t, footer, command)
|
||||
}
|
||||
assertContains(t, footer, "Esc clear")
|
||||
assertContains(t, footer, "Ctrl+C quit")
|
||||
}
|
||||
|
||||
func TestCommandFooterTextFallsBackWhenRegistryIsEmpty(t *testing.T) {
|
||||
footer := formatCommandFooterText(nil)
|
||||
|
||||
for _, command := range []string{"/help", "/model", "/provider", "/context", "/tools", "/permissions", "/clear", "/exit"} {
|
||||
assertContains(t, footer, command)
|
||||
}
|
||||
assertContains(t, footer, "Esc clear")
|
||||
assertContains(t, footer, "Ctrl+C quit")
|
||||
}
|
||||
|
||||
func TestHelpCommandAppendsHelpRow(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.input.SetValue("/help")
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
next := updated.(model)
|
||||
|
||||
if !transcriptContains(next.transcript, "/tools") {
|
||||
t.Fatalf("expected help transcript to mention /tools, got %#v", next.transcript)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "/model") || !transcriptContains(next.transcript, "/context") {
|
||||
t.Fatalf("expected help transcript to mention model and context commands, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCommandResetsTranscript(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "hello"})
|
||||
m.input.SetValue("/clear")
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
next := updated.(model)
|
||||
|
||||
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected clear to reset transcript, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsCommandListsRegisteredTools(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool("."))
|
||||
m := newModel(context.Background(), Options{Registry: registry})
|
||||
m.input.SetValue("/tools")
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
next := updated.(model)
|
||||
|
||||
if !transcriptContains(next.transcript, "read_file") {
|
||||
t.Fatalf("expected tools transcript to list read_file, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCommandShowsCurrentPlan(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
planTool := tools.NewUpdatePlanTool()
|
||||
result := planTool.Run(context.Background(), map[string]any{
|
||||
"plan": []any{
|
||||
map[string]any{
|
||||
"id": "one",
|
||||
"content": "Wire model catalog",
|
||||
"status": "completed",
|
||||
},
|
||||
map[string]any{
|
||||
"id": "two",
|
||||
"content": "Add max turns",
|
||||
"status": "in_progress",
|
||||
"notes": "Go exec parity",
|
||||
},
|
||||
},
|
||||
})
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("update_plan setup failed: %#v", result)
|
||||
}
|
||||
registry.Register(planTool)
|
||||
m := newModel(context.Background(), Options{Registry: registry})
|
||||
m.input.SetValue("/plan")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected /plan to be handled without starting an agent run")
|
||||
}
|
||||
for _, want := range []string{"Current Plan", "Wire model catalog", "Add max turns", "in_progress", "Go exec parity"} {
|
||||
if !transcriptContains(next.transcript, want) {
|
||||
t.Fatalf("expected plan transcript to contain %q, got %#v", want, next.transcript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCommandHandlesMissingPlanTool(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Registry: tools.NewRegistry()})
|
||||
m.input.SetValue("/plan")
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
next := updated.(model)
|
||||
|
||||
if !transcriptContains(next.transcript, "No plan is active") {
|
||||
t.Fatalf("expected missing plan message, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCommandShowsSessionState(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool("."))
|
||||
m := newModel(context.Background(), Options{
|
||||
Cwd: `D:\codings\Opensource\Zero`,
|
||||
ProviderName: "openai",
|
||||
ModelName: "gpt-4.1",
|
||||
Registry: registry,
|
||||
PermissionMode: agent.PermissionModeAsk,
|
||||
})
|
||||
m.input.SetValue("/context")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected /context to be handled without starting an agent run")
|
||||
}
|
||||
for _, want := range []string{
|
||||
`D:\codings\Opensource\Zero`,
|
||||
"provider: openai",
|
||||
"model: gpt-4.1",
|
||||
"permission mode: ask",
|
||||
"tools: 1",
|
||||
} {
|
||||
if !transcriptContains(next.transcript, want) {
|
||||
t.Fatalf("expected context transcript to contain %q, got %#v", want, next.transcript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCommandShowsActiveModelWithoutRunningAgent(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ProviderName: "openai",
|
||||
ModelName: "gpt-4.1",
|
||||
Provider: &fakeProvider{},
|
||||
})
|
||||
m.input.SetValue("/model list")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected /model to be handled without starting an agent run")
|
||||
}
|
||||
for _, want := range []string{"Active model: gpt-4.1", "provider: openai", "Available models", "* gpt-4.1"} {
|
||||
if !transcriptContains(next.transcript, want) {
|
||||
t.Fatalf("expected model transcript to contain %q, got %#v", want, next.transcript)
|
||||
}
|
||||
}
|
||||
if !transcriptHasMarkedModelEntry(next.transcript) {
|
||||
t.Fatalf("expected model transcript to contain a marked model entry, got %#v", next.transcript)
|
||||
}
|
||||
if transcriptContains(next.transcript, "Model switching") {
|
||||
t.Fatalf("expected /model list to show catalog, got switching placeholder: %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCommandKeepsSwitchingStatusExplicit(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ProviderName: "openai",
|
||||
ModelName: "gpt-4.1",
|
||||
Provider: &fakeProvider{},
|
||||
})
|
||||
m.input.SetValue("/model gpt-4.1-mini")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected /model to be handled without starting an agent run")
|
||||
}
|
||||
for _, want := range []string{"Active model: gpt-4.1", "provider: openai", "Model switching is not wired"} {
|
||||
if !transcriptContains(next.transcript, want) {
|
||||
t.Fatalf("expected model transcript to contain %q, got %#v", want, next.transcript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptSubmitAppendsUserAndAssistantRows(t *testing.T) {
|
||||
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventText, Content: "hello"},
|
||||
{Type: zeroruntime.StreamEventText, Content: " back"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}
|
||||
m := newModel(context.Background(), Options{
|
||||
Provider: provider,
|
||||
Registry: tools.NewRegistry(),
|
||||
})
|
||||
m.input.SetValue("say hi")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
next := updated.(model)
|
||||
if !transcriptContains(next.transcript, "say hi") {
|
||||
t.Fatalf("expected user row after submit, got %#v", next.transcript)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected submit to return agent command")
|
||||
}
|
||||
|
||||
msg := cmd()
|
||||
updated, _ = next.Update(msg)
|
||||
next = updated.(model)
|
||||
if !transcriptContains(next.transcript, "hello back") {
|
||||
t.Fatalf("expected assistant row after agent response, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptSubmitDoesNotStartAnotherRunWhilePending(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
Provider: &fakeProvider{},
|
||||
Registry: tools.NewRegistry(),
|
||||
})
|
||||
m.pending = true
|
||||
m.input.SetValue("second prompt")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected no command while another run is pending")
|
||||
}
|
||||
if transcriptContains(next.transcript, "second prompt") {
|
||||
t.Fatalf("pending prompt should not be appended, got %#v", next.transcript)
|
||||
}
|
||||
if !next.pending {
|
||||
t.Fatal("expected existing pending run to remain pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscCancelsPendingRun(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
cancelled := false
|
||||
m.pending = true
|
||||
m.activeRunID = 1
|
||||
m.runCancel = func() { cancelled = true }
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
next := updated.(model)
|
||||
|
||||
if !cancelled {
|
||||
t.Fatal("expected Esc to cancel pending run")
|
||||
}
|
||||
if next.pending {
|
||||
t.Fatal("expected Esc to clear pending state")
|
||||
}
|
||||
if next.activeRunID != 0 || next.runCancel != nil {
|
||||
t.Fatalf("expected active run state to clear, got id=%d cancel=%v", next.activeRunID, next.runCancel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleAgentResponseAfterCancelIsIgnored(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.pending = false
|
||||
m.activeRunID = 0
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "new prompt"})
|
||||
|
||||
updated, _ := m.Update(agentResponseMsg{
|
||||
runID: 1,
|
||||
rows: []transcriptRow{{kind: rowAssistant, text: "stale response"}},
|
||||
})
|
||||
next := updated.(model)
|
||||
|
||||
if transcriptContains(next.transcript, "stale response") {
|
||||
t.Fatalf("stale response should be ignored, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultRowDefaultsEmptyStatusToOK(t *testing.T) {
|
||||
text := toolResultRowText(agent.ToolResult{Name: "read_file", Output: "done"})
|
||||
|
||||
if !strings.Contains(text, "read_file ok done") {
|
||||
t.Fatalf("expected empty status to render as ok, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlCExits(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
|
||||
next := updated.(model)
|
||||
|
||||
if !next.exiting {
|
||||
t.Fatal("expected Ctrl+C to mark model exiting")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected Ctrl+C to return quit command")
|
||||
}
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, text string, want string) {
|
||||
t.Helper()
|
||||
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("expected %q to contain %q", text, want)
|
||||
}
|
||||
}
|
||||
|
||||
func transcriptContains(rows []transcriptRow, want string) bool {
|
||||
for _, row := range rows {
|
||||
if strings.Contains(row.text, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func transcriptHasMarkedModelEntry(rows []transcriptRow) bool {
|
||||
for _, row := range rows {
|
||||
for _, line := range strings.Split(row.text, "\n") {
|
||||
if strings.HasPrefix(line, "* ") && strings.Contains(line, " (") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// Options configures the reusable Zero terminal UI shell.
|
||||
type Options struct {
|
||||
Cwd string
|
||||
ProviderName string
|
||||
ModelName string
|
||||
Provider zeroruntime.Provider
|
||||
Registry *tools.Registry
|
||||
|
||||
AgentOptions agent.Options
|
||||
PermissionMode agent.PermissionMode
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type currentPlanReader interface {
|
||||
CurrentPlan() []tools.PlanItem
|
||||
}
|
||||
|
||||
func (m model) planText() string {
|
||||
tool, ok := m.registry.Get("update_plan")
|
||||
if !ok {
|
||||
return "No plan is active."
|
||||
}
|
||||
|
||||
reader, ok := tool.(currentPlanReader)
|
||||
if !ok {
|
||||
return "No plan is active."
|
||||
}
|
||||
|
||||
plan := reader.CurrentPlan()
|
||||
if len(plan) == 0 {
|
||||
return "No plan is active."
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(plan)+1)
|
||||
lines = append(lines, "Current Plan")
|
||||
for index, item := range plan {
|
||||
line := fmt.Sprintf("%d. [%s] %s", index+1, item.Status, item.Content)
|
||||
if item.Notes != "" {
|
||||
line += "\n Notes: " + item.Notes
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// Run starts the Zero Bubble Tea shell and returns a process-style exit code.
|
||||
func Run(ctx context.Context, options Options) int {
|
||||
program := tea.NewProgram(
|
||||
newModel(ctx, options),
|
||||
tea.WithContext(ctx),
|
||||
tea.WithInput(os.Stdin),
|
||||
tea.WithOutput(os.Stdout),
|
||||
)
|
||||
|
||||
if _, err := program.Run(); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type rowKind int
|
||||
|
||||
const (
|
||||
rowWelcome rowKind = iota
|
||||
rowUser
|
||||
rowAssistant
|
||||
rowToolCall
|
||||
rowToolResult
|
||||
rowSystem
|
||||
rowError
|
||||
)
|
||||
|
||||
type transcriptRow struct {
|
||||
kind rowKind
|
||||
text string
|
||||
}
|
||||
|
||||
type transcriptActionKind int
|
||||
|
||||
const (
|
||||
actionAppendUser transcriptActionKind = iota
|
||||
actionAppendAssistant
|
||||
actionAppendToolCall
|
||||
actionAppendToolResult
|
||||
actionAppendSystem
|
||||
actionAppendError
|
||||
actionClear
|
||||
)
|
||||
|
||||
type transcriptAction struct {
|
||||
kind transcriptActionKind
|
||||
text string
|
||||
name string
|
||||
status tools.Status
|
||||
}
|
||||
|
||||
func initialTranscript() []transcriptRow {
|
||||
return []transcriptRow{{
|
||||
kind: rowWelcome,
|
||||
text: "Welcome to Zero. Type /help for commands.",
|
||||
}}
|
||||
}
|
||||
|
||||
func reduceTranscript(rows []transcriptRow, action transcriptAction) []transcriptRow {
|
||||
switch action.kind {
|
||||
case actionClear:
|
||||
return initialTranscript()
|
||||
case actionAppendUser:
|
||||
return appendRow(rows, rowUser, action.text)
|
||||
case actionAppendAssistant:
|
||||
return appendRow(rows, rowAssistant, action.text)
|
||||
case actionAppendToolCall:
|
||||
return appendRow(rows, rowToolCall, fmt.Sprintf("tool call: %s", action.name))
|
||||
case actionAppendToolResult:
|
||||
status := action.status
|
||||
if status == "" {
|
||||
status = tools.StatusOK
|
||||
}
|
||||
return appendRow(rows, rowToolResult, fmt.Sprintf("tool result: %s %s %s", action.name, status, action.text))
|
||||
case actionAppendSystem:
|
||||
return appendRow(rows, rowSystem, action.text)
|
||||
case actionAppendError:
|
||||
return appendRow(rows, rowError, action.text)
|
||||
default:
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
func appendRow(rows []transcriptRow, kind rowKind, text string) []transcriptRow {
|
||||
next := append([]transcriptRow{}, rows...)
|
||||
next = append(next, transcriptRow{kind: kind, text: text})
|
||||
return next
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type Normalized struct {
|
||||
InputTokens int
|
||||
CachedInputTokens int
|
||||
OutputTokens int
|
||||
ReasoningTokens int
|
||||
TotalTokens int
|
||||
}
|
||||
|
||||
type RecordInput struct {
|
||||
ModelID string
|
||||
Usage zeroruntime.Usage
|
||||
Source string
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
ID string
|
||||
Sequence int
|
||||
ModelID string
|
||||
Provider modelregistry.ProviderKind
|
||||
Source string
|
||||
CreatedAt string
|
||||
Usage Normalized
|
||||
Cost modelregistry.CostBreakdown
|
||||
}
|
||||
|
||||
type ModelSummary struct {
|
||||
ModelID string
|
||||
Provider modelregistry.ProviderKind
|
||||
RecordCount int
|
||||
InputTokens int
|
||||
CachedInputTokens int
|
||||
OutputTokens int
|
||||
ReasoningTokens int
|
||||
TotalTokens int
|
||||
InputCost float64
|
||||
CachedInputCost float64
|
||||
OutputCost float64
|
||||
TotalCost float64
|
||||
FormattedTotalCost string
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
RecordCount int
|
||||
Currency string
|
||||
InputTokens int
|
||||
CachedInputTokens int
|
||||
OutputTokens int
|
||||
ReasoningTokens int
|
||||
TotalTokens int
|
||||
InputCost float64
|
||||
CachedInputCost float64
|
||||
OutputCost float64
|
||||
TotalCost float64
|
||||
FormattedTotalCost string
|
||||
ByModel []ModelSummary
|
||||
LastRecord *Record
|
||||
}
|
||||
|
||||
type TrackerOptions struct {
|
||||
Now func() time.Time
|
||||
Registry *modelregistry.Registry
|
||||
}
|
||||
|
||||
type Tracker struct {
|
||||
now func() time.Time
|
||||
registry modelregistry.Registry
|
||||
records []Record
|
||||
nextSeq int
|
||||
}
|
||||
|
||||
func NewTracker(options TrackerOptions) *Tracker {
|
||||
registry := options.Registry
|
||||
if registry == nil {
|
||||
defaultRegistry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
registry = &defaultRegistry
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Tracker{now: now, registry: *registry, nextSeq: 1}
|
||||
}
|
||||
|
||||
func (tracker *Tracker) Record(input RecordInput) (Record, error) {
|
||||
model, err := tracker.registry.Require(input.ModelID)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
normalized, runtimeUsage, err := Normalize(input.Usage)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
cost, err := modelregistry.CalculateCost(model, runtimeUsage)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
sequence := tracker.nextSeq
|
||||
tracker.nextSeq++
|
||||
record := Record{
|
||||
ID: fmt.Sprintf("zero_usage_%d", sequence),
|
||||
Sequence: sequence,
|
||||
ModelID: model.ID,
|
||||
Provider: model.Provider,
|
||||
Source: input.Source,
|
||||
CreatedAt: tracker.now().UTC().Format(time.RFC3339),
|
||||
Usage: normalized,
|
||||
Cost: cost,
|
||||
}
|
||||
tracker.records = append(tracker.records, record)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (tracker *Tracker) Records() []Record {
|
||||
return append([]Record{}, tracker.records...)
|
||||
}
|
||||
|
||||
func (tracker *Tracker) Summary() Summary {
|
||||
summary := Summary{Currency: "USD"}
|
||||
models := map[string]int{}
|
||||
for _, record := range tracker.records {
|
||||
summary.RecordCount++
|
||||
addUsageToSummary(&summary, record.Usage)
|
||||
addCostToSummary(&summary, record.Cost)
|
||||
index, ok := models[record.ModelID]
|
||||
if !ok {
|
||||
summary.ByModel = append(summary.ByModel, ModelSummary{
|
||||
ModelID: record.ModelID,
|
||||
Provider: record.Provider,
|
||||
})
|
||||
index = len(summary.ByModel) - 1
|
||||
models[record.ModelID] = index
|
||||
}
|
||||
addUsageToModel(&summary.ByModel[index], record.Usage)
|
||||
addCostToModel(&summary.ByModel[index], record.Cost)
|
||||
recordCopy := record
|
||||
summary.LastRecord = &recordCopy
|
||||
}
|
||||
summary.FormattedTotalCost = formatCost(summary.TotalCost)
|
||||
for index := range summary.ByModel {
|
||||
summary.ByModel[index].FormattedTotalCost = formatCost(summary.ByModel[index].TotalCost)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func (tracker *Tracker) Reset() {
|
||||
tracker.records = nil
|
||||
tracker.nextSeq = 1
|
||||
}
|
||||
|
||||
func Normalize(usage zeroruntime.Usage) (Normalized, zeroruntime.Usage, error) {
|
||||
inputTokens, err := nonNegative(firstNonZero(usage.InputTokens, usage.PromptTokens), "inputTokens")
|
||||
if err != nil {
|
||||
return Normalized{}, zeroruntime.Usage{}, err
|
||||
}
|
||||
outputTokens, err := nonNegative(firstNonZero(usage.OutputTokens, usage.CompletionTokens), "outputTokens")
|
||||
if err != nil {
|
||||
return Normalized{}, zeroruntime.Usage{}, err
|
||||
}
|
||||
cachedInputTokens, err := nonNegative(usage.CachedInputTokens, "cachedInputTokens")
|
||||
if err != nil {
|
||||
return Normalized{}, zeroruntime.Usage{}, err
|
||||
}
|
||||
if cachedInputTokens > inputTokens {
|
||||
cachedInputTokens = inputTokens
|
||||
}
|
||||
reasoningTokens, err := nonNegative(usage.ReasoningTokens, "reasoningTokens")
|
||||
if err != nil {
|
||||
return Normalized{}, zeroruntime.Usage{}, err
|
||||
}
|
||||
normalized := Normalized{
|
||||
InputTokens: inputTokens,
|
||||
CachedInputTokens: cachedInputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
ReasoningTokens: reasoningTokens,
|
||||
TotalTokens: inputTokens + outputTokens + reasoningTokens,
|
||||
}
|
||||
return normalized, zeroruntime.Usage{
|
||||
InputTokens: inputTokens,
|
||||
PromptTokens: inputTokens,
|
||||
CachedInputTokens: cachedInputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
CompletionTokens: outputTokens,
|
||||
ReasoningTokens: reasoningTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func FormatSummary(summary Summary) string {
|
||||
requestLabel := "requests"
|
||||
if summary.RecordCount == 1 {
|
||||
requestLabel = "request"
|
||||
}
|
||||
return fmt.Sprintf("%s %s, %s tokens, %s", comma(summary.RecordCount), requestLabel, comma(summary.TotalTokens), summary.FormattedTotalCost)
|
||||
}
|
||||
|
||||
func addUsageToSummary(summary *Summary, usage Normalized) {
|
||||
summary.InputTokens += usage.InputTokens
|
||||
summary.CachedInputTokens += usage.CachedInputTokens
|
||||
summary.OutputTokens += usage.OutputTokens
|
||||
summary.ReasoningTokens += usage.ReasoningTokens
|
||||
summary.TotalTokens += usage.TotalTokens
|
||||
}
|
||||
|
||||
func addUsageToModel(summary *ModelSummary, usage Normalized) {
|
||||
summary.RecordCount++
|
||||
summary.InputTokens += usage.InputTokens
|
||||
summary.CachedInputTokens += usage.CachedInputTokens
|
||||
summary.OutputTokens += usage.OutputTokens
|
||||
summary.ReasoningTokens += usage.ReasoningTokens
|
||||
summary.TotalTokens += usage.TotalTokens
|
||||
}
|
||||
|
||||
func addCostToSummary(summary *Summary, cost modelregistry.CostBreakdown) {
|
||||
summary.InputCost += cost.InputCost
|
||||
summary.CachedInputCost += cost.CachedInputCost
|
||||
summary.OutputCost += cost.OutputCost
|
||||
summary.TotalCost += cost.TotalCost
|
||||
}
|
||||
|
||||
func addCostToModel(summary *ModelSummary, cost modelregistry.CostBreakdown) {
|
||||
summary.InputCost += cost.InputCost
|
||||
summary.CachedInputCost += cost.CachedInputCost
|
||||
summary.OutputCost += cost.OutputCost
|
||||
summary.TotalCost += cost.TotalCost
|
||||
}
|
||||
|
||||
func formatCost(value float64) string {
|
||||
formatted, err := modelregistry.FormatCostUSD(value)
|
||||
if err != nil {
|
||||
return "$0.0000"
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
func nonNegative(value int, label string) (int, error) {
|
||||
if value < 0 {
|
||||
return 0, fmt.Errorf("expected %s to be non-negative", label)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func firstNonZero(values ...int) int {
|
||||
for _, value := range values {
|
||||
if value != 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func comma(value int) string {
|
||||
sign := ""
|
||||
if value < 0 {
|
||||
sign = "-"
|
||||
value = -value
|
||||
}
|
||||
digits := fmt.Sprintf("%d", value)
|
||||
if len(digits) <= 3 {
|
||||
return sign + digits
|
||||
}
|
||||
var out []byte
|
||||
prefix := len(digits) % 3
|
||||
if prefix == 0 {
|
||||
prefix = 3
|
||||
}
|
||||
out = append(out, digits[:prefix]...)
|
||||
for index := prefix; index < len(digits); index += 3 {
|
||||
out = append(out, ',')
|
||||
out = append(out, digits[index:index+3]...)
|
||||
}
|
||||
return sign + string(out)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestTrackerNormalizesUsageAndComputesModelCost(t *testing.T) {
|
||||
tracker := NewTracker(TrackerOptions{Now: fixedUsageClock("2026-06-04T13:00:00Z")})
|
||||
|
||||
record, err := tracker.Record(RecordInput{
|
||||
ModelID: "gpt-4.1",
|
||||
Source: "exec",
|
||||
Usage: zeroruntime.Usage{
|
||||
PromptTokens: 1_000,
|
||||
CompletionTokens: 250,
|
||||
CachedInputTokens: 200,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Record returned error: %v", err)
|
||||
}
|
||||
if record.Sequence != 1 || record.ID != "zero_usage_1" || record.CreatedAt != "2026-06-04T13:00:00Z" {
|
||||
t.Fatalf("unexpected record identity: %#v", record)
|
||||
}
|
||||
if record.Usage.InputTokens != 1_000 || record.Usage.OutputTokens != 250 || record.Usage.TotalTokens != 1_250 {
|
||||
t.Fatalf("usage not normalized: %#v", record.Usage)
|
||||
}
|
||||
if record.Cost.TotalCost <= 0 || record.Cost.ModelID != "gpt-4.1" {
|
||||
t.Fatalf("cost not computed: %#v", record.Cost)
|
||||
}
|
||||
|
||||
summary := tracker.Summary()
|
||||
if summary.RecordCount != 1 || summary.TotalTokens != 1_250 || summary.ByModel[0].ModelID != "gpt-4.1" {
|
||||
t.Fatalf("unexpected summary: %#v", summary)
|
||||
}
|
||||
if FormatSummary(summary) != "1 request, 1,250 tokens, "+summary.FormattedTotalCost {
|
||||
t.Fatalf("unexpected formatted summary: %q", FormatSummary(summary))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerRejectsInvalidUsageAndUnknownModels(t *testing.T) {
|
||||
tracker := NewTracker(TrackerOptions{})
|
||||
if _, err := tracker.Record(RecordInput{ModelID: "missing", Usage: zeroruntime.Usage{InputTokens: 1}}); err == nil {
|
||||
t.Fatal("expected unknown model error")
|
||||
}
|
||||
if _, err := tracker.Record(RecordInput{ModelID: "gpt-4.1", Usage: zeroruntime.Usage{InputTokens: -1}}); err == nil {
|
||||
t.Fatal("expected invalid usage error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerResetClearsRecords(t *testing.T) {
|
||||
tracker := NewTracker(TrackerOptions{})
|
||||
if _, err := tracker.Record(RecordInput{ModelID: "gpt-4.1", Usage: zeroruntime.Usage{InputTokens: 1, OutputTokens: 1}}); err != nil {
|
||||
t.Fatalf("Record returned error: %v", err)
|
||||
}
|
||||
tracker.Reset()
|
||||
if summary := tracker.Summary(); summary.RecordCount != 0 || len(summary.ByModel) != 0 {
|
||||
t.Fatalf("Reset did not clear tracker: %#v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func fixedUsageClock(value string) func() time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return func() time.Time { return parsed }
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package zeroruntime
|
||||
|
||||
import "context"
|
||||
|
||||
// CollectedStream is the non-streaming summary of provider events.
|
||||
type CollectedStream struct {
|
||||
Text string
|
||||
ToolCalls []ToolCall
|
||||
Usage Usage
|
||||
Error string
|
||||
}
|
||||
|
||||
// CollectOptions provides callbacks for consumers that need live stream updates.
|
||||
type CollectOptions struct {
|
||||
OnText func(string)
|
||||
OnUsage func(Usage)
|
||||
}
|
||||
|
||||
// SeedMessages creates the initial system and user turns for a request.
|
||||
func SeedMessages(systemPrompt string, userPrompt string) []Message {
|
||||
return []Message{
|
||||
{Role: MessageRoleSystem, Content: systemPrompt},
|
||||
{Role: MessageRoleUser, Content: userPrompt},
|
||||
}
|
||||
}
|
||||
|
||||
// CollectStream drains provider events into text, tool calls, usage, and error state.
|
||||
func CollectStream(ctx context.Context, events <-chan StreamEvent) CollectedStream {
|
||||
return CollectStreamWithOptions(ctx, events, CollectOptions{})
|
||||
}
|
||||
|
||||
// CollectStreamWithOptions drains provider events and emits optional live callbacks.
|
||||
func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, options CollectOptions) CollectedStream {
|
||||
collected := CollectedStream{}
|
||||
pendingToolCalls := make(map[string]*ToolCall)
|
||||
toolCallOrder := []string{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
collected.Error = ctx.Err().Error()
|
||||
appendOpenToolCalls(&collected, toolCallOrder, pendingToolCalls)
|
||||
return collected
|
||||
case event, ok := <-events:
|
||||
if !ok {
|
||||
appendOpenToolCalls(&collected, toolCallOrder, pendingToolCalls)
|
||||
return collected
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case StreamEventText:
|
||||
collected.Text += event.Content
|
||||
if options.OnText != nil {
|
||||
options.OnText(event.Content)
|
||||
}
|
||||
case StreamEventToolCallStart:
|
||||
toolCall := ensurePendingToolCall(event.ToolCallID, pendingToolCalls, &toolCallOrder)
|
||||
toolCall.Name = event.ToolName
|
||||
case StreamEventToolCallDelta:
|
||||
toolCall := ensurePendingToolCall(event.ToolCallID, pendingToolCalls, &toolCallOrder)
|
||||
toolCall.Arguments += event.ArgumentsFragment
|
||||
case StreamEventToolCallEnd:
|
||||
if toolCall, ok := pendingToolCalls[event.ToolCallID]; ok {
|
||||
collected.ToolCalls = append(collected.ToolCalls, *toolCall)
|
||||
delete(pendingToolCalls, event.ToolCallID)
|
||||
}
|
||||
case StreamEventUsage:
|
||||
inputTokens := event.Usage.EffectiveInputTokens()
|
||||
outputTokens := event.Usage.EffectiveOutputTokens()
|
||||
collected.Usage.InputTokens += inputTokens
|
||||
collected.Usage.OutputTokens += outputTokens
|
||||
collected.Usage.PromptTokens += inputTokens
|
||||
collected.Usage.CompletionTokens += outputTokens
|
||||
collected.Usage.CachedInputTokens += event.Usage.CachedInputTokens
|
||||
collected.Usage.ReasoningTokens += event.Usage.ReasoningTokens
|
||||
if options.OnUsage != nil {
|
||||
options.OnUsage(event.Usage)
|
||||
}
|
||||
case StreamEventError:
|
||||
collected.Error = event.Error
|
||||
appendOpenToolCalls(&collected, toolCallOrder, pendingToolCalls)
|
||||
return collected
|
||||
case StreamEventDone:
|
||||
appendOpenToolCalls(&collected, toolCallOrder, pendingToolCalls)
|
||||
return collected
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ensurePendingToolCall(
|
||||
toolCallID string,
|
||||
pendingToolCalls map[string]*ToolCall,
|
||||
toolCallOrder *[]string,
|
||||
) *ToolCall {
|
||||
toolCall, ok := pendingToolCalls[toolCallID]
|
||||
if ok {
|
||||
return toolCall
|
||||
}
|
||||
|
||||
toolCall = &ToolCall{ID: toolCallID}
|
||||
pendingToolCalls[toolCallID] = toolCall
|
||||
*toolCallOrder = append(*toolCallOrder, toolCallID)
|
||||
return toolCall
|
||||
}
|
||||
|
||||
func appendOpenToolCalls(
|
||||
collected *CollectedStream,
|
||||
toolCallOrder []string,
|
||||
pendingToolCalls map[string]*ToolCall,
|
||||
) {
|
||||
for _, id := range toolCallOrder {
|
||||
toolCall, ok := pendingToolCalls[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
collected.ToolCalls = append(collected.ToolCalls, *toolCall)
|
||||
delete(pendingToolCalls, id)
|
||||
}
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
package zeroruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mockProvider struct {
|
||||
events []StreamEvent
|
||||
}
|
||||
|
||||
func (provider mockProvider) StreamCompletion(
|
||||
ctx context.Context,
|
||||
request CompletionRequest,
|
||||
) (<-chan StreamEvent, error) {
|
||||
events := make(chan StreamEvent)
|
||||
go func() {
|
||||
defer close(events)
|
||||
for _, event := range provider.events {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case events <- event:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func TestSeedMessagesProducesSystemAndUserTurns(t *testing.T) {
|
||||
messages := SeedMessages("you are a helper", "inspect this repo")
|
||||
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(messages))
|
||||
}
|
||||
if messages[0].Role != MessageRoleSystem || messages[0].Content != "you are a helper" {
|
||||
t.Fatalf("unexpected system message: %#v", messages[0])
|
||||
}
|
||||
if messages[1].Role != MessageRoleUser || messages[1].Content != "inspect this repo" {
|
||||
t.Fatalf("unexpected user message: %#v", messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamEventNamesMatchProviderContract(t *testing.T) {
|
||||
cases := map[StreamEventType]string{
|
||||
StreamEventText: "text",
|
||||
StreamEventToolCallStart: "tool-call-start",
|
||||
StreamEventToolCallDelta: "tool-call-delta",
|
||||
StreamEventToolCallEnd: "tool-call-end",
|
||||
StreamEventUsage: "usage",
|
||||
StreamEventDone: "done",
|
||||
StreamEventError: "error",
|
||||
}
|
||||
|
||||
for eventType, want := range cases {
|
||||
if string(eventType) != want {
|
||||
t.Fatalf("event type %s = %q, want %q", eventType, string(eventType), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentEventNamesMatchPRDContract(t *testing.T) {
|
||||
cases := map[AgentEventType]string{
|
||||
AgentEventText: "text",
|
||||
AgentEventToolCall: "tool_call",
|
||||
AgentEventToolResult: "tool_result",
|
||||
AgentEventThinking: "thinking",
|
||||
AgentEventUsage: "usage",
|
||||
AgentEventPlanUpdate: "plan_update",
|
||||
AgentEventError: "error",
|
||||
AgentEventTurnEnd: "turn_end",
|
||||
}
|
||||
|
||||
for eventType, want := range cases {
|
||||
if string(eventType) != want {
|
||||
t.Fatalf("event type %s = %q, want %q", eventType, string(eventType), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUsageMapsProviderAliasesAndReasoningTokens(t *testing.T) {
|
||||
usage, err := NormalizeUsage(TokenUsage{
|
||||
PromptTokens: 100,
|
||||
CachedInputTokens: 25,
|
||||
CompletionTokens: 40,
|
||||
ReasoningTokens: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeUsage returned error: %v", err)
|
||||
}
|
||||
|
||||
if usage.EffectiveInputTokens() != 100 {
|
||||
t.Fatalf("input tokens = %d, want 100", usage.EffectiveInputTokens())
|
||||
}
|
||||
if usage.EffectiveOutputTokens() != 40 {
|
||||
t.Fatalf("output tokens = %d, want 40", usage.EffectiveOutputTokens())
|
||||
}
|
||||
if usage.CachedInputTokens != 25 {
|
||||
t.Fatalf("cached input tokens = %d, want 25", usage.CachedInputTokens)
|
||||
}
|
||||
if usage.ReasoningTokens != 10 {
|
||||
t.Fatalf("reasoning tokens = %d, want 10", usage.ReasoningTokens)
|
||||
}
|
||||
if usage.TotalTokens() != 150 {
|
||||
t.Fatalf("total tokens = %d, want 150", usage.TotalTokens())
|
||||
}
|
||||
if usage.BillableOutputTokens() != 50 {
|
||||
t.Fatalf("billable output tokens = %d, want 50", usage.BillableOutputTokens())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUsageClampsCachedInputTokens(t *testing.T) {
|
||||
usage, err := NormalizeUsage(TokenUsage{
|
||||
InputTokens: 5,
|
||||
CachedInputTokens: 12,
|
||||
OutputTokens: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeUsage returned error: %v", err)
|
||||
}
|
||||
if usage.CachedInputTokens != 5 {
|
||||
t.Fatalf("cached input tokens = %d, want 5", usage.CachedInputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUsageRejectsNegativeTokenCounts(t *testing.T) {
|
||||
_, err := NormalizeUsage(TokenUsage{OutputTokens: -1})
|
||||
if err == nil {
|
||||
t.Fatal("expected negative output token validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "output tokens") {
|
||||
t.Fatalf("error = %q, want output tokens", err.Error())
|
||||
}
|
||||
|
||||
_, err = NormalizeUsage(TokenUsage{InputTokens: 1, PromptTokens: -1})
|
||||
if err == nil {
|
||||
t.Fatal("expected negative prompt token alias validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "input tokens alias") {
|
||||
t.Fatalf("error = %q, want input tokens alias", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamAccumulatesTextToolCallsAndUsage(t *testing.T) {
|
||||
events := make(chan StreamEvent)
|
||||
go func() {
|
||||
defer close(events)
|
||||
events <- StreamEvent{Type: StreamEventText, Content: "Hello "}
|
||||
events <- StreamEvent{Type: StreamEventText, Content: "world"}
|
||||
events <- StreamEvent{Type: StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "read_file"}
|
||||
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{"pa`}
|
||||
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `th":"README.md"}`}
|
||||
events <- StreamEvent{Type: StreamEventToolCallEnd, ToolCallID: "call_1"}
|
||||
events <- StreamEvent{Type: StreamEventUsage, Usage: Usage{PromptTokens: 12, CompletionTokens: 8, CachedInputTokens: 3}}
|
||||
events <- StreamEvent{Type: StreamEventDone}
|
||||
}()
|
||||
|
||||
collected := CollectStream(context.Background(), events)
|
||||
|
||||
if collected.Text != "Hello world" {
|
||||
t.Fatalf("text = %q, want %q", collected.Text, "Hello world")
|
||||
}
|
||||
if len(collected.ToolCalls) != 1 {
|
||||
t.Fatalf("expected one tool call, got %d", len(collected.ToolCalls))
|
||||
}
|
||||
toolCall := collected.ToolCalls[0]
|
||||
if toolCall.ID != "call_1" || toolCall.Name != "read_file" || toolCall.Arguments != `{"path":"README.md"}` {
|
||||
t.Fatalf("unexpected tool call: %#v", toolCall)
|
||||
}
|
||||
if collected.Usage.PromptTokens != 12 || collected.Usage.CompletionTokens != 8 || collected.Usage.CachedInputTokens != 3 {
|
||||
t.Fatalf("unexpected usage: %#v", collected.Usage)
|
||||
}
|
||||
if collected.Usage.TotalTokens() != 20 {
|
||||
t.Fatalf("total tokens = %d, want 20", collected.Usage.TotalTokens())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamAccumulatesNormalizedUsageAliases(t *testing.T) {
|
||||
usage, err := NormalizeUsage(TokenUsage{InputTokens: 12, OutputTokens: 5, ReasoningTokens: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeUsage returned error: %v", err)
|
||||
}
|
||||
|
||||
events := make(chan StreamEvent)
|
||||
go func() {
|
||||
defer close(events)
|
||||
events <- StreamEvent{Type: StreamEventUsage, Usage: usage}
|
||||
events <- StreamEvent{Type: StreamEventDone}
|
||||
}()
|
||||
|
||||
collected := CollectStream(context.Background(), events)
|
||||
|
||||
if collected.Usage.EffectiveInputTokens() != 12 {
|
||||
t.Fatalf("input tokens = %d, want 12", collected.Usage.EffectiveInputTokens())
|
||||
}
|
||||
if collected.Usage.EffectiveOutputTokens() != 5 {
|
||||
t.Fatalf("output tokens = %d, want 5", collected.Usage.EffectiveOutputTokens())
|
||||
}
|
||||
if collected.Usage.ReasoningTokens != 3 {
|
||||
t.Fatalf("reasoning tokens = %d, want 3", collected.Usage.ReasoningTokens)
|
||||
}
|
||||
if collected.Usage.TotalTokens() != 20 {
|
||||
t.Fatalf("total tokens = %d, want 20", collected.Usage.TotalTokens())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamAccumulatesMixedUsageShapes(t *testing.T) {
|
||||
normalizedUsage, err := NormalizeUsage(TokenUsage{InputTokens: 6, OutputTokens: 3, ReasoningTokens: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeUsage returned error: %v", err)
|
||||
}
|
||||
|
||||
events := make(chan StreamEvent)
|
||||
go func() {
|
||||
defer close(events)
|
||||
events <- StreamEvent{Type: StreamEventUsage, Usage: Usage{PromptTokens: 10, CompletionTokens: 4, CachedInputTokens: 3}}
|
||||
events <- StreamEvent{Type: StreamEventUsage, Usage: normalizedUsage}
|
||||
events <- StreamEvent{Type: StreamEventDone}
|
||||
}()
|
||||
|
||||
collected := CollectStream(context.Background(), events)
|
||||
|
||||
if collected.Usage.EffectiveInputTokens() != 16 {
|
||||
t.Fatalf("input tokens = %d, want 16", collected.Usage.EffectiveInputTokens())
|
||||
}
|
||||
if collected.Usage.EffectiveOutputTokens() != 7 {
|
||||
t.Fatalf("output tokens = %d, want 7", collected.Usage.EffectiveOutputTokens())
|
||||
}
|
||||
if collected.Usage.CachedInputTokens != 3 {
|
||||
t.Fatalf("cached input tokens = %d, want 3", collected.Usage.CachedInputTokens)
|
||||
}
|
||||
if collected.Usage.ReasoningTokens != 2 {
|
||||
t.Fatalf("reasoning tokens = %d, want 2", collected.Usage.ReasoningTokens)
|
||||
}
|
||||
if collected.Usage.TotalTokens() != 25 {
|
||||
t.Fatalf("total tokens = %d, want 25", collected.Usage.TotalTokens())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamWithOptionsEmitsTextAndUsageCallbacks(t *testing.T) {
|
||||
events := make(chan StreamEvent)
|
||||
go func() {
|
||||
defer close(events)
|
||||
events <- StreamEvent{Type: StreamEventText, Content: "Hello "}
|
||||
events <- StreamEvent{Type: StreamEventUsage, Usage: Usage{PromptTokens: 12, CompletionTokens: 5, CachedInputTokens: 2}}
|
||||
events <- StreamEvent{Type: StreamEventText, Content: "zero"}
|
||||
events <- StreamEvent{Type: StreamEventDone}
|
||||
}()
|
||||
|
||||
var textDeltas []string
|
||||
var usageEvents []Usage
|
||||
collected := CollectStreamWithOptions(context.Background(), events, CollectOptions{
|
||||
OnText: func(delta string) { textDeltas = append(textDeltas, delta) },
|
||||
OnUsage: func(usage Usage) { usageEvents = append(usageEvents, usage) },
|
||||
})
|
||||
|
||||
if collected.Text != "Hello zero" {
|
||||
t.Fatalf("text = %q, want Hello zero", collected.Text)
|
||||
}
|
||||
if len(textDeltas) != 2 || textDeltas[0] != "Hello " || textDeltas[1] != "zero" {
|
||||
t.Fatalf("unexpected text callbacks: %#v", textDeltas)
|
||||
}
|
||||
if len(usageEvents) != 1 {
|
||||
t.Fatalf("expected one usage callback, got %#v", usageEvents)
|
||||
}
|
||||
if usageEvents[0].PromptTokens != 12 || usageEvents[0].CompletionTokens != 5 || usageEvents[0].CachedInputTokens != 2 {
|
||||
t.Fatalf("unexpected usage callback: %#v", usageEvents[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamKeepsArgumentDeltasBeforeToolCallStart(t *testing.T) {
|
||||
events := make(chan StreamEvent)
|
||||
go func() {
|
||||
defer close(events)
|
||||
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_buffered", ArgumentsFragment: `{"path":`}
|
||||
events <- StreamEvent{Type: StreamEventToolCallStart, ToolCallID: "call_buffered", ToolName: "read_file"}
|
||||
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_buffered", ArgumentsFragment: `"README.md"}`}
|
||||
events <- StreamEvent{Type: StreamEventToolCallEnd, ToolCallID: "call_buffered"}
|
||||
events <- StreamEvent{Type: StreamEventDone}
|
||||
}()
|
||||
|
||||
collected := CollectStream(context.Background(), events)
|
||||
|
||||
if len(collected.ToolCalls) != 1 {
|
||||
t.Fatalf("expected one buffered tool call, got %d", len(collected.ToolCalls))
|
||||
}
|
||||
toolCall := collected.ToolCalls[0]
|
||||
if toolCall.ID != "call_buffered" || toolCall.Name != "read_file" || toolCall.Arguments != `{"path":"README.md"}` {
|
||||
t.Fatalf("unexpected buffered tool call: %#v", toolCall)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamFlushesOpenToolCallsWhenChannelCloses(t *testing.T) {
|
||||
events := make(chan StreamEvent)
|
||||
go func() {
|
||||
defer close(events)
|
||||
events <- StreamEvent{Type: StreamEventToolCallStart, ToolCallID: "call_closed", ToolName: "grep"}
|
||||
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_closed", ArgumentsFragment: `{"query":"`}
|
||||
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_closed", ArgumentsFragment: `zero"}`}
|
||||
}()
|
||||
|
||||
collected := CollectStream(context.Background(), events)
|
||||
|
||||
if len(collected.ToolCalls) != 1 {
|
||||
t.Fatalf("expected one flushed tool call, got %d", len(collected.ToolCalls))
|
||||
}
|
||||
toolCall := collected.ToolCalls[0]
|
||||
if toolCall.ID != "call_closed" || toolCall.Name != "grep" || toolCall.Arguments != `{"query":"zero"}` {
|
||||
t.Fatalf("unexpected flushed tool call: %#v", toolCall)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamFlushesOpenToolCallsWhenContextCancels(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
events := make(chan StreamEvent)
|
||||
|
||||
go func() {
|
||||
events <- StreamEvent{Type: StreamEventToolCallStart, ToolCallID: "call_cancelled", ToolName: "read_file"}
|
||||
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_cancelled", ArgumentsFragment: `{"path":"README`}
|
||||
cancel()
|
||||
}()
|
||||
|
||||
collected := CollectStream(ctx, events)
|
||||
|
||||
if len(collected.ToolCalls) != 1 {
|
||||
t.Fatalf("expected one flushed tool call, got %d", len(collected.ToolCalls))
|
||||
}
|
||||
toolCall := collected.ToolCalls[0]
|
||||
if toolCall.ID != "call_cancelled" || toolCall.Name != "read_file" || toolCall.Arguments != `{"path":"README` {
|
||||
t.Fatalf("unexpected flushed tool call after cancel: %#v", toolCall)
|
||||
}
|
||||
if collected.Error == "" {
|
||||
t.Fatal("expected context cancellation to surface as collected error")
|
||||
}
|
||||
if collected.Error != context.Canceled.Error() {
|
||||
t.Fatalf("error = %q, want %q", collected.Error, context.Canceled.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamSurfacesStreamErrors(t *testing.T) {
|
||||
events := make(chan StreamEvent)
|
||||
go func() {
|
||||
defer close(events)
|
||||
events <- StreamEvent{Type: StreamEventToolCallStart, ToolCallID: "call_error", ToolName: "bash"}
|
||||
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_error", ArgumentsFragment: `{"command":"go test`}
|
||||
events <- StreamEvent{Type: StreamEventError, Error: "provider stream failed"}
|
||||
}()
|
||||
|
||||
collected := CollectStream(context.Background(), events)
|
||||
|
||||
if collected.Error != "provider stream failed" {
|
||||
t.Fatalf("error = %q, want provider stream failed", collected.Error)
|
||||
}
|
||||
if len(collected.ToolCalls) != 1 {
|
||||
t.Fatalf("expected one flushed tool call, got %d", len(collected.ToolCalls))
|
||||
}
|
||||
toolCall := collected.ToolCalls[0]
|
||||
if toolCall.ID != "call_error" || toolCall.Name != "bash" || toolCall.Arguments != `{"command":"go test` {
|
||||
t.Fatalf("unexpected flushed tool call after error: %#v", toolCall)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderContractCanBeImplementedByMock(t *testing.T) {
|
||||
var provider Provider = mockProvider{
|
||||
events: []StreamEvent{
|
||||
{Type: StreamEventText, Content: "ok"},
|
||||
{Type: StreamEventDone},
|
||||
},
|
||||
}
|
||||
|
||||
stream, err := provider.StreamCompletion(context.Background(), CompletionRequest{
|
||||
Messages: SeedMessages("system", "user"),
|
||||
Tools: []ToolDefinition{
|
||||
{
|
||||
Name: "read_file",
|
||||
Description: "Read a file",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
|
||||
collected := CollectStream(context.Background(), stream)
|
||||
if collected.Text != "ok" {
|
||||
t.Fatalf("text = %q, want ok", collected.Text)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user