Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae08789eea | ||
|
|
a6c5cb90b1 | ||
|
|
0326be775e | ||
|
|
bdb948dbe1 | ||
|
|
fc46b14ebe | ||
|
|
4669fe58f4 | ||
|
|
9754b309dd | ||
|
|
9d94b2dbcf | ||
|
|
aba9c59ac7 | ||
|
|
151a23faa7 | ||
|
|
c447d51456 | ||
|
|
eb0af5bbe5 | ||
|
|
50f59fee1b | ||
|
|
02f6ef7bf4 | ||
|
|
fab1908fde | ||
|
|
b5d94c0989 | ||
|
|
39cc174e94 | ||
|
|
207f1ca133 | ||
|
|
cef9a426a2 | ||
|
|
7b80238d4c | ||
|
|
23fb16ac0f | ||
|
|
de1126d007 | ||
|
|
cf5ee6d792 | ||
|
|
8b808f24c8 | ||
|
|
63601973c6 | ||
|
|
725bddca15 | ||
|
|
4401117071 | ||
|
|
5eafbe963a | ||
|
|
ef97ba54a3 | ||
|
|
d8a774c6ae | ||
|
|
18f1ae6736 | ||
|
|
6afec5b955 | ||
|
|
581cb9f765 | ||
|
|
7704018a47 | ||
|
|
b55bad7201 | ||
|
|
2a674fd0bd | ||
|
|
db54bba8ce | ||
|
|
3a3fad60cd | ||
|
|
65d386c19e | ||
|
|
3765b08186 | ||
|
|
4e7d56f7a8 | ||
|
|
6b289d2e8f | ||
|
|
f3f0dc0328 | ||
|
|
6b83f3cb7c | ||
|
|
c87fe4c46a | ||
|
|
52d2fb86f2 | ||
|
|
8b93106924 |
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@ -16,10 +16,10 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-22.04]
|
||||
os: [macos-15, ubuntu-22.04]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
|
||||
- name: Fetch Commander dependency
|
||||
@ -37,7 +37,7 @@ jobs:
|
||||
swift-version: '6.2.1'
|
||||
|
||||
- name: Cache Swift Package Manager
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
.build
|
||||
@ -52,7 +52,8 @@ jobs:
|
||||
- name: Run Tests (Unit Tests Only)
|
||||
run: |
|
||||
if [[ "${{ runner.os }}" == "Linux" ]]; then
|
||||
swift test --filter TachikomaTests --skip "OpenAIAudioProviderTests" --skip "ProviderEndToEndTests"
|
||||
# Several test suites mutate process-wide env/profile state.
|
||||
swift test --no-parallel --filter TachikomaTests --skip "OpenAIAudioProviderTests" --skip "ProviderEndToEndTests"
|
||||
else
|
||||
swift test --filter TachikomaTests
|
||||
fi
|
||||
@ -79,10 +80,10 @@ jobs:
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest]
|
||||
os: [macos-15]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch Commander dependency
|
||||
shell: bash
|
||||
@ -120,7 +121,7 @@ jobs:
|
||||
# Disabled for now: simulator SDK downloads are slow/flaky and not needed for core validation.
|
||||
if: false
|
||||
name: Apple Platforms (${{ matrix.platform }})
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
needs: test
|
||||
strategy:
|
||||
matrix:
|
||||
@ -130,12 +131,12 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: SwiftLint
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
# SwiftLint only runs on macOS
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install SwiftLint
|
||||
run: brew install swiftlint
|
||||
|
||||
14
.github/workflows/cross-platform.yml
vendored
14
.github/workflows/cross-platform.yml
vendored
@ -27,7 +27,7 @@ jobs:
|
||||
name: Ubuntu 22.04 LTS
|
||||
container: swift:6.2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Fetch Commander dependency
|
||||
shell: bash
|
||||
run: git clone --depth 1 https://github.com/steipete/Commander.git ../Commander
|
||||
@ -48,14 +48,15 @@ jobs:
|
||||
export OPENAI_API_KEY="${OPENAI_API_KEY:-test-key}"
|
||||
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-test-key}"
|
||||
SKIP_FLAGS="--skip ProviderEndToEndTests"
|
||||
swift test $SKIP_FLAGS
|
||||
# Several test suites mutate process-wide env/profile state.
|
||||
swift test --no-parallel $SKIP_FLAGS
|
||||
|
||||
test-linux-ubuntu-24:
|
||||
runs-on: ubuntu-24.04
|
||||
name: Ubuntu 24.04 LTS
|
||||
container: swift:6.2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Fetch Commander dependency
|
||||
shell: bash
|
||||
run: git clone --depth 1 https://github.com/steipete/Commander.git ../Commander
|
||||
@ -76,7 +77,8 @@ jobs:
|
||||
export OPENAI_API_KEY="${OPENAI_API_KEY:-test-key}"
|
||||
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-test-key}"
|
||||
SKIP_FLAGS="--skip ProviderEndToEndTests"
|
||||
swift test $SKIP_FLAGS
|
||||
# Several test suites mutate process-wide env/profile state.
|
||||
swift test --no-parallel $SKIP_FLAGS
|
||||
|
||||
# Optional: Build release artifacts
|
||||
build-release:
|
||||
@ -84,7 +86,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Create release info
|
||||
run: |
|
||||
@ -99,7 +101,7 @@ jobs:
|
||||
echo "Total: 4 platform configurations tested in parallel" >> release-info.txt
|
||||
|
||||
- name: Upload release info
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: cross-platform-validation
|
||||
path: release-info.txt
|
||||
|
||||
18
.github/workflows/lint.yml
vendored
18
.github/workflows/lint.yml
vendored
@ -13,10 +13,10 @@ concurrency:
|
||||
jobs:
|
||||
swiftlint:
|
||||
name: SwiftLint
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch Commander dependency
|
||||
shell: bash
|
||||
@ -28,7 +28,7 @@ jobs:
|
||||
xcode-version: '26.1'
|
||||
|
||||
- name: Cache SwiftLint
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.mint
|
||||
@ -66,10 +66,10 @@ jobs:
|
||||
|
||||
swiftformat:
|
||||
name: SwiftFormat
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch Commander dependency
|
||||
shell: bash
|
||||
@ -81,7 +81,7 @@ jobs:
|
||||
xcode-version: '26.1'
|
||||
|
||||
- name: Cache SwiftFormat
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.mint
|
||||
@ -108,10 +108,10 @@ jobs:
|
||||
|
||||
swift6-compatibility:
|
||||
name: Swift 6 Compatibility Check
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch Commander dependency
|
||||
shell: bash
|
||||
@ -158,7 +158,7 @@ jobs:
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch Commander dependency
|
||||
shell: bash
|
||||
|
||||
21
.github/workflows/test.yml
vendored
21
.github/workflows/test.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
# macOS tests with all Apple platforms
|
||||
test-apple-platforms:
|
||||
name: Test Apple Platforms
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch Commander dependency
|
||||
shell: bash
|
||||
@ -37,7 +37,7 @@ jobs:
|
||||
xcode-version: '26.1'
|
||||
|
||||
- name: Cache Swift Package Manager
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: .build
|
||||
key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
|
||||
@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch Commander dependency
|
||||
shell: bash
|
||||
@ -87,7 +87,7 @@ jobs:
|
||||
swift-version: ${{ matrix.swift-version }}
|
||||
|
||||
- name: Cache Swift Package Manager
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: .build
|
||||
key: ${{ runner.os }}-spm-${{ matrix.swift-version }}-${{ hashFiles('**/Package.resolved') }}
|
||||
@ -105,7 +105,8 @@ jobs:
|
||||
echo "OPENAI_API_KEY missing; skipping OpenAIAudioProviderTests"
|
||||
SKIP_FLAGS="$SKIP_FLAGS --skip OpenAIAudioProviderTests"
|
||||
fi
|
||||
swift test --verbose $SKIP_FLAGS
|
||||
# Several test suites mutate process-wide env/profile state.
|
||||
swift test --no-parallel --verbose $SKIP_FLAGS
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
@ -118,10 +119,10 @@ jobs:
|
||||
# Package validation
|
||||
validate-package:
|
||||
name: Validate Swift Package
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch Commander dependency
|
||||
shell: bash
|
||||
@ -150,11 +151,11 @@ jobs:
|
||||
# Integration tests with real APIs (optional, requires secrets)
|
||||
integration-tests:
|
||||
name: Integration Tests
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch Commander dependency
|
||||
shell: bash
|
||||
|
||||
@ -107,8 +107,8 @@ disabled_rules:
|
||||
|
||||
# Rule configurations
|
||||
file_length:
|
||||
warning: 1000
|
||||
error: 2000
|
||||
warning: 2000
|
||||
error: 2500
|
||||
ignore_comment_only_lines: true
|
||||
|
||||
function_parameter_count:
|
||||
@ -137,8 +137,8 @@ trailing_comma:
|
||||
mandatory_comma: true
|
||||
|
||||
type_body_length:
|
||||
warning: 800
|
||||
error: 1200
|
||||
warning: 1800
|
||||
error: 2200
|
||||
|
||||
type_name:
|
||||
min_length:
|
||||
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@ -2,6 +2,32 @@
|
||||
|
||||
All notable changes to the Tachikoma project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Added first-class OpenAI `chat-latest` support with parsing aliases, Responses API routing, model capabilities, and usage estimates.
|
||||
- Added first-class MiniMax support with the `MiniMax-M2.7` catalog models, `MINIMAX_API_KEY` / `MINIMAX_BASE_URL` configuration, bearer-token Anthropic-compatible transport, model parsing shortcuts, usage estimates, and provider tests.
|
||||
- Added explicit LM Studio model shortcuts such as `lmstudio` and `lmstudio/openai/gpt-oss-120b` so local provider selections no longer fall through to Ollama custom IDs.
|
||||
|
||||
### Changed
|
||||
- Refreshed the first-class model catalog to current provider IDs: OpenAI GPT-5.5/5.4, Claude Fable 5/Opus 4.8/Opus 4.7/Sonnet 4.6/Haiku 4.5, Gemini 3.1, Mistral latest aliases, Groq current production IDs, and xAI Grok 4.3/4.20.
|
||||
- Added explicit `claude-fable-5` support with 1M context, 128K max output, signed-thinking replay, refusal handling, and non-streaming generation; `LanguageModel.default` remains `claude-opus-4-8`, while `LanguageModel.defaultStreaming` now uses streaming-safe `gpt-5.5`.
|
||||
- Removed stale direct model support for retired or non-canonical IDs including GPT-5.1/5.2/pseudo-thinking models, deprecated Claude Sonnet/Opus 4 snapshots, Grok 2/3/4-fast rows, old Groq Llama/Mixtral/Gemma aliases, stale Mistral aliases, and invalid LM Studio `current`.
|
||||
|
||||
### Fixed
|
||||
- OpenAI `gpt-5-chat-latest` now preserves its distinct model identity, appears in model listings, and applies GPT-5 parameter filtering instead of being rewritten to `chat-latest`.
|
||||
- SwiftPM consumers now resolve Commander from the package URL instead of accidentally inheriting a sibling local checkout.
|
||||
- Ollama model parsing now preserves explicit custom vision model IDs such as `qwen2.5vl:3b` instead of falling back to `llama3.3` (#16).
|
||||
- Auth resolution now snapshots environment-ignore state consistently, preventing parallel tests and concurrent callers from falling back to stored OpenRouter credentials when an environment override is present.
|
||||
- SwiftPM consumers can now resolve Commander remotely instead of requiring a local `../Commander` checkout. Thanks @malpern.
|
||||
- Custom OpenAI-compatible and Anthropic-compatible providers now honor per-provider `options.apiKey` values from profile config. Thanks @381181295.
|
||||
- Google/Gemini request encoding now sends tool results as documented user `functionResponse` turns and merges consecutive same-role contents before calling the API. Thanks @hsrvc.
|
||||
- Google/Gemini tool schemas now drop orphan `required` entries before request encoding so Gemini accepts simplified MCP tool definitions. Thanks @bcharleson.
|
||||
- OpenAI Responses API providers now resolve shared OAuth/API-key credentials instead of requiring `OPENAI_API_KEY` directly.
|
||||
- Credential loading now only maps exact API-key credential names to provider API keys, so OAuth access/refresh tokens no longer overwrite configured OpenAI or Anthropic keys.
|
||||
- Custom OpenAI-compatible and Anthropic-compatible providers now forward configured proxy headers to request calls.
|
||||
- Anthropic base URL environment overrides now survive Swift release builds, so `ANTHROPIC_BASE_URL` can route requests through local proxies. Thanks @shraderdm.
|
||||
|
||||
## [0.2.0] - 2026-04-28
|
||||
|
||||
### Added
|
||||
@ -9,10 +35,10 @@ All notable changes to the Tachikoma project will be documented in this file.
|
||||
- Azure provider unit tests using URLProtocol stubs to verify path, query, and auth header construction.
|
||||
|
||||
### Changed
|
||||
- Added OpenAI's GPT-5.1 family (flagship/mini/nano) throughout the model enums, selectors, provider factories, capability registry, pricing tables, docs, and test suites. GPT aliases (`gpt`, `gpt-5`, `gpt-4o`) now normalize to `.openai(.gpt51)` so downstream apps inherit the new default seamlessly.
|
||||
- Added OpenAI's GPT-5.1 family (flagship/mini/nano) throughout the model enums, selectors, provider factories, capability registry, pricing tables, docs, and test suites. GPT aliases (`gpt`, `gpt-5`) now normalize to supported GPT-5 models so downstream apps inherit the new default seamlessly.
|
||||
- Expanded xAI Grok support to the full November 2025 catalog (`grok-4-fast-*`, `grok-code-fast-1`, `grok-2-*`, `grok-vision-beta`, etc.), updated the CLI shortcuts so `grok` now maps to `grok-4-fast-reasoning`, and refreshed selectors, provider parsers, capability tables, and docs snippets to match the official API lineup.
|
||||
- Google/Gemini support now targets the Gemini 2.5 family exclusively (`gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`), with updated model selectors, parsers, docs, and pricing tables; older 1.5/2.0 IDs are no longer recognized.
|
||||
- Removed deprecated OpenAI reasoning models (`o1`, `o1-mini`, `o3`, `o3-mini`) in favour of the GPT‑5 family plus `o4-mini`, updating enums, provider factories, capability tables, prompts, and documentation metadata accordingly.
|
||||
- Removed deprecated OpenAI reasoning models (`o1`, `o1-mini`, `o3`, `o3-mini`, `o4-mini`) in favour of the GPT‑5 family, updating enums, provider factories, capability tables, prompts, and documentation metadata accordingly.
|
||||
- Google/Gemini integration now uses the documented `x-goog-api-key` header with `alt=sse` streaming, adds fallbacks for `GOOGLE_API_KEY` / `GOOGLE_APPLICATION_CREDENTIALS`, and hardens the SSE decoder so live tests succeed consistently.
|
||||
- Pruned Anthropic model support to the Claude 4.x line (Opus 4, Sonnet 4 / 4.5, Haiku 4.5) to match current API availability and reduce maintenance burden.
|
||||
- `TachikomaConfiguration` now loads credentials first and lets environment variables override them so operators can supersede stored settings without editing credentials files.
|
||||
@ -61,8 +87,8 @@ All notable changes to the Tachikoma project will be documented in this file.
|
||||
|
||||
#### Provider Support
|
||||
- **OpenAI Provider**: Complete integration with dual API support
|
||||
- Chat Completions API for standard models (GPT-4o, GPT-4.1)
|
||||
- Responses API for reasoning models (o3, o4 series)
|
||||
- Chat Completions API for standard custom models
|
||||
- Responses API for GPT-5 models
|
||||
- Automatic API selection based on model capabilities
|
||||
- Parameter filtering for reasoning models
|
||||
- Full streaming support for both APIs
|
||||
@ -70,7 +96,7 @@ All notable changes to the Tachikoma project will be documented in this file.
|
||||
|
||||
- **Anthropic Provider**: Native Claude API integration
|
||||
- Support for Claude 4 (Opus, Sonnet) with thinking modes
|
||||
- Claude 3.5/3.7 series compatibility
|
||||
- Claude 4.x series compatibility
|
||||
- Content block handling for multimodal inputs
|
||||
- System prompt separation
|
||||
- Server-Sent Events streaming
|
||||
|
||||
@ -25,7 +25,7 @@ ai-cli "What is the capital of France?"
|
||||
ai-cli --model claude "Explain quantum computing"
|
||||
|
||||
# Stream the response
|
||||
ai-cli --stream --model gpt-4o "Write a short story"
|
||||
ai-cli --stream --model gpt-5.5 "Write a short story"
|
||||
```
|
||||
|
||||
## Parameters
|
||||
@ -35,7 +35,7 @@ ai-cli --stream --model gpt-4o "Write a short story"
|
||||
| `-m, --model <MODEL>` | Specify the AI model to use |
|
||||
| `--api <chat\|responses>` | For OpenAI models: select API type (default: responses for GPT-5) |
|
||||
| `-s, --stream` | Stream the response in real-time |
|
||||
| `--thinking` | Show reasoning process (O3, O4, GPT-5 - note: API currently doesn't expose actual reasoning) |
|
||||
| `--thinking` | Show GPT-5 reasoning process (note: API currently doesn't expose actual reasoning) |
|
||||
| `--verbose, -v` | Show detailed debug output |
|
||||
| `--config` | Show current configuration and API key status |
|
||||
| `--help, -h` | Show help message |
|
||||
@ -60,30 +60,25 @@ Add to your shell profile (`~/.zshrc`, `~/.bashrc`) for persistence.
|
||||
## Supported Models
|
||||
|
||||
### OpenAI
|
||||
- **GPT-5 Series**: `gpt-5`, `gpt-5-mini`, `gpt-5-nano`
|
||||
- **O-Series**: `o3`, `o3-mini`, `o3-pro`, `o4-mini`
|
||||
- **GPT-4**: `gpt-4.1`, `gpt-4.1-mini`, `gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo`
|
||||
- **Legacy**: `gpt-3.5-turbo`
|
||||
- **GPT-5 Series**: `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`
|
||||
|
||||
### Anthropic
|
||||
- **Claude 4**: `claude-opus-4-1-20250805`, `claude-sonnet-4-20250514`
|
||||
- **Claude 3.7**: `claude-3-7-sonnet`
|
||||
- **Claude 3.5**: `claude-3-5-opus`, `claude-3-5-sonnet`, `claude-3-5-haiku`
|
||||
- **Claude 4.x**: `claude-opus-4-7`, `claude-opus-4-5`, `claude-opus-4-1-20250805`, `claude-sonnet-4-6`, `claude-sonnet-4-5-20250929`, `claude-haiku-4-5`
|
||||
|
||||
### Google
|
||||
- **Gemini 2.5**: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`
|
||||
- **Gemini**: `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`
|
||||
|
||||
### Others
|
||||
- **Mistral**: `mistral-large-2`, `mistral-small`, `codestral`
|
||||
- **Groq**: `llama-3.1-70b`, `llama-3.1-8b`, `mixtral-8x7b`
|
||||
* **Grok**: `grok-4-0709`, `grok-4-fast-reasoning`, `grok-4-fast-non-reasoning`, `grok-code-fast-1`, `grok-3`, `grok-3-mini`, `grok-2-1212`, `grok-2-vision-1212`, `grok-2-image-1212`
|
||||
- **Ollama** (local): `llama3.3`, `llava`, `codellama`, any installed model
|
||||
- **Mistral**: `mistral-large-latest`, `mistral-medium-latest`, `mistral-medium-3-5`, `mistral-small-latest`, `open-mistral-nemo-2407`, `codestral-latest`
|
||||
- **Groq**: `openai/gpt-oss-120b`, `openai/gpt-oss-20b`, `llama-3.3-70b-versatile`, `llama-3.1-8b-instant`
|
||||
- **Grok**: `grok-4.3`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`
|
||||
- **Ollama** (local): `llama3.3`, `llava`, any installed model
|
||||
|
||||
### Model Shortcuts
|
||||
- `claude` → claude-opus-4-1-20250805
|
||||
- `gpt` → gpt-4.1
|
||||
- `gemini` → gemini-2.5-flash
|
||||
- `grok` → grok-4-fast-reasoning
|
||||
- `claude` → claude-opus-4-7
|
||||
- `gpt` → gpt-5.5
|
||||
- `gemini` → gemini-3.1-pro-preview
|
||||
- `grok` → grok-4.3
|
||||
- `llama` → llama3.3
|
||||
|
||||
## Examples
|
||||
@ -98,7 +93,7 @@ ai-cli --stream --model gpt-5 "Explain the theory of relativity"
|
||||
|
||||
# API selection for OpenAI
|
||||
ai-cli --model gpt-5 --api chat "Use Chat Completions API"
|
||||
ai-cli --model o3 --api responses "Use Responses API"
|
||||
ai-cli --model gpt-5.5 --api responses "Use Responses API"
|
||||
|
||||
# Debug mode
|
||||
ai-cli --verbose --model opus "Debug this request"
|
||||
|
||||
@ -58,7 +58,7 @@ struct AICLI {
|
||||
if let modelString = config.modelString {
|
||||
model = try ModelSelector.parseModel(modelString)
|
||||
} else {
|
||||
model = .openai(.gpt51) // Default to GPT-5.1
|
||||
model = .openai(.gpt55)
|
||||
}
|
||||
} catch {
|
||||
print("❌ Error parsing model: \(error)")
|
||||
@ -190,7 +190,7 @@ struct AICLI {
|
||||
-m, --model <MODEL> Specify the AI model to use
|
||||
--api <API> For OpenAI models: 'chat' or 'responses' (default: responses for GPT-5)
|
||||
-s, --stream Stream the response (partial support)
|
||||
--thinking Show reasoning/thinking process (O3, O4, GPT-5 via Responses API)
|
||||
--thinking Show reasoning/thinking process (GPT-5 via Responses API)
|
||||
--verbose, -v Show detailed debug output
|
||||
--config Show current configuration and exit
|
||||
-h, --help Show this help message
|
||||
@ -202,7 +202,7 @@ struct AICLI {
|
||||
|
||||
# Use specific models
|
||||
ai-cli --model claude "Explain quantum computing"
|
||||
ai-cli --model gpt-4o "Describe this image"
|
||||
ai-cli --model gpt-5.5 "Describe this image"
|
||||
ai-cli --model grok "Tell me a joke"
|
||||
ai-cli --model llama3.3 "Help me debug this code"
|
||||
|
||||
@ -213,53 +213,51 @@ struct AICLI {
|
||||
# Streaming responses
|
||||
ai-cli --stream --model claude "Write a short story"
|
||||
|
||||
# Show thinking process (reasoning models)
|
||||
ai-cli --thinking --model gpt-5-thinking "Solve this logic puzzle"
|
||||
# Show thinking process
|
||||
ai-cli --thinking --model gpt-5.5 "Solve this logic puzzle"
|
||||
ai-cli --thinking --model gpt-5 "Complex reasoning task"
|
||||
|
||||
PROVIDERS & MODELS:
|
||||
|
||||
OpenAI:
|
||||
• gpt-5, gpt-5-pro, gpt-5-mini, gpt-5-nano (GPT-5 series, August 2025)
|
||||
• gpt-5-thinking, gpt-5-thinking-mini, gpt-5-thinking-nano
|
||||
• gpt-4.1, gpt-4.1-mini, o4-mini (GPT-4.1 / reasoning)
|
||||
• gpt-4o, gpt-4o-mini (Multimodal)
|
||||
• gpt-4-turbo (Legacy)
|
||||
• gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.4-nano
|
||||
|
||||
Anthropic:
|
||||
• claude-opus-4-1-20250805, claude-sonnet-4-20250514 (Claude 4)
|
||||
• claude-3-7-sonnet (Claude 3.7)
|
||||
• claude-3-5-opus, claude-3-5-sonnet, claude-3-5-haiku (Claude 3.5)
|
||||
• claude-opus-4-7, claude-opus-4-5, claude-opus-4-1-20250805
|
||||
• claude-sonnet-4-6, claude-sonnet-4-5-20250929, claude-haiku-4-5
|
||||
|
||||
Google:
|
||||
• gemini-3.1-pro-preview, gemini-3.1-flash-lite
|
||||
• gemini-3-flash-preview
|
||||
• gemini-2.5-pro (reasoning, thinking support)
|
||||
• gemini-2.5-flash, gemini-2.5-flash-lite
|
||||
|
||||
Mistral:
|
||||
• mistral-large-2, mistral-large, mistral-small
|
||||
• mistral-nemo, codestral
|
||||
• mistral-large-latest, mistral-medium-latest, mistral-medium-3-5
|
||||
• mistral-small-latest, open-mistral-nemo-2407, codestral-latest
|
||||
|
||||
Groq (Ultra-fast):
|
||||
• llama-3.1-70b, llama-3.1-8b
|
||||
• mixtral-8x7b, gemma2-9b
|
||||
• openai/gpt-oss-120b, openai/gpt-oss-20b
|
||||
• llama-3.3-70b-versatile, llama-3.1-8b-instant
|
||||
• meta-llama/llama-4-maverick-17b-128e-instruct
|
||||
|
||||
Grok (xAI):
|
||||
• grok-4-0709, grok-4-fast-reasoning, grok-4-fast-non-reasoning
|
||||
• grok-code-fast-1, grok-3, grok-3-mini
|
||||
• grok-2-1212, grok-2-vision-1212, grok-2-image-1212 (Vision)
|
||||
• grok-4.3
|
||||
• grok-4.20-0309-reasoning, grok-4.20-0309-non-reasoning
|
||||
|
||||
Ollama (Local):
|
||||
• llama3.3, llama3.2, llama3.1 (Recommended)
|
||||
• llava, bakllava (Vision models)
|
||||
• codellama, mistral-nemo, qwen2.5
|
||||
• deepseek-r1, command-r-plus
|
||||
• mistral-nemo, qwen2.5
|
||||
• deepseek-r1:8b, deepseek-r1:671b, command-r-plus
|
||||
• Custom: any-model:tag
|
||||
|
||||
SHORTCUTS:
|
||||
• claude, opus → claude-opus-4-1-20250805
|
||||
• gpt, gpt4 → gpt-4.1
|
||||
• grok → grok-4-fast-reasoning
|
||||
• gemini → gemini-2.5-flash
|
||||
• claude, opus → claude-opus-4-7
|
||||
• gpt → gpt-5.5
|
||||
• grok → grok-4.3
|
||||
• gemini → gemini-3.1-pro-preview
|
||||
• llama, llama3 → llama3.3
|
||||
|
||||
API KEYS:
|
||||
@ -376,6 +374,8 @@ struct AICLI {
|
||||
case .openai: .openai
|
||||
case .anthropic: .anthropic
|
||||
case .google: .google
|
||||
case .minimax: .minimax
|
||||
case .minimaxCN: .minimaxCN
|
||||
case .mistral: .mistral
|
||||
case .groq: .groq
|
||||
case .grok: .grok
|
||||
@ -406,6 +406,14 @@ struct AICLI {
|
||||
print("# Legacy names still supported:")
|
||||
print("export GOOGLE_API_KEY='gk-your-key-here'")
|
||||
print("Get your key at: https://aistudio.google.com/apikey")
|
||||
case .minimax:
|
||||
print("Set your MiniMax API key:")
|
||||
print("export MINIMAX_API_KEY='your-key-here'")
|
||||
case .minimaxCN:
|
||||
print("Set your MiniMax China API key:")
|
||||
print("export MINIMAX_CN_API_KEY='your-key-here'")
|
||||
print("# or reuse the global MiniMax key name:")
|
||||
print("export MINIMAX_API_KEY='your-key-here'")
|
||||
case .mistral:
|
||||
print("Set your Mistral API key:")
|
||||
print("export MISTRAL_API_KEY='your-key-here'")
|
||||
@ -473,7 +481,7 @@ struct AICLI {
|
||||
|
||||
let supportsThinking = self.isReasoningModel(model) && actualApiMode != .chat
|
||||
if config.showThinking, !supportsThinking {
|
||||
print("⚠️ Note: --thinking only works with O3, O4, and GPT-5 models via Responses API")
|
||||
print("⚠️ Note: --thinking only works with GPT-5 models via Responses API")
|
||||
}
|
||||
|
||||
if case let .openai(openaiModel) = model, actualApiMode == .chat {
|
||||
@ -585,15 +593,15 @@ struct AICLI {
|
||||
static func isReasoningModel(_ model: LanguageModel) -> Bool {
|
||||
guard case let .openai(openaiModel) = model else { return false }
|
||||
switch openaiModel {
|
||||
case .o4Mini,
|
||||
.gpt5,
|
||||
case .gpt55,
|
||||
.gpt54,
|
||||
.gpt54Mini,
|
||||
.gpt54Nano,
|
||||
.gpt5Pro,
|
||||
.gpt5Mini,
|
||||
.gpt5Nano,
|
||||
.gpt5Thinking,
|
||||
.gpt5ThinkingMini,
|
||||
.gpt5ThinkingNano,
|
||||
.gpt5ChatLatest:
|
||||
.gpt5Mini:
|
||||
return true
|
||||
case .gpt5,
|
||||
.gpt5Nano:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@ -672,7 +680,7 @@ struct AICLI {
|
||||
}
|
||||
}
|
||||
|
||||
// If no summary, try content array (for O3/O4)
|
||||
// If no summary, try content array.
|
||||
if reasoningText == nil || reasoningText?.isEmpty == true {
|
||||
if let contentArray = output["content"] as? [[String: Any]] {
|
||||
let reasoningParts = contentArray.compactMap { item -> String? in
|
||||
@ -801,23 +809,19 @@ struct AICLI {
|
||||
switch model {
|
||||
case let .openai(openaiModel):
|
||||
switch openaiModel {
|
||||
case .gpt5: return nil // Pricing TBD
|
||||
case .gpt5Mini: return nil // Pricing TBD
|
||||
case .gpt5Nano: return nil // Pricing TBD
|
||||
case .gpt4o:
|
||||
case .gpt55, .gpt54, .gpt5:
|
||||
inputCostPer1k = 0.005
|
||||
outputCostPer1k = 0.015
|
||||
case .gpt4oMini:
|
||||
inputCostPer1k = 0.000_15
|
||||
outputCostPer1k = 0.0006
|
||||
outputCostPer1k = 0.020
|
||||
case .gpt54Mini, .gpt54Nano, .gpt5Mini, .gpt5Nano:
|
||||
return nil // Pricing TBD
|
||||
default: return nil
|
||||
}
|
||||
case let .anthropic(anthropicModel):
|
||||
switch anthropicModel {
|
||||
case .opus4, .opus4Thinking:
|
||||
case .opus47, .opus45, .opus4:
|
||||
inputCostPer1k = 0.015
|
||||
outputCostPer1k = 0.075
|
||||
case .sonnet4, .sonnet4Thinking:
|
||||
case .sonnet46, .sonnet45:
|
||||
inputCostPer1k = 0.003
|
||||
outputCostPer1k = 0.015
|
||||
case .haiku45:
|
||||
@ -825,6 +829,9 @@ struct AICLI {
|
||||
outputCostPer1k = 0.004
|
||||
default: return nil
|
||||
}
|
||||
case .minimax:
|
||||
inputCostPer1k = 0.0003
|
||||
outputCostPer1k = 0.0012
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -56,18 +56,18 @@ extension TachikomaExamples {
|
||||
print("Creating provider-specific models with type safety:")
|
||||
|
||||
// OpenAI models
|
||||
let gpt4o = Model.openai(.gpt4o)
|
||||
let gpt41 = Model.openai(.gpt41)
|
||||
let gpt55 = Model.openai(.gpt55)
|
||||
let gpt54 = Model.openai(.gpt54)
|
||||
let gpt5Mini = Model.openai(.gpt5Mini)
|
||||
|
||||
// Anthropic models
|
||||
let opus4 = Model.anthropic(.opus4)
|
||||
let sonnet4 = Model.anthropic(.sonnet4)
|
||||
let sonnet46 = Model.anthropic(.sonnet46)
|
||||
let haiku45 = Model.anthropic(.haiku45)
|
||||
|
||||
// Grok models
|
||||
let grok4 = Model.grok(.grok4FastReasoning)
|
||||
let grok2Vision = Model.grok(.grok2Vision)
|
||||
let grok43 = Model.grok(.grok43)
|
||||
let grokReasoning = Model.grok(.grok420Reasoning)
|
||||
|
||||
// Ollama models
|
||||
let llama33 = Model.ollama(.llama3_3)
|
||||
@ -75,17 +75,17 @@ extension TachikomaExamples {
|
||||
|
||||
// Custom endpoints
|
||||
let openRouter = Model.openRouter(modelId: "anthropic/claude-sonnet-4.5")
|
||||
let customAPI = Model.openaiCompatible(modelId: "gpt-4", baseURL: "https://api.azure.com")
|
||||
let customAPI = Model.openaiCompatible(modelId: "gpt-5.5", baseURL: "https://api.azure.com")
|
||||
|
||||
let models = [
|
||||
gpt4o,
|
||||
gpt41,
|
||||
gpt55,
|
||||
gpt54,
|
||||
gpt5Mini,
|
||||
opus4,
|
||||
sonnet4,
|
||||
sonnet46,
|
||||
haiku45,
|
||||
grok4,
|
||||
grok2Vision,
|
||||
grok43,
|
||||
grokReasoning,
|
||||
llama33,
|
||||
llava,
|
||||
openRouter,
|
||||
@ -100,9 +100,9 @@ extension TachikomaExamples {
|
||||
|
||||
// Model capabilities
|
||||
print("\nModel capabilities:")
|
||||
print(" • Vision support: \(gpt4o.supportsVision)")
|
||||
print(" • Vision support: \(gpt55.supportsVision)")
|
||||
print(" • Tool support: \(opus4.supportsTools)")
|
||||
print(" • Streaming support: \(sonnet4.supportsStreaming)")
|
||||
print(" • Streaming support: \(sonnet46.supportsStreaming)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -192,7 +192,7 @@ extension TachikomaExamples {
|
||||
// Record some usage
|
||||
try? await tracker.recordUsage(
|
||||
operation: .generation,
|
||||
model: "gpt-4o".lowercased(),
|
||||
model: "gpt-5.5".lowercased(),
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cost: 0.003,
|
||||
@ -273,9 +273,9 @@ extension TachikomaExamples {
|
||||
|
||||
// Test different provider creations (these will fail without API keys, which is expected)
|
||||
let models: [(String, Model)] = [
|
||||
("OpenAI GPT-4o", .openai(.gpt4o)),
|
||||
("OpenAI GPT-5.5", .openai(.gpt55)),
|
||||
("Anthropic Opus 4", .anthropic(.opus4)),
|
||||
("Grok 4 Fast", .grok(.grok4FastReasoning)),
|
||||
("Grok 4.3", .grok(.grok43)),
|
||||
("Ollama Llama 3.3", .ollama(.llama3_3)),
|
||||
]
|
||||
|
||||
@ -319,7 +319,7 @@ extension String {
|
||||
extension Model {
|
||||
var supportsVision: Bool {
|
||||
switch self {
|
||||
case .openai(.gpt4o), .grok(.grok2Vision), .ollama(.llava):
|
||||
case .openai(.gpt55), .ollama(.llava):
|
||||
true
|
||||
default:
|
||||
false
|
||||
|
||||
@ -7,7 +7,7 @@ An advanced AI agent command-line interface with conversation support, MCP tool
|
||||
- **Multi-turn Conversations**: Maintain context across multiple interactions
|
||||
- **MCP Tool Support**: Connect to Model Context Protocol servers for extended capabilities
|
||||
- **Status Bar UI**: Real-time status updates with spinners and progress indicators
|
||||
- **Thinking Display**: Show reasoning process for models that support it (O3, GPT-5)
|
||||
- **Thinking Display**: Show reasoning process for models that support it (GPT-5)
|
||||
- **Interactive Mode**: Continuous conversation with history management
|
||||
- **Multiple Output Formats**: Normal, JSON, or Markdown formatting
|
||||
- **Tool Visualization**: See tool calls with timing and results
|
||||
@ -50,7 +50,7 @@ In interactive mode:
|
||||
```bash
|
||||
agent-cli --model gpt-5 "Explain quantum computing"
|
||||
agent-cli --model claude "Write a haiku"
|
||||
agent-cli --model o3 --thinking "Solve this step by step: ..."
|
||||
agent-cli --model gpt-5-thinking --thinking "Solve this step by step: ..."
|
||||
```
|
||||
|
||||
### With MCP Tools
|
||||
@ -171,13 +171,10 @@ agent-cli --mcp-server "db -- npx @modelcontextprotocol/server-postgres postgres
|
||||
## Supported Models
|
||||
|
||||
### OpenAI
|
||||
- GPT-5 series: `gpt-5`, `gpt-5-mini`, `gpt-5-nano`
|
||||
- O-series: `o3`, `o3-mini`, `o3-pro`, `o4-mini`
|
||||
- GPT-4: `gpt-4.1`, `gpt-4o`, `gpt-4-turbo`
|
||||
- GPT-5 series: `gpt-5.5`, `gpt-5.4`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`
|
||||
|
||||
### Anthropic
|
||||
- Claude 4: `opus-4`, `sonnet-4`
|
||||
- Claude 3.5: `claude`, `sonnet`, `haiku`
|
||||
- Claude 4.x: `claude-opus-4.7`, `claude-sonnet-4.6`, `claude-haiku-4.5`
|
||||
|
||||
### Others
|
||||
- Google: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`
|
||||
@ -209,7 +206,7 @@ agent-cli --mcp-server "db -- npx @modelcontextprotocol/server-sqlite ./app.db"
|
||||
|
||||
### Complex Reasoning
|
||||
```bash
|
||||
agent-cli --model o3 --thinking \
|
||||
agent-cli --model gpt-5-thinking --thinking \
|
||||
"Plan a distributed system architecture for a social media platform"
|
||||
```
|
||||
|
||||
|
||||
@ -163,22 +163,21 @@ final class Agent {
|
||||
switch model {
|
||||
case let .openai(openaiModel):
|
||||
switch openaiModel {
|
||||
case .o4Mini,
|
||||
.gpt5,
|
||||
case .gpt5,
|
||||
.gpt5Pro,
|
||||
.gpt5Mini,
|
||||
.gpt5Nano,
|
||||
.gpt5Thinking,
|
||||
.gpt5ThinkingMini,
|
||||
.gpt5ThinkingNano,
|
||||
.gpt5ChatLatest:
|
||||
.gpt55,
|
||||
.gpt54,
|
||||
.gpt54Mini,
|
||||
.gpt54Nano:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
case let .anthropic(anthropicModel):
|
||||
switch anthropicModel {
|
||||
case .opus4Thinking, .sonnet4Thinking:
|
||||
case .opus47, .opus45, .opus4, .sonnet46, .sonnet45:
|
||||
true
|
||||
default:
|
||||
false
|
||||
|
||||
@ -27,7 +27,7 @@ struct AgentCLI: AsyncParsableCommand {
|
||||
@Argument(help: "Query or task for the agent")
|
||||
var query: String?
|
||||
|
||||
@Option(name: .shortAndLong, help: "AI model to use (e.g., gpt-5, claude, o3)")
|
||||
@Option(name: .shortAndLong, help: "AI model to use (e.g., gpt-5.5, gpt-5-thinking, claude)")
|
||||
var model: String = "gpt-5"
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Interactive conversation mode")
|
||||
@ -500,10 +500,13 @@ struct AgentCLI: AsyncParsableCommand {
|
||||
case .openai: .openai
|
||||
case .anthropic: .anthropic
|
||||
case .google: .google
|
||||
case .minimax: .minimax
|
||||
case .minimaxCN: .minimaxCN
|
||||
case .mistral: .mistral
|
||||
case .groq: .groq
|
||||
case .grok: .grok
|
||||
case .ollama: .ollama
|
||||
case .lmstudio: .lmstudio
|
||||
default: .custom("unknown")
|
||||
}
|
||||
|
||||
@ -548,7 +551,7 @@ enum CLIError: LocalizedError {
|
||||
/// Extension to add provider helpers
|
||||
extension Provider {
|
||||
static var allStandard: [Provider] {
|
||||
[.openai, .anthropic, .google, .mistral, .groq, .grok, .ollama]
|
||||
[.openai, .anthropic, .google, .minimax, .minimaxCN, .mistral, .groq, .grok, .ollama]
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
@ -556,6 +559,8 @@ extension Provider {
|
||||
case .openai: "OpenAI"
|
||||
case .anthropic: "Anthropic"
|
||||
case .google: "Google"
|
||||
case .minimax: "MiniMax"
|
||||
case .minimaxCN: "MiniMax China"
|
||||
case .mistral: "Mistral"
|
||||
case .groq: "Groq"
|
||||
case .grok: "Grok"
|
||||
|
||||
@ -49,7 +49,7 @@ echo " • Stream token handling"
|
||||
echo ""
|
||||
|
||||
echo "🔑 API Integration Examples (require valid API keys):"
|
||||
echo " • OpenAI GPT-4o, GPT-4.1, o3 generation"
|
||||
echo " • OpenAI GPT-5 generation"
|
||||
echo " • Anthropic Claude Opus 4, Sonnet 4 generation"
|
||||
echo " • Grok 4 and Grok 2 Vision models"
|
||||
echo " • Ollama local models (llama3.3, llava)"
|
||||
@ -70,7 +70,7 @@ echo "🚀 How to Use Tachikoma:"
|
||||
echo "========================"
|
||||
echo ""
|
||||
echo "1. Basic Generation:"
|
||||
echo ' let answer = try await generate("What is 2+2?", using: .openai(.gpt4o))'
|
||||
echo ' let answer = try await generate("What is 2+2?", using: .openai(.gpt55))'
|
||||
echo ""
|
||||
echo "2. With Tools:"
|
||||
echo ' @ToolKit'
|
||||
@ -88,4 +88,4 @@ echo ' let response = try await conversation.continue(using: .anthropic(.opus4
|
||||
echo ""
|
||||
|
||||
echo "🕷️ Tachikoma - Intelligent • Adaptable • Reliable"
|
||||
echo " All examples completed successfully!"
|
||||
echo " All examples completed successfully!"
|
||||
|
||||
@ -9,7 +9,7 @@ func demonstrateMultiChannelResponse() async throws {
|
||||
print("=== Multi-Channel Response Demo ===\n")
|
||||
|
||||
let result = try await generateText(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
messages: [
|
||||
.user("Explain how recursion works in programming"),
|
||||
],
|
||||
@ -36,7 +36,7 @@ func demonstrateReasoningEffort() async throws {
|
||||
// High effort for complex problem
|
||||
print("High effort response:")
|
||||
let complexResult = try await generateText(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
messages: [
|
||||
.user("Design a distributed system for real-time collaboration"),
|
||||
],
|
||||
@ -50,7 +50,7 @@ func demonstrateReasoningEffort() async throws {
|
||||
// Low effort for simple query
|
||||
print("\nLow effort response:")
|
||||
let simpleResult = try await generateText(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
messages: [
|
||||
.user("What is the capital of Japan?"),
|
||||
],
|
||||
@ -82,7 +82,7 @@ func demonstrateRetryHandler() async throws {
|
||||
print("Attempting API call...")
|
||||
// Simulate a call that might fail
|
||||
return try await generateText(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
messages: [.user("Hello")],
|
||||
)
|
||||
},
|
||||
@ -144,7 +144,7 @@ func demonstrateEnhancedTools() async throws {
|
||||
}
|
||||
|
||||
let result = try await generateText(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
messages: [
|
||||
.user("What's 25 * 4 and what's the weather in Tokyo?"),
|
||||
],
|
||||
@ -278,7 +278,7 @@ func demonstrateIntegratedFeatures() async throws {
|
||||
do {
|
||||
let result = try await retryHandler.execute {
|
||||
try await generateText(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
messages: [
|
||||
.system("You are a helpful assistant. Use channels to organize your response."),
|
||||
.user("Analyze the benefits of functional programming"),
|
||||
|
||||
@ -16,7 +16,7 @@ public struct RealtimeAPIDemo {
|
||||
// 1. Create configuration
|
||||
print("\n1️⃣ Creating Configuration...")
|
||||
let config = SessionConfiguration(
|
||||
model: "gpt-4o-realtime-preview",
|
||||
model: "gpt-realtime",
|
||||
voice: .nova,
|
||||
instructions: "You are a helpful voice assistant",
|
||||
inputAudioFormat: .pcm16,
|
||||
@ -108,7 +108,7 @@ public struct RealtimeAPIDemo {
|
||||
Example:
|
||||
let conversation = try RealtimeConversation(configuration: config)
|
||||
try await conversation.start(
|
||||
model: .gpt4oRealtime,
|
||||
model: .custom("gpt-realtime"),
|
||||
voice: .nova
|
||||
)
|
||||
════════════════════════════════════════════════
|
||||
@ -118,7 +118,7 @@ public struct RealtimeAPIDemo {
|
||||
/// Create a sample configuration for testing
|
||||
public static func createSampleConfiguration() -> SessionConfiguration {
|
||||
SessionConfiguration.voiceConversation(
|
||||
model: "gpt-4o-realtime-preview",
|
||||
model: "gpt-realtime",
|
||||
voice: .nova,
|
||||
)
|
||||
}
|
||||
@ -127,7 +127,7 @@ public struct RealtimeAPIDemo {
|
||||
public static func validateTypes() -> Bool {
|
||||
// Test configuration creation
|
||||
let config = self.createSampleConfiguration()
|
||||
guard config.model == "gpt-4o-realtime-preview" else { return false }
|
||||
guard config.model == "gpt-realtime" else { return false }
|
||||
|
||||
// Test VAD configuration
|
||||
let vad = RealtimeTurnDetection.serverVAD
|
||||
|
||||
@ -49,7 +49,7 @@ class RealtimeVoiceAssistant {
|
||||
|
||||
// Start the conversation
|
||||
self.conversation = try await startRealtimeConversation(
|
||||
model: .gpt4oRealtime,
|
||||
model: .custom("gpt-realtime"),
|
||||
voice: .nova,
|
||||
instructions: """
|
||||
You are a helpful voice assistant. Keep responses concise and natural.
|
||||
@ -151,7 +151,7 @@ class RealtimeDemo {
|
||||
|
||||
// Server VAD automatically detects when user starts/stops speaking
|
||||
let conversation = try await startRealtimeConversation(
|
||||
model: .gpt4oRealtime,
|
||||
model: .custom("gpt-realtime"),
|
||||
voice: .alloy,
|
||||
instructions: "You are a voice assistant with server-side voice activity detection",
|
||||
)
|
||||
@ -171,7 +171,7 @@ class RealtimeDemo {
|
||||
|
||||
// Configure for both text and audio responses
|
||||
let conversation = try await startRealtimeConversation(
|
||||
model: .gpt4oRealtime,
|
||||
model: .custom("gpt-realtime"),
|
||||
voice: .shimmer,
|
||||
)
|
||||
|
||||
@ -222,7 +222,7 @@ class RealtimeDemo {
|
||||
}
|
||||
|
||||
let conversation = try await startRealtimeConversation(
|
||||
model: .gpt4oRealtime,
|
||||
model: .custom("gpt-realtime"),
|
||||
voice: .echo,
|
||||
instructions: "You are a smart home assistant. Use the available tools to control devices.",
|
||||
tools: [smartHomeTool],
|
||||
@ -315,7 +315,7 @@ struct RealtimeVoiceView: View {
|
||||
private func setupConversation() async {
|
||||
do {
|
||||
conversation = try await startRealtimeConversation(
|
||||
model: .gpt4oRealtime,
|
||||
model: .custom("gpt-realtime"),
|
||||
voice: .nova,
|
||||
)
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ func testRealtimeConfiguration() async throws {
|
||||
// Test 1: Session Configuration
|
||||
print("\n1️⃣ Testing Session Configuration:")
|
||||
let voiceConfig = SessionConfiguration.voiceConversation(
|
||||
model: "gpt-4o-realtime-preview",
|
||||
model: "gpt-realtime",
|
||||
voice: .nova,
|
||||
)
|
||||
print(" ✅ Model: \(voiceConfig.model)")
|
||||
|
||||
@ -24,7 +24,7 @@ class BasicVoiceAssistant: ObservableObject {
|
||||
|
||||
// Start conversation with voice
|
||||
try await self.conversation?.start(
|
||||
model: .gpt4oRealtime,
|
||||
model: .custom("gpt-realtime"),
|
||||
voice: .nova,
|
||||
instructions: "You are a helpful voice assistant. Keep responses concise.",
|
||||
)
|
||||
@ -307,7 +307,7 @@ class AudioStreamingExample {
|
||||
func setupAudioStreaming(apiKey: String) async throws {
|
||||
// Configure for audio streaming
|
||||
var config = EnhancedSessionConfiguration(
|
||||
model: "gpt-4o-realtime-preview",
|
||||
model: "gpt-realtime",
|
||||
voice: .echo,
|
||||
inputAudioFormat: .pcm16,
|
||||
outputAudioFormat: .pcm16,
|
||||
@ -371,7 +371,7 @@ class MultiTurnConversation {
|
||||
func runConversation(apiKey: String) async throws {
|
||||
// Configure for multi-turn dialogue
|
||||
let config = EnhancedSessionConfiguration(
|
||||
model: "gpt-4o-realtime-preview",
|
||||
model: "gpt-realtime",
|
||||
voice: .fable,
|
||||
instructions: """
|
||||
You are a knowledgeable assistant engaged in a multi-turn conversation.
|
||||
@ -547,7 +547,7 @@ class VoiceAssistantViewController: UIViewController {
|
||||
self.conversation = try RealtimeConversation(configuration: config)
|
||||
|
||||
try await self.conversation?.start(
|
||||
model: .gpt4oRealtime,
|
||||
model: .custom("gpt-realtime"),
|
||||
voice: .shimmer,
|
||||
)
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ class RealtimeVoiceAssistant {
|
||||
|
||||
// Start with voice configuration
|
||||
try await conversation.start(
|
||||
model: .gpt4oRealtime,
|
||||
model: .custom("gpt-realtime"),
|
||||
voice: .nova,
|
||||
instructions: "You are a helpful, witty, and friendly AI assistant. Keep responses concise.",
|
||||
)
|
||||
@ -77,7 +77,7 @@ class RealtimeVoiceAssistant {
|
||||
|
||||
// Advanced configuration with all features
|
||||
let config = SessionConfiguration(
|
||||
model: "gpt-4o-realtime-preview",
|
||||
model: "gpt-realtime",
|
||||
voice: .nova,
|
||||
instructions: """
|
||||
You are an expert AI assistant with deep knowledge across many domains.
|
||||
@ -147,7 +147,7 @@ class RealtimeVoiceAssistant {
|
||||
|
||||
// Configuration with tools
|
||||
let config = SessionConfiguration.withTools(
|
||||
model: "gpt-4o-realtime-preview",
|
||||
model: "gpt-realtime",
|
||||
voice: .nova,
|
||||
tools: [
|
||||
// Weather tool
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
{
|
||||
"originHash" : "243e2c6245528e2e7d881e19f057c59ff495268ec997e5ed087ee704fa9a80d8",
|
||||
"originHash" : "12a454cd38a6ae2519d652cc0872f7f18feb64690ce83d1507bae6db71c1841c",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b",
|
||||
"version" : "0.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
"kind" : "remoteSourceControl",
|
||||
@ -24,8 +33,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
|
||||
"version" : "1.7.0"
|
||||
"revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008",
|
||||
"version" : "1.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -33,8 +42,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-async-algorithms.git",
|
||||
"state" : {
|
||||
"revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272",
|
||||
"version" : "1.1.3"
|
||||
"revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440",
|
||||
"version" : "1.1.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -51,8 +60,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections",
|
||||
"state" : {
|
||||
"revision" : "6675bc0ff86e61436e615df6fc5174e043e57924",
|
||||
"version" : "1.4.1"
|
||||
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
|
||||
"version" : "1.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -78,8 +87,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "5073617dac96330a486245e4c0179cb0a6fd2256",
|
||||
"version" : "1.12.0"
|
||||
"revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a",
|
||||
"version" : "1.13.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -87,8 +96,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237",
|
||||
"version" : "2.99.0"
|
||||
"revision" : "a8e036cb8628fcc1ff67dfec6ce8168617172c9b",
|
||||
"version" : "2.101.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -105,8 +114,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
|
||||
"state" : {
|
||||
"revision" : "6132fd4b5b4217ce4717c4775e4607f5c3120129",
|
||||
"version" : "0.12.0"
|
||||
"revision" : "a0ae212ebf6eab5f754c3129608bc5557637e605",
|
||||
"version" : "0.12.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -123,8 +132,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
|
||||
"version" : "1.6.4"
|
||||
"revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1",
|
||||
"version" : "1.7.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@ -1,8 +1,18 @@
|
||||
// swift-tools-version: 6.2
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import Foundation
|
||||
import PackageDescription
|
||||
|
||||
let packageDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||
let localCommanderPath = packageDirectory
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Commander")
|
||||
.appendingPathComponent("Package.swift")
|
||||
let commanderDependency: Package.Dependency = FileManager.default.fileExists(atPath: localCommanderPath.path)
|
||||
? .package(path: "../Commander")
|
||||
: .package(url: "https://github.com/steipete/Commander.git", from: "0.2.2")
|
||||
|
||||
let package = Package(
|
||||
name: "Tachikoma",
|
||||
platforms: [
|
||||
@ -49,7 +59,7 @@ let package = Package(
|
||||
targets: ["TachikomaConfigCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../Commander"),
|
||||
commanderDependency,
|
||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.4"),
|
||||
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.0"),
|
||||
.package(url: "https://github.com/apple/swift-configuration", from: "1.0.0"),
|
||||
|
||||
16
README.md
16
README.md
@ -10,6 +10,8 @@
|
||||
<a href="https://github.com/steipete/Tachikoma/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/steipete/Tachikoma/ci.yml?branch=main&style=for-the-badge&label=tests" alt="CI Status"></a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
Modern, Swift-native APIs for text, vision, tools, and realtime voice.
|
||||
</div>
|
||||
|
||||
@ -39,7 +41,7 @@ print(text)
|
||||
```swift
|
||||
import Tachikoma
|
||||
|
||||
let stream = try await stream("Explain actors in Swift.", using: .openai(.gpt52))
|
||||
let stream = try await stream("Explain actors in Swift.", using: .openai(.gpt54))
|
||||
for try await delta in stream {
|
||||
print(delta.content ?? "", terminator: "")
|
||||
}
|
||||
@ -64,7 +66,7 @@ import Tachikoma
|
||||
|
||||
let pngData: Data = /* ... */
|
||||
let image = ImageInput(data: pngData, mimeType: "image/png")
|
||||
let answer = try await analyze(image: image, prompt: "What’s in this image?", using: .openai(.gpt4o))
|
||||
let answer = try await analyze(image: image, prompt: "What’s in this image?", using: .openai(.gpt55))
|
||||
print(answer)
|
||||
```
|
||||
|
||||
@ -87,7 +89,7 @@ let tool = createTool(
|
||||
}
|
||||
|
||||
let result = try await generateText(
|
||||
model: .openai(.gpt52),
|
||||
model: .openai(.gpt54),
|
||||
messages: [.user("Compute 123 + 456 using the add tool.")],
|
||||
tools: [tool],
|
||||
maxSteps: 3
|
||||
@ -98,10 +100,10 @@ print(result.text)
|
||||
## Models
|
||||
|
||||
Common picks:
|
||||
- Anthropic: `claude-opus-4-5` (`LanguageModel.default`)
|
||||
- OpenAI: `gpt-5.2` (flagship), `gpt-5` (coding/agents), `o4-mini` (reasoning), `gpt-4o` (vision)
|
||||
- Google: `gemini-3-flash`
|
||||
- Grok: `grok-4-fast-reasoning`
|
||||
- Anthropic: `claude-opus-4-8` (`LanguageModel.default`, non-streaming), `claude-fable-5` (explicit opt-in)
|
||||
- OpenAI: `gpt-5.5` (`LanguageModel.defaultStreaming`), `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano`, `gpt-5`
|
||||
- Google: `gemini-3.1-pro-preview`, `gemini-3-flash`
|
||||
- Grok: `grok-4.3`
|
||||
- Local: `ollama/llama3.3`
|
||||
|
||||
Full catalog (including enum case names + provider notes): [`docs/models.md`](docs/models.md).
|
||||
|
||||
@ -25,6 +25,7 @@ public enum TKProviderId: String, CaseIterable, Sendable {
|
||||
case anthropic
|
||||
case grok
|
||||
case gemini
|
||||
case openrouter
|
||||
|
||||
public static func normalize(_ value: String) -> TKProviderId? {
|
||||
let lower = value.lowercased()
|
||||
@ -38,6 +39,7 @@ public enum TKProviderId: String, CaseIterable, Sendable {
|
||||
case .anthropic: "Anthropic"
|
||||
case .grok: "Grok (xAI)"
|
||||
case .gemini: "Gemini"
|
||||
case .openrouter: "OpenRouter"
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,6 +57,7 @@ public enum TKProviderId: String, CaseIterable, Sendable {
|
||||
// swiftformat:enable indent
|
||||
case .grok: ["X_AI_API_KEY", "XAI_API_KEY", "GROK_API_KEY"]
|
||||
case .gemini: ["GEMINI_API_KEY"]
|
||||
case .openrouter: ["OPENROUTER_API_KEY"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,8 +81,7 @@ public struct TKCredentialStore {
|
||||
public init() {}
|
||||
|
||||
private var baseDir: String {
|
||||
let dir = TachikomaConfiguration.profileDirectoryName
|
||||
return NSString(string: "~/" + dir).expandingTildeInPath
|
||||
TachikomaConfiguration.profileDirectoryPath
|
||||
}
|
||||
|
||||
private var credentialsPath: String {
|
||||
@ -137,8 +139,8 @@ public final class TKAuthManager {
|
||||
|
||||
private init() {}
|
||||
|
||||
private func environmentValue(for key: String) -> String? {
|
||||
guard !self.ignoreEnv else { return nil }
|
||||
private func environmentValue(for key: String, ignoringEnvironment: Bool) -> String? {
|
||||
guard !ignoringEnvironment else { return nil }
|
||||
let value = key.withCString { keyPtr -> String? in
|
||||
guard let cValue = getenv(keyPtr) else { return nil }
|
||||
let string = String(cString: cValue)
|
||||
@ -149,6 +151,14 @@ public final class TKAuthManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func authState() -> (ignoreEnv: Bool, creds: [String: String]) {
|
||||
self.lock.lock()
|
||||
let ignoreEnv = self.ignoreEnv
|
||||
let creds = self.ignoreStore ? [:] : self.store.load()
|
||||
self.lock.unlock()
|
||||
return (ignoreEnv, creds)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setIgnoreEnvironment(_ value: Bool) -> Bool {
|
||||
self.lock.lock()
|
||||
@ -168,20 +178,17 @@ public final class TKAuthManager {
|
||||
}
|
||||
|
||||
public func credentialValue(for key: String) -> String? {
|
||||
self.lock.lock()
|
||||
let creds = self.ignoreStore ? [:] : self.store.load()
|
||||
self.lock.unlock()
|
||||
if let env = self.environmentValue(for: key) { return env }
|
||||
return creds[key]
|
||||
let state = self.authState()
|
||||
if let env = self.environmentValue(for: key, ignoringEnvironment: state.ignoreEnv) { return env }
|
||||
return state.creds[key]
|
||||
}
|
||||
|
||||
public func resolveAuth(for provider: TKProviderId) -> TKAuthValue? {
|
||||
self.lock.lock()
|
||||
let creds = self.ignoreStore ? [:] : self.store.load()
|
||||
self.lock.unlock()
|
||||
let state = self.authState()
|
||||
let creds = state.creds
|
||||
switch provider {
|
||||
case .openai:
|
||||
if let env = self.environmentValue(for: "OPENAI_API_KEY") {
|
||||
if let env = self.environmentValue(for: "OPENAI_API_KEY", ignoringEnvironment: state.ignoreEnv) {
|
||||
return .bearer(env, betaHeader: nil)
|
||||
}
|
||||
if let access = creds["OPENAI_ACCESS_TOKEN"], !access.isEmpty {
|
||||
@ -191,7 +198,7 @@ public final class TKAuthManager {
|
||||
return .apiKey(key)
|
||||
}
|
||||
case .anthropic:
|
||||
if let env = self.environmentValue(for: "ANTHROPIC_API_KEY") {
|
||||
if let env = self.environmentValue(for: "ANTHROPIC_API_KEY", ignoringEnvironment: state.ignoreEnv) {
|
||||
return .apiKey(env)
|
||||
}
|
||||
if let access = creds["ANTHROPIC_ACCESS_TOKEN"], !access.isEmpty {
|
||||
@ -204,7 +211,7 @@ public final class TKAuthManager {
|
||||
case .grok:
|
||||
let envOrder = ["X_AI_API_KEY", "XAI_API_KEY", "GROK_API_KEY"]
|
||||
for k in envOrder {
|
||||
if let env = self.environmentValue(for: k) {
|
||||
if let env = self.environmentValue(for: k, ignoringEnvironment: state.ignoreEnv) {
|
||||
return .bearer(env, betaHeader: nil)
|
||||
}
|
||||
}
|
||||
@ -212,10 +219,17 @@ public final class TKAuthManager {
|
||||
if let val = creds[k], !val.isEmpty { return .bearer(val, betaHeader: nil) }
|
||||
}
|
||||
case .gemini:
|
||||
if let env = self.environmentValue(for: "GEMINI_API_KEY") {
|
||||
if let env = self.environmentValue(for: "GEMINI_API_KEY", ignoringEnvironment: state.ignoreEnv) {
|
||||
return .apiKey(env)
|
||||
}
|
||||
if let val = creds["GEMINI_API_KEY"], !val.isEmpty { return .apiKey(val) }
|
||||
case .openrouter:
|
||||
if let env = self.environmentValue(for: "OPENROUTER_API_KEY", ignoringEnvironment: state.ignoreEnv) {
|
||||
return .bearer(env, betaHeader: nil)
|
||||
}
|
||||
if let val = creds["OPENROUTER_API_KEY"], !val.isEmpty {
|
||||
return .bearer(val, betaHeader: nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -311,7 +325,7 @@ public final class TKAuthManager {
|
||||
requiresStateInTokenExchange: true,
|
||||
pkce: pkce,
|
||||
)
|
||||
case .grok, .gemini:
|
||||
case .grok, .gemini, .openrouter:
|
||||
OAuthConfig(
|
||||
prefix: "",
|
||||
authorize: "",
|
||||
@ -575,7 +589,7 @@ struct TKProviderValidator {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: [
|
||||
"model": "claude-3-haiku-20241022",
|
||||
"model": "claude-haiku-4-5",
|
||||
"max_tokens": 1,
|
||||
"messages": [
|
||||
["role": "user", "content": "ping"],
|
||||
@ -594,6 +608,13 @@ struct TKProviderValidator {
|
||||
var request = URLRequest(url: URL(string: url)!)
|
||||
request.httpMethod = "GET"
|
||||
return await HTTP.perform(request: request, timeoutSeconds: self.timeoutSeconds)
|
||||
case .openrouter:
|
||||
return await self.validateBearer(
|
||||
url: "https://openrouter.ai/api/v1/key",
|
||||
secret: secret,
|
||||
header: "Authorization",
|
||||
valuePrefix: "Bearer ",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,18 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
struct AnthropicReasoningReplayTarget {
|
||||
let provider: String
|
||||
let modelId: String
|
||||
let endpointIdentity: String?
|
||||
let allowsLegacyUnknown: Bool
|
||||
|
||||
func matches(_ customData: [String: String]) -> Bool {
|
||||
guard customData["tachikoma.reasoning.provider"] == self.provider else {
|
||||
return false
|
||||
}
|
||||
guard customData["tachikoma.reasoning.model"] == self.modelId else {
|
||||
return false
|
||||
}
|
||||
return customData["tachikoma.reasoning.base_url"] == self.endpointIdentity
|
||||
}
|
||||
}
|
||||
|
||||
enum AnthropicMessageConversion {
|
||||
static func convertMessagesToAnthropic(
|
||||
_ messages: [ModelMessage],
|
||||
thinkingEnabled: Bool,
|
||||
reasoningTarget: AnthropicReasoningReplayTarget? = nil,
|
||||
) throws
|
||||
-> (String?, [AnthropicMessage])
|
||||
{
|
||||
var systemMessage: String?
|
||||
var anthropicMessages: [AnthropicMessage] = []
|
||||
var pendingSignedThinking: (text: String, signature: String, type: String)?
|
||||
var pendingThinkingBlocks: [(text: String, signature: String?, type: String)] = []
|
||||
let thinkingSignatureKey = "anthropic.thinking.signature"
|
||||
let thinkingTypeKey = "anthropic.thinking.type"
|
||||
|
||||
func appendThinkingBlocks(
|
||||
_ pendingBlocks: [(text: String, signature: String?, type: String)],
|
||||
to content: inout [AnthropicContent],
|
||||
) {
|
||||
for pending in pendingBlocks {
|
||||
if pending.type == "redacted_thinking" {
|
||||
content.append(.redactedThinking(.init(
|
||||
type: "redacted_thinking",
|
||||
data: pending.text,
|
||||
)))
|
||||
} else if let signature = pending.signature {
|
||||
content.append(.thinking(.init(
|
||||
type: "thinking",
|
||||
thinking: pending.text,
|
||||
signature: signature,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for message in messages {
|
||||
switch message.role {
|
||||
case .system:
|
||||
@ -48,29 +86,32 @@ enum AnthropicMessageConversion {
|
||||
}.joined()
|
||||
let signature = message.metadata?.customData?[thinkingSignatureKey]
|
||||
let type = message.metadata?.customData?[thinkingTypeKey] ?? "thinking"
|
||||
if let signature, !signature.isEmpty {
|
||||
pendingSignedThinking = (text: text, signature: signature, type: type)
|
||||
let customData = message.metadata?.customData ?? [:]
|
||||
if
|
||||
customData["tachikoma.reasoning.provider"] != nil ||
|
||||
customData["tachikoma.reasoning.model"] != nil ||
|
||||
customData["tachikoma.reasoning.base_url"] != nil ||
|
||||
customData["anthropic.thinking.model"] != nil
|
||||
{
|
||||
guard reasoningTarget?.matches(customData) == true else {
|
||||
continue
|
||||
}
|
||||
} else if reasoningTarget?.allowsLegacyUnknown != true {
|
||||
continue
|
||||
}
|
||||
if type == "redacted_thinking" {
|
||||
pendingThinkingBlocks.append((text: text, signature: nil, type: type))
|
||||
} else if let signature, !signature.isEmpty {
|
||||
pendingThinkingBlocks.append((text: text, signature: signature, type: type))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var content: [AnthropicContent] = []
|
||||
|
||||
if thinkingEnabled, let pending = pendingSignedThinking {
|
||||
if pending.type == "redacted_thinking" {
|
||||
content.append(.redactedThinking(.init(
|
||||
type: "redacted_thinking",
|
||||
redactedThinking: pending.text,
|
||||
signature: pending.signature,
|
||||
)))
|
||||
} else {
|
||||
content.append(.thinking(.init(
|
||||
type: "thinking",
|
||||
thinking: pending.text,
|
||||
signature: pending.signature,
|
||||
)))
|
||||
}
|
||||
pendingSignedThinking = nil
|
||||
if thinkingEnabled, !pendingThinkingBlocks.isEmpty {
|
||||
appendThinkingBlocks(pendingThinkingBlocks, to: &content)
|
||||
pendingThinkingBlocks.removeAll()
|
||||
}
|
||||
|
||||
// Process each content part
|
||||
@ -139,21 +180,12 @@ enum AnthropicMessageConversion {
|
||||
}
|
||||
}
|
||||
|
||||
if thinkingEnabled, let pending = pendingSignedThinking {
|
||||
let thinkingContent: AnthropicContent = if pending.type == "redacted_thinking" {
|
||||
.redactedThinking(.init(
|
||||
type: "redacted_thinking",
|
||||
redactedThinking: pending.text,
|
||||
signature: pending.signature,
|
||||
))
|
||||
} else {
|
||||
.thinking(.init(
|
||||
type: "thinking",
|
||||
thinking: pending.text,
|
||||
signature: pending.signature,
|
||||
))
|
||||
if thinkingEnabled, !pendingThinkingBlocks.isEmpty {
|
||||
var content: [AnthropicContent] = []
|
||||
appendThinkingBlocks(pendingThinkingBlocks, to: &content)
|
||||
if !content.isEmpty {
|
||||
anthropicMessages.append(AnthropicMessage(role: "assistant", content: content))
|
||||
}
|
||||
anthropicMessages.append(AnthropicMessage(role: "assistant", content: [thinkingContent]))
|
||||
}
|
||||
|
||||
return (systemMessage, anthropicMessages)
|
||||
|
||||
@ -1,3 +1,12 @@
|
||||
#if canImport(CryptoKit)
|
||||
import CryptoKit
|
||||
|
||||
private typealias ReasoningEndpointHasher = CryptoKit.SHA256
|
||||
#else
|
||||
import Crypto
|
||||
|
||||
private typealias ReasoningEndpointHasher = Crypto.SHA256
|
||||
#endif
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
@ -16,18 +25,46 @@ public final class AnthropicProvider: ModelProvider {
|
||||
private let model: LanguageModel.Anthropic
|
||||
private let auth: TKAuthValue
|
||||
private let betaHeader: String
|
||||
private let additionalHeaders: [String: String]
|
||||
private let reasoningProvider: String
|
||||
private let reasoningModelId: String
|
||||
private let reasoningBaseURL: String?
|
||||
private let urlSession: URLSession
|
||||
|
||||
private static let requiredBetaFlags: [String] = [
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
]
|
||||
|
||||
public init(model: LanguageModel.Anthropic, configuration: TachikomaConfiguration) throws {
|
||||
public init(
|
||||
model: LanguageModel.Anthropic,
|
||||
configuration: TachikomaConfiguration,
|
||||
additionalHeaders: [String: String] = [:],
|
||||
authOverride: TKAuthValue? = nil,
|
||||
reasoningProvider: String = "anthropic",
|
||||
reasoningModelId: String? = nil,
|
||||
reasoningBaseURL: String? = nil,
|
||||
urlSession: URLSession = .shared,
|
||||
) throws {
|
||||
self.model = model
|
||||
self.modelId = model.modelId
|
||||
self.baseURL = configuration.getBaseURL(for: .anthropic) ?? "https://api.anthropic.com"
|
||||
self.additionalHeaders = additionalHeaders
|
||||
self.reasoningProvider = reasoningProvider
|
||||
self.reasoningModelId = reasoningModelId ?? model.modelId
|
||||
self.reasoningBaseURL = ReasoningEndpointIdentity.canonical(
|
||||
reasoningBaseURL ?? (reasoningProvider == "anthropic" ? self.baseURL : nil),
|
||||
)
|
||||
self.urlSession = urlSession
|
||||
|
||||
if let key = configuration.getAPIKey(for: .anthropic) {
|
||||
if let authOverride {
|
||||
self.auth = authOverride
|
||||
switch authOverride {
|
||||
case let .apiKey(key):
|
||||
self.apiKey = key
|
||||
case let .bearer(token, _):
|
||||
self.apiKey = token
|
||||
}
|
||||
} else if let key = configuration.getAPIKey(for: .anthropic) {
|
||||
self.auth = .apiKey(key)
|
||||
self.apiKey = key
|
||||
} else if let auth = TKAuthManager.shared.resolveAuth(for: .anthropic) {
|
||||
@ -42,16 +79,18 @@ public final class AnthropicProvider: ModelProvider {
|
||||
throw TachikomaError.authenticationFailed("ANTHROPIC_API_KEY not found")
|
||||
}
|
||||
|
||||
self.betaHeader = Self.mergedBetaHeader(configuration: configuration, auth: self.auth)
|
||||
self.betaHeader = Self.mergedBetaHeader(configuration: configuration, auth: self.auth, model: model)
|
||||
|
||||
let isFable = Self.isFable(model: model)
|
||||
let supportsSafeStreaming = !Self.hasStreamingRefusalRisk(model: model)
|
||||
self.capabilities = ModelCapabilities(
|
||||
supportsVision: model.supportsVision,
|
||||
supportsTools: model.supportsTools,
|
||||
supportsStreaming: true,
|
||||
supportsStreaming: supportsSafeStreaming,
|
||||
supportsAudioInput: model.supportsAudioInput,
|
||||
supportsAudioOutput: model.supportsAudioOutput,
|
||||
contextLength: model.contextLength,
|
||||
maxOutputTokens: 4096,
|
||||
contextLength: isFable ? 1_000_000 : model.contextLength,
|
||||
maxOutputTokens: isFable ? 128_000 : model.maxOutputTokens,
|
||||
)
|
||||
}
|
||||
|
||||
@ -79,6 +118,16 @@ public final class AnthropicProvider: ModelProvider {
|
||||
}
|
||||
|
||||
private static func mergedBetaHeader(configuration: TachikomaConfiguration, auth: TKAuthValue) -> String {
|
||||
self.mergedBetaHeader(configuration: configuration, auth: auth, model: nil)
|
||||
}
|
||||
|
||||
private static func mergedBetaHeader(
|
||||
configuration: TachikomaConfiguration,
|
||||
auth: TKAuthValue,
|
||||
model: LanguageModel.Anthropic?,
|
||||
)
|
||||
-> String
|
||||
{
|
||||
var existing: String?
|
||||
if case let .bearer(_, betaHeader) = auth {
|
||||
existing = betaHeader
|
||||
@ -88,19 +137,88 @@ public final class AnthropicProvider: ModelProvider {
|
||||
existing = configuration.credentialValue(for: "ANTHROPIC_BETA_HEADER")
|
||||
}
|
||||
|
||||
if let model, Self.isFable(model: model) {
|
||||
return existing?
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: ",") ?? ""
|
||||
}
|
||||
|
||||
return Self.mergedBetaHeader(existing: existing)
|
||||
}
|
||||
|
||||
private func anthropicThinking(from mode: AnthropicOptions.ThinkingMode?) -> AnthropicThinking? {
|
||||
private func anthropicThinking(
|
||||
from mode: AnthropicOptions.ThinkingMode?,
|
||||
model: LanguageModel.Anthropic,
|
||||
)
|
||||
-> AnthropicThinking?
|
||||
{
|
||||
guard let mode else { return nil }
|
||||
switch mode {
|
||||
case .disabled:
|
||||
return nil
|
||||
case .adaptive:
|
||||
if Self.isFable(model: model) { return nil }
|
||||
guard self.usesAdaptiveThinking(model: model) else { return nil }
|
||||
return AnthropicThinking(type: "adaptive", budgetTokens: nil)
|
||||
case let .enabled(budgetTokens):
|
||||
if Self.isFable(model: model) {
|
||||
return nil
|
||||
}
|
||||
if case .opus48 = model {
|
||||
return AnthropicThinking(type: "adaptive", budgetTokens: nil)
|
||||
}
|
||||
if case .opus47 = model {
|
||||
return AnthropicThinking(type: "adaptive", budgetTokens: nil)
|
||||
}
|
||||
return AnthropicThinking(type: "enabled", budgetTokens: budgetTokens)
|
||||
}
|
||||
}
|
||||
|
||||
private func anthropicOutputConfig(
|
||||
from mode: AnthropicOptions.ThinkingMode?,
|
||||
settings: GenerationSettings,
|
||||
model: LanguageModel.Anthropic,
|
||||
)
|
||||
-> AnthropicOutputConfig?
|
||||
{
|
||||
guard self.supportsEffort(model: model) else { return nil }
|
||||
if let effort = settings.reasoningEffort?.rawValue {
|
||||
return AnthropicOutputConfig(effort: effort)
|
||||
}
|
||||
if case .disabled = mode { return nil }
|
||||
|
||||
let effort = self.usesAdaptiveThinking(model: model) ? self.adaptiveEffort(from: mode) : nil
|
||||
return effort.map { AnthropicOutputConfig(effort: $0) }
|
||||
}
|
||||
|
||||
private func usesAdaptiveThinking(model: LanguageModel.Anthropic) -> Bool {
|
||||
if Self.isFable(model: model) { return true }
|
||||
if case .opus48 = model { return true }
|
||||
if case .opus47 = model { return true }
|
||||
if case .sonnet46 = model { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func supportsEffort(model: LanguageModel.Anthropic) -> Bool {
|
||||
if Self.isFable(model: model) { return true }
|
||||
switch model {
|
||||
case .opus48, .opus47, .opus45, .sonnet46:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func adaptiveEffort(from mode: AnthropicOptions.ThinkingMode?) -> String? {
|
||||
guard case let .enabled(budgetTokens) = mode else { return nil }
|
||||
|
||||
if budgetTokens <= 4096 { return ReasoningEffort.low.rawValue }
|
||||
if budgetTokens <= 12000 { return ReasoningEffort.medium.rawValue }
|
||||
return ReasoningEffort.high.rawValue
|
||||
}
|
||||
|
||||
private func messagesEndpointURL() throws -> URL {
|
||||
guard let baseURL = self.baseURL, let url = URL(string: baseURL) else {
|
||||
throw TachikomaError.invalidConfiguration("Invalid Anthropic base URL: \(self.baseURL ?? "<nil>")")
|
||||
@ -127,16 +245,49 @@ public final class AnthropicProvider: ModelProvider {
|
||||
self.applyAuth(to: &urlRequest, secret: apiKey)
|
||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
urlRequest.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
|
||||
for (key, value) in self.additionalHeaders {
|
||||
urlRequest.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
let requestedThinking = self.anthropicThinking(from: request.settings.providerOptions.anthropic?.thinking)
|
||||
let validatedSettings = request.settings.validated(for: .anthropic(self.model))
|
||||
if
|
||||
Self.isFable(model: self.model),
|
||||
case .disabled = validatedSettings.providerOptions.anthropic?.thinking
|
||||
{
|
||||
throw TachikomaError.invalidConfiguration(
|
||||
"Claude Fable 5 always uses adaptive thinking; disabled thinking is not supported",
|
||||
)
|
||||
}
|
||||
if Self.isFable(model: self.model), request.messages.last?.role == .assistant {
|
||||
throw TachikomaError.invalidConfiguration(
|
||||
"Claude Fable 5 does not support assistant prefill requests",
|
||||
)
|
||||
}
|
||||
let requestedThinking = self.anthropicThinking(
|
||||
from: validatedSettings.providerOptions.anthropic?.thinking,
|
||||
model: self.model,
|
||||
)
|
||||
let outputConfig = self.anthropicOutputConfig(
|
||||
from: validatedSettings.providerOptions.anthropic?.thinking,
|
||||
settings: validatedSettings,
|
||||
model: self.model,
|
||||
)
|
||||
var thinking: AnthropicThinking?
|
||||
let systemMessage: String?
|
||||
let messages: [AnthropicMessage]
|
||||
let preserveSignedThinking = requestedThinking != nil || self.requiresSignedThinkingReplay(model: self.model)
|
||||
let reasoningTarget = AnthropicReasoningReplayTarget(
|
||||
provider: self.reasoningProvider,
|
||||
modelId: self.reasoningModelId,
|
||||
endpointIdentity: self.reasoningBaseURL,
|
||||
allowsLegacyUnknown: !Self.isFable(model: self.model),
|
||||
)
|
||||
do {
|
||||
thinking = requestedThinking
|
||||
(systemMessage, messages) = try AnthropicMessageConversion.convertMessagesToAnthropic(
|
||||
request.messages,
|
||||
thinkingEnabled: requestedThinking != nil,
|
||||
thinkingEnabled: preserveSignedThinking,
|
||||
reasoningTarget: reasoningTarget,
|
||||
)
|
||||
} catch {
|
||||
// If we can't provide signed thinking blocks for a cached/history session, fall back to non-thinking mode.
|
||||
@ -145,19 +296,26 @@ public final class AnthropicProvider: ModelProvider {
|
||||
(systemMessage, messages) = try AnthropicMessageConversion.convertMessagesToAnthropic(
|
||||
request.messages,
|
||||
thinkingEnabled: false,
|
||||
reasoningTarget: reasoningTarget,
|
||||
)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
let maxTokens = validatedSettings.maxTokens ?? self.defaultMaxTokens(for: self.model)
|
||||
if !stream, Self.requiresExtendedNonStreamingTimeout(model: self.model, maxTokens: maxTokens) {
|
||||
urlRequest.timeoutInterval = 1800
|
||||
}
|
||||
|
||||
let anthropicRequest = try AnthropicMessageRequest(
|
||||
model: modelId,
|
||||
maxTokens: request.settings.maxTokens ?? 1024,
|
||||
temperature: request.settings.temperature,
|
||||
maxTokens: maxTokens,
|
||||
temperature: thinking == nil ? validatedSettings.temperature : nil,
|
||||
system: systemMessage,
|
||||
messages: messages,
|
||||
tools: request.tools?.map { try self.convertToolToAnthropic($0) },
|
||||
thinking: thinking,
|
||||
outputConfig: outputConfig,
|
||||
stream: stream,
|
||||
)
|
||||
|
||||
@ -187,7 +345,7 @@ public final class AnthropicProvider: ModelProvider {
|
||||
}
|
||||
}
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest)
|
||||
let (data, response) = try await self.urlSession.data(for: urlRequest)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw TachikomaError.networkError(NSError(domain: "Invalid response", code: 0))
|
||||
@ -219,7 +377,7 @@ public final class AnthropicProvider: ModelProvider {
|
||||
switch content {
|
||||
case let .text(textContent):
|
||||
textContent.text
|
||||
case .toolUse:
|
||||
case .thinking, .redactedThinking, .toolUse:
|
||||
nil
|
||||
}
|
||||
}.joined()
|
||||
@ -229,19 +387,63 @@ public final class AnthropicProvider: ModelProvider {
|
||||
outputTokens: anthropicResponse.usage.outputTokens,
|
||||
)
|
||||
|
||||
let finishReason: FinishReason? = switch anthropicResponse.stopReason {
|
||||
case "end_turn": .stop
|
||||
case "max_tokens": .length
|
||||
case "tool_use": .toolCalls
|
||||
case "stop_sequence": .stop
|
||||
default: .other
|
||||
let finishReason = Self.mapFinishReason(anthropicResponse.stopReason)
|
||||
if finishReason == .contentFilter {
|
||||
let fallbackRefusalText = if let category = anthropicResponse.stopDetails?.category {
|
||||
"Request refused by Anthropic content filter (\(category))"
|
||||
} else {
|
||||
"Request refused by Anthropic content filter"
|
||||
}
|
||||
let refusalText = anthropicResponse.stopDetails?.explanation ?? fallbackRefusalText
|
||||
return ProviderResponse(
|
||||
text: refusalText,
|
||||
usage: usage,
|
||||
finishReason: finishReason,
|
||||
toolCalls: nil,
|
||||
reasoning: [],
|
||||
assistantMessages: [],
|
||||
isBillable: usage.outputTokens > 0,
|
||||
)
|
||||
}
|
||||
|
||||
// Convert tool calls if present
|
||||
let toolCalls = anthropicResponse.content.compactMap { content -> AgentToolCall? in
|
||||
var reasoning: [ProviderReasoningBlock] = []
|
||||
var toolCalls: [AgentToolCall] = []
|
||||
var assistantMessages: [ModelMessage] = []
|
||||
|
||||
for content in anthropicResponse.content {
|
||||
switch content {
|
||||
case .text:
|
||||
return nil
|
||||
case let .text(textContent):
|
||||
if !textContent.text.isEmpty {
|
||||
assistantMessages.append(.assistant(textContent.text))
|
||||
}
|
||||
case let .thinking(thinking):
|
||||
let block = ProviderReasoningBlock(
|
||||
text: thinking.thinking,
|
||||
signature: thinking.signature,
|
||||
type: thinking.type,
|
||||
)
|
||||
reasoning.append(block)
|
||||
assistantMessages.append(ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text(thinking.thinking)],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: self.reasoningMetadata(
|
||||
type: thinking.type,
|
||||
signature: thinking.signature,
|
||||
)),
|
||||
))
|
||||
case let .redactedThinking(thinking):
|
||||
let block = ProviderReasoningBlock(
|
||||
text: thinking.data,
|
||||
type: thinking.type,
|
||||
)
|
||||
reasoning.append(block)
|
||||
assistantMessages.append(ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text(thinking.data)],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: self.reasoningMetadata(type: thinking.type)),
|
||||
))
|
||||
case let .toolUse(toolUse):
|
||||
// Convert input to AnyAgentToolValue dictionary
|
||||
var arguments: [String: AnyAgentToolValue] = [:]
|
||||
@ -257,11 +459,13 @@ public final class AnthropicProvider: ModelProvider {
|
||||
}
|
||||
}
|
||||
|
||||
return AgentToolCall(
|
||||
let toolCall = AgentToolCall(
|
||||
id: toolUse.id,
|
||||
name: toolUse.name,
|
||||
arguments: arguments,
|
||||
)
|
||||
toolCalls.append(toolCall)
|
||||
assistantMessages.append(ModelMessage(role: .assistant, content: [.toolCall(toolCall)]))
|
||||
}
|
||||
}
|
||||
|
||||
@ -270,9 +474,61 @@ public final class AnthropicProvider: ModelProvider {
|
||||
usage: usage,
|
||||
finishReason: finishReason,
|
||||
toolCalls: toolCalls.isEmpty ? nil : toolCalls,
|
||||
reasoning: reasoning,
|
||||
assistantMessages: assistantMessages,
|
||||
)
|
||||
}
|
||||
|
||||
private func reasoningMetadata(type: String, signature: String? = nil) -> [String: String] {
|
||||
var metadata = [
|
||||
"anthropic.thinking.model": self.reasoningModelId,
|
||||
"anthropic.thinking.type": type,
|
||||
"tachikoma.reasoning.provider": self.reasoningProvider,
|
||||
"tachikoma.reasoning.model": self.reasoningModelId,
|
||||
]
|
||||
if let signature, !signature.isEmpty {
|
||||
metadata["anthropic.thinking.signature"] = signature
|
||||
}
|
||||
if let reasoningBaseURL {
|
||||
metadata["tachikoma.reasoning.base_url"] = reasoningBaseURL
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
private func requiresSignedThinkingReplay(model: LanguageModel.Anthropic) -> Bool {
|
||||
Self.isFable(model: model)
|
||||
}
|
||||
|
||||
private func defaultMaxTokens(for model: LanguageModel.Anthropic) -> Int {
|
||||
if Self.isFable(model: model) { return min(128_000, 16384) }
|
||||
return 1024
|
||||
}
|
||||
|
||||
private static func isFable(model: LanguageModel.Anthropic) -> Bool {
|
||||
LanguageModel.Anthropic.isFable(modelId: model.modelId)
|
||||
}
|
||||
|
||||
private static func hasStreamingRefusalRisk(model: LanguageModel.Anthropic) -> Bool {
|
||||
LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: model.modelId)
|
||||
}
|
||||
|
||||
private static func requiresExtendedNonStreamingTimeout(model: LanguageModel.Anthropic, maxTokens: Int) -> Bool {
|
||||
self.isFable(model: model) || maxTokens >= 64000
|
||||
}
|
||||
|
||||
static func mapFinishReason(_ stopReason: String?) -> FinishReason? {
|
||||
switch stopReason {
|
||||
case "end_turn": .stop
|
||||
case "max_tokens": .length
|
||||
case "tool_use": .toolCalls
|
||||
case "stop_sequence": .stop
|
||||
case "model_context_window_exceeded": .length
|
||||
case "refusal": .contentFilter
|
||||
case nil: nil
|
||||
default: .other
|
||||
}
|
||||
}
|
||||
|
||||
private func applyAuth(to request: inout URLRequest, secret: String) {
|
||||
switch self.auth {
|
||||
case .apiKey:
|
||||
@ -280,10 +536,19 @@ public final class AnthropicProvider: ModelProvider {
|
||||
case .bearer:
|
||||
request.setValue("Bearer " + secret, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
request.setValue(self.betaHeader, forHTTPHeaderField: "anthropic-beta")
|
||||
if !self.betaHeader.isEmpty {
|
||||
request.setValue(self.betaHeader, forHTTPHeaderField: "anthropic-beta")
|
||||
}
|
||||
}
|
||||
|
||||
public func streamText(request: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, Error> {
|
||||
guard !Self.hasStreamingRefusalRisk(model: self.model) else {
|
||||
let message = "\(self.model.modelId) streaming is disabled because Anthropic refusals require rollback-aware handling"
|
||||
throw TachikomaError.invalidConfiguration(
|
||||
"\(message); use generateText instead",
|
||||
)
|
||||
}
|
||||
|
||||
let urlRequest = try self.makeURLRequest(for: request, stream: true)
|
||||
|
||||
// Debug logging only when explicitly enabled
|
||||
@ -334,7 +599,7 @@ public final class AnthropicProvider: ModelProvider {
|
||||
(Data, URLResponse),
|
||||
Error,
|
||||
>) in
|
||||
URLSession.shared.dataTask(with: urlRequest) { data, response, error in
|
||||
self.urlSession.dataTask(with: urlRequest) { data, response, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let data, let response {
|
||||
@ -362,7 +627,7 @@ public final class AnthropicProvider: ModelProvider {
|
||||
let lines = String(data: data, encoding: .utf8)?.components(separatedBy: "\n") ?? []
|
||||
#else
|
||||
// macOS/iOS: Use streaming API
|
||||
let (bytes, response) = try await URLSession.shared.bytes(for: urlRequest)
|
||||
let (bytes, response) = try await self.urlSession.bytes(for: urlRequest)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw TachikomaError.networkError(NSError(domain: "Invalid response", code: 0))
|
||||
@ -386,6 +651,7 @@ public final class AnthropicProvider: ModelProvider {
|
||||
var currentReasoningSignature: String?
|
||||
var currentReasoningType: String?
|
||||
var reasoningSignatureEmitted = false
|
||||
var finishReason: FinishReason?
|
||||
|
||||
do {
|
||||
for try await line in bytes.lines {
|
||||
@ -419,7 +685,7 @@ public final class AnthropicProvider: ModelProvider {
|
||||
currentReasoningType = nil
|
||||
reasoningSignatureEmitted = false
|
||||
}
|
||||
continuation.yield(TextStreamDelta.done())
|
||||
continuation.yield(.done(finishReason: finishReason))
|
||||
break
|
||||
}
|
||||
|
||||
@ -450,6 +716,12 @@ public final class AnthropicProvider: ModelProvider {
|
||||
currentReasoningSignature = nil
|
||||
currentReasoningType = block.type
|
||||
reasoningSignatureEmitted = false
|
||||
if block.type == "redacted_thinking", let data = block.data {
|
||||
continuation.yield(TextStreamDelta.reasoning(
|
||||
data,
|
||||
type: "redacted_thinking",
|
||||
))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
@ -554,7 +826,9 @@ public final class AnthropicProvider: ModelProvider {
|
||||
|
||||
case "message_delta":
|
||||
// Message-level updates (usage, etc.)
|
||||
// Usage is typically included in the done event, not separately
|
||||
if let stopReason = event.delta?.stopReason {
|
||||
finishReason = Self.mapFinishReason(stopReason)
|
||||
}
|
||||
continue
|
||||
|
||||
case "message_stop":
|
||||
@ -574,7 +848,7 @@ public final class AnthropicProvider: ModelProvider {
|
||||
currentReasoningType = nil
|
||||
reasoningSignatureEmitted = false
|
||||
}
|
||||
continuation.yield(TextStreamDelta.done())
|
||||
continuation.yield(.done(finishReason: finishReason))
|
||||
|
||||
default:
|
||||
// Unknown event type, skip
|
||||
@ -611,6 +885,7 @@ public final class AnthropicProvider: ModelProvider {
|
||||
var currentReasoningSignature: String?
|
||||
var currentReasoningType: String?
|
||||
var reasoningSignatureEmitted = false
|
||||
var finishReason: FinishReason?
|
||||
|
||||
do {
|
||||
for line in lines {
|
||||
@ -637,7 +912,7 @@ public final class AnthropicProvider: ModelProvider {
|
||||
type: currentReasoningType,
|
||||
))
|
||||
}
|
||||
continuation.yield(TextStreamDelta.done())
|
||||
continuation.yield(.done(finishReason: finishReason))
|
||||
break
|
||||
}
|
||||
|
||||
@ -656,6 +931,12 @@ public final class AnthropicProvider: ModelProvider {
|
||||
currentReasoningSignature = nil
|
||||
currentReasoningType = block.type
|
||||
reasoningSignatureEmitted = false
|
||||
if block.type == "redacted_thinking", let data = block.data {
|
||||
continuation.yield(TextStreamDelta.reasoning(
|
||||
data,
|
||||
type: "redacted_thinking",
|
||||
))
|
||||
}
|
||||
}
|
||||
case "content_block_delta":
|
||||
if let delta = event.delta {
|
||||
@ -678,6 +959,10 @@ public final class AnthropicProvider: ModelProvider {
|
||||
accumulatedReasoning += thinking
|
||||
}
|
||||
}
|
||||
case "message_delta":
|
||||
if let stopReason = event.delta?.stopReason {
|
||||
finishReason = Self.mapFinishReason(stopReason)
|
||||
}
|
||||
case "message_stop":
|
||||
if !accumulatedText.isEmpty {
|
||||
continuation.yield(TextStreamDelta.text(accumulatedText))
|
||||
@ -689,7 +974,7 @@ public final class AnthropicProvider: ModelProvider {
|
||||
type: currentReasoningType,
|
||||
))
|
||||
}
|
||||
continuation.yield(TextStreamDelta.done())
|
||||
continuation.yield(.done(finishReason: finishReason))
|
||||
default:
|
||||
continue
|
||||
}
|
||||
@ -753,6 +1038,37 @@ public final class AnthropicProvider: ModelProvider {
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
enum ReasoningEndpointIdentity {
|
||||
static func canonical(_ rawValue: String?) -> String? {
|
||||
guard
|
||||
let trimmed = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty,
|
||||
var components = URLComponents(string: trimmed),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased() else
|
||||
{
|
||||
return nil
|
||||
}
|
||||
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.user = nil
|
||||
components.password = nil
|
||||
components.fragment = nil
|
||||
while components.path.count > 1, components.path.hasSuffix("/") {
|
||||
components.path.removeLast()
|
||||
}
|
||||
|
||||
guard let value = components.string else { return nil }
|
||||
guard let data = value.data(using: .utf8) else { return nil }
|
||||
let digest = ReasoningEndpointHasher.hash(data: data)
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
return "sha256:\(digest)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for Ollama models
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public final class OllamaProvider: ModelProvider {
|
||||
|
||||
@ -11,12 +11,59 @@ public typealias ProviderFactoryOverride = (LanguageModel, TachikomaConfiguratio
|
||||
public final class TachikomaConfiguration: @unchecked Sendable {
|
||||
// MARK: - Profile Directory (for config/credentials)
|
||||
|
||||
/// Name of the profile directory under the user's HOME used to store
|
||||
/// configuration and credentials (e.g. \.tachikoma, \.peekaboo).
|
||||
/// Name of the profile directory under the user's HOME, or an absolute path,
|
||||
/// used to store configuration and credentials (e.g. \.tachikoma, \.peekaboo).
|
||||
/// Defaults to ".tachikoma". Host applications (like Peekaboo) should set this
|
||||
/// to their own folder name during startup.
|
||||
/// to their own folder/path during startup.
|
||||
public nonisolated(unsafe) static var profileDirectoryName: String = ".tachikoma"
|
||||
|
||||
public static var profileDirectoryPath: String {
|
||||
let profile = self.profileDirectoryName
|
||||
if self.isAbsoluteProfilePath(profile) || profile.hasPrefix("~") {
|
||||
return NSString(string: profile).expandingTildeInPath
|
||||
}
|
||||
|
||||
guard let homeDirectory = self.homeDirectoryPath else {
|
||||
return profile
|
||||
}
|
||||
|
||||
return "\(homeDirectory)/\(profile)"
|
||||
}
|
||||
|
||||
private static func isAbsoluteProfilePath(_ profile: String) -> Bool {
|
||||
if profile.hasPrefix("/") {
|
||||
return true
|
||||
}
|
||||
|
||||
#if os(Windows)
|
||||
if profile.hasPrefix("\\\\") {
|
||||
return true
|
||||
}
|
||||
|
||||
if profile.count >= 3 {
|
||||
let driveIndex = profile.index(after: profile.startIndex)
|
||||
let separatorIndex = profile.index(after: driveIndex)
|
||||
let drive = profile[profile.startIndex]
|
||||
let separator = profile[separatorIndex]
|
||||
return drive.isASCII && drive.isLetter && profile[driveIndex] == ":" &&
|
||||
(separator == "\\" || separator == "/")
|
||||
}
|
||||
#endif
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private static var homeDirectoryPath: String? {
|
||||
#if os(Windows)
|
||||
let homeDirectory = ProcessInfo.processInfo.environment["USERPROFILE"] ??
|
||||
((ProcessInfo.processInfo.environment["HOMEDRIVE"] ?? "") +
|
||||
(ProcessInfo.processInfo.environment["HOMEPATH"] ?? ""))
|
||||
return homeDirectory.isEmpty ? nil : homeDirectory
|
||||
#else
|
||||
return ProcessInfo.processInfo.environment["HOME"]
|
||||
#endif
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var _apiKeys: [String: String] = [:]
|
||||
private var _baseURLs: [String: String] = [:]
|
||||
@ -95,6 +142,10 @@ public final class TachikomaConfiguration: @unchecked Sendable {
|
||||
return configuredKey
|
||||
}
|
||||
|
||||
if provider == .minimaxCN, let sharedMiniMaxKey = self._apiKeys[Provider.minimax.identifier] {
|
||||
return sharedMiniMaxKey
|
||||
}
|
||||
|
||||
// Fall back to environment variable only if loadFromEnvironment is true
|
||||
if self._loadFromEnvironment {
|
||||
return provider.loadAPIKeyFromEnvironment()
|
||||
@ -281,7 +332,17 @@ public final class TachikomaConfiguration: @unchecked Sendable {
|
||||
self.loadFromEnvironment()
|
||||
}
|
||||
|
||||
/// Load configuration from environment variables
|
||||
/// Load configuration from environment variables.
|
||||
///
|
||||
/// Marked `@inline(never)` to work around a Swift release-mode optimizer
|
||||
/// issue where inlining this function into the singleton-init path causes
|
||||
/// the `_baseURLs[.anthropic]` write to be incorrectly eliminated, leaving
|
||||
/// `AnthropicProvider.init` reading a stale (default) value via
|
||||
/// `getBaseURL`. OpenAI / Ollama / MiniMax / Azure writes happen to
|
||||
/// survive the optimization; only Anthropic is empirically affected.
|
||||
/// Removing this annotation reintroduces the bug observed in
|
||||
/// openclaw/Peekaboo release builds for `claude-*` models. See #17.
|
||||
@inline(never)
|
||||
private func loadFromEnvironment() {
|
||||
// Load API keys for all standard providers from environment
|
||||
for provider in Provider.standardProviders {
|
||||
@ -294,6 +355,8 @@ public final class TachikomaConfiguration: @unchecked Sendable {
|
||||
let urlMappings: [Provider: String] = [
|
||||
.openai: "OPENAI_BASE_URL",
|
||||
.anthropic: "ANTHROPIC_BASE_URL",
|
||||
.minimax: "MINIMAX_BASE_URL",
|
||||
.minimaxCN: "MINIMAX_CN_BASE_URL",
|
||||
.ollama: "OLLAMA_BASE_URL",
|
||||
.azureOpenAI: "AZURE_OPENAI_ENDPOINT",
|
||||
]
|
||||
@ -312,23 +375,13 @@ public final class TachikomaConfiguration: @unchecked Sendable {
|
||||
/// Load configuration from credentials file
|
||||
private func loadFromCredentials() {
|
||||
// Load configuration from credentials file
|
||||
#if os(Windows)
|
||||
let homeDirectory = ProcessInfo.processInfo.environment["USERPROFILE"] ??
|
||||
(ProcessInfo.processInfo.environment["HOMEDRIVE"] ?? "" +
|
||||
(ProcessInfo.processInfo.environment["HOMEPATH"] ?? ""))
|
||||
guard !homeDirectory.isEmpty else { return }
|
||||
#else
|
||||
guard let homeDirectory = ProcessInfo.processInfo.environment["HOME"] else {
|
||||
return
|
||||
}
|
||||
#endif
|
||||
// Primary: configured profile directory/path (e.g. .peekaboo or PEEKABOO_CONFIG_DIR)
|
||||
let primaryCredentialsPath = "\(Self.profileDirectoryPath)/credentials"
|
||||
|
||||
// Primary: configured profile directory (e.g. .peekaboo)
|
||||
let primaryCredentialsPath = "\(homeDirectory)/\(Self.profileDirectoryName)/credentials"
|
||||
// Fallback: legacy .tachikoma directory for non-Peekaboo users
|
||||
let fallbackCredentialsPath = "\(homeDirectory)/.tachikoma/credentials"
|
||||
let fallbackCredentialsPath = Self.homeDirectoryPath.map { "\($0)/.tachikoma/credentials" }
|
||||
|
||||
let candidates = [primaryCredentialsPath, fallbackCredentialsPath]
|
||||
let candidates = [primaryCredentialsPath, fallbackCredentialsPath].compactMap(\.self)
|
||||
let credentialsPath = candidates.first { FileManager.default.fileExists(atPath: $0) }
|
||||
guard let path = credentialsPath else { return }
|
||||
let credentialsURL = URL(fileURLWithPath: path)
|
||||
@ -356,46 +409,32 @@ public final class TachikomaConfiguration: @unchecked Sendable {
|
||||
let key = components[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value = components[1...].joined(separator: "=").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Map credential keys to providers
|
||||
let lowercaseKey = key.lowercased()
|
||||
if lowercaseKey.contains("openai") {
|
||||
self.setAPIKey(value, for: .openai)
|
||||
} else if lowercaseKey.contains("anthropic") || lowercaseKey.contains("claude") {
|
||||
self.setAPIKey(value, for: .anthropic)
|
||||
} else if lowercaseKey.contains("grok") {
|
||||
self.setAPIKey(value, for: .grok)
|
||||
} else if lowercaseKey.contains("groq") {
|
||||
self.setAPIKey(value, for: .groq)
|
||||
} else if lowercaseKey.contains("mistral") {
|
||||
self.setAPIKey(value, for: .mistral)
|
||||
} else if lowercaseKey.contains("google") || lowercaseKey.contains("gemini") {
|
||||
self.setAPIKey(value, for: .google)
|
||||
} else if lowercaseKey.contains("ollama") {
|
||||
self.setAPIKey(value, for: .ollama)
|
||||
if let provider = Self.provider(forCredentialKey: key) {
|
||||
self.setAPIKey(value, for: provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func provider(forCredentialKey key: String) -> Provider? {
|
||||
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
return Provider.standardProviders.first { provider in
|
||||
let keys = ([provider.environmentVariable] + provider.alternativeEnvironmentVariables)
|
||||
.filter { !$0.isEmpty }
|
||||
.map { $0.uppercased() }
|
||||
return keys.contains(normalizedKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// Save current configuration to credentials file
|
||||
public func saveCredentials() throws {
|
||||
// Save current configuration to credentials file
|
||||
#if os(Windows)
|
||||
let homeDirectory = ProcessInfo.processInfo.environment["USERPROFILE"] ??
|
||||
(ProcessInfo.processInfo.environment["HOMEDRIVE"] ?? "" +
|
||||
(ProcessInfo.processInfo.environment["HOMEPATH"] ?? ""))
|
||||
guard !homeDirectory.isEmpty else {
|
||||
throw TachikomaError.invalidConfiguration("USERPROFILE directory not found")
|
||||
let profileDir = Self.profileDirectoryPath
|
||||
guard !profileDir.isEmpty else {
|
||||
throw TachikomaError.invalidConfiguration("Profile directory not found")
|
||||
}
|
||||
#else
|
||||
guard let homeDirectory = ProcessInfo.processInfo.environment["HOME"] else {
|
||||
throw TachikomaError.invalidConfiguration("HOME directory not found")
|
||||
}
|
||||
#endif
|
||||
|
||||
let profileDir = "\(homeDirectory)/\(Self.profileDirectoryName)"
|
||||
let credentialsPath = "\(profileDir)/credentials"
|
||||
|
||||
// Create directory if needed
|
||||
@ -410,7 +449,8 @@ public final class TachikomaConfiguration: @unchecked Sendable {
|
||||
|
||||
self.lock.withLock {
|
||||
for (provider, key) in self._apiKeys {
|
||||
let envVarName = "\(provider.uppercased())_API_KEY"
|
||||
let standardEnvVar = Provider.from(identifier: provider).environmentVariable
|
||||
let envVarName = standardEnvVar.isEmpty ? "\(provider.uppercased())_API_KEY" : standardEnvVar
|
||||
lines.append("\(envVarName)=\(key)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ public struct CustomProviderInfo: Sendable {
|
||||
public let id: String
|
||||
public let kind: Kind
|
||||
public let baseURL: String
|
||||
public let apiKey: String?
|
||||
public let headers: [String: String]
|
||||
public let models: [String: String] // map of alias->modelId (optional usage)
|
||||
}
|
||||
@ -29,6 +30,7 @@ public final class CustomProviderRegistry: @unchecked Sendable {
|
||||
guard
|
||||
let options = dict["options"] as? [String: Any],
|
||||
let baseURL = options["baseURL"] as? String else { continue }
|
||||
let apiKey = options["apiKey"] as? String
|
||||
let headers = options["headers"] as? [String: String] ?? [:]
|
||||
var models: [String: String] = [:]
|
||||
if let modelsDict = dict["models"] as? [String: Any] {
|
||||
@ -36,7 +38,14 @@ public final class CustomProviderRegistry: @unchecked Sendable {
|
||||
if let m = (v as? [String: Any])?["name"] as? String { models[k] = m }
|
||||
}
|
||||
}
|
||||
out[id] = CustomProviderInfo(id: id, kind: kind, baseURL: baseURL, headers: headers, models: models)
|
||||
out[id] = CustomProviderInfo(
|
||||
id: id,
|
||||
kind: kind,
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
headers: headers,
|
||||
models: models,
|
||||
)
|
||||
}
|
||||
self.providers = out
|
||||
}
|
||||
@ -52,12 +61,7 @@ public final class CustomProviderRegistry: @unchecked Sendable {
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func profileDirectoryPath() -> String {
|
||||
#if os(Windows)
|
||||
let home = ProcessInfo.processInfo.environment["USERPROFILE"] ?? ""
|
||||
#else
|
||||
let home = ProcessInfo.processInfo.environment["HOME"] ?? ""
|
||||
#endif
|
||||
return "\(home)/\(TachikomaConfiguration.profileDirectoryName)"
|
||||
TachikomaConfiguration.profileDirectoryPath
|
||||
}
|
||||
|
||||
private static func profileConfigPath() -> String {
|
||||
|
||||
@ -113,10 +113,10 @@ public enum EmbeddingModel: Sendable {
|
||||
// Convert to LanguageModel for usage tracking
|
||||
switch self {
|
||||
case .openai:
|
||||
.openai(.gpt4o) // Placeholder for tracking
|
||||
.openai(.gpt55) // Placeholder for tracking
|
||||
case .cohere, .voyage, .custom:
|
||||
// Return a dummy model for tracking purposes
|
||||
.openai(.gpt4o)
|
||||
.openai(.gpt55)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,10 +35,11 @@ public func generateText(
|
||||
var currentMessages = messages
|
||||
var allSteps: [GenerationStep] = []
|
||||
var totalUsage = Usage(inputTokens: 0, outputTokens: 0)
|
||||
var finalResponseStartIndex = messages.count
|
||||
|
||||
for stepIndex in 0..<maxSteps {
|
||||
let request = ProviderRequest(
|
||||
messages: currentMessages,
|
||||
messages: currentMessages.sanitizedForProvider(model, configuration: resolvedConfiguration),
|
||||
tools: tools,
|
||||
settings: settings,
|
||||
)
|
||||
@ -51,8 +52,23 @@ public func generateText(
|
||||
try await provider.generateText(request: request)
|
||||
}
|
||||
|
||||
// Track usage with proper session management
|
||||
if let usage = response.usage {
|
||||
let isContentFiltered = response.finishReason == .contentFilter
|
||||
let responseText = isContentFiltered ? "" : response.text
|
||||
let responseToolCalls = isContentFiltered ? [] : (response.toolCalls ?? [])
|
||||
let responseReasoning = isContentFiltered ? [] : response.reasoning
|
||||
let responseAssistantMessages = isContentFiltered ? [] : response.assistantMessages
|
||||
let responseMessageStartIndex = currentMessages.count
|
||||
finalResponseStartIndex = responseMessageStartIndex
|
||||
let responseHistoryMessages = model.responseHistoryMessages(
|
||||
nativeMessages: responseAssistantMessages,
|
||||
text: responseText,
|
||||
reasoning: responseReasoning,
|
||||
toolCalls: responseToolCalls,
|
||||
configuration: resolvedConfiguration,
|
||||
)
|
||||
|
||||
// Track billable usage with proper session management.
|
||||
if response.isBillable, let usage = response.usage {
|
||||
let actualSessionId = sessionId ?? "generation-\(UUID().uuidString)"
|
||||
|
||||
// Start session if not already started
|
||||
@ -86,8 +102,8 @@ public func generateText(
|
||||
// Create step record
|
||||
let step = GenerationStep(
|
||||
stepIndex: stepIndex,
|
||||
text: response.text,
|
||||
toolCalls: response.toolCalls ?? [],
|
||||
text: responseText,
|
||||
toolCalls: responseToolCalls,
|
||||
toolResults: [],
|
||||
usage: response.usage,
|
||||
finishReason: response.finishReason,
|
||||
@ -95,18 +111,26 @@ public func generateText(
|
||||
|
||||
allSteps.append(step)
|
||||
|
||||
// Add assistant message
|
||||
var assistantContent: [ModelMessage.ContentPart] = [.text(response.text)]
|
||||
if isContentFiltered {
|
||||
break
|
||||
}
|
||||
|
||||
if !responseHistoryMessages.isEmpty {
|
||||
currentMessages.append(contentsOf: responseHistoryMessages)
|
||||
if responseHistoryMessages.allSatisfy({ $0.channel == .thinking }) {
|
||||
currentMessages.append(ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("")],
|
||||
metadata: .init(customData: ["tachikoma.internal.boundary": "reasoning_only"]),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool calls
|
||||
if let toolCalls = response.toolCalls, !toolCalls.isEmpty {
|
||||
// Add tool calls to assistant message
|
||||
assistantContent.append(contentsOf: toolCalls.map { .toolCall($0) })
|
||||
currentMessages.append(ModelMessage(role: .assistant, content: assistantContent))
|
||||
|
||||
if !responseToolCalls.isEmpty {
|
||||
// Execute tools
|
||||
var toolResults: [AgentToolResult] = []
|
||||
for toolCall in toolCalls {
|
||||
for toolCall in responseToolCalls {
|
||||
if let tool = tools?.first(where: { $0.name == toolCall.name }) {
|
||||
do {
|
||||
// Debug: Log tool call details in verbose mode
|
||||
@ -124,7 +148,7 @@ public func generateText(
|
||||
|
||||
// Create execution context with full conversation and model info
|
||||
let context = ToolExecutionContext(
|
||||
messages: currentMessages,
|
||||
messages: currentMessages.sanitizedForToolContext(),
|
||||
model: model,
|
||||
settings: settings,
|
||||
sessionId: sessionId ?? "generation-\(UUID().uuidString)",
|
||||
@ -161,8 +185,8 @@ public func generateText(
|
||||
// Update step with tool results
|
||||
allSteps[stepIndex] = GenerationStep(
|
||||
stepIndex: stepIndex,
|
||||
text: response.text,
|
||||
toolCalls: toolCalls,
|
||||
text: responseText,
|
||||
toolCalls: responseToolCalls,
|
||||
toolResults: toolResults,
|
||||
usage: response.usage,
|
||||
finishReason: response.finishReason,
|
||||
@ -174,17 +198,17 @@ public func generateText(
|
||||
}
|
||||
} else {
|
||||
// No tool calls, we're done
|
||||
currentMessages.append(ModelMessage(role: .assistant, content: assistantContent))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Extract final text from last step
|
||||
var finalText = allSteps.last?.text ?? ""
|
||||
let originalFinalText = finalText
|
||||
var finalFinishReason = allSteps.last?.finishReason ?? .other
|
||||
|
||||
// Apply stop conditions if configured
|
||||
if let stopCondition = settings.stopConditions {
|
||||
if finalFinishReason != .contentFilter, let stopCondition = settings.stopConditions {
|
||||
// Check if we should stop and truncate the text
|
||||
if await stopCondition.shouldStop(text: finalText, delta: nil) {
|
||||
// Truncate text based on the type of stop condition
|
||||
@ -217,13 +241,16 @@ public func generateText(
|
||||
}
|
||||
}
|
||||
}
|
||||
let finalMessages = finalText == originalFinalText
|
||||
? currentMessages
|
||||
: currentMessages.replacingGeneratedAssistantText(after: finalResponseStartIndex, with: finalText)
|
||||
|
||||
return GenerateTextResult(
|
||||
text: finalText,
|
||||
usage: totalUsage,
|
||||
finishReason: finalFinishReason,
|
||||
steps: allSteps,
|
||||
messages: currentMessages,
|
||||
messages: finalMessages,
|
||||
)
|
||||
}
|
||||
|
||||
@ -254,6 +281,9 @@ public func streamText(
|
||||
{
|
||||
// Debug logging only when explicitly enabled via environment variable or verbose flag
|
||||
let resolvedConfiguration = TachikomaConfiguration.resolve(configuration)
|
||||
guard model.supportsStreaming else {
|
||||
throw TachikomaError.invalidConfiguration("\(model.modelId) does not support streaming")
|
||||
}
|
||||
let debugEnabled = ProcessInfo.processInfo.environment["DEBUG_TACHIKOMA"] != nil ||
|
||||
resolvedConfiguration.verbose
|
||||
if debugEnabled {
|
||||
@ -275,7 +305,7 @@ public func streamText(
|
||||
}
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: messages,
|
||||
messages: messages.sanitizedForProvider(model, configuration: resolvedConfiguration),
|
||||
tools: tools,
|
||||
settings: settings,
|
||||
)
|
||||
@ -296,12 +326,6 @@ public func streamText(
|
||||
stream = try await provider.streamText(request: request)
|
||||
}
|
||||
|
||||
// Apply stop conditions if configured
|
||||
if let stopCondition = settings.stopConditions {
|
||||
// Wrap the stream with stop condition checking
|
||||
stream = stream.stopWhen(stopCondition)
|
||||
}
|
||||
|
||||
// Use provided session or create a new one for tracking streaming usage
|
||||
let actualSessionId = sessionId ?? "streaming-\(UUID().uuidString)"
|
||||
if sessionId == nil {
|
||||
@ -311,23 +335,84 @@ public func streamText(
|
||||
// Wrap the stream to track usage when it completes
|
||||
let capturedModel = model
|
||||
let capturedSessionId = actualSessionId
|
||||
let capturedStream = stream
|
||||
let shouldEndSession = sessionId == nil
|
||||
let buffersUntilDone = model.buffersTextStreamUntilDone(settings: settings)
|
||||
if !buffersUntilDone, let stopCondition = settings.stopConditions {
|
||||
stream = stream.stopWhen(stopCondition)
|
||||
}
|
||||
let capturedStream = stream
|
||||
let capturedStopCondition = buffersUntilDone ? settings.stopConditions : nil
|
||||
|
||||
let trackedStream = AsyncThrowingStream<TextStreamDelta, Error> { continuation in
|
||||
Task {
|
||||
do {
|
||||
let totalInputTokens = 0
|
||||
var totalOutputTokens = 0
|
||||
var bufferedDeltas: [TextStreamDelta] = []
|
||||
var bufferedVisibleText = ""
|
||||
var didReceiveTerminal = false
|
||||
var didTriggerLocalStop = false
|
||||
|
||||
for try await delta in capturedStream {
|
||||
continuation.yield(delta)
|
||||
|
||||
func track(_ delta: TextStreamDelta) {
|
||||
// Track tokens as they come in (approximate)
|
||||
if case .textDelta = delta.type, let content = delta.content {
|
||||
// Rough approximation: ~4 characters per token
|
||||
totalOutputTokens += max(1, content.count / 4)
|
||||
}
|
||||
}
|
||||
|
||||
func yieldAndTrack(_ delta: TextStreamDelta) {
|
||||
track(delta)
|
||||
continuation.yield(delta)
|
||||
}
|
||||
|
||||
if let capturedStopCondition {
|
||||
await capturedStopCondition.reset()
|
||||
}
|
||||
|
||||
for try await delta in capturedStream {
|
||||
if buffersUntilDone, delta.type != .done {
|
||||
if !didTriggerLocalStop {
|
||||
bufferedDeltas.append(delta)
|
||||
track(delta)
|
||||
if
|
||||
let capturedStopCondition,
|
||||
case .textDelta = delta.type,
|
||||
let content = delta.content
|
||||
{
|
||||
bufferedVisibleText += content
|
||||
didTriggerLocalStop = await capturedStopCondition.shouldStop(
|
||||
text: bufferedVisibleText,
|
||||
delta: content,
|
||||
)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if case .done = delta.type {
|
||||
didReceiveTerminal = true
|
||||
if buffersUntilDone {
|
||||
if delta.finishReason == .contentFilter {
|
||||
bufferedDeltas.removeAll()
|
||||
yieldAndTrack(delta)
|
||||
} else {
|
||||
for bufferedDelta in bufferedDeltas {
|
||||
continuation.yield(bufferedDelta)
|
||||
}
|
||||
bufferedDeltas.removeAll()
|
||||
if didTriggerLocalStop {
|
||||
yieldAndTrack(TextStreamDelta.done(usage: delta.usage, finishReason: .stop))
|
||||
} else {
|
||||
yieldAndTrack(delta)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
yieldAndTrack(delta)
|
||||
}
|
||||
} else {
|
||||
yieldAndTrack(delta)
|
||||
}
|
||||
|
||||
if case .done = delta.type {
|
||||
// Record final usage (this is approximate for streaming)
|
||||
@ -348,6 +433,10 @@ public func streamText(
|
||||
}
|
||||
}
|
||||
|
||||
if buffersUntilDone, !didReceiveTerminal {
|
||||
throw TachikomaError.apiError("Stream ended before provider completion status was received")
|
||||
}
|
||||
|
||||
continuation.finish()
|
||||
} catch {
|
||||
if shouldEndSession {
|
||||
@ -392,7 +481,7 @@ public func generateObject<T: Codable & Sendable>(
|
||||
let provider = try resolvedConfiguration.makeProvider(for: model)
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: messages,
|
||||
messages: messages.sanitizedForProvider(model, configuration: resolvedConfiguration),
|
||||
tools: nil,
|
||||
settings: settings,
|
||||
outputFormat: .json,
|
||||
@ -406,6 +495,10 @@ public func generateObject<T: Codable & Sendable>(
|
||||
try await provider.generateText(request: request)
|
||||
}
|
||||
|
||||
if response.finishReason == .contentFilter {
|
||||
throw TachikomaError.apiError("Response was blocked by the provider content filter")
|
||||
}
|
||||
|
||||
// Parse the JSON response into the expected type
|
||||
guard let jsonData = response.text.data(using: .utf8) else {
|
||||
throw TachikomaError.invalidInput("Response text is not valid UTF-8")
|
||||
@ -446,11 +539,14 @@ public func streamObject<T: Codable & Sendable>(
|
||||
-> StreamObjectResult<T>
|
||||
{
|
||||
let resolvedConfiguration = TachikomaConfiguration.resolve(configuration)
|
||||
guard model.supportsStreaming else {
|
||||
throw TachikomaError.invalidConfiguration("\(model.modelId) does not support streaming")
|
||||
}
|
||||
let provider = try resolvedConfiguration.makeProvider(for: model)
|
||||
|
||||
// Create request with JSON output format
|
||||
let request = ProviderRequest(
|
||||
messages: messages,
|
||||
messages: messages.sanitizedForProvider(model, configuration: resolvedConfiguration),
|
||||
tools: nil,
|
||||
settings: settings,
|
||||
outputFormat: .json,
|
||||
@ -458,6 +554,7 @@ public func streamObject<T: Codable & Sendable>(
|
||||
|
||||
// Get the text stream from the provider
|
||||
let stream = try await provider.streamText(request: request)
|
||||
let buffersUntilDone = model.buffersObjectStreamUntilDone(settings: settings)
|
||||
|
||||
// Create a new stream that attempts to parse partial JSON objects
|
||||
let objectStream = AsyncThrowingStream<ObjectStreamDelta<T>, Error> { continuation in
|
||||
@ -466,6 +563,37 @@ public func streamObject<T: Codable & Sendable>(
|
||||
var accumulatedText = ""
|
||||
var lastValidObject: T?
|
||||
var hasStarted = false
|
||||
var bufferedStartDelta: ObjectStreamDelta<T>?
|
||||
var didFinishObject = false
|
||||
|
||||
func publishCompleteObject(allowLastValidObjectFallback: Bool) throws {
|
||||
if buffersUntilDone, let bufferedStartDelta {
|
||||
continuation.yield(bufferedStartDelta)
|
||||
}
|
||||
if
|
||||
let jsonData = accumulatedText.data(using: .utf8),
|
||||
let finalObject = try? JSONDecoder().decode(T.self, from: jsonData)
|
||||
{
|
||||
continuation.yield(ObjectStreamDelta(
|
||||
type: .complete,
|
||||
object: finalObject,
|
||||
rawText: accumulatedText,
|
||||
))
|
||||
} else if allowLastValidObjectFallback, let lastValidObject {
|
||||
// If we have a last valid object, use it as complete
|
||||
continuation.yield(ObjectStreamDelta(
|
||||
type: .complete,
|
||||
object: lastValidObject,
|
||||
rawText: accumulatedText,
|
||||
))
|
||||
} else {
|
||||
throw TachikomaError.invalidInput(
|
||||
"Failed to parse complete object from stream",
|
||||
)
|
||||
}
|
||||
continuation.yield(ObjectStreamDelta(type: .done))
|
||||
didFinishObject = true
|
||||
}
|
||||
|
||||
for try await delta in stream {
|
||||
if case .textDelta = delta.type, let content = delta.content {
|
||||
@ -474,7 +602,16 @@ public func streamObject<T: Codable & Sendable>(
|
||||
// Signal stream start
|
||||
if !hasStarted {
|
||||
hasStarted = true
|
||||
continuation.yield(ObjectStreamDelta(type: .start))
|
||||
let startDelta = ObjectStreamDelta<T>(type: .start)
|
||||
if buffersUntilDone {
|
||||
bufferedStartDelta = startDelta
|
||||
} else {
|
||||
continuation.yield(startDelta)
|
||||
}
|
||||
}
|
||||
|
||||
if buffersUntilDone {
|
||||
continue
|
||||
}
|
||||
|
||||
// Attempt to parse the accumulated JSON
|
||||
@ -482,44 +619,51 @@ public func streamObject<T: Codable & Sendable>(
|
||||
// Try to parse as complete object
|
||||
if let object = try? JSONDecoder().decode(T.self, from: jsonData) {
|
||||
lastValidObject = object
|
||||
continuation.yield(ObjectStreamDelta(
|
||||
let objectDelta = ObjectStreamDelta(
|
||||
type: .partial,
|
||||
object: object,
|
||||
rawText: accumulatedText,
|
||||
))
|
||||
)
|
||||
continuation.yield(objectDelta)
|
||||
} else if let partialObject = attemptPartialParse(T.self, from: accumulatedText) {
|
||||
// Attempt to parse as partial object
|
||||
lastValidObject = partialObject
|
||||
continuation.yield(ObjectStreamDelta(
|
||||
let objectDelta = ObjectStreamDelta(
|
||||
type: .partial,
|
||||
object: partialObject,
|
||||
rawText: accumulatedText,
|
||||
))
|
||||
)
|
||||
continuation.yield(objectDelta)
|
||||
}
|
||||
}
|
||||
} else if case .done = delta.type {
|
||||
// Final parse attempt
|
||||
if
|
||||
let jsonData = accumulatedText.data(using: .utf8),
|
||||
let finalObject = try? JSONDecoder().decode(T.self, from: jsonData)
|
||||
{
|
||||
continuation.yield(ObjectStreamDelta(
|
||||
type: .complete,
|
||||
object: finalObject,
|
||||
rawText: accumulatedText,
|
||||
))
|
||||
} else if let lastValidObject {
|
||||
// If we have a last valid object, use it as complete
|
||||
continuation.yield(ObjectStreamDelta(
|
||||
type: .complete,
|
||||
object: lastValidObject,
|
||||
rawText: accumulatedText,
|
||||
))
|
||||
} else {
|
||||
throw TachikomaError.invalidInput(
|
||||
"Failed to parse complete object from stream",
|
||||
)
|
||||
if delta.finishReason == .contentFilter {
|
||||
throw TachikomaError.apiError("Response was blocked by the provider content filter")
|
||||
}
|
||||
try publishCompleteObject(allowLastValidObjectFallback: delta.finishReason == .stop || delta
|
||||
.finishReason == nil)
|
||||
}
|
||||
}
|
||||
|
||||
if !didFinishObject, hasStarted {
|
||||
if buffersUntilDone {
|
||||
throw TachikomaError.apiError("Stream ended before provider completion status was received")
|
||||
} else if
|
||||
let jsonData = accumulatedText.data(using: .utf8),
|
||||
let finalObject = try? JSONDecoder().decode(T.self, from: jsonData)
|
||||
{
|
||||
continuation.yield(ObjectStreamDelta(
|
||||
type: .complete,
|
||||
object: finalObject,
|
||||
rawText: accumulatedText,
|
||||
))
|
||||
continuation.yield(ObjectStreamDelta(type: .done))
|
||||
} else if let lastValidObject {
|
||||
continuation.yield(ObjectStreamDelta(
|
||||
type: .complete,
|
||||
object: lastValidObject,
|
||||
rawText: accumulatedText,
|
||||
))
|
||||
continuation.yield(ObjectStreamDelta(type: .done))
|
||||
}
|
||||
}
|
||||
@ -599,6 +743,466 @@ private func fixPartialJSON(_ json: String) -> String {
|
||||
return fixed
|
||||
}
|
||||
|
||||
extension LanguageModel {
|
||||
fileprivate func buffersTextStreamUntilDone(settings: GenerationSettings) -> Bool {
|
||||
self.hasAnthropicStreamingRefusalRisk ||
|
||||
settings.streamBuffering == .untilTerminal ||
|
||||
(settings.stopConditions != nil && self.canEmitTerminalContentFilterAfterText)
|
||||
}
|
||||
|
||||
fileprivate func buffersObjectStreamUntilDone(settings: GenerationSettings) -> Bool {
|
||||
settings.streamBuffering == .untilTerminal ||
|
||||
self.hasAnthropicStreamingRefusalRisk
|
||||
}
|
||||
|
||||
private var hasAnthropicStreamingRefusalRisk: Bool {
|
||||
switch self {
|
||||
case let .anthropic(model):
|
||||
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: model.modelId)
|
||||
case let .anthropicCompatible(modelId, _):
|
||||
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
|
||||
case let .openRouter(modelId), let .together(modelId):
|
||||
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
|
||||
case let .openaiCompatible(modelId, _):
|
||||
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
|
||||
case let .custom(provider):
|
||||
if
|
||||
let parsed = ProviderParser.parse(provider.modelId),
|
||||
CustomProviderRegistry.shared.get(parsed.provider)?.kind == .anthropic
|
||||
{
|
||||
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: parsed.model)
|
||||
}
|
||||
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: provider.modelId)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var canEmitTerminalContentFilterAfterText: Bool {
|
||||
switch self {
|
||||
case .openai,
|
||||
.openaiCompatible,
|
||||
.openRouter,
|
||||
.together,
|
||||
.replicate,
|
||||
.google,
|
||||
.mistral,
|
||||
.groq,
|
||||
.grok,
|
||||
.azureOpenAI:
|
||||
return true
|
||||
case let .custom(provider):
|
||||
guard
|
||||
let parsed = ProviderParser.parse(provider.modelId),
|
||||
let registeredProvider = CustomProviderRegistry.shared.get(parsed.provider) else
|
||||
{
|
||||
return false
|
||||
}
|
||||
switch registeredProvider.kind {
|
||||
case .openai:
|
||||
return true
|
||||
case .anthropic:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReasoningReplayTarget {
|
||||
let provider: String
|
||||
let modelId: String
|
||||
let baseURL: String?
|
||||
let allowsLegacyUnknown: Bool
|
||||
|
||||
func matches(_ customData: [String: String]) -> Bool {
|
||||
guard customData["tachikoma.reasoning.provider"] == self.provider else {
|
||||
return false
|
||||
}
|
||||
guard customData["tachikoma.reasoning.model"] == self.modelId else {
|
||||
return false
|
||||
}
|
||||
return customData["tachikoma.reasoning.base_url"] == self.endpointIdentity
|
||||
}
|
||||
|
||||
var endpointIdentity: String? {
|
||||
ReasoningEndpointIdentity.canonical(self.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
extension [ModelMessage] {
|
||||
fileprivate func replacingGeneratedAssistantText(after prefixCount: Int, with text: String) -> [ModelMessage] {
|
||||
guard self.indices.contains(prefixCount) else {
|
||||
return self
|
||||
}
|
||||
|
||||
var messages = self
|
||||
var cursor = text.startIndex
|
||||
for messageIndex in prefixCount..<messages.count {
|
||||
let message = messages[messageIndex]
|
||||
guard message.role == .assistant, message.channel != .thinking else {
|
||||
continue
|
||||
}
|
||||
|
||||
var content: [ModelMessage.ContentPart] = []
|
||||
for part in message.content {
|
||||
guard case let .text(originalText) = part else {
|
||||
content.append(part)
|
||||
continue
|
||||
}
|
||||
|
||||
guard cursor < text.endIndex else {
|
||||
continue
|
||||
}
|
||||
|
||||
let remainingCount = text.distance(from: cursor, to: text.endIndex)
|
||||
let takeCount = Swift.min(originalText.count, remainingCount)
|
||||
let endIndex = text.index(cursor, offsetBy: takeCount)
|
||||
content.append(.text(String(text[cursor..<endIndex])))
|
||||
cursor = endIndex
|
||||
}
|
||||
|
||||
messages[messageIndex] = ModelMessage(
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: content,
|
||||
timestamp: message.timestamp,
|
||||
channel: message.channel,
|
||||
metadata: message.metadata,
|
||||
)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
extension [ModelMessage] {
|
||||
fileprivate func sanitizedForProvider(
|
||||
_ model: LanguageModel,
|
||||
configuration: TachikomaConfiguration,
|
||||
)
|
||||
-> [ModelMessage]
|
||||
{
|
||||
if let target = model.anthropicThinkingReplayTarget(configuration: configuration) {
|
||||
var sanitized: [ModelMessage] = []
|
||||
for message in self {
|
||||
if message.isSyntheticReasoningBoundary {
|
||||
if sanitized.last?.channel == .thinking {
|
||||
sanitized.append(message)
|
||||
}
|
||||
continue
|
||||
}
|
||||
guard message.channel == .thinking else {
|
||||
sanitized.append(message)
|
||||
continue
|
||||
}
|
||||
guard !message.hasOpenRouterReasoningReplayMetadata else {
|
||||
continue
|
||||
}
|
||||
guard let producerModel = message.metadata?.customData?["anthropic.thinking.model"] else {
|
||||
if
|
||||
target.allowsLegacyUnknown,
|
||||
message.metadata?.customData?["anthropic.thinking.type"] != nil
|
||||
{
|
||||
sanitized.append(message)
|
||||
}
|
||||
continue
|
||||
}
|
||||
let customData = message.metadata?.customData ?? [:]
|
||||
if producerModel == target.modelId, target.matches(customData) {
|
||||
sanitized.append(message)
|
||||
}
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
if let target = model.openRouterReasoningReplayTarget(configuration: configuration) {
|
||||
var sanitized: [ModelMessage] = []
|
||||
for message in self {
|
||||
if message.isSyntheticReasoningBoundary {
|
||||
if sanitized.last?.channel == .thinking {
|
||||
sanitized.append(message)
|
||||
}
|
||||
continue
|
||||
}
|
||||
guard message.channel == .thinking else {
|
||||
sanitized.append(message)
|
||||
continue
|
||||
}
|
||||
guard message.hasOpenRouterReasoningReplayMetadata else {
|
||||
continue
|
||||
}
|
||||
if target.matches(message.metadata?.customData ?? [:]) {
|
||||
sanitized.append(message)
|
||||
}
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
return self.filter { !$0.isSyntheticReasoningBoundary && $0.channel != .thinking }
|
||||
}
|
||||
}
|
||||
|
||||
extension ModelMessage {
|
||||
private var hasAnthropicThinkingReplayMetadata: Bool {
|
||||
guard let customData = metadata?.customData else { return false }
|
||||
return customData["anthropic.thinking.model"] != nil ||
|
||||
customData["anthropic.thinking.type"] != nil ||
|
||||
customData["anthropic.thinking.signature"] != nil
|
||||
}
|
||||
|
||||
fileprivate var hasOpenRouterReasoningReplayMetadata: Bool {
|
||||
guard let customData = metadata?.customData else { return false }
|
||||
return customData["openrouter.reasoning_details"] != nil ||
|
||||
customData["openrouter.reasoning"] != nil
|
||||
}
|
||||
|
||||
private var hasProviderReasoningReplayMetadata: Bool {
|
||||
self.hasAnthropicThinkingReplayMetadata || self.hasOpenRouterReasoningReplayMetadata
|
||||
}
|
||||
|
||||
fileprivate var isSyntheticReasoningBoundary: Bool {
|
||||
metadata?.customData?["tachikoma.internal.boundary"] == "reasoning_only"
|
||||
}
|
||||
}
|
||||
|
||||
extension [ModelMessage] {
|
||||
fileprivate func sanitizedForToolContext() -> [ModelMessage] {
|
||||
self.filter { $0.channel != .thinking && !$0.isSyntheticReasoningBoundary }
|
||||
}
|
||||
|
||||
fileprivate func containsAssistantText(_ text: String) -> Bool {
|
||||
guard !text.isEmpty else { return true }
|
||||
let assistantTexts = self.flatMap { message -> [String] in
|
||||
guard message.role == .assistant, message.channel != .thinking else {
|
||||
return []
|
||||
}
|
||||
return message.content.compactMap { part in
|
||||
if case let .text(value) = part {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return assistantTexts.contains(text) || assistantTexts.joined() == text
|
||||
}
|
||||
|
||||
fileprivate func containsReasoningBlock(_ reasoning: ProviderReasoningBlock) -> Bool {
|
||||
self.contains { message in
|
||||
message.role == .assistant && message.channel == .thinking && message.content.contains { part in
|
||||
guard case let .text(value) = part else { return false }
|
||||
if let signature = reasoning.signature, !signature.isEmpty {
|
||||
return message.metadata?.customData?["anthropic.thinking.signature"] == signature ||
|
||||
message.metadata?.customData?["tachikoma.reasoning.signature"] == signature
|
||||
}
|
||||
return value == reasoning.text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func containsToolCall(id: String) -> Bool {
|
||||
self.contains { message in
|
||||
message.role == .assistant && message.content.contains { part in
|
||||
if case let .toolCall(toolCall) = part {
|
||||
return toolCall.id == id
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LanguageModel {
|
||||
fileprivate func responseHistoryMessages(
|
||||
nativeMessages: [ModelMessage],
|
||||
text: String,
|
||||
reasoning: [ProviderReasoningBlock],
|
||||
toolCalls: [AgentToolCall],
|
||||
configuration: TachikomaConfiguration,
|
||||
)
|
||||
-> [ModelMessage]
|
||||
{
|
||||
var history = nativeMessages
|
||||
|
||||
for reasoningBlock in reasoning where !history.containsReasoningBlock(reasoningBlock) {
|
||||
history.append(ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text(reasoningBlock.text)],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: self.anthropicThinkingMetadata(
|
||||
for: reasoningBlock,
|
||||
configuration: configuration,
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
let missingToolCalls = toolCalls.filter { !history.containsToolCall(id: $0.id) }
|
||||
let isMissingText = !history.containsAssistantText(text)
|
||||
let needsFallbackBoundary = nativeMessages.isEmpty && text.isEmpty && missingToolCalls.isEmpty
|
||||
|
||||
guard isMissingText || !missingToolCalls.isEmpty || needsFallbackBoundary else {
|
||||
return history
|
||||
}
|
||||
|
||||
var fallbackContent: [ModelMessage.ContentPart] = []
|
||||
if isMissingText || needsFallbackBoundary {
|
||||
fallbackContent.append(.text(text))
|
||||
}
|
||||
fallbackContent.append(contentsOf: missingToolCalls.map { .toolCall($0) })
|
||||
let fallbackMetadata = needsFallbackBoundary
|
||||
? MessageMetadata(customData: ["tachikoma.internal.boundary": "reasoning_only"])
|
||||
: nil
|
||||
history.append(ModelMessage(role: .assistant, content: fallbackContent, metadata: fallbackMetadata))
|
||||
return history
|
||||
}
|
||||
|
||||
fileprivate func anthropicThinkingReplayTarget(configuration: TachikomaConfiguration) -> ReasoningReplayTarget? {
|
||||
switch self {
|
||||
case let .anthropic(model):
|
||||
return ReasoningReplayTarget(
|
||||
provider: "anthropic",
|
||||
modelId: model.modelId,
|
||||
baseURL: configuration.getBaseURL(for: .anthropic) ?? Provider.anthropic.defaultBaseURL,
|
||||
allowsLegacyUnknown: !LanguageModel.Anthropic.isFable(modelId: model.modelId),
|
||||
)
|
||||
case let .anthropicCompatible(modelId, baseURL):
|
||||
return ReasoningReplayTarget(
|
||||
provider: "anthropic-compatible",
|
||||
modelId: modelId,
|
||||
baseURL: baseURL,
|
||||
allowsLegacyUnknown: !LanguageModel.Anthropic.isFable(modelId: modelId),
|
||||
)
|
||||
case let .minimax(model):
|
||||
return ReasoningReplayTarget(
|
||||
provider: "minimax",
|
||||
modelId: model.modelId,
|
||||
baseURL: configuration.getBaseURL(for: .minimax) ?? Provider.minimax.defaultBaseURL,
|
||||
allowsLegacyUnknown: true,
|
||||
)
|
||||
case let .minimaxCN(model):
|
||||
return ReasoningReplayTarget(
|
||||
provider: "minimax-cn",
|
||||
modelId: model.modelId,
|
||||
baseURL: configuration.getBaseURL(for: .minimaxCN) ?? Provider.minimaxCN.defaultBaseURL,
|
||||
allowsLegacyUnknown: true,
|
||||
)
|
||||
case let .custom(provider):
|
||||
if let directAnthropicProvider = provider as? AnthropicProvider {
|
||||
return ReasoningReplayTarget(
|
||||
provider: "anthropic",
|
||||
modelId: directAnthropicProvider.modelId,
|
||||
baseURL: directAnthropicProvider.baseURL ?? Provider.anthropic.defaultBaseURL,
|
||||
allowsLegacyUnknown: !LanguageModel.Anthropic.isFable(modelId: directAnthropicProvider.modelId),
|
||||
)
|
||||
}
|
||||
if let compatibleProvider = provider as? AnthropicCompatibleProvider {
|
||||
return ReasoningReplayTarget(
|
||||
provider: "anthropic-compatible",
|
||||
modelId: compatibleProvider.modelId,
|
||||
baseURL: compatibleProvider.baseURL,
|
||||
allowsLegacyUnknown: !LanguageModel.Anthropic.isFable(modelId: compatibleProvider.modelId),
|
||||
)
|
||||
}
|
||||
guard
|
||||
let parsed = ProviderParser.parse(provider.modelId),
|
||||
let registeredProvider = CustomProviderRegistry.shared.get(parsed.provider),
|
||||
registeredProvider.kind == .anthropic else
|
||||
{
|
||||
return provider.modelId.contains("claude") || provider.modelId.contains("anthropic")
|
||||
? ReasoningReplayTarget(
|
||||
provider: "custom-anthropic",
|
||||
modelId: provider.modelId,
|
||||
baseURL: provider.baseURL,
|
||||
allowsLegacyUnknown: !LanguageModel.Anthropic.isFable(modelId: provider.modelId),
|
||||
)
|
||||
: nil
|
||||
}
|
||||
return ReasoningReplayTarget(
|
||||
provider: "custom-anthropic",
|
||||
modelId: parsed.model,
|
||||
baseURL: registeredProvider.baseURL,
|
||||
allowsLegacyUnknown: !LanguageModel.Anthropic.isFable(modelId: parsed.model),
|
||||
)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func openRouterReasoningReplayTarget(configuration: TachikomaConfiguration) -> ReasoningReplayTarget? {
|
||||
switch self {
|
||||
case let .openRouter(modelId):
|
||||
ReasoningReplayTarget(
|
||||
provider: "openrouter",
|
||||
modelId: modelId,
|
||||
baseURL: configuration.getBaseURL(for: .custom("openrouter")) ?? "https://openrouter.ai/api/v1",
|
||||
allowsLegacyUnknown: false,
|
||||
)
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func anthropicThinkingMetadata(
|
||||
for reasoning: ProviderReasoningBlock,
|
||||
configuration: TachikomaConfiguration,
|
||||
)
|
||||
-> [String: String]
|
||||
{
|
||||
if
|
||||
let rawJSON = reasoning.rawJSON,
|
||||
let target = self.openRouterReasoningReplayTarget(configuration: configuration)
|
||||
{
|
||||
var metadata = [
|
||||
"openrouter.reasoning_details": rawJSON,
|
||||
"tachikoma.reasoning.type": reasoning.type,
|
||||
"tachikoma.reasoning.provider": target.provider,
|
||||
"tachikoma.reasoning.model": target.modelId,
|
||||
]
|
||||
if let endpointIdentity = target.endpointIdentity {
|
||||
metadata["tachikoma.reasoning.base_url"] = endpointIdentity
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
if
|
||||
reasoning.type == "openrouter_reasoning",
|
||||
let target = self.openRouterReasoningReplayTarget(configuration: configuration)
|
||||
{
|
||||
var metadata = [
|
||||
"openrouter.reasoning": reasoning.text,
|
||||
"tachikoma.reasoning.type": reasoning.type,
|
||||
"tachikoma.reasoning.provider": target.provider,
|
||||
"tachikoma.reasoning.model": target.modelId,
|
||||
]
|
||||
if let endpointIdentity = target.endpointIdentity {
|
||||
metadata["tachikoma.reasoning.base_url"] = endpointIdentity
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
guard let target = self.anthropicThinkingReplayTarget(configuration: configuration) else {
|
||||
var customData = ["tachikoma.reasoning.type": reasoning.type]
|
||||
if let signature = reasoning.signature, !signature.isEmpty {
|
||||
customData["tachikoma.reasoning.signature"] = signature
|
||||
}
|
||||
return customData
|
||||
}
|
||||
|
||||
var customData = [
|
||||
"anthropic.thinking.type": reasoning.type,
|
||||
"anthropic.thinking.model": target.modelId,
|
||||
"tachikoma.reasoning.provider": target.provider,
|
||||
"tachikoma.reasoning.model": target.modelId,
|
||||
]
|
||||
if let endpointIdentity = target.endpointIdentity {
|
||||
customData["tachikoma.reasoning.base_url"] = endpointIdentity
|
||||
}
|
||||
if let signature = reasoning.signature, !signature.isEmpty {
|
||||
customData["anthropic.thinking.signature"] = signature
|
||||
}
|
||||
return customData
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Functions
|
||||
|
||||
/// Simple text generation from a prompt (convenience wrapper) - with Model enum
|
||||
@ -670,7 +1274,7 @@ public func analyze(
|
||||
model
|
||||
} else {
|
||||
// Use a vision-capable model by default
|
||||
.openai(.gpt4o)
|
||||
.openai(.gpt55)
|
||||
}
|
||||
|
||||
// Ensure the model supports vision
|
||||
@ -746,7 +1350,7 @@ public func analyze(
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public func stream(
|
||||
_ prompt: String,
|
||||
using model: LanguageModel = .default,
|
||||
using model: LanguageModel = .defaultStreaming,
|
||||
system: String? = nil,
|
||||
maxTokens: Int? = nil,
|
||||
temperature: Double? = nil,
|
||||
|
||||
@ -206,6 +206,10 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
|
||||
"ollama:\(submodel.modelId)"
|
||||
case let .lmstudio(submodel):
|
||||
"lmstudio:\(submodel.modelId)"
|
||||
case let .minimax(submodel):
|
||||
"minimax:\(submodel.modelId)"
|
||||
case let .minimaxCN(submodel):
|
||||
"minimax-cn:\(submodel.modelId)"
|
||||
case let .openRouter(modelId):
|
||||
"openrouter:\(modelId)"
|
||||
case let .together(modelId):
|
||||
@ -237,46 +241,16 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
|
||||
excludedParameters: ["temperature", "topP", "frequencyPenalty", "presencePenalty"],
|
||||
)
|
||||
|
||||
self.capabilities["openai:gpt-5.1"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5.2"] = gpt5Capabilities
|
||||
self.capabilities["openai:chat-latest"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5-chat-latest"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5.5"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5.4"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5.4-mini"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5.4-nano"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5-pro"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5-mini"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5-nano"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5-thinking"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5-thinking-mini"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5-thinking-nano"] = gpt5Capabilities
|
||||
self.capabilities["openai:gpt-5-chat-latest"] = gpt5Capabilities
|
||||
|
||||
// O4/GPT-5 reasoning models (fixed temperature, reasoning effort)
|
||||
let reasoningCapabilities = ModelParameterCapabilities(
|
||||
supportsTemperature: false,
|
||||
supportsTopP: false,
|
||||
supportedProviderOptions: .init(
|
||||
supportsReasoningEffort: true,
|
||||
supportsPreviousResponseId: true,
|
||||
),
|
||||
forcedTemperature: 1.0,
|
||||
excludedParameters: ["temperature", "topP"],
|
||||
)
|
||||
|
||||
self.capabilities["openai:o4"] = reasoningCapabilities
|
||||
self.capabilities["openai:o4-mini"] = reasoningCapabilities
|
||||
|
||||
// Standard GPT-4 models
|
||||
let gpt4Capabilities = ModelParameterCapabilities(
|
||||
supportedProviderOptions: .init(
|
||||
supportsParallelToolCalls: true,
|
||||
supportsResponseFormat: true,
|
||||
supportsLogprobs: true,
|
||||
),
|
||||
)
|
||||
|
||||
self.capabilities["openai:gpt-4o"] = gpt4Capabilities
|
||||
self.capabilities["openai:gpt-4o-mini"] = gpt4Capabilities
|
||||
self.capabilities["openai:gpt-4.1"] = gpt4Capabilities
|
||||
self.capabilities["openai:gpt-4.1-mini"] = gpt4Capabilities
|
||||
self.capabilities["openai:gpt-4-turbo"] = gpt4Capabilities
|
||||
|
||||
// Claude 4 models with thinking
|
||||
let claude4Capabilities = ModelParameterCapabilities(
|
||||
@ -286,11 +260,25 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
|
||||
),
|
||||
)
|
||||
|
||||
// Fable 5 and Opus 4.7+ map requested thinking to Anthropic's adaptive thinking request shape.
|
||||
let claudeAdaptiveThinkingCapabilities = ModelParameterCapabilities(
|
||||
supportsTemperature: false,
|
||||
supportsTopP: false,
|
||||
supportsTopK: false,
|
||||
supportedProviderOptions: .init(
|
||||
supportsThinking: true,
|
||||
supportsCacheControl: true,
|
||||
),
|
||||
excludedParameters: ["temperature", "topP", "topK"],
|
||||
)
|
||||
self.capabilities["anthropic:claude-fable-5"] = claudeAdaptiveThinkingCapabilities
|
||||
self.capabilities["anthropic:claude-opus-4-8"] = claudeAdaptiveThinkingCapabilities
|
||||
self.capabilities["anthropic:claude-opus-4-7"] = claudeAdaptiveThinkingCapabilities
|
||||
self.capabilities["anthropic:claude-opus-4-5"] = claude4Capabilities
|
||||
self.capabilities["anthropic:claude-opus-4-1-20250805"] = claude4Capabilities
|
||||
self.capabilities["anthropic:claude-sonnet-4-20250514"] = claude4Capabilities
|
||||
self.capabilities["anthropic:claude-sonnet-4-6"] = claude4Capabilities
|
||||
self.capabilities["anthropic:claude-sonnet-4-5-20250929"] = claude4Capabilities
|
||||
self.capabilities["anthropic:claude-haiku-4.5"] = claude4Capabilities
|
||||
self.capabilities["anthropic:claude-haiku-4-5"] = claude4Capabilities
|
||||
|
||||
// Google Gemini with thinking
|
||||
let geminiCapabilities = ModelParameterCapabilities(
|
||||
@ -301,6 +289,9 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
|
||||
),
|
||||
)
|
||||
|
||||
self.capabilities["google:gemini-3.5-flash"] = geminiCapabilities
|
||||
self.capabilities["google:gemini-3.1-pro-preview"] = geminiCapabilities
|
||||
self.capabilities["google:gemini-3.1-flash-lite"] = geminiCapabilities
|
||||
self.capabilities["google:gemini-2.5-pro"] = geminiCapabilities
|
||||
self.capabilities["google:gemini-2.5-flash"] = geminiCapabilities
|
||||
self.capabilities["google:gemini-2.5-flash-lite"] = geminiCapabilities
|
||||
@ -314,8 +305,12 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
|
||||
),
|
||||
)
|
||||
|
||||
self.capabilities["mistral:mistral-large-2"] = mistralCapabilities
|
||||
self.capabilities["mistral:codestral"] = mistralCapabilities
|
||||
self.capabilities["mistral:mistral-large-latest"] = mistralCapabilities
|
||||
self.capabilities["mistral:mistral-medium-latest"] = mistralCapabilities
|
||||
self.capabilities["mistral:mistral-medium-3-5"] = mistralCapabilities
|
||||
self.capabilities["mistral:mistral-small-latest"] = mistralCapabilities
|
||||
self.capabilities["mistral:open-mistral-nemo-2407"] = mistralCapabilities
|
||||
self.capabilities["mistral:codestral-latest"] = mistralCapabilities
|
||||
|
||||
// Groq models (ultra-fast inference)
|
||||
let groqCapabilities = ModelParameterCapabilities(
|
||||
@ -324,12 +319,12 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
|
||||
),
|
||||
)
|
||||
|
||||
self.capabilities["groq:llama-3.1-70b"] = groqCapabilities
|
||||
self.capabilities["groq:llama-3.1-8b"] = groqCapabilities
|
||||
self.capabilities["groq:llama-3-70b"] = groqCapabilities
|
||||
self.capabilities["groq:llama-3-8b"] = groqCapabilities
|
||||
self.capabilities["groq:mixtral-8x7b"] = groqCapabilities
|
||||
self.capabilities["groq:gemma2-9b"] = groqCapabilities
|
||||
self.capabilities["groq:openai/gpt-oss-120b"] = groqCapabilities
|
||||
self.capabilities["groq:openai/gpt-oss-20b"] = groqCapabilities
|
||||
self.capabilities["groq:llama-3.3-70b-versatile"] = groqCapabilities
|
||||
self.capabilities["groq:llama-3.1-8b-instant"] = groqCapabilities
|
||||
self.capabilities["groq:meta-llama/llama-4-maverick-17b-128e-instruct"] = groqCapabilities
|
||||
self.capabilities["groq:meta-llama/llama-4-scout-17b-16e-instruct"] = groqCapabilities
|
||||
|
||||
// Grok models
|
||||
let grokCapabilities = ModelParameterCapabilities(
|
||||
@ -339,17 +334,9 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
|
||||
),
|
||||
)
|
||||
|
||||
self.capabilities["grok:grok-4-0709"] = grokCapabilities
|
||||
self.capabilities["grok:grok-4-fast-reasoning"] = grokCapabilities
|
||||
self.capabilities["grok:grok-4-fast-non-reasoning"] = grokCapabilities
|
||||
self.capabilities["grok:grok-code-fast-1"] = grokCapabilities
|
||||
self.capabilities["grok:grok-3"] = grokCapabilities
|
||||
self.capabilities["grok:grok-3-mini"] = grokCapabilities
|
||||
self.capabilities["grok:grok-2-1212"] = grokCapabilities
|
||||
self.capabilities["grok:grok-2-vision-1212"] = grokCapabilities
|
||||
self.capabilities["grok:grok-2-image-1212"] = grokCapabilities
|
||||
self.capabilities["grok:grok-vision-beta"] = grokCapabilities
|
||||
self.capabilities["grok:grok-beta"] = grokCapabilities
|
||||
self.capabilities["grok:grok-4.3"] = grokCapabilities
|
||||
self.capabilities["grok:grok-4.20-0309-reasoning"] = grokCapabilities
|
||||
self.capabilities["grok:grok-4.20-0309-non-reasoning"] = grokCapabilities
|
||||
}
|
||||
|
||||
private func defaultCapabilities(for model: LanguageModel) -> ModelParameterCapabilities {
|
||||
@ -359,6 +346,19 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
|
||||
return registered
|
||||
}
|
||||
|
||||
if self.isAnthropicFableCompatible(model) {
|
||||
return ModelParameterCapabilities(
|
||||
supportsTemperature: false,
|
||||
supportsTopP: false,
|
||||
supportsTopK: false,
|
||||
supportedProviderOptions: .init(
|
||||
supportsThinking: true,
|
||||
supportsCacheControl: true,
|
||||
),
|
||||
excludedParameters: ["temperature", "topP", "topK"],
|
||||
)
|
||||
}
|
||||
|
||||
// Return provider-based defaults
|
||||
switch model {
|
||||
case .openai:
|
||||
@ -369,6 +369,7 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
|
||||
// Default Anthropic capabilities
|
||||
return ModelParameterCapabilities(
|
||||
supportedProviderOptions: .init(
|
||||
supportsThinking: true,
|
||||
supportsCacheControl: true,
|
||||
),
|
||||
)
|
||||
@ -388,11 +389,38 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
|
||||
supportsSeed: true,
|
||||
)
|
||||
|
||||
case .minimax, .minimaxCN:
|
||||
return ModelParameterCapabilities()
|
||||
|
||||
default:
|
||||
// Default capabilities for unknown models
|
||||
return ModelParameterCapabilities()
|
||||
}
|
||||
}
|
||||
|
||||
private func isAnthropicFableCompatible(_ model: LanguageModel) -> Bool {
|
||||
switch model {
|
||||
case let .anthropic(anthropic):
|
||||
return LanguageModel.Anthropic.isFable(modelId: anthropic.modelId)
|
||||
case let .anthropicCompatible(modelId, _):
|
||||
return LanguageModel.Anthropic.isFable(modelId: modelId)
|
||||
case let .openRouter(modelId),
|
||||
let .openaiCompatible(modelId, _),
|
||||
let .together(modelId):
|
||||
return LanguageModel.Anthropic.isFable(modelId: modelId)
|
||||
case let .custom(provider):
|
||||
guard
|
||||
let parsed = ProviderParser.parse(provider.modelId),
|
||||
LanguageModel.Anthropic.isFable(modelId: parsed.model),
|
||||
CustomProviderRegistry.shared.get(parsed.provider)?.kind == .anthropic else
|
||||
{
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GenerationSettings Extension
|
||||
@ -406,6 +434,7 @@ extension GenerationSettings {
|
||||
|
||||
var adjustedTemperature = temperature
|
||||
var adjustedTopP = topP
|
||||
var adjustedTopK = topK
|
||||
var adjustedFrequencyPenalty = frequencyPenalty
|
||||
var adjustedPresencePenalty = presencePenalty
|
||||
var adjustedProviderOptions = providerOptions
|
||||
@ -417,6 +446,9 @@ extension GenerationSettings {
|
||||
if capabilities.excludedParameters.contains("topP") {
|
||||
adjustedTopP = nil
|
||||
}
|
||||
if capabilities.excludedParameters.contains("topK") {
|
||||
adjustedTopK = nil
|
||||
}
|
||||
if capabilities.excludedParameters.contains("frequencyPenalty") {
|
||||
adjustedFrequencyPenalty = nil
|
||||
}
|
||||
@ -440,7 +472,7 @@ extension GenerationSettings {
|
||||
maxTokens: maxTokens,
|
||||
temperature: adjustedTemperature,
|
||||
topP: adjustedTopP,
|
||||
topK: topK,
|
||||
topK: adjustedTopK,
|
||||
frequencyPenalty: adjustedFrequencyPenalty,
|
||||
presencePenalty: adjustedPresencePenalty,
|
||||
stopSequences: stopSequences,
|
||||
@ -448,6 +480,7 @@ extension GenerationSettings {
|
||||
stopConditions: stopConditions,
|
||||
seed: seed,
|
||||
providerOptions: adjustedProviderOptions,
|
||||
streamBuffering: self.streamBuffering,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -36,14 +36,24 @@ struct OpenAICompatibleHelper {
|
||||
}
|
||||
|
||||
// Extract stop sequences from stop conditions
|
||||
let stopSequences = Self.extractStopSequences(from: request.settings.stopConditions)
|
||||
let settings = Self.validatedSettings(
|
||||
request.settings,
|
||||
providerName: providerName,
|
||||
modelId: modelId,
|
||||
baseURL: baseURL,
|
||||
)
|
||||
let stopSequences = Self.extractStopSequences(from: settings.stopConditions)
|
||||
|
||||
// Convert request to OpenAI-compatible format
|
||||
let openAIRequest = try OpenAIChatRequest(
|
||||
model: modelId,
|
||||
messages: convertMessages(request.messages),
|
||||
temperature: request.settings.temperature,
|
||||
maxTokens: request.settings.maxTokens,
|
||||
messages: convertMessages(
|
||||
request.messages,
|
||||
replayOpenRouterReasoningForModel: providerName == "OpenRouter" ? modelId : nil,
|
||||
replayOpenRouterReasoningForBaseURL: providerName == "OpenRouter" ? baseURL : nil,
|
||||
),
|
||||
temperature: settings.temperature,
|
||||
maxTokens: settings.maxTokens,
|
||||
tools: request.tools?.compactMap { try self.convertTool($0) },
|
||||
stream: false,
|
||||
stop: stopSequences.isEmpty ? nil : stopSequences,
|
||||
@ -100,14 +110,9 @@ struct OpenAICompatibleHelper {
|
||||
let usage = openAIResponse.usage.map {
|
||||
Usage(inputTokens: $0.promptTokens ?? 0, outputTokens: $0.completionTokens ?? 0)
|
||||
}
|
||||
let reasoning = Self.reasoningBlocks(from: choice.message)
|
||||
|
||||
let finishReason: FinishReason? = switch choice.finishReason {
|
||||
case "stop": .stop
|
||||
case "length": .length
|
||||
case "tool_calls": .toolCalls
|
||||
case "content_filter": .contentFilter
|
||||
default: .other
|
||||
}
|
||||
let finishReason = Self.mapFinishReason(choice.finishReason)
|
||||
|
||||
// Convert tool calls if present
|
||||
let toolCalls = choice.message.toolCalls?.compactMap { openAIToolCall -> AgentToolCall? in
|
||||
@ -142,6 +147,7 @@ struct OpenAICompatibleHelper {
|
||||
usage: usage,
|
||||
finishReason: finishReason,
|
||||
toolCalls: toolCalls,
|
||||
reasoning: reasoning,
|
||||
)
|
||||
}
|
||||
|
||||
@ -173,14 +179,27 @@ struct OpenAICompatibleHelper {
|
||||
}
|
||||
|
||||
// Extract stop sequences from stop conditions
|
||||
let stopSequences = Self.extractStopSequences(from: request.settings.stopConditions)
|
||||
guard !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId) else {
|
||||
throw TachikomaError.invalidConfiguration("\(modelId) does not support streaming")
|
||||
}
|
||||
let settings = Self.validatedSettings(
|
||||
request.settings,
|
||||
providerName: providerName,
|
||||
modelId: modelId,
|
||||
baseURL: baseURL,
|
||||
)
|
||||
let stopSequences = Self.extractStopSequences(from: settings.stopConditions)
|
||||
|
||||
// Convert request to OpenAI-compatible format
|
||||
let openAIRequest = try OpenAIChatRequest(
|
||||
model: modelId,
|
||||
messages: convertMessages(request.messages),
|
||||
temperature: request.settings.temperature,
|
||||
maxTokens: request.settings.maxTokens,
|
||||
messages: convertMessages(
|
||||
request.messages,
|
||||
replayOpenRouterReasoningForModel: providerName == "OpenRouter" ? modelId : nil,
|
||||
replayOpenRouterReasoningForBaseURL: providerName == "OpenRouter" ? baseURL : nil,
|
||||
),
|
||||
temperature: settings.temperature,
|
||||
maxTokens: settings.maxTokens,
|
||||
tools: request.tools?.compactMap { try self.convertTool($0) },
|
||||
stream: true,
|
||||
stop: stopSequences.isEmpty ? nil : stopSequences,
|
||||
@ -346,8 +365,10 @@ struct OpenAICompatibleHelper {
|
||||
}
|
||||
}
|
||||
|
||||
if choice.finishReason != nil {
|
||||
continuation.yield(TextStreamDelta.done())
|
||||
if let finishReason = choice.finishReason {
|
||||
continuation.yield(TextStreamDelta.done(
|
||||
finishReason: Self.mapFinishReason(finishReason),
|
||||
))
|
||||
break
|
||||
}
|
||||
}
|
||||
@ -427,9 +448,10 @@ struct OpenAICompatibleHelper {
|
||||
}
|
||||
|
||||
if let finishReason = choice.finishReason {
|
||||
if finishReason == "stop" || finishReason == "tool_calls" {
|
||||
continuation.yield(TextStreamDelta.done())
|
||||
}
|
||||
continuation.yield(TextStreamDelta.done(
|
||||
finishReason: Self.mapFinishReason(finishReason),
|
||||
))
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@ -456,6 +478,39 @@ struct OpenAICompatibleHelper {
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private static func mapFinishReason(_ reason: String?) -> FinishReason? {
|
||||
switch reason {
|
||||
case "stop": .stop
|
||||
case "length": .length
|
||||
case "tool_calls": .toolCalls
|
||||
case "content_filter": .contentFilter
|
||||
case nil: nil
|
||||
default: .other
|
||||
}
|
||||
}
|
||||
|
||||
private static func validatedSettings(
|
||||
_ settings: GenerationSettings,
|
||||
providerName: String,
|
||||
modelId: String,
|
||||
baseURL: String,
|
||||
)
|
||||
-> GenerationSettings
|
||||
{
|
||||
settings.validated(for: self.languageModel(providerName: providerName, modelId: modelId, baseURL: baseURL))
|
||||
}
|
||||
|
||||
private static func languageModel(providerName: String, modelId: String, baseURL: String) -> LanguageModel {
|
||||
switch providerName.lowercased() {
|
||||
case "openrouter":
|
||||
.openRouter(modelId: modelId)
|
||||
case "together":
|
||||
.together(modelId: modelId)
|
||||
default:
|
||||
.openaiCompatible(modelId: modelId, baseURL: baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract native stop sequences from stop conditions
|
||||
private static func extractStopSequences(from stopCondition: (any StopCondition)?) -> [String] {
|
||||
// Extract native stop sequences from stop conditions
|
||||
@ -512,18 +567,55 @@ struct OpenAICompatibleHelper {
|
||||
}
|
||||
}
|
||||
|
||||
private static func convertMessages(_ messages: [ModelMessage]) throws -> [OpenAIChatMessage] {
|
||||
messages.map { message in
|
||||
private static func convertMessages(
|
||||
_ messages: [ModelMessage],
|
||||
replayOpenRouterReasoningForModel modelId: String?,
|
||||
replayOpenRouterReasoningForBaseURL baseURL: String?,
|
||||
) throws
|
||||
-> [OpenAIChatMessage]
|
||||
{
|
||||
var converted: [OpenAIChatMessage] = []
|
||||
var pendingReasoningDetails: [JSONValue] = []
|
||||
var pendingReasoningText: [String] = []
|
||||
let endpointIdentity = ReasoningEndpointIdentity.canonical(baseURL)
|
||||
|
||||
for message in messages {
|
||||
if
|
||||
message.channel == .thinking,
|
||||
let customData = message.metadata?.customData,
|
||||
customData["tachikoma.reasoning.provider"] == "openrouter",
|
||||
customData["tachikoma.reasoning.model"] == modelId,
|
||||
customData["tachikoma.reasoning.base_url"] == endpointIdentity,
|
||||
let rawReasoningDetails = customData["openrouter.reasoning_details"]
|
||||
{
|
||||
pendingReasoningDetails.append(contentsOf: Self.decodeReasoningDetails(rawReasoningDetails))
|
||||
continue
|
||||
}
|
||||
if
|
||||
message.channel == .thinking,
|
||||
let customData = message.metadata?.customData,
|
||||
customData["tachikoma.reasoning.provider"] == "openrouter",
|
||||
customData["tachikoma.reasoning.model"] == modelId,
|
||||
customData["tachikoma.reasoning.base_url"] == endpointIdentity,
|
||||
let reasoning = customData["openrouter.reasoning"]
|
||||
{
|
||||
pendingReasoningText.append(reasoning)
|
||||
continue
|
||||
}
|
||||
if message.channel == .thinking {
|
||||
continue
|
||||
}
|
||||
|
||||
switch message.role {
|
||||
case .system:
|
||||
return OpenAIChatMessage(role: "system", content: message.content.compactMap { part in
|
||||
converted.append(OpenAIChatMessage(role: "system", content: message.content.compactMap { part in
|
||||
if case let .text(text) = part { return text }
|
||||
return nil
|
||||
}.joined())
|
||||
}.joined()))
|
||||
case .user:
|
||||
if message.content.count == 1, case let .text(text) = message.content.first! {
|
||||
// Simple text message
|
||||
return OpenAIChatMessage(role: "user", content: text)
|
||||
converted.append(OpenAIChatMessage(role: "user", content: text))
|
||||
} else {
|
||||
// Multi-modal message
|
||||
let content = message.content.compactMap { contentPart -> OpenAIChatMessageContent? in
|
||||
@ -540,7 +632,7 @@ struct OpenAICompatibleHelper {
|
||||
return nil // Skip tool calls and results in user messages
|
||||
}
|
||||
}
|
||||
return OpenAIChatMessage(role: "user", content: content)
|
||||
converted.append(OpenAIChatMessage(role: "user", content: content))
|
||||
}
|
||||
case .assistant:
|
||||
// Check if this assistant message contains tool calls
|
||||
@ -571,15 +663,25 @@ struct OpenAICompatibleHelper {
|
||||
|
||||
// If we have tool calls, create a message with tool calls
|
||||
if !toolCalls.isEmpty {
|
||||
return OpenAIChatMessage(
|
||||
converted.append(OpenAIChatMessage(
|
||||
role: "assistant",
|
||||
content: textContent.isEmpty ? nil : textContent,
|
||||
toolCalls: toolCalls,
|
||||
)
|
||||
reasoning: pendingReasoningText.isEmpty ? nil : pendingReasoningText.joined(separator: "\n"),
|
||||
reasoningDetails: pendingReasoningDetails.isEmpty ? nil : pendingReasoningDetails,
|
||||
))
|
||||
} else {
|
||||
// Regular text message
|
||||
return OpenAIChatMessage(role: "assistant", content: textContent)
|
||||
converted.append(OpenAIChatMessage(
|
||||
role: "assistant",
|
||||
content: textContent,
|
||||
toolCalls: nil,
|
||||
reasoning: pendingReasoningText.isEmpty ? nil : pendingReasoningText.joined(separator: "\n"),
|
||||
reasoningDetails: pendingReasoningDetails.isEmpty ? nil : pendingReasoningDetails,
|
||||
))
|
||||
}
|
||||
pendingReasoningText.removeAll()
|
||||
pendingReasoningDetails.removeAll()
|
||||
case .tool:
|
||||
// Extract tool call ID and result content from tool result
|
||||
var toolCallId: String?
|
||||
@ -598,9 +700,44 @@ struct OpenAICompatibleHelper {
|
||||
}
|
||||
}
|
||||
|
||||
return OpenAIChatMessage(role: "tool", content: resultContent, toolCallId: toolCallId)
|
||||
converted.append(OpenAIChatMessage(role: "tool", content: resultContent, toolCallId: toolCallId))
|
||||
}
|
||||
}
|
||||
|
||||
return converted
|
||||
}
|
||||
|
||||
private static func reasoningBlocks(from message: OpenAIChatResponse.Message) -> [ProviderReasoningBlock] {
|
||||
var blocks: [ProviderReasoningBlock] = []
|
||||
if let details = message.reasoningDetails, !details.isEmpty {
|
||||
blocks.append(ProviderReasoningBlock(
|
||||
text: message.reasoning ?? "",
|
||||
type: "openrouter_reasoning_details",
|
||||
rawJSON: Self.encodeReasoningDetails(details),
|
||||
))
|
||||
} else if let reasoning = message.reasoning, !reasoning.isEmpty {
|
||||
blocks.append(ProviderReasoningBlock(
|
||||
text: reasoning,
|
||||
type: "openrouter_reasoning",
|
||||
rawJSON: nil,
|
||||
))
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
private static func encodeReasoningDetails(_ details: [JSONValue]) -> String? {
|
||||
guard let data = try? JSONEncoder().encode(details) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func decodeReasoningDetails(_ rawJSON: String) -> [JSONValue] {
|
||||
guard
|
||||
let data = rawJSON.data(using: .utf8),
|
||||
let details = try? JSONDecoder().decode([JSONValue].self, from: data) else
|
||||
{
|
||||
return []
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
private static func convertTool(_ tool: AgentTool) throws -> OpenAITool {
|
||||
|
||||
@ -41,6 +41,12 @@ public enum Provider: Sendable, Hashable, Codable {
|
||||
/// Google provider (Gemini models)
|
||||
case google
|
||||
|
||||
/// MiniMax provider (Anthropic-compatible hosted models)
|
||||
case minimax
|
||||
|
||||
/// MiniMax China provider (Anthropic-compatible hosted models)
|
||||
case minimaxCN
|
||||
|
||||
/// Ollama provider (local model hosting)
|
||||
case ollama
|
||||
|
||||
@ -62,6 +68,8 @@ public enum Provider: Sendable, Hashable, Codable {
|
||||
case .groq: "groq"
|
||||
case .mistral: "mistral"
|
||||
case .google: "google"
|
||||
case .minimax: "minimax"
|
||||
case .minimaxCN: "minimax-cn"
|
||||
case .ollama: "ollama"
|
||||
case .lmstudio: "lmstudio"
|
||||
case .azureOpenAI: "azure-openai"
|
||||
@ -78,6 +86,8 @@ public enum Provider: Sendable, Hashable, Codable {
|
||||
case .groq: "Groq"
|
||||
case .mistral: "Mistral"
|
||||
case .google: "Google"
|
||||
case .minimax: "MiniMax"
|
||||
case .minimaxCN: "MiniMax China"
|
||||
case .ollama: "Ollama"
|
||||
case .lmstudio: "LMStudio"
|
||||
case .azureOpenAI: "Azure OpenAI"
|
||||
@ -94,6 +104,8 @@ public enum Provider: Sendable, Hashable, Codable {
|
||||
case .groq: "GROQ_API_KEY"
|
||||
case .mistral: "MISTRAL_API_KEY"
|
||||
case .google: "GEMINI_API_KEY"
|
||||
case .minimax: "MINIMAX_API_KEY"
|
||||
case .minimaxCN: "MINIMAX_CN_API_KEY"
|
||||
case .ollama: "OLLAMA_API_KEY"
|
||||
case .lmstudio: "" // LMStudio doesn't need API keys
|
||||
case .azureOpenAI: "AZURE_OPENAI_API_KEY"
|
||||
@ -106,6 +118,7 @@ public enum Provider: Sendable, Hashable, Codable {
|
||||
switch self {
|
||||
case .grok: ["XAI_API_KEY", "GROK_API_KEY"] // Additional Grok aliases
|
||||
case .google: ["GOOGLE_API_KEY"] // Backwards compatibility
|
||||
case .minimaxCN: ["MINIMAX_API_KEY"]
|
||||
case .azureOpenAI: ["AZURE_OPENAI_TOKEN", "AZURE_OPENAI_BEARER_TOKEN"]
|
||||
default: []
|
||||
}
|
||||
@ -120,6 +133,8 @@ public enum Provider: Sendable, Hashable, Codable {
|
||||
case .groq: "https://api.groq.com/openai/v1"
|
||||
case .mistral: "https://api.mistral.ai/v1"
|
||||
case .google: "https://generativelanguage.googleapis.com/v1beta"
|
||||
case .minimax: "https://api.minimax.io/anthropic"
|
||||
case .minimaxCN: "https://api.minimaxi.com/anthropic"
|
||||
case .ollama: "http://localhost:11434"
|
||||
case .lmstudio: "http://localhost:1234/v1"
|
||||
case .azureOpenAI: nil // Requires resource or endpoint
|
||||
@ -139,7 +154,7 @@ public enum Provider: Sendable, Hashable, Codable {
|
||||
|
||||
/// All standard providers (excludes custom)
|
||||
public static var standardProviders: [Provider] {
|
||||
[.openai, .anthropic, .grok, .groq, .mistral, .google, .ollama, .azureOpenAI]
|
||||
[.openai, .anthropic, .grok, .groq, .mistral, .google, .minimax, .minimaxCN, .ollama, .azureOpenAI]
|
||||
}
|
||||
|
||||
/// Create provider from string identifier
|
||||
@ -151,7 +166,9 @@ public enum Provider: Sendable, Hashable, Codable {
|
||||
case "grok", "xai": .grok
|
||||
case "groq": .groq
|
||||
case "mistral": .mistral
|
||||
case "google": .google
|
||||
case "google", "gemini": .google
|
||||
case "minimax": .minimax
|
||||
case "minimax-cn", "minimax_cn", "minimaxi": .minimaxCN
|
||||
case "ollama": .ollama
|
||||
case "azure-openai", "azure_openai", "azureopenai": .azureOpenAI
|
||||
default: .custom(identifier)
|
||||
|
||||
@ -56,7 +56,7 @@ public struct OpenAIOptions: Sendable, Codable {
|
||||
/// Verbosity level for GPT-5 models
|
||||
public var verbosity: Verbosity?
|
||||
|
||||
/// Reasoning effort for O3/O4 models
|
||||
/// Reasoning effort for GPT-5 models
|
||||
public var reasoningEffort: ReasoningEffort?
|
||||
|
||||
/// Previous response ID for Responses API chaining
|
||||
@ -159,6 +159,7 @@ public struct AnthropicOptions: Sendable, Codable {
|
||||
public enum ThinkingMode: Sendable, Codable {
|
||||
case disabled
|
||||
case enabled(budgetTokens: Int)
|
||||
case adaptive
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
@ -174,6 +175,8 @@ public struct AnthropicOptions: Sendable, Codable {
|
||||
case "enabled":
|
||||
let budget = try container.decode(Int.self, forKey: .budgetTokens)
|
||||
self = .enabled(budgetTokens: budget)
|
||||
case "adaptive":
|
||||
self = .adaptive
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .type,
|
||||
@ -191,6 +194,8 @@ public struct AnthropicOptions: Sendable, Codable {
|
||||
case let .enabled(budgetTokens):
|
||||
try container.encode("enabled", forKey: .type)
|
||||
try container.encode(budgetTokens, forKey: .budgetTokens)
|
||||
case .adaptive:
|
||||
try container.encode("adaptive", forKey: .type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,17 @@ public protocol StopCondition: Sendable {
|
||||
func reset() async
|
||||
}
|
||||
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
protocol StableCacheKeyStopCondition {
|
||||
var stableCacheKey: String? { get }
|
||||
}
|
||||
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
private func compositeStableCacheKey(kind: String, children: [String]) -> String {
|
||||
let encodedChildren = children.map { "\($0.utf8.count):\($0)" }.joined()
|
||||
return "\(kind):[\(encodedChildren)]"
|
||||
}
|
||||
|
||||
// MARK: - Built-in Stop Conditions
|
||||
|
||||
/// Stop when a specific string is encountered
|
||||
@ -39,6 +50,13 @@ public struct StringStopCondition: StopCondition {
|
||||
public func reset() async {}
|
||||
}
|
||||
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
extension StringStopCondition: StableCacheKeyStopCondition {
|
||||
var stableCacheKey: String? {
|
||||
"string:\(self.caseSensitive):\(self.stopString)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop when a regex pattern is matched
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public struct RegexStopCondition: StopCondition {
|
||||
@ -82,6 +100,13 @@ public struct RegexStopCondition: StopCondition {
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
extension RegexStopCondition: StableCacheKeyStopCondition {
|
||||
var stableCacheKey: String? {
|
||||
"regex:\(self.pattern)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop after a certain number of tokens
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public actor TokenCountStopCondition: StopCondition {
|
||||
@ -182,6 +207,15 @@ public struct AnyStopCondition: StopCondition {
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
extension AnyStopCondition: StableCacheKeyStopCondition {
|
||||
var stableCacheKey: String? {
|
||||
let keys = self.conditions.compactMap { ($0 as? StableCacheKeyStopCondition)?.stableCacheKey }
|
||||
guard keys.count == self.conditions.count else { return nil }
|
||||
return compositeStableCacheKey(kind: "any", children: keys)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop when all conditions are met
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public struct AllStopCondition: StopCondition {
|
||||
@ -211,6 +245,15 @@ public struct AllStopCondition: StopCondition {
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
extension AllStopCondition: StableCacheKeyStopCondition {
|
||||
var stableCacheKey: String? {
|
||||
let keys = self.conditions.compactMap { ($0 as? StableCacheKeyStopCondition)?.stableCacheKey }
|
||||
guard keys.count == self.conditions.count else { return nil }
|
||||
return compositeStableCacheKey(kind: "all", children: keys)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stateful Stop Conditions
|
||||
|
||||
/// Stop when a pattern appears consecutively N times
|
||||
@ -386,6 +429,13 @@ public struct NeverStopCondition: StopCondition {
|
||||
public func reset() async {}
|
||||
}
|
||||
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
extension NeverStopCondition: StableCacheKeyStopCondition {
|
||||
var stableCacheKey: String? {
|
||||
"never"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Integration with Generation Functions
|
||||
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
@ -431,9 +481,8 @@ extension AsyncThrowingStream where Element == TextStreamDelta {
|
||||
|
||||
// Check stop condition
|
||||
if await condition.shouldStop(text: accumulatedText, delta: content) {
|
||||
// Yield the current delta then stop
|
||||
continuation.yield(delta)
|
||||
continuation.yield(TextStreamDelta.done())
|
||||
continuation.yield(TextStreamDelta.done(finishReason: .stop))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ import Foundation
|
||||
//
|
||||
// ```swift
|
||||
// // Simple generation
|
||||
// let answer = try await generate("What is 2+2?", using: .openai(.gpt4o))
|
||||
// let answer = try await generate("What is 2+2?", using: .openai(.gpt55))
|
||||
//
|
||||
// // Conversation management
|
||||
// let conversation = Conversation()
|
||||
@ -114,7 +114,7 @@ public enum API {
|
||||
/// Model selection system
|
||||
public enum Models {
|
||||
/// Type-safe model selection
|
||||
/// - `.openai(.gpt4o)`, `.anthropic(.opus4)`, `.grok(.grok4)`, `.ollama(.llama3_3)`
|
||||
/// - `.openai(.gpt55)`, `.anthropic(.opus47)`, `.grok(.grok43)`, `.ollama(.llama3_3)`
|
||||
public static let typed = "Provider-specific model enums"
|
||||
|
||||
/// Custom endpoints
|
||||
@ -196,16 +196,16 @@ public enum API {
|
||||
/// Migration guide from legacy API to modern API
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public enum MigrationGuide {
|
||||
/// Legacy: `Tachikoma.shared.getModel("gpt-4").getResponse(request)`
|
||||
/// Modern: `generate("Hello", using: .openai(.gpt4o))`
|
||||
/// Legacy: `Tachikoma.shared.getModel("gpt-5.5").getResponse(request)`
|
||||
/// Modern: `generate("Hello", using: .openai(.gpt55))`
|
||||
public static let simpleGeneration = """
|
||||
// OLD (deprecated)
|
||||
let model = try await Tachikoma.shared.getModel("gpt-4")
|
||||
let model = try await Tachikoma.shared.getModel("gpt-5.5")
|
||||
let request = ModelRequest(messages: [.user(content: .text("Hello"))], settings: .default)
|
||||
let response = try await model.getResponse(request: request)
|
||||
|
||||
// NEW (modern)
|
||||
let response = try await generate("Hello", using: .openai(.gpt4o))
|
||||
let response = try await generate("Hello", using: .openai(.gpt55))
|
||||
"""
|
||||
|
||||
/// Legacy: Complex ModelRequest/ModelResponse handling
|
||||
|
||||
@ -186,8 +186,9 @@ public enum OpenAIAPIMode: String, Sendable, CaseIterable {
|
||||
public static func defaultMode(for model: LanguageModel.OpenAI) -> OpenAIAPIMode {
|
||||
// Determine default API mode for a given model
|
||||
switch model {
|
||||
case .o4Mini, .gpt5, .gpt5Pro, .gpt5Mini, .gpt5Nano, .gpt51, .gpt52:
|
||||
.responses // Reasoning models and GPT-5 default to Responses API
|
||||
case .chatLatest, .gpt5ChatLatest,
|
||||
.gpt5, .gpt5Pro, .gpt5Mini, .gpt5Nano, .gpt54, .gpt54Mini, .gpt54Nano, .gpt55:
|
||||
.responses // GPT-5 defaults to Responses API
|
||||
default:
|
||||
.chat // All other models use Chat Completions API
|
||||
}
|
||||
@ -325,6 +326,11 @@ public enum ImageInput: Sendable {
|
||||
/// Settings for text generation
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public struct GenerationSettings: Sendable {
|
||||
public enum StreamBufferingMode: String, Sendable, Codable {
|
||||
case incremental
|
||||
case untilTerminal
|
||||
}
|
||||
|
||||
public let maxTokens: Int?
|
||||
public let temperature: Double?
|
||||
public let topP: Double?
|
||||
@ -336,6 +342,7 @@ public struct GenerationSettings: Sendable {
|
||||
public let stopConditions: (any StopCondition)?
|
||||
public let seed: Int?
|
||||
public let providerOptions: ProviderOptions
|
||||
public let streamBuffering: StreamBufferingMode
|
||||
|
||||
public init(
|
||||
maxTokens: Int? = nil,
|
||||
@ -349,6 +356,7 @@ public struct GenerationSettings: Sendable {
|
||||
stopConditions: (any StopCondition)? = nil,
|
||||
seed: Int? = nil,
|
||||
providerOptions: ProviderOptions = .init(),
|
||||
streamBuffering: StreamBufferingMode = .incremental,
|
||||
) {
|
||||
self.maxTokens = maxTokens
|
||||
self.temperature = temperature
|
||||
@ -361,9 +369,27 @@ public struct GenerationSettings: Sendable {
|
||||
self.stopConditions = stopConditions
|
||||
self.seed = seed
|
||||
self.providerOptions = providerOptions
|
||||
self.streamBuffering = streamBuffering
|
||||
}
|
||||
|
||||
public static let `default` = GenerationSettings()
|
||||
|
||||
public func withStreamBuffering(_ mode: StreamBufferingMode) -> GenerationSettings {
|
||||
GenerationSettings(
|
||||
maxTokens: self.maxTokens,
|
||||
temperature: self.temperature,
|
||||
topP: self.topP,
|
||||
topK: self.topK,
|
||||
frequencyPenalty: self.frequencyPenalty,
|
||||
presencePenalty: self.presencePenalty,
|
||||
stopSequences: self.stopSequences,
|
||||
reasoningEffort: self.reasoningEffort,
|
||||
stopConditions: self.stopConditions,
|
||||
seed: self.seed,
|
||||
providerOptions: self.providerOptions,
|
||||
streamBuffering: mode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Manual Codable conformance excluding non-codable stopConditions
|
||||
@ -379,6 +405,7 @@ extension GenerationSettings: Codable {
|
||||
case reasoningEffort
|
||||
case seed
|
||||
case providerOptions
|
||||
case streamBuffering
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
@ -393,6 +420,8 @@ extension GenerationSettings: Codable {
|
||||
self.reasoningEffort = try container.decodeIfPresent(ReasoningEffort.self, forKey: .reasoningEffort)
|
||||
self.seed = try container.decodeIfPresent(Int.self, forKey: .seed)
|
||||
self.providerOptions = try container.decodeIfPresent(ProviderOptions.self, forKey: .providerOptions) ?? .init()
|
||||
self.streamBuffering = try container
|
||||
.decodeIfPresent(StreamBufferingMode.self, forKey: .streamBuffering) ?? .incremental
|
||||
self.stopConditions = nil // Can't decode function types
|
||||
}
|
||||
|
||||
@ -408,6 +437,7 @@ extension GenerationSettings: Codable {
|
||||
try container.encodeIfPresent(self.reasoningEffort, forKey: .reasoningEffort)
|
||||
try container.encodeIfPresent(self.seed, forKey: .seed)
|
||||
try container.encode(self.providerOptions, forKey: .providerOptions)
|
||||
try container.encode(self.streamBuffering, forKey: .streamBuffering)
|
||||
// Don't encode stopConditions since it can't be serialized
|
||||
}
|
||||
}
|
||||
@ -614,7 +644,7 @@ public enum ResponseChannel: String, Sendable, Codable, CaseIterable {
|
||||
case final // Final answer to the user
|
||||
}
|
||||
|
||||
/// Reasoning effort level for models that support it (o3, opus-4, etc.)
|
||||
/// Reasoning effort level for models that support it (GPT-5 thinking, opus-4, etc.)
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public enum ReasoningEffort: String, Sendable, Codable, CaseIterable {
|
||||
case low
|
||||
|
||||
@ -132,7 +132,9 @@ extension [ModelMessage] {
|
||||
/// Convert model messages to UI messages for display
|
||||
public func toUIMessages() -> [UIMessage] {
|
||||
// Convert model messages to UI messages for display
|
||||
map { modelMessage in
|
||||
compactMap { modelMessage in
|
||||
guard !modelMessage.isProviderNativeReasoningBlock else { return nil }
|
||||
guard !modelMessage.isSyntheticReasoningBoundary else { return nil }
|
||||
var content = ""
|
||||
var attachments: [UIAttachment] = []
|
||||
var toolCalls: [AgentToolCall] = []
|
||||
@ -179,6 +181,20 @@ extension [ModelMessage] {
|
||||
}
|
||||
}
|
||||
|
||||
extension ModelMessage {
|
||||
fileprivate var isProviderNativeReasoningBlock: Bool {
|
||||
guard channel == .thinking, let customData = metadata?.customData else { return false }
|
||||
return customData["anthropic.thinking.model"] != nil ||
|
||||
customData["anthropic.thinking.type"] != nil ||
|
||||
customData["anthropic.thinking.signature"] != nil ||
|
||||
customData["tachikoma.reasoning.provider"] != nil
|
||||
}
|
||||
|
||||
fileprivate var isSyntheticReasoningBoundary: Bool {
|
||||
metadata?.customData?["tachikoma.internal.boundary"] == "reasoning_only"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Streaming Extensions
|
||||
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -88,7 +88,7 @@ public struct ModelSettings: Sendable, Codable {
|
||||
|
||||
/// Legacy initializer for backward compatibility
|
||||
public init(
|
||||
modelName: String = "gpt-4",
|
||||
modelName: String = "gpt-5.5",
|
||||
maxTokens: Int? = nil,
|
||||
temperature: Double? = nil,
|
||||
topP: Double? = nil,
|
||||
|
||||
@ -84,6 +84,27 @@ public struct ProviderResponse: Sendable {
|
||||
public let usage: Usage?
|
||||
public let finishReason: FinishReason?
|
||||
public let toolCalls: [AgentToolCall]?
|
||||
public let reasoning: [ProviderReasoningBlock]
|
||||
public let assistantMessages: [ModelMessage]
|
||||
public let isBillable: Bool
|
||||
|
||||
public init(
|
||||
text: String,
|
||||
usage: Usage? = nil,
|
||||
finishReason: FinishReason? = nil,
|
||||
toolCalls: [AgentToolCall]? = nil,
|
||||
reasoning: [ProviderReasoningBlock] = [],
|
||||
assistantMessages: [ModelMessage] = [],
|
||||
isBillable: Bool = true,
|
||||
) {
|
||||
self.text = text
|
||||
self.usage = usage
|
||||
self.finishReason = finishReason
|
||||
self.toolCalls = toolCalls
|
||||
self.reasoning = reasoning
|
||||
self.assistantMessages = assistantMessages
|
||||
self.isBillable = isBillable
|
||||
}
|
||||
|
||||
public init(
|
||||
text: String,
|
||||
@ -91,9 +112,30 @@ public struct ProviderResponse: Sendable {
|
||||
finishReason: FinishReason? = nil,
|
||||
toolCalls: [AgentToolCall]? = nil,
|
||||
) {
|
||||
self.text = text
|
||||
self.usage = usage
|
||||
self.finishReason = finishReason
|
||||
self.toolCalls = toolCalls
|
||||
self.init(
|
||||
text: text,
|
||||
usage: usage,
|
||||
finishReason: finishReason,
|
||||
toolCalls: toolCalls,
|
||||
reasoning: [],
|
||||
assistantMessages: [],
|
||||
isBillable: true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-native signed reasoning block that must be replayed in later requests.
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public struct ProviderReasoningBlock: Sendable, Equatable {
|
||||
public let text: String
|
||||
public let signature: String?
|
||||
public let type: String
|
||||
public let rawJSON: String?
|
||||
|
||||
public init(text: String, signature: String? = nil, type: String = "thinking", rawJSON: String? = nil) {
|
||||
self.text = text
|
||||
self.signature = signature
|
||||
self.type = type
|
||||
self.rawJSON = rawJSON
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,12 +30,61 @@ public struct ModelSelector {
|
||||
return .google(googleModel)
|
||||
}
|
||||
|
||||
// MiniMax shortcuts and models
|
||||
if let miniMaxModel = parseMiniMaxCNModel(normalized) {
|
||||
return .minimaxCN(miniMaxModel)
|
||||
}
|
||||
|
||||
if let miniMaxModel = parseMiniMaxModel(normalized) {
|
||||
return .minimax(miniMaxModel)
|
||||
}
|
||||
|
||||
// Grok shortcuts and models
|
||||
if let grokModel = parseGrokModel(normalized) {
|
||||
return .grok(grokModel)
|
||||
}
|
||||
|
||||
// Ollama shortcuts and models
|
||||
if
|
||||
let qualified = ProviderParser.parse(normalized),
|
||||
["grok", "xai", "x-ai"].contains(qualified.provider.lowercased())
|
||||
{
|
||||
let model = qualified.model.lowercased()
|
||||
guard
|
||||
!Self.isUnsupportedLegacyGrokModel(model),
|
||||
let grokModel = self.parseGrokModel(model) else
|
||||
{
|
||||
throw ModelValidationError.unsupportedModel(modelString)
|
||||
}
|
||||
return .grok(grokModel)
|
||||
}
|
||||
|
||||
if
|
||||
Self.isUnsupportedLegacyOpenAIModel(normalized) ||
|
||||
Self.isUnsupportedLegacyAnthropicModel(normalized) ||
|
||||
Self.isUnsupportedLegacyGrokModel(normalized)
|
||||
{
|
||||
throw ModelValidationError.unsupportedModel(modelString)
|
||||
}
|
||||
|
||||
// LM Studio shortcuts and models
|
||||
if let lmStudioModel = parseLMStudioModel(normalized) {
|
||||
return .lmstudio(lmStudioModel)
|
||||
}
|
||||
|
||||
if let qualified = ProviderParser.parse(normalized) {
|
||||
let provider = qualified.provider.lowercased()
|
||||
if provider == "openai", let openaiModel = parseOpenAIModel(qualified.model.lowercased()) {
|
||||
return .openai(openaiModel)
|
||||
}
|
||||
if provider == "openrouter" {
|
||||
return .openRouter(modelId: qualified.model)
|
||||
}
|
||||
if !["ollama", "lmstudio", "lm-studio"].contains(provider) {
|
||||
return .openRouter(modelId: normalized)
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama shortcuts and models. Keep after explicit local providers because it accepts custom IDs.
|
||||
if let ollamaModel = parseOllamaModel(normalized) {
|
||||
return .ollama(ollamaModel)
|
||||
}
|
||||
@ -46,7 +95,7 @@ public struct ModelSelector {
|
||||
}
|
||||
|
||||
// Custom model ID - try to infer provider
|
||||
if normalized.contains("gpt") || normalized.contains("o3") || normalized.contains("o4") {
|
||||
if normalized.contains("gpt-5") || normalized.contains("gpt5") {
|
||||
return .openai(.custom(normalized))
|
||||
}
|
||||
|
||||
@ -66,20 +115,24 @@ public struct ModelSelector {
|
||||
|
||||
private static func parseOpenAIModel(_ input: String) -> Model.OpenAI? {
|
||||
switch input {
|
||||
// GPT-5.2 models
|
||||
case "gpt-5.2", "gpt5.2", "gpt-5-2", "gpt5-2", "gpt52":
|
||||
return .gpt52
|
||||
case "gpt-5.2-mini", "gpt5.2-mini", "gpt52-mini", "gpt52mini", "gpt-5-2-mini", "gpt5-2-mini":
|
||||
case "chat-latest", "chatlatest":
|
||||
return .chatLatest
|
||||
case "gpt-5-chat-latest", "gpt5-chat-latest", "gpt5chatlatest":
|
||||
return .gpt5ChatLatest
|
||||
// GPT-5.5 models
|
||||
case "gpt-5.5", "gpt5.5", "gpt-5-5", "gpt5-5", "gpt55":
|
||||
return .gpt55
|
||||
case "gpt-5.5-mini", "gpt5.5-mini", "gpt55-mini", "gpt55mini", "gpt-5-5-mini", "gpt5-5-mini":
|
||||
return .gpt5Mini
|
||||
case "gpt-5.2-nano", "gpt5.2-nano", "gpt52-nano", "gpt52nano", "gpt-5-2-nano", "gpt5-2-nano":
|
||||
return .gpt5Nano
|
||||
// GPT-5.1 models (latest)
|
||||
case "gpt-5.1", "gpt5.1", "gpt-5-1", "gpt5-1", "gpt51":
|
||||
return .gpt51
|
||||
case "gpt-5.1-mini", "gpt5.1-mini", "gpt51-mini", "gpt51mini", "gpt-5-1-mini", "gpt5-1-mini":
|
||||
return .gpt5Mini
|
||||
case "gpt-5.1-nano", "gpt5.1-nano", "gpt51-nano", "gpt51nano", "gpt-5-1-nano", "gpt5-1-nano":
|
||||
case "gpt-5.5-nano", "gpt5.5-nano", "gpt55-nano", "gpt55nano", "gpt-5-5-nano", "gpt5-5-nano":
|
||||
return .gpt5Nano
|
||||
// GPT-5.4 models
|
||||
case "gpt-5.4", "gpt5.4", "gpt-5-4", "gpt5-4", "gpt54":
|
||||
return .gpt54
|
||||
case "gpt-5.4-mini", "gpt5.4-mini", "gpt54-mini", "gpt54mini", "gpt-5-4-mini", "gpt5-4-mini":
|
||||
return .gpt54Mini
|
||||
case "gpt-5.4-nano", "gpt5.4-nano", "gpt54-nano", "gpt54nano", "gpt-5-4-nano", "gpt5-4-nano":
|
||||
return .gpt54Nano
|
||||
// GPT-5 models
|
||||
case "gpt-5", "gpt5":
|
||||
return .gpt5
|
||||
@ -89,35 +142,17 @@ public struct ModelSelector {
|
||||
return .gpt5Mini
|
||||
case "gpt-5-nano", "gpt5-nano", "gpt5nano":
|
||||
return .gpt5Nano
|
||||
case "gpt-5-thinking", "gpt5-thinking", "gpt5thinking":
|
||||
return .gpt5Thinking
|
||||
case "gpt-5-thinking-mini", "gpt5-thinking-mini", "gpt5thinkingmini":
|
||||
return .gpt5ThinkingMini
|
||||
case "gpt-5-thinking-nano", "gpt5-thinking-nano", "gpt5thinkingnano":
|
||||
return .gpt5ThinkingNano
|
||||
case "gpt-5-chat-latest", "gpt5-chat-latest":
|
||||
return .gpt5ChatLatest
|
||||
// Direct matches
|
||||
case "gpt-4o", "gpt4o":
|
||||
return .gpt4o
|
||||
case "gpt-4o-mini", "gpt4o-mini", "gpt4omini":
|
||||
return .gpt4oMini
|
||||
case "gpt-4.1", "gpt4.1", "gpt41":
|
||||
return .gpt41
|
||||
case "gpt-4.1-mini", "gpt4.1-mini", "gpt41mini":
|
||||
return .gpt41Mini
|
||||
case "o4-mini", "o4mini":
|
||||
return .o4Mini
|
||||
// Shortcuts
|
||||
case "gpt":
|
||||
return .gpt51 // Default to flagship GPT-5.1
|
||||
case "gpt4", "gpt-4":
|
||||
return .gpt4o // Default to latest GPT-4 variant
|
||||
return .gpt55 // Default to flagship GPT-5.5
|
||||
case "openai":
|
||||
return .gpt51 // Default to GPT-5.1
|
||||
return .gpt55 // Default to GPT-5.5
|
||||
default:
|
||||
// Check if it's an OpenAI model ID
|
||||
if input.hasPrefix("gpt") || input.hasPrefix("o4") {
|
||||
if self.isUnsupportedLegacyOpenAIModel(input) {
|
||||
return nil
|
||||
}
|
||||
if input.hasPrefix("gpt-5") || input.hasPrefix("gpt5") {
|
||||
return .custom(input)
|
||||
}
|
||||
return nil
|
||||
@ -127,31 +162,35 @@ public struct ModelSelector {
|
||||
private static func parseAnthropicModel(_ input: String) -> Model.Anthropic? {
|
||||
switch input {
|
||||
// Direct matches
|
||||
case "claude-opus-4-20250514":
|
||||
return .opus4
|
||||
case "claude-opus-4-20250514-thinking":
|
||||
return .opus4Thinking
|
||||
case "claude-fable-5", "claude-fable-5-latest", "fable-5", "fable.5", "fable5", "fable":
|
||||
return .fable5
|
||||
case "claude-opus-4-8", "claude-opus-4.8", "opus-4-8", "opus-4.8", "opus48",
|
||||
"claude-opus-4-8-latest":
|
||||
return .opus48
|
||||
case "claude-opus-4-7", "claude-opus-4.7", "opus-4-7", "opus-4.7", "opus47":
|
||||
return .opus47
|
||||
case "claude-opus-4-5", "claude-opus-4.5", "opus-4-5", "opus-4.5", "opus45":
|
||||
return .opus45
|
||||
case "claude-sonnet-4-20250514":
|
||||
return .sonnet4
|
||||
case "claude-sonnet-4-20250514-thinking":
|
||||
return .sonnet4Thinking
|
||||
case "claude-sonnet-4-6", "claude-sonnet-4.6", "sonnet-4-6", "sonnet-4.6", "sonnet46":
|
||||
return .sonnet46
|
||||
case "claude-sonnet-4-5-20250929", "claude-sonnet-4.5":
|
||||
return .sonnet45
|
||||
// Shortcuts
|
||||
case "claude":
|
||||
return .sonnet45 // Default plain Claude alias to latest Sonnet
|
||||
return .opus48
|
||||
case "claude-opus", "opus":
|
||||
return .opus45
|
||||
return .opus48
|
||||
case "claude-sonnet", "sonnet":
|
||||
return .sonnet4
|
||||
return .sonnet46
|
||||
case "claude-haiku", "haiku":
|
||||
return .haiku45
|
||||
case "anthropic":
|
||||
return .opus45 // Default Anthropic model
|
||||
return .opus48
|
||||
default:
|
||||
// Check if it's a Claude model ID
|
||||
if self.isUnsupportedLegacyAnthropicModel(input) {
|
||||
return nil
|
||||
}
|
||||
if input.hasPrefix("claude") {
|
||||
return .custom(input)
|
||||
}
|
||||
@ -159,8 +198,34 @@ public struct ModelSelector {
|
||||
}
|
||||
}
|
||||
|
||||
private static func isUnsupportedLegacyOpenAIModel(_ input: String) -> Bool {
|
||||
let normalized = input.lowercased()
|
||||
let compact = normalized.replacingOccurrences(of: "-", with: "").replacingOccurrences(of: ".", with: "")
|
||||
return normalized.hasPrefix("gpt-4") || compact.hasPrefix("gpt4") ||
|
||||
normalized.hasPrefix("gpt-3") || compact.hasPrefix("gpt3") ||
|
||||
normalized.hasPrefix("o3") || normalized.hasPrefix("o4") ||
|
||||
normalized.hasPrefix("gpt-5.1") || normalized.hasPrefix("gpt-5.2") ||
|
||||
compact.hasPrefix("gpt51") || compact.hasPrefix("gpt52") ||
|
||||
normalized.contains("gpt-5-thinking") || compact.contains("gpt5thinking")
|
||||
}
|
||||
|
||||
private static func isUnsupportedLegacyAnthropicModel(_ input: String) -> Bool {
|
||||
let normalized = input.lowercased()
|
||||
let compact = normalized.replacingOccurrences(of: "-", with: "").replacingOccurrences(of: ".", with: "")
|
||||
return normalized.hasPrefix("claude-3") || compact.hasPrefix("claude3") ||
|
||||
normalized == "claude-opus-4-20250514" ||
|
||||
normalized == "claude-sonnet-4-20250514" ||
|
||||
normalized.contains("-thinking")
|
||||
}
|
||||
|
||||
private static func parseGoogleModel(_ input: String) -> Model.Google? {
|
||||
switch input {
|
||||
case "gemini-3.5-flash", "gemini35flash", "gemini-3-5-flash":
|
||||
.gemini35Flash
|
||||
case "gemini-3.1-pro-preview", "gemini-3.1-pro", "gemini31pro", "gemini31propreview":
|
||||
.gemini31ProPreview
|
||||
case "gemini-3.1-flash-lite", "gemini31flashlite", "gemini-3.1-flashlite":
|
||||
.gemini31FlashLite
|
||||
case "gemini-3-flash", "gemini-3-flash-preview", "gemini3flash", "gemini-3flash":
|
||||
.gemini3Flash
|
||||
case "gemini-2.5-pro", "gemini25pro", "gemini2.5pro":
|
||||
@ -170,9 +235,9 @@ public struct ModelSelector {
|
||||
case "gemini-2.5-flash-lite", "gemini25flashlite", "gemini-2.5-flashlite":
|
||||
.gemini25FlashLite
|
||||
case "gemini":
|
||||
.gemini3Flash
|
||||
.gemini35Flash
|
||||
case "google":
|
||||
.gemini25Pro
|
||||
.gemini35Flash
|
||||
default:
|
||||
nil
|
||||
}
|
||||
@ -181,35 +246,22 @@ public struct ModelSelector {
|
||||
private static func parseGrokModel(_ input: String) -> Model.Grok? {
|
||||
switch input {
|
||||
// Direct matches for available models only
|
||||
case "grok-4-0709":
|
||||
return .grok4
|
||||
case "grok-4-fast-reasoning":
|
||||
return .grok4FastReasoning
|
||||
case "grok-4-fast-non-reasoning":
|
||||
return .grok4FastNonReasoning
|
||||
case "grok-code-fast-1":
|
||||
return .grokCodeFast1
|
||||
case "grok-3", "grok3":
|
||||
return .grok3
|
||||
case "grok-3-mini":
|
||||
return .grok3Mini
|
||||
case "grok-2-1212", "grok-2":
|
||||
return .grok2
|
||||
case "grok-2-vision-1212":
|
||||
return .grok2Vision
|
||||
case "grok-2-image-1212":
|
||||
return .grok2Image
|
||||
case "grok-vision-beta":
|
||||
return .grokVisionBeta
|
||||
case "grok-beta":
|
||||
return .grokBeta
|
||||
case "grok-4.3", "grok-4-3", "grok43", "grok-4.3-latest", "grok-4-latest", "grok-4", "grok-latest":
|
||||
return .grok43
|
||||
case "grok-4.20-0309-reasoning", "grok-4-20-0309-reasoning":
|
||||
return .grok420Reasoning
|
||||
case "grok-4.20-0309-non-reasoning", "grok-4-20-0309-non-reasoning":
|
||||
return .grok420NonReasoning
|
||||
// Shortcuts
|
||||
case "grok":
|
||||
return .grok4FastReasoning // Default to the latest fast Grok model
|
||||
return .grok43
|
||||
case "xai":
|
||||
return .grok3 // Default xAI model
|
||||
return .grok43
|
||||
default:
|
||||
// Check if it's a Grok model ID
|
||||
if self.isUnsupportedLegacyGrokModel(input) {
|
||||
return nil
|
||||
}
|
||||
if input.hasPrefix("grok") {
|
||||
return .custom(input)
|
||||
}
|
||||
@ -217,6 +269,51 @@ public struct ModelSelector {
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseMiniMaxModel(_ input: String) -> Model.MiniMax? {
|
||||
switch input {
|
||||
case "minimax-m2.7", "minimax-m2-7", "m2.7", "m2-7", "minimax/m2.7", "minimax/m2-7",
|
||||
"minimax/minimax-m2.7", "minimax/minimax-m2-7":
|
||||
.m27
|
||||
case "minimax-m2.7-highspeed", "minimax-m2-7-highspeed", "m2.7-highspeed", "m2-7-highspeed",
|
||||
"minimax/m2.7-highspeed", "minimax/m2-7-highspeed", "minimax/minimax-m2.7-highspeed",
|
||||
"minimax/minimax-m2-7-highspeed":
|
||||
.m27Highspeed
|
||||
case "minimax":
|
||||
.m27
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseMiniMaxCNModel(_ input: String) -> Model.MiniMax? {
|
||||
switch input {
|
||||
case "minimax-cn-m2.7", "minimax-cn-m2-7", "minimaxi-m2.7", "minimaxi-m2-7",
|
||||
"minimax_cn/m2.7", "minimax_cn/m2-7", "minimax_cn/minimax-m2.7",
|
||||
"minimax_cn/minimax-m2-7",
|
||||
"minimax-cn/m2.7", "minimax-cn/m2-7", "minimax-cn/minimax-m2.7",
|
||||
"minimax-cn/minimax-m2-7", "minimaxi/m2.7", "minimaxi/m2-7":
|
||||
.m27
|
||||
case "minimax-cn-m2.7-highspeed", "minimax-cn-m2-7-highspeed", "minimaxi-m2.7-highspeed",
|
||||
"minimaxi-m2-7-highspeed", "minimax-cn/m2.7-highspeed", "minimax-cn/m2-7-highspeed",
|
||||
"minimax_cn/m2.7-highspeed", "minimax_cn/m2-7-highspeed",
|
||||
"minimax_cn/minimax-m2.7-highspeed", "minimax_cn/minimax-m2-7-highspeed",
|
||||
"minimax-cn/minimax-m2.7-highspeed", "minimax-cn/minimax-m2-7-highspeed",
|
||||
"minimaxi/m2.7-highspeed", "minimaxi/m2-7-highspeed":
|
||||
.m27Highspeed
|
||||
case "minimax-cn", "minimax_cn", "minimaxi":
|
||||
.m27
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func isUnsupportedLegacyGrokModel(_ input: String) -> Bool {
|
||||
let normalized = input.lowercased()
|
||||
return normalized.contains("grok-4.20-multi-agent") ||
|
||||
normalized.contains("grok-4-20-multi-agent") ||
|
||||
normalized.contains("grok420multiagent")
|
||||
}
|
||||
|
||||
private static func parseOllamaModel(_ input: String) -> Model.Ollama? {
|
||||
switch input {
|
||||
// Direct matches
|
||||
@ -238,22 +335,12 @@ public struct ModelSelector {
|
||||
.qwen25vl7b
|
||||
case "qwen2.5vl:32b":
|
||||
.qwen25vl32b
|
||||
case "llama2", "llama2:latest":
|
||||
.llama2
|
||||
case "llama4", "llama4:latest":
|
||||
.llama4
|
||||
case "codellama", "codellama:latest":
|
||||
.codellama
|
||||
case "mistral", "mistral:latest":
|
||||
.mistral
|
||||
case "mistral-nemo", "mistral-nemo:latest":
|
||||
.mistralNemo
|
||||
case "mixtral", "mixtral:latest":
|
||||
.mixtral
|
||||
case "neural-chat", "neural-chat:latest":
|
||||
.neuralChat
|
||||
case "gemma", "gemma:latest":
|
||||
.gemma
|
||||
case "devstral", "devstral:latest":
|
||||
.devstral
|
||||
case "deepseek-r1:8b":
|
||||
@ -277,6 +364,33 @@ public struct ModelSelector {
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseLMStudioModel(_ input: String) -> Model.LMStudio? {
|
||||
if input == "lmstudio" || input == "lm-studio" {
|
||||
return .gptOSS120B
|
||||
}
|
||||
|
||||
for prefix in ["lmstudio/", "lm-studio/"] where input.hasPrefix(prefix) {
|
||||
let modelId = String(input.dropFirst(prefix.count))
|
||||
guard !modelId.isEmpty else { return nil }
|
||||
return Self.parseLMStudioModelIdentifier(modelId)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parseLMStudioModelIdentifier(_ input: String) -> Model.LMStudio {
|
||||
switch input {
|
||||
case "openai/gpt-oss-120b", "gpt-oss-120b", "gpt-oss:120b":
|
||||
.gptOSS120B
|
||||
case "openai/gpt-oss-20b", "gpt-oss-20b", "gpt-oss:20b":
|
||||
.gptOSS20B
|
||||
case "meta/llama-3.3-70b", "llama-3.3-70b", "llama3.3-70b":
|
||||
.llama3370B
|
||||
default:
|
||||
.custom(input)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Model Information
|
||||
|
||||
/// Get available models for a specific provider
|
||||
@ -302,11 +416,18 @@ public struct ModelSelector {
|
||||
}
|
||||
case "google", "gemini":
|
||||
return Model.Google.allCases.map(\.userFacingModelId)
|
||||
case "minimax", "minimax-cn", "minimaxi":
|
||||
return Model.MiniMax.allCases.map(\.modelId)
|
||||
case "ollama":
|
||||
return Model.Ollama.allCases.compactMap {
|
||||
if case .custom = $0 { return nil }
|
||||
return $0.modelId
|
||||
}
|
||||
case "lmstudio", "lm-studio":
|
||||
return Model.LMStudio.allCases.compactMap {
|
||||
if case .custom = $0 { return nil }
|
||||
return $0.modelId
|
||||
}
|
||||
default:
|
||||
return []
|
||||
}
|
||||
@ -377,6 +498,16 @@ public func getAllAvailableModels() -> String {
|
||||
models: ModelSelector.availableModels(for: "google"),
|
||||
)
|
||||
|
||||
output += formatModelList(
|
||||
title: "MiniMax",
|
||||
models: ModelSelector.availableModels(for: "minimax"),
|
||||
)
|
||||
|
||||
output += formatModelList(
|
||||
title: "MiniMax China",
|
||||
models: ModelSelector.availableModels(for: "minimax-cn"),
|
||||
)
|
||||
|
||||
output += formatModelList(
|
||||
title: "Grok (xAI)",
|
||||
models: ModelSelector.availableModels(for: "grok"),
|
||||
@ -388,14 +519,17 @@ public func getAllAvailableModels() -> String {
|
||||
)
|
||||
|
||||
output += "\nShortcuts:\n"
|
||||
output += " • claude, claude-opus, opus → claude-opus-4-20250514\n"
|
||||
output += " • gpt, gpt4 → gpt-4.1\n"
|
||||
output += " • gemini → gemini-3-flash\n"
|
||||
output += " • grok → grok-4-fast-reasoning\n"
|
||||
output += " • claude, claude-opus, opus → claude-opus-4-8\n"
|
||||
output += " • fable → claude-fable-5\n"
|
||||
output += " • gpt → gpt-5.5\n"
|
||||
output += " • gemini → gemini-3.5-flash\n"
|
||||
output += " • minimax → MiniMax-M2.7\n"
|
||||
output += " • minimax-cn → MiniMax-M2.7 via api.minimaxi.com\n"
|
||||
output += " • grok → grok-4.3\n"
|
||||
output += " • llama, llama3 → llama3.3\n"
|
||||
|
||||
output += "\nCustom Models:\n"
|
||||
output += " • OpenRouter: anthropic/claude-3.5-sonnet\n"
|
||||
output += " • OpenRouter: anthropic/claude-fable-5\n"
|
||||
output += " • Custom OpenAI: custom-gpt-model\n"
|
||||
output += " • Local Ollama: any-model:tag\n"
|
||||
|
||||
@ -423,15 +557,15 @@ extension ModelSelector {
|
||||
// Get recommended models for specific use cases
|
||||
switch useCase {
|
||||
case .coding:
|
||||
[.claude, .gpt4o, .google(.gemini25Pro)]
|
||||
[.claude, .openai(.gpt55), .google(.gemini35Flash)]
|
||||
case .vision:
|
||||
[.claude, .gpt4o, .google(.gemini3Flash)]
|
||||
[.claude, .openai(.gpt55), .google(.gemini35Flash)]
|
||||
case .reasoning:
|
||||
[.openai(.gpt5Mini), .claude, .google(.gemini25Pro)]
|
||||
[.openai(.gpt54), .claude, .google(.gemini35Flash)]
|
||||
case .local:
|
||||
[.llama, .ollama(.mistralNemo), .ollama(.commandRPlus)]
|
||||
case .general:
|
||||
[.claude, .gpt4o, .google(.gemini3Flash), .grok(.grok4FastReasoning), .llama]
|
||||
[.claude, .openai(.gpt55), .google(.gemini35Flash), .grok(.grok43), .llama]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -449,6 +583,7 @@ public enum UseCase {
|
||||
public enum ModelValidationError: Error, LocalizedError {
|
||||
case visionNotSupported(String)
|
||||
case toolsNotSupported(String)
|
||||
case unsupportedModel(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
@ -456,6 +591,8 @@ public enum ModelValidationError: Error, LocalizedError {
|
||||
"Model '\(modelId)' does not support vision inputs"
|
||||
case let .toolsNotSupported(modelId):
|
||||
"Model '\(modelId)' does not support tool calling"
|
||||
case let .unsupportedModel(modelId):
|
||||
"Model '\(modelId)' is no longer supported"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -89,11 +89,13 @@ struct AnthropicMessageRequest: Codable {
|
||||
let messages: [AnthropicMessage]
|
||||
let tools: [AnthropicTool]?
|
||||
let thinking: AnthropicThinking?
|
||||
let outputConfig: AnthropicOutputConfig?
|
||||
let stream: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case model, temperature, system, messages, tools, thinking, stream
|
||||
case maxTokens = "max_tokens"
|
||||
case outputConfig = "output_config"
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,6 +109,10 @@ struct AnthropicThinking: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
struct AnthropicOutputConfig: Codable {
|
||||
let effort: String?
|
||||
}
|
||||
|
||||
struct AnthropicMessage: Codable {
|
||||
let role: String
|
||||
let content: [AnthropicContent]
|
||||
@ -133,13 +139,11 @@ enum AnthropicContent: Codable {
|
||||
|
||||
struct RedactedThinkingContent: Codable {
|
||||
let type: String
|
||||
let redactedThinking: String
|
||||
let signature: String
|
||||
let data: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case redactedThinking = "redacted_thinking"
|
||||
case signature
|
||||
case data
|
||||
}
|
||||
}
|
||||
|
||||
@ -396,12 +400,14 @@ struct AnthropicMessageResponse: Codable {
|
||||
let model: String
|
||||
let stopReason: String?
|
||||
let stopSequence: String?
|
||||
let stopDetails: StopDetails?
|
||||
let usage: AnthropicUsage
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, type, role, content, model, usage
|
||||
case stopReason = "stop_reason"
|
||||
case stopSequence = "stop_sequence"
|
||||
case stopDetails = "stop_details"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@ -413,12 +419,20 @@ struct AnthropicMessageResponse: Codable {
|
||||
self.model = try container.decode(String.self, forKey: .model)
|
||||
self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
|
||||
self.stopSequence = try container.decodeIfPresent(String.self, forKey: .stopSequence)
|
||||
self.stopDetails = try container.decodeIfPresent(StopDetails.self, forKey: .stopDetails)
|
||||
self.usage = try container.decode(AnthropicUsage.self, forKey: .usage)
|
||||
}
|
||||
|
||||
struct StopDetails: Codable {
|
||||
let category: String?
|
||||
let explanation: String?
|
||||
}
|
||||
}
|
||||
|
||||
enum AnthropicResponseContent: Codable {
|
||||
case text(TextContent)
|
||||
case thinking(ThinkingContent)
|
||||
case redactedThinking(RedactedThinkingContent)
|
||||
case toolUse(ToolUseContent)
|
||||
|
||||
struct TextContent: Codable {
|
||||
@ -455,6 +469,22 @@ enum AnthropicResponseContent: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ThinkingContent: Codable {
|
||||
let type: String
|
||||
let thinking: String
|
||||
let signature: String
|
||||
}
|
||||
|
||||
struct RedactedThinkingContent: Codable {
|
||||
let type: String
|
||||
let data: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case data
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolUseContent: Codable {
|
||||
let type: String
|
||||
let id: String
|
||||
@ -534,6 +564,10 @@ enum AnthropicResponseContent: Codable {
|
||||
switch type {
|
||||
case "text":
|
||||
self = try .text(TextContent(from: decoder))
|
||||
case "thinking":
|
||||
self = try .thinking(ThinkingContent(from: decoder))
|
||||
case "redacted_thinking":
|
||||
self = try .redactedThinking(RedactedThinkingContent(from: decoder))
|
||||
case "tool_use":
|
||||
self = try .toolUse(ToolUseContent(from: decoder))
|
||||
default:
|
||||
@ -552,6 +586,10 @@ enum AnthropicResponseContent: Codable {
|
||||
switch self {
|
||||
case let .text(content):
|
||||
try content.encode(to: encoder)
|
||||
case let .thinking(content):
|
||||
try content.encode(to: encoder)
|
||||
case let .redactedThinking(content):
|
||||
try content.encode(to: encoder)
|
||||
case let .toolUse(content):
|
||||
try content.encode(to: encoder)
|
||||
}
|
||||
@ -561,6 +599,8 @@ enum AnthropicResponseContent: Codable {
|
||||
case type
|
||||
case text
|
||||
case thinking
|
||||
case redactedThinking = "redacted_thinking"
|
||||
case signature
|
||||
}
|
||||
}
|
||||
|
||||
@ -572,6 +612,17 @@ struct AnthropicUsage: Codable {
|
||||
case inputTokens = "input_tokens"
|
||||
case outputTokens = "output_tokens"
|
||||
}
|
||||
|
||||
init(inputTokens: Int, outputTokens: Int) {
|
||||
self.inputTokens = inputTokens
|
||||
self.outputTokens = outputTokens
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.inputTokens = try container.decodeIfPresent(Int.self, forKey: .inputTokens) ?? 0
|
||||
self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Streaming Types
|
||||
@ -606,12 +657,11 @@ struct AnthropicStreamContentBlock: Codable {
|
||||
let text: String?
|
||||
let input: Any?
|
||||
let thinking: String?
|
||||
let redactedThinking: String?
|
||||
let data: String?
|
||||
let signature: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type, id, name, text, input, thinking, signature
|
||||
case redactedThinking = "redacted_thinking"
|
||||
case type, id, name, text, input, thinking, data, signature
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@ -621,7 +671,7 @@ struct AnthropicStreamContentBlock: Codable {
|
||||
self.name = try? container.decode(String.self, forKey: .name)
|
||||
self.text = try? container.decode(String.self, forKey: .text)
|
||||
self.thinking = try? container.decode(String.self, forKey: .thinking)
|
||||
self.redactedThinking = try? container.decode(String.self, forKey: .redactedThinking)
|
||||
self.data = try? container.decode(String.self, forKey: .data)
|
||||
self.signature = try? container.decode(String.self, forKey: .signature)
|
||||
|
||||
// Decode input as generic JSON if present
|
||||
@ -647,7 +697,7 @@ struct AnthropicStreamContentBlock: Codable {
|
||||
try container.encodeIfPresent(self.name, forKey: .name)
|
||||
try container.encodeIfPresent(self.text, forKey: .text)
|
||||
try container.encodeIfPresent(self.thinking, forKey: .thinking)
|
||||
try container.encodeIfPresent(self.redactedThinking, forKey: .redactedThinking)
|
||||
try container.encodeIfPresent(self.data, forKey: .data)
|
||||
try container.encodeIfPresent(self.signature, forKey: .signature)
|
||||
if let input {
|
||||
let data = try JSONSerialization.data(withJSONObject: input)
|
||||
@ -671,6 +721,17 @@ struct AnthropicStreamDelta: Codable {
|
||||
case stopReason = "stop_reason"
|
||||
case stopSequence = "stop_sequence"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.type = try container.decodeIfPresent(String.self, forKey: .type) ?? ""
|
||||
self.text = try container.decodeIfPresent(String.self, forKey: .text)
|
||||
self.thinking = try container.decodeIfPresent(String.self, forKey: .thinking)
|
||||
self.signature = try container.decodeIfPresent(String.self, forKey: .signature)
|
||||
self.partialJson = try container.decodeIfPresent(String.self, forKey: .partialJson)
|
||||
self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
|
||||
self.stopSequence = try container.decodeIfPresent(String.self, forKey: .stopSequence)
|
||||
}
|
||||
}
|
||||
|
||||
struct AnthropicErrorResponse: Codable {
|
||||
|
||||
@ -6,32 +6,79 @@ public final class AnthropicCompatibleProvider: ModelProvider {
|
||||
public let modelId: String
|
||||
public let baseURL: String?
|
||||
public let apiKey: String?
|
||||
public let additionalHeaders: [String: String]
|
||||
public let capabilities: ModelCapabilities
|
||||
private let configuration: TachikomaConfiguration
|
||||
private let auth: TKAuthValue?
|
||||
private let reasoningProvider: String
|
||||
private let reasoningModelId: String
|
||||
private let reasoningBaseURL: String?
|
||||
|
||||
public init(modelId: String, baseURL: String, configuration: TachikomaConfiguration) throws {
|
||||
public init(
|
||||
modelId: String,
|
||||
baseURL: String,
|
||||
configuration: TachikomaConfiguration,
|
||||
apiKey: String? = nil,
|
||||
additionalHeaders: [String: String] = [:],
|
||||
auth: TKAuthValue? = nil,
|
||||
capabilities: ModelCapabilities? = nil,
|
||||
reasoningProvider: String = "anthropic-compatible",
|
||||
reasoningModelId: String? = nil,
|
||||
reasoningBaseURL: String? = nil,
|
||||
includeReasoningBaseURL: Bool = true,
|
||||
) throws {
|
||||
self.modelId = modelId
|
||||
self.baseURL = baseURL
|
||||
self.configuration = configuration
|
||||
self.additionalHeaders = additionalHeaders
|
||||
self.reasoningProvider = reasoningProvider
|
||||
self.reasoningModelId = reasoningModelId ?? modelId
|
||||
self.reasoningBaseURL = includeReasoningBaseURL ? (reasoningBaseURL ?? baseURL) : nil
|
||||
|
||||
// Try to get API key from configuration, otherwise try common environment variable patterns
|
||||
if let key = configuration.getAPIKey(for: .custom("anthropic_compatible")) {
|
||||
// Try explicit provider key, then configuration, then common environment variable patterns.
|
||||
if let key = apiKey {
|
||||
self.apiKey = key
|
||||
self.auth = auth ?? .apiKey(key)
|
||||
} else if let key = configuration.getAPIKey(for: .custom("anthropic_compatible")) {
|
||||
self.apiKey = key
|
||||
self.auth = auth ?? .apiKey(key)
|
||||
} else if
|
||||
let key = ProcessInfo.processInfo.environment["ANTHROPIC_COMPATIBLE_API_KEY"] ??
|
||||
ProcessInfo.processInfo.environment["API_KEY"]
|
||||
{
|
||||
self.apiKey = key
|
||||
self.auth = auth ?? .apiKey(key)
|
||||
} else if let auth {
|
||||
self.auth = auth
|
||||
switch auth {
|
||||
case let .apiKey(key):
|
||||
self.apiKey = key
|
||||
case let .bearer(token, _):
|
||||
self.apiKey = token
|
||||
}
|
||||
} else {
|
||||
self.apiKey = nil
|
||||
self.auth = nil
|
||||
}
|
||||
|
||||
self.capabilities = ModelCapabilities(
|
||||
let isFable = LanguageModel.Anthropic.isFable(modelId: modelId)
|
||||
let supportsSafeStreaming = !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
|
||||
let baseCapabilities = capabilities ?? ModelCapabilities(
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
supportsStreaming: true,
|
||||
contextLength: 200_000,
|
||||
maxOutputTokens: 8192,
|
||||
supportsStreaming: supportsSafeStreaming,
|
||||
contextLength: isFable ? 1_000_000 : 200_000,
|
||||
maxOutputTokens: isFable ? 128_000 : 8192,
|
||||
)
|
||||
self.capabilities = supportsSafeStreaming ? baseCapabilities : ModelCapabilities(
|
||||
supportsVision: baseCapabilities.supportsVision,
|
||||
supportsTools: baseCapabilities.supportsTools,
|
||||
supportsStreaming: false,
|
||||
supportsAudioInput: baseCapabilities.supportsAudioInput,
|
||||
supportsAudioOutput: baseCapabilities.supportsAudioOutput,
|
||||
contextLength: baseCapabilities.contextLength,
|
||||
maxOutputTokens: baseCapabilities.maxOutputTokens,
|
||||
costPerToken: baseCapabilities.costPerToken,
|
||||
)
|
||||
}
|
||||
|
||||
@ -62,6 +109,11 @@ public final class AnthropicCompatibleProvider: ModelProvider {
|
||||
return try AnthropicProvider(
|
||||
model: .custom(self.modelId),
|
||||
configuration: compatConfig,
|
||||
additionalHeaders: self.additionalHeaders,
|
||||
authOverride: self.auth,
|
||||
reasoningProvider: self.reasoningProvider,
|
||||
reasoningModelId: self.reasoningModelId,
|
||||
reasoningBaseURL: self.reasoningBaseURL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Provider for OpenAI-compatible APIs
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
@ -6,14 +9,27 @@ public final class OpenAICompatibleProvider: ModelProvider {
|
||||
public let modelId: String
|
||||
public let baseURL: String?
|
||||
public let apiKey: String?
|
||||
public let additionalHeaders: [String: String]
|
||||
public let capabilities: ModelCapabilities
|
||||
private let session: URLSession
|
||||
|
||||
public init(modelId: String, baseURL: String, configuration: TachikomaConfiguration) throws {
|
||||
public init(
|
||||
modelId: String,
|
||||
baseURL: String,
|
||||
configuration: TachikomaConfiguration,
|
||||
apiKey: String? = nil,
|
||||
additionalHeaders: [String: String] = [:],
|
||||
session: URLSession = .shared,
|
||||
) throws {
|
||||
self.modelId = modelId
|
||||
self.baseURL = baseURL
|
||||
self.additionalHeaders = additionalHeaders
|
||||
self.session = session
|
||||
|
||||
// Try to get API key from configuration, otherwise try common environment variable patterns
|
||||
if let key = configuration.getAPIKey(for: .custom("openai_compatible")) {
|
||||
// Try explicit provider key, then configuration, then common environment variable patterns.
|
||||
if let key = apiKey {
|
||||
self.apiKey = key
|
||||
} else if let key = configuration.getAPIKey(for: .custom("openai_compatible")) {
|
||||
self.apiKey = key
|
||||
} else if
|
||||
let key = ProcessInfo.processInfo.environment["OPENAI_COMPATIBLE_API_KEY"] ??
|
||||
@ -24,12 +40,13 @@ public final class OpenAICompatibleProvider: ModelProvider {
|
||||
self.apiKey = nil // Some compatible APIs don't require keys
|
||||
}
|
||||
|
||||
let isFable = LanguageModel.Anthropic.isFable(modelId: modelId)
|
||||
self.capabilities = ModelCapabilities(
|
||||
supportsVision: false,
|
||||
supportsTools: true,
|
||||
supportsStreaming: true,
|
||||
contextLength: 128_000,
|
||||
maxOutputTokens: 4096,
|
||||
supportsStreaming: !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId),
|
||||
contextLength: isFable ? 1_000_000 : 128_000,
|
||||
maxOutputTokens: isFable ? 128_000 : 4096,
|
||||
)
|
||||
}
|
||||
|
||||
@ -41,17 +58,25 @@ public final class OpenAICompatibleProvider: ModelProvider {
|
||||
baseURL: self.baseURL!,
|
||||
apiKey: self.apiKey ?? "",
|
||||
providerName: "OpenAICompatible",
|
||||
additionalHeaders: self.additionalHeaders,
|
||||
session: self.session,
|
||||
)
|
||||
}
|
||||
|
||||
public func streamText(request: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, Error> {
|
||||
guard !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: self.modelId) else {
|
||||
throw TachikomaError.invalidConfiguration("\(self.modelId) does not support streaming")
|
||||
}
|
||||
|
||||
// Use OpenAI-compatible streaming implementation
|
||||
try await OpenAICompatibleHelper.streamText(
|
||||
return try await OpenAICompatibleHelper.streamText(
|
||||
request: request,
|
||||
modelId: self.modelId,
|
||||
baseURL: self.baseURL!,
|
||||
apiKey: self.apiKey ?? "",
|
||||
providerName: "OpenAICompatible",
|
||||
additionalHeaders: self.additionalHeaders,
|
||||
session: self.session,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -214,16 +214,15 @@ public final class GoogleProvider: ModelProvider {
|
||||
}
|
||||
}
|
||||
|
||||
guard !parts.isEmpty else { continue }
|
||||
let role = switch message.role {
|
||||
case .assistant:
|
||||
"model"
|
||||
case .tool:
|
||||
"function"
|
||||
"user"
|
||||
default:
|
||||
"user"
|
||||
}
|
||||
contents.append(.init(role: role, parts: parts))
|
||||
Self.appendGoogleContent(role: role, parts: parts, to: &contents)
|
||||
}
|
||||
|
||||
let config = GoogleGenerateRequest.GenerationConfig(
|
||||
@ -253,6 +252,20 @@ public final class GoogleProvider: ModelProvider {
|
||||
// MARK: - Streaming Helpers
|
||||
|
||||
extension GoogleProvider {
|
||||
private static func appendGoogleContent(
|
||||
role: String,
|
||||
parts: [GoogleGenerateRequest.Content.Part],
|
||||
to contents: inout [GoogleGenerateRequest.Content],
|
||||
) {
|
||||
guard !parts.isEmpty else { return }
|
||||
|
||||
if let last = contents.last, last.role == role {
|
||||
contents[contents.count - 1] = .init(role: role, parts: last.parts + parts)
|
||||
} else {
|
||||
contents.append(.init(role: role, parts: parts))
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildToolCallNameMap(from messages: [ModelMessage]) -> [String: String] {
|
||||
var map: [String: String] = [:]
|
||||
for message in messages {
|
||||
@ -271,12 +284,6 @@ extension GoogleProvider {
|
||||
}
|
||||
|
||||
private static func convertTool(_ tool: AgentTool) throws -> GoogleGenerateRequest.Tool.FunctionDeclaration {
|
||||
var parameters: [String: Any] = [
|
||||
"type": "object",
|
||||
"properties": [:],
|
||||
"required": tool.parameters.required,
|
||||
]
|
||||
|
||||
var properties: [String: Any] = [:]
|
||||
for (key, prop) in tool.parameters.properties {
|
||||
var propDict: [String: Any] = [
|
||||
@ -295,7 +302,21 @@ extension GoogleProvider {
|
||||
}
|
||||
properties[key] = propDict
|
||||
}
|
||||
parameters["properties"] = properties
|
||||
|
||||
// Gemini validates that every name in `required` exists in `properties`. Tools occasionally
|
||||
// ship with a `required` entry whose property got filtered out during MCP→Agent conversion
|
||||
// (e.g. anyOf/oneOf schemas that don't survive the simplified MCP→Agent translation). Drop
|
||||
// any orphan `required` names so we never send Gemini an invalid schema.
|
||||
let declaredKeys = Set(properties.keys)
|
||||
let sanitizedRequired = tool.parameters.required.filter { declaredKeys.contains($0) }
|
||||
|
||||
var parameters: [String: Any] = [
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
]
|
||||
if !sanitizedRequired.isEmpty {
|
||||
parameters["required"] = sanitizedRequired
|
||||
}
|
||||
|
||||
guard let schema = JSONValue(value: parameters) else {
|
||||
throw TachikomaError.invalidInput("Failed to encode tool parameters for '\(tool.name)'")
|
||||
|
||||
@ -11,8 +11,15 @@ public final class GrokProvider: ModelProvider {
|
||||
private let model: LanguageModel.Grok
|
||||
|
||||
public init(model: LanguageModel.Grok, configuration: TachikomaConfiguration) throws {
|
||||
let modelId = model.modelId
|
||||
guard !Self.requiresResponsesAPIRouting(modelId) else {
|
||||
throw TachikomaError.unsupportedOperation(
|
||||
"\(modelId) requires xAI Responses API routing",
|
||||
)
|
||||
}
|
||||
|
||||
self.model = model
|
||||
self.modelId = model.modelId
|
||||
self.modelId = modelId
|
||||
self.baseURL = configuration.getBaseURL(for: .grok) ?? "https://api.x.ai/v1"
|
||||
|
||||
// Get API key from configuration system (environment or credentials)
|
||||
@ -31,6 +38,16 @@ public final class GrokProvider: ModelProvider {
|
||||
)
|
||||
}
|
||||
|
||||
private static func requiresResponsesAPIRouting(_ modelId: String) -> Bool {
|
||||
let normalized = modelId.lowercased()
|
||||
let compact = normalized
|
||||
.replacingOccurrences(of: "-", with: "")
|
||||
.replacingOccurrences(of: ".", with: "")
|
||||
return normalized.contains("grok-4.20-multi-agent") ||
|
||||
normalized.contains("grok-4-20-multi-agent") ||
|
||||
compact.contains("grok420multiagent")
|
||||
}
|
||||
|
||||
public func generateText(request: ProviderRequest) async throws -> ProviderResponse {
|
||||
// Grok uses OpenAI-compatible API format - delegate to shared implementation
|
||||
try await OpenAICompatibleHelper.generateText(
|
||||
|
||||
@ -41,7 +41,7 @@ public actor LMStudioProvider: ModelProvider {
|
||||
|
||||
public init(
|
||||
baseURL: String = "http://localhost:1234/v1",
|
||||
modelId: String = "current",
|
||||
modelId: String = "openai/gpt-oss-20b",
|
||||
apiKey: String? = nil,
|
||||
sessionConfiguration: URLSessionConfiguration = .default,
|
||||
) {
|
||||
@ -74,8 +74,8 @@ public actor LMStudioProvider: ModelProvider {
|
||||
|
||||
for url in commonURLs {
|
||||
let provider = LMStudioProvider(baseURL: url)
|
||||
if await (try? provider.healthCheck()) != nil {
|
||||
return provider
|
||||
if let status = await (try? provider.healthCheck()) {
|
||||
return LMStudioProvider(baseURL: url, modelId: status.model ?? "openai/gpt-oss-20b")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,10 @@ public final class OpenAIProvider: ModelProvider {
|
||||
configuration: TachikomaConfiguration,
|
||||
session: URLSession = .shared,
|
||||
) throws {
|
||||
guard !model.isUnsupportedLegacyFamily else {
|
||||
throw TachikomaError.unsupportedOperation("OpenAI model '\(model.modelId)' is no longer supported")
|
||||
}
|
||||
|
||||
self.model = model
|
||||
self.modelId = model.modelId
|
||||
self.baseURL = configuration.getBaseURL(for: .openai) ?? "https://api.openai.com/v1"
|
||||
|
||||
@ -2,11 +2,8 @@ import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Provider for OpenAI Responses API (GPT-5, o3, o4)
|
||||
/// Provider for OpenAI Responses API (GPT-5)
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public final class OpenAIResponsesProvider: ModelProvider {
|
||||
public let modelId: String
|
||||
@ -17,6 +14,7 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
private let model: LanguageModel.OpenAI
|
||||
private let configuration: TachikomaConfiguration
|
||||
private let session: URLSession
|
||||
private let auth: TKAuthValue
|
||||
|
||||
private static let debugLogURL = URL(fileURLWithPath: "/tmp/tachikoma-gpt5.log")
|
||||
|
||||
@ -31,15 +29,28 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
configuration: TachikomaConfiguration,
|
||||
session: URLSession = .shared,
|
||||
) throws {
|
||||
guard !model.isUnsupportedLegacyFamily else {
|
||||
throw TachikomaError.unsupportedOperation("OpenAI model '\(model.modelId)' is no longer supported")
|
||||
}
|
||||
|
||||
self.model = model
|
||||
self.modelId = model.modelId
|
||||
self.configuration = configuration
|
||||
self.session = session
|
||||
self.baseURL = configuration.getBaseURL(for: .openai) ?? "https://api.openai.com/v1"
|
||||
|
||||
// Get API key from configuration
|
||||
// Prefer configuration-provided key first (test configs use this), then shared OAuth/API-key auth.
|
||||
if let key = configuration.getAPIKey(for: .openai) {
|
||||
self.auth = .bearer(key, betaHeader: nil)
|
||||
self.apiKey = key
|
||||
} else if let auth = TKAuthManager.shared.resolveAuth(for: .openai) {
|
||||
self.auth = auth
|
||||
switch auth {
|
||||
case let .apiKey(key):
|
||||
self.apiKey = key
|
||||
case let .bearer(token, _):
|
||||
self.apiKey = token
|
||||
}
|
||||
} else {
|
||||
throw TachikomaError.authenticationFailed("OPENAI_API_KEY not found")
|
||||
}
|
||||
@ -47,13 +58,15 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
// Set capabilities based on model
|
||||
let isReasoningModel = Self.isReasoningModel(model)
|
||||
let isGPT5 = Self.isGPT5Model(model)
|
||||
let hasLargeOutputWindow = isReasoningModel || isGPT5 || model == .chatLatest
|
||||
let maxOutputTokens = model == .gpt5ChatLatest ? 16384 : (hasLargeOutputWindow ? 128_000 : 4096)
|
||||
|
||||
self.capabilities = ModelCapabilities(
|
||||
supportsVision: model.supportsVision,
|
||||
supportsTools: model.supportsTools,
|
||||
supportsStreaming: true,
|
||||
contextLength: model.contextLength,
|
||||
maxOutputTokens: isReasoningModel || isGPT5 ? 128_000 : 4096,
|
||||
maxOutputTokens: maxOutputTokens,
|
||||
)
|
||||
}
|
||||
|
||||
@ -65,7 +78,8 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
let url = URL(string: "\(baseURL!)/responses")!
|
||||
var urlRequest = URLRequest(url: url)
|
||||
urlRequest.httpMethod = "POST"
|
||||
urlRequest.setValue("Bearer \(self.apiKey!)", forHTTPHeaderField: "Authorization")
|
||||
let (authHeaderName, prefix, secret) = self.authHeader()
|
||||
urlRequest.setValue("\(prefix)\(secret)", forHTTPHeaderField: authHeaderName)
|
||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
// Add OpenAI-specific headers
|
||||
@ -134,7 +148,8 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
let finalURLRequest: URLRequest = {
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("Bearer \(self.apiKey!)", forHTTPHeaderField: "Authorization")
|
||||
let (authHeaderName, prefix, secret) = self.authHeader()
|
||||
req.setValue("\(prefix)\(secret)", forHTTPHeaderField: authHeaderName)
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
|
||||
@ -198,6 +213,21 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
// Parse the entire response for Linux
|
||||
let responseText = String(data: data, encoding: .utf8) ?? ""
|
||||
let lines = responseText.components(separatedBy: "\n")
|
||||
var streamState = ResponsesStreamState()
|
||||
for line in lines {
|
||||
if
|
||||
try Self.processResponsesStreamLine(
|
||||
line,
|
||||
model: self.model,
|
||||
state: &streamState,
|
||||
continuation: continuation,
|
||||
)
|
||||
{
|
||||
return
|
||||
}
|
||||
}
|
||||
continuation.finish()
|
||||
return
|
||||
#else
|
||||
// macOS/iOS: Use streaming API
|
||||
let (bytes, response) = try await self.session.bytes(for: finalURLRequest)
|
||||
@ -227,157 +257,18 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
throw TachikomaError.apiError("Failed to start streaming: \(errorMessage)")
|
||||
}
|
||||
|
||||
var previousContent = "" // Track previously sent content for GPT-5 preambles
|
||||
struct PartialToolCall {
|
||||
var id: String
|
||||
var name: String?
|
||||
var arguments: String
|
||||
}
|
||||
var pendingToolCalls: [String: PartialToolCall] = [:]
|
||||
var streamState = ResponsesStreamState()
|
||||
|
||||
for try await line in bytes.lines {
|
||||
// Handle SSE format
|
||||
if line.hasPrefix("data: ") {
|
||||
let jsonString = String(line.dropFirst(6))
|
||||
|
||||
if ProcessInfo.processInfo.environment["DEBUG_TACHIKOMA_STREAM"] != nil {
|
||||
Self.debugLog("raw stream: \(jsonString)")
|
||||
}
|
||||
|
||||
if jsonString == "[DONE]" {
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
if let data = jsonString.data(using: .utf8) {
|
||||
// Try GPT-5 format first
|
||||
if Self.isGPT5Model(self.model) {
|
||||
if
|
||||
let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let eventType = event["type"] as? String
|
||||
{
|
||||
if ProcessInfo.processInfo.environment["DEBUG_TACHIKOMA"] != nil {
|
||||
Self.debugLog("event: \(eventType) payload: \(event)")
|
||||
}
|
||||
|
||||
switch eventType {
|
||||
case "response.output_text.delta":
|
||||
if let delta = event["delta"] as? String, !delta.isEmpty {
|
||||
continuation.yield(TextStreamDelta.text(delta))
|
||||
}
|
||||
|
||||
case "response.output_item.added":
|
||||
if
|
||||
let item = event["item"] as? [String: Any],
|
||||
let itemType = item["type"] as? String,
|
||||
itemType == "function_call"
|
||||
{
|
||||
let identifier = (item["id"] as? String) ??
|
||||
(item["call_id"] as? String) ?? UUID().uuidString
|
||||
var partial = pendingToolCalls[identifier] ?? PartialToolCall(
|
||||
id: identifier,
|
||||
name: nil,
|
||||
arguments: "",
|
||||
)
|
||||
if let name = item["name"] as? String {
|
||||
partial.name = name
|
||||
}
|
||||
pendingToolCalls[identifier] = partial
|
||||
}
|
||||
|
||||
case "response.function_call_arguments.delta":
|
||||
if
|
||||
let itemId = event["item_id"] as? String,
|
||||
let delta = event["delta"] as? String
|
||||
{
|
||||
var partial = pendingToolCalls[itemId] ?? PartialToolCall(
|
||||
id: itemId,
|
||||
name: nil,
|
||||
arguments: "",
|
||||
)
|
||||
partial.arguments.append(delta)
|
||||
pendingToolCalls[itemId] = partial
|
||||
}
|
||||
|
||||
case "response.function_call_arguments.done":
|
||||
if
|
||||
let itemId = event["item_id"] as? String,
|
||||
let arguments = event["arguments"] as? String
|
||||
{
|
||||
var partial = pendingToolCalls[itemId] ?? PartialToolCall(
|
||||
id: itemId,
|
||||
name: nil,
|
||||
arguments: "",
|
||||
)
|
||||
partial.arguments = arguments
|
||||
pendingToolCalls[itemId] = partial
|
||||
|
||||
if
|
||||
let name = partial.name,
|
||||
let toolCall = Self.makeToolCall(
|
||||
id: itemId,
|
||||
name: name,
|
||||
argumentsJSON: arguments,
|
||||
)
|
||||
{
|
||||
continuation.yield(.tool(toolCall))
|
||||
pendingToolCalls.removeValue(forKey: itemId)
|
||||
}
|
||||
}
|
||||
|
||||
case "response.completed":
|
||||
continuation.finish()
|
||||
return
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Try standard Responses API format (O3, etc.)
|
||||
do {
|
||||
let chunk = try JSONDecoder().decode(
|
||||
OpenAIResponsesStreamChunk.self,
|
||||
from: data,
|
||||
)
|
||||
|
||||
// Convert to TextStreamDelta
|
||||
if
|
||||
let choice = chunk.choices.first,
|
||||
let content = choice.delta.content,
|
||||
!content.isEmpty
|
||||
{
|
||||
// Handle accumulated content for models with preambles
|
||||
if content.hasPrefix(previousContent), !previousContent.isEmpty {
|
||||
// This is accumulated content, extract just the delta
|
||||
let delta = String(content.dropFirst(previousContent.count))
|
||||
if !delta.isEmpty {
|
||||
continuation.yield(TextStreamDelta.text(delta))
|
||||
previousContent = content // Update the accumulated content
|
||||
}
|
||||
} else {
|
||||
// This is a true delta or the first chunk
|
||||
continuation.yield(TextStreamDelta.text(content))
|
||||
previousContent += content // Accumulate for comparison
|
||||
}
|
||||
}
|
||||
|
||||
// Check for finish
|
||||
if
|
||||
let choice = chunk.choices.first,
|
||||
choice.finishReason != nil
|
||||
{
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors for incomplete chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if line.hasPrefix("event: ") {
|
||||
// Track event types for GPT-5 streaming (but we handle them in data lines)
|
||||
// This helps us understand the stream structure
|
||||
if
|
||||
try Self.processResponsesStreamLine(
|
||||
line,
|
||||
model: self.model,
|
||||
state: &streamState,
|
||||
continuation: continuation,
|
||||
)
|
||||
{
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@ -390,6 +281,210 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ResponsesStreamState {
|
||||
struct PartialToolCall {
|
||||
var id: String
|
||||
var name: String?
|
||||
var arguments: String
|
||||
}
|
||||
|
||||
var previousContent = ""
|
||||
var pendingToolCalls: [String: PartialToolCall] = [:]
|
||||
var didYieldToolCall = false
|
||||
var didReceiveRefusal = false
|
||||
}
|
||||
|
||||
private static func processResponsesStreamLine(
|
||||
_ line: String,
|
||||
model: LanguageModel.OpenAI,
|
||||
state: inout ResponsesStreamState,
|
||||
continuation: AsyncThrowingStream<TextStreamDelta, Error>.Continuation,
|
||||
) throws
|
||||
-> Bool
|
||||
{
|
||||
guard line.hasPrefix("data: ") else {
|
||||
return false
|
||||
}
|
||||
|
||||
let jsonString = String(line.dropFirst(6))
|
||||
if ProcessInfo.processInfo.environment["DEBUG_TACHIKOMA_STREAM"] != nil {
|
||||
Self.debugLog("raw stream: \(jsonString)")
|
||||
}
|
||||
|
||||
if jsonString == "[DONE]" {
|
||||
continuation.finish()
|
||||
return true
|
||||
}
|
||||
|
||||
guard let data = jsonString.data(using: .utf8) else {
|
||||
return false
|
||||
}
|
||||
|
||||
if Self.usesResponsesEventStream(model) {
|
||||
guard
|
||||
let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let eventType = event["type"] as? String else
|
||||
{
|
||||
return false
|
||||
}
|
||||
|
||||
if ProcessInfo.processInfo.environment["DEBUG_TACHIKOMA"] != nil {
|
||||
Self.debugLog("event: \(eventType) payload: \(event)")
|
||||
}
|
||||
|
||||
switch eventType {
|
||||
case "response.output_text.delta":
|
||||
if let delta = event["delta"] as? String, !delta.isEmpty {
|
||||
continuation.yield(TextStreamDelta.text(delta))
|
||||
}
|
||||
|
||||
case "response.output_item.added":
|
||||
if
|
||||
let item = event["item"] as? [String: Any],
|
||||
let itemType = item["type"] as? String,
|
||||
itemType == "function_call"
|
||||
{
|
||||
let identifier = (item["id"] as? String) ??
|
||||
(item["call_id"] as? String) ?? UUID().uuidString
|
||||
var partial = state.pendingToolCalls[identifier] ?? ResponsesStreamState.PartialToolCall(
|
||||
id: identifier,
|
||||
name: nil,
|
||||
arguments: "",
|
||||
)
|
||||
if let name = item["name"] as? String {
|
||||
partial.name = name
|
||||
}
|
||||
state.pendingToolCalls[identifier] = partial
|
||||
}
|
||||
|
||||
case "response.function_call_arguments.delta":
|
||||
if
|
||||
let itemId = event["item_id"] as? String,
|
||||
let delta = event["delta"] as? String
|
||||
{
|
||||
var partial = state.pendingToolCalls[itemId] ?? ResponsesStreamState.PartialToolCall(
|
||||
id: itemId,
|
||||
name: nil,
|
||||
arguments: "",
|
||||
)
|
||||
partial.arguments.append(delta)
|
||||
state.pendingToolCalls[itemId] = partial
|
||||
}
|
||||
|
||||
case "response.function_call_arguments.done":
|
||||
if
|
||||
let itemId = event["item_id"] as? String,
|
||||
let arguments = event["arguments"] as? String
|
||||
{
|
||||
var partial = state.pendingToolCalls[itemId] ?? ResponsesStreamState.PartialToolCall(
|
||||
id: itemId,
|
||||
name: nil,
|
||||
arguments: "",
|
||||
)
|
||||
partial.arguments = arguments
|
||||
state.pendingToolCalls[itemId] = partial
|
||||
|
||||
if
|
||||
let name = partial.name,
|
||||
let toolCall = Self.makeToolCall(id: itemId, name: name, argumentsJSON: arguments)
|
||||
{
|
||||
continuation.yield(.tool(toolCall))
|
||||
state.didYieldToolCall = true
|
||||
state.pendingToolCalls.removeValue(forKey: itemId)
|
||||
}
|
||||
}
|
||||
|
||||
case "response.refusal.delta",
|
||||
"response.refusal.done":
|
||||
state.didReceiveRefusal = true
|
||||
|
||||
case "response.completed":
|
||||
let finishReason: FinishReason = state.didReceiveRefusal
|
||||
? .contentFilter
|
||||
: (state.didYieldToolCall ? .toolCalls : .stop)
|
||||
continuation.yield(.done(finishReason: finishReason))
|
||||
continuation.finish()
|
||||
return true
|
||||
|
||||
case "response.incomplete":
|
||||
let finishReason = Self.finishReasonForIncompleteResponseEvent(event)
|
||||
continuation.yield(.done(finishReason: finishReason))
|
||||
continuation.finish()
|
||||
return true
|
||||
|
||||
case "response.failed",
|
||||
"error":
|
||||
throw TachikomaError.apiError(Self.errorMessageForResponseStreamEvent(event))
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
let chunk = try JSONDecoder().decode(OpenAIResponsesStreamChunk.self, from: data)
|
||||
if
|
||||
let choice = chunk.choices.first,
|
||||
let content = choice.delta.content,
|
||||
!content.isEmpty
|
||||
{
|
||||
if content.hasPrefix(state.previousContent), !state.previousContent.isEmpty {
|
||||
let delta = String(content.dropFirst(state.previousContent.count))
|
||||
if !delta.isEmpty {
|
||||
continuation.yield(TextStreamDelta.text(delta))
|
||||
state.previousContent = content
|
||||
}
|
||||
} else {
|
||||
continuation.yield(TextStreamDelta.text(content))
|
||||
state.previousContent += content
|
||||
}
|
||||
}
|
||||
|
||||
if let choice = chunk.choices.first, let finishReason = choice.finishReason {
|
||||
continuation.yield(.done(finishReason: Self.finishReasonForChatStream(finishReason)))
|
||||
continuation.finish()
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors for incomplete chunks.
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private static func finishReasonForChatStream(_ reason: String) -> FinishReason {
|
||||
switch reason {
|
||||
case "stop": .stop
|
||||
case "length": .length
|
||||
case "tool_calls": .toolCalls
|
||||
case "content_filter": .contentFilter
|
||||
default: .other
|
||||
}
|
||||
}
|
||||
|
||||
private static func errorMessageForResponseStreamEvent(_ event: [String: Any]) -> String {
|
||||
let eventType = event["type"] as? String ?? "error"
|
||||
let errorPayload = (event["error"] as? [String: Any]) ??
|
||||
(event["response"] as? [String: Any]).flatMap { $0["error"] as? [String: Any] }
|
||||
if let message = errorPayload?["message"] as? String, !message.isEmpty {
|
||||
return "OpenAI Responses API stream \(eventType): \(message)"
|
||||
}
|
||||
if let message = event["message"] as? String, !message.isEmpty {
|
||||
return "OpenAI Responses API stream \(eventType): \(message)"
|
||||
}
|
||||
return "OpenAI Responses API stream \(eventType)"
|
||||
}
|
||||
|
||||
private func authHeader() -> (String, String, String) {
|
||||
switch self.auth {
|
||||
case let .apiKey(key):
|
||||
("Authorization", "Bearer ", key)
|
||||
case let .bearer(token, _):
|
||||
("Authorization", "Bearer ", token)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func buildResponsesRequest(
|
||||
@ -534,6 +629,29 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private static func finishReasonForIncompleteResponseEvent(_ event: [String: Any]) -> FinishReason {
|
||||
guard
|
||||
let response = event["response"] as? [String: Any],
|
||||
let incompleteDetails = response["incomplete_details"] as? [String: Any],
|
||||
let reason = incompleteDetails["reason"] as? String else
|
||||
{
|
||||
return .other
|
||||
}
|
||||
|
||||
return Self.finishReasonForIncompleteReason(reason)
|
||||
}
|
||||
|
||||
private static func finishReasonForIncompleteReason(_ reason: String?) -> FinishReason {
|
||||
switch reason {
|
||||
case "content_filter":
|
||||
.contentFilter
|
||||
case "max_output_tokens":
|
||||
.length
|
||||
default:
|
||||
.other
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMessageEntry(role: String, message: ModelMessage) -> ResponsesMessage? {
|
||||
let parts = self.convertContentParts(for: message)
|
||||
guard !parts.isEmpty else { return nil }
|
||||
@ -731,15 +849,16 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
}
|
||||
|
||||
static func convertToProviderResponse(_ response: OpenAIResponsesResponse) throws -> ProviderResponse {
|
||||
// Handle GPT-5 format (output array) vs O3 format (choices array)
|
||||
let text: String
|
||||
let toolCalls: [AgentToolCall]?
|
||||
let finishReason: FinishReason?
|
||||
// Handle GPT-5 output arrays and alternate choices arrays.
|
||||
var text: String
|
||||
var toolCalls: [AgentToolCall]?
|
||||
var finishReason: FinishReason?
|
||||
|
||||
if let outputs = response.output {
|
||||
// GPT-5 format with output array
|
||||
var collectedText = ""
|
||||
var collectedToolCalls: [AgentToolCall] = []
|
||||
var didCollectRefusal = false
|
||||
|
||||
for output in outputs {
|
||||
if output.type == "message" {
|
||||
@ -750,6 +869,10 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
if let textSegment = chunk.text {
|
||||
collectedText.append(textSegment)
|
||||
}
|
||||
case "refusal":
|
||||
if chunk.refusal != nil || chunk.text != nil {
|
||||
didCollectRefusal = true
|
||||
}
|
||||
case "tool_call":
|
||||
if
|
||||
let toolCall = chunk.toolCall,
|
||||
@ -771,14 +894,24 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
}
|
||||
|
||||
text = collectedText
|
||||
toolCalls = collectedToolCalls.isEmpty ? nil : collectedToolCalls
|
||||
if let toolCalls, !toolCalls.isEmpty {
|
||||
finishReason = .toolCalls
|
||||
let incompleteFinishReason = response.status == "incomplete"
|
||||
? Self.finishReasonForIncompleteReason(response.incompleteDetails?.reason)
|
||||
: nil
|
||||
if incompleteFinishReason == .contentFilter || didCollectRefusal {
|
||||
text = ""
|
||||
toolCalls = nil
|
||||
finishReason = .contentFilter
|
||||
} else {
|
||||
finishReason = .stop
|
||||
toolCalls = collectedToolCalls.isEmpty ? nil : collectedToolCalls
|
||||
}
|
||||
if finishReason == nil, let toolCalls, !toolCalls.isEmpty {
|
||||
finishReason = .toolCalls
|
||||
}
|
||||
if finishReason == nil {
|
||||
finishReason = incompleteFinishReason ?? .stop
|
||||
}
|
||||
} else if let choices = response.choices, let choice = choices.first {
|
||||
// O3 format with choices array
|
||||
// Alternate format with choices array.
|
||||
text = choice.message.content ?? ""
|
||||
|
||||
// Convert tool calls
|
||||
@ -790,20 +923,26 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
case "stop": finishReason = .stop
|
||||
case "length": finishReason = .length
|
||||
case "tool_calls": finishReason = .toolCalls
|
||||
case "content_filter": finishReason = .contentFilter
|
||||
default: finishReason = .stop
|
||||
}
|
||||
} else {
|
||||
finishReason = nil
|
||||
}
|
||||
if finishReason == .contentFilter || choice.message.refusal != nil {
|
||||
text = ""
|
||||
toolCalls = nil
|
||||
finishReason = .contentFilter
|
||||
}
|
||||
} else {
|
||||
throw TachikomaError.apiError("No output or choices in response")
|
||||
}
|
||||
|
||||
// Convert usage (handle both GPT-5 and O3 formats)
|
||||
// Convert usage across Responses API token field variants.
|
||||
let usage: Usage?
|
||||
if let apiUsage = response.usage {
|
||||
// GPT-5 uses input_tokens/output_tokens
|
||||
// O3 uses prompt_tokens/completion_tokens
|
||||
// Alternate responses can use prompt_tokens/completion_tokens.
|
||||
let inputTokens = apiUsage.inputTokens ?? apiUsage.promptTokens ?? 0
|
||||
let outputTokens = apiUsage.outputTokens ?? apiUsage.completionTokens ?? 0
|
||||
|
||||
@ -898,31 +1037,28 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private static func isReasoningModel(_ model: LanguageModel.OpenAI) -> Bool {
|
||||
private static func isReasoningModel(_: LanguageModel.OpenAI) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
private static func isGPT5Model(_ model: LanguageModel.OpenAI) -> Bool {
|
||||
switch model {
|
||||
case .o4Mini:
|
||||
case .gpt55,
|
||||
.gpt54,
|
||||
.gpt54Mini,
|
||||
.gpt54Nano,
|
||||
.gpt5,
|
||||
.gpt5Pro,
|
||||
.gpt5Mini,
|
||||
.gpt5Nano:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private static func isGPT5Model(_ model: LanguageModel.OpenAI) -> Bool {
|
||||
switch model {
|
||||
case .gpt52,
|
||||
.gpt51,
|
||||
.gpt5,
|
||||
.gpt5Pro,
|
||||
.gpt5Mini,
|
||||
.gpt5Nano,
|
||||
.gpt5Thinking,
|
||||
.gpt5ThinkingMini,
|
||||
.gpt5ThinkingNano,
|
||||
.gpt5ChatLatest:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
private static func usesResponsesEventStream(_ model: LanguageModel.OpenAI) -> Bool {
|
||||
model == .chatLatest || model == .gpt5ChatLatest || self.isGPT5Model(model)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -28,7 +28,7 @@ struct OpenAIResponsesRequest: Encodable {
|
||||
let serviceTier: String?
|
||||
let include: [String]?
|
||||
|
||||
/// Reasoning configuration (for o3/o4/GPT-5)
|
||||
/// Reasoning configuration (for GPT-5 thinking models)
|
||||
let reasoning: ReasoningConfig?
|
||||
|
||||
/// Truncation for long inputs
|
||||
@ -477,18 +477,24 @@ struct OpenAIResponsesResponse: Codable {
|
||||
let id: String
|
||||
let object: String?
|
||||
let createdAt: Int? // GPT-5 uses created_at
|
||||
let created: Int? // O3 uses created
|
||||
let created: Int? // Alternate responses can use created
|
||||
let status: String?
|
||||
let model: String
|
||||
let output: [ResponsesOutput]? // GPT-5 uses output array
|
||||
let choices: [ResponsesChoice]? // O3 uses choices array
|
||||
let choices: [ResponsesChoice]? // Alternate responses can use choices array
|
||||
let usage: ResponsesUsage?
|
||||
let metadata: ResponsesMetadata?
|
||||
let incompleteDetails: IncompleteDetails?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, object, status, model, output, choices, usage, metadata
|
||||
case createdAt = "created_at"
|
||||
case created
|
||||
case incompleteDetails = "incomplete_details"
|
||||
}
|
||||
|
||||
struct IncompleteDetails: Codable {
|
||||
let reason: String?
|
||||
}
|
||||
|
||||
/// GPT-5 output format
|
||||
@ -513,17 +519,31 @@ struct OpenAIResponsesResponse: Codable {
|
||||
struct OutputContent: Codable {
|
||||
let type: String
|
||||
let text: String?
|
||||
let refusal: String?
|
||||
let toolCall: ResponsesToolCall?
|
||||
|
||||
init(
|
||||
type: String,
|
||||
text: String? = nil,
|
||||
refusal: String? = nil,
|
||||
toolCall: ResponsesToolCall? = nil,
|
||||
) {
|
||||
self.type = type
|
||||
self.text = text
|
||||
self.refusal = refusal
|
||||
self.toolCall = toolCall
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case text
|
||||
case refusal
|
||||
case toolCall = "tool_call"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// O3 choices format (kept for compatibility)
|
||||
/// Alternate choices format.
|
||||
struct ResponsesChoice: Codable {
|
||||
let index: Int
|
||||
let message: ResponsesOutputMessage
|
||||
@ -614,7 +634,7 @@ struct OpenAIResponsesResponse: Codable {
|
||||
|
||||
// MARK: - Streaming Response Types
|
||||
|
||||
/// Server-sent event for streaming responses (O3 and older models)
|
||||
/// Server-sent event for alternate streaming responses.
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
struct OpenAIResponsesStreamChunk: Codable {
|
||||
let id: String
|
||||
|
||||
@ -77,11 +77,14 @@ struct OpenAIChatMessage: Codable {
|
||||
let content: Either<String, [OpenAIChatMessageContent]>?
|
||||
let toolCallId: String?
|
||||
let toolCalls: [AgentToolCall]?
|
||||
let reasoning: String?
|
||||
let reasoningDetails: [JSONValue]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case role, content
|
||||
case role, content, reasoning
|
||||
case toolCallId = "tool_call_id"
|
||||
case toolCalls = "tool_calls"
|
||||
case reasoningDetails = "reasoning_details"
|
||||
}
|
||||
|
||||
struct AgentToolCall: Codable {
|
||||
@ -100,6 +103,8 @@ struct OpenAIChatMessage: Codable {
|
||||
self.content = .left(content)
|
||||
self.toolCallId = toolCallId
|
||||
self.toolCalls = nil
|
||||
self.reasoning = nil
|
||||
self.reasoningDetails = nil
|
||||
}
|
||||
|
||||
init(role: String, content: [OpenAIChatMessageContent], toolCallId: String? = nil) {
|
||||
@ -107,13 +112,23 @@ struct OpenAIChatMessage: Codable {
|
||||
self.content = .right(content)
|
||||
self.toolCallId = toolCallId
|
||||
self.toolCalls = nil
|
||||
self.reasoning = nil
|
||||
self.reasoningDetails = nil
|
||||
}
|
||||
|
||||
init(role: String, content: String? = nil, toolCalls: [AgentToolCall]?) {
|
||||
init(
|
||||
role: String,
|
||||
content: String? = nil,
|
||||
toolCalls: [AgentToolCall]?,
|
||||
reasoning: String? = nil,
|
||||
reasoningDetails: [JSONValue]? = nil,
|
||||
) {
|
||||
self.role = role
|
||||
self.content = content.map { .left($0) }
|
||||
self.toolCallId = nil
|
||||
self.toolCalls = toolCalls
|
||||
self.reasoning = reasoning
|
||||
self.reasoningDetails = reasoningDetails
|
||||
}
|
||||
}
|
||||
|
||||
@ -246,10 +261,13 @@ struct OpenAIChatResponse: Codable {
|
||||
let role: String
|
||||
let content: String?
|
||||
let toolCalls: [AgentToolCall]?
|
||||
let reasoning: String?
|
||||
let reasoningDetails: [JSONValue]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case role, content
|
||||
case role, content, reasoning
|
||||
case toolCalls = "tool_calls"
|
||||
case reasoningDetails = "reasoning_details"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -24,21 +24,27 @@ public final class OpenRouterProvider: ModelProvider {
|
||||
|
||||
if let key = configuration.getAPIKey(for: .custom("openrouter")) {
|
||||
self.apiKey = key
|
||||
} else if let auth = TKAuthManager.shared.resolveAuth(for: .openrouter) {
|
||||
switch auth {
|
||||
case let .apiKey(key), let .bearer(key, _):
|
||||
self.apiKey = key
|
||||
}
|
||||
} else {
|
||||
throw TachikomaError.authenticationFailed("OPENROUTER_API_KEY not found")
|
||||
}
|
||||
|
||||
let isFable = LanguageModel.Anthropic.isFable(modelId: modelId)
|
||||
self.capabilities = ModelCapabilities(
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
supportsStreaming: true,
|
||||
contextLength: 128_000,
|
||||
maxOutputTokens: 4096,
|
||||
supportsStreaming: !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId),
|
||||
contextLength: isFable ? 1_000_000 : 128_000,
|
||||
maxOutputTokens: isFable ? 128_000 : 4096,
|
||||
)
|
||||
|
||||
self.defaultHeaders = [
|
||||
"HTTP-Referer": ProcessInfo.processInfo.environment["OPENROUTER_REFERER"] ?? "https://peekaboo.app",
|
||||
"X-Title": ProcessInfo.processInfo.environment["OPENROUTER_TITLE"] ?? "Peekaboo",
|
||||
"X-OpenRouter-Title": ProcessInfo.processInfo.environment["OPENROUTER_TITLE"] ?? "Peekaboo",
|
||||
]
|
||||
}
|
||||
|
||||
@ -62,6 +68,9 @@ public final class OpenRouterProvider: ModelProvider {
|
||||
guard let baseURL, let apiKey else {
|
||||
throw TachikomaError.invalidConfiguration("OpenRouter provider missing base URL or API key")
|
||||
}
|
||||
guard !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: self.modelId) else {
|
||||
throw TachikomaError.invalidConfiguration("\(self.modelId) does not support streaming")
|
||||
}
|
||||
|
||||
return try await OpenAICompatibleHelper.streamText(
|
||||
request: request,
|
||||
|
||||
@ -15,19 +15,18 @@ public struct ProviderFactory {
|
||||
// Create a provider for the specified language model
|
||||
switch model {
|
||||
case let .openai(openaiModel):
|
||||
// Use Responses API for reasoning models (o4) and GPT-5 family
|
||||
// Use Responses API for the GPT-5 family
|
||||
switch openaiModel {
|
||||
case .o4Mini,
|
||||
.gpt52,
|
||||
.gpt51,
|
||||
case .chatLatest,
|
||||
.gpt5ChatLatest,
|
||||
.gpt55,
|
||||
.gpt54,
|
||||
.gpt54Mini,
|
||||
.gpt54Nano,
|
||||
.gpt5,
|
||||
.gpt5Pro,
|
||||
.gpt5Mini,
|
||||
.gpt5Nano,
|
||||
.gpt5Thinking,
|
||||
.gpt5ThinkingMini,
|
||||
.gpt5ThinkingNano,
|
||||
.gpt5ChatLatest:
|
||||
.gpt5Nano:
|
||||
return try OpenAIResponsesProvider(model: openaiModel, configuration: configuration)
|
||||
default:
|
||||
return try OpenAIProvider(model: openaiModel, configuration: configuration)
|
||||
@ -59,6 +58,28 @@ public struct ProviderFactory {
|
||||
modelId: lmstudioModel.modelId,
|
||||
)
|
||||
|
||||
case let .minimax(minimaxModel):
|
||||
guard let apiKey = configuration.getAPIKey(for: .minimax) else {
|
||||
throw TachikomaError.authenticationFailed("MINIMAX_API_KEY not found")
|
||||
}
|
||||
return try Self.makeMiniMaxProvider(
|
||||
model: minimaxModel,
|
||||
provider: .minimax,
|
||||
apiKey: apiKey,
|
||||
configuration: configuration,
|
||||
)
|
||||
|
||||
case let .minimaxCN(minimaxModel):
|
||||
guard let apiKey = configuration.getAPIKey(for: .minimaxCN) ?? configuration.getAPIKey(for: .minimax) else {
|
||||
throw TachikomaError.authenticationFailed("MINIMAX_CN_API_KEY or MINIMAX_API_KEY not found")
|
||||
}
|
||||
return try Self.makeMiniMaxProvider(
|
||||
model: minimaxModel,
|
||||
provider: .minimaxCN,
|
||||
apiKey: apiKey,
|
||||
configuration: configuration,
|
||||
)
|
||||
|
||||
case let .openRouter(modelId):
|
||||
return try OpenRouterProvider(modelId: modelId, configuration: configuration)
|
||||
|
||||
@ -94,12 +115,18 @@ public struct ProviderFactory {
|
||||
modelId: parsed.model,
|
||||
baseURL: custom.baseURL,
|
||||
configuration: configuration,
|
||||
apiKey: custom.apiKey,
|
||||
additionalHeaders: custom.headers,
|
||||
)
|
||||
case .anthropic:
|
||||
return try AnthropicCompatibleProvider(
|
||||
modelId: parsed.model,
|
||||
baseURL: custom.baseURL,
|
||||
configuration: configuration,
|
||||
apiKey: custom.apiKey,
|
||||
additionalHeaders: custom.headers,
|
||||
reasoningProvider: "custom-anthropic",
|
||||
reasoningBaseURL: custom.baseURL,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -107,6 +134,34 @@ public struct ProviderFactory {
|
||||
return provider
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeMiniMaxProvider(
|
||||
model: LanguageModel.MiniMax,
|
||||
provider: Provider,
|
||||
apiKey: String,
|
||||
configuration: TachikomaConfiguration,
|
||||
) throws
|
||||
-> any ModelProvider
|
||||
{
|
||||
let baseURL = configuration.getBaseURL(for: provider) ?? provider.defaultBaseURL ?? "https://api.minimax.io/anthropic"
|
||||
return try AnthropicCompatibleProvider(
|
||||
modelId: model.modelId,
|
||||
baseURL: baseURL,
|
||||
configuration: configuration,
|
||||
apiKey: apiKey,
|
||||
// MiniMax's Anthropic-compatible setup uses Claude Code-style Authorization auth, not Anthropic x-api-key.
|
||||
auth: .bearer(apiKey, betaHeader: nil),
|
||||
capabilities: ModelCapabilities(
|
||||
supportsVision: model.supportsVision,
|
||||
supportsTools: model.supportsTools,
|
||||
supportsStreaming: true,
|
||||
contextLength: model.contextLength,
|
||||
maxOutputTokens: 8192,
|
||||
),
|
||||
reasoningProvider: provider == .minimaxCN ? "minimax-cn" : "minimax",
|
||||
reasoningBaseURL: baseURL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Third-Party Aggregators
|
||||
|
||||
@ -8,10 +8,10 @@ public enum ProviderParser {
|
||||
/// The provider name (e.g., "openai", "anthropic", "ollama")
|
||||
public let provider: String
|
||||
|
||||
/// The model name (e.g., "gpt-4", "claude-3", "llava:latest")
|
||||
/// The model name (e.g., "gpt-5.5", "claude-fable-5", "llava:latest")
|
||||
public let model: String
|
||||
|
||||
/// The full string representation (e.g., "openai/gpt-4")
|
||||
/// The full string representation (e.g., "openai/gpt-5.5")
|
||||
public var fullString: String {
|
||||
"\(self.provider)/\(self.model)"
|
||||
}
|
||||
@ -23,7 +23,7 @@ public enum ProviderParser {
|
||||
}
|
||||
|
||||
/// Parse a provider string in the format "provider/model"
|
||||
/// - Parameter providerString: String like "openai/gpt-4" or "ollama/llava:latest"
|
||||
/// - Parameter providerString: String like "openai/gpt-5.5" or "ollama/llava:latest"
|
||||
/// - Returns: Parsed configuration or nil if invalid format
|
||||
public static func parse(_ providerString: String) -> ProviderConfig? {
|
||||
// Parse a provider string in the format "provider/model"
|
||||
@ -44,7 +44,7 @@ public enum ProviderParser {
|
||||
}
|
||||
|
||||
/// Parse a comma-separated list of providers
|
||||
/// - Parameter providersString: String like "openai/gpt-4,anthropic/claude-3,ollama/llava:latest"
|
||||
/// - Parameter providersString: String like "openai/gpt-5.5,anthropic/claude-fable-5,ollama/llava:latest"
|
||||
/// - Returns: Array of parsed configurations
|
||||
public static func parseList(_ providersString: String) -> [ProviderConfig] {
|
||||
// Parse a comma-separated list of providers
|
||||
@ -54,7 +54,7 @@ public enum ProviderParser {
|
||||
}
|
||||
|
||||
/// Get the first provider from a comma-separated list
|
||||
/// - Parameter providersString: String like "openai/gpt-4,anthropic/claude-3"
|
||||
/// - Parameter providersString: String like "openai/gpt-5.5,anthropic/claude-fable-5"
|
||||
/// - Returns: First parsed configuration or nil if none valid
|
||||
public static func parseFirst(_ providersString: String) -> ProviderConfig? {
|
||||
// Get the first provider from a comma-separated list
|
||||
@ -94,6 +94,8 @@ public enum ProviderParser {
|
||||
/// - hasOpenAI: Whether OpenAI API key is available
|
||||
/// - hasAnthropic: Whether Anthropic API key is available
|
||||
/// - hasGrok: Whether Grok API key is available
|
||||
/// - hasGoogle: Whether Google/Gemini API key is available
|
||||
/// - hasMiniMax: Whether MiniMax API key is available
|
||||
/// - hasOllama: Whether Ollama is available (always true as it doesn't require API key)
|
||||
/// - configuredDefault: Optional default from configuration
|
||||
/// - isEnvironmentProvided: Whether the providers string came from environment variable
|
||||
@ -103,6 +105,8 @@ public enum ProviderParser {
|
||||
hasOpenAI: Bool,
|
||||
hasAnthropic: Bool,
|
||||
hasGrok: Bool = false,
|
||||
hasGoogle: Bool? = nil,
|
||||
hasMiniMax: Bool = false,
|
||||
hasOllama: Bool = true,
|
||||
configuredDefault: LanguageModel? = nil,
|
||||
isEnvironmentProvided: Bool = false,
|
||||
@ -113,14 +117,21 @@ public enum ProviderParser {
|
||||
let providers = self.parseList(providersString)
|
||||
var environmentModel: LanguageModel?
|
||||
|
||||
let canUseGoogleProvider = hasGoogle ?? true
|
||||
let canFallbackToGoogle = hasGoogle ?? false
|
||||
|
||||
for config in providers {
|
||||
switch config.provider.lowercased() {
|
||||
case "openai" where hasOpenAI:
|
||||
environmentModel = self.parseOpenAIModel(config.model)
|
||||
case "anthropic" where hasAnthropic:
|
||||
environmentModel = self.parseAnthropicModel(config.model)
|
||||
case "google", "gemini":
|
||||
case "google" where canUseGoogleProvider, "gemini" where canUseGoogleProvider:
|
||||
environmentModel = self.parseGoogleModel(config.model)
|
||||
case "minimax" where hasMiniMax:
|
||||
environmentModel = self.parseMiniMaxModel(config.model)
|
||||
case "minimax-cn" where hasMiniMax, "minimax_cn" where hasMiniMax, "minimaxi" where hasMiniMax:
|
||||
environmentModel = self.parseMiniMaxCNModel(config.model)
|
||||
case "grok" where hasGrok, "xai" where hasGrok:
|
||||
environmentModel = self.parseGrokModel(config.model)
|
||||
case "ollama" where hasOllama:
|
||||
@ -151,6 +162,8 @@ public enum ProviderParser {
|
||||
hasOpenAI: hasOpenAI,
|
||||
hasAnthropic: hasAnthropic,
|
||||
hasGrok: hasGrok,
|
||||
hasGoogle: canFallbackToGoogle,
|
||||
hasMiniMax: hasMiniMax,
|
||||
hasOllama: hasOllama,
|
||||
)
|
||||
}
|
||||
@ -169,6 +182,8 @@ public enum ProviderParser {
|
||||
hasOpenAI: Bool,
|
||||
hasAnthropic: Bool,
|
||||
hasGrok: Bool = false,
|
||||
hasGoogle: Bool? = nil,
|
||||
hasMiniMax: Bool = false,
|
||||
hasOllama: Bool = true,
|
||||
configuredDefault: LanguageModel? = nil,
|
||||
)
|
||||
@ -180,6 +195,8 @@ public enum ProviderParser {
|
||||
hasOpenAI: hasOpenAI,
|
||||
hasAnthropic: hasAnthropic,
|
||||
hasGrok: hasGrok,
|
||||
hasGoogle: hasGoogle,
|
||||
hasMiniMax: hasMiniMax,
|
||||
hasOllama: hasOllama,
|
||||
configuredDefault: configuredDefault,
|
||||
isEnvironmentProvided: false,
|
||||
@ -202,32 +219,38 @@ public enum ProviderParser {
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private static func parseOpenAIModel(_ modelString: String) -> LanguageModel? {
|
||||
switch modelString.lowercased() {
|
||||
case "o4-mini": .openai(.o4Mini)
|
||||
case "gpt-5.2", "gpt5.2", "gpt-5-2", "gpt5-2", "gpt52": .openai(.gpt52)
|
||||
case "gpt-5.2-mini", "gpt5.2-mini", "gpt-5-2-mini", "gpt5-2-mini", "gpt52-mini", "gpt52mini":
|
||||
let normalized = modelString.lowercased()
|
||||
let compact = normalized.replacingOccurrences(of: "-", with: "").replacingOccurrences(of: ".", with: "")
|
||||
guard
|
||||
!normalized.hasPrefix("gpt-4"), !compact.hasPrefix("gpt4"),
|
||||
!normalized.hasPrefix("gpt-3"), !compact.hasPrefix("gpt3"),
|
||||
!normalized.hasPrefix("o3"), !normalized.hasPrefix("o4"),
|
||||
!normalized.hasPrefix("gpt-5.1"), !compact.hasPrefix("gpt51"),
|
||||
!normalized.hasPrefix("gpt-5.2"), !compact.hasPrefix("gpt52"),
|
||||
!normalized.contains("gpt-5-thinking"), !compact.contains("gpt5thinking") else
|
||||
{
|
||||
return nil
|
||||
}
|
||||
|
||||
return switch normalized {
|
||||
case "chat-latest", "chatlatest":
|
||||
.openai(.chatLatest)
|
||||
case "gpt-5-chat-latest", "gpt5-chat-latest", "gpt5chatlatest":
|
||||
.openai(.gpt5ChatLatest)
|
||||
case "gpt-5.5", "gpt5.5", "gpt-5-5", "gpt5-5", "gpt55": .openai(.gpt55)
|
||||
case "gpt-5.5-mini", "gpt5.5-mini", "gpt-5-5-mini", "gpt5-5-mini", "gpt55-mini", "gpt55mini":
|
||||
.openai(.gpt5Mini)
|
||||
case "gpt-5.2-nano", "gpt5.2-nano", "gpt-5-2-nano", "gpt5-2-nano", "gpt52-nano", "gpt52nano":
|
||||
.openai(.gpt5Nano)
|
||||
case "gpt-5.1", "gpt5.1", "gpt-5-1", "gpt5-1", "gpt51": .openai(.gpt51)
|
||||
case "gpt-5.1-mini", "gpt5.1-mini", "gpt-5-1-mini", "gpt5-1-mini", "gpt51-mini", "gpt51mini":
|
||||
.openai(.gpt5Mini)
|
||||
case "gpt-5.1-nano", "gpt5.1-nano", "gpt-5-1-nano", "gpt5-1-nano", "gpt51-nano", "gpt51nano":
|
||||
case "gpt-5.5-nano", "gpt5.5-nano", "gpt-5-5-nano", "gpt5-5-nano", "gpt55-nano", "gpt55nano":
|
||||
.openai(.gpt5Nano)
|
||||
case "gpt-5.4", "gpt5.4", "gpt-5-4", "gpt5-4", "gpt54": .openai(.gpt54)
|
||||
case "gpt-5.4-mini", "gpt5.4-mini", "gpt-5-4-mini", "gpt5-4-mini", "gpt54-mini", "gpt54mini":
|
||||
.openai(.gpt54Mini)
|
||||
case "gpt-5.4-nano", "gpt5.4-nano", "gpt-5-4-nano", "gpt5-4-nano", "gpt54-nano", "gpt54nano":
|
||||
.openai(.gpt54Nano)
|
||||
case "gpt-5", "gpt5": .openai(.gpt5)
|
||||
case "gpt-5-pro", "gpt5-pro", "gpt5pro": .openai(.gpt5Pro)
|
||||
case "gpt-5-mini", "gpt5-mini", "gpt5mini": .openai(.gpt5Mini)
|
||||
case "gpt-5-nano", "gpt5-nano", "gpt5nano": .openai(.gpt5Nano)
|
||||
case "gpt-5-thinking", "gpt5-thinking", "gpt5thinking": .openai(.gpt5Thinking)
|
||||
case "gpt-5-thinking-mini", "gpt5-thinking-mini", "gpt5thinkingmini": .openai(.gpt5ThinkingMini)
|
||||
case "gpt-5-thinking-nano", "gpt5-thinking-nano", "gpt5thinkingnano": .openai(.gpt5ThinkingNano)
|
||||
case "gpt-5-chat-latest", "gpt5-chat-latest": .openai(.gpt5ChatLatest)
|
||||
case "gpt-4.1", "gpt4.1": .openai(.gpt41)
|
||||
case "gpt-4.1-mini", "gpt4.1-mini": .openai(.gpt41Mini)
|
||||
case "gpt-4o", "gpt4o": .openai(.gpt4o)
|
||||
case "gpt-4o-mini", "gpt4o-mini": .openai(.gpt4oMini)
|
||||
case "gpt-4-turbo", "gpt4-turbo": .openai(.gpt4Turbo)
|
||||
case "gpt-3.5-turbo", "gpt35-turbo": .openai(.gpt35Turbo)
|
||||
default:
|
||||
// Handle custom/fine-tuned models
|
||||
.openai(.custom(modelString))
|
||||
@ -235,15 +258,34 @@ public enum ProviderParser {
|
||||
}
|
||||
|
||||
private static func parseAnthropicModel(_ modelString: String) -> LanguageModel? {
|
||||
switch modelString.lowercased() {
|
||||
let normalized = modelString.lowercased()
|
||||
let compact = normalized.replacingOccurrences(of: "-", with: "").replacingOccurrences(of: ".", with: "")
|
||||
guard
|
||||
!normalized.hasPrefix("claude-3"), !compact.hasPrefix("claude3"),
|
||||
normalized != "claude-opus-4-20250514",
|
||||
normalized != "claude-sonnet-4-20250514",
|
||||
!normalized.contains("-thinking") else
|
||||
{
|
||||
return nil
|
||||
}
|
||||
|
||||
return switch normalized {
|
||||
case "claude-fable-5", "claude-fable-5-latest", "fable-5", "fable.5", "fable5", "fable":
|
||||
.anthropic(.fable5)
|
||||
case "claude-opus-4-8", "claude-opus-4.8", "claude-opus-4-8-latest", "opus-4-8", "opus-4.8",
|
||||
"opus48", "claude", "claude-latest", "claude_latest", "claudelatest", "claude-default", "claude_default":
|
||||
.anthropic(.opus48)
|
||||
case "claude-opus-4-7", "claude-opus-4.7", "claude-opus-4-7-latest", "opus-4-7", "opus-4.7", "opus47":
|
||||
.anthropic(.opus47)
|
||||
case "claude-opus-4-5", "claude-opus-4.5", "claude-opus-4-5-latest", "opus-4-5", "opus-4.5", "opus45":
|
||||
.anthropic(.opus45)
|
||||
case "claude-opus-4-1-20250805", "claude-opus-4-20250514", "claude-opus-4", "opus-4": .anthropic(.opus4)
|
||||
case "claude-opus-4-1-20250805-thinking", "claude-opus-4-20250514-thinking", "claude-opus-4-thinking",
|
||||
"opus-4-thinking": .anthropic(.opus4Thinking)
|
||||
case "claude-sonnet-4-20250514", "claude-sonnet-4", "sonnet-4": .anthropic(.sonnet4)
|
||||
case "claude-sonnet-4-20250514-thinking", "claude-sonnet-4-thinking",
|
||||
"sonnet-4-thinking": .anthropic(.sonnet4Thinking)
|
||||
case "claude-opus-4-1-20250805", "claude-opus-4", "opus-4": .anthropic(.opus4)
|
||||
case "claude-sonnet-4-6", "claude-sonnet-4.6", "sonnet-4-6", "sonnet-4.6", "sonnet46":
|
||||
.anthropic(.sonnet46)
|
||||
case "claude-sonnet-4-5-20250929", "claude-sonnet-4.5", "sonnet-4-5", "sonnet-4.5", "sonnet45":
|
||||
.anthropic(.sonnet45)
|
||||
case "claude-haiku-4-5-20251001", "claude-haiku-4-5", "claude-haiku-4.5", "haiku-4-5", "haiku45":
|
||||
.anthropic(.haiku45)
|
||||
default:
|
||||
// Handle custom models
|
||||
.anthropic(.custom(modelString))
|
||||
@ -252,6 +294,12 @@ public enum ProviderParser {
|
||||
|
||||
private static func parseGoogleModel(_ modelString: String) -> LanguageModel? {
|
||||
switch modelString.lowercased() {
|
||||
case "gemini-3.5-flash", "gemini35flash", "gemini-3-5-flash":
|
||||
.google(.gemini35Flash)
|
||||
case "gemini-3.1-pro-preview", "gemini-3.1-pro", "gemini31pro", "gemini31propreview":
|
||||
.google(.gemini31ProPreview)
|
||||
case "gemini-3.1-flash-lite", "gemini31flashlite", "gemini-3.1-flashlite":
|
||||
.google(.gemini31FlashLite)
|
||||
case "gemini-3-flash", "gemini-3-flash-preview", "gemini3flash", "gemini-3flash":
|
||||
.google(.gemini3Flash)
|
||||
case "gemini-2.5-pro", "gemini25pro", "gemini2.5pro":
|
||||
@ -261,7 +309,29 @@ public enum ProviderParser {
|
||||
case "gemini-2.5-flash-lite", "gemini25flashlite", "gemini-2.5-flashlite":
|
||||
.google(.gemini25FlashLite)
|
||||
case "gemini":
|
||||
.google(.gemini3Flash)
|
||||
.google(.gemini35Flash)
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseMiniMaxModel(_ modelString: String) -> LanguageModel? {
|
||||
switch modelString.lowercased() {
|
||||
case "minimax-m2.7", "minimax-m2-7", "m2.7", "m2-7":
|
||||
.minimax(.m27)
|
||||
case "minimax-m2.7-highspeed", "minimax-m2-7-highspeed", "m2.7-highspeed", "m2-7-highspeed":
|
||||
.minimax(.m27Highspeed)
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseMiniMaxCNModel(_ modelString: String) -> LanguageModel? {
|
||||
switch modelString.lowercased() {
|
||||
case "minimax-m2.7", "minimax-m2-7", "m2.7", "m2-7":
|
||||
.minimaxCN(.m27)
|
||||
case "minimax-m2.7-highspeed", "minimax-m2-7-highspeed", "m2.7-highspeed", "m2-7-highspeed":
|
||||
.minimaxCN(.m27Highspeed)
|
||||
default:
|
||||
nil
|
||||
}
|
||||
@ -269,22 +339,27 @@ public enum ProviderParser {
|
||||
|
||||
private static func parseGrokModel(_ modelString: String) -> LanguageModel? {
|
||||
switch modelString.lowercased() {
|
||||
case "grok-4-0709": .grok(.grok4)
|
||||
case "grok-4-fast-reasoning": .grok(.grok4FastReasoning)
|
||||
case "grok-4-fast-non-reasoning": .grok(.grok4FastNonReasoning)
|
||||
case "grok-code-fast-1": .grok(.grokCodeFast1)
|
||||
case "grok-3", "grok3": .grok(.grok3)
|
||||
case "grok-3-mini": .grok(.grok3Mini)
|
||||
case "grok-2-1212": .grok(.grok2)
|
||||
case "grok-2-vision-1212": .grok(.grok2Vision)
|
||||
case "grok-2-image-1212": .grok(.grok2Image)
|
||||
case "grok-vision-beta": .grok(.grokVisionBeta)
|
||||
case "grok-beta": .grok(.grokBeta)
|
||||
case "grok-4.3", "grok-4-3", "grok43", "grok-4.3-latest", "grok-4-latest", "grok-4", "grok-latest":
|
||||
return .grok(.grok43)
|
||||
case "grok-4.20-0309-reasoning", "grok-4-20-0309-reasoning":
|
||||
return .grok(.grok420Reasoning)
|
||||
case "grok-4.20-0309-non-reasoning", "grok-4-20-0309-non-reasoning":
|
||||
return .grok(.grok420NonReasoning)
|
||||
default:
|
||||
.grok(.custom(modelString))
|
||||
if self.isUnsupportedLegacyGrokModel(modelString) {
|
||||
return nil
|
||||
}
|
||||
return .grok(.custom(modelString))
|
||||
}
|
||||
}
|
||||
|
||||
private static func isUnsupportedLegacyGrokModel(_ modelString: String) -> Bool {
|
||||
let normalized = modelString.lowercased()
|
||||
return normalized.contains("grok-4.20-multi-agent") ||
|
||||
normalized.contains("grok-4-20-multi-agent") ||
|
||||
normalized.contains("grok420multiagent")
|
||||
}
|
||||
|
||||
private static func parseOllamaModel(_ modelString: String) -> LanguageModel? {
|
||||
switch modelString.lowercased() {
|
||||
// GPT-OSS models
|
||||
@ -300,7 +375,6 @@ public enum ProviderParser {
|
||||
case "llava:34b": .ollama(.custom("llava:34b"))
|
||||
case "mistral-nemo", "mistral-nemo:latest": .ollama(.mistralNemo)
|
||||
case "qwen2.5", "qwen2.5:latest": .ollama(.qwen25)
|
||||
case "codellama", "codellama:latest": .ollama(.codellama)
|
||||
default:
|
||||
.ollama(.custom(modelString))
|
||||
}
|
||||
@ -310,16 +384,22 @@ public enum ProviderParser {
|
||||
hasOpenAI: Bool,
|
||||
hasAnthropic: Bool,
|
||||
hasGrok: Bool,
|
||||
hasGoogle: Bool,
|
||||
hasMiniMax: Bool,
|
||||
hasOllama _: Bool,
|
||||
)
|
||||
-> LanguageModel
|
||||
{
|
||||
if hasAnthropic {
|
||||
.anthropic(.opus4)
|
||||
.anthropic(.opus48)
|
||||
} else if hasOpenAI {
|
||||
.openai(.gpt5Mini)
|
||||
.openai(.gpt55)
|
||||
} else if hasGrok {
|
||||
.grok(.grok4FastReasoning)
|
||||
.grok(.grok43)
|
||||
} else if hasGoogle {
|
||||
.google(.gemini35Flash)
|
||||
} else if hasMiniMax {
|
||||
.minimax(.m27)
|
||||
} else {
|
||||
.ollama(.llama33)
|
||||
}
|
||||
|
||||
@ -27,12 +27,13 @@ public final class TogetherProvider: ModelProvider {
|
||||
throw TachikomaError.authenticationFailed("TOGETHER_API_KEY not found")
|
||||
}
|
||||
|
||||
let isFable = LanguageModel.Anthropic.isFable(modelId: modelId)
|
||||
self.capabilities = ModelCapabilities(
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
supportsStreaming: true,
|
||||
contextLength: 128_000,
|
||||
maxOutputTokens: 4096,
|
||||
supportsStreaming: !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId),
|
||||
contextLength: isFable ? 1_000_000 : 128_000,
|
||||
maxOutputTokens: isFable ? 128_000 : 4096,
|
||||
)
|
||||
}
|
||||
|
||||
@ -55,6 +56,9 @@ public final class TogetherProvider: ModelProvider {
|
||||
guard let baseURL, let apiKey else {
|
||||
throw TachikomaError.invalidConfiguration("Together provider missing base URL or API key")
|
||||
}
|
||||
guard !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: self.modelId) else {
|
||||
throw TachikomaError.invalidConfiguration("\(self.modelId) does not support streaming")
|
||||
}
|
||||
|
||||
return try await OpenAICompatibleHelper.streamText(
|
||||
request: request,
|
||||
|
||||
@ -6,18 +6,44 @@ import UIKit
|
||||
// MARK: - Cache Key
|
||||
|
||||
/// Hashable key for cache entries
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public struct CacheProviderIdentity: Hashable, Sendable {
|
||||
public let providerKind: String
|
||||
public let modelId: String
|
||||
public let endpointIdentity: String?
|
||||
|
||||
public init(providerKind: String, modelId: String, baseURL: String?) {
|
||||
self.providerKind = providerKind
|
||||
self.modelId = modelId
|
||||
self.endpointIdentity = ReasoningEndpointIdentity.canonical(baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
struct CacheKey: Hashable {
|
||||
let hash: String
|
||||
let model: String? // Store model ID for invalidation
|
||||
let isCacheable: Bool
|
||||
|
||||
init(from request: ProviderRequest, model: String? = nil) {
|
||||
self.model = model
|
||||
init(from request: ProviderRequest, providerIdentity: CacheProviderIdentity? = nil, model: String? = nil) {
|
||||
self.model = providerIdentity?.modelId ?? model
|
||||
// Create a unique hash from the request
|
||||
var hasher = Hasher()
|
||||
if let providerIdentity {
|
||||
hasher.combine(providerIdentity.providerKind)
|
||||
hasher.combine(providerIdentity.modelId)
|
||||
hasher.combine(providerIdentity.endpointIdentity)
|
||||
}
|
||||
// Combine message content
|
||||
for message in request.messages {
|
||||
hasher.combine(message.role.rawValue)
|
||||
hasher.combine(message.channel?.rawValue)
|
||||
hasher.combine(message.metadata?.conversationId)
|
||||
hasher.combine(message.metadata?.turnId)
|
||||
for key in message.metadata?.customData?.keys.sorted() ?? [] {
|
||||
hasher.combine(key)
|
||||
hasher.combine(message.metadata?.customData?[key])
|
||||
}
|
||||
for part in message.content {
|
||||
switch part {
|
||||
case let .text(text):
|
||||
@ -41,8 +67,32 @@ struct CacheKey: Hashable {
|
||||
hasher.combine(request.settings.temperature)
|
||||
hasher.combine(request.settings.maxTokens)
|
||||
hasher.combine(request.settings.topP)
|
||||
hasher.combine(request.settings.topK)
|
||||
hasher.combine(request.settings.frequencyPenalty)
|
||||
hasher.combine(request.settings.presencePenalty)
|
||||
hasher.combine(request.settings.stopSequences)
|
||||
hasher.combine(request.settings.reasoningEffort?.rawValue)
|
||||
hasher.combine(request.settings.seed)
|
||||
if let stopConditions = request.settings.stopConditions {
|
||||
guard let cacheKey = (stopConditions as? StableCacheKeyStopCondition)?.stableCacheKey else {
|
||||
self.hash = ""
|
||||
self.isCacheable = false
|
||||
return
|
||||
}
|
||||
hasher.combine(cacheKey)
|
||||
}
|
||||
if let providerOptionsData = try? Self.providerOptionsEncoder.encode(request.settings.providerOptions) {
|
||||
hasher.combine(providerOptionsData)
|
||||
}
|
||||
self.hash = String(hasher.finalize())
|
||||
self.isCacheable = true
|
||||
}
|
||||
|
||||
private static let providerOptionsEncoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
return encoder
|
||||
}()
|
||||
}
|
||||
|
||||
// MARK: - Response Cache
|
||||
@ -75,11 +125,16 @@ public actor ResponseCache {
|
||||
public func get(
|
||||
for request: ProviderRequest,
|
||||
ttlOverride: TimeInterval? = nil,
|
||||
providerIdentity: CacheProviderIdentity? = nil,
|
||||
)
|
||||
-> ProviderResponse?
|
||||
{
|
||||
// Get cached response with TTL validation
|
||||
let key = CacheKey(from: request)
|
||||
let key = CacheKey(from: request, providerIdentity: providerIdentity)
|
||||
guard key.isCacheable else {
|
||||
self.statistics.recordMiss()
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let entry = cache[key] else {
|
||||
self.statistics.recordMiss()
|
||||
@ -109,9 +164,13 @@ public actor ResponseCache {
|
||||
for request: ProviderRequest,
|
||||
ttl: TimeInterval? = nil,
|
||||
priority: CachePriority = .normal,
|
||||
providerIdentity: CacheProviderIdentity? = nil,
|
||||
) {
|
||||
// Store response with custom TTL and priority
|
||||
let key = CacheKey(from: request)
|
||||
let key = CacheKey(from: request, providerIdentity: providerIdentity)
|
||||
guard key.isCacheable else {
|
||||
return
|
||||
}
|
||||
|
||||
// Check memory limit
|
||||
if self.shouldEvictForMemory() {
|
||||
@ -457,9 +516,33 @@ final class CacheEntry: @unchecked Sendable {
|
||||
// Rough estimation based on response content
|
||||
let textSize = self.response.text.utf8.count
|
||||
let toolCallsSize = (response.toolCalls?.count ?? 0) * 100 // Estimate 100 bytes per tool call
|
||||
let reasoningSize = self.response.reasoning.reduce(0) { total, block in
|
||||
total + block.text.utf8.count +
|
||||
(block.signature?.utf8.count ?? 0) +
|
||||
(block.rawJSON?.utf8.count ?? 0) +
|
||||
block.type.utf8.count
|
||||
}
|
||||
let assistantMessageSize = self.response.assistantMessages.reduce(0) { total, message in
|
||||
let contentSize = message.content.reduce(0) { contentTotal, part in
|
||||
switch part {
|
||||
case let .text(text):
|
||||
contentTotal + text.utf8.count
|
||||
case let .image(image):
|
||||
contentTotal + image.mimeType.utf8.count + image.data.utf8.count
|
||||
case let .toolCall(call):
|
||||
contentTotal + call.id.utf8.count + call.name.utf8.count + 100
|
||||
case let .toolResult(result):
|
||||
contentTotal + result.toolCallId.utf8.count + 100
|
||||
}
|
||||
}
|
||||
let metadataSize = (message.metadata?.customData ?? [:]).reduce(0) { metadataTotal, pair in
|
||||
metadataTotal + pair.key.utf8.count + pair.value.utf8.count
|
||||
}
|
||||
return total + contentSize + metadataSize + (message.channel?.rawValue.utf8.count ?? 0)
|
||||
}
|
||||
let usageSize = 50 // Fixed overhead for usage data
|
||||
|
||||
return textSize + toolCallsSize + usageSize + 100 // 100 bytes overhead
|
||||
return textSize + toolCallsSize + reasoningSize + assistantMessageSize + usageSize + 100
|
||||
}
|
||||
}
|
||||
|
||||
@ -546,6 +629,7 @@ extension ResponseCache {
|
||||
public struct CacheAwareProvider<Base: ModelProvider>: ModelProvider {
|
||||
let provider: Base
|
||||
let cache: ResponseCache
|
||||
private let providerIdentity: CacheProviderIdentity
|
||||
|
||||
public var modelId: String {
|
||||
self.provider.modelId
|
||||
@ -563,11 +647,21 @@ public struct CacheAwareProvider<Base: ModelProvider>: ModelProvider {
|
||||
self.provider.capabilities
|
||||
}
|
||||
|
||||
init(provider: Base, cache: ResponseCache) {
|
||||
self.provider = provider
|
||||
self.cache = cache
|
||||
self.providerIdentity = CacheProviderIdentity(
|
||||
providerKind: String(reflecting: Base.self),
|
||||
modelId: provider.modelId,
|
||||
baseURL: provider.baseURL,
|
||||
)
|
||||
}
|
||||
|
||||
public func generateText(request: ProviderRequest) async throws -> ProviderResponse {
|
||||
// Check cache with smart TTL based on request type
|
||||
let ttl = self.determineTTL(for: request)
|
||||
|
||||
if let cached = await cache.get(for: request, ttlOverride: ttl) {
|
||||
if let cached = await cache.get(for: request, ttlOverride: ttl, providerIdentity: self.providerIdentity) {
|
||||
return cached
|
||||
}
|
||||
|
||||
@ -575,7 +669,13 @@ public struct CacheAwareProvider<Base: ModelProvider>: ModelProvider {
|
||||
let response = try await provider.generateText(request: request)
|
||||
let priority = self.determinePriority(for: request)
|
||||
|
||||
await self.cache.store(response, for: request, ttl: ttl, priority: priority)
|
||||
await self.cache.store(
|
||||
response,
|
||||
for: request,
|
||||
ttl: ttl,
|
||||
priority: priority,
|
||||
providerIdentity: self.providerIdentity,
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
@ -528,39 +528,38 @@ public struct ModelCostCalculator: Sendable {
|
||||
// OpenAI Pricing (as of 2025)
|
||||
case let .openai(openaiModel):
|
||||
switch openaiModel {
|
||||
case .o4Mini: (1.50, 6.00)
|
||||
case .gpt52: (5.00, 20.00) // GPT-5.2 pricing estimate
|
||||
case .gpt51: (5.00, 20.00) // GPT-5.1 pricing estimate
|
||||
case .chatLatest: (5.00, 30.00) // ChatGPT Instant alias pricing estimate
|
||||
case .gpt5ChatLatest: (1.25, 10.00)
|
||||
case .gpt55: (5.00, 20.00) // GPT-5.5 pricing estimate
|
||||
case .gpt54: (5.00, 20.00) // GPT-5.4 pricing estimate
|
||||
case .gpt54Mini: (1.00, 4.00)
|
||||
case .gpt54Nano: (0.50, 2.00)
|
||||
case .gpt5: (5.00, 20.00) // GPT-5 pricing estimate
|
||||
case .gpt5Pro: (12.00, 48.00) // Higher reasoning budget
|
||||
case .gpt5Mini: (1.00, 4.00) // GPT-5 Mini pricing estimate
|
||||
case .gpt5Nano: (0.50, 2.00) // GPT-5 Nano pricing estimate
|
||||
case .gpt5Thinking: (16.00, 64.00) // Extended reasoning premium
|
||||
case .gpt5ThinkingMini: (4.00, 16.00)
|
||||
case .gpt5ThinkingNano: (1.50, 6.00)
|
||||
case .gpt5ChatLatest: (2.50, 10.00)
|
||||
case .gpt41: (2.50, 10.00)
|
||||
case .gpt41Mini: (0.15, 0.60)
|
||||
case .gpt4o: (2.50, 10.00)
|
||||
case .gpt4oMini: (0.15, 0.60)
|
||||
case .gpt4oRealtime: (5.00, 20.00) // Realtime API pricing estimate
|
||||
case .gpt4Turbo: (10.00, 30.00)
|
||||
case .gpt35Turbo: (0.50, 1.50)
|
||||
case .custom: (2.50, 10.00) // Default estimate
|
||||
}
|
||||
// Anthropic Pricing (as of 2025)
|
||||
// Anthropic Pricing (as of 2026)
|
||||
case let .anthropic(anthropicModel):
|
||||
switch anthropicModel {
|
||||
case .fable5: (10.00, 50.00)
|
||||
case .opus48: (5.00, 25.00)
|
||||
case .opus47: (5.00, 25.00)
|
||||
case .opus45: (5.00, 25.00)
|
||||
case .opus4, .opus4Thinking: (15.00, 75.00)
|
||||
case .sonnet4, .sonnet4Thinking: (3.00, 15.00)
|
||||
case .opus4: (15.00, 75.00)
|
||||
case .sonnet46: (3.00, 15.00)
|
||||
case .sonnet45: (4.00, 18.00)
|
||||
case .haiku45: (1.20, 6.00)
|
||||
case .custom: (3.00, 15.00) // Default estimate
|
||||
case let .custom(id):
|
||||
id.lowercased().contains("claude-fable-5") ? (10.00, 50.00) : (3.00, 15.00)
|
||||
}
|
||||
// Google Pricing (estimates)
|
||||
// Google Pricing (standard tier, as of 2026)
|
||||
case let .google(googleModel):
|
||||
switch googleModel {
|
||||
case .gemini35Flash: (1.50, 9.00)
|
||||
case .gemini31ProPreview: (1.25, 10.00)
|
||||
case .gemini31FlashLite: (0.10, 0.40)
|
||||
case .gemini3Flash: (0.50, 3.00)
|
||||
case .gemini25Pro: (1.25, 10.00)
|
||||
case .gemini25Flash: (0.30, 2.50)
|
||||
@ -569,7 +568,12 @@ public struct ModelCostCalculator: Sendable {
|
||||
// Other providers - estimates
|
||||
case .mistral: (2.00, 6.00)
|
||||
case .groq: (0.27, 0.27) // Groq has very low pricing
|
||||
case .grok: (2.00, 8.00)
|
||||
case let .grok(grokModel):
|
||||
switch grokModel {
|
||||
case .grok43, .grok420MultiAgent, .grok420Reasoning, .grok420NonReasoning: (1.25, 2.50)
|
||||
case .custom: (2.00, 8.00)
|
||||
}
|
||||
case .minimax, .minimaxCN: (0.30, 1.20)
|
||||
case .ollama: (0.00, 0.00) // Local inference
|
||||
case .lmstudio: (0.00, 0.00) // Local inference
|
||||
case .openRouter, .together, .replicate: (1.00, 3.00) // Typical aggregator pricing
|
||||
|
||||
@ -26,32 +26,43 @@ public final class Agent<Context>: @unchecked Sendable {
|
||||
public private(set) var tools: [AgentTool]
|
||||
|
||||
/// Language model used by this agent
|
||||
public var model: LanguageModel
|
||||
public var model: LanguageModel {
|
||||
didSet {
|
||||
self.usesImplicitDefaultModel = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Generation settings for the agent
|
||||
public var settings: GenerationSettings
|
||||
|
||||
/// Provider configuration for generation and streaming
|
||||
private let configuration: TachikomaConfiguration
|
||||
|
||||
/// The context instance passed to tool executions
|
||||
private let context: Context
|
||||
|
||||
/// Current conversation history
|
||||
public private(set) var conversation: Conversation
|
||||
private var usesImplicitDefaultModel: Bool
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
instructions: String,
|
||||
model: LanguageModel = .default,
|
||||
model: LanguageModel? = nil,
|
||||
tools: [AgentTool] = [],
|
||||
settings: GenerationSettings = .default,
|
||||
configuration: TachikomaConfiguration = .current,
|
||||
context: Context,
|
||||
) {
|
||||
self.name = name
|
||||
self.instructions = instructions
|
||||
self.model = model
|
||||
self.usesImplicitDefaultModel = model == nil
|
||||
self.model = model ?? .default
|
||||
self.tools = tools
|
||||
self.settings = settings
|
||||
self.configuration = configuration
|
||||
self.context = context
|
||||
self.conversation = Conversation()
|
||||
self.conversation = Conversation(configuration: configuration)
|
||||
|
||||
// Add system message with instructions
|
||||
self.conversation.addSystemMessage(instructions)
|
||||
@ -71,66 +82,133 @@ public final class Agent<Context>: @unchecked Sendable {
|
||||
|
||||
/// Execute a single message with the agent
|
||||
public func execute(_ message: String) async throws -> AgentResponse {
|
||||
// Add user message to conversation
|
||||
self.conversation.addUserMessage(message)
|
||||
let conversation = self.conversation
|
||||
let model = self.model
|
||||
let tools = self.tools
|
||||
let settings = self.settings
|
||||
|
||||
// Generate response using the conversation
|
||||
let result = try await generateText(
|
||||
model: model,
|
||||
messages: conversation.getModelMessages(),
|
||||
tools: self.tools.isEmpty ? nil : self.tools,
|
||||
settings: self.settings,
|
||||
maxSteps: 5, // Allow multi-step tool execution
|
||||
)
|
||||
return try await conversation.withContinuationLock {
|
||||
conversation.addUserMessage(message)
|
||||
let conversationMessages = conversation.messages
|
||||
let modelMessages = conversationMessages.map { $0.toModelMessage() }
|
||||
let snapshotIDs = conversationMessages.map(\.id)
|
||||
let anchorID = conversationMessages.last?.id
|
||||
let result = try await generateText(
|
||||
model: model,
|
||||
messages: modelMessages,
|
||||
tools: tools.isEmpty ? nil : tools,
|
||||
settings: settings,
|
||||
maxSteps: 5, // Allow multi-step tool execution
|
||||
configuration: self.configuration,
|
||||
)
|
||||
|
||||
// Add assistant response to conversation
|
||||
self.conversation.addAssistantMessage(result.text)
|
||||
|
||||
// Add any tool calls and results to conversation
|
||||
for step in result.steps {
|
||||
if !step.toolCalls.isEmpty {
|
||||
for _ in step.toolCalls {
|
||||
// Tool calls are already added by generateText
|
||||
let didMerge: Bool
|
||||
if result.finishReason == .contentFilter {
|
||||
didMerge = conversation.mergeContentFilterResult(
|
||||
result.messages,
|
||||
originalMessages: modelMessages,
|
||||
afterMessageID: anchorID,
|
||||
validatingSnapshotIDs: snapshotIDs,
|
||||
)
|
||||
} else if let anchorID {
|
||||
let generatedMessages = Array(result.messages.dropFirst(modelMessages.count))
|
||||
let didMerge = conversation.appendGeneratedMessages(
|
||||
generatedMessages,
|
||||
afterMessageID: anchorID,
|
||||
validatingSnapshotIDs: snapshotIDs,
|
||||
)
|
||||
guard didMerge else {
|
||||
throw TachikomaError.invalidConfiguration(
|
||||
"Conversation changed during generation; refusing to merge response",
|
||||
)
|
||||
}
|
||||
return AgentResponse(
|
||||
text: result.text,
|
||||
usage: result.usage,
|
||||
finishReason: result.finishReason ?? .other,
|
||||
steps: result.steps,
|
||||
conversationLength: conversation.messages.count,
|
||||
)
|
||||
} else {
|
||||
didMerge = conversation.messages.isEmpty
|
||||
}
|
||||
if !step.toolResults.isEmpty {
|
||||
for _ in step.toolResults {
|
||||
// Tool results are already added by generateText
|
||||
}
|
||||
|
||||
guard didMerge else {
|
||||
throw TachikomaError.invalidConfiguration(
|
||||
"Conversation changed during generation; refusing to merge response",
|
||||
)
|
||||
}
|
||||
|
||||
return AgentResponse(
|
||||
text: result.text,
|
||||
usage: result.usage,
|
||||
finishReason: result.finishReason ?? .other,
|
||||
steps: result.steps,
|
||||
conversationLength: conversation.messages.count,
|
||||
)
|
||||
}
|
||||
|
||||
return AgentResponse(
|
||||
text: result.text,
|
||||
usage: result.usage,
|
||||
finishReason: result.finishReason ?? .other,
|
||||
steps: result.steps,
|
||||
conversationLength: self.conversation.messages.count,
|
||||
)
|
||||
}
|
||||
|
||||
/// Stream a response from the agent
|
||||
public func stream(_ message: String) async throws -> AsyncThrowingStream<TextStreamDelta, Error> {
|
||||
let streamingModel = if self.usesImplicitDefaultModel {
|
||||
LanguageModel.defaultStreaming
|
||||
} else if self.model.supportsStreaming {
|
||||
self.model
|
||||
} else {
|
||||
self.model
|
||||
}
|
||||
guard streamingModel.supportsStreaming else {
|
||||
throw TachikomaError.invalidConfiguration("\(self.model.modelId) does not support streaming")
|
||||
}
|
||||
|
||||
let conversation = self.conversation
|
||||
try await conversation.acquireContinuationLock()
|
||||
let gateRelease = AsyncReleaseOnce {
|
||||
await conversation.releaseContinuationLock()
|
||||
}
|
||||
|
||||
// Add user message to conversation
|
||||
self.conversation.addUserMessage(message)
|
||||
conversation.addUserMessage(message)
|
||||
let conversationMessages = conversation.messages
|
||||
let modelMessages = conversationMessages.map { $0.toModelMessage() }
|
||||
let snapshotIDs = conversationMessages.map(\.id)
|
||||
let buffersUntilDone = self.settings.streamBuffering == .untilTerminal
|
||||
|
||||
// Stream response
|
||||
let streamResult = try await streamText(
|
||||
model: model,
|
||||
messages: conversation.getModelMessages(),
|
||||
tools: self.tools.isEmpty ? nil : self.tools,
|
||||
settings: self.settings,
|
||||
maxSteps: 5,
|
||||
)
|
||||
let streamResult: StreamTextResult
|
||||
do {
|
||||
streamResult = try await streamText(
|
||||
model: streamingModel,
|
||||
messages: modelMessages,
|
||||
tools: self.tools.isEmpty ? nil : self.tools,
|
||||
settings: self.settings,
|
||||
maxSteps: 5,
|
||||
configuration: self.configuration,
|
||||
)
|
||||
} catch {
|
||||
gateRelease.release()
|
||||
throw error
|
||||
}
|
||||
|
||||
// Track final message in conversation (this is approximate for streaming)
|
||||
return AsyncThrowingStream<TextStreamDelta, Error> { continuation in
|
||||
Task {
|
||||
let producer = Task {
|
||||
defer {
|
||||
gateRelease.release()
|
||||
}
|
||||
do {
|
||||
var assistantText = ""
|
||||
var bufferedDeltas: [TextStreamDelta] = []
|
||||
var didReceiveTerminal = false
|
||||
|
||||
for try await delta in streamResult.stream {
|
||||
continuation.yield(delta)
|
||||
try Task.checkCancellation()
|
||||
if buffersUntilDone {
|
||||
bufferedDeltas.append(delta)
|
||||
} else {
|
||||
continuation.yield(delta)
|
||||
}
|
||||
|
||||
// Collect assistant text
|
||||
if case .textDelta = delta.type, let content = delta.content {
|
||||
@ -138,25 +216,60 @@ public final class Agent<Context>: @unchecked Sendable {
|
||||
}
|
||||
|
||||
if case .done = delta.type {
|
||||
didReceiveTerminal = true
|
||||
guard delta.finishReason != .contentFilter else {
|
||||
let didRollback = conversation.replaceModelMessages(
|
||||
modelMessages.droppingLastUserTurn(),
|
||||
validatingSnapshotIDs: snapshotIDs,
|
||||
)
|
||||
guard didRollback else {
|
||||
throw TachikomaError.invalidConfiguration(
|
||||
"Conversation changed during streaming; refusing to merge response",
|
||||
)
|
||||
}
|
||||
assistantText = ""
|
||||
bufferedDeltas.removeAll()
|
||||
if buffersUntilDone {
|
||||
continuation.yield(delta)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if buffersUntilDone {
|
||||
for bufferedDelta in bufferedDeltas {
|
||||
continuation.yield(bufferedDelta)
|
||||
}
|
||||
bufferedDeltas.removeAll()
|
||||
}
|
||||
// Add final assistant message to conversation
|
||||
if !assistantText.isEmpty {
|
||||
self.conversation.addAssistantMessage(assistantText)
|
||||
conversation.addAssistantMessage(assistantText)
|
||||
assistantText = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if buffersUntilDone, !didReceiveTerminal, !bufferedDeltas.isEmpty {
|
||||
throw TachikomaError.apiError("Stream ended before provider completion status was received")
|
||||
}
|
||||
if !buffersUntilDone, !assistantText.isEmpty {
|
||||
try Task.checkCancellation()
|
||||
conversation.addAssistantMessage(assistantText)
|
||||
}
|
||||
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
producer.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the agent's conversation history
|
||||
public func resetConversation() {
|
||||
// Reset the agent's conversation history
|
||||
self.conversation = Conversation()
|
||||
self.conversation = Conversation(configuration: self.configuration)
|
||||
self.conversation.addSystemMessage(self.instructions)
|
||||
}
|
||||
|
||||
@ -169,7 +282,7 @@ public final class Agent<Context>: @unchecked Sendable {
|
||||
public func updateInstructions(_ newInstructions: String) {
|
||||
// Create new conversation with updated instructions
|
||||
let oldMessages = self.conversation.getModelMessages().filter { $0.role != .system }
|
||||
self.conversation = Conversation()
|
||||
self.conversation = Conversation(configuration: self.configuration)
|
||||
self.conversation.addSystemMessage(newInstructions)
|
||||
|
||||
// Re-add non-system messages
|
||||
@ -179,6 +292,30 @@ public final class Agent<Context>: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
final class AsyncReleaseOnce: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var didRelease = false
|
||||
private let operation: @Sendable () async -> Void
|
||||
|
||||
init(operation: @escaping @Sendable () async -> Void) {
|
||||
self.operation = operation
|
||||
}
|
||||
|
||||
func release() {
|
||||
self.lock.lock()
|
||||
guard !self.didRelease else {
|
||||
self.lock.unlock()
|
||||
return
|
||||
}
|
||||
self.didRelease = true
|
||||
self.lock.unlock()
|
||||
|
||||
Task {
|
||||
await self.operation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response from an agent execution
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public struct AgentResponse: Sendable {
|
||||
@ -537,3 +674,33 @@ public enum SessionStatus: String, Codable, Sendable, CaseIterable {
|
||||
case failed
|
||||
case cancelled
|
||||
}
|
||||
|
||||
extension LanguageModel {
|
||||
var requiresTerminalRefusalBuffering: Bool {
|
||||
switch self {
|
||||
case let .anthropic(model):
|
||||
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: model.modelId)
|
||||
case let .anthropicCompatible(modelId, _):
|
||||
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
|
||||
case let .openRouter(modelId), let .together(modelId):
|
||||
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
|
||||
case let .openaiCompatible(modelId, _):
|
||||
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
|
||||
case let .custom(provider):
|
||||
guard
|
||||
let parsed = ProviderParser.parse(provider.modelId),
|
||||
let registeredProvider = CustomProviderRegistry.shared.get(parsed.provider) else
|
||||
{
|
||||
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: provider.modelId)
|
||||
}
|
||||
switch registeredProvider.kind {
|
||||
case .openai:
|
||||
return false
|
||||
case .anthropic:
|
||||
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: parsed.model)
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,10 +3,66 @@ import Tachikoma
|
||||
|
||||
// MARK: - Conversation Management
|
||||
|
||||
private actor ContinuationGate {
|
||||
private struct Waiter {
|
||||
let id: UUID
|
||||
let continuation: CheckedContinuation<Bool, Never>
|
||||
}
|
||||
|
||||
private var isLocked = false
|
||||
private var waiters: [Waiter] = []
|
||||
|
||||
func acquire() async throws {
|
||||
try Task.checkCancellation()
|
||||
|
||||
if !self.isLocked {
|
||||
self.isLocked = true
|
||||
return
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
let acquired = await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.waiters.append(Waiter(id: id, continuation: continuation))
|
||||
}
|
||||
} onCancel: {
|
||||
Task { await self.cancelWaiter(id) }
|
||||
}
|
||||
|
||||
guard acquired else {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
if Task.isCancelled {
|
||||
self.release()
|
||||
throw CancellationError()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelWaiter(_ id: UUID) {
|
||||
guard let index = self.waiters.firstIndex(where: { $0.id == id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let waiter = self.waiters.remove(at: index)
|
||||
waiter.continuation.resume(returning: false)
|
||||
}
|
||||
|
||||
func release() {
|
||||
if self.waiters.isEmpty {
|
||||
self.isLocked = false
|
||||
} else {
|
||||
let waiter = self.waiters.removeFirst()
|
||||
waiter.continuation.resume(returning: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A conversation with an AI model
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public final class Conversation: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let continuationGate = ContinuationGate()
|
||||
private var _messages: [ConversationMessage] = []
|
||||
|
||||
/// The configuration used by this conversation
|
||||
@ -59,7 +115,6 @@ public final class Conversation: @unchecked Sendable {
|
||||
|
||||
/// Get messages as ModelMessage array for API compatibility
|
||||
public func getModelMessages() -> [ModelMessage] {
|
||||
// Get messages as ModelMessage array for API compatibility
|
||||
self.messages.map { $0.toModelMessage() }
|
||||
}
|
||||
|
||||
@ -72,31 +127,193 @@ public final class Conversation: @unchecked Sendable {
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
/// Continue the conversation with a model
|
||||
public func continueConversation(using model: Model? = nil, tools _: [AgentTool]? = nil) async throws -> String {
|
||||
// Convert conversation messages to model messages
|
||||
let modelMessages = self.messages.map { conversationMessage in
|
||||
ModelMessage(
|
||||
id: conversationMessage.id,
|
||||
role: ModelMessage.Role(rawValue: conversationMessage.role.rawValue) ?? .user,
|
||||
content: [.text(conversationMessage.content)],
|
||||
timestamp: conversationMessage.timestamp,
|
||||
/// Replace the conversation with lossless ModelMessage history.
|
||||
public func replaceModelMessages(_ modelMessages: [ModelMessage]) {
|
||||
self.lock.lock()
|
||||
self._messages = modelMessages.map { ConversationMessage.from($0) }
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
/// Replace the conversation only if the original snapshot is still current.
|
||||
public func replaceModelMessages(
|
||||
_ modelMessages: [ModelMessage],
|
||||
validatingSnapshotIDs snapshotIDs: [String],
|
||||
)
|
||||
-> Bool
|
||||
{
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
|
||||
guard self._messages.count >= snapshotIDs.count else {
|
||||
return false
|
||||
}
|
||||
|
||||
let currentPrefixIDs = self._messages.prefix(snapshotIDs.count).map(\.id)
|
||||
guard currentPrefixIDs == snapshotIDs else {
|
||||
return false
|
||||
}
|
||||
|
||||
let laterMessages = self._messages.dropFirst(snapshotIDs.count)
|
||||
self._messages = modelMessages.map { ConversationMessage.from($0) } + laterMessages
|
||||
return true
|
||||
}
|
||||
|
||||
/// Replace the original snapshot with generated history while preserving later appends.
|
||||
public func mergeGeneratedMessages(_ modelMessages: [ModelMessage], replacingPrefixCount prefixCount: Int) {
|
||||
self.lock.lock()
|
||||
let laterMessages = self._messages.dropFirst(min(prefixCount, self._messages.count))
|
||||
self._messages = modelMessages.map { ConversationMessage.from($0) } + laterMessages
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
/// Insert generated response messages after the snapshot anchor while preserving concurrent appends.
|
||||
public func appendGeneratedMessages(_ modelMessages: [ModelMessage], afterMessageID messageID: String) {
|
||||
guard !modelMessages.isEmpty else { return }
|
||||
|
||||
self.lock.lock()
|
||||
let conversationMessages = modelMessages.map { ConversationMessage.from($0) }
|
||||
if let index = self._messages.firstIndex(where: { $0.id == messageID }) {
|
||||
self._messages.insert(contentsOf: conversationMessages, at: self._messages.index(after: index))
|
||||
} else {
|
||||
self._messages.append(contentsOf: conversationMessages)
|
||||
}
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
/// Insert generated response messages only if the snapshot prefix is still current.
|
||||
public func appendGeneratedMessages(
|
||||
_ modelMessages: [ModelMessage],
|
||||
afterMessageID messageID: String,
|
||||
validatingSnapshotIDs snapshotIDs: [String],
|
||||
)
|
||||
-> Bool
|
||||
{
|
||||
guard !modelMessages.isEmpty else { return true }
|
||||
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
|
||||
guard self._messages.count >= snapshotIDs.count else {
|
||||
return false
|
||||
}
|
||||
|
||||
let currentPrefixIDs = self._messages.prefix(snapshotIDs.count).map(\.id)
|
||||
guard currentPrefixIDs == snapshotIDs else {
|
||||
return false
|
||||
}
|
||||
|
||||
let conversationMessages = modelMessages.map { ConversationMessage.from($0) }
|
||||
if let index = self._messages.firstIndex(where: { $0.id == messageID }) {
|
||||
self._messages.insert(contentsOf: conversationMessages, at: self._messages.index(after: index))
|
||||
} else {
|
||||
self._messages.append(contentsOf: conversationMessages)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Merge a refused generation without losing completed tool steps.
|
||||
public func mergeContentFilterResult(
|
||||
_ resultMessages: [ModelMessage],
|
||||
originalMessages: [ModelMessage],
|
||||
afterMessageID _: String?,
|
||||
validatingSnapshotIDs snapshotIDs: [String],
|
||||
)
|
||||
-> Bool
|
||||
{
|
||||
let generatedMessages = Array(resultMessages.dropFirst(originalMessages.count))
|
||||
if !generatedMessages.isEmpty {
|
||||
return self.replaceModelMessages(
|
||||
originalMessages + generatedMessages,
|
||||
validatingSnapshotIDs: snapshotIDs,
|
||||
)
|
||||
}
|
||||
|
||||
// Generate response using the core API
|
||||
let response = try await generateText(
|
||||
model: model ?? .default,
|
||||
messages: modelMessages,
|
||||
tools: [],
|
||||
settings: .default,
|
||||
configuration: configuration,
|
||||
return self.replaceModelMessages(
|
||||
originalMessages.droppingLastUserTurn(),
|
||||
validatingSnapshotIDs: snapshotIDs,
|
||||
)
|
||||
}
|
||||
|
||||
// Add the response to the conversation
|
||||
self.addAssistantMessage(response.text)
|
||||
public func removeMessage(id: String) {
|
||||
self.lock.lock()
|
||||
self._messages.removeAll { $0.id == id }
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
return response.text
|
||||
public func withContinuationLock<T>(_ operation: () async throws -> T) async throws -> T {
|
||||
try await self.acquireContinuationLock()
|
||||
do {
|
||||
let result = try await operation()
|
||||
await self.releaseContinuationLock()
|
||||
return result
|
||||
} catch {
|
||||
await self.releaseContinuationLock()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func acquireContinuationLock() async throws {
|
||||
try await self.continuationGate.acquire()
|
||||
}
|
||||
|
||||
public func releaseContinuationLock() async {
|
||||
await self.continuationGate.release()
|
||||
}
|
||||
|
||||
/// Continue the conversation with a model
|
||||
public func continueConversation(
|
||||
using model: Model? = nil,
|
||||
tools: [AgentTool]? = nil,
|
||||
maxSteps: Int = 5,
|
||||
) async throws
|
||||
-> String
|
||||
{
|
||||
try await self.withContinuationLock {
|
||||
let conversationMessages = self.messages
|
||||
let modelMessages = conversationMessages.map { $0.toModelMessage() }
|
||||
let snapshotIDs = conversationMessages.map(\.id)
|
||||
let anchorID = conversationMessages.last?.id
|
||||
|
||||
// Generate response using the core API
|
||||
let response = try await generateText(
|
||||
model: model ?? .default,
|
||||
messages: modelMessages,
|
||||
tools: tools,
|
||||
settings: .default,
|
||||
maxSteps: maxSteps,
|
||||
configuration: configuration,
|
||||
)
|
||||
|
||||
let didMerge: Bool
|
||||
if response.finishReason == .contentFilter {
|
||||
didMerge = self.mergeContentFilterResult(
|
||||
response.messages,
|
||||
originalMessages: modelMessages,
|
||||
afterMessageID: anchorID,
|
||||
validatingSnapshotIDs: snapshotIDs,
|
||||
)
|
||||
} else if let anchorID {
|
||||
let generatedMessages = Array(response.messages.dropFirst(modelMessages.count))
|
||||
didMerge = self.appendGeneratedMessages(
|
||||
generatedMessages,
|
||||
afterMessageID: anchorID,
|
||||
validatingSnapshotIDs: snapshotIDs,
|
||||
)
|
||||
} else if self.messages.isEmpty {
|
||||
self.replaceModelMessages(response.messages)
|
||||
didMerge = true
|
||||
} else {
|
||||
didMerge = false
|
||||
}
|
||||
|
||||
guard didMerge else {
|
||||
throw TachikomaError.invalidConfiguration(
|
||||
"Conversation changed during generation; refusing to merge response",
|
||||
)
|
||||
}
|
||||
|
||||
return response.text
|
||||
}
|
||||
}
|
||||
|
||||
/// Continue the conversation with a model, streaming the response
|
||||
@ -106,43 +323,90 @@ public final class Conversation: @unchecked Sendable {
|
||||
) async throws
|
||||
-> AsyncThrowingStream<String, Error>
|
||||
{
|
||||
// Convert conversation messages to model messages
|
||||
let modelMessages = self.messages.map { conversationMessage in
|
||||
ModelMessage(
|
||||
id: conversationMessage.id,
|
||||
role: ModelMessage.Role(rawValue: conversationMessage.role.rawValue) ?? .user,
|
||||
content: [.text(conversationMessage.content)],
|
||||
timestamp: conversationMessage.timestamp,
|
||||
)
|
||||
try await self.acquireContinuationLock()
|
||||
let gateRelease = AsyncReleaseOnce {
|
||||
await self.releaseContinuationLock()
|
||||
}
|
||||
let conversationMessages = self.messages
|
||||
let modelMessages = conversationMessages.map { $0.toModelMessage() }
|
||||
let snapshotIDs = conversationMessages.map(\.id)
|
||||
let resolvedModel = model ?? .defaultStreaming
|
||||
let streamSettings = GenerationSettings.default
|
||||
let buffersUntilDone = streamSettings.streamBuffering == .untilTerminal ||
|
||||
resolvedModel.requiresTerminalRefusalBuffering
|
||||
|
||||
// Generate response using the core API
|
||||
let responseStream = try await streamText(
|
||||
model: model ?? .default,
|
||||
messages: modelMessages,
|
||||
tools: tools ?? [], // Use provided tools or empty array
|
||||
settings: .default,
|
||||
configuration: configuration,
|
||||
)
|
||||
let responseStream: StreamTextResult
|
||||
do {
|
||||
responseStream = try await streamText(
|
||||
model: resolvedModel,
|
||||
messages: modelMessages,
|
||||
tools: tools ?? [], // Use provided tools or empty array
|
||||
settings: streamSettings,
|
||||
configuration: self.configuration,
|
||||
)
|
||||
} catch {
|
||||
gateRelease.release()
|
||||
throw error
|
||||
}
|
||||
|
||||
// Create a new stream to process the response and update the conversation
|
||||
return AsyncThrowingStream<String, Error> { continuation in
|
||||
Task {
|
||||
let producer = Task {
|
||||
defer {
|
||||
gateRelease.release()
|
||||
}
|
||||
var fullResponse = ""
|
||||
var isContentFiltered = false
|
||||
var bufferedText: [String] = []
|
||||
var didApproveBufferedResponse = !buffersUntilDone
|
||||
var didReceiveTerminal = false
|
||||
do {
|
||||
for try await delta in responseStream.stream {
|
||||
try Task.checkCancellation()
|
||||
switch delta.type {
|
||||
case .textDelta:
|
||||
if let text = delta.content {
|
||||
continuation.yield(text)
|
||||
if buffersUntilDone {
|
||||
bufferedText.append(text)
|
||||
} else {
|
||||
continuation.yield(text)
|
||||
}
|
||||
fullResponse += text
|
||||
}
|
||||
case .done where delta.finishReason == .contentFilter:
|
||||
didReceiveTerminal = true
|
||||
isContentFiltered = true
|
||||
let didRollback = self.replaceModelMessages(
|
||||
modelMessages.droppingLastUserTurn(),
|
||||
validatingSnapshotIDs: snapshotIDs,
|
||||
)
|
||||
guard didRollback else {
|
||||
throw TachikomaError.invalidConfiguration(
|
||||
"Conversation changed during streaming; refusing to merge response",
|
||||
)
|
||||
}
|
||||
fullResponse = ""
|
||||
bufferedText.removeAll()
|
||||
case .done:
|
||||
didReceiveTerminal = true
|
||||
if buffersUntilDone {
|
||||
for text in bufferedText {
|
||||
continuation.yield(text)
|
||||
}
|
||||
didApproveBufferedResponse = true
|
||||
bufferedText.removeAll()
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
if buffersUntilDone, !didReceiveTerminal, !bufferedText.isEmpty {
|
||||
throw TachikomaError.apiError("Stream ended before provider completion status was received")
|
||||
}
|
||||
// Add the full response to the conversation
|
||||
if !fullResponse.isEmpty {
|
||||
if !isContentFiltered, !fullResponse.isEmpty, didApproveBufferedResponse {
|
||||
try Task.checkCancellation()
|
||||
self.addAssistantMessage(fullResponse)
|
||||
}
|
||||
continuation.finish()
|
||||
@ -150,10 +414,20 @@ public final class Conversation: @unchecked Sendable {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
producer.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension [ModelMessage] {
|
||||
func droppingLastUserTurn() -> [ModelMessage] {
|
||||
guard self.last?.role == .user else { return self }
|
||||
return Array(self.dropLast())
|
||||
}
|
||||
}
|
||||
|
||||
/// A message in a conversation
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public struct ConversationMessage: Sendable, Codable, Equatable {
|
||||
@ -161,6 +435,9 @@ public struct ConversationMessage: Sendable, Codable, Equatable {
|
||||
public let role: Role
|
||||
public let content: String
|
||||
public let timestamp: Date
|
||||
public let contentParts: [ModelMessage.ContentPart]?
|
||||
public let channel: ResponseChannel?
|
||||
public let metadata: MessageMetadata?
|
||||
|
||||
public enum Role: String, Sendable, Codable, CaseIterable {
|
||||
case system
|
||||
@ -169,11 +446,22 @@ public struct ConversationMessage: Sendable, Codable, Equatable {
|
||||
case tool
|
||||
}
|
||||
|
||||
public init(id: String = UUID().uuidString, role: Role, content: String, timestamp: Date = Date()) {
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
role: Role,
|
||||
content: String,
|
||||
timestamp: Date = Date(),
|
||||
contentParts: [ModelMessage.ContentPart]? = nil,
|
||||
channel: ResponseChannel? = nil,
|
||||
metadata: MessageMetadata? = nil,
|
||||
) {
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.timestamp = timestamp
|
||||
self.contentParts = contentParts
|
||||
self.channel = channel
|
||||
self.metadata = metadata
|
||||
}
|
||||
|
||||
/// Convert to ModelMessage for API compatibility
|
||||
@ -189,8 +477,10 @@ public struct ConversationMessage: Sendable, Codable, Equatable {
|
||||
return ModelMessage(
|
||||
id: self.id,
|
||||
role: modelRole,
|
||||
content: [.text(self.content)],
|
||||
content: self.contentParts ?? [.text(self.content)],
|
||||
timestamp: self.timestamp,
|
||||
channel: self.channel,
|
||||
metadata: self.metadata,
|
||||
)
|
||||
}
|
||||
|
||||
@ -219,6 +509,9 @@ public struct ConversationMessage: Sendable, Codable, Equatable {
|
||||
role: role,
|
||||
content: textContent,
|
||||
timestamp: modelMessage.timestamp,
|
||||
contentParts: modelMessage.content,
|
||||
channel: modelMessage.channel,
|
||||
metadata: modelMessage.metadata,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -128,7 +128,7 @@ public struct InputAudioTranscription: Sendable, Codable {
|
||||
/// Session configuration with all options
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public struct SessionConfiguration: Sendable, Codable {
|
||||
/// Model to use (e.g., "gpt-4o-realtime-preview")
|
||||
/// Model to use (e.g., "gpt-realtime")
|
||||
public var model: String
|
||||
|
||||
/// Voice for audio responses
|
||||
@ -213,7 +213,7 @@ public struct SessionConfiguration: Sendable, Codable {
|
||||
}
|
||||
|
||||
public init(
|
||||
model: String = "gpt-4o-realtime-preview",
|
||||
model: String = "gpt-realtime",
|
||||
voice: RealtimeVoice = .alloy,
|
||||
instructions: String? = nil,
|
||||
inputAudioFormat: RealtimeAudioFormat = .pcm16,
|
||||
@ -242,7 +242,7 @@ public struct SessionConfiguration: Sendable, Codable {
|
||||
|
||||
/// Create a default configuration for voice conversations
|
||||
public static func voiceConversation(
|
||||
model: String = "gpt-4o-realtime-preview",
|
||||
model: String = "gpt-realtime",
|
||||
voice: RealtimeVoice = .alloy,
|
||||
)
|
||||
-> SessionConfiguration
|
||||
@ -258,7 +258,7 @@ public struct SessionConfiguration: Sendable, Codable {
|
||||
|
||||
/// Create a configuration for text-only interactions
|
||||
public static func textOnly(
|
||||
model: String = "gpt-4o-realtime-preview",
|
||||
model: String = "gpt-realtime",
|
||||
)
|
||||
-> SessionConfiguration
|
||||
{
|
||||
@ -273,7 +273,7 @@ public struct SessionConfiguration: Sendable, Codable {
|
||||
|
||||
/// Create a configuration with tools
|
||||
public static func withTools(
|
||||
model: String = "gpt-4o-realtime-preview",
|
||||
model: String = "gpt-realtime",
|
||||
voice: RealtimeVoice = .alloy,
|
||||
tools: [RealtimeTool],
|
||||
)
|
||||
|
||||
@ -150,7 +150,7 @@ public final class RealtimeConversation: ObservableObject {
|
||||
|
||||
// Create session with configuration
|
||||
let sessionConfig = SessionConfiguration(
|
||||
model: "gpt-4o-realtime-preview",
|
||||
model: "gpt-realtime",
|
||||
voice: .alloy,
|
||||
instructions: nil,
|
||||
tools: nil,
|
||||
@ -167,7 +167,7 @@ public final class RealtimeConversation: ObservableObject {
|
||||
|
||||
/// Start the conversation
|
||||
public func start(
|
||||
model: LanguageModel.OpenAI = .gpt4oRealtime,
|
||||
model: LanguageModel.OpenAI = .custom("gpt-realtime"),
|
||||
voice: RealtimeVoice = .alloy,
|
||||
instructions: String? = nil,
|
||||
tools: [RealtimeTool]? = nil,
|
||||
@ -495,7 +495,7 @@ public final class RealtimeConversation: ObservableObject {
|
||||
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public func startRealtimeConversation(
|
||||
model: LanguageModel.OpenAI = .gpt4oRealtime,
|
||||
model: LanguageModel.OpenAI = .custom("gpt-realtime"),
|
||||
voice: RealtimeVoice = .alloy,
|
||||
instructions: String? = nil,
|
||||
tools: [AgentTool]? = nil,
|
||||
@ -548,7 +548,7 @@ public final class RealtimeConversation {
|
||||
}
|
||||
|
||||
public func start(
|
||||
model _: LanguageModel.OpenAI = .gpt4oRealtime,
|
||||
model _: LanguageModel.OpenAI = .custom("gpt-realtime"),
|
||||
voice _: RealtimeVoice = .alloy,
|
||||
instructions _: String? = nil,
|
||||
tools _: [RealtimeTool]? = nil,
|
||||
@ -560,7 +560,7 @@ public final class RealtimeConversation {
|
||||
// swiftformat:disable wrapMultilineStatementBraces wrapReturnType indent
|
||||
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
|
||||
public func startRealtimeConversation(
|
||||
model _: LanguageModel.OpenAI = .gpt4oRealtime,
|
||||
model _: LanguageModel.OpenAI = .custom("gpt-realtime"),
|
||||
voice _: RealtimeVoice = .alloy,
|
||||
instructions _: String? = nil,
|
||||
tools _: [AgentTool]? = nil,
|
||||
|
||||
@ -188,7 +188,7 @@ public struct RealtimeSessionConfig: Codable, Sendable {
|
||||
}
|
||||
|
||||
public init(
|
||||
model: String = "gpt-4o-realtime-preview",
|
||||
model: String = "gpt-realtime",
|
||||
voice: RealtimeVoice = .alloy,
|
||||
instructions: String? = nil,
|
||||
tools: [RealtimeTool]? = nil,
|
||||
|
||||
@ -16,20 +16,16 @@ public enum TranscriptionModel: Sendable, CustomStringConvertible {
|
||||
|
||||
public enum OpenAI: String, CaseIterable, Sendable {
|
||||
case whisper1 = "whisper-1"
|
||||
case gpt4oTranscribe = "gpt-4o-transcribe"
|
||||
case gpt4oMiniTranscribe = "gpt-4o-mini-transcribe"
|
||||
|
||||
public var supportsTimestamps: Bool {
|
||||
switch self {
|
||||
case .whisper1: true
|
||||
case .gpt4oTranscribe, .gpt4oMiniTranscribe: false
|
||||
}
|
||||
}
|
||||
|
||||
public var supportsLanguageDetection: Bool {
|
||||
switch self {
|
||||
case .whisper1: true
|
||||
case .gpt4oTranscribe, .gpt4oMiniTranscribe: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -155,12 +151,10 @@ public enum SpeechModel: Sendable, CustomStringConvertible {
|
||||
public enum OpenAI: String, CaseIterable, Sendable {
|
||||
case tts1 = "tts-1"
|
||||
case tts1HD = "tts-1-hd"
|
||||
case gpt4oMiniTTS = "gpt-4o-mini-tts"
|
||||
|
||||
public var supportsVoiceInstructions: Bool {
|
||||
switch self {
|
||||
case .tts1, .tts1HD: false
|
||||
case .gpt4oMiniTTS: true
|
||||
}
|
||||
}
|
||||
|
||||
@ -226,5 +220,5 @@ public enum SpeechModel: Sendable, CustomStringConvertible {
|
||||
public static let `default`: SpeechModel = .openai(.tts1)
|
||||
public static let highQuality: SpeechModel = .openai(.tts1HD)
|
||||
public static let fast: SpeechModel = .openai(.tts1)
|
||||
public static let expressive: SpeechModel = .openai(.gpt4oMiniTTS)
|
||||
public static let expressive: SpeechModel = .openai(.tts1HD)
|
||||
}
|
||||
|
||||
@ -116,7 +116,7 @@ struct TKConfigCLI {
|
||||
var mutable = raw
|
||||
let timeout = self.parseTimeout(&mutable)
|
||||
print("Providers:")
|
||||
for pid in [TKProviderId.openai, .anthropic, .grok, .gemini] {
|
||||
for pid in [TKProviderId.openai, .anthropic, .grok, .gemini, .openrouter] {
|
||||
let status = await TKConfigCLI.status(for: pid, timeout: timeout)
|
||||
print(" \(pid.displayName): \(status)")
|
||||
}
|
||||
@ -150,6 +150,8 @@ struct TKConfigCLI {
|
||||
}
|
||||
case .gemini:
|
||||
if let v = env["GEMINI_API_KEY"], !v.isEmpty { return ("GEMINI_API_KEY", v) }
|
||||
case .openrouter:
|
||||
if let v = env["OPENROUTER_API_KEY"], !v.isEmpty { return ("OPENROUTER_API_KEY", v) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -169,6 +171,8 @@ struct TKConfigCLI {
|
||||
}
|
||||
case .gemini:
|
||||
if let v = manager.credentialValue(for: "GEMINI_API_KEY") { return ("GEMINI_API_KEY", v) }
|
||||
case .openrouter:
|
||||
if let v = manager.credentialValue(for: "OPENROUTER_API_KEY") { return ("OPENROUTER_API_KEY", v) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -499,12 +499,7 @@ public final class TachikomaMCPClientManager {
|
||||
}
|
||||
|
||||
private func profileDirectoryPath() -> String {
|
||||
#if os(Windows)
|
||||
let home = ProcessInfo.processInfo.environment["USERPROFILE"] ?? ""
|
||||
#else
|
||||
let home = ProcessInfo.processInfo.environment["HOME"] ?? ""
|
||||
#endif
|
||||
return "\(home)/\(self.profileDirectoryName)"
|
||||
TachikomaConfiguration.profileDirectoryPath
|
||||
}
|
||||
|
||||
private func profileConfigPath() -> String {
|
||||
|
||||
@ -12,7 +12,7 @@ import Tachikoma
|
||||
|
||||
// Use Tachikoma normally without MCP
|
||||
let result = try await generateText(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
messages: messages,
|
||||
tools: staticTools
|
||||
)
|
||||
@ -26,7 +26,7 @@ import TachikomaMCP
|
||||
// Now you can use MCP tools alongside static tools
|
||||
let mcpTools = try await MCPToolDiscovery.withFilesystem()
|
||||
let result = try await generateText(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
messages: messages,
|
||||
tools: staticTools + mcpTools
|
||||
)
|
||||
|
||||
@ -191,7 +191,7 @@ struct MCPClientTests {
|
||||
let imageData = Data("test".utf8)
|
||||
let imageResponse = ToolResponse.image(data: imageData, mimeType: "image/png")
|
||||
#expect(imageResponse.content.count == 1)
|
||||
if case .image(data: let data, mimeType: let mimeType, annotations: _, _meta: _) = imageResponse.content.first {
|
||||
if case let .image(data: data, mimeType: mimeType, annotations: _, _meta: _) = imageResponse.content.first {
|
||||
#expect(data == imageData.base64EncodedString())
|
||||
#expect(mimeType == "image/png")
|
||||
} else {
|
||||
|
||||
@ -130,7 +130,7 @@ struct MCPToolAdapterTests {
|
||||
|
||||
#expect(response.isError == false)
|
||||
#expect(response.content.count == 1)
|
||||
if case .image(data: let data, mimeType: let mimeType, annotations: _, _meta: _) = response.content[0] {
|
||||
if case let .image(data: data, mimeType: mimeType, annotations: _, _meta: _) = response.content[0] {
|
||||
#expect(data == imageData.base64EncodedString())
|
||||
#expect(mimeType == "image/jpeg")
|
||||
} else {
|
||||
|
||||
@ -362,7 +362,7 @@ enum AudioFunctionsTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `multiple provider integration`() async throws {
|
||||
func `multiple provider integration`() async {
|
||||
await TestHelpers.withStandardTestConfiguration { config in
|
||||
let audioData = TestHelpers.sampleAudioData(configuration: config)
|
||||
|
||||
|
||||
@ -81,43 +81,47 @@ struct AudioProviderFactoryTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `AudioConfiguration reads configuration keys before environment`() {
|
||||
let previousOpenAIKey = getenv("OPENAI_API_KEY").flatMap { String(cString: $0) }
|
||||
unsetenv("OPENAI_API_KEY")
|
||||
defer {
|
||||
if let previousOpenAIKey {
|
||||
setenv("OPENAI_API_KEY", previousOpenAIKey, 1)
|
||||
func `AudioConfiguration reads configuration keys before environment`() async {
|
||||
await TestEnvironmentMutex.shared.withLock {
|
||||
let previousOpenAIKey = getenv("OPENAI_API_KEY").flatMap { String(cString: $0) }
|
||||
unsetenv("OPENAI_API_KEY")
|
||||
defer {
|
||||
if let previousOpenAIKey {
|
||||
setenv("OPENAI_API_KEY", previousOpenAIKey, 1)
|
||||
}
|
||||
}
|
||||
|
||||
let configuration = TachikomaConfiguration()
|
||||
configuration.setAPIKey("configured-key", for: "openai")
|
||||
|
||||
let resolvedKey = AudioConfiguration.getAPIKey(for: "openai", configuration: configuration)
|
||||
#expect(resolvedKey == "configured-key")
|
||||
}
|
||||
|
||||
let configuration = TachikomaConfiguration()
|
||||
configuration.setAPIKey("configured-key", for: "openai")
|
||||
|
||||
let resolvedKey = AudioConfiguration.getAPIKey(for: "openai", configuration: configuration)
|
||||
#expect(resolvedKey == "configured-key")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `AudioConfiguration falls back to environment variable`() {
|
||||
let previousOpenAIKey = getenv("OPENAI_API_KEY").flatMap { String(cString: $0) }
|
||||
setenv("OPENAI_API_KEY", "env-key-123", 1)
|
||||
defer {
|
||||
if let previousOpenAIKey {
|
||||
setenv("OPENAI_API_KEY", previousOpenAIKey, 1)
|
||||
} else {
|
||||
unsetenv("OPENAI_API_KEY")
|
||||
func `AudioConfiguration falls back to environment variable`() async {
|
||||
await TestEnvironmentMutex.shared.withLock {
|
||||
let previousOpenAIKey = getenv("OPENAI_API_KEY").flatMap { String(cString: $0) }
|
||||
setenv("OPENAI_API_KEY", "env-key-123", 1)
|
||||
defer {
|
||||
if let previousOpenAIKey {
|
||||
setenv("OPENAI_API_KEY", previousOpenAIKey, 1)
|
||||
} else {
|
||||
unsetenv("OPENAI_API_KEY")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let expectedKey = getenv("OPENAI_API_KEY").map { String(cString: $0) }
|
||||
let resolvedKey = AudioConfiguration.getAPIKey(
|
||||
for: "openai",
|
||||
configuration: TachikomaConfiguration(loadFromEnvironment: false),
|
||||
)
|
||||
if let expectedKey {
|
||||
#expect(resolvedKey == expectedKey)
|
||||
} else {
|
||||
Issue.record("Expected environment override for OPENAI_API_KEY")
|
||||
let expectedKey = getenv("OPENAI_API_KEY").map { String(cString: $0) }
|
||||
let resolvedKey = AudioConfiguration.getAPIKey(
|
||||
for: "openai",
|
||||
configuration: TachikomaConfiguration(loadFromEnvironment: false),
|
||||
)
|
||||
if let expectedKey {
|
||||
#expect(resolvedKey == expectedKey)
|
||||
} else {
|
||||
Issue.record("Expected environment override for OPENAI_API_KEY")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,6 +44,7 @@ struct AuthManagerTests {
|
||||
unsetenv("ANTHROPIC_ACCESS_TOKEN")
|
||||
unsetenv("GEMINI_API_KEY")
|
||||
unsetenv("GOOGLE_API_KEY")
|
||||
unsetenv("OPENROUTER_API_KEY")
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -79,6 +80,29 @@ struct AuthManagerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `openrouter resolves env and credential keys as bearer auth`() async throws {
|
||||
try await self.withIsolatedAuthState {
|
||||
self.resetAuthEnv()
|
||||
try TKAuthManager.shared.setCredential(key: "OPENROUTER_API_KEY", value: "credential-openrouter-key")
|
||||
|
||||
guard case let .bearer(credentialToken, _)? = TKAuthManager.shared.resolveAuth(for: .openrouter) else {
|
||||
Issue.record("Expected OpenRouter bearer auth from credentials")
|
||||
return
|
||||
}
|
||||
#expect(credentialToken == "credential-openrouter-key")
|
||||
|
||||
setenv("OPENROUTER_API_KEY", "env-openrouter-key", 1)
|
||||
defer { unsetenv("OPENROUTER_API_KEY") }
|
||||
|
||||
guard case let .bearer(envToken, _)? = TKAuthManager.shared.resolveAuth(for: .openrouter) else {
|
||||
Issue.record("Expected OpenRouter bearer auth from environment")
|
||||
return
|
||||
}
|
||||
#expect(envToken == "env-openrouter-key")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `validate success mock`() async throws {
|
||||
|
||||
@ -17,7 +17,7 @@ struct AsyncSequenceTests {
|
||||
|
||||
let result = StreamTextResult(
|
||||
stream: testStream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
)
|
||||
|
||||
@ -86,7 +86,7 @@ struct AsyncSequenceTests {
|
||||
|
||||
let result = StreamObjectResult(
|
||||
objectStream: testStream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
schema: TestData.self,
|
||||
)
|
||||
@ -117,7 +117,7 @@ struct AsyncSequenceTests {
|
||||
|
||||
let result = StreamTextResult(
|
||||
stream: testStream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
)
|
||||
|
||||
@ -149,7 +149,7 @@ struct AsyncSequenceTests {
|
||||
|
||||
let result = StreamTextResult(
|
||||
stream: testStream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
)
|
||||
|
||||
@ -183,7 +183,7 @@ struct AsyncSequenceTests {
|
||||
|
||||
let result = StreamTextResult(
|
||||
stream: testStream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
)
|
||||
|
||||
@ -218,7 +218,7 @@ struct AsyncSequenceTests {
|
||||
|
||||
let result = StreamTextResult(
|
||||
stream: testStream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
)
|
||||
|
||||
@ -262,7 +262,7 @@ struct AsyncSequenceTests {
|
||||
|
||||
let result = StreamObjectResult(
|
||||
objectStream: testStream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
schema: TestItem.self,
|
||||
)
|
||||
|
||||
@ -55,7 +55,7 @@ struct ConfigurationArchitectureTests {
|
||||
// Example 1: Zero configuration
|
||||
_ = {
|
||||
Task {
|
||||
_ = try await generate("What is 2+2?", using: .openai(.gpt4o))
|
||||
_ = try await generate("What is 2+2?", using: .openai(.gpt55))
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,7 +66,7 @@ struct ConfigurationArchitectureTests {
|
||||
)
|
||||
|
||||
Task {
|
||||
_ = try await generate("Hello", using: .openai(.gpt4o))
|
||||
_ = try await generate("Hello", using: .openai(.gpt55))
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,7 +79,7 @@ struct ConfigurationArchitectureTests {
|
||||
|
||||
_ = try await generate(
|
||||
"Test prompt",
|
||||
using: .openai(.gpt4o),
|
||||
using: .openai(.gpt55),
|
||||
configuration: testConfig,
|
||||
)
|
||||
}
|
||||
|
||||
@ -33,4 +33,17 @@ struct ConfigurationEnvironmentTests {
|
||||
let configuration = TachikomaConfiguration(loadFromEnvironment: true)
|
||||
#expect(configuration.getBaseURL(for: .openai) == "https://env.example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `TachikomaConfiguration picks up ANTHROPIC_BASE_URL from environment`() {
|
||||
let key = "ANTHROPIC_BASE_URL"
|
||||
setenv(key, "https://env.example.com", 1)
|
||||
defer { unsetenv(key) }
|
||||
|
||||
let rawValue = Provider.environmentValue(for: key)
|
||||
#expect(rawValue == "https://env.example.com")
|
||||
|
||||
let configuration = TachikomaConfiguration(loadFromEnvironment: true)
|
||||
#expect(configuration.getBaseURL(for: .anthropic) == "https://env.example.com")
|
||||
}
|
||||
}
|
||||
|
||||
164
Tests/TachikomaTests/Core/CredentialLoadingTests.swift
Normal file
164
Tests/TachikomaTests/Core/CredentialLoadingTests.swift
Normal file
@ -0,0 +1,164 @@
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#else
|
||||
import Glibc
|
||||
#endif
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Tachikoma
|
||||
|
||||
@Suite(.serialized)
|
||||
struct CredentialLoadingTests {
|
||||
@Test
|
||||
func `OAuth tokens are not loaded as OpenAI API keys`() async throws {
|
||||
try await self.withIsolatedCredentials(
|
||||
"""
|
||||
OPENAI_ACCESS_TOKEN=access-token
|
||||
OPENAI_REFRESH_TOKEN=refresh-token
|
||||
OPENAI_ACCESS_EXPIRES=4102444800
|
||||
""",
|
||||
) {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: true)
|
||||
#expect(config.getAPIKey(for: .openai) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `OpenAI API key credential is preferred over OAuth token noise`() async throws {
|
||||
try await self.withIsolatedCredentials(
|
||||
"""
|
||||
OPENAI_ACCESS_TOKEN=access-token
|
||||
OPENAI_API_KEY=api-key
|
||||
OPENAI_REFRESH_TOKEN=refresh-token
|
||||
""",
|
||||
) {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: true)
|
||||
#expect(config.getAPIKey(for: .openai) == "api-key")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Absolute profile path credentials load without HOME`() async throws {
|
||||
#if !os(Windows)
|
||||
try await TestEnvironmentMutex.shared.withLock {
|
||||
let originalProfileDirectory = TachikomaConfiguration.profileDirectoryName
|
||||
let profilePath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("tachikoma-absolute-credentials-\(UUID().uuidString)")
|
||||
.path
|
||||
let credentialPath = "\(profilePath)/credentials"
|
||||
let savedHome = getenv("HOME").map { String(cString: $0) }
|
||||
let savedEnvironment = self.unsetOpenAIEnvironment() + [("HOME", savedHome)]
|
||||
|
||||
TachikomaConfiguration.profileDirectoryName = profilePath
|
||||
try FileManager.default.createDirectory(atPath: profilePath, withIntermediateDirectories: true)
|
||||
try "OPENAI_API_KEY=absolute-api-key\n".write(toFile: credentialPath, atomically: true, encoding: .utf8)
|
||||
unsetenv("HOME")
|
||||
|
||||
defer {
|
||||
self.restoreEnvironment(savedEnvironment)
|
||||
TachikomaConfiguration.profileDirectoryName = originalProfileDirectory
|
||||
try? FileManager.default.removeItem(atPath: profilePath)
|
||||
}
|
||||
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: true)
|
||||
#expect(config.getAPIKey(for: .openai) == "absolute-api-key")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@Test
|
||||
func `MiniMax China credentials save and reload with canonical env name`() async throws {
|
||||
#if !os(Windows)
|
||||
try await TestEnvironmentMutex.shared.withLock {
|
||||
let originalProfileDirectory = TachikomaConfiguration.profileDirectoryName
|
||||
let profilePath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("tachikoma-minimax-cn-credentials-\(UUID().uuidString)")
|
||||
.path
|
||||
let credentialPath = "\(profilePath)/credentials"
|
||||
let savedEnvironment = self.savedEnvironment(for: ["MINIMAX_CN_API_KEY", "MINIMAX_API_KEY"])
|
||||
|
||||
TachikomaConfiguration.profileDirectoryName = profilePath
|
||||
for (key, _) in savedEnvironment {
|
||||
unsetenv(key)
|
||||
}
|
||||
|
||||
defer {
|
||||
self.restoreEnvironment(savedEnvironment)
|
||||
TachikomaConfiguration.profileDirectoryName = originalProfileDirectory
|
||||
try? FileManager.default.removeItem(atPath: profilePath)
|
||||
}
|
||||
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setAPIKey("cn-api-key", for: .minimaxCN)
|
||||
try config.saveCredentials()
|
||||
|
||||
let savedCredentials = try String(contentsOfFile: credentialPath, encoding: .utf8)
|
||||
#expect(savedCredentials.contains("MINIMAX_CN_API_KEY=cn-api-key"))
|
||||
#expect(!savedCredentials.contains("MINIMAX-CN_API_KEY"))
|
||||
|
||||
let reloaded = TachikomaConfiguration(loadFromEnvironment: true)
|
||||
#expect(reloaded.getAPIKey(for: .minimaxCN) == "cn-api-key")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@Test
|
||||
func `MiniMax China availability accepts configured shared MiniMax key`() {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setAPIKey("shared-minimax-key", for: .minimax)
|
||||
|
||||
#expect(config.getAPIKey(for: .minimaxCN) == "shared-minimax-key")
|
||||
#expect(config.hasAPIKey(for: .minimaxCN))
|
||||
}
|
||||
|
||||
private func withIsolatedCredentials<T: Sendable>(
|
||||
_ credentials: String,
|
||||
_ body: @Sendable () throws -> T,
|
||||
) async throws
|
||||
-> T
|
||||
{
|
||||
try await TestEnvironmentMutex.shared.withLock {
|
||||
let originalProfileDirectory = TachikomaConfiguration.profileDirectoryName
|
||||
let profileDirectory = ".tachikoma-credential-tests-\(UUID().uuidString)"
|
||||
let homeDirectory = try #require(ProcessInfo.processInfo.environment["HOME"])
|
||||
let profilePath = "\(homeDirectory)/\(profileDirectory)"
|
||||
let credentialPath = "\(profilePath)/credentials"
|
||||
let savedEnvironment = self.unsetOpenAIEnvironment()
|
||||
|
||||
TachikomaConfiguration.profileDirectoryName = profileDirectory
|
||||
try FileManager.default.createDirectory(atPath: profilePath, withIntermediateDirectories: true)
|
||||
try credentials.write(toFile: credentialPath, atomically: true, encoding: .utf8)
|
||||
|
||||
defer {
|
||||
self.restoreEnvironment(savedEnvironment)
|
||||
TachikomaConfiguration.profileDirectoryName = originalProfileDirectory
|
||||
try? FileManager.default.removeItem(atPath: profilePath)
|
||||
}
|
||||
|
||||
return try body()
|
||||
}
|
||||
}
|
||||
|
||||
private func unsetOpenAIEnvironment() -> [(String, String?)] {
|
||||
let keys = ["OPENAI_API_KEY", "OPENAI_ACCESS_TOKEN", "OPENAI_REFRESH_TOKEN", "OPENAI_ACCESS_EXPIRES"]
|
||||
let saved = self.savedEnvironment(for: keys)
|
||||
keys.forEach { unsetenv($0) }
|
||||
return saved
|
||||
}
|
||||
|
||||
private func savedEnvironment(for keys: [String]) -> [(String, String?)] {
|
||||
keys.map { key in
|
||||
(key, getenv(key).map { String(cString: $0) })
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreEnvironment(_ saved: [(String, String?)]) {
|
||||
for (key, value) in saved {
|
||||
if let value {
|
||||
setenv(key, value, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,7 +38,8 @@ struct CustomProviderRegistryTests {
|
||||
/* secret token */
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${WEATHER_TOKEN}"
|
||||
}
|
||||
},
|
||||
"apiKey": "${WEATHER_API_KEY}"
|
||||
},
|
||||
"models": {
|
||||
"fast": { "name": "weather-fast" }
|
||||
@ -46,7 +47,10 @@ struct CustomProviderRegistryTests {
|
||||
},
|
||||
"claude-proxy": {
|
||||
"type": "anthropic",
|
||||
"options": { "baseURL": "https://anthropic.local" }
|
||||
"options": {
|
||||
"baseURL": "https://anthropic.local",
|
||||
"apiKey": "claude-provider-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -68,6 +72,7 @@ struct CustomProviderRegistryTests {
|
||||
#endif
|
||||
TachikomaConfiguration.profileDirectoryName = originalProfileDir
|
||||
unsetenv("WEATHER_TOKEN")
|
||||
unsetenv("WEATHER_API_KEY")
|
||||
self.resetRegistry(
|
||||
forProfile: originalProfileDir,
|
||||
originalHome: originalHome,
|
||||
@ -78,6 +83,7 @@ struct CustomProviderRegistryTests {
|
||||
TachikomaConfiguration.profileDirectoryName = profileDirName
|
||||
self.setHomeEnvironment(to: tempHome.path)
|
||||
setenv("WEATHER_TOKEN", "sk-test-weather", 1)
|
||||
setenv("WEATHER_API_KEY", "sk-provider-key", 1)
|
||||
|
||||
CustomProviderRegistry.shared.loadFromProfile()
|
||||
let providers = CustomProviderRegistry.shared.list()
|
||||
@ -87,14 +93,57 @@ struct CustomProviderRegistryTests {
|
||||
let weather = try #require(providers["weather-ai"])
|
||||
#expect(weather.kind == .openai)
|
||||
#expect(weather.baseURL == "https://api.example.com")
|
||||
#expect(weather.apiKey == "sk-provider-key")
|
||||
#expect(weather.headers["Authorization"] == "Bearer sk-test-weather")
|
||||
#expect(weather.models["fast"] == "weather-fast")
|
||||
|
||||
let claude = try #require(CustomProviderRegistry.shared.get("claude-proxy"))
|
||||
#expect(claude.kind == .anthropic)
|
||||
#expect(claude.apiKey == "claude-provider-key")
|
||||
#expect(claude.headers.isEmpty)
|
||||
#expect(claude.models.isEmpty)
|
||||
|
||||
let dynamicProvider = DynamicCustomProvider(modelId: "weather-ai/fast")
|
||||
let resolved = try ProviderFactory.createProvider(
|
||||
for: .custom(provider: dynamicProvider),
|
||||
configuration: TachikomaConfiguration(loadFromEnvironment: false),
|
||||
)
|
||||
let compatibleProvider = try #require(resolved as? OpenAICompatibleProvider)
|
||||
#expect(compatibleProvider.apiKey == "sk-provider-key")
|
||||
#expect(compatibleProvider.additionalHeaders["Authorization"] == "Bearer sk-test-weather")
|
||||
|
||||
let claudeProvider = DynamicCustomProvider(modelId: "claude-proxy/sonnet")
|
||||
let resolvedClaude = try ProviderFactory.createProvider(
|
||||
for: .custom(provider: claudeProvider),
|
||||
configuration: TachikomaConfiguration(loadFromEnvironment: false),
|
||||
)
|
||||
let compatibleClaude = try #require(resolvedClaude as? AnthropicCompatibleProvider)
|
||||
#expect(compatibleClaude.apiKey == "claude-provider-key")
|
||||
|
||||
let fableModel = LanguageModel.custom(
|
||||
provider: DynamicCustomProvider(modelId: "claude-proxy/claude-fable-5"),
|
||||
)
|
||||
#expect(fableModel.supportsStreaming == false)
|
||||
let resolvedFable = try ProviderFactory.createProvider(
|
||||
for: fableModel,
|
||||
configuration: TachikomaConfiguration(loadFromEnvironment: false),
|
||||
)
|
||||
let compatibleFable = try #require(resolvedFable as? AnthropicCompatibleProvider)
|
||||
#expect(compatibleFable.capabilities.supportsStreaming == false)
|
||||
|
||||
let directFableProvider = DynamicCustomProvider(
|
||||
modelId: "claude-fable-5",
|
||||
capabilities: ModelCapabilities(supportsStreaming: false),
|
||||
)
|
||||
let directFableModel = LanguageModel.custom(provider: directFableProvider)
|
||||
#expect(directFableModel.supportsStreaming == false)
|
||||
|
||||
let unrelatedFableNamedProvider = DynamicCustomProvider(
|
||||
modelId: "local-claude-fable-5-benchmark",
|
||||
capabilities: ModelCapabilities(supportsStreaming: true),
|
||||
)
|
||||
#expect(LanguageModel.custom(provider: unrelatedFableNamedProvider).supportsStreaming == true)
|
||||
|
||||
#expect(CustomProviderRegistry.shared.get("missing") == nil)
|
||||
}
|
||||
|
||||
@ -145,3 +194,25 @@ struct CustomProviderRegistryTests {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private final class DynamicCustomProvider: ModelProvider {
|
||||
let modelId: String
|
||||
let baseURL: String? = nil
|
||||
let apiKey: String? = nil
|
||||
let capabilities: ModelCapabilities
|
||||
|
||||
init(modelId: String, capabilities: ModelCapabilities = ModelCapabilities()) {
|
||||
self.modelId = modelId
|
||||
self.capabilities = capabilities
|
||||
}
|
||||
|
||||
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
|
||||
ProviderResponse(text: "")
|
||||
}
|
||||
|
||||
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,17 +4,9 @@ import Testing
|
||||
|
||||
struct GrokModelCatalogTests {
|
||||
private static let catalog: [Model.Grok] = [
|
||||
.grok4,
|
||||
.grok4FastReasoning,
|
||||
.grok4FastNonReasoning,
|
||||
.grokCodeFast1,
|
||||
.grok3,
|
||||
.grok3Mini,
|
||||
.grok2,
|
||||
.grok2Vision,
|
||||
.grok2Image,
|
||||
.grokVisionBeta,
|
||||
.grokBeta,
|
||||
.grok43,
|
||||
.grok420Reasoning,
|
||||
.grok420NonReasoning,
|
||||
]
|
||||
|
||||
private func requireModernPlatforms(_ body: () throws -> Void) rethrows {
|
||||
@ -39,6 +31,7 @@ struct GrokModelCatalogTests {
|
||||
let parsed = try ModelSelector.parseModel(model.modelId)
|
||||
#expect(parsed == .grok(model))
|
||||
}
|
||||
#expect(try ModelSelector.parseModel("grok-4.3-latest") == .grok(.grok43))
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,13 +45,69 @@ struct GrokModelCatalogTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Vision capability only flips on for vision/image Grok models`() {
|
||||
func `Grok model vision support matches current xAI catalog`() {
|
||||
self.requireModernPlatforms {
|
||||
let visionModels: Set<Model.Grok> = [.grok2Vision, .grok2Image, .grokVisionBeta]
|
||||
#expect(Model.grok(.grok43).supportsVision)
|
||||
#expect(Model.grok(.grok420Reasoning).supportsVision)
|
||||
#expect(Model.grok(.grok420NonReasoning).supportsVision)
|
||||
#expect(Model.grok(.grok420MultiAgent).supportsVision == false)
|
||||
#expect(Model.grok(.grok420MultiAgent).supportsTools == false)
|
||||
}
|
||||
}
|
||||
|
||||
for model in Self.catalog {
|
||||
let languageModel = Model.grok(model)
|
||||
#expect(languageModel.supportsVision == visionModels.contains(model))
|
||||
@Test
|
||||
func `ModelSelector preserves server-redirected Grok identifiers`() throws {
|
||||
try self.requireModernPlatforms {
|
||||
for id in [
|
||||
"grok-4-0709",
|
||||
"grok-3",
|
||||
"grok-2-1212",
|
||||
"grok-4-fast",
|
||||
"grok-code-fast-1",
|
||||
] {
|
||||
let parsed = try ModelSelector.parseModel(id)
|
||||
#expect(parsed == .grok(.custom(id)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ModelSelector keeps provider-qualified Grok slugs on xAI`() throws {
|
||||
try self.requireModernPlatforms {
|
||||
let parsed = try ModelSelector.parseModel("xai/grok-code-fast-1")
|
||||
|
||||
#expect(parsed == .grok(.custom("grok-code-fast-1")))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ModelSelector rejects unsupported Grok multi-agent identifiers`() {
|
||||
self.requireModernPlatforms {
|
||||
for id in [
|
||||
"grok-4.20-multi-agent-0309",
|
||||
"grok420multiagent",
|
||||
"xai/grok-4.20-multi-agent",
|
||||
] {
|
||||
#expect(throws: ModelValidationError.self) {
|
||||
_ = try ModelSelector.parseModel(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Grok provider rejects multi-agent until Responses routing exists`() {
|
||||
self.requireModernPlatforms {
|
||||
let config = TachikomaConfiguration(apiKeys: ["grok": "test-key"])
|
||||
|
||||
#expect(throws: TachikomaError.self) {
|
||||
_ = try ProviderFactory.createProvider(for: .grok(.grok420MultiAgent), configuration: config)
|
||||
}
|
||||
#expect(throws: TachikomaError.self) {
|
||||
_ = try GrokProvider(model: .grok420MultiAgent, configuration: config)
|
||||
}
|
||||
#expect(throws: TachikomaError.self) {
|
||||
_ = try GrokProvider(model: .custom("grok420multiagent"), configuration: config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ struct LanguageModelCoverageTests {
|
||||
func `OpenAI enum exposes properties`() {
|
||||
let models = LanguageModel.OpenAI.allCases
|
||||
#expect(!models.isEmpty)
|
||||
#expect(models.contains(.gpt5ChatLatest))
|
||||
for model in models {
|
||||
#expect(!model.modelId.isEmpty)
|
||||
_ = model.supportsVision
|
||||
@ -52,6 +53,13 @@ struct LanguageModelCoverageTests {
|
||||
#expect(model.contextLength > 0)
|
||||
}
|
||||
|
||||
for model in LanguageModel.MiniMax.allCases {
|
||||
#expect(!model.modelId.isEmpty)
|
||||
_ = model.supportsVision
|
||||
_ = model.supportsTools
|
||||
#expect(model.contextLength > 0)
|
||||
}
|
||||
|
||||
for model in LanguageModel.Ollama.allCases {
|
||||
#expect(!model.modelId.isEmpty)
|
||||
_ = model.supportsVision
|
||||
@ -68,12 +76,14 @@ struct LanguageModelCoverageTests {
|
||||
@Test
|
||||
func `LanguageModel top level switches`() {
|
||||
let baseModels: [LanguageModel] = [
|
||||
.openai(.gpt51),
|
||||
.anthropic(.opus45),
|
||||
.google(.gemini25Flash),
|
||||
.mistral(.large2),
|
||||
.groq(.mixtral8x7b),
|
||||
.grok(.grok4),
|
||||
.openai(.gpt55),
|
||||
.anthropic(.fable5),
|
||||
.google(.gemini35Flash),
|
||||
.mistral(.medium35),
|
||||
.groq(.llama3370b),
|
||||
.grok(.grok43),
|
||||
.minimax(.m27),
|
||||
.minimaxCN(.m27),
|
||||
.ollama(.llama33),
|
||||
.lmstudio(.gptOSS20B),
|
||||
.openRouter(modelId: "openrouter/alpha"),
|
||||
|
||||
@ -9,9 +9,9 @@ struct MinimalModernAPITests {
|
||||
@Test
|
||||
func `Model enum construction`() {
|
||||
// Test that model enums can be constructed
|
||||
let openaiModel = Model.openai(.gpt4o)
|
||||
let anthropicModel = Model.anthropic(.opus45)
|
||||
_ = Model.grok(.grok4)
|
||||
let openaiModel = Model.openai(.gpt55)
|
||||
let anthropicModel = Model.anthropic(.opus48)
|
||||
_ = Model.grok(.grok43)
|
||||
_ = Model.ollama(.llama33)
|
||||
|
||||
// Test that they can be used in a switch statement
|
||||
@ -35,13 +35,131 @@ struct MinimalModernAPITests {
|
||||
let defaultModel = Model.default
|
||||
// Should compile without errors
|
||||
switch defaultModel {
|
||||
case .anthropic(.opus45):
|
||||
case .anthropic(.opus48):
|
||||
break // Expected default
|
||||
default:
|
||||
Issue.record("Expected default to be Anthropic Opus 4.5")
|
||||
Issue.record("Expected default to be Anthropic Opus 4.8")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Streaming default value`() {
|
||||
#expect(Model.default.supportsStreaming == false)
|
||||
#expect(Model.defaultStreaming == .openai(.gpt55))
|
||||
#expect(Model.defaultStreaming.supportsStreaming == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent default model preserves execution default`() {
|
||||
let agent = Agent(name: "test", instructions: "test", context: ())
|
||||
|
||||
#expect(agent.model == .default)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent stream uses streaming fallback for execution default`() async throws {
|
||||
let seenModel = MinimalModelBox()
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { model, _ in
|
||||
seenModel.model = model
|
||||
return MinimalStreamingProvider(deltas: [
|
||||
.text("ok"),
|
||||
.done(finishReason: .stop),
|
||||
])
|
||||
}
|
||||
let agent = Agent(name: "test", instructions: "test", configuration: config, context: ())
|
||||
|
||||
let stream = try await agent.stream("hi")
|
||||
var received = ""
|
||||
for try await delta in stream where delta.type == .textDelta {
|
||||
received += delta.content ?? ""
|
||||
}
|
||||
|
||||
#expect(agent.model == .default)
|
||||
#expect(seenModel.model == .openai(.gpt55))
|
||||
#expect(!received.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent stream rejects explicit execution default`() async throws {
|
||||
let agent = Agent(name: "test", instructions: "test", model: .default, context: ())
|
||||
|
||||
await #expect(throws: TachikomaError.self) {
|
||||
_ = try await agent.stream("hi")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent stream rejects nonstreaming model after mutation`() async throws {
|
||||
let agent = Agent(name: "test", instructions: "test", context: ())
|
||||
agent.model = .anthropic(.fable5)
|
||||
|
||||
await #expect(throws: TachikomaError.self) {
|
||||
_ = try await agent.stream("hi")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent stream flushes buffered text on natural completion`() async throws {
|
||||
let provider = MinimalStreamingProvider(deltas: [
|
||||
.text("ok"),
|
||||
])
|
||||
let agent = Agent(
|
||||
name: "test",
|
||||
instructions: "test",
|
||||
model: .custom(provider: provider),
|
||||
context: (),
|
||||
)
|
||||
|
||||
let stream = try await agent.stream("hi")
|
||||
var received = ""
|
||||
for try await delta in stream where delta.type == .textDelta {
|
||||
received += delta.content ?? ""
|
||||
}
|
||||
|
||||
#expect(received == "ok")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent stream flushes buffered compatible text when done has no finish reason`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
MinimalStreamingProvider(deltas: [
|
||||
.text("ok"),
|
||||
.done(),
|
||||
])
|
||||
}
|
||||
let agent = Agent(
|
||||
name: "test",
|
||||
instructions: "test",
|
||||
model: .openaiCompatible(modelId: "gpt-compatible", baseURL: "https://example.test"),
|
||||
configuration: config,
|
||||
context: (),
|
||||
)
|
||||
|
||||
let stream = try await agent.stream("hi")
|
||||
var received = ""
|
||||
for try await delta in stream where delta.type == .textDelta {
|
||||
received += delta.content ?? ""
|
||||
}
|
||||
|
||||
#expect(received == "ok")
|
||||
#expect(agent.conversation.messages.map(\.content) == ["test", "hi", "ok"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent conversation uses agent configuration`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
MinimalStaticProvider(response: ProviderResponse(text: "configured", finishReason: .stop))
|
||||
}
|
||||
let agent = Agent(name: "test", instructions: "test", configuration: config, context: ())
|
||||
|
||||
let text = try await agent.conversation.continueConversation(using: .openai(.gpt55))
|
||||
|
||||
#expect(text == "configured")
|
||||
}
|
||||
|
||||
// MARK: - Tool System Tests
|
||||
|
||||
@Test
|
||||
@ -126,4 +244,757 @@ extension MinimalModernAPITests {
|
||||
#expect(message.role == .user)
|
||||
#expect(message.content == "Test")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Conversation preserves signed thinking messages`() {
|
||||
let conversation = Conversation()
|
||||
let signedThinking = ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("private reasoning")],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: [
|
||||
"anthropic.thinking.signature": "sig",
|
||||
"anthropic.thinking.type": "thinking",
|
||||
]),
|
||||
)
|
||||
|
||||
conversation.replaceModelMessages([.user("hi"), signedThinking, .assistant("hello")])
|
||||
|
||||
let messages = conversation.getModelMessages()
|
||||
#expect(messages.count == 3)
|
||||
#expect(messages[1] == signedThinking)
|
||||
#expect(conversation.messages[1].content == "private reasoning")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Conversation merge preserves messages appended after snapshot`() {
|
||||
let conversation = Conversation()
|
||||
conversation.addUserMessage("original")
|
||||
let snapshotCount = conversation.messages.count
|
||||
conversation.addUserMessage("concurrent")
|
||||
|
||||
conversation.mergeGeneratedMessages(
|
||||
[.user("original"), .assistant("generated")],
|
||||
replacingPrefixCount: snapshotCount,
|
||||
)
|
||||
|
||||
let messages = conversation.getModelMessages()
|
||||
#expect(messages.map(\.role) == [.user, .assistant, .user])
|
||||
if case let .text(text) = messages[2].content.first {
|
||||
#expect(text == "concurrent")
|
||||
} else {
|
||||
Issue.record("Expected preserved concurrent user message")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Conversation refusal rollback preserves messages appended after snapshot`() {
|
||||
let conversation = Conversation()
|
||||
conversation.addUserMessage("blocked")
|
||||
let snapshotIDs = conversation.messages.map(\.id)
|
||||
conversation.addUserMessage("concurrent")
|
||||
|
||||
let didReplace = conversation.replaceModelMessages([], validatingSnapshotIDs: snapshotIDs)
|
||||
|
||||
#expect(didReplace == true)
|
||||
#expect(conversation.messages.map(\.content) == ["concurrent"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Conversation lock removes cancelled waiters`() async throws {
|
||||
let conversation = Conversation()
|
||||
let probe = ConversationLockProbe()
|
||||
|
||||
let first = Task {
|
||||
try await conversation.withContinuationLock {
|
||||
await probe.markFirstStarted()
|
||||
await probe.waitForRelease()
|
||||
}
|
||||
}
|
||||
|
||||
await probe.waitUntilFirstStarted()
|
||||
|
||||
let second = Task {
|
||||
try await conversation.withContinuationLock {
|
||||
await probe.markSecondRan()
|
||||
}
|
||||
}
|
||||
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
second.cancel()
|
||||
|
||||
do {
|
||||
try await second.value
|
||||
Issue.record("Expected queued waiter to be cancelled")
|
||||
} catch is CancellationError {
|
||||
// Expected
|
||||
}
|
||||
|
||||
await probe.releaseFirst()
|
||||
try await first.value
|
||||
|
||||
try await conversation.withContinuationLock {
|
||||
await probe.markThirdRan()
|
||||
}
|
||||
|
||||
#expect(await probe.secondRan == false)
|
||||
#expect(await probe.thirdRan == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Conversation append generated messages preserves concurrent appends`() {
|
||||
let conversation = Conversation()
|
||||
conversation.addUserMessage("original")
|
||||
let anchorID = conversation.messages[0].id
|
||||
conversation.addUserMessage("concurrent")
|
||||
|
||||
conversation.appendGeneratedMessages([.assistant("generated")], afterMessageID: anchorID)
|
||||
|
||||
#expect(conversation.messages.map(\.content) == ["original", "generated", "concurrent"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Conversation continue persists generated message from empty history`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
MinimalStaticProvider(response: ProviderResponse(text: "hello", finishReason: .stop))
|
||||
}
|
||||
let conversation = Conversation(configuration: config)
|
||||
|
||||
let text = try await conversation.continueConversation(using: .anthropic(.opus48))
|
||||
|
||||
#expect(text == "hello")
|
||||
#expect(conversation.messages.map(\.content) == ["hello"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Conversation continue rolls back refused trailing user turn`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
MinimalStaticProvider(response: ProviderResponse(text: "Refused by policy", finishReason: .contentFilter))
|
||||
}
|
||||
let conversation = Conversation(configuration: config)
|
||||
conversation.addUserMessage("blocked")
|
||||
|
||||
let text = try await conversation.continueConversation(using: .anthropic(.fable5))
|
||||
|
||||
#expect(text.isEmpty)
|
||||
#expect(conversation.messages.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Conversation continue preserves completed tool history after late refusal`() async throws {
|
||||
let provider = MinimalSequenceProvider(responses: [
|
||||
ProviderResponse(
|
||||
text: "",
|
||||
finishReason: .toolCalls,
|
||||
toolCalls: [AgentToolCall(id: "call-1", name: "side_effect", arguments: [:])],
|
||||
),
|
||||
ProviderResponse(text: "Refused by policy", finishReason: .contentFilter),
|
||||
])
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in provider }
|
||||
let conversation = Conversation(configuration: config)
|
||||
conversation.addUserMessage("do it")
|
||||
|
||||
let text = try await conversation.continueConversation(
|
||||
using: .anthropic(.fable5),
|
||||
tools: [sideEffectTool],
|
||||
maxSteps: 2,
|
||||
)
|
||||
|
||||
#expect(text.isEmpty)
|
||||
let messages = conversation.getModelMessages()
|
||||
#expect(messages.map(\.role) == [.user, .assistant, .tool])
|
||||
#expect(messages[0].content == [.text("do it")])
|
||||
#expect(messages[1].content.contains { part in
|
||||
if case let .toolCall(toolCall) = part {
|
||||
return toolCall.id == "call-1"
|
||||
}
|
||||
return false
|
||||
})
|
||||
#expect(messages[2].content.contains { part in
|
||||
if case let .toolResult(toolResult) = part {
|
||||
return toolResult.toolCallId == "call-1"
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent stream rejects non-streaming model before mutating conversation`() async throws {
|
||||
let agent = Agent(
|
||||
name: "test",
|
||||
instructions: "test",
|
||||
model: .anthropic(.fable5),
|
||||
context: (),
|
||||
)
|
||||
|
||||
await #expect(throws: TachikomaError.self) {
|
||||
_ = try await agent.stream("hi")
|
||||
}
|
||||
|
||||
#expect(agent.conversation.messages.map(\.content) == ["test"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Conversation streaming rolls back refused trailing user turn`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
MinimalStreamingProvider(deltas: [
|
||||
.text("partial"),
|
||||
.done(finishReason: .contentFilter),
|
||||
])
|
||||
}
|
||||
let conversation = Conversation(configuration: config)
|
||||
conversation.addUserMessage("blocked")
|
||||
|
||||
let stream = try await conversation.continueConversationStreaming(using: .openai(.gpt55))
|
||||
var received = ""
|
||||
for try await chunk in stream {
|
||||
received += chunk
|
||||
}
|
||||
|
||||
#expect(received == "partial")
|
||||
#expect(conversation.messages.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Conversation streaming flushes buffered text on natural completion`() async throws {
|
||||
let provider = MinimalStreamingProvider(deltas: [
|
||||
.text("ok"),
|
||||
])
|
||||
let conversation = Conversation(configuration: TachikomaConfiguration(loadFromEnvironment: false))
|
||||
|
||||
let stream = try await conversation.continueConversationStreaming(using: .custom(provider: provider))
|
||||
var received = ""
|
||||
for try await chunk in stream {
|
||||
received += chunk
|
||||
}
|
||||
|
||||
#expect(received == "ok")
|
||||
#expect(conversation.messages.map(\.content) == ["ok"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Conversation streaming flushes buffered compatible text when done has no finish reason`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
MinimalStreamingProvider(deltas: [
|
||||
.text("ok"),
|
||||
.done(),
|
||||
])
|
||||
}
|
||||
let conversation = Conversation(configuration: config)
|
||||
|
||||
let stream = try await conversation.continueConversationStreaming(
|
||||
using: .openaiCompatible(modelId: "gpt-compatible", baseURL: "https://example.test"),
|
||||
)
|
||||
var received = ""
|
||||
for try await chunk in stream {
|
||||
received += chunk
|
||||
}
|
||||
|
||||
#expect(received == "ok")
|
||||
#expect(conversation.messages.map(\.content) == ["ok"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Conversation streaming flushes compatible text when stream ends without done`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
MinimalStreamingProvider(deltas: [
|
||||
.text("partial"),
|
||||
])
|
||||
}
|
||||
let conversation = Conversation(configuration: config)
|
||||
|
||||
let stream = try await conversation.continueConversationStreaming(
|
||||
using: .openaiCompatible(modelId: "gpt-compatible", baseURL: "https://example.test"),
|
||||
)
|
||||
|
||||
var received = ""
|
||||
for try await chunk in stream {
|
||||
received += chunk
|
||||
}
|
||||
|
||||
#expect(received == "partial")
|
||||
#expect(conversation.messages.map(\.content) == ["partial"])
|
||||
}
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
private struct AgentRefusalTests {
|
||||
@Test
|
||||
func `Agent execute rolls back refused user turn`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
MinimalStaticProvider(response: ProviderResponse(text: "Refused by policy", finishReason: .contentFilter))
|
||||
}
|
||||
|
||||
let agent = Agent(
|
||||
name: "test",
|
||||
instructions: "test",
|
||||
model: .anthropic(.fable5),
|
||||
configuration: config,
|
||||
context: (),
|
||||
)
|
||||
|
||||
let response = try await agent.execute("blocked")
|
||||
|
||||
#expect(response.text.isEmpty)
|
||||
#expect(response.finishReason == .contentFilter)
|
||||
#expect(agent.conversation.messages.map(\.content) == ["test"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent stream stays incremental by default when terminal content filter arrives`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
MinimalStreamingProvider(deltas: [
|
||||
.text("partial"),
|
||||
.done(finishReason: .contentFilter),
|
||||
])
|
||||
}
|
||||
|
||||
let agent = Agent(
|
||||
name: "test",
|
||||
instructions: "test",
|
||||
model: .openai(.gpt55),
|
||||
configuration: config,
|
||||
context: (),
|
||||
)
|
||||
|
||||
let stream = try await agent.stream("blocked")
|
||||
var received: [TextStreamDelta] = []
|
||||
for try await delta in stream {
|
||||
received.append(delta)
|
||||
}
|
||||
|
||||
#expect(received.contains { $0.type == .textDelta && $0.content == "partial" })
|
||||
#expect(received.contains { $0.type == .done && $0.finishReason == .contentFilter })
|
||||
#expect(agent.conversation.messages.map(\.content) == ["test"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent stream explicit terminal buffering errors when stream ends without done`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
MinimalStreamingProvider(deltas: [
|
||||
.text("partial"),
|
||||
])
|
||||
}
|
||||
|
||||
let agent = Agent(
|
||||
name: "test",
|
||||
instructions: "test",
|
||||
model: .openaiCompatible(modelId: "gpt-compatible", baseURL: "https://example.test"),
|
||||
settings: GenerationSettings(streamBuffering: .untilTerminal),
|
||||
configuration: config,
|
||||
context: (),
|
||||
)
|
||||
|
||||
let stream = try await agent.stream("hi")
|
||||
do {
|
||||
for try await _ in stream {}
|
||||
Issue.record("Expected missing terminal status error")
|
||||
} catch let error as TachikomaError {
|
||||
guard case let .apiError(message) = error else {
|
||||
Issue.record("Expected apiError, got \(error)")
|
||||
return
|
||||
}
|
||||
#expect(message.contains("completion status"))
|
||||
}
|
||||
|
||||
#expect(!agent.conversation.messages.map(\.content).contains("partial"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent stream explicit terminal buffering suppresses Azure OpenAI refusals`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
MinimalStreamingProvider(deltas: [
|
||||
.text("partial"),
|
||||
.done(finishReason: .contentFilter),
|
||||
])
|
||||
}
|
||||
|
||||
let agent = Agent(
|
||||
name: "test",
|
||||
instructions: "test",
|
||||
model: .azureOpenAI(deployment: "gpt-compatible", endpoint: "https://example.openai.azure.com"),
|
||||
settings: GenerationSettings(streamBuffering: .untilTerminal),
|
||||
configuration: config,
|
||||
context: (),
|
||||
)
|
||||
|
||||
let stream = try await agent.stream("blocked")
|
||||
var received: [TextStreamDelta] = []
|
||||
for try await delta in stream {
|
||||
received.append(delta)
|
||||
}
|
||||
|
||||
#expect(!received.contains { $0.type == .textDelta && $0.content == "partial" })
|
||||
#expect(received.contains { $0.type == .done && $0.finishReason == .contentFilter })
|
||||
#expect(agent.conversation.messages.map(\.content) == ["test"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent stream explicit terminal buffering suppresses Google refusals`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
MinimalStreamingProvider(deltas: [
|
||||
.text("partial"),
|
||||
.done(finishReason: .contentFilter),
|
||||
])
|
||||
}
|
||||
|
||||
let agent = Agent(
|
||||
name: "test",
|
||||
instructions: "test",
|
||||
model: .google(.gemini25Flash),
|
||||
settings: GenerationSettings(streamBuffering: .untilTerminal),
|
||||
configuration: config,
|
||||
context: (),
|
||||
)
|
||||
|
||||
let stream = try await agent.stream("blocked")
|
||||
var received: [TextStreamDelta] = []
|
||||
for try await delta in stream {
|
||||
received.append(delta)
|
||||
}
|
||||
|
||||
#expect(!received.contains { $0.type == .textDelta && $0.content == "partial" })
|
||||
#expect(received.contains { $0.type == .done && $0.finishReason == .contentFilter })
|
||||
#expect(agent.conversation.messages.map(\.content) == ["test"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent stream explicit terminal buffering suppresses registered custom OpenAI refusals`() async throws {
|
||||
try await self.withRegisteredCustomProvider(
|
||||
"""
|
||||
{
|
||||
"customProviders": {
|
||||
"proxy": {
|
||||
"type": "openai",
|
||||
"options": { "baseURL": "https://example.test/v1" }
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
) {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
MinimalStreamingProvider(
|
||||
modelId: "proxy/gpt-compatible",
|
||||
deltas: [
|
||||
.text("partial"),
|
||||
.done(finishReason: .contentFilter),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
let agent = Agent(
|
||||
name: "test",
|
||||
instructions: "test",
|
||||
model: .custom(provider: MinimalStreamingProvider(modelId: "proxy/gpt-compatible", deltas: [])),
|
||||
settings: GenerationSettings(streamBuffering: .untilTerminal),
|
||||
configuration: config,
|
||||
context: (),
|
||||
)
|
||||
|
||||
let stream = try await agent.stream("blocked")
|
||||
var received: [TextStreamDelta] = []
|
||||
for try await delta in stream {
|
||||
received.append(delta)
|
||||
}
|
||||
|
||||
#expect(!received.contains { $0.type == .textDelta && $0.content == "partial" })
|
||||
#expect(received.contains { $0.type == .done && $0.finishReason == .contentFilter })
|
||||
#expect(agent.conversation.messages.map(\.content) == ["test"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent stream releases continuation gate when consumer stops early`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in
|
||||
StallingStreamingProvider()
|
||||
}
|
||||
let agent = Agent(
|
||||
name: "test",
|
||||
instructions: "test",
|
||||
model: .custom(provider: StallingStreamingProvider()),
|
||||
configuration: config,
|
||||
context: (),
|
||||
)
|
||||
|
||||
do {
|
||||
let stream = try await agent.stream("first")
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
let firstDelta = try await iterator.next()
|
||||
#expect(firstDelta?.type == .textDelta)
|
||||
#expect(firstDelta?.content == "partial")
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
|
||||
let response = try await withTimeout(0.2) {
|
||||
try await agent.execute("second")
|
||||
}
|
||||
#expect(response.text == "after")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Agent execute preserves completed tool history after late refusal`() async throws {
|
||||
let provider = MinimalSequenceProvider(responses: [
|
||||
ProviderResponse(
|
||||
text: "",
|
||||
finishReason: .toolCalls,
|
||||
toolCalls: [AgentToolCall(id: "call-1", name: "side_effect", arguments: [:])],
|
||||
),
|
||||
ProviderResponse(text: "Refused by policy", finishReason: .contentFilter),
|
||||
])
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setProviderFactoryOverride { _, _ in provider }
|
||||
|
||||
let agent = Agent(
|
||||
name: "test",
|
||||
instructions: "test",
|
||||
model: .anthropic(.fable5),
|
||||
tools: [sideEffectTool],
|
||||
configuration: config,
|
||||
context: (),
|
||||
)
|
||||
|
||||
let response = try await agent.execute("do it")
|
||||
|
||||
#expect(response.text.isEmpty)
|
||||
#expect(response.finishReason == .contentFilter)
|
||||
let messages = agent.conversation.getModelMessages()
|
||||
#expect(messages.map(\.role) == [.system, .user, .assistant, .tool])
|
||||
#expect(messages[1].content == [.text("do it")])
|
||||
#expect(messages[2].content.contains { part in
|
||||
if case let .toolCall(toolCall) = part {
|
||||
return toolCall.id == "call-1"
|
||||
}
|
||||
return false
|
||||
})
|
||||
#expect(messages[3].content.contains { part in
|
||||
if case let .toolResult(toolResult) = part {
|
||||
return toolResult.toolCallId == "call-1"
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
private func withRegisteredCustomProvider(
|
||||
_ configJSON: String,
|
||||
operation: () async throws -> Void,
|
||||
) async throws {
|
||||
let originalProfile = TachikomaConfiguration.profileDirectoryName
|
||||
let tempProfile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let emptyProfile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
|
||||
try FileManager.default.createDirectory(at: tempProfile, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: emptyProfile, withIntermediateDirectories: true)
|
||||
try configJSON.write(to: tempProfile.appendingPathComponent("config.json"), atomically: true, encoding: .utf8)
|
||||
try #"{"customProviders":{}}"#.write(
|
||||
to: emptyProfile.appendingPathComponent("config.json"),
|
||||
atomically: true,
|
||||
encoding: .utf8,
|
||||
)
|
||||
|
||||
TachikomaConfiguration.profileDirectoryName = tempProfile.path
|
||||
CustomProviderRegistry.shared.loadFromProfile()
|
||||
|
||||
do {
|
||||
try await operation()
|
||||
TachikomaConfiguration.profileDirectoryName = emptyProfile.path
|
||||
CustomProviderRegistry.shared.loadFromProfile()
|
||||
TachikomaConfiguration.profileDirectoryName = originalProfile
|
||||
} catch {
|
||||
TachikomaConfiguration.profileDirectoryName = emptyProfile.path
|
||||
CustomProviderRegistry.shared.loadFromProfile()
|
||||
TachikomaConfiguration.profileDirectoryName = originalProfile
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StallingStreamingProvider: ModelProvider {
|
||||
let modelId = "stalling-streaming"
|
||||
let baseURL: String? = nil
|
||||
let apiKey: String? = nil
|
||||
let capabilities = ModelCapabilities(supportsStreaming: true)
|
||||
|
||||
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
|
||||
ProviderResponse(text: "after")
|
||||
}
|
||||
|
||||
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.yield(.text("partial"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor ConversationLockProbe {
|
||||
var secondRan = false
|
||||
var thirdRan = false
|
||||
private var firstStarted = false
|
||||
private var firstStartedWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func markFirstStarted() {
|
||||
self.firstStarted = true
|
||||
let waiters = self.firstStartedWaiters
|
||||
self.firstStartedWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilFirstStarted() async {
|
||||
if self.firstStarted {
|
||||
return
|
||||
}
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
self.firstStartedWaiters.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForRelease() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.releaseWaiters.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func releaseFirst() {
|
||||
let waiters = self.releaseWaiters
|
||||
self.releaseWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func markSecondRan() {
|
||||
self.secondRan = true
|
||||
}
|
||||
|
||||
func markThirdRan() {
|
||||
self.thirdRan = true
|
||||
}
|
||||
}
|
||||
|
||||
private final class MinimalModelBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _model: LanguageModel?
|
||||
|
||||
var model: LanguageModel? {
|
||||
get {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self._model
|
||||
}
|
||||
set {
|
||||
self.lock.lock()
|
||||
self._model = newValue
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MinimalStaticProvider: ModelProvider {
|
||||
let modelId = "minimal-static"
|
||||
let baseURL: String? = nil
|
||||
let apiKey: String? = nil
|
||||
let capabilities = ModelCapabilities()
|
||||
let response: ProviderResponse
|
||||
|
||||
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
|
||||
self.response
|
||||
}
|
||||
|
||||
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MinimalStreamingProvider: ModelProvider {
|
||||
let modelId: String
|
||||
let baseURL: String? = nil
|
||||
let apiKey: String? = nil
|
||||
let capabilities = ModelCapabilities(supportsStreaming: true)
|
||||
let deltas: [TextStreamDelta]
|
||||
|
||||
init(modelId: String = "minimal-streaming", deltas: [TextStreamDelta]) {
|
||||
self.modelId = modelId
|
||||
self.deltas = deltas
|
||||
}
|
||||
|
||||
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
|
||||
ProviderResponse(text: "")
|
||||
}
|
||||
|
||||
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
for delta in self.deltas {
|
||||
continuation.yield(delta)
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MinimalSequenceProvider: ModelProvider {
|
||||
let modelId = "minimal-sequence"
|
||||
let baseURL: String? = nil
|
||||
let apiKey: String? = nil
|
||||
let capabilities = ModelCapabilities()
|
||||
private let queue: MinimalResponseQueue
|
||||
|
||||
init(responses: [ProviderResponse]) {
|
||||
self.queue = MinimalResponseQueue(responses: responses)
|
||||
}
|
||||
|
||||
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
|
||||
self.queue.next()
|
||||
}
|
||||
|
||||
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class MinimalResponseQueue: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var responses: [ProviderResponse]
|
||||
|
||||
init(responses: [ProviderResponse]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
func next() -> ProviderResponse {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
|
||||
if self.responses.count > 1 {
|
||||
return self.responses.removeFirst()
|
||||
}
|
||||
return self.responses[0]
|
||||
}
|
||||
}
|
||||
|
||||
private let sideEffectTool = Tachikoma.createTool(
|
||||
name: "side_effect",
|
||||
description: "Records an external action",
|
||||
parameters: [],
|
||||
required: [],
|
||||
) { _ in
|
||||
AnyAgentToolValue(string: "done")
|
||||
}
|
||||
|
||||
@ -7,8 +7,11 @@ enum ModelCapabilitiesTests {
|
||||
@Test
|
||||
func `GPT-5 models exclude temperature and topP`() {
|
||||
let models: [LanguageModel] = [
|
||||
.openai(.gpt52),
|
||||
.openai(.gpt51),
|
||||
.openai(.gpt5ChatLatest),
|
||||
.openai(.gpt55),
|
||||
.openai(.gpt54),
|
||||
.openai(.gpt54Mini),
|
||||
.openai(.gpt54Nano),
|
||||
.openai(.gpt5),
|
||||
.openai(.gpt5Mini),
|
||||
.openai(.gpt5Nano),
|
||||
@ -27,34 +30,24 @@ enum ModelCapabilitiesTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Gemini 3 Flash supports thinking config options`() {
|
||||
let capabilities = ModelCapabilityRegistry.shared.capabilities(for: .google(.gemini3Flash))
|
||||
func `chat-latest does not advertise audio input`() {
|
||||
#expect(LanguageModel.openai(.chatLatest).supportsVision)
|
||||
#expect(LanguageModel.openai(.chatLatest).supportsTools)
|
||||
#expect(LanguageModel.openai(.chatLatest).supportsAudioInput == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Gemini 3_5 Flash supports thinking config options`() {
|
||||
let capabilities = ModelCapabilityRegistry.shared.capabilities(for: .google(.gemini35Flash))
|
||||
#expect(capabilities.supportsTopK)
|
||||
#expect(capabilities.supportedProviderOptions.supportsThinkingConfig)
|
||||
#expect(capabilities.supportedProviderOptions.supportsSafetySettings)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Reasoning models have forced temperature`() {
|
||||
let model = LanguageModel.openai(.o4Mini)
|
||||
let capabilities = ModelCapabilityRegistry.shared.capabilities(for: model)
|
||||
|
||||
#expect(!capabilities.supportsTemperature)
|
||||
#expect(!capabilities.supportsTopP)
|
||||
#expect(capabilities.forcedTemperature == 1.0)
|
||||
#expect(capabilities.excludedParameters.contains("temperature"))
|
||||
#expect(capabilities.excludedParameters.contains("topP"))
|
||||
#expect(capabilities.supportedProviderOptions.supportsReasoningEffort)
|
||||
#expect(capabilities.supportedProviderOptions.supportsPreviousResponseId)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `GPT-4 models support standard parameters`() {
|
||||
func `Custom OpenAI models support standard parameters`() {
|
||||
let models: [LanguageModel] = [
|
||||
.openai(.gpt4o),
|
||||
.openai(.gpt4oMini),
|
||||
.openai(.gpt41),
|
||||
.openai(.gpt4Turbo),
|
||||
.openai(.custom("custom-openai")),
|
||||
]
|
||||
|
||||
for model in models {
|
||||
@ -65,17 +58,16 @@ enum ModelCapabilitiesTests {
|
||||
#expect(capabilities.supportsMaxTokens)
|
||||
#expect(capabilities.supportsFrequencyPenalty)
|
||||
#expect(capabilities.supportsPresencePenalty)
|
||||
#expect(capabilities.supportedProviderOptions.supportsParallelToolCalls)
|
||||
#expect(capabilities.supportedProviderOptions.supportsResponseFormat)
|
||||
#expect(capabilities.supportedProviderOptions.supportsLogprobs)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Claude models support thinking`() {
|
||||
let models: [LanguageModel] = [
|
||||
.anthropic(.fable5),
|
||||
.anthropic(.opus47),
|
||||
.anthropic(.opus4),
|
||||
.anthropic(.sonnet4),
|
||||
.anthropic(.sonnet46),
|
||||
.anthropic(.sonnet45),
|
||||
.anthropic(.haiku45),
|
||||
]
|
||||
@ -88,9 +80,37 @@ enum ModelCapabilitiesTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Claude Fable 5 and Opus 4_7 plus 4_8 advertise adaptive thinking without sampling options`() {
|
||||
for model in [LanguageModel.anthropic(.fable5), .anthropic(.opus47), .anthropic(.opus48)] {
|
||||
let capabilities = ModelCapabilityRegistry.shared.capabilities(for: model)
|
||||
|
||||
#expect(!capabilities.supportsTemperature)
|
||||
#expect(!capabilities.supportsTopP)
|
||||
#expect(!capabilities.supportsTopK)
|
||||
#expect(capabilities.excludedParameters.contains("temperature"))
|
||||
#expect(capabilities.excludedParameters.contains("topP"))
|
||||
#expect(capabilities.excludedParameters.contains("topK"))
|
||||
#expect(capabilities.supportedProviderOptions.supportsThinking)
|
||||
#expect(capabilities.supportedProviderOptions.supportsCacheControl)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Custom Anthropic models keep thinking options by default`() {
|
||||
let capabilities = ModelCapabilityRegistry.shared
|
||||
.capabilities(for: .anthropic(.custom("claude-opus-latest")))
|
||||
|
||||
#expect(capabilities.supportedProviderOptions.supportsThinking)
|
||||
#expect(capabilities.supportedProviderOptions.supportsCacheControl)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Google models support topK and thinking`() {
|
||||
let models: [LanguageModel] = [
|
||||
.google(.gemini35Flash),
|
||||
.google(.gemini31ProPreview),
|
||||
.google(.gemini31FlashLite),
|
||||
.google(.gemini25Pro),
|
||||
.google(.gemini25Flash),
|
||||
.google(.gemini25FlashLite),
|
||||
@ -108,8 +128,9 @@ enum ModelCapabilitiesTests {
|
||||
@Test
|
||||
func `Mistral models support safe mode`() {
|
||||
let models: [LanguageModel] = [
|
||||
.mistral(.large2),
|
||||
.mistral(.codestral),
|
||||
.mistral(.largeLatest),
|
||||
.mistral(.medium35),
|
||||
.mistral(.codestralLatest),
|
||||
]
|
||||
|
||||
for model in models {
|
||||
@ -122,8 +143,8 @@ enum ModelCapabilitiesTests {
|
||||
@Test
|
||||
func `Groq models support speed level`() {
|
||||
let models: [LanguageModel] = [
|
||||
.groq(.llama3170b),
|
||||
.groq(.llama370b),
|
||||
.groq(.llama3370b),
|
||||
.groq(.llama4Maverick),
|
||||
]
|
||||
|
||||
for model in models {
|
||||
@ -136,8 +157,8 @@ enum ModelCapabilitiesTests {
|
||||
@Test
|
||||
func `Grok models support fun mode`() {
|
||||
let models: [LanguageModel] = [
|
||||
.grok(.grok4),
|
||||
.grok(.grok3),
|
||||
.grok(.grok43),
|
||||
.grok(.grok420Reasoning),
|
||||
]
|
||||
|
||||
for model in models {
|
||||
@ -151,7 +172,7 @@ enum ModelCapabilitiesTests {
|
||||
|
||||
struct SettingsValidationTests {
|
||||
@Test
|
||||
func `Validate settings for GPT-5.1`() {
|
||||
func `Validate settings for GPT-5.5`() {
|
||||
let settings = GenerationSettings(
|
||||
maxTokens: 1000,
|
||||
temperature: 0.7,
|
||||
@ -166,7 +187,7 @@ enum ModelCapabilitiesTests {
|
||||
),
|
||||
)
|
||||
|
||||
let validated = settings.validated(for: .openai(.gpt51))
|
||||
let validated = settings.validated(for: .openai(.gpt55))
|
||||
|
||||
#expect(validated.maxTokens == 1000)
|
||||
#expect(validated.temperature == nil) // Excluded
|
||||
@ -178,7 +199,20 @@ enum ModelCapabilitiesTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Validate settings for O3 with forced temperature`() {
|
||||
func `Validate settings preserves stream buffering mode`() {
|
||||
let settings = GenerationSettings(
|
||||
temperature: 0.7,
|
||||
streamBuffering: .untilTerminal,
|
||||
)
|
||||
|
||||
let validated = settings.validated(for: .openai(.gpt55))
|
||||
|
||||
#expect(validated.temperature == nil)
|
||||
#expect(validated.streamBuffering == .untilTerminal)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Validate settings for GPT-5 strips unsupported options`() {
|
||||
let settings = GenerationSettings(
|
||||
temperature: 0.5,
|
||||
topP: 0.8,
|
||||
@ -190,16 +224,16 @@ enum ModelCapabilitiesTests {
|
||||
),
|
||||
)
|
||||
|
||||
let validated = settings.validated(for: LanguageModel.openai(.o4Mini))
|
||||
let validated = settings.validated(for: LanguageModel.openai(.gpt55))
|
||||
|
||||
#expect(validated.temperature == 1.0) // Forced to 1.0
|
||||
#expect(validated.temperature == nil) // Excluded
|
||||
#expect(validated.topP == nil) // Excluded
|
||||
#expect(validated.providerOptions.openai?.reasoningEffort == .high) // Kept
|
||||
#expect(validated.providerOptions.openai?.verbosity == nil) // Removed
|
||||
#expect(validated.providerOptions.openai?.reasoningEffort == nil) // Removed
|
||||
#expect(validated.providerOptions.openai?.verbosity == .medium) // Kept
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Validate settings for GPT-4`() {
|
||||
func `Validate settings for custom OpenAI model`() {
|
||||
let settings = GenerationSettings(
|
||||
maxTokens: 2000,
|
||||
temperature: 0.8,
|
||||
@ -216,17 +250,13 @@ enum ModelCapabilitiesTests {
|
||||
),
|
||||
)
|
||||
|
||||
let validated = settings.validated(for: .openai(.gpt4o))
|
||||
let validated = settings.validated(for: .openai(.custom("custom-openai")))
|
||||
|
||||
#expect(validated.maxTokens == 2000)
|
||||
#expect(validated.temperature == 0.8)
|
||||
#expect(validated.topP == 0.95)
|
||||
#expect(validated.frequencyPenalty == 0.2)
|
||||
#expect(validated.presencePenalty == 0.1)
|
||||
#expect(validated.providerOptions.openai?.parallelToolCalls == true)
|
||||
#expect(validated.providerOptions.openai?.responseFormat == .json)
|
||||
#expect(validated.providerOptions.openai?.logprobs == true)
|
||||
#expect(validated.providerOptions.openai?.topLogprobs == 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -252,6 +282,72 @@ enum ModelCapabilitiesTests {
|
||||
// OpenAI options remain unfiltered (they won't be used by Anthropic provider)
|
||||
#expect(validated.providerOptions.openai?.verbosity == .high)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Validate Anthropic options keeps adaptive thinking for Opus 4_8`() {
|
||||
let settings = GenerationSettings(
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
providerOptions: .init(
|
||||
anthropic: .init(
|
||||
thinking: .enabled(budgetTokens: 3000),
|
||||
cacheControl: .persistent,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
let validated = settings.validated(for: LanguageModel.anthropic(.opus48))
|
||||
|
||||
#expect(validated.temperature == nil)
|
||||
#expect(validated.topP == nil)
|
||||
#expect(validated.topK == nil)
|
||||
#expect(validated.providerOptions.anthropic?.thinking != nil)
|
||||
#expect(validated.providerOptions.anthropic?.cacheControl == .persistent)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Validate Anthropic-compatible Fable strips unsupported sampling`() {
|
||||
let settings = GenerationSettings(
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
providerOptions: .init(
|
||||
anthropic: .init(thinking: .adaptive),
|
||||
),
|
||||
)
|
||||
|
||||
let validated = settings.validated(for: LanguageModel.anthropicCompatible(
|
||||
modelId: "claude-fable-5",
|
||||
baseURL: "https://example.test",
|
||||
))
|
||||
|
||||
#expect(validated.temperature == nil)
|
||||
#expect(validated.topP == nil)
|
||||
#expect(validated.topK == nil)
|
||||
#expect(validated.providerOptions.anthropic?.thinking != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Validate direct custom Fable strips unsupported sampling`() {
|
||||
let settings = GenerationSettings(
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
providerOptions: .init(
|
||||
anthropic: .init(thinking: .adaptive),
|
||||
),
|
||||
)
|
||||
|
||||
let validated = settings.validated(
|
||||
for: LanguageModel.anthropic(.custom("anthropic.claude-fable-5")),
|
||||
)
|
||||
|
||||
#expect(validated.temperature == nil)
|
||||
#expect(validated.topP == nil)
|
||||
#expect(validated.topK == nil)
|
||||
#expect(validated.providerOptions.anthropic?.thinking != nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomModelTests {
|
||||
@ -307,8 +403,8 @@ enum ModelCapabilitiesTests {
|
||||
@Test
|
||||
func `Concurrent capability access`() async {
|
||||
let models: [LanguageModel] = [
|
||||
.openai(.gpt51),
|
||||
.openai(.gpt4o),
|
||||
.openai(.gpt54),
|
||||
.openai(.gpt55),
|
||||
.anthropic(.opus4),
|
||||
.google(.gemini25Flash),
|
||||
]
|
||||
|
||||
@ -9,21 +9,53 @@ struct ModelParsingTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse GPT-5.1 base model`() {
|
||||
let parsed = LanguageModel.parse(from: "gpt-5.1")
|
||||
#expect(parsed == .openai(.gpt51))
|
||||
func `parse GPT-5.5 base model`() {
|
||||
let parsed = LanguageModel.parse(from: "gpt-5.5")
|
||||
#expect(parsed == .openai(.gpt55))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse GPT-5.2 base model`() {
|
||||
let parsed = LanguageModel.parse(from: "gpt-5.2")
|
||||
#expect(parsed == .openai(.gpt52))
|
||||
func `parse chat latest OpenAI alias`() throws {
|
||||
#expect(LanguageModel.parse(from: "chat-latest") == .openai(.chatLatest))
|
||||
#expect(LanguageModel.parse(from: "gpt-5-chat-latest") == .openai(.gpt5ChatLatest))
|
||||
#expect(LanguageModel.parse(from: "openai/chat-latest") == .openai(.chatLatest))
|
||||
#expect(LanguageModel.parse(from: "openai/gpt-5-chat-latest") == .openai(.gpt5ChatLatest))
|
||||
#expect(try ModelSelector.parseModel("openai/chat-latest") == .openai(.chatLatest))
|
||||
#expect(try ModelSelector.parseModel("openai/gpt-5-chat-latest") == .openai(.gpt5ChatLatest))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse GPT-5.1 nano alias`() {
|
||||
let parsed = LanguageModel.parse(from: "gpt51-nano")
|
||||
#expect(parsed == .openai(.gpt5Nano))
|
||||
func `parse GPT-5.4 base model`() {
|
||||
let parsed = LanguageModel.parse(from: "gpt-5.4")
|
||||
#expect(parsed == .openai(.gpt54))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse GPT-5.4 nano alias`() {
|
||||
let parsed = LanguageModel.parse(from: "gpt54-nano")
|
||||
#expect(parsed == .openai(.gpt54Nano))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `LanguageModel rejects retired OpenAI ids`() {
|
||||
for model in ["gpt-4o", "gpt-4.1", "gpt-5.1", "gpt-5.2", "gpt-5-thinking"] {
|
||||
#expect(LanguageModel.parse(from: model) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse Claude Fable 5 model id`() throws {
|
||||
#expect(LanguageModel.parse(from: "claude-fable-5") == .anthropic(.fable5))
|
||||
#expect(LanguageModel.parse(from: "fable") == .anthropic(.fable5))
|
||||
#expect(try ModelSelector.parseModel("fable5") == .anthropic(.fable5))
|
||||
#expect(LanguageModel.parse(from: "my-fable5-7b") == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse Claude Opus 4.8 model id`() {
|
||||
let parsed = LanguageModel.parse(from: "claude-opus-4-8")
|
||||
#expect(parsed == .anthropic(.opus48))
|
||||
#expect(LanguageModel.parse(from: "my-opus48-distill") == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -33,20 +65,172 @@ struct ModelParsingTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse shorthand Claude alias`() {
|
||||
func `parse shorthand Claude alias`() throws {
|
||||
let parsed = LanguageModel.parse(from: "claude")
|
||||
#expect(parsed == .anthropic(.sonnet45))
|
||||
#expect(parsed == .anthropic(.opus48))
|
||||
#expect(try ModelSelector.parseModel("anthropic") == .anthropic(.opus48))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse Gemini 3 Flash model id`() {
|
||||
let parsed = LanguageModel.parse(from: "gemini-3-flash")
|
||||
#expect(parsed == .google(.gemini3Flash))
|
||||
func `parse Gemini 3.5 Flash model id`() {
|
||||
let parsed = LanguageModel.parse(from: "gemini-3.5-flash")
|
||||
#expect(parsed == .google(.gemini35Flash))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse shorthand Gemini alias`() {
|
||||
let parsed = LanguageModel.parse(from: "gemini")
|
||||
#expect(parsed == .google(.gemini3Flash))
|
||||
#expect(parsed == .google(.gemini35Flash))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse provider qualified latest hosted models`() throws {
|
||||
#expect(LanguageModel.parse(from: "anthropic/claude-fable-5") == .anthropic(.fable5))
|
||||
#expect(LanguageModel.parse(from: "anthropic/claude-opus-4-8") == .anthropic(.opus48))
|
||||
#expect(LanguageModel.parse(from: "google/gemini-3.5-flash") == .google(.gemini35Flash))
|
||||
#expect(LanguageModel.parse(from: "xai/grok-4.3-latest") == .grok(.grok43))
|
||||
#expect(LanguageModel.parse(from: "grok-4-latest") == .grok(.grok43))
|
||||
#expect(LanguageModel.parse(from: "grok-4") == .grok(.grok43))
|
||||
#expect(LanguageModel.parse(from: "xai/grok-code-fast-1") == .grok(.custom("grok-code-fast-1")))
|
||||
#expect(try ModelSelector.parseModel("grok-4") == .grok(.grok43))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse rejects provider-qualified hosted model mismatches`() {
|
||||
#expect(LanguageModel.parse(from: "openai/claude") == nil)
|
||||
#expect(LanguageModel.parse(from: "google/claude") == nil)
|
||||
#expect(LanguageModel.parse(from: "xai/gemini-3.5-flash") == nil)
|
||||
#expect(LanguageModel.parse(from: "anthropic/gpt-5.5") == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ModelSelector keeps generic slash IDs as OpenRouter models`() throws {
|
||||
#expect(try ModelSelector
|
||||
.parseModel("anthropic/claude-opus-4-8") == .openRouter(modelId: "anthropic/claude-opus-4-8"))
|
||||
#expect(try ModelSelector
|
||||
.parseModel("google/gemini-3.5-flash") == .openRouter(modelId: "google/gemini-3.5-flash"))
|
||||
#expect(try ModelSelector.parseModel("xai/grok-4.3-latest") == .grok(.grok43))
|
||||
#expect(try ModelSelector.parseModel("openai/claude") == .openRouter(modelId: "openai/claude"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse MiniMax model ids`() throws {
|
||||
#expect(LanguageModel.parse(from: "MiniMax-M2.7") == .minimax(.m27))
|
||||
#expect(LanguageModel.parse(from: "minimax/m2.7") == .minimax(.m27))
|
||||
#expect(try ModelSelector.parseModel("minimax/m2-7") == .minimax(.m27))
|
||||
#expect(LanguageModel.parse(from: "minimax/MiniMax-M2.7-highspeed") == .minimax(.m27Highspeed))
|
||||
#expect(LanguageModel.parse(from: "minimax/m2.7-highspeed") == .minimax(.m27Highspeed))
|
||||
#expect(try ModelSelector.parseModel("minimax/m2-7-highspeed") == .minimax(.m27Highspeed))
|
||||
#expect(LanguageModel.parse(from: "minimax") == .minimax(.m27))
|
||||
#expect(LanguageModel.parse(from: "minimax-cn/MiniMax-M2.7") == .minimaxCN(.m27))
|
||||
#expect(LanguageModel.parse(from: "minimax-cn/m2.7-highspeed") == .minimaxCN(.m27Highspeed))
|
||||
#expect(try ModelSelector.parseModel("minimax-cn/m2-7") == .minimaxCN(.m27))
|
||||
#expect(try ModelSelector.parseModel("minimax_cn/m2.7") == .minimaxCN(.m27))
|
||||
#expect(LanguageModel.parse(from: "minimaxi/m2.7") == .minimaxCN(.m27))
|
||||
#expect(LanguageModel.parse(from: "minimax-cn") == .minimaxCN(.m27))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse OpenRouter model ids`() throws {
|
||||
#expect(LanguageModel
|
||||
.parse(from: "openrouter/xiaomi/mimo-v2.5-pro") == .openRouter(modelId: "xiaomi/mimo-v2.5-pro"))
|
||||
#expect(LanguageModel.parse(from: "xiaomi/mimo-v2.5-pro") == .openRouter(modelId: "xiaomi/mimo-v2.5-pro"))
|
||||
#expect(try ModelSelector.parseModel("xiaomi/mimo-v2.5-pro") == .openRouter(modelId: "xiaomi/mimo-v2.5-pro"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse custom Ollama Qwen vision model without falling back to Llama`() {
|
||||
let parsed = LanguageModel.parse(from: "qwen2.5vl:3b")
|
||||
#expect(parsed == .ollama(.custom("qwen2.5vl:3b")))
|
||||
#expect(parsed?.modelId == "qwen2.5vl:3b")
|
||||
#expect(parsed?.supportsVision == true)
|
||||
#expect(parsed?.supportsTools == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse provider-qualified custom Ollama model`() {
|
||||
let parsed = LanguageModel.parse(from: "ollama/qwen2.5vl:3b")
|
||||
#expect(parsed == .ollama(.custom("qwen2.5vl:3b")))
|
||||
#expect(parsed?.modelId == "qwen2.5vl:3b")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse local provider shortcuts`() {
|
||||
#expect(LanguageModel.parse(from: "ollama") == .ollama(.llama33))
|
||||
#expect(LanguageModel.parse(from: "lmstudio") == .lmstudio(.gptOSS120B))
|
||||
#expect(LanguageModel.parse(from: "lmstudio/openai/gpt-oss-120b") == .lmstudio(.gptOSS120B))
|
||||
#expect(LanguageModel.parse(from: "lmstudio/custom-local-model") == .lmstudio(.custom("custom-local-model")))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ModelSelector parses local provider selections`() throws {
|
||||
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
|
||||
#expect(try ModelSelector.parseModel("lmstudio") == .lmstudio(.gptOSS120B))
|
||||
#expect(try ModelSelector.parseModel("lmstudio/openai/gpt-oss-120b") == .lmstudio(.gptOSS120B))
|
||||
#expect(try ModelSelector.parseModel("lm-studio/custom-local") == .lmstudio(.custom("custom-local")))
|
||||
#expect(ModelSelector.availableModels(for: "lmstudio").contains("openai/gpt-oss-120b"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ProviderParser keeps configured Google model behavior`() {
|
||||
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
|
||||
let model = ProviderParser.determineDefaultModel(
|
||||
from: "google/gemini-3.1-pro-preview",
|
||||
hasOpenAI: false,
|
||||
hasAnthropic: false,
|
||||
)
|
||||
|
||||
#expect(model == .google(.gemini31ProPreview))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ProviderParser keeps keyless fallback local by default`() {
|
||||
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
|
||||
let model = ProviderParser.determineDefaultModel(
|
||||
from: "",
|
||||
hasOpenAI: false,
|
||||
hasAnthropic: false,
|
||||
)
|
||||
|
||||
#expect(model == .ollama(.llama33))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ProviderParser accepts MiniMax China provider aliases`() {
|
||||
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
|
||||
for provider in ["minimax-cn", "minimax_cn", "minimaxi"] {
|
||||
let model = ProviderParser.determineDefaultModel(
|
||||
from: "\(provider)/m2.7",
|
||||
hasOpenAI: false,
|
||||
hasAnthropic: false,
|
||||
hasMiniMax: true,
|
||||
)
|
||||
|
||||
#expect(model == .minimaxCN(.m27))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ModelSelector rejects legacy OpenAI before Ollama fallback`() throws {
|
||||
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
|
||||
for model in ["gpt-4o", "gpt-4.1", "gpt-3.5-turbo", "o4-mini", "o3-mini", "gpt-5.2"] {
|
||||
#expect(throws: ModelValidationError.self) {
|
||||
_ = try ModelSelector.parseModel(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ModelSelector rejects Claude 3 before Ollama fallback`() throws {
|
||||
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
|
||||
#expect(throws: ModelValidationError.self) {
|
||||
_ = try ModelSelector.parseModel("claude-3-sonnet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,6 +121,337 @@ struct OpenAICompatibleHelperTests {
|
||||
#expect(deltas == "Hello world")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `streamText maps content filter finish reasons`() async throws {
|
||||
let request = ProviderRequest(
|
||||
messages: [ModelMessage(role: .user, content: [.text("blocked")])],
|
||||
)
|
||||
|
||||
let deltas = try await withMockedSession { urlRequest in
|
||||
let sse = """
|
||||
data: {\"id\":\"chunk_1\",\"choices\":[{\"delta\":{\"content\":\"partial\"},\"index\":0,\"finish_reason\":null}]}
|
||||
|
||||
data: {\"id\":\"chunk_2\",\"choices\":[{\"delta\":{},\"index\":0,\"finish_reason\":\"content_filter\"}]}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
""".utf8Data()
|
||||
let response = HTTPURLResponse(
|
||||
url: urlRequest.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: ["Content-Type": "text/event-stream"],
|
||||
)!
|
||||
return (response, sse)
|
||||
} operation: { session in
|
||||
let stream = try await OpenAICompatibleHelper.streamText(
|
||||
request: request,
|
||||
modelId: "compatible-model",
|
||||
baseURL: "https://mock.compatible",
|
||||
apiKey: "sk-test",
|
||||
providerName: "TestProvider",
|
||||
session: session,
|
||||
)
|
||||
|
||||
var deltas: [TextStreamDelta] = []
|
||||
for try await delta in stream {
|
||||
deltas.append(delta)
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
#expect(deltas.contains { $0.type == .textDelta && $0.content == "partial" })
|
||||
#expect(deltas.contains { $0.type == .done && $0.finishReason == .contentFilter })
|
||||
}
|
||||
|
||||
@Test
|
||||
func `OpenAI-compatible provider forwards configured headers`() async throws {
|
||||
let request = ProviderRequest(
|
||||
messages: [ModelMessage(role: .user, content: [.text("ping")])],
|
||||
)
|
||||
|
||||
try await self.withMockedSession { urlRequest in
|
||||
#expect(urlRequest.value(forHTTPHeaderField: "client_id") == "proxy-client")
|
||||
#expect(urlRequest.value(forHTTPHeaderField: "client_secret") == "proxy-secret")
|
||||
return self.jsonResponse(for: urlRequest, data: Self.chatCompletionPayload(text: "pong"))
|
||||
} operation: { session in
|
||||
let configuration = TachikomaConfiguration(apiKeys: ["openai_compatible": "sk-test"])
|
||||
let provider = try OpenAICompatibleProvider(
|
||||
modelId: "compatible-model",
|
||||
baseURL: "https://mock.compatible",
|
||||
configuration: configuration,
|
||||
additionalHeaders: [
|
||||
"client_id": "proxy-client",
|
||||
"client_secret": "proxy-secret",
|
||||
],
|
||||
session: session,
|
||||
)
|
||||
|
||||
let response = try await provider.generateText(request: request)
|
||||
#expect(response.text == "pong")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `generateText decodes OpenRouter reasoning details`() async throws {
|
||||
let response = try await withMockedSession { urlRequest in
|
||||
let reasoningDetails: [[String: String]] = [["type": "reasoning.encrypted", "data": "sealed"]]
|
||||
let toolCall: [String: Any] = [
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": ["name": "lookup", "arguments": "{}"],
|
||||
]
|
||||
let toolCalls = [toolCall]
|
||||
let choice: [String: Any] = [
|
||||
"index": 0,
|
||||
"message": [
|
||||
"role": "assistant",
|
||||
"content": NSNull(),
|
||||
"reasoning_details": reasoningDetails,
|
||||
"tool_calls": toolCalls,
|
||||
],
|
||||
"finish_reason": "tool_calls",
|
||||
]
|
||||
let payload: [String: Any] = [
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 1_700_000_000,
|
||||
"model": "anthropic/claude-fable-5",
|
||||
"choices": [choice],
|
||||
]
|
||||
return try self.jsonResponse(for: urlRequest, data: JSONSerialization.data(withJSONObject: payload))
|
||||
} operation: { session in
|
||||
try await OpenAICompatibleHelper.generateText(
|
||||
request: ProviderRequest(messages: [.user("hi")]),
|
||||
modelId: "anthropic/claude-fable-5",
|
||||
baseURL: "https://mock.compatible",
|
||||
apiKey: "sk-test",
|
||||
providerName: "OpenRouter",
|
||||
session: session,
|
||||
)
|
||||
}
|
||||
|
||||
let reasoning = try #require(response.reasoning.first)
|
||||
#expect(reasoning.type == "openrouter_reasoning_details")
|
||||
#expect(reasoning.rawJSON?.contains("reasoning.encrypted") == true)
|
||||
#expect(response.toolCalls?.first?.id == "call-1")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `generateText strips unsupported Fable sampling for OpenRouter route`() async throws {
|
||||
let capture = CapturedRequest()
|
||||
let request = ProviderRequest(
|
||||
messages: [ModelMessage(role: .user, content: [.text("ping")])],
|
||||
settings: GenerationSettings(maxTokens: 128, temperature: 0.7),
|
||||
)
|
||||
|
||||
_ = try await self.withMockedSession { urlRequest in
|
||||
capture.body = self.bodyData(from: urlRequest)
|
||||
return self.jsonResponse(for: urlRequest, data: Self.chatCompletionPayload(text: "pong"))
|
||||
} operation: { session in
|
||||
try await OpenAICompatibleHelper.generateText(
|
||||
request: request,
|
||||
modelId: "anthropic/claude-fable-5",
|
||||
baseURL: "https://mock.compatible",
|
||||
apiKey: "sk-test",
|
||||
providerName: "OpenRouter",
|
||||
session: session,
|
||||
)
|
||||
}
|
||||
|
||||
let bodyJSON = try #require(capture.body).jsonObject()
|
||||
#expect(bodyJSON["temperature"] == nil)
|
||||
#expect(bodyJSON["max_tokens"] as? Int == 128)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `generateText replays OpenRouter reasoning details on assistant tool messages`() async throws {
|
||||
let capture = CapturedRequest()
|
||||
let rawReasoning = #"[{"type":"reasoning.encrypted","data":"sealed"}]"#
|
||||
let call = AgentToolCall(id: "call-1", name: "lookup", arguments: [:])
|
||||
let request = try ProviderRequest(messages: [
|
||||
.user("hi"),
|
||||
ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("")],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: [
|
||||
"openrouter.reasoning_details": rawReasoning,
|
||||
"tachikoma.reasoning.provider": "openrouter",
|
||||
"tachikoma.reasoning.model": "anthropic/claude-fable-5",
|
||||
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
|
||||
.canonical("https://mock.compatible")),
|
||||
]),
|
||||
),
|
||||
ModelMessage(role: .assistant, content: [.toolCall(call)]),
|
||||
ModelMessage(
|
||||
role: .tool,
|
||||
content: [.toolResult(.success(toolCallId: "call-1", result: AnyAgentToolValue(string: "ok")))],
|
||||
),
|
||||
])
|
||||
|
||||
_ = try await self.withMockedSession { urlRequest in
|
||||
capture.body = self.bodyData(from: urlRequest)
|
||||
return self.jsonResponse(for: urlRequest, data: Self.chatCompletionPayload(text: "done"))
|
||||
} operation: { session in
|
||||
try await OpenAICompatibleHelper.generateText(
|
||||
request: request,
|
||||
modelId: "anthropic/claude-fable-5",
|
||||
baseURL: "https://mock.compatible",
|
||||
apiKey: "sk-test",
|
||||
providerName: "OpenRouter",
|
||||
session: session,
|
||||
)
|
||||
}
|
||||
|
||||
let bodyJSON = try #require(capture.body).jsonObject()
|
||||
let messages = try #require(bodyJSON["messages"] as? [[String: Any]])
|
||||
let assistant = try #require(messages.first { $0["role"] as? String == "assistant" })
|
||||
let details = try #require(assistant["reasoning_details"] as? [[String: Any]])
|
||||
#expect(details.first?["type"] as? String == "reasoning.encrypted")
|
||||
#expect(details.first?["data"] as? String == "sealed")
|
||||
#expect(assistant["tool_calls"] != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `generateText replays OpenRouter reasoning details on reasoning-only assistant boundary`() async throws {
|
||||
let capture = CapturedRequest()
|
||||
let rawReasoning = #"[{"type":"reasoning.encrypted","data":"sealed"}]"#
|
||||
let request = try ProviderRequest(messages: [
|
||||
.user("first"),
|
||||
ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("")],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: [
|
||||
"openrouter.reasoning_details": rawReasoning,
|
||||
"tachikoma.reasoning.provider": "openrouter",
|
||||
"tachikoma.reasoning.model": "anthropic/claude-fable-5",
|
||||
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
|
||||
.canonical("https://mock.compatible")),
|
||||
]),
|
||||
),
|
||||
ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("")],
|
||||
metadata: .init(customData: ["tachikoma.internal.boundary": "reasoning_only"]),
|
||||
),
|
||||
.user("next"),
|
||||
])
|
||||
|
||||
_ = try await self.withMockedSession { urlRequest in
|
||||
capture.body = self.bodyData(from: urlRequest)
|
||||
return self.jsonResponse(for: urlRequest, data: Self.chatCompletionPayload(text: "done"))
|
||||
} operation: { session in
|
||||
try await OpenAICompatibleHelper.generateText(
|
||||
request: request,
|
||||
modelId: "anthropic/claude-fable-5",
|
||||
baseURL: "https://mock.compatible",
|
||||
apiKey: "sk-test",
|
||||
providerName: "OpenRouter",
|
||||
session: session,
|
||||
)
|
||||
}
|
||||
|
||||
let bodyJSON = try #require(capture.body).jsonObject()
|
||||
let messages = try #require(bodyJSON["messages"] as? [[String: Any]])
|
||||
let assistantIndex = try #require(messages.firstIndex { $0["role"] as? String == "assistant" })
|
||||
let assistant = messages[assistantIndex]
|
||||
let details = try #require(assistant["reasoning_details"] as? [[String: Any]])
|
||||
#expect(details.first?["data"] as? String == "sealed")
|
||||
let nextMessage = try #require(messages.indices
|
||||
.contains(assistantIndex + 1) ? messages[assistantIndex + 1] : nil)
|
||||
#expect(nextMessage["role"] as? String == "user")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `generateText does not replay OpenRouter reasoning from another endpoint`() async throws {
|
||||
let capture = CapturedRequest()
|
||||
let rawReasoning = #"[{"type":"reasoning.encrypted","data":"sealed"}]"#
|
||||
let call = AgentToolCall(id: "call-1", name: "lookup", arguments: [:])
|
||||
let request = try ProviderRequest(messages: [
|
||||
.user("hi"),
|
||||
ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("")],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: [
|
||||
"openrouter.reasoning_details": rawReasoning,
|
||||
"tachikoma.reasoning.provider": "openrouter",
|
||||
"tachikoma.reasoning.model": "anthropic/claude-fable-5",
|
||||
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
|
||||
.canonical("https://other.example.test")),
|
||||
]),
|
||||
),
|
||||
ModelMessage(role: .assistant, content: [.toolCall(call)]),
|
||||
ModelMessage(
|
||||
role: .tool,
|
||||
content: [.toolResult(.success(toolCallId: "call-1", result: AnyAgentToolValue(string: "ok")))],
|
||||
),
|
||||
])
|
||||
|
||||
_ = try await self.withMockedSession { urlRequest in
|
||||
capture.body = self.bodyData(from: urlRequest)
|
||||
return self.jsonResponse(for: urlRequest, data: Self.chatCompletionPayload(text: "done"))
|
||||
} operation: { session in
|
||||
try await OpenAICompatibleHelper.generateText(
|
||||
request: request,
|
||||
modelId: "anthropic/claude-fable-5",
|
||||
baseURL: "https://mock.compatible",
|
||||
apiKey: "sk-test",
|
||||
providerName: "OpenRouter",
|
||||
session: session,
|
||||
)
|
||||
}
|
||||
|
||||
let bodyJSON = try #require(capture.body).jsonObject()
|
||||
let messages = try #require(bodyJSON["messages"] as? [[String: Any]])
|
||||
let assistantMessages = messages.filter { $0["role"] as? String == "assistant" }
|
||||
#expect(assistantMessages.allSatisfy { $0["reasoning_details"] == nil })
|
||||
}
|
||||
|
||||
@Test
|
||||
func `generateText drops unmatched OpenRouter reasoning instead of serializing it as text`() async throws {
|
||||
let capture = CapturedRequest()
|
||||
let request = try ProviderRequest(messages: [
|
||||
.user("hi"),
|
||||
ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("private reasoning")],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: [
|
||||
"openrouter.reasoning": "private reasoning",
|
||||
"tachikoma.reasoning.provider": "openrouter",
|
||||
"tachikoma.reasoning.model": "other-model",
|
||||
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
|
||||
.canonical("https://mock.compatible")),
|
||||
]),
|
||||
),
|
||||
.assistant("visible"),
|
||||
])
|
||||
|
||||
_ = try await self.withMockedSession { urlRequest in
|
||||
capture.body = self.bodyData(from: urlRequest)
|
||||
return self.jsonResponse(for: urlRequest, data: Self.chatCompletionPayload(text: "done"))
|
||||
} operation: { session in
|
||||
try await OpenAICompatibleHelper.generateText(
|
||||
request: request,
|
||||
modelId: "anthropic/claude-fable-5",
|
||||
baseURL: "https://mock.compatible",
|
||||
apiKey: "sk-test",
|
||||
providerName: "OpenRouter",
|
||||
session: session,
|
||||
)
|
||||
}
|
||||
|
||||
let bodyJSON = try #require(capture.body).jsonObject()
|
||||
let messages = try #require(bodyJSON["messages"] as? [[String: Any]])
|
||||
let assistantMessages = messages.filter { $0["role"] as? String == "assistant" }
|
||||
#expect(assistantMessages.count == 1)
|
||||
#expect(assistantMessages.first?["content"] as? String == "visible")
|
||||
#expect(try String(data: #require(capture.body), encoding: .utf8)?.contains("private reasoning") == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `non-200 responses surface TachikomaError.apiError`() async {
|
||||
await self.withMockedSession { urlRequest in
|
||||
|
||||
@ -207,6 +207,24 @@ enum ProviderOptionsTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Encode and decode Anthropic adaptive thinking mode`() throws {
|
||||
let original = AnthropicOptions(
|
||||
thinking: .adaptive,
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(original)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let decoded = try decoder.decode(AnthropicOptions.self, from: data)
|
||||
|
||||
guard case .adaptive = decoded.thinking else {
|
||||
Issue.record("Expected thinking to be adaptive")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Encode and decode provider options container`() throws {
|
||||
let original = ProviderOptions(
|
||||
|
||||
@ -50,6 +50,8 @@ enum ProviderTests {
|
||||
#expect(Provider.groq.identifier == "groq")
|
||||
#expect(Provider.mistral.identifier == "mistral")
|
||||
#expect(Provider.google.identifier == "google")
|
||||
#expect(Provider.minimax.identifier == "minimax")
|
||||
#expect(Provider.minimaxCN.identifier == "minimax-cn")
|
||||
#expect(Provider.ollama.identifier == "ollama")
|
||||
#expect(Provider.azureOpenAI.identifier == "azure-openai")
|
||||
}
|
||||
@ -68,6 +70,8 @@ enum ProviderTests {
|
||||
#expect(Provider.groq.displayName == "Groq")
|
||||
#expect(Provider.mistral.displayName == "Mistral")
|
||||
#expect(Provider.google.displayName == "Google")
|
||||
#expect(Provider.minimax.displayName == "MiniMax")
|
||||
#expect(Provider.minimaxCN.displayName == "MiniMax China")
|
||||
#expect(Provider.ollama.displayName == "Ollama")
|
||||
#expect(Provider.azureOpenAI.displayName == "Azure OpenAI")
|
||||
#expect(Provider.custom("test").displayName == "Test")
|
||||
@ -81,6 +85,8 @@ enum ProviderTests {
|
||||
#expect(Provider.groq.environmentVariable == "GROQ_API_KEY")
|
||||
#expect(Provider.mistral.environmentVariable == "MISTRAL_API_KEY")
|
||||
#expect(Provider.google.environmentVariable == "GEMINI_API_KEY")
|
||||
#expect(Provider.minimax.environmentVariable == "MINIMAX_API_KEY")
|
||||
#expect(Provider.minimaxCN.environmentVariable == "MINIMAX_CN_API_KEY")
|
||||
#expect(Provider.ollama.environmentVariable == "OLLAMA_API_KEY")
|
||||
#expect(Provider.azureOpenAI.environmentVariable == "AZURE_OPENAI_API_KEY")
|
||||
#expect(Provider.custom("test").environmentVariable.isEmpty)
|
||||
@ -90,6 +96,7 @@ enum ProviderTests {
|
||||
func `Alternative environment variables`() {
|
||||
#expect(Provider.grok.alternativeEnvironmentVariables == ["XAI_API_KEY", "GROK_API_KEY"])
|
||||
#expect(Provider.google.alternativeEnvironmentVariables == ["GOOGLE_API_KEY"])
|
||||
#expect(Provider.minimaxCN.alternativeEnvironmentVariables == ["MINIMAX_API_KEY"])
|
||||
#expect(Provider.openai.alternativeEnvironmentVariables.isEmpty)
|
||||
#expect(Provider.anthropic.alternativeEnvironmentVariables.isEmpty)
|
||||
#expect(Provider.azureOpenAI.alternativeEnvironmentVariables == [
|
||||
@ -106,6 +113,8 @@ enum ProviderTests {
|
||||
#expect(Provider.groq.defaultBaseURL == "https://api.groq.com/openai/v1")
|
||||
#expect(Provider.mistral.defaultBaseURL == "https://api.mistral.ai/v1")
|
||||
#expect(Provider.google.defaultBaseURL == "https://generativelanguage.googleapis.com/v1beta")
|
||||
#expect(Provider.minimax.defaultBaseURL == "https://api.minimax.io/anthropic")
|
||||
#expect(Provider.minimaxCN.defaultBaseURL == "https://api.minimaxi.com/anthropic")
|
||||
#expect(Provider.ollama.defaultBaseURL == "http://localhost:11434")
|
||||
#expect(Provider.azureOpenAI.defaultBaseURL == nil)
|
||||
#expect(Provider.custom("test").defaultBaseURL == nil)
|
||||
@ -119,6 +128,8 @@ enum ProviderTests {
|
||||
#expect(Provider.groq.requiresAPIKey == true)
|
||||
#expect(Provider.mistral.requiresAPIKey == true)
|
||||
#expect(Provider.google.requiresAPIKey == true)
|
||||
#expect(Provider.minimax.requiresAPIKey == true)
|
||||
#expect(Provider.minimaxCN.requiresAPIKey == true)
|
||||
#expect(Provider.ollama.requiresAPIKey == false) // Ollama typically doesn't require API key
|
||||
#expect(Provider.azureOpenAI.requiresAPIKey == true)
|
||||
#expect(Provider.custom("test").requiresAPIKey == true) // Assume custom providers need keys
|
||||
@ -134,6 +145,9 @@ enum ProviderTests {
|
||||
#expect(Provider.from(identifier: "groq") == .groq)
|
||||
#expect(Provider.from(identifier: "mistral") == .mistral)
|
||||
#expect(Provider.from(identifier: "google") == .google)
|
||||
#expect(Provider.from(identifier: "minimax") == .minimax)
|
||||
#expect(Provider.from(identifier: "minimax-cn") == .minimaxCN)
|
||||
#expect(Provider.from(identifier: "minimaxi") == .minimaxCN)
|
||||
#expect(Provider.from(identifier: "ollama") == .ollama)
|
||||
#expect(Provider.from(identifier: "azure-openai") == .azureOpenAI)
|
||||
}
|
||||
@ -172,6 +186,8 @@ enum ProviderTests {
|
||||
.groq,
|
||||
.mistral,
|
||||
.google,
|
||||
.minimax,
|
||||
.minimaxCN,
|
||||
.ollama,
|
||||
.azureOpenAI,
|
||||
]
|
||||
@ -194,6 +210,18 @@ enum ProviderTests {
|
||||
#expect(provider.alternativeEnvironmentVariables == ["XAI_API_KEY", "GROK_API_KEY"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `MiniMax China falls back to MiniMax environment variable`() async {
|
||||
let resolved = await withTemporaryEnvironment([
|
||||
"MINIMAX_CN_API_KEY": nil,
|
||||
"MINIMAX_API_KEY": "shared-minimax-key",
|
||||
]) {
|
||||
Provider.minimaxCN.loadAPIKeyFromEnvironment()
|
||||
}
|
||||
|
||||
#expect(resolved == "shared-minimax-key")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Custom providers don't have environment variables`() {
|
||||
let customProvider = Provider.custom("test")
|
||||
|
||||
@ -327,7 +327,7 @@ private struct MockTextProvider: ModelProvider {
|
||||
|
||||
private struct MockOpenAIProvider: ModelProvider {
|
||||
var modelId: String {
|
||||
"gpt-4"
|
||||
"gpt-5.5"
|
||||
}
|
||||
|
||||
var baseURL: String? {
|
||||
|
||||
@ -180,6 +180,30 @@ struct StopConditionsTests {
|
||||
#expect(!collectedText.contains("ignored"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Stop conditions finish immediately after local match`() async throws {
|
||||
let stream = AsyncThrowingStream<TextStreamDelta, Error> { continuation in
|
||||
Task {
|
||||
continuation.yield(TextStreamDelta(type: .textDelta, content: "STOP"))
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
continuation.yield(TextStreamDelta(type: .textDelta, content: "late"))
|
||||
continuation.yield(TextStreamDelta(type: .done, finishReason: .length))
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
let start = Date()
|
||||
var received: [TextStreamDelta] = []
|
||||
for try await delta in stream.stopWhen(StringStopCondition("STOP")) {
|
||||
received.append(delta)
|
||||
}
|
||||
|
||||
#expect(Date().timeIntervalSince(start) < 0.5)
|
||||
#expect(received.map(\.content).compactMap(\.self) == ["STOP"])
|
||||
#expect(received.last?.type == .done)
|
||||
#expect(received.last?.finishReason == .stop)
|
||||
}
|
||||
|
||||
// MARK: - Builder Pattern Tests
|
||||
|
||||
@Test
|
||||
|
||||
@ -89,12 +89,12 @@ struct StreamObjectTests {
|
||||
|
||||
let result = StreamObjectResult(
|
||||
objectStream: testStream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
schema: TestPerson.self,
|
||||
)
|
||||
|
||||
#expect(result.model == .openai(.gpt4o))
|
||||
#expect(result.model == .openai(.gpt55))
|
||||
#expect(result.schema == TestPerson.self)
|
||||
}
|
||||
|
||||
@ -157,7 +157,7 @@ struct StreamObjectTests {
|
||||
|
||||
let result = StreamObjectResult(
|
||||
objectStream: testStream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
schema: TestPerson.self,
|
||||
)
|
||||
@ -193,7 +193,7 @@ struct StreamObjectTests {
|
||||
|
||||
let result = StreamObjectResult(
|
||||
objectStream: testStream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
schema: TestPerson.self,
|
||||
)
|
||||
@ -221,7 +221,7 @@ struct StreamObjectTests {
|
||||
|
||||
let result = StreamObjectResult(
|
||||
objectStream: testStream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
schema: TestPerson.self,
|
||||
)
|
||||
|
||||
@ -231,7 +231,7 @@ struct StreamTransformTests {
|
||||
|
||||
let result = StreamTextResult(
|
||||
stream: stream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
)
|
||||
|
||||
@ -264,7 +264,7 @@ struct StreamTransformTests {
|
||||
|
||||
let result = StreamTextResult(
|
||||
stream: stream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
)
|
||||
|
||||
@ -291,7 +291,7 @@ struct StreamTransformTests {
|
||||
|
||||
let result = StreamTextResult(
|
||||
stream: stream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
)
|
||||
|
||||
|
||||
@ -367,35 +367,35 @@ struct TachikomaConfigurationTests {
|
||||
Task {
|
||||
// Uses default (.current)
|
||||
_ = try await generateText(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
messages: [.user("Test")],
|
||||
)
|
||||
|
||||
// Uses explicit
|
||||
_ = try await generateText(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
messages: [.user("Test")],
|
||||
configuration: explicitConfig,
|
||||
)
|
||||
|
||||
// Stream functions
|
||||
_ = try await streamText(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
messages: [.user("Test")],
|
||||
)
|
||||
|
||||
_ = try await streamText(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
messages: [.user("Test")],
|
||||
configuration: explicitConfig,
|
||||
)
|
||||
|
||||
// Convenience functions
|
||||
_ = try await generate("Test", using: .openai(.gpt4o))
|
||||
_ = try await generate("Test", using: .openai(.gpt4o), configuration: explicitConfig)
|
||||
_ = try await generate("Test", using: .openai(.gpt55))
|
||||
_ = try await generate("Test", using: .openai(.gpt55), configuration: explicitConfig)
|
||||
|
||||
_ = try await stream("Test", using: .openai(.gpt4o))
|
||||
_ = try await stream("Test", using: .openai(.gpt4o), configuration: explicitConfig)
|
||||
_ = try await stream("Test", using: .openai(.gpt55))
|
||||
_ = try await stream("Test", using: .openai(.gpt55), configuration: explicitConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@ -559,9 +559,9 @@ struct TachikomaConfigurationTests {
|
||||
return DummyProvider()
|
||||
}
|
||||
|
||||
let provider = try config.makeProvider(for: .openai(.gpt4o))
|
||||
let provider = try config.makeProvider(for: .openai(.gpt55))
|
||||
#expect(provider is DummyProvider)
|
||||
#expect(capturedModel == .openai(.gpt4o))
|
||||
#expect(capturedModel == .openai(.gpt55))
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -569,8 +569,8 @@ struct TachikomaConfigurationTests {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setAPIKey("mock-key", for: .openai)
|
||||
|
||||
let provider = try config.makeProvider(for: .openai(.gpt4o))
|
||||
#expect(provider is OpenAIProvider)
|
||||
let provider = try config.makeProvider(for: .openai(.gpt55))
|
||||
#expect(provider is OpenAIResponsesProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@ func `Debug Grok streaming issue`() async throws {
|
||||
// Test with minimal setup
|
||||
let stream = try await stream(
|
||||
"Say hello",
|
||||
using: .grok(.grok3),
|
||||
using: .grok(.grok43),
|
||||
)
|
||||
|
||||
var receivedContent = false
|
||||
|
||||
@ -59,6 +59,67 @@ struct ResponseCacheTests {
|
||||
#expect(cached?.finishReason == .stop)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ResponseCache keys include reasoning metadata`() async {
|
||||
let cache = ResponseCache()
|
||||
let response = ProviderResponse(text: "cached", usage: nil, finishReason: .stop)
|
||||
|
||||
func request(signature: String) -> ProviderRequest {
|
||||
ProviderRequest(
|
||||
messages: [
|
||||
.user("Hello"),
|
||||
ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("thinking")],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: [
|
||||
"anthropic.thinking.signature": signature,
|
||||
"anthropic.thinking.type": "thinking",
|
||||
]),
|
||||
),
|
||||
.assistant("Hi"),
|
||||
],
|
||||
tools: nil,
|
||||
settings: .default,
|
||||
)
|
||||
}
|
||||
|
||||
await cache.store(response, for: request(signature: "sig-a"))
|
||||
|
||||
#expect(await cache.get(for: request(signature: "sig-a"))?.text == "cached")
|
||||
#expect(await cache.get(for: request(signature: "sig-b")) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `CacheEntry size includes reasoning and assistant messages`() {
|
||||
let small = CacheEntry(response: ProviderResponse(text: "ok"))
|
||||
let largePayload = String(repeating: "x", count: 4096)
|
||||
let large = CacheEntry(response: ProviderResponse(
|
||||
text: "ok",
|
||||
reasoning: [
|
||||
ProviderReasoningBlock(text: largePayload, signature: largePayload, type: "thinking"),
|
||||
ProviderReasoningBlock(
|
||||
text: "",
|
||||
type: "openrouter_reasoning_details",
|
||||
rawJSON: largePayload,
|
||||
),
|
||||
],
|
||||
assistantMessages: [
|
||||
ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text(largePayload)],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: [
|
||||
"anthropic.thinking.model": "claude-fable-5",
|
||||
"anthropic.thinking.signature": largePayload,
|
||||
]),
|
||||
),
|
||||
],
|
||||
))
|
||||
|
||||
#expect(large.estimatedMemorySize() > small.estimatedMemorySize() + 12000)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ResponseCache cache miss`() async {
|
||||
let cache = ResponseCache()
|
||||
@ -267,6 +328,99 @@ struct ResponseCacheTests {
|
||||
#expect(key1.hash != key3.hash)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `CacheKey includes reasoning effort and Anthropic thinking options`() {
|
||||
let messages = [ModelMessage.user("Hello")]
|
||||
let lowEffort = ProviderRequest(
|
||||
messages: messages,
|
||||
settings: GenerationSettings(
|
||||
reasoningEffort: .low,
|
||||
providerOptions: .init(anthropic: .init(thinking: .adaptive)),
|
||||
),
|
||||
)
|
||||
let highEffort = ProviderRequest(
|
||||
messages: messages,
|
||||
settings: GenerationSettings(
|
||||
reasoningEffort: .high,
|
||||
providerOptions: .init(anthropic: .init(thinking: .adaptive)),
|
||||
),
|
||||
)
|
||||
let disabledThinking = ProviderRequest(
|
||||
messages: messages,
|
||||
settings: GenerationSettings(
|
||||
reasoningEffort: .low,
|
||||
providerOptions: .init(anthropic: .init(thinking: .disabled)),
|
||||
),
|
||||
)
|
||||
|
||||
#expect(CacheKey(from: lowEffort).hash != CacheKey(from: highEffort).hash)
|
||||
#expect(CacheKey(from: lowEffort).hash != CacheKey(from: disabledThinking).hash)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `CacheKey includes string stop condition values`() {
|
||||
let endRequest = ProviderRequest(
|
||||
messages: [ModelMessage.user("Hello")],
|
||||
settings: GenerationSettings(stopConditions: StringStopCondition("END")),
|
||||
)
|
||||
let stopRequest = ProviderRequest(
|
||||
messages: [ModelMessage.user("Hello")],
|
||||
settings: GenerationSettings(stopConditions: StringStopCondition("STOP")),
|
||||
)
|
||||
|
||||
#expect(CacheKey(from: endRequest).hash != CacheKey(from: stopRequest).hash)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `CacheKey encodes composite stop conditions without delimiter collisions`() async {
|
||||
let cache = ResponseCache()
|
||||
let splitRequest = ProviderRequest(
|
||||
messages: [ModelMessage.user("Hello")],
|
||||
settings: GenerationSettings(stopConditions: AnyStopCondition(
|
||||
StringStopCondition("a"),
|
||||
StringStopCondition("b"),
|
||||
)),
|
||||
)
|
||||
let joinedRequest = ProviderRequest(
|
||||
messages: [ModelMessage.user("Hello")],
|
||||
settings: GenerationSettings(stopConditions: AnyStopCondition(
|
||||
StringStopCondition("a,string:true:b"),
|
||||
)),
|
||||
)
|
||||
|
||||
#expect(CacheKey(from: splitRequest).hash != CacheKey(from: joinedRequest).hash)
|
||||
|
||||
await cache.store(ProviderResponse(text: "split", finishReason: .stop), for: splitRequest)
|
||||
let joinedCached = await cache.get(for: joinedRequest)
|
||||
|
||||
#expect(joinedCached == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `CacheKey marks custom stop conditions uncacheable`() {
|
||||
let request = ProviderRequest(
|
||||
messages: [ModelMessage.user("Hello")],
|
||||
settings: GenerationSettings(stopConditions: PredicateStopCondition { _, _ in false }),
|
||||
)
|
||||
|
||||
let key = CacheKey(from: request)
|
||||
#expect(key.isCacheable == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ResponseCache skips custom stop condition entries`() async {
|
||||
let cache = ResponseCache()
|
||||
let request = ProviderRequest(
|
||||
messages: [ModelMessage.user("Hello")],
|
||||
settings: GenerationSettings(stopConditions: PredicateStopCondition { _, _ in false }),
|
||||
)
|
||||
|
||||
await cache.store(ProviderResponse(text: "cached", finishReason: .stop), for: request)
|
||||
let cached = await cache.get(for: request)
|
||||
|
||||
#expect(cached == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `CacheKey includes tools in hash`() {
|
||||
let tool1 = AgentTool(
|
||||
@ -317,7 +471,7 @@ struct ResponseCacheTests {
|
||||
|
||||
// Create a mock provider
|
||||
let mockProvider = ResponseCacheMockProvider(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
response: ProviderResponse(text: "Cached response", usage: nil, finishReason: .stop),
|
||||
)
|
||||
|
||||
@ -336,7 +490,7 @@ struct ResponseCacheTests {
|
||||
// Use a simple counter that can be modified in the closure
|
||||
let callCount = Box(value: 0)
|
||||
var mockProvider = ResponseCacheMockProvider(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
response: ProviderResponse(text: "Response", usage: nil, finishReason: .stop),
|
||||
)
|
||||
mockProvider.onGenerateText = { _ in
|
||||
@ -362,13 +516,45 @@ struct ResponseCacheTests {
|
||||
#expect(callCount.value == 1) // Provider not called again
|
||||
}
|
||||
|
||||
@Test
|
||||
func `CachedProvider keys include provider endpoint identity`() async throws {
|
||||
let cache = ResponseCache()
|
||||
let callCountA = Box(value: 0)
|
||||
let callCountB = Box(value: 0)
|
||||
var providerA = ResponseCacheMockProvider(
|
||||
model: .openaiCompatible(modelId: "shared-model", baseURL: "https://gateway.test/v1?tenant=a"),
|
||||
response: ProviderResponse(text: "tenant-a", usage: nil, finishReason: .stop),
|
||||
mockModelId: "shared-model",
|
||||
mockBaseURL: "https://gateway.test/v1?tenant=a",
|
||||
)
|
||||
var providerB = ResponseCacheMockProvider(
|
||||
model: .openaiCompatible(modelId: "shared-model", baseURL: "https://gateway.test/v1?tenant=b"),
|
||||
response: ProviderResponse(text: "tenant-b", usage: nil, finishReason: .stop),
|
||||
mockModelId: "shared-model",
|
||||
mockBaseURL: "https://gateway.test/v1?tenant=b",
|
||||
)
|
||||
providerA.onGenerateText = { _ in callCountA.value += 1 }
|
||||
providerB.onGenerateText = { _ in callCountB.value += 1 }
|
||||
|
||||
let cachedA = await cache.wrapProvider(providerA)
|
||||
let cachedB = await cache.wrapProvider(providerB)
|
||||
let request = ProviderRequest(messages: [ModelMessage.user("Test")], tools: nil, settings: .default)
|
||||
|
||||
#expect(try await cachedA.generateText(request: request).text == "tenant-a")
|
||||
#expect(try await cachedB.generateText(request: request).text == "tenant-b")
|
||||
#expect(try await cachedA.generateText(request: request).text == "tenant-a")
|
||||
#expect(try await cachedB.generateText(request: request).text == "tenant-b")
|
||||
#expect(callCountA.value == 1)
|
||||
#expect(callCountB.value == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `CachedProvider doesn't cache streaming`() async throws {
|
||||
let cache = ResponseCache()
|
||||
|
||||
let callCount = Box(value: 0)
|
||||
var mockProvider = ResponseCacheMockProvider(
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
response: ProviderResponse(text: "Test", usage: nil, finishReason: .stop),
|
||||
)
|
||||
mockProvider.onStreamText = { _ in
|
||||
@ -397,15 +583,17 @@ struct ResponseCacheTests {
|
||||
private struct ResponseCacheMockProvider: ModelProvider {
|
||||
let model: LanguageModel
|
||||
let response: ProviderResponse
|
||||
let mockModelId: String
|
||||
let mockBaseURL: String?
|
||||
var onGenerateText: (@Sendable (ProviderRequest) -> Void)?
|
||||
var onStreamText: (@Sendable (ProviderRequest) -> Void)?
|
||||
|
||||
var modelId: String {
|
||||
"mock-model"
|
||||
self.mockModelId
|
||||
}
|
||||
|
||||
var baseURL: String? {
|
||||
nil
|
||||
self.mockBaseURL
|
||||
}
|
||||
|
||||
var apiKey: String? {
|
||||
@ -419,11 +607,15 @@ private struct ResponseCacheMockProvider: ModelProvider {
|
||||
init(
|
||||
model: LanguageModel,
|
||||
response: ProviderResponse,
|
||||
mockModelId: String = "mock-model",
|
||||
mockBaseURL: String? = nil,
|
||||
onGenerateText: (@Sendable (ProviderRequest) -> Void)? = nil,
|
||||
onStreamText: (@Sendable (ProviderRequest) -> Void)? = nil,
|
||||
) {
|
||||
self.model = model
|
||||
self.response = response
|
||||
self.mockModelId = mockModelId
|
||||
self.mockBaseURL = mockBaseURL
|
||||
self.onGenerateText = onGenerateText
|
||||
self.onStreamText = onStreamText
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ struct IntegrationTests {
|
||||
|
||||
let streamResult = StreamTextResult(
|
||||
stream: textStream,
|
||||
model: .openai(.gpt4o),
|
||||
model: .openai(.gpt55),
|
||||
settings: .default,
|
||||
)
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ struct LMStudioProviderTests {
|
||||
func `Provider initialization`() {
|
||||
let provider = LMStudioProvider(
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
modelId: "gpt-oss-120b",
|
||||
modelId: "openai/gpt-oss-120b",
|
||||
apiKey: nil,
|
||||
)
|
||||
|
||||
@ -18,7 +18,7 @@ struct LMStudioProviderTests {
|
||||
let capabilities = provider.capabilities
|
||||
|
||||
#expect(baseURL == "http://localhost:1234/v1")
|
||||
#expect(modelId == "gpt-oss-120b")
|
||||
#expect(modelId == "openai/gpt-oss-120b")
|
||||
#expect(apiKey == nil)
|
||||
#expect(capabilities.supportsTools == true)
|
||||
#expect(capabilities.supportsStreaming == true)
|
||||
@ -28,11 +28,11 @@ struct LMStudioProviderTests {
|
||||
func `Model enum integration`() {
|
||||
let model1 = LanguageModel.lmstudio(.gptOSS120B)
|
||||
let model2 = LanguageModel.lmstudio(.gptOSS20B)
|
||||
let model3 = LanguageModel.lmstudio(.current)
|
||||
let model3 = LanguageModel.lmstudio(.llama3370B)
|
||||
|
||||
#expect(model1.modelId == "gpt-oss-120b")
|
||||
#expect(model2.modelId == "gpt-oss-20b")
|
||||
#expect(model3.modelId == "current")
|
||||
#expect(model1.modelId == "openai/gpt-oss-120b")
|
||||
#expect(model2.modelId == "openai/gpt-oss-20b")
|
||||
#expect(model3.modelId == "meta/llama-3.3-70b")
|
||||
|
||||
#expect(model1.supportsTools == true)
|
||||
#expect(model1.contextLength == 128_000)
|
||||
@ -47,7 +47,7 @@ struct LMStudioProviderTests {
|
||||
#expect(model2.providerName == "LMStudio")
|
||||
|
||||
#expect(model1.modelId == "gpt-oss:120b")
|
||||
#expect(model2.modelId == "gpt-oss-120b")
|
||||
#expect(model2.modelId == "openai/gpt-oss-120b")
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -59,7 +59,7 @@ struct LMStudioProviderTests {
|
||||
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
|
||||
|
||||
let modelId = provider.modelId
|
||||
#expect(modelId == "gpt-oss-120b")
|
||||
#expect(modelId == "openai/gpt-oss-120b")
|
||||
|
||||
// Should work without API key (local model)
|
||||
#expect(provider is LMStudioProvider)
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
import Testing
|
||||
@testable import Tachikoma
|
||||
|
||||
@ -22,6 +25,19 @@ struct AnthropicInterleavedDefaultsTests {
|
||||
#expect(parts.contains("fine-grained-tool-streaming-2025-05-14"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Endpoint identity includes routing query without exposing raw values`() {
|
||||
let tenantA = ReasoningEndpointIdentity.canonical("https://gateway.test/v1?tenant=a")
|
||||
let tenantB = ReasoningEndpointIdentity.canonical("https://gateway.test/v1?tenant=b")
|
||||
|
||||
#expect(tenantA != tenantB)
|
||||
#expect(tenantA?.hasPrefix("sha256:") == true)
|
||||
#expect(tenantA?.contains("tenant") == false)
|
||||
#expect(tenantA?.contains("gateway") == false)
|
||||
#expect(ReasoningEndpointIdentity.canonical("https://gateway.test/v1/?tenant=a") == tenantA)
|
||||
#expect(ReasoningEndpointIdentity.canonical("https://user:secret@gateway.test/v1?tenant=a#frag") == tenantA)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Provider request includes beta header and thinking payload`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
@ -57,6 +73,358 @@ struct AnthropicInterleavedDefaultsTests {
|
||||
#expect(thinking["budget_tokens"] as? Int == 12000)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Opus 4_7 request strips unsupported sampling and uses adaptive thinking`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .opus47, configuration: config)
|
||||
|
||||
let settings = GenerationSettings(
|
||||
maxTokens: 64,
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
providerOptions: .init(anthropic: .init(thinking: .enabled(budgetTokens: 12000))),
|
||||
)
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: [.user("hi")],
|
||||
settings: settings,
|
||||
)
|
||||
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
|
||||
#expect(json["model"] as? String == "claude-opus-4-7")
|
||||
#expect(json["temperature"] == nil)
|
||||
#expect(json["top_p"] == nil)
|
||||
#expect(json["top_k"] == nil)
|
||||
let thinking = try #require(json["thinking"] as? [String: Any])
|
||||
#expect(thinking["type"] as? String == "adaptive")
|
||||
#expect(thinking["budget_tokens"] == nil)
|
||||
let outputConfig = try #require(json["output_config"] as? [String: Any])
|
||||
#expect(outputConfig["effort"] as? String == "medium")
|
||||
#expect(json["max_tokens"] as? Int == 64)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Opus 4_8 request strips unsupported sampling and uses adaptive thinking`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .opus48, configuration: config)
|
||||
|
||||
let settings = GenerationSettings(
|
||||
maxTokens: 64,
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
reasoningEffort: .low,
|
||||
providerOptions: .init(anthropic: .init(thinking: .enabled(budgetTokens: 12000))),
|
||||
)
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: [.user("hi")],
|
||||
settings: settings,
|
||||
)
|
||||
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
|
||||
#expect(json["model"] as? String == "claude-opus-4-8")
|
||||
#expect(json["temperature"] == nil)
|
||||
#expect(json["top_p"] == nil)
|
||||
#expect(json["top_k"] == nil)
|
||||
let thinking = try #require(json["thinking"] as? [String: Any])
|
||||
#expect(thinking["type"] as? String == "adaptive")
|
||||
#expect(thinking["budget_tokens"] == nil)
|
||||
let outputConfig = try #require(json["output_config"] as? [String: Any])
|
||||
#expect(outputConfig["effort"] as? String == "low")
|
||||
#expect(json["max_tokens"] as? Int == 64)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Fable 5 request omits thinking config and uses effort output config`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .fable5, configuration: config)
|
||||
|
||||
let settings = GenerationSettings(
|
||||
maxTokens: 128_000,
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
reasoningEffort: .high,
|
||||
providerOptions: .init(anthropic: .init(thinking: .adaptive)),
|
||||
)
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: [.user("hi")],
|
||||
settings: settings,
|
||||
)
|
||||
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
|
||||
#expect(json["model"] as? String == "claude-fable-5")
|
||||
#expect(json["temperature"] == nil)
|
||||
#expect(json["top_p"] == nil)
|
||||
#expect(json["top_k"] == nil)
|
||||
#expect(json["thinking"] == nil)
|
||||
let outputConfig = try #require(json["output_config"] as? [String: Any])
|
||||
#expect(outputConfig["effort"] as? String == "high")
|
||||
#expect(json["max_tokens"] as? Int == 128_000)
|
||||
#expect(urlRequest.value(forHTTPHeaderField: "anthropic-beta") == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Fable 5 request uses model-aware default output budget`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .fable5, configuration: config)
|
||||
|
||||
let request = ProviderRequest(messages: [.user("hi")])
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
|
||||
#expect(json["max_tokens"] as? Int == 16384)
|
||||
#expect(urlRequest.timeoutInterval == 1800)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Fable 5 long output requests extend non-streaming timeout`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .fable5, configuration: config)
|
||||
|
||||
let urlRequest = try provider.makeURLRequest(
|
||||
for: ProviderRequest(
|
||||
messages: [.user("long")],
|
||||
settings: GenerationSettings(maxTokens: 128_000),
|
||||
),
|
||||
stream: false,
|
||||
)
|
||||
|
||||
#expect(urlRequest.timeoutInterval == 1800)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Opus long output requests extend non-streaming timeout`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
|
||||
for model in [LanguageModel.Anthropic.opus47, .opus48] {
|
||||
let provider = try AnthropicProvider(model: model, configuration: config)
|
||||
let urlRequest = try provider.makeURLRequest(
|
||||
for: ProviderRequest(
|
||||
messages: [.user("long")],
|
||||
settings: GenerationSettings(maxTokens: 128_000),
|
||||
),
|
||||
stream: false,
|
||||
)
|
||||
|
||||
#expect(urlRequest.timeoutInterval == 1800)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Custom Fable model id uses Fable request defaults`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .custom("claude-fable-5"), configuration: config)
|
||||
|
||||
let request = ProviderRequest(messages: [.user("hi")])
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
|
||||
#expect(provider.capabilities.supportsStreaming == false)
|
||||
#expect(provider.capabilities.contextLength == 1_000_000)
|
||||
#expect(provider.capabilities.maxOutputTokens == 128_000)
|
||||
#expect(LanguageModel.anthropic(.custom("claude-fable-5")).supportsStreaming == false)
|
||||
#expect(LanguageModel.anthropic(.custom("claude-fable-5")).contextLength == 1_000_000)
|
||||
#expect(LanguageModel.Anthropic.custom("claude-fable-5").maxOutputTokens == 128_000)
|
||||
#expect(json["model"] as? String == "claude-fable-5")
|
||||
#expect(json["thinking"] == nil)
|
||||
#expect(json["max_tokens"] as? Int == 16384)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Qualified custom Fable model id uses Fable request defaults`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .custom("anthropic.claude-fable-5"), configuration: config)
|
||||
|
||||
let request = ProviderRequest(messages: [.user("hi")])
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
|
||||
#expect(provider.capabilities.supportsStreaming == false)
|
||||
#expect(provider.capabilities.contextLength == 1_000_000)
|
||||
#expect(provider.capabilities.maxOutputTokens == 128_000)
|
||||
#expect(LanguageModel.anthropic(.custom("anthropic.claude-fable-5")).contextLength == 1_000_000)
|
||||
#expect(LanguageModel.Anthropic.custom("anthropic.claude-fable-5").maxOutputTokens == 128_000)
|
||||
#expect(json["model"] as? String == "anthropic.claude-fable-5")
|
||||
#expect(json["thinking"] == nil)
|
||||
#expect(json["max_tokens"] as? Int == 16384)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Fable 5 rejects disabled thinking mode`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .fable5, configuration: config)
|
||||
let settings = GenerationSettings(
|
||||
maxTokens: 64,
|
||||
providerOptions: .init(anthropic: .init(thinking: .disabled)),
|
||||
)
|
||||
|
||||
#expect(throws: TachikomaError.self) {
|
||||
_ = try provider.makeURLRequest(
|
||||
for: ProviderRequest(messages: [.user("hi")], settings: settings),
|
||||
stream: false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Custom Fable model id rejects disabled thinking mode`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .custom("claude-fable-5"), configuration: config)
|
||||
let settings = GenerationSettings(
|
||||
maxTokens: 64,
|
||||
providerOptions: .init(anthropic: .init(thinking: .disabled)),
|
||||
)
|
||||
|
||||
#expect(throws: TachikomaError.self) {
|
||||
_ = try provider.makeURLRequest(
|
||||
for: ProviderRequest(messages: [.user("hi")], settings: settings),
|
||||
stream: false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Opus reasoning effort is kept when thinking is disabled`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .opus48, configuration: config)
|
||||
|
||||
let settings = GenerationSettings(
|
||||
maxTokens: 64,
|
||||
reasoningEffort: .low,
|
||||
providerOptions: .init(anthropic: .init(thinking: .disabled)),
|
||||
)
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: [.user("hi")],
|
||||
settings: settings,
|
||||
)
|
||||
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
let outputConfig = try #require(json["output_config"] as? [String: Any])
|
||||
|
||||
#expect(json["thinking"] == nil)
|
||||
#expect(outputConfig["effort"] as? String == "low")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Opus effort is sent without thinking when reasoning effort is configured`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .opus48, configuration: config)
|
||||
|
||||
let settings = GenerationSettings(
|
||||
maxTokens: 64,
|
||||
reasoningEffort: .low,
|
||||
)
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: [.user("hi")],
|
||||
settings: settings,
|
||||
)
|
||||
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
let outputConfig = try #require(json["output_config"] as? [String: Any])
|
||||
|
||||
#expect(json["thinking"] == nil)
|
||||
#expect(outputConfig["effort"] as? String == "low")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Unsupported adaptive thinking is omitted for older Claude models`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .opus45, configuration: config)
|
||||
|
||||
let settings = GenerationSettings(
|
||||
maxTokens: 64,
|
||||
providerOptions: .init(anthropic: .init(thinking: .adaptive)),
|
||||
)
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: [.user("hi")],
|
||||
settings: settings,
|
||||
)
|
||||
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
|
||||
#expect(json["thinking"] == nil)
|
||||
#expect(json["output_config"] == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Sonnet 4_6 request keeps adaptive thinking payload`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .sonnet46, configuration: config)
|
||||
|
||||
let settings = GenerationSettings(
|
||||
maxTokens: 64,
|
||||
temperature: 0.7,
|
||||
reasoningEffort: .medium,
|
||||
providerOptions: .init(anthropic: .init(thinking: .adaptive)),
|
||||
)
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: [.user("hi")],
|
||||
settings: settings,
|
||||
)
|
||||
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
let thinking = try #require(json["thinking"] as? [String: Any])
|
||||
let outputConfig = try #require(json["output_config"] as? [String: Any])
|
||||
|
||||
#expect(json["model"] as? String == "claude-sonnet-4-6")
|
||||
#expect(json["temperature"] == nil)
|
||||
#expect(thinking["type"] as? String == "adaptive")
|
||||
#expect(outputConfig["effort"] as? String == "medium")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Custom Anthropic request keeps thinking payload`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .custom("claude-opus-4-5-latest"), configuration: config)
|
||||
|
||||
let settings = GenerationSettings(
|
||||
maxTokens: 64,
|
||||
providerOptions: .init(anthropic: .init(thinking: .enabled(budgetTokens: 12000))),
|
||||
)
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: [.user("hi")],
|
||||
settings: settings,
|
||||
)
|
||||
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
let thinking = try #require(json["thinking"] as? [String: Any])
|
||||
|
||||
#expect(json["model"] as? String == "claude-opus-4-5-latest")
|
||||
#expect(thinking["type"] as? String == "enabled")
|
||||
#expect(thinking["budget_tokens"] as? Int == 12000)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Provider respects custom baseURL`() throws {
|
||||
let config = TachikomaConfiguration(
|
||||
@ -70,6 +438,24 @@ struct AnthropicInterleavedDefaultsTests {
|
||||
#expect(urlRequest.url?.absoluteString == "https://entropic.example/v1/messages")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Provider includes additional proxy headers`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(
|
||||
model: .opus45,
|
||||
configuration: config,
|
||||
additionalHeaders: [
|
||||
"client_id": "proxy-client",
|
||||
"client_secret": "proxy-secret",
|
||||
],
|
||||
)
|
||||
|
||||
let request = ProviderRequest(messages: [.user("hi")])
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
#expect(urlRequest.value(forHTTPHeaderField: "client_id") == "proxy-client")
|
||||
#expect(urlRequest.value(forHTTPHeaderField: "client_secret") == "proxy-secret")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Stream delta decodes thinking_delta payload`() throws {
|
||||
let data = try #require("{\"type\":\"thinking_delta\",\"thinking\":\"ok\"}".data(using: .utf8))
|
||||
@ -87,6 +473,29 @@ struct AnthropicInterleavedDefaultsTests {
|
||||
#expect(delta.signature == "sig")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Stream delta decodes message_delta stop reason without delta type`() throws {
|
||||
let data = try #require(
|
||||
"{\"stop_reason\":\"refusal\",\"stop_sequence\":null}".data(using: .utf8),
|
||||
)
|
||||
let delta = try JSONDecoder().decode(AnthropicStreamDelta.self, from: data)
|
||||
#expect(delta.type.isEmpty)
|
||||
#expect(delta.stopReason == "refusal")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Stream event decodes partial usage with stop reason`() throws {
|
||||
let data = try #require(
|
||||
#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":42}}"#
|
||||
.data(using: .utf8),
|
||||
)
|
||||
let event = try JSONDecoder().decode(AnthropicStreamEvent.self, from: data)
|
||||
|
||||
#expect(event.delta?.stopReason == "end_turn")
|
||||
#expect(event.usage?.inputTokens == 0)
|
||||
#expect(event.usage?.outputTokens == 42)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Signed thinking blocks are preserved for assistant messages`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
@ -125,7 +534,125 @@ struct AnthropicInterleavedDefaultsTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Redacted thinking blocks preserve signature without text`() throws {
|
||||
func `Fable 5 preserves signed thinking history while omitting request thinking field`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .fable5, configuration: config)
|
||||
let signedThinking = try ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("fable thinking")],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: [
|
||||
"anthropic.thinking.model": "claude-fable-5",
|
||||
"anthropic.thinking.signature": "sig-fable",
|
||||
"anthropic.thinking.type": "thinking",
|
||||
"tachikoma.reasoning.provider": "anthropic",
|
||||
"tachikoma.reasoning.model": "claude-fable-5",
|
||||
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
|
||||
.canonical("https://api.anthropic.com")),
|
||||
]),
|
||||
)
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: [.user("hi"), signedThinking, .assistant("hello"), .user("continue")],
|
||||
settings: GenerationSettings(maxTokens: 64),
|
||||
)
|
||||
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
let messages = try #require(json["messages"] as? [[String: Any]])
|
||||
let assistant = try #require(messages[1]["content"] as? [[String: Any]])
|
||||
|
||||
#expect(json["thinking"] == nil)
|
||||
#expect(assistant.first?["type"] as? String == "thinking")
|
||||
#expect(assistant.first?["thinking"] as? String == "fable thinking")
|
||||
#expect(assistant.first?["signature"] as? String == "sig-fable")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Fable 5 drops mismatched signed thinking history in direct provider requests`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .fable5, configuration: config)
|
||||
let signedThinking = try ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("foreign thinking")],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: [
|
||||
"anthropic.thinking.model": "claude-fable-5",
|
||||
"anthropic.thinking.signature": "sig-foreign",
|
||||
"anthropic.thinking.type": "thinking",
|
||||
"tachikoma.reasoning.provider": "anthropic",
|
||||
"tachikoma.reasoning.model": "claude-fable-5",
|
||||
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
|
||||
.canonical("https://other.example.test")),
|
||||
]),
|
||||
)
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: [.user("hi"), signedThinking, .assistant("hello"), .user("continue")],
|
||||
settings: GenerationSettings(maxTokens: 64),
|
||||
)
|
||||
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
let messages = try #require(json["messages"] as? [[String: Any]])
|
||||
let assistant = try #require(messages[1]["content"] as? [[String: Any]])
|
||||
|
||||
#expect(assistant.count == 1)
|
||||
#expect(assistant.first?["type"] as? String == "text")
|
||||
#expect(assistant.first?["text"] as? String == "hello")
|
||||
#expect(String(data: body, encoding: .utf8)?.contains("foreign thinking") == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Fable 5 rejects assistant prefill requests`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .fable5, configuration: config)
|
||||
|
||||
#expect(throws: TachikomaError.self) {
|
||||
_ = try provider.makeURLRequest(
|
||||
for: ProviderRequest(messages: [.user("hi"), .assistant("prefill")]),
|
||||
stream: false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Anthropic refusal stop reason maps to content filter`() {
|
||||
#expect(AnthropicProvider.mapFinishReason("refusal") == .contentFilter)
|
||||
#expect(AnthropicProvider.mapFinishReason("model_context_window_exceeded") == .length)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Anthropic refusal response decodes stop details explanation`() throws {
|
||||
let data = """
|
||||
{
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
"model": "claude-fable-5",
|
||||
"stop_reason": "refusal",
|
||||
"stop_details": {
|
||||
"category": "cyber",
|
||||
"explanation": "I cannot help with that request."
|
||||
},
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 0
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let response = try JSONDecoder().decode(AnthropicMessageResponse.self, from: data)
|
||||
|
||||
#expect(response.stopDetails?.category == "cyber")
|
||||
#expect(response.stopDetails?.explanation == "I cannot help with that request.")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Redacted thinking blocks preserve opaque data`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .opus45, configuration: config)
|
||||
|
||||
@ -136,10 +663,9 @@ struct AnthropicInterleavedDefaultsTests {
|
||||
|
||||
let redacted = ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("")],
|
||||
content: [.text("opaque-redacted-data")],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: [
|
||||
"anthropic.thinking.signature": "sig-redacted",
|
||||
"anthropic.thinking.type": "redacted_thinking",
|
||||
]),
|
||||
)
|
||||
@ -156,8 +682,232 @@ struct AnthropicInterleavedDefaultsTests {
|
||||
|
||||
let assistant = try #require(messages[1]["content"] as? [[String: Any]])
|
||||
#expect(assistant.first?["type"] as? String == "redacted_thinking")
|
||||
#expect((assistant.first?["redacted_thinking"] as? String)?.isEmpty == true)
|
||||
#expect(assistant.first?["signature"] as? String == "sig-redacted")
|
||||
#expect(assistant.first?["data"] as? String == "opaque-redacted-data")
|
||||
#expect(assistant.first?["signature"] == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Redacted thinking response decodes opaque data`() throws {
|
||||
let data = try #require(
|
||||
"""
|
||||
{"type":"redacted_thinking","data":"opaque-redacted-data"}
|
||||
""".data(using: .utf8),
|
||||
)
|
||||
|
||||
let content = try JSONDecoder().decode(AnthropicResponseContent.self, from: data)
|
||||
guard case let .redactedThinking(redacted) = content else {
|
||||
Issue.record("Expected redacted thinking content")
|
||||
return
|
||||
}
|
||||
#expect(redacted.data == "opaque-redacted-data")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Consecutive thinking blocks are preserved in order`() throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .fable5, configuration: config)
|
||||
|
||||
let signedThinking = try ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("signed")],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: [
|
||||
"anthropic.thinking.model": "claude-fable-5",
|
||||
"anthropic.thinking.signature": "sig",
|
||||
"anthropic.thinking.type": "thinking",
|
||||
"tachikoma.reasoning.provider": "anthropic",
|
||||
"tachikoma.reasoning.model": "claude-fable-5",
|
||||
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
|
||||
.canonical("https://api.anthropic.com")),
|
||||
]),
|
||||
)
|
||||
let redactedThinking = try ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("opaque")],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: [
|
||||
"anthropic.thinking.model": "claude-fable-5",
|
||||
"anthropic.thinking.type": "redacted_thinking",
|
||||
"tachikoma.reasoning.provider": "anthropic",
|
||||
"tachikoma.reasoning.model": "claude-fable-5",
|
||||
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
|
||||
.canonical("https://api.anthropic.com")),
|
||||
]),
|
||||
)
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: [.user("hi"), signedThinking, redactedThinking, .assistant("hello"), .user("continue")],
|
||||
settings: GenerationSettings(maxTokens: 64),
|
||||
)
|
||||
|
||||
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
|
||||
let body = try #require(urlRequest.httpBody)
|
||||
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
let messages = try #require(json["messages"] as? [[String: Any]])
|
||||
let assistant = try #require(messages[1]["content"] as? [[String: Any]])
|
||||
|
||||
#expect(assistant.count == 3)
|
||||
#expect(assistant[0]["type"] as? String == "thinking")
|
||||
#expect(assistant[0]["thinking"] as? String == "signed")
|
||||
#expect(assistant[0]["signature"] as? String == "sig")
|
||||
#expect(assistant[1]["type"] as? String == "redacted_thinking")
|
||||
#expect(assistant[1]["data"] as? String == "opaque")
|
||||
#expect(assistant[2]["type"] as? String == "text")
|
||||
#expect(assistant[2]["text"] as? String == "hello")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Current Anthropic models expose documented output caps`() {
|
||||
#expect(LanguageModel.Anthropic.fable5.maxOutputTokens == 128_000)
|
||||
#expect(LanguageModel.Anthropic.opus47.maxOutputTokens == 128_000)
|
||||
#expect(LanguageModel.Anthropic.opus48.maxOutputTokens == 128_000)
|
||||
#expect(LanguageModel.Anthropic.sonnet46.maxOutputTokens == 64000)
|
||||
#expect(LanguageModel.Anthropic.haiku45.maxOutputTokens == 64000)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Fable and Opus 4_8 streaming are disabled until rollback is supported`() async throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
|
||||
let provider = try AnthropicProvider(model: .fable5, configuration: config)
|
||||
let opusProvider = try AnthropicProvider(model: .opus48, configuration: config)
|
||||
|
||||
#expect(provider.capabilities.supportsStreaming == false)
|
||||
#expect(LanguageModel.anthropic(.fable5).supportsStreaming == false)
|
||||
#expect(opusProvider.capabilities.supportsStreaming == false)
|
||||
#expect(LanguageModel.anthropic(.opus47).supportsStreaming == true)
|
||||
#expect(LanguageModel.anthropic(.opus48).supportsStreaming == false)
|
||||
#expect(LanguageModel.anthropic(.sonnet46).supportsStreaming == true)
|
||||
#expect(LanguageModel.anthropic(.sonnet45).supportsStreaming == true)
|
||||
#expect(LanguageModel.anthropic(.haiku45).supportsStreaming == true)
|
||||
await #expect(throws: TachikomaError.self) {
|
||||
_ = try await provider.streamText(request: ProviderRequest(messages: [.user("hi")]))
|
||||
}
|
||||
await #expect(throws: TachikomaError.self) {
|
||||
_ = try await opusProvider.streamText(request: ProviderRequest(messages: [.user("hi")]))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Opus 4_8 detection avoids substring false positives`() {
|
||||
#expect(LanguageModel.Anthropic.isOpus48(modelId: "claude-opus-4-8") == true)
|
||||
#expect(LanguageModel.Anthropic.isOpus48(modelId: "anthropic/claude-opus-4.8") == true)
|
||||
#expect(LanguageModel.Anthropic.isOpus48(modelId: "my-opus48-distill") == false)
|
||||
#expect(LanguageModel.Anthropic.isOpus48(modelId: "opus480") == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Fable detection avoids substring false positives`() {
|
||||
#expect(LanguageModel.Anthropic.isFable(modelId: "claude-fable-5") == true)
|
||||
#expect(LanguageModel.Anthropic.isFable(modelId: "anthropic/claude-fable-5") == true)
|
||||
#expect(LanguageModel.Anthropic.isFable(modelId: "vendor/claude-fable-50") == false)
|
||||
#expect(LanguageModel.Anthropic.isFable(modelId: "my-claude-fable-5-distill") == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Anthropic-compatible provider tags native thinking with wrapper identity`() async throws {
|
||||
let sessionConfig = URLSessionConfiguration.ephemeral
|
||||
sessionConfig.protocolClasses = [AnthropicIdentityURLProtocol.self]
|
||||
let provider = try AnthropicProvider(
|
||||
model: .custom("claude-fable-5"),
|
||||
configuration: TachikomaConfiguration(apiKeys: ["anthropic": "test-key"]),
|
||||
reasoningProvider: "anthropic-compatible",
|
||||
reasoningModelId: "claude-fable-5",
|
||||
reasoningBaseURL: "https://user:secret@example.test/path?token=secret#frag",
|
||||
urlSession: URLSession(configuration: sessionConfig),
|
||||
)
|
||||
|
||||
let response = try await provider.generateText(request: ProviderRequest(messages: [.user("hi")]))
|
||||
let thinking = try #require(response.assistantMessages.first { $0.channel == .thinking })
|
||||
#expect(thinking.metadata?.customData?["tachikoma.reasoning.provider"] == "anthropic-compatible")
|
||||
#expect(thinking.metadata?.customData?["tachikoma.reasoning.model"] == "claude-fable-5")
|
||||
let endpointIdentity = thinking.metadata?.customData?["tachikoma.reasoning.base_url"]
|
||||
#expect(endpointIdentity == ReasoningEndpointIdentity.canonical("https://example.test/path?token=secret"))
|
||||
#expect(endpointIdentity?.hasPrefix("sha256:") == true)
|
||||
#expect(endpointIdentity?.contains("path") == false)
|
||||
#expect(endpointIdentity?.contains("secret") == false)
|
||||
#expect(endpointIdentity?.contains("token") == false)
|
||||
#expect(thinking.metadata?.customData?["anthropic.thinking.signature"] == "sig")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Compatible refusal-prone Anthropic streaming and capabilities are disabled`() async throws {
|
||||
let config = TachikomaConfiguration(apiKeys: ["anthropic_compatible": "test-key"])
|
||||
let provider = try AnthropicCompatibleProvider(
|
||||
modelId: "claude-fable-5",
|
||||
baseURL: "https://example.test",
|
||||
configuration: config,
|
||||
)
|
||||
let opusProvider = try AnthropicCompatibleProvider(
|
||||
modelId: "claude-opus-4-8",
|
||||
baseURL: "https://example.test",
|
||||
configuration: config,
|
||||
)
|
||||
let overriddenProvider = try AnthropicCompatibleProvider(
|
||||
modelId: "claude-fable-5",
|
||||
baseURL: "https://example.test",
|
||||
configuration: config,
|
||||
capabilities: ModelCapabilities(supportsStreaming: true),
|
||||
)
|
||||
|
||||
#expect(provider.capabilities.supportsStreaming == false)
|
||||
#expect(opusProvider.capabilities.supportsStreaming == false)
|
||||
#expect(overriddenProvider.capabilities.supportsStreaming == false)
|
||||
#expect(provider.capabilities.contextLength == 1_000_000)
|
||||
#expect(provider.capabilities.maxOutputTokens == 128_000)
|
||||
#expect(LanguageModel.anthropicCompatible(
|
||||
modelId: "claude-fable-5",
|
||||
baseURL: "https://example.test",
|
||||
).supportsStreaming == false)
|
||||
#expect(LanguageModel.anthropicCompatible(
|
||||
modelId: "claude-opus-4-8",
|
||||
baseURL: "https://example.test",
|
||||
).supportsStreaming == false)
|
||||
#expect(LanguageModel.openaiCompatible(
|
||||
modelId: "claude-fable-5",
|
||||
baseURL: "https://example.test",
|
||||
).supportsStreaming == false)
|
||||
#expect(LanguageModel.anthropicCompatible(
|
||||
modelId: "claude-fable-5",
|
||||
baseURL: "https://example.test",
|
||||
).contextLength == 1_000_000)
|
||||
#expect(LanguageModel.anthropicCompatible(
|
||||
modelId: "anthropic.claude-fable-5",
|
||||
baseURL: "https://example.test",
|
||||
).contextLength == 1_000_000)
|
||||
let openAICompatibleProvider = try OpenAICompatibleProvider(
|
||||
modelId: "claude-fable-5",
|
||||
baseURL: "https://example.test",
|
||||
configuration: TachikomaConfiguration(apiKeys: ["openai_compatible": "test-key"]),
|
||||
)
|
||||
let openRouterProvider = try OpenRouterProvider(
|
||||
modelId: "anthropic/claude-fable-5",
|
||||
configuration: TachikomaConfiguration(apiKeys: ["openrouter": "test-key"]),
|
||||
)
|
||||
let togetherProvider = try TogetherProvider(
|
||||
modelId: "anthropic/claude-fable-5",
|
||||
configuration: TachikomaConfiguration(apiKeys: ["together": "test-key"]),
|
||||
)
|
||||
#expect(openAICompatibleProvider.capabilities.supportsStreaming == false)
|
||||
#expect(openRouterProvider.capabilities.supportsStreaming == false)
|
||||
#expect(togetherProvider.capabilities.supportsStreaming == false)
|
||||
#expect(openAICompatibleProvider.capabilities.contextLength == 1_000_000)
|
||||
#expect(openAICompatibleProvider.capabilities.maxOutputTokens == 128_000)
|
||||
#expect(openRouterProvider.capabilities.contextLength == 1_000_000)
|
||||
#expect(openRouterProvider.capabilities.maxOutputTokens == 128_000)
|
||||
#expect(togetherProvider.capabilities.contextLength == 1_000_000)
|
||||
#expect(togetherProvider.capabilities.maxOutputTokens == 128_000)
|
||||
await #expect(throws: TachikomaError.self) {
|
||||
_ = try await provider.streamText(request: ProviderRequest(messages: [.user("hi")]))
|
||||
}
|
||||
await #expect(throws: TachikomaError.self) {
|
||||
_ = try await openAICompatibleProvider.streamText(request: ProviderRequest(messages: [.user("hi")]))
|
||||
}
|
||||
await #expect(throws: TachikomaError.self) {
|
||||
_ = try await openRouterProvider.streamText(request: ProviderRequest(messages: [.user("hi")]))
|
||||
}
|
||||
await #expect(throws: TachikomaError.self) {
|
||||
_ = try await togetherProvider.streamText(request: ProviderRequest(messages: [.user("hi")]))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -187,3 +937,49 @@ struct AnthropicInterleavedDefaultsTests {
|
||||
#expect(assistant.first?["type"] as? String == "text")
|
||||
}
|
||||
}
|
||||
|
||||
private final class AnthropicIdentityURLProtocol: URLProtocol {
|
||||
override class func canInit(with _: URLRequest) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
request
|
||||
}
|
||||
|
||||
override func startLoading() {
|
||||
guard
|
||||
let url = self.request.url,
|
||||
let response = HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: ["Content-Type": "application/json"],
|
||||
) else
|
||||
{
|
||||
self.client?.urlProtocol(self, didFailWithError: TachikomaError.invalidInput("Missing mock response"))
|
||||
return
|
||||
}
|
||||
|
||||
let body = """
|
||||
{
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-fable-5",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "private", "signature": "sig"},
|
||||
{"type": "text", "text": "ok"}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 2}
|
||||
}
|
||||
""".data(using: .utf8) ?? Data()
|
||||
|
||||
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
self.client?.urlProtocol(self, didLoad: body)
|
||||
self.client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ private final class AzureTestURLProtocol: URLProtocol {
|
||||
static let responseBody: Data = """
|
||||
{
|
||||
"id": "chatcmpl-azure",
|
||||
"model": "gpt-4o",
|
||||
"model": "gpt-5.5",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
@ -85,7 +85,7 @@ struct AzureOpenAIProviderTests {
|
||||
await AzureTestURLProtocol.reset()
|
||||
|
||||
let provider = try AzureOpenAIProvider(
|
||||
deploymentId: "gpt-4o",
|
||||
deploymentId: "gpt-5.5",
|
||||
resource: "my-aoai",
|
||||
apiVersion: "2025-04-01-preview",
|
||||
endpoint: nil,
|
||||
@ -99,7 +99,7 @@ struct AzureOpenAIProviderTests {
|
||||
#expect(response.text == "hello azure")
|
||||
|
||||
let sentRequest = await AzureTestURLProtocol.fetchLastRequest()
|
||||
#expect(sentRequest?.url?.path == "/openai/deployments/gpt-4o/chat/completions")
|
||||
#expect(sentRequest?.url?.path == "/openai/deployments/gpt-5.5/chat/completions")
|
||||
|
||||
if let components = sentRequest?.url.flatMap({ URLComponents(url: $0, resolvingAgainstBaseURL: false) }) {
|
||||
let apiVersion = components.queryItems?.first { $0.name == "api-version" }?.value
|
||||
@ -122,7 +122,7 @@ struct AzureOpenAIProviderTests {
|
||||
await AzureTestURLProtocol.reset()
|
||||
|
||||
let provider = try AzureOpenAIProvider(
|
||||
deploymentId: "gpt-4o-mini",
|
||||
deploymentId: "gpt-5-mini",
|
||||
resource: nil,
|
||||
apiVersion: "2025-04-01-preview",
|
||||
endpoint: nil,
|
||||
|
||||
@ -3,7 +3,7 @@ import Foundation
|
||||
import Testing
|
||||
@testable import Tachikoma
|
||||
|
||||
@Suite(.enabled(if: ProcessInfo.processInfo.environment["INTEGRATION_TESTS"] != nil))
|
||||
@Suite(.serialized, .enabled(if: ProcessInfo.processInfo.environment["INTEGRATION_TESTS"] != nil))
|
||||
struct ProviderIntegrationTests {
|
||||
// MARK: - Test Configuration
|
||||
|
||||
@ -14,50 +14,108 @@ struct ProviderIntegrationTests {
|
||||
static let streamMessage = "Count from 1 to 3"
|
||||
}
|
||||
|
||||
private static func hasEnv(_ name: String) -> Bool {
|
||||
guard let value = ProcessInfo.processInfo.environment[name] else {
|
||||
return false
|
||||
private struct LiveCredentials {
|
||||
var openAI: String?
|
||||
var anthropic: String?
|
||||
var google: String?
|
||||
var mistral: String?
|
||||
var groq: String?
|
||||
var grok: String?
|
||||
|
||||
static func capture() -> Self {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
return Self(
|
||||
openAI: Self.validKey(environment["OPENAI_API_KEY"]),
|
||||
anthropic: Self.validKey(environment["ANTHROPIC_API_KEY"]),
|
||||
google: Self.validKey(environment["GEMINI_API_KEY"]) ?? Self.validKey(environment["GOOGLE_API_KEY"]),
|
||||
mistral: Self.validKey(environment["MISTRAL_API_KEY"]),
|
||||
groq: Self.validKey(environment["GROQ_API_KEY"]),
|
||||
grok: Self.validKey(environment["X_AI_API_KEY"])
|
||||
?? Self.validKey(environment["XAI_API_KEY"])
|
||||
?? Self.validKey(environment["GROK_API_KEY"]),
|
||||
)
|
||||
}
|
||||
|
||||
private static func validKey(_ value: String?) -> String? {
|
||||
guard let key = value?.trimmingCharacters(in: .whitespacesAndNewlines), !key.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let lowercased = key.lowercased()
|
||||
guard
|
||||
key != "env-key",
|
||||
key != "cred-key",
|
||||
key != "test-key",
|
||||
!lowercased.hasPrefix("test-") else
|
||||
{
|
||||
return nil
|
||||
}
|
||||
return key
|
||||
}
|
||||
return !value.isEmpty
|
||||
}
|
||||
|
||||
private static let liveCredentials = LiveCredentials.capture()
|
||||
|
||||
private static var hasOpenAIKey: Bool {
|
||||
hasEnv("OPENAI_API_KEY")
|
||||
liveCredentials.openAI != nil
|
||||
}
|
||||
|
||||
private static var hasAnthropicKey: Bool {
|
||||
hasEnv("ANTHROPIC_API_KEY")
|
||||
liveCredentials.anthropic != nil
|
||||
}
|
||||
|
||||
private static var hasGoogleKey: Bool {
|
||||
hasEnv("GEMINI_API_KEY") || hasEnv("GOOGLE_API_KEY")
|
||||
liveCredentials.google != nil
|
||||
}
|
||||
|
||||
private static var hasMistralKey: Bool {
|
||||
hasEnv("MISTRAL_API_KEY")
|
||||
liveCredentials.mistral != nil
|
||||
}
|
||||
|
||||
private static var hasGroqKey: Bool {
|
||||
hasEnv("GROQ_API_KEY")
|
||||
liveCredentials.groq != nil
|
||||
}
|
||||
|
||||
private static var hasGrokKey: Bool {
|
||||
hasEnv("X_AI_API_KEY") || hasEnv("XAI_API_KEY") || hasEnv("GROK_API_KEY")
|
||||
liveCredentials.grok != nil
|
||||
}
|
||||
|
||||
private static func liveConfiguration() -> TachikomaConfiguration {
|
||||
let credentials = Self.liveCredentials
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
if let openAI = credentials.openAI {
|
||||
config.setAPIKey(openAI, for: .openai)
|
||||
}
|
||||
if let anthropic = credentials.anthropic {
|
||||
config.setAPIKey(anthropic, for: .anthropic)
|
||||
}
|
||||
if let google = credentials.google {
|
||||
config.setAPIKey(google, for: .google)
|
||||
}
|
||||
if let mistral = credentials.mistral {
|
||||
config.setAPIKey(mistral, for: .mistral)
|
||||
}
|
||||
if let groq = credentials.groq {
|
||||
config.setAPIKey(groq, for: .groq)
|
||||
}
|
||||
if let grok = credentials.grok {
|
||||
config.setAPIKey(grok, for: .grok)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// MARK: - OpenAI Integration Tests
|
||||
|
||||
@Test(.enabled(if: Self.hasOpenAIKey))
|
||||
func `OpenAI Provider - Real API Call`() async throws {
|
||||
let model = Model.openai(.gpt4oMini)
|
||||
let config = TachikomaConfiguration()
|
||||
let model = Model.openai(.gpt5Mini)
|
||||
let config = Self.liveConfiguration()
|
||||
do {
|
||||
_ = try ProviderFactory.createProvider(for: model, configuration: config)
|
||||
|
||||
let response = try await generate(
|
||||
TestConfig.shortMessage,
|
||||
using: model,
|
||||
maxTokens: 50,
|
||||
maxTokens: 300,
|
||||
temperature: 0.0,
|
||||
configuration: config,
|
||||
)
|
||||
@ -72,8 +130,8 @@ struct ProviderIntegrationTests {
|
||||
|
||||
@Test(.enabled(if: Self.hasOpenAIKey))
|
||||
func `OpenAI Provider - Tool Calling`() async throws {
|
||||
let model = Model.openai(.gpt4oMini)
|
||||
let config = TachikomaConfiguration()
|
||||
let model = Model.openai(.gpt5Mini)
|
||||
let config = Self.liveConfiguration()
|
||||
|
||||
do {
|
||||
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
|
||||
@ -117,8 +175,8 @@ struct ProviderIntegrationTests {
|
||||
|
||||
@Test(.enabled(if: Self.hasOpenAIKey))
|
||||
func `OpenAI Provider - Streaming`() async throws {
|
||||
let model = Model.openai(.gpt4oMini)
|
||||
let config = TachikomaConfiguration()
|
||||
let model = Model.openai(.gpt5Mini)
|
||||
let config = Self.liveConfiguration()
|
||||
|
||||
do {
|
||||
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
|
||||
@ -128,7 +186,7 @@ struct ProviderIntegrationTests {
|
||||
ModelMessage(role: .user, content: [.text(TestConfig.streamMessage)]),
|
||||
],
|
||||
tools: nil,
|
||||
settings: .init(maxTokens: 100, temperature: 0.0),
|
||||
settings: .init(maxTokens: 300, temperature: 0.0),
|
||||
)
|
||||
|
||||
let stream = try await provider.streamText(request: request)
|
||||
@ -164,13 +222,15 @@ struct ProviderIntegrationTests {
|
||||
|
||||
@Test(.enabled(if: Self.hasAnthropicKey))
|
||||
func `Anthropic Provider - Real API Call`() async throws {
|
||||
let model = Model.anthropic(.sonnet4)
|
||||
let model = Model.anthropic(.sonnet46)
|
||||
let config = Self.liveConfiguration()
|
||||
do {
|
||||
let response = try await generate(
|
||||
TestConfig.shortMessage,
|
||||
using: model,
|
||||
maxTokens: 50,
|
||||
temperature: 0.0,
|
||||
configuration: config,
|
||||
)
|
||||
|
||||
if !(response.lowercased().contains("hello") && response.contains("Tachikoma")) {
|
||||
@ -183,8 +243,8 @@ struct ProviderIntegrationTests {
|
||||
|
||||
@Test(.enabled(if: Self.hasAnthropicKey))
|
||||
func `Anthropic Provider - Tool Calling`() async throws {
|
||||
let model = Model.anthropic(.sonnet4)
|
||||
let config = TachikomaConfiguration()
|
||||
let model = Model.anthropic(.sonnet46)
|
||||
let config = Self.liveConfiguration()
|
||||
|
||||
do {
|
||||
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
|
||||
@ -261,13 +321,15 @@ struct ProviderIntegrationTests {
|
||||
|
||||
@Test(.enabled(if: Self.hasGrokKey))
|
||||
func `Grok Provider - Real API Call`() async throws {
|
||||
let model = Model.grok(.grok3)
|
||||
let model = Model.grok(.grok43)
|
||||
let config = Self.liveConfiguration()
|
||||
do {
|
||||
let response = try await generate(
|
||||
TestConfig.shortMessage,
|
||||
using: model,
|
||||
maxTokens: 50,
|
||||
temperature: 0.0,
|
||||
configuration: config,
|
||||
)
|
||||
if !(response.lowercased().contains("hello") && response.contains("Tachikoma")) {
|
||||
Self.warn("Grok integration returned: \(response.prefix(120))…")
|
||||
@ -282,12 +344,14 @@ struct ProviderIntegrationTests {
|
||||
@Test(.enabled(if: Self.hasGoogleKey))
|
||||
func `Google Provider - Real API Call`() async throws {
|
||||
let model = Model.google(.gemini25Flash)
|
||||
let config = Self.liveConfiguration()
|
||||
do {
|
||||
let response = try await generate(
|
||||
TestConfig.shortMessage,
|
||||
using: model,
|
||||
maxTokens: 50,
|
||||
temperature: 0.0,
|
||||
configuration: config,
|
||||
)
|
||||
if !(response.lowercased().contains("hello") && response.contains("Tachikoma")) {
|
||||
Self.warn("Google integration returned: \(response.prefix(120))…")
|
||||
@ -301,8 +365,15 @@ struct ProviderIntegrationTests {
|
||||
|
||||
@Test(.enabled(if: Self.hasMistralKey))
|
||||
func `Mistral Provider - Real API Call`() async throws {
|
||||
let model = Model.mistral(.small)
|
||||
let response = try await generate(TestConfig.shortMessage, using: model, maxTokens: 50, temperature: 0.0)
|
||||
let model = Model.mistral(.smallLatest)
|
||||
let config = Self.liveConfiguration()
|
||||
let response = try await generate(
|
||||
TestConfig.shortMessage,
|
||||
using: model,
|
||||
maxTokens: 50,
|
||||
temperature: 0.0,
|
||||
configuration: config,
|
||||
)
|
||||
|
||||
#expect(response.lowercased().contains("hello"))
|
||||
#expect(response.contains("Tachikoma"))
|
||||
@ -312,8 +383,15 @@ struct ProviderIntegrationTests {
|
||||
|
||||
@Test(.enabled(if: Self.hasGroqKey))
|
||||
func `Groq Provider - Real API Call`() async throws {
|
||||
let model = Model.groq(.llama38b)
|
||||
let response = try await generate(TestConfig.shortMessage, using: model, maxTokens: 50, temperature: 0.0)
|
||||
let model = Model.groq(.llama318b)
|
||||
let config = Self.liveConfiguration()
|
||||
let response = try await generate(
|
||||
TestConfig.shortMessage,
|
||||
using: model,
|
||||
maxTokens: 50,
|
||||
temperature: 0.0,
|
||||
configuration: config,
|
||||
)
|
||||
|
||||
#expect(response.lowercased().contains("hello"))
|
||||
#expect(response.contains("Tachikoma"))
|
||||
@ -323,12 +401,12 @@ struct ProviderIntegrationTests {
|
||||
|
||||
@Test(.enabled(if: Self.hasOpenAIKey))
|
||||
func `Multi-Modal Provider - Vision Support`() async throws {
|
||||
let model = Model.openai(.gpt4o)
|
||||
let config = TachikomaConfiguration()
|
||||
let model = Model.openai(.gpt55)
|
||||
let config = Self.liveConfiguration()
|
||||
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
|
||||
|
||||
// Create a simple base64 encoded 1x1 red pixel PNG
|
||||
let redPixelPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
|
||||
// Create a simple base64 encoded 16x16 red square PNG
|
||||
let redPixelPNG = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAF0lEQVR4nGP4z8BAEiJN9aiGUQ1DSgMAkPn/Afnh+ngAAAAASUVORK5CYII="
|
||||
|
||||
let imageContent = ModelMessage.ContentPart.ImageContent(
|
||||
data: redPixelPNG,
|
||||
@ -343,13 +421,13 @@ struct ProviderIntegrationTests {
|
||||
]),
|
||||
],
|
||||
tools: nil,
|
||||
settings: .init(maxTokens: 50, temperature: 0.0),
|
||||
settings: .init(maxTokens: 300, temperature: 0.0),
|
||||
)
|
||||
|
||||
let response = try await provider.generateText(request: request)
|
||||
|
||||
let normalized = response.text.lowercased()
|
||||
#expect(normalized.contains("red") || normalized.contains("yellow"))
|
||||
#expect(normalized.contains("red"))
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
@ -9,10 +9,10 @@ struct ProviderSystemTests {
|
||||
@Test
|
||||
func `Provider Factory - OpenAI Provider Creation`() async throws {
|
||||
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
|
||||
let model = Model.openai(.gpt4o)
|
||||
let model = Model.openai(.gpt55)
|
||||
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
|
||||
|
||||
#expect(provider.modelId == "gpt-4o")
|
||||
#expect(provider.modelId == "gpt-5.5")
|
||||
#expect(provider.capabilities.supportsVision == true)
|
||||
#expect(provider.capabilities.supportsTools == true)
|
||||
#expect(provider.capabilities.supportsStreaming == true)
|
||||
@ -22,12 +22,27 @@ struct ProviderSystemTests {
|
||||
@Test
|
||||
func `Provider Factory - Anthropic Provider Creation`() async throws {
|
||||
try await TestHelpers.withTestConfiguration(apiKeys: ["anthropic": "test-key"]) { config in
|
||||
let model = Model.anthropic(.opus4)
|
||||
let model = Model.anthropic(.fable5)
|
||||
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
|
||||
|
||||
#expect(provider.modelId == "claude-opus-4-1-20250805")
|
||||
#expect(provider.modelId == "claude-fable-5")
|
||||
#expect(provider.capabilities.supportsVision == true)
|
||||
#expect(provider.capabilities.supportsTools == true)
|
||||
#expect(provider.capabilities.supportsStreaming == false)
|
||||
#expect(provider.capabilities.contextLength == 1_000_000)
|
||||
#expect(provider.capabilities.maxOutputTokens == 128_000)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Provider Factory - MiniMax Provider Creation`() async throws {
|
||||
try await TestHelpers.withTestConfiguration(apiKeys: ["minimax": "test-key"]) { config in
|
||||
let model = Model.minimax(.m27)
|
||||
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
|
||||
|
||||
#expect(provider.modelId == "MiniMax-M2.7")
|
||||
#expect(provider.capabilities.supportsVision == false)
|
||||
#expect(provider.capabilities.supportsTools == true)
|
||||
#expect(provider.capabilities.supportsStreaming == true)
|
||||
}
|
||||
}
|
||||
@ -35,10 +50,10 @@ struct ProviderSystemTests {
|
||||
@Test
|
||||
func `Provider Factory - Grok Provider Creation`() async throws {
|
||||
try await TestHelpers.withTestConfiguration(apiKeys: ["grok": "test-key"]) { config in
|
||||
let model = Model.grok(.grok4FastReasoning)
|
||||
let model = Model.grok(.grok43)
|
||||
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
|
||||
|
||||
#expect(provider.modelId == "grok-4-fast-reasoning")
|
||||
#expect(provider.modelId == "grok-4.3")
|
||||
#expect(provider.capabilities.supportsTools == true)
|
||||
#expect(provider.capabilities.supportsStreaming == true)
|
||||
}
|
||||
@ -81,20 +96,39 @@ struct ProviderSystemTests {
|
||||
|
||||
let previousOpenAI = getenv("OPENAI_API_KEY").flatMap { String(cString: $0) }
|
||||
let previousAnthropic = getenv("ANTHROPIC_API_KEY").flatMap { String(cString: $0) }
|
||||
let previousMiniMax = getenv("MINIMAX_API_KEY").flatMap { String(cString: $0) }
|
||||
let previousAnthropicCompatible = getenv("ANTHROPIC_COMPATIBLE_API_KEY").flatMap { String(cString: $0) }
|
||||
let previousGeneric = getenv("API_KEY").flatMap { String(cString: $0) }
|
||||
unsetenv("OPENAI_API_KEY")
|
||||
unsetenv("ANTHROPIC_API_KEY")
|
||||
unsetenv("MINIMAX_API_KEY")
|
||||
setenv("ANTHROPIC_COMPATIBLE_API_KEY", "generic-compatible-key", 1)
|
||||
setenv("API_KEY", "generic-key", 1)
|
||||
defer {
|
||||
if let previousOpenAI { setenv("OPENAI_API_KEY", previousOpenAI, 1) }
|
||||
if let previousAnthropic { setenv("ANTHROPIC_API_KEY", previousAnthropic, 1) }
|
||||
// swiftlint:disable:next statement_position
|
||||
if let previousMiniMax { setenv("MINIMAX_API_KEY", previousMiniMax, 1) }
|
||||
else { unsetenv("MINIMAX_API_KEY") }
|
||||
if let previousAnthropicCompatible {
|
||||
setenv("ANTHROPIC_COMPATIBLE_API_KEY", previousAnthropicCompatible, 1)
|
||||
} else {
|
||||
unsetenv("ANTHROPIC_COMPATIBLE_API_KEY")
|
||||
}
|
||||
if let previousGeneric { setenv("API_KEY", previousGeneric, 1) } else { unsetenv("API_KEY") }
|
||||
}
|
||||
|
||||
#expect(throws: TachikomaError.self) {
|
||||
try OpenAIProvider(model: .gpt4o, configuration: config)
|
||||
try OpenAIProvider(model: .gpt55, configuration: config)
|
||||
}
|
||||
|
||||
#expect(throws: TachikomaError.self) {
|
||||
try AnthropicProvider(model: .opus4, configuration: config)
|
||||
}
|
||||
|
||||
#expect(throws: TachikomaError.self) {
|
||||
try ProviderFactory.createProvider(for: .minimax(.m27), configuration: config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,17 +136,15 @@ struct ProviderSystemTests {
|
||||
|
||||
@Test
|
||||
func `Model Capabilities - Vision Support`() {
|
||||
#expect(Model.openai(.gpt4o).supportsVision == true)
|
||||
#expect(Model.openai(.gpt4oMini).supportsVision == true)
|
||||
#expect(Model.openai(.gpt41).supportsVision == false)
|
||||
#expect(Model.openai(.gpt55).supportsVision == true)
|
||||
#expect(Model.openai(.gpt5Mini).supportsVision == true)
|
||||
#expect(Model.openai(.custom("text-only-openai")).supportsVision == false)
|
||||
|
||||
#expect(Model.anthropic(.fable5).supportsVision == true)
|
||||
#expect(Model.anthropic(.opus4).supportsVision == true)
|
||||
#expect(Model.anthropic(.sonnet4).supportsVision == true)
|
||||
#expect(Model.anthropic(.sonnet46).supportsVision == true)
|
||||
|
||||
#expect(Model.grok(.grok2Vision).supportsVision == true)
|
||||
#expect(Model.grok(.grok2Image).supportsVision == true)
|
||||
#expect(Model.grok(.grok2).supportsVision == false)
|
||||
#expect(Model.grok(.grok4).supportsVision == false)
|
||||
#expect(Model.grok(.grok43).supportsVision == true)
|
||||
|
||||
#expect(Model.ollama(.llava).supportsVision == true)
|
||||
#expect(Model.ollama(.llama33).supportsVision == false)
|
||||
@ -121,13 +153,14 @@ struct ProviderSystemTests {
|
||||
|
||||
@Test
|
||||
func `Model Capabilities - Tool Support`() {
|
||||
#expect(Model.openai(.gpt4o).supportsTools == true)
|
||||
#expect(Model.openai(.gpt41).supportsTools == true)
|
||||
#expect(Model.openai(.gpt55).supportsTools == true)
|
||||
#expect(Model.openai(.gpt55).supportsTools == true)
|
||||
|
||||
#expect(Model.anthropic(.fable5).supportsTools == true)
|
||||
#expect(Model.anthropic(.opus4).supportsTools == true)
|
||||
#expect(Model.anthropic(.sonnet4).supportsTools == true)
|
||||
#expect(Model.anthropic(.sonnet46).supportsTools == true)
|
||||
|
||||
#expect(Model.grok(.grok4).supportsTools == true)
|
||||
#expect(Model.grok(.grok43).supportsTools == true)
|
||||
|
||||
#expect(Model.ollama(.llama33).supportsTools == true)
|
||||
#expect(Model.ollama(.llava).supportsTools == false) // Vision models don't support tools
|
||||
@ -136,9 +169,28 @@ struct ProviderSystemTests {
|
||||
|
||||
@Test
|
||||
func `Model Capabilities - Streaming Support`() {
|
||||
#expect(Model.openai(.gpt4o).supportsStreaming == true)
|
||||
#expect(Model.openai(.gpt55).supportsStreaming == true)
|
||||
#expect(Model.anthropic(.opus4).supportsStreaming == true)
|
||||
#expect(Model.grok(.grok4).supportsStreaming == true)
|
||||
#expect(Model.anthropic(.opus47).supportsStreaming == true)
|
||||
#expect(Model.anthropic(.opus48).supportsStreaming == false)
|
||||
#expect(Model.anthropic(.fable5).supportsStreaming == false)
|
||||
#expect(Model.openRouter(modelId: "anthropic/claude-fable-5").supportsStreaming == false)
|
||||
#expect(Model.openRouter(modelId: "fable5").supportsStreaming == false)
|
||||
#expect(Model.openRouter(modelId: "anthropic/claude-opus-4-8").supportsStreaming == false)
|
||||
#expect(Model.together(modelId: "claude-fable-5").supportsStreaming == false)
|
||||
#expect(Model.openaiCompatible(
|
||||
modelId: "anthropic/claude-fable-5",
|
||||
baseURL: "https://example.test",
|
||||
).supportsStreaming == false)
|
||||
#expect(Model.openaiCompatible(
|
||||
modelId: "fable5",
|
||||
baseURL: "https://example.test",
|
||||
).supportsStreaming == false)
|
||||
#expect(Model.openaiCompatible(
|
||||
modelId: "sonnet4-local",
|
||||
baseURL: "https://example.test",
|
||||
).supportsStreaming == true)
|
||||
#expect(Model.grok(.grok43).supportsStreaming == true)
|
||||
#expect(Model.ollama(.llama33).supportsStreaming == true)
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,11 @@ import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#else
|
||||
import Glibc
|
||||
#endif
|
||||
import Testing
|
||||
@testable import Tachikoma
|
||||
|
||||
@ -18,8 +23,11 @@ struct OpenAIResponsesProviderTests {
|
||||
let config = self.openAIConfig()
|
||||
|
||||
let gpt5Models: [LanguageModel.OpenAI] = [
|
||||
.gpt52,
|
||||
.gpt51,
|
||||
.chatLatest,
|
||||
.gpt55,
|
||||
.gpt54,
|
||||
.gpt54Mini,
|
||||
.gpt54Nano,
|
||||
.gpt5,
|
||||
.gpt5Mini,
|
||||
.gpt5Nano,
|
||||
@ -39,8 +47,8 @@ struct OpenAIResponsesProviderTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `GPT-5.1 text.verbosity parameter is set correctly`() throws {
|
||||
// Test that the text.verbosity parameter is properly configured for GPT-5.1
|
||||
func `GPT-5.5 text.verbosity parameter is set correctly`() throws {
|
||||
// Test that the text.verbosity parameter is properly configured for GPT-5.5
|
||||
let config = self.openAIConfig()
|
||||
|
||||
// Skip if no API key
|
||||
@ -49,7 +57,7 @@ struct OpenAIResponsesProviderTests {
|
||||
}
|
||||
|
||||
let provider = try OpenAIResponsesProvider(
|
||||
model: .gpt51,
|
||||
model: .gpt55,
|
||||
configuration: config,
|
||||
)
|
||||
|
||||
@ -64,26 +72,27 @@ struct OpenAIResponsesProviderTests {
|
||||
|
||||
// We can't directly test the internal request building without making it public
|
||||
// But we can verify the provider is configured correctly
|
||||
#expect(provider.modelId == "gpt-5.1")
|
||||
#expect(provider.modelId == "gpt-5.5")
|
||||
#expect(provider.capabilities.supportsTools == true)
|
||||
#expect(provider.capabilities.supportsVision == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Reasoning models use Responses API`() throws {
|
||||
// Test that reasoning-oriented models also use the OpenAIResponsesProvider
|
||||
func `GPT-5 models use Responses API`() throws {
|
||||
// Test that GPT-5 models use the OpenAIResponsesProvider
|
||||
let config = self.openAIConfig()
|
||||
|
||||
let reasoningModels: [LanguageModel.OpenAI] = [
|
||||
.o4Mini,
|
||||
.gpt52,
|
||||
.gpt51,
|
||||
let responsesModels: [LanguageModel.OpenAI] = [
|
||||
.chatLatest,
|
||||
.gpt55,
|
||||
.gpt54,
|
||||
.gpt54Mini,
|
||||
.gpt54Nano,
|
||||
.gpt5,
|
||||
.gpt5Mini,
|
||||
.gpt5Thinking,
|
||||
]
|
||||
|
||||
for model in reasoningModels {
|
||||
for model in responsesModels {
|
||||
let provider = try ProviderFactory.createProvider(
|
||||
for: .openai(model),
|
||||
configuration: config,
|
||||
@ -97,11 +106,10 @@ struct OpenAIResponsesProviderTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Legacy models use standard OpenAI provider`() throws {
|
||||
// Test that non-GPT-5/reasoning models use the standard OpenAIProvider
|
||||
func `Custom OpenAI models use standard OpenAI provider`() throws {
|
||||
let config = self.openAIConfig()
|
||||
|
||||
let legacyModels: [LanguageModel.OpenAI] = [.gpt4o, .gpt4oMini, .gpt41]
|
||||
let legacyModels: [LanguageModel.OpenAI] = [.custom("custom-openai")]
|
||||
|
||||
for model in legacyModels {
|
||||
let provider = try ProviderFactory.createProvider(
|
||||
@ -111,7 +119,7 @@ struct OpenAIResponsesProviderTests {
|
||||
|
||||
#expect(
|
||||
provider is OpenAIProvider,
|
||||
"Legacy model \(model) should use OpenAIProvider",
|
||||
"Custom model \(model) should use OpenAIProvider",
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -196,6 +204,7 @@ struct OpenAIResponsesProviderTests {
|
||||
choices: nil,
|
||||
usage: nil,
|
||||
metadata: nil,
|
||||
incompleteDetails: nil,
|
||||
)
|
||||
|
||||
let providerResponse = try OpenAIResponsesProvider.convertToProviderResponse(response)
|
||||
@ -208,6 +217,159 @@ struct OpenAIResponsesProviderTests {
|
||||
#expect(providerResponse.finishReason == .toolCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `GPT-5 incomplete content filter response maps finish reason`() throws {
|
||||
let output = OpenAIResponsesResponse.ResponsesOutput(
|
||||
id: "out_1",
|
||||
type: "message",
|
||||
status: "incomplete",
|
||||
content: [
|
||||
.init(type: "output_text", text: "blocked partial", toolCall: nil),
|
||||
],
|
||||
role: "assistant",
|
||||
toolCall: nil,
|
||||
)
|
||||
|
||||
let response = try JSONDecoder().decode(OpenAIResponsesResponse.self, from: #require("""
|
||||
{
|
||||
"id": "resp_1",
|
||||
"object": "response",
|
||||
"created_at": 0,
|
||||
"status": "incomplete",
|
||||
"model": "gpt-5",
|
||||
"output": [
|
||||
{
|
||||
"id": "out_1",
|
||||
"type": "message",
|
||||
"status": "incomplete",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "output_text", "text": "blocked partial" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"incomplete_details": { "reason": "content_filter" }
|
||||
}
|
||||
""".data(using: .utf8)))
|
||||
|
||||
let providerResponse = try OpenAIResponsesProvider.convertToProviderResponse(response)
|
||||
|
||||
#expect(output.status == "incomplete")
|
||||
#expect(providerResponse.text.isEmpty)
|
||||
#expect(providerResponse.finishReason == .contentFilter)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `GPT-5 incomplete content filter discards parsed tool calls`() throws {
|
||||
let toolCall = OpenAIResponsesResponse.ResponsesToolCall(
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: .init(name: "see", arguments: "{\"mode\":\"screen\"}"),
|
||||
)
|
||||
let output = OpenAIResponsesResponse.ResponsesOutput(
|
||||
id: "out_1",
|
||||
type: "message",
|
||||
status: "incomplete",
|
||||
content: [
|
||||
.init(type: "output_text", text: "blocked partial", toolCall: nil),
|
||||
.init(type: "tool_call", text: nil, toolCall: toolCall),
|
||||
],
|
||||
role: "assistant",
|
||||
toolCall: nil,
|
||||
)
|
||||
let response = OpenAIResponsesResponse(
|
||||
id: "resp_1",
|
||||
object: "response",
|
||||
createdAt: 0,
|
||||
created: nil,
|
||||
status: "incomplete",
|
||||
model: "gpt-5",
|
||||
output: [output],
|
||||
choices: nil,
|
||||
usage: nil,
|
||||
metadata: nil,
|
||||
incompleteDetails: .init(reason: "content_filter"),
|
||||
)
|
||||
|
||||
let providerResponse = try OpenAIResponsesProvider.convertToProviderResponse(response)
|
||||
|
||||
#expect(providerResponse.text.isEmpty)
|
||||
#expect(providerResponse.toolCalls == nil)
|
||||
#expect(providerResponse.finishReason == .contentFilter)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `GPT-5 completed refusal output maps to content filter`() throws {
|
||||
let output = OpenAIResponsesResponse.ResponsesOutput(
|
||||
id: "out_1",
|
||||
type: "message",
|
||||
status: "completed",
|
||||
content: [
|
||||
.init(type: "refusal", refusal: "I cannot help with that."),
|
||||
],
|
||||
role: "assistant",
|
||||
toolCall: nil,
|
||||
)
|
||||
let response = OpenAIResponsesResponse(
|
||||
id: "resp_1",
|
||||
object: "response",
|
||||
createdAt: 0,
|
||||
created: nil,
|
||||
status: "completed",
|
||||
model: "gpt-5",
|
||||
output: [output],
|
||||
choices: nil,
|
||||
usage: nil,
|
||||
metadata: nil,
|
||||
incompleteDetails: nil,
|
||||
)
|
||||
|
||||
let providerResponse = try OpenAIResponsesProvider.convertToProviderResponse(response)
|
||||
|
||||
#expect(providerResponse.text.isEmpty)
|
||||
#expect(providerResponse.toolCalls == nil)
|
||||
#expect(providerResponse.finishReason == .contentFilter)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Alternate choices content filter suppresses text and tool calls`() throws {
|
||||
let response = try JSONDecoder().decode(OpenAIResponsesResponse.self, from: #require("""
|
||||
{
|
||||
"id": "chatcmpl_1",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "gpt-5",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "blocked partial",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "see",
|
||||
"arguments": "{\\"mode\\":\\"screen\\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"finish_reason": "content_filter",
|
||||
"logprobs": null
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)))
|
||||
|
||||
let providerResponse = try OpenAIResponsesProvider.convertToProviderResponse(response)
|
||||
|
||||
#expect(providerResponse.text.isEmpty)
|
||||
#expect(providerResponse.toolCalls == nil)
|
||||
#expect(providerResponse.finishReason == .contentFilter)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Responses provider hits /v1/responses and encodes body`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
@ -239,6 +401,67 @@ struct OpenAIResponsesProviderTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `chat-latest Responses payload omits GPT-5 reasoning controls`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setAPIKey("live-openai", for: .openai)
|
||||
|
||||
try await self.withMockedSession { request in
|
||||
let body = try #require(Self.bodyData(from: request))
|
||||
let json = try JSONSerialization.jsonObject(with: body) as? [String: Any]
|
||||
#expect(json?["model"] as? String == "chat-latest")
|
||||
#expect(json?["reasoning"] == nil)
|
||||
#expect(json?["text"] == nil)
|
||||
|
||||
return NetworkMocking.jsonResponse(for: request, data: Self.responsesPayload(text: "pong"))
|
||||
} operation: { session in
|
||||
let provider = try OpenAIResponsesProvider(model: .chatLatest, configuration: config, session: session)
|
||||
_ = try await provider.generateText(request: self.sampleRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `GPT-5 Chat Responses payload preserves model ID`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setAPIKey("live-openai", for: .openai)
|
||||
|
||||
try await self.withMockedSession { request in
|
||||
let body = try #require(Self.bodyData(from: request))
|
||||
let json = try JSONSerialization.jsonObject(with: body) as? [String: Any]
|
||||
#expect(json?["model"] as? String == "gpt-5-chat-latest")
|
||||
#expect(json?["reasoning"] == nil)
|
||||
#expect(json?["text"] == nil)
|
||||
|
||||
return NetworkMocking.jsonResponse(for: request, data: Self.responsesPayload(text: "pong"))
|
||||
} operation: { session in
|
||||
let provider = try OpenAIResponsesProvider(
|
||||
model: .gpt5ChatLatest,
|
||||
configuration: config,
|
||||
session: session,
|
||||
)
|
||||
#expect(provider.capabilities.contextLength == 128_000)
|
||||
#expect(provider.capabilities.maxOutputTokens == 16384)
|
||||
_ = try await provider.generateText(request: self.sampleRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Responses provider resolves OAuth access token`() async throws {
|
||||
try await self.withIsolatedAuthState {
|
||||
try TKAuthManager.shared.setCredential(key: "OPENAI_ACCESS_TOKEN", value: "oauth-access-token")
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
|
||||
try await self.withMockedSession { request in
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer oauth-access-token")
|
||||
return NetworkMocking.jsonResponse(for: request, data: Self.responsesPayload(text: "oauth ok"))
|
||||
} operation: { session in
|
||||
let provider = try OpenAIResponsesProvider(model: .gpt5Mini, configuration: config, session: session)
|
||||
let response = try await provider.generateText(request: self.sampleRequest)
|
||||
#expect(response.text.contains("oauth ok"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Responses payload uses data URL string for images`() async throws {
|
||||
let config = self.openAIConfig()
|
||||
@ -450,27 +673,235 @@ struct OpenAIResponsesProviderTests {
|
||||
#expect(request.url?.path == "/v1/responses")
|
||||
let payload = Self.responsesStreamPayload(chunks: [
|
||||
Self.streamChunkJSON(content: "Hello", finishReason: nil),
|
||||
Self.streamChunkJSON(content: "Hello world", finishReason: nil),
|
||||
Self.streamChunkJSON(content: " world", finishReason: nil),
|
||||
Self.streamChunkJSON(content: nil, finishReason: "stop"),
|
||||
])
|
||||
return NetworkMocking.streamResponse(for: request, data: payload)
|
||||
} operation: { session in
|
||||
let provider = try OpenAIResponsesProvider(model: .o4Mini, configuration: config, session: session)
|
||||
let provider = try OpenAIResponsesProvider(model: .gpt55, configuration: config, session: session)
|
||||
let stream = try await provider.streamText(request: self.sampleRequest)
|
||||
|
||||
var collected = ""
|
||||
var receivedDone = false
|
||||
for try await delta in stream {
|
||||
switch delta.type {
|
||||
case .textDelta:
|
||||
collected.append(delta.content ?? "")
|
||||
case .done:
|
||||
break
|
||||
receivedDone = true
|
||||
case .toolCall, .toolResult, .reasoning:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
#expect(collected == "Hello world")
|
||||
#expect(receivedDone)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Responses provider marks completed tool streams as tool calls`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setAPIKey("live-openai", for: .openai)
|
||||
|
||||
try await self.withMockedSession { request in
|
||||
#expect(request.url?.path == "/v1/responses")
|
||||
let payload = Self.responsesStreamPayload(chunks: [
|
||||
Self.streamEventJSON([
|
||||
"type": "response.output_item.added",
|
||||
"item": [
|
||||
"id": "item_1",
|
||||
"type": "function_call",
|
||||
"name": "lookup",
|
||||
],
|
||||
]),
|
||||
Self.streamEventJSON([
|
||||
"type": "response.function_call_arguments.done",
|
||||
"item_id": "item_1",
|
||||
"arguments": #"{"query":"weather"}"#,
|
||||
]),
|
||||
Self.streamEventJSON(["type": "response.completed"]),
|
||||
])
|
||||
return NetworkMocking.streamResponse(for: request, data: payload)
|
||||
} operation: { session in
|
||||
let provider = try OpenAIResponsesProvider(model: .gpt55, configuration: config, session: session)
|
||||
let stream = try await provider.streamText(request: self.sampleRequest)
|
||||
|
||||
var sawToolCall = false
|
||||
var finishReason: FinishReason?
|
||||
for try await delta in stream {
|
||||
if delta.type == .toolCall {
|
||||
sawToolCall = true
|
||||
}
|
||||
if delta.type == .done {
|
||||
finishReason = delta.finishReason
|
||||
}
|
||||
}
|
||||
|
||||
#expect(sawToolCall)
|
||||
#expect(finishReason == .toolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Responses provider maps incomplete content filter stream finish reason`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setAPIKey("live-openai", for: .openai)
|
||||
|
||||
try await self.withMockedSession { request in
|
||||
#expect(request.url?.path == "/v1/responses")
|
||||
let payload = Self.responsesStreamPayload(chunks: [
|
||||
Self.streamChunkJSON(content: "partial", finishReason: nil),
|
||||
Self.streamEventJSON([
|
||||
"type": "response.incomplete",
|
||||
"response": [
|
||||
"incomplete_details": ["reason": "content_filter"],
|
||||
],
|
||||
]),
|
||||
])
|
||||
return NetworkMocking.streamResponse(for: request, data: payload)
|
||||
} operation: { session in
|
||||
let provider = try OpenAIResponsesProvider(model: .gpt55, configuration: config, session: session)
|
||||
let stream = try await provider.streamText(request: self.sampleRequest)
|
||||
|
||||
var collected = ""
|
||||
var finishReason: FinishReason?
|
||||
for try await delta in stream {
|
||||
if case .textDelta = delta.type {
|
||||
collected.append(delta.content ?? "")
|
||||
}
|
||||
if delta.type == .done {
|
||||
finishReason = delta.finishReason
|
||||
}
|
||||
}
|
||||
|
||||
#expect(collected == "partial")
|
||||
#expect(finishReason == .contentFilter)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Responses provider maps refusal stream events to content filter`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setAPIKey("live-openai", for: .openai)
|
||||
|
||||
try await self.withMockedSession { request in
|
||||
#expect(request.url?.path == "/v1/responses")
|
||||
let payload = Self.responsesStreamPayload(chunks: [
|
||||
Self.streamEventJSON([
|
||||
"type": "response.refusal.delta",
|
||||
"delta": "no",
|
||||
]),
|
||||
Self.streamEventJSON(["type": "response.refusal.done"]),
|
||||
Self.streamEventJSON(["type": "response.completed"]),
|
||||
])
|
||||
return NetworkMocking.streamResponse(for: request, data: payload)
|
||||
} operation: { session in
|
||||
let provider = try OpenAIResponsesProvider(model: .gpt55, configuration: config, session: session)
|
||||
let stream = try await provider.streamText(request: self.sampleRequest)
|
||||
|
||||
var finishReason: FinishReason?
|
||||
for try await delta in stream where delta.type == .done {
|
||||
finishReason = delta.finishReason
|
||||
}
|
||||
|
||||
#expect(finishReason == .contentFilter)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Responses provider throws on failed stream event`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setAPIKey("live-openai", for: .openai)
|
||||
|
||||
try await self.withMockedSession { request in
|
||||
#expect(request.url?.path == "/v1/responses")
|
||||
let payload = Self.responsesStreamPayload(chunks: [
|
||||
Self.streamEventJSON([
|
||||
"type": "response.failed",
|
||||
"response": [
|
||||
"error": [
|
||||
"message": "stream failed after partial output",
|
||||
],
|
||||
],
|
||||
]),
|
||||
])
|
||||
return NetworkMocking.streamResponse(for: request, data: payload)
|
||||
} operation: { session in
|
||||
let provider = try OpenAIResponsesProvider(model: .gpt55, configuration: config, session: session)
|
||||
let stream = try await provider.streamText(request: self.sampleRequest)
|
||||
|
||||
do {
|
||||
for try await _ in stream {}
|
||||
Issue.record("Expected stream failure")
|
||||
} catch let error as TachikomaError {
|
||||
guard case let .apiError(message) = error else {
|
||||
Issue.record("Expected apiError, got \(error)")
|
||||
return
|
||||
}
|
||||
#expect(message.contains("response.failed"))
|
||||
#expect(message.contains("stream failed after partial output"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Responses provider throws on error stream event`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setAPIKey("live-openai", for: .openai)
|
||||
|
||||
try await self.withMockedSession { request in
|
||||
#expect(request.url?.path == "/v1/responses")
|
||||
let payload = Self.responsesStreamPayload(chunks: [
|
||||
Self.streamEventJSON([
|
||||
"type": "error",
|
||||
"message": "top-level stream error",
|
||||
]),
|
||||
])
|
||||
return NetworkMocking.streamResponse(for: request, data: payload)
|
||||
} operation: { session in
|
||||
let provider = try OpenAIResponsesProvider(model: .gpt55, configuration: config, session: session)
|
||||
let stream = try await provider.streamText(request: self.sampleRequest)
|
||||
|
||||
do {
|
||||
for try await _ in stream {}
|
||||
Issue.record("Expected stream failure")
|
||||
} catch let error as TachikomaError {
|
||||
guard case let .apiError(message) = error else {
|
||||
Issue.record("Expected apiError, got \(error)")
|
||||
return
|
||||
}
|
||||
#expect(message.contains("error"))
|
||||
#expect(message.contains("top-level stream error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `chat-latest streams Responses event deltas`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setAPIKey("live-openai", for: .openai)
|
||||
|
||||
try await self.withMockedSession { request in
|
||||
#expect(request.url?.path == "/v1/responses")
|
||||
let payload = Self.responsesStreamPayload(chunks: [
|
||||
Self.streamChunkJSON(content: "Hello", finishReason: nil),
|
||||
Self.streamChunkJSON(content: " latest", finishReason: nil),
|
||||
Self.streamChunkJSON(content: nil, finishReason: "stop"),
|
||||
])
|
||||
return NetworkMocking.streamResponse(for: request, data: payload)
|
||||
} operation: { session in
|
||||
let provider = try OpenAIResponsesProvider(model: .chatLatest, configuration: config, session: session)
|
||||
let stream = try await provider.streamText(request: self.sampleRequest)
|
||||
|
||||
var collected = ""
|
||||
for try await delta in stream {
|
||||
if case .textDelta = delta.type {
|
||||
collected.append(delta.content ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
#expect(collected == "Hello latest")
|
||||
}
|
||||
}
|
||||
|
||||
@ -544,33 +975,27 @@ struct OpenAIResponsesProviderTests {
|
||||
}
|
||||
|
||||
private static func streamChunkJSON(content: String?, finishReason: String?) -> String {
|
||||
var delta: [String: Any] = [
|
||||
"role": "assistant",
|
||||
]
|
||||
if let content {
|
||||
delta["content"] = content
|
||||
}
|
||||
|
||||
var choice: [String: Any] = [
|
||||
"index": 0,
|
||||
"delta": delta,
|
||||
]
|
||||
if let finishReason {
|
||||
choice["finish_reason"] = finishReason
|
||||
let chunk: [String: Any] = [
|
||||
"type": "response.output_text.delta",
|
||||
"delta": content,
|
||||
]
|
||||
let data = try! JSONSerialization.data(withJSONObject: chunk)
|
||||
return String(data: data, encoding: .utf8)!
|
||||
}
|
||||
|
||||
let chunk: [String: Any] = [
|
||||
"id": "resp_stream",
|
||||
"object": "response",
|
||||
"created": 1_700_000_000,
|
||||
"model": "o4-mini",
|
||||
"choices": [choice],
|
||||
"type": finishReason == nil ? "response.output_text.done" : "response.completed",
|
||||
]
|
||||
|
||||
let data = try! JSONSerialization.data(withJSONObject: chunk)
|
||||
return String(data: data, encoding: .utf8)!
|
||||
}
|
||||
|
||||
private static func streamEventJSON(_ event: [String: Any]) -> String {
|
||||
let data = try! JSONSerialization.data(withJSONObject: event)
|
||||
return String(data: data, encoding: .utf8)!
|
||||
}
|
||||
|
||||
private func withMockedSession<T>(
|
||||
handler: @Sendable @escaping (URLRequest) throws -> (HTTPURLResponse, Data),
|
||||
operation: (URLSession) async throws -> T,
|
||||
@ -597,6 +1022,39 @@ struct OpenAIResponsesProviderTests {
|
||||
enableMockOverride: false,
|
||||
)
|
||||
}
|
||||
|
||||
private func withIsolatedAuthState<T: Sendable>(
|
||||
_ body: @Sendable () async throws -> T,
|
||||
) async rethrows
|
||||
-> T
|
||||
{
|
||||
try await TestEnvironmentMutex.shared.withLock {
|
||||
let originalProfileDirectory = TachikomaConfiguration.profileDirectoryName
|
||||
let profileDirectory = ".tachikoma-responses-auth-tests-\(UUID().uuidString)"
|
||||
let profilePath = NSString(string: "~/" + profileDirectory).expandingTildeInPath
|
||||
let previousIgnoreEnvironment = TKAuthManager.shared.setIgnoreEnvironment(false)
|
||||
let previousIgnoreCredentialStore = TKAuthManager.shared.setIgnoreCredentialStore(false)
|
||||
let savedOpenAIKey = getenv("OPENAI_API_KEY").map { String(cString: $0) }
|
||||
|
||||
TachikomaConfiguration.profileDirectoryName = profileDirectory
|
||||
unsetenv("OPENAI_API_KEY")
|
||||
try? FileManager.default.removeItem(atPath: profilePath)
|
||||
|
||||
defer {
|
||||
if let savedOpenAIKey {
|
||||
setenv("OPENAI_API_KEY", savedOpenAIKey, 1)
|
||||
} else {
|
||||
unsetenv("OPENAI_API_KEY")
|
||||
}
|
||||
TKAuthManager.shared.setIgnoreEnvironment(previousIgnoreEnvironment)
|
||||
TKAuthManager.shared.setIgnoreCredentialStore(previousIgnoreCredentialStore)
|
||||
TachikomaConfiguration.profileDirectoryName = originalProfileDirectory
|
||||
try? FileManager.default.removeItem(atPath: profilePath)
|
||||
}
|
||||
|
||||
return try await body()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class ResponsesTestURLProtocol: URLProtocol {
|
||||
|
||||
@ -57,7 +57,7 @@ struct ProviderEndToEndTests {
|
||||
let config = Self.makeConfiguration { config in
|
||||
config.setAPIKey("sk-live-openai", for: .openai)
|
||||
}
|
||||
let provider = try OpenAIProvider(model: .gpt4o, configuration: config)
|
||||
let provider = try OpenAIProvider(model: .gpt55, configuration: config)
|
||||
let response = try await provider.generateText(request: Self.basicRequest)
|
||||
#expect(response.text == "OpenAI chat success")
|
||||
}
|
||||
@ -74,7 +74,7 @@ struct ProviderEndToEndTests {
|
||||
let config = Self.makeConfiguration { config in
|
||||
config.setAPIKey("live-anthropic", for: .anthropic)
|
||||
}
|
||||
let provider = try AnthropicProvider(model: .sonnet4, configuration: config)
|
||||
let provider = try AnthropicProvider(model: .sonnet46, configuration: config)
|
||||
let response = try await provider.generateText(request: Self.basicRequest)
|
||||
#expect(response.text == "Claude says hello")
|
||||
}
|
||||
@ -97,21 +97,141 @@ struct ProviderEndToEndTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Google provider encodes tool results as user function responses`() async throws {
|
||||
let toolCall = AgentToolCall(
|
||||
id: "call_weather",
|
||||
name: "get_weather",
|
||||
arguments: ["location": AnyAgentToolValue(string: "Vienna")],
|
||||
)
|
||||
let toolResult = AgentToolResult.success(
|
||||
toolCallId: "call_weather",
|
||||
result: AnyAgentToolValue(object: ["temperature": AnyAgentToolValue(int: 21)]),
|
||||
)
|
||||
let providerRequest = ProviderRequest(
|
||||
messages: [
|
||||
.user("Weather?"),
|
||||
ModelMessage(role: .assistant, content: [.text("Checking."), .toolCall(toolCall)]),
|
||||
ModelMessage(role: .tool, content: [.toolResult(toolResult)]),
|
||||
],
|
||||
settings: .init(maxTokens: 32),
|
||||
)
|
||||
|
||||
try await NetworkMocking.withMockedNetwork { request in
|
||||
let body = try #require(self.bodyData(from: request))
|
||||
let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
let contents = try #require(json["contents"] as? [[String: Any]])
|
||||
|
||||
#expect(contents.count == 3)
|
||||
#expect(contents.compactMap { $0["role"] as? String } == ["user", "model", "user"])
|
||||
|
||||
let modelParts = try #require(contents[1]["parts"] as? [[String: Any]])
|
||||
#expect(modelParts.count == 2)
|
||||
#expect(modelParts[1]["functionCall"] != nil)
|
||||
|
||||
let toolParts = try #require(contents[2]["parts"] as? [[String: Any]])
|
||||
let functionResponse = try #require(toolParts.first?["functionResponse"] as? [String: Any])
|
||||
#expect(functionResponse["id"] as? String == "call_weather")
|
||||
#expect(functionResponse["name"] as? String == "get_weather")
|
||||
|
||||
return NetworkMocking.streamResponse(for: request, data: Self.googleStreamPayload(text: "Done"))
|
||||
} operation: {
|
||||
let config = Self.makeConfiguration { config in
|
||||
config.setAPIKey("google-live", for: .google)
|
||||
}
|
||||
let provider = try GoogleProvider(model: .gemini25Flash, configuration: config)
|
||||
let response = try await provider.generateText(request: providerRequest)
|
||||
#expect(response.text.contains("Done"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Google provider drops orphan required tool parameters`() async throws {
|
||||
let tool = AgentTool(
|
||||
name: "search",
|
||||
description: "Search files",
|
||||
parameters: AgentToolParameters(
|
||||
properties: [
|
||||
"query": AgentToolParameterProperty(
|
||||
name: "query",
|
||||
type: .string,
|
||||
description: "Search query",
|
||||
),
|
||||
],
|
||||
required: ["query", "mode"],
|
||||
),
|
||||
) { _ in
|
||||
AnyAgentToolValue(string: "ok")
|
||||
}
|
||||
let orphanOnlyTool = AgentTool(
|
||||
name: "noop",
|
||||
description: "No-op",
|
||||
parameters: AgentToolParameters(
|
||||
properties: [
|
||||
"reason": AgentToolParameterProperty(
|
||||
name: "reason",
|
||||
type: .string,
|
||||
description: "Reason",
|
||||
),
|
||||
],
|
||||
required: ["missing"],
|
||||
),
|
||||
) { _ in
|
||||
AnyAgentToolValue(string: "ok")
|
||||
}
|
||||
|
||||
let providerRequest = ProviderRequest(
|
||||
messages: [ModelMessage(role: .user, content: [.text("Find it")])],
|
||||
tools: [tool, orphanOnlyTool],
|
||||
settings: .init(maxTokens: 32),
|
||||
)
|
||||
|
||||
try await NetworkMocking.withMockedNetwork { request in
|
||||
let body = try #require(self.bodyData(from: request))
|
||||
let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
let tools = try #require(json["tools"] as? [[String: Any]])
|
||||
let declarations = try #require(tools.first?["functionDeclarations"] as? [[String: Any]])
|
||||
var parametersByName: [String: [String: Any]] = [:]
|
||||
for declaration in declarations {
|
||||
let name = try #require(declaration["name"] as? String)
|
||||
let parameters = try #require(declaration["parameters"] as? [String: Any])
|
||||
#expect(!parametersByName.keys.contains(name))
|
||||
parametersByName[name] = parameters
|
||||
}
|
||||
let searchParameters = try #require(parametersByName["search"])
|
||||
let noopParameters = try #require(parametersByName["noop"])
|
||||
|
||||
#expect(searchParameters["properties"] is [String: Any])
|
||||
#expect(searchParameters["required"] as? [String] == ["query"])
|
||||
#expect(noopParameters["properties"] is [String: Any])
|
||||
#expect(noopParameters["required"] == nil)
|
||||
|
||||
return NetworkMocking.streamResponse(for: request, data: Self.googleStreamPayload(text: "Done"))
|
||||
} operation: {
|
||||
let config = Self.makeConfiguration { config in
|
||||
config.setAPIKey("google-live", for: .google)
|
||||
}
|
||||
let provider = try GoogleProvider(model: .gemini25Flash, configuration: config)
|
||||
let response = try await provider.generateText(request: providerRequest)
|
||||
#expect(response.text.contains("Done"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OpenAI-compatible providers
|
||||
|
||||
@Test
|
||||
func `Mistral provider uses OpenAI-compatible flow`() async throws {
|
||||
try await self.assertOpenAICompatibleProvider(.mistral(.small), provider: .mistral)
|
||||
try await self.assertOpenAICompatibleProvider(.mistral(.smallLatest), provider: .mistral)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Groq provider uses OpenAI-compatible flow`() async throws {
|
||||
try await self.assertOpenAICompatibleProvider(.groq(.llama38b), provider: .groq)
|
||||
try await self.assertOpenAICompatibleProvider(.groq(.llama318b), provider: .groq)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Grok provider uses OpenAI-compatible flow`() async throws {
|
||||
try await self.assertOpenAICompatibleProvider(.grok(.grok4FastReasoning), provider: .grok)
|
||||
try await self.assertOpenAICompatibleProvider(.grok(.grok43), provider: .grok)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -204,6 +324,7 @@ struct ProviderEndToEndTests {
|
||||
try await NetworkMocking.withMockedNetwork { request in
|
||||
self.expectPath(request, endsWith: "/chat/completions")
|
||||
#expect(request.value(forHTTPHeaderField: "HTTP-Referer") == "https://peekaboo.app")
|
||||
#expect(request.value(forHTTPHeaderField: "X-OpenRouter-Title") == "Peekaboo")
|
||||
return NetworkMocking.jsonResponse(for: request, data: Self.chatCompletionPayload(text: "OpenRouter reply"))
|
||||
} operation: {
|
||||
let config = Self.makeConfiguration { config in
|
||||
@ -292,6 +413,110 @@ struct ProviderEndToEndTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Anthropic-compatible provider accepts auth override`() async throws {
|
||||
try await NetworkMocking.withMockedNetwork { request in
|
||||
self.expectPath(request, endsWith: "/messages")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer compat-token")
|
||||
#expect(request.value(forHTTPHeaderField: "x-api-key") == nil)
|
||||
return NetworkMocking.jsonResponse(for: request, data: Self.anthropicPayload(text: "Compat bearer"))
|
||||
} operation: {
|
||||
let provider = try AnthropicCompatibleProvider(
|
||||
modelId: "claude-compat-4",
|
||||
baseURL: "https://compat.anthropic.test",
|
||||
configuration: Self.makeConfiguration { _ in },
|
||||
auth: .bearer("compat-token", betaHeader: nil),
|
||||
)
|
||||
let response = try await provider.generateText(request: Self.basicRequest)
|
||||
#expect(response.text == "Compat bearer")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `MiniMax provider uses bearer auth`() async throws {
|
||||
try await NetworkMocking.withMockedNetwork { request in
|
||||
self.expectPath(request, endsWith: "/messages")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer live-minimax")
|
||||
#expect(request.value(forHTTPHeaderField: "x-api-key") == nil)
|
||||
return NetworkMocking.jsonResponse(for: request, data: Self.anthropicPayload(text: "MiniMax ok"))
|
||||
} operation: {
|
||||
let config = Self.makeConfiguration { config in
|
||||
config.setAPIKey("live-minimax", for: .minimax)
|
||||
}
|
||||
let provider = try ProviderFactory.createProvider(for: .minimax(.m27), configuration: config)
|
||||
let response = try await provider.generateText(request: Self.basicRequest)
|
||||
#expect(response.text == "MiniMax ok")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `MiniMax reasoning metadata is bound to configured endpoint`() async throws {
|
||||
let baseURL = "https://minimax-proxy.test/anthropic?tenant=a"
|
||||
try await NetworkMocking.withMockedNetwork { request in
|
||||
#expect(request.url?.host == "minimax-proxy.test")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer live-minimax")
|
||||
return NetworkMocking.jsonResponse(
|
||||
for: request,
|
||||
data: Self.anthropicPayloadWithThinking(
|
||||
text: "MiniMax ok",
|
||||
thinking: "native-thought",
|
||||
signature: "sig-mm",
|
||||
),
|
||||
)
|
||||
} operation: {
|
||||
let config = Self.makeConfiguration { config in
|
||||
config.setAPIKey("live-minimax", for: .minimax)
|
||||
config.setBaseURL(baseURL, for: .minimax)
|
||||
}
|
||||
let provider = try ProviderFactory.createProvider(for: .minimax(.m27), configuration: config)
|
||||
let response = try await provider.generateText(request: Self.basicRequest)
|
||||
let thinkingMessage = try #require(response.assistantMessages.first { $0.channel == .thinking })
|
||||
let metadata = try #require(thinkingMessage.metadata?.customData)
|
||||
|
||||
#expect(metadata["tachikoma.reasoning.provider"] == "minimax")
|
||||
#expect(metadata["tachikoma.reasoning.model"] == "MiniMax-M2.7")
|
||||
#expect(metadata["anthropic.thinking.signature"] == "sig-mm")
|
||||
#expect(metadata["tachikoma.reasoning.base_url"] == ReasoningEndpointIdentity.canonical(baseURL))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `MiniMax China provider uses China endpoint and bearer auth`() async throws {
|
||||
try await NetworkMocking.withMockedNetwork { request in
|
||||
#expect(request.url?.host == "api.minimaxi.com")
|
||||
self.expectPath(request, endsWith: "/messages")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer live-minimax-cn")
|
||||
#expect(request.value(forHTTPHeaderField: "x-api-key") == nil)
|
||||
return NetworkMocking.jsonResponse(for: request, data: Self.anthropicPayload(text: "MiniMax China ok"))
|
||||
} operation: {
|
||||
let config = Self.makeConfiguration { config in
|
||||
config.setAPIKey("live-minimax-cn", for: .minimaxCN)
|
||||
}
|
||||
let provider = try ProviderFactory.createProvider(for: .minimaxCN(.m27), configuration: config)
|
||||
let response = try await provider.generateText(request: Self.basicRequest)
|
||||
#expect(response.text == "MiniMax China ok")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `MiniMax China provider falls back to MiniMax API key`() async throws {
|
||||
try await NetworkMocking.withMockedNetwork { request in
|
||||
#expect(request.url?.host == "api.minimaxi.com")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer shared-minimax")
|
||||
return NetworkMocking.jsonResponse(
|
||||
for: request,
|
||||
data: Self.anthropicPayload(text: "MiniMax China fallback ok"),
|
||||
)
|
||||
} operation: {
|
||||
let config = Self.makeConfiguration { config in
|
||||
config.setAPIKey("shared-minimax", for: .minimax)
|
||||
}
|
||||
let provider = try ProviderFactory.createProvider(for: .minimaxCN(.m27), configuration: config)
|
||||
let response = try await provider.generateText(request: Self.basicRequest)
|
||||
#expect(response.text == "MiniMax China fallback ok")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func assertOpenAICompatibleProvider(_ model: LanguageModel, provider: Provider) async throws {
|
||||
@ -362,7 +587,7 @@ struct ProviderEndToEndTests {
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"created": 1_723_000_000,
|
||||
"model": "gpt-4o",
|
||||
"model": "gpt-5.5",
|
||||
"choices": [
|
||||
[
|
||||
"index": 0,
|
||||
@ -387,7 +612,26 @@ struct ProviderEndToEndTests {
|
||||
"content": [
|
||||
["type": "text", "text": text],
|
||||
],
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"stop_reason": "end_turn",
|
||||
"usage": [
|
||||
"input_tokens": 12,
|
||||
"output_tokens": 6,
|
||||
],
|
||||
]
|
||||
return try! JSONSerialization.data(withJSONObject: dict)
|
||||
}
|
||||
|
||||
private static func anthropicPayloadWithThinking(text: String, thinking: String, signature: String) -> Data {
|
||||
let dict: [String: Any] = [
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
["type": "thinking", "thinking": thinking, "signature": signature],
|
||||
["type": "text", "text": text],
|
||||
],
|
||||
"model": "MiniMax-M2.7",
|
||||
"stop_reason": "end_turn",
|
||||
"usage": [
|
||||
"input_tokens": 12,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user