Compare commits

..

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

162 changed files with 3584 additions and 12443 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 --parallel $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

@ -4,6 +4,5 @@ READ ~/Projects/agent-scripts/{AGENTS.MD,TOOLS.MD} BEFORE ANYTHING (skip if file
Tachikoma notes:
- Keep this repo in sync with Peekaboo; bump the submodule there after changes.
- Batch git network ops with Peekaboo: commit related changes first, then push/pull repos together so the submodule pointer never races the source repo.
- Default workflow: `pnpm install`, `pnpm run lint`, `pnpm run test` before publishing.
- Adapters live under `src/providers`; keep new providers consistent with existing patterns.

View File

@ -2,67 +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
- First-class Azure OpenAI provider: deployment-based model case `.azureOpenAI`, Azure-specific URL/header/query wiring (api-version, api-key or bearer token), env overrides (`AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_BEARER_TOKEN`, `AZURE_OPENAI_ENDPOINT`/`RESOURCE`, `AZURE_OPENAI_API_VERSION`), and README guidance.
- 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.
- 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.
- 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.
- `TachikomaConfiguration` can optionally override the provider factory so test harnesses can inject mock providers without affecting production defaults, improving hermetic test runs.
- Implemented OpenRouter, Together, Replicate, and Anthropic-compatible providers on top of the shared helpers so aggregator models no longer throw “not yet implemented” errors and honour custom base URLs/headers.
- `Provider.environmentValue` falls back to classic `getenv` lookups when the modern configuration reader returns no value, ensuring environment overrides succeed on macOS 14 deployments.
- Provider environment reads now use direct process environment lookups, so test overrides and runtime unsets behave deterministically across SwiftPM/Xcode runs.
### Fixed
- MCP bridge conversions now handle embedded resources and resource links from `swift-sdk` 0.11, and test helpers no longer swap mock keys for live environment credentials during provider/audio suites.
- `retryWithCancellation` now registers token handlers per-attempt and cancels in-flight work, resolving hangs when external cancellation should short-circuit retries.
- Audio provider tests and helpers consistently force mock mode when exercising stub audio payloads, preventing accidental live API calls that fail to decode fixtures.
- `TestHelpers` expose discardable configuration helpers and stricter mock-key detection, reducing compiler warnings and flaky skips.
- OpenAI transcription timestamp tests no longer hit the live API and succeed reliably under both mock and real key configurations.
- Google provider API-key resolution no longer treats `GOOGLE_APPLICATION_CREDENTIALS` file paths as credential strings.
- Anthropic OAuth login token exchange now uses the correct request format (JSON body + `state`). Thanks @jonathanglasmeyer.
### Testing
- Added dedicated Grok catalog tests (selector + capability assertions) plus provider factory/e2e coverage so every supported xAI model is exercised in mock suites without hitting the live API.
- Integration suites now respect real API keys loaded from the environment, covering Anthropic Sonnet 4 tool-calling, OpenAI GPT5 responses, Grok/Grok vision flows, and Google/Mistral smoke tests.
- Full `INTEGRATION_TESTS=1 swift test` runs complete without recorded issues, including agent ergonomics and audio suites.
- Added provider-level network E2E coverage using local `URLProtocol` stubs plus new OpenAI Responses API tests (request encoding + streaming) so critical serialization paths are exercised without live traffic.
- `ProviderEndToEndTests` now exercise every provider flavor (OpenRouter/Together/Replicate, OpenAI/Anthropic compatible, etc.), pushing overall line coverage above 40% while keeping the suite deterministic via URLProtocol stubs.
## [0.1.0] - 2026-01-18
### Added
@ -72,6 +11,51 @@ All notable changes to the Tachikoma project will be documented in this file.
- Config system with credential store + env overrides, model registry, and capability lookup helpers.
- Test helpers and mock infrastructure for deterministic provider/unit coverage.
All notable changes to the Tachikoma project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- First-class Azure OpenAI provider: deployment-based model case `.azureOpenAI`, Azure-specific URL/header/query wiring (api-version, api-key or bearer token), env overrides (`AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_BEARER_TOKEN`, `AZURE_OPENAI_ENDPOINT`/`RESOURCE`, `AZURE_OPENAI_API_VERSION`), and README guidance.
- Azure provider unit tests using URLProtocol stubs to verify path, query, and auth header construction.
### Changed
- Added OpenAI's GPT-5.1 family (flagship/mini/nano) throughout the model enums, selectors, provider factories, capability registry, pricing tables, docs, and test suites. GPT aliases (`gpt`, `gpt-5`, `gpt-4o`) now normalize to `.openai(.gpt51)` so downstream apps inherit the new default seamlessly.
- Expanded xAI Grok support to the full November 2025 catalog (`grok-4-fast-*`, `grok-code-fast-1`, `grok-2-*`, `grok-vision-beta`, etc.), updated the CLI shortcuts so `grok` now maps to `grok-4-fast-reasoning`, and refreshed selectors, provider parsers, capability tables, and docs snippets to match the official API lineup.
- Google/Gemini support now targets the Gemini 2.5 family exclusively (`gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`), with updated model selectors, parsers, docs, and pricing tables; older 1.5/2.0 IDs are no longer recognized.
- Removed deprecated OpenAI reasoning models (`o1`, `o1-mini`, `o3`, `o3-mini`) in favour of the 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.
- `TachikomaConfiguration` can optionally override the provider factory so test harnesses can inject mock providers without affecting production defaults, improving hermetic test runs.
- Implemented OpenRouter, Together, Replicate, and Anthropic-compatible providers on top of the shared helpers so aggregator models no longer throw “not yet implemented” errors and honour custom base URLs/headers.
- `Provider.environmentValue` falls back to classic `getenv` lookups when the modern configuration reader returns no value, ensuring environment overrides succeed on macOS 14 deployments.
### Fixed
- `retryWithCancellation` now registers token handlers per-attempt and cancels in-flight work, resolving hangs when external cancellation should short-circuit retries.
- Audio provider tests and helpers consistently force mock mode when exercising stub audio payloads, preventing accidental live API calls that fail to decode fixtures.
- `TestHelpers` expose discardable configuration helpers and stricter mock-key detection, reducing compiler warnings and flaky skips.
- OpenAI transcription timestamp tests no longer hit the live API and succeed reliably under both mock and real key configurations.
- Anthropic OAuth login token exchange now uses the correct request format (JSON body + `state`). Thanks @jonathanglasmeyer.
### Testing
- Added dedicated Grok catalog tests (selector + capability assertions) plus provider factory/e2e coverage so every supported xAI model is exercised in mock suites without hitting the live API.
- Integration suites now respect real API keys loaded from the environment, covering Anthropic Sonnet 4 tool-calling, OpenAI GPT5 responses, Grok/Grok vision flows, and Google/Mistral smoke tests.
- Full `INTEGRATION_TESTS=1 swift test` runs complete without recorded issues, including agent ergonomics and audio suites.
- Added provider-level network E2E coverage using local `URLProtocol` stubs plus new OpenAI Responses API tests (request encoding + streaming) so critical serialization paths are exercised without live traffic.
- `ProviderEndToEndTests` now exercise every provider flavor (OpenRouter/Together/Replicate, OpenAI/Anthropic compatible, etc.), pushing overall line coverage above 40% while keeping the suite deterministic via URLProtocol stubs.
### Planned Features
- Enhanced caching with persistence and TTL
- Bidirectional streaming support
- Request batching for high-volume usage
- Advanced error recovery mechanisms
- Metrics collection and monitoring
- Distributed caching support
## [1.0.0] - 2025-01-XX
### Added
@ -87,8 +71,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 +80,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
@ -225,4 +209,4 @@ This is the initial release, so no breaking changes from previous versions.
## License
This project is licensed under the MIT License. See LICENSE file for details.
This project is licensed under the MIT License. See LICENSE file for details.

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 |
@ -48,7 +48,7 @@ Set API keys for your providers:
```bash
export OPENAI_API_KEY='sk-...' # OpenAI models
export ANTHROPIC_API_KEY='sk-ant-...' # Claude models
export GEMINI_API_KEY='...' # Gemini models (legacy GOOGLE_API_KEY also accepted)
export GEMINI_API_KEY='...' # Gemini models (legacy GOOGLE_API_KEY or GOOGLE_APPLICATION_CREDENTIALS also accepted)
export MISTRAL_API_KEY='...' # Mistral models
export GROQ_API_KEY='gsk-...' # Groq models
export X_AI_API_KEY='xai-...' # Grok models
@ -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,61 +213,63 @@ 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:
Set the appropriate environment variable for your provider:
OPENAI_API_KEY for OpenAI models
ANTHROPIC_API_KEY for Claude models
GEMINI_API_KEY for Gemini models (legacy GOOGLE_API_KEY also accepted)
GEMINI_API_KEY for Gemini models (legacy GOOGLE_API_KEY / GOOGLE_APPLICATION_CREDENTIALS also accepted)
MISTRAL_API_KEY for Mistral models
GROQ_API_KEY for Groq models
X_AI_API_KEY, XAI_API_KEY, or GROK_API_KEY for Grok models
X_AI_API_KEY or XAI_API_KEY for Grok models
Ollama requires local installation (no API key needed)
For detailed documentation, visit: https://github.com/steipete/tachikoma
@ -312,7 +314,7 @@ struct AICLI {
self.checkAPIKeyStatus(provider: "Google", envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"])
self.checkAPIKeyStatus(provider: "Mistral", envVars: ["MISTRAL_API_KEY"])
self.checkAPIKeyStatus(provider: "Groq", envVars: ["GROQ_API_KEY"])
self.checkAPIKeyStatus(provider: "Grok", envVars: ["X_AI_API_KEY", "XAI_API_KEY", "GROK_API_KEY"])
self.checkAPIKeyStatus(provider: "Grok", envVars: ["X_AI_API_KEY", "XAI_API_KEY"])
// Ollama status
print(" • Ollama: Local (no API key required)")
@ -350,7 +352,7 @@ struct AICLI {
let envVar = provider.environmentVariable.isEmpty ? "API key" : provider.environmentVariable
if provider == .grok {
// Special case for Grok with alternative variables
throw CLIError.missingAPIKey("X_AI_API_KEY, XAI_API_KEY, or GROK_API_KEY")
throw CLIError.missingAPIKey("X_AI_API_KEY or XAI_API_KEY")
} else {
throw CLIError.missingAPIKey(envVar)
}
@ -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
@ -405,15 +405,9 @@ struct AICLI {
print("export GEMINI_API_KEY='gk-your-key-here'")
print("# Legacy names still supported:")
print("export GOOGLE_API_KEY='gk-your-key-here'")
print("# or service-account path:")
print("export GOOGLE_APPLICATION_CREDENTIALS='/path/to/service-account.json'")
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'")
@ -427,7 +421,6 @@ struct AICLI {
print("export X_AI_API_KEY='xai-your-key-here'")
print("# or alternatively:")
print("export XAI_API_KEY='xai-your-key-here'")
print("export GROK_API_KEY='xai-your-key-here'")
print("Get your key at: https://console.x.ai/")
case .ollama:
print("Install Ollama locally:")
@ -481,7 +474,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 +586,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 +673,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 +802,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 +826,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"
```
@ -217,7 +220,7 @@ Set API keys for providers:
```bash
export OPENAI_API_KEY='sk-...'
export ANTHROPIC_API_KEY='sk-ant-...'
export GEMINI_API_KEY='...' # Legacy GOOGLE_API_KEY still works
export GEMINI_API_KEY='...' # Legacy GOOGLE_API_KEY / GOOGLE_APPLICATION_CREDENTIALS still work
export MISTRAL_API_KEY='...'
export GROQ_API_KEY='gsk-...'
export X_AI_API_KEY='xai-...'

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")
@ -301,7 +301,8 @@ struct AgentCLI: AsyncParsableCommand {
private func loadMessages(from path: String) async throws -> [ModelMessage] {
let url = URL(fileURLWithPath: path)
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([ModelMessage].self, from: data)
let messages = try JSONDecoder().decode([ModelMessage].self, from: data)
return messages
}
private func saveMessages(_ messages: [ModelMessage], to path: String) async throws {
@ -500,13 +501,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")
}
@ -548,10 +546,10 @@ enum CLIError: LocalizedError {
}
}
/// Extension to add provider helpers
// 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 +557,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

@ -4,14 +4,14 @@ import Foundation
// swift-sh Tachikoma ~> 1.0.0
// This demo script shows how to use Tachikoma in a real application
// Run with: swift-sh DemoScript.swift
// Or: swift run DemoScript (if added to Package.swift)
/// This demo script shows how to use Tachikoma in a real application
/// Run with: swift-sh DemoScript.swift
/// Or: swift run DemoScript (if added to Package.swift)
print("🕷️ Tachikoma Demo Script")
print("=" * 40)
/// Check environment variables directly
// Check environment variables directly
let env = ProcessInfo.processInfo.environment
print("Environment check:")
for key in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "X_AI_API_KEY"] {

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)")
@ -106,7 +106,7 @@ func testRealtimeConfiguration() async throws {
print(" swift run RealtimeVoiceAssistant --basic")
}
/// Extension for string multiplication
// Extension for string multiplication
extension String {
static func * (string: String, count: Int) -> String {
String(repeating: string, count: count)

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,6 +1,6 @@
MIT License
Copyright (c) 2026 Peter Steinberger
\g<1>2026 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -1,22 +1,13 @@
{
"originHash" : "12a454cd38a6ae2519d652cc0872f7f18feb64690ce83d1507bae6db71c1841c",
"originHash" : "71bb7313f33f89ee4c3627d91346c2371cad8918a22c27c75a7b66f6ebde75ef",
"pins" : [
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b",
"version" : "0.2.2"
}
},
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattt/eventsource.git",
"state" : {
"revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
"version" : "1.4.1"
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
"version" : "1.3.0"
}
},
{
@ -33,8 +24,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008",
"version" : "1.7.1"
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
"version" : "1.5.1"
}
},
{
@ -42,26 +33,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms.git",
"state" : {
"revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440",
"version" : "1.1.4"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
"version" : "1.1.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
"version" : "1.6.0"
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
"version" : "1.3.0"
}
},
{
@ -69,8 +51,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-configuration",
"state" : {
"revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9",
"version" : "1.2.0"
"revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749",
"version" : "1.0.0"
}
},
{
@ -78,8 +60,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1",
"version" : "4.5.0"
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
"version" : "4.2.0"
}
},
{
@ -87,17 +69,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a",
"version" : "1.13.2"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "a8e036cb8628fcc1ff67dfec6ce8168617172c9b",
"version" : "2.101.1"
"revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca",
"version" : "1.8.0"
}
},
{
@ -114,8 +87,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
"state" : {
"revision" : "a0ae212ebf6eab5f754c3129608bc5557637e605",
"version" : "0.12.1"
"revision" : "c0407a0b52677cb395d824cac2879b963075ba8c",
"version" : "0.10.2"
}
},
{
@ -123,8 +96,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle",
"state" : {
"revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a",
"version" : "2.11.0"
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
"version" : "2.9.1"
}
},
{
@ -132,8 +105,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1",
"version" : "1.7.2"
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
"version" : "1.6.3"
}
}
],

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,9 +49,9 @@ 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/modelcontextprotocol/swift-sdk.git", from: "0.10.2"),
.package(url: "https://github.com/apple/swift-configuration", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.1"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "4.2.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)")
}
}
@ -525,6 +485,7 @@ extension ProviderFactory {
-> any ModelProvider
{
// Create a provider with configuration
try createProvider(for: model, configuration: configuration)
let provider = try createProvider(for: model, configuration: configuration)
return provider
}
}

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)
}
@ -18,7 +17,7 @@ public final class CustomProviderRegistry: @unchecked Sendable {
private init() {}
/// Load from ~/.<profile>/config.json customProviders
// Load from ~/.<profile>/config.json customProviders
public func loadFromProfile() {
guard let json = Self.loadRawConfigJSON() else { return }
guard let cp = json["customProviders"] as? [String: Any] else { return }
@ -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,12 +52,15 @@ 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 {
"\(self.profileDirectoryPath())/config.json"
}
private static func profileConfigPath() -> String { "\(self.profileDirectoryPath())/config.json" }
private static func stripJSONComments(from json: String) -> String {
var result = ""

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)
}
}
}
@ -220,7 +220,7 @@ protocol EmbeddingProvider: Sendable {
/// Request for embedding generation
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
struct EmbeddingRequest {
struct EmbeddingRequest: Sendable {
let input: EmbeddingInput
let settings: EmbeddingSettings
}

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

@ -1,3 +1,4 @@
import Configuration
#if canImport(Darwin)
import Darwin
#else
@ -41,12 +42,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 +63,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 +79,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 +95,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"
@ -116,9 +105,8 @@ public enum Provider: Sendable, Hashable, Codable {
/// Alternative environment variable names (for compatibility)
public var alternativeEnvironmentVariables: [String] {
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 .grok: ["XAI_API_KEY"] // Additional Grok alias
case .google: ["GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS"] // Backwards compatibility
case .azureOpenAI: ["AZURE_OPENAI_TOKEN", "AZURE_OPENAI_BEARER_TOKEN"]
default: []
}
@ -133,8 +121,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 +140,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 +152,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)
@ -180,21 +164,56 @@ public enum Provider: Sendable, Hashable, Codable {
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
extension Provider {
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, *)
private static var environmentReader: ConfigReader {
enum Holder {
static let reader = ConfigReader(
provider: EnvironmentVariablesProvider(
secretsSpecifier: .dynamic { key, _ in
let lowercased = key.lowercased()
return lowercased.contains("key") ||
lowercased.contains("token") ||
lowercased.contains("secret")
},
),
)
}
return Holder.reader
}
/// Load API key from environment variables
/// Checks primary environment variable first, then alternatives
public func loadAPIKeyFromEnvironment() -> String? {
// Check primary environment variable
if
!self.environmentVariable.isEmpty,
let key = Self.processEnvironmentValue(for: self.environmentVariable),
!key.isEmpty
{
return key
if !self.environmentVariable.isEmpty {
if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, *) {
if
let key = Self.environmentReader.string(
forKey: ConfigKey([self.environmentVariable]),
isSecret: true,
),
!key.isEmpty
{
return key
}
} else if
let key = ProcessInfo.processInfo.environment[environmentVariable],
!key.isEmpty
{
return key
}
}
// Check alternative environment variables
for altVar in self.alternativeEnvironmentVariables {
if let key = Self.processEnvironmentValue(for: altVar), !key.isEmpty {
if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, *) {
if
let key = Self.environmentReader.string(forKey: ConfigKey([altVar]), isSecret: true),
!key.isEmpty
{
return key
}
} else if let key = ProcessInfo.processInfo.environment[altVar], !key.isEmpty {
return key
}
}
@ -209,12 +228,19 @@ extension Provider {
/// Read an environment value using the shared configuration reader.
public static func environmentValue(for key: String, isSecret: Bool = false) -> String? {
_ = isSecret
return self.processEnvironmentValue(for: key)
}
// Read an environment value using the shared configuration reader.
if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, *) {
if
let value = environmentReader.string(forKey: ConfigKey([key]), isSecret: isSecret),
!value.isEmpty
{
return value
}
}
private static func processEnvironmentValue(for key: String) -> String? {
guard let pointer = getenv(key) else { return nil }
guard let pointer = getenv(key) else {
return nil
}
return String(cString: pointer)
}
}

View File

@ -74,7 +74,7 @@ public struct ProviderConfiguration: Sendable {
self.customHeaders = customHeaders
}
/// Common configurations for providers
// Common configurations for providers
public static let openAI = ProviderConfiguration(
maxTokens: 4096,
maxContextLength: 128_000,
@ -111,21 +111,10 @@ public final class ProviderAdapter: EnhancedModelProvider {
private let baseProvider: ModelProvider
public let configuration: ProviderConfiguration
public var modelId: String {
self.baseProvider.modelId
}
public var baseURL: String? {
self.baseProvider.baseURL
}
public var apiKey: String? {
self.baseProvider.apiKey
}
public var capabilities: ModelCapabilities {
self.baseProvider.capabilities
}
public var modelId: String { self.baseProvider.modelId }
public var baseURL: String? { self.baseProvider.baseURL }
public var apiKey: String? { self.baseProvider.apiKey }
public var capabilities: ModelCapabilities { self.baseProvider.capabilities }
public init(provider: ModelProvider, configuration: ProviderConfiguration) {
self.baseProvider = provider

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

@ -27,13 +27,9 @@ public enum AgentValueType: String, Sendable, Codable, CaseIterable {
// MARK: - Built-in Type Conformances
extension String: AgentToolValue {
public static var agentValueType: AgentValueType {
.string
}
public static var agentValueType: AgentValueType { .string }
public func toJSON() throws -> Any {
self
}
public func toJSON() throws -> Any { self }
public static func fromJSON(_ json: Any) throws -> Self {
guard let string = json as? String else {
@ -44,13 +40,9 @@ extension String: AgentToolValue {
}
extension Int: AgentToolValue {
public static var agentValueType: AgentValueType {
.integer
}
public static var agentValueType: AgentValueType { .integer }
public func toJSON() throws -> Any {
self
}
public func toJSON() throws -> Any { self }
public static func fromJSON(_ json: Any) throws -> Self {
if let int = json as? Int {
@ -64,13 +56,9 @@ extension Int: AgentToolValue {
}
extension Double: AgentToolValue {
public static var agentValueType: AgentValueType {
.number
}
public static var agentValueType: AgentValueType { .number }
public func toJSON() throws -> Any {
self
}
public func toJSON() throws -> Any { self }
public static func fromJSON(_ json: Any) throws -> Self {
if let double = json as? Double {
@ -84,13 +72,9 @@ extension Double: AgentToolValue {
}
extension Bool: AgentToolValue {
public static var agentValueType: AgentValueType {
.boolean
}
public static var agentValueType: AgentValueType { .boolean }
public func toJSON() throws -> Any {
self
}
public func toJSON() throws -> Any { self }
public static func fromJSON(_ json: Any) throws -> Self {
guard let bool = json as? Bool else {
@ -103,15 +87,11 @@ extension Bool: AgentToolValue {
/// Null value type
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public struct AgentNullValue: AgentToolValue, Equatable {
public static var agentValueType: AgentValueType {
.null
}
public static var agentValueType: AgentValueType { .null }
public init() {}
public func toJSON() throws -> Any {
NSNull()
}
public func toJSON() throws -> Any { NSNull() }
public static func fromJSON(_ json: Any) throws -> Self {
if json is NSNull {
@ -123,9 +103,7 @@ public struct AgentNullValue: AgentToolValue, Equatable {
/// Array conformance
extension Array: AgentToolValue where Element: AgentToolValue {
public static var agentValueType: AgentValueType {
.array
}
public static var agentValueType: AgentValueType { .array }
public func toJSON() throws -> Any {
try map { try $0.toJSON() }
@ -141,9 +119,7 @@ extension Array: AgentToolValue where Element: AgentToolValue {
/// Dictionary conformance
extension Dictionary: AgentToolValue where Key == String, Value: AgentToolValue {
public static var agentValueType: AgentValueType {
.object
}
public static var agentValueType: AgentValueType { .object }
public func toJSON() throws -> Any {
var result: [String: Any] = [:]
@ -182,9 +158,7 @@ public struct AnyAgentToolValue: AgentToolValue, Equatable, Codable {
case object([String: AnyAgentToolValue])
}
public static var agentValueType: AgentValueType {
.object
} // Generic type
public static var agentValueType: AgentValueType { .object } // Generic type
public init(_ value: some AgentToolValue) throws {
let json = try value.toJSON()

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
}
@ -247,7 +246,7 @@ public struct ModelMessage: Sendable, Codable, Equatable {
self.metadata = metadata
}
/// Convenience initializers
// Convenience initializers
public static func system(_ text: String) -> ModelMessage {
ModelMessage(role: .system, content: [.text(text)])
}
@ -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,30 +361,12 @@ 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
// Manual Codable conformance excluding non-codable stopConditions
extension GenerationSettings: Codable {
enum CodingKeys: String, CodingKey {
case maxTokens
@ -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
}
}
@ -538,7 +508,7 @@ public struct TextStreamDelta: Sendable {
self.finishReason = finishReason
}
/// Convenience constructors
// Convenience constructors
public static func text(_ content: String, channel: ResponseChannel? = nil) -> TextStreamDelta {
TextStreamDelta(type: .textDelta, content: content, channel: channel)
}
@ -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, *)

View File

@ -151,7 +151,7 @@ public struct RecoverySuggestion: Sendable {
self.helpURL = helpURL
}
/// Common recovery suggestions
// Common recovery suggestions
public static let checkAPIKey = RecoverySuggestion(
suggestion: "Check that your API key is valid and has the necessary permissions",
actions: [.validateAPIKey, .regenerateAPIKey],

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
}
}
@ -198,7 +194,7 @@ enum AnthropicContent: Codable {
try Self.encodeAnyDict(self.input, to: inputEncoder)
}
/// Helper methods for encoding/decoding [String: Any]
// Helper methods for encoding/decoding [String: Any]
private static func decodeAnyDict(from decoder: Decoder) throws -> [String: Any] {
let container = try decoder.singleValueContainer()
return try container.decode([String: JSONValue].self).mapValues { $0.value }
@ -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

@ -9,13 +9,8 @@ public final class AzureOpenAIProvider: ModelProvider {
public let modelId: String
public let apiVersion: String
public let capabilities: ModelCapabilities
public var baseURL: String? {
self.resolvedBaseURL
}
public var apiKey: String? {
self.resolvedAPIKey
}
public var baseURL: String? { self.resolvedBaseURL }
public var apiKey: String? { self.resolvedAPIKey }
private let authHeaderName: String
private let authHeaderValuePrefix: String

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

@ -12,9 +12,7 @@ public final class GoogleProvider: ModelProvider {
public let capabilities: ModelCapabilities
private let model: LanguageModel.Google
private var apiModelName: String {
self.model.apiModelId
}
private var apiModelName: String { self.model.apiModelId }
public init(model: LanguageModel.Google, configuration: TachikomaConfiguration) throws {
self.model = model
@ -214,15 +212,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 +251,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 +269,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 +293,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

@ -16,22 +16,11 @@ public actor LMStudioProvider: ModelProvider {
private let configuredModelId: String
private let configuredCapabilities: ModelCapabilities
/// Expose as optional for protocol conformance, but it's never actually nil
public nonisolated var baseURL: String? {
self.actualBaseURL
}
public nonisolated var apiKey: String? {
self.configuredApiKey
}
public nonisolated var modelId: String {
self.configuredModelId
}
public nonisolated var capabilities: ModelCapabilities {
self.configuredCapabilities
}
// Expose as optional for protocol conformance, but it's never actually nil
public nonisolated var baseURL: String? { self.actualBaseURL }
public nonisolated var apiKey: String? { self.configuredApiKey }
public nonisolated var modelId: String { self.configuredModelId }
public nonisolated var capabilities: ModelCapabilities { self.configuredCapabilities }
private let session: URLSession
private let encoder = JSONEncoder()
@ -41,7 +30,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 +63,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

@ -12,15 +12,10 @@ struct OpenAIEmbeddingProvider: EmbeddingProvider, ModelProvider {
let apiKey: String?
let baseURL: String?
var modelId: String {
self.model.rawValue
}
var modelId: String { self.model.rawValue }
var capabilities: ModelCapabilities { ModelCapabilities() }
var capabilities: ModelCapabilities {
ModelCapabilities()
}
/// ModelProvider conformance (not used for embeddings)
// ModelProvider conformance (not used for embeddings)
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
throw TachikomaError.unsupportedOperation("Text generation not supported for embedding models")
}
@ -97,25 +92,17 @@ struct OpenAIEmbeddingProvider: EmbeddingProvider, ModelProvider {
}
}
/// Placeholder providers for other services
// Placeholder providers for other services
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
struct CohereEmbeddingProvider: EmbeddingProvider, ModelProvider {
let model: EmbeddingModel.CohereEmbedding
let apiKey: String?
var modelId: String {
self.model.rawValue
}
var modelId: String { self.model.rawValue }
var baseURL: String? { nil }
var capabilities: ModelCapabilities { ModelCapabilities() }
var baseURL: String? {
nil
}
var capabilities: ModelCapabilities {
ModelCapabilities()
}
/// ModelProvider conformance (not used for embeddings)
// ModelProvider conformance (not used for embeddings)
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
throw TachikomaError.unsupportedOperation("Text generation not supported for embedding models")
}
@ -134,19 +121,11 @@ struct VoyageEmbeddingProvider: EmbeddingProvider, ModelProvider {
let model: EmbeddingModel.VoyageEmbedding
let apiKey: String?
var modelId: String {
self.model.rawValue
}
var modelId: String { self.model.rawValue }
var baseURL: String? { nil }
var capabilities: ModelCapabilities { ModelCapabilities() }
var baseURL: String? {
nil
}
var capabilities: ModelCapabilities {
ModelCapabilities()
}
/// ModelProvider conformance (not used for embeddings)
// ModelProvider conformance (not used for embeddings)
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
throw TachikomaError.unsupportedOperation("Text generation not supported for embedding models")
}

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

@ -11,7 +11,7 @@ struct OpenAIResponsesRequest: Encodable {
let topP: Double?
let maxOutputTokens: Int?
/// Response format and text configuration
// Response format and text configuration
let text: TextConfig? // GPT-5 text configuration with verbosity
// Tool configuration
@ -28,13 +28,13 @@ 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
// Truncation for long inputs
let truncation: String?
/// Streaming support
// Streaming support
let stream: Bool?
enum CodingKeys: String, CodingKey {
@ -62,7 +62,7 @@ struct OpenAIResponsesRequest: Encodable {
/// Text verbosity levels for GPT-5
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
enum TextVerbosity: String, Codable {
enum TextVerbosity: String, Codable, Sendable {
case low
case medium
case high
@ -70,7 +70,7 @@ enum TextVerbosity: String, Codable {
/// Internal reasoning effort levels for OpenAI responses provider
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
enum OpenAIReasoningEffort: String, Codable {
enum OpenAIReasoningEffort: String, Codable, Sendable {
case minimal
case low
case medium
@ -79,7 +79,7 @@ enum OpenAIReasoningEffort: String, Codable {
/// Reasoning summary modes for reasoning models
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
enum ReasoningSummary: String, Codable {
enum ReasoningSummary: String, Codable, Sendable {
case concise
case detailed
case auto
@ -87,13 +87,13 @@ enum ReasoningSummary: String, Codable {
/// Text configuration for GPT-5 models
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
struct TextConfig: Codable {
struct TextConfig: Codable, Sendable {
let verbosity: TextVerbosity?
}
/// Reasoning configuration for reasoning models
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
struct ReasoningConfig: Codable {
struct ReasoningConfig: Codable, Sendable {
let effort: OpenAIReasoningEffort?
let summary: ReasoningSummary?
}
@ -186,11 +186,11 @@ struct JSONSchemaFormat: Codable {
/// Message format for Responses API
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
struct ResponsesMessage: Codable {
struct ResponsesMessage: Codable, Sendable {
let role: String
let content: ResponsesContent
enum ResponsesContent: Codable {
enum ResponsesContent: Codable, Sendable {
case text(String)
case parts([ResponsesContentPart])
@ -218,7 +218,7 @@ struct ResponsesMessage: Codable {
/// Content part for multimodal messages
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
struct ResponsesContentPart: Codable {
struct ResponsesContentPart: Codable, Sendable {
let type: String
let text: String?
/// OpenAI Responses API (GPT5.x) accepts `image_url` only as a string (URL or data URL).
@ -226,7 +226,7 @@ struct ResponsesContentPart: Codable {
/// avoid 400s ("expected an image URL, but got an object instead").
let imageUrl: ImageURL?
struct ImageURL: Codable {
struct ImageURL: Codable, Sendable {
let url: String
let detail: String?
}
@ -271,12 +271,12 @@ struct ResponsesContentPart: Codable {
/// Heterogeneous input entries supported by the Responses API
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
enum ResponsesInputItem: Encodable {
enum ResponsesInputItem: Encodable, Sendable {
case message(ResponsesMessage)
case functionCall(FunctionCall)
case functionCallOutput(FunctionCallOutput)
struct FunctionCall: Encodable {
struct FunctionCall: Encodable, Sendable {
let type: String = "function_call"
let callId: String
let name: String
@ -290,7 +290,7 @@ enum ResponsesInputItem: Encodable {
}
}
struct FunctionCallOutput: Encodable {
struct FunctionCallOutput: Encodable, Sendable {
let type: String = "function_call_output"
let callId: String
let output: String
@ -473,32 +473,26 @@ struct ResponsesTool: Codable {
/// Response from OpenAI Responses API (GPT-5 format)
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
struct OpenAIResponsesResponse: Codable {
struct OpenAIResponsesResponse: Codable, Sendable {
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
struct ResponsesOutput: Codable {
// GPT-5 output format
struct ResponsesOutput: Codable, Sendable {
let id: String
let type: String
let status: String?
@ -516,35 +510,21 @@ struct OpenAIResponsesResponse: Codable {
case toolCall = "tool_call"
}
struct OutputContent: Codable {
struct OutputContent: Codable, Sendable {
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.
struct ResponsesChoice: Codable {
// O3 choices format (kept for compatibility)
struct ResponsesChoice: Codable, Sendable {
let index: Int
let message: ResponsesOutputMessage
let finishReason: String?
@ -558,7 +538,7 @@ struct OpenAIResponsesResponse: Codable {
}
}
struct ResponsesOutputMessage: Codable {
struct ResponsesOutputMessage: Codable, Sendable {
let role: String
let content: String?
let toolCalls: [ResponsesToolCall]?
@ -572,18 +552,18 @@ struct OpenAIResponsesResponse: Codable {
}
}
struct ResponsesToolCall: Codable {
struct ResponsesToolCall: Codable, Sendable {
let id: String
let type: String
let function: ResponsesToolFunction
struct ResponsesToolFunction: Codable {
struct ResponsesToolFunction: Codable, Sendable {
let name: String
let arguments: String
}
}
struct ResponsesUsage: Codable {
struct ResponsesUsage: Codable, Sendable {
let inputTokens: Int?
let outputTokens: Int?
let totalTokens: Int?
@ -604,7 +584,7 @@ struct OpenAIResponsesResponse: Codable {
case outputTokensDetails = "output_tokens_details"
}
struct TokenDetails: Codable {
struct TokenDetails: Codable, Sendable {
let cachedTokens: Int?
enum CodingKeys: String, CodingKey {
@ -612,7 +592,7 @@ struct OpenAIResponsesResponse: Codable {
}
}
struct OutputTokenDetails: Codable {
struct OutputTokenDetails: Codable, Sendable {
let reasoningTokens: Int?
enum CodingKeys: String, CodingKey {
@ -621,7 +601,7 @@ struct OpenAIResponsesResponse: Codable {
}
}
struct ResponsesMetadata: Codable {
struct ResponsesMetadata: Codable, Sendable {
let responseId: String?
let reasoningItemIds: [String]?
@ -634,16 +614,16 @@ 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 {
struct OpenAIResponsesStreamChunk: Codable, Sendable {
let id: String
let object: String
let created: Int
let model: String
let choices: [StreamChoice]
struct StreamChoice: Codable {
struct StreamChoice: Codable, Sendable {
let index: Int
let delta: StreamDelta
let finishReason: String?
@ -655,7 +635,7 @@ struct OpenAIResponsesStreamChunk: Codable {
}
}
struct StreamDelta: Codable {
struct StreamDelta: Codable, Sendable {
let role: String?
let content: String?
let toolCalls: [StreamToolCall]?
@ -667,13 +647,13 @@ struct OpenAIResponsesStreamChunk: Codable {
}
}
struct StreamToolCall: Codable {
struct StreamToolCall: Codable, Sendable {
let index: Int
let id: String?
let type: String?
let function: StreamToolFunction?
struct StreamToolFunction: Codable {
struct StreamToolFunction: Codable, Sendable {
let name: String?
let arguments: 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
}
}
@ -155,7 +140,7 @@ enum OpenAIChatMessageContent: Codable {
let url: String
}
/// Provide custom Codable to match OpenAI schema (flattened objects with type field)
// Provide custom Codable to match OpenAI schema (flattened objects with type field)
enum CodingKeys: String, CodingKey {
case type
case text
@ -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"
}
}
@ -344,8 +326,8 @@ struct OpenAIErrorResponse: Codable {
}
}
/// Helper type for Either content
enum Either<Left: Codable, Right: Codable>: Codable {
// Helper type for Either content
enum Either<Left, Right>: Codable where Left: Codable, Right: Codable {
case left(Left)
case right(Right)

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

@ -97,17 +97,9 @@ public struct AnyAgentTool: Sendable {
private let _schema: AgentToolSchema
private let _execute: @Sendable (AnyAgentToolValue, ToolExecutionContext) async throws -> AnyAgentToolValue
public var name: String {
self._name
}
public var description: String {
self._description
}
public var schema: AgentToolSchema {
self._schema
}
public var name: String { self._name }
public var description: String { self._description }
public var schema: AgentToolSchema { self._schema }
public init<T: AgentToolProtocol>(_ tool: T) {
self._name = tool.name

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,39 +546,17 @@ 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
}
public var baseURL: String? {
self.provider.baseURL
}
public var apiKey: String? {
self.provider.apiKey
}
public var capabilities: ModelCapabilities {
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 var modelId: String { self.provider.modelId }
public var baseURL: String? { self.provider.baseURL }
public var apiKey: String? { self.provider.apiKey }
public var capabilities: ModelCapabilities { self.provider.capabilities }
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 +564,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

@ -267,7 +267,7 @@ public struct UsageOperation: Sendable, Codable {
self.type = type
}
/// For backward compatibility, provide a computed property to reconstruct model info
// For backward compatibility, provide a computed property to reconstruct model info
public var modelDescription: String {
"\(self.providerName)/\(self.modelId)"
}
@ -393,21 +393,10 @@ public struct UsageReport: Sendable {
public let endDate: Date
public let sessions: [UsageSession]
public var totalSessions: Int {
self.sessions.count
}
public var totalOperations: Int {
self.sessions.reduce(0) { $0 + $1.operations.count }
}
public var totalTokens: Int {
self.sessions.reduce(0) { $0 + $1.totalTokens }
}
public var totalCost: Double {
self.sessions.reduce(0) { $0 + $1.totalCost }
}
public var totalSessions: Int { self.sessions.count }
public var totalOperations: Int { self.sessions.reduce(0) { $0 + $1.operations.count } }
public var totalTokens: Int { self.sessions.reduce(0) { $0 + $1.totalTokens } }
public var totalCost: Double { self.sessions.reduce(0) { $0 + $1.totalCost } }
public let providerBreakdown: [String: ProviderUsage]
public let modelBreakdown: [String: ModelUsage]
@ -528,38 +517,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 +558,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()
}
let trackedStream = AsyncThrowingStream<TextStreamDelta, Error> { continuation in
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,27 @@ 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()
}
}
return trackedStream
}
/// 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 +171,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 +181,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 +539,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()
}
let processedStream = AsyncThrowingStream<String, Error> { continuation in
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,17 +150,9 @@ 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())
return processedStream
}
}
@ -435,9 +163,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 +171,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 +191,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 +221,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

@ -150,7 +150,7 @@ public func createTool(
)
}
/// Helper struct for type-safe tool creation (moved outside to avoid nesting in generic function)
// Helper struct for type-safe tool creation (moved outside to avoid nesting in generic function)
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
private struct ConcreteAgentTool<I: AgentToolValue, O: AgentToolValue>: AgentToolProtocol {
typealias Input = I

View File

@ -238,30 +238,22 @@ public func transcribeBatch(
of: (Int, TranscriptionResult).self,
returning: [TranscriptionResult].self,
) { group in
let limit = min(max(concurrency, 1), audioURLs.count)
var nextIndex = 0
let semaphore = AsyncSemaphore(value: concurrency)
func submit(_ index: Int) {
let url = audioURLs[index]
for (index, url) in audioURLs.indexed() {
group.addTask {
await semaphore.wait()
defer { Task { await semaphore.signal() } }
let audio = try AudioData(contentsOf: url)
let result = try await transcribe(audio, using: model, language: language, configuration: configuration)
return (index, result)
}
}
while nextIndex < limit {
submit(nextIndex)
nextIndex += 1
}
var results: [(Int, TranscriptionResult)] = []
while let result = try await group.next() {
for try await result in group {
results.append(result)
if nextIndex < audioURLs.count {
submit(nextIndex)
nextIndex += 1
}
}
// Sort by original index to maintain order
@ -290,29 +282,21 @@ public func generateSpeechBatch(
-> [SpeechResult]
{
try await withThrowingTaskGroup(of: (Int, SpeechResult).self, returning: [SpeechResult].self) { group in
let limit = min(max(concurrency, 1), texts.count)
var nextIndex = 0
let semaphore = AsyncSemaphore(value: concurrency)
func submit(_ index: Int) {
let text = texts[index]
for (index, text) in texts.indexed() {
group.addTask {
await semaphore.wait()
defer { Task { await semaphore.signal() } }
let result = try await generateSpeech(text, using: model, voice: voice, configuration: configuration)
return (index, result)
}
}
while nextIndex < limit {
submit(nextIndex)
nextIndex += 1
}
var results: [(Int, SpeechResult)] = []
while let result = try await group.next() {
for try await result in group {
results.append(result)
if nextIndex < texts.count {
submit(nextIndex)
nextIndex += 1
}
}
// Sort by original index to maintain order
@ -394,3 +378,34 @@ public func capabilities(
}
// MARK: - Helper Types
/// Simple semaphore for controlling concurrency
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
actor AsyncSemaphore {
private var value: Int
private var waiters: [CheckedContinuation<Void, Never>] = []
init(value: Int) {
self.value = value
}
func wait() async {
if self.value > 0 {
self.value -= 1
return
}
await withCheckedContinuation { continuation in
self.waiters.append(continuation)
}
}
func signal() {
if self.waiters.isEmpty {
self.value += 1
} else {
let waiter = self.waiters.removeFirst()
waiter.resume()
}
}
}

View File

@ -50,7 +50,7 @@ public final class RealtimeAudioManager: NSObject {
private let audioSession = AVAudioSession.sharedInstance()
#endif
/// Audio format for API (24kHz PCM16)
// Audio format for API (24kHz PCM16)
private let apiFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: 24000,
@ -297,7 +297,7 @@ public final class RealtimeAudioManager: NSObject {
#endif
}
// Set audio input device (macOS only)
/// Set audio input device (macOS only)
#if os(macOS)
public func setInputDevice(_: AudioDeviceID) throws {
// Note: Setting audio device on macOS requires more complex Core Audio API

View File

@ -304,7 +304,9 @@ public final class RealtimeAudioProcessor: @unchecked Sendable {
}
let mantissa = UInt8((s >> (exponent + 3)) & 0x0F)
return ~(sign | (exponent << 4) | mantissa)
let encoded = ~(sign | (exponent << 4) | mantissa)
return encoded
}
private func decodeUlaw(_ ulaw: UInt8) -> Int16 {

View File

@ -275,7 +275,7 @@ public protocol AudioStreamPipelineDelegate: AnyObject {
func audioStreamPipeline(didEncounterError error: Error) async
}
/// Default implementation for optional methods
// Default implementation for optional methods
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
extension AudioStreamPipelineDelegate {
// Pipeline started

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

@ -135,7 +135,7 @@ public final class RealtimeConversation: ObservableObject {
private var audioBuffer = Data()
private let audioChunkSize = 1024 * 4 // 4KB chunks
/// Background tasks
// Background tasks
private var eventProcessingTask: Task<Void, Never>?
// MARK: - Initialization
@ -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

@ -39,9 +39,7 @@ public enum RealtimeClientEvent: RealtimeEventProtocol {
}
}
public var eventId: String? {
UUID().uuidString
}
public var eventId: String? { UUID().uuidString }
}
// MARK: - Server Events (Events sent from server to client)
@ -113,9 +111,7 @@ public enum RealtimeServerEvent: RealtimeEventProtocol {
}
}
public var eventId: String? {
nil
}
public var eventId: String? { nil }
}
// MARK: - Session Events
@ -188,7 +184,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

@ -111,7 +111,7 @@ public struct AgentToolWrapper: RealtimeExecutableTool {
}
}
/// Helper function to convert Any to AnyAgentToolValue
// Helper function to convert Any to AnyAgentToolValue
private func convertAnyToToolArgument(_ value: Any) -> AnyAgentToolValue {
do {
return try AnyAgentToolValue.fromJSON(value)
@ -121,7 +121,7 @@ public struct AgentToolWrapper: RealtimeExecutableTool {
}
}
/// Helper function to convert AnyAgentToolValue to Any
// Helper function to convert AnyAgentToolValue to Any
private func convertToolArgumentToAny(_ arg: AnyAgentToolValue) -> Any {
do {
return try arg.toJSON()

View File

@ -88,7 +88,7 @@ public final class AudioRecorder: ObservableObject, AudioRecorderProtocol {
private let channels: AVAudioChannelCount = 1
private let bitDepth: Int = 16
/// Maximum recording duration (5 minutes by default)
// Maximum recording duration (5 minutes by default)
public var maxRecordingDuration: TimeInterval = 300
// MARK: - Initialization
@ -211,7 +211,8 @@ public final class AudioRecorder: ObservableObject, AudioRecorderProtocol {
recordingURL = nil
}
return try AudioData(contentsOf: url)
let audioData = try AudioData(contentsOf: url)
return audioData
}
/// Cancel recording without returning data
@ -417,17 +418,9 @@ private let logger = Logger(label: "tachikoma.audio.recorder")
public final class AudioRecorder: AudioRecorderProtocol {
public init() {}
public var isRecording: Bool {
false
}
public var isAvailable: Bool {
false
}
public var recordingDuration: TimeInterval {
0
}
public var isRecording: Bool { false }
public var isAvailable: Bool { false }
public var recordingDuration: TimeInterval { 0 }
public func startRecording() async throws {
throw AudioRecordingError.audioEngineError("Audio recording is unavailable on this platform")

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
}
}
}
@ -35,13 +39,8 @@ public enum TranscriptionModel: Sendable, CustomStringConvertible {
case whisperLargeV3Turbo = "whisper-large-v3-turbo"
case distilWhisperLargeV3En = "distil-whisper-large-v3-en"
public var supportsTimestamps: Bool {
true
}
public var supportsLanguageDetection: Bool {
true
}
public var supportsTimestamps: Bool { true }
public var supportsLanguageDetection: Bool { true }
}
public enum Deepgram: String, CaseIterable, Sendable {
@ -50,30 +49,17 @@ public enum TranscriptionModel: Sendable, CustomStringConvertible {
case enhanced
case base
public var supportsTimestamps: Bool {
true
}
public var supportsLanguageDetection: Bool {
true
}
public var supportsSummarization: Bool {
true
}
public var supportsTimestamps: Bool { true }
public var supportsLanguageDetection: Bool { true }
public var supportsSummarization: Bool { true }
}
public enum ElevenLabs: String, CaseIterable, Sendable {
case scribeV1 = "scribe_v1"
case scribeV1Experimental = "scribe_v1_experimental"
public var supportsTimestamps: Bool {
false
}
public var supportsLanguageDetection: Bool {
true
}
public var supportsTimestamps: Bool { false }
public var supportsLanguageDetection: Bool { true }
}
// MARK: - Model Properties
@ -151,10 +137,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
}
}
@ -172,13 +160,8 @@ public enum SpeechModel: Sendable, CustomStringConvertible {
case multilingualV2 = "eleven_multilingual_v2"
case englishV1 = "eleven_english_v1"
public var supportsVoiceCloning: Bool {
true
}
public var supportedFormats: [AudioFormat] {
[.mp3, .wav, .pcm]
}
public var supportsVoiceCloning: Bool { true }
public var supportedFormats: [AudioFormat] { [.mp3, .wav, .pcm] }
}
// MARK: - Model Properties
@ -220,5 +203,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

@ -98,7 +98,7 @@ public enum VoiceOption: Sendable, Hashable {
case nova
case shimmer
/// Custom voice (provider-specific)
// Custom voice (provider-specific)
case custom(String)
public var stringValue: String {

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

@ -1,167 +0,0 @@
import Foundation
import MCP
import Tachikoma
enum MCPContentBridge {
private struct ResourceLinkDetails {
let title: String?
let description: String?
let mimeType: String?
}
static func summary(for content: MCP.Tool.Content) -> String {
switch content {
case let .text(text, _, _):
return text
case let .image(data, mimeType, _, _):
return "[Image: \(mimeType), size: \(data.count) bytes]"
case let .resource(resource, _, _):
if let text = resource.text {
return text
}
if let blob = resource.blob {
return "[Resource: \(resource.uri), blob size: \(blob.count) bytes]"
}
return "[Resource: \(resource.uri)]"
case let .resourceLink(uri, _, _, _, mimeType, _):
if let mimeType {
return "[Resource Link: \(uri), type: \(mimeType)]"
}
return "[Resource Link: \(uri)]"
case let .audio(data, mimeType, _, _):
return "[Audio: \(mimeType), size: \(data.count) bytes]"
}
}
static func convert(_ content: MCP.Tool.Content) -> AnyAgentToolValue {
switch content {
case let .text(text, _, _):
AnyAgentToolValue(string: text)
case let .image(data, mimeType, _, _):
AnyAgentToolValue(object: [
"type": AnyAgentToolValue(string: "image"),
"mimeType": AnyAgentToolValue(string: mimeType),
"data": AnyAgentToolValue(string: data),
])
case let .resource(resource, annotations, meta):
AnyAgentToolValue(object: self.resourceObject(
resource: resource,
annotations: annotations,
meta: meta,
))
case let .resourceLink(uri, name, title, description, mimeType, annotations):
AnyAgentToolValue(object: self.resourceLinkObject(
uri: uri,
name: name,
details: ResourceLinkDetails(
title: title,
description: description,
mimeType: mimeType,
),
annotations: annotations,
))
case let .audio(data, mimeType, _, _):
AnyAgentToolValue(object: [
"type": AnyAgentToolValue(string: "audio"),
"mimeType": AnyAgentToolValue(string: mimeType),
"data": AnyAgentToolValue(string: data),
])
}
}
private static func resourceObject(
resource: Resource.Content,
annotations: Resource.Annotations?,
meta: Metadata?,
)
-> [String: AnyAgentToolValue]
{
var resourceDict: [String: AnyAgentToolValue] = [
"type": AnyAgentToolValue(string: "resource"),
"uri": AnyAgentToolValue(string: resource.uri),
]
if let mimeType = resource.mimeType {
resourceDict["mimeType"] = AnyAgentToolValue(string: mimeType)
} else {
resourceDict["mimeType"] = AnyAgentToolValue(null: ())
}
if let text = resource.text {
resourceDict["text"] = AnyAgentToolValue(string: text)
} else {
resourceDict["text"] = AnyAgentToolValue(null: ())
}
if let blob = resource.blob {
resourceDict["blob"] = AnyAgentToolValue(string: blob)
}
if let annotations {
resourceDict["annotations"] = self.convert(annotations)
}
if let meta {
resourceDict["meta"] = self.convert(meta)
}
return resourceDict
}
private static func resourceLinkObject(
uri: String,
name: String,
details: ResourceLinkDetails,
annotations: Resource.Annotations?,
)
-> [String: AnyAgentToolValue]
{
var resourceDict: [String: AnyAgentToolValue] = [
"type": AnyAgentToolValue(string: "resourceLink"),
"uri": AnyAgentToolValue(string: uri),
"name": AnyAgentToolValue(string: name),
]
if let title = details.title {
resourceDict["title"] = AnyAgentToolValue(string: title)
}
if let description = details.description {
resourceDict["description"] = AnyAgentToolValue(string: description)
}
if let mimeType = details.mimeType {
resourceDict["mimeType"] = AnyAgentToolValue(string: mimeType)
}
if let annotations {
resourceDict["annotations"] = self.convert(annotations)
}
return resourceDict
}
private static func convert(_ annotations: Resource.Annotations) -> AnyAgentToolValue {
var dict: [String: AnyAgentToolValue] = [:]
if let audience = annotations.audience {
dict["audience"] = AnyAgentToolValue(array: audience.map {
AnyAgentToolValue(string: $0.rawValue)
})
}
if let priority = annotations.priority {
dict["priority"] = AnyAgentToolValue(double: priority)
}
if let lastModified = annotations.lastModified {
dict["lastModified"] = AnyAgentToolValue(string: lastModified)
}
return AnyAgentToolValue(object: dict)
}
private static func convert(_ metadata: Metadata) -> AnyAgentToolValue {
AnyAgentToolValue(object: metadata.fields.mapValues { $0.toAnyAgentToolValue() })
}
}

View File

@ -185,7 +185,7 @@ public enum MCPToolAdapter {
private static func convertResponse(_ response: ToolResponse) -> AnyAgentToolValue {
if response.isError {
let errorMessage = response.content.compactMap { content -> String? in
if case let .text(text, _, _) = content {
if case let .text(text) = content {
return text
}
return nil
@ -249,6 +249,34 @@ public enum MCPToolAdapter {
/// Convert MCP Tool.Content to AnyAgentToolValue
private static func convertContent(_ content: MCP.Tool.Content) -> AnyAgentToolValue {
MCPContentBridge.convert(content)
// Convert MCP Tool.Content to AnyAgentToolValue
switch content {
case let .text(text):
return AnyAgentToolValue(string: text)
case let .image(data, mimeType, _):
return AnyAgentToolValue(object: [
"type": AnyAgentToolValue(string: "image"),
"mimeType": AnyAgentToolValue(string: mimeType),
"data": AnyAgentToolValue(string: data),
])
case let .resource(uri, mimeType, text):
var resourceDict: [String: AnyAgentToolValue] = [
"type": AnyAgentToolValue(string: "resource"),
"uri": AnyAgentToolValue(string: uri),
"mimeType": AnyAgentToolValue(string: mimeType),
]
if let text {
resourceDict["text"] = AnyAgentToolValue(string: text)
} else {
resourceDict["text"] = AnyAgentToolValue(null: ())
}
return AnyAgentToolValue(object: resourceDict)
case let .audio(data, mimeType):
return AnyAgentToolValue(object: [
"type": AnyAgentToolValue(string: "audio"),
"mimeType": AnyAgentToolValue(string: mimeType),
"data": AnyAgentToolValue(string: data),
])
}
}
}

View File

@ -161,7 +161,7 @@ public final class MCPToolProvider: DynamicToolProvider {
// If there's an error, return it as a string
if response.isError {
let errorMessage = response.content.compactMap { content -> String? in
if case let .text(text, _, _) = content {
if case let .text(text) = content {
return text
}
return nil
@ -184,6 +184,33 @@ public final class MCPToolProvider: DynamicToolProvider {
}
private func convertContentToAnyAgentToolValue(_ content: MCP.Tool.Content) -> AnyAgentToolValue {
MCPContentBridge.convert(content)
switch content {
case let .text(text):
return AnyAgentToolValue(string: text)
case let .image(data, mimeType, _):
return AnyAgentToolValue(object: [
"type": AnyAgentToolValue(string: "image"),
"mimeType": AnyAgentToolValue(string: mimeType),
"data": AnyAgentToolValue(string: data),
])
case let .resource(uri, mimeType, text):
var resourceDict: [String: AnyAgentToolValue] = [
"type": AnyAgentToolValue(string: "resource"),
"uri": AnyAgentToolValue(string: uri),
"mimeType": AnyAgentToolValue(string: mimeType),
]
if let text {
resourceDict["text"] = AnyAgentToolValue(string: text)
} else {
resourceDict["text"] = AnyAgentToolValue(null: ())
}
return AnyAgentToolValue(object: resourceDict)
case let .audio(data, mimeType):
return AnyAgentToolValue(object: [
"type": AnyAgentToolValue(string: "audio"),
"mimeType": AnyAgentToolValue(string: mimeType),
"data": AnyAgentToolValue(string: data),
])
}
}
}

View File

@ -114,7 +114,7 @@ extension ToolResponse {
// If there's an error, return error message
if isError {
let errorMessage = content.compactMap { content -> String? in
if case let .text(text, _, _) = content {
if case let .text(text) = content {
return text
}
return nil
@ -125,7 +125,18 @@ extension ToolResponse {
// Convert the first content item to a result
if let firstContent = content.first {
return AnyAgentToolValue(string: MCPContentBridge.summary(for: firstContent))
switch firstContent {
case let .text(text):
return AnyAgentToolValue(string: text)
case let .image(data, mimeType, _):
// For images, return a descriptive string
return AnyAgentToolValue(string: "[Image: \(mimeType), size: \(data.count) bytes]")
case let .resource(uri, _, text):
// For resources, return the text content if available
return AnyAgentToolValue(string: text ?? "[Resource: \(uri)]")
case let .audio(data, mimeType):
return AnyAgentToolValue(string: "[Audio: \(mimeType), size: \(data.count) bytes]")
}
}
// No content
@ -137,7 +148,7 @@ extension ToolResponse {
// If there's an error, return it as a string
if isError {
let errorMessage = content.compactMap { content -> String? in
if case let .text(text, _, _) = content {
if case let .text(text) = content {
return text
}
return nil
@ -160,6 +171,31 @@ extension ToolResponse {
}
private func convertContentToAnyAgentToolValue(_ content: MCP.Tool.Content) -> AnyAgentToolValue {
MCPContentBridge.convert(content)
switch content {
case let .text(text):
return AnyAgentToolValue(string: text)
case let .image(data, mimeType, _):
return AnyAgentToolValue(object: [
"type": AnyAgentToolValue(string: "image"),
"mimeType": AnyAgentToolValue(string: mimeType),
"data": AnyAgentToolValue(string: data),
])
case let .resource(uri, mimeType, text):
var resourceDict: [String: AnyAgentToolValue] = [
"type": AnyAgentToolValue(string: "resource"),
"uri": AnyAgentToolValue(string: uri),
"mimeType": AnyAgentToolValue(string: mimeType),
]
if let text {
resourceDict["text"] = AnyAgentToolValue(string: text)
}
return AnyAgentToolValue(object: resourceDict)
case let .audio(data, mimeType):
return AnyAgentToolValue(object: [
"type": AnyAgentToolValue(string: "audio"),
"mimeType": AnyAgentToolValue(string: mimeType),
"data": AnyAgentToolValue(string: data),
])
}
}
}

View File

@ -4,7 +4,7 @@ import FoundationNetworking
#endif
import Logging
/// Actor to manage mutable state for Sendable conformance
// Actor to manage mutable state for Sendable conformance
private actor HTTPTransportState {
var urlSession: URLSession?
var baseURL: URL?
@ -18,21 +18,10 @@ private actor HTTPTransportState {
self.headers = headers
}
func getSession() -> URLSession? {
self.urlSession
}
func getBaseURL() -> URL? {
self.baseURL
}
func getTimeout() -> TimeInterval {
self.requestTimeout
}
func getHeaders() -> [String: String] {
self.headers
}
func getSession() -> URLSession? { self.urlSession }
func getBaseURL() -> URL? { self.baseURL }
func getTimeout() -> TimeInterval { self.requestTimeout }
func getHeaders() -> [String: String] { self.headers }
}
/// HTTP transport for MCP communication

View File

@ -2,7 +2,7 @@ import Foundation
import Logging
import MCP
/// Shared JSON-RPC types for HTTP transport
// Shared JSON-RPC types for HTTP transport
struct HTTPJSONRPCRequest<P: Encodable>: Encodable {
let jsonrpc = "2.0"
let method: String
@ -56,7 +56,7 @@ public struct MCPServerConfig: Sendable, Codable {
}
}
/// Actor to manage mutable state for Sendable conformance
// Actor to manage mutable state for Sendable conformance
private actor MCPClientState {
var transport: (any MCPTransport)?
var tools: [Tool] = []
@ -258,7 +258,7 @@ struct InitializeParams: Codable {
}
}
/// Some servers use snake_case for protocol_version in initialize
// Some servers use snake_case for protocol_version in initialize
struct InitializeParamsSnake: Codable {
let protocolVersion: String
let clientInfo: ClientInfo

View File

@ -189,7 +189,7 @@ public final class TachikomaMCPClientManager {
return all
}
/// Execute a tool on a specific server
// Execute a tool on a specific server
public func executeTool(
serverName: String,
toolName: String,
@ -222,9 +222,7 @@ public final class TachikomaMCPClientManager {
// MARK: Health/Info (lightweight)
public func getServerNames() -> [String] {
Array(self.effectiveConfigs.keys).sorted()
}
public func getServerNames() -> [String] { Array(self.effectiveConfigs.keys).sorted() }
public func getServerInfo(name: String) async -> (config: MCPServerConfig, connected: Bool)? {
guard let cfg = effectiveConfigs[name] else { return nil }
@ -499,7 +497,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

@ -5,7 +5,7 @@ import FoundationNetworking
import Logging
import MCP
/// Internal state for SSE transport
// Internal state for SSE transport
private actor SSEState {
var transport: HTTPClientTransport?
var baseURL: URL?
@ -16,60 +16,29 @@ private actor SSEState {
var timeoutTasks: [Int: Task<Void, Never>] = [:]
var requestTimeoutNs: UInt64 = 30_000_000_000 // default 30s
func setTransport(_ t: HTTPClientTransport?) {
self.transport = t
}
func setTransport(_ t: HTTPClientTransport?) { self.transport = t }
func getTransport() -> HTTPClientTransport? { self.transport }
func setBaseURL(_ url: URL?) { self.baseURL = url }
func setHeaders(_ h: [String: String]) { self.headers = h }
func setEndpoint(_ url: URL?) { self.endpointURL = url }
func getEndpoint() -> URL? { self.endpointURL }
func getBaseURL() -> URL? { self.baseURL }
func getTransport() -> HTTPClientTransport? {
self.transport
}
func setBaseURL(_ url: URL?) {
self.baseURL = url
}
func setHeaders(_ h: [String: String]) {
self.headers = h
}
func setEndpoint(_ url: URL?) {
self.endpointURL = url
}
func getEndpoint() -> URL? {
self.endpointURL
}
func getBaseURL() -> URL? {
self.baseURL
}
func getNextId() -> Int {
defer { nextId += 1 }
func getNextId() -> Int { defer { nextId += 1 }
return self.nextId
}
func addPending(_ id: Int, _ cont: CheckedContinuation<Data, Swift.Error>) {
self.pendingRequests[id] = cont
}
func removePending(_ id: Int) -> CheckedContinuation<Data, Swift.Error>? {
self.pendingRequests
.removeValue(forKey: id)
func addPending(_ id: Int, _ cont: CheckedContinuation<Data, Swift.Error>) { self.pendingRequests[id] = cont }
func removePending(_ id: Int) -> CheckedContinuation<Data, Swift.Error>? { self.pendingRequests
.removeValue(forKey: id)
}
func setTimeout(_ seconds: TimeInterval) {
self.requestTimeoutNs = UInt64((seconds > 0 ? seconds : 30) * 1_000_000_000)
}
func addTimeoutTask(_ id: Int, _ task: Task<Void, Never>) {
self.timeoutTasks[id] = task
}
func cancelTimeout(_ id: Int) {
self.timeoutTasks.removeValue(forKey: id)?.cancel()
}
func addTimeoutTask(_ id: Int, _ task: Task<Void, Never>) { self.timeoutTasks[id] = task }
func cancelTimeout(_ id: Int) { self.timeoutTasks.removeValue(forKey: id)?.cancel() }
func cancelAll(_ error: Swift.Error) {
for (_, c) in self.pendingRequests {
c.resume(throwing: error)
@ -130,7 +99,7 @@ public final class SSETransport: MCPTransport {
await self.state.setTransport(nil)
}
/// Expose underlying swift-sdk HTTP transport for advanced usage
// Expose underlying swift-sdk HTTP transport for advanced usage
public func underlyingSDKTransport() async -> HTTPClientTransport? {
await self.state.getTransport()
}

View File

@ -8,7 +8,7 @@ import Logging
import MCP
import Tachikoma
/// Actor to manage mutable state for Sendable conformance
// Actor to manage mutable state for Sendable conformance
private actor StdioTransportState {
var process: Process?
var inputPipe: Pipe?

View File

@ -19,7 +19,7 @@ public protocol MCPTool: Sendable {
/// Wrapper for tool arguments received from MCP
public struct ToolArguments: Sendable {
/// Execute the tool with the given arguments
// Execute the tool with the given arguments
private let raw: Value
public init(raw: [String: Any]) {
@ -174,7 +174,7 @@ private func ValueToAny(_ value: Value) -> Any {
}
}
/// Helper function to convert Any to Value
// Helper function to convert Any to Value
private func convertToValue(_ value: Any) -> Value {
switch value {
case let string as String:
@ -213,7 +213,7 @@ public struct ToolResponse: Sendable {
public static func text(_ text: String, meta: Value? = nil) -> ToolResponse {
// Create a text response
ToolResponse(
content: [.text(text: text, annotations: nil, _meta: nil)],
content: [.text(text)],
isError: false,
meta: meta,
)
@ -223,7 +223,7 @@ public struct ToolResponse: Sendable {
public static func error(_ message: String, meta: Value? = nil) -> ToolResponse {
// Create an error response
ToolResponse(
content: [.text(text: message, annotations: nil, _meta: nil)],
content: [.text(message)],
isError: true,
meta: meta,
)
@ -233,7 +233,7 @@ public struct ToolResponse: Sendable {
public static func image(data: Data, mimeType: String = "image/png", meta: Value? = nil) -> ToolResponse {
// Create an image response
ToolResponse(
content: [.image(data: data.base64EncodedString(), mimeType: mimeType, annotations: nil, _meta: nil)],
content: [.image(data: data.base64EncodedString(), mimeType: mimeType, metadata: nil)],
isError: false,
meta: meta,
)
@ -250,5 +250,5 @@ public struct ToolResponse: Sendable {
}
}
/// Type alias for convenience
// Type alias for convenience
public typealias MCPContent = MCP.Tool.Content

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

@ -4,9 +4,10 @@ import Testing
@testable import Tachikoma
@testable import TachikomaMCP
@Suite("MCP Client Tests")
struct MCPClientTests {
@Test
func `MCPServerConfig initialization with all parameters`() {
@Test("MCPServerConfig initialization with all parameters")
func serverConfigFullInit() {
let config = MCPServerConfig(
transport: "stdio",
command: "npx",
@ -28,8 +29,8 @@ struct MCPClientTests {
#expect(config.description == "Test server")
}
@Test
func `MCPServerConfig initialization with minimal parameters`() {
@Test("MCPServerConfig initialization with minimal parameters")
func serverConfigMinimalInit() {
let config = MCPServerConfig(command: "test")
#expect(config.transport == "stdio")
@ -42,8 +43,8 @@ struct MCPClientTests {
#expect(config.description == nil)
}
@Test
func `MCPClient initialization`() {
@Test("MCPClient initialization")
func clientInit() {
let config = MCPServerConfig(
command: "test-command",
args: ["arg1"],
@ -53,8 +54,8 @@ struct MCPClientTests {
_ = MCPClient(name: "test-client", config: config)
}
@Test
func `MCPError descriptions`() {
@Test("MCPError descriptions")
func errorDescriptions() {
#expect(MCPError.serverDisabled.errorDescription == "MCP server is disabled")
#expect(MCPError.notConnected.errorDescription == "MCP client is not connected")
#expect(MCPError.invalidResponse.errorDescription == "Invalid response from MCP server")
@ -63,8 +64,8 @@ struct MCPClientTests {
#expect(MCPError.executionFailed("error").errorDescription == "Execution failed: error")
}
@Test
func `ToolArguments convenience methods`() {
@Test("ToolArguments convenience methods")
func toolArgumentsConvenienceMethods() {
let args = ToolArguments(raw: [
"text": "hello",
"number": 42,
@ -105,8 +106,8 @@ struct MCPClientTests {
#expect(emptyArgs.isEmpty == true)
}
@Test
func `ToolArguments from Value`() {
@Test("ToolArguments from Value")
func toolArgumentsFromValue() {
let value = Value.object([
"name": .string("test"),
"count": .int(42),
@ -120,8 +121,8 @@ struct MCPClientTests {
#expect(args.getBool("active") == true)
}
@Test
func `ToolArguments raw dictionary preserves nested structures`() {
@Test("ToolArguments raw dictionary preserves nested structures")
func toolArgumentsRawDictionary() {
let value = Value.object([
"text": .string("hello"),
"number": .int(5),
@ -166,13 +167,13 @@ struct MCPClientTests {
#expect(dictionary["none"] is NSNull)
}
@Test
func `ToolResponse creation methods`() {
@Test("ToolResponse creation methods")
func toolResponseCreation() {
// Text response
let textResponse = ToolResponse.text("Success")
#expect(textResponse.content.count == 1)
#expect(textResponse.isError == false)
if case .text(text: let text, annotations: _, _meta: _) = textResponse.content.first {
if case let .text(text) = textResponse.content.first {
#expect(text == "Success")
} else {
Issue.record("Expected text content")
@ -181,7 +182,7 @@ struct MCPClientTests {
// Error response
let errorResponse = ToolResponse.error("Failed")
#expect(errorResponse.isError == true)
if case .text(text: let text, annotations: _, _meta: _) = errorResponse.content.first {
if case let .text(text) = errorResponse.content.first {
#expect(text == "Failed")
} else {
Issue.record("Expected text content")
@ -191,7 +192,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 let .image(data, mimeType, _) = imageResponse.content.first {
#expect(data == imageData.base64EncodedString())
#expect(mimeType == "image/png")
} else {
@ -200,14 +201,14 @@ struct MCPClientTests {
// Multi-content response
let multiResponse = ToolResponse.multiContent([
.text(text: "Part 1", annotations: nil, _meta: nil),
.text(text: "Part 2", annotations: nil, _meta: nil),
.text("Part 1"),
.text("Part 2"),
])
#expect(multiResponse.content.count == 2)
}
@Test
func `ToolResponse with metadata`() {
@Test("ToolResponse with metadata")
func toolResponseWithMetadata() {
let meta = Value.object([
"executionTime": .double(1.5),
"status": .string("ok"),
@ -224,15 +225,15 @@ struct MCPClientTests {
}
}
@Test
func `MCPToolProvider initialization`() {
@Test("MCPToolProvider initialization")
func toolProviderInit() {
let config = MCPServerConfig(command: "test")
let client = MCPClient(name: "test", config: config)
_ = MCPToolProvider(client: client)
}
@Test
func `MCPToolProvider as DynamicToolProvider`() {
@Test("MCPToolProvider as DynamicToolProvider")
func toolProviderAsDynamicToolProvider() {
let config = MCPServerConfig(command: "test")
let client = MCPClient(name: "test-server", config: config)
let provider = MCPToolProvider(client: client)
@ -241,8 +242,8 @@ struct MCPClientTests {
_ = provider as DynamicToolProvider
}
@Test
func `Tool metadata structure`() {
@Test("Tool metadata structure")
func toolMetadata() {
let tool = MCP.Tool(
name: "test-tool",
description: "A test tool",
@ -268,8 +269,8 @@ struct MCPClientTests {
}
}
@Test
func `Value encoding and decoding`() throws {
@Test("Value encoding and decoding")
func valueEncodingDecoding() throws {
let originalValue = Value.object([
"string": .string("test"),
"number": .int(42),
@ -291,8 +292,8 @@ struct MCPClientTests {
#expect(originalValue == decodedValue)
}
@Test
func `ToolArguments decoding`() throws {
@Test("ToolArguments decoding")
func toolArgumentsDecoding() throws {
struct TestArgs: Decodable {
let name: String
let count: Int
@ -340,39 +341,40 @@ private struct MockMCPTool: MCPTool {
}
}
@Suite("Mock Tool Tests")
struct MockToolTests {
@Test
func `Mock tool execution with valid arguments`() async throws {
@Test("Mock tool execution with valid arguments")
func mockToolValidExecution() async throws {
let tool = MockMCPTool()
let args = ToolArguments(raw: ["message": "Hello World"])
let response = try await tool.execute(arguments: args)
#expect(response.isError == false)
if case .text(text: let text, annotations: _, _meta: _) = response.content.first {
if case let .text(text) = response.content.first {
#expect(text == "Received: Hello World")
} else {
Issue.record("Expected text response")
}
}
@Test
func `Mock tool execution with missing arguments`() async throws {
@Test("Mock tool execution with missing arguments")
func mockToolMissingArguments() async throws {
let tool = MockMCPTool()
let args = ToolArguments(raw: [:])
let response = try await tool.execute(arguments: args)
#expect(response.isError == true)
if case .text(text: let text, annotations: _, _meta: _) = response.content.first {
if case let .text(text) = response.content.first {
#expect(text == "Missing required 'message' parameter")
} else {
Issue.record("Expected error text")
}
}
@Test
func `Mock tool schema validation`() {
@Test("Mock tool schema validation")
func mockToolSchema() {
let tool = MockMCPTool()
#expect(tool.name == "mock_tool")

View File

@ -3,9 +3,10 @@ import Tachikoma
import Testing
@testable import TachikomaMCP
@Suite("MCP Tool Adapter Tests")
struct MCPToolAdapterTests {
@Test
func `ToolArguments getString`() {
@Test("ToolArguments getString")
func toolArgumentsGetString() {
let args = ToolArguments(raw: [
"name": "Alice",
"count": 42,
@ -20,8 +21,8 @@ struct MCPToolAdapterTests {
#expect(args.getString("missing") == nil)
}
@Test
func `ToolArguments getNumber`() {
@Test("ToolArguments getNumber")
func toolArgumentsGetNumber() {
let args = ToolArguments(raw: [
"int": 42,
"double": 3.14,
@ -36,8 +37,8 @@ struct MCPToolAdapterTests {
#expect(args.getNumber("missing") == nil)
}
@Test
func `ToolArguments getInt`() {
@Test("ToolArguments getInt")
func toolArgumentsGetInt() {
let args = ToolArguments(raw: [
"int": 42,
"double": 3.14,
@ -52,8 +53,8 @@ struct MCPToolAdapterTests {
#expect(args.getInt("missing") == nil)
}
@Test
func `ToolArguments getBool`() {
@Test("ToolArguments getBool")
func toolArgumentsGetBool() {
let args = ToolArguments(raw: [
"bool": true,
"stringTrue": "true",
@ -74,8 +75,8 @@ struct MCPToolAdapterTests {
#expect(args.getBool("missing") == nil)
}
@Test
func `ToolArguments getStringArray`() {
@Test("ToolArguments getStringArray")
func toolArgumentsGetStringArray() {
let args = ToolArguments(raw: [
"array": ["a", "b", "c"],
"mixed": ["string", 123, true],
@ -88,8 +89,8 @@ struct MCPToolAdapterTests {
#expect(args.getStringArray("missing") == nil)
}
@Test
func `ToolArguments isEmpty`() {
@Test("ToolArguments isEmpty")
func toolArgumentsIsEmpty() {
let emptyArgs = ToolArguments(raw: [:])
#expect(emptyArgs.isEmpty == true)
@ -97,40 +98,40 @@ struct MCPToolAdapterTests {
#expect(nonEmptyArgs.isEmpty == false)
}
@Test
func `ToolResponse text creation`() {
@Test("ToolResponse text creation")
func toolResponseText() {
let response = ToolResponse.text("Hello, world!")
#expect(response.isError == false)
#expect(response.content.count == 1)
if case .text(text: let text, annotations: _, _meta: _) = response.content[0] {
if case let .text(text) = response.content[0] {
#expect(text == "Hello, world!")
} else {
Issue.record("Expected text content")
}
}
@Test
func `ToolResponse error creation`() {
@Test("ToolResponse error creation")
func toolResponseError() {
let response = ToolResponse.error("Something went wrong")
#expect(response.isError == true)
#expect(response.content.count == 1)
if case .text(text: let text, annotations: _, _meta: _) = response.content[0] {
if case let .text(text) = response.content[0] {
#expect(text == "Something went wrong")
} else {
Issue.record("Expected text content")
}
}
@Test
func `ToolResponse image creation`() {
@Test("ToolResponse image creation")
func toolResponseImage() {
let imageData = Data([0xFF, 0xD8, 0xFF]) // JPEG header
let response = ToolResponse.image(data: imageData, mimeType: "image/jpeg")
#expect(response.isError == false)
#expect(response.content.count == 1)
if case let .image(data: data, mimeType: mimeType, annotations: _, _meta: _) = response.content[0] {
if case let .image(data, mimeType, _) = response.content[0] {
#expect(data == imageData.base64EncodedString())
#expect(mimeType == "image/jpeg")
} else {

View File

@ -2,9 +2,10 @@ import MCP
import Testing
@testable import TachikomaMCP
@Suite("Schema Builder Tests")
struct SchemaBuilderTests {
@Test
func `Build string schema`() {
@Test("Build string schema")
func stringSchema() {
let schema = SchemaBuilder.string(
description: "User name",
enum: ["Alice", "Bob"],
@ -31,8 +32,8 @@ struct SchemaBuilderTests {
}
}
@Test
func `Build boolean schema`() {
@Test("Build boolean schema")
func booleanSchema() {
let schema = SchemaBuilder.boolean(
description: "Is active",
default: true,
@ -48,8 +49,8 @@ struct SchemaBuilderTests {
#expect(dict["default"] == .bool(true))
}
@Test
func `Build number schema`() {
@Test("Build number schema")
func numberSchema() {
let schema = SchemaBuilder.number(
description: "Temperature",
minimum: 0.0,
@ -69,8 +70,8 @@ struct SchemaBuilderTests {
#expect(dict["default"] == .double(25.0))
}
@Test
func `Build integer schema`() {
@Test("Build integer schema")
func integerSchema() {
let schema = SchemaBuilder.integer(
description: "Count",
minimum: 1,
@ -90,8 +91,8 @@ struct SchemaBuilderTests {
#expect(dict["default"] == .int(10))
}
@Test
func `Build array schema`() {
@Test("Build array schema")
func arraySchema() {
let itemSchema = SchemaBuilder.string()
let schema = SchemaBuilder.array(
items: itemSchema,
@ -114,8 +115,8 @@ struct SchemaBuilderTests {
#expect(dict["uniqueItems"] == .bool(true))
}
@Test
func `Build object schema`() {
@Test("Build object schema")
func objectSchema() {
let schema = SchemaBuilder.object(
properties: [
"name": SchemaBuilder.string(description: "User name"),
@ -150,8 +151,8 @@ struct SchemaBuilderTests {
}
}
@Test
func `Build nullable schema`() {
@Test("Build nullable schema")
func testNullableSchema() {
let baseSchema = SchemaBuilder.string(description: "Optional value")
let nullableSchema = SchemaBuilder.nullable(baseSchema)
@ -167,8 +168,8 @@ struct SchemaBuilderTests {
}
}
@Test
func `Build oneOf schema`() {
@Test("Build oneOf schema")
func oneOfSchema() {
let schemas = [
SchemaBuilder.string(),
SchemaBuilder.integer(),
@ -190,8 +191,8 @@ struct SchemaBuilderTests {
}
}
@Test
func `Build anyOf schema`() {
@Test("Build anyOf schema")
func anyOfSchema() {
let schemas = [
SchemaBuilder.string(),
SchemaBuilder.integer(),

View File

@ -4,9 +4,10 @@ import Testing
@testable import Tachikoma
@testable import TachikomaMCP
@Suite("Type Conversion Tests")
struct TypeConversionTests {
@Test
func `AnyAgentToolValue to JSON conversion`() throws {
@Test("AnyAgentToolValue to JSON conversion")
func anyAgentToolValueToJSON() throws {
// String
let stringVal = AnyAgentToolValue(string: "hello")
let stringJSON = try stringVal.toJSON()
@ -49,8 +50,8 @@ struct TypeConversionTests {
#expect(objectJSON?["count"] as? Int == 5)
}
@Test
func `Any to AnyAgentToolValue conversion`() {
@Test("Any to AnyAgentToolValue conversion")
func anyToAnyAgentToolValue() throws {
// String
let stringVal = AnyAgentToolValue.from("hello")
#expect(stringVal.stringValue == "hello")
@ -98,8 +99,8 @@ struct TypeConversionTests {
#expect(dateVal.stringValue != nil)
}
@Test
func `AnyAgentToolValue to Value conversion`() {
@Test("AnyAgentToolValue to Value conversion")
func anyAgentToolValueToValue() {
// String
let stringVal = AnyAgentToolValue(string: "hello")
let stringValue = stringVal.toValue()
@ -150,8 +151,8 @@ struct TypeConversionTests {
}
}
@Test
func `Value to AnyAgentToolValue conversion`() {
@Test("Value to AnyAgentToolValue conversion")
func valueToAnyAgentToolValue() {
// String
let stringValue = Value.string("hello")
let stringVal = stringValue.toAnyAgentToolValue()
@ -202,8 +203,8 @@ struct TypeConversionTests {
}
}
@Test
func `ToolArguments initialization from AgentToolArguments`() {
@Test("ToolArguments initialization from AgentToolArguments")
func toolArgumentsFromAgentToolArguments() throws {
let agentArgs = AgentToolArguments([
"text": AnyAgentToolValue(string: "hello"),
"number": AnyAgentToolValue(int: 42),
@ -228,8 +229,8 @@ struct TypeConversionTests {
}
}
@Test
func `ToolResponse to AnyAgentToolValue conversion via toAgentToolResult`() {
@Test("ToolResponse to AnyAgentToolValue conversion via toAgentToolResult")
func toolResponseToAgentToolResult() {
// Text response
let textResponse = ToolResponse.text("Success message")
let textResult = textResponse.toAgentToolResult()
@ -256,8 +257,8 @@ struct TypeConversionTests {
#expect(emptyResult.stringValue == "Success")
}
@Test
func `ToolResponse to AnyAgentToolValue conversion`() {
@Test("ToolResponse to AnyAgentToolValue conversion")
func toolResponseToAnyAgentToolValue() {
// Single text content
let textResponse = ToolResponse.text("Hello")
let textVal = textResponse.toAnyAgentToolValue()
@ -265,8 +266,8 @@ struct TypeConversionTests {
// Multiple content items
let multiResponse = ToolResponse.multiContent([
.text(text: "Part 1", annotations: nil, _meta: nil),
.text(text: "Part 2", annotations: nil, _meta: nil),
.text("Part 1"),
.text("Part 2"),
])
let multiVal = multiResponse.toAnyAgentToolValue()
if let elements = multiVal.arrayValue {
@ -279,7 +280,7 @@ struct TypeConversionTests {
// Image content
let imageResponse = ToolResponse(content: [
.image(data: "base64data", mimeType: "image/png", annotations: nil, _meta: nil),
.image(data: "base64data", mimeType: "image/png", metadata: nil),
])
let imageVal = imageResponse.toAnyAgentToolValue()
if let props = imageVal.objectValue {
@ -292,13 +293,7 @@ struct TypeConversionTests {
// Resource content
let resourceResponse = ToolResponse(content: [
.resource(
resource: .text(
"content",
uri: "https://example.com",
mimeType: "text/html",
),
),
.resource(uri: "https://example.com", mimeType: "text/html", text: "content"),
])
let resourceVal = resourceResponse.toAnyAgentToolValue()
if let props = resourceVal.objectValue {
@ -309,32 +304,10 @@ struct TypeConversionTests {
} else {
Issue.record("Expected object for resource content")
}
// Resource link content
let resourceLinkResponse = ToolResponse(content: [
.resourceLink(
uri: "https://example.com/help",
name: "help-doc",
title: "Help",
description: "Operator help",
mimeType: "text/markdown",
),
])
let resourceLinkVal = resourceLinkResponse.toAnyAgentToolValue()
if let props = resourceLinkVal.objectValue {
#expect(props["type"]?.stringValue == "resourceLink")
#expect(props["uri"]?.stringValue == "https://example.com/help")
#expect(props["name"]?.stringValue == "help-doc")
#expect(props["title"]?.stringValue == "Help")
#expect(props["description"]?.stringValue == "Operator help")
#expect(props["mimeType"]?.stringValue == "text/markdown")
} else {
Issue.record("Expected object for resource link content")
}
}
@Test
func `Round-trip conversions`() throws {
@Test("Round-trip conversions")
func roundTripConversions() throws {
// AnyAgentToolValue -> Value -> AnyAgentToolValue
let originalVal = AnyAgentToolValue(object: [
"string": AnyAgentToolValue(string: "test"),

View File

@ -2,11 +2,12 @@ import Foundation
import Testing
@testable import Tachikoma
@Suite("AgentToolValue Protocol System Tests")
struct AgentToolValueTests {
// MARK: - Basic Type Conformance Tests
@Test
func `String conforms to AgentToolValue`() throws {
@Test("String conforms to AgentToolValue")
func stringConformance() throws {
let value = "Hello, World!"
let json = try value.toJSON()
#expect(json as? String == "Hello, World!")
@ -16,8 +17,8 @@ struct AgentToolValueTests {
#expect(String.agentValueType == .string)
}
@Test
func `Int conforms to AgentToolValue`() throws {
@Test("Int conforms to AgentToolValue")
func intConformance() throws {
let value = 42
let json = try value.toJSON()
#expect(json as? Int == 42)
@ -31,8 +32,8 @@ struct AgentToolValueTests {
#expect(fromDouble == 42)
}
@Test
func `Double conforms to AgentToolValue`() throws {
@Test("Double conforms to AgentToolValue")
func doubleConformance() throws {
let value = 3.141_59
let json = try value.toJSON()
#expect(json as? Double == 3.141_59)
@ -46,8 +47,8 @@ struct AgentToolValueTests {
#expect(fromInt == 42.0)
}
@Test
func `Bool conforms to AgentToolValue`() throws {
@Test("Bool conforms to AgentToolValue")
func boolConformance() throws {
let value = true
let json = try value.toJSON()
#expect(json as? Bool == true)
@ -57,8 +58,8 @@ struct AgentToolValueTests {
#expect(Bool.agentValueType == .boolean)
}
@Test
func `AgentNullValue works correctly`() throws {
@Test("AgentNullValue works correctly")
func testNullValue() throws {
let value = AgentNullValue()
let json = try value.toJSON()
#expect(json is NSNull)
@ -68,8 +69,8 @@ struct AgentToolValueTests {
#expect(AgentNullValue.agentValueType == .null)
}
@Test
func `Array conforms to AgentToolValue`() throws {
@Test("Array conforms to AgentToolValue")
func arrayConformance() throws {
let value = ["apple", "banana", "cherry"]
let json = try value.toJSON()
let jsonArray = json as? [Any]
@ -81,8 +82,8 @@ struct AgentToolValueTests {
#expect([String].agentValueType == .array)
}
@Test
func `Dictionary conforms to AgentToolValue`() throws {
@Test("Dictionary conforms to AgentToolValue")
func dictionaryConformance() throws {
let value = ["name": "John", "city": "NYC"]
let json = try value.toJSON()
let jsonDict = json as? [String: Any]
@ -96,8 +97,8 @@ struct AgentToolValueTests {
// MARK: - AnyAgentToolValue Tests
@Test
func `AnyAgentToolValue wraps basic types`() {
@Test("AnyAgentToolValue wraps basic types")
func anyAgentToolValueBasicTypes() throws {
// String
let stringValue = AnyAgentToolValue(string: "test")
#expect(stringValue.stringValue == "test")
@ -125,8 +126,8 @@ struct AgentToolValueTests {
#expect(nullValue.stringValue == nil)
}
@Test
func `AnyAgentToolValue wraps complex types`() {
@Test("AnyAgentToolValue wraps complex types")
func anyAgentToolValueComplexTypes() throws {
// Array
let array = [
AnyAgentToolValue(string: "a"),
@ -151,8 +152,8 @@ struct AgentToolValueTests {
#expect(objectValue.objectValue?["active"]?.boolValue == true)
}
@Test
func `AnyAgentToolValue JSON conversion`() throws {
@Test("AnyAgentToolValue JSON conversion")
func anyAgentToolValueJSON() throws {
// Test fromJSON with various types
let stringValue = try AnyAgentToolValue.fromJSON("hello")
#expect(stringValue.stringValue == "hello")
@ -179,8 +180,8 @@ struct AgentToolValueTests {
#expect(dictValue.objectValue?["num"]?.intValue == 123)
}
@Test
func `AnyAgentToolValue Codable conformance`() throws {
@Test("AnyAgentToolValue Codable conformance")
func anyAgentToolValueCodable() throws {
struct TestContainer: Codable {
let value: AnyAgentToolValue
}
@ -222,8 +223,8 @@ struct AgentToolValueTests {
// MARK: - AgentToolCall and AgentToolResult Tests
@Test
func `AgentToolCall uses AnyAgentToolValue`() {
@Test("AgentToolCall uses AnyAgentToolValue")
func agentToolCall() throws {
let arguments = [
"prompt": AnyAgentToolValue(string: "Hello"),
"temperature": AnyAgentToolValue(double: 0.7),
@ -243,8 +244,8 @@ struct AgentToolValueTests {
#expect(toolCall.arguments["maxTokens"]?.intValue == 100)
}
@Test
func `AgentToolCall legacy init with Any`() throws {
@Test("AgentToolCall legacy init with Any")
func agentToolCallLegacyInit() throws {
let arguments: [String: Any] = [
"text": "Hello",
"count": 42,
@ -262,8 +263,8 @@ struct AgentToolValueTests {
#expect(toolCall.arguments["enabled"]?.boolValue == true)
}
@Test
func `AgentToolResult uses AnyAgentToolValue`() {
@Test("AgentToolResult uses AnyAgentToolValue")
func agentToolResult() {
let successResult = AgentToolResult.success(
toolCallId: "call_123",
result: AnyAgentToolValue(string: "Success!"),
@ -285,8 +286,8 @@ struct AgentToolValueTests {
// MARK: - AgentToolArguments Tests
@Test
func `AgentToolArguments accessor methods`() throws {
@Test("AgentToolArguments accessor methods")
func agentToolArgumentsAccessors() throws {
let args = AgentToolArguments([
"string": AnyAgentToolValue(string: "text"),
"number": AnyAgentToolValue(double: 42.5),
@ -322,8 +323,8 @@ struct AgentToolValueTests {
#expect(args.optionalBooleanValue("missing") == nil)
}
@Test
func `AgentToolArguments error handling`() throws {
@Test("AgentToolArguments error handling")
func agentToolArgumentsErrors() throws {
let args = AgentToolArguments([
"text": AnyAgentToolValue(string: "hello"),
])
@ -341,17 +342,15 @@ struct AgentToolValueTests {
// MARK: - Type-Safe Tool Protocol Tests
@Test
func `AgentToolProtocol implementation`() async throws {
@Test("AgentToolProtocol implementation")
func agentToolProtocol() async throws {
// Define a concrete tool
struct WeatherTool: AgentToolProtocol {
struct Input: AgentToolValue, Equatable {
let location: String
let units: String
static var agentValueType: AgentValueType {
.object
}
static var agentValueType: AgentValueType { .object }
func toJSON() throws -> Any {
["location": self.location, "units": self.units]
@ -373,9 +372,7 @@ struct AgentToolValueTests {
let temperature: Double
let conditions: String
static var agentValueType: AgentValueType {
.object
}
static var agentValueType: AgentValueType { .object }
func toJSON() throws -> Any {
["temperature": self.temperature, "conditions": self.conditions]
@ -393,14 +390,8 @@ struct AgentToolValueTests {
}
}
var name: String {
"get_weather"
}
var description: String {
"Get current weather"
}
var name: String { "get_weather" }
var description: String { "Get current weather" }
var schema: AgentToolSchema {
AgentToolSchema(
properties: [
@ -443,8 +434,8 @@ struct AgentToolValueTests {
// MARK: - Edge Cases and Error Handling
@Test
func `Handle integer/double ambiguity`() throws {
@Test("Handle integer/double ambiguity")
func integerDoubleAmbiguity() throws {
// Test that whole numbers can be treated as integers
let wholeDouble = try AnyAgentToolValue.fromJSON(42.0)
#expect(wholeDouble.intValue == 42)
@ -461,8 +452,8 @@ struct AgentToolValueTests {
#expect(largeInt.intValue == safeInteger)
}
@Test
func `Handle nested structures`() throws {
@Test("Handle nested structures")
func nestedStructures() throws {
let nested = [
"level1": [
"level2": [
@ -478,8 +469,8 @@ struct AgentToolValueTests {
#expect(level3?["value"]?.stringValue == "deep")
}
@Test
func `Handle mixed-type arrays`() throws {
@Test("Handle mixed-type arrays")
func mixedTypeArrays() throws {
let mixed: [Any] = ["string", 42, true, NSNull(), ["nested": "object"]]
let value = try AnyAgentToolValue.fromJSON(mixed)

View File

@ -2,15 +2,16 @@ import Foundation
import Testing
@testable import Tachikoma
@Suite("Async Ergonomics Tests")
struct AsyncErgonomicsTests {
@Test
func `Timeout error description`() {
@Test("Timeout error description")
func timeoutErrorDescription() throws {
let error = TimeoutError(timeout: 5.5)
#expect(error.errorDescription == "Operation timed out after 5.5 seconds")
}
@Test
func `Cancellation token basic operations`() async {
@Test("Cancellation token basic operations")
func cancellationTokenBasic() async throws {
let token = CancellationToken()
#expect(await token.cancelled == false)
@ -24,8 +25,8 @@ struct AsyncErgonomicsTests {
#expect(await token.cancelled == true)
}
@Test
func `Cancellation token with handlers`() async throws {
@Test("Cancellation token with handlers")
func cancellationTokenHandlers() async throws {
let token = CancellationToken()
class Flag: @unchecked Sendable {
@ -48,8 +49,8 @@ struct AsyncErgonomicsTests {
#expect(flag.value == true)
}
@Test
func `Retry configuration defaults`() {
@Test("Retry configuration defaults")
func retryConfigurationDefaults() throws {
let config = RetryConfiguration.default
#expect(config.maxAttempts == 3)
@ -59,8 +60,8 @@ struct AsyncErgonomicsTests {
#expect(config.timeout == nil)
}
@Test
func `Retry configuration presets`() {
@Test("Retry configuration presets")
func retryConfigurationPresets() throws {
let aggressive = RetryConfiguration.aggressive
#expect(aggressive.maxAttempts == 5)
#expect(aggressive.delay == 0.5)
@ -70,8 +71,8 @@ struct AsyncErgonomicsTests {
#expect(conservative.delay == 2.0)
}
@Test
func `Retry with cancellation - immediate success`() async throws {
@Test("Retry with cancellation - immediate success")
func retryWithCancellationImmediateSuccess() async throws {
let result = try await retryWithCancellation(
configuration: .init(maxAttempts: 3, delay: 0.01),
) {
@ -81,8 +82,8 @@ struct AsyncErgonomicsTests {
#expect(result == "Success")
}
@Test
func `With timeout basic functionality`() async throws {
@Test("With timeout basic functionality")
func withTimeoutBasic() async throws {
let result = try await withTimeout(0.1) {
"Quick result"
}
@ -90,8 +91,8 @@ struct AsyncErgonomicsTests {
#expect(result == "Quick result")
}
@Test
func `With timeout throws on timeout`() async throws {
@Test("With timeout throws on timeout")
func withTimeoutThrows() async throws {
do {
_ = try await withTimeout(0.01) {
try await Task<Never, Never>.sleep(nanoseconds: 1_000_000_000) // 1 second
@ -103,8 +104,8 @@ struct AsyncErgonomicsTests {
}
}
@Test
func `Async stream collect basic`() async throws {
@Test("Async stream collect basic")
func asyncStreamCollectBasic() async throws {
let stream = AsyncThrowingStream<Int, Error> { continuation in
continuation.yield(1)
continuation.yield(2)
@ -116,8 +117,8 @@ struct AsyncErgonomicsTests {
#expect(results == [1, 2, 3])
}
@Test
func `Task group with auto cancellation`() async throws {
@Test("Task group with auto cancellation")
func taskGroupAutoCancellation() async throws {
class Flag: @unchecked Sendable {
var cancelled = false
}

View File

@ -3,12 +3,14 @@ import Testing
@testable import Tachikoma
@testable import TachikomaAudio
enum AudioFunctionsTests {
@Suite("Audio Functions Tests")
struct AudioFunctionsTests {
// MARK: - Basic Transcription Function Tests
@Suite("Basic Transcription Functions Tests")
struct BasicTranscriptionFunctionsTests {
@Test
func `transcribe() convenience function works`() async throws {
@Test("transcribe() convenience function works")
func transcribeConvenienceFunctionWorks() async throws {
try await TestHelpers.withMockProviderEnvironment {
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let audioData = TestHelpers.sampleAudioData(configuration: config)
@ -18,8 +20,8 @@ enum AudioFunctionsTests {
}
}
@Test
func `transcribe() with full model specification works`() async throws {
@Test("transcribe() with full model specification works")
func transcribeWithFullModelSpecificationWorks() async throws {
try await TestHelpers.withMockProviderEnvironment {
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let audioData = TestHelpers.sampleAudioData(configuration: config)
@ -41,8 +43,8 @@ enum AudioFunctionsTests {
}
}
@Test
func `transcribe() from file URL works`() async throws {
@Test("transcribe() from file URL works")
func transcribeFromFileURLWorks() async throws {
try await TestHelpers.withMockProviderEnvironment {
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let audioData = TestHelpers.sampleAudioData(configuration: config)
@ -66,8 +68,8 @@ enum AudioFunctionsTests {
}
}
@Test
func `transcribe() with timestamps`() async {
@Test("transcribe() with timestamps")
func transcribeWithTimestamps() async throws {
await TestHelpers.withMockProviderEnvironment {
await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let audioData = TestHelpers.sampleAudioData(configuration: config)
@ -93,8 +95,8 @@ enum AudioFunctionsTests {
}
}
@Test
func `transcribe() with abort signal`() async {
@Test("transcribe() with abort signal")
func transcribeWithAbortSignal() async throws {
await TestHelpers.withMockProviderEnvironment {
await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let audioData = TestHelpers.sampleAudioData(configuration: config)
@ -118,9 +120,10 @@ enum AudioFunctionsTests {
// MARK: - Basic Speech Generation Function Tests
@Suite("Basic Speech Generation Functions Tests")
struct BasicSpeechGenerationFunctionsTests {
@Test
func `generateSpeech() convenience function works`() async throws {
@Test("generateSpeech() convenience function works")
func generateSpeechConvenienceFunctionWorks() async throws {
try await TestHelpers.withMockProviderEnvironment {
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let audioData = try await generateSpeech("Hello, world!", configuration: config)
@ -130,8 +133,8 @@ enum AudioFunctionsTests {
}
}
@Test
func `generateSpeech() with voice selection`() async throws {
@Test("generateSpeech() with voice selection")
func generateSpeechWithVoiceSelection() async throws {
try await TestHelpers.withMockProviderEnvironment {
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let audioData = try await generateSpeech("Hello, world!", voice: .nova, configuration: config)
@ -141,8 +144,8 @@ enum AudioFunctionsTests {
}
}
@Test
func `generateSpeech() with full model specification works`() async throws {
@Test("generateSpeech() with full model specification works")
func generateSpeechWithFullModelSpecificationWorks() async throws {
try await TestHelpers.withMockProviderEnvironment {
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let result = try await generateSpeech(
@ -159,8 +162,8 @@ enum AudioFunctionsTests {
}
}
@Test
func `generateSpeech() direct to file using convenience function`() async throws {
@Test("generateSpeech() direct to file using convenience function")
func generateSpeechDirectToFile() async throws {
try await TestHelpers.withMockProviderEnvironment {
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let tempDir = FileManager.default.temporaryDirectory
@ -184,8 +187,8 @@ enum AudioFunctionsTests {
}
}
@Test
func `generateSpeech() with abort signal`() async {
@Test("generateSpeech() with abort signal")
func generateSpeechWithAbortSignal() async throws {
await TestHelpers.withMockProviderEnvironment {
await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let abortSignal = AbortSignal()
@ -208,9 +211,10 @@ enum AudioFunctionsTests {
// MARK: - Batch Operations Tests
@Suite("Batch Operations Tests")
struct BatchOperationsTests {
@Test
func `transcribeBatch() function works`() async throws {
@Test("transcribeBatch() function works")
func transcribeBatchFunctionWorks() async throws {
try await TestHelpers.withMockProviderEnvironment {
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let audioData = TestHelpers.sampleAudioData(configuration: config)
@ -242,8 +246,8 @@ enum AudioFunctionsTests {
}
}
@Test
func `generateSpeechBatch() function works`() async throws {
@Test("generateSpeechBatch() function works")
func generateSpeechBatchFunctionWorks() async throws {
try await TestHelpers.withMockProviderEnvironment {
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let texts = ["Hello", "World"]
@ -263,23 +267,24 @@ enum AudioFunctionsTests {
// MARK: - Utility Functions Tests
@Suite("Utility Functions Tests")
struct UtilityFunctionsTests {
@Test
func `availableTranscriptionModels() returns models`() {
@Test("availableTranscriptionModels() returns models")
func availableTranscriptionModelsReturnsModels() {
let models = availableTranscriptionModels()
#expect(!models.isEmpty)
#expect(models.contains { $0.description == TranscriptionModel.openai(.whisper1).description })
}
@Test
func `availableSpeechModels() returns models`() {
@Test("availableSpeechModels() returns models")
func availableSpeechModelsReturnsModels() {
let models = availableSpeechModels()
#expect(!models.isEmpty)
#expect(models.contains { $0.description == SpeechModel.openai(.tts1).description })
}
@Test
func `capabilities() for transcription models`() throws {
@Test("capabilities() for transcription models")
func capabilitiesForTranscriptionModels() throws {
let config = TestHelpers.createTestConfiguration(apiKeys: ["openai": "test-key"])
let capabilities = try capabilities(for: TranscriptionModel.openai(.whisper1), configuration: config)
#expect(capabilities.supportsTimestamps == true)
@ -287,8 +292,8 @@ enum AudioFunctionsTests {
#expect(capabilities.supportedFormats.contains(.wav))
}
@Test
func `capabilities() for speech models`() throws {
@Test("capabilities() for speech models")
func capabilitiesForSpeechModels() throws {
let config = TestHelpers.createTestConfiguration(apiKeys: ["openai": "test-key"])
let capabilities = try capabilities(for: SpeechModel.openai(.tts1), configuration: config)
#expect(capabilities.supportsSpeedControl == true)
@ -299,9 +304,10 @@ enum AudioFunctionsTests {
// MARK: - Error Handling Tests
@Suite("Error Handling Tests")
struct ErrorHandlingTests {
@Test
func `transcribe() handles empty audio data`() async {
@Test("transcribe() handles empty audio data")
func transcribeHandlesEmptyAudioData() async throws {
await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let emptyAudioData = AudioData(data: Data(), format: .wav)
@ -311,8 +317,8 @@ enum AudioFunctionsTests {
}
}
@Test
func `generateSpeech() handles empty text`() async {
@Test("generateSpeech() handles empty text")
func generateSpeechHandlesEmptyText() async throws {
await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
await #expect(throws: TachikomaError.self) {
_ = try await generateSpeech("", using: .openai(.tts1), configuration: config)
@ -320,8 +326,8 @@ enum AudioFunctionsTests {
}
}
@Test
func `functions handle missing API keys`() async {
@Test("functions handle missing API keys")
func functionsHandleMissingAPIKeys() async throws {
guard TestHelpers.isMockAPIKey(ProcessInfo.processInfo.environment["OPENAI_API_KEY"]) else { return }
await TestHelpers.withEmptyTestConfiguration { config in
let audioData = AudioData(data: Data([0x01, 0x02]), format: .wav)
@ -339,9 +345,10 @@ enum AudioFunctionsTests {
// MARK: - Integration Tests
@Suite("Integration Tests")
struct IntegrationTests {
@Test
func `transcribe and generate speech pipeline`() async {
@Test("transcribe and generate speech pipeline")
func transcribeAndGenerateSpeechPipeline() async throws {
await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let originalAudioData = TestHelpers.sampleAudioData(configuration: config)
@ -361,9 +368,9 @@ enum AudioFunctionsTests {
}
}
@Test
func `multiple provider integration`() async {
await TestHelpers.withStandardTestConfiguration { config in
@Test("multiple provider integration")
func multipleProviderIntegration() async throws {
try await TestHelpers.withStandardTestConfiguration { config in
let audioData = TestHelpers.sampleAudioData(configuration: config)
// Test different transcription providers

View File

@ -7,10 +7,10 @@ import Testing
@testable import Tachikoma
@testable import TachikomaAudio
@Suite(.serialized)
@Suite("Audio Provider Factories", .serialized)
struct AudioProviderFactoryTests {
@Test
func `TranscriptionProviderFactory returns mock provider in test mode`() throws {
@Test("TranscriptionProviderFactory returns mock provider in test mode")
func transcriptionFactoryReturnsMockInTestMode() throws {
let previousTestMode = getenv("TACHIKOMA_TEST_MODE").flatMap { String(cString: $0) }
setenv("TACHIKOMA_TEST_MODE", "mock", 1)
defer {
@ -32,8 +32,8 @@ struct AudioProviderFactoryTests {
#expect(provider is MockTranscriptionProvider)
}
@Test
func `TranscriptionProviderFactory requires API key in test mode`() {
@Test("TranscriptionProviderFactory requires API key in test mode")
func transcriptionFactoryRequiresAPIKeyInTestMode() {
let previousTestMode = getenv("TACHIKOMA_TEST_MODE").flatMap { String(cString: $0) }
setenv("TACHIKOMA_TEST_MODE", "mock", 1)
defer {
@ -57,8 +57,8 @@ struct AudioProviderFactoryTests {
}
}
@Test
func `SpeechProviderFactory returns mock provider in test mode`() throws {
@Test("SpeechProviderFactory returns mock provider in test mode")
func speechFactoryReturnsMockInTestMode() throws {
let previousTestMode = getenv("TACHIKOMA_TEST_MODE").flatMap { String(cString: $0) }
setenv("TACHIKOMA_TEST_MODE", "mock", 1)
defer {
@ -80,48 +80,44 @@ struct AudioProviderFactoryTests {
#expect(provider is MockSpeechProvider)
}
@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)
}
@Test("AudioConfiguration reads configuration keys before environment")
func audioConfigurationPrefersExplicitConfiguration() {
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)
@Test("AudioConfiguration falls back to environment variable")
func audioConfigurationFallsBackToEnvironment() {
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")
}
}
}

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