Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

106 changed files with 1722 additions and 10268 deletions

View File

@ -16,10 +16,10 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-15, ubuntu-22.04]
os: [macos-latest, ubuntu-22.04]
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Fetch Commander dependency
@ -37,7 +37,7 @@ jobs:
swift-version: '6.2.1'
- name: Cache Swift Package Manager
uses: actions/cache@v6
uses: actions/cache@v4
with:
path: |
.build
@ -52,8 +52,7 @@ jobs:
- name: Run Tests (Unit Tests Only)
run: |
if [[ "${{ runner.os }}" == "Linux" ]]; then
# Several test suites mutate process-wide env/profile state.
swift test --no-parallel --filter TachikomaTests --skip "OpenAIAudioProviderTests" --skip "ProviderEndToEndTests"
swift test --filter TachikomaTests --skip "OpenAIAudioProviderTests" --skip "ProviderEndToEndTests"
else
swift test --filter TachikomaTests
fi
@ -80,10 +79,10 @@ jobs:
continue-on-error: true
strategy:
matrix:
os: [macos-15]
os: [macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Fetch Commander dependency
shell: bash
@ -121,7 +120,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-15
runs-on: macos-latest
needs: test
strategy:
matrix:
@ -131,12 +130,12 @@ jobs:
lint:
name: SwiftLint
runs-on: macos-15
runs-on: macos-latest
# SwiftLint only runs on macOS
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Install SwiftLint
run: brew install swiftlint

View File

@ -27,7 +27,7 @@ jobs:
name: Ubuntu 22.04 LTS
container: swift:6.2
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Fetch Commander dependency
shell: bash
run: git clone --depth 1 https://github.com/steipete/Commander.git ../Commander
@ -48,15 +48,14 @@ jobs:
export OPENAI_API_KEY="${OPENAI_API_KEY:-test-key}"
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-test-key}"
SKIP_FLAGS="--skip ProviderEndToEndTests"
# Several test suites mutate process-wide env/profile state.
swift test --no-parallel $SKIP_FLAGS
swift test $SKIP_FLAGS
test-linux-ubuntu-24:
runs-on: ubuntu-24.04
name: Ubuntu 24.04 LTS
container: swift:6.2
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Fetch Commander dependency
shell: bash
run: git clone --depth 1 https://github.com/steipete/Commander.git ../Commander
@ -77,8 +76,7 @@ jobs:
export OPENAI_API_KEY="${OPENAI_API_KEY:-test-key}"
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-test-key}"
SKIP_FLAGS="--skip ProviderEndToEndTests"
# Several test suites mutate process-wide env/profile state.
swift test --no-parallel $SKIP_FLAGS
swift test $SKIP_FLAGS
# Optional: Build release artifacts
build-release:
@ -86,7 +84,7 @@ jobs:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Create release info
run: |
@ -101,7 +99,7 @@ jobs:
echo "Total: 4 platform configurations tested in parallel" >> release-info.txt
- name: Upload release info
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: cross-platform-validation
path: release-info.txt

View File

@ -13,10 +13,10 @@ concurrency:
jobs:
swiftlint:
name: SwiftLint
runs-on: macos-15
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Fetch Commander dependency
shell: bash
@ -28,7 +28,7 @@ jobs:
xcode-version: '26.1'
- name: Cache SwiftLint
uses: actions/cache@v6
uses: actions/cache@v4
with:
path: |
~/.mint
@ -66,10 +66,10 @@ jobs:
swiftformat:
name: SwiftFormat
runs-on: macos-15
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Fetch Commander dependency
shell: bash
@ -81,7 +81,7 @@ jobs:
xcode-version: '26.1'
- name: Cache SwiftFormat
uses: actions/cache@v6
uses: actions/cache@v4
with:
path: |
~/.mint
@ -108,10 +108,10 @@ jobs:
swift6-compatibility:
name: Swift 6 Compatibility Check
runs-on: macos-15
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Fetch Commander dependency
shell: bash
@ -158,7 +158,7 @@ jobs:
runs-on: macos-15
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Fetch Commander dependency
shell: bash

View File

@ -14,7 +14,7 @@ jobs:
# macOS tests with all Apple platforms
test-apple-platforms:
name: Test Apple Platforms
runs-on: macos-15
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
@ -25,7 +25,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Fetch Commander dependency
shell: bash
@ -37,7 +37,7 @@ jobs:
xcode-version: '26.1'
- name: Cache Swift Package Manager
uses: actions/cache@v6
uses: actions/cache@v4
with:
path: .build
key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
@ -75,7 +75,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Fetch Commander dependency
shell: bash
@ -87,7 +87,7 @@ jobs:
swift-version: ${{ matrix.swift-version }}
- name: Cache Swift Package Manager
uses: actions/cache@v6
uses: actions/cache@v4
with:
path: .build
key: ${{ runner.os }}-spm-${{ matrix.swift-version }}-${{ hashFiles('**/Package.resolved') }}
@ -105,8 +105,7 @@ jobs:
echo "OPENAI_API_KEY missing; skipping OpenAIAudioProviderTests"
SKIP_FLAGS="$SKIP_FLAGS --skip OpenAIAudioProviderTests"
fi
# Several test suites mutate process-wide env/profile state.
swift test --no-parallel --verbose $SKIP_FLAGS
swift test --verbose $SKIP_FLAGS
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@ -119,10 +118,10 @@ jobs:
# Package validation
validate-package:
name: Validate Swift Package
runs-on: macos-15
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Fetch Commander dependency
shell: bash
@ -151,11 +150,11 @@ jobs:
# Integration tests with real APIs (optional, requires secrets)
integration-tests:
name: Integration Tests
runs-on: macos-15
runs-on: macos-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Fetch Commander dependency
shell: bash

View File

@ -107,8 +107,8 @@ disabled_rules:
# Rule configurations
file_length:
warning: 2000
error: 2500
warning: 1000
error: 2000
ignore_comment_only_lines: true
function_parameter_count:
@ -137,8 +137,8 @@ trailing_comma:
mandatory_comma: true
type_body_length:
warning: 1800
error: 2200
warning: 800
error: 1200
type_name:
min_length:

View File

@ -2,32 +2,6 @@
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
@ -35,10 +9,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`) now normalize to supported GPT-5 models 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`, `gpt-4o`) now normalize to `.openai(.gpt51)` 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`, `o4-mini`) in favour of the GPT5 family, updating enums, provider factories, capability tables, prompts, and documentation metadata accordingly.
- Removed deprecated OpenAI reasoning models (`o1`, `o1-mini`, `o3`, `o3-mini`) in favour of the GPT5 family plus `o4-mini`, 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.
@ -87,8 +61,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 custom models
- Responses API for GPT-5 models
- Chat Completions API for standard models (GPT-4o, GPT-4.1)
- Responses API for reasoning models (o3, o4 series)
- Automatic API selection based on model capabilities
- Parameter filtering for reasoning models
- Full streaming support for both APIs
@ -96,7 +70,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 4.x series compatibility
- Claude 3.5/3.7 series compatibility
- Content block handling for multimodal inputs
- System prompt separation
- Server-Sent Events streaming

View File

@ -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-5.5 "Write a short story"
ai-cli --stream --model gpt-4o "Write a short story"
```
## Parameters
@ -35,7 +35,7 @@ ai-cli --stream --model gpt-5.5 "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 GPT-5 reasoning process (note: API currently doesn't expose actual reasoning) |
| `--thinking` | Show reasoning process (O3, O4, GPT-5 - 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,25 +60,30 @@ Add to your shell profile (`~/.zshrc`, `~/.bashrc`) for persistence.
## Supported Models
### OpenAI
- **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`
- **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`
### Anthropic
- **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`
- **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`
### Google
- **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`
- **Gemini 2.5**: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`
### Others
- **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
- **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
### Model Shortcuts
- `claude` → claude-opus-4-7
- `gpt` → gpt-5.5
- `gemini` → gemini-3.1-pro-preview
- `grok` → grok-4.3
- `claude` → claude-opus-4-1-20250805
- `gpt` → gpt-4.1
- `gemini` → gemini-2.5-flash
- `grok` → grok-4-fast-reasoning
- `llama` → llama3.3
## Examples
@ -93,7 +98,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 gpt-5.5 --api responses "Use Responses API"
ai-cli --model o3 --api responses "Use Responses API"
# Debug mode
ai-cli --verbose --model opus "Debug this request"

View File

@ -58,7 +58,7 @@ struct AICLI {
if let modelString = config.modelString {
model = try ModelSelector.parseModel(modelString)
} else {
model = .openai(.gpt55)
model = .openai(.gpt51) // Default to GPT-5.1
}
} 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 (GPT-5 via Responses API)
--thinking Show reasoning/thinking process (O3, O4, 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-5.5 "Describe this image"
ai-cli --model gpt-4o "Describe this image"
ai-cli --model grok "Tell me a joke"
ai-cli --model llama3.3 "Help me debug this code"
@ -213,51 +213,53 @@ struct AICLI {
# Streaming responses
ai-cli --stream --model claude "Write a short story"
# Show thinking process
ai-cli --thinking --model gpt-5.5 "Solve this logic puzzle"
# Show thinking process (reasoning models)
ai-cli --thinking --model gpt-5-thinking "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.5, gpt-5.4, gpt-5.4-mini, gpt-5.4-nano
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)
Anthropic:
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
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)
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-latest, mistral-medium-latest, mistral-medium-3-5
mistral-small-latest, open-mistral-nemo-2407, codestral-latest
mistral-large-2, mistral-large, mistral-small
mistral-nemo, codestral
Groq (Ultra-fast):
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
llama-3.1-70b, llama-3.1-8b
mixtral-8x7b, gemma2-9b
Grok (xAI):
grok-4.3
grok-4.20-0309-reasoning, grok-4.20-0309-non-reasoning
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)
Ollama (Local):
llama3.3, llama3.2, llama3.1 (Recommended)
llava, bakllava (Vision models)
mistral-nemo, qwen2.5
deepseek-r1:8b, deepseek-r1:671b, command-r-plus
codellama, mistral-nemo, qwen2.5
deepseek-r1, command-r-plus
Custom: any-model:tag
SHORTCUTS:
claude, opus claude-opus-4-7
gpt gpt-5.5
grok grok-4.3
gemini gemini-3.1-pro-preview
claude, opus claude-opus-4-1-20250805
gpt, gpt4 gpt-4.1
grok grok-4-fast-reasoning
gemini gemini-2.5-flash
llama, llama3 llama3.3
API KEYS:
@ -374,8 +376,6 @@ 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,14 +406,6 @@ 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'")
@ -481,7 +473,7 @@ struct AICLI {
let supportsThinking = self.isReasoningModel(model) && actualApiMode != .chat
if config.showThinking, !supportsThinking {
print("⚠️ Note: --thinking only works with GPT-5 models via Responses API")
print("⚠️ Note: --thinking only works with O3, O4, and GPT-5 models via Responses API")
}
if case let .openai(openaiModel) = model, actualApiMode == .chat {
@ -593,15 +585,15 @@ struct AICLI {
static func isReasoningModel(_ model: LanguageModel) -> Bool {
guard case let .openai(openaiModel) = model else { return false }
switch openaiModel {
case .gpt55,
.gpt54,
.gpt54Mini,
.gpt54Nano,
case .o4Mini,
.gpt5,
.gpt5Pro,
.gpt5Mini:
return true
case .gpt5,
.gpt5Nano:
.gpt5Mini,
.gpt5Nano,
.gpt5Thinking,
.gpt5ThinkingMini,
.gpt5ThinkingNano,
.gpt5ChatLatest:
return true
default:
return false
@ -680,7 +672,7 @@ struct AICLI {
}
}
// If no summary, try content array.
// If no summary, try content array (for O3/O4)
if reasoningText == nil || reasoningText?.isEmpty == true {
if let contentArray = output["content"] as? [[String: Any]] {
let reasoningParts = contentArray.compactMap { item -> String? in
@ -809,19 +801,23 @@ struct AICLI {
switch model {
case let .openai(openaiModel):
switch openaiModel {
case .gpt55, .gpt54, .gpt5:
case .gpt5: return nil // Pricing TBD
case .gpt5Mini: return nil // Pricing TBD
case .gpt5Nano: return nil // Pricing TBD
case .gpt4o:
inputCostPer1k = 0.005
outputCostPer1k = 0.020
case .gpt54Mini, .gpt54Nano, .gpt5Mini, .gpt5Nano:
return nil // Pricing TBD
outputCostPer1k = 0.015
case .gpt4oMini:
inputCostPer1k = 0.000_15
outputCostPer1k = 0.0006
default: return nil
}
case let .anthropic(anthropicModel):
switch anthropicModel {
case .opus47, .opus45, .opus4:
case .opus4, .opus4Thinking:
inputCostPer1k = 0.015
outputCostPer1k = 0.075
case .sonnet46, .sonnet45:
case .sonnet4, .sonnet4Thinking:
inputCostPer1k = 0.003
outputCostPer1k = 0.015
case .haiku45:
@ -829,9 +825,6 @@ struct AICLI {
outputCostPer1k = 0.004
default: return nil
}
case .minimax:
inputCostPer1k = 0.0003
outputCostPer1k = 0.0012
default:
return nil
}

View File

@ -56,18 +56,18 @@ extension TachikomaExamples {
print("Creating provider-specific models with type safety:")
// OpenAI models
let gpt55 = Model.openai(.gpt55)
let gpt54 = Model.openai(.gpt54)
let gpt4o = Model.openai(.gpt4o)
let gpt41 = Model.openai(.gpt41)
let gpt5Mini = Model.openai(.gpt5Mini)
// Anthropic models
let opus4 = Model.anthropic(.opus4)
let sonnet46 = Model.anthropic(.sonnet46)
let sonnet4 = Model.anthropic(.sonnet4)
let haiku45 = Model.anthropic(.haiku45)
// Grok models
let grok43 = Model.grok(.grok43)
let grokReasoning = Model.grok(.grok420Reasoning)
let grok4 = Model.grok(.grok4FastReasoning)
let grok2Vision = Model.grok(.grok2Vision)
// 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-5.5", baseURL: "https://api.azure.com")
let customAPI = Model.openaiCompatible(modelId: "gpt-4", baseURL: "https://api.azure.com")
let models = [
gpt55,
gpt54,
gpt4o,
gpt41,
gpt5Mini,
opus4,
sonnet46,
sonnet4,
haiku45,
grok43,
grokReasoning,
grok4,
grok2Vision,
llama33,
llava,
openRouter,
@ -100,9 +100,9 @@ extension TachikomaExamples {
// Model capabilities
print("\nModel capabilities:")
print(" • Vision support: \(gpt55.supportsVision)")
print(" • Vision support: \(gpt4o.supportsVision)")
print(" • Tool support: \(opus4.supportsTools)")
print(" • Streaming support: \(sonnet46.supportsStreaming)")
print(" • Streaming support: \(sonnet4.supportsStreaming)")
}
}
@ -192,7 +192,7 @@ extension TachikomaExamples {
// Record some usage
try? await tracker.recordUsage(
operation: .generation,
model: "gpt-5.5".lowercased(),
model: "gpt-4o".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-5.5", .openai(.gpt55)),
("OpenAI GPT-4o", .openai(.gpt4o)),
("Anthropic Opus 4", .anthropic(.opus4)),
("Grok 4.3", .grok(.grok43)),
("Grok 4 Fast", .grok(.grok4FastReasoning)),
("Ollama Llama 3.3", .ollama(.llama3_3)),
]
@ -319,7 +319,7 @@ extension String {
extension Model {
var supportsVision: Bool {
switch self {
case .openai(.gpt55), .ollama(.llava):
case .openai(.gpt4o), .grok(.grok2Vision), .ollama(.llava):
true
default:
false

View File

@ -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 (GPT-5)
- **Thinking Display**: Show reasoning process for models that support it (O3, 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 gpt-5-thinking --thinking "Solve this step by step: ..."
agent-cli --model o3 --thinking "Solve this step by step: ..."
```
### With MCP Tools
@ -171,10 +171,13 @@ agent-cli --mcp-server "db -- npx @modelcontextprotocol/server-postgres postgres
## Supported Models
### OpenAI
- GPT-5 series: `gpt-5.5`, `gpt-5.4`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`
- 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`
### Anthropic
- Claude 4.x: `claude-opus-4.7`, `claude-sonnet-4.6`, `claude-haiku-4.5`
- Claude 4: `opus-4`, `sonnet-4`
- Claude 3.5: `claude`, `sonnet`, `haiku`
### Others
- Google: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`
@ -206,7 +209,7 @@ agent-cli --mcp-server "db -- npx @modelcontextprotocol/server-sqlite ./app.db"
### Complex Reasoning
```bash
agent-cli --model gpt-5-thinking --thinking \
agent-cli --model o3 --thinking \
"Plan a distributed system architecture for a social media platform"
```

View File

@ -163,21 +163,22 @@ final class Agent {
switch model {
case let .openai(openaiModel):
switch openaiModel {
case .gpt5,
case .o4Mini,
.gpt5,
.gpt5Pro,
.gpt5Mini,
.gpt5Nano,
.gpt55,
.gpt54,
.gpt54Mini,
.gpt54Nano:
.gpt5Thinking,
.gpt5ThinkingMini,
.gpt5ThinkingNano,
.gpt5ChatLatest:
true
default:
false
}
case let .anthropic(anthropicModel):
switch anthropicModel {
case .opus47, .opus45, .opus4, .sonnet46, .sonnet45:
case .opus4Thinking, .sonnet4Thinking:
true
default:
false

View File

@ -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.5, gpt-5-thinking, claude)")
@Option(name: .shortAndLong, help: "AI model to use (e.g., gpt-5, claude, o3)")
var model: String = "gpt-5"
@Flag(name: .shortAndLong, help: "Interactive conversation mode")
@ -500,13 +500,10 @@ 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")
}
@ -551,7 +548,7 @@ enum CLIError: LocalizedError {
/// Extension to add provider helpers
extension Provider {
static var allStandard: [Provider] {
[.openai, .anthropic, .google, .minimax, .minimaxCN, .mistral, .groq, .grok, .ollama]
[.openai, .anthropic, .google, .mistral, .groq, .grok, .ollama]
}
var displayName: String {
@ -559,8 +556,6 @@ 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"

View File

@ -49,7 +49,7 @@ echo " • Stream token handling"
echo ""
echo "🔑 API Integration Examples (require valid API keys):"
echo " • OpenAI GPT-5 generation"
echo " • OpenAI GPT-4o, GPT-4.1, o3 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(.gpt55))'
echo ' let answer = try await generate("What is 2+2?", using: .openai(.gpt4o))'
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!"

View File

@ -9,7 +9,7 @@ func demonstrateMultiChannelResponse() async throws {
print("=== Multi-Channel Response Demo ===\n")
let result = try await generateText(
model: .openai(.gpt55),
model: .openai(.gpt4o),
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(.gpt55),
model: .openai(.gpt4o),
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(.gpt55),
model: .openai(.gpt4o),
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(.gpt55),
model: .openai(.gpt4o),
messages: [.user("Hello")],
)
},
@ -144,7 +144,7 @@ func demonstrateEnhancedTools() async throws {
}
let result = try await generateText(
model: .openai(.gpt55),
model: .openai(.gpt4o),
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(.gpt55),
model: .openai(.gpt4o),
messages: [
.system("You are a helpful assistant. Use channels to organize your response."),
.user("Analyze the benefits of functional programming"),

View File

@ -16,7 +16,7 @@ public struct RealtimeAPIDemo {
// 1. Create configuration
print("\n1⃣ Creating Configuration...")
let config = SessionConfiguration(
model: "gpt-realtime",
model: "gpt-4o-realtime-preview",
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: .custom("gpt-realtime"),
model: .gpt4oRealtime,
voice: .nova
)
@ -118,7 +118,7 @@ public struct RealtimeAPIDemo {
/// Create a sample configuration for testing
public static func createSampleConfiguration() -> SessionConfiguration {
SessionConfiguration.voiceConversation(
model: "gpt-realtime",
model: "gpt-4o-realtime-preview",
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-realtime" else { return false }
guard config.model == "gpt-4o-realtime-preview" else { return false }
// Test VAD configuration
let vad = RealtimeTurnDetection.serverVAD

View File

@ -49,7 +49,7 @@ class RealtimeVoiceAssistant {
// Start the conversation
self.conversation = try await startRealtimeConversation(
model: .custom("gpt-realtime"),
model: .gpt4oRealtime,
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: .custom("gpt-realtime"),
model: .gpt4oRealtime,
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: .custom("gpt-realtime"),
model: .gpt4oRealtime,
voice: .shimmer,
)
@ -222,7 +222,7 @@ class RealtimeDemo {
}
let conversation = try await startRealtimeConversation(
model: .custom("gpt-realtime"),
model: .gpt4oRealtime,
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: .custom("gpt-realtime"),
model: .gpt4oRealtime,
voice: .nova,
)

View File

@ -13,7 +13,7 @@ func testRealtimeConfiguration() async throws {
// Test 1: Session Configuration
print("\n1⃣ Testing Session Configuration:")
let voiceConfig = SessionConfiguration.voiceConversation(
model: "gpt-realtime",
model: "gpt-4o-realtime-preview",
voice: .nova,
)
print(" ✅ Model: \(voiceConfig.model)")

View File

@ -24,7 +24,7 @@ class BasicVoiceAssistant: ObservableObject {
// Start conversation with voice
try await self.conversation?.start(
model: .custom("gpt-realtime"),
model: .gpt4oRealtime,
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-realtime",
model: "gpt-4o-realtime-preview",
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-realtime",
model: "gpt-4o-realtime-preview",
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: .custom("gpt-realtime"),
model: .gpt4oRealtime,
voice: .shimmer,
)

View File

@ -29,7 +29,7 @@ class RealtimeVoiceAssistant {
// Start with voice configuration
try await conversation.start(
model: .custom("gpt-realtime"),
model: .gpt4oRealtime,
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-realtime",
model: "gpt-4o-realtime-preview",
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-realtime",
model: "gpt-4o-realtime-preview",
voice: .nova,
tools: [
// Weather tool

View File

@ -1,15 +1,6 @@
{
"originHash" : "12a454cd38a6ae2519d652cc0872f7f18feb64690ce83d1507bae6db71c1841c",
"originHash" : "243e2c6245528e2e7d881e19f057c59ff495268ec997e5ed087ee704fa9a80d8",
"pins" : [
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b",
"version" : "0.2.2"
}
},
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
@ -33,8 +24,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008",
"version" : "1.7.1"
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
"version" : "1.7.0"
}
},
{
@ -42,8 +33,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms.git",
"state" : {
"revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440",
"version" : "1.1.4"
"revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272",
"version" : "1.1.3"
}
},
{
@ -60,8 +51,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
"version" : "1.6.0"
"revision" : "6675bc0ff86e61436e615df6fc5174e043e57924",
"version" : "1.4.1"
}
},
{
@ -87,8 +78,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a",
"version" : "1.13.2"
"revision" : "5073617dac96330a486245e4c0179cb0a6fd2256",
"version" : "1.12.0"
}
},
{
@ -96,8 +87,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "a8e036cb8628fcc1ff67dfec6ce8168617172c9b",
"version" : "2.101.1"
"revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237",
"version" : "2.99.0"
}
},
{
@ -114,8 +105,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
"state" : {
"revision" : "a0ae212ebf6eab5f754c3129608bc5557637e605",
"version" : "0.12.1"
"revision" : "6132fd4b5b4217ce4717c4775e4607f5c3120129",
"version" : "0.12.0"
}
},
{
@ -132,8 +123,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1",
"version" : "1.7.2"
"revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
"version" : "1.6.4"
}
}
],

View File

@ -1,18 +1,8 @@
// 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: [
@ -59,7 +49,7 @@ let package = Package(
targets: ["TachikomaConfigCLI"]),
],
dependencies: [
commanderDependency,
.package(path: "../Commander"),
.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"),

View File

@ -10,8 +10,6 @@
<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>
![Tachikoma banner](docs/assets/readme-banner.jpg)
Modern, Swift-native APIs for text, vision, tools, and realtime voice.
</div>
@ -41,7 +39,7 @@ print(text)
```swift
import Tachikoma
let stream = try await stream("Explain actors in Swift.", using: .openai(.gpt54))
let stream = try await stream("Explain actors in Swift.", using: .openai(.gpt52))
for try await delta in stream {
print(delta.content ?? "", terminator: "")
}
@ -66,7 +64,7 @@ import Tachikoma
let pngData: Data = /* ... */
let image = ImageInput(data: pngData, mimeType: "image/png")
let answer = try await analyze(image: image, prompt: "Whats in this image?", using: .openai(.gpt55))
let answer = try await analyze(image: image, prompt: "Whats in this image?", using: .openai(.gpt4o))
print(answer)
```
@ -89,7 +87,7 @@ let tool = createTool(
}
let result = try await generateText(
model: .openai(.gpt54),
model: .openai(.gpt52),
messages: [.user("Compute 123 + 456 using the add tool.")],
tools: [tool],
maxSteps: 3
@ -100,10 +98,10 @@ print(result.text)
## Models
Common picks:
- 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`
- 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`
- Local: `ollama/llama3.3`
Full catalog (including enum case names + provider notes): [`docs/models.md`](docs/models.md).

View File

@ -25,7 +25,6 @@ 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()
@ -39,7 +38,6 @@ public enum TKProviderId: String, CaseIterable, Sendable {
case .anthropic: "Anthropic"
case .grok: "Grok (xAI)"
case .gemini: "Gemini"
case .openrouter: "OpenRouter"
}
}
@ -57,7 +55,6 @@ 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"]
}
}
@ -81,7 +78,8 @@ public struct TKCredentialStore {
public init() {}
private var baseDir: String {
TachikomaConfiguration.profileDirectoryPath
let dir = TachikomaConfiguration.profileDirectoryName
return NSString(string: "~/" + dir).expandingTildeInPath
}
private var credentialsPath: String {
@ -139,8 +137,8 @@ public final class TKAuthManager {
private init() {}
private func environmentValue(for key: String, ignoringEnvironment: Bool) -> String? {
guard !ignoringEnvironment else { return nil }
private func environmentValue(for key: String) -> String? {
guard !self.ignoreEnv else { return nil }
let value = key.withCString { keyPtr -> String? in
guard let cValue = getenv(keyPtr) else { return nil }
let string = String(cString: cValue)
@ -151,14 +149,6 @@ 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()
@ -178,17 +168,20 @@ public final class TKAuthManager {
}
public func credentialValue(for key: String) -> String? {
let state = self.authState()
if let env = self.environmentValue(for: key, ignoringEnvironment: state.ignoreEnv) { return env }
return state.creds[key]
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]
}
public func resolveAuth(for provider: TKProviderId) -> TKAuthValue? {
let state = self.authState()
let creds = state.creds
self.lock.lock()
let creds = self.ignoreStore ? [:] : self.store.load()
self.lock.unlock()
switch provider {
case .openai:
if let env = self.environmentValue(for: "OPENAI_API_KEY", ignoringEnvironment: state.ignoreEnv) {
if let env = self.environmentValue(for: "OPENAI_API_KEY") {
return .bearer(env, betaHeader: nil)
}
if let access = creds["OPENAI_ACCESS_TOKEN"], !access.isEmpty {
@ -198,7 +191,7 @@ public final class TKAuthManager {
return .apiKey(key)
}
case .anthropic:
if let env = self.environmentValue(for: "ANTHROPIC_API_KEY", ignoringEnvironment: state.ignoreEnv) {
if let env = self.environmentValue(for: "ANTHROPIC_API_KEY") {
return .apiKey(env)
}
if let access = creds["ANTHROPIC_ACCESS_TOKEN"], !access.isEmpty {
@ -211,7 +204,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, ignoringEnvironment: state.ignoreEnv) {
if let env = self.environmentValue(for: k) {
return .bearer(env, betaHeader: nil)
}
}
@ -219,17 +212,10 @@ 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", ignoringEnvironment: state.ignoreEnv) {
if let env = self.environmentValue(for: "GEMINI_API_KEY") {
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
}
@ -325,7 +311,7 @@ public final class TKAuthManager {
requiresStateInTokenExchange: true,
pkce: pkce,
)
case .grok, .gemini, .openrouter:
case .grok, .gemini:
OAuthConfig(
prefix: "",
authorize: "",
@ -589,7 +575,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-haiku-4-5",
"model": "claude-3-haiku-20241022",
"max_tokens": 1,
"messages": [
["role": "user", "content": "ping"],
@ -608,13 +594,6 @@ 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 ",
)
}
}

View File

@ -1,56 +1,18 @@
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 pendingThinkingBlocks: [(text: String, signature: String?, type: String)] = []
var pendingSignedThinking: (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:
@ -86,32 +48,29 @@ enum AnthropicMessageConversion {
}.joined()
let signature = message.metadata?.customData?[thinkingSignatureKey]
let type = message.metadata?.customData?[thinkingTypeKey] ?? "thinking"
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))
if let signature, !signature.isEmpty {
pendingSignedThinking = (text: text, signature: signature, type: type)
}
continue
}
var content: [AnthropicContent] = []
if thinkingEnabled, !pendingThinkingBlocks.isEmpty {
appendThinkingBlocks(pendingThinkingBlocks, to: &content)
pendingThinkingBlocks.removeAll()
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
}
// Process each content part
@ -180,12 +139,21 @@ enum AnthropicMessageConversion {
}
}
if thinkingEnabled, !pendingThinkingBlocks.isEmpty {
var content: [AnthropicContent] = []
appendThinkingBlocks(pendingThinkingBlocks, to: &content)
if !content.isEmpty {
anthropicMessages.append(AnthropicMessage(role: "assistant", content: content))
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,
))
}
anthropicMessages.append(AnthropicMessage(role: "assistant", content: [thinkingContent]))
}
return (systemMessage, anthropicMessages)

View File

@ -1,12 +1,3 @@
#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
@ -25,46 +16,18 @@ 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,
additionalHeaders: [String: String] = [:],
authOverride: TKAuthValue? = nil,
reasoningProvider: String = "anthropic",
reasoningModelId: String? = nil,
reasoningBaseURL: String? = nil,
urlSession: URLSession = .shared,
) throws {
public init(model: LanguageModel.Anthropic, configuration: TachikomaConfiguration) 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 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) {
if let key = configuration.getAPIKey(for: .anthropic) {
self.auth = .apiKey(key)
self.apiKey = key
} else if let auth = TKAuthManager.shared.resolveAuth(for: .anthropic) {
@ -79,18 +42,16 @@ public final class AnthropicProvider: ModelProvider {
throw TachikomaError.authenticationFailed("ANTHROPIC_API_KEY not found")
}
self.betaHeader = Self.mergedBetaHeader(configuration: configuration, auth: self.auth, model: model)
self.betaHeader = Self.mergedBetaHeader(configuration: configuration, auth: self.auth)
let isFable = Self.isFable(model: model)
let supportsSafeStreaming = !Self.hasStreamingRefusalRisk(model: model)
self.capabilities = ModelCapabilities(
supportsVision: model.supportsVision,
supportsTools: model.supportsTools,
supportsStreaming: supportsSafeStreaming,
supportsStreaming: true,
supportsAudioInput: model.supportsAudioInput,
supportsAudioOutput: model.supportsAudioOutput,
contextLength: isFable ? 1_000_000 : model.contextLength,
maxOutputTokens: isFable ? 128_000 : model.maxOutputTokens,
contextLength: model.contextLength,
maxOutputTokens: 4096,
)
}
@ -118,16 +79,6 @@ 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
@ -137,88 +88,19 @@ 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?,
model: LanguageModel.Anthropic,
)
-> AnthropicThinking?
{
private func anthropicThinking(from mode: AnthropicOptions.ThinkingMode?) -> 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>")")
@ -245,49 +127,16 @@ 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 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,
)
let requestedThinking = self.anthropicThinking(from: request.settings.providerOptions.anthropic?.thinking)
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: preserveSignedThinking,
reasoningTarget: reasoningTarget,
thinkingEnabled: requestedThinking != nil,
)
} catch {
// If we can't provide signed thinking blocks for a cached/history session, fall back to non-thinking mode.
@ -296,26 +145,19 @@ 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: maxTokens,
temperature: thinking == nil ? validatedSettings.temperature : nil,
maxTokens: request.settings.maxTokens ?? 1024,
temperature: request.settings.temperature,
system: systemMessage,
messages: messages,
tools: request.tools?.map { try self.convertToolToAnthropic($0) },
thinking: thinking,
outputConfig: outputConfig,
stream: stream,
)
@ -345,7 +187,7 @@ public final class AnthropicProvider: ModelProvider {
}
}
let (data, response) = try await self.urlSession.data(for: urlRequest)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw TachikomaError.networkError(NSError(domain: "Invalid response", code: 0))
@ -377,7 +219,7 @@ public final class AnthropicProvider: ModelProvider {
switch content {
case let .text(textContent):
textContent.text
case .thinking, .redactedThinking, .toolUse:
case .toolUse:
nil
}
}.joined()
@ -387,63 +229,19 @@ public final class AnthropicProvider: ModelProvider {
outputTokens: anthropicResponse.usage.outputTokens,
)
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,
)
let finishReason: FinishReason? = switch anthropicResponse.stopReason {
case "end_turn": .stop
case "max_tokens": .length
case "tool_use": .toolCalls
case "stop_sequence": .stop
default: .other
}
var reasoning: [ProviderReasoningBlock] = []
var toolCalls: [AgentToolCall] = []
var assistantMessages: [ModelMessage] = []
for content in anthropicResponse.content {
// Convert tool calls if present
let toolCalls = anthropicResponse.content.compactMap { content -> AgentToolCall? in
switch content {
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 .text:
return nil
case let .toolUse(toolUse):
// Convert input to AnyAgentToolValue dictionary
var arguments: [String: AnyAgentToolValue] = [:]
@ -459,13 +257,11 @@ public final class AnthropicProvider: ModelProvider {
}
}
let toolCall = AgentToolCall(
return AgentToolCall(
id: toolUse.id,
name: toolUse.name,
arguments: arguments,
)
toolCalls.append(toolCall)
assistantMessages.append(ModelMessage(role: .assistant, content: [.toolCall(toolCall)]))
}
}
@ -474,61 +270,9 @@ 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:
@ -536,19 +280,10 @@ public final class AnthropicProvider: ModelProvider {
case .bearer:
request.setValue("Bearer " + secret, forHTTPHeaderField: "Authorization")
}
if !self.betaHeader.isEmpty {
request.setValue(self.betaHeader, forHTTPHeaderField: "anthropic-beta")
}
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
@ -599,7 +334,7 @@ public final class AnthropicProvider: ModelProvider {
(Data, URLResponse),
Error,
>) in
self.urlSession.dataTask(with: urlRequest) { data, response, error in
URLSession.shared.dataTask(with: urlRequest) { data, response, error in
if let error {
continuation.resume(throwing: error)
} else if let data, let response {
@ -627,7 +362,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 self.urlSession.bytes(for: urlRequest)
let (bytes, response) = try await URLSession.shared.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw TachikomaError.networkError(NSError(domain: "Invalid response", code: 0))
@ -651,7 +386,6 @@ 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 {
@ -685,7 +419,7 @@ public final class AnthropicProvider: ModelProvider {
currentReasoningType = nil
reasoningSignatureEmitted = false
}
continuation.yield(.done(finishReason: finishReason))
continuation.yield(TextStreamDelta.done())
break
}
@ -716,12 +450,6 @@ 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
}
}
@ -826,9 +554,7 @@ public final class AnthropicProvider: ModelProvider {
case "message_delta":
// Message-level updates (usage, etc.)
if let stopReason = event.delta?.stopReason {
finishReason = Self.mapFinishReason(stopReason)
}
// Usage is typically included in the done event, not separately
continue
case "message_stop":
@ -848,7 +574,7 @@ public final class AnthropicProvider: ModelProvider {
currentReasoningType = nil
reasoningSignatureEmitted = false
}
continuation.yield(.done(finishReason: finishReason))
continuation.yield(TextStreamDelta.done())
default:
// Unknown event type, skip
@ -885,7 +611,6 @@ public final class AnthropicProvider: ModelProvider {
var currentReasoningSignature: String?
var currentReasoningType: String?
var reasoningSignatureEmitted = false
var finishReason: FinishReason?
do {
for line in lines {
@ -912,7 +637,7 @@ public final class AnthropicProvider: ModelProvider {
type: currentReasoningType,
))
}
continuation.yield(.done(finishReason: finishReason))
continuation.yield(TextStreamDelta.done())
break
}
@ -931,12 +656,6 @@ 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 {
@ -959,10 +678,6 @@ 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))
@ -974,7 +689,7 @@ public final class AnthropicProvider: ModelProvider {
type: currentReasoningType,
))
}
continuation.yield(.done(finishReason: finishReason))
continuation.yield(TextStreamDelta.done())
default:
continue
}
@ -1038,37 +753,6 @@ 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 {

View File

@ -11,59 +11,12 @@ 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, or an absolute path,
/// used to store configuration and credentials (e.g. \.tachikoma, \.peekaboo).
/// Name of the profile directory under the user's HOME used to store
/// configuration and credentials (e.g. \.tachikoma, \.peekaboo).
/// Defaults to ".tachikoma". Host applications (like Peekaboo) should set this
/// to their own folder/path during startup.
/// to their own folder name 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] = [:]
@ -142,10 +95,6 @@ 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()
@ -332,17 +281,7 @@ public final class TachikomaConfiguration: @unchecked Sendable {
self.loadFromEnvironment()
}
/// 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)
/// Load configuration from environment variables
private func loadFromEnvironment() {
// Load API keys for all standard providers from environment
for provider in Provider.standardProviders {
@ -355,8 +294,6 @@ 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",
]
@ -375,13 +312,23 @@ public final class TachikomaConfiguration: @unchecked Sendable {
/// Load configuration from credentials file
private func loadFromCredentials() {
// Load configuration from credentials file
// Primary: configured profile directory/path (e.g. .peekaboo or PEEKABOO_CONFIG_DIR)
let primaryCredentialsPath = "\(Self.profileDirectoryPath)/credentials"
#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 (e.g. .peekaboo)
let primaryCredentialsPath = "\(homeDirectory)/\(Self.profileDirectoryName)/credentials"
// Fallback: legacy .tachikoma directory for non-Peekaboo users
let fallbackCredentialsPath = Self.homeDirectoryPath.map { "\($0)/.tachikoma/credentials" }
let fallbackCredentialsPath = "\(homeDirectory)/.tachikoma/credentials"
let candidates = [primaryCredentialsPath, fallbackCredentialsPath].compactMap(\.self)
let candidates = [primaryCredentialsPath, fallbackCredentialsPath]
let credentialsPath = candidates.first { FileManager.default.fileExists(atPath: $0) }
guard let path = credentialsPath else { return }
let credentialsURL = URL(fileURLWithPath: path)
@ -409,32 +356,46 @@ public final class TachikomaConfiguration: @unchecked Sendable {
let key = components[0].trimmingCharacters(in: .whitespacesAndNewlines)
let value = components[1...].joined(separator: "=").trimmingCharacters(in: .whitespacesAndNewlines)
if let provider = Self.provider(forCredentialKey: key) {
self.setAPIKey(value, for: provider)
// 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)
}
}
}
}
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
let profileDir = Self.profileDirectoryPath
guard !profileDir.isEmpty else {
throw TachikomaError.invalidConfiguration("Profile directory not found")
#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")
}
#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
@ -449,8 +410,7 @@ public final class TachikomaConfiguration: @unchecked Sendable {
self.lock.withLock {
for (provider, key) in self._apiKeys {
let standardEnvVar = Provider.from(identifier: provider).environmentVariable
let envVarName = standardEnvVar.isEmpty ? "\(provider.uppercased())_API_KEY" : standardEnvVar
let envVarName = "\(provider.uppercased())_API_KEY"
lines.append("\(envVarName)=\(key)")
}
}

View File

@ -7,7 +7,6 @@ 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)
}
@ -30,7 +29,6 @@ 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] {
@ -38,14 +36,7 @@ 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,
apiKey: apiKey,
headers: headers,
models: models,
)
out[id] = CustomProviderInfo(id: id, kind: kind, baseURL: baseURL, headers: headers, models: models)
}
self.providers = out
}
@ -61,7 +52,12 @@ public final class CustomProviderRegistry: @unchecked Sendable {
// MARK: - Helpers
private static func profileDirectoryPath() -> String {
TachikomaConfiguration.profileDirectoryPath
#if os(Windows)
let home = ProcessInfo.processInfo.environment["USERPROFILE"] ?? ""
#else
let home = ProcessInfo.processInfo.environment["HOME"] ?? ""
#endif
return "\(home)/\(TachikomaConfiguration.profileDirectoryName)"
}
private static func profileConfigPath() -> String {

View File

@ -113,10 +113,10 @@ public enum EmbeddingModel: Sendable {
// Convert to LanguageModel for usage tracking
switch self {
case .openai:
.openai(.gpt55) // Placeholder for tracking
.openai(.gpt4o) // Placeholder for tracking
case .cohere, .voyage, .custom:
// Return a dummy model for tracking purposes
.openai(.gpt55)
.openai(.gpt4o)
}
}
}

View File

@ -35,11 +35,10 @@ 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.sanitizedForProvider(model, configuration: resolvedConfiguration),
messages: currentMessages,
tools: tools,
settings: settings,
)
@ -52,23 +51,8 @@ public func generateText(
try await provider.generateText(request: request)
}
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 {
// Track usage with proper session management
if let usage = response.usage {
let actualSessionId = sessionId ?? "generation-\(UUID().uuidString)"
// Start session if not already started
@ -102,8 +86,8 @@ public func generateText(
// Create step record
let step = GenerationStep(
stepIndex: stepIndex,
text: responseText,
toolCalls: responseToolCalls,
text: response.text,
toolCalls: response.toolCalls ?? [],
toolResults: [],
usage: response.usage,
finishReason: response.finishReason,
@ -111,26 +95,18 @@ public func generateText(
allSteps.append(step)
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"]),
))
}
}
// Add assistant message
var assistantContent: [ModelMessage.ContentPart] = [.text(response.text)]
// Handle tool calls
if !responseToolCalls.isEmpty {
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))
// Execute tools
var toolResults: [AgentToolResult] = []
for toolCall in responseToolCalls {
for toolCall in toolCalls {
if let tool = tools?.first(where: { $0.name == toolCall.name }) {
do {
// Debug: Log tool call details in verbose mode
@ -148,7 +124,7 @@ public func generateText(
// Create execution context with full conversation and model info
let context = ToolExecutionContext(
messages: currentMessages.sanitizedForToolContext(),
messages: currentMessages,
model: model,
settings: settings,
sessionId: sessionId ?? "generation-\(UUID().uuidString)",
@ -185,8 +161,8 @@ public func generateText(
// Update step with tool results
allSteps[stepIndex] = GenerationStep(
stepIndex: stepIndex,
text: responseText,
toolCalls: responseToolCalls,
text: response.text,
toolCalls: toolCalls,
toolResults: toolResults,
usage: response.usage,
finishReason: response.finishReason,
@ -198,17 +174,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 finalFinishReason != .contentFilter, let stopCondition = settings.stopConditions {
if 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
@ -241,16 +217,13 @@ 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: finalMessages,
messages: currentMessages,
)
}
@ -281,9 +254,6 @@ 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 {
@ -305,7 +275,7 @@ public func streamText(
}
let request = ProviderRequest(
messages: messages.sanitizedForProvider(model, configuration: resolvedConfiguration),
messages: messages,
tools: tools,
settings: settings,
)
@ -326,6 +296,12 @@ 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 {
@ -335,84 +311,23 @@ public func streamText(
// Wrap the stream to track usage when it completes
let capturedModel = model
let capturedSessionId = actualSessionId
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 shouldEndSession = sessionId == 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
func track(_ delta: TextStreamDelta) {
for try await delta in capturedStream {
continuation.yield(delta)
// 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)
@ -433,10 +348,6 @@ public func streamText(
}
}
if buffersUntilDone, !didReceiveTerminal {
throw TachikomaError.apiError("Stream ended before provider completion status was received")
}
continuation.finish()
} catch {
if shouldEndSession {
@ -481,7 +392,7 @@ public func generateObject<T: Codable & Sendable>(
let provider = try resolvedConfiguration.makeProvider(for: model)
let request = ProviderRequest(
messages: messages.sanitizedForProvider(model, configuration: resolvedConfiguration),
messages: messages,
tools: nil,
settings: settings,
outputFormat: .json,
@ -495,10 +406,6 @@ 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")
@ -539,14 +446,11 @@ 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.sanitizedForProvider(model, configuration: resolvedConfiguration),
messages: messages,
tools: nil,
settings: settings,
outputFormat: .json,
@ -554,7 +458,6 @@ 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
@ -563,37 +466,6 @@ 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 {
@ -602,16 +474,7 @@ public func streamObject<T: Codable & Sendable>(
// Signal stream start
if !hasStarted {
hasStarted = true
let startDelta = ObjectStreamDelta<T>(type: .start)
if buffersUntilDone {
bufferedStartDelta = startDelta
} else {
continuation.yield(startDelta)
}
}
if buffersUntilDone {
continue
continuation.yield(ObjectStreamDelta(type: .start))
}
// Attempt to parse the accumulated JSON
@ -619,51 +482,44 @@ public func streamObject<T: Codable & Sendable>(
// Try to parse as complete object
if let object = try? JSONDecoder().decode(T.self, from: jsonData) {
lastValidObject = object
let objectDelta = ObjectStreamDelta(
continuation.yield(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
let objectDelta = ObjectStreamDelta(
continuation.yield(ObjectStreamDelta(
type: .partial,
object: partialObject,
rawText: accumulatedText,
)
continuation.yield(objectDelta)
))
}
}
} else if case .done = delta.type {
if delta.finishReason == .contentFilter {
throw TachikomaError.apiError("Response was blocked by the provider content filter")
// 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",
)
}
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))
}
}
@ -743,466 +599,6 @@ 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
@ -1274,7 +670,7 @@ public func analyze(
model
} else {
// Use a vision-capable model by default
.openai(.gpt55)
.openai(.gpt4o)
}
// Ensure the model supports vision
@ -1350,7 +746,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 = .defaultStreaming,
using model: LanguageModel = .default,
system: String? = nil,
maxTokens: Int? = nil,
temperature: Double? = nil,

View File

@ -206,10 +206,6 @@ 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):
@ -241,16 +237,46 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
excludedParameters: ["temperature", "topP", "frequencyPenalty", "presencePenalty"],
)
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.1"] = gpt5Capabilities
self.capabilities["openai:gpt-5.2"] = 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(
@ -260,25 +286,11 @@ 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-6"] = claude4Capabilities
self.capabilities["anthropic:claude-sonnet-4-20250514"] = 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(
@ -289,9 +301,6 @@ 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
@ -305,12 +314,8 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
),
)
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
self.capabilities["mistral:mistral-large-2"] = mistralCapabilities
self.capabilities["mistral:codestral"] = mistralCapabilities
// Groq models (ultra-fast inference)
let groqCapabilities = ModelParameterCapabilities(
@ -319,12 +324,12 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
),
)
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
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
// Grok models
let grokCapabilities = ModelParameterCapabilities(
@ -334,9 +339,17 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
),
)
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
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
}
private func defaultCapabilities(for model: LanguageModel) -> ModelParameterCapabilities {
@ -346,19 +359,6 @@ 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,7 +369,6 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
// Default Anthropic capabilities
return ModelParameterCapabilities(
supportedProviderOptions: .init(
supportsThinking: true,
supportsCacheControl: true,
),
)
@ -389,38 +388,11 @@ 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
@ -434,7 +406,6 @@ extension GenerationSettings {
var adjustedTemperature = temperature
var adjustedTopP = topP
var adjustedTopK = topK
var adjustedFrequencyPenalty = frequencyPenalty
var adjustedPresencePenalty = presencePenalty
var adjustedProviderOptions = providerOptions
@ -446,9 +417,6 @@ extension GenerationSettings {
if capabilities.excludedParameters.contains("topP") {
adjustedTopP = nil
}
if capabilities.excludedParameters.contains("topK") {
adjustedTopK = nil
}
if capabilities.excludedParameters.contains("frequencyPenalty") {
adjustedFrequencyPenalty = nil
}
@ -472,7 +440,7 @@ extension GenerationSettings {
maxTokens: maxTokens,
temperature: adjustedTemperature,
topP: adjustedTopP,
topK: adjustedTopK,
topK: topK,
frequencyPenalty: adjustedFrequencyPenalty,
presencePenalty: adjustedPresencePenalty,
stopSequences: stopSequences,
@ -480,7 +448,6 @@ extension GenerationSettings {
stopConditions: stopConditions,
seed: seed,
providerOptions: adjustedProviderOptions,
streamBuffering: self.streamBuffering,
)
}

View File

@ -36,24 +36,14 @@ struct OpenAICompatibleHelper {
}
// Extract stop sequences from stop conditions
let settings = Self.validatedSettings(
request.settings,
providerName: providerName,
modelId: modelId,
baseURL: baseURL,
)
let stopSequences = Self.extractStopSequences(from: settings.stopConditions)
let stopSequences = Self.extractStopSequences(from: request.settings.stopConditions)
// Convert request to OpenAI-compatible format
let openAIRequest = try OpenAIChatRequest(
model: modelId,
messages: convertMessages(
request.messages,
replayOpenRouterReasoningForModel: providerName == "OpenRouter" ? modelId : nil,
replayOpenRouterReasoningForBaseURL: providerName == "OpenRouter" ? baseURL : nil,
),
temperature: settings.temperature,
maxTokens: settings.maxTokens,
messages: convertMessages(request.messages),
temperature: request.settings.temperature,
maxTokens: request.settings.maxTokens,
tools: request.tools?.compactMap { try self.convertTool($0) },
stream: false,
stop: stopSequences.isEmpty ? nil : stopSequences,
@ -110,9 +100,14 @@ 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 = Self.mapFinishReason(choice.finishReason)
let finishReason: FinishReason? = switch choice.finishReason {
case "stop": .stop
case "length": .length
case "tool_calls": .toolCalls
case "content_filter": .contentFilter
default: .other
}
// Convert tool calls if present
let toolCalls = choice.message.toolCalls?.compactMap { openAIToolCall -> AgentToolCall? in
@ -147,7 +142,6 @@ struct OpenAICompatibleHelper {
usage: usage,
finishReason: finishReason,
toolCalls: toolCalls,
reasoning: reasoning,
)
}
@ -179,27 +173,14 @@ struct OpenAICompatibleHelper {
}
// Extract stop sequences from stop conditions
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)
let stopSequences = Self.extractStopSequences(from: request.settings.stopConditions)
// Convert request to OpenAI-compatible format
let openAIRequest = try OpenAIChatRequest(
model: modelId,
messages: convertMessages(
request.messages,
replayOpenRouterReasoningForModel: providerName == "OpenRouter" ? modelId : nil,
replayOpenRouterReasoningForBaseURL: providerName == "OpenRouter" ? baseURL : nil,
),
temperature: settings.temperature,
maxTokens: settings.maxTokens,
messages: convertMessages(request.messages),
temperature: request.settings.temperature,
maxTokens: request.settings.maxTokens,
tools: request.tools?.compactMap { try self.convertTool($0) },
stream: true,
stop: stopSequences.isEmpty ? nil : stopSequences,
@ -365,10 +346,8 @@ struct OpenAICompatibleHelper {
}
}
if let finishReason = choice.finishReason {
continuation.yield(TextStreamDelta.done(
finishReason: Self.mapFinishReason(finishReason),
))
if choice.finishReason != nil {
continuation.yield(TextStreamDelta.done())
break
}
}
@ -448,10 +427,9 @@ struct OpenAICompatibleHelper {
}
if let finishReason = choice.finishReason {
continuation.yield(TextStreamDelta.done(
finishReason: Self.mapFinishReason(finishReason),
))
break
if finishReason == "stop" || finishReason == "tool_calls" {
continuation.yield(TextStreamDelta.done())
}
}
}
} catch {
@ -478,39 +456,6 @@ 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
@ -567,55 +512,18 @@ struct OpenAICompatibleHelper {
}
}
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
}
private static func convertMessages(_ messages: [ModelMessage]) throws -> [OpenAIChatMessage] {
messages.map { message in
switch message.role {
case .system:
converted.append(OpenAIChatMessage(role: "system", content: message.content.compactMap { part in
return 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
converted.append(OpenAIChatMessage(role: "user", content: text))
return OpenAIChatMessage(role: "user", content: text)
} else {
// Multi-modal message
let content = message.content.compactMap { contentPart -> OpenAIChatMessageContent? in
@ -632,7 +540,7 @@ struct OpenAICompatibleHelper {
return nil // Skip tool calls and results in user messages
}
}
converted.append(OpenAIChatMessage(role: "user", content: content))
return OpenAIChatMessage(role: "user", content: content)
}
case .assistant:
// Check if this assistant message contains tool calls
@ -663,25 +571,15 @@ struct OpenAICompatibleHelper {
// If we have tool calls, create a message with tool calls
if !toolCalls.isEmpty {
converted.append(OpenAIChatMessage(
return 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
converted.append(OpenAIChatMessage(
role: "assistant",
content: textContent,
toolCalls: nil,
reasoning: pendingReasoningText.isEmpty ? nil : pendingReasoningText.joined(separator: "\n"),
reasoningDetails: pendingReasoningDetails.isEmpty ? nil : pendingReasoningDetails,
))
return OpenAIChatMessage(role: "assistant", content: textContent)
}
pendingReasoningText.removeAll()
pendingReasoningDetails.removeAll()
case .tool:
// Extract tool call ID and result content from tool result
var toolCallId: String?
@ -700,44 +598,9 @@ struct OpenAICompatibleHelper {
}
}
converted.append(OpenAIChatMessage(role: "tool", content: resultContent, toolCallId: toolCallId))
return 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 {

View File

@ -41,12 +41,6 @@ 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
@ -68,8 +62,6 @@ 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"
@ -86,8 +78,6 @@ 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"
@ -104,8 +94,6 @@ 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"
@ -118,7 +106,6 @@ 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: []
}
@ -133,8 +120,6 @@ 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
@ -154,7 +139,7 @@ public enum Provider: Sendable, Hashable, Codable {
/// All standard providers (excludes custom)
public static var standardProviders: [Provider] {
[.openai, .anthropic, .grok, .groq, .mistral, .google, .minimax, .minimaxCN, .ollama, .azureOpenAI]
[.openai, .anthropic, .grok, .groq, .mistral, .google, .ollama, .azureOpenAI]
}
/// Create provider from string identifier
@ -166,9 +151,7 @@ public enum Provider: Sendable, Hashable, Codable {
case "grok", "xai": .grok
case "groq": .groq
case "mistral": .mistral
case "google", "gemini": .google
case "minimax": .minimax
case "minimax-cn", "minimax_cn", "minimaxi": .minimaxCN
case "google": .google
case "ollama": .ollama
case "azure-openai", "azure_openai", "azureopenai": .azureOpenAI
default: .custom(identifier)

View File

@ -56,7 +56,7 @@ public struct OpenAIOptions: Sendable, Codable {
/// Verbosity level for GPT-5 models
public var verbosity: Verbosity?
/// Reasoning effort for GPT-5 models
/// Reasoning effort for O3/O4 models
public var reasoningEffort: ReasoningEffort?
/// Previous response ID for Responses API chaining
@ -159,7 +159,6 @@ 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
@ -175,8 +174,6 @@ 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,
@ -194,8 +191,6 @@ 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)
}
}
}

View File

@ -13,17 +13,6 @@ 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
@ -50,13 +39,6 @@ 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 {
@ -100,13 +82,6 @@ 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 {
@ -207,15 +182,6 @@ 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 {
@ -245,15 +211,6 @@ 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
@ -429,13 +386,6 @@ 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, *)
@ -481,8 +431,9 @@ 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(finishReason: .stop))
continuation.yield(TextStreamDelta.done())
continuation.finish()
return
}

View File

@ -27,7 +27,7 @@ import Foundation
//
// ```swift
// // Simple generation
// let answer = try await generate("What is 2+2?", using: .openai(.gpt55))
// let answer = try await generate("What is 2+2?", using: .openai(.gpt4o))
//
// // Conversation management
// let conversation = Conversation()
@ -114,7 +114,7 @@ public enum API {
/// Model selection system
public enum Models {
/// Type-safe model selection
/// - `.openai(.gpt55)`, `.anthropic(.opus47)`, `.grok(.grok43)`, `.ollama(.llama3_3)`
/// - `.openai(.gpt4o)`, `.anthropic(.opus4)`, `.grok(.grok4)`, `.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-5.5").getResponse(request)`
/// Modern: `generate("Hello", using: .openai(.gpt55))`
/// Legacy: `Tachikoma.shared.getModel("gpt-4").getResponse(request)`
/// Modern: `generate("Hello", using: .openai(.gpt4o))`
public static let simpleGeneration = """
// OLD (deprecated)
let model = try await Tachikoma.shared.getModel("gpt-5.5")
let model = try await Tachikoma.shared.getModel("gpt-4")
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(.gpt55))
let response = try await generate("Hello", using: .openai(.gpt4o))
"""
/// Legacy: Complex ModelRequest/ModelResponse handling

View File

@ -186,9 +186,8 @@ 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 .chatLatest, .gpt5ChatLatest,
.gpt5, .gpt5Pro, .gpt5Mini, .gpt5Nano, .gpt54, .gpt54Mini, .gpt54Nano, .gpt55:
.responses // GPT-5 defaults to Responses API
case .o4Mini, .gpt5, .gpt5Pro, .gpt5Mini, .gpt5Nano, .gpt51, .gpt52:
.responses // Reasoning models and GPT-5 default to Responses API
default:
.chat // All other models use Chat Completions API
}
@ -326,11 +325,6 @@ 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?
@ -342,7 +336,6 @@ 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,
@ -356,7 +349,6 @@ public struct GenerationSettings: Sendable {
stopConditions: (any StopCondition)? = nil,
seed: Int? = nil,
providerOptions: ProviderOptions = .init(),
streamBuffering: StreamBufferingMode = .incremental,
) {
self.maxTokens = maxTokens
self.temperature = temperature
@ -369,27 +361,9 @@ 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
@ -405,7 +379,6 @@ extension GenerationSettings: Codable {
case reasoningEffort
case seed
case providerOptions
case streamBuffering
}
public init(from decoder: Decoder) throws {
@ -420,8 +393,6 @@ 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
}
@ -437,7 +408,6 @@ 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
}
}
@ -644,7 +614,7 @@ public enum ResponseChannel: String, Sendable, Codable, CaseIterable {
case final // Final answer to the user
}
/// Reasoning effort level for models that support it (GPT-5 thinking, opus-4, etc.)
/// Reasoning effort level for models that support it (o3, 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

View File

@ -132,9 +132,7 @@ extension [ModelMessage] {
/// Convert model messages to UI messages for display
public func toUIMessages() -> [UIMessage] {
// Convert model messages to UI messages for display
compactMap { modelMessage in
guard !modelMessage.isProviderNativeReasoningBlock else { return nil }
guard !modelMessage.isSyntheticReasoningBoundary else { return nil }
map { modelMessage in
var content = ""
var attachments: [UIAttachment] = []
var toolCalls: [AgentToolCall] = []
@ -181,20 +179,6 @@ 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

View File

@ -88,7 +88,7 @@ public struct ModelSettings: Sendable, Codable {
/// Legacy initializer for backward compatibility
public init(
modelName: String = "gpt-5.5",
modelName: String = "gpt-4",
maxTokens: Int? = nil,
temperature: Double? = nil,
topP: Double? = nil,

View File

@ -84,58 +84,16 @@ 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,
usage: Usage? = nil,
finishReason: FinishReason? = nil,
toolCalls: [AgentToolCall]? = nil,
) {
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
}
}

View File

@ -30,61 +30,12 @@ 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)
}
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.
// Ollama shortcuts and models
if let ollamaModel = parseOllamaModel(normalized) {
return .ollama(ollamaModel)
}
@ -95,7 +46,7 @@ public struct ModelSelector {
}
// Custom model ID - try to infer provider
if normalized.contains("gpt-5") || normalized.contains("gpt5") {
if normalized.contains("gpt") || normalized.contains("o3") || normalized.contains("o4") {
return .openai(.custom(normalized))
}
@ -115,24 +66,20 @@ public struct ModelSelector {
private static func parseOpenAIModel(_ input: String) -> Model.OpenAI? {
switch input {
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":
// 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":
return .gpt5Mini
case "gpt-5.5-nano", "gpt5.5-nano", "gpt55-nano", "gpt55nano", "gpt-5-5-nano", "gpt5-5-nano":
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":
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
@ -142,17 +89,35 @@ 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 .gpt55 // Default to flagship GPT-5.5
return .gpt51 // Default to flagship GPT-5.1
case "gpt4", "gpt-4":
return .gpt4o // Default to latest GPT-4 variant
case "openai":
return .gpt55 // Default to GPT-5.5
return .gpt51 // Default to GPT-5.1
default:
// Check if it's an OpenAI model ID
if self.isUnsupportedLegacyOpenAIModel(input) {
return nil
}
if input.hasPrefix("gpt-5") || input.hasPrefix("gpt5") {
if input.hasPrefix("gpt") || input.hasPrefix("o4") {
return .custom(input)
}
return nil
@ -162,35 +127,31 @@ public struct ModelSelector {
private static func parseAnthropicModel(_ input: String) -> Model.Anthropic? {
switch input {
// Direct matches
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-20250514":
return .opus4
case "claude-opus-4-20250514-thinking":
return .opus4Thinking
case "claude-opus-4-5", "claude-opus-4.5", "opus-4-5", "opus-4.5", "opus45":
return .opus45
case "claude-sonnet-4-6", "claude-sonnet-4.6", "sonnet-4-6", "sonnet-4.6", "sonnet46":
return .sonnet46
case "claude-sonnet-4-20250514":
return .sonnet4
case "claude-sonnet-4-20250514-thinking":
return .sonnet4Thinking
case "claude-sonnet-4-5-20250929", "claude-sonnet-4.5":
return .sonnet45
// Shortcuts
case "claude":
return .opus48
return .sonnet45 // Default plain Claude alias to latest Sonnet
case "claude-opus", "opus":
return .opus48
return .opus45
case "claude-sonnet", "sonnet":
return .sonnet46
return .sonnet4
case "claude-haiku", "haiku":
return .haiku45
case "anthropic":
return .opus48
return .opus45 // Default Anthropic model
default:
// Check if it's a Claude model ID
if self.isUnsupportedLegacyAnthropicModel(input) {
return nil
}
if input.hasPrefix("claude") {
return .custom(input)
}
@ -198,34 +159,8 @@ 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":
@ -235,9 +170,9 @@ public struct ModelSelector {
case "gemini-2.5-flash-lite", "gemini25flashlite", "gemini-2.5-flashlite":
.gemini25FlashLite
case "gemini":
.gemini35Flash
.gemini3Flash
case "google":
.gemini35Flash
.gemini25Pro
default:
nil
}
@ -246,22 +181,35 @@ public struct ModelSelector {
private static func parseGrokModel(_ input: String) -> Model.Grok? {
switch input {
// Direct matches for available models only
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
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
// Shortcuts
case "grok":
return .grok43
return .grok4FastReasoning // Default to the latest fast Grok model
case "xai":
return .grok43
return .grok3 // Default xAI model
default:
// Check if it's a Grok model ID
if self.isUnsupportedLegacyGrokModel(input) {
return nil
}
if input.hasPrefix("grok") {
return .custom(input)
}
@ -269,51 +217,6 @@ 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
@ -335,12 +238,22 @@ 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":
@ -364,33 +277,6 @@ 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
@ -416,18 +302,11 @@ 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 []
}
@ -498,16 +377,6 @@ 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"),
@ -519,17 +388,14 @@ public func getAllAvailableModels() -> String {
)
output += "\nShortcuts:\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 += " • 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 += " • llama, llama3 → llama3.3\n"
output += "\nCustom Models:\n"
output += " • OpenRouter: anthropic/claude-fable-5\n"
output += " • OpenRouter: anthropic/claude-3.5-sonnet\n"
output += " • Custom OpenAI: custom-gpt-model\n"
output += " • Local Ollama: any-model:tag\n"
@ -557,15 +423,15 @@ extension ModelSelector {
// Get recommended models for specific use cases
switch useCase {
case .coding:
[.claude, .openai(.gpt55), .google(.gemini35Flash)]
[.claude, .gpt4o, .google(.gemini25Pro)]
case .vision:
[.claude, .openai(.gpt55), .google(.gemini35Flash)]
[.claude, .gpt4o, .google(.gemini3Flash)]
case .reasoning:
[.openai(.gpt54), .claude, .google(.gemini35Flash)]
[.openai(.gpt5Mini), .claude, .google(.gemini25Pro)]
case .local:
[.llama, .ollama(.mistralNemo), .ollama(.commandRPlus)]
case .general:
[.claude, .openai(.gpt55), .google(.gemini35Flash), .grok(.grok43), .llama]
[.claude, .gpt4o, .google(.gemini3Flash), .grok(.grok4FastReasoning), .llama]
}
}
}
@ -583,7 +449,6 @@ public enum UseCase {
public enum ModelValidationError: Error, LocalizedError {
case visionNotSupported(String)
case toolsNotSupported(String)
case unsupportedModel(String)
public var errorDescription: String? {
switch self {
@ -591,8 +456,6 @@ 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"
}
}
}

View File

@ -89,13 +89,11 @@ 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"
}
}
@ -109,10 +107,6 @@ struct AnthropicThinking: Codable {
}
}
struct AnthropicOutputConfig: Codable {
let effort: String?
}
struct AnthropicMessage: Codable {
let role: String
let content: [AnthropicContent]
@ -139,11 +133,13 @@ enum AnthropicContent: Codable {
struct RedactedThinkingContent: Codable {
let type: String
let data: String
let redactedThinking: String
let signature: String
enum CodingKeys: String, CodingKey {
case type
case data
case redactedThinking = "redacted_thinking"
case signature
}
}
@ -400,14 +396,12 @@ 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 {
@ -419,20 +413,12 @@ 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 {
@ -469,22 +455,6 @@ 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
@ -564,10 +534,6 @@ 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:
@ -586,10 +552,6 @@ 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)
}
@ -599,8 +561,6 @@ enum AnthropicResponseContent: Codable {
case type
case text
case thinking
case redactedThinking = "redacted_thinking"
case signature
}
}
@ -612,17 +572,6 @@ 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
@ -657,11 +606,12 @@ struct AnthropicStreamContentBlock: Codable {
let text: String?
let input: Any?
let thinking: String?
let data: String?
let redactedThinking: String?
let signature: String?
enum CodingKeys: String, CodingKey {
case type, id, name, text, input, thinking, data, signature
case type, id, name, text, input, thinking, signature
case redactedThinking = "redacted_thinking"
}
init(from decoder: Decoder) throws {
@ -671,7 +621,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.data = try? container.decode(String.self, forKey: .data)
self.redactedThinking = try? container.decode(String.self, forKey: .redactedThinking)
self.signature = try? container.decode(String.self, forKey: .signature)
// Decode input as generic JSON if present
@ -697,7 +647,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.data, forKey: .data)
try container.encodeIfPresent(self.redactedThinking, forKey: .redactedThinking)
try container.encodeIfPresent(self.signature, forKey: .signature)
if let input {
let data = try JSONSerialization.data(withJSONObject: input)
@ -721,17 +671,6 @@ 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 {

View File

@ -6,79 +6,32 @@ 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,
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 {
public init(modelId: String, baseURL: String, configuration: TachikomaConfiguration) 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 explicit provider key, then configuration, then common environment variable patterns.
if let key = apiKey {
// Try to get API key from configuration, otherwise try common environment variable patterns
if let key = configuration.getAPIKey(for: .custom("anthropic_compatible")) {
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
}
let isFable = LanguageModel.Anthropic.isFable(modelId: modelId)
let supportsSafeStreaming = !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
let baseCapabilities = capabilities ?? ModelCapabilities(
self.capabilities = ModelCapabilities(
supportsVision: true,
supportsTools: true,
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,
supportsStreaming: true,
contextLength: 200_000,
maxOutputTokens: 8192,
)
}
@ -109,11 +62,6 @@ 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,
)
}
}

View File

@ -1,7 +1,4 @@
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, *)
@ -9,27 +6,14 @@ 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,
apiKey: String? = nil,
additionalHeaders: [String: String] = [:],
session: URLSession = .shared,
) throws {
public init(modelId: String, baseURL: String, configuration: TachikomaConfiguration) throws {
self.modelId = modelId
self.baseURL = baseURL
self.additionalHeaders = additionalHeaders
self.session = session
// 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")) {
// Try to get API key from configuration, otherwise try common environment variable patterns
if let key = configuration.getAPIKey(for: .custom("openai_compatible")) {
self.apiKey = key
} else if
let key = ProcessInfo.processInfo.environment["OPENAI_COMPATIBLE_API_KEY"] ??
@ -40,13 +24,12 @@ 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: !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId),
contextLength: isFable ? 1_000_000 : 128_000,
maxOutputTokens: isFable ? 128_000 : 4096,
supportsStreaming: true,
contextLength: 128_000,
maxOutputTokens: 4096,
)
}
@ -58,25 +41,17 @@ 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
return try await OpenAICompatibleHelper.streamText(
try await OpenAICompatibleHelper.streamText(
request: request,
modelId: self.modelId,
baseURL: self.baseURL!,
apiKey: self.apiKey ?? "",
providerName: "OpenAICompatible",
additionalHeaders: self.additionalHeaders,
session: self.session,
)
}
}

View File

@ -214,15 +214,16 @@ public final class GoogleProvider: ModelProvider {
}
}
guard !parts.isEmpty else { continue }
let role = switch message.role {
case .assistant:
"model"
case .tool:
"user"
"function"
default:
"user"
}
Self.appendGoogleContent(role: role, parts: parts, to: &contents)
contents.append(.init(role: role, parts: parts))
}
let config = GoogleGenerateRequest.GenerationConfig(
@ -252,20 +253,6 @@ 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 {
@ -284,6 +271,12 @@ 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] = [
@ -302,21 +295,7 @@ extension GoogleProvider {
}
properties[key] = propDict
}
// Gemini validates that every name in `required` exists in `properties`. Tools occasionally
// ship with a `required` entry whose property got filtered out during MCPAgent conversion
// (e.g. anyOf/oneOf schemas that don't survive the simplified MCPAgent 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
}
parameters["properties"] = properties
guard let schema = JSONValue(value: parameters) else {
throw TachikomaError.invalidInput("Failed to encode tool parameters for '\(tool.name)'")

View File

@ -11,15 +11,8 @@ 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 = modelId
self.modelId = model.modelId
self.baseURL = configuration.getBaseURL(for: .grok) ?? "https://api.x.ai/v1"
// Get API key from configuration system (environment or credentials)
@ -38,16 +31,6 @@ 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(

View File

@ -41,7 +41,7 @@ public actor LMStudioProvider: ModelProvider {
public init(
baseURL: String = "http://localhost:1234/v1",
modelId: String = "openai/gpt-oss-20b",
modelId: String = "current",
apiKey: String? = nil,
sessionConfiguration: URLSessionConfiguration = .default,
) {
@ -74,8 +74,8 @@ public actor LMStudioProvider: ModelProvider {
for url in commonURLs {
let provider = LMStudioProvider(baseURL: url)
if let status = await (try? provider.healthCheck()) {
return LMStudioProvider(baseURL: url, modelId: status.model ?? "openai/gpt-oss-20b")
if await (try? provider.healthCheck()) != nil {
return provider
}
}

View File

@ -20,10 +20,6 @@ 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"

View File

@ -2,8 +2,11 @@ import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Provider for OpenAI Responses API (GPT-5)
/// Provider for OpenAI Responses API (GPT-5, o3, o4)
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public final class OpenAIResponsesProvider: ModelProvider {
public let modelId: String
@ -14,7 +17,6 @@ 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")
@ -29,28 +31,15 @@ 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"
// Prefer configuration-provided key first (test configs use this), then shared OAuth/API-key auth.
// Get API key from configuration
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")
}
@ -58,15 +47,13 @@ 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: maxOutputTokens,
maxOutputTokens: isReasoningModel || isGPT5 ? 128_000 : 4096,
)
}
@ -78,8 +65,7 @@ public final class OpenAIResponsesProvider: ModelProvider {
let url = URL(string: "\(baseURL!)/responses")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
let (authHeaderName, prefix, secret) = self.authHeader()
urlRequest.setValue("\(prefix)\(secret)", forHTTPHeaderField: authHeaderName)
urlRequest.setValue("Bearer \(self.apiKey!)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Add OpenAI-specific headers
@ -148,8 +134,7 @@ public final class OpenAIResponsesProvider: ModelProvider {
let finalURLRequest: URLRequest = {
var req = URLRequest(url: url)
req.httpMethod = "POST"
let (authHeaderName, prefix, secret) = self.authHeader()
req.setValue("\(prefix)\(secret)", forHTTPHeaderField: authHeaderName)
req.setValue("Bearer \(self.apiKey!)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("text/event-stream", forHTTPHeaderField: "Accept")
@ -213,21 +198,6 @@ 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)
@ -257,18 +227,157 @@ public final class OpenAIResponsesProvider: ModelProvider {
throw TachikomaError.apiError("Failed to start streaming: \(errorMessage)")
}
var streamState = ResponsesStreamState()
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] = [:]
for try await line in bytes.lines {
if
try Self.processResponsesStreamLine(
line,
model: self.model,
state: &streamState,
continuation: continuation,
)
{
return
// 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
}
}
@ -281,210 +390,6 @@ 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(
@ -629,29 +534,6 @@ 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 }
@ -849,16 +731,15 @@ public final class OpenAIResponsesProvider: ModelProvider {
}
static func convertToProviderResponse(_ response: OpenAIResponsesResponse) throws -> ProviderResponse {
// Handle GPT-5 output arrays and alternate choices arrays.
var text: String
var toolCalls: [AgentToolCall]?
var finishReason: FinishReason?
// Handle GPT-5 format (output array) vs O3 format (choices array)
let text: String
let toolCalls: [AgentToolCall]?
let 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" {
@ -869,10 +750,6 @@ 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,
@ -894,24 +771,14 @@ public final class OpenAIResponsesProvider: ModelProvider {
}
text = collectedText
let incompleteFinishReason = response.status == "incomplete"
? Self.finishReasonForIncompleteReason(response.incompleteDetails?.reason)
: nil
if incompleteFinishReason == .contentFilter || didCollectRefusal {
text = ""
toolCalls = nil
finishReason = .contentFilter
} else {
toolCalls = collectedToolCalls.isEmpty ? nil : collectedToolCalls
}
if finishReason == nil, let toolCalls, !toolCalls.isEmpty {
toolCalls = collectedToolCalls.isEmpty ? nil : collectedToolCalls
if let toolCalls, !toolCalls.isEmpty {
finishReason = .toolCalls
}
if finishReason == nil {
finishReason = incompleteFinishReason ?? .stop
} else {
finishReason = .stop
}
} else if let choices = response.choices, let choice = choices.first {
// Alternate format with choices array.
// O3 format with choices array
text = choice.message.content ?? ""
// Convert tool calls
@ -923,26 +790,20 @@ 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 across Responses API token field variants.
// Convert usage (handle both GPT-5 and O3 formats)
let usage: Usage?
if let apiUsage = response.usage {
// GPT-5 uses input_tokens/output_tokens
// Alternate responses can use prompt_tokens/completion_tokens.
// O3 uses prompt_tokens/completion_tokens
let inputTokens = apiUsage.inputTokens ?? apiUsage.promptTokens ?? 0
let outputTokens = apiUsage.outputTokens ?? apiUsage.completionTokens ?? 0
@ -1037,28 +898,31 @@ public final class OpenAIResponsesProvider: ModelProvider {
}
}
private static func isReasoningModel(_: LanguageModel.OpenAI) -> Bool {
false
}
private static func isGPT5Model(_ model: LanguageModel.OpenAI) -> Bool {
private static func isReasoningModel(_ model: LanguageModel.OpenAI) -> Bool {
switch model {
case .gpt55,
.gpt54,
.gpt54Mini,
.gpt54Nano,
.gpt5,
.gpt5Pro,
.gpt5Mini,
.gpt5Nano:
case .o4Mini:
true
default:
false
}
}
private static func usesResponsesEventStream(_ model: LanguageModel.OpenAI) -> Bool {
model == .chatLatest || model == .gpt5ChatLatest || self.isGPT5Model(model)
private static func isGPT5Model(_ model: LanguageModel.OpenAI) -> Bool {
switch model {
case .gpt52,
.gpt51,
.gpt5,
.gpt5Pro,
.gpt5Mini,
.gpt5Nano,
.gpt5Thinking,
.gpt5ThinkingMini,
.gpt5ThinkingNano,
.gpt5ChatLatest:
true
default:
false
}
}
}

View File

@ -28,7 +28,7 @@ struct OpenAIResponsesRequest: Encodable {
let serviceTier: String?
let include: [String]?
/// Reasoning configuration (for GPT-5 thinking models)
/// Reasoning configuration (for o3/o4/GPT-5)
let reasoning: ReasoningConfig?
/// Truncation for long inputs
@ -477,24 +477,18 @@ struct OpenAIResponsesResponse: Codable {
let id: String
let object: String?
let createdAt: Int? // GPT-5 uses created_at
let created: Int? // Alternate responses can use created
let created: Int? // O3 uses created
let status: String?
let model: String
let output: [ResponsesOutput]? // GPT-5 uses output array
let choices: [ResponsesChoice]? // Alternate responses can use choices array
let choices: [ResponsesChoice]? // O3 uses 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
@ -519,31 +513,17 @@ 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"
}
}
}
/// Alternate choices format.
/// O3 choices format (kept for compatibility)
struct ResponsesChoice: Codable {
let index: Int
let message: ResponsesOutputMessage
@ -634,7 +614,7 @@ struct OpenAIResponsesResponse: Codable {
// MARK: - Streaming Response Types
/// Server-sent event for alternate streaming responses.
/// Server-sent event for streaming responses (O3 and older models)
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
struct OpenAIResponsesStreamChunk: Codable {
let id: String

View File

@ -77,14 +77,11 @@ 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, reasoning
case role, content
case toolCallId = "tool_call_id"
case toolCalls = "tool_calls"
case reasoningDetails = "reasoning_details"
}
struct AgentToolCall: Codable {
@ -103,8 +100,6 @@ 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) {
@ -112,23 +107,13 @@ 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]?,
reasoning: String? = nil,
reasoningDetails: [JSONValue]? = nil,
) {
init(role: String, content: String? = nil, toolCalls: [AgentToolCall]?) {
self.role = role
self.content = content.map { .left($0) }
self.toolCallId = nil
self.toolCalls = toolCalls
self.reasoning = reasoning
self.reasoningDetails = reasoningDetails
}
}
@ -261,13 +246,10 @@ 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, reasoning
case role, content
case toolCalls = "tool_calls"
case reasoningDetails = "reasoning_details"
}
}

View File

@ -24,27 +24,21 @@ 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: !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId),
contextLength: isFable ? 1_000_000 : 128_000,
maxOutputTokens: isFable ? 128_000 : 4096,
supportsStreaming: true,
contextLength: 128_000,
maxOutputTokens: 4096,
)
self.defaultHeaders = [
"HTTP-Referer": ProcessInfo.processInfo.environment["OPENROUTER_REFERER"] ?? "https://peekaboo.app",
"X-OpenRouter-Title": ProcessInfo.processInfo.environment["OPENROUTER_TITLE"] ?? "Peekaboo",
"X-Title": ProcessInfo.processInfo.environment["OPENROUTER_TITLE"] ?? "Peekaboo",
]
}
@ -68,9 +62,6 @@ 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,

View File

@ -15,18 +15,19 @@ public struct ProviderFactory {
// Create a provider for the specified language model
switch model {
case let .openai(openaiModel):
// Use Responses API for the GPT-5 family
// Use Responses API for reasoning models (o4) and GPT-5 family
switch openaiModel {
case .chatLatest,
.gpt5ChatLatest,
.gpt55,
.gpt54,
.gpt54Mini,
.gpt54Nano,
case .o4Mini,
.gpt52,
.gpt51,
.gpt5,
.gpt5Pro,
.gpt5Mini,
.gpt5Nano:
.gpt5Nano,
.gpt5Thinking,
.gpt5ThinkingMini,
.gpt5ThinkingNano,
.gpt5ChatLatest:
return try OpenAIResponsesProvider(model: openaiModel, configuration: configuration)
default:
return try OpenAIProvider(model: openaiModel, configuration: configuration)
@ -58,28 +59,6 @@ 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)
@ -115,18 +94,12 @@ 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,
)
}
}
@ -134,34 +107,6 @@ 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

View File

@ -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-5.5", "claude-fable-5", "llava:latest")
/// The model name (e.g., "gpt-4", "claude-3", "llava:latest")
public let model: String
/// The full string representation (e.g., "openai/gpt-5.5")
/// The full string representation (e.g., "openai/gpt-4")
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-5.5" or "ollama/llava:latest"
/// - Parameter providerString: String like "openai/gpt-4" 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-5.5,anthropic/claude-fable-5,ollama/llava:latest"
/// - Parameter providersString: String like "openai/gpt-4,anthropic/claude-3,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-5.5,anthropic/claude-fable-5"
/// - Parameter providersString: String like "openai/gpt-4,anthropic/claude-3"
/// - 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,8 +94,6 @@ 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
@ -105,8 +103,6 @@ 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,
@ -117,21 +113,14 @@ 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" where canUseGoogleProvider, "gemini" where canUseGoogleProvider:
case "google", "gemini":
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:
@ -162,8 +151,6 @@ public enum ProviderParser {
hasOpenAI: hasOpenAI,
hasAnthropic: hasAnthropic,
hasGrok: hasGrok,
hasGoogle: canFallbackToGoogle,
hasMiniMax: hasMiniMax,
hasOllama: hasOllama,
)
}
@ -182,8 +169,6 @@ public enum ProviderParser {
hasOpenAI: Bool,
hasAnthropic: Bool,
hasGrok: Bool = false,
hasGoogle: Bool? = nil,
hasMiniMax: Bool = false,
hasOllama: Bool = true,
configuredDefault: LanguageModel? = nil,
)
@ -195,8 +180,6 @@ public enum ProviderParser {
hasOpenAI: hasOpenAI,
hasAnthropic: hasAnthropic,
hasGrok: hasGrok,
hasGoogle: hasGoogle,
hasMiniMax: hasMiniMax,
hasOllama: hasOllama,
configuredDefault: configuredDefault,
isEnvironmentProvided: false,
@ -219,38 +202,32 @@ public enum ProviderParser {
// MARK: - Private Helpers
private static func parseOpenAIModel(_ modelString: String) -> LanguageModel? {
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":
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":
.openai(.gpt5Mini)
case "gpt-5.5-nano", "gpt5.5-nano", "gpt-5-5-nano", "gpt5-5-nano", "gpt55-nano", "gpt55nano":
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":
.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))
@ -258,34 +235,15 @@ public enum ProviderParser {
}
private static func parseAnthropicModel(_ modelString: String) -> LanguageModel? {
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)
switch modelString.lowercased() {
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", "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)
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)
default:
// Handle custom models
.anthropic(.custom(modelString))
@ -294,12 +252,6 @@ 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":
@ -309,29 +261,7 @@ public enum ProviderParser {
case "gemini-2.5-flash-lite", "gemini25flashlite", "gemini-2.5-flashlite":
.google(.gemini25FlashLite)
case "gemini":
.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)
.google(.gemini3Flash)
default:
nil
}
@ -339,27 +269,22 @@ public enum ProviderParser {
private static func parseGrokModel(_ modelString: String) -> LanguageModel? {
switch modelString.lowercased() {
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)
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)
default:
if self.isUnsupportedLegacyGrokModel(modelString) {
return nil
}
return .grok(.custom(modelString))
.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
@ -375,6 +300,7 @@ 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))
}
@ -384,22 +310,16 @@ public enum ProviderParser {
hasOpenAI: Bool,
hasAnthropic: Bool,
hasGrok: Bool,
hasGoogle: Bool,
hasMiniMax: Bool,
hasOllama _: Bool,
)
-> LanguageModel
{
if hasAnthropic {
.anthropic(.opus48)
.anthropic(.opus4)
} else if hasOpenAI {
.openai(.gpt55)
.openai(.gpt5Mini)
} else if hasGrok {
.grok(.grok43)
} else if hasGoogle {
.google(.gemini35Flash)
} else if hasMiniMax {
.minimax(.m27)
.grok(.grok4FastReasoning)
} else {
.ollama(.llama33)
}

View File

@ -27,13 +27,12 @@ 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: !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId),
contextLength: isFable ? 1_000_000 : 128_000,
maxOutputTokens: isFable ? 128_000 : 4096,
supportsStreaming: true,
contextLength: 128_000,
maxOutputTokens: 4096,
)
}
@ -56,9 +55,6 @@ 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,

View File

@ -6,44 +6,18 @@ 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, providerIdentity: CacheProviderIdentity? = nil, model: String? = nil) {
self.model = providerIdentity?.modelId ?? model
init(from request: ProviderRequest, model: String? = nil) {
self.model = 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):
@ -67,32 +41,8 @@ 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
@ -125,16 +75,11 @@ 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, providerIdentity: providerIdentity)
guard key.isCacheable else {
self.statistics.recordMiss()
return nil
}
let key = CacheKey(from: request)
guard let entry = cache[key] else {
self.statistics.recordMiss()
@ -164,13 +109,9 @@ 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, providerIdentity: providerIdentity)
guard key.isCacheable else {
return
}
let key = CacheKey(from: request)
// Check memory limit
if self.shouldEvictForMemory() {
@ -516,33 +457,9 @@ 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 + reasoningSize + assistantMessageSize + usageSize + 100
return textSize + toolCallsSize + usageSize + 100 // 100 bytes overhead
}
}
@ -629,7 +546,6 @@ 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
@ -647,21 +563,11 @@ 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, providerIdentity: self.providerIdentity) {
if let cached = await cache.get(for: request, ttlOverride: ttl) {
return cached
}
@ -669,13 +575,7 @@ 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,
providerIdentity: self.providerIdentity,
)
await self.cache.store(response, for: request, ttl: ttl, priority: priority)
return response
}

View File

@ -528,38 +528,39 @@ public struct ModelCostCalculator: Sendable {
// OpenAI Pricing (as of 2025)
case let .openai(openaiModel):
switch openaiModel {
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 .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 .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 2026)
// Anthropic Pricing (as of 2025)
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: (15.00, 75.00)
case .sonnet46: (3.00, 15.00)
case .opus4, .opus4Thinking: (15.00, 75.00)
case .sonnet4, .sonnet4Thinking: (3.00, 15.00)
case .sonnet45: (4.00, 18.00)
case .haiku45: (1.20, 6.00)
case let .custom(id):
id.lowercased().contains("claude-fable-5") ? (10.00, 50.00) : (3.00, 15.00)
case .custom: (3.00, 15.00) // Default estimate
}
// Google Pricing (standard tier, as of 2026)
// Google Pricing (estimates)
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)
@ -568,12 +569,7 @@ 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 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 .grok: (2.00, 8.00)
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

View File

@ -26,43 +26,32 @@ public final class Agent<Context>: @unchecked Sendable {
public private(set) var tools: [AgentTool]
/// Language model used by this agent
public var model: LanguageModel {
didSet {
self.usesImplicitDefaultModel = false
}
}
public var model: LanguageModel
/// 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? = nil,
model: LanguageModel = .default,
tools: [AgentTool] = [],
settings: GenerationSettings = .default,
configuration: TachikomaConfiguration = .current,
context: Context,
) {
self.name = name
self.instructions = instructions
self.usesImplicitDefaultModel = model == nil
self.model = model ?? .default
self.model = model
self.tools = tools
self.settings = settings
self.configuration = configuration
self.context = context
self.conversation = Conversation(configuration: configuration)
self.conversation = Conversation()
// Add system message with instructions
self.conversation.addSystemMessage(instructions)
@ -82,133 +71,66 @@ public final class Agent<Context>: @unchecked Sendable {
/// Execute a single message with the agent
public func execute(_ message: String) async throws -> AgentResponse {
let conversation = self.conversation
let model = self.model
let tools = self.tools
let settings = self.settings
// Add user message to conversation
self.conversation.addUserMessage(message)
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,
)
// 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
)
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",
)
// 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
}
return AgentResponse(
text: result.text,
usage: result.usage,
finishReason: result.finishReason ?? .other,
steps: result.steps,
conversationLength: conversation.messages.count,
)
} else {
didMerge = conversation.messages.isEmpty
}
guard didMerge else {
throw TachikomaError.invalidConfiguration(
"Conversation changed during generation; refusing to merge response",
)
if !step.toolResults.isEmpty {
for _ in step.toolResults {
// Tool results are already added by generateText
}
}
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
conversation.addUserMessage(message)
let conversationMessages = conversation.messages
let modelMessages = conversationMessages.map { $0.toModelMessage() }
let snapshotIDs = conversationMessages.map(\.id)
let buffersUntilDone = self.settings.streamBuffering == .untilTerminal
self.conversation.addUserMessage(message)
// Stream response
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
}
let streamResult = try await streamText(
model: model,
messages: conversation.getModelMessages(),
tools: self.tools.isEmpty ? nil : self.tools,
settings: self.settings,
maxSteps: 5,
)
// Track final message in conversation (this is approximate for streaming)
return AsyncThrowingStream<TextStreamDelta, Error> { continuation in
let producer = Task {
defer {
gateRelease.release()
}
Task {
do {
var assistantText = ""
var bufferedDeltas: [TextStreamDelta] = []
var didReceiveTerminal = false
for try await delta in streamResult.stream {
try Task.checkCancellation()
if buffersUntilDone {
bufferedDeltas.append(delta)
} else {
continuation.yield(delta)
}
continuation.yield(delta)
// Collect assistant text
if case .textDelta = delta.type, let content = delta.content {
@ -216,60 +138,25 @@ 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 {
conversation.addAssistantMessage(assistantText)
assistantText = ""
self.conversation.addAssistantMessage(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(configuration: self.configuration)
self.conversation = Conversation()
self.conversation.addSystemMessage(self.instructions)
}
@ -282,7 +169,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(configuration: self.configuration)
self.conversation = Conversation()
self.conversation.addSystemMessage(newInstructions)
// Re-add non-system messages
@ -292,30 +179,6 @@ 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 {
@ -674,33 +537,3 @@ 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
}
}
}

View File

@ -3,66 +3,10 @@ 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
@ -115,6 +59,7 @@ 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() }
}
@ -127,193 +72,31 @@ public final class Conversation: @unchecked Sendable {
self.lock.unlock()
}
/// 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,
)
}
return self.replaceModelMessages(
originalMessages.droppingLastUserTurn(),
validatingSnapshotIDs: snapshotIDs,
)
}
public func removeMessage(id: String) {
self.lock.lock()
self._messages.removeAll { $0.id == id }
self.lock.unlock()
}
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,
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,
)
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
}
// Generate response using the core API
let response = try await generateText(
model: model ?? .default,
messages: modelMessages,
tools: [],
settings: .default,
configuration: configuration,
)
// Add the response to the conversation
self.addAssistantMessage(response.text)
return response.text
}
/// Continue the conversation with a model, streaming the response
@ -323,90 +106,43 @@ public final class Conversation: @unchecked Sendable {
) async throws
-> AsyncThrowingStream<String, Error>
{
try await self.acquireContinuationLock()
let gateRelease = AsyncReleaseOnce {
await self.releaseContinuationLock()
// 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,
)
}
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: 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
}
let responseStream = try await streamText(
model: model ?? .default,
messages: modelMessages,
tools: tools ?? [], // Use provided tools or empty array
settings: .default,
configuration: configuration,
)
// Create a new stream to process the response and update the conversation
return AsyncThrowingStream<String, Error> { continuation in
let producer = Task {
defer {
gateRelease.release()
}
Task {
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 {
if buffersUntilDone {
bufferedText.append(text)
} else {
continuation.yield(text)
}
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 !isContentFiltered, !fullResponse.isEmpty, didApproveBufferedResponse {
try Task.checkCancellation()
if !fullResponse.isEmpty {
self.addAssistantMessage(fullResponse)
}
continuation.finish()
@ -414,20 +150,10 @@ 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 {
@ -435,9 +161,6 @@ 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
@ -446,22 +169,11 @@ public struct ConversationMessage: Sendable, Codable, Equatable {
case tool
}
public init(
id: String = UUID().uuidString,
role: Role,
content: String,
timestamp: Date = Date(),
contentParts: [ModelMessage.ContentPart]? = nil,
channel: ResponseChannel? = nil,
metadata: MessageMetadata? = nil,
) {
public init(id: String = UUID().uuidString, role: Role, content: String, timestamp: Date = Date()) {
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
@ -477,10 +189,8 @@ public struct ConversationMessage: Sendable, Codable, Equatable {
return ModelMessage(
id: self.id,
role: modelRole,
content: self.contentParts ?? [.text(self.content)],
content: [.text(self.content)],
timestamp: self.timestamp,
channel: self.channel,
metadata: self.metadata,
)
}
@ -509,9 +219,6 @@ public struct ConversationMessage: Sendable, Codable, Equatable {
role: role,
content: textContent,
timestamp: modelMessage.timestamp,
contentParts: modelMessage.content,
channel: modelMessage.channel,
metadata: modelMessage.metadata,
)
}
}

View File

@ -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-realtime")
/// Model to use (e.g., "gpt-4o-realtime-preview")
public var model: String
/// Voice for audio responses
@ -213,7 +213,7 @@ public struct SessionConfiguration: Sendable, Codable {
}
public init(
model: String = "gpt-realtime",
model: String = "gpt-4o-realtime-preview",
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-realtime",
model: String = "gpt-4o-realtime-preview",
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-realtime",
model: String = "gpt-4o-realtime-preview",
)
-> SessionConfiguration
{
@ -273,7 +273,7 @@ public struct SessionConfiguration: Sendable, Codable {
/// Create a configuration with tools
public static func withTools(
model: String = "gpt-realtime",
model: String = "gpt-4o-realtime-preview",
voice: RealtimeVoice = .alloy,
tools: [RealtimeTool],
)

View File

@ -150,7 +150,7 @@ public final class RealtimeConversation: ObservableObject {
// Create session with configuration
let sessionConfig = SessionConfiguration(
model: "gpt-realtime",
model: "gpt-4o-realtime-preview",
voice: .alloy,
instructions: nil,
tools: nil,
@ -167,7 +167,7 @@ public final class RealtimeConversation: ObservableObject {
/// Start the conversation
public func start(
model: LanguageModel.OpenAI = .custom("gpt-realtime"),
model: LanguageModel.OpenAI = .gpt4oRealtime,
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 = .custom("gpt-realtime"),
model: LanguageModel.OpenAI = .gpt4oRealtime,
voice: RealtimeVoice = .alloy,
instructions: String? = nil,
tools: [AgentTool]? = nil,
@ -548,7 +548,7 @@ public final class RealtimeConversation {
}
public func start(
model _: LanguageModel.OpenAI = .custom("gpt-realtime"),
model _: LanguageModel.OpenAI = .gpt4oRealtime,
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 = .custom("gpt-realtime"),
model _: LanguageModel.OpenAI = .gpt4oRealtime,
voice _: RealtimeVoice = .alloy,
instructions _: String? = nil,
tools _: [AgentTool]? = nil,

View File

@ -188,7 +188,7 @@ public struct RealtimeSessionConfig: Codable, Sendable {
}
public init(
model: String = "gpt-realtime",
model: String = "gpt-4o-realtime-preview",
voice: RealtimeVoice = .alloy,
instructions: String? = nil,
tools: [RealtimeTool]? = nil,

View File

@ -16,16 +16,20 @@ 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
}
}
}
@ -151,10 +155,12 @@ 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
}
}
@ -220,5 +226,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(.tts1HD)
public static let expressive: SpeechModel = .openai(.gpt4oMiniTTS)
}

View File

@ -116,7 +116,7 @@ struct TKConfigCLI {
var mutable = raw
let timeout = self.parseTimeout(&mutable)
print("Providers:")
for pid in [TKProviderId.openai, .anthropic, .grok, .gemini, .openrouter] {
for pid in [TKProviderId.openai, .anthropic, .grok, .gemini] {
let status = await TKConfigCLI.status(for: pid, timeout: timeout)
print(" \(pid.displayName): \(status)")
}
@ -150,8 +150,6 @@ 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
}
@ -171,8 +169,6 @@ 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
}

View File

@ -499,7 +499,12 @@ public final class TachikomaMCPClientManager {
}
private func profileDirectoryPath() -> String {
TachikomaConfiguration.profileDirectoryPath
#if os(Windows)
let home = ProcessInfo.processInfo.environment["USERPROFILE"] ?? ""
#else
let home = ProcessInfo.processInfo.environment["HOME"] ?? ""
#endif
return "\(home)/\(self.profileDirectoryName)"
}
private func profileConfigPath() -> String {

View File

@ -12,7 +12,7 @@ import Tachikoma
// Use Tachikoma normally without MCP
let result = try await generateText(
model: .openai(.gpt55),
model: .openai(.gpt4o),
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(.gpt55),
model: .openai(.gpt4o),
messages: messages,
tools: staticTools + mcpTools
)

View File

@ -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 let .image(data: data, mimeType: mimeType, annotations: _, _meta: _) = imageResponse.content.first {
if case .image(data: let data, mimeType: let mimeType, annotations: _, _meta: _) = imageResponse.content.first {
#expect(data == imageData.base64EncodedString())
#expect(mimeType == "image/png")
} else {

View File

@ -130,7 +130,7 @@ struct MCPToolAdapterTests {
#expect(response.isError == false)
#expect(response.content.count == 1)
if case let .image(data: data, mimeType: mimeType, annotations: _, _meta: _) = response.content[0] {
if case .image(data: let data, mimeType: let mimeType, annotations: _, _meta: _) = response.content[0] {
#expect(data == imageData.base64EncodedString())
#expect(mimeType == "image/jpeg")
} else {

View File

@ -362,7 +362,7 @@ enum AudioFunctionsTests {
}
@Test
func `multiple provider integration`() async {
func `multiple provider integration`() async throws {
await TestHelpers.withStandardTestConfiguration { config in
let audioData = TestHelpers.sampleAudioData(configuration: config)

View File

@ -81,47 +81,43 @@ struct AudioProviderFactoryTests {
}
@Test
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)
}
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)
}
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`() 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)
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 {
Issue.record("Expected environment override for OPENAI_API_KEY")
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")
}
}
}

View File

@ -44,7 +44,6 @@ struct AuthManagerTests {
unsetenv("ANTHROPIC_ACCESS_TOKEN")
unsetenv("GEMINI_API_KEY")
unsetenv("GOOGLE_API_KEY")
unsetenv("OPENROUTER_API_KEY")
}
@Test
@ -80,29 +79,6 @@ 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 {

View File

@ -17,7 +17,7 @@ struct AsyncSequenceTests {
let result = StreamTextResult(
stream: testStream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
)
@ -86,7 +86,7 @@ struct AsyncSequenceTests {
let result = StreamObjectResult(
objectStream: testStream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
schema: TestData.self,
)
@ -117,7 +117,7 @@ struct AsyncSequenceTests {
let result = StreamTextResult(
stream: testStream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
)
@ -149,7 +149,7 @@ struct AsyncSequenceTests {
let result = StreamTextResult(
stream: testStream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
)
@ -183,7 +183,7 @@ struct AsyncSequenceTests {
let result = StreamTextResult(
stream: testStream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
)
@ -218,7 +218,7 @@ struct AsyncSequenceTests {
let result = StreamTextResult(
stream: testStream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
)
@ -262,7 +262,7 @@ struct AsyncSequenceTests {
let result = StreamObjectResult(
objectStream: testStream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
schema: TestItem.self,
)

View File

@ -55,7 +55,7 @@ struct ConfigurationArchitectureTests {
// Example 1: Zero configuration
_ = {
Task {
_ = try await generate("What is 2+2?", using: .openai(.gpt55))
_ = try await generate("What is 2+2?", using: .openai(.gpt4o))
}
}
@ -66,7 +66,7 @@ struct ConfigurationArchitectureTests {
)
Task {
_ = try await generate("Hello", using: .openai(.gpt55))
_ = try await generate("Hello", using: .openai(.gpt4o))
}
}
@ -79,7 +79,7 @@ struct ConfigurationArchitectureTests {
_ = try await generate(
"Test prompt",
using: .openai(.gpt55),
using: .openai(.gpt4o),
configuration: testConfig,
)
}

View File

@ -33,17 +33,4 @@ 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")
}
}

View File

@ -1,164 +0,0 @@
#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)
}
}
}
}

View File

@ -38,8 +38,7 @@ struct CustomProviderRegistryTests {
/* secret token */
"headers": {
"Authorization": "Bearer ${WEATHER_TOKEN}"
},
"apiKey": "${WEATHER_API_KEY}"
}
},
"models": {
"fast": { "name": "weather-fast" }
@ -47,10 +46,7 @@ struct CustomProviderRegistryTests {
},
"claude-proxy": {
"type": "anthropic",
"options": {
"baseURL": "https://anthropic.local",
"apiKey": "claude-provider-key"
}
"options": { "baseURL": "https://anthropic.local" }
}
}
}
@ -72,7 +68,6 @@ struct CustomProviderRegistryTests {
#endif
TachikomaConfiguration.profileDirectoryName = originalProfileDir
unsetenv("WEATHER_TOKEN")
unsetenv("WEATHER_API_KEY")
self.resetRegistry(
forProfile: originalProfileDir,
originalHome: originalHome,
@ -83,7 +78,6 @@ 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()
@ -93,57 +87,14 @@ 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)
}
@ -194,25 +145,3 @@ 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

View File

@ -4,9 +4,17 @@ import Testing
struct GrokModelCatalogTests {
private static let catalog: [Model.Grok] = [
.grok43,
.grok420Reasoning,
.grok420NonReasoning,
.grok4,
.grok4FastReasoning,
.grok4FastNonReasoning,
.grokCodeFast1,
.grok3,
.grok3Mini,
.grok2,
.grok2Vision,
.grok2Image,
.grokVisionBeta,
.grokBeta,
]
private func requireModernPlatforms(_ body: () throws -> Void) rethrows {
@ -31,7 +39,6 @@ struct GrokModelCatalogTests {
let parsed = try ModelSelector.parseModel(model.modelId)
#expect(parsed == .grok(model))
}
#expect(try ModelSelector.parseModel("grok-4.3-latest") == .grok(.grok43))
}
}
@ -45,69 +52,13 @@ struct GrokModelCatalogTests {
}
@Test
func `Grok model vision support matches current xAI catalog`() {
func `Vision capability only flips on for vision/image Grok models`() {
self.requireModernPlatforms {
#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)
}
}
let visionModels: Set<Model.Grok> = [.grok2Vision, .grok2Image, .grokVisionBeta]
@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)
for model in Self.catalog {
let languageModel = Model.grok(model)
#expect(languageModel.supportsVision == visionModels.contains(model))
}
}
}

View File

@ -6,7 +6,6 @@ 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
@ -53,13 +52,6 @@ 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
@ -76,14 +68,12 @@ struct LanguageModelCoverageTests {
@Test
func `LanguageModel top level switches`() {
let baseModels: [LanguageModel] = [
.openai(.gpt55),
.anthropic(.fable5),
.google(.gemini35Flash),
.mistral(.medium35),
.groq(.llama3370b),
.grok(.grok43),
.minimax(.m27),
.minimaxCN(.m27),
.openai(.gpt51),
.anthropic(.opus45),
.google(.gemini25Flash),
.mistral(.large2),
.groq(.mixtral8x7b),
.grok(.grok4),
.ollama(.llama33),
.lmstudio(.gptOSS20B),
.openRouter(modelId: "openrouter/alpha"),

View File

@ -9,9 +9,9 @@ struct MinimalModernAPITests {
@Test
func `Model enum construction`() {
// Test that model enums can be constructed
let openaiModel = Model.openai(.gpt55)
let anthropicModel = Model.anthropic(.opus48)
_ = Model.grok(.grok43)
let openaiModel = Model.openai(.gpt4o)
let anthropicModel = Model.anthropic(.opus45)
_ = Model.grok(.grok4)
_ = Model.ollama(.llama33)
// Test that they can be used in a switch statement
@ -35,131 +35,13 @@ struct MinimalModernAPITests {
let defaultModel = Model.default
// Should compile without errors
switch defaultModel {
case .anthropic(.opus48):
case .anthropic(.opus45):
break // Expected default
default:
Issue.record("Expected default to be Anthropic Opus 4.8")
Issue.record("Expected default to be Anthropic Opus 4.5")
}
}
@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
@ -244,757 +126,4 @@ 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")
}

View File

@ -7,11 +7,8 @@ enum ModelCapabilitiesTests {
@Test
func `GPT-5 models exclude temperature and topP`() {
let models: [LanguageModel] = [
.openai(.gpt5ChatLatest),
.openai(.gpt55),
.openai(.gpt54),
.openai(.gpt54Mini),
.openai(.gpt54Nano),
.openai(.gpt52),
.openai(.gpt51),
.openai(.gpt5),
.openai(.gpt5Mini),
.openai(.gpt5Nano),
@ -30,24 +27,34 @@ enum ModelCapabilitiesTests {
}
@Test
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))
func `Gemini 3 Flash supports thinking config options`() {
let capabilities = ModelCapabilityRegistry.shared.capabilities(for: .google(.gemini3Flash))
#expect(capabilities.supportsTopK)
#expect(capabilities.supportedProviderOptions.supportsThinkingConfig)
#expect(capabilities.supportedProviderOptions.supportsSafetySettings)
}
@Test
func `Custom OpenAI models support standard parameters`() {
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`() {
let models: [LanguageModel] = [
.openai(.custom("custom-openai")),
.openai(.gpt4o),
.openai(.gpt4oMini),
.openai(.gpt41),
.openai(.gpt4Turbo),
]
for model in models {
@ -58,16 +65,17 @@ 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(.sonnet46),
.anthropic(.sonnet4),
.anthropic(.sonnet45),
.anthropic(.haiku45),
]
@ -80,37 +88,9 @@ 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),
@ -128,9 +108,8 @@ enum ModelCapabilitiesTests {
@Test
func `Mistral models support safe mode`() {
let models: [LanguageModel] = [
.mistral(.largeLatest),
.mistral(.medium35),
.mistral(.codestralLatest),
.mistral(.large2),
.mistral(.codestral),
]
for model in models {
@ -143,8 +122,8 @@ enum ModelCapabilitiesTests {
@Test
func `Groq models support speed level`() {
let models: [LanguageModel] = [
.groq(.llama3370b),
.groq(.llama4Maverick),
.groq(.llama3170b),
.groq(.llama370b),
]
for model in models {
@ -157,8 +136,8 @@ enum ModelCapabilitiesTests {
@Test
func `Grok models support fun mode`() {
let models: [LanguageModel] = [
.grok(.grok43),
.grok(.grok420Reasoning),
.grok(.grok4),
.grok(.grok3),
]
for model in models {
@ -172,7 +151,7 @@ enum ModelCapabilitiesTests {
struct SettingsValidationTests {
@Test
func `Validate settings for GPT-5.5`() {
func `Validate settings for GPT-5.1`() {
let settings = GenerationSettings(
maxTokens: 1000,
temperature: 0.7,
@ -187,7 +166,7 @@ enum ModelCapabilitiesTests {
),
)
let validated = settings.validated(for: .openai(.gpt55))
let validated = settings.validated(for: .openai(.gpt51))
#expect(validated.maxTokens == 1000)
#expect(validated.temperature == nil) // Excluded
@ -199,20 +178,7 @@ enum ModelCapabilitiesTests {
}
@Test
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`() {
func `Validate settings for O3 with forced temperature`() {
let settings = GenerationSettings(
temperature: 0.5,
topP: 0.8,
@ -224,16 +190,16 @@ enum ModelCapabilitiesTests {
),
)
let validated = settings.validated(for: LanguageModel.openai(.gpt55))
let validated = settings.validated(for: LanguageModel.openai(.o4Mini))
#expect(validated.temperature == nil) // Excluded
#expect(validated.temperature == 1.0) // Forced to 1.0
#expect(validated.topP == nil) // Excluded
#expect(validated.providerOptions.openai?.reasoningEffort == nil) // Removed
#expect(validated.providerOptions.openai?.verbosity == .medium) // Kept
#expect(validated.providerOptions.openai?.reasoningEffort == .high) // Kept
#expect(validated.providerOptions.openai?.verbosity == nil) // Removed
}
@Test
func `Validate settings for custom OpenAI model`() {
func `Validate settings for GPT-4`() {
let settings = GenerationSettings(
maxTokens: 2000,
temperature: 0.8,
@ -250,13 +216,17 @@ enum ModelCapabilitiesTests {
),
)
let validated = settings.validated(for: .openai(.custom("custom-openai")))
let validated = settings.validated(for: .openai(.gpt4o))
#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
@ -282,72 +252,6 @@ 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 {
@ -403,8 +307,8 @@ enum ModelCapabilitiesTests {
@Test
func `Concurrent capability access`() async {
let models: [LanguageModel] = [
.openai(.gpt54),
.openai(.gpt55),
.openai(.gpt51),
.openai(.gpt4o),
.anthropic(.opus4),
.google(.gemini25Flash),
]

View File

@ -9,53 +9,21 @@ struct ModelParsingTests {
}
@Test
func `parse GPT-5.5 base model`() {
let parsed = LanguageModel.parse(from: "gpt-5.5")
#expect(parsed == .openai(.gpt55))
func `parse GPT-5.1 base model`() {
let parsed = LanguageModel.parse(from: "gpt-5.1")
#expect(parsed == .openai(.gpt51))
}
@Test
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))
func `parse GPT-5.2 base model`() {
let parsed = LanguageModel.parse(from: "gpt-5.2")
#expect(parsed == .openai(.gpt52))
}
@Test
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)
func `parse GPT-5.1 nano alias`() {
let parsed = LanguageModel.parse(from: "gpt51-nano")
#expect(parsed == .openai(.gpt5Nano))
}
@Test
@ -65,172 +33,20 @@ struct ModelParsingTests {
}
@Test
func `parse shorthand Claude alias`() throws {
func `parse shorthand Claude alias`() {
let parsed = LanguageModel.parse(from: "claude")
#expect(parsed == .anthropic(.opus48))
#expect(try ModelSelector.parseModel("anthropic") == .anthropic(.opus48))
#expect(parsed == .anthropic(.sonnet45))
}
@Test
func `parse Gemini 3.5 Flash model id`() {
let parsed = LanguageModel.parse(from: "gemini-3.5-flash")
#expect(parsed == .google(.gemini35Flash))
func `parse Gemini 3 Flash model id`() {
let parsed = LanguageModel.parse(from: "gemini-3-flash")
#expect(parsed == .google(.gemini3Flash))
}
@Test
func `parse shorthand Gemini alias`() {
let parsed = LanguageModel.parse(from: "gemini")
#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")
}
}
#expect(parsed == .google(.gemini3Flash))
}
}

View File

@ -121,337 +121,6 @@ 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

View File

@ -207,24 +207,6 @@ 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(

View File

@ -50,8 +50,6 @@ 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")
}
@ -70,8 +68,6 @@ 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")
@ -85,8 +81,6 @@ 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)
@ -96,7 +90,6 @@ 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 == [
@ -113,8 +106,6 @@ 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)
@ -128,8 +119,6 @@ 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
@ -145,9 +134,6 @@ 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)
}
@ -186,8 +172,6 @@ enum ProviderTests {
.groq,
.mistral,
.google,
.minimax,
.minimaxCN,
.ollama,
.azureOpenAI,
]
@ -210,18 +194,6 @@ 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")

View File

@ -327,7 +327,7 @@ private struct MockTextProvider: ModelProvider {
private struct MockOpenAIProvider: ModelProvider {
var modelId: String {
"gpt-5.5"
"gpt-4"
}
var baseURL: String? {

View File

@ -180,30 +180,6 @@ 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

View File

@ -89,12 +89,12 @@ struct StreamObjectTests {
let result = StreamObjectResult(
objectStream: testStream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
schema: TestPerson.self,
)
#expect(result.model == .openai(.gpt55))
#expect(result.model == .openai(.gpt4o))
#expect(result.schema == TestPerson.self)
}
@ -157,7 +157,7 @@ struct StreamObjectTests {
let result = StreamObjectResult(
objectStream: testStream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
schema: TestPerson.self,
)
@ -193,7 +193,7 @@ struct StreamObjectTests {
let result = StreamObjectResult(
objectStream: testStream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
schema: TestPerson.self,
)
@ -221,7 +221,7 @@ struct StreamObjectTests {
let result = StreamObjectResult(
objectStream: testStream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
schema: TestPerson.self,
)

View File

@ -231,7 +231,7 @@ struct StreamTransformTests {
let result = StreamTextResult(
stream: stream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
)
@ -264,7 +264,7 @@ struct StreamTransformTests {
let result = StreamTextResult(
stream: stream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
)
@ -291,7 +291,7 @@ struct StreamTransformTests {
let result = StreamTextResult(
stream: stream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
)

View File

@ -367,35 +367,35 @@ struct TachikomaConfigurationTests {
Task {
// Uses default (.current)
_ = try await generateText(
model: .openai(.gpt55),
model: .openai(.gpt4o),
messages: [.user("Test")],
)
// Uses explicit
_ = try await generateText(
model: .openai(.gpt55),
model: .openai(.gpt4o),
messages: [.user("Test")],
configuration: explicitConfig,
)
// Stream functions
_ = try await streamText(
model: .openai(.gpt55),
model: .openai(.gpt4o),
messages: [.user("Test")],
)
_ = try await streamText(
model: .openai(.gpt55),
model: .openai(.gpt4o),
messages: [.user("Test")],
configuration: explicitConfig,
)
// Convenience functions
_ = try await generate("Test", using: .openai(.gpt55))
_ = try await generate("Test", using: .openai(.gpt55), configuration: explicitConfig)
_ = try await generate("Test", using: .openai(.gpt4o))
_ = try await generate("Test", using: .openai(.gpt4o), configuration: explicitConfig)
_ = try await stream("Test", using: .openai(.gpt55))
_ = try await stream("Test", using: .openai(.gpt55), configuration: explicitConfig)
_ = try await stream("Test", using: .openai(.gpt4o))
_ = try await stream("Test", using: .openai(.gpt4o), configuration: explicitConfig)
}
}
@ -559,9 +559,9 @@ struct TachikomaConfigurationTests {
return DummyProvider()
}
let provider = try config.makeProvider(for: .openai(.gpt55))
let provider = try config.makeProvider(for: .openai(.gpt4o))
#expect(provider is DummyProvider)
#expect(capturedModel == .openai(.gpt55))
#expect(capturedModel == .openai(.gpt4o))
}
@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(.gpt55))
#expect(provider is OpenAIResponsesProvider)
let provider = try config.makeProvider(for: .openai(.gpt4o))
#expect(provider is OpenAIProvider)
}
}
}

View File

@ -31,7 +31,7 @@ func `Debug Grok streaming issue`() async throws {
// Test with minimal setup
let stream = try await stream(
"Say hello",
using: .grok(.grok43),
using: .grok(.grok3),
)
var receivedContent = false

View File

@ -59,67 +59,6 @@ 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()
@ -328,99 +267,6 @@ 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(
@ -471,7 +317,7 @@ struct ResponseCacheTests {
// Create a mock provider
let mockProvider = ResponseCacheMockProvider(
model: .openai(.gpt55),
model: .openai(.gpt4o),
response: ProviderResponse(text: "Cached response", usage: nil, finishReason: .stop),
)
@ -490,7 +336,7 @@ struct ResponseCacheTests {
// Use a simple counter that can be modified in the closure
let callCount = Box(value: 0)
var mockProvider = ResponseCacheMockProvider(
model: .openai(.gpt55),
model: .openai(.gpt4o),
response: ProviderResponse(text: "Response", usage: nil, finishReason: .stop),
)
mockProvider.onGenerateText = { _ in
@ -516,45 +362,13 @@ 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(.gpt55),
model: .openai(.gpt4o),
response: ProviderResponse(text: "Test", usage: nil, finishReason: .stop),
)
mockProvider.onStreamText = { _ in
@ -583,17 +397,15 @@ 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 {
self.mockModelId
"mock-model"
}
var baseURL: String? {
self.mockBaseURL
nil
}
var apiKey: String? {
@ -607,15 +419,11 @@ 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
}

View File

@ -29,7 +29,7 @@ struct IntegrationTests {
let streamResult = StreamTextResult(
stream: textStream,
model: .openai(.gpt55),
model: .openai(.gpt4o),
settings: .default,
)

View File

@ -7,7 +7,7 @@ struct LMStudioProviderTests {
func `Provider initialization`() {
let provider = LMStudioProvider(
baseURL: "http://localhost:1234/v1",
modelId: "openai/gpt-oss-120b",
modelId: "gpt-oss-120b",
apiKey: nil,
)
@ -18,7 +18,7 @@ struct LMStudioProviderTests {
let capabilities = provider.capabilities
#expect(baseURL == "http://localhost:1234/v1")
#expect(modelId == "openai/gpt-oss-120b")
#expect(modelId == "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(.llama3370B)
let model3 = LanguageModel.lmstudio(.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.modelId == "gpt-oss-120b")
#expect(model2.modelId == "gpt-oss-20b")
#expect(model3.modelId == "current")
#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 == "openai/gpt-oss-120b")
#expect(model2.modelId == "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 == "openai/gpt-oss-120b")
#expect(modelId == "gpt-oss-120b")
// Should work without API key (local model)
#expect(provider is LMStudioProvider)

View File

@ -1,7 +1,4 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import Testing
@testable import Tachikoma
@ -25,19 +22,6 @@ 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"])
@ -73,358 +57,6 @@ 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(
@ -438,24 +70,6 @@ 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))
@ -473,29 +87,6 @@ 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"])
@ -534,125 +125,7 @@ struct AnthropicInterleavedDefaultsTests {
}
@Test
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 {
func `Redacted thinking blocks preserve signature without text`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .opus45, configuration: config)
@ -663,9 +136,10 @@ struct AnthropicInterleavedDefaultsTests {
let redacted = ModelMessage(
role: .assistant,
content: [.text("opaque-redacted-data")],
content: [.text("")],
channel: .thinking,
metadata: .init(customData: [
"anthropic.thinking.signature": "sig-redacted",
"anthropic.thinking.type": "redacted_thinking",
]),
)
@ -682,232 +156,8 @@ struct AnthropicInterleavedDefaultsTests {
let assistant = try #require(messages[1]["content"] as? [[String: Any]])
#expect(assistant.first?["type"] as? String == "redacted_thinking")
#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")]))
}
#expect((assistant.first?["redacted_thinking"] as? String)?.isEmpty == true)
#expect(assistant.first?["signature"] as? String == "sig-redacted")
}
@Test
@ -937,49 +187,3 @@ 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() {}
}

View File

@ -20,7 +20,7 @@ private final class AzureTestURLProtocol: URLProtocol {
static let responseBody: Data = """
{
"id": "chatcmpl-azure",
"model": "gpt-5.5",
"model": "gpt-4o",
"choices": [
{
"index": 0,
@ -85,7 +85,7 @@ struct AzureOpenAIProviderTests {
await AzureTestURLProtocol.reset()
let provider = try AzureOpenAIProvider(
deploymentId: "gpt-5.5",
deploymentId: "gpt-4o",
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-5.5/chat/completions")
#expect(sentRequest?.url?.path == "/openai/deployments/gpt-4o/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-5-mini",
deploymentId: "gpt-4o-mini",
resource: nil,
apiVersion: "2025-04-01-preview",
endpoint: nil,

View File

@ -3,7 +3,7 @@ import Foundation
import Testing
@testable import Tachikoma
@Suite(.serialized, .enabled(if: ProcessInfo.processInfo.environment["INTEGRATION_TESTS"] != nil))
@Suite(.enabled(if: ProcessInfo.processInfo.environment["INTEGRATION_TESTS"] != nil))
struct ProviderIntegrationTests {
// MARK: - Test Configuration
@ -14,108 +14,50 @@ struct ProviderIntegrationTests {
static let streamMessage = "Count from 1 to 3"
}
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
private static func hasEnv(_ name: String) -> Bool {
guard let value = ProcessInfo.processInfo.environment[name] else {
return false
}
return !value.isEmpty
}
private static let liveCredentials = LiveCredentials.capture()
private static var hasOpenAIKey: Bool {
liveCredentials.openAI != nil
hasEnv("OPENAI_API_KEY")
}
private static var hasAnthropicKey: Bool {
liveCredentials.anthropic != nil
hasEnv("ANTHROPIC_API_KEY")
}
private static var hasGoogleKey: Bool {
liveCredentials.google != nil
hasEnv("GEMINI_API_KEY") || hasEnv("GOOGLE_API_KEY")
}
private static var hasMistralKey: Bool {
liveCredentials.mistral != nil
hasEnv("MISTRAL_API_KEY")
}
private static var hasGroqKey: Bool {
liveCredentials.groq != nil
hasEnv("GROQ_API_KEY")
}
private static var hasGrokKey: Bool {
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
hasEnv("X_AI_API_KEY") || hasEnv("XAI_API_KEY") || hasEnv("GROK_API_KEY")
}
// MARK: - OpenAI Integration Tests
@Test(.enabled(if: Self.hasOpenAIKey))
func `OpenAI Provider - Real API Call`() async throws {
let model = Model.openai(.gpt5Mini)
let config = Self.liveConfiguration()
let model = Model.openai(.gpt4oMini)
let config = TachikomaConfiguration()
do {
_ = try ProviderFactory.createProvider(for: model, configuration: config)
let response = try await generate(
TestConfig.shortMessage,
using: model,
maxTokens: 300,
maxTokens: 50,
temperature: 0.0,
configuration: config,
)
@ -130,8 +72,8 @@ struct ProviderIntegrationTests {
@Test(.enabled(if: Self.hasOpenAIKey))
func `OpenAI Provider - Tool Calling`() async throws {
let model = Model.openai(.gpt5Mini)
let config = Self.liveConfiguration()
let model = Model.openai(.gpt4oMini)
let config = TachikomaConfiguration()
do {
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
@ -175,8 +117,8 @@ struct ProviderIntegrationTests {
@Test(.enabled(if: Self.hasOpenAIKey))
func `OpenAI Provider - Streaming`() async throws {
let model = Model.openai(.gpt5Mini)
let config = Self.liveConfiguration()
let model = Model.openai(.gpt4oMini)
let config = TachikomaConfiguration()
do {
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
@ -186,7 +128,7 @@ struct ProviderIntegrationTests {
ModelMessage(role: .user, content: [.text(TestConfig.streamMessage)]),
],
tools: nil,
settings: .init(maxTokens: 300, temperature: 0.0),
settings: .init(maxTokens: 100, temperature: 0.0),
)
let stream = try await provider.streamText(request: request)
@ -222,15 +164,13 @@ struct ProviderIntegrationTests {
@Test(.enabled(if: Self.hasAnthropicKey))
func `Anthropic Provider - Real API Call`() async throws {
let model = Model.anthropic(.sonnet46)
let config = Self.liveConfiguration()
let model = Model.anthropic(.sonnet4)
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")) {
@ -243,8 +183,8 @@ struct ProviderIntegrationTests {
@Test(.enabled(if: Self.hasAnthropicKey))
func `Anthropic Provider - Tool Calling`() async throws {
let model = Model.anthropic(.sonnet46)
let config = Self.liveConfiguration()
let model = Model.anthropic(.sonnet4)
let config = TachikomaConfiguration()
do {
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
@ -321,15 +261,13 @@ struct ProviderIntegrationTests {
@Test(.enabled(if: Self.hasGrokKey))
func `Grok Provider - Real API Call`() async throws {
let model = Model.grok(.grok43)
let config = Self.liveConfiguration()
let model = Model.grok(.grok3)
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))")
@ -344,14 +282,12 @@ 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))")
@ -365,15 +301,8 @@ struct ProviderIntegrationTests {
@Test(.enabled(if: Self.hasMistralKey))
func `Mistral Provider - Real API Call`() async throws {
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,
)
let model = Model.mistral(.small)
let response = try await generate(TestConfig.shortMessage, using: model, maxTokens: 50, temperature: 0.0)
#expect(response.lowercased().contains("hello"))
#expect(response.contains("Tachikoma"))
@ -383,15 +312,8 @@ struct ProviderIntegrationTests {
@Test(.enabled(if: Self.hasGroqKey))
func `Groq Provider - Real API Call`() async throws {
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,
)
let model = Model.groq(.llama38b)
let response = try await generate(TestConfig.shortMessage, using: model, maxTokens: 50, temperature: 0.0)
#expect(response.lowercased().contains("hello"))
#expect(response.contains("Tachikoma"))
@ -401,12 +323,12 @@ struct ProviderIntegrationTests {
@Test(.enabled(if: Self.hasOpenAIKey))
func `Multi-Modal Provider - Vision Support`() async throws {
let model = Model.openai(.gpt55)
let config = Self.liveConfiguration()
let model = Model.openai(.gpt4o)
let config = TachikomaConfiguration()
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
// Create a simple base64 encoded 16x16 red square PNG
let redPixelPNG = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAF0lEQVR4nGP4z8BAEiJN9aiGUQ1DSgMAkPn/Afnh+ngAAAAASUVORK5CYII="
// Create a simple base64 encoded 1x1 red pixel PNG
let redPixelPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
let imageContent = ModelMessage.ContentPart.ImageContent(
data: redPixelPNG,
@ -421,13 +343,13 @@ struct ProviderIntegrationTests {
]),
],
tools: nil,
settings: .init(maxTokens: 300, temperature: 0.0),
settings: .init(maxTokens: 50, temperature: 0.0),
)
let response = try await provider.generateText(request: request)
let normalized = response.text.lowercased()
#expect(normalized.contains("red"))
#expect(normalized.contains("red") || normalized.contains("yellow"))
}
// MARK: - Helper Methods

View File

@ -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(.gpt55)
let model = Model.openai(.gpt4o)
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
#expect(provider.modelId == "gpt-5.5")
#expect(provider.modelId == "gpt-4o")
#expect(provider.capabilities.supportsVision == true)
#expect(provider.capabilities.supportsTools == true)
#expect(provider.capabilities.supportsStreaming == true)
@ -22,27 +22,12 @@ 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(.fable5)
let model = Model.anthropic(.opus4)
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
#expect(provider.modelId == "claude-fable-5")
#expect(provider.modelId == "claude-opus-4-1-20250805")
#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)
}
}
@ -50,10 +35,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(.grok43)
let model = Model.grok(.grok4FastReasoning)
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
#expect(provider.modelId == "grok-4.3")
#expect(provider.modelId == "grok-4-fast-reasoning")
#expect(provider.capabilities.supportsTools == true)
#expect(provider.capabilities.supportsStreaming == true)
}
@ -96,39 +81,20 @@ 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: .gpt55, configuration: config)
try OpenAIProvider(model: .gpt4o, configuration: config)
}
#expect(throws: TachikomaError.self) {
try AnthropicProvider(model: .opus4, configuration: config)
}
#expect(throws: TachikomaError.self) {
try ProviderFactory.createProvider(for: .minimax(.m27), configuration: config)
}
}
}
@ -136,15 +102,17 @@ struct ProviderSystemTests {
@Test
func `Model Capabilities - Vision Support`() {
#expect(Model.openai(.gpt55).supportsVision == true)
#expect(Model.openai(.gpt5Mini).supportsVision == true)
#expect(Model.openai(.custom("text-only-openai")).supportsVision == false)
#expect(Model.openai(.gpt4o).supportsVision == true)
#expect(Model.openai(.gpt4oMini).supportsVision == true)
#expect(Model.openai(.gpt41).supportsVision == false)
#expect(Model.anthropic(.fable5).supportsVision == true)
#expect(Model.anthropic(.opus4).supportsVision == true)
#expect(Model.anthropic(.sonnet46).supportsVision == true)
#expect(Model.anthropic(.sonnet4).supportsVision == true)
#expect(Model.grok(.grok43).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.ollama(.llava).supportsVision == true)
#expect(Model.ollama(.llama33).supportsVision == false)
@ -153,14 +121,13 @@ struct ProviderSystemTests {
@Test
func `Model Capabilities - Tool Support`() {
#expect(Model.openai(.gpt55).supportsTools == true)
#expect(Model.openai(.gpt55).supportsTools == true)
#expect(Model.openai(.gpt4o).supportsTools == true)
#expect(Model.openai(.gpt41).supportsTools == true)
#expect(Model.anthropic(.fable5).supportsTools == true)
#expect(Model.anthropic(.opus4).supportsTools == true)
#expect(Model.anthropic(.sonnet46).supportsTools == true)
#expect(Model.anthropic(.sonnet4).supportsTools == true)
#expect(Model.grok(.grok43).supportsTools == true)
#expect(Model.grok(.grok4).supportsTools == true)
#expect(Model.ollama(.llama33).supportsTools == true)
#expect(Model.ollama(.llava).supportsTools == false) // Vision models don't support tools
@ -169,28 +136,9 @@ struct ProviderSystemTests {
@Test
func `Model Capabilities - Streaming Support`() {
#expect(Model.openai(.gpt55).supportsStreaming == true)
#expect(Model.openai(.gpt4o).supportsStreaming == true)
#expect(Model.anthropic(.opus4).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.grok(.grok4).supportsStreaming == true)
#expect(Model.ollama(.llama33).supportsStreaming == true)
}

View File

@ -2,11 +2,6 @@ import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if canImport(Darwin)
import Darwin
#else
import Glibc
#endif
import Testing
@testable import Tachikoma
@ -23,11 +18,8 @@ struct OpenAIResponsesProviderTests {
let config = self.openAIConfig()
let gpt5Models: [LanguageModel.OpenAI] = [
.chatLatest,
.gpt55,
.gpt54,
.gpt54Mini,
.gpt54Nano,
.gpt52,
.gpt51,
.gpt5,
.gpt5Mini,
.gpt5Nano,
@ -47,8 +39,8 @@ struct OpenAIResponsesProviderTests {
}
@Test
func `GPT-5.5 text.verbosity parameter is set correctly`() throws {
// Test that the text.verbosity parameter is properly configured for GPT-5.5
func `GPT-5.1 text.verbosity parameter is set correctly`() throws {
// Test that the text.verbosity parameter is properly configured for GPT-5.1
let config = self.openAIConfig()
// Skip if no API key
@ -57,7 +49,7 @@ struct OpenAIResponsesProviderTests {
}
let provider = try OpenAIResponsesProvider(
model: .gpt55,
model: .gpt51,
configuration: config,
)
@ -72,27 +64,26 @@ 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.5")
#expect(provider.modelId == "gpt-5.1")
#expect(provider.capabilities.supportsTools == true)
#expect(provider.capabilities.supportsVision == true)
}
@Test
func `GPT-5 models use Responses API`() throws {
// Test that GPT-5 models use the OpenAIResponsesProvider
func `Reasoning models use Responses API`() throws {
// Test that reasoning-oriented models also use the OpenAIResponsesProvider
let config = self.openAIConfig()
let responsesModels: [LanguageModel.OpenAI] = [
.chatLatest,
.gpt55,
.gpt54,
.gpt54Mini,
.gpt54Nano,
let reasoningModels: [LanguageModel.OpenAI] = [
.o4Mini,
.gpt52,
.gpt51,
.gpt5,
.gpt5Mini,
.gpt5Thinking,
]
for model in responsesModels {
for model in reasoningModels {
let provider = try ProviderFactory.createProvider(
for: .openai(model),
configuration: config,
@ -106,10 +97,11 @@ struct OpenAIResponsesProviderTests {
}
@Test
func `Custom OpenAI models use standard OpenAI provider`() throws {
func `Legacy models use standard OpenAI provider`() throws {
// Test that non-GPT-5/reasoning models use the standard OpenAIProvider
let config = self.openAIConfig()
let legacyModels: [LanguageModel.OpenAI] = [.custom("custom-openai")]
let legacyModels: [LanguageModel.OpenAI] = [.gpt4o, .gpt4oMini, .gpt41]
for model in legacyModels {
let provider = try ProviderFactory.createProvider(
@ -119,7 +111,7 @@ struct OpenAIResponsesProviderTests {
#expect(
provider is OpenAIProvider,
"Custom model \(model) should use OpenAIProvider",
"Legacy model \(model) should use OpenAIProvider",
)
}
}
@ -204,7 +196,6 @@ struct OpenAIResponsesProviderTests {
choices: nil,
usage: nil,
metadata: nil,
incompleteDetails: nil,
)
let providerResponse = try OpenAIResponsesProvider.convertToProviderResponse(response)
@ -217,159 +208,6 @@ 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)
@ -401,67 +239,6 @@ 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()
@ -673,235 +450,27 @@ struct OpenAIResponsesProviderTests {
#expect(request.url?.path == "/v1/responses")
let payload = Self.responsesStreamPayload(chunks: [
Self.streamChunkJSON(content: "Hello", finishReason: nil),
Self.streamChunkJSON(content: " world", finishReason: nil),
Self.streamChunkJSON(content: "Hello world", finishReason: nil),
Self.streamChunkJSON(content: nil, finishReason: "stop"),
])
return NetworkMocking.streamResponse(for: request, data: payload)
} operation: { session in
let provider = try OpenAIResponsesProvider(model: .gpt55, configuration: config, session: session)
let provider = try OpenAIResponsesProvider(model: .o4Mini, 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:
receivedDone = true
break
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")
}
}
@ -975,24 +544,30 @@ struct OpenAIResponsesProviderTests {
}
private static func streamChunkJSON(content: String?, finishReason: String?) -> String {
var delta: [String: Any] = [
"role": "assistant",
]
if let content {
let chunk: [String: Any] = [
"type": "response.output_text.delta",
"delta": content,
]
let data = try! JSONSerialization.data(withJSONObject: chunk)
return String(data: data, encoding: .utf8)!
delta["content"] = content
}
var choice: [String: Any] = [
"index": 0,
"delta": delta,
]
if let finishReason {
choice["finish_reason"] = finishReason
}
let chunk: [String: Any] = [
"type": finishReason == nil ? "response.output_text.done" : "response.completed",
"id": "resp_stream",
"object": "response",
"created": 1_700_000_000,
"model": "o4-mini",
"choices": [choice],
]
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)
let data = try! JSONSerialization.data(withJSONObject: chunk)
return String(data: data, encoding: .utf8)!
}
@ -1022,39 +597,6 @@ 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 {

View File

@ -57,7 +57,7 @@ struct ProviderEndToEndTests {
let config = Self.makeConfiguration { config in
config.setAPIKey("sk-live-openai", for: .openai)
}
let provider = try OpenAIProvider(model: .gpt55, configuration: config)
let provider = try OpenAIProvider(model: .gpt4o, 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: .sonnet46, configuration: config)
let provider = try AnthropicProvider(model: .sonnet4, configuration: config)
let response = try await provider.generateText(request: Self.basicRequest)
#expect(response.text == "Claude says hello")
}
@ -97,141 +97,21 @@ 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(.smallLatest), provider: .mistral)
try await self.assertOpenAICompatibleProvider(.mistral(.small), provider: .mistral)
}
@Test
func `Groq provider uses OpenAI-compatible flow`() async throws {
try await self.assertOpenAICompatibleProvider(.groq(.llama318b), provider: .groq)
try await self.assertOpenAICompatibleProvider(.groq(.llama38b), provider: .groq)
}
@Test
func `Grok provider uses OpenAI-compatible flow`() async throws {
try await self.assertOpenAICompatibleProvider(.grok(.grok43), provider: .grok)
try await self.assertOpenAICompatibleProvider(.grok(.grok4FastReasoning), provider: .grok)
}
@Test
@ -324,7 +204,6 @@ 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
@ -413,110 +292,6 @@ 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 {
@ -587,7 +362,7 @@ struct ProviderEndToEndTests {
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1_723_000_000,
"model": "gpt-5.5",
"model": "gpt-4o",
"choices": [
[
"index": 0,
@ -612,26 +387,7 @@ struct ProviderEndToEndTests {
"content": [
["type": "text", "text": text],
],
"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",
"model": "claude-sonnet-4-20250514",
"stop_reason": "end_turn",
"usage": [
"input_tokens": 12,

Some files were not shown because too many files have changed in this diff Show More